Compare commits

..
Author SHA1 Message Date
Ludovic Marie 128575e4e5 Merge pull request #14029 from horizon-rust/layers/client-lib-style
🤖 Agent: ui_theme's palette, sizes and Geist fonts come from menu_style; with no fonts given (NEUTRAL) the Horizon overlays measure and draw no text instead of panicking.
2026-11-09 21:15:21 +01:00
Ludovic Marie cb870cf80d Merge pull request #14027 from horizon-rust/layers/render-theme-out
🤖 Agent: hud_kit's tween curve is pinned to horizon_theme::MOTION by a dev-dependency test, and the HDR logo reads the icon the window hook was given.
2026-11-09 21:11:27 +01:00
Horizon agentandClaude Opus 5.5 8aed306751 ui_theme: main's text_line test reads the body size and ink from the style
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 19:24:26 +01:00
Horizon agent bb19f898e4 Merge branch 'layers/render-theme-out' into layers/client-lib-style 2026-11-09 19:17:07 +01:00
Horizon agent bbc17333bd Merge branch 'layers/render-style' into layers/render-theme-out 2026-11-09 19:17:06 +01:00
Horizon agentandClaude Opus 5.5 8630bbed74 rustfmt
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 19:17:03 +01:00
Horizon agent 2c5baa6a60 Merge branch 'layers/render-theme-out' into layers/client-lib-style
# Conflicts:
#	crates/horizon-client/src/ui_theme.rs
2026-11-09 19:12:40 +01:00
Horizon agent ed35d477be Merge branch 'layers/render-style' into layers/render-theme-out
# Conflicts:
#	tools/layers.json
2026-11-09 19:10:41 +01:00
Horizon agent 7a236ba054 Merge branch 'layers/window-icon-hook' into layers/render-style
# Conflicts:
#	crates/horizon-client/src/client_lib.rs
2026-11-09 19:10:23 +01:00
Horizon agent bf125a1a69 Merge remote-tracking branch 'origin/main' into layers/window-icon-hook
# Conflicts:
#	crates/horizon-client/src/entry.rs
2026-11-09 19:09:55 +01:00
Ludovic Marie 7e6d2121b7 Merge pull request #14095 from vivescene/perf/client-mob-skips
🤖 Agent: both skips only move side-effect-free checks (box intersection, empty effect lists) ahead of the lookups; the set and order of mobs that push or swirl is unchanged.
2026-11-09 19:08:49 +01:00
Ludovic Marie a89865b1c5 Merge pull request #14094 from MSedgeMC/fix/book-title-centered
🤖 Agent: text_start_x is EditBox.updateTextPosition's centred textX ((width - text width) / 2 from getX), and position_at measures the click from the same start, as findClickedPositionInText does.
2026-11-09 19:08:44 +01:00
Ludovic Marie c8e48f9cea Merge pull request #14089 from Mik00000/perf/particle-collision-cache
🤖 Agent: ParticleLevel::block is World::block with a one-chunk Cell in front (same air fallback), and the other edits (lava smoke, bubble removal, lifetime_alpha) keep their random draws and results. Queue BEFORE 14097 (its Ctx impl sits beside this PR's Ctx<'a, 'l>).
2026-11-09 18:59:04 +01:00
Ludovic Marie bdb6452da9 Merge pull request #14073 from alexcatdad/nix-packages
🤖 Agent: packaging only; Cargo.lock has no git sources, so buildRustPackage needs no outputHashes, and nothing the source filter drops (launcher/, nix/, docs pages) is read by the build.
2026-11-09 18:58:58 +01:00
Ludovic Marie 2ddd9705d9 Merge pull request #14059 from MSedgeMC/fix/port-tooltip
🤖 Agent: PORT_INFO_TEXT is drawn after every widget so it sits on top, and a remote world (no port box) gets none.
2026-11-09 18:58:53 +01:00
Ludovic Marie 868d3b76a5 Merge pull request #14058 from MSedgeMC/fix/social-tooltip-delay
🤖 Agent: the delay is per button identity (row id + kind) with vanilla's strict elapsed > 500 ms, and costs one Instant::now() per frame on the social screen only.
2026-11-09 18:58:47 +01:00
Ludovic Marie b4f24319ac Merge pull request #14083 from horizon-rust/agent/remote-poll-move
🤖 Agent: pure move; poll_stationary_client, poll_remote and poll_remote_events go byte-identical from remote.rs to remote_poll.rs (a #[path] child of remote), only visibility and super::super::server_links change.
2026-11-09 18:53:19 +01:00
Ludovic Marie 962f5949ac Merge pull request #13613 from vnsmoreira/fix/command-block-last-output
🤖 Agent: LastOutput is now saved as a component, keeping the red, click-to-suggest feedback of a failed command (BaseCommandBlock / ComponentSerialization.CODEC). The case keeps to failed commands, so no new score holder changes the later /scoreboard players list order. command_exec: 19063/19065; the 2 left are the zombie PersistenceRequired pair, which fails the same way on main 8321f11360.
2026-11-09 18:46:17 +01:00
Ludovic Marie 5e9024e0a1 Merge pull request #14063 from TheDanikReal/main
🤖 Agent: all 1266 entries keep their {placeholders}; translation file only, approved by a Team member who reads it.
2026-11-09 18:46:13 +01:00
Ludovic Marie 559c2d4e81 Merge pull request #14062 from MSedgeMC/fix/sign-messages-codec
🤖 Agent: a non-list messages now empties the side as SignText.CODEC does; still open: 26.3 also fails a side whose messages are missing or not exactly 4 (listOf(4, 4)), a follow-up.
2026-11-09 18:46:09 +01:00
Ludovic Marie ee449e0ef2 Merge pull request #13734 from RINNECODER/up10/campfire-smoke-limit
🤖 Agent: campfire smoke now goes through the particle limiter like vanilla's; marker_particles 2187/2187 pass on a union with main.
2026-11-09 18:46:05 +01:00
Ludovic Marie dc3d97ce25 Merge pull request #13728 from DUzzL/fix/13673-cured-villager-gear
🤖 Agent: cure drops and cursed gear go to the inventory as in vanilla; zombie_cure_sound 804/804 pass on a union with main.
2026-11-09 18:46:00 +01:00
Ludovic Marie cab5f23234 Merge pull request #13724 from DUzzL/fix/13672-cushion-lava
🤖 Agent: the cushion now takes lava damage at the check interval, as vanilla does; cushion_placement (2852 cases), fall_damage and living_fall pass on a union with main.
2026-11-09 18:45:56 +01:00
Ludovic Marie 2c5d1990ce Merge pull request #13957 from horizon-rust/layers/packs-sky
🤖 Agent: OptiFine custom sky now reaches the engine only through pack_hooks::PackFormats::custom_sky, resolved once per pack load (the per-frame draw keeps the same layer list and shader). layer plan M6 step 1. verify green on this head; no vanilla behaviour changed (parity.py selects 633 harnesses only because game.rs is touched; none observes pack skies).
2026-11-09 18:45:52 +01:00
MSedgeMC ec3e4df6e3 port_map: EditBox.setCentered 2026-11-09 16:55:26 +00:00
MSedgeMC a0f02b61ea test: a centred edit box starts its text in the middle
The title box is set centred; the test pins the offset for an empty, a short
and an odd-width value, and that the sign screen's title box asks for it.
2026-11-09 16:55:26 +00:00
MSedgeMC 7f2e64b603 fix: the book signing title box centres its text
BookSignScreen sets its 15 character title box centred, so the title and the
caret sit in the middle of the 114 pixel box. EditBox had no centred mode: the
text (and a click on it) started at the box's left edge.
2026-11-09 16:55:26 +00:00
Horizon agentandClaude Opus 5.5 c873af989a Menu style: ui_theme's overlays take their palette, sizes and fonts from it
horizon-client-lib no longer depends on horizon-theme outside its tests
(layer plan M6). menu_style gains the overlays' colours, ui_theme's type
scale and shapes, and its fonts (none given: the overlays draw no text);
ui_theme, the on-screen keyboard, the missing-resources window and the
Windows boot paint read them. horizon_look gives Horizon's.

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

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

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 17:54:58 +01:00
Horizon agentandClaude Opus 5.5 b77e4c4924 horizon_look.rs: the module the previous commit declares
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 17:54:56 +01:00
valuttaandClaude Opus 5.5 861b9e7b16 Client tick: skip far mobs before the pushes' lookups and effectless ones before their size
remote_pushes looked up every mob's player entry and scoreboard team
before testing whether its box meets the player's; effect_swirls worked
out every mob's width and height before seeing it had no effects. Both
skips come first now, for the same pushes and particles. At 10,000 pigs
the client tick takes about a quarter less of the main thread.

Fixes #14053

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 17:53:00 +01:00
MSedgeMC b75193985b port_map: WorldOptionsScreen.init's port box tooltip 2026-11-09 16:51:28 +00:00
MSedgeMC 1a07a02990 port_map: the social row buttons' tooltip delay and the PlayerEntry row 2026-11-09 16:51:22 +00:00
Mik00000 c221aa9ec9 perf(render): cache chunk lookups inside ParticleLevel
Maintainer feedback from PR #13906 requested keeping signatures identical and
placing the one-chunk lookup cache directly inside ParticleLevel via
Cell<Option<((i32, i32), Option<&'a Chunk>)>>, avoiding threading _cached twins
and preventing particles.rs from growing beyond base size.

- Store cached_chunk inside ParticleLevel and read through ParticleLevel::block.
- Decouple Ctx lifetime parameter in polytone.rs (Ctx<'a, 'l>) so ParticleLevel's
  invariant 'a does not constrain stack evaluation borrows.
- Keep particles.rs strictly under line size limits (5,195 non-test lines vs 5,197 base).
- Passes check_file_size.py, cargo fmt, clippy -D warnings, and particle unit tests.
2026-11-09 18:47:29 +02:00
Horizon agentandClaude Opus 5.5 3e77ec425f remote.rs: the packet poll loop moves to remote_poll.rs (pure move)
poll_stationary_client, poll_remote and poll_remote_events move unchanged
into game/remote_poll.rs, a module of remote.rs, so a change to the loop
(#12271's cancellable packets) no longer grows remote.rs, which is over the
3000-line limit. Only the visibility (pub(in crate::game)) and one path
(super::super::server_links) follow the new module.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 17:45:28 +01:00
Horizon agent 2a018699ac Merge remote-tracking branch 'origin/main' into layers/packs-sky
# Conflicts:
#	crates/horizon-client/src/client_lib.rs
2026-11-09 17:34:13 +01:00
Vinícius MoreiraandClaude Opus 5.5 5757de7e74 Merge main into #13613
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 13:28:51 -03:00
MSedgeMC 916ab6b8b1 A reopened social screen starts its tooltips' wait again
The screen's widgets are new on every open in vanilla, so the half-second
wait starts over rather than resuming a hover from the last time.
2026-11-09 16:26:28 +00:00
MSedgeMC b56e58fde3 Document the sign side codec fallback 2026-11-09 16:24:35 +00:00
alexcatdadandClaude Opus 5.5 0df07ea3cf Nix: build horizon-server, the engine, horizon-prepare and the client from the flake
The flake built only the launcher, for Linux. It now also builds the game's programs from this
workspace, with nixpkgs' rustPlatform and Cargo.lock, on x86_64-linux, aarch64-linux and
aarch64-darwin (nix/):

- horizon-engine: horizon-client --no-default-features, the headless game (no ALSA or udev);
- horizon-server, with horizon-engine linked beside it, where the server looks for the game;
- horizon-prepare, with a JDK 25 as its default JAVA_HOME and its root (research-lock.json, the
  exporters' sources) in share/: a wrapper copies that root somewhere writable unless --root is given;
- horizon-client with horizon-ctl, the libraries wgpu and winit open at run time on its rpath.

The launcher and the dev shell stay as they were, on Linux. docs/nix.md says how to use them. Nothing
of Mojang's is fetched or packaged. Part of #13841.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 18:23:29 +02:00
TheDanikReal e527784bc3 Fix more Belarusian translations 2026-11-09 16:23:03 +00:00
MSedgeMC 5dd06d27cb The port box explains itself on hover
WorldOptionsScreen gives portEdit the PORT_INFO_TEXT tooltip, so hovering the
LAN port box says what it is; Horizon drew no tooltip for it. The box stays
visible with LAN off (only its editing stops), so the tooltip shows there too.
2026-11-09 16:21:49 +00:00
MSedgeMC 3c5a95239d A social screen row button's tooltip waits half a second
PlayerEntry sets TOOLTIP_DELAY (500 ms) on its report, mute and show
buttons, so their tooltips appear only after the cursor has rested on the
same button for half a second (WidgetTooltipHolder). Horizon's social screen
drew the row button's tooltip on the first hovered frame.

social_screen::TooltipDelay now tracks the hovered Tip and its start time and
answers whether the tooltip shows; the screen's draw passes the current tip.
2026-11-09 16:21:43 +00:00
TheDanikReal af593b6559 Fix Belarusian translations 2026-11-09 16:08:47 +00:00
Horizon agentandClaude Opus 5.5 aa060c5fe1 Horizon's look is given from its own module
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 16:59:57 +01:00
Horizon agentandClaude Opus 5.5 07ff96c50c rustfmt
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 16:59:57 +01:00
Horizon agentandClaude Opus 5.5 19c23a96c6 rustfmt
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 16:59:57 +01:00
Horizon agentandClaude Opus 5.5 29d8c49bc2 Window icon: the engine takes it from the layer above
horizon-client-input's window code no longer embeds Horizon's icon: the
startup gives it (window::set_icon_png), and with_horizon_icon becomes
with_icon. horizon-client-input no longer depends on horizon-theme outside
its tests (layer plan M6).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 16:59:57 +01:00
Vinícius MoreiraandClaude Opus 5.5 ed3fabb7b4 command_exec: the command block case keeps to failed commands
A success's feedback names the player with an entity hover, a new score
holder (which changes a later scoreboard players list) or a bracketed
value, whose components the two engines build differently. The case
keeps the two failures, whose styled feedback is what the save must keep.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 12:35:40 -03:00
MSedgeMC 83c50cf720 A sign whose messages are not a list loads an empty side
SignText.CODEC reads a side's messages as a list; any other tag fails the side
and leaves SignText.EMPTY. Horizon ignored the bad field but still applied the
side's colour and glow.
2026-11-09 15:32:32 +00:00
Horizon agentandClaude Opus 5.5 15da067a21 fix: deref the sky layers; rustfmt
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 15:25:26 +01:00
Horizon agentandClaude Opus 5.5 382776e2ee Custom sky layers through the engine's pack-format hook
layer plan M6 (pack formats), step 1: the renderer's custom sky layers are
engine types (crate::custom_sky: Blend, Draw, Conditions, the SkyLayers
trait); OptiFine's sky format fills them through crate::pack_hooks, a hook
the platform installs at startup. The OptiFine glue moves to pack_formats/,
which only it names horizon_optifine and moves to a platform crate later.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 15:17:38 +01:00
Horizon agentandClaude Opus 5.5 9163335028 command_exec: the command block case scores the player, not a new holder
A fake holder (#cb) created here stays tracked after its objective is
removed, and the later /scoreboard players list then lists the holders
in another order than vanilla's hash map. Horizon's open-hash order for
that insertion is a separate bug; this case is about LastOutput.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 13:49:14 +01:00
Vinícius MoreiraandClaude Opus 5.5 d3392cb35b A command block saves its last output as a component
BaseCommandBlock.save writes LastOutput with ComponentSerialization.CODEC:
the time-stamped feedback component with its colours and click events.
Horizon wrote its plain characters as a string.

Fixes #13077

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 09:40:24 -03:00
RINNECODERandClaude Opus 5.5 26d55e5d39 Campfire smoke goes through the particle limiter
`make_campfire_smoke` made both of its particles with the engine's
`create`, which skips `ClientLevel.doAddParticle`'s limiter, so with
Particles on Minimal or Decreased, or a campfire more than 32 blocks
away, a smoking (doused) campfire still drew every plain `smoke` puff.

As `CampfireBlock.makeParticles` does, the cosy/signal smoke now goes
through `addAlwaysVisibleParticle(.., true, ..)` (`add_with(.., true,
true)`: always made, but it still rolls the Particles option) and the
plain smoke through `addParticle` (`add`).

Fixes #13663

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 01:59:31 -06:00
DUzzL f82a1bd4fb A cured zombie villager drops its picked-up gear instead of wearing it 2026-11-09 08:30:18 +01:00
DUzzL 60b7307b3f Break cushions in lava at the check interval 2026-11-09 08:19:30 +01:00
126 changed files with 3081 additions and 3462 deletions

No files matched your search

+1 -1
View File
@@ -186,7 +186,7 @@ state stays separate from the library tests. This compiles gameplay twice for ex
| Resource packs | L: `packs.rs` + `packs/`, `pack_screen.rs`, `atlas_sources.rs`, `sprites.rs`, `mipmap.rs`, `model_library.rs`, `font.rs`, `mobs/cem.rs` + `mobs/cem_anim.rs` (OptiFine/EMF entity models and their animations); `horizon-client-core/src/lang.rs`; crates `horizon-assets`, `horizon-optifine`; G: `packs.rs` |
| Agent control | L: `control.rs` (socket), `bin/horizon-ctl/`; G: `control.rs`, `agent.rs`, `events.rs`, `ui_control.rs` ([docs/agent-control.md](docs/agent-control.md)) |
| Mods | crate `horizon-mods`; G: `mods.rs`; L: `mod_content.rs`, `mod_list.rs` |
| Horizon extras, settings | L: `extras.rs`, `graphics.rs`, `settings.rs`, `minimap.rs`; G: `extras.rs` + `extras/` (each extra's glue: `mouse_tweaks.rs`, `sorting.rs`, `pvp.rs`, `reconnect.rs`), `options.rs` |
| Horizon extras, settings | L: `extras.rs`, `graphics.rs`, `settings.rs`, `minimap.rs`; G: `extras.rs`, `tweaks.rs`, `options.rs` |
## How `Game` is organised
+4 -2
View File
@@ -9,11 +9,13 @@ description = "The client's input layer: key bindings and their names, mouse, co
[dependencies]
horizon-client-core = { path = "../horizon-client-core" }
winit = "0.30.13"
# The window icon.
horizon-theme = { path = "../horizon-theme" }
png = "0.18"
arboard = { version = "3.6.1", default-features = false, features = ["wayland-data-control"] }
[dev-dependencies]
# The window icon test decodes Horizon's icon.
horizon-theme = { path = "../horizon-theme" }
[features]
controllers = []
+32 -10
View File
@@ -638,10 +638,30 @@ pub fn fullscreen_request(
}
}
/// Horizon's icon for the window's title bar and the task bar (X11 and Windows; Wayland takes
/// it from the `horizon.desktop` entry, macOS from the app bundle). `None` if it can't be read.
/// The PNG the windows show as their icon, given once at startup by the layer that brands the
/// game (the engine has none of its own).
static ICON_PNG: std::sync::OnceLock<&'static [u8]> = std::sync::OnceLock::new();
/// Give the windows their icon, a PNG: call it before the first window opens. A second call
/// keeps the first icon.
pub fn set_icon_png(png: &'static [u8]) {
let _ = ICON_PNG.set(png);
}
/// The icon given to [`set_icon_png`], if any.
pub fn icon_png() -> Option<&'static [u8]> {
ICON_PNG.get().copied()
}
/// The icon for the window's title bar and the task bar (X11 and Windows; Wayland takes it from
/// the `.desktop` entry, macOS from the app bundle). `None` with no icon given, or one that
/// can't be read.
pub fn icon() -> Option<winit::window::Icon> {
let mut decoder = png::Decoder::new(std::io::Cursor::new(horizon_theme::ICON_PNG));
decode_icon(icon_png()?)
}
fn decode_icon(png: &[u8]) -> Option<winit::window::Icon> {
let mut decoder = png::Decoder::new(std::io::Cursor::new(png));
decoder.ignore_checksums(true);
decoder.set_transformations(png::Transformations::EXPAND | png::Transformations::STRIP_16);
let mut reader = decoder.read_info().ok()?;
@@ -654,14 +674,15 @@ pub fn icon() -> Option<winit::window::Icon> {
winit::window::Icon::from_rgba(bytes, info.width, info.height).ok()
}
/// Apply Horizon's icon to every window. Windows has a separate taskbar icon slot from the small
/// window icon; requiring the decoded icon there prevents a generic taskbar icon from appearing.
pub fn with_horizon_icon(
attributes: winit::window::WindowAttributes,
) -> winit::window::WindowAttributes {
/// Apply the icon given to [`set_icon_png`] to a window. Windows has a separate taskbar icon slot
/// from the small window icon; requiring a given icon to decode there prevents a generic taskbar
/// icon from appearing.
pub fn with_icon(attributes: winit::window::WindowAttributes) -> winit::window::WindowAttributes {
#[cfg(not(target_os = "windows"))]
let icon = icon();
#[cfg(target_os = "windows")]
let icon = Some(icon.expect("Horizon's icon must be available on Windows"));
let icon =
icon_png().map(|png| decode_icon(png).expect("the window icon must decode on Windows"));
let attributes = attributes.with_window_icon(icon.clone());
#[cfg(target_os = "windows")]
@@ -706,7 +727,8 @@ mod tests {
}
#[test]
fn the_window_icon_decodes() {
assert!(icon().is_some());
assert!(decode_icon(horizon_theme::ICON_PNG).is_some());
assert!(decode_icon(b"not a png").is_none());
}
#[test]
+2 -1
View File
@@ -72,7 +72,6 @@ flate2 = { version = "1.1.10", default-features = false, features = ["zlib-rs",
mio = { version = "1.2.4", features = ["os-poll", "net"] }
# Shared chunk frames are found by one keyed 128-bit hash of their body (lan/conn.rs SharedFrames).
xxhash-rust = { version = "0.8.19", features = ["xxh3"] }
horizon-theme = { path = "../horizon-theme" }
# Opt-in data collection: the game keeps, the launcher sends (docs/telemetry.md).
horizon-telemetry = { path = "../horizon-telemetry", default-features = false }
horizon-bench = { path = "../horizon-bench" }
@@ -132,6 +131,8 @@ windows-segment-heap = []
workspace = true
[dev-dependencies]
# ui_theme's unit tests draw text in the design system's fonts.
horizon-theme = { path = "../horizon-theme" }
horizon-client-render = { path = "../horizon-client-render", features = ["test-support"] }
horizon-items = { path = "../horizon-items", features = ["test-support"] }
# Every WGSL shader translated as wgpu's DirectX 12 backend does (gfx.rs tests).
+2 -1
View File
@@ -30,7 +30,6 @@ horizon-render-pipeline = { path = "../horizon-render-pipeline" }
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"
@@ -82,5 +81,7 @@ test-support = []
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"] }
@@ -362,7 +362,7 @@ fn fixture_frame(hud: &HudBatch) -> Frame<'_> {
outline: Vec::new(),
outline_after_terrain: false,
outline_high_contrast: false,
look: Default::default(),
pvp: Default::default(),
debug_lines: &[],
debug_lines_on_top: &[],
debug_crosshair: &[],
@@ -430,7 +430,7 @@ fn fixture_frame(hud: &HudBatch) -> Frame<'_> {
outline: Vec::new(),
outline_after_terrain: false,
outline_high_contrast: false,
look: Default::default(),
pvp: Default::default(),
debug_lines: &[],
debug_lines_on_top: &[],
debug_crosshair: &[],
@@ -121,8 +121,8 @@ impl CommandBlock {
condition_met: flag("conditionMet", false),
}
}
/// `CommandBlockEntity.saveAdditional`. The last output is written as a
/// plain string component.
/// `CommandBlockEntity.saveAdditional`. The last output is the component
/// itself (`ComponentSerialization.CODEC`), styles and click events kept.
pub fn save(&self) -> Vec<(&'static str, Tag)> {
let mut out = vec![
("Command", Tag::String(self.command.as_str().into())),
@@ -135,7 +135,7 @@ impl CommandBlock {
if self.track_output
&& let Some(last) = &self.last_output
{
out.push(("LastOutput", Tag::String(last.plain().as_str().into())));
out.push(("LastOutput", scoreboard::component_tag(&last.component())));
}
out.push((
"UpdateLastExecution",
+76 -204
View File
@@ -1,27 +1,34 @@
//! OptiFine custom skies (`horizon_optifine::sky`) as the game draws them: a pack's layers with
//! their decoded textures, read once with the pack, and each frame's choice of layers, their
//! brightness and rotation ([`CustomSky::draws`]), which `gfx::custom_sky` draws over the
//! vanilla sky. A pack without `optifine/sky` gives an empty [`CustomSky`] and the sky stays
//! vanilla.
//! Custom sky layers: skyboxes a pack draws over the vanilla sky, in a format vanilla doesn't read
//! (OptiFine's), which the platform's pack formats fill ([`crate::pack_hooks`]). The layers'
//! textures are read once with the pack; each frame the game asks for the layers to draw, their
//! brightness and rotation ([`SkyLayers::draws`]), and `gfx::custom_sky` draws them over the
//! vanilla sky. A pack without layers keeps the vanilla sky.
use std::path::Path;
use std::sync::Arc;
use glam::{Mat4, Vec3};
use horizon_optifine::sky::{Blend, Conditions, Skies, SkyLayer, Transition};
use glam::Mat4;
/// A layer's texture, RGBA8.
/// A layer's texture, RGBA8: the skybox's six faces in a 3x2 grid, bottom, top and south in the
/// top row, west, north and east below (`gfx::custom_sky`'s faces).
pub struct Image {
pub width: u32,
pub height: u32,
pub rgba: Vec<u8>,
}
/// The pack's layers, by world, with one texture per layer (`images[i]` for the `i`th layer
/// over all worlds, in `worlds` order).
#[derive(Default)]
pub struct CustomSky {
worlds: Vec<(i32, Vec<SkyLayer>)>,
pub images: Vec<Image>,
/// How a layer mixes with what is drawn below it.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Blend {
Add,
Subtract,
Multiply,
Dodge,
Burn,
Screen,
Replace,
Overlay,
Alpha,
}
/// One layer of a frame: which texture, with which blend, how bright and turned how.
@@ -34,62 +41,44 @@ pub struct Draw {
pub rotation: Mat4,
}
/// The weather of a frame: thunder is a storm's rain, so it is only `Thunder`.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Weather {
Clear,
Rain,
Thunder,
}
/// What a frame's layers may depend on.
#[derive(Clone, Copy, Debug)]
pub struct Conditions<'a> {
/// Ticks of the day, counting the days.
pub day_time: i64,
pub weather: Weather,
/// The biome at the camera, namespaced.
pub biome: &'a str,
/// The camera's block height.
pub y: i32,
}
/// The eased brightness of each layer, kept by the game between frames.
#[derive(Default)]
pub struct State {
world: Option<i32>,
eased: Vec<Transition>,
/// The world the layers were last drawn for.
pub world: Option<i32>,
/// Each layer's eased visibility, 0 to 1.
pub eased: Vec<f32>,
}
/// Textures past this many texels in all are left out (a skybox is a few megapixels at most).
const TEXEL_BUDGET: u64 = 64 << 20;
impl CustomSky {
/// The custom skies of the overlay at `root`. A layer whose texture does not decode is
/// dropped with a warning.
pub fn load(root: &Path) -> Self {
let skies = Skies::scan(root);
let mut out = Self::default();
let mut texels = 0u64;
for (world, sky) in skies.worlds {
let mut layers = Vec::new();
for layer in sky.layers {
match crate::hud::decode(&root.join(&layer.source)) {
Ok((width, height, rgba))
if texels + u64::from(width) * u64::from(height) <= TEXEL_BUDGET =>
{
texels += u64::from(width) * u64::from(height);
out.images.push(Image {
width,
height,
rgba,
});
layers.push(layer);
}
Ok(_) => log::warn!("{}: past the custom sky's texture budget", layer.source),
Err(e) => log::warn!("{}: {e:#}", layer.source),
}
}
if !layers.is_empty() {
out.worlds.push((world, layers));
}
}
out
}
pub fn is_empty(&self) -> bool {
self.worlds.is_empty()
}
/// Whether `world` has layers (`0` the Overworld, `1` the End).
pub fn has(&self, world: i32) -> bool {
self.worlds.iter().any(|(w, _)| *w == world)
}
/// This frame's layers of `world` into `out` (cleared first), in drawing order: those whose
/// conditions hold, eased over their `transition` by `seconds` since the last frame, at the
/// brightness of their fade times. `sun_turns` is the sun's angle over 360° (`0` at noon).
pub fn draws(
/// A pack's custom sky layers, by world (`0` the Overworld, `1` the End).
pub trait SkyLayers: Send + Sync {
/// Every layer's texture: [`Draw::image`] indexes it.
fn images(&self) -> &[Image];
/// Whether `world` has layers.
fn has(&self, world: i32) -> bool;
/// This frame's layers of `world` into `out` (cleared first), in drawing order, eased by
/// `seconds` since the last frame. `sun_turns` is the sun's angle over 360° (`0` at noon).
fn draws(
&self,
world: i32,
conditions: &Conditions,
@@ -97,147 +86,30 @@ impl CustomSky {
seconds: f32,
state: &mut State,
out: &mut Vec<Draw>,
) {
);
}
/// No layers: the vanilla sky.
struct NoLayers;
impl SkyLayers for NoLayers {
fn images(&self) -> &[Image] {
&[]
}
fn has(&self, _: i32) -> bool {
false
}
fn draws(&self, _: i32, _: &Conditions, _: f32, _: f32, s: &mut State, out: &mut Vec<Draw>) {
out.clear();
let mut first = 0;
let mut found = None;
for (w, layers) in &self.worlds {
if *w == world {
found = Some(layers);
break;
}
first += layers.len();
}
let Some(layers) = found else {
state.world = None;
return;
};
// A new world (or pack) starts each layer where its conditions put it: no fade in.
let snap = state.world != Some(world) || state.eased.len() != layers.len();
if snap {
state.world = Some(world);
state.eased.clear();
state.eased.resize(layers.len(), Transition::default());
}
for (i, (layer, eased)) in layers.iter().zip(&mut state.eased).enumerate() {
let target = if layer.shows(conditions) { 1.0 } else { 0.0 };
let shown = if snap {
eased.value = target;
target
} else {
eased.step(target, seconds, layer.transition)
};
let brightness = shown * layer.brightness(conditions.day_time);
if brightness <= 0.0 {
continue;
}
let turns = layer.turns(conditions.day_time, sun_turns);
let rotation = if turns == 0.0 {
Mat4::IDENTITY
} else {
Mat4::from_axis_angle(Vec3::from_array(layer.axis), turns * std::f32::consts::TAU)
};
out.push(Draw {
image: (first + i) as u16,
blend: layer.blend,
brightness,
rotation,
});
}
s.world = None;
}
}
#[cfg(test)]
mod tests {
use super::*;
use horizon_optifine::sky::Now;
fn pack(files: &[(&str, &str)]) -> (std::path::PathBuf, CustomSky) {
let root = std::env::temp_dir().join(format!(
"horizon-custom-sky-{}-{}",
std::process::id(),
files.len()
));
let _ = std::fs::remove_dir_all(&root);
let dir = root.join("assets/minecraft/optifine/sky/world0");
std::fs::create_dir_all(&dir).unwrap();
let png = {
let mut bytes = Vec::new();
let mut encoder = png::Encoder::new(&mut bytes, 6, 4);
encoder.set_color(png::ColorType::Rgba);
encoder.set_depth(png::BitDepth::Eight);
let mut writer = encoder.write_header().unwrap();
writer
.write_image_data(&[255, 0, 0, 255].repeat(24))
.unwrap();
writer.finish().unwrap();
bytes
};
for (name, text) in files {
std::fs::write(dir.join(format!("{name}.properties")), text).unwrap();
std::fs::write(dir.join(format!("{name}.png")), &png).unwrap();
}
let sky = CustomSky::load(&root);
(root, sky)
}
fn noon(biome: &str) -> Conditions<'_> {
Conditions {
day_time: 6000,
weather: Now::Clear,
biome,
y: 64,
}
}
#[test]
fn layers_ease_in_at_a_biome_border_but_not_on_arrival() {
let (root, sky) = pack(&[
("sky1", "biomes=desert\ntransition=2"),
("sky2", "blend=alpha\nrotate=false"),
]);
let _ = std::fs::remove_dir_all(root);
assert!(sky.has(0) && !sky.has(1));
assert_eq!(sky.images[0].width, 6);
let (mut state, mut out) = (State::default(), Vec::new());
sky.draws(
0,
&noon("minecraft:desert"),
0.0,
0.05,
&mut state,
&mut out,
);
assert_eq!(
out.iter().map(|d| d.brightness).collect::<Vec<_>>(),
[1.0, 1.0]
);
// Out of the desert: the first layer fades over its two seconds.
sky.draws(0, &noon("minecraft:plains"), 0.0, 0.5, &mut state, &mut out);
assert_eq!(out[0].brightness, 0.75);
assert_eq!(out[1].blend, Blend::Alpha);
sky.draws(0, &noon("minecraft:plains"), 0.0, 2.0, &mut state, &mut out);
assert_eq!(out.len(), 1, "a dark layer is not drawn");
assert_eq!(out[0].image, 1);
// Into the End, which has no layers, and back: no fade.
sky.draws(1, &noon("minecraft:desert"), 0.0, 0.0, &mut state, &mut out);
assert!(out.is_empty());
sky.draws(0, &noon("minecraft:desert"), 0.0, 0.0, &mut state, &mut out);
assert_eq!(out.len(), 2);
}
#[test]
fn a_layer_turns_west_like_the_sun() {
let (root, sky) = pack(&[("sky1", "")]);
let _ = std::fs::remove_dir_all(root);
let (mut state, mut out) = (State::default(), Vec::new());
// A quarter turn after noon (sunset): the skybox's zenith has gone down to the west.
let c = Conditions {
day_time: 12000,
..noon("minecraft:plains")
};
sky.draws(0, &c, 0.25, 0.0, &mut state, &mut out);
let up = out[0].rotation.transform_vector3(Vec3::Y);
assert!((up - Vec3::NEG_X).length() < 1e-5, "{up}");
}
/// The custom sky layers of the overlay at `root`, from the installed pack formats.
pub fn load(root: &Path) -> Arc<dyn SkyLayers> {
crate::pack_hooks::formats()
.custom_sky(root)
.unwrap_or_else(|| Arc::new(NoLayers))
}
+9 -9
View File
@@ -1001,8 +1001,8 @@ pub struct Frame<'a> {
pub outline_after_terrain: bool,
/// `BlockOutlineRenderState.highContrast`: a thick black outline under a bright one.
pub outline_high_contrast: bool,
/// The outline, hurt overlay and glint colours a crate above set (`horizon_hooks::look`).
pub look: horizon_hooks::look::Frame,
/// The PvP & HUD page's outline and hit colour (`pvp::Frame`; default: vanilla's).
pub pvp: crate::pvp::Frame,
/// `lines` with a colour and pixel width (`gizmos::camera_relative`):
/// the fishing line and debug gizmos, depth tested, then gizmos drawn
/// over everything.
@@ -1329,7 +1329,7 @@ pub struct Renderer {
celestial_buffer: wgpu::Buffer,
end_sky: end_sky::EndSky,
stars: stars::Stars,
/// OptiFine custom sky layers (`crate::custom_sky`), and the frame's from the game.
/// Custom sky layers (`crate::custom_sky`), and the frame's from the game.
custom_sky: custom_sky::CustomSky,
custom_sky_draws: Vec<crate::custom_sky::Draw>,
/// The glowing entities' outline (`LevelRenderer.entityOutlineTarget`).
@@ -5517,11 +5517,11 @@ impl Renderer {
))
});
}
/// The pack's OptiFine custom sky layer textures (`crate::custom_sky::CustomSky::load`).
pub fn set_custom_sky(&mut self, pack: &crate::custom_sky::CustomSky) {
/// The pack's custom sky layer textures (`crate::custom_sky::load`).
pub fn set_custom_sky(&mut self, pack: &dyn crate::custom_sky::SkyLayers) {
self.custom_sky.set_pack(&self.device, &self.queue, pack);
}
/// The custom sky layers the next frame draws (`CustomSky::draws`): the game refills it each
/// The custom sky layers the next frame draws (`SkyLayers::draws`): the game refills it each
/// frame, empty when the world's sky has none.
pub fn custom_sky_draws(&mut self) -> &mut Vec<crate::custom_sky::Draw> {
&mut self.custom_sky_draws
@@ -8506,9 +8506,9 @@ impl Renderer {
colored_count: [
frame.colored_lights.len().min(crate::dynamic_light::MAX) as u32,
// PvP & HUD's hit colour: entity.wgsl's hurt texel, 0 for vanilla's.
frame.look.hurt_texel,
frame.pvp.hurt_texel,
// PvP & HUD's glint colour: entity.wgsl's `tinted_glint`, 0 for vanilla's.
frame.look.glint,
frame.pvp.glint,
0,
],
emissive_layers: {
@@ -9495,7 +9495,7 @@ impl Renderer {
}
};
// A custom outline (PvP & HUD) takes the bright stroke's colour, scales both widths.
let (custom, scale) = frame.look.outline.map_or((None, 1.0), |o| (Some(o.0), o.1));
let (custom, scale) = frame.pvp.outline.map_or((None, 1.0), |(c, s)| (Some(c), s));
if frame.outline_high_contrast {
stroke(0xff00_0000, 7.0 * scale, lines);
stroke(custom.unwrap_or(0xff57_ffe1), width * scale, lines);
@@ -524,7 +524,7 @@ impl Scene {
outline: Vec::new(),
outline_after_terrain: false,
outline_high_contrast: false,
look: Default::default(),
pvp: Default::default(),
debug_lines: &[],
debug_lines_on_top: &[],
debug_crosshair: &[],
@@ -1,12 +1,11 @@
//! OptiFine custom sky layers on the GPU: one texture per layer, kept with the renderer (its pack),
//! Custom sky layers (`crate::custom_sky`) on the GPU: one texture per layer, kept with the renderer (its pack),
//! a static skybox mesh of the 3x2 grid's six faces, and one pipeline per blend mode. A frame
//! uploads one small uniform per drawn layer ([`crate::custom_sky::Draw`]) and draws 36 vertices
//! each, after the vanilla sky's disc and sunrise and before the sun, moon and stars.
use glam::Mat4;
use horizon_optifine::sky::{Blend, Face};
use wgpu::util::DeviceExt;
use crate::custom_sky::{CustomSky as Pack, Draw};
use crate::custom_sky::{Blend, Draw, SkyLayers};
/// The blend modes in the shader's `mode` order (custom_sky.wgsl).
const MODES: [Blend; 9] = [
@@ -85,6 +84,76 @@ fn blend(mode: Blend) -> wgpu::BlendComponent {
}
}
/// A face of the skybox, as the 3x2 grid of its texture holds them: `Bottom`, `Top`, `South` in
/// the top row, `West`, `North`, `East` below.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
enum Face {
Bottom,
Top,
South,
West,
North,
East,
}
impl Face {
const ALL: [Self; 6] = [
Self::Bottom,
Self::Top,
Self::South,
Self::West,
Self::North,
Self::East,
];
/// The face's cell of the texture, `[u0, v0, u1, v1]` in 0..1.
fn cell(self) -> [f32; 4] {
let (col, row) = match self {
Self::Bottom => (0, 0),
Self::Top => (1, 0),
Self::South => (2, 0),
Self::West => (0, 1),
Self::North => (1, 1),
Self::East => (2, 1),
};
let (u, v) = (col as f32 / 3.0, row as f32 / 2.0);
[u, v, u + 1.0 / 3.0, v + 0.5]
}
/// The face's corners (on the unit cube around the camera; x east, y up, z south) in the
/// order of its cell's `(u0, v0)`, `(u1, v0)`, `(u1, v1)`, `(u0, v1)`. The side faces are
/// seen from inside with up at the top of their cell; the top face has north at the bottom of
/// its cell, the bottom face north at the top (the template's `N` marks).
fn corners(self) -> [[f32; 3]; 4] {
match self {
// Seen looking north: west on the left.
Self::North => [
[-1., 1., -1.],
[1., 1., -1.],
[1., -1., -1.],
[-1., -1., -1.],
],
Self::South => [[1., 1., 1.], [-1., 1., 1.], [-1., -1., 1.], [1., -1., 1.]],
Self::East => [[1., 1., -1.], [1., 1., 1.], [1., -1., 1.], [1., -1., -1.]],
Self::West => [
[-1., 1., 1.],
[-1., 1., -1.],
[-1., -1., -1.],
[-1., -1., 1.],
],
// Looking up with north toward the cell's bottom edge: east on the right.
Self::Top => [[-1., 1., 1.], [1., 1., 1.], [1., 1., -1.], [-1., 1., -1.]],
// Looking down with north toward the cell's top edge: east on the right.
Self::Bottom => [
[-1., -1., -1.],
[1., -1., -1.],
[1., -1., 1.],
[-1., -1., 1.],
],
}
}
}
/// The skybox's 36 vertices: two triangles per face, each corner with its cell's UV.
fn mesh() -> Vec<Vertex> {
let mut out = Vec::with_capacity(36);
@@ -256,10 +325,15 @@ impl CustomSky {
}
/// The pack's layer textures, replacing any before.
pub(super) fn set_pack(&mut self, device: &wgpu::Device, queue: &wgpu::Queue, pack: &Pack) {
pub(super) fn set_pack(
&mut self,
device: &wgpu::Device,
queue: &wgpu::Queue,
pack: &dyn SkyLayers,
) {
let limit = device.limits().max_texture_dimension_2d;
self.groups = pack
.images
.images()
.iter()
.map(|image| {
// Too large for this GPU: a 1x1 transparent texel, so the layer draws nothing.
@@ -413,6 +487,38 @@ impl CustomSky {
mod tests {
use super::*;
#[test]
fn the_grid_holds_each_face_once_and_faces_point_out() {
let mut cells: Vec<_> = Face::ALL
.iter()
.map(|f| f.cell().map(|x| (x * 6.0) as i32))
.collect();
cells.dedup();
assert_eq!(cells.len(), 6);
assert_eq!(Face::East.cell(), [2.0 / 3.0, 0.5, 1.0, 1.0]);
for face in Face::ALL {
let c = face.corners();
let sub = |a: [f32; 3], b: [f32; 3]| [a[0] - b[0], a[1] - b[1], a[2] - b[2]];
let (a, b) = (sub(c[1], c[0]), sub(c[3], c[0]));
let n = [
a[1] * b[2] - a[2] * b[1],
a[2] * b[0] - a[0] * b[2],
a[0] * b[1] - a[1] * b[0],
];
let centre: [f32; 3] =
std::array::from_fn(|i| c.iter().map(|p| p[i]).sum::<f32>() / 4.0);
// Seen from inside, u runs right and v down, so u x v points away from the camera.
let dot: f32 = (0..3).map(|i| n[i] * centre[i]).sum();
assert!(dot > 0.0, "{face:?} is mirrored");
}
// The top cell's bottom edge (v1) is north: its corners 2 and 3 have z = -1.
assert_eq!(Face::Top.corners()[2][2], -1.0);
assert_eq!(Face::Top.corners()[3][2], -1.0);
// The bottom cell's top edge (v0) is north.
assert_eq!(Face::Bottom.corners()[0][2], -1.0);
assert_eq!(Face::Bottom.corners()[1][2], -1.0);
}
/// What a blend leaves a sky texel of `dst` with when the shader outputs `src`
/// (custom_sky.wgsl) for a layer texel `t` at brightness `b`, per channel.
fn mix(mode: Blend, dst: f32, t: f32, a: f32, b: f32) -> f32 {
@@ -6,7 +6,7 @@ struct Layer {
// Projection * view (camera-relative) * the layer's rotation.
clip_from_sky: mat4x4<f32>,
brightness: f32,
// horizon_optifine::sky::Blend, in gfx/custom_sky.rs's MODES order.
// crate::custom_sky::Blend, in gfx/custom_sky.rs's MODES order.
mode: u32,
pad: vec2<u32>,
hdr_settings: vec4<f32>,
+6 -4
View File
@@ -1001,12 +1001,14 @@ impl Hdr {
}
}
/// Horizon's icon (horizon-theme) as a 1-bit mask: its white strokes, one colour with hard
/// edges, are the logo; everything else is not.
/// The windows' icon (`window::set_icon_png`: Horizon's in the official game) as a 1-bit mask:
/// its white strokes, one colour with hard edges, are the logo; everything else is not. No icon
/// given: an empty mask.
fn logo_mask(device: &wgpu::Device, queue: &wgpu::Queue) -> wgpu::TextureView {
use wgpu::util::DeviceExt;
let (w, h, rgba) =
crate::hud::decode_bytes(horizon_theme::ICON_PNG).unwrap_or((1, 1, vec![0; 4]));
let (w, h, rgba) = horizon_client_input::window::icon_png()
.and_then(|png| crate::hud::decode_bytes(png).ok())
.unwrap_or((1, 1, vec![0; 4]));
let mask: Vec<u8> = rgba
.as_chunks::<4>()
.0
@@ -602,7 +602,7 @@ pub(super) fn fixture_frame(hud: &HudBatch) -> Frame<'_> {
outline: Vec::new(),
outline_after_terrain: false,
outline_high_contrast: false,
look: Default::default(),
pvp: Default::default(),
debug_lines: &[],
debug_lines_on_top: &[],
debug_crosshair: &[],
@@ -440,7 +440,7 @@ fn fixture_frame(hud: &HudBatch) -> Frame<'_> {
outline: Vec::new(),
outline_after_terrain: false,
outline_high_contrast: false,
look: Default::default(),
pvp: Default::default(),
debug_lines: &[],
debug_lines_on_top: &[],
debug_crosshair: &[],
@@ -409,7 +409,7 @@ fn frame(hud: &HudBatch) -> Frame<'_> {
outline: Vec::new(),
outline_after_terrain: false,
outline_high_contrast: false,
look: Default::default(),
pvp: Default::default(),
debug_lines: &[],
debug_lines_on_top: &[],
debug_crosshair: &[],
@@ -2664,7 +2664,7 @@ pub(super) mod initial_history_tests {
outline: Vec::new(),
outline_after_terrain: false,
outline_high_contrast: false,
look: Default::default(),
pvp: Default::default(),
debug_lines: &[],
debug_lines_on_top: &[],
debug_crosshair: &[],
@@ -2,16 +2,21 @@
//! colours and the controls it draws with. The screen (`graphics_ui`) only
//! lays out rows and handles input; a menu theme replaces this module.
//!
//! It takes the design system's Blocks palette (`horizon_theme::BLOCKS_DARK`,
//! `docs/design-system.md`) in a lighter form: square panels of dark glass
//! with a thin edge, so the world shows through, flat controls, and the violet
//! face on the one main button, in vanilla's font.
//! Its colours come from `menu_style`, given at startup (in the official game the design
//! system's Blocks palette, `docs/design-system.md`), in a lighter form: square panels of
//! dark glass with a thin edge, so the world shows through, flat controls, and the face on
//! the one main button, in vanilla's font.
use crate::menu_style::{self, SettingsColours};
use crate::{
hud::{HudAtlas, HudBatch},
text::Text,
ui,
};
use horizon_theme::BLOCKS_DARK as B;
/// The settings pages' colours (`menu_style`, given at startup).
fn b() -> &'static SettingsColours {
&menu_style::get().settings
}
/// What a setting costs in frame time, shown with its description.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
@@ -36,54 +41,91 @@ impl Cost {
/// `0xRRGGBB`: the palette's success, warn and danger.
pub fn rgb(self) -> u32 {
match self {
Cost::None => B.ink_soft,
Cost::Low | Cost::Saves => B.success,
Cost::Medium => B.warn,
Cost::High => B.danger,
Cost::None => b().ink_soft,
Cost::Low | Cost::Saves => b().success,
Cost::Medium => b().warn,
Cost::High => b().danger,
}
}
}
/// `0xRRGGBB` and an alpha in the HUD's colour encoding.
const fn hex(c: u32, a: u8) -> u32 {
(a as u32) << 24 | (c & 0xff) << 16 | (c >> 8 & 0xff) << 8 | (c >> 16 & 0xff)
}
use menu_style::hud_colour as hex;
/// The plate and the strip: the page's dark, see-through, so the world shows behind them.
const GLASS: u32 = hex(B.page, 118);
fn glass() -> u32 {
hex(b().page, 118)
}
/// Their 1 px edge.
const EDGE: u32 = hex(B.ink, 26);
pub fn edge() -> u32 {
hex(b().ink, 26)
}
/// A group's panel, a little lighter than the plate it sits on.
const GROUP: u32 = hex(B.ink, 11);
fn group_fill() -> u32 {
hex(b().ink, 11)
}
/// A hovered or focused row, lit on its group's panel.
pub const HOVER: u32 = hex(B.ink, 18);
pub fn hover() -> u32 {
hex(b().ink, 18)
}
/// The open page in the page list.
pub const OPEN: u32 = hex(B.ink, 26);
pub fn open() -> u32 {
hex(b().ink, 26)
}
/// The rule under the plate's heading, and between two rows of a group.
pub const HEAD_RULE: u32 = hex(B.ink, 31);
pub const ROW_RULE: u32 = hex(B.ink, 15);
pub fn head_rule() -> u32 {
hex(b().ink, 31)
}
pub fn row_rule() -> u32 {
hex(b().ink, 15)
}
/// Fields, boxes and the value stepper: sunk, see-through.
const WELL: u32 = hex(B.outline, 102);
fn well_fill() -> u32 {
hex(b().outline, 102)
}
/// A button, a slider's track, the scroller's thumb.
const BUTTON: u32 = hex(B.ink, 20);
const BUTTON_HOVER: u32 = hex(B.ink, 41);
const TRACK: u32 = hex(B.ink, 64);
const HANDLE: u32 = hex(B.ink, 255);
fn button_fill() -> u32 {
hex(b().ink, 20)
}
fn button_hover() -> u32 {
hex(b().ink, 41)
}
fn track_fill() -> u32 {
hex(b().ink, 64)
}
fn handle_fill() -> u32 {
hex(b().ink, 255)
}
/// The one main button.
const FACE: u32 = hex(B.face, 255);
const FACE_HOVER: u32 = hex(0x6a50e6, 255);
pub fn face() -> u32 {
hex(b().face, 255)
}
fn face_hover() -> u32 {
hex(b().face_hover, 255)
}
/// A tick box's edge when off.
const BOX_EDGE: u32 = hex(B.ink_soft, 89);
const OUTLINE: u32 = hex(B.outline, 255);
pub const ACCENT: u32 = hex(B.accent, 255);
fn box_edge() -> u32 {
hex(b().ink_soft, 89)
}
fn outline() -> u32 {
hex(b().outline, 255)
}
pub fn accent() -> u32 {
hex(b().accent, 255)
}
/// Vanilla's white text, and its grey for what can't be used now.
pub const WHITE: u32 = u32::MAX;
pub const MUTED: u32 = hex(B.muted, 255);
pub fn muted() -> u32 {
hex(b().muted, 255)
}
/// A page that isn't open, a value at rest.
pub const SOFT: u32 = hex(B.ink_soft, 255);
pub fn soft() -> u32 {
hex(b().ink_soft, 255)
}
/// Vanilla's grey for a tooltip's lines under its first (`ChatFormatting.GRAY`).
pub const GREY: u32 = hex(0xaaaaaa, 255);
/// A light veil over the backdrop, under the panels.
pub const VEIL: u32 = hex(B.page, 77);
pub fn veil() -> u32 {
hex(b().page, 77)
}
/// Sizes in GUI pixels.
/// Shorter screens than this (a 720p window at scale 3) keep a narrower margin on top.
@@ -120,21 +162,21 @@ pub fn ring(batch: &mut HudBatch, atlas: &HudAtlas, [x, y, w, h]: [f32; 4], colo
}
/// The screen's plate, and the strip under it: dark glass with a thin edge.
pub fn panel(batch: &mut HudBatch, atlas: &HudAtlas, rect: [f32; 4]) {
fill(batch, atlas, rect, GLASS);
ring(batch, atlas, rect, EDGE);
fill(batch, atlas, rect, glass());
ring(batch, atlas, rect, edge());
}
/// A group's panel on the plate, its rows on it.
pub fn group(batch: &mut HudBatch, atlas: &HudAtlas, rect: [f32; 4]) {
fill(batch, atlas, rect, GROUP);
fill(batch, atlas, rect, group_fill());
}
/// A sunk well: the search field, the value stepper.
pub fn well(batch: &mut HudBatch, atlas: &HudAtlas, rect: [f32; 4]) {
fill(batch, atlas, rect, WELL);
fill(batch, atlas, rect, well_fill());
}
/// The scroller: a faint track and its thumb.
pub fn scroller(batch: &mut HudBatch, atlas: &HudAtlas, track: [f32; 4], thumb: [f32; 4]) {
fill(batch, atlas, track, ROW_RULE);
fill(batch, atlas, thumb, TRACK);
fill(batch, atlas, track, row_rule());
fill(batch, atlas, thumb, track_fill());
}
/// `text` cut to `width` with an ellipsis, as a long name gives way to its control: the HUD
/// kit's `fit::ellipsize`.
@@ -142,11 +184,11 @@ pub use crate::hud_kit::fit::ellipsize as fit;
/// A 12 px tick box ending at `right`, centred in the row at `y`: its edge in the accent when on.
pub fn checkbox(batch: &mut HudBatch, atlas: &HudAtlas, right: f32, y: f32, on: bool, live: bool) {
let rect = [right - 12.0, y + 4.0, 12.0, 12.0];
fill(batch, atlas, rect, WELL);
fill(batch, atlas, rect, well_fill());
let edge = match (on, live) {
(true, true) => ACCENT,
(true, false) => MUTED,
(false, _) => BOX_EDGE,
(true, true) => accent(),
(true, false) => muted(),
(false, _) => box_edge(),
};
ring(batch, atlas, rect, edge);
if on {
@@ -169,7 +211,7 @@ fn tick(batch: &mut HudBatch, atlas: &HudAtlas, x: f32, y: f32, color: u32) {
for (i, c) in row.chars().enumerate() {
if c == '#' {
let (px, py) = (x + i as f32, y + j as f32);
fill(batch, atlas, [px + 1.0, py + 1.0, 1.0, 1.0], OUTLINE);
fill(batch, atlas, [px + 1.0, py + 1.0, 1.0, 1.0], outline());
fill(batch, atlas, [px, py, 1.0, 1.0], color);
}
}
@@ -198,17 +240,17 @@ pub fn button_with_room(
) {
let [x, y, w, h] = rect;
let face = match (live, primary, hover) {
(false, _, _) => WELL,
(true, true, false) => FACE,
(true, true, true) => FACE_HOVER,
(true, false, false) => BUTTON,
(true, false, true) => BUTTON_HOVER,
(false, _, _) => well_fill(),
(true, true, false) => face(),
(true, true, true) => face_hover(),
(true, false, false) => button_fill(),
(true, false, true) => button_hover(),
};
fill(batch, atlas, rect, face);
if hover && live {
ring(batch, atlas, rect, WHITE);
}
let color = if live { WHITE } else { MUTED };
let color = if live { WHITE } else { muted() };
let ty = y + ((h - 8.0) / 2.0).floor();
let left = x + 2.0 + room;
scrolling_label(
@@ -252,7 +294,7 @@ pub fn slider(
(hover, live): (bool, bool),
) {
let [x, y, w, h] = rect;
let color = if live { WHITE } else { MUTED };
let color = if live { WHITE } else { muted() };
scrolling_label(
batch,
atlas,
@@ -263,17 +305,22 @@ pub fn slider(
);
let line = y + h - 3.0;
let at = x + (position.clamp(0.0, 1.0) * (w - 4.0)).round();
fill(batch, atlas, [x, line, w, 2.0], TRACK);
fill(batch, atlas, [x, line, w, 2.0], track_fill());
fill(
batch,
atlas,
[x, line, at - x, 2.0],
if live { ACCENT } else { MUTED },
if live { accent() } else { muted() },
);
let handle = [at, line - 3.0, 4.0, 8.0];
fill(batch, atlas, handle, if live { HANDLE } else { MUTED });
fill(
batch,
atlas,
handle,
if live { handle_fill() } else { muted() },
);
if hover && live {
ring(batch, atlas, [at - 1.0, line - 4.0, 6.0, 10.0], ACCENT);
ring(batch, atlas, [at - 1.0, line - 4.0, 6.0, 10.0], accent());
}
}
/// A value that a press steps through: in a well, between the arrows that say so.
@@ -290,13 +337,13 @@ pub fn cycler(
ring(batch, atlas, rect, WHITE);
}
let ty = y + ((h - 8.0) / 2.0).floor();
let arrows = if hover && live { WHITE } else { MUTED };
let arrows = if hover && live { WHITE } else { muted() };
batch.text_styled(atlas, &Text::literal("<"), x + 3.0, ty, arrows, true);
batch.text_styled(atlas, &Text::literal(">"), x + w - 8.0, ty, arrows, true);
let color = match (live, hover) {
(false, _) => MUTED,
(false, _) => muted(),
(true, true) => WHITE,
(true, false) => SOFT,
(true, false) => soft(),
};
scrolling_label(batch, atlas, value, [x + 9.0, ty, w - 18.0], (y, h), color);
}
@@ -3,8 +3,9 @@
//! where a tween is at the frame's [`Clock`]; nothing here holds state or runs on its own, and
//! no motion ever decides what input does.
//!
//! The curves are the design system's (`horizon_theme::QUICK`, `MOVE`): a [`Tween`] takes one
//! of them, at the theme's duration or a shorter one of its own.
//! A [`Tween`] takes a duration and a CSS `cubic-bezier` curve: Horizon's overlays use the design
//! system's (`horizon_theme::QUICK`, `MOVE`), at the theme's duration or a shorter one of their
//! own.
//!
//! The game builds the clock once a frame (`Clock::new(now, still)`). A still clock (the game is
//! paused, frame-stepped by `HORIZON_STILL` or a screenshot sequence, or a repro recording or
@@ -16,7 +17,7 @@
//! use std::time::{Duration, Instant};
//!
//! let opened = Instant::now();
//! let open = Tween::new(Duration::from_millis(90), horizon_theme::QUICK);
//! let open = Tween::new(Duration::from_millis(90), [0.4, 0.0, 0.2, 1.0]);
//! // Half way through: partly faded in, partly grown from 94%.
//! let clock = Clock::new(opened + Duration::from_millis(45), false);
//! let alpha = motion::fade(open, clock, opened);
@@ -26,7 +27,6 @@
//! let still = Clock::new(opened, true);
//! assert_eq!(motion::fade(open, still, opened), 1.0);
//! ```
use horizon_theme::Motion;
use std::time::{Duration, Instant};
/// The time a frame shows, built once a frame by the game.
@@ -50,28 +50,20 @@ impl Clock {
}
}
/// A duration and one of the design system's easing curves.
/// A duration and an easing curve.
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct Tween {
/// How long it runs.
pub duration: Duration,
/// The CSS `cubic-bezier` control points of its curve (a `horizon_theme::Motion`'s).
/// The CSS `cubic-bezier` control points of its curve.
pub curve: [f32; 4],
}
impl Tween {
/// `motion`'s curve over `duration`: the dial's motions are shorter than the launcher's, on
/// the same curves.
pub const fn new(duration: Duration, motion: Motion) -> Self {
Self {
duration,
curve: motion.curve,
}
}
/// A theme motion as it is: its own duration and curve.
pub const fn of(motion: Motion) -> Self {
Self::new(motion.duration(), motion)
/// `curve` over `duration`: the dial's motions are shorter than the launcher's, on the same
/// curves.
pub const fn new(duration: Duration, curve: [f32; 4]) -> Self {
Self { duration, curve }
}
/// The linear progress, 0 to 1, `since` a start at this clock; 1 on a still clock.
@@ -84,7 +76,7 @@ impl Tween {
/// The eased progress, 0 to 1, on the tween's curve.
pub fn progress(self, clock: Clock, since: Instant) -> f32 {
horizon_theme::bezier(self.curve, self.linear(clock, since))
bezier(self.curve, self.linear(clock, since))
}
/// Whether it has finished (always, on a still clock).
@@ -98,6 +90,29 @@ impl Tween {
}
}
/// A CSS `cubic-bezier(x1, y1, x2, y2)` at `t` in 0..1: solves x for the curve parameter,
/// returns y.
pub fn bezier([x1, y1, x2, y2]: [f32; 4], t: f32) -> f32 {
let t = t.clamp(0.0, 1.0);
if t == 0.0 || t == 1.0 {
return t;
}
let at = |a: f32, b: f32, s: f32| {
let u = 1.0 - s;
3.0 * u * u * s * a + 3.0 * u * s * s * b + s * s * s
};
let (mut lo, mut hi) = (0f32, 1f32);
for _ in 0..24 {
let mid = (lo + hi) / 2.0;
if at(x1, x2, mid) < t {
lo = mid;
} else {
hi = mid;
}
}
at(y1, y2, (lo + hi) / 2.0)
}
/// An alpha from 0 to 1 as something fades in.
pub fn fade(tween: Tween, clock: Clock, since: Instant) -> f32 {
tween.progress(clock, since)
@@ -145,7 +160,7 @@ mod tests {
#[test]
fn a_tween_runs_from_its_start_to_its_end_and_holds() {
let t0 = Instant::now();
let tween = Tween::new(MS(80), horizon_theme::MOVE);
let tween = Tween::new(MS(80), horizon_theme::MOVE.curve);
assert_eq!(tween.progress(at(t0, 0), t0), 0.0);
assert_eq!(tween.progress(at(t0, 80), t0), 1.0);
assert_eq!(tween.progress(at(t0, 500), t0), 1.0);
@@ -160,8 +175,7 @@ mod tests {
fn a_still_clock_shows_every_motion_settled() {
let t0 = Instant::now();
let still = Clock::new(t0, true);
let tween = Tween::of(horizon_theme::QUICK);
assert_eq!(tween.duration, horizon_theme::QUICK.duration());
let tween = Tween::new(horizon_theme::QUICK.duration(), horizon_theme::QUICK.curve);
assert_eq!(fade(tween, still, t0), 1.0);
assert_eq!(fade_out(tween, still, t0), 0.0);
assert_eq!(scale_in(tween, still, t0, 0.94), 1.0);
@@ -173,7 +187,7 @@ mod tests {
#[test]
fn the_helpers_start_where_they_say() {
let t0 = Instant::now();
let tween = Tween::new(MS(100), horizon_theme::QUICK);
let tween = Tween::new(MS(100), horizon_theme::QUICK.curve);
let c = at(t0, 0);
assert_eq!(fade(tween, c, t0), 0.0);
assert_eq!(fade_out(tween, c, t0), 1.0);
@@ -184,10 +198,25 @@ mod tests {
assert!((flash(tween, at(t0, 25), t0) - 0.75).abs() < 1e-6);
}
#[test]
fn the_curve_is_the_design_systems() {
for motion in horizon_theme::MOTION {
for i in 0..=20 {
let t = i as f32 / 20.0;
assert_eq!(
bezier(motion.curve, t),
motion.ease(t),
"{} at {t}",
motion.name
);
}
}
}
#[test]
fn a_turn_takes_the_short_way_round() {
let t0 = Instant::now();
let tween = Tween::new(MS(80), horizon_theme::MOVE);
let tween = Tween::new(MS(80), horizon_theme::MOVE.curve);
// From up-left (7/8 of a turn) to up-right: through up, not round the bottom.
let from = 7.0 * FRAC_PI_4;
let half = turn(tween, at(t0, 1), t0, from, FRAC_PI_4);
@@ -38,6 +38,12 @@
//! ```
use crate::hud::{HudAtlas, HudBatch};
use crate::hud_kit::{colour, fit};
use crate::menu_style::{self, OverlayColours};
/// The overlays' colours (`menu_style`, given at startup).
fn overlay() -> &'static OverlayColours {
&menu_style::get().overlay
}
/// A pip under the strip: its size and the step from one to the next, in GUI px.
pub const PIP: [f32; 2] = [3.0, 2.0];
@@ -45,8 +51,12 @@ pub const PIP_STEP: f32 = 5.0;
/// How far, in GUI px, the arrows sit from the name.
pub const ARROW_GAP: f32 = 4.0;
/// An arrow with a page that way, and one at an end, with none.
pub const ARROW: u32 = colour(horizon_theme::BACKDROP_MUTED, 255);
pub const ARROW_OFF: u32 = colour(horizon_theme::BACKDROP_MUTED, 77);
pub fn arrow_colour() -> u32 {
colour(overlay().backdrop_muted, 255)
}
pub fn arrow_off_colour() -> u32 {
colour(overlay().backdrop_muted, 77)
}
/// The pips (the selected one is white).
pub const PIP_OFF: u32 = colour(0xFFFFFF, 89);
pub const PIP_ON: u32 = colour(0xFFFFFF, 255);
@@ -65,9 +75,15 @@ pub const EDGE_FADE: f32 = 12.0;
pub const FADE: [f32; 5] = [1.0, 0.7, 0.35, 0.15, 0.1];
/// The selected tab's fill and text, and the others'.
pub const SELECTED_FILL: u32 = colour(0xFFFFFF, 242);
pub const SELECTED_TEXT: u32 = colour(horizon_theme::DARK.page, 255);
pub const TAB_FILL: u32 = colour(horizon_theme::DARK.page, 191);
pub const TAB_TEXT: u32 = colour(horizon_theme::BACKDROP_MUTED, 255);
pub fn selected_text() -> u32 {
colour(overlay().page, 255)
}
pub fn tab_fill() -> u32 {
colour(overlay().page, 191)
}
pub fn tab_text() -> u32 {
colour(overlay().backdrop_muted, 255)
}
/// The line of text's height in GUI px (vanilla's font, without its shadow).
const LINE: f32 = 8.0;
@@ -144,7 +160,11 @@ pub fn draw(batch: &mut HudBatch, atlas: &HudAtlas, strip: &Strip) {
arrow,
ax,
ay,
if on { ARROW } else { ARROW_OFF },
if on {
arrow_colour()
} else {
arrow_off_colour()
},
true,
);
}
@@ -282,9 +302,9 @@ pub fn draw_tabs(batch: &mut HudBatch, atlas: &HudAtlas, strip: &TabStrip, alpha
for tab in &row.tabs {
let [tx, ty, tw, th] = tab.area;
let (fill, text) = if tab.selected {
(SELECTED_FILL, SELECTED_TEXT)
(SELECTED_FILL, selected_text())
} else {
(TAB_FILL, TAB_TEXT)
(tab_fill(), tab_text())
};
let tab_start = batch.vertices.len();
// Cut where the edges' fades start, so each fade runs over its own piece.
@@ -380,15 +400,16 @@ mod tests {
let [ax, _] = strip.arrows[i];
(batch.vertices.iter())
.filter(|v| {
(v.pos[0] - ax).abs() < 4.0 && v.color & 0x00FF_FFFF == ARROW & 0x00FF_FFFF
(v.pos[0] - ax).abs() < 4.0
&& v.color & 0x00FF_FFFF == arrow_colour() & 0x00FF_FFFF
})
.map(|v| v.color >> 24)
.max()
.unwrap_or(0)
};
let first = collapsed(&atlas, &pages, 0, AREA);
assert_eq!(alpha(&first, 0), ARROW_OFF >> 24);
assert_eq!(alpha(&first, 1), ARROW >> 24);
assert_eq!(alpha(&first, 0), arrow_off_colour() >> 24);
assert_eq!(alpha(&first, 1), arrow_colour() >> 24);
}
#[test]
+3
View File
@@ -75,6 +75,7 @@ pub mod light;
pub mod lod;
pub mod logging;
pub mod menu_blur;
pub mod menu_style;
pub mod mesher;
pub mod minimap;
pub mod mipmap;
@@ -89,6 +90,8 @@ pub mod options_fixes;
pub mod options_ui;
pub mod overlay;
pub mod pack_browser;
pub mod pack_formats;
pub mod pack_hooks;
pub mod pack_options_ui;
pub mod pack_palette;
pub mod pack_screen;
@@ -0,0 +1,145 @@
//! The look of the screens vanilla doesn't have that the engine still draws: the settings pages
//! (`graphics_style`), the HUD kit's tab strip, and the overlays drawn with `ui_theme` (the
//! on-screen keyboard, the boot screen, the missing-resources window). Vanilla's screens never
//! read it.
//!
//! The layer above gives it once at startup ([`install`]), before the first frame; the
//! official game gives Horizon's design system (`docs/design-system.md`). With none given, the
//! screens draw in [`NEUTRAL`]: vanilla's greys and chat colours, and no text where they would
//! use their own fonts. A read is one atomic load, and only those screens make it.
use std::sync::OnceLock;
/// Colours as `0xRRGGBB`, sizes in dp (the launcher's logical pixels).
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct MenuStyle {
pub settings: SettingsColours,
pub overlay: OverlayColours,
pub sizes: Sizes,
/// The overlays' TrueType faces; none: they draw no text.
pub fonts: Option<Fonts>,
}
/// The settings pages: square panels of dark glass over the world, flat controls, one face.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct SettingsColours {
/// The plate behind everything, drawn see-through.
pub page: u32,
/// Text, and the light the panels, rules, hovers and controls are drawn in.
pub ink: u32,
/// Secondary text and values.
pub ink_soft: u32,
/// Labels, descriptions, what can't be used.
pub muted: u32,
/// Wells and the dark edge of small marks.
pub outline: u32,
/// Focus, a checked box, a slider's fill.
pub accent: u32,
/// The one main button, and while hovered.
pub face: u32,
pub face_hover: u32,
pub success: u32,
pub warn: u32,
pub danger: u32,
}
/// Overlays drawn over the game (the HUD kit's tab strip, `ui_theme`'s overlays).
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct OverlayColours {
/// The dark page under text and tabs.
pub page: u32,
/// Muted text over a veiled backdrop.
pub backdrop_muted: u32,
/// Text and the light hover and pressed plates are drawn in.
pub ink: u32,
/// Secondary text and values.
pub ink_soft: u32,
/// One-pixel rules between rows.
pub rule: u32,
/// The main action, and its text.
pub accent: u32,
pub on_accent: u32,
}
/// `ui_theme`'s type scale and shapes, in dp.
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct Sizes {
pub caption: f32,
pub label: f32,
pub ui: f32,
pub body: f32,
pub title: f32,
/// The boot screen's wordmark at its smallest, and its console lines.
pub display: f32,
pub micro: f32,
/// Fields; plates (hover rows, selection); a large control's height.
pub radius: f32,
pub radius_plate: f32,
pub control_large: f32,
}
/// TrueType faces (`ui_theme::Face`).
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct Fonts {
pub regular: &'static [u8],
pub medium: &'static [u8],
pub semibold: &'static [u8],
pub mono: &'static [u8],
}
/// The look with nothing given: vanilla's greys and chat colours.
pub const NEUTRAL: MenuStyle = MenuStyle {
settings: SettingsColours {
page: 0x101010,
ink: 0xffffff,
ink_soft: 0xe0e0e0,
muted: 0xa0a0a0,
outline: 0x000000,
accent: 0xffffff,
face: 0x555555,
face_hover: 0x6f6f6f,
success: 0x55ff55,
warn: 0xffff55,
danger: 0xff5555,
},
overlay: OverlayColours {
page: 0x101010,
backdrop_muted: 0xa0a0a0,
ink: 0xffffff,
ink_soft: 0xe0e0e0,
rule: 0x303030,
accent: 0xffffff,
on_accent: 0x000000,
},
sizes: Sizes {
caption: 12.0,
label: 13.0,
ui: 14.0,
body: 15.0,
title: 28.0,
display: 44.0,
micro: 11.0,
radius: 0.0,
radius_plate: 0.0,
control_large: 40.0,
},
fonts: None,
};
static STYLE: OnceLock<MenuStyle> = OnceLock::new();
/// Give the menus their look: call it once at startup. A second call keeps the first look and
/// returns false.
pub fn install(style: MenuStyle) -> bool {
STYLE.set(style).is_ok()
}
/// The look given to [`install`], or [`NEUTRAL`].
#[inline]
pub fn get() -> &'static MenuStyle {
STYLE.get().unwrap_or(&NEUTRAL)
}
/// `0xRRGGBB` and an alpha in the HUD's colour encoding (`0xAABBGGRR`).
pub const fn hud_colour(rgb: u32, alpha: u8) -> u32 {
(alpha as u32) << 24 | (rgb & 0xff) << 16 | (rgb >> 8 & 0xff) << 8 | (rgb >> 16 & 0xff)
}
+44 -16
View File
@@ -1331,8 +1331,6 @@ pub enum Page {
ModProject,
/// A native mod's settings page: its index in `mods.installed`.
NativeMod(usize),
/// A page a crate registered (`horizon_hooks::pages`): its index in `OptionsUi::pages`.
Added(usize),
/// Horizon's shader packs, either kind (`shader_pack_list`).
ShaderPacks,
/// The picked pack's options: the shader screen (`shader_ui`) at the pack's main screen.
@@ -1392,8 +1390,6 @@ enum Act {
/// A native mod's key (mod, key) waits for a key; a button of its page.
NativeKey(usize, usize),
NativeButton(usize, usize),
/// A registered page's row: (page, row).
PageRow(usize, usize),
OpenModFolder,
/// Browse mods: the page of listing `.0` of `mod_browser`.
ModProject(usize),
@@ -1621,11 +1617,8 @@ pub struct OptionsUi {
/// Key Binds: the mapping waiting for a key ([`NATIVE_KEY`]: a native
/// mod's, `selected_native_key`).
pub selected_key: Option<&'static str>,
/// The native mod's key waiting: (mod, key) in `mods.installed`, or a registered page's
/// (page, row) ([`pages::PAGE_KEY`]).
/// The native mod's key waiting: (mod, key) in `mods.installed`.
selected_native_key: Option<(usize, usize)>,
/// The pages crates registered (`horizon_hooks::pages`).
pages: &'static [&'static dyn horizon_hooks::pages::Page],
/// Bindings: the action waiting for a pad button.
pub selected_pad_action: Option<&'static str>,
/// The glyphs menus show (`Glyphs::of`: the setting, else the pad's family, and a Nintendo
@@ -1737,7 +1730,6 @@ impl Default for OptionsUi {
scrollbar_drag_y: None,
selected_key: None,
selected_native_key: None,
pages: horizon_hooks::pages::engine::all(),
selected_pad_action: None,
pad_glyphs: crate::pad_glyph::Glyphs::default(),
pad_driving: false,
@@ -2375,7 +2367,6 @@ impl OptionsUi {
Page::ModBrowser => "horizon.mods.browse.title",
Page::ModProject => "horizon.mods.browse.project",
Page::NativeMod(_) => "Mod Settings",
Page::Added(i) => pages::title(i),
Page::ShaderPacks => "horizon.pages.shader_packs",
Page::ShaderOptions => "horizon.pages.shader_options",
Page::PackOptions => "horizon.pack.options.title",
@@ -2656,7 +2647,47 @@ impl OptionsUi {
flush(&mut rows, &mut section);
rows
}
Page::Horizon => self.horizon_rows(s),
Page::Horizon => {
// Two to a row, as vanilla's OptionsList.addSmall lays out
// buttons; each opens one of Horizon's settings pages. Each page has
// one way in: graphics and shader packs are Video Settings' (the graphics
// screen), the controller is Controls', mods are the Options screen's.
let open =
|key: &str, page: Page| button(0.0, 0.0, 150.0, tr(key), Act::Push(page));
vec![
Row::Buttons(
open("horizon.pages.open.extras", Page::Extras),
Some(open("horizon.pages.open.pvp", Page::Pvp)),
),
Row::Buttons(
open(
"horizon.pages.open.split_setup",
if self.split_guest {
Page::SplitProfiles
} else {
Page::SplitSetup
},
),
Some(open("horizon.pages.open.studio", Page::Studio)),
),
// No AI hides the AI Agent page and everything AI.
Row::Buttons(
self.extra_button(s, crate::extras::Item::DiscordPresence),
Some(if s.extras.no_ai {
self.extra_button(s, crate::extras::Item::NoAi)
} else {
open("horizon.pages.open.agent", Page::Agent)
}),
),
]
.into_iter()
.chain(
(!s.extras.no_ai).then(|| {
Row::Buttons(self.extra_button(s, crate::extras::Item::NoAi), None)
}),
)
.collect()
}
Page::Agent => {
let mut rows = vec![Row::Buttons(
self.extra_button(s, crate::extras::Item::AgentControl),
@@ -2939,7 +2970,6 @@ impl OptionsUi {
Page::ModBrowser => self.mod_browser_rows(),
Page::ModProject => self.mod_project_rows(s),
Page::NativeMod(i) => self.native_rows(s, i),
Page::Added(i) => self.added_rows(i),
_ => {
let _ = s;
Vec::new()
@@ -4208,7 +4238,6 @@ impl OptionsUi {
}
Act::NativeKey(i, k) => self.wait_native_key(i, k),
Act::NativeButton(i, b) => events.push(Event::NativeButton(i, b)),
Act::PageRow(p, r) => events.extend(self.page_act(p, r, self.shift)),
Act::OpenModFolder => events.push(Event::OpenModFolder),
Act::ModProject(i) => {
if let Some(listing) = self.mod_browser.listings.get(i) {
@@ -4712,8 +4741,8 @@ impl OptionsUi {
let Some(name) = self.selected_key.take() else {
return Vec::new();
};
if name == NATIVE_KEY || name == pages::PAGE_KEY {
return self.bind_native_key(s, name, value);
if name == NATIVE_KEY {
return self.bind_native_key(s, value);
}
if !s.extras.set_key(name, value.clone()) {
s.keys.set(name, &value);
@@ -5097,7 +5126,6 @@ impl OptionsUi {
}
Act::NativeKey(i, k) => self.wait_native_key(i, k),
Act::NativeButton(i, b) => events.push(Event::NativeButton(i, b)),
Act::PageRow(p, r) => events.extend(self.page_act(p, r, shift)),
Act::OpenModFolder => events.push(Event::OpenModFolder),
Act::ModProject(i) => {
if let Some(listing) = self.mod_browser.listings.get(i) {
@@ -1,151 +1,8 @@
//! The pages crates add to the options screens: a native mod's settings page (its keys and
//! buttons), and the pages crates above the engine register (`horizon_hooks::pages`, docs/layers.md
//! "Platform registries") with their entries on Horizon Settings. Kept out of options_ui.rs, which
//! may not grow (tools/check_file_size.py).
//! buttons). Kept out of options_ui.rs, which may not grow (tools/check_file_size.py).
use super::*;
use horizon_hooks::pages as added;
/// `selected_key` while a registered page's key waits (`selected_native_key`: page, row).
pub(super) const PAGE_KEY: &str = "horizon.pageKey";
/// A registered page's title (a translation key).
pub(super) fn title(page: usize) -> &'static str {
added::engine::all().get(page).map_or("", |p| p.title())
}
fn label_text(label: added::Label) -> Text {
match label {
added::Label::Key(key) => tr(key),
added::Label::Value(value) => Text::literal(value),
}
}
impl OptionsUi {
/// Horizon Settings: its pages, then the pages crates registered for it.
pub(super) fn horizon_rows(&self, s: &Settings) -> Vec<Row> {
// Two to a row, as vanilla's OptionsList.addSmall lays out
// buttons; each opens one of Horizon's settings pages. Each page has
// one way in: graphics and shader packs are Video Settings' (the graphics
// screen), the controller is Controls', mods are the Options screen's.
let open = |key: &str, page: Page| button(0.0, 0.0, 150.0, tr(key), Act::Push(page));
let mut rows: Vec<Row> = vec![
Row::Buttons(
open("horizon.pages.open.extras", Page::Extras),
Some(open("horizon.pages.open.pvp", Page::Pvp)),
),
Row::Buttons(
open(
"horizon.pages.open.split_setup",
if self.split_guest {
Page::SplitProfiles
} else {
Page::SplitSetup
},
),
Some(open("horizon.pages.open.studio", Page::Studio)),
),
// No AI hides the AI Agent page and everything AI.
Row::Buttons(
self.extra_button(s, crate::extras::Item::DiscordPresence),
Some(if s.extras.no_ai {
self.extra_button(s, crate::extras::Item::NoAi)
} else {
open("horizon.pages.open.agent", Page::Agent)
}),
),
]
.into_iter()
.chain(
(!s.extras.no_ai)
.then(|| Row::Buttons(self.extra_button(s, crate::extras::Item::NoAi), None)),
)
.collect();
self.added_entries(&mut rows);
rows
}
/// The entries of the pages registered for Horizon Settings, two to a row like the engine's.
fn added_entries(&self, rows: &mut Vec<Row>) {
let mut entries = self
.pages
.iter()
.enumerate()
.filter(|(_, page)| page.place() == added::Place::HorizonSettings)
.map(|(i, page)| button(0.0, 0.0, 150.0, tr(page.title()), Act::Push(Page::Added(i))));
while let Some(first) = entries.next() {
rows.push(Row::Buttons(first, entries.next()));
}
}
/// A registered page's rows, asked of it each time the page is drawn.
pub(super) fn added_rows(&self, page: usize) -> Vec<Row> {
let Some(registered) = self.pages.get(page) else {
return Vec::new();
};
let mut rows = Vec::new();
registered.rows(&mut rows);
rows.into_iter()
.enumerate()
.map(|(r, row)| self.added_row(page, r, row))
.collect()
}
fn added_row(&self, page: usize, r: usize, row: added::Row) -> Row {
let act = Act::PageRow(page, r);
let wide = |label: Text| Row::Buttons(button(0.0, 0.0, 310.0, label, act.clone()), None);
match row {
added::Row::Heading(key) => Row::Heading(key.to_owned()),
added::Row::Note(text) => Row::Note(text),
added::Row::Toggle { label, on } => wide(generic(label, on_off(on))),
added::Row::Cycle { label, value } => wide(generic(label, label_text(value))),
added::Row::Button { label } => wide(tr(label)),
added::Row::Key { label, key } => {
let mut message = keybinds::display_name(&key);
if self.selected_key == Some(PAGE_KEY)
&& self.selected_native_key == Some((page, r))
{
message = Text::literal("> ")
.append(message.with_color(text::WHITE))
.append(" <")
.with_color(text::YELLOW);
}
let caption = button(0.0, 0.0, 150.0, tr(label), Act::None);
let change = button(0.0, 0.0, 150.0, message, act);
Row::Buttons(inactive(caption), Some(change))
}
// A row kind newer than this engine: shown empty.
_ => Row::Note(String::new()),
}
}
/// A registered page's row was pressed: a key row waits for a key, any other row hears it.
pub(super) fn page_act(&mut self, page: usize, r: usize, shift: bool) -> Vec<Event> {
let Some(registered) = self.pages.get(page) else {
return Vec::new();
};
let mut rows = Vec::new();
registered.rows(&mut rows);
match rows.get(r) {
Some(added::Row::Key { .. }) => {
self.selected_key = Some(PAGE_KEY);
self.selected_native_key = Some((page, r));
Vec::new()
}
Some(_) => {
registered.act(
r,
if shift {
added::Act::PressBack
} else {
added::Act::Press
},
);
vec![Event::Changed]
}
None => Vec::new(),
}
}
/// A native mod's page: its name, its keys as Key Binds rows (the
/// player's choice, else the default), then its buttons.
pub(super) fn native_rows(&self, s: &Settings, i: usize) -> Vec<Row> {
@@ -189,21 +46,9 @@ impl OptionsUi {
self.selected_native_key = Some((i, k));
}
/// The key pressed for the native mod's key, or the registered page's ([`PAGE_KEY`]), that
/// waited for one.
pub(super) fn bind_native_key(
&mut self,
s: &mut Settings,
name: &str,
value: String,
) -> Vec<Event> {
/// The key pressed for the native mod's key that waited for one.
pub(super) fn bind_native_key(&mut self, s: &mut Settings, value: String) -> Vec<Event> {
let (i, k) = self.selected_native_key.take().unwrap_or_default();
if name == PAGE_KEY {
if let Some(page) = self.pages.get(i) {
page.act(k, added::Act::Bind(value));
}
return vec![Event::Changed];
}
let Some(m) = s.mods.installed.get(i) else {
return Vec::new();
};
@@ -215,113 +60,3 @@ impl OptionsUi {
vec![Event::Changed]
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::sync::Mutex;
/// A page with a heading, a switch, a number that steps either way and a key.
struct Sample {
on: Mutex<bool>,
step: Mutex<i32>,
key: Mutex<String>,
}
impl added::Page for Sample {
fn id(&self) -> &'static str {
"test.sample"
}
fn title(&self) -> &'static str {
"test.sample.title"
}
fn place(&self) -> added::Place {
added::Place::HorizonSettings
}
fn rows(&self, rows: &mut Vec<added::Row>) {
rows.push(added::Row::Heading("test.sample.heading"));
rows.push(added::Row::Toggle {
label: "test.sample.switch",
on: *self.on.lock().unwrap(),
});
rows.push(added::Row::Cycle {
label: "test.sample.step",
value: added::Label::Value(self.step.lock().unwrap().to_string()),
});
rows.push(added::Row::Key {
label: "test.sample.key",
key: self.key.lock().unwrap().clone(),
});
}
fn act(&self, row: usize, act: added::Act) {
match (row, act) {
(1, added::Act::Press) => {
let mut on = self.on.lock().unwrap();
*on = !*on;
}
(2, added::Act::Press) => *self.step.lock().unwrap() += 1,
(2, added::Act::PressBack) => *self.step.lock().unwrap() -= 1,
(3, added::Act::Bind(key)) => *self.key.lock().unwrap() = key,
other => panic!("row and act the page doesn't expect: {other:?}"),
}
}
}
static SAMPLE: Sample = Sample {
on: Mutex::new(false),
step: Mutex::new(0),
key: Mutex::new(String::new()),
};
static PAGES: [&dyn added::Page; 1] = [&SAMPLE];
/// Clicks the active button that does `act`, with Shift held or not.
fn press(ui: &mut OptionsUi, s: &mut Settings, act: Act, shift: bool) -> Vec<Event> {
let size = (480.0, 240.0);
let (widgets, ..) = ui.layout(s, size.0, size.1);
let (x, y) = widgets
.iter()
.find_map(|w| match &w.kind {
WKind::Button {
act: a,
active: true,
..
} if *a == act => Some((w.x + 1.0, w.y + 1.0)),
_ => None,
})
.unwrap_or_else(|| panic!("no button does {act:?}"));
ui.shift = shift;
let atlas = super::super::tests::atlas();
ui.click(s, &atlas, x, y, size, [480, 240], false, false)
}
#[test]
fn a_registered_page_opens_from_horizon_settings_and_hears_its_rows() {
let mut s = Settings::default();
let mut ui = OptionsUi {
pages: &PAGES,
..OptionsUi::default()
};
ui.show(Page::Horizon);
press(&mut ui, &mut s, Act::Push(Page::Added(0)), false);
assert_eq!(ui.page(), Page::Added(0));
// Rows: the heading (0), the switch (1), the step (2), the key (3).
let events = press(&mut ui, &mut s, Act::PageRow(0, 1), false);
assert_eq!(
events.last(),
Some(&Event::Changed),
"the settings get saved"
);
assert!(*SAMPLE.on.lock().unwrap());
press(&mut ui, &mut s, Act::PageRow(0, 2), false);
press(&mut ui, &mut s, Act::PageRow(0, 2), true);
press(&mut ui, &mut s, Act::PageRow(0, 2), true);
assert_eq!(*SAMPLE.step.lock().unwrap(), -1, "Shift steps back");
// A key row waits for the next key, as on Key Binds, and the page gets its name.
press(&mut ui, &mut s, Act::PageRow(0, 3), false);
let atlas = super::super::tests::atlas();
let events = ui.key(&mut s, &atlas, winit::keyboard::KeyCode::KeyZ, None);
assert_eq!(events, [Event::Changed]);
assert_eq!(*SAMPLE.key.lock().unwrap(), "key.keyboard.z");
assert_eq!(ui.selected_key, None);
}
}
@@ -0,0 +1,252 @@
//! OptiFine custom skies (`horizon_optifine::sky`) on the engine's sky layers
//! ([`crate::custom_sky`]): a pack's layers with their decoded textures, read once with the pack,
//! and each frame's choice of layers, their brightness and rotation. A pack without
//! `optifine/sky` has none, and the sky stays vanilla.
use std::path::Path;
use glam::{Mat4, Vec3};
use horizon_optifine::sky::{Blend, Conditions, Now, Skies, SkyLayer, Transition};
use crate::custom_sky::{self as engine, Draw, Image, SkyLayers, State};
/// The pack's layers, by world, with one texture per layer (`images[i]` for the `i`th layer
/// over all worlds, in `worlds` order).
#[derive(Default)]
pub struct CustomSky {
worlds: Vec<(i32, Vec<SkyLayer>)>,
pub images: Vec<Image>,
}
/// Textures past this many texels in all are left out (a skybox is a few megapixels at most).
const TEXEL_BUDGET: u64 = 64 << 20;
impl CustomSky {
/// The custom skies of the overlay at `root`. A layer whose texture does not decode is
/// dropped with a warning.
pub fn load(root: &Path) -> Self {
let skies = Skies::scan(root);
let mut out = Self::default();
let mut texels = 0u64;
for (world, sky) in skies.worlds {
let mut layers = Vec::new();
for layer in sky.layers {
match crate::hud::decode(&root.join(&layer.source)) {
Ok((width, height, rgba))
if texels + u64::from(width) * u64::from(height) <= TEXEL_BUDGET =>
{
texels += u64::from(width) * u64::from(height);
out.images.push(Image {
width,
height,
rgba,
});
layers.push(layer);
}
Ok(_) => log::warn!("{}: past the custom sky's texture budget", layer.source),
Err(e) => log::warn!("{}: {e:#}", layer.source),
}
}
if !layers.is_empty() {
out.worlds.push((world, layers));
}
}
out
}
pub fn is_empty(&self) -> bool {
self.worlds.is_empty()
}
}
/// The engine's sky blend for OptiFine's.
fn blend(blend: Blend) -> engine::Blend {
match blend {
Blend::Add => engine::Blend::Add,
Blend::Subtract => engine::Blend::Subtract,
Blend::Multiply => engine::Blend::Multiply,
Blend::Dodge => engine::Blend::Dodge,
Blend::Burn => engine::Blend::Burn,
Blend::Screen => engine::Blend::Screen,
Blend::Replace => engine::Blend::Replace,
Blend::Overlay => engine::Blend::Overlay,
Blend::Alpha => engine::Blend::Alpha,
}
}
impl SkyLayers for CustomSky {
fn images(&self) -> &[Image] {
&self.images
}
/// Whether `world` has layers (`0` the Overworld, `1` the End).
fn has(&self, world: i32) -> bool {
self.worlds.iter().any(|(w, _)| *w == world)
}
/// The layers whose conditions hold, eased over their `transition`, at the brightness of
/// their fade times.
fn draws(
&self,
world: i32,
conditions: &engine::Conditions,
sun_turns: f32,
seconds: f32,
state: &mut State,
out: &mut Vec<Draw>,
) {
let conditions = &Conditions {
day_time: conditions.day_time,
weather: match conditions.weather {
engine::Weather::Clear => Now::Clear,
engine::Weather::Rain => Now::Rain,
engine::Weather::Thunder => Now::Thunder,
},
biome: conditions.biome,
y: conditions.y,
};
out.clear();
let mut first = 0;
let mut found = None;
for (w, layers) in &self.worlds {
if *w == world {
found = Some(layers);
break;
}
first += layers.len();
}
let Some(layers) = found else {
state.world = None;
return;
};
// A new world (or pack) starts each layer where its conditions put it: no fade in.
let snap = state.world != Some(world) || state.eased.len() != layers.len();
if snap {
state.world = Some(world);
state.eased.clear();
state.eased.resize(layers.len(), 0.0);
}
for (i, (layer, eased)) in layers.iter().zip(&mut state.eased).enumerate() {
let target = if layer.shows(conditions) { 1.0 } else { 0.0 };
let shown = if snap {
*eased = target;
target
} else {
let mut t = Transition { value: *eased };
*eased = t.step(target, seconds, layer.transition);
*eased
};
let brightness = shown * layer.brightness(conditions.day_time);
if brightness <= 0.0 {
continue;
}
let turns = layer.turns(conditions.day_time, sun_turns);
let rotation = if turns == 0.0 {
Mat4::IDENTITY
} else {
Mat4::from_axis_angle(Vec3::from_array(layer.axis), turns * std::f32::consts::TAU)
};
out.push(Draw {
image: (first + i) as u16,
blend: blend(layer.blend),
brightness,
rotation,
});
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::custom_sky::{Conditions, Weather};
fn pack(files: &[(&str, &str)]) -> (std::path::PathBuf, CustomSky) {
let root = std::env::temp_dir().join(format!(
"horizon-custom-sky-{}-{}",
std::process::id(),
files.len()
));
let _ = std::fs::remove_dir_all(&root);
let dir = root.join("assets/minecraft/optifine/sky/world0");
std::fs::create_dir_all(&dir).unwrap();
let png = {
let mut bytes = Vec::new();
let mut encoder = png::Encoder::new(&mut bytes, 6, 4);
encoder.set_color(png::ColorType::Rgba);
encoder.set_depth(png::BitDepth::Eight);
let mut writer = encoder.write_header().unwrap();
writer
.write_image_data(&[255, 0, 0, 255].repeat(24))
.unwrap();
writer.finish().unwrap();
bytes
};
for (name, text) in files {
std::fs::write(dir.join(format!("{name}.properties")), text).unwrap();
std::fs::write(dir.join(format!("{name}.png")), &png).unwrap();
}
let sky = CustomSky::load(&root);
(root, sky)
}
fn noon(biome: &str) -> Conditions<'_> {
Conditions {
day_time: 6000,
weather: Weather::Clear,
biome,
y: 64,
}
}
#[test]
fn layers_ease_in_at_a_biome_border_but_not_on_arrival() {
let (root, sky) = pack(&[
("sky1", "biomes=desert\ntransition=2"),
("sky2", "blend=alpha\nrotate=false"),
]);
let _ = std::fs::remove_dir_all(root);
assert!(sky.has(0) && !sky.has(1));
assert_eq!(sky.images[0].width, 6);
let (mut state, mut out) = (State::default(), Vec::new());
sky.draws(
0,
&noon("minecraft:desert"),
0.0,
0.05,
&mut state,
&mut out,
);
assert_eq!(
out.iter().map(|d| d.brightness).collect::<Vec<_>>(),
[1.0, 1.0]
);
// Out of the desert: the first layer fades over its two seconds.
sky.draws(0, &noon("minecraft:plains"), 0.0, 0.5, &mut state, &mut out);
assert_eq!(out[0].brightness, 0.75);
assert_eq!(out[1].blend, engine::Blend::Alpha);
sky.draws(0, &noon("minecraft:plains"), 0.0, 2.0, &mut state, &mut out);
assert_eq!(out.len(), 1, "a dark layer is not drawn");
assert_eq!(out[0].image, 1);
// Into the End, which has no layers, and back: no fade.
sky.draws(1, &noon("minecraft:desert"), 0.0, 0.0, &mut state, &mut out);
assert!(out.is_empty());
sky.draws(0, &noon("minecraft:desert"), 0.0, 0.0, &mut state, &mut out);
assert_eq!(out.len(), 2);
}
#[test]
fn a_layer_turns_west_like_the_sun() {
let (root, sky) = pack(&[("sky1", "")]);
let _ = std::fs::remove_dir_all(root);
let (mut state, mut out) = (State::default(), Vec::new());
// A quarter turn after noon (sunset): the skybox's zenith has gone down to the west.
let c = Conditions {
day_time: 12000,
..noon("minecraft:plains")
};
sky.draws(0, &c, 0.25, 0.0, &mut state, &mut out);
let up = out[0].rotation.transform_vector3(Vec3::Y);
assert!((up - Vec3::NEG_X).length() < 1e-5, "{up}");
}
}
@@ -0,0 +1,26 @@
//! The resource pack formats vanilla doesn't read (OptiFine's, Polytone's) on the engine's
//! extension points ([`crate::pack_hooks`]). This is platform code (docs/layers.md) kept in this
//! crate until the platform's executable installs it: it uses only the crate's public API, so it
//! moves to a platform crate unchanged, and nothing else in the crate names it.
mod custom_sky;
use std::path::Path;
use std::sync::Arc;
use crate::pack_hooks::PackFormats;
/// OptiFine's and Polytone's formats.
pub struct Formats;
impl PackFormats for Formats {
fn custom_sky(&self, root: &Path) -> Option<Arc<dyn crate::custom_sky::SkyLayers>> {
let sky = custom_sky::CustomSky::load(root);
(!sky.is_empty()).then(|| Arc::new(sky) as _)
}
}
/// Installs these formats ([`crate::pack_hooks::install`]); the game does at startup.
pub fn install() {
crate::pack_hooks::install(&Formats);
}
@@ -0,0 +1,35 @@
//! The engine's extension points for resource pack formats vanilla doesn't read (OptiFine's,
//! Polytone's: platform code, docs/layers.md). The engine asks [`formats`] once per pack load and
//! gets engine types back (tables, or an object it calls at most once a frame), never a format's
//! own types. The platform installs its formats at startup ([`install`]); with none installed,
//! packs load as vanilla reads them.
use std::path::Path;
use std::sync::{Arc, OnceLock};
/// What the pack formats beyond vanilla's add to a pack. Each method reads one overlay of a pack
/// (its folder at `root`); the default is "nothing in this format".
pub trait PackFormats: Send + Sync {
/// The sky layers the overlay draws over the vanilla sky, if it has any.
fn custom_sky(&self, _root: &Path) -> Option<Arc<dyn crate::custom_sky::SkyLayers>> {
None
}
}
/// No format beyond vanilla's.
struct Vanilla;
impl PackFormats for Vanilla {}
static FORMATS: OnceLock<&'static dyn PackFormats> = OnceLock::new();
/// Installs the pack formats, before the first pack loads. The first call wins: later ones are
/// ignored, so each test or probe that needs the formats may install them.
pub fn install(formats: &'static dyn PackFormats) {
let _ = FORMATS.set(formats);
}
/// The installed pack formats, or vanilla's alone.
pub fn formats() -> &'static dyn PackFormats {
FORMATS.get().copied().unwrap_or(&Vanilla)
}
+44 -44
View File
@@ -1222,8 +1222,9 @@ pub struct ParticleLevel<'a> {
pub collision: &'a BlockCollisionRegistry<'a>,
/// Local player feet position and vertical velocity (`PlayerCloudParticle`).
pub player: Option<([f64; 3], f64)>,
pub cached_chunk: std::cell::Cell<Option<((i32, i32), Option<&'a crate::chunk::Chunk>)>>,
}
impl ParticleLevel<'_> {
impl<'a> ParticleLevel<'a> {
fn collide(&self, bb: Aabb, d: [f64; 3]) -> [f64; 3] {
let mut scene = SceneCollisions {
world: self.world,
@@ -1231,9 +1232,6 @@ impl ParticleLevel<'_> {
subject: None,
};
let movement = Vec3::new(d[0], d[1], d[2]);
// `Entity.collideBoundingBox(null, ...)`: no entity shapes, no border. Where no
// block of the swept box's cells can collide, it finds no shape and the
// movement stands, as the resolution below would return it.
if scene.cells_without_collision(bb.expand_towards(movement)) {
return d;
}
@@ -1249,38 +1247,46 @@ impl ParticleLevel<'_> {
p[2].floor() as i32,
]
}
#[inline]
pub fn block(&self, x: i32, y: i32, z: i32) -> horizon_world::registry::BlockStateId {
let (cx, cz) = (x >> 4, z >> 4);
let chunk = match self.cached_chunk.get() {
Some(((ccx, ccz), c)) if ccx == cx && ccz == cz => c,
_ => {
let c = self.world.chunks.get(&(cx, cz)).map(|c| c.as_ref());
self.cached_chunk.set(Some(((cx, cz), c)));
c
}
};
chunk.map_or(self.world.air, |c| c.block(x & 15, y, z & 15))
}
/// The fluid at a block and `FluidState.getHeight`.
fn fluid(&self, b: [i32; 3]) -> (Fluid, f64) {
let s = self.facts.get(self.world.block(b[0], b[1], b[2]));
let s = self.facts.get(self.block(b[0], b[1], b[2]));
if s.fluid == Fluid::None {
return (Fluid::None, 0.0);
}
let above = self.facts.get(self.world.block(b[0], b[1] + 1, b[2]));
let height = if above.fluid == s.fluid {
let above = self.facts.get(self.block(b[0], b[1] + 1, b[2]));
let h = if above.fluid == s.fluid {
1.0
} else {
f64::from(s.fluid_level) / 9.0
};
(s.fluid, height)
(s.fluid, h)
}
/// The fluid at a block and whether it is a source (`FluidState.isSource`).
pub(crate) fn fluid_source(&self, b: [i32; 3]) -> (Fluid, bool) {
let s = self.facts.get(self.world.block(b[0], b[1], b[2]));
let s = self.facts.get(self.block(b[0], b[1], b[2]));
(s.fluid, s.fluid_level == 8 && !s.fluid_falling)
}
/// `getCollisionShape(...).max(Y, x, z)` for cached shapes.
fn collision_top(&self, b: [i32; 3], fx: f64, fz: f64) -> f64 {
let state = self.world.block(b[0], b[1], b[2]);
let state = self.block(b[0], b[1], b[2]);
match self.collision.cached_shapes().cached_shape(state) {
Ok(Some(shape)) if !shape.is_empty() => shape.max_at(Axis::Y, fx, fz),
Ok(Some(_)) => 0.0,
_ => {
if self.facts.get(state).full_collision {
1.0
} else {
0.0
}
}
_ if self.facts.get(state).full_collision => 1.0,
_ => 0.0,
}
}
}
@@ -4052,15 +4058,12 @@ impl ParticleEngine {
}
Behaviour::Lava => {
p.base_tick(level);
if !p.removed {
let odds = p.age as f32 / p.lifetime as f32;
if p.random.next_float() > odds {
self.add(
&ParticleOptions::simple(ParticleType::Smoke),
p.position,
p.velocity,
);
}
if !p.removed && p.random.next_float() > p.age as f32 / p.lifetime as f32 {
self.add(
&ParticleOptions::simple(ParticleType::Smoke),
p.position,
p.velocity,
);
}
}
Behaviour::Crit => {
@@ -4144,9 +4147,7 @@ impl ParticleEngine {
*c *= f64::from(0.98f32);
}
if p.on_ground {
if p.random.next_float() < 0.5 {
p.removed = true;
}
p.removed |= p.random.next_float() < 0.5;
p.velocity[0] *= f64::from(0.7f32);
p.velocity[2] *= f64::from(0.7f32);
}
@@ -4186,13 +4187,10 @@ impl ParticleEngine {
} => {
p.base_tick(level);
let (fluid, source) = level.fluid_source(ParticleLevel::block_at(p.position));
if !(p.removed || (fluid == Fluid::Water && source)) {
p.removed = true;
}
if !p.removed && p.position[1] >= y_end {
p.removed = true;
}
if !p.removed && p.position[1] <= y_prev {
if !(fluid == Fluid::Water && source)
|| p.position[1] >= y_end
|| p.position[1] <= y_prev
{
p.removed = true;
}
// randomHorizontalWiggling, then a second, horizontal move.
@@ -4216,9 +4214,8 @@ impl ParticleEngine {
p.base_tick(level);
self.sprite_from_age(p);
if p.age as f32 > fade_start {
let frames_since_fade_out_start = p.age as f32 - fade_start;
p.rgba[3] =
(p.lifetime as f32 - frames_since_fade_out_start) / p.lifetime as f32;
(p.lifetime as f32 - (p.age as f32 - fade_start)) / p.lifetime as f32;
}
}
Behaviour::NoxiousGasCloud => {
@@ -4682,7 +4679,7 @@ impl ParticleEngine {
self.sprite_from_age(p);
half(p);
let b = ParticleLevel::block_at(p.position);
if level.facts.is_air(level.world.block(b[0], b[1], b[2])) {
if level.facts.is_air(level.block(b[0], b[1], b[2])) {
p.velocity[1] -= f64::from(0.0074f32);
}
}
@@ -4690,7 +4687,7 @@ impl ParticleEngine {
Behaviour::Firefly => {
p.base_tick(level);
let b = ParticleLevel::block_at(p.position);
if !level.facts.is_air(level.world.block(b[0], b[1], b[2])) {
if !level.facts.is_air(level.block(b[0], b[1], b[2])) {
p.removed = true;
} else {
p.rgba[3] = fade_amount(p.lifetime_progress(p.age as f32), 0.3, 0.5);
@@ -5154,11 +5151,12 @@ fn hdr_tier(ty: ParticleType) -> u8 {
}
/// `Particle.LifetimeAlpha.currentAlphaForAge`.
fn lifetime_alpha(a: [f32; 4], age: i32, lifetime: i32, partial: f32) -> f32 {
if (a[0] - a[1]).abs() < 1.0e-5 {
return a[0];
}
let t = ((age as f32 + partial) / lifetime as f32 - a[2]) / (a[3] - a[2]);
a[0] + (a[1] - a[0]) * t.clamp(0.0, 1.0)
if (a[0] - a[1]).abs() < 1.0e-5 {
a[0]
} else {
a[0] + (a[1] - a[0]) * t.clamp(0.0, 1.0)
}
}
struct Starter {
@@ -5589,6 +5587,7 @@ mod tests {
facts: &facts,
collision: &collision,
player: None,
cached_chunk: std::cell::Cell::new(None),
};
let mut engine = ParticleEngine::new(Path::new(""), &HashMap::new(), &HashSet::new());
engine.set_sprites.push(vec![10, 11, 12]);
@@ -5680,6 +5679,7 @@ mod tests {
facts: &facts,
collision: &collision,
player: None,
cached_chunk: std::cell::Cell::new(None),
};
let mut engine = ParticleEngine::new(Path::new(""), &HashMap::new(), &HashSet::new());
let mut particle = Particle::blank(ParticleType::Smoke, [0.0; 3]);
@@ -124,6 +124,7 @@ mod tests {
facts: &facts,
collision: &collision,
player: None,
cached_chunk: std::cell::Cell::new(None),
};
let mut e = engine();
let mut p = e
@@ -490,14 +490,14 @@ enum Subject<'a> {
}
/// What one expression runs against: its subject, the frame and the level.
struct Ctx<'a> {
struct Ctx<'a, 'l> {
subject: Subject<'a>,
random: &'a mut LegacyRandom,
custom: &'a mut f64,
customs: &'a mut BTreeMap<String, f64>,
frame: &'a Frame,
registry: &'a Registry,
level: Option<&'a ParticleLevel<'a>>,
level: Option<&'a ParticleLevel<'l>>,
blocks: Vec<[i32; 3]>,
}
@@ -515,7 +515,7 @@ fn axis(name: &str) -> usize {
usize::from(name.as_bytes()[0] - b'x')
}
impl Ctx<'_> {
impl Ctx<'_, '_> {
fn position(&self) -> [f64; 3] {
match &self.subject {
Subject::Particle(p) => p.position,
@@ -683,7 +683,7 @@ impl Ctx<'_> {
}
}
impl Host for Ctx<'_> {
impl Host for Ctx<'_, '_> {
fn root(&mut self, root: Root) -> V {
match root {
Root::Object => V::Object(SELF),
@@ -1662,6 +1662,7 @@ mod tests {
facts: &scene.facts,
collision: &scene.collision,
player: None,
cached_chunk: std::cell::Cell::new(None),
};
let mut engine = engine(Registry::read(&root, Default::default(), blocks));
// Air has no emitters; the torch fires its first emitter only.
@@ -1701,6 +1702,7 @@ mod tests {
facts: &scene.facts,
collision: &scene.collision,
player: None,
cached_chunk: std::cell::Cell::new(None),
};
let mut engine = engine(Registry::read(&root, Default::default(), blocks));
engine.polytone_animate(&level, [1, 64, 1]);
@@ -1722,6 +1724,7 @@ mod tests {
facts: &scene.facts,
collision: &scene.collision,
player: None,
cached_chunk: std::cell::Cell::new(None),
};
let mut engine = engine(Registry::read(&root, Default::default(), blocks));
engine.polytone_animate(&level, [1, 64, 1]);
@@ -1767,6 +1770,7 @@ mod tests {
facts: &scene.facts,
collision: &scene.collision,
player: None,
cached_chunk: std::cell::Cell::new(None),
};
let registry = Registry::read(&root, Default::default(), blocks);
let frame = registry.frame.clone();
@@ -1820,6 +1824,7 @@ mod tests {
facts: &scene.facts,
collision: &scene.collision,
player: None,
cached_chunk: std::cell::Cell::new(None),
};
let registry = Registry::read(&root, Default::default(), blocks);
let mut engine = engine(registry);
@@ -1853,6 +1858,7 @@ mod tests {
facts: &scene.facts,
collision: &scene.collision,
player: None,
cached_chunk: std::cell::Cell::new(None),
};
let mut engine = engine(Registry::read(&root, Default::default(), blocks));
let rect = crate::hud::Rect {
@@ -1905,6 +1911,7 @@ mod tests {
facts: &scene.facts,
collision: &scene.collision,
player: None,
cached_chunk: std::cell::Cell::new(None),
};
let mut engine = engine(Registry::read(&root, Default::default(), blocks));
let rect = crate::hud::Rect {
@@ -1966,6 +1973,7 @@ mod tests {
facts: &scene.facts,
collision: &scene.collision,
player: None,
cached_chunk: std::cell::Cell::new(None),
};
let mut engine = engine(Registry::read(&root, Default::default(), blocks));
engine.polytone_entity(&level, "minecraft:player", [1.0, 70.0, 1.0]);
@@ -2014,6 +2022,7 @@ mod tests {
facts: &scene.facts,
collision: &scene.collision,
player: None,
cached_chunk: std::cell::Cell::new(None),
};
let mut engine = engine(Registry::read(&root, Default::default(), blocks));
let rect = crate::hud::Rect {
@@ -2080,6 +2089,7 @@ mod tests {
facts: &scene.facts,
collision: &scene.collision,
player: None,
cached_chunk: std::cell::Cell::new(None),
};
let registry = Arc::new(Registry::read(&root, Default::default(), blocks));
// The chunk's biome is id 0: plains, whose foliage is #336699.
@@ -2198,6 +2208,7 @@ mod tests {
facts: &scene.facts,
collision: &scene.collision,
player: None,
cached_chunk: std::cell::Cell::new(None),
};
let mut engine = engine(Registry::read(&root, Default::default(), blocks));
engine.polytone_entity(&level, "minecraft:player", [1.5, 70.0, 1.5]);
@@ -2276,6 +2287,7 @@ mod tests {
facts: &scene.facts,
collision: &scene.collision,
player: None,
cached_chunk: std::cell::Cell::new(None),
};
let mut engine = engine(Registry::read(&root, Default::default(), blocks));
// The test chunk's biome is the table's first: a cold one first, then a warm one.
@@ -93,6 +93,7 @@ fn particle_tick_cost() {
facts: &facts,
collision: &collision,
player: None,
cached_chunk: std::cell::Cell::new(None),
};
let ty = |name: &str| ParticleType::from_name(name).unwrap();
let (fx, fy, fz) = (0.5, f64::from(FLOOR_Y + 5), 20.5);
+87 -57
View File
@@ -4,7 +4,6 @@
//! Horizon Settings > PvP & HUD. Every option is off by default, so the game looks like
//! vanilla until the player changes one, and none of them shows anything vanilla doesn't
//! already tell the player.
use horizon_hooks::look::Look;
/// Colours the PvP options pick from. `Inverted` is vanilla's crosshair blend: the colour
/// under it, inverted.
@@ -264,6 +263,15 @@ impl ViewWeather {
Self::ALL.into_iter().find(|c| c.name() == s)
}
}
/// The day time this player sees with `fixed` set: the same day (the moon's phase follows the
/// world's), at that time.
pub fn view_day_time(world: i64, fixed: Option<ViewTime>) -> i64 {
match fixed {
Some(t) => world.div_euclid(24000) * 24000 + t.day_time(),
None => world,
}
}
/// The crosshair's look. Its defaults draw vanilla's: a 15-pixel cross, one pixel thick,
/// inverting what is under it.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
@@ -520,36 +528,24 @@ impl Hit {
}
}
/// What the renderer reads of these options each frame (`FrameInputs::pvp`); the default is
/// vanilla's look.
#[derive(Clone, Copy, Debug, Default, PartialEq)]
pub struct Frame {
/// The block outline's stroke ([`Outline::stroke`]).
pub outline: Option<(u32, f32)>,
/// The hurt overlay texel ([`Hit::texel`]), 0 for vanilla's.
pub hurt_texel: u32,
/// The glint's colour ([`Tweaks::glint_texel`]), 0 for vanilla's.
pub glint: u32,
}
impl Pvp {
/// What the engine reads of these options (`horizon_hooks::look`): all it draws differently.
/// Sets PvP & HUD's fields of `look`, the others' stay.
pub fn apply(&self, look: &mut Look) {
let (c, t) = (self.combat, self.tweaks);
look.legacy_pose = c.legacy;
look.hide_attack_indicator = c.legacy && !c.indicator;
look.mute_attack_sounds = c.legacy && c.sounds;
look.hide_attack_particles = c.legacy;
look.frame.outline = self.outline.stroke();
look.frame.hurt_texel = self.hit.texel();
look.frame.glint = t.glint_texel();
look.shield_size = t.shield_size;
look.totem_scale = f32::from(t.totem_size) / 100.0;
look.fire_lower = f32::from(t.fire_lower) / 100.0;
look.clear_lava = t.clear_lava;
look.clear_water = t.clear_water;
look.chat.timestamps = self.chat.timestamps;
look.chat.compact = self.chat.compact;
look.chat.copy = self.chat.copy;
look.chat.search = self.chat.search;
look.hide_effect_icons = self.hud.effects;
look.time_of_day = t.time.map(ViewTime::day_time);
look.weather = t.weather.map(ViewWeather::levels);
}
/// [`Pvp::apply`] on vanilla's look.
pub fn look(&self) -> Look {
let mut look = Look::default();
self.apply(&mut look);
look
pub fn frame(&self) -> Frame {
Frame {
outline: self.outline.stroke(),
hurt_texel: self.hit.texel(),
glint: self.tweaks.glint_texel(),
}
}
}
@@ -745,6 +741,15 @@ pub struct ChatOptions {
/// Ctrl+F in the chat searches the chat's messages.
pub search: bool,
}
impl ChatOptions {
/// What `chat::Chat` lays its lines out with.
pub fn qol(&self) -> crate::chat::ChatQol {
crate::chat::ChatQol {
timestamps: self.timestamps,
compact: self.compact,
}
}
}
/// Combat feel (`docs/pvp.md`): how fights look. Legacy draws them as 1.8.9 did; what the
/// game sends to a server never changes, so a vanilla server and its anticheat see 26.3.
@@ -761,6 +766,21 @@ pub struct Combat {
/// (`horizon_client::legacy_combat`). Never used as a client of a server.
pub rules: bool,
}
impl Combat {
/// Whether this client leaves out sound `name` a server sent: under Legacy with its
/// sounds, the `entity.player.attack.*` family (strong, weak, crit, sweep, knockback,
/// nodamage), which 1.9 brought with the attack cooldown.
pub fn mutes_sound(&self, name: &str) -> bool {
let name = name.strip_prefix("minecraft:").unwrap_or(name);
self.legacy && self.sounds && name.starts_with("entity.player.attack.")
}
/// Whether this client leaves out particles of `ty` a server sent: under Legacy, the
/// sweep and the damage hearts, which 1.9 brought (1.8.9's hits show crits only).
pub fn hides_particle(&self, ty: crate::particles::ParticleType) -> bool {
use crate::particles::ParticleType as T;
self.legacy && matches!(ty, T::SweepAttack | T::DamageIndicator)
}
}
#[derive(Clone, Debug, PartialEq)]
pub struct Pvp {
@@ -1687,6 +1707,18 @@ mod tests {
assert_eq!(fresh.extras.pvp.tweaks.fire_lower, PRESET_FIRE_LOWER);
}
#[test]
fn a_fixed_time_keeps_the_worlds_day() {
// Day 9 at 5000: the moon's phase (the day number) stays, the time of day is the chosen.
let world = 9 * 24000 + 5000;
assert_eq!(view_day_time(world, None), world);
assert_eq!(
view_day_time(world, Some(ViewTime::Midnight)),
9 * 24000 + 18000
);
assert_eq!(view_day_time(-100, Some(ViewTime::Noon)), -24000 + 6000);
}
#[test]
fn the_glint_colour_reaches_the_shaders_as_rgb_bytes() {
let mut t = Tweaks::default();
@@ -1773,33 +1805,31 @@ mod tests {
}
}
/// Legacy leaves out what 1.9 added to a hit: its sounds only with the sounds option, the
/// sweep and hearts always; the indicator stays when kept. Modern changes nothing.
#[test]
fn combat_feel_reaches_the_look() {
let mut pvp = Pvp::default();
assert_eq!(pvp.look(), Look::default(), "vanilla's look");
let strong = "minecraft:entity.player.attack.strong";
pvp.combat.legacy = true;
let look = pvp.look();
assert!(look.legacy_pose && look.hide_attack_particles && look.hide_attack_indicator);
assert!(!look.mutes_sound(strong), "only with the sounds option");
pvp.combat.sounds = true;
pvp.combat.indicator = true;
let look = pvp.look();
assert!(look.mutes_sound(strong) && !look.hide_attack_indicator);
}
#[test]
fn view_time_weather_and_sizes_reach_the_look() {
let mut pvp = Pvp::default();
pvp.tweaks.time = Some(ViewTime::Midnight);
pvp.tweaks.weather = Some(ViewWeather::Thunder);
pvp.tweaks.totem_size = 50;
pvp.tweaks.fire_lower = 40;
let look = pvp.look();
assert_eq!(look.day_time(9 * 24000 + 5000), 9 * 24000 + 18000);
assert_eq!(look.weather, Some((1.0, 1.0)));
assert_eq!((look.totem_scale, look.fire_lower), (0.5, 0.4));
fn legacy_leaves_out_what_1_9_added_to_a_hit() {
use crate::particles::ParticleType as T;
let modern = Combat::default();
let legacy = Combat {
legacy: true,
..Combat::default()
};
let sounds = Combat {
sounds: true,
..legacy
};
for name in [
"minecraft:entity.player.attack.strong",
"entity.player.attack.sweep",
"minecraft:entity.player.attack.nodamage",
] {
assert!(sounds.mutes_sound(name), "{name}");
assert!(!legacy.mutes_sound(name), "only with the sounds option");
assert!(!modern.mutes_sound(name));
}
// 1.8.9 had the hurt sound: it stays.
assert!(!sounds.mutes_sound("minecraft:entity.player.hurt"));
assert!(legacy.hides_particle(T::SweepAttack) && legacy.hides_particle(T::DamageIndicator));
assert!(!legacy.hides_particle(T::Crit) && !legacy.hides_particle(T::EnchantedHit));
assert!(!modern.hides_particle(T::SweepAttack));
}
}
@@ -337,13 +337,6 @@ impl Settings {
.and_then(|mode| self.video_modes.iter().position(|m| *m == mode))
.map_or(-1, |i| i as i32)
}
/// Loads only the crates' settings stores (`horizon_hooks::settings`) from the files next to
/// `path`, into the seat whose turn it is (`horizon_hooks::seat`): a splitscreen player whose
/// settings were made from P1's rather than loaded.
pub fn load_stores(path: &Path) {
let dir = path.parent();
horizon_hooks::settings::engine::load(&mut |file| fs::read_to_string(dir?.join(file)).ok());
}
pub fn load(path: &Path) -> Self {
let mut horizon = path
.parent()
@@ -237,17 +237,17 @@ impl<P: Copy, S: Copy> Lines<P, S> {
pub(crate) fn backdrop(batch: &mut HudBatch, atlas: &HudAtlas, in_world: bool) {
let (w, h) = (batch.width, batch.height);
ui::menu_background(batch, atlas, 0.0, 0.0, w, h, in_world);
style::fill(batch, atlas, [0.0, 0.0, w, h], style::VEIL);
style::fill(batch, atlas, [0.0, 0.0, w, h], style::veil());
}
/// The mark and the screen's name over the search field.
pub(crate) fn mark(batch: &mut HudBatch, atlas: &HudAtlas, cols: &Columns, title: &str) {
let (x, y) = (cols.side_x, cols.side_y);
style::fill(batch, atlas, [x, y + 2.0, 6.0, 6.0], style::ACCENT);
style::fill(batch, atlas, [x, y + 2.0, 6.0, 6.0], style::accent());
let horizon = Text::literal("Horizon");
batch.text_styled(atlas, &horizon, x + 10.0, y, style::WHITE, true);
let title = Text::literal(title);
batch.text_styled(atlas, &title, x, y + 10.0, style::MUTED, true);
batch.text_styled(atlas, &title, x, y + 10.0, style::muted(), true);
}
/// A page in the page list: the open page on a plate with the accent's bar beside it, the
@@ -273,13 +273,13 @@ pub(crate) fn page_tab(
crate::widgets::target_as(rect, crate::widgets::Kind::Tab(open));
}
if open {
style::fill(batch, atlas, rect, style::OPEN);
style::fill(batch, atlas, [px, py, 2.0, ph], style::ACCENT);
style::fill(batch, atlas, rect, style::open());
style::fill(batch, atlas, [px, py, 2.0, ph], style::accent());
}
let color = match (open || hover, has) {
(true, _) => style::WHITE,
(false, true) => style::SOFT,
(false, false) => style::MUTED,
(false, true) => style::soft(),
(false, false) => style::muted(),
};
let ty = py + ((ph - 8.0) / 2.0).floor();
let name = Text::literal(style::fit(atlas, name, pw - 12.0));
@@ -312,12 +312,12 @@ pub(crate) fn search_field(
search.draw(batch, atlas, None);
if search.value.is_empty() && !focused {
let hint = Text::literal(search.message.clone());
batch.text_styled(atlas, &hint, x + 5.0, search.y, style::MUTED, true);
batch.text_styled(atlas, &hint, x + 5.0, search.y, style::muted(), true);
}
if !search.value.is_empty() {
let hover = ui::hovered(cursor, clear[0], clear[1], clear[2], clear[3]);
crate::widgets::widget_cursor(hover, true);
let color = if hover { style::WHITE } else { style::MUTED };
let color = if hover { style::WHITE } else { style::muted() };
let cy = y + ((h - 8.0) / 2.0).floor();
ui::centered_text(
batch,
@@ -344,7 +344,7 @@ pub(crate) fn plate(batch: &mut HudBatch, atlas: &HudAtlas, cols: &Columns, head
batch,
atlas,
[px + 8.0, py + 24.0, pw - 16.0, 1.0],
style::HEAD_RULE,
style::head_rule(),
);
}
@@ -383,9 +383,9 @@ pub(crate) fn list<P: Copy, S: Copy>(
}
let Some(setting) = line.setting else {
let (name, color, dy) = match line.group {
Some(("", _)) => (page_name(line.page), style::MUTED, 3.0),
Some((name, _)) => (lang::get(name), style::MUTED, 3.0),
None => (page_name(line.page), style::ACCENT, 4.0),
Some(("", _)) => (page_name(line.page), style::muted(), 3.0),
Some((name, _)) => (lang::get(name), style::muted(), 3.0),
None => (page_name(line.page), style::accent(), 4.0),
};
let name = Text::literal(name);
batch.text_styled(atlas, &name, cols.list_x + 2.0, y + dy, color, true);
@@ -393,7 +393,7 @@ pub(crate) fn list<P: Copy, S: Copy>(
};
if !after_name {
let rule = [cols.list_x + 8.0, y, cols.list_w - 16.0, 1.0];
style::fill(batch, atlas, rule, style::ROW_RULE);
style::fill(batch, atlas, rule, style::row_rule());
}
row(batch, setting, y);
}
@@ -404,7 +404,7 @@ pub(crate) fn list<P: Copy, S: Copy>(
&none,
cols.list_x + 2.0,
cols.top + 4.0,
style::MUTED,
style::muted(),
true,
);
}
@@ -499,7 +499,7 @@ pub(crate) fn row(batch: &mut HudBatch, atlas: &HudAtlas, cols: &Columns, y: f32
let active = live && (hover || focused || held);
if active {
let lit = [rect[0] + 2.0, y + 1.0, rect[2] - 4.0, style::ROW - 2.0];
style::fill(batch, atlas, lit, style::HOVER);
style::fill(batch, atlas, lit, style::hover());
}
crate::widgets::widget_cursor(hover, live);
// The control socket's widget tree, the narrator and a controller's focus.
@@ -523,8 +523,8 @@ pub(crate) fn row(batch: &mut HudBatch, atlas: &HudAtlas, cols: &Columns, y: f32
if live {
crate::widgets::target_as(rect, kind);
}
let color = if live { style::WHITE } else { style::MUTED };
let soft = if live { style::SOFT } else { style::MUTED };
let color = if live { style::WHITE } else { style::muted() };
let soft = if live { style::soft() } else { style::muted() };
let ty = y + ((style::ROW - 8.0) / 2.0).floor();
let lit = live && (hover || held);
let left = rect[0] + style::INSET;
@@ -611,7 +611,7 @@ fn lit(batch: &mut HudBatch, atlas: &HudAtlas, [x, y, w, _]: [f32; 4]) {
batch,
atlas,
[x + 2.0, y + 1.0, w - 4.0, style::ROW - 2.0],
style::HOVER,
style::hover(),
);
}
@@ -644,7 +644,7 @@ fn read_row(
let room = rect[2] - 2.0 * style::INSET - name_w - 8.0;
let value = style::fit(atlas, value, room);
let vx = rect[0] + rect[2] - style::INSET - atlas.font.width(&value);
batch.text_styled(atlas, &Text::literal(value), vx, ty, style::SOFT, true);
batch.text_styled(atlas, &Text::literal(value), vx, ty, style::soft(), true);
if focused {
style::ring(
batch,
@@ -749,7 +749,7 @@ fn pad_row(batch: &mut HudBatch, atlas: &HudAtlas, cols: &Columns, y: f32, view:
batch.text_styled(atlas, &yellow(close), right.ceil(), ty, style::WHITE, true);
}
let left = rect[0] + style::INSET;
let color = if live { style::WHITE } else { style::MUTED };
let color = if live { style::WHITE } else { style::muted() };
let shown = Text::literal(style::fit(atlas, name, chip[0] - 6.0 - left));
batch.text_styled(atlas, &shown, left, ty, color, true);
if focused {
@@ -799,7 +799,7 @@ fn side_lines(
];
crate::widgets::record(line, rect, false);
let shown = Text::literal(style::fit(atlas, line, rect[2]));
batch.text_styled(atlas, &shown, rect[0], rect[1], style::MUTED, true);
batch.text_styled(atlas, &shown, rect[0], rect[1], style::muted(), true);
}
}
/// A line under the page list: its inset, and its height.
@@ -881,7 +881,7 @@ fn heading(batch: &mut HudBatch, atlas: &HudAtlas, text: &str, [x, y, w, _]: [f3
let rect = [x + SIDE_INSET, y + HEADING_Y, w - 2.0 * SIDE_INSET, 8.0];
crate::widgets::record(text, rect, false);
let shown = Text::literal(style::fit(atlas, text, rect[2]));
batch.text_styled(atlas, &shown, rect[0], rect[1], style::MUTED, true);
batch.text_styled(atlas, &shown, rect[0], rect[1], style::muted(), true);
}
/// Vanilla's scroller, inside the panel's right edge, sized as `ui::Scroll` drags it.
@@ -1896,8 +1896,8 @@ impl<S: Screen> Shell<S> {
// The row's page is open: lit as the list's open page is.
if list.lit == Some(*shown) {
let row @ [x, y, _, h] = strip.area;
style::fill(batch, atlas, row, style::OPEN);
style::fill(batch, atlas, [x, y, 2.0, h], style::ACCENT);
style::fill(batch, atlas, row, style::open());
style::fill(batch, atlas, [x, y, 2.0, h], style::accent());
}
tabs::draw(batch, atlas, strip);
}
@@ -2134,7 +2134,14 @@ impl<S: Screen> Shell<S> {
crate::widgets::narratable(|| crate::widgets::Narratable::text(row.name.clone(), rect));
let ty = y + ((style::ROW - 8.0) / 2.0).floor();
let name = Text::literal(style::fit(atlas, &row.name, rect[2] - 2.0 * style::INSET));
batch.text_styled(atlas, &name, rect[0] + style::INSET, ty, style::MUTED, true);
batch.text_styled(
atlas,
&name,
rect[0] + style::INSET,
ty,
style::muted(),
true,
);
return;
}
let over = |[x, y, w, h]: [f32; 4]| pointer.is_some_and(|at| ui::hovered(at, x, y, w, h));
@@ -2240,7 +2247,7 @@ impl<S: Screen> Shell<S> {
// Labelled for the widget tree: what the strip says is what a test or an agent reads.
crate::widgets::record(about, cols.dock, false);
let color = if why.is_some() {
style::MUTED
style::muted()
} else {
style::GREY
};
+27 -1
View File
@@ -192,7 +192,12 @@ impl SignData {
let side = |t: Option<&Tag>| -> SignText {
let mut out = SignText::default();
let Some(t) = t else { return out };
if let Some(Tag::List(messages)) = t.get("messages") {
if let Some(messages) = t.get("messages") {
// `SignText.CODEC` reads a side all or nothing: a messages
// field that is not a list leaves the side empty.
let Tag::List(messages) = messages else {
return SignText::default();
};
for (i, m) in messages.iter().take(4).enumerate() {
out.lines[i] = component_text(m);
out.click[i] = click_event(m);
@@ -307,6 +312,27 @@ pub fn editor_sprite(folder: &str, wood: &str) -> String {
#[cfg(test)]
mod tests {
use super::*;
/// `SignText.CODEC` reads messages as a list: a string field fails the
/// whole side, taking its colour and glow with it.
#[test]
fn messages_that_are_not_a_list_load_an_empty_side() {
let tag = Tag::compound([(
"front_text",
Tag::compound([
("messages", Tag::String("not a list".into())),
("color", Tag::String("red".into())),
("has_glowing_text", Tag::Byte(1)),
]),
)]);
let mut decoded = SignData::default();
decoded.decode(&tag);
assert_eq!(
decoded.front,
SignText::default(),
"a failed message list takes the side's colour and glow with it"
);
}
#[test]
fn waxed_and_glowing_text_take_any_number() {
let tag = Tag::compound([
@@ -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);
+41 -2
View File
@@ -1238,6 +1238,9 @@ pub struct EditBox {
/// Grey completion shown after the text (EditBox.setSuggestion).
pub suggestion: Option<String>,
pub text_color: u32,
/// `EditBox.setCentered`: the text starts in the middle of the box's
/// width, not at its left edge (`BookSignScreen`'s title).
pub centered: bool,
/// `EditBox.textColorUneditable`: the colour of a box `setEditable(false)`
/// made read-only, which still shows its value and caret.
pub text_color_uneditable: u32,
@@ -1536,6 +1539,7 @@ impl EditBox {
focused_at: Instant::now(),
suggestion: None,
text_color: rgba(224, 224, 224, 255),
centered: false,
text_color_uneditable: rgba(112, 112, 112, 255),
editable: true,
message: String::new(),
@@ -1559,6 +1563,16 @@ impl EditBox {
self.y
}
}
/// `EditBox.updateTextPosition`: where the text starts, `(width - text
/// width) / 2` in for a centred box (vanilla's `textX`), else at
/// `text_x` (4 in from a border).
pub fn text_start_x(&self, text_width: f32) -> f32 {
if self.centered {
self.x + ((self.width - text_width) / 2.0).floor()
} else {
self.text_x()
}
}
pub fn reset_blink(&mut self) {
self.focused_at = Instant::now();
}
@@ -1641,8 +1655,13 @@ impl EditBox {
/// EditBox.findClickedPositionInText: the character boundary under GUI `x`.
pub fn position_at(&mut self, atlas: &HudAtlas, x: f32) -> usize {
self.layout(atlas);
let room = (x.floor() - self.text_x()).min(self.inner_width());
let rest: String = self.value.chars().skip(self.display).collect();
let shown: String = rest
.chars()
.take(atlas.font.fit(&rest, self.inner_width()))
.collect();
let start = self.text_start_x(atlas.font.width_text(&Text::literal(shown.as_str())));
let room = (x.floor() - start).min(self.inner_width());
self.display + atlas.font.fit(&rest, room)
}
/// EditBox.onClick: the cursor to the pointer, extending the selection
@@ -1849,7 +1868,12 @@ impl EditBox {
),
}
};
let (tx, ty) = (self.text_x(), self.text_y());
let start = if self.centered {
self.text_start_x(atlas.font.width_text(&Text::literal(displayed.as_str())))
} else {
self.text_x()
};
let (tx, ty) = (start, self.text_y());
let mut draw_x = tx;
if !displayed.is_empty() {
let half = if on_screen {
@@ -2650,4 +2674,19 @@ mod tests {
assert!(edit.key(winit::keyboard::KeyCode::Backspace, false, false));
assert_eq!(edit.value, "name");
}
/// `EditBox.setCentered` (`BookSignScreen`'s title box): with the flag the
/// text starts `(width - text width) / 2` in, which is also where a click
/// maps; without it the text starts at the box's left edge.
#[test]
fn a_centred_box_starts_its_text_in_the_middle() {
let mut edit = EditBox::new(10.0, 0.0, 114.0, 9.0);
edit.bordered = false;
assert_eq!(edit.text_start_x(0.0), 10.0);
edit.centered = true;
assert_eq!(edit.text_start_x(0.0), 67.0);
assert_eq!(edit.text_start_x(18.0), 58.0);
// (114 - 5) / 2 = 54.5, rounded down.
assert_eq!(edit.text_start_x(5.0), 64.0);
}
}
@@ -97,6 +97,7 @@ pub fn main() -> Result<()> {
facts: &facts,
collision: &collision,
player: None,
cached_chunk: std::cell::Cell::new(None),
};
let mut engine = ParticleEngine::new(&root, &Default::default(), &Default::default());
let mut out = BufWriter::new(File::create(&args[2])?);
@@ -194,6 +194,7 @@ pub fn main() -> Result<()> {
facts: &facts,
collision: &collision,
player: None,
cached_chunk: std::cell::Cell::new(None),
};
let v = |key: &str| -> [f64; 3] {
std::array::from_fn(|i| case[key][i].as_f64().unwrap_or(0.0))
+49 -4
View File
@@ -612,6 +612,7 @@ fn do_animate_tick(
facts: ctx.facts,
collision: ctx.collision,
player: None,
cached_chunk: std::cell::Cell::new(None),
};
engine.polytone_animate(&level, pos);
let facts = ctx.facts.get(state);
@@ -1994,8 +1995,10 @@ pub fn campfire_particles<'a>(
}
}
}
/// `CampfireBlock.makeParticles` (always visible, overriding the limiter);
/// `smoking` adds the plain smoke puff at the logs.
/// `CampfireBlock.makeParticles`: the cosy or signal smoke through
/// `addAlwaysVisibleParticle(.., true, ..)` (it overrides the limiter but still
/// rolls the Particles option); `smoking` adds the plain smoke puff at the logs
/// through `addParticle`, so the limiter can drop it.
pub fn make_campfire_smoke(
engine: &mut ParticleEngine,
pos: [i32; 3],
@@ -2014,7 +2017,13 @@ pub fn make_campfire_smoke(
} else {
T::CampfireCosySmoke
};
engine.create(&ParticleOptions::simple(ty), [x, y, z], [0.0, 0.07, 0.0]);
engine.add_with(
&ParticleOptions::simple(ty),
[x, y, z],
[0.0, 0.07, 0.0],
true,
true,
);
if smoking {
let r = engine.random();
let dx = r.next_double() / 4.0 * sign(r);
@@ -2024,13 +2033,49 @@ pub fn make_campfire_smoke(
f64::from(pos[1]) + 0.4,
f64::from(pos[2]) + 0.5 + dz,
];
engine.create(&ParticleOptions::simple(T::Smoke), at, [0.0, 0.005, 0.0]);
add(engine, T::Smoke, at, [0.0, 0.005, 0.0]);
}
}
#[cfg(test)]
mod tests {
use super::*;
/// `CampfireBlock.makeParticles` through `ClientLevel.doAddParticle`: the
/// cosy smoke overrides the limiter, the plain smoke does not, so it is
/// dropped beyond 32 blocks and at Particles: Minimal, and at Decreased
/// each puff is dropped when `calculateParticleLevel`'s `nextInt(3)` is 0.
#[test]
fn doused_campfire_smoke_goes_through_the_particle_limiter() {
use horizon_core::random::RandomSource as _;
use std::collections::{HashMap, HashSet};
let run = |level: u8, camera: [f64; 3]| {
let mut engine = ParticleEngine::new(Path::new(""), &HashMap::new(), &HashSet::new());
engine.seed_randoms(5, 9, 0);
engine.created_requests = Some(Vec::new());
engine.particle_level = level;
engine.camera = camera;
for _ in 0..20 {
make_campfire_smoke(&mut engine, [0, 64, 0], false, true);
}
let made = engine.created_requests.take().unwrap();
let count = |ty| made.iter().filter(|(o, ..)| o.ty == ty).count();
(count(T::CampfireCosySmoke), count(T::Smoke))
};
let near = [0.5, 65.0, 0.5];
assert_eq!(run(0, near), (20, 20), "All");
assert_eq!(run(2, near), (20, 0), "Minimal");
assert_eq!(run(0, [100.5, 65.0, 0.5]), (20, 0), "100 blocks away");
// Decreased: per puff the cosy smoke's roll, then the plain smoke's.
let mut level = LegacyRandom::new(9);
let kept = (0..20)
.filter(|_| {
level.next_int_bound(3).unwrap();
level.next_int_bound(3).unwrap() != 0
})
.count();
assert!(0 < kept && kept < 20);
assert_eq!(run(1, near), (20, kept), "Decreased");
}
#[test]
fn classifies_animated_blocks() {
let none = |_: &str| None;
+9 -10
View File
@@ -17,15 +17,15 @@ pub use horizon_client_render::{
containers, controller_ui, crash, custom_sky, datapack, dynamic_light, effects, environment,
extras, fast_hash, fluid_render, fog, font, gfx, graphics, graphics_style, graphics_ui,
hdr_calibration, hud, hud_kit, hud_layout, hud_overlays, inventory_effects, java_text,
jigsaw_block, json_folder, level_loading, light, lod, logging, menu_blur, mesher, minimap,
mipmap, mod_browser, mod_list, model_library, models, multiline_edit, native_emissive,
options_file, options_fixes, options_ui, overlay, pack_browser, pack_palette, pack_screen,
packs, pad_cursor, pad_glyph, par, particles, poi_sections, polytone_colors,
polytone_creative_tabs, polytone_post, post_chain, potent_sulfur, preedit, providers, pvp,
pvp_hud, random_source, screen_draw, screen_effects, sculk, section_mask, server_terrain,
settings, shader_options, shader_pack_list, signs, skins, social, sound_engine, sound_events,
sound_preview, spans, split_layout, split_map, split_profiles, sprites, streaming,
structure_block, tab_list, terrain_model, test_block, test_instance_block, testgen,
jigsaw_block, json_folder, level_loading, light, lod, logging, menu_blur, menu_style, mesher,
minimap, mipmap, mod_browser, mod_list, model_library, models, multiline_edit, native_emissive,
options_file, options_fixes, options_ui, overlay, pack_browser, pack_formats, pack_hooks,
pack_palette, pack_screen, packs, pad_cursor, pad_glyph, par, particles, poi_sections,
polytone_colors, polytone_creative_tabs, polytone_post, post_chain, potent_sulfur, preedit,
providers, pvp, pvp_hud, random_source, screen_draw, screen_effects, sculk, section_mask,
server_terrain, settings, shader_options, shader_pack_list, signs, skins, social, sound_engine,
sound_events, sound_preview, spans, split_layout, split_map, split_profiles, sprites,
streaming, structure_block, tab_list, terrain_model, test_block, test_instance_block, testgen,
title_edition, trims, ui, vault, weather, widgets, world, worldborder, zip,
};
pub mod attack_strength;
@@ -87,7 +87,6 @@ pub mod item_tooltip;
pub mod join_seq;
pub mod lan;
pub mod landing;
pub mod last_server;
pub mod legacy_combat;
pub mod locator_bar;
pub mod lod_net;
+3 -5
View File
@@ -13,6 +13,9 @@ 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.
if !cfg!(feature = "horizon-extras") {
horizon_client::options_ui::HORIZON_EXTRAS
@@ -21,11 +24,6 @@ pub fn main_with(mods: crate::mod_api::Mods) {
horizon_client::telemetry::mark_start();
// Opt-in telemetry keeps each crash report (docs/telemetry.md), whatever the mode.
crash::on_report(horizon_client::telemetry::keep_crash);
// Auto-reconnect, a Horizon extra still in the engine crates, runs on the connection hooks as
// the crates above do (horizon_client::reconnect; docs/layers.md, plan M5).
if cfg!(feature = "horizon-extras") {
let _ = horizon_hooks::register(&horizon_client::reconnect::HOOKS);
}
// Crates above the engine registered their hooks before this (horizon-official's `main`);
// none registers after this point.
horizon_hooks::engine::seal();
+1 -165
View File
@@ -1,11 +1,9 @@
//! HUD boxes in the corners: the minimap's place and mods' panels, small
//! boxes in GUI pixels stacked per corner (`docs/extras.md`), and the layers crates above the
//! engine register (`horizon_hooks::hud`).
//! boxes in GUI pixels stacked per corner (`docs/extras.md`).
use crate::{
extras::Corner,
hud::{HudAtlas, HudBatch, Rect, rgba},
};
use horizon_hooks::hud;
/// The gap between an overlay and the screen edge or the next overlay.
const MARGIN: f32 = 2.0;
@@ -127,114 +125,6 @@ pub fn draw_lines(
}
}
/// Draws the HUD layers crates registered, after the extras' and mods' boxes, which they stack
/// with. `shown`: whether each corner of `Corner::ALL` shows (the debug screen covers some).
pub fn draw_layers(
layers: &[&dyn hud::Layer],
batch: &mut HudBatch,
atlas: &HudAtlas,
corners: &mut Corners,
shown: [bool; 4],
) {
let mut canvas = Canvas {
batch,
atlas,
corners,
shown,
};
for layer in layers {
layer.draw(&mut canvas);
}
}
struct Canvas<'a> {
batch: &'a mut HudBatch,
atlas: &'a HudAtlas,
corners: &'a mut Corners,
shown: [bool; 4],
}
/// `0xAARRGGBB` as the batch's colour.
fn argb_color(argb: u32) -> u32 {
let [a, r, g, b] = argb.to_be_bytes();
rgba(r, g, b, a)
}
impl hud::Canvas for Canvas<'_> {
fn size(&self) -> (f32, f32) {
(self.batch.width, self.batch.height)
}
fn tr(&self, key: &str) -> String {
crate::lang::get(key)
}
fn text_width(&self, text: &str) -> f32 {
HudBatch::text_width(self.atlas, text)
}
fn text(&mut self, x: f32, y: f32, text: &str, argb: u32, shadow: bool) {
self.batch
.text(self.atlas, text, x, y, argb_color(argb), shadow);
}
fn fill(&mut self, x: f32, y: f32, w: f32, h: f32, argb: u32) {
self.batch.fill(self.atlas, x, y, w, h, argb_color(argb));
}
fn place(&mut self, corner: hud::Corner, w: f32, h: f32) -> Option<(f32, f32)> {
let corner = match corner {
hud::Corner::TopLeft => Corner::TopLeft,
hud::Corner::TopRight => Corner::TopRight,
hud::Corner::BottomLeft => Corner::BottomLeft,
hud::Corner::BottomRight => Corner::BottomRight,
};
let i = Corner::ALL.iter().position(|c| *c == corner)?;
self.shown[i].then(|| self.corners.place(corner, w, h))
}
}
/// Draws on an open container screen (`horizon_hooks::containers`): `draw` gets a canvas whose
/// (0, 0) is the panel's top left corner, at `origin` on the screen. It has no corners: `place`
/// answers `None`.
pub fn draw_on_panel(
batch: &mut HudBatch,
atlas: &HudAtlas,
origin: (f32, f32),
draw: impl FnOnce(&mut dyn hud::Canvas),
) {
draw(&mut Panel {
batch,
atlas,
origin,
});
}
struct Panel<'a> {
batch: &'a mut HudBatch,
atlas: &'a HudAtlas,
origin: (f32, f32),
}
impl hud::Canvas for Panel<'_> {
fn size(&self) -> (f32, f32) {
(self.batch.width, self.batch.height)
}
fn tr(&self, key: &str) -> String {
crate::lang::get(key)
}
fn text_width(&self, text: &str) -> f32 {
HudBatch::text_width(self.atlas, text)
}
fn text(&mut self, x: f32, y: f32, text: &str, argb: u32, shadow: bool) {
let (x, y) = (self.origin.0 + x, self.origin.1 + y);
self.batch
.text(self.atlas, text, x, y, argb_color(argb), shadow);
}
fn fill(&mut self, x: f32, y: f32, w: f32, h: f32, argb: u32) {
let (x, y) = (self.origin.0 + x, self.origin.1 + y);
self.batch.fill(self.atlas, x, y, w, h, argb_color(argb));
}
fn place(&mut self, _: hud::Corner, _: f32, _: f32) -> Option<(f32, f32)> {
None
}
}
#[cfg(test)]
mod tests {
use super::*;
@@ -250,58 +140,4 @@ mod tests {
assert_eq!(c.place(Corner::BottomRight, 50.0, 10.0), (348.0, 274.0));
assert_eq!(c.place(Corner::BottomLeft, 10.0, 10.0), (2.0, 288.0));
}
/// Draws a box of one line in a corner and keeps where it went.
struct Panel {
corner: hud::Corner,
at: std::sync::Mutex<Vec<Option<(f32, f32)>>>,
}
impl hud::Layer for Panel {
fn draw(&self, canvas: &mut dyn hud::Canvas) {
let w = canvas.text_width("12 CPS") + 4.0;
let at = canvas.place(self.corner, w, 12.0);
if let Some((x, y)) = at {
canvas.fill(x, y, w, 12.0, 0x7800_0000);
canvas.text(x + 2.0, y + 2.0, "12 CPS", 0xFFFF_FFFF, true);
}
self.at.lock().unwrap().push(at);
}
}
#[test]
fn layers_stack_with_the_extras_and_skip_hidden_corners() {
let atlas =
HudAtlas::build(std::path::Path::new("/nonexistent/horizon-test-assets")).unwrap();
let mut batch = HudBatch::new([800, 600]);
let (width, height) = (batch.width, batch.height);
let mut corners = Corners::new(width, height, 0.0);
// The minimap already took the top right.
corners.place(Corner::TopRight, 64.0, 64.0);
let right = Panel {
corner: hud::Corner::TopRight,
at: Default::default(),
};
let left = Panel {
corner: hud::Corner::TopLeft,
at: Default::default(),
};
let layers: [&dyn hud::Layer; 2] = [&right, &left];
let empty = batch.vertices.len();
draw_layers(
&layers,
&mut batch,
&atlas,
&mut corners,
[false, true, true, true],
);
let (_, y) = right.at.lock().unwrap()[0].expect("the top right shows");
assert_eq!(y, 2.0 + 64.0 + 2.0, "under the minimap");
assert_eq!(
*left.at.lock().unwrap(),
[None],
"the debug screen covers the top left"
);
assert!(batch.vertices.len() > empty, "the shown box is drawn");
}
}
+23 -20
View File
@@ -237,7 +237,6 @@ mod llamas;
mod loading_screen;
mod loading_window;
mod locator_bar;
mod look;
mod loom;
mod mace;
mod machines;
@@ -384,6 +383,7 @@ mod potions;
mod powder_snow;
mod prediction;
mod pumpkin;
mod pvp;
mod quick_actions;
mod raids;
mod random_textures;
@@ -467,6 +467,7 @@ mod trial_spawner;
mod trial_visuals;
mod trident;
mod tutorial;
mod tweaks;
mod ui_control;
mod use_hand;
mod using_item;
@@ -2017,8 +2018,8 @@ pub struct PackResources {
clouds: Arc<(u32, u32, Vec<bool>)>,
/// misc/forcefield.png: (width, height, RGBA) for the world border wall.
forcefield: Arc<(u32, u32, Vec<u8>)>,
/// OptiFine custom sky layers (`optifine/sky`) with their textures; empty without them.
custom_sky: Arc<horizon_client::custom_sky::CustomSky>,
/// Custom sky layers (OptiFine's `optifine/sky`) with their textures; none without them.
custom_sky: Arc<dyn horizon_client::custom_sky::SkyLayers>,
/// The overworld's, Nether's and End's attribute stacks with the packs' Polytone biome and
/// dimension modifiers (#11486), when a pack has some: what the frame draws.
environments: Option<Arc<[Environment; 3]>>,
@@ -2221,7 +2222,7 @@ struct Game<'a> {
/// `AbstractContainerScreen`'s quick craft and skip-release state.
inv_clicks: horizon_client::container_screen::Clicks,
/// The Mouse Tweaks extra's drag and wheel (`tweaks`).
tweaks: extras::mouse_tweaks::Tweaks,
tweaks: tweaks::Tweaks,
/// The wheel and hovered slot of `BundleMouseActions`.
bundle_mouse: bundles::BundleMouse,
/// The last inventory click, for the double click that gathers a stack.
@@ -2810,9 +2811,10 @@ struct Game<'a> {
pending_bench: Option<(Instant, std::sync::mpsc::Sender<String>)>,
/// Recent frame times for the performance overlay.
frame_graph: horizon_client::perf_overlay::FrameGraph,
/// Horizon's extras (mods' minimap and counters; PvP & HUD's): `game/extras.rs`.
/// Horizon's extras (minimap, auto-reconnect; mods' counters): `game/extras.rs`.
extras: extras::ExtrasState,
pvp: extras::pvp::PvpState,
/// The PvP & HUD options' state (`game/pvp.rs`).
pvp: pvp::PvpState,
/// Horizon mods (`game/mods.rs`), none running unless turned on.
mods: mods::ModsState,
/// Every item and recipe, for mods (`game/native_recipes.rs`), made once.
@@ -2877,9 +2879,9 @@ struct Game<'a> {
entity_attachments: HashMap<[i32; 4], horizon_world::attachments::Attachments>,
/// Connected to a server: the world comes from the network (game/remote.rs).
remote: Option<remote::Remote>,
/// The multiplayer server connected to last, and whether it is a Realm; the connection's
/// lifecycle reaches the hooks through it (`last_server.rs`).
last_server: horizon_client::last_server::LastServer,
/// The open connection joined from Realms: `ServerData.isRealm` for the friends
/// service's presence status.
realm_session: bool,
/// Active packet replay session (game/studio.rs).
pub(super) studio_replay: Option<horizon_client::studio::ReplayDriver>,
pub(super) studio_freecam: horizon_client::studio::StudioFreecam,
@@ -5530,7 +5532,7 @@ impl Game<'_> {
}
} else if self.remote.is_some() {
// `getCurrentServer().isRealm()`: the connection came from Realms.
if self.last_server.realm {
if self.realm_session {
PresenceStatus::PlayingRealms
} else {
PresenceStatus::PlayingServer
@@ -5873,7 +5875,7 @@ impl Game<'_> {
fn hud_layers(&mut self, size: [u32; 2]) -> HudBatch {
self.pad.guide_shown.clear();
let mut batch = HudBatch::new(size);
batch.glint_colour = self.glint_texel();
batch.glint_colour = self.settings.extras.pvp.tweaks.glint_texel();
// GlobalSettingsUniform's GameTime, for a resource pack's text shader.
batch.game_time = ((self.world.game_time.rem_euclid(24000)) as f32
+ self.clock.fraction() as f32)
@@ -5986,7 +5988,8 @@ impl Game<'_> {
// Hud.extractCrosshair / extractItemHotbar / extractPlayerHealth /
// extractVehicleHealth and the experience bar, laid out by hud_layout.
// Combat feel Legacy hides the attack indicator unless the player keeps it.
let indicator = if self.attack_indicator_hidden() {
let combat = self.settings.extras.pvp.combat;
let indicator = if combat.legacy && !combat.indicator {
0
} else {
self.settings.attack_indicator
@@ -6050,7 +6053,7 @@ impl Game<'_> {
});
self.gui_layer("minecraft:effects", &mut batch, |g, batch| {
// The PvP & HUD effects widget takes the icons' place.
if !g.effect_icons_hidden() {
if !g.pvp_effects_shown() {
g.draw_effect_icons(batch);
}
});
@@ -6485,7 +6488,7 @@ impl Game<'_> {
self.draw_hovered_slot_highlight(batch, false, ox, oy);
self.draw_inventory_slots(batch, ox, oy);
self.draw_hovered_slot_highlight(batch, true, ox, oy);
self.draw_container_extras(batch, ox, oy);
self.draw_sort_buttons(batch, ox, oy);
self.draw_recipe_book(batch);
let effect_tip = if self.container.is_none() && self.craft_size == 2 && !self.mount_active()
{
@@ -9895,7 +9898,7 @@ impl Game<'_> {
outline,
outline_after_terrain,
outline_high_contrast: self.settings.file.bool("highContrastBlockOutline"),
look: self.look_frame(),
pvp: self.settings.extras.pvp.frame(),
debug_lines: &debug_lines,
debug_lines_on_top: &debug_lines_on_top,
debug_crosshair: &debug_crosshair,
@@ -10706,7 +10709,7 @@ impl Game<'_> {
if self.screen == Screen::Inventory
&& d != 0.0
&& !bundle
&& !self.container_scroll(d)
&& !self.tweak_scroll(d, self.inventory_target().0)
{
self.station_scroll(d);
}
@@ -10954,7 +10957,7 @@ impl Game<'_> {
return Ok(());
}
if pressed
&& (self.container_mouse_press(button)
&& (self.sort_mouse_press(button)
|| self.hud_editor_key_press(&horizon_client::keybinds::mouse_name(
button,
)))
@@ -11496,7 +11499,7 @@ impl Game<'_> {
}
}
self.extras_frame(elapsed);
self.reconnect_when_due()?;
self.extras_retry()?;
self.apply_mouse_look();
self.pad_look(elapsed.as_secs_f32());
self.advance_flight();
@@ -11607,7 +11610,7 @@ impl Game<'_> {
self.settings.simulation_distance,
self.settings.entity_distance,
);
server.set_legacy_rules(self.pvp_legacy_rules());
server.set_legacy_rules(self.settings.extras.pvp.combat.rules);
}
self.clock.end_frame(self.pause);
let mut consumed = false;
@@ -12207,7 +12210,7 @@ impl<'a> Game<'a> {
persist: Default::default(),
entity_attachments: Default::default(),
remote: None,
last_server: Default::default(),
realm_session: false,
studio_replay: None,
studio_freecam: Default::default(),
studio_timeline: Default::default(),
+15
View File
@@ -33,6 +33,8 @@ fn book_title_box() -> ui::EditBox {
edit.bordered = false;
edit.max_length = 15;
edit.text_color = BLACK;
// BookSignScreen sets the title box centred.
edit.centered = true;
edit.focused = true;
edit
}
@@ -1618,6 +1620,19 @@ pub(super) fn lectern_signal(world: &horizon_client::world::World, pos: BlockPos
}
}
#[cfg(test)]
mod title_box_tests {
use super::book_title_box;
/// `BookSignScreen` sets its 15 character title box centred and unbordered.
#[test]
fn the_sign_title_box_is_centred() {
let edit = book_title_box();
assert!(edit.centered);
assert!(!edit.bordered);
assert_eq!(edit.max_length, 15);
}
}
#[cfg(test)]
mod lectern_books_tag_tests {
use super::super::native_machines::tests::server_game;
+4 -4
View File
@@ -1278,7 +1278,7 @@ impl Game<'_> {
let ctrl = self.has_control_down_with_quirk();
let shift = self.has_shift_down();
// PvP & HUD's chat search: Ctrl+F starts and stops it, Enter stops it (sends nothing).
if self.chat_look().search
if self.settings.extras.pvp.chat.search
&& match key {
KeyCode::KeyF => ctrl,
KeyCode::Enter | KeyCode::NumpadEnter => self.pvp.chat_search,
@@ -1487,7 +1487,7 @@ impl Game<'_> {
cursor.1 as f64,
);
// PvP & HUD's copy on click: a message where the click has nothing else to do.
if self.chat_look().copy
if self.settings.extras.pvp.chat.copy
&& !self.has_shift_down()
&& style.as_ref().is_none_or(|s| s.click.is_none())
&& let Some(message) =
@@ -1665,8 +1665,8 @@ impl Game<'_> {
/// Chat lines over the HUD (focused while the chat screen is open).
pub(super) fn draw_chat(&mut self, batch: &mut HudBatch) {
let focused = self.screen == Screen::Chat;
let options = self.chat_look();
self.ui.chat.set_qol(&self.gui.atlas, self.chat_qol());
let options = self.settings.extras.pvp.chat;
self.ui.chat.set_qol(&self.gui.atlas, options.qol());
// A search ends with its option; a chat closed some other way ends it in keep_chat_draft.
if self.pvp.chat_search && self.ui.chat_screen.is_none() {
// The chat went without closing (leaving the world): nothing to give back.
@@ -227,16 +227,16 @@ impl Game<'_> {
);
}
for m in self.mobs.iter() {
if !m.alive() {
// A mob with no effects swirls nothing: skipped before its size
// is worked out, which every mob in a crowded world would pay
// for (#14053).
if !m.alive() || (m.extra.effect_particles.is_empty() && m.extra.effects.is_empty()) {
continue;
}
let size = (f64::from(m.width()), f64::from(m.height()));
let invisible = m.invisible || m.has_effect("minecraft:invisibility");
if m.extra.effect_particles.is_empty() {
// A mob of this game: its own effects, visible and not ambient.
if m.extra.effects.is_empty() {
continue;
}
let list: Vec<_> = m
.extra
.effects
@@ -92,6 +92,7 @@ fn a_mods_fireworks_burst_and_bang_on_the_client_only() {
facts: game.res.facts(),
collision: game.collision,
player: None,
cached_chunk: std::cell::Cell::new(None),
};
game.particles.tick(&level);
};
+36 -5
View File
@@ -471,15 +471,46 @@ impl Game<'_> {
continue;
}
self.cushions[i].check_interval = 0;
let c = self.cushions[i].clone();
if self.cushion_in_fire(&c) || !self.cushion_survives(&c) {
self.break_cushion(i, false);
continue;
if !self.check_cushion(i) {
i += 1;
}
i += 1;
}
}
/// `Cushion.tickAtCheckInterval` plus the survive check; true when the cushion broke.
fn check_cushion(&mut self, i: usize) -> bool {
let c = self.cushions[i].clone();
let p = c.position;
let (x, y, z) = (p.x.floor() as i32, p.y.floor() as i32, p.z.floor() as i32);
// collidedWithFluid: the fluid box of the cushion's own block against its box.
if let Some((height, _)) =
self.fluid_height_of(x, y, z, Some(horizon_client::blocks::Fluid::Lava))
{
let fluid = Aabb::new(
Vec3::new(f64::from(x), f64::from(y), f64::from(z)),
Vec3::new(
f64::from(x) + 1.0,
f64::from(y) + f64::from(height),
f64::from(z) + 1.0,
),
);
if aabb(c.bounds())
.deflate(Vec3::new(1.0e-7, 1.0e-7, 1.0e-7))
.intersects(fluid)
{
// LAVA_IGNITE's lavaHurt deals 4 damage, which breaks the cushion.
self.break_cushion(i, false);
self.lava_hurt_sound([p.x, p.y, p.z]);
return true;
}
}
if self.cushion_in_fire(&c) || !self.cushion_survives(&c) {
self.break_cushion(i, false);
return true;
}
false
}
/// `LivingEntity.stopRiding` off a cushion: `dismountVehicle` beside it,
/// and `Cushion.removePassenger`'s get-up sound for a rider that left
/// rather than one whose cushion was killed.
+7 -7
View File
@@ -1,8 +1,8 @@
//! OptiFine custom skies (`horizon_client::custom_sky`): each frame, the layers of the world's sky
//! whose day, weather, biome and height hold at the camera, handed to the renderer, which draws
//! them over the vanilla sky. A pack without `optifine/sky` hands it none.
//! Custom skies (`horizon_client::custom_sky`, OptiFine's format): each frame, the layers of the
//! world's sky whose day, weather, biome and height hold at the camera, handed to the renderer,
//! which draws them over the vanilla sky. A pack without layers hands it none.
use super::*;
use horizon_optifine::sky::{Conditions, Now};
use horizon_client::custom_sky::{Conditions, Weather};
/// The layers' eased brightness between frames, and when the last frame was.
#[derive(Default)]
@@ -46,11 +46,11 @@ impl Game<'_> {
let mut state = std::mem::take(&mut self.custom_sky.state);
// Level.isThundering / isRaining.
let weather = if thunder > 0.9 && rain > 0.2 {
Now::Thunder
Weather::Thunder
} else if rain > 0.2 {
Now::Rain
Weather::Rain
} else {
Now::Clear
Weather::Clear
};
let conditions = Conditions {
day_time: self.view_day_time(),
+1 -1
View File
@@ -1249,7 +1249,7 @@ impl Game<'_> {
self.sync_ops(&lists);
let anticheat = load_anticheat();
let chat_filter = load_chat_filter();
self.set_pvp_legacy_rules(load_combat());
self.settings.extras.pvp.combat.rules = load_combat();
// Horizon's far ring (docs/hosting.md): server.toml's `max_view_distance`.
let far = props
.raw
+74 -17
View File
@@ -2,21 +2,15 @@
//! (`native_minimap`: its tiles, image and HUD) and zoom (`native_zoom`) on the camera; clicks
//! and frame times for mods (`native_clicks`, `native_frame_rate`).
//! Each hook does nothing while its extra is off and no mod uses it.
//!
//! Each Horizon extra's game glue is a child module here, so it leaves the engine in one move
//! (docs/layers.md, plan M5): `reconnect` (auto-reconnect), `pvp` (the PvP & HUD options),
//! `mouse_tweaks` and `sorting` (the inventory extras).
pub(in crate::game) mod mouse_tweaks;
pub(in crate::game) mod pvp;
mod reconnect;
mod sorting;
use super::{Dim, Game, Screen, native_hooks::NativeMinimap};
use horizon_client::{
extras::{Corner, Cps, FpsCounter, fov_target},
extras_hud::{self, Corners},
hud::{HudBatch, MINIMAP_SPRITE},
minimap::{self, Layer, Minimap, View},
multiplayer,
reconnect::{Retry, Tracker},
title::Mode,
};
use std::time::Duration;
use winit::event::MouseButton;
@@ -30,6 +24,8 @@ pub(super) struct ExtrasState {
minimap: Minimap,
/// The dimension the minimap's tiles belong to.
minimap_dim: Option<Dim>,
/// Auto-reconnect's attempts at the current server.
reconnect: Tracker,
}
/// CPU time the minimap's tiles may take per frame.
const MINIMAP_BUDGET: Duration = Duration::from_micros(300);
@@ -61,7 +57,6 @@ impl Game<'_> {
if !playing {
self.extras.mouse = [false; 2];
}
self.sync_reconnect_setting();
self.update_minimap();
// For mods (`render.frame-stats`, `native_frame_rate`).
let now = self.extras_now();
@@ -83,6 +78,74 @@ impl Game<'_> {
let cps = (l.per_second(now) as u32, r.per_second(now) as u32);
(cps, self.extras.mouse)
}
/// Auto-reconnect: a connection to `address` as `name` starts.
pub(super) fn extras_connecting(&mut self, address: &str, name: &str, lan: bool) {
self.extras.reconnect.connecting(address, name, lan);
}
/// The server this game connected to last.
pub(super) fn server_address(&self) -> Option<&str> {
self.extras.reconnect.address()
}
/// The server this game connected to last was found on the local network.
pub(super) fn server_is_lan(&self) -> bool {
self.extras.reconnect.lan()
}
/// Auto-reconnect: the server let the player in.
pub(super) fn extras_joined(&mut self) {
self.extras.reconnect.joined(std::time::Instant::now());
}
/// Auto-reconnect: the player left the server on purpose.
pub(super) fn extras_left(&mut self) {
self.extras.reconnect.reset();
}
/// Auto-reconnect's next attempt after a disconnect, when it is on and
/// the reason allows one.
pub(super) fn reconnect_after(
&mut self,
reason: &str,
key: Option<&str>,
from_server: bool,
) -> Option<Retry> {
if !self.settings.extras.auto_reconnect {
self.extras.reconnect.reset();
return None;
}
let now = std::time::Instant::now();
self.extras.reconnect.ended(reason, key, from_server, now)
}
/// Show the attempt's countdown on the Disconnected screen.
pub(super) fn offer_reconnect(&mut self, retry: Option<Retry>) {
let Some(retry) = retry else {
return;
};
if let Some(title) = self.title.as_mut()
&& let Mode::Multiplayer(multiplayer::Screen::Disconnected { retry: slot, .. }) =
&mut title.mode
{
log::info!(
"Horizon: reconnecting to {} in {} s",
horizon_client::net::shown_address(&retry.address),
retry.seconds_left(std::time::Instant::now())
);
*slot = Some(retry);
}
}
/// Auto-reconnect: connect again once the countdown is over. An
/// online-mode server gets the signed-in account again: `connect_server`
/// takes it from `auth::current()`, held in memory for the whole run
/// (the launcher's session file is gone after the first read).
pub(super) fn extras_retry(&mut self) -> horizon_client::Result<()> {
if let Some(retry) = self.title.as_mut().and_then(|t| t.due_retry()) {
self.connect_server_with_options(
&retry.address,
&retry.name,
None,
retry.lan,
self.realm_session,
)?;
}
Ok(())
}
/// The minimap a mod shows (`native_minimap`): its corner, its side
/// in GUI pixels and its view of the player.
fn minimap_shown(&self) -> Option<(Corner, f32, View)> {
@@ -210,18 +273,12 @@ impl Game<'_> {
}
self.draw_pvp_hud(batch, &mut corners);
self.native_hud(batch, &mut corners);
// The layers crates registered (`horizon_hooks::hud`): none, no canvas.
let layers = horizon_hooks::hud::engine::all();
if !layers.is_empty() {
let shown = Corner::ALL.map(|corner| self.native_corner_shown(corner));
extras_hud::draw_layers(layers, batch, &self.gui.atlas, &mut corners, shown);
}
}
/// The height the status effect icons take at the top right
/// (`Hud.extractEffects`: beneficial at y 1, harmful at y 26).
fn effect_rows_height(&self) -> f32 {
let effects = &self.potions.effects.list;
if effects.is_empty() || self.screen != Screen::Playing || self.effect_icons_hidden() {
if effects.is_empty() || self.screen != Screen::Playing || self.pvp_effects_shown() {
return 0.0;
}
let harmful = effects.iter().any(|e| {
@@ -1,11 +0,0 @@
//! Auto-reconnect (`autoReconnect`, docs/extras.md) runs on the engine's connection hooks
//! (`horizon_client::reconnect::HOOKS`, registered in `entry.rs`). Its setting still lives in
//! the engine's extras, so it is handed over here; it leaves with the extras page (plan M5).
use crate::game::Game;
impl Game<'_> {
/// Once per frame: the `autoReconnect` setting, for the hook to read when a connection ends.
pub(in crate::game) fn sync_reconnect_setting(&self) {
horizon_client::reconnect::HOOKS.set_enabled(self.settings.extras.auto_reconnect);
}
}
@@ -1,155 +0,0 @@
//! Sorting (`sort`, `sortKey`; #332, `docs/extras.md`), off by default: a small button by a
//! menu's storage slots and one by the inventory's (an optional key sorts the part under the
//! cursor). The clicks come from `inv_sort::plan` and go through the Mouse Tweaks' ordinary slot
//! clicks (`mouse_tweaks.rs`), so they work wherever vanilla's do.
use crate::game::Game;
use horizon_client::{
hud::{HudBatch, rgba},
inv_sort,
items::ItemStack,
keybinds,
menu::Input,
stations::hover_name,
};
use winit::{event::MouseButton, keyboard::KeyCode};
/// A sort button's side in GUI pixels.
const SORT_BUTTON: f32 = 9.0;
impl Game<'_> {
/// The sort buttons of the open screen, with the extra on: their
/// corner (from the screen's) and the slots each sorts. Any menu screen
/// gets them, by its slots' roles (`native_slot_roles`): one by the top
/// right of its storage slots (a chest's title line) when it has some,
/// one by the main inventory's (the "Inventory" label's line).
fn sort_buttons(&self) -> Vec<((f32, f32), Vec<usize>)> {
use crate::game::NativeSlotRole as Role;
if !self.settings.extras.sort {
return Vec::new();
}
let roles = self.native_slot_roles();
let of = |role| -> Vec<usize> {
roles
.iter()
.filter(|(_, r)| *r == role)
.map(|(s, _)| *s)
.collect()
};
// Over the section's last column: (160, 5) over a chest's slots.
let right = |slots: &[usize]| {
slots
.iter()
.map(|&s| self.slot_position(s).0 + 8.0)
.fold(f32::MIN, f32::max)
};
let mut buttons = Vec::new();
let storage = of(Role::Storage);
if !storage.is_empty() {
let top = storage
.iter()
.map(|&s| self.slot_position(s).1 - 13.0)
.fold(f32::MAX, f32::min);
buttons.push(((right(&storage), top), storage));
}
// On the "Inventory" label's line (`inventoryLabelY`, height - 94, 12
// above the first row): a mod's screen places its inventory itself.
let main = of(Role::Inventory);
if !main.is_empty() {
let y = self
.native_screen_inventory_y()
.map_or(self.screen_height() - 95.0, |y| y - 13.0);
buttons.push(((right(&main), y), main));
}
buttons
}
/// The sort buttons: a bordered square with three bars, lighter under
/// the cursor.
pub(in crate::game) fn draw_sort_buttons(&self, batch: &mut HudBatch, ox: f32, oy: f32) {
let atlas = &self.gui.atlas;
for ((x, y), _) in self.sort_buttons() {
let (x, y) = (ox + x, oy + y);
let (cx, cy) = self.cursor;
let hovered = cx >= x && cx < x + SORT_BUTTON && cy >= y && cy < y + SORT_BUTTON;
let face = if hovered {
rgba(220, 220, 220, 255)
} else {
rgba(160, 160, 160, 255)
};
batch.fill(atlas, x, y, SORT_BUTTON, SORT_BUTTON, rgba(55, 55, 55, 255));
batch.fill(
atlas,
x + 1.0,
y + 1.0,
SORT_BUTTON - 2.0,
SORT_BUTTON - 2.0,
face,
);
for (i, w) in [5.0, 4.0, 3.0].into_iter().enumerate() {
let by = y + 2.0 + 2.0 * i as f32;
batch.fill(atlas, x + 2.0, by, w, 1.0, rgba(55, 55, 55, 255));
}
}
}
/// A left press on a sort button sorts its slots (true).
pub(in crate::game) fn sort_press(&mut self) -> bool {
let Some(r) = self.renderer.as_ref() else {
return false;
};
let batch = HudBatch::new([r.config.width, r.config.height]);
let (ox, oy) = self.inventory_origin(&batch);
let (cx, cy) = self.cursor;
let hit = self.sort_buttons().into_iter().find(|((x, y), _)| {
let (x, y) = (ox + x, oy + y);
cx >= x && cx < x + SORT_BUTTON && cy >= y && cy < y + SORT_BUTTON
});
let Some((_, slots)) = hit else {
return false;
};
self.sort_slots(&slots);
true
}
/// The sort key: sorts the part under the cursor (the container when
/// the cursor is over no slot).
pub(in crate::game) fn sort_key_press(&mut self, key: KeyCode) -> bool {
keybinds::key_name(key).is_some_and(|name| self.sort_input(name))
}
pub(in crate::game) fn sort_mouse_press(&mut self, button: MouseButton) -> bool {
self.sort_input(&keybinds::mouse_name(button))
}
fn sort_input(&mut self, name: &str) -> bool {
let e = &self.settings.extras;
if !e.sort || e.sort_key != name || self.recipe_search_focused() {
return false;
}
let buttons = self.sort_buttons();
let hovered = self.inventory_target().0;
let slots = buttons
.iter()
.find(|(_, slots)| hovered.is_some_and(|h| slots.contains(&h)))
.or(buttons.first())
.map(|(_, slots)| slots.clone());
let Some(slots) = slots else {
return false;
};
self.sort_slots(&slots);
true
}
/// Sorts `slots` with ordinary left clicks (needs an empty hand).
fn sort_slots(&mut self, slots: &[usize]) {
if self.carried.is_some() {
return;
}
let views: Vec<Option<ItemStack>> = slots.iter().map(|&s| self.slot_view(s)).collect();
let clicks = {
let rank = |s: &ItemStack| {
let order = self.creative_screen.order_of(s).unwrap_or(usize::MAX);
(order, hover_name(&self.pack.names, s))
};
let max = |s: &ItemStack| self.max_stack(s);
inv_sort::plan(&views, &rank, &max)
};
for i in clicks {
self.tweak_click(slots[i], 0, Input::Pickup);
}
}
}
@@ -238,7 +238,7 @@ impl Game<'_> {
let off_hand_empty = items[1].is_none();
// Combat feel Legacy: a sword is held up while use is held, as 1.8.9's block looked.
// Only the picture changes: the clicks and what they send stay 26.3's.
let legacy_block = self.legacy_pose()
let legacy_block = self.settings.extras.pvp.combat.legacy
&& self.right
&& self.screen == Screen::Playing
&& items[0]
@@ -426,7 +426,7 @@ impl Game<'_> {
let mut m = to_world
* horizon_client::first_person::item_pose(left, inverse_height, attack, pose);
// PvP & HUD's shield size (100% is vanilla's), toward the hand holding it.
let shield_size = self.shield_size();
let shield_size = self.settings.extras.pvp.tweaks.shield_size;
if shield_size != 100 && &*stack.item == "minecraft:shield" {
m *= glam::Mat4::from_scale(glam::Vec3::splat(f32::from(shield_size) / 100.0));
}
@@ -449,7 +449,7 @@ impl Game<'_> {
};
let state = self.resolve_item_in_hand(&stack, context, Some(&owner), seed, cit_hand);
// Combat feel Legacy: 1.8.9's pose for a flat item drawn with vanilla's display.
if self.legacy_pose()
if self.settings.extras.pvp.combat.legacy
&& !state.layers.is_empty()
&& state
.layers
+1 -1
View File
@@ -568,7 +568,7 @@ impl Game<'_> {
self.hands_busy(),
self.hand_strength.swap_scale(1.0, speed),
changed,
self.legacy_pose(),
self.settings.extras.pvp.combat.legacy,
);
for h in 0..2 {
if replace[h] {
+2 -2
View File
@@ -477,7 +477,7 @@ impl Game<'_> {
.store(settings.entity_distance.to_bits(), Ordering::Relaxed);
flags
.legacy_rules
.store(self.pvp_legacy_rules(), Ordering::Relaxed);
.store(settings.extras.pvp.combat.rules, Ordering::Relaxed);
let (ready_tx, ready) = channel();
let (tasks, server_tasks) = channel();
let server_flags = flags.clone();
@@ -1095,7 +1095,7 @@ impl Game<'_> {
// IntegratedServer.getScaledTrackingDistance reads the option each time.
self.settings.entity_distance =
f32::from_bits(flags.entity_distance.load(Ordering::Relaxed));
self.set_pvp_legacy_rules(flags.legacy_rules.load(Ordering::Relaxed));
self.settings.extras.pvp.combat.rules = flags.legacy_rules.load(Ordering::Relaxed);
let Some(host) = self.lan.as_mut() else {
return;
};
@@ -2,9 +2,6 @@
//! drags, throws and the press/release of a slot click.
use super::*;
/// What crates above the engine get of these screens (`horizon_hooks::containers`).
mod containers;
/// Q throws the whole stack with Control down: the same modifier word the
/// screens read (`has_control_down`), never a latched sprint toggle.
pub(super) fn drop_whole_stack(modifiers: winit::keyboard::ModifiersState) -> bool {
@@ -164,7 +161,7 @@ impl Game<'_> {
}
return;
}
if self.tweak_press(slot, right, shift) || self.container_press(right) {
if self.tweak_press(slot, right, shift) {
return;
}
if let Some(slot) = slot
@@ -266,9 +263,7 @@ impl Game<'_> {
}
/// Mouse movement while a quick craft drag is running.
pub(super) fn inventory_drag_move(&mut self) {
if self.tweaks_on() && self.tweak_drag_move(self.inventory_target().0)
|| self.container_drag()
{
if self.tweaks_on() && self.tweak_drag_move(self.inventory_target().0) {
return;
}
let Some((ty, slots)) = self.inv_drag.clone() else {
@@ -431,7 +426,6 @@ impl Game<'_> {
pub(super) fn inventory_mouse_release(&mut self, right: bool) {
use horizon_client::container_screen::{Release, Target};
self.tweak_release();
self.container_release();
let (slot, outside) = self.inventory_target();
let dragged = self.inv_drag.as_ref().is_some_and(|(_, s)| !s.is_empty());
let release = self.inv_clicks.release(
@@ -1,303 +0,0 @@
//! The engine's side of the container screen API (`horizon_hooks::containers`, docs/layers.md,
//! "Container screens"): the open menu screen as a [`Container`] for the length of one hook call,
//! and the input and drawing that reach the hooks. With no hook registered nothing here is built.
use crate::game::{Game, NativeSlotRole, Screen};
use horizon_client::{
extras_hud, hud::HudBatch, items::ItemStack, keybinds, menu::Input, stations::hover_name,
};
use horizon_hooks::{
containers::{Click, Container, Role, Slot, SlotId, Stack},
engine,
};
use winit::{event::MouseButton, keyboard::KeyCode};
/// The open screen's menu, over the game for one call. `origin`: the panel's top left corner in
/// GUI pixels.
struct Open<'g, 'a> {
game: &'g mut Game<'a>,
origin: (f32, f32),
}
/// The same, read only: what drawing gets.
struct View<'g, 'a> {
game: &'g Game<'a>,
origin: (f32, f32),
}
fn role(role: NativeSlotRole) -> Role {
match role {
NativeSlotRole::Hotbar => Role::Hotbar,
NativeSlotRole::Inventory => Role::Inventory,
NativeSlotRole::Storage => Role::Storage,
NativeSlotRole::CraftingGrid | NativeSlotRole::Input => Role::Input,
NativeSlotRole::Result => Role::Result,
NativeSlotRole::Equipment => Role::Equipment,
}
}
/// Stacks that merge (`same_item_same_components`) share a kind.
fn kind(stack: &ItemStack) -> u64 {
use std::hash::{Hash, Hasher};
let mut h = std::collections::hash_map::DefaultHasher::new();
stack.item.hash(&mut h);
stack.damage.hash(&mut h);
format!("{:?}", stack.extra).hash(&mut h);
h.finish()
}
fn slot_id(slot: usize) -> SlotId {
SlotId(u32::try_from(slot).unwrap_or(u32::MAX))
}
fn game_slot(slot: SlotId) -> usize {
slot.0 as usize
}
impl<'a> Game<'a> {
/// The open menu's type: the server's, else the menu this window opened itself.
fn container_menu(&self) -> &str {
if let Some(menu) = self.remote_menu_type() {
return menu;
}
use horizon_client::containers::BlockEntity;
if self.mount_active() {
return "horizon:mount";
}
if self.station.is_some() {
return "horizon:station";
}
match self.container_kind() {
None if self.craft_size == 3 => "minecraft:crafting",
None => "inventory",
Some(BlockEntity::Chest(_)) => "minecraft:generic_9x3",
Some(BlockEntity::Furnace(_)) => "minecraft:furnace",
Some(_) => "horizon:block",
}
}
fn container_slots(&self) -> Vec<Slot> {
self.native_slot_roles()
.into_iter()
.map(|(s, r)| {
let (x, y) = self.slot_position(s);
Slot {
id: slot_id(s),
role: role(r),
x,
y,
}
})
.collect()
}
fn container_stack(&self, stack: &ItemStack) -> Stack {
Stack {
item: stack.item.to_string(),
count: stack.count,
max: self.max_stack(stack),
kind: kind(stack),
}
}
/// The stack of `kind` the screen shows, on the cursor or in a slot.
fn container_find(&mut self, of: &Stack) -> Option<ItemStack> {
if let Some(c) = self.carried.as_ref().filter(|c| kind(c) == of.kind) {
return Some(c.clone());
}
self.slots()
.into_iter()
.filter_map(|s| self.slot_view(s))
.find(|s| kind(s) == of.kind)
}
/// The panel's top left corner, None without a window.
fn container_origin(&self) -> Option<(f32, f32)> {
let r = self.renderer.as_ref()?;
Some(self.inventory_origin(&HudBatch::new([r.config.width, r.config.height])))
}
/// The open screen for the hooks' input, None when no hook may take it.
fn container_open(&mut self) -> Option<Open<'_, 'a>> {
if self.screen != Screen::Inventory || !engine::containers() {
return None;
}
let origin = self.container_origin()?;
Some(Open { game: self, origin })
}
fn hovered(&self) -> Option<SlotId> {
self.inventory_target().0.map(slot_id)
}
/// A press over the panel: true when a hook took it, and vanilla's drag doesn't start.
pub(in crate::game) fn container_press(&mut self, right: bool) -> bool {
let slot = self.hovered();
let taken = self
.container_open()
.is_some_and(|mut c| engine::container_press(&mut c, slot, right));
if taken {
self.inv_drag = None;
self.inv_clicks.skip_release();
}
taken
}
/// The cursor moved with a button held: true when a hook owns the drag, which ends vanilla's.
pub(in crate::game) fn container_drag(&mut self) -> bool {
let slot = self.hovered();
let taken = self
.container_open()
.is_some_and(|mut c| engine::container_drag(&mut c, slot));
if taken {
self.inv_drag = None;
self.inv_clicks.skip_release();
}
taken
}
pub(in crate::game) fn container_release(&mut self) {
if let Some(mut c) = self.container_open() {
engine::container_release(&mut c);
}
}
/// The wheel over an inventory screen: true when the Mouse Tweaks or a hook used it.
pub(in crate::game) fn container_scroll(&mut self, steps: f32) -> bool {
let slot = self.inventory_target().0;
if self.tweak_scroll(steps, slot) {
return true;
}
let slot = slot.map(slot_id);
self.container_open()
.is_some_and(|mut c| engine::container_wheel(&mut c, slot, steps))
}
/// A key the screen got: sorting's key, else a hook's.
pub(in crate::game) fn container_key_press(&mut self, key: KeyCode) -> bool {
self.sort_key_press(key)
|| keybinds::key_name(key).is_some_and(|name| self.container_key(name))
}
/// A mouse button the screen got, as a key.
pub(in crate::game) fn container_mouse_press(&mut self, button: MouseButton) -> bool {
self.sort_mouse_press(button) || self.container_key(&keybinds::mouse_name(button))
}
fn container_key(&mut self, name: &str) -> bool {
if self.recipe_search_focused() {
return false;
}
let slot = self.hovered();
self.container_open()
.is_some_and(|mut c| engine::container_key(&mut c, slot, name))
}
/// Draws the sort buttons and the hooks' drawing over the panel at (`ox`, `oy`).
pub(in crate::game) fn draw_container_extras(&self, batch: &mut HudBatch, ox: f32, oy: f32) {
self.draw_sort_buttons(batch, ox, oy);
if !engine::containers() {
return;
}
let view = View {
game: self,
origin: (ox, oy),
};
extras_hud::draw_on_panel(batch, &self.gui.atlas, (ox, oy), |canvas| {
engine::container_draw(&view, canvas);
});
}
}
/// What both readings answer alike.
macro_rules! reads {
() => {
fn menu(&self) -> &str {
self.game.container_menu()
}
fn slots(&self) -> Vec<Slot> {
self.game.container_slots()
}
fn carried(&self) -> Option<Stack> {
let c = self.game.carried.as_ref()?;
Some(self.game.container_stack(c))
}
fn spread(&self) -> usize {
self.game.inv_drag.as_ref().map_or(0, |(_, s)| s.len())
}
fn cursor(&self) -> (f32, f32) {
let (x, y) = self.game.cursor;
(x - self.origin.0, y - self.origin.1)
}
fn shift(&self) -> bool {
self.game.has_shift_down()
}
};
}
impl Container for Open<'_, '_> {
reads!();
fn stack(&mut self, slot: SlotId) -> Option<Stack> {
let s = self.game.slot_view(game_slot(slot))?;
Some(self.game.container_stack(&s))
}
fn takes_one(&mut self, slot: SlotId, of: &Stack) -> bool {
self.game
.container_find(of)
.is_some_and(|s| self.game.tweak_takes_one(game_slot(slot), &s))
}
fn landing(&mut self, slot: SlotId) -> Vec<SlotId> {
let landing = self.game.tweak_landing(game_slot(slot));
landing.into_iter().map(slot_id).collect()
}
fn click(&mut self, slot: SlotId, click: Click) -> bool {
let slot = game_slot(slot);
match click {
Click::Pick { right } => self.game.tweak_click(slot, i32::from(right), Input::Pickup),
Click::QuickMove => self.game.tweak_click(slot, 0, Input::QuickMove),
Click::Put { one } => self.game.tweak_put(slot, one),
_ => false,
}
}
fn order(&mut self, slot: SlotId) -> (u32, String) {
let Some(s) = self.game.slot_view(game_slot(slot)) else {
return (u32::MAX, String::new());
};
let order = self.game.creative_screen.order_of(&s);
let order = order.map_or(u32::MAX, |o| u32::try_from(o).unwrap_or(u32::MAX));
(order, hover_name(&self.game.pack.names, &s))
}
}
/// Drawing gets `&dyn Container`, which can't call the `&mut self` reads.
impl Container for View<'_, '_> {
reads!();
fn stack(&mut self, _: SlotId) -> Option<Stack> {
None
}
fn takes_one(&mut self, _: SlotId, _: &Stack) -> bool {
false
}
fn landing(&mut self, _: SlotId) -> Vec<SlotId> {
Vec::new()
}
fn click(&mut self, _: SlotId, _: Click) -> bool {
false
}
fn order(&mut self, _: SlotId) -> (u32, String) {
(u32::MAX, String::new())
}
}
#[cfg(test)]
mod tests {
use super::*;
use horizon_client::items::ItemStack;
fn stack(item: &str, count: u32, damage: u32) -> ItemStack {
ItemStack {
item: item.into(),
count,
damage,
extra: None,
}
}
#[test]
fn stacks_that_merge_share_a_kind() {
let a = kind(&stack("minecraft:stone", 1, 0));
assert_eq!(
a,
kind(&stack("minecraft:stone", 64, 0)),
"the count is no part"
);
assert_ne!(a, kind(&stack("minecraft:dirt", 1, 0)));
let worn = stack("minecraft:iron_sword", 1, 3);
assert_ne!(kind(&worn), kind(&stack("minecraft:iron_sword", 1, 0)));
}
}
@@ -571,7 +571,7 @@ impl Game<'_> {
return;
};
// Combat feel Legacy draws a hit as 1.8.9 did: no sweep, no damage hearts.
if self.particle_hidden(options.ty) {
if self.settings.extras.pvp.combat.hides_particle(options.ty) {
return;
}
let (d, s) = (p.dist.map(f64::from), p.max_speed.map(f64::from));
@@ -152,6 +152,14 @@ impl WorldOptions {
}
self.port.value.parse::<u16>().ok().filter(|p| *p >= 1024)
}
/// `portEdit.setTooltip(PORT_INFO_TEXT)`: the port box explains itself while
/// the cursor is over it. The box is drawn even with LAN off, so the tooltip
/// shows there too; a remote world has no such box.
fn port_tooltip(port: &EditBox, cursor: (f32, f32), remote: bool) -> Option<Text> {
(!remote && ui::hovered(cursor, port.x, port.y, port.width, port.height))
.then(|| Text::translate("lanServer.port", &[]))
}
fn snapshot(&self) -> Snapshot {
Snapshot {
default_mode: self.default_mode,
@@ -1003,6 +1011,10 @@ impl Game<'_> {
hover,
);
}
// The widgets are drawn; the port box's tooltip goes over them.
if let Some(tip) = WorldOptions::port_tooltip(&o.port, cursor, o.remote) {
ui::tooltip(batch, atlas, &[tip], cursor);
}
}
/// The mouse wheel over the screen (ScrollableLayout, 10 pixels a step).
@@ -1899,4 +1911,15 @@ mod tests {
assert!(o.locked);
assert!(!o.active(Button::Difficulty));
}
/// `portEdit.setTooltip(PORT_INFO_TEXT)`: hovering the port box shows its
/// label; a remote world (`o.remote`) has no port box to hover.
#[test]
fn the_port_box_explains_itself_on_hover() {
let port = EditBox::new(158.0, 100.0, 40.0, 20.0);
assert!(WorldOptions::port_tooltip(&port, (159.0, 101.0), false).is_some());
assert!(WorldOptions::port_tooltip(&port, (157.0, 101.0), false).is_none());
assert!(WorldOptions::port_tooltip(&port, (159.0, 121.0), false).is_none());
assert!(WorldOptions::port_tooltip(&port, (159.0, 101.0), true).is_none());
}
}
@@ -8,7 +8,7 @@ impl Game<'_> {
/// Whether the hits this game resolves follow 1.8.9: legacy rules are on and this game
/// is the server (its own server thread, a LAN host, a dedicated server).
pub(super) fn legacy_rules(&self) -> bool {
self.remote.is_none() && self.pvp_legacy_rules()
self.remote.is_none() && self.settings.extras.pvp.combat.rules
}
/// `melee_damage` under legacy rules: (damage, attack strength, critical, enchanted).
@@ -98,9 +98,8 @@ impl Loading {
.map_or([1280, 720], |o| o.window_size)
.map(f64::from);
self.fullscreen = horizon_client::window::FullscreenState::new(fullscreen);
let attributes = horizon_client::window::with_horizon_icon(
Window::default_attributes().with_title("Horizon"),
);
let attributes =
horizon_client::window::with_icon(Window::default_attributes().with_title("Horizon"));
// Wayland names the window by its app id, which the horizon.desktop entry matches.
#[cfg(target_os = "linux")]
let attributes = winit::platform::wayland::WindowAttributesExtWayland::with_name(
@@ -130,22 +129,25 @@ impl Loading {
// redraw the same design when they start. GDI is unsafe:
// horizon-threads holds it (this crate forbids unsafe code).
#[cfg(target_os = "windows")]
let boot_paint_stop = hwnd(&window).map(|hwnd| {
let stop = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false));
let repaint_stop = stop.clone();
std::thread::spawn(move || {
while !repaint_stop.load(std::sync::atomic::Ordering::Relaxed) {
horizon_threads::boot_paint::paint(
hwnd,
horizon_theme::fonts::GEIST_SEMIBOLD,
horizon_theme::fonts::GEIST_MONO,
&horizon_client::logging::recent_lines(),
);
std::thread::sleep(std::time::Duration::from_millis(300));
}
// In the fonts given to `menu_style`; with none, the window stays as it is.
let boot_paint_stop = hwnd(&window)
.zip(horizon_client::menu_style::get().fonts)
.map(|(hwnd, fonts)| {
let stop = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false));
let repaint_stop = stop.clone();
std::thread::spawn(move || {
while !repaint_stop.load(std::sync::atomic::Ordering::Relaxed) {
horizon_threads::boot_paint::paint(
hwnd,
fonts.semibold,
fonts.mono,
&horizon_client::logging::recent_lines(),
);
std::thread::sleep(std::time::Duration::from_millis(300));
}
});
stop
});
stop
});
let offscreen = self.options.as_ref().is_some_and(|o| o.offscreen);
let boot_step = startup::step("renderer.boot");
match loading_renderer(
@@ -91,7 +91,7 @@ pub(super) fn build_renderer(
.prepare_glint_atlas(renderer.device.limits().max_texture_dimension_2d, filtering)?;
renderer.set_lines_shader(atlas.lines_shader.as_deref());
renderer.set_sky_shaders(atlas.sky_shader.as_deref(), atlas.stars_shader.as_deref());
renderer.set_custom_sky(&pack.custom_sky);
renderer.set_custom_sky(&*pack.custom_sky);
renderer.set_shared_material_maps(&atlas.material_maps);
let cit_glints = pack.item_renderer.cit.as_ref().map(|c| &c.glint_textures);
renderer
@@ -200,7 +200,7 @@ pub(super) fn loading_frame(hud: &HudBatch) -> Frame<'_> {
outline: Vec::new(),
outline_after_terrain: false,
outline_high_contrast: false,
look: Default::default(),
pvp: Default::default(),
debug_lines: &[],
debug_lines_on_top: &[],
debug_crosshair: &[],
-91
View File
@@ -1,91 +0,0 @@
//! What a crate above may change in how the game draws (`horizon_hooks::look`): the engine reads
//! it here only, and each reading is vanilla's while nothing set a look. PvP & HUD sets it today
//! (`game/extras/pvp.rs`).
use super::{Dim, Game};
use horizon_client::{chat::ChatQol, particles::ParticleType};
use horizon_hooks::look::{Chat, Frame, engine::get as look};
impl Game<'_> {
/// 1.8.9's pictures of a fight: the sword block, the flat item pose and the hand swing.
pub(super) fn legacy_pose(&self) -> bool {
look().legacy_pose
}
pub(super) fn attack_indicator_hidden(&self) -> bool {
look().hide_attack_indicator
}
/// A sound a server sent that this player doesn't hear (`Look::mutes_sound`).
pub(super) fn sound_muted(&self, name: &str) -> bool {
look().mutes_sound(name)
}
/// A particle a server sent that this player doesn't see: with the attack particles hidden,
/// the sweep and the damage hearts, which 1.9 added to a hit (1.8.9's show crits only).
pub(super) fn particle_hidden(&self, ty: ParticleType) -> bool {
let attack = matches!(
ty,
ParticleType::SweepAttack | ParticleType::DamageIndicator
);
attack && look().hide_attack_particles
}
/// The block outline, hurt overlay and glint colours the renderer draws with.
pub(super) fn look_frame(&self) -> Frame {
look().frame
}
/// The enchantment glint's colour texel, 0 for vanilla's.
pub(super) fn glint_texel(&self) -> u32 {
look().frame.glint
}
/// The held shield's size in percent (100 is vanilla's).
pub(super) fn shield_size(&self) -> u8 {
look().shield_size
}
/// The totem animation's scale (1 is vanilla's).
pub(super) fn totem_scale(&self) -> f32 {
look().totem_scale
}
/// How far the first-person fire overlay is lowered, as a fraction (0 is vanilla's).
pub(super) fn fire_lower(&self) -> f32 {
look().fire_lower
}
/// Lava's fog as with fire resistance, and water's as fully adapted.
pub(super) fn clear_fluids(&self) -> (bool, bool) {
let look = look();
(look.clear_lava, look.clear_water)
}
/// The chat's extras: Ctrl+F search, copy on click, timestamps, repeats counted.
pub(super) fn chat_look(&self) -> Chat {
look().chat
}
/// What the chat lays its lines out with.
pub(super) fn chat_qol(&self) -> ChatQol {
let chat = look().chat;
ChatQol {
timestamps: chat.timestamps,
compact: chat.compact,
}
}
/// Whether vanilla's status effect icons are left out (`Hud.extractEffects`).
pub(super) fn effect_icons_hidden(&self) -> bool {
look().hide_effect_icons
}
/// Whether this window may draw its own time and weather: only a client of a server (single
/// player's included) draws them, never the server that ticks the world.
fn own_view(&self) -> bool {
self.remote.is_some() && self.dim == Dim::Overworld
}
/// The day time the frame draws (sky, sun, moon, the sky's light): the look's time of day,
/// else the world's. The world's own `day_time` and everything that plays by it stay.
pub(super) fn view_day_time(&self) -> i64 {
if !self.own_view() {
return self.world.day_time;
}
look().day_time(self.world.day_time)
}
/// The rain and thunder levels the frame draws (rain, the sky's colour and light, fog): the
/// look's weather, else the world's. Gameplay (riptide, mobs, crops) keeps the world's.
pub(super) fn view_weather(&self, partial: f32) -> (f32, f32) {
match look().weather {
Some(levels) if self.own_view() && self.can_have_weather() => levels,
_ => self.weather_levels(partial),
}
}
}
@@ -25,6 +25,7 @@ impl Game<'_> {
facts: self.res.facts(),
collision: self.collision,
player: None,
cached_chunk: std::cell::Cell::new(None),
};
self.particles.tick(&level);
}
@@ -93,10 +93,9 @@ impl ApplicationHandler for Missing {
if self.window.is_some() {
return;
}
let attributes = horizon_client::window::with_horizon_icon(
Window::default_attributes().with_title("Horizon"),
)
.with_inner_size(winit::dpi::LogicalSize::new(1040.0, 700.0));
let attributes =
horizon_client::window::with_icon(Window::default_attributes().with_title("Horizon"))
.with_inner_size(winit::dpi::LogicalSize::new(1040.0, 700.0));
let made = event_loop
.create_window(attributes)
.map_err(Into::into)
+1 -1
View File
@@ -299,7 +299,7 @@ impl Game<'_> {
/// address isn't, even on this computer or the local network: an address can be a proxy, a
/// tunnel or a friend's relay as well.
fn direct_craft_free_server(&self) -> bool {
self.last_server.lan()
self.server_is_lan()
|| self
.remote
.as_ref()
+1 -1
View File
@@ -62,7 +62,7 @@ impl Game<'_> {
}
return Ok(());
}
if self.container_key_press(key)
if self.sort_key_press(key)
|| horizon_client::keybinds::key_name(key)
.is_some_and(|name| self.hud_editor_key_press(name))
{
@@ -143,6 +143,7 @@ impl Game<'_> {
facts: self.res.facts(),
collision: self.collision,
player: None,
cached_chunk: std::cell::Cell::new(None),
};
find_noxious_gas_source_block(&level, [pos.0, pos.1, pos.2])
}
@@ -198,6 +199,7 @@ impl Game<'_> {
facts: self.res.facts(),
collision: self.collision,
player: None,
cached_chunk: std::cell::Cell::new(None),
};
// The gas reads blocks only, so the mobs it reaches can be found
// before any gets its effect.
@@ -264,6 +266,7 @@ impl Game<'_> {
facts: self.res.facts(),
collision: self.collision,
player: None,
cached_chunk: std::cell::Cell::new(None),
};
can_be_reached_by_noxious_gas(&level, source, eye)
};
@@ -356,6 +359,7 @@ impl Game<'_> {
facts: self.res.facts(),
collision: self.collision,
player: None,
cached_chunk: std::cell::Cell::new(None),
};
let height = unobstructed_block_count(&level, [pos.0, pos.1 + 1, pos.2], water_blocks);
let area = launch_area(pos, height);
@@ -419,6 +423,7 @@ impl Game<'_> {
facts: self.res.facts(),
collision: self.collision,
player: None,
cached_chunk: std::cell::Cell::new(None),
};
unobstructed_block_count(&level, [pos.0, pos.1 + 1, pos.2], water_blocks)
};
@@ -1,6 +1,6 @@
//! The PvP & HUD page's options in the game (`horizon_client::pvp`, `docs/pvp.md`). Each
//! does nothing while it is off.
use crate::game::Game;
use super::{Dim, Game};
use horizon_client::{
extras::Corner,
extras_hud::Corners,
@@ -14,7 +14,7 @@ use std::sync::Arc;
use winit::event::MouseButton;
#[derive(Default)]
pub(in crate::game) struct PvpState {
pub(super) struct PvpState {
crosshair: CrosshairCache,
/// Each armour slot's item, damage and maximum damage, and whether durability is in percent.
armour: Widget<([Option<(Arc<str>, u32, u32)>; 4], bool)>,
@@ -41,13 +41,13 @@ pub(in crate::game) struct PvpState {
speed: pvp_hud::Speed,
speed_shown: Widget<u32>,
/// Each widget drawn last frame and its box on screen, for the HUD editor.
pub(in crate::game) boxes: Vec<(pvp_hud::WidgetId, [f32; 4])>,
pub(super) boxes: Vec<(pvp_hud::WidgetId, [f32; 4])>,
/// The HUD editor, while it is open.
pub(in crate::game) editor: Option<crate::game::hud_editor::HudEditor>,
pub(super) editor: Option<super::hud_editor::HudEditor>,
/// The open chat's box holds a chat search's query (Ctrl+F).
pub(in crate::game) chat_search: bool,
pub(super) chat_search: bool,
/// What the chat box held when the search borrowed it.
pub(in crate::game) chat_search_aside: String,
pub(super) chat_search_aside: String,
}
/// `GuiGraphicsExtractor`'s grey for a detail line (an effect's time left).
const DETAIL: u32 = 0xFF7F_7F7F;
@@ -78,13 +78,13 @@ const ARROWS: [&str; 3] = [
impl Game<'_> {
/// Whether the custom crosshair replaces vanilla's sprite.
pub(in crate::game) fn custom_crosshair(&self) -> bool {
pub(super) fn custom_crosshair(&self) -> bool {
self.settings.extras.pvp.crosshair.custom
}
/// The custom crosshair, centred where vanilla's 15x15 sprite is (`Hud.extractCrosshair`):
/// its outline in black, then the shape, inverting what is under it when its colour is
/// `Inverted`. The runs are cached, so a frame only adds their quads.
pub(in crate::game) fn draw_custom_crosshair(&mut self, batch: &mut HudBatch, w: i32, h: i32) {
pub(super) fn draw_custom_crosshair(&mut self, batch: &mut HudBatch, w: i32, h: i32) {
let mut c = self.settings.extras.pvp.crosshair;
if let Some(hover) = c.hover
&& self.crosshair_living() == Some(true)
@@ -114,7 +114,7 @@ impl Game<'_> {
/// the outline's edges come from) as translucent faces drawn after translucent terrain,
/// pushed out a little so they don't fight the block's own faces. Only while the outline
/// shows (`outline` is its edges).
pub(in crate::game) fn outline_fill(
pub(super) fn outline_fill(
&self,
outline: &[[f64; 6]],
camera: glam::DVec3,
@@ -176,33 +176,42 @@ impl Game<'_> {
}
}
}
/// Once per frame: the options that live outside the game's state, and the look the engine
/// draws with (`game/look.rs`), the only way it reads them.
pub(in crate::game) fn pvp_frame(&mut self) {
/// Once per frame: the options that live outside the game's state.
pub(super) fn pvp_frame(&mut self) {
let tweaks = self.settings.extras.pvp.tweaks;
horizon_client::enchant::set_numeric_levels(tweaks.numeric_enchants);
horizon_hooks::look::update(|look| self.settings.extras.pvp.apply(look));
}
/// Legacy rules: the hits a server resolves follow 1.8.9 (`legacy_rules.rs`).
pub(in crate::game) fn pvp_legacy_rules(&self) -> bool {
self.settings.extras.pvp.combat.rules
/// Whether this window may draw PvP & HUD's own time and weather: only a client of a server
/// (single player's included) draws them, never the server that ticks the world.
fn own_view(&self) -> bool {
self.remote.is_some() && self.dim == Dim::Overworld
}
/// The server's legacy rules, from server.toml or the window's options.
pub(in crate::game) fn set_pvp_legacy_rules(&mut self, on: bool) {
self.settings.extras.pvp.combat.rules = on;
/// The day time the frame draws (sky, sun, moon, the sky's light): PvP & HUD's time of day,
/// else the world's. The world's own `day_time` and everything that plays by it stay.
pub(super) fn view_day_time(&self) -> i64 {
let fixed = self.settings.extras.pvp.tweaks.time;
horizon_client::pvp::view_day_time(self.world.day_time, fixed.filter(|_| self.own_view()))
}
/// The rain and thunder levels the frame draws (rain, the sky's colour and light, fog): PvP &
/// HUD's weather, else the world's. Gameplay (riptide, mobs, crops) keeps the world's.
pub(super) fn view_weather(&self, partial: f32) -> (f32, f32) {
match self.settings.extras.pvp.tweaks.weather {
Some(w) if self.own_view() && self.can_have_weather() => w.levels(),
_ => self.weather_levels(partial),
}
}
/// This player attacked entity `id`, `distance` from the eyes to where the crosshair's
/// ray met its box (the combo counter waits for the server's report; the reach widget
/// shows the distance).
pub(in crate::game) fn pvp_attacked(&mut self, id: i32, distance: f64) {
pub(super) fn pvp_attacked(&mut self, id: i32, distance: f64) {
let now = self.started.elapsed().as_secs_f64();
self.pvp.combo.attacked(id, now);
self.pvp.reach.hit(distance, now);
}
/// A mouse press, counted for clicks per second while playing with a widget that shows them.
pub(in crate::game) fn pvp_click(&mut self, button: MouseButton) {
pub(super) fn pvp_click(&mut self, button: MouseButton) {
let hud = &self.settings.extras.pvp.hud;
if !(hud.cps || hud.keys) || self.screen != crate::game::Screen::Playing {
if !(hud.cps || hud.keys) || self.screen != super::Screen::Playing {
return;
}
let i = match button {
@@ -223,7 +232,7 @@ impl Game<'_> {
}
/// The server reports entity `id` hurt; `own` when it is this player,
/// `by_me` whether this player was the damage's direct entity (if named).
pub(in crate::game) fn pvp_hurt(&mut self, id: i32, own: bool, by_me: Option<bool>) {
pub(super) fn pvp_hurt(&mut self, id: i32, own: bool, by_me: Option<bool>) {
if own {
self.pvp.combo.broken();
} else {
@@ -231,9 +240,13 @@ impl Game<'_> {
self.pvp.combo.hurt(id, by_me, now);
}
}
/// Whether the effects widget replaces vanilla's effect icons (`Hud.extractEffects`).
pub(super) fn pvp_effects_shown(&self) -> bool {
self.settings.extras.pvp.hud.effects
}
/// The HUD widgets that are on, after the minimap and before mods' boxes. Each rebuilds its
/// lines only when what it shows changed.
pub(in crate::game) fn draw_pvp_hud(&mut self, batch: &mut HudBatch, corners: &mut Corners) {
pub(super) fn draw_pvp_hud(&mut self, batch: &mut HudBatch, corners: &mut Corners) {
let hud = self.settings.extras.pvp.hud;
if self.player.is_none() || !hud.any() {
// The editor frames only what was drawn.
@@ -254,8 +267,8 @@ impl Game<'_> {
let at = [p.position.x, p.position.y, p.position.z].map(|v| v.floor() as i32);
(at, pvp_hud::facing(self.yaw))
});
// The address is copied only when it changed.
let server = self.last_server.address().filter(|_| multiplayer);
// The address is copied only when it changed (`server_address` borrows the whole game).
let server = self.server_address().filter(|_| multiplayer);
let server = (hud.server && self.pvp.server.next.as_deref() != server)
.then(|| server.map(str::to_owned));
let now = self.started.elapsed().as_secs_f64();
@@ -678,7 +691,7 @@ impl Game<'_> {
false => s.item == held.item,
};
let slots = self.inventory.iter().map(Option::as_ref).enumerate();
let offhand = std::iter::once((crate::game::shield::OFFHAND_SLOT, self.offhand.as_ref()));
let offhand = std::iter::once((super::shield::OFFHAND_SLOT, self.offhand.as_ref()));
let mut found = slots
.chain(offhand)
.filter_map(|(i, s)| Some((i, s?)))
+21 -300
View File
@@ -60,6 +60,9 @@ use std::{
};
use winit::{event::MouseButton, keyboard::KeyCode};
#[path = "remote_poll.rs"]
mod poll;
type BuiltinCache = Vec<(std::path::PathBuf, Arc<Builtin>)>;
static BUILTIN: std::sync::Mutex<BuiltinCache> = std::sync::Mutex::new(Vec::new());
@@ -979,7 +982,7 @@ impl Game<'_> {
lan: bool,
realm: bool,
) -> Result<()> {
self.last_server.realm = realm;
self.realm_session = realm;
self.reset_tutorial();
self.perf.world_opening();
// A server's own biomes come over the connection; the last local
@@ -1027,7 +1030,7 @@ impl Game<'_> {
root.clone(),
),
None => {
self.last_server.connecting(address, name, lan);
self.extras_connecting(address, name, lan);
match cookies {
Some(cookies) => Session::connect_transferred(
address,
@@ -1522,7 +1525,7 @@ impl Game<'_> {
self.finish_perf();
if reason.is_none() {
// Leaving on purpose: nothing for auto-reconnect to retry.
self.last_server.left();
self.extras_left();
}
self.clear_waypoints();
self.hud_disconnected();
@@ -2174,288 +2177,6 @@ impl Game<'_> {
pub(super) fn remote_backlog(&self) -> usize {
self.remote.as_ref().map_or(0, |r| r.backlog.len())
}
/// A headless command oracle has a real connection but no movement tick.
/// Acknowledge loaded terrain without predicting or sending movement.
pub(super) fn poll_stationary_client(&mut self) -> Result<()> {
self.poll_remote()?;
let loaded = self.player.as_ref().is_some_and(|p| {
self.streaming.is_lit(
(p.position.x.floor() as i32) >> 4,
(p.position.z.floor() as i32) >> 4,
)
});
if loaded && self.remote.as_ref().is_some_and(|r| !r.loaded_sent) {
self.send(S::PlayerLoaded(Empty));
let r = self.remote.as_mut().unwrap();
r.loaded_sent = true;
r.level_load.finish();
}
Ok(())
}
/// Process what the network thread received (once per frame, like the
/// client's packet processor), in order. Chunk packets get
/// `CHUNK_PACKETS_PER_FRAME`: once a frame has spent it, the events from
/// the next chunk packet on wait for the next frame, unless they have
/// already waited `BACKLOG_LIMIT`. A chunk packet still decoding holds
/// back what follows it.
pub(super) fn poll_remote(&mut self) -> Result<()> {
let polled = self.poll_remote_events();
// The block events and block changes of a piston clock start and end
// thousands of moves at once: the collision view and the renderer
// take them all in one sync, before the tick or frame reads them.
if std::mem::take(&mut self.moving_pistons_stale) {
self.sync_moving_pistons();
}
polled
}
fn poll_remote_events(&mut self) -> Result<()> {
let Some(r) = self.remote.as_mut() else {
return Ok(());
};
let mut events = std::mem::take(&mut r.backlog);
// Chunk packets start decoding now; the frame that reaches one in
// order inserts its column.
for event in r.session.poll() {
events.push_back(match event {
Event::Packet(p) if matches!(*p, C::LevelChunkWithLight(_)) => {
match r.decoder.submit(r.chunk_format.clone(), p) {
None => Received::Chunk(r.chunk_format.clone()),
Some(p) => Received::Event(Event::Packet(p)),
}
}
event => Received::Event(event),
});
}
let overdue = r
.backlog_since
.is_some_and(|since| since.elapsed() >= BACKLOG_LIMIT);
let mut chunks = Duration::ZERO;
// A run of ForgetLevelChunk (thousands when the server's view moves far: a
// teleport at 32 chunks) is forgotten at once, before whatever follows it.
let mut forgotten = horizon_client::fast_hash::FxHashSet::default();
while let Some(item) = events.pop_front() {
if let Received::Event(Event::Packet(packet)) = &item
&& let C::ForgetLevelChunk(p) = &**packet
{
self.native_client_packet(packet);
forgotten.insert((p.pos[0], p.pos[1]));
continue;
}
self.forget_remote_columns(&mut forgotten);
let chunk = matches!(item, Received::Chunk(_));
if chunk && !overdue && chunks >= CHUNK_PACKETS_PER_FRAME {
events.push_front(item);
break;
}
let started = chunk.then(Instant::now);
let event = match item {
Received::Event(event) => event,
Received::Chunk(format) => {
// A column still decoding waits for the next frame, with
// what came after it, overdue or not.
let Some(next) = self.remote.as_ref().map(|r| r.decoder.next()) else {
break;
};
let (packet, column) = match next {
Next::Ready(decoded) => decoded,
Next::Decoding => {
events.push_front(Received::Chunk(format));
break;
}
Next::Lost => {
log::warn!("Horizon: the chunk decoder stopped; a column is missing");
continue;
}
};
self.native_client_packet(&packet);
if let C::LevelChunkWithLight(p) = *packet {
self.remote_chunk(&p, Some((&format, column)));
}
chunks += started.map_or(Duration::ZERO, |t| t.elapsed());
continue;
}
};
match event {
Event::Status(s) => {
if let Some(r) = self.remote.as_mut() {
r.status = s;
}
}
Event::Joined {
registries,
name,
uuid,
} => {
self.set_registries(registries);
self.last_server.joined();
// handleLogin: the level loading screen replaces any dialog.
self.close_dialogs();
if let Some(r) = self.remote.as_mut() {
r.joined = true;
// handleLogin: startWaitingForNewLevel with Reason.OTHER.
r.profile = uuid;
r.profile_name = name.clone();
// LevelLoadingScreen's line: no player name.
r.status = horizon_client::lang::get_or(
"multiplayer.downloadingTerrain",
"Loading terrain...",
);
}
}
Event::Features(features) => {
if let Some(remote) = &mut self.remote {
remote.features = features;
}
}
Event::Brand(brand) => {
if let Some(remote) = &mut self.remote {
remote.brand = brand;
}
}
Event::QuietCommands => {
if let Some(remote) = &mut self.remote {
remote.quiet_commands = true;
}
}
Event::ModNet => {
if let Some(remote) = &mut self.remote {
remote.mod_net = true;
}
}
Event::FarTerrain(offer) => {
if let Some(remote) = &mut self.remote {
remote.far_terrain = Some(offer);
}
}
Event::Reconfiguring => {
// handleConfigurationStart: the held-back chat shows, then
// clearClientLevel opens ServerReconfigScreen over no level.
self.flush_chat_queue();
self.send_chat_acknowledgement();
self.close_dialogs();
// The server configuring us now (a proxy's next one)
// offers its own far terrain, or none, and announces its
// own features again (a Horizon server's quiet commands).
if let Some(remote) = &mut self.remote {
remote.far_terrain = None;
remote.quiet_commands = false;
remote.mod_net = false;
}
self.clear_lod();
if let Some(remote) = &self.remote {
remote.session.reset_far_terrain();
}
self.player = None;
self.screen = Screen::Playing;
self.capture(false);
// clearClientLevel drops the client level now, so its
// unload comes before the configuration screen, not
// after the next login's load.
if let Some(old) = self.remote.as_mut().and_then(|r| r.heard_level.take()) {
self.native_client_level_unload(&old);
}
let ticks = self.ticks;
if let Some(r) = self.remote.as_mut() {
r.reconfiguring = Some(ticks);
r.chat_tree_received = false;
}
}
Event::Reconfigured(registries) => {
// handleConfigurationStart → clearClientLevel, and the next
// ClientPacketListener starts with no players or scoreboard,
// no waypoints (its own ClientWaypointManager: a proxy's next
// server never untracks the last one's), and its new
// ClientSuggestionProvider with no custom chat completions.
// clearClientLevel clears the narrator.
self.clear_narrator();
self.hud_disconnected();
self.score.board = Default::default();
self.clear_waypoints();
if let Some(r) = self.remote.as_mut() {
r.attachments.clear();
r.players.clear();
r.tab_players.clear();
r.listed.clear();
r.signatures = Default::default();
r.custom_completions.clear();
}
self.ui.registries.custom_completions.clear();
self.set_registries(registries);
}
// handleTransfer: "Cannot transfer to server from
// singleplayer" (no ServerData), so the world just closes.
Event::Transfer { host, port } => {
if let Some(r) = self.remote.as_mut()
&& r.session.address != "local"
{
r.transfer = Some(format!("{host}:{port}"));
}
}
Event::PackPush(p) => self.handle_pack_push(p),
Event::PackPop(id) => self.handle_pack_pop(id),
Event::ServerLinks(links) => {
if let Some(r) = self.remote.as_mut() {
r.server_links = super::server_links::trusted(links);
}
}
Event::ShowDialog(dialog) => self.show_dialog(&dialog),
Event::ClearDialog => self.clear_dialog(),
Event::CodeOfConduct(text) => self.handle_code_of_conduct(text),
Event::DecodeError(e) => {
if let Some(r) = self.remote.as_mut() {
r.decode_errors += 1;
if r.decode_errors <= 20 {
log::warn!("Horizon: skipped undecodable packet {e}");
}
}
}
Event::Disconnected {
reason,
key,
from_server,
} => {
log::info!("Horizon: disconnected: {reason}");
// ClientboundTransferPacket left a target: connect there
// instead of showing the disconnect, the stored cookies
// carried over (ClientCommonPacketListenerImpl.
// handleTransfer → ConnectScreen.startConnecting).
let transfer = self.remote.as_mut().and_then(|r| {
r.transfer.take().map(|address| {
(address, r.session.name.clone(), r.session.cookies.clone())
})
});
if let Some((address, name, cookies)) = transfer {
self.leave_server(None)?;
self.connect_server_with_cookies(&address, &name, Some(cookies))?;
return Ok(());
}
let plan = self.last_server.ended(&reason, key.as_deref(), from_server);
self.leave_server(Some(reason))?;
horizon_client::last_server::offer(self.title.as_mut(), plan);
return Ok(());
}
Event::Packet(packet) => self.handle_packet(*packet)?,
}
chunks += started.map_or(Duration::ZERO, |t| t.elapsed());
if self.remote.is_none() {
break;
}
}
if self.remote.is_some() {
self.forget_remote_columns(&mut forgotten);
}
if let Some(r) = self.remote.as_mut() {
r.backlog_since = if events.is_empty() {
None
} else {
Some(r.backlog_since.unwrap_or_else(Instant::now))
};
r.backlog = events;
}
self.send_suggestion_request();
Ok(())
}
/// ClientSuggestionProvider.customSuggestion: the chat's completion
/// request for an `ask_server` node goes to the server.
fn send_suggestion_request(&mut self) {
@@ -3873,7 +3594,7 @@ impl Game<'_> {
C::Explode(p) => self.remote_explode(&p),
C::Sound(p) => {
if let Some(name) = self.remote_sound_name(&p.sound)
&& !self.sound_muted(&name)
&& !self.settings.extras.pvp.combat.mutes_sound(&name)
{
let at = [
f64::from(p.x) / 8.0,
@@ -3895,7 +3616,7 @@ impl Game<'_> {
}
C::SoundEntity(p) => {
if let Some(name) = self.remote_sound_name(&p.sound)
&& !self.sound_muted(&name)
&& !self.settings.extras.pvp.combat.mutes_sound(&name)
&& let Some(state) = self.remote_sound_entity_state(p.id)
{
let source = usize::try_from(p.source)
@@ -6746,7 +6467,7 @@ impl Game<'_> {
// (`KeyboardInput.tick`: 8 directions, full speed). The server only hears those keys
// (`ServerboundPlayerInputPacket`), and an anticheat that predicts from them pulls back
// a player moving at the stick's own angle and speed.
if self.integrated.is_none() && self.last_server.address().is_some() {
if self.integrated.is_none() && self.server_address().is_some() {
keys.analog = None;
}
let [mut strafe, mut forward] = keys.direction();
@@ -7083,24 +6804,24 @@ impl Game<'_> {
let mut push = Vec3::ZERO;
let order = |id: u32| r.ticked_before_player.contains(&id) == before_player;
for m in self.mobs.iter().filter(|m| order(m.id)) {
let listed = r
.level_players
.get(&(m.id as i32))
.and_then(|uuid| r.tab_players.get(uuid));
let spectator = listed.is_some_and(|t| t.game_mode == SPECTATOR);
let mob_team = match listed {
Some(t) => board.players_team(&t.name),
None => teams.of(Who::Mob(m.id)),
};
// Bats never push; a spectator has no physics; passengers of the
// same vehicle never push each other.
// Bats never push, nor passengers of one vehicle, nor a spectator; the
// box test comes before the lookups that far mobs never need (#14053).
if !m.alive()
|| spectator
|| m.species() == horizon_client::mobs::Species::Bat
|| self
.riding
.is_some_and(|v| v == m.id || m.extra.mount == Some(v))
|| !m.bounds().intersects(bounds)
{
continue;
}
let listed = r
.level_players
.get(&(m.id as i32))
.and_then(|uuid| r.tab_players.get(uuid));
let mob_team =
listed.map_or_else(|| teams.of(Who::Mob(m.id)), |t| board.players_team(&t.name));
if listed.is_some_and(|t| t.game_mode == SPECTATOR)
|| !horizon_client::scoreboard::pushable_by(mob_team, player_team, true)
{
continue;
@@ -0,0 +1,288 @@
//! The server connection's received events, once a frame (`poll_remote`): chunk packets
//! within their per-frame budget, then every other packet in order (`handle_packet`).
use super::*;
impl Game<'_> {
/// A headless command oracle has a real connection but no movement tick.
/// Acknowledge loaded terrain without predicting or sending movement.
pub(in crate::game) fn poll_stationary_client(&mut self) -> Result<()> {
self.poll_remote()?;
let loaded = self.player.as_ref().is_some_and(|p| {
self.streaming.is_lit(
(p.position.x.floor() as i32) >> 4,
(p.position.z.floor() as i32) >> 4,
)
});
if loaded && self.remote.as_ref().is_some_and(|r| !r.loaded_sent) {
self.send(S::PlayerLoaded(Empty));
let r = self.remote.as_mut().unwrap();
r.loaded_sent = true;
r.level_load.finish();
}
Ok(())
}
/// Process what the network thread received (once per frame, like the
/// client's packet processor), in order. Chunk packets get
/// `CHUNK_PACKETS_PER_FRAME`: once a frame has spent it, the events from
/// the next chunk packet on wait for the next frame, unless they have
/// already waited `BACKLOG_LIMIT`. A chunk packet still decoding holds
/// back what follows it.
pub(in crate::game) fn poll_remote(&mut self) -> Result<()> {
let polled = self.poll_remote_events();
// The block events and block changes of a piston clock start and end
// thousands of moves at once: the collision view and the renderer
// take them all in one sync, before the tick or frame reads them.
if std::mem::take(&mut self.moving_pistons_stale) {
self.sync_moving_pistons();
}
polled
}
fn poll_remote_events(&mut self) -> Result<()> {
let Some(r) = self.remote.as_mut() else {
return Ok(());
};
let mut events = std::mem::take(&mut r.backlog);
// Chunk packets start decoding now; the frame that reaches one in
// order inserts its column.
for event in r.session.poll() {
events.push_back(match event {
Event::Packet(p) if matches!(*p, C::LevelChunkWithLight(_)) => {
match r.decoder.submit(r.chunk_format.clone(), p) {
None => Received::Chunk(r.chunk_format.clone()),
Some(p) => Received::Event(Event::Packet(p)),
}
}
event => Received::Event(event),
});
}
let overdue = r
.backlog_since
.is_some_and(|since| since.elapsed() >= BACKLOG_LIMIT);
let mut chunks = Duration::ZERO;
// A run of ForgetLevelChunk (thousands when the server's view moves far: a
// teleport at 32 chunks) is forgotten at once, before whatever follows it.
let mut forgotten = horizon_client::fast_hash::FxHashSet::default();
while let Some(item) = events.pop_front() {
if let Received::Event(Event::Packet(packet)) = &item
&& let C::ForgetLevelChunk(p) = &**packet
{
self.native_client_packet(packet);
forgotten.insert((p.pos[0], p.pos[1]));
continue;
}
self.forget_remote_columns(&mut forgotten);
let chunk = matches!(item, Received::Chunk(_));
if chunk && !overdue && chunks >= CHUNK_PACKETS_PER_FRAME {
events.push_front(item);
break;
}
let started = chunk.then(Instant::now);
let event = match item {
Received::Event(event) => event,
Received::Chunk(format) => {
// A column still decoding waits for the next frame, with
// what came after it, overdue or not.
let Some(next) = self.remote.as_ref().map(|r| r.decoder.next()) else {
break;
};
let (packet, column) = match next {
Next::Ready(decoded) => decoded,
Next::Decoding => {
events.push_front(Received::Chunk(format));
break;
}
Next::Lost => {
log::warn!("Horizon: the chunk decoder stopped; a column is missing");
continue;
}
};
self.native_client_packet(&packet);
if let C::LevelChunkWithLight(p) = *packet {
self.remote_chunk(&p, Some((&format, column)));
}
chunks += started.map_or(Duration::ZERO, |t| t.elapsed());
continue;
}
};
match event {
Event::Status(s) => {
if let Some(r) = self.remote.as_mut() {
r.status = s;
}
}
Event::Joined {
registries,
name,
uuid,
} => {
self.set_registries(registries);
self.extras_joined();
// handleLogin: the level loading screen replaces any dialog.
self.close_dialogs();
if let Some(r) = self.remote.as_mut() {
r.joined = true;
// handleLogin: startWaitingForNewLevel with Reason.OTHER.
r.profile = uuid;
r.profile_name = name.clone();
// LevelLoadingScreen's line: no player name.
r.status = horizon_client::lang::get_or(
"multiplayer.downloadingTerrain",
"Loading terrain...",
);
}
}
Event::Features(features) => {
if let Some(remote) = &mut self.remote {
remote.features = features;
}
}
Event::Brand(brand) => {
if let Some(remote) = &mut self.remote {
remote.brand = brand;
}
}
Event::QuietCommands => {
if let Some(remote) = &mut self.remote {
remote.quiet_commands = true;
}
}
Event::ModNet => {
if let Some(remote) = &mut self.remote {
remote.mod_net = true;
}
}
Event::FarTerrain(offer) => {
if let Some(remote) = &mut self.remote {
remote.far_terrain = Some(offer);
}
}
Event::Reconfiguring => {
// handleConfigurationStart: the held-back chat shows, then
// clearClientLevel opens ServerReconfigScreen over no level.
self.flush_chat_queue();
self.send_chat_acknowledgement();
self.close_dialogs();
// The server configuring us now (a proxy's next one)
// offers its own far terrain, or none, and announces its
// own features again (a Horizon server's quiet commands).
if let Some(remote) = &mut self.remote {
remote.far_terrain = None;
remote.quiet_commands = false;
remote.mod_net = false;
}
self.clear_lod();
if let Some(remote) = &self.remote {
remote.session.reset_far_terrain();
}
self.player = None;
self.screen = Screen::Playing;
self.capture(false);
// clearClientLevel drops the client level now, so its
// unload comes before the configuration screen, not
// after the next login's load.
if let Some(old) = self.remote.as_mut().and_then(|r| r.heard_level.take()) {
self.native_client_level_unload(&old);
}
let ticks = self.ticks;
if let Some(r) = self.remote.as_mut() {
r.reconfiguring = Some(ticks);
r.chat_tree_received = false;
}
}
Event::Reconfigured(registries) => {
// handleConfigurationStart → clearClientLevel, and the next
// ClientPacketListener starts with no players or scoreboard,
// no waypoints (its own ClientWaypointManager: a proxy's next
// server never untracks the last one's), and its new
// ClientSuggestionProvider with no custom chat completions.
// clearClientLevel clears the narrator.
self.clear_narrator();
self.hud_disconnected();
self.score.board = Default::default();
self.clear_waypoints();
if let Some(r) = self.remote.as_mut() {
r.attachments.clear();
r.players.clear();
r.tab_players.clear();
r.listed.clear();
r.signatures = Default::default();
r.custom_completions.clear();
}
self.ui.registries.custom_completions.clear();
self.set_registries(registries);
}
// handleTransfer: "Cannot transfer to server from
// singleplayer" (no ServerData), so the world just closes.
Event::Transfer { host, port } => {
if let Some(r) = self.remote.as_mut()
&& r.session.address != "local"
{
r.transfer = Some(format!("{host}:{port}"));
}
}
Event::PackPush(p) => self.handle_pack_push(p),
Event::PackPop(id) => self.handle_pack_pop(id),
Event::ServerLinks(links) => {
if let Some(r) = self.remote.as_mut() {
r.server_links = super::super::server_links::trusted(links);
}
}
Event::ShowDialog(dialog) => self.show_dialog(&dialog),
Event::ClearDialog => self.clear_dialog(),
Event::CodeOfConduct(text) => self.handle_code_of_conduct(text),
Event::DecodeError(e) => {
if let Some(r) = self.remote.as_mut() {
r.decode_errors += 1;
if r.decode_errors <= 20 {
log::warn!("Horizon: skipped undecodable packet {e}");
}
}
}
Event::Disconnected {
reason,
key,
from_server,
} => {
log::info!("Horizon: disconnected: {reason}");
// ClientboundTransferPacket left a target: connect there
// instead of showing the disconnect, the stored cookies
// carried over (ClientCommonPacketListenerImpl.
// handleTransfer → ConnectScreen.startConnecting).
let transfer = self.remote.as_mut().and_then(|r| {
r.transfer.take().map(|address| {
(address, r.session.name.clone(), r.session.cookies.clone())
})
});
if let Some((address, name, cookies)) = transfer {
self.leave_server(None)?;
self.connect_server_with_cookies(&address, &name, Some(cookies))?;
return Ok(());
}
let retry = self.reconnect_after(&reason, key.as_deref(), from_server);
self.leave_server(Some(reason))?;
self.offer_reconnect(retry);
return Ok(());
}
Event::Packet(packet) => self.handle_packet(*packet)?,
}
chunks += started.map_or(Duration::ZERO, |t| t.elapsed());
if self.remote.is_none() {
break;
}
}
if self.remote.is_some() {
self.forget_remote_columns(&mut forgotten);
}
if let Some(r) = self.remote.as_mut() {
r.backlog_since = if events.is_empty() {
None
} else {
Some(r.backlog_since.unwrap_or_else(Instant::now))
};
r.backlog = events;
}
self.send_suggestion_request();
Ok(())
}
}
@@ -164,8 +164,8 @@ impl Game<'_> {
boss_world_fog: self.boss_overlay.should_create_world_fog(),
spectator: self.spectator(),
fire_resistance: self.potions.effects.get("fire_resistance").is_some()
|| self.clear_fluids().0,
full_water_vision: self.clear_fluids().1,
|| self.settings.extras.pvp.tweaks.clear_lava,
full_water_vision: self.settings.extras.pvp.tweaks.clear_water,
night_vision: self.fog_night_vision(),
environment: self.pack.polytone_colors.env,
};
@@ -405,7 +405,7 @@ impl Game<'_> {
on_fire: self.camera_type.first_person()
&& !self.spectator()
&& self.player.as_ref().is_some_and(|p| p.fire_ticks > 0),
fire_lower: self.fire_lower(),
fire_lower: f32::from(self.settings.extras.pvp.tweaks.fire_lower) / 100.0,
gui_overlays: &gui_overlays,
})
}
@@ -679,6 +679,7 @@ impl Game<'_> {
.player
.as_ref()
.map(|p| ([p.position.x, p.position.y, p.position.z], p.velocity.y)),
cached_chunk: std::cell::Cell::new(None),
};
laps.lap("tp_level");
// Resource packs' Polytone entity emitters on the player (#11486): fog, dust and
@@ -190,7 +190,7 @@ impl PackResources {
let clouds = named_scope(scope, "pack.clouds", || load_clouds(root));
let forcefield = named_scope(scope, "pack.forcefield", || load_forcefield(root));
let custom_sky = named_scope(scope, "pack.custom_sky", || {
horizon_client::custom_sky::CustomSky::load(root)
horizon_client::custom_sky::load(root)
});
let environments =
named_scope(scope, "pack.environments", || pack_environments(root));
@@ -246,7 +246,7 @@ impl PackResources {
names: Arc::new(names),
clouds: Arc::new(clouds),
forcefield: Arc::new(forcefield),
custom_sky: Arc::new(custom_sky),
custom_sky,
environments,
polytone_particles,
polytone_post,
+6 -1
View File
@@ -24,6 +24,8 @@ pub(super) struct Social {
names: HashMap<u128, String>,
page: Page,
pub(super) search: EditBox,
/// `PlayerEntry.TOOLTIP_DELAY`: the row buttons' tooltips wait 500 ms.
tooltip: social_screen::TooltipDelay,
pub(super) scroll: f32,
/// Opened from the pause menu: Done goes back there.
from_pause: bool,
@@ -38,6 +40,7 @@ impl Default for Social {
names: HashMap::new(),
page: Page::All,
search,
tooltip: Default::default(),
scroll: 0.0,
from_pause: false,
}
@@ -77,6 +80,7 @@ impl Game<'_> {
self.hide_social_tutorial();
self.social.page = Page::All;
self.social.search.set_value("");
self.social.tooltip = Default::default();
self.social.scroll = 0.0;
self.social.from_pause = from_pause;
self.screen = Screen::Social;
@@ -150,7 +154,7 @@ impl Game<'_> {
}
/// The server's name: a local world's name, a server's entry in the list.
fn social_server_name(&self) -> String {
match (&self.integrated, self.last_server.address()) {
match (&self.integrated, self.server_address()) {
(Some(server), _) => server.world_name.clone(),
(None, Some(address)) if self.remote.is_some() => address.to_owned(),
_ => self.level.name.clone(),
@@ -168,6 +172,7 @@ impl Game<'_> {
self.cursor,
&view,
&mut self.social.search,
&mut self.social.tooltip,
true,
);
}
+1 -3
View File
@@ -261,8 +261,6 @@ impl<'a> Split<'a> {
.get(i.wrapping_sub(1))
.map(|g| g.options.clone()),
);
let seat = self.guests.get(i.wrapping_sub(1)).map_or(1, |g| g.seat);
horizon_hooks::engine::set_seat(seat - 1);
self.turn = i;
}
@@ -515,7 +513,7 @@ impl<'a> Split<'a> {
if self.host.integrated.is_some() || self.host.remote.is_none() {
return None;
}
self.host.last_server.address().map(str::to_owned)
self.host.server_address().map(str::to_owned)
}
/// The pads' players who accepted, and the testing players (`HORIZON_SPLIT`, one a frame),
@@ -126,7 +126,6 @@ impl Split<'_> {
// Until it has a profile, a scratch options file: never P1's.
let options = Self::folder()?.join(".joining/options.txt");
OPTIONS.set(Some(options.clone()));
horizon_hooks::engine::set_seat(seat - 1);
let mut settings = self.host.settings.clone();
if pad.is_some() {
// The Controller setting is P1's own: a player who pressed a pad to join plays
@@ -136,7 +135,6 @@ impl Split<'_> {
}
let game = self.start_guest(seat, &options, settings);
OPTIONS.set(None);
horizon_hooks::engine::set_seat(0);
let mut game = game?;
if let Some(pad) = pad {
// The press that joined it doesn't act in its game.
@@ -301,31 +299,16 @@ impl Split<'_> {
}
let profile = profiles.join(&name);
let options = profile.join("options.txt");
let made = if options.exists() {
None
let mut settings = if options.exists() {
horizon_client::settings::Settings::load(&options)
} else {
std::fs::create_dir_all(&profile)?;
let mut new = self.host.settings.clone();
// The Controller setting is P1's, as on the profile page (`join`).
new.controller.enabled |= self.guests[i].pad.is_some();
// On P1's seat, so the crates' settings stores write P1's values too.
let turn = horizon_hooks::seat();
horizon_hooks::engine::set_seat(0);
new.save(&options);
horizon_hooks::engine::set_seat(turn);
Some(new)
new
};
// The crates' stores load into the guest's seat, before its turn starts below.
let turn = horizon_hooks::seat();
horizon_hooks::engine::set_seat(self.guests[i].seat - 1);
let mut settings = match made {
Some(new) => {
horizon_client::settings::Settings::load_stores(&options);
new
}
None => horizon_client::settings::Settings::load(&options),
};
horizon_hooks::engine::set_seat(turn);
// A profile an earlier join saved with P1's off, or the player's pad would be dead.
settings.controller.enabled |= self.guests[i].pad.is_some();
// P1's own world through memory (`Ok`), else P1's server over the network (`Err`).
@@ -516,10 +516,9 @@ impl<'a> Split<'a> {
continue;
}
// Opened without the focus: whoever types in the game's window keeps the keyboard.
let attributes =
horizon_client::window::with_horizon_icon(Window::default_attributes())
.with_active(false)
.with_visible(window_shown(true, self.offscreen));
let attributes = horizon_client::window::with_icon(Window::default_attributes())
.with_active(false)
.with_visible(window_shown(true, self.offscreen));
// A monitor's window covers it, borderless. On macOS not the system's fullscreen, a
// Space of its own that hides the other windows as it slides in.
let placement = self.placement(*key).cloned();
+1 -1
View File
@@ -206,7 +206,7 @@ impl Game<'_> {
to_world.transform_vector3(b).normalize(),
);
// PvP & HUD's totem animation size (100% is vanilla's), about the item's centre.
let size = self.totem_scale();
let size = f32::from(self.settings.extras.pvp.tweaks.totem_size) / 100.0;
let pose = pose * glam::Mat4::from_scale(glam::Vec3::splat(size));
self.submit_item_lit(&state, to_world * pose, vertex_light(0xFF), lights, out);
}
@@ -17,12 +17,26 @@
//! other side. Down over a result slot takes one craft or the whole furnace
//! output to the inventory; nothing ever goes into a result slot.
//!
//! Sorting, which clicks through `tweak_click` too, is in `sorting.rs`.
use crate::game::{Creative, Game, Screen};
use horizon_client::{containers::BlockEntity, items::ItemStack, menu::Input};
//! Sorting draws a small button by a menu's storage slots and one by the
//! inventory's (an optional key sorts the part under the cursor); the
//! clicks come from `inv_sort::plan`.
use super::{Creative, Game, Screen};
use horizon_client::{
containers::BlockEntity,
hud::{HudBatch, rgba},
inv_sort,
items::ItemStack,
keybinds,
menu::Input,
stations::hover_name,
};
use winit::{event::MouseButton, keyboard::KeyCode};
/// A sort button's side in GUI pixels.
const SORT_BUTTON: f32 = 9.0;
#[derive(Default)]
pub(in crate::game) struct Tweaks {
pub(super) struct Tweaks {
drag: Option<Drag>,
/// Wheel steps not yet acted on (a trackpad scrolls in fractions).
wheel: f32,
@@ -30,7 +44,7 @@ pub(in crate::game) struct Tweaks {
/// A server's menu the tweaks run in: their clicks are vanilla's in these,
/// and the menu engine knows which slot takes what. Not the menus whose slots
/// cost or trigger something (anvils, enchanting, merchants, beacons, crafters).
pub(in crate::game) fn tweak_menu(name: &str) -> bool {
pub(super) fn tweak_menu(name: &str) -> bool {
name == "inventory"
|| name.starts_with("minecraft:generic_9x")
|| matches!(
@@ -58,7 +72,7 @@ struct Drag {
impl Game<'_> {
/// The open screen is one the tweaks run in, with the extra on.
pub(in crate::game) fn tweaks_on(&self) -> bool {
pub(super) fn tweaks_on(&self) -> bool {
if !self.settings.extras.mouse_tweaks
|| self.screen != Screen::Inventory
|| self.station.is_some()
@@ -73,7 +87,7 @@ impl Game<'_> {
}
}
/// One ordinary click on a game slot. False when nothing took it.
pub(in crate::game) fn tweak_click(&mut self, slot: usize, button: i32, input: Input) -> bool {
pub(super) fn tweak_click(&mut self, slot: usize, button: i32, input: Input) -> bool {
if self.remote.is_some() {
return self.remote_menu_ops(&[(Some(slot), button, input)]);
}
@@ -89,7 +103,7 @@ impl Game<'_> {
/// drag (`QUICK_CRAFT`) and not a click: a server that has the hand empty
/// (the stack it was to come from just burned as fuel) ignores a drag, but
/// a click would pick up what's in `slot`.
pub(in crate::game) fn tweak_put(&mut self, slot: usize, right: bool) -> bool {
fn tweak_put(&mut self, slot: usize, right: bool) -> bool {
let kind = i32::from(right) << 2;
let ops = [
(None, kind, Input::QuickCraft),
@@ -109,12 +123,7 @@ impl Game<'_> {
}
/// A press on `slot`: true when the tweaks took it (a right-drag
/// placing), else vanilla's press runs and the tweaks watch the drag.
pub(in crate::game) fn tweak_press(
&mut self,
slot: Option<usize>,
right: bool,
shift: bool,
) -> bool {
pub(super) fn tweak_press(&mut self, slot: Option<usize>, right: bool, shift: bool) -> bool {
self.tweaks.drag = None;
let Some(slot) = slot.filter(|&s| s != 200 && s < Creative::DISPLAY) else {
return false;
@@ -165,7 +174,7 @@ impl Game<'_> {
}
/// The cursor moved while a button is held: true when the tweaks own
/// the drag (vanilla's quick craft drag doesn't grow).
pub(in crate::game) fn tweak_drag_move(&mut self, slot: Option<usize>) -> bool {
pub(super) fn tweak_drag_move(&mut self, slot: Option<usize>) -> bool {
let Some(mut drag) = self.tweaks.drag.take() else {
return false;
};
@@ -213,11 +222,11 @@ impl Game<'_> {
owned
}
/// The button went up: the drag ends.
pub(in crate::game) fn tweak_release(&mut self) {
pub(super) fn tweak_release(&mut self) {
self.tweaks.drag = None;
}
/// The wheel over an inventory screen: true when the tweaks used it.
pub(in crate::game) fn tweak_scroll(&mut self, delta: f32, slot: Option<usize>) -> bool {
pub(super) fn tweak_scroll(&mut self, delta: f32, slot: Option<usize>) -> bool {
if !self.settings.extras.mouse_tweaks_wheel || !self.tweaks_on() || self.carried.is_some() {
return false;
}
@@ -247,7 +256,7 @@ impl Game<'_> {
result
}
/// `slot` takes one more of `stack`, by the open menu's rules.
pub(in crate::game) fn tweak_takes_one(&mut self, slot: usize, stack: &ItemStack) -> bool {
fn tweak_takes_one(&mut self, slot: usize, stack: &ItemStack) -> bool {
let mut takes = false;
self.menu_session(|menu, env, to_menu| {
takes = to_menu(slot).is_some_and(|i| menu.takes_one(env, i, stack));
@@ -257,7 +266,7 @@ impl Game<'_> {
}
/// Where vanilla's quick move would put `slot`'s items, tried on a copy
/// of the menu that's dropped (`Some(false)` keeps nothing).
pub(in crate::game) fn tweak_landing(&mut self, slot: usize) -> Vec<usize> {
fn tweak_landing(&mut self, slot: usize) -> Vec<usize> {
let slots = self.slots();
let mut landing = Vec::new();
self.menu_session(|menu, env, to_menu| {
@@ -347,6 +356,144 @@ impl Game<'_> {
}
}
impl Game<'_> {
/// The sort buttons of the open screen, with the extra on: their
/// corner (from the screen's) and the slots each sorts. Any menu screen
/// gets them, by its slots' roles (`native_slot_roles`): one by the top
/// right of its storage slots (a chest's title line) when it has some,
/// one by the main inventory's (the "Inventory" label's line).
fn sort_buttons(&self) -> Vec<((f32, f32), Vec<usize>)> {
use super::NativeSlotRole as Role;
if !self.settings.extras.sort {
return Vec::new();
}
let roles = self.native_slot_roles();
let of = |role| -> Vec<usize> {
roles
.iter()
.filter(|(_, r)| *r == role)
.map(|(s, _)| *s)
.collect()
};
// Over the section's last column: (160, 5) over a chest's slots.
let right = |slots: &[usize]| {
slots
.iter()
.map(|&s| self.slot_position(s).0 + 8.0)
.fold(f32::MIN, f32::max)
};
let mut buttons = Vec::new();
let storage = of(Role::Storage);
if !storage.is_empty() {
let top = storage
.iter()
.map(|&s| self.slot_position(s).1 - 13.0)
.fold(f32::MAX, f32::min);
buttons.push(((right(&storage), top), storage));
}
// On the "Inventory" label's line (`inventoryLabelY`, height - 94, 12
// above the first row): a mod's screen places its inventory itself.
let main = of(Role::Inventory);
if !main.is_empty() {
let y = self
.native_screen_inventory_y()
.map_or(self.screen_height() - 95.0, |y| y - 13.0);
buttons.push(((right(&main), y), main));
}
buttons
}
/// The sort buttons: a bordered square with three bars, lighter under
/// the cursor.
pub(super) fn draw_sort_buttons(&self, batch: &mut HudBatch, ox: f32, oy: f32) {
let atlas = &self.gui.atlas;
for ((x, y), _) in self.sort_buttons() {
let (x, y) = (ox + x, oy + y);
let (cx, cy) = self.cursor;
let hovered = cx >= x && cx < x + SORT_BUTTON && cy >= y && cy < y + SORT_BUTTON;
let face = if hovered {
rgba(220, 220, 220, 255)
} else {
rgba(160, 160, 160, 255)
};
batch.fill(atlas, x, y, SORT_BUTTON, SORT_BUTTON, rgba(55, 55, 55, 255));
batch.fill(
atlas,
x + 1.0,
y + 1.0,
SORT_BUTTON - 2.0,
SORT_BUTTON - 2.0,
face,
);
for (i, w) in [5.0, 4.0, 3.0].into_iter().enumerate() {
let by = y + 2.0 + 2.0 * i as f32;
batch.fill(atlas, x + 2.0, by, w, 1.0, rgba(55, 55, 55, 255));
}
}
}
/// A left press on a sort button sorts its slots (true).
pub(super) fn sort_press(&mut self) -> bool {
let Some(r) = self.renderer.as_ref() else {
return false;
};
let batch = HudBatch::new([r.config.width, r.config.height]);
let (ox, oy) = self.inventory_origin(&batch);
let (cx, cy) = self.cursor;
let hit = self.sort_buttons().into_iter().find(|((x, y), _)| {
let (x, y) = (ox + x, oy + y);
cx >= x && cx < x + SORT_BUTTON && cy >= y && cy < y + SORT_BUTTON
});
let Some((_, slots)) = hit else {
return false;
};
self.sort_slots(&slots);
true
}
/// The sort key: sorts the part under the cursor (the container when
/// the cursor is over no slot).
pub(super) fn sort_key_press(&mut self, key: KeyCode) -> bool {
keybinds::key_name(key).is_some_and(|name| self.sort_input(name))
}
pub(super) fn sort_mouse_press(&mut self, button: MouseButton) -> bool {
self.sort_input(&keybinds::mouse_name(button))
}
fn sort_input(&mut self, name: &str) -> bool {
let e = &self.settings.extras;
if !e.sort || e.sort_key != name || self.recipe_search_focused() {
return false;
}
let buttons = self.sort_buttons();
let hovered = self.inventory_target().0;
let slots = buttons
.iter()
.find(|(_, slots)| hovered.is_some_and(|h| slots.contains(&h)))
.or(buttons.first())
.map(|(_, slots)| slots.clone());
let Some(slots) = slots else {
return false;
};
self.sort_slots(&slots);
true
}
/// Sorts `slots` with ordinary left clicks (needs an empty hand).
fn sort_slots(&mut self, slots: &[usize]) {
if self.carried.is_some() {
return;
}
let views: Vec<Option<ItemStack>> = slots.iter().map(|&s| self.slot_view(s)).collect();
let clicks = {
let rank = |s: &ItemStack| {
let order = self.creative_screen.order_of(s).unwrap_or(usize::MAX);
(order, hover_name(&self.pack.names, s))
};
let max = |s: &ItemStack| self.max_stack(s);
inv_sort::plan(&views, &rank, &max)
};
for i in clicks {
self.tweak_click(slots[i], 0, Input::Pickup);
}
}
}
#[cfg(test)]
mod tests {
use super::tweak_menu;
@@ -273,9 +273,8 @@ impl ApplicationHandler for Game<'_> {
let fullscreen = self.settings.fullscreen || self.options.fullscreen;
let size = self.options.window_size.map(f64::from);
self.fullscreen = horizon_client::window::FullscreenState::new(fullscreen);
let attributes = horizon_client::window::with_horizon_icon(
Window::default_attributes().with_title("Horizon"),
);
let attributes =
horizon_client::window::with_icon(Window::default_attributes().with_title("Horizon"));
// Wayland names the window by its app id, which the horizon.desktop entry matches.
#[cfg(target_os = "linux")]
let attributes = winit::platform::wayland::WindowAttributesExtWayland::with_name(
@@ -1,24 +1,8 @@
//! Opening and closing a local world from the client: the title screen's actions, entering a
//! world on the server thread, and saving and leaving it on exit. Also connecting again to a
//! server when an attempt a hook planned is due (`last_server.rs`).
//! world on the server thread, and saving and leaving it on exit.
use super::*;
impl Game<'_> {
/// Connects again once a planned attempt's countdown is over. An online-mode server gets the
/// signed-in account again: `connect_server` takes it from `auth::current()`, held in memory
/// for the whole run (the launcher's session file is gone after the first read).
pub(super) fn reconnect_when_due(&mut self) -> Result<()> {
if let Some(retry) = self.title.as_mut().and_then(|t| t.due_retry()) {
self.connect_server_with_options(
&retry.address,
&retry.name,
None,
retry.lan,
self.last_server.realm,
)?;
}
Ok(())
}
/// `onAbort`: the confirm screen's Back or Escape reopens the Data
/// Packs screen with the selection it had.
pub(super) fn reopen_data_packs(&mut self, event_loop: &dyn EventLoopCtl) -> Result<()> {
@@ -6,6 +6,7 @@ use super::{Game, commands::Target};
use horizon_client::{
advancements as adv,
gossip::GossipType,
items::ItemStack,
lan::playerdata,
mobs::{Mob, Species, villager, zombie_villager},
};
@@ -143,7 +144,10 @@ impl Game<'_> {
let Some(v) = self.mobs.iter_mut().find(|m| m.id == vid) else {
return;
};
convert_to_villager(&old, v);
let drops = convert_to_villager(&old, v);
for stack in drops {
self.spawn_at_location(old.position, 0.0, stack);
}
// `level.getPlayerByUUID(conversionStarter)`: the player who fed the
// apple, if still in this level (the host or a server's guest).
let starter = zombie_villager::cure(&old).and_then(|c| c.starter);
@@ -165,6 +169,10 @@ impl Game<'_> {
g.trigger_cured_zombie_villager(&zombie_view, &villager_view)
});
}
// The nausea comes after the advancement criterion (finishConversion).
if let Some(v) = self.mobs.iter_mut().find(|m| m.id == vid) {
v.add_effect("minecraft:nausea", 200, 0);
}
if !old.silent {
self.level_event_at(
1027,
@@ -278,11 +286,38 @@ pub(super) fn convert_to_zombie_villager(from: &Mob, to: &mut Mob) {
carry_over(from, to);
to.extra.age = if from.is_baby() { i32::MIN / 2 } else { 0 };
}
/// A cured zombie villager becomes a villager again, queasy for 10 seconds.
pub(super) fn convert_to_villager(from: &Mob, to: &mut Mob) {
/// A cured zombie villager becomes a villager again (the caller adds the
/// 10 second nausea after the cure criterion).
/// `convertTo(.., keepEquipment = false)` leaves the villager bare, then
/// `ZombieVillager.finishConversion` runs `Mob.dropPreservedEquipment` with
/// "no `PREVENT_ARMOR_CHANGE`": kept (cursed) stacks go to the villager's
/// slot `EquipmentSlot.getIndex() + 300` (its inventory), picked-up gear
/// (drop chance > 1) is returned for the caller to drop, the rest vanishes.
pub(super) fn convert_to_villager(from: &Mob, to: &mut Mob) -> Vec<ItemStack> {
use horizon_client::mobs::{drops, finalize};
carry_over(from, to);
let mut drops_out = Vec::new();
// `EquipmentSlot.VALUES` order: main hand, off hand, feet, legs, chest, head.
for (slot, name) in drops::SLOTS.iter().take(6).enumerate() {
let Some(stack) = finalize::item_stack(from, slot) else {
continue;
};
drops::clear_slot(to, name);
if stack.enchantment_level("minecraft:binding_curse") > 0 {
// `getIndex() + 300`: hands 0..1, then feet, legs, chest, head 0..3.
let index = if slot < 2 { slot } else { slot - 2 };
let inventory = &mut villager::ext_mut(to).inventory;
if index < inventory.len() {
inventory[index] = stack;
} else {
inventory.push(stack);
}
} else if drops::drop_chance(from, name) > 1.0 {
drops_out.push(stack);
}
}
to.extra.age = if from.is_baby() { -24000 } else { 0 };
to.add_effect("minecraft:nausea", 200, 0);
drops_out
}
#[cfg(test)]
@@ -384,11 +419,34 @@ mod tests {
assert!(zombie.invulnerable_flag);
assert!(zombie.extra.left_handed);
let mut cured = mob(3, Species::Villager, "minecraft:villager");
convert_to_villager(&zombie, &mut cured);
let drops = convert_to_villager(&zombie, &mut cured);
assert!(drops.is_empty());
assert!(cured.silent);
assert!(cured.no_ai);
assert!(cured.no_gravity);
assert!(cured.invulnerable_flag);
assert!(cured.extra.left_handed);
// The cure keeps no equipment: picked-up gear drops, cursed gear goes
// to the inventory, the rest vanishes.
use horizon_client::mobs::finalize::{
FEET, HEAD, MAINHAND, item_stack, set_drop_chance, set_item, set_item_stack,
};
let mut gear = mob(4, Species::ZombieVillager, "minecraft:zombie_villager");
set_item(&mut gear, MAINHAND, "minecraft:iron_sword");
set_drop_chance(&mut gear, MAINHAND, 2.0);
set_item(&mut gear, HEAD, "minecraft:iron_helmet");
let mut boots = ItemStack::new("minecraft:iron_boots", 1);
boots.set_crafting_enchantments(vec![("minecraft:binding_curse".into(), 1)]);
set_item_stack(&mut gear, FEET, boots);
let mut bare = mob(5, Species::Villager, "minecraft:villager");
let drops = convert_to_villager(&gear, &mut bare);
assert_eq!(drops.len(), 1);
assert_eq!(drops[0].item.as_ref(), "minecraft:iron_sword");
for slot in 0..=5 {
assert!(item_stack(&bare, slot).is_none(), "slot {slot}");
}
let kept = &villager::ext(&bare).unwrap().inventory;
assert_eq!(kept.len(), 1);
assert_eq!(kept[0].item.as_ref(), "minecraft:iron_boots");
}
}
+56
View File
@@ -0,0 +1,56 @@
//! Horizon's look, given to the engine's extension points at startup (docs/layers.md): the
//! windows' icon and the colours, sizes and fonts of the menus vanilla doesn't have
//! (`docs/design-system.md`).
//! Its own module so it can leave the engine crate as a pure file move, for the official
//! executable to call.
use horizon_client::menu_style::{Fonts, MenuStyle, OverlayColours, SettingsColours, Sizes};
use horizon_theme as theme;
/// Give the engine Horizon's look; call it once, before the first window opens.
pub(crate) fn give() {
horizon_client::window::set_icon_png(theme::ICON_PNG);
let (blocks, dark) = (theme::BLOCKS_DARK, theme::DARK);
horizon_client::menu_style::install(MenuStyle {
settings: SettingsColours {
page: blocks.page,
ink: blocks.ink,
ink_soft: blocks.ink_soft,
muted: blocks.muted,
outline: blocks.outline,
accent: blocks.accent,
face: blocks.face,
// The face's hover, lighter than `face`: the settings screen's own shade.
face_hover: 0x6a50e6,
success: blocks.success,
warn: blocks.warn,
danger: blocks.danger,
},
overlay: OverlayColours {
page: dark.page,
backdrop_muted: theme::BACKDROP_MUTED,
ink: dark.ink,
ink_soft: dark.ink_soft,
rule: dark.rule,
accent: dark.accent,
on_accent: dark.on_accent,
},
sizes: Sizes {
caption: theme::CAPTION.px,
label: theme::LABEL.px,
ui: theme::UI.px,
body: theme::BODY.px,
title: theme::TITLE.px,
display: theme::DISPLAY.px,
micro: theme::MICRO.px,
radius: theme::RADIUS,
radius_plate: theme::RADIUS_PLATE,
control_large: theme::CONTROL_LARGE,
},
fonts: Some(Fonts {
regular: theme::fonts::GEIST_REGULAR,
medium: theme::fonts::GEIST_MEDIUM,
semibold: theme::fonts::GEIST_SEMIBOLD,
mono: theme::fonts::GEIST_MONO,
}),
});
}
-96
View File
@@ -1,96 +0,0 @@
//! The multiplayer server this window connected to last, which splitscreen players join and the
//! friends service's presence names, and the connection's lifecycle as the hooks of the layers
//! above hear it (`horizon_hooks`: connecting, joined, left, ended). A hook may plan another
//! attempt when a connection ends; the Disconnected screen shows its countdown ([`offer`]).
use crate::{multiplayer, net, title};
use horizon_hooks::{Ended, Reconnect, Server, engine};
/// What the window knows of its last server. Forgotten when the player leaves it, or when a
/// connection ends with no attempt planned.
#[derive(Debug, Default)]
pub struct LastServer {
address: Option<String>,
lan: bool,
/// The open connection joined from Realms: `ServerData.isRealm` for the friends service's
/// presence status.
pub realm: bool,
}
impl LastServer {
/// A connection to a multiplayer server starts.
pub fn connecting(&mut self, address: &str, name: &str, lan: bool) {
self.address = Some(address.to_owned());
self.lan = lan;
engine::connecting(&Server::new(address, name, lan));
}
/// The server let the player in.
pub fn joined(&self) {
engine::joined();
}
/// The player left on purpose.
pub fn left(&mut self) {
self.forget();
engine::left();
}
/// The connection ended: the attempt a hook plans, if any.
pub fn ended(
&mut self,
reason: &str,
key: Option<&str>,
from_server: bool,
) -> Option<Reconnect> {
let plan = engine::ended(&Ended::new(reason, key, from_server));
if plan.is_none() {
self.forget();
}
plan
}
fn forget(&mut self) {
self.address = None;
self.lan = false;
}
/// The server's address, while the window keeps it.
pub fn address(&self) -> Option<&str> {
self.address.as_deref()
}
/// That server was found on the local network.
pub fn lan(&self) -> bool {
self.lan
}
}
/// Shows a planned attempt's countdown on the Disconnected screen.
pub fn offer(title: Option<&mut title::Title>, plan: Option<Reconnect>) {
let Some(plan) = plan else {
return;
};
if let Some(title) = title
&& let title::Mode::Multiplayer(multiplayer::Screen::Disconnected { retry, .. }) =
&mut title.mode
{
log::info!(
"Horizon: reconnecting to {} in {} s",
net::shown_address(&plan.address),
plan.seconds_left(std::time::Instant::now())
);
*retry = Some(plan);
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn the_server_is_forgotten_when_left_or_lost_without_a_plan() {
let mut last = LastServer::default();
last.connecting("example.org", "Player", true);
assert_eq!((last.address(), last.lan()), (Some("example.org"), true));
// No hook plans an attempt in this test process.
assert_eq!(last.ended("Timed out", None, false), None);
assert_eq!((last.address(), last.lan()), (None, false));
last.connecting("example.org:25566", "Player", false);
last.left();
assert_eq!(last.address(), None);
}
}
+1
View File
@@ -6,6 +6,7 @@ extern crate self as horizon_client;
pub use horizon_client_lib::*;
pub mod entry;
pub mod game;
mod horizon_look;
pub mod mod_api;
#[cfg(test)]
+3 -3
View File
@@ -140,8 +140,8 @@ pub enum Screen {
},
Disconnected {
reason: String,
/// The next attempt a hook planned (`last_server`), until it is cancelled.
retry: Option<horizon_hooks::Reconnect>,
/// Auto-reconnect's next attempt, until it is cancelled.
retry: Option<crate::reconnect::Retry>,
/// Wrapped reason lines, set by the last draw; the buttons sit below them.
lines: std::cell::Cell<usize>,
},
@@ -1970,7 +1970,7 @@ impl Screen {
}
}
/// Auto-reconnect's attempt, once its countdown is over.
pub fn due_retry(&mut self) -> Option<horizon_hooks::Reconnect> {
pub fn due_retry(&mut self) -> Option<crate::reconnect::Retry> {
let Self::Disconnected { retry, .. } = self else {
return None;
};
+43 -27
View File
@@ -46,11 +46,11 @@ impl NoResources {
let x = ((ui.w - width) / 2.0).round();
let lines: Vec<Vec<String>> = PARAGRAPHS
.iter()
.map(|p| wrap(ui, Face::Regular, BODY, p, width))
.map(|p| wrap(ui, Face::Regular, body(), p, width))
.collect();
let text_h = lines.iter().map(|l| l.len() as f32 * LINE).sum::<f32>()
+ (lines.len() - 1) as f32 * PARAGRAPH_GAP;
let height = TITLE_LINE + 16.0 + text_h + 28.0 + CONTROL + 20.0 + SMALL_LINE;
let height = TITLE_LINE + 16.0 + text_h + 28.0 + control() + 20.0 + SMALL_LINE;
let y = ((ui.h - height) / 2.0).max(24.0).round();
let buttons_y = y + TITLE_LINE + 16.0 + text_h + 28.0;
let mut buttons = Vec::new();
@@ -59,7 +59,7 @@ impl NoResources {
("Download the launcher", Action::Download),
("Quit", Action::Quit),
] {
let w = (ui.measure(Face::Medium, BUTTON_TEXT, label) + 40.0).round();
let w = (ui.measure(Face::Medium, button_text(), label) + 40.0).round();
buttons.push(Button {
label,
action,
@@ -80,21 +80,29 @@ impl NoResources {
pub fn draw(&self, ui: &mut Ui) {
let layout = self.layout(ui);
ui.rounded(0.0, 0.0, ui.w, ui.h, 0.0, color::BG);
ui.rounded(0.0, 0.0, ui.w, ui.h, 0.0, color::bg());
let mut y = layout.y;
ui.text_line(
Face::Medium,
TITLE_SIZE,
title_size(),
TITLE_LINE,
layout.x,
y,
color::TEXT,
color::text(),
TITLE,
);
y += TITLE_LINE + 16.0;
for paragraph in &layout.lines {
for line in paragraph {
ui.text_line(Face::Regular, BODY, LINE, layout.x, y, color::TEXT, line);
ui.text_line(
Face::Regular,
body(),
LINE,
layout.x,
y,
color::text(),
line,
);
y += LINE;
}
y += PARAGRAPH_GAP;
@@ -102,20 +110,20 @@ impl NoResources {
for button in &layout.buttons {
let hovered = button.contains(ui.cursor);
let (plate, ink) = match (button.action, hovered) {
(Action::Download, false) => (color::ACCENT, color::ON_ACCENT),
(Action::Download, false) => (color::accent(), color::on_accent()),
(Action::Download, true) => {
(color::with_alpha(color::ACCENT, 0.85), color::ON_ACCENT)
(color::with_alpha(color::accent(), 0.85), color::on_accent())
}
(Action::Quit, false) => (color::RAISED, color::TEXT),
(Action::Quit, true) => (color::HOVER, color::TEXT),
(Action::Quit, false) => (color::raised(), color::text()),
(Action::Quit, true) => (color::hover(), color::text()),
};
let h = CONTROL;
let h = control();
ui.rounded(button.x, button.y, button.w, h, h / 2.0, plate);
let lw = ui.measure(Face::Medium, BUTTON_TEXT, button.label);
let lw = ui.measure(Face::Medium, button_text(), button.label);
let lx = button.x + ((button.w - lw) / 2.0).round();
ui.text_line(
Face::Medium,
BUTTON_TEXT,
button_text(),
h,
lx,
button.y,
@@ -123,11 +131,11 @@ impl NoResources {
button.label,
);
}
let small_y = layout.buttons[0].y + CONTROL + 20.0;
let small_y = layout.buttons[0].y + control() + 20.0;
for (i, line) in wrap(
ui,
Face::Regular,
ui_theme::SMALL,
ui_theme::small(),
&self.looked_in,
layout.width,
)
@@ -137,11 +145,11 @@ impl NoResources {
let line_y = small_y + i as f32 * SMALL_LINE;
ui.text_line(
Face::Regular,
ui_theme::SMALL,
ui_theme::small(),
SMALL_LINE,
layout.x,
line_y,
color::MUTED,
color::muted(),
line,
);
}
@@ -157,14 +165,22 @@ impl NoResources {
}
}
const TITLE_SIZE: f32 = horizon_theme::TITLE.px;
fn title_size() -> f32 {
ui_theme::sizes().title
}
const TITLE_LINE: f32 = 40.0;
const BODY: f32 = horizon_theme::BODY.px;
fn body() -> f32 {
ui_theme::sizes().body
}
const LINE: f32 = 24.0;
const PARAGRAPH_GAP: f32 = 10.0;
const BUTTON_TEXT: f32 = horizon_theme::UI.px;
fn button_text() -> f32 {
ui_theme::sizes().ui
}
const SMALL_LINE: f32 = 18.0;
const CONTROL: f32 = horizon_theme::CONTROL_LARGE;
fn control() -> f32 {
ui_theme::sizes().control_large
}
struct Layout {
x: f32,
@@ -184,7 +200,7 @@ struct Button {
impl Button {
fn contains(&self, (cx, cy): (f32, f32)) -> bool {
cx >= self.x && cx < self.x + self.w && cy >= self.y && cy < self.y + CONTROL
cx >= self.x && cx < self.x + self.w && cy >= self.y && cy < self.y + control()
}
}
@@ -232,7 +248,7 @@ mod tests {
for size in [[1280, 720], [800, 600], [2560, 1440]] {
let (download, quit, density) = with_ui(size, (0.0, 0.0), |ui| {
let layout = screen.layout(ui);
let center = |b: &Button| (b.x + b.w / 2.0, b.y + CONTROL / 2.0);
let center = |b: &Button| (b.x + b.w / 2.0, b.y + control() / 2.0);
(
center(&layout.buttons[0]),
center(&layout.buttons[1]),
@@ -255,7 +271,7 @@ mod tests {
let layout = screen.layout(ui);
let last = layout.buttons.last().unwrap();
assert!(
last.x + last.w <= ui.w && last.y + CONTROL <= ui.h,
last.x + last.w <= ui.w && last.y + control() <= ui.h,
"{size:?}"
);
});
@@ -266,10 +282,10 @@ mod tests {
fn text_wraps_within_the_column_and_keeps_every_word() {
with_ui([1280, 720], (0.0, 0.0), |ui| {
for paragraph in PARAGRAPHS {
let lines = wrap(ui, Face::Regular, BODY, paragraph, 300.0);
let lines = wrap(ui, Face::Regular, body(), paragraph, 300.0);
assert!(lines.len() > 1, "{paragraph}");
for line in &lines {
assert!(ui.measure(Face::Regular, BODY, line) <= 300.0, "{line}");
assert!(ui.measure(Face::Regular, body(), line) <= 300.0, "{line}");
}
assert_eq!(lines.join(" "), paragraph);
}
+12 -12
View File
@@ -293,16 +293,16 @@ impl Keyboard {
y0 - 12.0,
width + 24.0,
height,
ui_theme::RADIUS_PLATE,
color::with_alpha(color::BG, 0.94),
ui_theme::radius_plate(),
color::with_alpha(color::bg(), 0.94),
);
ui.border(
x0 - 12.0,
y0 - 12.0,
width + 24.0,
height,
ui_theme::RADIUS_PLATE,
color::BORDER,
ui_theme::radius_plate(),
color::border(),
);
for (r, row) in self.rows().iter().enumerate() {
let y = y0 + r as f32 * (KEY_H + GAP);
@@ -314,13 +314,13 @@ impl Keyboard {
let lit = matches!(key, Key::Shift) && (self.shift || self.caps)
|| matches!(key, Key::Layer) && self.symbols;
let (plate, ink) = if focused {
(color::ACCENT, color::ON_ACCENT)
(color::accent(), color::on_accent())
} else if lit {
(color::HOVER, color::TEXT)
(color::hover(), color::text())
} else {
(color::RAISED, color::TEXT)
(color::raised(), color::text())
};
ui.rounded(x, y, w, KEY_H, ui_theme::RADIUS, plate);
ui.rounded(x, y, w, KEY_H, ui_theme::radius(), plate);
let label = match key {
Key::Char(ch) => self.letter(*ch),
Key::Shift if self.caps => "CAPS".into(),
@@ -331,10 +331,10 @@ impl Keyboard {
Key::Backspace => tr("horizon.osk.delete", "Delete"),
Key::Done => tr("gui.done", "Done"),
};
let lw = ui.measure(Face::Medium, ui_theme::BODY, &label);
let lw = ui.measure(Face::Medium, ui_theme::body(), &label);
ui.text_line(
Face::Medium,
ui_theme::BODY,
ui_theme::body(),
KEY_H,
x + ((w - lw) / 2.0).round(),
y,
@@ -359,10 +359,10 @@ impl Keyboard {
.collect();
ui.text_centered(
Face::Regular,
ui_theme::SMALL,
ui_theme::small(),
x0 + width / 2.0,
y0 + 5.0 * (KEY_H + GAP) + 2.0,
color::MUTED,
color::muted(),
&line.join(" "),
);
}
+36 -97
View File
@@ -2,18 +2,8 @@
//! connection may be retried, and when. Only lost connections and the
//! server's own shutdown or restart are retried, never kicks, bans or
//! refusals; attempts back off and stop after a few.
//!
//! It runs on the engine's connection hooks ([`HOOKS`], `horizon_hooks`): the engine
//! reports a connection's start, join, leave and end, and shows and runs the attempt
//! this plans. Nothing else in the engine reads it, so it leaves for a Horizon crate
//! as a file move once its setting can leave too (docs/layers.md, plan M5).
use horizon_hooks::{Ended, Hooks, Server, seat};
use std::sync::Mutex;
use std::time::{Duration, Instant};
/// A planned attempt.
pub use horizon_hooks::Reconnect as Retry;
/// Seconds before each attempt; there are no more after the last.
pub const DELAYS: [u64; 5] = [5, 10, 20, 40, 60];
/// Staying connected this long counts the next drop as a fresh one.
@@ -121,7 +111,43 @@ pub struct Tracker {
attempts: usize,
joined: Option<Instant>,
}
/// A planned attempt.
#[derive(Clone, Debug, PartialEq)]
pub struct Retry {
pub address: String,
pub name: String,
pub lan: bool,
pub at: Instant,
/// 1-based, of `DELAYS.len()`.
pub attempt: usize,
}
impl Retry {
/// Whole seconds left, rounded up.
pub fn seconds_left(&self, now: Instant) -> u64 {
let left = self.at.saturating_duration_since(now);
left.as_secs() + u64::from(left.subsec_nanos() > 0)
}
pub fn due(&self, now: Instant) -> bool {
now >= self.at
}
pub fn text(&self, now: Instant) -> String {
format!(
"Reconnecting in {} s (attempt {} of {})",
self.seconds_left(now),
self.attempt,
DELAYS.len()
)
}
}
impl Tracker {
/// The server connected to last (splitscreen players join the same one).
pub fn address(&self) -> Option<&str> {
self.address.as_deref()
}
/// That server was found on the local network.
pub fn lan(&self) -> bool {
self.lan
}
/// A connection to `address` as `name` starts: a new address starts
/// counting anew.
pub fn connecting(&mut self, address: &str, name: &str, lan: bool) {
@@ -170,65 +196,6 @@ impl Tracker {
lan: self.lan,
at: now + delay,
attempt: self.attempts,
attempts: DELAYS.len(),
})
}
}
/// Auto-reconnect on the engine's connection hooks: registered at startup with the other
/// extras (`entry.rs`); it plans nothing while its setting is off. Splitscreen players share
/// the hooks, so each seat (`horizon_hooks::seat`) has its own tracker and setting, as each
/// player's game had before.
pub static HOOKS: AutoReconnect = AutoReconnect {
seats: Mutex::new(Vec::new()),
};
pub struct AutoReconnect {
seats: Mutex<Vec<Seat>>,
}
#[derive(Default)]
struct Seat {
tracker: Tracker,
enabled: bool,
}
impl AutoReconnect {
/// The `autoReconnect` setting of the player whose turn it is.
pub fn set_enabled(&self, on: bool) {
self.with(|s| s.enabled = on);
}
fn with<T>(&self, f: impl FnOnce(&mut Seat) -> T) -> T {
let mut seats = self.seats.lock().unwrap_or_else(|e| e.into_inner());
let i = seat();
if seats.len() <= i {
seats.resize_with(i + 1, Seat::default);
}
f(&mut seats[i])
}
}
impl Hooks for AutoReconnect {
fn connecting(&self, server: &Server<'_>) {
self.with(|s| {
s.tracker
.connecting(server.address, server.name, server.lan)
});
}
fn joined(&self) {
self.with(|s| s.tracker.joined(Instant::now()));
}
fn left(&self) {
self.with(|s| s.tracker.reset());
}
fn ended(&self, end: &Ended<'_>) -> Option<Retry> {
self.with(|s| {
if !s.enabled {
s.tracker.reset();
return None;
}
s.tracker
.ended(end.reason, end.key, end.from_server, Instant::now())
})
}
}
@@ -491,33 +458,6 @@ mod tests {
);
}
#[test]
fn each_splitscreen_seat_keeps_its_own_attempts_and_setting() {
use horizon_hooks::engine::set_seat;
let hooks = AutoReconnect {
seats: Mutex::new(Vec::new()),
};
let lost = Ended::new("Timed out", Some("disconnect.timeout"), false);
for (seat, on) in [(0, false), (1, true)] {
set_seat(seat);
hooks.set_enabled(on);
hooks.connecting(&Server::new("example.org", "Player", false));
}
// P2 has it on: an attempt in 5 s. P1 has it off: none, and P2's state stays.
set_seat(1);
assert_eq!(hooks.ended(&lost).map(|r| r.attempt), Some(1));
set_seat(0);
assert_eq!(hooks.ended(&lost), None);
set_seat(1);
hooks.connecting(&Server::new("example.org", "Player", false));
assert_eq!(hooks.ended(&lost).map(|r| r.attempt), Some(2));
// P2 leaving on purpose starts it over.
hooks.left();
hooks.connecting(&Server::new("example.org", "Player", false));
assert_eq!(hooks.ended(&lost).map(|r| r.attempt), Some(1));
set_seat(0);
}
#[test]
fn the_countdown_rounds_up() {
let now = Instant::now();
@@ -527,7 +467,6 @@ mod tests {
lan: false,
at: now + Duration::from_millis(4200),
attempt: 2,
attempts: DELAYS.len(),
};
assert_eq!(r.seconds_left(now), 5);
assert_eq!(r.text(now), "Reconnecting in 5 s (attempt 2 of 5)");
+94 -14
View File
@@ -9,6 +9,7 @@ use crate::{
text::Text,
ui,
};
use std::time::{Duration, Instant};
pub const TITLE: &str = "gui.socialInteractions.title";
/// `SocialInteractionsScreen`'s page.
@@ -185,8 +186,35 @@ pub fn click(size: (f32, f32), cursor: (f32, f32), view: &SocialView) -> Option<
}
None
}
/// The tooltip of what the cursor is over.
fn tooltip(size: (f32, f32), cursor: (f32, f32), view: &SocialView) -> Option<Text> {
/// `PlayerEntry.TOOLTIP_DELAY`: a row button's tooltip shows once the cursor
/// has rested on that same button for half a second.
pub const TOOLTIP_DELAY: Duration = Duration::from_millis(500);
/// The row button whose tooltip the cursor is over.
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
pub enum Tip {
Report(u128),
Toggle { id: u128, hidden: bool },
}
impl Tip {
fn text(self) -> Text {
match self {
Tip::Report(_) => {
Text::translate("gui.socialInteractions.tooltip.report.disabled", &[])
}
Tip::Toggle { hidden: true, .. } => {
Text::translate("gui.socialInteractions.tooltip.show", &[])
}
Tip::Toggle { hidden: false, .. } => {
Text::translate("gui.socialInteractions.tooltip.hide", &[])
}
}
}
}
/// The tooltip of what the cursor is over: the row button it names.
fn tip_at(size: (f32, f32), cursor: (f32, f32), view: &SocialView) -> Option<Tip> {
let (w, h) = size;
if cursor.1 < LIST_START || cursor.1 >= list_end(h) {
return None;
@@ -198,28 +226,49 @@ fn tooltip(size: (f32, f32), cursor: (f32, f32), view: &SocialView) -> Option<Te
}
let (hide, report) = row_buttons(w, y);
if ui::hovered(cursor, hide[0], hide[1], 20.0, 20.0) {
let key = if e.hidden {
"gui.socialInteractions.tooltip.show"
} else {
"gui.socialInteractions.tooltip.hide"
};
return Some(Text::translate(key, &[]));
return Some(Tip::Toggle {
id: e.id,
hidden: e.hidden,
});
}
if ui::hovered(cursor, report[0], report[1], 20.0, 20.0) {
return Some(Text::translate(
"gui.socialInteractions.tooltip.report.disabled",
&[],
));
return Some(Tip::Report(e.id));
}
}
None
}
/// `WidgetTooltipHolder`'s delay: the tooltip of the control the cursor has
/// rested on for `TOOLTIP_DELAY`, and nothing while it moves between them.
#[derive(Clone, Copy, Debug, Default)]
pub struct TooltipDelay {
hovered: Option<(Tip, Instant)>,
}
impl TooltipDelay {
/// The tip to draw at `now`, starting the wait when the cursor reaches a
/// new button (vanilla's `displayStartTime`).
pub fn shown(&mut self, hovered: Option<Tip>, now: Instant) -> Option<Tip> {
let Some(tip) = hovered else {
self.hovered = None;
return None;
};
match self.hovered {
Some((t, at)) if t == tip => (now.duration_since(at) > TOOLTIP_DELAY).then_some(tip),
_ => {
self.hovered = Some((tip, now));
None
}
}
}
}
pub fn draw(
batch: &mut HudBatch,
atlas: &HudAtlas,
cursor: (f32, f32),
view: &SocialView,
search: &mut ui::EditBox,
tooltip: &mut TooltipDelay,
in_world: bool,
) {
let (w, h) = (batch.width, batch.height);
@@ -419,8 +468,8 @@ pub fn draw(
);
}
}
if let Some(text) = tooltip((w, h), cursor, view) {
ui::tooltip(batch, atlas, &[text], cursor);
if let Some(tip) = tooltip.shown(tip_at((w, h), cursor, view), Instant::now()) {
ui::tooltip(batch, atlas, &[tip.text()], cursor);
}
}
@@ -517,4 +566,35 @@ mod tests {
let names: Vec<_> = rows.iter().map(|e| e.name.as_str()).collect();
assert_eq!(names, ["me", "_x", "Alpha", "zed", "§odd"]);
}
/// `PlayerEntry.TOOLTIP_DELAY` (500 ms): the row buttons' tooltips wait
/// while container item tooltips do not. Moving to another button, or
/// away and back, starts the wait again.
#[test]
fn a_row_buttons_tooltip_waits_half_a_second() {
let start = Instant::now();
let at = |ms| start + Duration::from_millis(ms);
let mut delay = TooltipDelay::default();
let tip = Tip::Toggle {
id: 7,
hidden: false,
};
assert_eq!(delay.shown(Some(tip), at(0)), None);
assert_eq!(delay.shown(Some(tip), at(499)), None);
assert_eq!(delay.shown(Some(tip), at(500)), None, "500 ms is not > 500");
assert_eq!(delay.shown(Some(tip), at(501)), Some(tip));
// Another row button is another widget: its own wait.
let other = Tip::Toggle {
id: 8,
hidden: true,
};
assert_eq!(delay.shown(Some(other), at(502)), None);
assert_eq!(delay.shown(Some(other), at(1002)), None);
assert_eq!(delay.shown(Some(other), at(1003)), Some(other));
// Leaving hides it at once; coming back starts over.
assert_eq!(delay.shown(None, at(1004)), None);
assert_eq!(delay.shown(Some(tip), at(1005)), None);
assert_eq!(delay.shown(Some(tip), at(1505)), None);
assert_eq!(delay.shown(Some(tip), at(1506)), Some(tip));
}
}
@@ -408,7 +408,7 @@ impl TimelineUi {
let tx = start_x + sec * px_per_sec;
if tx <= start_x + w {
// Major tick line
style::fill(batch, atlas, [tx, y + 8.0, 1.0, 8.0], style::HEAD_RULE);
style::fill(batch, atlas, [tx, y + 8.0, 1.0, 8.0], style::head_rule());
// Label every 2 or 5 seconds depending on width
let is_labeled = if total_secs > 60.0 {
(sec as u32).is_multiple_of(10)
@@ -424,7 +424,7 @@ impl TimelineUi {
"horizon.studio.timeline.seconds",
&[Text::literal(seconds)],
);
batch.text_styled(atlas, &label, tx + 2.0, y + 1.0, style::SOFT, true);
batch.text_styled(atlas, &label, tx + 2.0, y + 1.0, style::soft(), true);
}
}
sec += sec_step;
@@ -459,7 +459,7 @@ impl TimelineUi {
// Base lane well
let lane_rect = [start_x, y, track_w, bar_h];
style::fill(batch, atlas, lane_rect, 0xFF14_171F);
style::ring(batch, atlas, lane_rect, style::ROW_RULE);
style::ring(batch, atlas, lane_rect, style::row_rule());
let total_dur = self.duration_ms.max(1) as f32;
@@ -514,7 +514,7 @@ impl TimelineUi {
&scene_title,
seg_x + 5.0,
y + 5.0,
style::MUTED,
style::muted(),
true,
);
}
@@ -577,7 +577,7 @@ impl TimelineUi {
let px = start_x + norm * track_w;
// Scrubber line from ruler through bottom of tracks
style::fill(batch, atlas, [px - 1.0, y, 2.0, h], style::ACCENT);
style::fill(batch, atlas, [px - 1.0, y, 2.0, h], style::accent());
// Inverted triangle marker at ruler top
let tri_color = style::WHITE;
+8 -4
View File
@@ -178,7 +178,7 @@ impl TopBarUi {
);
}
let color = if open {
style::ACCENT
style::accent()
} else if hovered {
style::WHITE
} else {
@@ -220,7 +220,7 @@ impl TopBarUi {
if pause_hovered {
style::WHITE
} else {
style::MUTED
style::muted()
},
true,
);
@@ -339,7 +339,11 @@ impl TopBarUi {
&Text::translate(key, &[]),
x + 8.0,
y + 6.0,
if hovered { style::WHITE } else { style::MUTED },
if hovered {
style::WHITE
} else {
style::muted()
},
true,
);
if !shortcut.is_empty() {
@@ -350,7 +354,7 @@ impl TopBarUi {
&shortcut,
x + menu_width - shortcut_width - 8.0,
y + 6.0,
style::SOFT,
style::soft(),
true,
);
}
+1 -1
View File
@@ -4707,7 +4707,7 @@ Would you like to enable the Narrator or visit the Accessibility Settings?",
}
}
/// The server auto-reconnect retries now, once its countdown is over.
pub fn due_retry(&mut self) -> Option<horizon_hooks::Reconnect> {
pub fn due_retry(&mut self) -> Option<crate::reconnect::Retry> {
match &mut self.mode {
Mode::Multiplayer(screen) => screen.due_retry(),
_ => None,
+111 -46
View File
@@ -1,6 +1,6 @@
//! The design system's widgets drawn in the game (`crates/horizon-theme`:
//! palette, type scale, radii, control sizes, motion and the bundled fonts;
//! `docs/design-system.md`). Every menu is vanilla's (`ui`); what is left
//! The design system's widgets drawn in the game: palette, type scale, radii,
//! control sizes and fonts from `menu_style`, given at startup (in the official
//! game `crates/horizon-theme`, `docs/design-system.md`). Every menu is vanilla's (`ui`); what is left
//! here draws Horizon's own overlays that vanilla has no screen for: the
//! on-screen keyboard a controller types with (`osk`), plus the
//! `horizon.txt` preferences.
@@ -39,44 +39,76 @@ pub(crate) fn set_preference(dir: &Path, key: &str, value: Option<&str>) {
let _ = std::fs::write(dir.join("horizon.txt"), out);
}
/// The dark palette (`horizon_theme::DARK`) in the HUD's colour encoding, plus the
/// The overlays' palette (`menu_style`) in the HUD's colour encoding, plus the
/// translucent plates the overlays draw over the game.
pub(crate) mod color {
use horizon_theme::DARK as P;
pub const fn hex(c: u32, a: u8) -> u32 {
(a as u32) << 24 | (c & 0xff) << 16 | (c >> 8 & 0xff) << 8 | (c >> 16 & 0xff)
use horizon_client_render::menu_style::{self, OverlayColours};
pub use menu_style::hud_colour as hex;
fn p() -> &'static OverlayColours {
&menu_style::get().overlay
}
/// The page, under every veil.
pub const BG: u32 = hex(P.page, 255);
pub fn bg() -> u32 {
hex(p().page, 255)
}
/// Hover plates, secondary buttons, fields, the segmented track: the
/// dark palette's soft plate, as ink over the veil so the plates read the
/// same over any backdrop (over the page colour they are `plate_soft`).
pub const RAISED: u32 = hex(P.ink, 20);
pub fn raised() -> u32 {
hex(p().ink, 20)
}
/// Pressed plates and a switch that is off (`plate`, the same way).
pub const HOVER: u32 = hex(P.ink, 36);
pub fn hover() -> u32 {
hex(p().ink, 36)
}
/// One-pixel rules between rows, slider tracks.
pub const BORDER: u32 = hex(P.rule, 255);
pub fn border() -> u32 {
hex(p().rule, 255)
}
/// Text and selection (`ink`).
pub const TEXT: u32 = hex(P.ink, 255);
pub fn text() -> u32 {
hex(p().ink, 255)
}
/// Values and secondary text (`ink_soft`).
pub const MUTED: u32 = hex(P.ink_soft, 255);
pub const ACCENT: u32 = hex(P.accent, 255);
pub const ON_ACCENT: u32 = hex(P.on_accent, 255);
pub fn muted() -> u32 {
hex(p().ink_soft, 255)
}
pub fn accent() -> u32 {
hex(p().accent, 255)
}
pub fn on_accent() -> u32 {
hex(p().on_accent, 255)
}
pub(crate) fn with_alpha(c: u32, a: f32) -> u32 {
let base = (c >> 24) as f32;
(c & 0x00ff_ffff) | ((base * a.clamp(0.0, 1.0)).round() as u32) << 24
}
}
/// The launcher's type scale and shapes (`horizon_theme`), in dp.
pub(crate) const SMALL: f32 = horizon_theme::CAPTION.px;
pub(crate) const BODY: f32 = horizon_theme::LABEL.px;
/// The boot screen's wordmark (`DISPLAY`) and console lines (`MICRO`).
pub(crate) const DISPLAY: f32 = horizon_theme::DISPLAY.px;
pub(crate) const MICRO: f32 = horizon_theme::MICRO.px;
/// Fields; plates (hover rows, selection); pills (buttons, segments).
pub(crate) const RADIUS: f32 = horizon_theme::RADIUS;
pub(crate) const RADIUS_PLATE: f32 = horizon_theme::RADIUS_PLATE;
/// The type scale and shapes (`menu_style`), in dp.
pub(crate) fn sizes() -> &'static horizon_client_render::menu_style::Sizes {
&horizon_client_render::menu_style::get().sizes
}
pub(crate) fn small() -> f32 {
sizes().caption
}
pub(crate) fn body() -> f32 {
sizes().label
}
/// The boot screen's wordmark (`display`) and console lines (`micro`).
fn display() -> f32 {
sizes().display
}
fn micro() -> f32 {
sizes().micro
}
/// Fields; plates (hover rows, selection).
pub(crate) fn radius() -> f32 {
sizes().radius
}
pub(crate) fn radius_plate() -> f32 {
sizes().radius_plate
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
pub(crate) enum Face {
@@ -87,20 +119,34 @@ pub(crate) enum Face {
/// Counts, timestamps and the boot screen's console lines.
Mono,
}
#[cfg(not(test))]
const TEST_FONTS: Option<horizon_client_render::menu_style::Fonts> = None;
#[cfg(test)]
const TEST_FONTS: Option<horizon_client_render::menu_style::Fonts> =
Some(horizon_client_render::menu_style::Fonts {
regular: horizon_theme::fonts::GEIST_REGULAR,
medium: horizon_theme::fonts::GEIST_MEDIUM,
semibold: horizon_theme::fonts::GEIST_SEMIBOLD,
mono: horizon_theme::fonts::GEIST_MONO,
});
struct Fonts {
faces: [FontRef<'static>; 4],
}
impl Fonts {
fn load() -> Self {
let f = |b: &'static [u8]| FontRef::try_from_slice(b).expect("bundled font");
Self {
/// The faces given in `menu_style`; none given, none (unit tests take the design system's).
fn load() -> Option<Self> {
let given = horizon_client_render::menu_style::get()
.fonts
.or(TEST_FONTS)?;
let f = |b: &'static [u8]| FontRef::try_from_slice(b).expect("given font");
Some(Self {
faces: [
f(horizon_theme::fonts::GEIST_REGULAR),
f(horizon_theme::fonts::GEIST_MEDIUM),
f(horizon_theme::fonts::GEIST_SEMIBOLD),
f(horizon_theme::fonts::GEIST_MONO),
f(given.regular),
f(given.medium),
f(given.semibold),
f(given.mono),
],
}
})
}
fn face(&self, f: Face) -> &FontRef<'static> {
&self.faces[f as usize]
@@ -142,7 +188,8 @@ struct Cached {
}
/// Glyphs and corner masks rasterized into the atlas region `horizon/ui-text`.
struct Cache {
fonts: Fonts,
/// None: no fonts were given, and text draws nothing.
fonts: Option<Fonts>,
entries: HashMap<Key, Cached>,
shelf: (u32, u32, u32),
pending: Vec<crate::font::Patch>,
@@ -230,9 +277,16 @@ impl Cache {
if let Some(c) = self.entries.get(&key) {
return *c;
}
let scale = self.fonts.scale(face, f32::from(size) / 4.0);
let Some(fonts) = &self.fonts else {
return Cached {
rect: None,
left: 0.0,
top: 0.0,
};
};
let scale = fonts.scale(face, f32::from(size) / 4.0);
let glyph = id.with_scale_and_position(scale, ab_glyph::point(0.0, 0.0));
let made = match self.fonts.face(face).outline_glyph(glyph) {
let made = match fonts.face(face).outline_glyph(glyph) {
Some(outline) => {
let b = outline.px_bounds();
let (w, h) = (b.width() as u32, b.height() as u32);
@@ -459,14 +513,17 @@ impl<'a> Ui<'a> {
let atlas = self.atlas;
let gui = self.scale;
with_cache(atlas, |c| {
let scale = c.fonts.scale(face, px);
let Some(fonts) = &c.fonts else {
return (0.0, 0.0, 0.0, Vec::new());
};
let scale = fonts.scale(face, px);
let (ascent, descent) = {
let f = c.fonts.face(face).as_scaled(scale);
let f = fonts.face(face).as_scaled(scale);
(f.ascent(), f.descent())
};
let chars: Vec<(char, Option<crate::font::Glyph>)> = unformatted(text)
.map(|ch| {
let pack = (!ch.is_whitespace() && c.fonts.face(face).glyph_id(ch).0 == 0)
let pack = (!ch.is_whitespace() && fonts.face(face).glyph_id(ch).0 == 0)
.then(|| atlas.font.glyph(ch))
// The pack lacks it too (its missing glyph): Geist's box, like vanilla's.
.filter(|g| g.rect.is_some() || g.right <= 0.0);
@@ -491,7 +548,10 @@ impl<'a> Ui<'a> {
continue;
}
let (id, advance, kern) = {
let font = c.fonts.face(face);
let Some(fonts) = &c.fonts else {
break;
};
let font = fonts.face(face);
let id = font.glyph_id(ch);
let f = font.as_scaled(scale);
(id, f.h_advance(id), prev.map_or(0.0, |p| f.kern(p, id)))
@@ -606,8 +666,8 @@ pub fn draw_boot(atlas: &HudAtlas, batch: &mut HudBatch, logs: &[String]) {
/// The wordmark with its tagline, centred.
fn wordmark(atlas: &HudAtlas, batch: &mut HudBatch) {
let mut ui = Ui::new(atlas, batch, (0.0, 0.0));
let size = (ui.w / 16.0).clamp(DISPLAY, 96.0);
let small = (size * 0.25).clamp(MICRO, 18.0);
let size = (ui.w / 16.0).clamp(display(), 96.0);
let small = (size * 0.25).clamp(micro(), 18.0);
let block = size * 1.45 + small * 0.6 + small * 1.5;
let top = (ui.h - block) / 2.0;
ui.text_centered(Face::SemiBold, size, ui.w / 2.0, top, BOOT_TEXT, "Horizon");
@@ -629,7 +689,7 @@ pub fn draw_boot_log(atlas: &HudAtlas, batch: &mut HudBatch, logs: &[String]) {
return;
}
let mut ui = Ui::new(atlas, batch, (0.0, 0.0));
let (size, line, pad) = (MICRO, MICRO * 1.5, 16.0);
let (size, line, pad) = (micro(), micro() * 1.5, 16.0);
let max_w = ui.w / 2.0;
let max_rows = (((ui.h - pad) / line) as usize).clamp(1, 24);
let mut y = ui.h - pad - line;
@@ -696,7 +756,12 @@ mod tests {
let atlas = HudAtlas::build(Path::new("/nonexistent/horizon-test-assets")).unwrap();
reset_atlas();
let draw = |cache: &mut Cache| {
let glyph = cache.fonts.face(Face::Regular).glyph_id('A');
let glyph = cache
.fonts
.as_ref()
.unwrap()
.face(Face::Regular)
.glyph_id('A');
assert!(
cache
.glyph(&atlas, Face::Regular, glyph, 16.0)
@@ -782,7 +847,7 @@ mod tests {
let lines = wrapping_lines(
&ui,
Face::Mono,
MICRO,
micro(),
"the quick brown fox jumps over the lazy dog",
90.0,
);
@@ -790,7 +855,7 @@ mod tests {
assert!(
lines
.iter()
.all(|l| ui.measure(Face::Mono, MICRO, l) <= 90.0),
.all(|l| ui.measure(Face::Mono, micro(), l) <= 90.0),
"every wrapped line fits"
);
assert_eq!(
@@ -812,7 +877,7 @@ mod tests {
fn drawn(atlas: &HudAtlas, text: &str) -> (f32, Vec<([f32; 2], [f32; 2])>) {
let mut batch = HudBatch::new([1280, 720]);
let mut ui = Ui::new(atlas, &mut batch, (0.0, 0.0));
let w = ui.text_line(Face::Medium, BODY, 20.0, 10.0, 10.0, color::TEXT, text);
let w = ui.text_line(Face::Medium, body(), 20.0, 10.0, 10.0, color::text(), text);
let quads = batch
.vertices
.iter()
-93
View File
@@ -1,93 +0,0 @@
//! Container screens (docs/layers.md, "Container screens"): input and drawing on an open container
//! screen (the inventory, chests, furnaces, crafting tables...) for a crate above the engine, as
//! Mouse Tweaks and sorting need. The engine implements [`Container`] for the length of one call
//! of a hook (`Hooks::container_press` and the others); everything in it is plain data.
/// A slot of the open screen: opaque, stable while the screen is open.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
pub struct SlotId(pub u32);
/// What a slot is for.
#[non_exhaustive]
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Role {
/// The player's hotbar.
Hotbar,
/// The player's main inventory, above the hotbar.
Inventory,
/// A slot that only holds items: a chest's, a hopper's, a mount's chest.
Storage,
/// A slot a recipe or a furnace reads: a crafting grid's, a furnace's input and fuel.
Input,
/// A slot that only gives: a crafting or furnace result.
Result,
/// The player's armour and off hand, a mount's saddle and armour.
Equipment,
Other,
}
/// A slot drawn on the screen.
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct Slot {
pub id: SlotId,
pub role: Role,
/// Its top left corner in GUI pixels, from the panel's.
pub x: f32,
pub y: f32,
}
/// The items in a slot or on the cursor.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct Stack {
/// The item's id (`minecraft:stone`).
pub item: String,
pub count: u32,
/// How many one slot holds of it.
pub max: u32,
/// Equal for stacks of the same item with the same components: those merge.
pub kind: u64,
}
/// One ordinary slot click, as vanilla's screen sends it.
#[non_exhaustive]
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Click {
/// Picks up, puts down or swaps (`PICKUP`), the left or right button.
Pick { right: bool },
/// The Shift click (`QUICK_MOVE`).
QuickMove,
/// Puts the cursor's stack, or one of it, in the slot as a one-slot drag (`QUICK_CRAFT`): a
/// server whose cursor is already empty ignores it, where a click would pick up the slot.
Put { one: bool },
}
/// The open screen's menu, for the length of one call. The engine implements it. The reads that
/// ask the menu's rules take `&mut self`, as the engine's do; drawing gets the others only.
pub trait Container {
/// Its menu type: `inventory` for the player's own, else the registry name the server opened
/// (`minecraft:generic_9x3`, `minecraft:furnace`...).
fn menu(&self) -> &str;
/// Every slot drawn, in the screen's order, in every menu: a crate keeps to the menus it acts
/// in, by [`Container::menu`] (Mouse Tweaks stays out of those whose slots cost or trigger
/// something: anvils, enchanting, merchants, beacons, crafters). Creative's tabs are no
/// container screen.
fn slots(&self) -> Vec<Slot>;
fn stack(&mut self, slot: SlotId) -> Option<Stack>;
/// The stack on the cursor.
fn carried(&self) -> Option<Stack>;
/// Whether `slot` takes one more of `of`, by the menu's rules (`mayPlace`, the slot's limit).
fn takes_one(&mut self, slot: SlotId, of: &Stack) -> bool;
/// Where a Shift click on `slot` would put its items (`quickMoveStack`), first first.
fn landing(&mut self, slot: SlotId) -> Vec<SlotId>;
/// One ordinary click. False when nothing took it.
fn click(&mut self, slot: SlotId, click: Click) -> bool;
/// How many slots vanilla's drag of the cursor's stack spreads over now (0 while none runs).
fn spread(&self) -> usize;
/// The cursor, in GUI pixels from the panel's corner.
fn cursor(&self) -> (f32, f32);
/// Whether Shift is held.
fn shift(&self) -> bool;
/// The creative tabs' order of the stack in `slot` (`u32::MAX` when no tab lists it) and the
/// name it shows: how sorting ranks it.
fn order(&mut self, slot: SlotId) -> (u32, String);
}
-57
View File
@@ -1,57 +0,0 @@
//! HUD drawing (docs/layers.md, "Platform registries"): a crate above the engine draws on the HUD
//! where the extras do, in the corners they share, without an engine file naming it. With no layer
//! registered the engine builds no canvas.
use crate::TooLate;
use crate::registry::Registry;
/// A corner of the screen. Boxes placed in one stack away from its edge.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Corner {
TopLeft,
TopRight,
BottomLeft,
BottomRight,
}
/// What a layer draws with, in GUI pixels (the player's GUI scale applied). Colours are
/// `0xAARRGGBB`.
pub trait Canvas {
/// The screen's width and height.
fn size(&self) -> (f32, f32);
/// The text of a translation key in the player's language.
fn tr(&self, key: &str) -> String;
/// How wide `text` is drawn.
fn text_width(&self, text: &str) -> f32;
/// A line of text from its top-left corner, with the game's text shadow or not.
fn text(&mut self, x: f32, y: f32, text: &str, argb: u32, shadow: bool);
/// A filled rectangle.
fn fill(&mut self, x: f32, y: f32, w: f32, h: f32, argb: u32);
/// Where a `w` × `h` box goes in `corner`: below (or above) what the corner holds already,
/// the minimap, the PvP widgets and mods' boxes. `None` while the corner is hidden (the debug
/// screen covers the top left): draw nothing there then.
fn place(&mut self, corner: Corner, w: f32, h: f32) -> Option<(f32, f32)>;
}
/// Something a crate draws on the HUD, every frame the HUD is drawn (not with F1, nor under a
/// screen that hides it). It reads its own state and must return quickly.
pub trait Layer: Sync {
fn draw(&self, canvas: &mut dyn Canvas);
}
static LAYERS: Registry<dyn Layer> = Registry::new();
/// Adds `layer` to the HUD, above the layers registered before it. Before the game starts, as
/// [`crate::register`].
pub fn register(layer: &'static dyn Layer) -> Result<(), TooLate> {
LAYERS.register(layer)
}
/// The engine's side. Only the engine calls it.
pub mod engine {
use super::*;
/// Every registered layer, sealing the list on the first call.
pub fn all() -> &'static [&'static dyn Layer] {
LAYERS.all()
}
}
+2 -315
View File
@@ -13,22 +13,14 @@
//! does the matching thing.
//!
//! The platform registries (docs/layers.md, "Platform registries") have the same shape:
//! [`settings`] for the settings each crate keeps in its own lines, [`pages`] for its settings
//! pages, [`hud`] for what it draws on the HUD.
//! [`settings`] for the settings each crate keeps in its own lines.
#![forbid(unsafe_code)]
pub mod containers;
pub mod hud;
pub mod look;
pub mod pages;
mod registry;
pub mod settings;
use containers::{Container, SlotId};
use registry::Registry;
use std::cell::Cell;
use std::sync::atomic::{AtomicBool, Ordering};
use std::time::Instant;
/// What a crate above the engine hears from the game. Every method has an empty default:
/// implement only those you need. Hooks run on the game's thread (the render thread) unless a
@@ -45,49 +37,6 @@ pub trait Hooks: Sync {
/// The game is ending normally: its window closed and the world is saved. Not after a crash.
fn shutdown(&self) {}
/// This window starts connecting to a multiplayer server (the server list, a direct
/// connection, a Realm, a LAN world). Not single player's own server.
fn connecting(&self, _server: &Server<'_>) {}
/// The server let the player in.
fn joined(&self) {}
/// The player left the server on purpose: nothing to retry.
fn left(&self) {}
/// The connection ended without the player leaving it (lost, closed, kicked, refused). A hook
/// may plan another attempt: the engine shows its countdown on the Disconnected screen, with
/// Cancel, and connects again when it is due. Every hook hears the end; the first plan wins.
fn ended(&self, _end: &Ended<'_>) -> Option<Reconnect> {
None
}
// An open container screen (`containers`): each input hook returns whether it took the
// input, and then vanilla's handling of it doesn't run. The first hook that takes it wins.
/// A mouse button went down over the panel, on `slot` or none.
fn container_press(&self, _c: &mut dyn Container, _slot: Option<SlotId>, _right: bool) -> bool {
false
}
/// The cursor moved with a button held, now over `slot` or none.
fn container_drag(&self, _c: &mut dyn Container, _slot: Option<SlotId>) -> bool {
false
}
/// The button went up. Every hook hears it.
fn container_release(&self, _c: &mut dyn Container) {}
/// The wheel turned `steps` notches over `slot` or none (up is positive; a trackpad turns it
/// in fractions).
fn container_wheel(&self, _c: &mut dyn Container, _slot: Option<SlotId>, _steps: f32) -> bool {
false
}
/// A key or a mouse button the screen got, by its name (`key.keyboard.r`, `key.mouse.middle`),
/// with the cursor over `slot` or none.
fn container_key(&self, _c: &mut dyn Container, _slot: Option<SlotId>, _key: &str) -> bool {
false
}
/// Draws over the panel, from its top left corner, after the slots and before the tooltip.
fn container_draw(&self, _c: &dyn Container, _canvas: &mut dyn hud::Canvas) {}
}
/// What [`Hooks::started`] gets.
@@ -103,77 +52,6 @@ impl<'a> Started<'a> {
}
}
/// What [`Hooks::connecting`] gets.
#[non_exhaustive]
pub struct Server<'a> {
/// As the player gave it (`host[:port]`).
pub address: &'a str,
/// The player name the connection uses.
pub name: &'a str,
/// Found on the local network.
pub lan: bool,
}
impl<'a> Server<'a> {
pub fn new(address: &'a str, name: &'a str, lan: bool) -> Self {
Self { address, name, lan }
}
}
/// What [`Hooks::ended`] gets.
#[non_exhaustive]
pub struct Ended<'a> {
/// The reason shown on the Disconnected screen.
pub reason: &'a str,
/// Its translation key, when it has one (`disconnect.timeout`, `multiplayer.disconnect.kicked`).
pub key: Option<&'a str>,
/// The server sent it (a disconnect packet), rather than the connection failing.
pub from_server: bool,
}
impl<'a> Ended<'a> {
pub fn new(reason: &'a str, key: Option<&'a str>, from_server: bool) -> Self {
Self {
reason,
key,
from_server,
}
}
}
/// Another attempt at a server, planned by [`Hooks::ended`].
#[derive(Clone, Debug, PartialEq)]
pub struct Reconnect {
pub address: String,
pub name: String,
pub lan: bool,
/// When to connect.
pub at: Instant,
/// This attempt, 1-based, of `attempts`.
pub attempt: usize,
pub attempts: usize,
}
impl Reconnect {
/// Whole seconds left, rounded up.
pub fn seconds_left(&self, now: Instant) -> u64 {
let left = self.at.saturating_duration_since(now);
left.as_secs() + u64::from(left.subsec_nanos() > 0)
}
pub fn due(&self, now: Instant) -> bool {
now >= self.at
}
/// The Disconnected screen's countdown line.
pub fn text(&self, now: Instant) -> String {
format!(
"Reconnecting in {} s (attempt {} of {})",
self.seconds_left(now),
self.attempt,
self.attempts
)
}
}
static HOOKS: Registry<dyn Hooks> = Registry::new();
/// Why [`register`] refused a hook.
@@ -200,34 +78,15 @@ fn all() -> &'static [&'static dyn Hooks] {
HOOKS.all()
}
thread_local! {
static SEAT: Cell<usize> = const { Cell::new(0) };
}
/// The splitscreen player whose turn it is: 0 for P1 (and without splitscreen), 1 for P2...
/// Splitscreen players are one process, each `Game` in turn on the game's thread, so a crate's
/// statics are shared by them: a crate that keeps per-player state (settings, pages, HUD) indexes
/// it by this. A player keeps its seat while it plays. Other threads always see 0.
pub fn seat() -> usize {
SEAT.get()
}
/// The engine's side: where it calls the hooks. Only the engine calls these.
pub mod engine {
use super::*;
/// The player whose turn starts on this thread ([`seat`]).
pub fn set_seat(seat: usize) {
SEAT.set(seat);
}
/// Seals every list (hooks and the registries); their first use does it anyway. Call where
/// registration ends.
pub fn seal() {
all();
crate::settings::all();
crate::pages::engine::all();
crate::hud::engine::all();
}
/// [`Hooks::started`].
@@ -257,79 +116,6 @@ pub mod engine {
hooks.shutdown();
}
}
/// [`Hooks::connecting`].
pub fn connecting(server: &Server<'_>) {
for hooks in all() {
hooks.connecting(server);
}
}
/// [`Hooks::joined`].
pub fn joined() {
for hooks in all() {
hooks.joined();
}
}
/// [`Hooks::left`].
pub fn left() {
for hooks in all() {
hooks.left();
}
}
/// Whether a container screen's input reaches a hook: false while none is registered, and
/// the engine builds no `Container` then.
pub fn containers() -> bool {
!all().is_empty()
}
/// [`Hooks::container_press`]: true when a hook took it.
pub fn container_press(c: &mut dyn Container, slot: Option<SlotId>, right: bool) -> bool {
all().iter().any(|h| h.container_press(c, slot, right))
}
/// [`Hooks::container_drag`].
pub fn container_drag(c: &mut dyn Container, slot: Option<SlotId>) -> bool {
all().iter().any(|h| h.container_drag(c, slot))
}
/// [`Hooks::container_release`], to every hook.
pub fn container_release(c: &mut dyn Container) {
for hooks in all() {
hooks.container_release(c);
}
}
/// [`Hooks::container_wheel`].
pub fn container_wheel(c: &mut dyn Container, slot: Option<SlotId>, steps: f32) -> bool {
all().iter().any(|h| h.container_wheel(c, slot, steps))
}
/// [`Hooks::container_key`].
pub fn container_key(c: &mut dyn Container, slot: Option<SlotId>, key: &str) -> bool {
all().iter().any(|h| h.container_key(c, slot, key))
}
/// [`Hooks::container_draw`].
pub fn container_draw(c: &dyn Container, canvas: &mut dyn crate::hud::Canvas) {
for hooks in all() {
hooks.container_draw(c, canvas);
}
}
/// [`Hooks::ended`]: every hook hears it, and the first plan is the one kept.
pub fn ended(end: &Ended<'_>) -> Option<Reconnect> {
let mut plan = None;
for hooks in all() {
let mine = hooks.ended(end);
if plan.is_none() {
plan = mine;
}
}
plan
}
}
#[cfg(test)]
@@ -351,126 +137,27 @@ mod tests {
fn published(&self, open: bool) {
self.0.lock().unwrap().push(format!("published {open}"));
}
fn ended(&self, end: &Ended<'_>) -> Option<Reconnect> {
self.0.lock().unwrap().push(format!("ended {}", end.reason));
None
}
}
/// Plans an attempt numbered by its field.
struct Planner(usize);
impl Hooks for Planner {
fn ended(&self, end: &Ended<'_>) -> Option<Reconnect> {
Some(Reconnect {
address: end.reason.to_owned(),
name: String::new(),
lan: false,
at: Instant::now(),
attempt: self.0,
attempts: 5,
})
}
}
static FIRST: Planner = Planner(1);
static SECOND: Planner = Planner(2);
/// Implements nothing: the defaults do nothing.
struct Silent;
impl Hooks for Silent {}
static RECORDER: Recorder = Recorder(StdMutex::new(Vec::new()));
/// Takes its key on a container screen; notes what reached it.
struct Keys(&'static str, StdMutex<Vec<String>>);
impl Hooks for Keys {
fn container_key(&self, _: &mut dyn Container, _: Option<SlotId>, key: &str) -> bool {
self.1.lock().unwrap().push(format!("key {key}"));
key == self.0
}
fn container_release(&self, _: &mut dyn Container) {
self.1.lock().unwrap().push("release".into());
}
}
static SORT: Keys = Keys("key.keyboard.r", StdMutex::new(Vec::new()));
static ALSO_R: Keys = Keys("key.keyboard.r", StdMutex::new(Vec::new()));
/// A screen with nothing in it.
struct Empty;
impl Container for Empty {
fn menu(&self) -> &str {
"inventory"
}
fn slots(&self) -> Vec<containers::Slot> {
Vec::new()
}
fn stack(&mut self, _: SlotId) -> Option<containers::Stack> {
None
}
fn carried(&self) -> Option<containers::Stack> {
None
}
fn takes_one(&mut self, _: SlotId, _: &containers::Stack) -> bool {
false
}
fn landing(&mut self, _: SlotId) -> Vec<SlotId> {
Vec::new()
}
fn click(&mut self, _: SlotId, _: containers::Click) -> bool {
false
}
fn spread(&self) -> usize {
0
}
fn cursor(&self) -> (f32, f32) {
(0.0, 0.0)
}
fn shift(&self) -> bool {
false
}
fn order(&mut self, _: SlotId) -> (u32, String) {
(u32::MAX, String::new())
}
}
#[test]
fn registered_hooks_hear_the_game_and_late_ones_are_refused() {
register(&RECORDER).unwrap();
register(&Silent).unwrap();
register(&FIRST).unwrap();
register(&SECOND).unwrap();
register(&SORT).unwrap();
register(&ALSO_R).unwrap();
engine::started(&Started::new("0.2.31"));
// Changes only: the same state every frame is said once.
for open in [false, true, true, true, false, false] {
engine::published(open);
}
// Every hook hears the end; the first plan registered is the one kept.
let plan = engine::ended(&Ended::new("lost", None, false)).unwrap();
assert_eq!(plan.attempt, 1);
// A container screen's key goes to the first hook that takes it, its release to all.
assert!(engine::containers());
assert!(!engine::container_key(&mut Empty, None, "key.keyboard.e"));
assert!(engine::container_key(&mut Empty, None, "key.keyboard.r"));
engine::container_release(&mut Empty);
assert_eq!(
*SORT.1.lock().unwrap(),
["key key.keyboard.e", "key key.keyboard.r", "release"]
);
assert_eq!(
*ALSO_R.1.lock().unwrap(),
["key key.keyboard.e", "release"],
"taken before it"
);
engine::shutdown();
assert_eq!(
*RECORDER.0.lock().unwrap(),
[
"started 0.2.31",
"published true",
"published false",
"ended lost"
]
["started 0.2.31", "published true", "published false"]
);
assert_eq!(register(&Silent), Err(TooLate));
}
Loaded 100 of 126 files, more files were not shown because too many files have changed in this diff. Show more