Compare commits

..
Author SHA1 Message Date
Horizon agentandClaude Opus 5.5 1ff33fd853 Merge origin/main into split/cosmetics-settings
extras.rs: main dropped DiscordPresence, this branch dropped the cosmetics
rows: both removals kept. layers.json: main dropped client-lib -> theme,
this branch dropped client-render -> cosmetics: both removals kept.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 23:51:45 +01:00
Ludovic Marie 537539fe58 Merge pull request #14291 from horizon-rust/split/cosmetics-fx
🤖 Agent: step 2b-iii of spec #14091 moves companions, trails and auras onto the hooks; stacked on #14282.
2026-11-09 23:37:32 +01:00
Ludovic Marie cbbd362eed Merge pull request #14011 from v0ntr3n/fix/stand-command-hurt-events-freeze-immunity
🤖 Agent: the explosion/ignite/burn branches now attribute their game events to the cause in place and ignition clears TicksFrozen; damage_immunity checks the stand's active slots first. Reworked so no dead copy remains; 15/15 stand tests pass with resources.
2026-11-09 23:37:28 +01:00
Ludovic Marie 1430b9990a Merge pull request #14381 from horizon-rust/split/discord-setting
🤖 Agent: the Discord switch keeps its discordPresence line in horizon-extras.txt through horizon-discord's settings store, so players keep their choice while the row moves to Extras > Multiplayer; e2e 4/4 and verify green on this head.
2026-11-09 23:37:24 +01:00
Ludovic Marie a84f637dd6 Merge pull request #14282 from horizon-rust/split/cosmetics-fx-api
🤖 Agent: step 2b-ii of spec #14091 gives PlayerDraw motion, view, looks, particles and first_person so the cosmetics need nothing else from the engine; stacked on #14274.
2026-11-09 23:32:09 +01:00
Ludovic Marie efab4d1bbf Merge pull request #14274 from horizon-rust/split/cosmetics-own
🤖 Agent: step 2b-i of spec #14091 draws the player's own body and the inventory picture through PlayerDraw; stacked on #14265.
2026-11-09 23:23:44 +01:00
Ludovic Marie 7b50bae250 Merge pull request #13958 from horizon-rust/layers/window-icon-hook
🤖 Agent: the engine takes the window icon from the layer above, and entry.rs keeps main's pack_formats::install() after horizon_look::give(), so the icon is still given before any window opens. The head now also carries the menu_style stack (#14026, #14027, #14029): graphics_style's and ui_theme's colours are one OnceLock read per call from menu_style, Horizon's BLOCKS_DARK values come from horizon_look.rs at startup so the menus look the same, and the renderer and client lib no longer depend on horizon-theme. Merges cleanly with main.
2026-11-09 23:19:07 +01:00
Ludovic Marie 3780c215a7 Merge pull request #14265 from horizon-rust/split/cosmetics-layers
🤖 Agent: step 2a-ii of spec #14091 draws the cosmetic items on the body through PlayerDraw instead of calling the cosmetics from the engine; stacked on #14214.
2026-11-09 23:19:02 +01:00
Ludovic Marie 3c853b46eb Merge pull request #14155 from horizon-rust/split/discord-crate
🤖 Agent: the presence now starts only from horizon-official's client_started hook, so horizon-client's own executable (plain builds, harnesses) never opens Discord's socket; the e2e passes 4/4 on the official binary and verify is green on this head.
2026-11-09 23:13:59 +01:00
Ludovic Marie 0b8d44458a Merge pull request #14026 from horizon-rust/layers/render-style
🤖 Agent: graphics_style's and ui_theme's colours are now one OnceLock read per call from menu_style; Horizon's BLOCKS_DARK values come from horizon_look.rs at startup, so the menus look the same; the renderer and client lib no longer depend on horizon-theme.
2026-11-09 22:32:43 +01:00
Horizon agentandClaude Opus 5.5 c21f627431 discord_presence_e2e: scroll the Extras page to the switch
Only the rows on screen are widgets; the Multiplayer group is below the fold
of an offscreen window.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 22:26:36 +01:00
Horizon agentandClaude Opus 5.5 62e62c21e6 horizon-discord: the switch is the first splitscreen player's (seat)
hooks::seat() is on main now: other players' settings neither load nor
save the line, and their Extras page has no row.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 22:02:53 +01:00
Horizon agent a6350aa8f9 Merge branch 'split/discord-crate' into split/discord-setting
# Conflicts:
#	crates/horizon-hooks/src/lib.rs
2026-11-09 22:02:16 +01:00
Horizon agent c817ed7fff Merge remote-tracking branch 'origin/main' into split/discord-crate
# Conflicts:
#	crates/horizon-client/Cargo.toml
#	crates/horizon-hooks/src/lib.rs
#	crates/horizon-platform/src/lib.rs
2026-11-09 22:02:02 +01:00
Horizon agentandClaude Opus 5.5 4e22c1ad9a rustfmt
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 21:27:08 +01:00
Horizon agentandClaude Opus 5.5 e1aa435188 horizon-discord: the Discord switch leaves the engine's Extras (layer plan M5)
The crate keeps its switch: a settings store for its discordPresence line of
horizon-extras.txt (players keep their choice) and a row in the Extras page's
Multiplayer group, which horizon-official registers. The engine loses
Extras::discord_presence, Item::DiscordPresence and the Horizon Settings
button; the hooks lose Activity::Hidden.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 21:19:29 +01:00
Ludovic Marie 128575e4e5 Merge pull request #14029 from horizon-rust/layers/client-lib-style
🤖 Agent: ui_theme's palette, sizes and Geist fonts come from menu_style; with no fonts given (NEUTRAL) the Horizon overlays measure and draw no text instead of panicking.
2026-11-09 21:15:21 +01:00
Horizon agent 536c0f938b Merge branch 'split/cosmetics-fx' into split/cosmetics-settings 2026-11-09 21:13:54 +01:00
Ludovic Marie cb870cf80d Merge pull request #14027 from horizon-rust/layers/render-theme-out
🤖 Agent: hud_kit's tween curve is pinned to horizon_theme::MOTION by a dev-dependency test, and the HDR logo reads the icon the window hook was given.
2026-11-09 21:11:27 +01:00
Horizon agentandClaude Opus 5.5 e138304d2e Put the aura test's item at the feet
It reused the crown's 18-pixel attach offset, which an aura may not have
(at most 6 pixels above the feet), so the item failed to parse.

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

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 21:08:57 +01:00
Horizon agentandClaude Opus 5.5 398bec3d6e Move the cosmetics settings out of the engine's Extras (spec #14091 step 5a)
The six cosmetics settings (my cosmetics, in single player, other players'
cosmetics, hide others' trails and auras, Horizon logos, show me as Horizon)
leave horizon-client-render's Extras for game/cosmetics/settings.rs:

- their horizon-extras.txt lines go through a settings Store (same keys, so
  players and the launcher's Wardrobe keep their choice; others' line still
  written only once chosen), always registered so a game without cosmetics
  keeps the Wardrobe's lines;
- their rows are a Place::Extras page section, shown while cosmetics are
  offered, in the same Cosmetics group at the end of the Extras page;
- the values live in the per-seat Seat, and the owner flag is read there
  instead of being copied into Extras each frame.

horizon-client-render no longer depends on horizon-cosmetics: the allowed
entry "horizon-client-render -> horizon-cosmetics" is removed.

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

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

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 20:27:38 +01:00
Horizon agent a5a60598e4 Merge branch 'split/cosmetics-fx-api' into split/cosmetics-fx 2026-11-09 20:20:28 +01:00
Horizon agent 0fd4e22139 Merge branch 'split/cosmetics-own' into split/cosmetics-fx-api
# Conflicts:
#	crates/horizon-hooks/src/lib.rs
2026-11-09 20:20:25 +01:00
Horizon agent ec19b6320a Merge branch 'split/cosmetics-layers' into split/cosmetics-own 2026-11-09 20:20:01 +01:00
Horizon agent 16e29a2774 Merge branch 'split/cosmetics-draw' into split/cosmetics-layers 2026-11-09 20:20:00 +01:00
Horizon agentandClaude Opus 5.5 d016678e24 cosmetics test: the prop's root is the item's, whatever its name
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 20:17:44 +01:00
Horizon agentandClaude Opus 5.5 3d9df204e9 Cosmetics: companions, trails and auras through the hooks; the game's own path removed (spec #14091 step 2b-iii)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 20:08:05 +01:00
Horizon agentandClaude Opus 5.5 984b7d8635 Hooks: PlayerDraw gets motion, view, looks and particles, and first_person (spec #14091 step 2b-ii)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 20:02:47 +01:00
Horizon agentandClaude Opus 5.5 afa60454b2 Cosmetics: the own body and the inventory picture through PlayerDraw (spec #14091 step 2b-i)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 19:56:59 +01:00
Horizon agentandClaude Opus 5.5 aa8592fbf9 Cosmetics: their items on the body drawn through PlayerDraw (spec #14091 step 2a-ii)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 19:52:20 +01:00
Horizon agentandClaude Opus 5.5 cef35e64e7 horizon-hooks: std's Mutex by its path after the merge
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 19:32:42 +01:00
Horizon agent 3a9b05d21d Merge remote-tracking branch 'origin/main' into split/discord-crate
# Conflicts:
#	crates/horizon-client-lib/Cargo.toml
#	crates/horizon-client/Cargo.toml
2026-11-09 19:31:04 +01:00
Horizon agentandClaude Opus 5.5 8aed306751 ui_theme: main's text_line test reads the body size and ink from the style
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 19:24:26 +01:00
Horizon agent bb19f898e4 Merge branch 'layers/render-theme-out' into layers/client-lib-style 2026-11-09 19:17:07 +01:00
Horizon agent bbc17333bd Merge branch 'layers/render-style' into layers/render-theme-out 2026-11-09 19:17:06 +01:00
Horizon agentandClaude Opus 5.5 8630bbed74 rustfmt
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 19:17:03 +01:00
Horizon agent 2c5baa6a60 Merge branch 'layers/render-theme-out' into layers/client-lib-style
# Conflicts:
#	crates/horizon-client/src/ui_theme.rs
2026-11-09 19:12:40 +01:00
Horizon agent ed35d477be Merge branch 'layers/render-style' into layers/render-theme-out
# Conflicts:
#	tools/layers.json
2026-11-09 19:10:41 +01:00
Horizon agent 7a236ba054 Merge branch 'layers/window-icon-hook' into layers/render-style
# Conflicts:
#	crates/horizon-client/src/client_lib.rs
2026-11-09 19:10:23 +01:00
Horizon agent bf125a1a69 Merge remote-tracking branch 'origin/main' into layers/window-icon-hook
# Conflicts:
#	crates/horizon-client/src/entry.rs
2026-11-09 19:09:55 +01:00
Horizon agentandClaude Opus 5.5 661a253ec4 Armor stand /damage: attribute and clear freeze in the existing branches
Main already attributes the hit branch and has entity_event_by. The
explosion, ignite and burn branches now take the cause in place, so the
separate refused function (which left them dead) is gone; causeDamage keeps
one name. The frost-walker test no longer shadows game() (it did not compile).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 19:04:13 +01:00
Horizon agent 7bfa76577f Merge remote-tracking branch 'origin/main' into w14011 2026-11-09 18:57:29 +01:00
Horizon agentandClaude Opus 5.5 8ac6a79035 rustfmt, Cargo.lock: horizon-discord
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 18:19:44 +01:00
Horizon agentandClaude Opus 5.5 153e0d20a4 horizon-discord: the hook's Client by its path
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 18:18:32 +01:00
Horizon agentandClaude Opus 5.5 01343707af horizon-discord: Discord Rich Presence leaves the engine for a Horizon crate
Layer plan M5: discord_presence.rs moves to the new Horizon crate
horizon-discord (file move), which the official executable registers on two
new platform hooks: Hooks::client_started (every game window, automated
runs included, never a dedicated server) and Hooks::activity (what the
player does, dispatched on change only, P1's). game/discord.rs becomes the
engine's game/activity.rs, which reports Activity (Hidden while the Discord
switch is off, until the switch moves to the settings store). The discord
Cargo feature goes: horizon-client's own executable has no presence.
discord_presence_e2e.py runs the official executable.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 18:11:42 +01:00
Horizon agentandClaude Opus 5.5 c873af989a Menu style: ui_theme's overlays take their palette, sizes and fonts from it
horizon-client-lib no longer depends on horizon-theme outside its tests
(layer plan M6). menu_style gains the overlays' colours, ui_theme's type
scale and shapes, and its fonts (none given: the overlays draw no text);
ui_theme, the on-screen keyboard, the missing-resources window and the
Windows boot paint read them. horizon_look gives Horizon's.

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

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

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 17:54:58 +01:00
Horizon agentandClaude Opus 5.5 b77e4c4924 horizon_look.rs: the module the previous commit declares
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 17:54:56 +01:00
Tran Anh Van 96aa9791e6 Isolate ArmorStand.hurtServer changes to avoid 3-way merge overlap 2026-11-09 23:12:17 +07:00
Horizon agentandClaude Opus 5.5 aa060c5fe1 Horizon's look is given from its own module
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 16:59:57 +01:00
Horizon agentandClaude Opus 5.5 07ff96c50c rustfmt
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 16:59:57 +01:00
Horizon agentandClaude Opus 5.5 19c23a96c6 rustfmt
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 16:59:57 +01:00
Horizon agentandClaude Opus 5.5 29d8c49bc2 Window icon: the engine takes it from the layer above
horizon-client-input's window code no longer embeds Horizon's icon: the
startup gives it (window::set_icon_png), and with_horizon_icon becomes
with_icon. horizon-client-input no longer depends on horizon-theme outside
its tests (layer plan M6).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 16:59:57 +01:00
Tran Anh Van c66936f747 Sync sculk entity_event_by and entity_killed_event_by with upstream main 2026-11-09 22:54:38 +07:00
Tran Anh Van 0a6874877d Sync Guest::may_build with upstream main 2026-11-09 22:49:53 +07:00
Tran Anh Van e2ad02a58a Complete ArmorStand.hurtServer game events, freeze clearing, and damage immunity 2026-11-09 22:46:25 +07:00
89 changed files with 3250 additions and 4155 deletions

No files matched your search

Generated
+10 -47
View File
@@ -706,25 +706,6 @@ dependencies = [
"cfg-if",
]
[[package]]
name = "crossbeam-deque"
version = "0.8.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "622f3fc73690be383c7214310406f28a90e6edeadc3cea882f9d71e495b9711a"
dependencies = [
"crossbeam-epoch",
"crossbeam-utils",
]
[[package]]
name = "crossbeam-epoch"
version = "0.9.21"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dc74980687109a3b14c72fd458107bf0baa1da1a1a805e178d15501ba9b86d9d"
dependencies = [
"crossbeam-utils",
]
[[package]]
name = "crossbeam-queue"
version = "0.3.14"
@@ -945,12 +926,6 @@ version = "1.0.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813"
[[package]]
name = "either"
version = "1.19.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0e9c71c2167ca323c882b99918929403426e2373ea17242ff5653e0d5e1058be"
[[package]]
name = "embedded-io"
version = "0.4.0"
@@ -1579,7 +1554,6 @@ dependencies = [
"open",
"png",
"pollster",
"rayon",
"rustix 1.1.5",
"serde",
"serde_json",
@@ -1610,7 +1584,6 @@ dependencies = [
"horizon-client-input",
"horizon-command",
"horizon-core",
"horizon-cosmetics",
"horizon-hdr",
"horizon-hooks",
"horizon-items",
@@ -1676,6 +1649,15 @@ dependencies = [
"ureq",
]
[[package]]
name = "horizon-discord"
version = "0.1.0"
dependencies = [
"horizon-platform",
"log",
"serde_json",
]
[[package]]
name = "horizon-dualsense"
version = "0.1.0"
@@ -1819,6 +1801,7 @@ dependencies = [
name = "horizon-official"
version = "0.1.0"
dependencies = [
"horizon-discord",
"horizon-friends",
"horizon-game",
"horizon-heartbeat",
@@ -3680,26 +3663,6 @@ dependencies = [
"objc2-quartz-core 0.3.2",
]
[[package]]
name = "rayon"
version = "1.12.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fb39b166781f92d482534ef4b4b1b2568f42613b53e5b6c160e24cfbfa30926d"
dependencies = [
"either",
"rayon-core",
]
[[package]]
name = "rayon-core"
version = "1.13.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "22e18b0f0062d30d4230b2e85ff77fdfe4326feb054b9783a3460d8435c8ab91"
dependencies = [
"crossbeam-deque",
"crossbeam-utils",
]
[[package]]
name = "rc2"
version = "0.8.1"
+1
View File
@@ -34,6 +34,7 @@ members = [
"crates/horizon-game",
"crates/horizon-official",
"crates/horizon-heartbeat",
"crates/horizon-discord",
"crates/horizon-official-servers",
"crates/horizon-bench",
"crates/horizon-friends",
+4 -2
View File
@@ -9,11 +9,13 @@ description = "The client's input layer: key bindings and their names, mouse, co
[dependencies]
horizon-client-core = { path = "../horizon-client-core" }
winit = "0.30.13"
# The window icon.
horizon-theme = { path = "../horizon-theme" }
png = "0.18"
arboard = { version = "3.6.1", default-features = false, features = ["wayland-data-control"] }
[dev-dependencies]
# The window icon test decodes Horizon's icon.
horizon-theme = { path = "../horizon-theme" }
[features]
controllers = []
+32 -10
View File
@@ -638,10 +638,30 @@ pub fn fullscreen_request(
}
}
/// Horizon's icon for the window's title bar and the task bar (X11 and Windows; Wayland takes
/// it from the `horizon.desktop` entry, macOS from the app bundle). `None` if it can't be read.
/// The PNG the windows show as their icon, given once at startup by the layer that brands the
/// game (the engine has none of its own).
static ICON_PNG: std::sync::OnceLock<&'static [u8]> = std::sync::OnceLock::new();
/// Give the windows their icon, a PNG: call it before the first window opens. A second call
/// keeps the first icon.
pub fn set_icon_png(png: &'static [u8]) {
let _ = ICON_PNG.set(png);
}
/// The icon given to [`set_icon_png`], if any.
pub fn icon_png() -> Option<&'static [u8]> {
ICON_PNG.get().copied()
}
/// The icon for the window's title bar and the task bar (X11 and Windows; Wayland takes it from
/// the `.desktop` entry, macOS from the app bundle). `None` with no icon given, or one that
/// can't be read.
pub fn icon() -> Option<winit::window::Icon> {
let mut decoder = png::Decoder::new(std::io::Cursor::new(horizon_theme::ICON_PNG));
decode_icon(icon_png()?)
}
fn decode_icon(png: &[u8]) -> Option<winit::window::Icon> {
let mut decoder = png::Decoder::new(std::io::Cursor::new(png));
decoder.ignore_checksums(true);
decoder.set_transformations(png::Transformations::EXPAND | png::Transformations::STRIP_16);
let mut reader = decoder.read_info().ok()?;
@@ -654,14 +674,15 @@ pub fn icon() -> Option<winit::window::Icon> {
winit::window::Icon::from_rgba(bytes, info.width, info.height).ok()
}
/// Apply Horizon's icon to every window. Windows has a separate taskbar icon slot from the small
/// window icon; requiring the decoded icon there prevents a generic taskbar icon from appearing.
pub fn with_horizon_icon(
attributes: winit::window::WindowAttributes,
) -> winit::window::WindowAttributes {
/// Apply the icon given to [`set_icon_png`] to a window. Windows has a separate taskbar icon slot
/// from the small window icon; requiring a given icon to decode there prevents a generic taskbar
/// icon from appearing.
pub fn with_icon(attributes: winit::window::WindowAttributes) -> winit::window::WindowAttributes {
#[cfg(not(target_os = "windows"))]
let icon = icon();
#[cfg(target_os = "windows")]
let icon = Some(icon.expect("Horizon's icon must be available on Windows"));
let icon =
icon_png().map(|png| decode_icon(png).expect("the window icon must decode on Windows"));
let attributes = attributes.with_window_icon(icon.clone());
#[cfg(target_os = "windows")]
@@ -739,7 +760,8 @@ mod tests {
}
#[test]
fn the_window_icon_decodes() {
assert!(icon().is_some());
assert!(decode_icon(horizon_theme::ICON_PNG).is_some());
assert!(decode_icon(b"not a png").is_none());
}
#[test]
+3 -9
View File
@@ -27,9 +27,6 @@ horizon-mcp = { path = "../horizon-mcp" }
# The server thread ahead of the workers (MinecraftServer.spin's Thread.setPriority).
horizon-optifine = { path = "../horizon-optifine" }
horizon-threads = { path = "../horizon-threads" }
# One pool of helper threads for the server tick's parallel phases (`parallel.rs`): threads made
# once, not for every phase of every tick.
rayon = "1.10"
horizon-hdr = { path = "../horizon-hdr" }
horizon-world = { path = "../horizon-world" }
horizon-protocol = { path = "../horizon-protocol" }
@@ -73,7 +70,6 @@ flate2 = { version = "1.1.10", default-features = false, features = ["zlib-rs",
mio = { version = "1.2.4", features = ["os-poll", "net"] }
# Shared chunk frames are found by one keyed 128-bit hash of their body (lan/conn.rs SharedFrames).
xxhash-rust = { version = "0.8.19", features = ["xxh3"] }
horizon-theme = { path = "../horizon-theme" }
# Opt-in data collection: the game keeps, the launcher sends (docs/telemetry.md).
horizon-telemetry = { path = "../horizon-telemetry", default-features = false }
horizon-bench = { path = "../horizon-bench" }
@@ -112,16 +108,12 @@ objc2-foundation = { version = "0.3", default-features = false, features = ["std
wgpu = { version = "30.0.1", default-features = false, features = ["gles"] }
[features]
default = ["audio", "controllers", "horizon-extras"]
default = ["audio", "controllers"]
# Sound output. Without it (and `controllers`) the program links neither libasound nor libudev on
# Linux: the server build (`--no-default-features`, released as horizon-engine; docs/server.md).
audio = ["horizon-client-render/audio"]
# Controllers: gilrs, and the DualSense's own output. Without it only `horizon-ctl pad`'s virtual pad.
controllers = ["dep:gilrs", "horizon-client-input/controllers"]
# Horizon's own parts still in this crate (docs/forking.md, docs/layers.md): the features below.
horizon-extras = ["discord"]
# "Playing Horizon" on the player's Discord profile (`discord_presence`).
discord = []
# Records the call sites of mob random draws (`mob_ai_probe` traces).
rng-trace = ["horizon-core/rng-trace"]
# Compatibility with earlier experiment commands: main already uses mimalloc.
@@ -133,6 +125,8 @@ windows-segment-heap = []
workspace = true
[dev-dependencies]
# ui_theme's unit tests draw text in the design system's fonts.
horizon-theme = { path = "../horizon-theme" }
horizon-client-render = { path = "../horizon-client-render", features = ["test-support"] }
horizon-items = { path = "../horizon-items", features = ["test-support"] }
# Every WGSL shader translated as wgpu's DirectX 12 backend does (gfx.rs tests).
+2 -2
View File
@@ -9,7 +9,6 @@ description = "The client's world data, rendering and screens: chunks, meshing,
[dependencies]
# The settings stores of the crates above the engine (docs/layers.md, "Platform registries").
horizon-hooks = { path = "../horizon-hooks" }
horizon-cosmetics = { path = "../horizon-cosmetics" }
# The GUI atlas's pixels, kept compressed once uploaded (hud.rs `pack_pixels`).
lz4_flex = { version = "0.14", default-features = false, features = ["std", "safe-encode", "safe-decode", "checked-decode"] }
horizon-assets = { path = "../horizon-assets" }
@@ -29,7 +28,6 @@ horizon-protocol = { path = "../horizon-protocol" }
horizon-shader-api = { path = "../horizon-shader-api" }
horizon-shaders = { path = "../horizon-shaders" }
horizon-system = { path = "../horizon-system" }
horizon-theme = { path = "../horizon-theme" }
horizon-threads = { path = "../horizon-threads" }
horizon-world = { path = "../horizon-world" }
ab_glyph = "0.2.32"
@@ -85,5 +83,7 @@ horizon-extras = []
workspace = true
[dev-dependencies]
# Tests: a TrueType font to load, and the design system the menus' motion curves match.
horizon-theme = { path = "../horizon-theme" }
horizon-items = { path = "../horizon-items", features = ["test-support"] }
naga = { version = "30.0.1", features = ["wgsl-in", "hlsl-out", "glsl-out", "msl-out", "spv-out"] }
+12 -235
View File
@@ -2,7 +2,7 @@
//! settings, kept in `horizon-extras.txt` next to `options.txt` so that file
//! stays exactly vanilla's, and the logic behind them. Every extra is off by
//! default; see `docs/extras.md`. Horizon's own switches outside the Extras page
//! (No AI, Agent Control, Discord Rich Presence) are kept here too.
//! (No AI, Agent Control) are kept here too.
use std::{collections::VecDeque, fs, path::Path};
pub const FILE: &str = "horizon-extras.txt";
@@ -145,8 +145,6 @@ pub struct Extras {
/// F3+R and F3+E start/stop a repro recording and write its bundle (off by
/// default: a recording copies the world).
pub repro_keys: bool,
/// "Playing Horizon" on the player's Discord profile (`discord_presence`; on by default).
pub discord_presence: bool,
/// Another controller can join P1's world (`splitscreen`, off by default).
pub splitscreen: bool,
/// Two players in one window: 0 auto (side by side when wider than tall), 1 side by side,
@@ -168,24 +166,6 @@ pub struct Extras {
/// built-in track and leaving the off ones out (`musicPage`, off by
/// default: vanilla plays exactly as before).
pub music_page: bool,
/// Cosmetics (`horizon_cosmetics`, off unless enabled), the lines `horizon_cosmetics::prefs`
/// shares with the launcher's Wardrobe: Show my cosmetics (the account's, sent to the service;
/// on by default), how much of other players' to show (no line until chosen:
/// `effective_others`), and whether their trails and auras are hidden (off by default).
pub cosmetics_mine: bool,
pub cosmetics_others: Option<horizon_cosmetics::prefs::Others>,
pub cosmetics_hide_fx: bool,
/// My cosmetics in single player too (`cosmeticsSolo`, off by default): the window's own server
/// names the player by their offline profile, which the service doesn't know, so their own
/// cosmetics and name tag style show only with this on.
pub cosmetics_solo: bool,
/// Whether the signed-in account owns a cosmetic, as the service said (never saved; the game
/// sets it): with no choice made, others' cosmetics show only then.
pub cosmetics_owner: bool,
/// Horizon's mark before the names of other Horizon players (name tags, player list), and
/// whether the service may say this player plays Horizon: both on by default.
pub horizon_marks: bool,
pub horizon_listed: bool,
/// 3D skin layers (`threeDSkinLayers`, Options → Skin Customization): each
/// painted texel of a player's outer layer drawn as a slab
/// (`horizon_client::mobs::skin_layers`).
@@ -233,7 +213,6 @@ impl Default for Extras {
no_ai: false,
agent_control: false,
repro_keys: false,
discord_presence: true,
splitscreen: false,
splitscreen_two: 0,
splitscreen_three: 0,
@@ -241,13 +220,6 @@ impl Default for Extras {
wheel_cycles_settings: false,
music_off: Vec::new(),
music_page: false,
cosmetics_mine: true,
cosmetics_others: None,
cosmetics_hide_fx: false,
cosmetics_solo: false,
cosmetics_owner: false,
horizon_marks: true,
horizon_listed: true,
three_d_skin_layers: false,
pvp: Default::default(),
}
@@ -257,11 +229,6 @@ fn bool_text(v: bool) -> &'static str {
if v { "true" } else { "false" }
}
impl Extras {
/// How much of other players' cosmetics shows: the player's choice, else all of it for an
/// account that owns a cosmetic and none for any other (a vanilla game).
pub fn effective_others(&self) -> horizon_cosmetics::prefs::Others {
horizon_cosmetics::prefs::Others::effective(self.cosmetics_others, self.cosmetics_owner)
}
pub fn load(dir: &Path) -> Self {
fs::read_to_string(dir.join(FILE))
.map(|t| Self::parse(&t))
@@ -338,7 +305,6 @@ impl Extras {
horizon_core::no_ai::KEY => e.no_ai = flag(e.no_ai),
"agentControl" => e.agent_control = flag(e.agent_control),
"reproKeys" => e.repro_keys = flag(e.repro_keys),
"discordPresence" => e.discord_presence = flag(e.discord_presence),
"splitscreen" => e.splitscreen = flag(e.splitscreen),
"splitscreenTwo" => e.splitscreen_two = value.parse().unwrap_or(0).min(2),
"splitscreenThree" => e.splitscreen_three = value.parse().unwrap_or(0).min(2),
@@ -357,15 +323,6 @@ impl Extras {
.collect()
}
"musicPage" => e.music_page = flag(e.music_page),
"cosmeticsMine" => e.cosmetics_mine = flag(e.cosmetics_mine),
"cosmeticsOthers" => {
e.cosmetics_others =
horizon_cosmetics::prefs::Others::parse(value).or(e.cosmetics_others)
}
"cosmeticsHideFx" => e.cosmetics_hide_fx = flag(e.cosmetics_hide_fx),
"cosmeticsSolo" => e.cosmetics_solo = flag(e.cosmetics_solo),
horizon_cosmetics::prefs::MARKS => e.horizon_marks = flag(e.horizon_marks),
horizon_cosmetics::prefs::LISTED => e.horizon_listed = flag(e.horizon_listed),
SKIN_LAYERS_KEY => e.three_d_skin_layers = flag(e.three_d_skin_layers),
key => {
e.pvp.parse_line(key, value);
@@ -376,7 +333,7 @@ impl Extras {
}
pub fn serialize(&self) -> String {
let mut text = format!(
"packBrowser:{}\npregen:{}\nfullbright:{}\ndynamicLights:{}\nzoom:{}\nzoomKey:{}\nzoomLevel:{}\nzoomSmooth:{}\nzoomSensitivity:{}\nlodZoomDetail:{}\nfps:{}\nfpsCorner:{}\nfpsLows:{}\nkeystrokes:{}\nkeystrokesCorner:{}\nkeystrokesCps:{}\nmovedToMods:{}\nminimap:{}\nminimapCorner:{}\nminimapSize:{}\nminimapShape:{}\nminimapZoom:{}\nminimapRotate:{}\nautoReconnect:{}\nuiSoundReserve:{}\nownNameTag:{}\nhideBlockParticles:{}\nmouseTweaks:{}\nmouseTweaksWheel:{}\nsort:{}\nsortKey:{}\nchatAnimation:{}\nchatIndicator:{}\n{}:{}\nagentControl:{}\nreproKeys:{}\ndiscordPresence:{}\nsplitscreen:{}\nsplitscreenTwo:{}\nsplitscreenThree:{}\nsplitscreenNames:{}\nwheelCyclesListSettings:{}\nmusicOff:{}\nmusicPage:{}\ncosmeticsMine:{}\n{}cosmeticsHideFx:{}\ncosmeticsSolo:{}\n{}:{}\n{}:{}\n{}:{}\n",
"packBrowser:{}\npregen:{}\nfullbright:{}\ndynamicLights:{}\nzoom:{}\nzoomKey:{}\nzoomLevel:{}\nzoomSmooth:{}\nzoomSensitivity:{}\nlodZoomDetail:{}\nfps:{}\nfpsCorner:{}\nfpsLows:{}\nkeystrokes:{}\nkeystrokesCorner:{}\nkeystrokesCps:{}\nmovedToMods:{}\nminimap:{}\nminimapCorner:{}\nminimapSize:{}\nminimapShape:{}\nminimapZoom:{}\nminimapRotate:{}\nautoReconnect:{}\nuiSoundReserve:{}\nownNameTag:{}\nhideBlockParticles:{}\nmouseTweaks:{}\nmouseTweaksWheel:{}\nsort:{}\nsortKey:{}\nchatAnimation:{}\nchatIndicator:{}\n{}:{}\nagentControl:{}\nreproKeys:{}\nsplitscreen:{}\nsplitscreenTwo:{}\nsplitscreenThree:{}\nsplitscreenNames:{}\nwheelCyclesListSettings:{}\nmusicOff:{}\nmusicPage:{}\n{}:{}\n",
bool_text(self.pack_browser),
bool_text(self.pregen),
bool_text(self.fullbright),
@@ -418,7 +375,6 @@ impl Extras {
bool_text(self.no_ai),
bool_text(self.agent_control),
bool_text(self.repro_keys),
bool_text(self.discord_presence),
bool_text(self.splitscreen),
self.splitscreen_two,
self.splitscreen_three,
@@ -426,16 +382,6 @@ impl Extras {
bool_text(self.wheel_cycles_settings),
self.music_off.join(","),
bool_text(self.music_page),
bool_text(self.cosmetics_mine),
// Written once the player chose.
self.cosmetics_others
.map_or(String::new(), |o| format!("cosmeticsOthers:{}\n", o.name())),
bool_text(self.cosmetics_hide_fx),
bool_text(self.cosmetics_solo),
horizon_cosmetics::prefs::MARKS,
bool_text(self.horizon_marks),
horizon_cosmetics::prefs::LISTED,
bool_text(self.horizon_listed),
SKIN_LAYERS_KEY,
bool_text(self.three_d_skin_layers),
);
@@ -613,8 +559,6 @@ pub enum Item {
/// Not on the Extras page: Horizon Settings shows No AI, its AI Agent page Agent Control.
NoAi,
AgentControl,
/// On the Horizon Settings page.
DiscordPresence,
/// On the AI Agent page, next to Agent Control: the F3 recording shortcuts.
ReproKeys,
/// On the Splitscreen settings page.
@@ -623,13 +567,6 @@ pub enum Item {
/// The Select Resource Packs screen's Add button (Modrinth).
PackBrowser,
Pregen,
/// Shown only while cosmetics are enabled (`horizon_cosmetics::enabled`).
CosmeticsMine,
CosmeticsOthers,
CosmeticsHideFx,
CosmeticsSolo,
HorizonMarks,
HorizonMe,
/// On the PvP & HUD page (`pvp::rows`).
Pvp(crate::pvp::Opt),
}
@@ -637,8 +574,10 @@ pub enum Item {
pub fn rows() -> Vec<Result<Item, &'static str>> {
use Item::*;
// Fullbright, Dynamic lights and Hide block-break particles change the picture: they
// are on the graphics screen's Quality page (`graphics_ui::rows`), not here.
let mut rows = vec![
// are on the graphics screen's Quality page (`graphics_ui::rows`), not here. The Cosmetics
// group comes last, from the cosmetics' own section (`pages::Place::Extras`) while they are
// offered.
vec![
Err("horizon.extras.header.third_person"),
Ok(OwnNameTag),
Err("horizon.extras.header.inventory"),
@@ -659,19 +598,7 @@ pub fn rows() -> Vec<Result<Item, &'static str>> {
Err("horizon.extras.header.menus"),
Ok(WheelCyclesSettings),
Ok(PackBrowser),
];
if horizon_cosmetics::enabled() {
rows.extend([
Err("horizon.extras.header.cosmetics"),
Ok(CosmeticsMine),
Ok(CosmeticsSolo),
Ok(CosmeticsOthers),
Ok(CosmeticsHideFx),
Ok(HorizonMarks),
Ok(HorizonMe),
]);
}
rows
]
}
impl Item {
/// The language key of the caption (`horizon.*`, assets/horizon/lang).
@@ -691,19 +618,12 @@ impl Item {
Item::NoAi => "horizon.extras.no_ai",
Item::AgentControl => "horizon.extras.agent_control",
Item::ReproKeys => "horizon.extras.repro_keys",
Item::DiscordPresence => "horizon.extras.discord_presence",
Item::Splitscreen => "horizon.extras.splitscreen",
Item::ChatAnimation => "horizon.extras.chat_animation",
Item::ChatIndicator => "horizon.extras.chat_indicator",
Item::WheelCyclesSettings => "horizon.extras.wheel_cycles_settings",
Item::PackBrowser => "horizon.extras.pack_browser",
Item::Pregen => "horizon.extras.pregen",
Item::CosmeticsMine => "horizon.extras.cosmetics_mine",
Item::CosmeticsOthers => "horizon.extras.cosmetics_others",
Item::CosmeticsHideFx => "horizon.extras.cosmetics_hide_fx",
Item::CosmeticsSolo => "horizon.extras.cosmetics_solo",
Item::HorizonMarks => "horizon.extras.horizon_marks",
Item::HorizonMe => "horizon.extras.horizon_me",
Item::Pvp(o) => o.caption_key(),
}
}
@@ -724,19 +644,12 @@ impl Item {
Item::NoAi => "horizon.extras.no_ai.tooltip",
Item::AgentControl => "horizon.extras.agent_control.tooltip",
Item::ReproKeys => "horizon.extras.repro_keys.tooltip",
Item::DiscordPresence => "horizon.extras.discord_presence.tooltip",
Item::Splitscreen => "horizon.extras.splitscreen.tooltip",
Item::ChatAnimation => "horizon.extras.chat_animation.tooltip",
Item::ChatIndicator => "horizon.extras.chat_indicator.tooltip",
Item::WheelCyclesSettings => "horizon.extras.wheel_cycles_list_settings.tooltip",
Item::PackBrowser => "horizon.extras.pack_browser.tooltip",
Item::Pregen => "horizon.extras.pregen.tooltip",
Item::CosmeticsMine => "horizon.extras.cosmetics_mine.tooltip",
Item::CosmeticsOthers => "horizon.extras.cosmetics_others.tooltip",
Item::CosmeticsHideFx => "horizon.extras.cosmetics_hide_fx.tooltip",
Item::CosmeticsSolo => "horizon.extras.cosmetics_solo.tooltip",
Item::HorizonMarks => "horizon.extras.horizon_marks.tooltip",
Item::HorizonMe => "horizon.extras.horizon_me.tooltip",
Item::Pvp(o) => o.description_key(),
}
}
@@ -768,17 +681,10 @@ impl Item {
Item::NoAi => "No AI",
Item::AgentControl => "Agent Control",
Item::ReproKeys => "Recording shortcuts",
Item::DiscordPresence => "Discord Rich Presence",
Item::Splitscreen => "Splitscreen",
Item::WheelCyclesSettings => "Scroll wheel changes settings",
Item::PackBrowser => "Resource pack browser",
Item::Pregen => "Chunk pregeneration",
Item::CosmeticsMine => "My cosmetics",
Item::CosmeticsOthers => "Other players' cosmetics",
Item::CosmeticsHideFx => "Hide other players' trails and auras",
Item::CosmeticsSolo => "In single player",
Item::HorizonMarks => "Horizon logos",
Item::HorizonMe => "Show me as Horizon",
Item::Pvp(o) => o.caption(),
}
}
@@ -792,10 +698,8 @@ impl Item {
ChatIndicator => ChatAnimation,
Pvp(o) => return o.parent().map(Pvp),
Fullbright | DynamicLights | OwnNameTag | HideBlockParticles | MouseTweaks | Sort
| AutoReconnect | NoAi | AgentControl | ReproKeys | DiscordPresence | Splitscreen
| ChatAnimation | WheelCyclesSettings | MusicPage | PackBrowser | CosmeticsMine
| CosmeticsOthers | CosmeticsHideFx | CosmeticsSolo | HorizonMarks | HorizonMe
| Pregen => {
| AutoReconnect | NoAi | AgentControl | ReproKeys | Splitscreen | ChatAnimation
| WheelCyclesSettings | MusicPage | PackBrowser | Pregen => {
return None;
}
})
@@ -844,9 +748,6 @@ impl Item {
Item::NoAi => {
"Hides and turns off every AI feature: agent control, horizon-ctl and the MCP servers. The launcher's switch too."
}
Item::DiscordPresence => {
"Shows Playing Horizon on your Discord profile while Discord runs: menus, single player or multiplayer, the dimension and the time played. Never a world's name or a server's address."
}
Item::Splitscreen => {
"In a world, another controller can join as a new player: press A when it asks, then pick a profile and where to play."
}
@@ -856,24 +757,6 @@ impl Item {
Item::ReproKeys => {
"F3+R starts or stops a repro recording and F3+E writes its bundle. Off by default: a recording copies the world."
}
Item::CosmeticsMine => {
"Hidden: every Horizon player sees you without your cosmetics, you included. The same switch as the launcher's Wardrobe."
}
Item::CosmeticsOthers => {
"What other Horizon players wear: everything, only their name tag styles, or nothing. Hidden unless you own cosmetics, until you choose. Hidden, the game never asks what they wear."
}
Item::CosmeticsHideFx => {
"Other players' trails and auras stay hidden; their other cosmetics still show. Off by default."
}
Item::CosmeticsSolo => {
"Show your cosmetics and your name tag's badge in your single-player worlds too (your name tag with Own name tag). Off: only on servers."
}
Item::HorizonMarks => {
"Show the Horizon logo next to Horizon players: a small logo before the names of other players who use Horizon, above their heads and in the player list. Only Horizon players see it: servers receive nothing."
}
Item::HorizonMe => {
"Show me as a Horizon player: other Horizon players see the logo by your name. On, the service answers anyone who asks about you that you played Horizon in the last 3 hours; off, it never reports you."
}
Item::Pvp(o) => o.description(),
Item::WheelCyclesSettings => {
"The scroll wheel over a button in the settings changes its value, as in vanilla. Off, the wheel only scrolls the list."
@@ -900,12 +783,6 @@ impl Item {
| Item::Splitscreen
| Item::WheelCyclesSettings
| Item::PackBrowser => Some(Cost::None),
Item::CosmeticsMine
| Item::CosmeticsOthers
| Item::CosmeticsHideFx
| Item::CosmeticsSolo
| Item::HorizonMarks
| Item::HorizonMe => Some(Cost::Low),
_ => None,
}
}
@@ -927,15 +804,10 @@ impl Item {
Item::NoAi => e.no_ai,
Item::AgentControl => e.agent_control,
Item::ReproKeys => e.repro_keys,
Item::DiscordPresence => e.discord_presence,
Item::Splitscreen => e.splitscreen,
Item::WheelCyclesSettings => e.wheel_cycles_settings,
Item::PackBrowser => e.pack_browser,
Item::Pregen => e.pregen,
Item::CosmeticsHideFx => e.cosmetics_hide_fx,
Item::CosmeticsSolo => e.cosmetics_solo,
Item::HorizonMarks => e.horizon_marks,
Item::HorizonMe => e.horizon_listed,
Item::Pvp(o) => return o.switch(&e.pvp),
_ => return None,
})
@@ -945,30 +817,14 @@ impl Item {
match self {
Item::SortKey => crate::keybinds::display_name(&e.sort_key).plain(),
Item::Pvp(o) if o.switch(&e.pvp).is_none() => o.value(&e.pvp).unwrap_or_default(),
Item::CosmeticsMine => match e.cosmetics_mine {
true => crate::lang::get_or("horizon.extras.cosmetics.shown", "Shown"),
false => crate::lang::get_or("horizon.extras.cosmetics.hidden", "Hidden"),
},
Item::CosmeticsOthers => {
use horizon_cosmetics::prefs::Others;
match e.effective_others() {
Others::Shown => crate::lang::get_or("horizon.extras.cosmetics.shown", "Shown"),
Others::NameTags => {
crate::lang::get_or("horizon.extras.cosmetics.name_tags", "Name tags only")
}
Others::Hidden => {
crate::lang::get_or("horizon.extras.cosmetics.hidden", "Hidden")
}
}
}
other => match other.switch(e) {
Some(true) => crate::lang::get_or("On", "On"),
_ => crate::lang::get_or("Off", "Off"),
},
}
}
/// A click (`back` for the right button: it turns the three-way cosmetics button the other
/// way; every other item left is a switch, so both flip it). The sort key binds instead.
/// A click (`back` for the right button: a PvP & HUD choice steps back; a switch flips
/// either way). The sort key binds instead.
pub fn cycle(self, e: &mut Extras, back: bool) {
match self {
Item::Fullbright => e.fullbright = !e.fullbright,
@@ -986,21 +842,10 @@ impl Item {
Item::NoAi => e.no_ai = !e.no_ai,
Item::AgentControl => e.agent_control = !e.agent_control,
Item::ReproKeys => e.repro_keys = !e.repro_keys,
Item::DiscordPresence => e.discord_presence = !e.discord_presence,
Item::Splitscreen => e.splitscreen = !e.splitscreen,
Item::WheelCyclesSettings => e.wheel_cycles_settings = !e.wheel_cycles_settings,
Item::PackBrowser => e.pack_browser = !e.pack_browser,
Item::Pregen => e.pregen = !e.pregen,
Item::CosmeticsMine => e.cosmetics_mine = !e.cosmetics_mine,
Item::CosmeticsOthers => {
// From what the button shows; the result is the player's choice from now on.
let o = e.effective_others();
e.cosmetics_others = Some(if back { o.next().next() } else { o.next() });
}
Item::CosmeticsHideFx => e.cosmetics_hide_fx = !e.cosmetics_hide_fx,
Item::CosmeticsSolo => e.cosmetics_solo = !e.cosmetics_solo,
Item::HorizonMarks => e.horizon_marks = !e.horizon_marks,
Item::HorizonMe => e.horizon_listed = !e.horizon_listed,
Item::Pvp(o) => o.cycle(&mut e.pvp, back),
Item::SortKey => {}
}
@@ -1019,73 +864,6 @@ impl Item {
mod tests {
use super::*;
#[test]
fn other_players_cosmetics_cycle_three_ways_and_read_the_old_switch() {
use horizon_cosmetics::prefs::Others;
let mut e = Extras {
cosmetics_owner: true,
..Extras::default()
};
assert_eq!(
(e.effective_others(), e.cosmetics_hide_fx, e.cosmetics_mine),
(Others::Shown, false, true)
);
Item::CosmeticsOthers.cycle(&mut e, false);
assert_eq!(e.cosmetics_others, Some(Others::NameTags));
Item::CosmeticsOthers.cycle(&mut e, false);
assert_eq!(e.cosmetics_others, Some(Others::Hidden));
// The right button goes back.
Item::CosmeticsOthers.cycle(&mut e, true);
assert_eq!(e.cosmetics_others, Some(Others::NameTags));
// Ownership is the service's, never written.
assert_eq!(
Extras::parse(&e.serialize()),
Extras {
cosmetics_owner: false,
..e.clone()
}
);
// The launcher's Wardrobe writes the same lines.
assert_eq!(
horizon_cosmetics::prefs::Prefs::parse(&e.serialize()).others,
Some(Others::NameTags)
);
// Before this, the line was on or off.
assert_eq!(
Extras::parse("cosmeticsOthers:false").cosmetics_others,
Some(Others::Hidden)
);
}
#[test]
fn others_cosmetics_are_off_for_a_player_who_owns_none_until_chosen() {
use horizon_cosmetics::prefs::Others;
let mut e = Extras::default();
// Nothing chosen, nothing owned: the button shows Hidden and no line is written.
assert_eq!(e.effective_others(), Others::Hidden);
assert_eq!(
Item::CosmeticsOthers.value(&e),
crate::lang::get_or("horizon.extras.cosmetics.hidden", "Hidden")
);
assert!(!e.serialize().contains("cosmeticsOthers"));
// The service says the account owns one: shown, still not a choice.
e.cosmetics_owner = true;
assert_eq!(e.effective_others(), Others::Shown);
assert!(!e.serialize().contains("cosmeticsOthers"));
// A non-owner pressing the button goes on from Hidden, and that choice is kept.
e.cosmetics_owner = false;
Item::CosmeticsOthers.cycle(&mut e, false);
assert_eq!(e.cosmetics_others, Some(Others::Shown));
let saved = Extras::parse(&e.serialize());
assert_eq!(saved.effective_others(), Others::Shown);
// An owner who chose Hidden keeps it.
let hidden = Extras {
cosmetics_owner: true,
..Extras::parse("cosmeticsOthers:hidden\n")
};
assert_eq!(hidden.effective_others(), Others::Hidden);
}
#[test]
fn a_saved_wheel_setting_from_before_the_default_changed_is_dropped() {
assert!(!Extras::parse("wheelCyclesSettings:true\n").wheel_cycles_settings);
@@ -1104,7 +882,7 @@ mod tests {
assert!(!d.mouse_tweaks && !d.sort && !d.own_name_tag && !d.ui_sound_reserve);
assert!(!d.hide_block_particles && !d.pregen);
assert!(!d.music_page && d.music_off.is_empty());
assert!(d.discord_presence && !d.wheel_cycles_settings);
assert!(!d.wheel_cycles_settings);
assert_eq!(Extras::parse(""), d);
assert_eq!(Extras::parse(&d.serialize()), d);
let mut e = d.clone();
@@ -1143,7 +921,6 @@ mod tests {
e.sort_key = "key.keyboard.r".into();
e.chat_animation = true;
e.chat_indicator = false;
e.discord_presence = false;
assert!(!e.splitscreen);
Item::Splitscreen.cycle(&mut e, false);
assert_eq!(Item::Splitscreen.switch(&e), Some(true));
+2 -19
View File
@@ -206,14 +206,13 @@ impl Screen for ExtrasScreen {
fn note_lines(&self) -> usize {
NOTE_LINES
}
/// The rows read the extras, the mods, whether cosmetics are offered and the sections' rows.
/// The rows read the extras, the mods and the sections' rows.
fn stamp(&self, s: &Settings) -> u64 {
let mut added = Vec::new();
for (_, section) in sections() {
section.rows(&mut added);
}
let cosmetics = horizon_cosmetics::enabled();
shell::stamp_of(&(&s.extras, &s.mods, cosmetics, added))
shell::stamp_of(&(&s.extras, &s.mods, added))
}
fn sheets(&self, s: &Settings) -> Vec<Sheet<(), ExtraRow>> {
let mut groups = vec![Group {
@@ -321,22 +320,6 @@ mod tests {
assert_ne!(s.extras.mouse_tweaks_wheel, before);
}
#[test]
fn the_three_way_cosmetics_row_turns_both_ways() {
use horizon_cosmetics::prefs::Others;
let mut s = Settings::default();
let mut screen = ExtrasScreen;
let row = ExtraRow::Item(Item::CosmeticsOthers);
// Not chosen, nothing owned: it shows Hidden, and forward wraps to Shown.
assert_eq!(s.extras.effective_others(), Others::Hidden);
screen.press(&mut s, row, false);
assert_eq!(s.extras.cosmetics_others, Some(Others::Shown));
screen.press(&mut s, row, true);
assert_eq!(s.extras.cosmetics_others, Some(Others::Hidden));
screen.press(&mut s, row, true);
assert_eq!(s.extras.cosmetics_others, Some(Others::NameTags));
}
/// The sort key binds instead of cycling; the wheel leaves it be.
#[test]
fn the_key_row_waits_for_a_key() {
+6 -4
View File
@@ -1001,12 +1001,14 @@ impl Hdr {
}
}
/// Horizon's icon (horizon-theme) as a 1-bit mask: its white strokes, one colour with hard
/// edges, are the logo; everything else is not.
/// The windows' icon (`window::set_icon_png`: Horizon's in the official game) as a 1-bit mask:
/// its white strokes, one colour with hard edges, are the logo; everything else is not. No icon
/// given: an empty mask.
fn logo_mask(device: &wgpu::Device, queue: &wgpu::Queue) -> wgpu::TextureView {
use wgpu::util::DeviceExt;
let (w, h, rgba) =
crate::hud::decode_bytes(horizon_theme::ICON_PNG).unwrap_or((1, 1, vec![0; 4]));
let (w, h, rgba) = horizon_client_input::window::icon_png()
.and_then(|png| crate::hud::decode_bytes(png).ok())
.unwrap_or((1, 1, vec![0; 4]));
let mask: Vec<u8> = rgba
.as_chunks::<4>()
.0
@@ -2,16 +2,21 @@
//! colours and the controls it draws with. The screen (`graphics_ui`) only
//! lays out rows and handles input; a menu theme replaces this module.
//!
//! It takes the design system's Blocks palette (`horizon_theme::BLOCKS_DARK`,
//! `docs/design-system.md`) in a lighter form: square panels of dark glass
//! with a thin edge, so the world shows through, flat controls, and the violet
//! face on the one main button, in vanilla's font.
//! Its colours come from `menu_style`, given at startup (in the official game the design
//! system's Blocks palette, `docs/design-system.md`), in a lighter form: square panels of
//! dark glass with a thin edge, so the world shows through, flat controls, and the face on
//! the one main button, in vanilla's font.
use crate::menu_style::{self, SettingsColours};
use crate::{
hud::{HudAtlas, HudBatch},
text::Text,
ui,
};
use horizon_theme::BLOCKS_DARK as B;
/// The settings pages' colours (`menu_style`, given at startup).
fn b() -> &'static SettingsColours {
&menu_style::get().settings
}
/// What a setting costs in frame time, shown with its description.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
@@ -36,54 +41,91 @@ impl Cost {
/// `0xRRGGBB`: the palette's success, warn and danger.
pub fn rgb(self) -> u32 {
match self {
Cost::None => B.ink_soft,
Cost::Low | Cost::Saves => B.success,
Cost::Medium => B.warn,
Cost::High => B.danger,
Cost::None => b().ink_soft,
Cost::Low | Cost::Saves => b().success,
Cost::Medium => b().warn,
Cost::High => b().danger,
}
}
}
/// `0xRRGGBB` and an alpha in the HUD's colour encoding.
const fn hex(c: u32, a: u8) -> u32 {
(a as u32) << 24 | (c & 0xff) << 16 | (c >> 8 & 0xff) << 8 | (c >> 16 & 0xff)
}
use menu_style::hud_colour as hex;
/// The plate and the strip: the page's dark, see-through, so the world shows behind them.
const GLASS: u32 = hex(B.page, 118);
fn glass() -> u32 {
hex(b().page, 118)
}
/// Their 1 px edge.
const EDGE: u32 = hex(B.ink, 26);
pub fn edge() -> u32 {
hex(b().ink, 26)
}
/// A group's panel, a little lighter than the plate it sits on.
const GROUP: u32 = hex(B.ink, 11);
fn group_fill() -> u32 {
hex(b().ink, 11)
}
/// A hovered or focused row, lit on its group's panel.
pub const HOVER: u32 = hex(B.ink, 18);
pub fn hover() -> u32 {
hex(b().ink, 18)
}
/// The open page in the page list.
pub const OPEN: u32 = hex(B.ink, 26);
pub fn open() -> u32 {
hex(b().ink, 26)
}
/// The rule under the plate's heading, and between two rows of a group.
pub const HEAD_RULE: u32 = hex(B.ink, 31);
pub const ROW_RULE: u32 = hex(B.ink, 15);
pub fn head_rule() -> u32 {
hex(b().ink, 31)
}
pub fn row_rule() -> u32 {
hex(b().ink, 15)
}
/// Fields, boxes and the value stepper: sunk, see-through.
const WELL: u32 = hex(B.outline, 102);
fn well_fill() -> u32 {
hex(b().outline, 102)
}
/// A button, a slider's track, the scroller's thumb.
const BUTTON: u32 = hex(B.ink, 20);
const BUTTON_HOVER: u32 = hex(B.ink, 41);
const TRACK: u32 = hex(B.ink, 64);
const HANDLE: u32 = hex(B.ink, 255);
fn button_fill() -> u32 {
hex(b().ink, 20)
}
fn button_hover() -> u32 {
hex(b().ink, 41)
}
fn track_fill() -> u32 {
hex(b().ink, 64)
}
fn handle_fill() -> u32 {
hex(b().ink, 255)
}
/// The one main button.
const FACE: u32 = hex(B.face, 255);
const FACE_HOVER: u32 = hex(0x6a50e6, 255);
pub fn face() -> u32 {
hex(b().face, 255)
}
fn face_hover() -> u32 {
hex(b().face_hover, 255)
}
/// A tick box's edge when off.
const BOX_EDGE: u32 = hex(B.ink_soft, 89);
const OUTLINE: u32 = hex(B.outline, 255);
pub const ACCENT: u32 = hex(B.accent, 255);
fn box_edge() -> u32 {
hex(b().ink_soft, 89)
}
fn outline() -> u32 {
hex(b().outline, 255)
}
pub fn accent() -> u32 {
hex(b().accent, 255)
}
/// Vanilla's white text, and its grey for what can't be used now.
pub const WHITE: u32 = u32::MAX;
pub const MUTED: u32 = hex(B.muted, 255);
pub fn muted() -> u32 {
hex(b().muted, 255)
}
/// A page that isn't open, a value at rest.
pub const SOFT: u32 = hex(B.ink_soft, 255);
pub fn soft() -> u32 {
hex(b().ink_soft, 255)
}
/// Vanilla's grey for a tooltip's lines under its first (`ChatFormatting.GRAY`).
pub const GREY: u32 = hex(0xaaaaaa, 255);
/// A light veil over the backdrop, under the panels.
pub const VEIL: u32 = hex(B.page, 77);
pub fn veil() -> u32 {
hex(b().page, 77)
}
/// Sizes in GUI pixels.
/// Shorter screens than this (a 720p window at scale 3) keep a narrower margin on top.
@@ -120,21 +162,21 @@ pub fn ring(batch: &mut HudBatch, atlas: &HudAtlas, [x, y, w, h]: [f32; 4], colo
}
/// The screen's plate, and the strip under it: dark glass with a thin edge.
pub fn panel(batch: &mut HudBatch, atlas: &HudAtlas, rect: [f32; 4]) {
fill(batch, atlas, rect, GLASS);
ring(batch, atlas, rect, EDGE);
fill(batch, atlas, rect, glass());
ring(batch, atlas, rect, edge());
}
/// A group's panel on the plate, its rows on it.
pub fn group(batch: &mut HudBatch, atlas: &HudAtlas, rect: [f32; 4]) {
fill(batch, atlas, rect, GROUP);
fill(batch, atlas, rect, group_fill());
}
/// A sunk well: the search field, the value stepper.
pub fn well(batch: &mut HudBatch, atlas: &HudAtlas, rect: [f32; 4]) {
fill(batch, atlas, rect, WELL);
fill(batch, atlas, rect, well_fill());
}
/// The scroller: a faint track and its thumb.
pub fn scroller(batch: &mut HudBatch, atlas: &HudAtlas, track: [f32; 4], thumb: [f32; 4]) {
fill(batch, atlas, track, ROW_RULE);
fill(batch, atlas, thumb, TRACK);
fill(batch, atlas, track, row_rule());
fill(batch, atlas, thumb, track_fill());
}
/// `text` cut to `width` with an ellipsis, as a long name gives way to its control: the HUD
/// kit's `fit::ellipsize`.
@@ -142,11 +184,11 @@ pub use crate::hud_kit::fit::ellipsize as fit;
/// A 12 px tick box ending at `right`, centred in the row at `y`: its edge in the accent when on.
pub fn checkbox(batch: &mut HudBatch, atlas: &HudAtlas, right: f32, y: f32, on: bool, live: bool) {
let rect = [right - 12.0, y + 4.0, 12.0, 12.0];
fill(batch, atlas, rect, WELL);
fill(batch, atlas, rect, well_fill());
let edge = match (on, live) {
(true, true) => ACCENT,
(true, false) => MUTED,
(false, _) => BOX_EDGE,
(true, true) => accent(),
(true, false) => muted(),
(false, _) => box_edge(),
};
ring(batch, atlas, rect, edge);
if on {
@@ -169,7 +211,7 @@ fn tick(batch: &mut HudBatch, atlas: &HudAtlas, x: f32, y: f32, color: u32) {
for (i, c) in row.chars().enumerate() {
if c == '#' {
let (px, py) = (x + i as f32, y + j as f32);
fill(batch, atlas, [px + 1.0, py + 1.0, 1.0, 1.0], OUTLINE);
fill(batch, atlas, [px + 1.0, py + 1.0, 1.0, 1.0], outline());
fill(batch, atlas, [px, py, 1.0, 1.0], color);
}
}
@@ -198,17 +240,17 @@ pub fn button_with_room(
) {
let [x, y, w, h] = rect;
let face = match (live, primary, hover) {
(false, _, _) => WELL,
(true, true, false) => FACE,
(true, true, true) => FACE_HOVER,
(true, false, false) => BUTTON,
(true, false, true) => BUTTON_HOVER,
(false, _, _) => well_fill(),
(true, true, false) => face(),
(true, true, true) => face_hover(),
(true, false, false) => button_fill(),
(true, false, true) => button_hover(),
};
fill(batch, atlas, rect, face);
if hover && live {
ring(batch, atlas, rect, WHITE);
}
let color = if live { WHITE } else { MUTED };
let color = if live { WHITE } else { muted() };
let ty = y + ((h - 8.0) / 2.0).floor();
let left = x + 2.0 + room;
scrolling_label(
@@ -252,7 +294,7 @@ pub fn slider(
(hover, live): (bool, bool),
) {
let [x, y, w, h] = rect;
let color = if live { WHITE } else { MUTED };
let color = if live { WHITE } else { muted() };
scrolling_label(
batch,
atlas,
@@ -263,17 +305,22 @@ pub fn slider(
);
let line = y + h - 3.0;
let at = x + (position.clamp(0.0, 1.0) * (w - 4.0)).round();
fill(batch, atlas, [x, line, w, 2.0], TRACK);
fill(batch, atlas, [x, line, w, 2.0], track_fill());
fill(
batch,
atlas,
[x, line, at - x, 2.0],
if live { ACCENT } else { MUTED },
if live { accent() } else { muted() },
);
let handle = [at, line - 3.0, 4.0, 8.0];
fill(batch, atlas, handle, if live { HANDLE } else { MUTED });
fill(
batch,
atlas,
handle,
if live { handle_fill() } else { muted() },
);
if hover && live {
ring(batch, atlas, [at - 1.0, line - 4.0, 6.0, 10.0], ACCENT);
ring(batch, atlas, [at - 1.0, line - 4.0, 6.0, 10.0], accent());
}
}
/// A value that a press steps through: in a well, between the arrows that say so.
@@ -290,13 +337,13 @@ pub fn cycler(
ring(batch, atlas, rect, WHITE);
}
let ty = y + ((h - 8.0) / 2.0).floor();
let arrows = if hover && live { WHITE } else { MUTED };
let arrows = if hover && live { WHITE } else { muted() };
batch.text_styled(atlas, &Text::literal("<"), x + 3.0, ty, arrows, true);
batch.text_styled(atlas, &Text::literal(">"), x + w - 8.0, ty, arrows, true);
let color = match (live, hover) {
(false, _) => MUTED,
(false, _) => muted(),
(true, true) => WHITE,
(true, false) => SOFT,
(true, false) => soft(),
};
scrolling_label(batch, atlas, value, [x + 9.0, ty, w - 18.0], (y, h), color);
}
@@ -3,8 +3,9 @@
//! where a tween is at the frame's [`Clock`]; nothing here holds state or runs on its own, and
//! no motion ever decides what input does.
//!
//! The curves are the design system's (`horizon_theme::QUICK`, `MOVE`): a [`Tween`] takes one
//! of them, at the theme's duration or a shorter one of its own.
//! A [`Tween`] takes a duration and a CSS `cubic-bezier` curve: Horizon's overlays use the design
//! system's (`horizon_theme::QUICK`, `MOVE`), at the theme's duration or a shorter one of their
//! own.
//!
//! The game builds the clock once a frame (`Clock::new(now, still)`). A still clock (the game is
//! paused, frame-stepped by `HORIZON_STILL` or a screenshot sequence, or a repro recording or
@@ -16,7 +17,7 @@
//! use std::time::{Duration, Instant};
//!
//! let opened = Instant::now();
//! let open = Tween::new(Duration::from_millis(90), horizon_theme::QUICK);
//! let open = Tween::new(Duration::from_millis(90), [0.4, 0.0, 0.2, 1.0]);
//! // Half way through: partly faded in, partly grown from 94%.
//! let clock = Clock::new(opened + Duration::from_millis(45), false);
//! let alpha = motion::fade(open, clock, opened);
@@ -26,7 +27,6 @@
//! let still = Clock::new(opened, true);
//! assert_eq!(motion::fade(open, still, opened), 1.0);
//! ```
use horizon_theme::Motion;
use std::time::{Duration, Instant};
/// The time a frame shows, built once a frame by the game.
@@ -50,28 +50,20 @@ impl Clock {
}
}
/// A duration and one of the design system's easing curves.
/// A duration and an easing curve.
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct Tween {
/// How long it runs.
pub duration: Duration,
/// The CSS `cubic-bezier` control points of its curve (a `horizon_theme::Motion`'s).
/// The CSS `cubic-bezier` control points of its curve.
pub curve: [f32; 4],
}
impl Tween {
/// `motion`'s curve over `duration`: the dial's motions are shorter than the launcher's, on
/// the same curves.
pub const fn new(duration: Duration, motion: Motion) -> Self {
Self {
duration,
curve: motion.curve,
}
}
/// A theme motion as it is: its own duration and curve.
pub const fn of(motion: Motion) -> Self {
Self::new(motion.duration(), motion)
/// `curve` over `duration`: the dial's motions are shorter than the launcher's, on the same
/// curves.
pub const fn new(duration: Duration, curve: [f32; 4]) -> Self {
Self { duration, curve }
}
/// The linear progress, 0 to 1, `since` a start at this clock; 1 on a still clock.
@@ -84,7 +76,7 @@ impl Tween {
/// The eased progress, 0 to 1, on the tween's curve.
pub fn progress(self, clock: Clock, since: Instant) -> f32 {
horizon_theme::bezier(self.curve, self.linear(clock, since))
bezier(self.curve, self.linear(clock, since))
}
/// Whether it has finished (always, on a still clock).
@@ -98,6 +90,29 @@ impl Tween {
}
}
/// A CSS `cubic-bezier(x1, y1, x2, y2)` at `t` in 0..1: solves x for the curve parameter,
/// returns y.
pub fn bezier([x1, y1, x2, y2]: [f32; 4], t: f32) -> f32 {
let t = t.clamp(0.0, 1.0);
if t == 0.0 || t == 1.0 {
return t;
}
let at = |a: f32, b: f32, s: f32| {
let u = 1.0 - s;
3.0 * u * u * s * a + 3.0 * u * s * s * b + s * s * s
};
let (mut lo, mut hi) = (0f32, 1f32);
for _ in 0..24 {
let mid = (lo + hi) / 2.0;
if at(x1, x2, mid) < t {
lo = mid;
} else {
hi = mid;
}
}
at(y1, y2, (lo + hi) / 2.0)
}
/// An alpha from 0 to 1 as something fades in.
pub fn fade(tween: Tween, clock: Clock, since: Instant) -> f32 {
tween.progress(clock, since)
@@ -145,7 +160,7 @@ mod tests {
#[test]
fn a_tween_runs_from_its_start_to_its_end_and_holds() {
let t0 = Instant::now();
let tween = Tween::new(MS(80), horizon_theme::MOVE);
let tween = Tween::new(MS(80), horizon_theme::MOVE.curve);
assert_eq!(tween.progress(at(t0, 0), t0), 0.0);
assert_eq!(tween.progress(at(t0, 80), t0), 1.0);
assert_eq!(tween.progress(at(t0, 500), t0), 1.0);
@@ -160,8 +175,7 @@ mod tests {
fn a_still_clock_shows_every_motion_settled() {
let t0 = Instant::now();
let still = Clock::new(t0, true);
let tween = Tween::of(horizon_theme::QUICK);
assert_eq!(tween.duration, horizon_theme::QUICK.duration());
let tween = Tween::new(horizon_theme::QUICK.duration(), horizon_theme::QUICK.curve);
assert_eq!(fade(tween, still, t0), 1.0);
assert_eq!(fade_out(tween, still, t0), 0.0);
assert_eq!(scale_in(tween, still, t0, 0.94), 1.0);
@@ -173,7 +187,7 @@ mod tests {
#[test]
fn the_helpers_start_where_they_say() {
let t0 = Instant::now();
let tween = Tween::new(MS(100), horizon_theme::QUICK);
let tween = Tween::new(MS(100), horizon_theme::QUICK.curve);
let c = at(t0, 0);
assert_eq!(fade(tween, c, t0), 0.0);
assert_eq!(fade_out(tween, c, t0), 1.0);
@@ -184,10 +198,25 @@ mod tests {
assert!((flash(tween, at(t0, 25), t0) - 0.75).abs() < 1e-6);
}
#[test]
fn the_curve_is_the_design_systems() {
for motion in horizon_theme::MOTION {
for i in 0..=20 {
let t = i as f32 / 20.0;
assert_eq!(
bezier(motion.curve, t),
motion.ease(t),
"{} at {t}",
motion.name
);
}
}
}
#[test]
fn a_turn_takes_the_short_way_round() {
let t0 = Instant::now();
let tween = Tween::new(MS(80), horizon_theme::MOVE);
let tween = Tween::new(MS(80), horizon_theme::MOVE.curve);
// From up-left (7/8 of a turn) to up-right: through up, not round the bottom.
let from = 7.0 * FRAC_PI_4;
let half = turn(tween, at(t0, 1), t0, from, FRAC_PI_4);
@@ -38,6 +38,12 @@
//! ```
use crate::hud::{HudAtlas, HudBatch};
use crate::hud_kit::{colour, fit};
use crate::menu_style::{self, OverlayColours};
/// The overlays' colours (`menu_style`, given at startup).
fn overlay() -> &'static OverlayColours {
&menu_style::get().overlay
}
/// A pip under the strip: its size and the step from one to the next, in GUI px.
pub const PIP: [f32; 2] = [3.0, 2.0];
@@ -45,8 +51,12 @@ pub const PIP_STEP: f32 = 5.0;
/// How far, in GUI px, the arrows sit from the name.
pub const ARROW_GAP: f32 = 4.0;
/// An arrow with a page that way, and one at an end, with none.
pub const ARROW: u32 = colour(horizon_theme::BACKDROP_MUTED, 255);
pub const ARROW_OFF: u32 = colour(horizon_theme::BACKDROP_MUTED, 77);
pub fn arrow_colour() -> u32 {
colour(overlay().backdrop_muted, 255)
}
pub fn arrow_off_colour() -> u32 {
colour(overlay().backdrop_muted, 77)
}
/// The pips (the selected one is white).
pub const PIP_OFF: u32 = colour(0xFFFFFF, 89);
pub const PIP_ON: u32 = colour(0xFFFFFF, 255);
@@ -65,9 +75,15 @@ pub const EDGE_FADE: f32 = 12.0;
pub const FADE: [f32; 5] = [1.0, 0.7, 0.35, 0.15, 0.1];
/// The selected tab's fill and text, and the others'.
pub const SELECTED_FILL: u32 = colour(0xFFFFFF, 242);
pub const SELECTED_TEXT: u32 = colour(horizon_theme::DARK.page, 255);
pub const TAB_FILL: u32 = colour(horizon_theme::DARK.page, 191);
pub const TAB_TEXT: u32 = colour(horizon_theme::BACKDROP_MUTED, 255);
pub fn selected_text() -> u32 {
colour(overlay().page, 255)
}
pub fn tab_fill() -> u32 {
colour(overlay().page, 191)
}
pub fn tab_text() -> u32 {
colour(overlay().backdrop_muted, 255)
}
/// The line of text's height in GUI px (vanilla's font, without its shadow).
const LINE: f32 = 8.0;
@@ -144,7 +160,11 @@ pub fn draw(batch: &mut HudBatch, atlas: &HudAtlas, strip: &Strip) {
arrow,
ax,
ay,
if on { ARROW } else { ARROW_OFF },
if on {
arrow_colour()
} else {
arrow_off_colour()
},
true,
);
}
@@ -282,9 +302,9 @@ pub fn draw_tabs(batch: &mut HudBatch, atlas: &HudAtlas, strip: &TabStrip, alpha
for tab in &row.tabs {
let [tx, ty, tw, th] = tab.area;
let (fill, text) = if tab.selected {
(SELECTED_FILL, SELECTED_TEXT)
(SELECTED_FILL, selected_text())
} else {
(TAB_FILL, TAB_TEXT)
(tab_fill(), tab_text())
};
let tab_start = batch.vertices.len();
// Cut where the edges' fades start, so each fade runs over its own piece.
@@ -380,15 +400,16 @@ mod tests {
let [ax, _] = strip.arrows[i];
(batch.vertices.iter())
.filter(|v| {
(v.pos[0] - ax).abs() < 4.0 && v.color & 0x00FF_FFFF == ARROW & 0x00FF_FFFF
(v.pos[0] - ax).abs() < 4.0
&& v.color & 0x00FF_FFFF == arrow_colour() & 0x00FF_FFFF
})
.map(|v| v.color >> 24)
.max()
.unwrap_or(0)
};
let first = collapsed(&atlas, &pages, 0, AREA);
assert_eq!(alpha(&first, 0), ARROW_OFF >> 24);
assert_eq!(alpha(&first, 1), ARROW >> 24);
assert_eq!(alpha(&first, 0), arrow_off_colour() >> 24);
assert_eq!(alpha(&first, 1), arrow_colour() >> 24);
}
#[test]
+1
View File
@@ -74,6 +74,7 @@ pub mod light;
pub mod lod;
pub mod logging;
pub mod menu_blur;
pub mod menu_style;
pub mod mesher;
pub mod minimap;
pub mod mipmap;
@@ -0,0 +1,145 @@
//! The look of the screens vanilla doesn't have that the engine still draws: the settings pages
//! (`graphics_style`), the HUD kit's tab strip, and the overlays drawn with `ui_theme` (the
//! on-screen keyboard, the boot screen, the missing-resources window). Vanilla's screens never
//! read it.
//!
//! The layer above gives it once at startup ([`install`]), before the first frame; the
//! official game gives Horizon's design system (`docs/design-system.md`). With none given, the
//! screens draw in [`NEUTRAL`]: vanilla's greys and chat colours, and no text where they would
//! use their own fonts. A read is one atomic load, and only those screens make it.
use std::sync::OnceLock;
/// Colours as `0xRRGGBB`, sizes in dp (the launcher's logical pixels).
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct MenuStyle {
pub settings: SettingsColours,
pub overlay: OverlayColours,
pub sizes: Sizes,
/// The overlays' TrueType faces; none: they draw no text.
pub fonts: Option<Fonts>,
}
/// The settings pages: square panels of dark glass over the world, flat controls, one face.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct SettingsColours {
/// The plate behind everything, drawn see-through.
pub page: u32,
/// Text, and the light the panels, rules, hovers and controls are drawn in.
pub ink: u32,
/// Secondary text and values.
pub ink_soft: u32,
/// Labels, descriptions, what can't be used.
pub muted: u32,
/// Wells and the dark edge of small marks.
pub outline: u32,
/// Focus, a checked box, a slider's fill.
pub accent: u32,
/// The one main button, and while hovered.
pub face: u32,
pub face_hover: u32,
pub success: u32,
pub warn: u32,
pub danger: u32,
}
/// Overlays drawn over the game (the HUD kit's tab strip, `ui_theme`'s overlays).
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct OverlayColours {
/// The dark page under text and tabs.
pub page: u32,
/// Muted text over a veiled backdrop.
pub backdrop_muted: u32,
/// Text and the light hover and pressed plates are drawn in.
pub ink: u32,
/// Secondary text and values.
pub ink_soft: u32,
/// One-pixel rules between rows.
pub rule: u32,
/// The main action, and its text.
pub accent: u32,
pub on_accent: u32,
}
/// `ui_theme`'s type scale and shapes, in dp.
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct Sizes {
pub caption: f32,
pub label: f32,
pub ui: f32,
pub body: f32,
pub title: f32,
/// The boot screen's wordmark at its smallest, and its console lines.
pub display: f32,
pub micro: f32,
/// Fields; plates (hover rows, selection); a large control's height.
pub radius: f32,
pub radius_plate: f32,
pub control_large: f32,
}
/// TrueType faces (`ui_theme::Face`).
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct Fonts {
pub regular: &'static [u8],
pub medium: &'static [u8],
pub semibold: &'static [u8],
pub mono: &'static [u8],
}
/// The look with nothing given: vanilla's greys and chat colours.
pub const NEUTRAL: MenuStyle = MenuStyle {
settings: SettingsColours {
page: 0x101010,
ink: 0xffffff,
ink_soft: 0xe0e0e0,
muted: 0xa0a0a0,
outline: 0x000000,
accent: 0xffffff,
face: 0x555555,
face_hover: 0x6f6f6f,
success: 0x55ff55,
warn: 0xffff55,
danger: 0xff5555,
},
overlay: OverlayColours {
page: 0x101010,
backdrop_muted: 0xa0a0a0,
ink: 0xffffff,
ink_soft: 0xe0e0e0,
rule: 0x303030,
accent: 0xffffff,
on_accent: 0x000000,
},
sizes: Sizes {
caption: 12.0,
label: 13.0,
ui: 14.0,
body: 15.0,
title: 28.0,
display: 44.0,
micro: 11.0,
radius: 0.0,
radius_plate: 0.0,
control_large: 40.0,
},
fonts: None,
};
static STYLE: OnceLock<MenuStyle> = OnceLock::new();
/// Give the menus their look: call it once at startup. A second call keeps the first look and
/// returns false.
pub fn install(style: MenuStyle) -> bool {
STYLE.set(style).is_ok()
}
/// The look given to [`install`], or [`NEUTRAL`].
#[inline]
pub fn get() -> &'static MenuStyle {
STYLE.get().unwrap_or(&NEUTRAL)
}
/// `0xRRGGBB` and an alpha in the HUD's colour encoding (`0xAABBGGRR`).
pub const fn hud_colour(rgb: u32, alpha: u8) -> u32 {
(alpha as u32) << 24 | (rgb & 0xff) << 16 | (rgb >> 8 & 0xff) << 8 | (rgb >> 16 & 0xff)
}
@@ -124,21 +124,15 @@ impl OptionsUi {
Some(open("horizon.pages.open.studio", Page::Studio)),
),
// No AI hides the AI Agent page and everything AI.
Row::Buttons(
self.extra_button(s, crate::extras::Item::DiscordPresence),
Some(if s.extras.no_ai {
self.extra_button(s, crate::extras::Item::NoAi)
} else {
open("horizon.pages.open.agent", Page::Agent)
}),
),
]
.into_iter()
.chain(
(!s.extras.no_ai)
.then(|| Row::Buttons(self.extra_button(s, crate::extras::Item::NoAi), None)),
)
.collect();
if s.extras.no_ai {
Row::Buttons(self.extra_button(s, crate::extras::Item::NoAi), None)
} else {
Row::Buttons(
open("horizon.pages.open.agent", Page::Agent),
Some(self.extra_button(s, crate::extras::Item::NoAi)),
)
},
];
self.added_entries(&mut rows);
rows
}
@@ -237,17 +237,17 @@ impl<P: Copy, S: Copy> Lines<P, S> {
pub(crate) fn backdrop(batch: &mut HudBatch, atlas: &HudAtlas, in_world: bool) {
let (w, h) = (batch.width, batch.height);
ui::menu_background(batch, atlas, 0.0, 0.0, w, h, in_world);
style::fill(batch, atlas, [0.0, 0.0, w, h], style::VEIL);
style::fill(batch, atlas, [0.0, 0.0, w, h], style::veil());
}
/// The mark and the screen's name over the search field.
pub(crate) fn mark(batch: &mut HudBatch, atlas: &HudAtlas, cols: &Columns, title: &str) {
let (x, y) = (cols.side_x, cols.side_y);
style::fill(batch, atlas, [x, y + 2.0, 6.0, 6.0], style::ACCENT);
style::fill(batch, atlas, [x, y + 2.0, 6.0, 6.0], style::accent());
let horizon = Text::literal("Horizon");
batch.text_styled(atlas, &horizon, x + 10.0, y, style::WHITE, true);
let title = Text::literal(title);
batch.text_styled(atlas, &title, x, y + 10.0, style::MUTED, true);
batch.text_styled(atlas, &title, x, y + 10.0, style::muted(), true);
}
/// A page in the page list: the open page on a plate with the accent's bar beside it, the
@@ -273,13 +273,13 @@ pub(crate) fn page_tab(
crate::widgets::target_as(rect, crate::widgets::Kind::Tab(open));
}
if open {
style::fill(batch, atlas, rect, style::OPEN);
style::fill(batch, atlas, [px, py, 2.0, ph], style::ACCENT);
style::fill(batch, atlas, rect, style::open());
style::fill(batch, atlas, [px, py, 2.0, ph], style::accent());
}
let color = match (open || hover, has) {
(true, _) => style::WHITE,
(false, true) => style::SOFT,
(false, false) => style::MUTED,
(false, true) => style::soft(),
(false, false) => style::muted(),
};
let ty = py + ((ph - 8.0) / 2.0).floor();
let name = Text::literal(style::fit(atlas, name, pw - 12.0));
@@ -312,12 +312,12 @@ pub(crate) fn search_field(
search.draw(batch, atlas, None);
if search.value.is_empty() && !focused {
let hint = Text::literal(search.message.clone());
batch.text_styled(atlas, &hint, x + 5.0, search.y, style::MUTED, true);
batch.text_styled(atlas, &hint, x + 5.0, search.y, style::muted(), true);
}
if !search.value.is_empty() {
let hover = ui::hovered(cursor, clear[0], clear[1], clear[2], clear[3]);
crate::widgets::widget_cursor(hover, true);
let color = if hover { style::WHITE } else { style::MUTED };
let color = if hover { style::WHITE } else { style::muted() };
let cy = y + ((h - 8.0) / 2.0).floor();
ui::centered_text(
batch,
@@ -344,7 +344,7 @@ pub(crate) fn plate(batch: &mut HudBatch, atlas: &HudAtlas, cols: &Columns, head
batch,
atlas,
[px + 8.0, py + 24.0, pw - 16.0, 1.0],
style::HEAD_RULE,
style::head_rule(),
);
}
@@ -383,9 +383,9 @@ pub(crate) fn list<P: Copy, S: Copy>(
}
let Some(setting) = line.setting else {
let (name, color, dy) = match line.group {
Some(("", _)) => (page_name(line.page), style::MUTED, 3.0),
Some((name, _)) => (lang::get(name), style::MUTED, 3.0),
None => (page_name(line.page), style::ACCENT, 4.0),
Some(("", _)) => (page_name(line.page), style::muted(), 3.0),
Some((name, _)) => (lang::get(name), style::muted(), 3.0),
None => (page_name(line.page), style::accent(), 4.0),
};
let name = Text::literal(name);
batch.text_styled(atlas, &name, cols.list_x + 2.0, y + dy, color, true);
@@ -393,7 +393,7 @@ pub(crate) fn list<P: Copy, S: Copy>(
};
if !after_name {
let rule = [cols.list_x + 8.0, y, cols.list_w - 16.0, 1.0];
style::fill(batch, atlas, rule, style::ROW_RULE);
style::fill(batch, atlas, rule, style::row_rule());
}
row(batch, setting, y);
}
@@ -404,7 +404,7 @@ pub(crate) fn list<P: Copy, S: Copy>(
&none,
cols.list_x + 2.0,
cols.top + 4.0,
style::MUTED,
style::muted(),
true,
);
}
@@ -499,7 +499,7 @@ pub(crate) fn row(batch: &mut HudBatch, atlas: &HudAtlas, cols: &Columns, y: f32
let active = live && (hover || focused || held);
if active {
let lit = [rect[0] + 2.0, y + 1.0, rect[2] - 4.0, style::ROW - 2.0];
style::fill(batch, atlas, lit, style::HOVER);
style::fill(batch, atlas, lit, style::hover());
}
crate::widgets::widget_cursor(hover, live);
// The control socket's widget tree, the narrator and a controller's focus.
@@ -523,8 +523,8 @@ pub(crate) fn row(batch: &mut HudBatch, atlas: &HudAtlas, cols: &Columns, y: f32
if live {
crate::widgets::target_as(rect, kind);
}
let color = if live { style::WHITE } else { style::MUTED };
let soft = if live { style::SOFT } else { style::MUTED };
let color = if live { style::WHITE } else { style::muted() };
let soft = if live { style::soft() } else { style::muted() };
let ty = y + ((style::ROW - 8.0) / 2.0).floor();
let lit = live && (hover || held);
let left = rect[0] + style::INSET;
@@ -611,7 +611,7 @@ fn lit(batch: &mut HudBatch, atlas: &HudAtlas, [x, y, w, _]: [f32; 4]) {
batch,
atlas,
[x + 2.0, y + 1.0, w - 4.0, style::ROW - 2.0],
style::HOVER,
style::hover(),
);
}
@@ -644,7 +644,7 @@ fn read_row(
let room = rect[2] - 2.0 * style::INSET - name_w - 8.0;
let value = style::fit(atlas, value, room);
let vx = rect[0] + rect[2] - style::INSET - atlas.font.width(&value);
batch.text_styled(atlas, &Text::literal(value), vx, ty, style::SOFT, true);
batch.text_styled(atlas, &Text::literal(value), vx, ty, style::soft(), true);
if focused {
style::ring(
batch,
@@ -749,7 +749,7 @@ fn pad_row(batch: &mut HudBatch, atlas: &HudAtlas, cols: &Columns, y: f32, view:
batch.text_styled(atlas, &yellow(close), right.ceil(), ty, style::WHITE, true);
}
let left = rect[0] + style::INSET;
let color = if live { style::WHITE } else { style::MUTED };
let color = if live { style::WHITE } else { style::muted() };
let shown = Text::literal(style::fit(atlas, name, chip[0] - 6.0 - left));
batch.text_styled(atlas, &shown, left, ty, color, true);
if focused {
@@ -799,7 +799,7 @@ fn side_lines(
];
crate::widgets::record(line, rect, false);
let shown = Text::literal(style::fit(atlas, line, rect[2]));
batch.text_styled(atlas, &shown, rect[0], rect[1], style::MUTED, true);
batch.text_styled(atlas, &shown, rect[0], rect[1], style::muted(), true);
}
}
/// A line under the page list: its inset, and its height.
@@ -881,7 +881,7 @@ fn heading(batch: &mut HudBatch, atlas: &HudAtlas, text: &str, [x, y, w, _]: [f3
let rect = [x + SIDE_INSET, y + HEADING_Y, w - 2.0 * SIDE_INSET, 8.0];
crate::widgets::record(text, rect, false);
let shown = Text::literal(style::fit(atlas, text, rect[2]));
batch.text_styled(atlas, &shown, rect[0], rect[1], style::MUTED, true);
batch.text_styled(atlas, &shown, rect[0], rect[1], style::muted(), true);
}
/// Vanilla's scroller, inside the panel's right edge, sized as `ui::Scroll` drags it.
@@ -1896,8 +1896,8 @@ impl<S: Screen> Shell<S> {
// The row's page is open: lit as the list's open page is.
if list.lit == Some(*shown) {
let row @ [x, y, _, h] = strip.area;
style::fill(batch, atlas, row, style::OPEN);
style::fill(batch, atlas, [x, y, 2.0, h], style::ACCENT);
style::fill(batch, atlas, row, style::open());
style::fill(batch, atlas, [x, y, 2.0, h], style::accent());
}
tabs::draw(batch, atlas, strip);
}
@@ -2134,7 +2134,14 @@ impl<S: Screen> Shell<S> {
crate::widgets::narratable(|| crate::widgets::Narratable::text(row.name.clone(), rect));
let ty = y + ((style::ROW - 8.0) / 2.0).floor();
let name = Text::literal(style::fit(atlas, &row.name, rect[2] - 2.0 * style::INSET));
batch.text_styled(atlas, &name, rect[0] + style::INSET, ty, style::MUTED, true);
batch.text_styled(
atlas,
&name,
rect[0] + style::INSET,
ty,
style::muted(),
true,
);
return;
}
let over = |[x, y, w, h]: [f32; 4]| pointer.is_some_and(|at| ui::hovered(at, x, y, w, h));
@@ -2240,7 +2247,7 @@ impl<S: Screen> Shell<S> {
// Labelled for the widget tree: what the strip says is what a test or an agent reads.
crate::widgets::record(about, cols.dock, false);
let color = if why.is_some() {
style::MUTED
style::muted()
} else {
style::GREY
};
@@ -304,19 +304,19 @@ impl StudioUi {
// Card background & highlight
if is_selected {
style::fill(batch, atlas, rect, style::OPEN);
style::fill(batch, atlas, rect, style::open());
} else if is_hovered {
style::fill(batch, atlas, rect, style::HOVER);
style::fill(batch, atlas, rect, style::hover());
} else {
style::well(batch, atlas, rect);
}
let border_color = if is_selected {
style::ACCENT
style::accent()
} else if is_hovered {
style::WHITE
} else {
style::ROW_RULE
style::row_rule()
};
style::ring(batch, atlas, rect, border_color);
+3 -6
View File
@@ -119,14 +119,11 @@ audio = ["horizon-client-render/audio", "horizon-client-lib/audio"]
controllers = ["horizon-client-lib/controllers", "dep:gilrs", "dep:horizon-dualsense", "horizon-client-input/controllers"]
# Horizon's own extras over the vanilla game, for a fork that wants them out (docs/forking.md):
# cosmetics and emotes, the "Horizon player" presence, the official server added to
# the server list, the Horizon Settings and Mods pages, Discord and Play with Friends, and ray
# tracing on request (`HORIZON_RAY_TRACING=1`). Players always get them; there is no switch. `grep -rn horizon-extras crates/` lists
# the server list, the Horizon Settings and Mods pages and Play with Friends, and ray tracing on
# request (`HORIZON_RAY_TRACING=1`). Players always get them; there is no switch. `grep -rn horizon-extras crates/` lists
# every place it gates. Without it (`--no-default-features --features audio,controllers`) the
# client is best effort: CI builds only the default.
horizon-extras = ["horizon-client-lib/horizon-extras", "horizon-client-render/horizon-extras", "discord", "friends"]
# Online extras a server or a port can leave out; the server build has none of them. "Playing Horizon"
# on Discord (game/discord.rs).
discord = ["horizon-client-lib/discord"]
horizon-extras = ["horizon-client-render/horizon-extras", "friends"]
# Play with Friends: sharing a world behind an invite code (game/friends.rs). Joining one is
# horizon-friends' `game` side, which horizon-official registers (`horizon_hooks::tunnel`).
friends = ["dep:horizon-friends"]
@@ -1208,7 +1208,6 @@ pub fn main() -> horizon_client::Result<()> {
dragon: None,
leash_boats: &[],
who_lookup: None,
foreign: None,
others_grid: $grid,
others_index: None,
others_villagers: None,
+2 -5
View File
@@ -8,7 +8,6 @@ pub mod animate_tick;
pub mod arrows;
pub mod bed_geometry;
pub mod block_contact;
pub mod parallel;
pub mod powder_snow;
pub use horizon_client_render::{
assets, atlas_sources, audio, auth, autotune, ban_notice, bidi, biome, block_edit,
@@ -18,8 +17,8 @@ pub use horizon_client_render::{
containers, controller_ui, crash, custom_sky, datapack, dynamic_light, effects, environment,
extras, fast_hash, fluid_render, fog, font, gfx, graphics, graphics_style, graphics_ui,
hdr_calibration, hud, hud_kit, hud_layout, hud_overlays, inventory_effects, java_text,
jigsaw_block, json_folder, level_loading, light, lod, logging, menu_blur, mesher, minimap,
mipmap, mod_browser, mod_list, model_library, models, multiline_edit, native_emissive,
jigsaw_block, json_folder, level_loading, light, lod, logging, menu_blur, menu_style, mesher,
minimap, mipmap, mod_browser, mod_list, model_library, models, multiline_edit, native_emissive,
options_file, options_fixes, options_ui, overlay, pack_browser, pack_formats, pack_hooks,
pack_palette, pack_screen, packs, pad_cursor, pad_glyph, par, particles, poi_sections,
polytone_creative_tabs, polytone_post, post_chain, potent_sulfur, preedit, providers, pvp,
@@ -59,8 +58,6 @@ pub mod debug_overlay;
pub mod debugworld;
pub mod dialog;
pub mod dialog_screen;
#[cfg(feature = "discord")]
pub mod discord_presence;
pub mod end_crystal;
pub mod end_gateway;
pub mod entities;
+104 -2
View File
@@ -84,9 +84,7 @@ fn slime_at(world: &World, facts: &BlockFacts, position: BlockPos) -> bool {
mod fluids;
mod item_fall;
mod item_no_physics;
mod obstacles;
pub use fluids::{fall_reset_hit, fluid_flow, update_entity_fluids, water_clip};
pub use obstacles::ObstacleGrid;
#[cfg(test)]
mod drop_motion_tests {
@@ -1113,6 +1111,110 @@ fn lava_cauldron_contacts(
}
contacts
}
/// The boxes that stop a dropped item (boats, minecarts, shulkers, a held-still happy ghast),
/// binned by 8-block cell, so an item looks only at the ones near it: a farm with many drops and
/// many vehicles checks a handful of boxes per item, not every box in the level.
#[derive(Default)]
pub struct ObstacleGrid {
cells: crate::fast_hash::FxHashMap<(i32, i32, i32), Vec<u32>>,
boxes: Vec<Aabb>,
/// Each box's entity's `getRootVehicle` when it rides something; empty
/// when none was given.
roots: Vec<Option<u32>>,
found: Vec<u32>,
}
impl ObstacleGrid {
const CELL: f64 = 8.0;
pub fn new(boxes: Vec<Aabb>) -> Self {
Self::with_roots(boxes, Vec::new())
}
/// [`Self::new`] with the vehicle at the bottom of what each box's entity
/// rides (`roots[i]` for `boxes[i]`), so [`Self::near_except`] can leave a
/// mover's own riders out.
pub fn with_roots(boxes: Vec<Aabb>, roots: Vec<Option<u32>>) -> Self {
let mut cells: crate::fast_hash::FxHashMap<(i32, i32, i32), Vec<u32>> = Default::default();
for (i, b) in boxes.iter().enumerate() {
let (lo, hi) = Self::span(*b);
for x in lo.0..=hi.0 {
for y in lo.1..=hi.1 {
for z in lo.2..=hi.2 {
cells.entry((x, y, z)).or_default().push(i as u32);
}
}
}
}
Self {
cells,
boxes,
roots,
found: Vec::new(),
}
}
fn span(b: Aabb) -> ((i32, i32, i32), (i32, i32, i32)) {
let cell = |v: Vec3| {
(
(v.x / Self::CELL).floor() as i32,
(v.y / Self::CELL).floor() as i32,
(v.z / Self::CELL).floor() as i32,
)
};
(cell(b.min_position()), cell(b.max_position()))
}
pub fn is_empty(&self) -> bool {
self.boxes.is_empty()
}
/// The boxes meeting `area` (`getEntityCollisions`'s query box), in the order they were given.
pub fn near(&mut self, area: Aabb, out: &mut Vec<Aabb>) {
self.near_except(area, None, out);
}
/// [`Self::near`] without the boxes of what rides `vehicle` (given with
/// [`Self::with_roots`]): `Entity.canCollideWith` leaves out
/// `isPassengerOfSameVehicle`, so a stand's riders do not stop it.
pub fn near_except(&mut self, area: Aabb, vehicle: Option<u32>, out: &mut Vec<Aabb>) {
out.clear();
if self.boxes.is_empty() {
return;
}
let kept = |i: usize| vehicle.is_none() || self.roots.get(i).copied().flatten() != vehicle;
let (lo, hi) = Self::span(area);
let side = |lo: i32, hi: i32| (i64::from(hi) - i64::from(lo) + 1).max(0);
let cells = side(lo.0, hi.0)
.saturating_mul(side(lo.1, hi.1))
.saturating_mul(side(lo.2, hi.2));
// An area over more cells than there are boxes (a stand loaded with a
// huge Motion asks for one millions of blocks long): test each box
// once rather than walk the cells.
if cells > self.boxes.len() as i64 {
out.extend(
self.boxes
.iter()
.enumerate()
.filter(|&(i, b)| kept(i) && b.intersects(area))
.map(|(_, b)| *b),
);
return;
}
self.found.clear();
for x in lo.0..=hi.0 {
for y in lo.1..=hi.1 {
for z in lo.2..=hi.2 {
if let Some(list) = self.cells.get(&(x, y, z)) {
self.found.extend(list);
}
}
}
}
self.found.sort_unstable();
self.found.dedup();
for &i in &self.found {
let b = self.boxes[i as usize];
if kept(i as usize) && b.intersects(area) {
out.push(b);
}
}
}
}
/// Dropped items by block cell, for `ItemEntity.mergeWithNeighbours`'s
/// search around an item. Holds indexes into the tick's item list.
#[derive(Default)]
@@ -1,122 +0,0 @@
//! The boxes that stop a dropped item, by cell (`ObstacleGrid`).
use crate::fast_hash::FxHashMap;
use horizon_core::geometry::{Aabb, Vec3};
/// The boxes that stop a dropped item (boats, minecarts, shulkers, a held-still happy ghast),
/// binned by 8-block cell, so an item looks only at the ones near it: a farm with many drops and
/// many vehicles checks a handful of boxes per item, not every box in the level.
#[derive(Default)]
pub struct ObstacleGrid {
cells: FxHashMap<(i32, i32, i32), Vec<u32>>,
boxes: Vec<Aabb>,
/// Each box's entity's `getRootVehicle` when it rides something; empty
/// when none was given.
roots: Vec<Option<u32>>,
found: Vec<u32>,
}
impl ObstacleGrid {
const CELL: f64 = 8.0;
pub fn new(boxes: Vec<Aabb>) -> Self {
Self::with_roots(boxes, Vec::new())
}
/// [`Self::new`] with the vehicle at the bottom of what each box's entity
/// rides (`roots[i]` for `boxes[i]`), so [`Self::near_except`] can leave a
/// mover's own riders out.
pub fn with_roots(boxes: Vec<Aabb>, roots: Vec<Option<u32>>) -> Self {
let mut cells: FxHashMap<(i32, i32, i32), Vec<u32>> = Default::default();
for (i, b) in boxes.iter().enumerate() {
let (lo, hi) = Self::span(*b);
for x in lo.0..=hi.0 {
for y in lo.1..=hi.1 {
for z in lo.2..=hi.2 {
cells.entry((x, y, z)).or_default().push(i as u32);
}
}
}
}
Self {
cells,
boxes,
roots,
found: Vec::new(),
}
}
fn span(b: Aabb) -> ((i32, i32, i32), (i32, i32, i32)) {
let cell = |v: Vec3| {
(
(v.x / Self::CELL).floor() as i32,
(v.y / Self::CELL).floor() as i32,
(v.z / Self::CELL).floor() as i32,
)
};
(cell(b.min_position()), cell(b.max_position()))
}
pub fn is_empty(&self) -> bool {
self.boxes.is_empty()
}
/// The boxes meeting `area` (`getEntityCollisions`'s query box), in the order they were given.
pub fn near(&mut self, area: Aabb, out: &mut Vec<Aabb>) {
self.near_except(area, None, out);
}
/// [`Self::near`] without the boxes of what rides `vehicle` (given with
/// [`Self::with_roots`]): `Entity.canCollideWith` leaves out
/// `isPassengerOfSameVehicle`, so a stand's riders do not stop it.
pub fn near_except(&mut self, area: Aabb, vehicle: Option<u32>, out: &mut Vec<Aabb>) {
let mut found = std::mem::take(&mut self.found);
self.near_with(area, vehicle, &mut found, out);
self.found = found;
}
/// [`Self::near`] with the caller's scratch, so several threads can ask one grid.
pub fn near_shared(&self, area: Aabb, found: &mut Vec<u32>, out: &mut Vec<Aabb>) {
self.near_with(area, None, found, out);
}
fn near_with(
&self,
area: Aabb,
vehicle: Option<u32>,
found: &mut Vec<u32>,
out: &mut Vec<Aabb>,
) {
out.clear();
if self.boxes.is_empty() {
return;
}
let kept = |i: usize| vehicle.is_none() || self.roots.get(i).copied().flatten() != vehicle;
let (lo, hi) = Self::span(area);
let side = |lo: i32, hi: i32| (i64::from(hi) - i64::from(lo) + 1).max(0);
let cells = side(lo.0, hi.0)
.saturating_mul(side(lo.1, hi.1))
.saturating_mul(side(lo.2, hi.2));
// An area over more cells than there are boxes (a stand loaded with a
// huge Motion asks for one millions of blocks long): test each box
// once rather than walk the cells.
if cells > self.boxes.len() as i64 {
out.extend(
self.boxes
.iter()
.enumerate()
.filter(|&(i, b)| kept(i) && b.intersects(area))
.map(|(_, b)| *b),
);
return;
}
found.clear();
for x in lo.0..=hi.0 {
for y in lo.1..=hi.1 {
for z in lo.2..=hi.2 {
if let Some(list) = self.cells.get(&(x, y, z)) {
found.extend(list);
}
}
}
}
found.sort_unstable();
found.dedup();
for &i in found.iter() {
let b = self.boxes[i as usize];
if kept(i as usize) && b.intersects(area) {
out.push(b);
}
}
}
}
+1
View File
@@ -13,6 +13,7 @@ pub fn main_with(mods: crate::mod_api::Mods) {
log::warn!("Trait mods already installed; ignoring the second list");
}
horizon_threads::configure_allocator();
crate::horizon_look::give();
// OptiFine's and Polytone's pack formats (platform, docs/layers.md), before any pack loads.
horizon_client::pack_formats::install();
// horizon-extras (docs/forking.md): without the feature, no Horizon Settings or Mods page.
+6 -15
View File
@@ -49,6 +49,7 @@ fn pack_files(world_tables: &Option<WorldTables>) -> horizon_world::loot::PackOv
}
}
mod activity;
mod advancements;
mod advancements_screen;
mod agent;
@@ -128,8 +129,6 @@ mod decorated_pot;
mod dedicated;
mod deflect;
mod dialogs;
#[cfg(feature = "discord")]
mod discord;
mod dispense_fixture;
mod dispenser;
mod dragon;
@@ -6717,8 +6716,8 @@ impl Game<'_> {
.extend(out.drain(player_start..));
}
self.mark_motion(out.len(), 0);
// Companions in first person, trails and auras (game/cosmetics/fx.rs).
self.cosmetics_fx_geometry(camera, alpha, &mut out);
// What crates draw around players: first person, see-through (game/player_hooks.rs).
self.players_after_geometry(camera, alpha, &mut out);
self.mark_entity(out.len(), "minecraft:evoker_fangs");
self.fangs_geometry(camera, alpha, &mut out);
self.block_item_display_geometry(camera, alpha, &mut out);
@@ -11253,8 +11252,7 @@ impl Game<'_> {
self.heal_hold_latches();
// For the hooks above the engine (the "I'm playing" ping): said when it changes.
horizon_hooks::engine::published(self.is_published());
#[cfg(feature = "discord")]
self.update_discord_presence();
self.report_activity();
let steps = self.clock.advance(elapsed);
for _ in 0..steps {
let started = Instant::now();
@@ -12529,15 +12527,8 @@ pub fn run_with() -> Result<()> {
if options.execution_role.is_client() {
narrator::start(&early_settings);
}
// "Playing Horizon" on Discord: never for a server; an automated run only talks to a test's fake Discord.
#[cfg(feature = "discord")]
if options.dedicated.is_none() {
let automated = options.benchmark.is_some()
|| options.offscreen
|| options.frames.is_some()
|| options.world_frames.is_some();
horizon_client::discord_presence::start(early_settings.extras.discord_presence, automated);
}
// A game window's hooks (Discord's presence): never for a server.
activity::client_started(&options);
// Snapshots of derived tables live in the game folder, never beside the
// shared resources.
Behaviours::set_cache_dir(std::env::current_dir()?.join(".horizon-cache"));
@@ -0,0 +1,68 @@
//! What the player is doing, for the hooks above the engine (`Hooks::activity`: the Horizon
//! layer's Discord presence shows it).
use super::*;
use horizon_client::command::args::GameMode;
use horizon_hooks::{Activity, Client, Dimension, Play};
/// At startup, for a game window (never a dedicated server's): the player's first activity, then
/// `Hooks::client_started`.
pub(super) fn client_started(options: &Options) {
if options.dedicated.is_some() {
return;
}
let automated = options.benchmark.is_some()
|| options.offscreen
|| options.frames.is_some()
|| options.world_frames.is_some();
horizon_hooks::engine::activity(Activity::Menus);
horizon_hooks::engine::client_started(&Client::new(automated));
}
impl Game<'_> {
/// Each frame: only a change reaches the hooks.
pub(super) fn report_activity(&self) {
// One status per computer: P1's, not a splitscreen player's.
if self.options.split_guest {
return;
}
horizon_hooks::engine::activity(self.activity());
}
fn activity(&self) -> Activity {
if self.title.is_some() {
return Activity::Menus;
}
let max = horizon_client::lan::MAX_PLAYERS;
let play = if let Some(host) = &self.lan {
Play::Hosting {
players: 1 + host.hub.guests.len(),
max,
}
} else if let Some(server) = &self.integrated {
match (server.published(), &self.remote) {
(Some(_), Some(remote)) => Play::Hosting {
players: remote.player_count(),
max,
},
_ => Play::Singleplayer,
}
} else if self.remote.is_some() {
Play::Multiplayer
} else {
Play::Singleplayer
};
Activity::World {
play,
dimension: match self.dim {
Dim::Overworld => Dimension::Overworld,
Dim::Nether => Dimension::Nether,
Dim::End => Dimension::End,
},
mode: match self.mode {
GameMode::Survival => horizon_hooks::GameMode::Survival,
GameMode::Creative => horizon_hooks::GameMode::Creative,
GameMode::Adventure => horizon_hooks::GameMode::Adventure,
GameMode::Spectator => horizon_hooks::GameMode::Spectator,
},
}
}
}
+93 -14
View File
@@ -1278,10 +1278,21 @@ impl Game<'_> {
.raw
.get(&id)
.is_some_and(|tag| horizon_client::entity_nbt::flag(tag, "Invulnerable"));
// Entity.isInvulnerableTo: the flag, then EnchantmentHelper.isImmuneToDamage.
let a = &self.armor_stands[i];
if (invulnerable && !creative) || a.invisible || a.marker {
if (invulnerable && !creative)
|| a.invisible
|| a.marker
|| self.stand_immune_to_damage(i, damage_type, causing)
{
return false;
}
// ArmorStand.hurtServer attributes its game events to the source
// entity: the player, a LAN guest or the mob.
let by = match causing {
Some(Target::Player | Target::Guest(_) | Target::Mob(_)) => causing,
_ => None,
};
let (explosion, ignites, burns) = (
tagged("minecraft:is_explosion"),
tagged("minecraft:ignites_armor_stands"),
@@ -1292,21 +1303,26 @@ impl Game<'_> {
tagged("minecraft:always_kills_armor_stands"),
);
if explosion {
self.break_armor_stand_by_anything(i);
self.break_armor_stand_with_source(i, by);
return false;
}
if ignites {
if self.armor_stands[i].fire_ticks > 0 {
self.cause_stand_damage(i, 0.15);
self.cause_stand_damage(i, 0.15, by);
} else {
// igniteForSeconds(5.0f): at least 100 ticks.
// igniteForSeconds(5.0f): at least 100 ticks, and
// Entity.igniteForTicks calls clearFreeze.
let a = &mut self.armor_stands[i];
a.fire_ticks = a.fire_ticks.max(100);
if let Some(horizon_world::nbt::Tag::Compound(map)) = self.persist.raw.get_mut(&id)
{
map.remove(&"TicksFrozen".into());
}
}
return false;
}
if burns && self.armor_stands[i].health > 0.5 {
self.cause_stand_damage(i, 4.0);
self.cause_stand_damage(i, 4.0, by);
return false;
}
if !breaks && !always_kills {
@@ -1321,22 +1337,85 @@ impl Game<'_> {
if !may_build {
return false;
}
// ArmorStand.hurtServer attributes its game events to the source
// entity: the player, a LAN guest or the mob.
let by = match causing {
Some(Target::Player | Target::Guest(_) | Target::Mob(_)) => causing,
_ => None,
};
self.break_armor_stand_by_hit(i, creative, always_kills, by)
}
/// `EnchantmentHelper.isImmuneToDamage` for the stand at `i`: whether an
/// enchantment on an active equipment slot has a `minecraft:damage_immunity`
/// effect whose requirements hold for `damage_type` and `causing`.
fn stand_immune_to_damage(
&mut self,
i: usize,
damage_type: &str,
causing: Option<super::commands::Target>,
) -> bool {
use super::commands::Target;
let mut checks = Vec::new();
let equipment = &self.armor_stands[i].equipment;
for (stack, slot) in equipment.iter().zip(ArmorStand::SLOTS) {
let Some(stack) = stack else { continue };
let armor = matches!(slot, "head" | "chest" | "legs" | "feet");
let hand = matches!(slot, "mainhand" | "offhand");
for (id, level) in stack.enchantments() {
let Some(def) = self.res.stations().enchantments.get(id) else {
continue;
};
if !def.slots.iter().any(|s| {
s == slot || s == "any" || (s == "armor" && armor) || (s == "hand" && hand)
}) {
continue;
}
let immunity = &def.effects["minecraft:damage_immunity"];
for entry in immunity.as_array().into_iter().flatten() {
checks.push((entry.get("requirements").cloned(), *level));
}
}
}
if checks.is_empty() {
return false;
}
let attacker = match causing {
Some(Target::Player | Target::Guest(_)) => Some("minecraft:player".to_owned()),
Some(Target::Mob(m_id)) => self
.mobs
.iter()
.find(|m| m.id == m_id)
.map(|m| m.kind.id.clone()),
Some(Target::Stand(_)) => Some("minecraft:armor_stand".to_owned()),
_ => None,
};
let canonical = if damage_type.contains(':') {
damage_type.to_owned()
} else {
format!("minecraft:{damage_type}")
};
let ctx = horizon_client::enchant::EffectContext {
items: Some(self.res.items()),
damage_type: Some(&canonical),
is_direct: attacker.is_some(),
this: Some("minecraft:armor_stand"),
attacker: attacker.as_deref(),
direct_attacker: attacker.as_deref(),
..Default::default()
};
let ench = &self.res.stations().enchantments;
let rng = &mut self.world.random;
for (req, level) in checks {
if req.is_none_or(|r| ench.condition(&r, level, &ctx, rng)) {
return true;
}
}
false
}
/// `ArmorStand.causeDamage`: the health less `amount`, broken by anything
/// at 0.5 or less.
fn cause_stand_damage(&mut self, i: usize, amount: f32) {
/// at 0.5 or less, else `gameEvent(ENTITY_DAMAGE, damageSource.getEntity())`.
fn cause_stand_damage(&mut self, i: usize, amount: f32, by: Option<super::commands::Target>) {
let health = self.armor_stands[i].health - amount;
if health <= 0.5 {
self.break_armor_stand_by_anything(i);
self.break_armor_stand_with_source(i, by);
} else {
let at = self.armor_stands[i].position;
self.armor_stands[i].health = health;
self.stand_game_event("minecraft:entity_damage", [at.x, at.y, at.z], by);
}
}
/// ArmorStandRenderer: the stand with its default pose, then armor layers.
@@ -239,19 +239,6 @@ impl Game<'_> {
self.tick_scheduled()?;
}
self.with_level(|b, level, ticks| {
// The masks the random ticks will read, built on helper threads first while the
// parallel tick is on (`HORIZON_PARALLEL_TICK`): the draws and ticks don't depend on them.
if !debug && horizon_client::parallel::lanes() > 1 {
horizon_client::spans::time("random_tick_masks", || {
let chunks = &level.world.chunks;
level.random_tick_sections.prepare_masks(
&scratch.ticking,
|key| chunks.get(&key).cloned(),
level.facts,
&|id| b.random_tick_candidate(BlockStateId(id)),
);
});
}
horizon_client::spans::time("random_ticks", || {
for (x, z) in scratch.ticking.drain(..).filter(|_| !debug) {
// The last answer, asked from Y `from` at a world revision:
+10 -28
View File
@@ -682,14 +682,19 @@ impl Game<'_> {
/// of it (`ClipContext.Block.FALLDAMAGE_RESETTING`, `ClipContext.Fluid.WATER`)
/// and a hit resets the fall, before the move's own drop counts.
pub(super) fn vehicle_fall_reset(&self, fall: f64, from: Vec3, moved: Vec3) -> f64 {
fall_reset(
let length = moved.length_squared();
if fall == 0.0 || length < 1.0 {
return fall;
}
let reset = &self.res.motion().fall_reset;
let hit = horizon_client::entities::fall_reset_hit(
&self.world,
self.res.motion(),
self.res.facts(),
fall,
&self.res.motion().fluids,
|p| reset.contains(&*self.res.facts().get(self.world.block(p.x, p.y, p.z)).name),
from,
moved,
)
);
if hit { 0.0 } else { fall }
}
/// `AbstractBoat.checkFallDamage` after a move that went `moved_y` (with
/// `b` already where it landed): on the ground the fall resets; a move
@@ -2066,26 +2071,3 @@ mod tests {
}
}
horizon_client::impl_joined!(Boat);
/// [`Game::vehicle_fall_reset`] from the tables it reads.
pub(super) fn fall_reset(
world: &horizon_client::world::World,
motion: &horizon_client::motion::MotionResources,
facts: &horizon_client::blocks::BlockFacts,
fall: f64,
from: Vec3,
moved: Vec3,
) -> f64 {
if fall == 0.0 || moved.length_squared() < 1.0 {
return fall;
}
let reset = &motion.fall_reset;
let hit = horizon_client::entities::fall_reset_hit(
world,
&motion.fluids,
|p| reset.contains(&*facts.get(world.block(p.x, p.y, p.z)).name),
from,
moved,
);
if hit { 0.0 } else { fall }
}
@@ -380,7 +380,7 @@ impl Game<'_> {
let (mut meta, mut defaults) = (Vec::new(), Vec::new());
super::lan::mob_meta(
m,
hub.sync_data(),
hub,
None,
g.world.game_time,
&mut meta,
File diff suppressed because it is too large. Load diff
@@ -71,11 +71,6 @@ pub(in crate::game) fn pose_playing(pose: Option<&Arc<dyn BodyPose>>) -> Option<
any.downcast_ref::<EmotePose>().map(|e| &e.0)
}
/// The emote `m` plays, if any.
pub(in crate::game) fn playing(m: &Mob) -> Option<&Playing> {
pose_playing(m.emote.as_ref())
}
/// The pose that plays `next` where `now` is laid, unless it plays that already (None: keep
/// it; a new one allocates).
fn replaced(
+160 -255
View File
@@ -1,47 +1,78 @@
//! Companions, trails and auras ([docs/cosmetics.md](../../../../../docs/cosmetics.md)): worn
//! like the other cosmetics but on no bone. A companion floats beside its wearer and follows with
//! a lag (`horizon_cosmetics::follow`), drawn with the wearer; a trail's particles
//! (`horizon_cosmetics::trail`) and an aura blend, so they go after every player in one
//! see-through span (`cosmetics_fx_geometry`). Nothing of them is an entity, collides or is sent
//! to a server.
//! (`horizon_cosmetics::trail`) and an aura blend, so they are drawn see-through after every
//! player (`PlayerDraw::particles`, `Look::see_through`). Nothing of them is an entity, collides
//! or is sent to a server.
use super::{FULL_LIGHT, OWN};
use crate::game::Game;
use super::Seat;
use glam::{DVec3, Mat4, Vec3};
use horizon_client::mesher::Vertex;
use horizon_client::mobs::anim::Poses;
use horizon_client::mobs::{Mob, Part};
use horizon_client::fast_hash::FxHashMap;
use horizon_cosmetics::follow::{self, Follow};
use horizon_cosmetics::trail::Emitter;
use horizon_cosmetics::trail::{Budget, Emitter};
use horizon_cosmetics::{Item, Slot};
use horizon_hooks::players::{Kind, Light, Look, Particle, Pass, PlayerDraw, PlayerModel};
use std::sync::Arc;
use std::time::Instant;
/// How far from the camera (blocks) a wearer's companion, trail and aura are drawn.
const FAR: f32 = 48.0;
/// Who wears it, as drawn this frame.
pub(super) struct Wearer<'a> {
pub m: &'a Mob,
/// The player's own model.
pub own: bool,
/// `OWN`, or the entity id.
pub key: u32,
/// The wearer's model matrix (`render::base_transform`), relative to `camera`.
pub base: Mat4,
pub camera: DVec3,
pub alpha: f32,
pub light: u32,
/// The key of the player's own model in `Fx`'s maps (others go by entity id).
const OWN: u32 = u32::MAX;
/// What companions and trails keep between frames, per wearer (marked with the frame they were
/// last drawn in; the others are dropped).
#[derive(Default)]
pub(super) struct Fx {
companions: FxHashMap<u32, (Follow, u32)>,
trails: FxHashMap<u32, (Emitter, u32)>,
budget: Budget,
clock: Option<(Instant, Instant)>,
/// Ticks since the first frame, and the last frame's length in seconds.
now: f64,
dt: f32,
frame: u32,
/// Kept so a frame of trails allocates none.
particles: Vec<Particle>,
}
/// `m`'s head yaw this frame (degrees).
fn head_yaw(m: &Mob, alpha: f32) -> f32 {
let turn = horizon_client::mobs::living::wrap(m.head_yaw - m.previous_head_yaw);
m.previous_head_yaw + turn * alpha
}
impl Fx {
/// A new frame: the clock moves on and what no frame drew since the last one goes.
pub(super) fn frame(&mut self) {
self.frame = self.frame.wrapping_add(1);
let frame = self.frame;
let now = Instant::now();
let (start, last) = self.clock.get_or_insert((now, now));
self.dt = now.duration_since(*last).as_secs_f32().min(0.25);
self.now = now.duration_since(*start).as_secs_f64() * 20.0;
*last = now;
let seen = |mark: u32| frame.wrapping_sub(mark) <= 1;
self.companions.retain(|_, (_, mark)| seen(*mark));
let budget = &mut self.budget;
self.trails.retain(|_, (e, mark)| {
let keep = seen(*mark);
if !keep {
e.clear(budget);
}
keep
});
}
/// Whether `m` lies in a bed.
fn asleep(m: &Mob) -> bool {
m.extra.avatar_pose == horizon_client::pose::Pose::Sleeping
/// The companion of wearer `key` moved toward `target` (once a frame, however many times it
/// is drawn).
fn follow(&mut self, key: u32, target: DVec3, yaw: f32, lag: f32) -> Follow {
let (frame, dt) = (self.frame, self.dt);
let (f, mark) = self
.companions
.entry(key)
.or_insert((Follow::at(target, yaw), frame));
if *mark != frame {
f.step(target, yaw, lag, dt);
*mark = frame;
}
*f
}
}
/// Where a companion floats: `at` (relative to the camera) facing `yaw`, at the wearer's scale,
@@ -52,270 +83,144 @@ fn companion_matrix(at: Vec3, yaw: f32, scale: f32) -> Mat4 {
* Mat4::from_scale(Vec3::new(-scale, -scale, scale))
}
impl Game<'_> {
/// A companion, trail or aura `at` wears: a companion goes where the wearer's other items go
/// (`gpu` and `out`, `bounds` grown), trails and auras to the frame's see-through vertices.
/// Other players' trails and auras stay hidden with "Hide other players' trails and auras".
pub(super) fn cosmetics_fx(
&self,
at: &Wearer,
item: &Arc<Item>,
gpu: bool,
bounds: Option<(Vec3, Vec3)>,
poses: &mut Poses,
out: &mut Vec<Vertex>,
) -> Option<(Vec3, Vec3)> {
if !at.own && item.slot.is_effect() && self.settings.extras.cosmetics_hide_fx {
return bounds;
/// Full light for what glows, else `otherwise`.
fn glowing(glow: bool, otherwise: Light) -> Light {
if glow { Light::Full } else { otherwise }
}
impl Seat {
/// A companion, trail or aura `m` wears, around its model as `draw` draws it. Other players'
/// trails and auras stay hidden with "Hide other players' trails and auras".
pub(super) fn fx(&mut self, m: &PlayerModel, item: &Arc<Item>, draw: &mut dyn PlayerDraw) {
let own = m.kind == Kind::Own;
if !own && item.slot.is_effect() && self.settings.hide_fx {
return;
}
// Past `FAR` a companion, trail or aura is a few pixels across for the cost of a model:
// not drawn, and a trail that isn't drawn doesn't spawn (its particles go back to the
// budget).
if at.base.w_axis.truncate().length_squared() > FAR * FAR {
return bounds;
let base = Mat4::from_cols_array_2d(&draw.base());
if base.w_axis.truncate().length_squared() > FAR * FAR {
return;
}
let Some(uv) = self.uv(item, |id| draw.sprite(id)) else {
return;
};
let key = if own { OWN } else { m.id };
match item.slot {
Slot::Companion => {
let Some((model, uv, matrix)) = self.companion_at(at, item, None) else {
return bounds;
};
self.pose_companion(item, at.m, at.alpha, poses, &model);
let glow = item.companion.as_ref().is_some_and(|c| c.glow);
let light = if glow { FULL_LIGHT } else { at.light };
self.cosmetics_model(&model, matrix, poses, uv, light, gpu, bounds, out)
}
Slot::Trail => {
self.trail(at, item);
bounds
}
Slot::Aura => {
self.aura(at, item, poses);
bounds
}
_ => bounds,
Slot::Companion => self.companion(key, item, uv, base, draw),
Slot::Trail => self.trail(key, item, uv, draw),
Slot::Aura => self.aura(item, uv, base, draw),
_ => {}
}
}
/// The companion's model, texture and matrix this frame, its follow moved on once a frame.
/// `first_person`: the camera's forward and left, for the player's own in first person: the
/// companion keeps out of the middle of the view.
fn companion_at(
&self,
at: &Wearer,
/// The companion where it floats, its follow moved on once a frame. In first person it keeps
/// out of the middle of the view, lit where it floats.
fn companion(
&mut self,
key: u32,
item: &Arc<Item>,
first_person: Option<(Vec3, Vec3)>,
) -> Option<(Arc<Part>, [f32; 4], Mat4)> {
let c = item.companion.as_ref()?;
let (models, uv) = self.cosmetics_baked(item)?;
let model = models.first()?.1.clone();
let scale = at.base.x_axis.truncate().length();
let target = at.base.transform_point3(Vec3::from(c.anchor) / 16.0);
let head = at
.base
.transform_point3(Vec3::from(horizon_cosmetics::HEAD) / 16.0);
let yaw = head_yaw(at.m, at.alpha);
let follow = self.companion_follow(at.key, at.camera + target.as_dvec3(), yaw, c.lag);
uv: [f32; 4],
base: Mat4,
draw: &mut dyn PlayerDraw,
) {
let Some(c) = item.companion.as_ref() else {
return;
};
let Some((_, mesh)) = self.kept(item).meshes.first().cloned() else {
return;
};
let first_person = draw.pass() == Pass::FirstPerson;
let (motion, view) = (draw.motion(), draw.view());
let camera = DVec3::from(view.camera);
let scale = base.x_axis.truncate().length();
let target = base.transform_point3(Vec3::from(c.anchor) / 16.0);
let head = base.transform_point3(Vec3::from(horizon_cosmetics::HEAD) / 16.0);
let follow = self
.fx
.follow(key, camera + target.as_dvec3(), motion.head_yaw, c.lag);
let clearance = (horizon_cosmetics::HEAD_CLEARANCE + c.reach) / 16.0 * scale;
let mut rel = (follow.pos - at.camera).as_vec3();
if let Some((forward, left)) = first_person {
rel = follow::out_of_view(rel, forward, left);
let mut rel = (follow.pos - camera).as_vec3();
if first_person {
rel = follow::out_of_view(rel, Vec3::from(view.forward), Vec3::from(view.left));
}
rel = follow::clear_of(rel, head, clearance);
Some((model, uv, companion_matrix(rel, follow.yaw, scale)))
}
/// The companion of wearer `key` moved toward `target` (once a frame, however many times it
/// is drawn).
fn companion_follow(&self, key: u32, target: DVec3, yaw: f32, lag: f32) -> Follow {
let frame = self.cosmetics.frame.get();
let mut fx = self.cosmetics.fx.borrow_mut();
let dt = fx.dt;
let (f, mark) = fx
.companions
.entry(key)
.or_insert((Follow::at(target, yaw), frame));
if *mark != frame {
f.step(target, yaw, lag, dt);
*mark = frame;
}
*f
}
/// The companion's animations, and its `look` part turned with the wearer's pitch.
fn pose_companion(&self, item: &Item, m: &Mob, alpha: f32, poses: &mut Poses, model: &Part) {
poses.reset(model);
item.animate(m.age as f32 + alpha, |name, pose| *poses.get(name) = pose);
if let Some(look) = item.companion.as_ref().and_then(|c| c.look.as_deref()) {
let pitch = m.previous_head_pitch + (m.head_pitch - m.previous_head_pitch) * alpha;
poses.get(look)[3] += (pitch * 0.6).clamp(-30.0, 30.0).to_radians();
}
let matrix = companion_matrix(rel, follow.yaw, scale).to_cols_array_2d();
let near = if first_person {
Light::AtPlace
} else {
Light::Model
};
let look = Look::new(uv).lit(glowing(c.glow, near));
let ticks = draw.ticks();
// Its animations, and its `look` part turned with the wearer's pitch.
let pitch = (motion.head_pitch * 0.6).clamp(-30.0, 30.0).to_radians();
draw.draw(&mesh, matrix, look, &mut |p| {
item.animate(ticks, |name, pose| *p.pose(name) = pose);
if let Some(part) = c.look.as_deref() {
p.pose(part)[3] += pitch;
}
});
}
/// The trail's particles: spawned once a frame by the distance walked (not while sneaking or
/// asleep), facing the camera.
fn trail(&self, at: &Wearer, item: &Arc<Item>) {
fn trail(&mut self, key: u32, item: &Arc<Item>, uv: [f32; 4], draw: &mut dyn PlayerDraw) {
let Some(t) = &item.trail else {
return;
};
let Some((_, uv)) = self.cosmetics_baked(item) else {
return;
};
let m = at.m;
let a = f64::from(at.alpha);
let feet = DVec3::new(
m.previous.x + (m.position.x - m.previous.x) * a,
m.previous.y + (m.position.y - m.previous.y) * a,
m.previous.z + (m.position.z - m.previous.z) * a,
);
let moves = !m.extra.crouching && !asleep(m);
let q = self.camera_orientation();
let (right, up) = (q * Vec3::X, q * Vec3::Y);
let light = if t.glow { FULL_LIGHT } else { at.light };
let frame = self.cosmetics.frame.get();
let mut fx = self.cosmetics.fx.borrow_mut();
let super::Fx {
let (motion, view) = (draw.motion(), draw.view());
let moves = !motion.crouching && !motion.asleep;
let Fx {
trails,
budget,
now,
clear,
frame,
particles,
..
} = &mut *fx;
} = &mut self.fx;
let (e, mark) = trails
.entry(at.key)
.entry(key)
.or_insert_with(|| (Emitter::default(), frame.wrapping_sub(1)));
if *mark != frame {
e.step(t, feet, moves, *now, budget);
*mark = frame;
}
for quad in e.quads(t, *now, at.camera, right, up) {
let color = ((quad.alpha.clamp(0.0, 1.0) * 255.0) as u32) << 24 | 0x00FF_FFFF;
for (corner, [u, v]) in quad.corners.iter().zip(quad.uv) {
clear.push(Vertex {
pos: corner.to_array(),
uv: [uv[0] + (uv[2] - uv[0]) * u, uv[1] + (uv[3] - uv[1]) * v],
layer: 0,
color,
light,
// Unlit: a particle keeps its colour whichever way it faces.
normal: [0, 0, 0, 0],
glint_uv: [0.0, 0.0],
});
}
if *mark != *frame {
e.step(t, DVec3::from(motion.feet), moves, *now, budget);
*mark = *frame;
}
let (camera, right, up) = (
DVec3::from(view.camera),
Vec3::from(view.right),
Vec3::from(view.up),
);
particles.clear();
particles.extend(
e.quads(t, *now, camera, right, up)
.map(|q| Particle::new(q.corners.map(|c| c.to_array()), q.uv, q.alpha)),
);
draw.particles(uv, particles, glowing(t.glow, Light::Model));
}
/// The aura at the wearer's feet, see-through. None while they fly, swim or sleep: it would
/// tip over with the body.
fn aura(&self, at: &Wearer, item: &Arc<Item>, poses: &mut Poses) {
let (m, Some(a)) = (at.m, &item.aura) else {
fn aura(&mut self, item: &Arc<Item>, uv: [f32; 4], base: Mat4, draw: &mut dyn PlayerDraw) {
let Some(a) = &item.aura else {
return;
};
if m.extra.fall_flying || asleep(m) || m.extra.swim.amount(at.alpha) > 0.0 {
let motion = draw.motion();
if motion.flying || motion.asleep || motion.swimming {
return;
}
let Some((models, uv)) = self.cosmetics_baked(item) else {
return;
};
let Some((_, model)) = models.first() else {
let Some((_, mesh)) = self.kept(item).meshes.first().cloned() else {
return;
};
// The model's origin is the middle of the feet: 24 pixels under the neck.
let matrix = at.base * Mat4::from_translation(Vec3::new(0.0, 24.0 / 16.0, 0.0));
poses.reset(model);
item.animate(m.age as f32 + at.alpha, |name, pose| {
*poses.get(name) = pose
let matrix = base * Mat4::from_translation(Vec3::new(0.0, 24.0 / 16.0, 0.0));
let look = Look::new(uv)
.lit(glowing(a.glow, Light::Model))
.see_through(a.alpha);
let ticks = draw.ticks();
draw.draw(&mesh, matrix.to_cols_array_2d(), look, &mut |p| {
item.animate(ticks, |name, pose| *p.pose(name) = pose)
});
let light = if a.glow { FULL_LIGHT } else { at.light };
let mut fx = self.cosmetics.fx.borrow_mut();
let start = fx.clear.len();
horizon_client::mobs::render_model(
model,
matrix,
&|p: &Part| poses.pose(p),
uv,
light,
0,
&mut fx.clear,
);
let color = ((a.alpha * 255.0) as u32) << 24 | 0x00FF_FFFF;
for v in &mut fx.clear[start..] {
v.color = color;
// Seen through, a face turned away would add to the near one (and a card's two
// faces would double it): `entity.wgsl` drops back faces, so it is `alpha` at most.
v.normal[3] = horizon_client::mesher::SHADED_CULL_W;
}
}
/// After every player: the player's own companion in first person (out of the middle of the
/// view, after the first-person model went to the shadow pass only), then the frame's trails
/// and auras in one see-through span.
pub(in crate::game) fn cosmetics_fx_geometry(
&self,
camera: DVec3,
alpha: f64,
out: &mut Vec<Vertex>,
) {
if !super::enabled() {
return;
}
if !self.own_body_in_view()
&& let Some(m) = self.player_model.as_ref()
{
self.mark_entity(out.len(), "minecraft:player");
self.own_companion_first_person(m, camera, alpha as f32, out);
}
let fx = self.cosmetics.fx.borrow();
if !fx.clear.is_empty() {
self.mark_translucent_entity(out.len(), "minecraft:player");
out.extend_from_slice(&fx.clear);
}
}
fn own_companion_first_person(
&self,
m: &Mob,
camera: DVec3,
alpha: f32,
out: &mut Vec<Vertex>,
) {
if horizon_client::mobs::trader::is_invisible(m) || self.own_invisible() {
return;
}
let Some(worn) = self.worn(m, true) else {
return;
};
let Some(item) = worn.iter().find(|i| i.slot == Slot::Companion) else {
return;
};
let at = Wearer {
m,
own: true,
key: OWN,
base: horizon_client::mobs::render::base_transform(m, alpha, camera),
camera,
alpha,
light: FULL_LIGHT,
};
let (yaw, pitch) = (self.yaw.to_radians(), self.pitch.to_radians());
let forward = Vec3::new(
-yaw.sin() * pitch.cos(),
-pitch.sin(),
yaw.cos() * pitch.cos(),
);
let left = Vec3::new(yaw.cos(), 0.0, yaw.sin());
let Some((model, uv, matrix)) = self.companion_at(&at, item, Some((forward, left))) else {
return;
};
// Lit where it floats.
let p = (camera + matrix.w_axis.truncate().as_dvec3()).floor();
let light = self.world.light(p.x as i32, p.y as i32, p.z as i32);
let mut light = (u32::from(light >> 4) * 16) | ((u32::from(light & 15) * 16) << 8);
if item.companion.as_ref().is_some_and(|c| c.glow) {
light = FULL_LIGHT;
}
let mut poses = self.cosmetics.poses.borrow_mut();
let poses = &mut poses.1;
self.pose_companion(item, m, alpha, poses, &model);
self.cosmetics_model(&model, matrix, poses, uv, light, false, None, out);
}
}
@@ -0,0 +1,285 @@
//! The cosmetics' settings (spec #14091 step 5a): their lines of `horizon-extras.txt`, the ones
//! the launcher's Wardrobe shares (`horizon_cosmetics::prefs`), through the settings store
//! ([`STORE`]), and their rows in the Extras page's Cosmetics group ([`SECTION`]). Each
//! splitscreen player has their own, as their own settings file: they live in the player's
//! [`Seat`].
use super::{Seat, service};
use horizon_cosmetics::prefs::{self, Others};
use horizon_hooks::pages::{Act, Label, Page, Place, Row};
use horizon_hooks::settings::Store;
/// My cosmetics in single player too.
const SOLO: &str = "cosmeticsSolo";
/// The settings, each with its line in `horizon-extras.txt`.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub(super) struct Settings {
/// Show my cosmetics (the account's, told to the service; `prefs::MINE`).
pub mine: bool,
/// How much of other players' cosmetics shows: no line until the player chooses
/// (`Seat::others`).
pub others: Option<Others>,
/// Other players' trails and auras hidden.
pub hide_fx: bool,
/// My cosmetics in single player too: the window's own server names the player by their
/// offline profile, which the service doesn't know, so they show only with this on.
pub solo: bool,
/// Horizon's mark before Horizon players' names (name tags, player list).
pub marks: bool,
/// The service may say this player plays Horizon.
pub listed: bool,
}
impl Default for Settings {
fn default() -> Self {
Self {
mine: true,
others: None,
hide_fx: false,
solo: false,
marks: true,
listed: true,
}
}
}
const KEYS: [&str; 6] = [
prefs::MINE,
prefs::OTHERS,
prefs::HIDE_FX,
SOLO,
prefs::MARKS,
prefs::LISTED,
];
impl Settings {
/// One line; a value it can't read keeps the setting.
fn load(&mut self, key: &str, value: &str) {
let flag = |was: bool| match value {
"true" => true,
"false" => false,
_ => was,
};
match key {
prefs::MINE => self.mine = flag(self.mine),
// `Others::parse` reads the old two-way switch too.
prefs::OTHERS => self.others = Others::parse(value).or(self.others),
prefs::HIDE_FX => self.hide_fx = flag(self.hide_fx),
SOLO => self.solo = flag(self.solo),
prefs::MARKS => self.marks = flag(self.marks),
prefs::LISTED => self.listed = flag(self.listed),
_ => {}
}
}
/// Their lines; others' only once chosen.
fn save(&self, line: &mut dyn FnMut(&str, &str)) {
let text = |on: bool| if on { "true" } else { "false" };
line(prefs::MINE, text(self.mine));
if let Some(others) = self.others {
line(prefs::OTHERS, others.name());
}
line(prefs::HIDE_FX, text(self.hide_fx));
line(SOLO, text(self.solo));
line(prefs::MARKS, text(self.marks));
line(prefs::LISTED, text(self.listed));
}
}
/// The settings' lines of `horizon-extras.txt`, the player's own file in splitscreen: the game
/// loads and saves it on the player's turn, so they are that seat's.
pub(super) static STORE: Lines = Lines;
pub(super) struct Lines;
impl Store for Lines {
fn file(&self) -> &'static str {
prefs::FILE
}
fn owns(&self, key: &str) -> bool {
KEYS.contains(&key)
}
fn load(&self, key: &str, value: &str) {
service(|s| s.settings.load(key, value));
}
fn save(&self, line: &mut dyn FnMut(&str, &str)) {
service(|s| s.settings).save(line);
}
fn is_default(&self) -> bool {
service(|s| s.settings) == Settings::default()
}
}
/// The rows, top to bottom.
const ROWS: [&str; 6] = [
"horizon.extras.cosmetics_mine",
"horizon.extras.cosmetics_solo",
"horizon.extras.cosmetics_others",
"horizon.extras.cosmetics_hide_fx",
"horizon.extras.horizon_marks",
"horizon.extras.horizon_me",
];
const TOOLTIPS: [&str; 6] = [
"horizon.extras.cosmetics_mine.tooltip",
"horizon.extras.cosmetics_solo.tooltip",
"horizon.extras.cosmetics_others.tooltip",
"horizon.extras.cosmetics_hide_fx.tooltip",
"horizon.extras.horizon_marks.tooltip",
"horizon.extras.horizon_me.tooltip",
];
/// The rows in the Extras page's Cosmetics group, while cosmetics are offered
/// ([`super::enabled`]).
pub(super) static SECTION: Section = Section;
pub(super) struct Section;
impl Section {
fn rows_of(seat: &Seat, rows: &mut Vec<Row>) {
let s = &seat.settings;
let shown = |on: bool| {
Label::Key(if on {
"horizon.extras.cosmetics.shown"
} else {
"horizon.extras.cosmetics.hidden"
})
};
let others = Label::Key(match seat.others() {
Others::Shown => "horizon.extras.cosmetics.shown",
Others::NameTags => "horizon.extras.cosmetics.name_tags",
Others::Hidden => "horizon.extras.cosmetics.hidden",
});
let toggle = |label, on| Row::Toggle { label, on };
let cycle = |label, value| Row::Cycle { label, value };
rows.extend([
cycle(ROWS[0], shown(s.mine)),
toggle(ROWS[1], s.solo),
cycle(ROWS[2], others),
toggle(ROWS[3], s.hide_fx),
toggle(ROWS[4], s.marks),
toggle(ROWS[5], s.listed),
]);
}
/// A press on row `row`; `back` turns the three-way row the other way.
fn act_on(seat: &mut Seat, row: usize, back: bool) {
let s = &mut seat.settings;
match row {
0 => s.mine = !s.mine,
1 => s.solo = !s.solo,
2 => {
// From what the row shows; the result is the player's choice from now on.
let o = seat.others();
seat.settings.others = Some(if back { o.next().next() } else { o.next() });
}
3 => s.hide_fx = !s.hide_fx,
4 => s.marks = !s.marks,
5 => s.listed = !s.listed,
_ => {}
}
}
}
impl Page for Section {
fn id(&self) -> &'static str {
"horizon-cosmetics.extras"
}
fn title(&self) -> &'static str {
"horizon.extras.header.cosmetics"
}
fn place(&self) -> Place {
Place::Extras
}
fn rows(&self, rows: &mut Vec<Row>) {
if super::enabled() {
service(|seat| Self::rows_of(seat, rows));
}
}
fn act(&self, row: usize, act: Act) {
let back = match act {
Act::Press => false,
Act::PressBack => true,
_ => return,
};
service(|seat| Self::act_on(seat, row, back));
}
fn tooltip(&self, row: usize) -> Option<Label> {
TOOLTIPS.get(row).copied().map(Label::Key)
}
}
#[cfg(test)]
mod tests {
use super::*;
fn loaded(text: &str) -> Settings {
let mut s = Settings::default();
for (key, value) in text.lines().filter_map(|l| l.split_once(':')) {
s.load(key.trim(), value.trim());
}
s
}
fn saved(s: &Settings) -> String {
let mut text = String::new();
s.save(&mut |k, v| text.push_str(&format!("{k}:{v}\n")));
text
}
#[test]
fn the_lines_round_trip_and_the_wardrobe_reads_them() {
let s = Settings {
mine: false,
others: Some(Others::NameTags),
hide_fx: true,
solo: true,
marks: false,
listed: false,
};
let text = saved(&s);
assert_eq!(loaded(&text), s);
// The launcher's Wardrobe reads the same lines.
let wardrobe = prefs::Prefs::parse(&text);
assert_eq!(
(wardrobe.mine, wardrobe.others, wardrobe.hide_fx),
(false, Some(Others::NameTags), true)
);
// No choice made: no `cosmeticsOthers` line; the old switch still reads.
assert!(!saved(&Settings::default()).contains(prefs::OTHERS));
assert_eq!(
loaded("cosmeticsOthers:false\n").others,
Some(Others::Hidden)
);
// A value it can't read keeps the setting.
assert!(loaded("cosmeticsMine:maybe\n").mine);
}
#[test]
fn others_cycle_three_ways_from_what_the_row_shows() {
let mut seat = Seat::default();
// Not chosen, nothing owned: Hidden, and forward wraps to Shown.
assert_eq!(seat.others(), Others::Hidden);
Section::act_on(&mut seat, 2, false);
assert_eq!(seat.settings.others, Some(Others::Shown));
Section::act_on(&mut seat, 2, true);
assert_eq!(seat.settings.others, Some(Others::Hidden));
Section::act_on(&mut seat, 2, true);
assert_eq!(seat.settings.others, Some(Others::NameTags));
// Owning one shows them by default, until the player chooses.
let mut owner = Seat::default();
owner.owner.owns = true;
assert_eq!(owner.others(), Others::Shown);
let mut rows = Vec::new();
Section::rows_of(&owner, &mut rows);
assert_eq!(
rows[2],
Row::Cycle {
label: ROWS[2],
value: Label::Key("horizon.extras.cosmetics.shown")
}
);
assert_eq!(rows.len(), TOOLTIPS.len());
}
}
+272 -76
View File
@@ -3,8 +3,6 @@
//! breeding, conversions, drops), projectiles, player interactions
//! (feeding, shearing, milking, spawn eggs, throwing) and rendering.
mod mob_geometry;
mod mob_group;
mod prep;
mod projectile_world;
mod pufferfish_player;
mod spawn_eggs;
@@ -1050,11 +1048,18 @@ impl Game<'_> {
teams: std::sync::Arc::new(self.mob_teams()),
items: Some(self.items()),
lid_openers: &self.lid_openers,
mod_effects: self.remote.is_none().then_some(self as _),
mod_effects: if self.remote.is_none() {
Some(self)
} else {
None
},
// A warden's away suspects are UUIDs: on the server side, where the
// entity UUIDs live, they resolve back to the mobs that are here.
who_lookup: self.remote.is_none().then_some(self as _),
foreign: None,
who_lookup: if self.remote.is_none() {
Some(self)
} else {
None
},
border: Some(self.border.geometry()),
// The first player in the level: a server has no player of its own.
difficulty: self.local_difficulty(
@@ -1182,32 +1187,50 @@ impl Game<'_> {
self.init_sulfur_attribute_maps();
let uuid = self.level.player_uuid;
let level_id = self.dim.id();
// Each mob's climbing, refreshed, and its snapshot entry (creatures/prep.rs).
// LivingEntity.isPushable reads onClimbable live; a mob's cached one
// is only refreshed by its own push step, so one that has not ticked
// yet (the first tick after a load, a fresh spawn) or has moved since
// would be pushed out of a ladder or scaffolding. Refreshed here, from
// where each mob stands now, before the snapshot others push it by.
let climbing: Vec<bool> = {
let w = self.mob_world_base(&[]);
self.mobs.iter().map(|m| m.on_climbable(&w)).collect()
};
for (m, climbing) in self.mobs.iter_mut().zip(climbing) {
m.on_climbable = climbing;
}
let mut snapshot = std::mem::take(&mut self.creatures.seen_buf);
self.prepare_mobs(uuid, &mut snapshot);
snapshot.extend(self.mobs.iter().map(|m| seen(m, uuid)));
let mut events = std::mem::take(&mut self.creatures.event_buf);
events.clear();
// SulfurCube.primeWhenOnPoweredPosition runs before super.tick adopts
// the body: the cubes whose adopted body explodes, as this tick starts.
let primable = self.sulfur_cubes_adopted_explosive();
let mobs = std::mem::take(&mut self.mobs);
let mut mobs = std::mem::take(&mut self.mobs);
// Each mob's target as the mods last heard it, by its place in `mobs`
// (`native_living_change_target`).
let mut targets = std::mem::take(&mut self.creatures.target_buf);
targets.clear();
targets.extend(mobs.iter().map(|m| m.ai.target));
let died = std::mem::take(&mut self.died);
let mut dead = Vec::new();
let mut corpses = Vec::new();
let mut died = std::mem::take(&mut self.died);
let mut ticked = std::mem::take(&mut self.creatures.ticked);
ticked.clear();
let ticking = self.entity_ticking_area();
// With guests on, each mob sees its nearest player (game/lan_mobs.rs).
let mut guests = self.lan_guest_views();
// What stands in a guest's slot while a mob holds that guest's view for
// its tick (see `lent` below).
let mut spare = guests.first().map(|g| g.1.clone());
let players = self.level_players();
let nearest = super::natural_spawning::NearestPlayers::new(&players);
// With guests on, each mob sees its nearest player (game/lan_mobs.rs).
let guests = self.lan_guest_views();
let bindings = match self.lan.as_mut() {
let mut bindings = match self.lan.as_mut() {
Some(host) if !guests.is_empty() => std::mem::take(&mut host.mob_players),
_ => Default::default(),
};
// The guests' entity ids by connection, for the shulker bullets fired
// at the guest a mob sees.
let guest_ids: Vec<(u32, u32)> = match self.lan.as_ref() {
Some(host) if !guests.is_empty() => host
.hub
@@ -1217,9 +1240,23 @@ impl Game<'_> {
.collect(),
_ => Vec::new(),
};
let mut guest_events = Vec::new();
// The block writes since the last tick: mobs re-path around them.
let path_writes = std::mem::take(self.world.path_writes.get_or_insert_with(Vec::new));
let group = {
// The mobs whose bodies go this tick: (id, feet, width, height, split).
// 🤖 Agent: the last field is the grown slime, magma cube or sulfur
// cube the body leaves behind (`AbstractCubeMob.remove`), captured as
// the body goes because it has left the list by the time it splits.
let mut gone = Vec::new();
let level_random;
{
// The level random, lent to the mobs (brain behaviours draw from
// it) and taken back after their ticks.
let mut scratch = self.world.random.clone();
let mut pushes = horizon_client::mobs::PendingPushes::default();
let mut keep = Vec::with_capacity(mobs.len());
let mut grid =
horizon_client::mobs::seen_grid::SeenGrid::new(snapshot.iter().map(|s| s.bounds));
// All but the snapshot is the same for every mob of the tick, so
// build it once: the player view and the teams scan every UUID.
self.leash_boats = self
@@ -1229,52 +1266,87 @@ impl Game<'_> {
.collect();
let mut base = self.mob_world_base(&[]);
let path_changes = horizon_client::mobs::collision_changes(&base, &path_writes);
// Each mob keeps its snapshot slot all tick; the first slot of an
// id answers, as a scan would.
let mut index = horizon_client::fast_hash::FxHashMap::with_capacity_and_hasher(
snapshot.len(),
Default::default(),
);
let mut villager_slots = Vec::new();
let mut solid_slots = Vec::new();
for (i, s) in snapshot.iter().enumerate() {
index.entry(s.id).or_insert(i as u32);
if s.species == horizon_client::mobs::Species::Villager {
villager_slots.push(i as u32);
}
if s.may_block_mobs() {
solid_slots.push(i as u32);
}
}
laps.lap("creature_prepare");
let single_player = horizon_client::mobs::single_player_pos(
players.iter().map(|p| (p.position, p.spectator)),
);
let nearest = prep::MobsNearest::new(&nearest, &mobs, single_player.is_none());
let mut despawn_w = base.despawn_view();
let mut cached_difficulty: Option<((i32, i32), spawning::LocalDifficulty)> = None;
let shared = mob_group::Shared {
uuid,
level_id,
path_changes: &path_changes,
guest_ids: &guest_ids,
};
let start = mob_group::Start {
events,
// The level random, lent to the mobs and taken back after their ticks.
random: self.world.random.clone(),
guests,
player: base.player.take(),
bindings,
died,
ticked,
targets,
};
let mut group = mob_group::Group::new(mobs.into(), snapshot, start);
for i in 0..group.mobs.len() {
let position = group.mobs[i].position;
let (bx, bz) = (position.x.floor() as i32, position.z.floor() as i32);
let seen_guest = group.bind_guest(i);
for i in 0..mobs.len() {
let m = &mut mobs[i];
let (bx, bz) = (m.position.x.floor() as i32, m.position.z.floor() as i32);
let seen_guest = if guests.is_empty() {
None
} else {
let bound = bindings.get(&m.id).copied();
let conn = super::lan_mobs::player_for(m, base.player.as_ref(), &guests, bound);
match conn {
Some(conn) => {
bindings.insert(m.id, conn);
}
None => {
bindings.remove(&m.id);
}
}
conn
};
if !base.world.loaded(bx, bz) {
group.unloaded(i);
keep.push(!m.hostile() || m.persistent);
continue;
}
let nearest_player = single_player.unwrap_or_else(|| nearest.at(i, position));
let nearest_player = single_player.unwrap_or_else(|| {
nearest
.nearest(m.position.x, m.position.y, m.position.z)
.map(|p| p.position)
});
// ServerLevel.tick: every loaded entity checks despawning, but
// only those in entity-ticking chunks (within the simulation
// distance) tick.
// distance) tick. The despawn check reads the nearest player
// and the difficulty, not the player the mob sees.
if !ticking(bx, bz) {
despawn_w.nearest_player = nearest_player;
group.rest(i, &despawn_w);
let kept = horizon_client::mobs::ai::despawn_check(m, &despawn_w);
if !kept {
died.remove(&m.id);
snapshot[i].alive = false;
}
keep.push(kept);
continue;
}
let controlling = group.controlling(i);
let controlling_player_movement = horizon_client::mobs::equine::controlled(m)
.then(|| {
base.player
.as_ref()
.filter(|p| p.riding_vehicle == Some(m.id))
.map(|p| p.velocity)
.or_else(|| {
guests
.iter()
.find(|(_, p)| p.riding_vehicle == Some(m.id))
.map(|(_, p)| p.velocity)
})
})
.flatten();
// A mod canceled its tick: it stays as it is.
if round.canceled(group.mobs[i].id) {
group.keep.push(true);
if round.canceled(m.id) {
keep.push(true);
continue;
}
// getCurrentDifficultyAt(blockPosition()): the mob's own chunk,
@@ -1284,34 +1356,175 @@ impl Game<'_> {
Some((key, difficulty)) if key == chunk_key => difficulty,
_ => {
let difficulty =
self.local_difficulty([position.x, position.y, position.z]);
self.local_difficulty([m.position.x, m.position.y, m.position.z]);
cached_difficulty = Some((chunk_key, difficulty));
difficulty
}
};
let player = (seen_guest, nearest_player, controlling);
group.tick(i, player, difficulty, &base, &shared);
horizon_client::mobs::apply_mob_pushes(m, &mut pushes);
horizon_client::mobs::blocks_changed(m, &path_changes);
let from = events.len();
// The guest's view is lent to the mob for its tick, not copied:
// the tick only reads it (`&MobWorld`), and it goes back to the
// guest's slot straight after.
let lent = seen_guest.and_then(|conn| guests.iter().position(|g| g.0 == conn));
let active_player = match (seen_guest, lent) {
(None, _) => base.player.take(),
(Some(_), Some(k)) => spare
.take()
.map(|stand_in| std::mem::replace(&mut guests[k].1, stand_in)),
(Some(_), None) => None,
};
let mut w = MobWorld {
others: &snapshot,
others_grid: Some(&grid),
others_index: Some(&index),
others_villagers: Some(&villager_slots),
others_solid: Some(&solid_slots),
difficulty,
nearest_player,
player: active_player,
teams: std::sync::Arc::clone(&base.teams),
..base
};
w.controlling_player_movement = controlling_player_movement;
ticked.insert(mobs[i].id);
// `horizon-report`'s cost by mob type (tick_profile.rs).
let sampled = super::tick_profile::start().map(|t| (t, mobs[i].kind.clone()));
let kept =
horizon_client::mobs::riders::tick(&mut mobs, i, &w, &mut scratch, &mut events);
if let Some((started, kind)) = sampled {
super::tick_profile::add(level_id, &kind, started);
}
let m = &mut mobs[i];
if seen_guest.is_none() {
base.player = w.player;
} else if let Some(k) = lent {
spare = w.player.map(|v| std::mem::replace(&mut guests[k].1, v));
}
// Its tick changed its target: the mods may keep the one it had
// (`native_living_change_target`), and then what the tick did to
// the new one is not dealt.
if let Some(&before) = targets.get(i)
&& m.ai.target != before
{
let named = target_named(
m.ai.target,
seen_guest,
&guests,
&guest_ids,
base.player.as_ref(),
);
if let Some((player, mob)) = named {
let kind = super::native_hooks::mob_kind(m);
let mut cancel = false;
Self::native_living_change_target(
level_id,
m.id,
&kind,
player,
mob,
&mut cancel,
);
if cancel {
if let Some(new) = m.ai.target {
let mut k = from;
while k < events.len() {
if hits_target(&events[k], new) {
events.remove(k);
} else {
k += 1;
}
}
}
m.ai.target = before;
}
}
targets[i] = m.ai.target;
}
// A killed mob's body is removed: its effects that act on
// death run (`LivingEntity.remove` with KILLED).
if !kept && !m.alive() && !m.removed && !m.extra.effects.is_empty() {
corpses.push(m.clone());
}
horizon_client::mobs::take_mob_pushes(&mut events, from, &mut pushes);
// LivingEntity.tickDeath: after twenty ticks the body is removed.
if !kept && !m.removed && !m.alive() && m.death_time >= 20 {
let (width, height) = (f64::from(m.width()), f64::from(m.height()));
// 🤖 Agent: `AbstractCubeMob.remove` splits a grown cube into
// its smaller halves when it is removed, twenty ticks after
// the death. The parent leaves the list before the split
// runs, so its size, position, kind and persistence are
// captured here, as they are at the removal.
let split = if matches!(
m.species(),
Species::Slime | Species::MagmaCube | Species::SulfurCube
) && m.extra.size > 1
{
Some(m.clone())
} else {
None
};
gone.push((m.id, m.position, width, height, split));
}
if let Some(conn) = seen_guest {
// ShulkerBullet.finalTarget is the guest's entity: the
// bullet homes on it while the projectiles tick, where
// `Who::Player` is the host's own player.
if let Some(&(_, id)) = guest_ids.iter().find(|g| g.0 == conn) {
for e in &mut events[from..] {
if let MobEvent::Projectile(p) = e
&& let ProjectileKind::ShulkerBullet { target, .. } = &mut p.kind
&& *target == Some(Who::Player)
{
*target = Some(Who::Mob(id));
}
}
}
let mut k = from;
while k < events.len() {
if super::lan_mobs::acts_on_player(&events[k]) {
guest_events.push((conn, m.id, events.remove(k)));
} else {
k += 1;
}
}
}
// Killed during its own tick (drowning, burning, falling...).
if !m.alive() && !m.removed && died.insert(m.id) {
dead.push(m.clone());
}
if !kept {
died.remove(&m.id);
}
keep.push(kept);
// ServerLevel ticks entities one after another: the ones
// after this mob see it as it is now.
snapshot[i] = seen(m, uuid);
snapshot[i].alive &= kept;
grid.update(i, snapshot[i].bounds);
horizon_client::mobs::apply_brain_events(&mut mobs, &mut events, from);
}
// Targets changed after the mob's own tick (another mob's alert):
// heard now, and a cancel puts the old one back before its next tick.
for (i, m) in group.mobs.iter_mut().enumerate() {
let Some(&before) = group.targets.get(i) else {
for (i, m) in mobs.iter_mut().enumerate() {
let Some(&before) = targets.get(i) else {
break;
};
if m.ai.target == before {
continue;
}
let seen_guest = if group.guests.is_empty() {
let seen_guest = if guests.is_empty() {
None
} else {
group.bindings.get(&m.id).copied()
bindings.get(&m.id).copied()
};
let named = target_named(
m.ai.target,
seen_guest,
&group.guests,
&guests,
&guest_ids,
group.player.as_ref(),
base.player.as_ref(),
);
if let Some((player, mob)) = named {
let kind = super::native_hooks::mob_kind(m);
@@ -1330,29 +1543,13 @@ impl Game<'_> {
}
}
laps.lap("creature_loop");
let mut flags = std::mem::take(&mut group.keep).into_iter();
group.mobs.retain(|_| flags.next().unwrap_or(true));
for m in &mut group.mobs {
horizon_client::mobs::apply_mob_pushes(m, &mut group.pushes);
let mut flags = keep.into_iter();
mobs.retain(|_| flags.next().unwrap_or(true));
for m in &mut mobs {
horizon_client::mobs::apply_mob_pushes(m, &mut pushes);
}
group
};
let mob_group::Group {
mut mobs,
mut snapshot,
mut events,
random: level_random,
guests,
mut bindings,
died,
ticked,
mut targets,
dead,
corpses,
gone,
guest_events,
..
} = group;
level_random = scratch;
}
self.world.random = level_random;
if let Some(host) = self.lan.as_mut()
&& !guests.is_empty()
@@ -1368,7 +1565,7 @@ impl Game<'_> {
// first, as `append` put them; with none, the ticked list goes back
// as it is instead of being copied into a new one, every mob a tick.
if self.mobs.is_empty() {
self.mobs = mobs.into();
self.mobs = mobs;
} else {
self.mobs.append(&mut mobs);
}
@@ -8179,7 +8376,6 @@ mod pearl_tests {
dragon: None,
leash_boats: &[],
who_lookup: None,
foreign: None,
others_grid: None,
others_index: None,
others_villagers: None,
@@ -1,338 +0,0 @@
//! The mob loop of `tick_creature_entities` over a group of mobs, in their list order, with
//! everything the loop keeps for them: their snapshot and its indexes, the pushes and events
//! their ticks give, the level random they draw from, the players they see and what dies. The
//! serial loop is one group holding every mob; a group of the parallel mob tick (#14457) holds
//! the mobs no mob outside it can reach this tick.
use super::*;
use horizon_client::{
fast_hash::{FxHashMap, FxHashSet},
mobs::{MobSlot, PendingPushes, seen_grid::SeenGrid, spawning::LocalDifficulty},
};
use horizon_core::random::LegacyRandom;
/// What every mob of the loop reads and none writes.
pub(super) struct Shared<'a> {
pub uuid: Option<[i32; 4]>,
pub level_id: &'static str,
/// The blocks whose collision changed since the last tick: mobs re-path around them.
pub path_changes: &'a [[i32; 3]],
/// The guests' entity ids by connection, for the shulker bullets fired at the guest a mob
/// sees.
pub guest_ids: &'a [(u32, u32)],
}
/// A mob whose body goes this tick: (id, feet, width, height, the grown cube it splits into).
/// The last field is the grown slime, magma cube or sulfur cube the body leaves behind
/// (`AbstractCubeMob.remove`), captured as the body goes because it has left the list by the
/// time it splits.
pub(super) type Gone = (u32, Vec3, f64, f64, Option<Mob>);
/// A group's mobs and everything the loop keeps for them, in their list order.
pub(super) struct Group<M: MobSlot> {
pub mobs: Vec<M>,
/// Each mob's snapshot entry: as it was before the tick until its turn, as its turn left it
/// after.
pub snapshot: Vec<Seen>,
grid: SeenGrid,
/// The first slot of each id, as a scan would find it.
index: FxHashMap<u32, u32>,
villager_slots: Vec<u32>,
solid_slots: Vec<u32>,
pub pushes: PendingPushes,
pub events: Vec<MobEvent>,
/// The level random, lent to the mobs (brain behaviours draw from it).
pub random: LegacyRandom,
pub guests: super::super::lan_mobs::GuestViews,
/// What stands in a guest's slot while a mob holds that guest's view for its tick.
spare: Option<PlayerView>,
/// The host's own player, lent to each mob that sees it for its tick.
pub player: Option<PlayerView>,
/// The guest each mob sees, by mob id.
pub bindings: FxHashMap<u32, u32>,
pub died: FxHashSet<u32>,
pub ticked: FxHashSet<u32>,
/// Each mob's target as the mods last heard it (`native_living_change_target`).
pub targets: Vec<Option<Who>>,
/// Whether each mob stays in the list, pushed at its turn.
pub keep: Vec<bool>,
pub dead: Vec<Mob>,
pub corpses: Vec<Mob>,
pub gone: Vec<Gone>,
pub guest_events: Vec<(u32, u32, MobEvent)>,
}
/// What a group starts from besides its mobs and their snapshot.
pub(super) struct Start {
pub events: Vec<MobEvent>,
pub random: LegacyRandom,
pub guests: super::super::lan_mobs::GuestViews,
pub player: Option<PlayerView>,
pub bindings: FxHashMap<u32, u32>,
pub died: FxHashSet<u32>,
pub ticked: FxHashSet<u32>,
pub targets: Vec<Option<Who>>,
}
impl<M: MobSlot> Group<M> {
/// A group of `mobs`, whose snapshot entries are `snapshot`.
pub(super) fn new(mobs: Vec<M>, snapshot: Vec<Seen>, start: Start) -> Self {
let grid = SeenGrid::new(snapshot.iter().map(|s| s.bounds));
let mut index = FxHashMap::with_capacity_and_hasher(snapshot.len(), Default::default());
let (mut villager_slots, mut solid_slots) = (Vec::new(), Vec::new());
for (i, s) in snapshot.iter().enumerate() {
index.entry(s.id).or_insert(i as u32);
if s.species == Species::Villager {
villager_slots.push(i as u32);
}
if s.may_block_mobs() {
solid_slots.push(i as u32);
}
}
let n = mobs.len();
Self {
spare: start.guests.first().map(|g| g.1.clone()),
mobs,
snapshot,
grid,
index,
villager_slots,
solid_slots,
pushes: PendingPushes::default(),
events: start.events,
random: start.random,
guests: start.guests,
player: start.player,
bindings: start.bindings,
died: start.died,
ticked: start.ticked,
targets: start.targets,
keep: Vec::with_capacity(n),
dead: Vec::new(),
corpses: Vec::new(),
gone: Vec::new(),
guest_events: Vec::new(),
}
}
/// The guest mob `i` sees now, kept in the bindings: none while no guest is on.
pub(super) fn bind_guest(&mut self, i: usize) -> Option<u32> {
if self.guests.is_empty() {
return None;
}
let m = self.mobs[i].mob();
let bound = self.bindings.get(&m.id).copied();
let conn = super::super::lan_mobs::player_for(m, self.player.as_ref(), &self.guests, bound);
match conn {
Some(conn) => self.bindings.insert(m.id, conn),
None => self.bindings.remove(&m.id),
};
conn
}
/// Mob `i`'s turn when its chunk isn't loaded: it is kept as it is, unless it would despawn.
pub(super) fn unloaded(&mut self, i: usize) {
let m = self.mobs[i].mob();
self.keep.push(!m.hostile() || m.persistent);
}
/// Mob `i`'s turn outside the entity-ticking area: only its despawn check, which reads the
/// nearest player and the difficulty, not the player the mob sees.
pub(super) fn rest(&mut self, i: usize, despawn_w: &MobWorld) {
let m = self.mobs[i].mob_mut();
let kept = horizon_client::mobs::ai::despawn_check(m, despawn_w);
if !kept {
self.died.remove(&m.id);
self.snapshot[i].alive = false;
}
self.keep.push(kept);
}
/// Who controls mob `i`'s moves this tick: the velocity of the player riding it, when it is
/// a controlled mount.
pub(super) fn controlling(&self, i: usize) -> Option<Vec3> {
let m = self.mobs[i].mob();
horizon_client::mobs::equine::controlled(m)
.then(|| {
self.player
.as_ref()
.filter(|p| p.riding_vehicle == Some(m.id))
.map(|p| p.velocity)
.or_else(|| {
self.guests
.iter()
.find(|(_, p)| p.riding_vehicle == Some(m.id))
.map(|(_, p)| p.velocity)
})
})
.flatten()
}
/// Mob `i`'s own tick, then what the loop does with what it did. `players`: the guest it
/// sees (none: the host's player), its nearest player, and the velocity of the player
/// controlling it as a mount ([`Self::controlling`]).
pub(super) fn tick(
&mut self,
i: usize,
(seen_guest, nearest_player, controlling_player_movement): (
Option<u32>,
Option<Vec3>,
Option<Vec3>,
),
difficulty: LocalDifficulty,
base: &MobWorld,
shared: &Shared,
) {
let m = self.mobs[i].mob_mut();
horizon_client::mobs::apply_mob_pushes(m, &mut self.pushes);
horizon_client::mobs::blocks_changed(m, shared.path_changes);
let from = self.events.len();
// The guest's view is lent to the mob for its tick, not copied:
// the tick only reads it (`&MobWorld`), and it goes back to the
// guest's slot straight after.
let lent = seen_guest.and_then(|conn| self.guests.iter().position(|g| g.0 == conn));
let active_player = match (seen_guest, lent) {
(None, _) => self.player.take(),
(Some(_), Some(k)) => self
.spare
.take()
.map(|stand_in| std::mem::replace(&mut self.guests[k].1, stand_in)),
(Some(_), None) => None,
};
let mut w = MobWorld {
others: &self.snapshot,
others_grid: Some(&self.grid),
others_index: Some(&self.index),
others_villagers: Some(&self.villager_slots),
others_solid: Some(&self.solid_slots),
difficulty,
nearest_player,
player: active_player,
teams: std::sync::Arc::clone(&base.teams),
..*base
};
w.controlling_player_movement = controlling_player_movement;
self.ticked.insert(self.mobs[i].mob().id);
// `horizon-report`'s cost by mob type (tick_profile.rs).
let sampled =
super::super::tick_profile::start().map(|t| (t, self.mobs[i].mob().kind.clone()));
let kept = horizon_client::mobs::riders::tick(
&mut self.mobs,
i,
&w,
&mut self.random,
&mut self.events,
);
if let Some((started, kind)) = sampled {
super::super::tick_profile::add(shared.level_id, &kind, started);
}
let m = self.mobs[i].mob_mut();
if seen_guest.is_none() {
self.player = w.player;
} else if let Some(k) = lent {
self.spare = w
.player
.map(|v| std::mem::replace(&mut self.guests[k].1, v));
}
// Its tick changed its target: the mods may keep the one it had
// (`native_living_change_target`), and then what the tick did to
// the new one is not dealt.
if let Some(&before) = self.targets.get(i)
&& m.ai.target != before
{
let named = target_named(
m.ai.target,
seen_guest,
&self.guests,
shared.guest_ids,
self.player.as_ref(),
);
if let Some((player, mob)) = named {
let kind = super::super::native_hooks::mob_kind(m);
let mut cancel = false;
Game::native_living_change_target(
shared.level_id,
m.id,
&kind,
player,
mob,
&mut cancel,
);
if cancel {
if let Some(new) = m.ai.target {
let mut k = from;
while k < self.events.len() {
if hits_target(&self.events[k], new) {
self.events.remove(k);
} else {
k += 1;
}
}
}
m.ai.target = before;
}
}
self.targets[i] = m.ai.target;
}
// A killed mob's body is removed: its effects that act on
// death run (`LivingEntity.remove` with KILLED).
if !kept && !m.alive() && !m.removed && !m.extra.effects.is_empty() {
self.corpses.push(m.clone());
}
horizon_client::mobs::take_mob_pushes(&mut self.events, from, &mut self.pushes);
// LivingEntity.tickDeath: after twenty ticks the body is removed.
if !kept && !m.removed && !m.alive() && m.death_time >= 20 {
let (width, height) = (f64::from(m.width()), f64::from(m.height()));
// `AbstractCubeMob.remove` splits a grown cube into its smaller halves when it is
// removed, twenty ticks after the death. The parent leaves the list before the split
// runs, so its size, position, kind and persistence are captured here, as they are
// at the removal.
let split = if matches!(
m.species(),
Species::Slime | Species::MagmaCube | Species::SulfurCube
) && m.extra.size > 1
{
Some(m.clone())
} else {
None
};
self.gone.push((m.id, m.position, width, height, split));
}
if let Some(conn) = seen_guest {
// ShulkerBullet.finalTarget is the guest's entity: the
// bullet homes on it while the projectiles tick, where
// `Who::Player` is the host's own player.
if let Some(&(_, id)) = shared.guest_ids.iter().find(|g| g.0 == conn) {
for e in &mut self.events[from..] {
if let MobEvent::Projectile(p) = e
&& let ProjectileKind::ShulkerBullet { target, .. } = &mut p.kind
&& *target == Some(Who::Player)
{
*target = Some(Who::Mob(id));
}
}
}
let mut k = from;
while k < self.events.len() {
if super::super::lan_mobs::acts_on_player(&self.events[k]) {
self.guest_events.push((conn, m.id, self.events.remove(k)));
} else {
k += 1;
}
}
}
// Killed during its own tick (drowning, burning, falling...).
if !m.alive() && !m.removed && self.died.insert(m.id) {
self.dead.push(m.clone());
}
if !kept {
self.died.remove(&m.id);
}
self.keep.push(kept);
// ServerLevel ticks entities one after another: the ones
// after this mob see it as it is now.
self.snapshot[i] = seen(m, shared.uuid);
self.snapshot[i].alive &= kept;
self.grid.update(i, self.snapshot[i].bounds);
horizon_client::mobs::apply_brain_events(&mut self.mobs, &mut self.events, from);
}
}
@@ -1,247 +0,0 @@
//! What the mob loop (`tick_creature_entities`) prepares for every mob before it runs, on the tick's
//! helper threads while the parallel tick is on (`horizon_client::parallel`): each answer reads only
//! that mob, the blocks and the players, so the helpers give what one thread gives.
use super::super::{Game, natural_spawning::NearestPlayers};
use horizon_client::mobs::{Mob, MobWorld, Seen};
use horizon_client::parallel::par_map;
use horizon_core::geometry::Vec3;
/// From this many mobs a preparation goes to the helper threads.
const MIN_MOBS: usize = 256;
impl Game<'_> {
/// `LivingEntity.isPushable` reads onClimbable live; a mob's cached one is only refreshed by its
/// own push step, so one that has not ticked yet (the first tick after a load, a fresh spawn) or
/// has moved since would be pushed out of a ladder or scaffolding. Refreshed here, from where
/// each mob stands now; then each mob's entry of the snapshot others push it by goes in
/// `snapshot`.
pub(super) fn prepare_mobs(&mut self, player: Option<[i32; 4]>, snapshot: &mut Vec<Seen>) {
let climbing: Vec<bool> = {
let w = self.mob_world_base(&[]);
let rebuild = hookless(&w);
par_map(&self.mobs, MIN_MOBS, |part, out| {
let w = rebuild();
out.extend(part.iter().map(|m| m.on_climbable(&w)));
})
};
for (m, climbing) in self.mobs.iter_mut().zip(climbing) {
m.on_climbable = climbing;
}
snapshot.extend(par_map(&self.mobs, MIN_MOBS, |part, out| {
out.extend(part.iter().map(|m| super::seen(m, player)));
}));
}
}
/// The nearest player each mob's despawn check reads (`Mob.checkDespawn`), found for every mob before
/// the loop. A mob that moved before its turn is asked again where it stands.
pub(super) struct MobsNearest<'p> {
players: &'p NearestPlayers<'p>,
/// Each mob's position as the loop started and its nearest player there; empty while the
/// parallel tick is off (every mob asks at its turn).
found: Vec<(Vec3, Option<Vec3>)>,
}
impl<'p> MobsNearest<'p> {
/// `wanted`: whether the loop asks (not with one player, whose position answers).
pub(super) fn new(players: &'p NearestPlayers<'p>, mobs: &[Mob], wanted: bool) -> Self {
let found = if wanted && horizon_client::parallel::lanes() > 1 {
par_map(mobs, MIN_MOBS, |part, out| {
out.extend(
part.iter()
.map(|m| (m.position, Self::ask(players, m.position))),
);
})
} else {
Vec::new()
};
Self { players, found }
}
fn ask(players: &NearestPlayers, at: Vec3) -> Option<Vec3> {
players.nearest(at.x, at.y, at.z).map(|p| p.position)
}
/// The nearest player to mob `i` of the loop, standing at `at`.
pub(super) fn at(&self, i: usize, at: Vec3) -> Option<Vec3> {
match self.found.get(i) {
Some(&(was, nearest)) if was == at => nearest,
_ => Self::ask(self.players, at),
}
}
}
/// `base` without its hooks into the game, which can't leave the server thread, as a recipe a
/// helper thread builds its own world from: native mod effects (the phases on helpers don't run
/// them) and the UUID lookup.
fn hookless<'w>(base: &MobWorld<'w>) -> impl Fn() -> MobWorld<'w> + Sync + use<'w> {
// Every field but the two hooks, named, so that a field added to `MobWorld` is decided here.
let MobWorld {
world,
registry,
facts,
fluid_tables,
movement,
collision,
suffocation,
data,
player,
controlling_player_movement,
nearest_player,
others,
dragon,
leash_boats,
others_grid,
others_index,
others_villagers,
others_solid,
daylight,
monsters_burn,
game_time,
day_time,
raining,
thundering,
level,
block_tags,
landing,
biomes,
mob_griefing,
universal_anger,
mob_drops,
max_entity_cramming,
difficulty,
dimension_type,
world_seed,
sea_level,
teams,
items,
lid_openers,
mod_effects: _,
who_lookup: _,
foreign: _,
border,
} = base.clone();
move || MobWorld {
world,
registry,
facts,
fluid_tables,
movement,
collision,
suffocation,
data,
player: player.clone(),
controlling_player_movement,
nearest_player,
others,
dragon,
leash_boats,
others_grid,
others_index,
others_villagers,
others_solid,
daylight,
monsters_burn,
game_time,
day_time,
raining,
thundering,
level,
block_tags,
landing,
biomes,
mob_griefing,
universal_anger,
mob_drops,
max_entity_cramming,
difficulty,
dimension_type,
world_seed,
sea_level,
teams: std::sync::Arc::clone(&teams),
items,
lid_openers,
mod_effects: None,
who_lookup: None,
foreign: None,
border,
}
}
#[cfg(test)]
mod tests {
use super::super::super::natural_spawning::LevelPlayer;
use super::*;
use horizon_core::geometry::Aabb;
#[test]
#[ignore = "needs prepared resources and scratch homes/cwd"]
fn preparation_refreshes_climbing_and_nearest_after_a_move() {
let mut game = super::super::super::native_machines::tests::server_game();
game.build_registries();
game.set_allow_commands(true);
for command in [
"setblock 8 80 9 stone",
"setblock 8 80 8 ladder[facing=north]",
"summon pig 8.5 80 8.5 {NoAI:1b}",
"summon pig 9.5 80 8.5 {NoAI:1b}",
] {
assert!(game.run_command(command), "{command}");
}
assert_eq!(game.mobs.len(), 2);
let examples = game.mobs.clone();
game.mobs = (0..MIN_MOBS + 37)
.map(|i| {
let mut mob = examples[i % 2].clone();
mob.id = 10_000 + i as u32;
mob.on_climbable = i % 2 != 0; // deliberately stale in both cases
mob
})
.collect::<Vec<_>>()
.into();
let mut snapshot = Vec::new();
game.prepare_mobs(None, &mut snapshot);
assert_eq!(snapshot.len(), game.mobs.len());
for (i, (m, seen)) in game.mobs.iter().zip(&snapshot).enumerate() {
assert_eq!(m.on_climbable, i % 2 == 0, "mob {i}");
assert_eq!(seen.id, m.id);
assert_eq!(seen.position, m.position);
assert_eq!(seen.pushable, i % 2 != 0, "snapshot {i}");
}
let player = |x, spectator| {
let position = Vec3::new(x, 80.0, 8.5);
LevelPlayer {
uuid: 0,
position,
bounds: Aabb::new(position, position),
alive: false, // dead players still count for checkDespawn
creative: false,
spectator,
}
};
let players = [player(8.5, true), player(7.5, false), player(9.5, false)];
let index = NearestPlayers::new(&players);
let nearest = MobsNearest::new(&index, &game.mobs, true);
for (i, mob) in game.mobs.iter().enumerate() {
let expected = if i % 2 == 0 {
players[1].position
} else {
players[2].position
};
assert_eq!(nearest.at(i, mob.position), Some(expected));
// A previous mob's push moved this mob across the nearest-player tie.
assert_eq!(
nearest.at(i, Vec3::new(10.0, 80.0, 8.5)),
Some(players[2].position)
);
}
let no_cache = MobsNearest::new(&index, &game.mobs, false);
assert_eq!(
no_cache.at(0, game.mobs[0].position),
Some(players[1].position)
);
let spectators = [player(8.5, true)];
let index = NearestPlayers::new(&spectators);
let nearest = MobsNearest::new(&index, &game.mobs, true);
assert_eq!(nearest.at(0, game.mobs[0].position), None);
}
}
-49
View File
@@ -1,49 +0,0 @@
//! What the player is doing, for Discord Rich Presence (`horizon_client::discord_presence`).
use super::*;
use horizon_client::discord_presence::{self as presence, Dimension, Play, Status};
impl Game<'_> {
/// Each frame: only a change reaches the presence thread.
pub(super) fn update_discord_presence(&self) {
// One Discord status per computer: P1's, not a splitscreen player's.
if self.options.split_guest {
return;
}
presence::set(self.discord_status());
}
fn discord_status(&self) -> Status {
if !self.settings.extras.discord_presence {
return Status::Off;
}
if self.title.is_some() {
return Status::Menus;
}
let play = if let Some(host) = &self.lan {
Play::Hosting {
players: 1 + host.hub.guests.len(),
max: horizon_client::lan::MAX_PLAYERS,
}
} else if let Some(server) = &self.integrated {
match (server.published(), &self.remote) {
(Some(_), Some(remote)) => Play::Hosting {
players: remote.player_count(),
max: horizon_client::lan::MAX_PLAYERS,
},
_ => Play::Singleplayer,
}
} else if self.remote.is_some() {
Play::Multiplayer
} else {
Play::Singleplayer
};
Status::World {
play,
dimension: match self.dim {
Dim::Overworld => Dimension::Overworld,
Dim::Nether => Dimension::Nether,
Dim::End => Dimension::End,
},
mode: self.mode,
}
}
}
+35 -6
View File
@@ -594,9 +594,24 @@ impl Game<'_> {
splash: &mut impl FnMut(horizon_client::entities::ItemSplash),
) -> (bool, bool) {
let no_physics = escape_speed.is_some();
let step = self
.item_tables()
.move_item(item, escape_speed, entity_boxes, splash);
let heights = item.push_by_fluid(&self.world, &self.res.motion().fluids);
// Entity.baseTick splashes before moving, except on the first tick.
item.water_entry(heights.0 > 0.0, item.ticks == 0, splash);
item.lava_fall(heights.1 > 0.0);
let step = item.tick_move_before_block_effects(
&self.world,
self.res.facts(),
self.collision,
no_physics,
escape_speed,
heights,
entity_boxes,
Some(self.border.geometry()),
Some(&self.res.motion().blocks),
);
item.check_fall(&step, |fall, from, moved| {
self.vehicle_fall_reset(fall, from, moved)
});
if let Some(pos) = step.vertical_contact {
let state = self.world.block(pos.x, pos.y, pos.z);
let position = item.position;
@@ -615,9 +630,23 @@ impl Game<'_> {
return (false, true);
}
}
let alive = self
.item_tables()
.settle_item(item, no_physics, step, splash);
item.finish_move_block_effects(
&self.world,
self.res.facts(),
no_physics,
Some(&self.res.motion().blocks),
step,
);
item.push_by_bubble_column(
&self.world,
self.pack.assets.registry,
self.res.facts(),
self.collision,
);
let alive = item.tick_settle(&self.world, self.res.facts(), no_physics, step);
// ItemEntity.tick refreshes immersion and splashes on water entry.
let (water, _) = item.push_by_fluid(&self.world, &self.res.motion().fluids);
item.water_entry(water > 0.0, false, splash);
(alive, false)
}
+25 -36
View File
@@ -2,8 +2,6 @@
//! `tick_experience`), the stacks a destroyed container lets fall, and spawning and merging orbs.
use super::*;
mod ahead;
impl Game<'_> {
pub(super) fn tick_items(&mut self) {
// EntityTickEvent.Pre for the items about to tick, and the Post after them.
@@ -45,13 +43,6 @@ impl Game<'_> {
horizon_client::entities::ObstacleGrid::new(self.item_entity_collisions());
let mut nearby = Vec::new();
let mut bubble_tops = Vec::new();
let mut moves = ahead::Moves::work_out(self, &items, &obstacles, round, |item| {
!self.joined_during_entity_pass(item.id)
&& ticking(
item.position.x.floor() as i32,
item.position.z.floor() as i32,
)
});
// `ItemUtils.onContainerDestroyed`: what a destroyed item held, and
// where it lay, for adding to the level once its own tick is done.
let mut destroyed: Vec<(ItemStack, Vec3)> = Vec::new();
@@ -74,35 +65,33 @@ impl Game<'_> {
{
continue;
}
let mut at_turn = |item: &mut ItemEntity| {
// `getEntityCollisions(entity, bounds.expandTowards(movement))`: the boxes around
// where the item can reach this tick.
obstacles.near(ahead::reach(item), &mut nearby);
// ItemEntity.tick's noPhysics (servers only): out of a block, a solid entity
// or the border, placed or closed on it, towards the closest open side (#1252).
let no_physics = merging
&& item.inside_block(
&self.world,
self.collision,
&nearby,
Some(self.border.geometry()),
);
// `moveTowardsClosestSpace` draws the speed from the item's own random.
let escape_speed = no_physics.then(|| item.random.next_float() * 0.2 + 0.1);
let mut entered = Vec::new();
let (alive, discarded) = self.tick_item_with_block_contacts(
item,
escape_speed,
// `getEntityCollisions(entity, bounds.expandTowards(movement))`: the boxes around
// where the item can reach this tick (a block of slack for the next velocity).
let reach = Vec3::new(
item.velocity.x.abs() + 1.0,
item.velocity.y.abs() + 1.0,
item.velocity.z.abs() + 1.0,
);
obstacles.near(item.bounds().inflate(reach), &mut nearby);
// ItemEntity.tick's noPhysics (servers only): out of a block, a solid entity
// or the border, placed or closed on it, towards the closest open side (#1252).
let no_physics = merging
&& item.inside_block(
&self.world,
self.collision,
&nearby,
false,
&mut |splash| entered.push(splash),
Some(self.border.geometry()),
);
(no_physics, alive, discarded, entered)
};
let (no_physics, alive, discarded, entered) = match moves.take(i, item, &mut at_turn) {
Some(moved) => moved,
None => at_turn(item),
};
// `moveTowardsClosestSpace` draws the speed from the item's own random.
let escape_speed = no_physics.then(|| item.random.next_float() * 0.2 + 0.1);
let mut entered = Vec::new();
let (alive, discarded) = self.tick_item_with_block_contacts(
item,
escape_speed,
&nearby,
false,
&mut |splash| entered.push(splash),
);
// Entity.doWaterSplashEffect: the server plays the sound (broadcast
// to clients). Its addParticle is empty.
for splash in entered {
@@ -1,336 +0,0 @@
//! The dropped items' own moves, worked out ahead of the item loop on the tick's helper threads
//! while the parallel tick is on (`horizon_client::parallel`).
//!
//! An item's move (`ItemEntity.tick` up to its merge) reads only itself, the level's blocks, the
//! obstacle boxes and the border, and the item loop changes none of them: so an item's move worked
//! out on a copy before the loop is the move its turn would make. Its escape from inside a block
//! draws the item's own random, which the copy carries. A merge before its turn can change its
//! count, age and pickup delay: such an item moves at its turn instead. With a native mod loaded
//! (its hooks see every item) or a mod listening to the round, every item moves at its turn.
use super::super::{boats::fall_reset, entity::Entity as _, native_ticks};
use super::*;
use horizon_client::{
blocks::BlockFacts,
entities::{ItemEntity, ItemSplash, ItemStep, ObstacleGrid},
motion::MotionResources,
world::World,
};
use horizon_core::geometry::{Aabb, Vec3, border::BorderGeometry};
use horizon_world::{block_collision::BlockCollisionRegistry, registry::BlockRegistry};
/// From this many items their moves go to the helper threads.
const MIN_ITEMS: usize = 256;
/// What an item's own move reads.
pub(in crate::game) struct ItemTables<'a> {
world: &'a World,
facts: &'a BlockFacts,
collision: &'a BlockCollisionRegistry<'a>,
motion: &'a MotionResources,
registry: &'a BlockRegistry,
border: &'a BorderGeometry,
}
impl<'w> Game<'w> {
pub(in crate::game) fn item_tables(&self) -> ItemTables<'_> {
ItemTables {
world: &self.world,
facts: self.res.facts(),
collision: self.collision,
motion: self.res.motion(),
registry: self.pack.assets.registry,
border: self.border.geometry(),
}
}
}
impl ItemTables<'_> {
/// `ItemEntity.tick` up to its move's vertical contact: the fluids, the water splash, the move
/// (pushed out at `escape_speed` when inside a block) and its fall.
pub(in crate::game) fn move_item(
&self,
item: &mut ItemEntity,
escape_speed: Option<f32>,
entity_boxes: &[Aabb],
splash: &mut impl FnMut(ItemSplash),
) -> ItemStep {
let heights = item.push_by_fluid(self.world, &self.motion.fluids);
// Entity.baseTick splashes before moving, except on the first tick.
item.water_entry(heights.0 > 0.0, item.ticks == 0, splash);
item.lava_fall(heights.1 > 0.0);
let step = item.tick_move_before_block_effects(
self.world,
self.facts,
self.collision,
escape_speed.is_some(),
escape_speed,
heights,
entity_boxes,
Some(self.border),
Some(&self.motion.blocks),
);
item.check_fall(&step, |fall, from, moved| {
fall_reset(self.world, self.motion, self.facts, fall, from, moved)
});
step
}
/// The rest of the move after its vertical contact: block effects, bubble columns, settling
/// and the water entry. Whether the item lives on.
pub(in crate::game) fn settle_item(
&self,
item: &mut ItemEntity,
no_physics: bool,
step: ItemStep,
splash: &mut impl FnMut(ItemSplash),
) -> bool {
item.finish_move_block_effects(
self.world,
self.facts,
no_physics,
Some(&self.motion.blocks),
step,
);
item.push_by_bubble_column(self.world, self.registry, self.facts, self.collision);
let alive = item.tick_settle(self.world, self.facts, no_physics, step);
// ItemEntity.tick refreshes immersion and splashes on water entry.
let (water, _) = item.push_by_fluid(self.world, &self.motion.fluids);
item.water_entry(water > 0.0, false, splash);
alive
}
}
/// `getEntityCollisions(entity, bounds.expandTowards(movement))`: the box around an item that its
/// move reads others' boxes in (a block of slack for the next velocity).
pub(super) fn reach(item: &ItemEntity) -> Aabb {
let v = item.velocity;
item.bounds()
.inflate(Vec3::new(v.x.abs() + 1.0, v.y.abs() + 1.0, v.z.abs() + 1.0))
}
/// An item's move as its turn makes it: without physics (inside a block), alive, discarded, and
/// its splashes.
pub(super) type Moved = (bool, bool, bool, Vec<ItemSplash>);
/// One item's move, worked out on a copy.
pub(super) struct Ahead {
moved: ItemEntity,
result: Moved,
/// What a merge before its turn can change: its count, age and pickup delay.
started: (u32, i32, i64),
}
fn started(item: &ItemEntity) -> (u32, i32, i64) {
(item.stack.count, item.age, item.pickup_delay)
}
/// `item`'s whole move on a server with no native mod, from the tables alone.
fn move_alone(
tables: &ItemTables,
obstacles: &ObstacleGrid,
item: &mut ItemEntity,
found: &mut Vec<u32>,
near: &mut Vec<Aabb>,
) -> Moved {
obstacles.near_shared(reach(item), found, near);
let no_physics = item.inside_block(tables.world, tables.collision, near, Some(tables.border));
// `moveTowardsClosestSpace` draws the speed from the item's own random.
let escape_speed = no_physics.then(|| item.random.next_float() * 0.2 + 0.1);
let mut splashes = Vec::new();
let step = tables.move_item(item, escape_speed, near, &mut |s| splashes.push(s));
if step.vertical_contact.is_some() && item.stack.count == 0 {
return (no_physics, false, true, splashes);
}
let alive = tables.settle_item(item, no_physics, step, &mut |s| splashes.push(s));
(no_physics, alive, false, splashes)
}
#[cfg(test)]
thread_local! {
/// A test's choice of the path, over the parallel tick's switch.
static AHEAD: std::cell::Cell<Option<bool>> = const { std::cell::Cell::new(None) };
/// The moves a test's tick put in place, and those a merge made it refuse.
static TAKEN: std::cell::Cell<(usize, usize)> = const { std::cell::Cell::new((0, 0)) };
}
/// Whether the moves are worked out ahead: while the parallel tick is on.
fn ahead() -> bool {
#[cfg(test)]
if let Some(ahead) = AHEAD.with(std::cell::Cell::get) {
return ahead;
}
horizon_client::parallel::lanes() > 1
}
/// The moves worked out ahead, by the item's place in the loop.
pub(super) struct Moves(Vec<Option<Ahead>>);
impl Moves {
/// The moves of the items that will tick (`ticks`), on the helper threads; none while the
/// parallel tick is off, for few items, or with a mod that could see an item's move.
pub(super) fn work_out(
game: &Game,
items: &[ItemEntity],
obstacles: &ObstacleGrid,
round: &native_ticks::TickRound,
ticks: impl Fn(&ItemEntity) -> bool,
) -> Self {
if !ahead()
|| items.len() < MIN_ITEMS
|| game.remote.is_some()
|| round.listening()
|| !horizon_client::mod_api::mods().is_empty()
{
return Self(Vec::new());
}
let todo: Vec<usize> = (0..items.len()).filter(|&i| ticks(&items[i])).collect();
let tables = game.item_tables();
let done = horizon_client::parallel::par_map(&todo, MIN_ITEMS / 4, |part, out| {
let (mut found, mut near) = (Vec::new(), Vec::new());
for &i in part {
let mut moved = items[i].clone();
let result = move_alone(&tables, obstacles, &mut moved, &mut found, &mut near);
let started = started(&items[i]);
out.push((
i,
Ahead {
moved,
result,
started,
},
));
}
});
let mut moves: Vec<Option<Ahead>> = (0..items.len()).map(|_| None).collect();
for (i, a) in done {
moves[i] = Some(a);
}
Self(moves)
}
/// Item `i`'s move, put in place, when it was worked out and no merge changed the item since.
/// Under `HORIZON_PARALLEL_TICK=verify` the move is made again at its turn, on a copy, and a
/// difference is logged.
pub(super) fn take(
&mut self,
i: usize,
item: &mut ItemEntity,
again: impl FnOnce(&mut ItemEntity) -> Moved,
) -> Option<Moved> {
let a = self.0.get_mut(i)?.take()?;
let fits = a.started == started(item);
#[cfg(test)]
TAKEN.with(|t| {
let (taken, refused) = t.get();
t.set((taken + usize::from(fits), refused + usize::from(!fits)));
});
if !fits {
return None;
}
if horizon_client::parallel::parallel_tick() == 2 {
let mut serial = item.clone();
let expected = again(&mut serial);
check(item.id, &serial, &expected, &a.moved, &a.result);
}
*item = a.moved;
Some(a.result)
}
}
/// `HORIZON_PARALLEL_TICK=verify`: an item's move worked out ahead against the one at its turn.
fn check(id: u32, serial: &ItemEntity, expected: &Moved, ahead: &ItemEntity, got: &Moved) {
use std::sync::atomic::{AtomicU64, Ordering};
static CHECKED: AtomicU64 = AtomicU64::new(0);
static DIFFERED: AtomicU64 = AtomicU64::new(0);
if format!("{serial:?} {expected:?}") != format!("{ahead:?} {got:?}") {
DIFFERED.fetch_add(1, Ordering::Relaxed);
log::error!("parallel tick: item {id}'s move worked out ahead differs from its turn's");
}
let n = CHECKED.fetch_add(1, Ordering::Relaxed) + 1;
if n == 1 || n.is_multiple_of(10_000) {
log::info!(
"parallel tick: {n} item moves worked out ahead checked, {} differed",
DIFFERED.load(Ordering::Relaxed)
);
}
}
#[cfg(test)]
mod tests {
use super::super::super::native_machines::tests::server_game;
use super::{AHEAD, TAKEN};
use horizon_client::{chunk::Chunk, items::ItemStack};
use std::sync::Arc;
/// One item tick with the moves worked out ahead leaves every item, and the level's random,
/// as the tick that moves each item at its turn: items escaping a block (their own random),
/// floating in water, falling, and merging into a later item before its turn (whose move
/// is then made at its turn).
#[test]
#[ignore = "needs prepared resources in HORIZON_TEST_RESOURCES"]
fn moves_worked_out_ahead_match_those_made_at_each_turn() {
let mut game = server_game();
let feet = horizon_core::geometry::Vec3::new(8.5, 70.0, 8.5);
game.player = Some(horizon_client::motion::PlayerMotion::new(game.motion, feet).unwrap());
// `ChunkMap.prepareEntityTickingChunk`: the chunks within 2 are loaded too.
for x in -2..=2 {
for z in -2..=2 {
game.world
.chunks
.insert((x, z), Arc::new(Chunk::empty(x, z)));
}
}
game.update_chunk_tickets();
let registry = game.pack.assets.registry;
let stone = registry.default_state("minecraft:stone").unwrap();
let water = registry.default_state("minecraft:water").unwrap();
for x in 0..16 {
for z in 0..16 {
let (fx, fz) = (f64::from(x) + 0.5, f64::from(z) + 0.5);
let stack = |count| ItemStack::new("minecraft:stone", count);
match (x + z) % 5 {
0 => {
game.world.set(x, 65, z, stone).unwrap();
game.spawn_item(stack(1), [fx, 65.3, fz], [0.0; 3], 10);
}
1 => {
game.world.set(x, 65, z, water).unwrap();
game.spawn_item(stack(1), [fx, 65.5, fz], [0.0; 3], 10);
}
2 => {
game.spawn_item(stack(1), [fx, 66.0, fz], [0.0; 3], 0);
game.spawn_item(stack(5), [fx, 66.0, fz], [0.0; 3], 0);
}
_ => game.spawn_item(stack(1), [fx, 67.0, fz], [0.1, 0.2, -0.1], 10),
}
}
}
for item in game.dropped.iter_mut() {
item.ticks = 39;
}
game.ticked_entities_through = u32::MAX;
let (items, random) = (game.dropped.clone(), game.world.random.clone());
let mut runs = Vec::new();
for ahead in [false, true] {
game.dropped = items.clone();
game.world.random = random.clone();
AHEAD.with(|a| a.set(Some(ahead)));
TAKEN.with(|t| t.set((0, 0)));
game.tick_items();
let left: Vec<String> = game.dropped.iter().map(|i| format!("{i:?}")).collect();
runs.push((
left,
format!("{:?}", game.world.random),
TAKEN.with(|t| t.get()),
));
}
AHEAD.with(|a| a.set(None));
assert!(runs[0].0.len() < items.len(), "some items merged");
assert_eq!(runs[0].0, runs[1].0);
assert_eq!(runs[0].1, runs[1].1);
// The path under test ran: most moves were put in place, and a merge refused some.
let (taken, refused) = runs[1].2;
assert!(
taken >= 200 && refused > 0,
"{taken} put in place, {refused} refused"
);
}
}
+23 -16
View File
@@ -4,7 +4,6 @@
//! costs one `Option` check per tick.
mod event_timings;
mod level_event_targets;
mod mob_states;
use super::{Game, shield::OFFHAND_SLOT};
use event_timings::EventTimings;
@@ -364,12 +363,12 @@ pub(super) fn baby_index(species: Species) -> Option<u8> {
/// own: `mob_meta`'s arms hold these mobs' other entries.
fn mob_variant_meta(
m: &Mob,
sync: &SyncData,
hub: &Hub,
meta: &mut Vec<(u8, i32, Value)>,
defaults: &mut Vec<(u8, i32, Value)>,
) {
let (kind, e) = (m.kind.id.as_str(), &m.extra);
let id = |registry: &str, name: &str| sync.id(registry, &format!("minecraft:{name}"));
let id = |registry: &str, name: &str| hub.registry_id(registry, &format!("minecraft:{name}"));
match kind {
// Axolotl.DATA_VARIANT (after AgeableMob's two).
"minecraft:axolotl" => {
@@ -502,7 +501,7 @@ fn mob_variant_meta(
/// `defaults` gets the defaults of the entries that can go back to theirs.
pub(super) fn mob_meta(
m: &Mob,
sync: &SyncData,
hub: &Hub,
player_entity: Option<i32>,
game_time: i64,
meta: &mut Vec<(u8, i32, Value)>,
@@ -559,7 +558,7 @@ pub(super) fn mob_meta(
// LivingEntity.DATA_EFFECT_PARTICLES and DATA_EFFECT_AMBIENCE_ID: the
// swirls of its effects (the ones a mob has are visible, not ambient).
meta.extend(
sync.effect_meta(
hub.effect_meta(
(m.extra.effects.iter())
.filter(|e| !m.extra.hidden_effects.contains(&e.0))
.map(|e| (e.0, m.extra.ambient_effects.contains(&e.0))),
@@ -591,7 +590,7 @@ pub(super) fn mob_meta(
}
}
let e = &m.extra;
let id = |registry: &str, name: &str| sync.id(registry, &format!("minecraft:{name}"));
let id = |registry: &str, name: &str| hub.registry_id(registry, &format!("minecraft:{name}"));
// Pig.DATA_BOOST_TIME and Strider.DATA_BOOST_TIME (18, the first entry
// after AgeableMob's two). The total `boost()` stored in `counter2`.
// The default 0 is not registered: vanilla never writes it back, and the
@@ -599,7 +598,7 @@ pub(super) fn mob_meta(
if matches!(kind, "minecraft:pig" | "minecraft:strider") && e.counter2 > 0 {
meta.push((18, serializer::INT, Value::Int(e.counter2)));
}
mob_variant_meta(m, sync, meta, defaults);
mob_variant_meta(m, hub, meta, defaults);
mob_raider_meta(m, meta, defaults);
match kind {
"minecraft:guardian" | "minecraft:elder_guardian" => {
@@ -1949,15 +1948,14 @@ impl Game<'_> {
mut kept: Option<&mut super::lan_idle::IdlePass<'_>>,
) {
out.clear();
let mut states = mob_states::Later::new(self.world.game_time);
let mut speeds = super::lan_attributes::SpeedByType::default();
// ChunkMap.tick sends the changes of an entity outside the
// entity-ticking range only when it changed section or needs sync.
let levels = self.tickets.levels();
let idle_at = |p: horizon_core::geometry::Vec3| {
!levels.in_entity_ticking_range((p.x.floor() as i32) >> 4, (p.z.floor() as i32) >> 4)
};
for (index, m) in (self.mobs.iter().enumerate()).filter(|(_, m)| !m.removed && m.id > after)
{
for m in self.mobs.iter().filter(|m| !m.removed && m.id > after) {
// The lead's holder: the player the mob sees, another mob or a
// fence knot (game/lan_entities.rs).
let player_entity = || {
@@ -1988,7 +1986,6 @@ impl Game<'_> {
let bare = idle
&& (kept.as_deref_mut()).is_some_and(|k| k.bare(hub, m.id as i32, kind, &place))
&& !m.needs_sync;
let slot = out.as_slice().len();
let e = out.add(m.id as i32, entity_uuid(m.id), kind);
e.position = place.position;
(e.yaw, e.pitch, e.head_yaw) = place.rotation;
@@ -2002,11 +1999,21 @@ impl Game<'_> {
if bare {
continue;
}
let (sync, sources) = (hub.sync_data(), self.attr_sources());
states.state((slot, index), m, player_entity(), sync, sources, e);
mob_meta(
m,
hub,
player_entity(),
self.world.game_time,
&mut e.meta,
&mut e.defaults,
);
// HappyGhast.aiStep: requiresPrecisePosition while on a still timeout.
e.precise = m.species() == horizon_client::mobs::Species::HappyGhast
&& horizon_client::mobs::happy_ghast::on_still_timeout(m);
mob_freeze_meta(m, &mut e.meta, &mut e.defaults);
mob_equipment(m, hub, &mut e.equipment);
self.mob_attributes(m, &mut speeds, &mut e.attributes);
}
states.fill(&self.mobs, hub.sync_data(), self.attr_sources(), out);
for d in self.dropped.iter().filter(|d| d.id > after) {
let idle = idle_at(d.position);
let place = super::lan_idle::Place {
@@ -4109,7 +4116,7 @@ mod tests {
let hub = meta_hub("RaiderMetaTest");
let meta_of = |m: &Mob| {
let (mut meta, mut defaults) = (Vec::new(), Vec::new());
super::mob_meta(m, hub.sync_data(), None, 0, &mut meta, &mut defaults);
super::mob_meta(m, &hub, None, 0, &mut meta, &mut defaults);
(meta, defaults)
};
@@ -4138,7 +4145,7 @@ mod tests {
// The same health: the ghoul's own maximum is 40, the zombie's 20.
m.health = 10.0;
let (mut meta, mut defaults) = (Vec::new(), Vec::new());
super::mob_meta(&m, hub.sync_data(), None, 0, &mut meta, &mut defaults);
super::mob_meta(&m, &hub, None, 0, &mut meta, &mut defaults);
(meta, defaults)
};
for (template, id) in [
@@ -1,286 +0,0 @@
//! The part of each mob's state for its clients (`Game::lan_entities`) that reads only the mob and
//! the synced tables: its entity data and attribute instances. While the parallel tick is on
//! (`horizon_client::parallel`) it is built after the pass, on the tick's helper threads, and put in
//! place; otherwise in place during the pass, as one thread always did.
use super::super::lan_attributes::{AttrSources, SpeedByType};
use super::{mob_freeze_meta, mob_meta};
use horizon_client::lan::{
data::SyncData,
entity::{EntityState, States},
};
use horizon_client::mobs::Mob;
use horizon_protocol::{entity_data::Value, packets::play::AttributeSnapshot};
/// From this many mobs the states go to the helper threads.
const MIN_MOBS: usize = 1024;
/// One mob's entity data (`meta`, and the `defaults` its entries can go back to) and attribute
/// instances; whether its position is sent precisely.
#[allow(clippy::too_many_arguments)]
pub(super) fn mob_state(
m: &Mob,
sync: &SyncData,
sources: AttrSources,
player_entity: Option<i32>,
game_time: i64,
speeds: &mut SpeedByType,
meta: &mut Vec<(u8, i32, Value)>,
defaults: &mut Vec<(u8, i32, Value)>,
attributes: &mut Vec<AttributeSnapshot>,
) -> bool {
mob_meta(m, sync, player_entity, game_time, meta, defaults);
mob_freeze_meta(m, meta, defaults);
sources.mob_attributes(m, speeds, attributes);
// HappyGhast.aiStep: requiresPrecisePosition while on a still timeout.
m.species() == horizon_client::mobs::Species::HappyGhast
&& horizon_client::mobs::happy_ghast::on_still_timeout(m)
}
#[cfg(test)]
thread_local! {
/// A test's choice of the path, over the parallel tick's switch.
static DEFER: std::cell::Cell<Option<bool>> = const { std::cell::Cell::new(None) };
}
/// Whether the states wait for the helper threads: while the parallel tick is on.
fn deferring() -> bool {
#[cfg(test)]
if let Some(defer) = DEFER.with(std::cell::Cell::get) {
return defer;
}
horizon_client::parallel::lanes() > 1
}
/// The mobs' states of one pass: built in place, or (while the parallel tick is on) the mobs whose
/// states wait for the helper threads: (their slot in the states, their index in the mobs, the
/// player entity their lead may name).
pub(super) struct Later {
waiting: Option<Vec<(usize, usize, Option<i32>)>>,
game_time: i64,
speeds: SpeedByType,
}
impl Later {
pub(super) fn new(game_time: i64) -> Self {
Self {
waiting: deferring().then(Vec::new),
game_time,
speeds: SpeedByType::default(),
}
}
/// Mob `index`'s state `e`, in `slot` of the states: built now, or left for [`Self::fill`].
pub(super) fn state(
&mut self,
(slot, index): (usize, usize),
m: &Mob,
player_entity: Option<i32>,
sync: &SyncData,
sources: AttrSources,
e: &mut EntityState,
) {
if let Some(waiting) = &mut self.waiting {
waiting.push((slot, index, player_entity));
return;
}
let (meta, defaults, attributes) = (&mut e.meta, &mut e.defaults, &mut e.attributes);
e.precise = mob_state(
m,
sync,
sources,
player_entity,
self.game_time,
&mut self.speeds,
meta,
defaults,
attributes,
);
}
/// The waiting states, built on the helper threads and put in place.
pub(super) fn fill(
self,
mobs: &[Mob],
sync: &SyncData,
sources: AttrSources,
out: &mut States,
) {
let Some(waiting) = self.waiting else {
return;
};
let game_time = self.game_time;
let built = horizon_client::parallel::par_map(&waiting, MIN_MOBS, |part, done| {
let mut speeds = SpeedByType::default();
for &(slot, index, player) in part {
let (mut meta, mut defaults, mut attributes) = (Vec::new(), Vec::new(), Vec::new());
let precise = mob_state(
&mobs[index],
sync,
sources,
player,
game_time,
&mut speeds,
&mut meta,
&mut defaults,
&mut attributes,
);
done.push((slot, meta, defaults, attributes, precise));
}
});
let states = out.as_mut_slice();
for (slot, meta, defaults, attributes, precise) in built {
let e = &mut states[slot];
(e.meta, e.defaults, e.attributes, e.precise) = (meta, defaults, attributes, precise);
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use horizon_client::lan::{NetConfig, hub::HubConfig};
/// The states put in place after the pass are those built in place during it, for mobs whose
/// entity data and attributes vary: effects, armor, a horse's rolled jump, variants, a baby,
/// a happy ghast, and what `/attribute` changed.
#[test]
#[ignore = "needs prepared resources and scratch homes/cwd"]
fn deferred_states_match_those_built_in_place() {
crate::mod_api::in_own_process(
"game::lan::mob_states::tests::deferred_states_match_those_built_in_place",
deferred_states_match_those_built_in_place_in_own_process,
);
}
fn deferred_states_match_those_built_in_place_in_own_process() {
let mut game = super::super::super::native_machines::tests::server_game();
game.build_registries();
game.set_allow_commands(true);
for command in [
"summon piglin 8.5 80 8.5 {NoAI:1b,PersistenceRequired:1b}",
"effect give @e[type=piglin] speed 30 1",
"item replace entity @e[type=piglin,limit=1] armor.head with iron_helmet",
"summon horse 10.5 80 8.5 {NoAI:1b}",
"summon sheep 12.5 80 8.5 {NoAI:1b,Color:3b,Age:-24000}",
"summon axolotl 14.5 80 8.5 {NoAI:1b,Variant:2}",
"summon happy_ghast 16.5 84 8.5 {NoAI:1b}",
"summon pig 18.5 80 8.5 {NoAI:1b}",
"summon frog 20.5 80 8.5 {NoAI:1b}",
"attribute @e[type=pig,limit=1] minecraft:scale base set 1.5",
] {
assert!(game.run_command(command), "{command}");
}
let examples = game.mobs.clone();
game.mobs = (0..MIN_MOBS * 2 + 37)
.map(|i| {
let mut mob = examples[i % examples.len()].clone();
mob.id = 10_000 + i as u32;
mob.removed = i % 11 == 0;
mob
})
.collect::<Vec<_>>()
.into();
game.lan_host(
super::super::LanSettings {
port: 0,
guest_commands: true,
force_game_mode: false,
},
NetConfig::loopback(0, String::new(), 2),
HubConfig {
default_game_mode: 0,
guest_commands: true,
force_game_mode: false,
hardcore: false,
view_distance: 2,
simulation_distance: 2,
dimension: "minecraft:overworld".into(),
allow_flight: false,
seed: 0,
max_players: 20,
integrated: false,
levels: Default::default(),
},
)
.unwrap();
let mut lan = game.lan.take().unwrap();
// The shared tables must retain pack-selected effect colors, not just
// vanilla's registry color. This process owns the global pack palette.
horizon_client::brewing::set_pack_colors(vec![("speed".into(), 0xAA12_3456)]);
let players = Default::default();
let mut runs = Vec::new();
struct Reset;
impl Drop for Reset {
fn drop(&mut self) {
DEFER.with(|d| d.set(None));
}
}
let _reset = Reset;
for after in [0, 10_012] {
runs.clear();
for defer in [false, true, false] {
DEFER.with(|d| d.set(Some(defer)));
let mut states = States::default();
game.lan_entities(&players, &mut lan.hub, &mut states, false, after, None);
runs.push(states.as_slice().to_vec());
}
let expected_ids: Vec<_> = game
.mobs
.iter()
.filter(|m| !m.removed && m.id > after)
.map(|m| m.id as i32)
.collect();
assert_eq!(
runs[0].iter().map(|e| e.id).collect::<Vec<_>>(),
expected_ids
);
assert!(runs[0].iter().filter(|e| !e.meta.is_empty()).count() >= MIN_MOBS);
assert!(runs[0].iter().any(|e| !e.attributes.is_empty()));
let colored: Vec<_> = runs[0]
.iter()
.filter(|e| e.kind == "minecraft:piglin")
.collect();
assert!(!colored.is_empty());
for e in colored {
let particles = e.meta.iter().find(|e| e.0 == 10).unwrap();
let Value::Particles(particles) = &particles.2 else {
panic!("effect particle metadata");
};
assert_eq!(particles.len(), 1);
assert_eq!(
particles[0].data,
Some(horizon_protocol::entity_data::ParticleData::Color(
0xFF12_3456_u32 as i32
))
);
}
assert_eq!(runs[0], runs[1], "deferred, after {after}");
assert_eq!(runs[0], runs[2], "switch off, after {after}");
// With deferral off the caller can read the metadata immediately,
// before fill, and fill must not replace the vectors it already owns.
let m = game
.mobs
.iter()
.find(|m| !m.removed && m.id > after)
.unwrap();
let mut states = States::default();
let e = states.add(m.id as i32, 0, &m.kind.id);
let mut later = Later::new(game.world.game_time);
later.state((0, 0), m, None, lan.hub.sync_data(), game.attr_sources(), e);
assert_eq!(e.meta, runs[0][0].meta);
assert_eq!(e.defaults, runs[0][0].defaults);
assert_eq!(e.attributes, runs[0][0].attributes);
let pointers = (e.meta.as_ptr(), e.defaults.as_ptr(), e.attributes.as_ptr());
later.fill(
&game.mobs,
lan.hub.sync_data(),
game.attr_sources(),
&mut states,
);
let e = &states.as_slice()[0];
assert_eq!(
pointers,
(e.meta.as_ptr(), e.defaults.as_ptr(), e.attributes.as_ptr())
);
}
}
}
@@ -22,7 +22,7 @@ fn a_sulfur_cube_sends_its_fuse_at_19_and_from_bucket_at_20() {
let hub = meta_hub("SulfurCubeMetaTest");
let meta_of = |m: &Mob| {
let (mut meta, mut defaults) = (Vec::new(), Vec::new());
super::mob_meta(m, hub.sync_data(), None, 0, &mut meta, &mut defaults);
super::mob_meta(m, &hub, None, 0, &mut meta, &mut defaults);
(meta, defaults)
};
@@ -103,32 +103,10 @@ impl Game<'_> {
}
}
/// What a mob's synced attribute instances are read from (`mob_attributes`).
pub(super) fn attr_sources(&self) -> AttrSources<'_> {
AttrSources {
catalog: &self.motion.attributes,
items: self.items(),
commands: &self.cmds.attributes,
}
}
}
/// What the synced attribute instances are read from: the attribute catalog, the item table and
/// what `/attribute` changed. Plain tables, so the tick's helper threads can read them
/// (`Game::lan_entities`).
#[derive(Clone, Copy)]
pub(super) struct AttrSources<'a> {
catalog: &'a horizon_world::attributes::entity::Catalog,
items: &'a horizon_client::items::Items,
commands:
&'a std::collections::HashMap<Target, horizon_world::attributes::entity::EntityAttributes>,
}
impl AttrSources<'_> {
/// The registry id of a client-syncable attribute the entity type has
/// (`AttributeMap.getInstance` is null for the others), by name.
fn synced_attribute(&self, entity: &str, name: &str) -> Option<i32> {
let catalog = self.catalog;
let catalog = &self.motion.attributes;
let id = catalog.id(name)?;
(catalog.attribute(id).syncable && catalog.default_base(entity, id).is_some())
.then(|| id.registry_id())
@@ -136,7 +114,7 @@ impl AttrSources<'_> {
/// The type's base value of an attribute, as the instance starts.
fn attribute_default(&self, entity: &str, name: &str) -> f64 {
let catalog = self.catalog;
let catalog = &self.motion.attributes;
catalog
.id(name)
.and_then(|id| catalog.default_base(entity, id))
@@ -154,7 +132,7 @@ impl AttrSources<'_> {
out: &mut Vec<AttributeSnapshot>,
) {
for (slot, item) in slots {
let Some(info) = item.and_then(|i| self.items.get(i)) else {
let Some(info) = item.and_then(|i| self.items().get(i)) else {
continue;
};
for (attribute, id, amount, op, group) in &info.modifiers {
@@ -328,16 +306,6 @@ impl AttrSources<'_> {
}
}
/// The instances `/attribute` created in an entity's generic map
/// replace the ones derived from its state.
fn generic_attributes_of(&self, t: Target, out: &mut Vec<AttributeSnapshot>) {
if let Some(map) = self.commands.get(&t) {
generic_attributes_into(map, out);
}
}
}
impl Game<'_> {
/// An armor stand's synced instances: movement speed (its first
/// `aiStep`), its equipment's modifiers and what `/attribute` changed.
pub(super) fn stand_attributes(
@@ -347,19 +315,18 @@ impl Game<'_> {
) -> Vec<AttributeSnapshot> {
const KIND: &str = "minecraft:armor_stand";
let mut out = Vec::new();
let sources = self.attr_sources();
if let Some(a) = sources.synced_attribute(KIND, "minecraft:movement_speed") {
if let Some(a) = self.synced_attribute(KIND, "minecraft:movement_speed") {
instance(
&mut out,
a,
sources.attribute_default(KIND, "minecraft:movement_speed"),
self.attribute_default(KIND, "minecraft:movement_speed"),
);
}
let slots = horizon_client::entities::ArmorStand::SLOTS
.into_iter()
.zip(equipment.iter().map(|s| s.as_ref().map(|s| &s.item)));
sources.equipment_attributes(KIND, |_| None, slots, &mut out);
sources.generic_attributes_of(Target::Stand(id), &mut out);
self.equipment_attributes(KIND, |_| None, slots, &mut out);
self.generic_attributes_of(Target::Stand(id), &mut out);
out
}
@@ -405,6 +372,14 @@ impl Game<'_> {
}
}
}
/// The instances `/attribute` created in an entity's generic map
/// replace the ones derived from its state.
fn generic_attributes_of(&self, t: Target, out: &mut Vec<AttributeSnapshot>) {
if let Some(map) = self.cmds.attributes.get(&t) {
generic_attributes_into(map, out);
}
}
}
#[cfg(test)]
@@ -34,8 +34,6 @@ pub(super) fn run_event_loop(mut game: Game<'_>) -> Result<()> {
narrator::stop();
leave_fullscreen_after(&result, &mut game.settings);
result?;
#[cfg(feature = "discord")]
horizon_client::discord_presence::stop();
write_report(&game)?;
if let Some(e) = game.error.take() {
return Err(e);
@@ -98,9 +96,8 @@ impl Loading {
.map_or([1280, 720], |o| o.window_size)
.map(f64::from);
self.fullscreen = horizon_client::window::FullscreenState::new(fullscreen);
let attributes = horizon_client::window::with_horizon_icon(
Window::default_attributes().with_title("Horizon"),
);
let attributes =
horizon_client::window::with_icon(Window::default_attributes().with_title("Horizon"));
// Wayland names the window by its app id, which the horizon.desktop entry matches.
#[cfg(target_os = "linux")]
let attributes = winit::platform::wayland::WindowAttributesExtWayland::with_name(
@@ -130,22 +127,25 @@ impl Loading {
// redraw the same design when they start. GDI is unsafe:
// horizon-threads holds it (this crate forbids unsafe code).
#[cfg(target_os = "windows")]
let boot_paint_stop = hwnd(&window).map(|hwnd| {
let stop = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false));
let repaint_stop = stop.clone();
std::thread::spawn(move || {
while !repaint_stop.load(std::sync::atomic::Ordering::Relaxed) {
horizon_threads::boot_paint::paint(
hwnd,
horizon_theme::fonts::GEIST_SEMIBOLD,
horizon_theme::fonts::GEIST_MONO,
&horizon_client::logging::recent_lines(),
);
std::thread::sleep(std::time::Duration::from_millis(300));
}
// In the fonts given to `menu_style`; with none, the window stays as it is.
let boot_paint_stop = hwnd(&window)
.zip(horizon_client::menu_style::get().fonts)
.map(|(hwnd, fonts)| {
let stop = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false));
let repaint_stop = stop.clone();
std::thread::spawn(move || {
while !repaint_stop.load(std::sync::atomic::Ordering::Relaxed) {
horizon_threads::boot_paint::paint(
hwnd,
fonts.semibold,
fonts.mono,
&horizon_client::logging::recent_lines(),
);
std::thread::sleep(std::time::Duration::from_millis(300));
}
});
stop
});
stop
});
let offscreen = self.options.as_ref().is_some_and(|o| o.offscreen);
let boot_step = startup::step("renderer.boot");
match loading_renderer(
@@ -93,10 +93,9 @@ impl ApplicationHandler for Missing {
if self.window.is_some() {
return;
}
let attributes = horizon_client::window::with_horizon_icon(
Window::default_attributes().with_title("Horizon"),
)
.with_inner_size(winit::dpi::LogicalSize::new(1040.0, 700.0));
let attributes =
horizon_client::window::with_icon(Window::default_attributes().with_title("Horizon"))
.with_inner_size(winit::dpi::LogicalSize::new(1040.0, 700.0));
let made = event_loop
.create_window(attributes)
.map_err(Into::into)
+4 -17
View File
@@ -801,19 +801,8 @@ impl Game<'_> {
packed,
out,
);
if super::cosmetics::enabled() {
let base = horizon_client::mobs::render::base_transform(m, alpha as f32, camera);
self.cosmetics_geometry(
m,
true,
base,
Some(camera),
alpha as f32,
packed,
false,
out,
);
}
let world = horizon_hooks::players::Pass::World;
self.own_layers_geometry(m, world, camera, alpha as f32, packed, out);
self.player_elytra_geometry(m, chest, camera, alpha as f32, packed, out);
self.spin_attack_geometry(m, camera, alpha as f32, packed, out);
self.shoulder_parrots_geometry(m, camera, alpha as f32, packed, out);
@@ -927,10 +916,8 @@ impl Game<'_> {
240 | 240 << 8,
&mut verts,
);
if super::cosmetics::enabled() {
let base = horizon_client::mobs::render::base_transform(&m, 1.0, camera);
self.cosmetics_geometry(&m, true, base, None, 1.0, 240 | 240 << 8, false, &mut verts);
}
let picture = horizon_hooks::players::Pass::Picture;
self.own_layers_geometry(&m, picture, camera, 1.0, 240 | 240 << 8, &mut verts);
// `WingsLayer` draws through `RenderTypes.armorCutoutNoCull`: the
// wings' faces show from both sides (the elytra texture is empty on
// the face that looks at the body).
+204 -39
View File
@@ -11,17 +11,15 @@ use horizon_client::mobs::anim::{self, BodyPose, PooledPoses, Poses};
use horizon_client::mobs::{Mob, Part, Polygon, render};
use horizon_hooks::engine;
use horizon_hooks::players::{
Bone, CameraView, Frame, Gear, Kind, Mesh, PartPoses, Pass, PlayerDraw, PlayerModel,
PlayerModels, SpriteStore, Sprites,
Bone, CameraView, Frame, Gear, Kind, Light, Look, Mesh, Motion, PartPoses, Particle, Pass,
PlayerDraw, PlayerModel, PlayerModels, SpriteStore, Sprites, View,
};
use std::sync::Arc;
/// Registers the hooks of the Horizon parts still in this crate (cosmetics: spec #14091 step 5
/// moves them out). Before the hooks are sealed.
pub(crate) fn register() {
if super::cosmetics::enabled()
&& let Err(e) = horizon_hooks::register(&super::cosmetics::HOOKS)
{
if let Err(e) = super::cosmetics::register() {
log::error!("Cosmetics: their hooks came too late: {e}");
}
}
@@ -182,6 +180,14 @@ fn part_of(mesh: &Arc<Mesh>) -> Arc<Part> {
})
}
/// Full light, packed as the vertices carry it.
const FULL: u32 = 240 | 240 << 8;
thread_local! {
/// What crates draw see-through this frame, added after every player.
static SEE_THROUGH: std::cell::RefCell<Vec<Vertex>> = const { std::cell::RefCell::new(Vec::new()) };
}
/// Forgets the meshes no crate holds any more (once a frame).
fn forget_meshes() {
let _ = MESHES.try_with(|m| {
@@ -199,7 +205,7 @@ impl PartPoses for PosesOf<'_> {
}
}
/// A player model drawn in the world pass, as a crate draws on it: posed on the GPU's skin path
/// A player model drawn, as a crate draws on it: posed on the GPU's skin path
/// when `gpu` (the mob pass's skin frame, begun; the bounds of what went there grow, for
/// culling), else its vertices go to `out`.
struct DrawPlayer<'a, 'g> {
@@ -208,6 +214,7 @@ struct DrawPlayer<'a, 'g> {
pass: Pass,
/// The model's matrix (`render::base_transform`).
base: glam::Mat4,
camera: glam::DVec3,
alpha: f32,
light: u32,
gpu: bool,
@@ -223,6 +230,19 @@ impl DrawPlayer<'_, '_> {
let (key, _) = render::body(self.m);
self.m.kind.layer(&key).unwrap_or(&self.m.kind.model)
}
/// `light` packed; `at`: the place `Light::AtPlace` reads, relative to the camera.
fn packed(&self, light: Light, at: glam::Vec3) -> u32 {
match light {
Light::Full => FULL,
Light::AtPlace => {
let p = (self.camera + at.as_dvec3()).floor();
let l = self.game.world.light(p.x as i32, p.y as i32, p.z as i32);
(u32::from(l >> 4) * 16) | ((u32::from(l & 15) * 16) << 8)
}
_ => self.light,
}
}
}
impl PlayerDraw for DrawPlayer<'_, '_> {
@@ -234,7 +254,52 @@ impl PlayerDraw for DrawPlayer<'_, '_> {
self.m.age as f32 + self.alpha
}
fn base(&self) -> [[f32; 4]; 4] {
self.base.to_cols_array_2d()
}
fn motion(&self) -> Motion {
let (m, a) = (self.m, self.alpha);
let t = f64::from(a);
let (p, n) = (m.previous, m.position);
let feet = [
p.x + (n.x - p.x) * t,
p.y + (n.y - p.y) * t,
p.z + (n.z - p.z) * t,
];
let turn = horizon_client::mobs::living::wrap(m.head_yaw - m.previous_head_yaw);
let yaw = m.previous_head_yaw + turn * a;
let pitch = m.previous_head_pitch + (m.head_pitch - m.previous_head_pitch) * a;
let asleep = m.extra.avatar_pose == horizon_client::pose::Pose::Sleeping;
let swimming = m.extra.swim.amount(a) > 0.0;
Motion::new(feet, yaw, pitch).poses(
m.extra.crouching,
asleep,
m.extra.fall_flying,
swimming,
)
}
fn view(&self) -> View {
let q = self.game.camera_orientation();
let (right, up) = (
(q * glam::Vec3::X).to_array(),
(q * glam::Vec3::Y).to_array(),
);
let (yaw, pitch) = (self.game.yaw.to_radians(), self.game.pitch.to_radians());
let forward = [
-yaw.sin() * pitch.cos(),
-pitch.sin(),
yaw.cos() * pitch.cos(),
];
let left = [yaw.cos(), 0.0, yaw.sin()];
View::new(self.camera.to_array(), right, up).looking(forward, left)
}
fn bone(&mut self, bone: Bone) -> Option<[[f32; 4]; 4]> {
if self.pass == Pass::FirstPerson {
return None;
}
if self.body.is_none() {
let mut poses = Poses::pooled();
poses.reset(self.body());
@@ -260,13 +325,30 @@ impl PlayerDraw for DrawPlayer<'_, '_> {
&mut self,
mesh: &Arc<Mesh>,
matrix: [[f32; 4]; 4],
uv: [f32; 4],
look: Look,
pose: &mut dyn FnMut(&mut dyn PartPoses),
) {
let model = part_of(mesh);
let matrix = glam::Mat4::from_cols_array_2d(&matrix);
let (uv, light) = (look.uv, self.packed(look.light, matrix.w_axis.truncate()));
self.poses.reset(&model);
pose(&mut PosesOf(&mut self.poses));
let poses = &self.poses;
if let Some(alpha) = look.see_through {
let color = ((alpha.clamp(0.0, 1.0) * 255.0) as u32) << 24 | 0x00FF_FFFF;
let pose_of = |p: &Part| poses.pose(p);
SEE_THROUGH.with_borrow_mut(|out| {
let start = out.len();
horizon_client::mobs::render_model(&model, matrix, &pose_of, uv, light, 0, out);
for v in &mut out[start..] {
v.color = color;
// Seen through, a face turned away would add to the near one: `entity.wgsl`
// drops back faces, so it is `alpha` at most.
v.normal[3] = horizon_client::mesher::SHADED_CULL_W;
}
});
return;
}
if self.gpu {
let mut models = self.game.skin_models.borrow_mut();
let mut frame = self.game.skin_frame.borrow_mut();
@@ -276,30 +358,43 @@ impl PlayerDraw for DrawPlayer<'_, '_> {
uv,
scroll: 0.0,
color: 0xFFFF_FFFF,
light: self.light,
light,
overlay: 0,
};
if skin.draw(&model, matrix, &self.poses, paint) {
if skin.draw(&model, matrix, poses, paint) {
self.bounds = skin.bounds;
return;
}
}
let poses = &self.poses;
horizon_client::mobs::render_model(
&model,
matrix,
&|p: &Part| poses.pose(p),
uv,
self.light,
0,
self.out,
);
let pose_of = |p: &Part| poses.pose(p);
horizon_client::mobs::render_model(&model, matrix, &pose_of, uv, light, 0, self.out);
}
fn particles(&mut self, uv: [f32; 4], particles: &[Particle], light: Light) {
let light = self.packed(light, glam::Vec3::ZERO);
SEE_THROUGH.with_borrow_mut(|out| {
for q in particles {
let color = ((q.alpha.clamp(0.0, 1.0) * 255.0) as u32) << 24 | 0x00FF_FFFF;
for (corner, [u, v]) in q.corners.iter().zip(q.uv) {
out.push(Vertex {
pos: *corner,
uv: [uv[0] + (uv[2] - uv[0]) * u, uv[1] + (uv[3] - uv[1]) * v],
layer: 0,
color,
light,
// Unlit: a particle keeps its colour whichever way it faces.
normal: [0, 0, 0, 0],
glint_uv: [0.0, 0.0],
});
}
}
});
}
}
impl Game<'_> {
/// What crates draw on player model `m` in the world pass (`creatures.rs`), then the
/// cosmetics still drawn here: see [`DrawPlayer`]. The bounds of what went to the GPU.
/// What crates draw on player model `m` in the world pass (`creatures.rs`): see
/// [`DrawPlayer`]. The bounds of what went to the GPU.
#[allow(clippy::too_many_arguments)]
pub(super) fn player_layers_geometry(
&self,
@@ -315,33 +410,102 @@ impl Game<'_> {
self.serving.is_none(),
"a server thread calls no client hook"
);
let kind = kind(m)?;
let mut bounds = None;
if engine::any() {
let model = model(m, kind, self.remote_player_uuid(m.id));
let kind = kind(m).filter(|_| engine::any())?;
let model = model(m, kind, self.remote_player_uuid(m.id));
let mut draw = DrawPlayer {
game: self,
m,
pass: Pass::World,
base,
camera,
alpha,
light,
gpu,
out,
bounds: None,
body: None,
poses: Poses::pooled(),
};
engine::player_layers(&model, &mut draw);
draw.bounds
}
/// What crates draw on the window's own model `m` (its vertices to `out`): in the world in
/// third person (`Pass::World`), or in the inventory's picture of it (`Pass::Picture`).
/// `camera`: the world view's camera, or the picture's.
pub(super) fn own_layers_geometry(
&self,
m: &Mob,
pass: Pass,
camera: glam::DVec3,
alpha: f32,
light: u32,
out: &mut Vec<Vertex>,
) {
debug_assert!(
self.serving.is_none(),
"a server thread calls no client hook"
);
if !engine::any() {
return;
}
let model = model(m, Kind::Own, self.remote.as_ref().map(|r| r.profile()));
let mut draw = DrawPlayer {
game: self,
m,
pass,
base: render::base_transform(m, alpha, camera),
camera,
alpha,
light,
gpu: false,
out,
bounds: None,
body: None,
poses: Poses::pooled(),
};
engine::player_layers(&model, &mut draw);
}
/// After every player (`game.rs`): what crates draw around the window's own model in first
/// person, then what was drawn see-through, in one span.
pub(super) fn players_after_geometry(
&self,
camera: glam::DVec3,
alpha: f64,
out: &mut Vec<Vertex>,
) {
if engine::any()
&& !self.own_body_in_view()
&& let Some(m) = self.player_model.as_ref()
{
let own = model(m, Kind::Own, self.remote.as_ref().map(|r| r.profile()));
let own = own.looks(own.gear, own.invisible || self.own_invisible());
self.mark_entity(out.len(), "minecraft:player");
let mut draw = DrawPlayer {
game: self,
m,
pass: Pass::World,
base,
alpha,
light,
gpu,
pass: Pass::FirstPerson,
base: render::base_transform(m, alpha as f32, camera),
camera,
alpha: alpha as f32,
light: FULL,
gpu: false,
out: &mut *out,
bounds: None,
body: None,
poses: Poses::pooled(),
};
engine::player_layers(&model, &mut draw);
bounds = draw.bounds;
}
let worn = super::cosmetics::enabled()
.then(|| self.cosmetics_geometry(m, false, base, Some(camera), alpha, light, gpu, out))
.flatten();
match (bounds, worn) {
(Some((l, h)), Some((lo, hi))) => Some((l.min(lo), h.max(hi))),
(b, w) => b.or(w),
engine::first_person(&own, &mut draw);
}
let _ = SEE_THROUGH.try_with(|v| {
let mut v = v.borrow_mut();
if !v.is_empty() {
self.mark_translucent_entity(out.len(), "minecraft:player");
out.extend_from_slice(&v);
v.clear();
}
});
}
/// Once a frame, before drawing.
@@ -358,6 +522,7 @@ impl Game<'_> {
let frame = Frame::new(connection, self.integrated.is_some());
engine::frame(&frame, &mut HookStore(self));
forget_meshes();
let _ = SEE_THROUGH.try_with(|v| v.borrow_mut().clear());
self.cosmetics_frame();
}
+1 -2
View File
@@ -11463,8 +11463,7 @@ impl Remote {
pub(super) fn listed_player_count(&self) -> usize {
self.listed.len()
}
/// Players in the server's player list, this one included (for Discord).
#[cfg(feature = "discord")]
/// Players in the server's player list, this one included (for the activity hooks).
pub(super) fn player_count(&self) -> usize {
self.players.len()
}
@@ -306,8 +306,6 @@ pub(super) fn run_loading(
App::Loading(loading) => leave_fullscreen_after(&result, &mut loading.settings),
}
result?;
#[cfg(feature = "discord")]
horizon_client::discord_presence::stop();
match app {
App::Ready(mut split) => {
write_report(&split.host)?;
@@ -516,10 +516,9 @@ impl<'a> Split<'a> {
continue;
}
// Opened without the focus: whoever types in the game's window keeps the keyboard.
let attributes =
horizon_client::window::with_horizon_icon(Window::default_attributes())
.with_active(false)
.with_visible(window_shown(true, self.offscreen));
let attributes = horizon_client::window::with_icon(Window::default_attributes())
.with_active(false)
.with_visible(window_shown(true, self.offscreen));
// A monitor's window covers it, borderless. On macOS not the system's fullscreen, a
// Space of its own that hides the other windows as it slides in.
let placement = self.placement(*key).cloned();
@@ -113,6 +113,117 @@ fn fire_from_damage_lights_or_burns_the_stand_though_the_command_fails() {
assert!(health.ends_with(": 16.0f"), "{health}");
}
#[test]
#[ignore = "needs prepared resources in HORIZON_TEST_RESOURCES"]
fn igniting_a_frozen_stand_clears_its_freeze_while_burning_keeps_it() {
let mut game = game();
summon(&mut game, ",TicksFrozen:140");
let (_, frozen) = say(&mut game, &format!("data get entity {STAND} TicksFrozen"));
assert!(frozen.ends_with(": 140"), "{frozen}");
assert!(!game.run_command(&format!("damage {STAND} 1 minecraft:in_fire")));
let (_, fire) = say(&mut game, &format!("data get entity {STAND} Fire"));
assert!(fire.ends_with(": 100s"), "{fire}");
let (ok, frozen_after) = say(&mut game, &format!("data get entity {STAND} TicksFrozen"));
assert!(!ok, "{frozen_after}");
// When already burning, in_fire calls causeDamage(0.15) instead of igniteForSeconds.
assert!(game.run_command(&format!("data merge entity {STAND} {{TicksFrozen:140}}")));
assert!(!game.run_command(&format!("damage {STAND} 1 minecraft:in_fire")));
let (ok, still_frozen) = say(&mut game, &format!("data get entity {STAND} TicksFrozen"));
assert!(ok && still_frozen.ends_with(": 140"), "{still_frozen}");
}
#[test]
#[ignore = "needs prepared resources in HORIZON_TEST_RESOURCES"]
fn damage_immunity_on_worn_boots_refuses_campfire_but_not_in_fire_or_wrong_slot() {
let mut game = game();
summon(
&mut game,
r#",equipment:{feet:{id:"minecraft:diamond_boots",count:1,components:{"minecraft:enchantments":{"minecraft:frost_walker":1}}}}"#,
);
// campfire is in both #ignites_armor_stands and #burn_from_stepping.
assert!(!game.run_command(&format!("damage {STAND} 1 minecraft:campfire")));
let (_, fire) = say(&mut game, &format!("data get entity {STAND} Fire"));
assert!(fire.ends_with(": 0s"), "{fire}");
// in_fire is not in #burn_from_stepping, so it still ignites the stand.
assert!(!game.run_command(&format!("damage {STAND} 1 minecraft:in_fire")));
let (_, fire) = say(&mut game, &format!("data get entity {STAND} Fire"));
assert!(fire.ends_with(": 100s"), "{fire}");
// Frost Walker on a helmet (head) is not in an active slot for the enchantment.
let mut helmeted = self::game();
summon(
&mut helmeted,
r#",equipment:{head:{id:"minecraft:diamond_helmet",count:1,components:{"minecraft:enchantments":{"minecraft:frost_walker":1}}}}"#,
);
assert!(!helmeted.run_command(&format!("damage {STAND} 1 minecraft:campfire")));
let (_, fire) = say(&mut helmeted, &format!("data get entity {STAND} Fire"));
assert!(fire.ends_with(": 100s"), "{fire}");
}
fn capture() {
super::sculk::TEST_POSTED_EVENTS.with(|events| *events.borrow_mut() = Some(Vec::new()));
}
fn take_events() -> Vec<(&'static str, [f64; 3], Option<horizon_client::mobs::Who>)> {
super::sculk::TEST_POSTED_EVENTS.with(|events| events.borrow_mut().take().unwrap_or_default())
}
#[test]
#[ignore = "needs prepared resources in HORIZON_TEST_RESOURCES"]
fn cause_damage_and_explosion_post_attributed_game_events() {
use horizon_client::mobs::Who;
let mut game = game();
summon(&mut game, ",Fire:200s");
assert!(game.run_command("summon pig 14.5 100 2.5 {NoAI:1b}"));
let pig_id = game.mobs[0].id;
let at = [2.5, 100.0, 8.5];
// Burning stand hurt by in_fire (0.15) posts entity_damage attributed to the pig.
capture();
assert!(!game.run_command(&format!(
"damage {STAND} 1 minecraft:in_fire by @e[type=pig,limit=1]"
)));
assert_eq!(
take_events(),
vec![("minecraft:entity_damage", at, Some(Who::Mob(pig_id)))]
);
// Burning stand hurt by on_fire (4.0) while surviving posts entity_damage.
capture();
assert!(!game.run_command(&format!(
"damage {STAND} 1 minecraft:on_fire by @e[type=pig,limit=1]"
)));
assert_eq!(
take_events(),
vec![("minecraft:entity_damage", at, Some(Who::Mob(pig_id)))]
);
// Burning stand broken by on_fire (health <= 0.5) posts entity_die attributed to the pig.
assert!(game.run_command(&format!("data merge entity {STAND} {{Health:4f}}")));
capture();
assert!(!game.run_command(&format!(
"damage {STAND} 1 minecraft:on_fire by @e[type=pig,limit=1]"
)));
assert!(!game.run_command(&format!("execute if entity {STAND}")));
assert_eq!(
take_events(),
vec![("minecraft:entity_die", at, Some(Who::Mob(pig_id)))]
);
// Stand broken by explosion posts entity_die attributed to the pig.
summon(&mut game, "");
capture();
assert!(!game.run_command(&format!(
"damage {STAND} 1 minecraft:explosion by @e[type=pig,limit=1]"
)));
assert!(!game.run_command(&format!("execute if entity {STAND}")));
assert_eq!(
take_events(),
vec![("minecraft:entity_die", at, Some(Who::Mob(pig_id)))]
);
}
// Controls added after the baseline: the guards before the acting sources,
// and a stand broken once however often it is hurt.
@@ -273,9 +273,8 @@ impl ApplicationHandler for Game<'_> {
let fullscreen = self.settings.fullscreen || self.options.fullscreen;
let size = self.options.window_size.map(f64::from);
self.fullscreen = horizon_client::window::FullscreenState::new(fullscreen);
let attributes = horizon_client::window::with_horizon_icon(
Window::default_attributes().with_title("Horizon"),
);
let attributes =
horizon_client::window::with_icon(Window::default_attributes().with_title("Horizon"));
// Wayland names the window by its app id, which the horizon.desktop entry matches.
#[cfg(target_os = "linux")]
let attributes = winit::platform::wayland::WindowAttributesExtWayland::with_name(
+56
View File
@@ -0,0 +1,56 @@
//! Horizon's look, given to the engine's extension points at startup (docs/layers.md): the
//! windows' icon and the colours, sizes and fonts of the menus vanilla doesn't have
//! (`docs/design-system.md`).
//! Its own module so it can leave the engine crate as a pure file move, for the official
//! executable to call.
use horizon_client::menu_style::{Fonts, MenuStyle, OverlayColours, SettingsColours, Sizes};
use horizon_theme as theme;
/// Give the engine Horizon's look; call it once, before the first window opens.
pub(crate) fn give() {
horizon_client::window::set_icon_png(theme::ICON_PNG);
let (blocks, dark) = (theme::BLOCKS_DARK, theme::DARK);
horizon_client::menu_style::install(MenuStyle {
settings: SettingsColours {
page: blocks.page,
ink: blocks.ink,
ink_soft: blocks.ink_soft,
muted: blocks.muted,
outline: blocks.outline,
accent: blocks.accent,
face: blocks.face,
// The face's hover, lighter than `face`: the settings screen's own shade.
face_hover: 0x6a50e6,
success: blocks.success,
warn: blocks.warn,
danger: blocks.danger,
},
overlay: OverlayColours {
page: dark.page,
backdrop_muted: theme::BACKDROP_MUTED,
ink: dark.ink,
ink_soft: dark.ink_soft,
rule: dark.rule,
accent: dark.accent,
on_accent: dark.on_accent,
},
sizes: Sizes {
caption: theme::CAPTION.px,
label: theme::LABEL.px,
ui: theme::UI.px,
body: theme::BODY.px,
title: theme::TITLE.px,
display: theme::DISPLAY.px,
micro: theme::MICRO.px,
radius: theme::RADIUS,
radius_plate: theme::RADIUS_PLATE,
control_large: theme::CONTROL_LARGE,
},
fonts: Some(Fonts {
regular: theme::fonts::GEIST_REGULAR,
medium: theme::fonts::GEIST_MEDIUM,
semibold: theme::fonts::GEIST_SEMIBOLD,
mono: theme::fonts::GEIST_MONO,
}),
});
}
-53
View File
@@ -4,7 +4,6 @@
//! (`data/minecraft/...`) and the registries report.
use crate::chunk::LevelShapes;
use horizon_protocol::{
entity_data,
packets::configuration::{RegistryData, RegistryEntry, RegistryTags, TagEntry, UpdateTags},
registry::{Builtin, Dynamic},
};
@@ -507,58 +506,6 @@ impl SyncData {
.any(|(_, e)| e.from_pack.contains(&true))
}
/// `LivingEntity.updateSynchronizedMobEffectParticles`: DATA_EFFECT_PARTICLES
/// (10) and DATA_EFFECT_AMBIENCE_ID (11) for the visible `effects`, as
/// (effect, ambient). The entries are left out when there is nothing to
/// show, which is their default.
pub fn effect_meta<'a>(
&self,
effects: impl IntoIterator<Item = (&'a str, bool)>,
) -> Vec<(u8, i32, entity_data::Value)> {
let mut particles = Vec::new();
let mut all_ambient = true;
for (effect, ambient) in effects {
let effect = effect.trim_start_matches("minecraft:");
// MobEffect.particleFactory: the omens have their own particles,
// the others an entity effect in their colour (faint if ambient).
let (name, data) = match effect {
"trial_omen" | "raid_omen" => (effect, None),
_ => {
let Some(def) = crate::brewing::effect(effect) else {
continue;
};
let alpha = if ambient { 38 } else { 255 };
let argb = (alpha << 24) | def.shown_color() as i32;
(
"entity_effect",
Some(entity_data::ParticleData::Color(argb)),
)
}
};
let Some(id) = self.id("minecraft:particle_type", &format!("minecraft:{name}")) else {
continue;
};
particles.push(entity_data::Particle { id, data });
all_ambient &= ambient;
}
if particles.is_empty() {
return Vec::new();
}
let mut meta = vec![(
10,
entity_data::serializer::PARTICLES,
entity_data::Value::Particles(particles),
)];
if all_ambient {
meta.push((
11,
entity_data::serializer::BOOLEAN,
entity_data::Value::Bool(true),
));
}
meta
}
/// The id of a name in a built-in or synchronized registry.
pub fn id(&self, registry: &str, name: &str) -> Option<i32> {
if registry == "minecraft:data_component_type"
+34 -7
View File
@@ -24,7 +24,7 @@ use horizon_core::{
};
use horizon_protocol::ids::play::clientbound as ids;
use horizon_protocol::{
entity_data::{Value, serializer},
entity_data::{Particle, ParticleData, Value, serializer},
packets::play::{self, Clientbound as C, Serverbound as S},
text::{literal, translatable},
};
@@ -3250,12 +3250,39 @@ impl Hub {
&self,
effects: impl IntoIterator<Item = (&'a str, bool)>,
) -> Vec<(u8, i32, Value)> {
self.sync.effect_meta(effects)
}
/// The registries and tables the clients are synced with (read by helper threads).
pub fn sync_data(&self) -> &SyncData {
&self.sync
let mut particles = Vec::new();
let mut all_ambient = true;
for (effect, ambient) in effects {
let effect = effect.trim_start_matches("minecraft:");
// MobEffect.particleFactory: the omens have their own particles,
// the others an entity effect in their colour (faint if ambient).
let (name, data) = match effect {
"trial_omen" | "raid_omen" => (effect, None),
_ => {
let Some(def) = crate::brewing::effect(effect) else {
continue;
};
let alpha = if ambient { 38 } else { 255 };
let argb = (alpha << 24) | def.shown_color() as i32;
("entity_effect", Some(ParticleData::Color(argb)))
}
};
let Some(id) =
self.registry_id("minecraft:particle_type", &format!("minecraft:{name}"))
else {
continue;
};
particles.push(Particle { id, data });
all_ambient &= ambient;
}
if particles.is_empty() {
return Vec::new();
}
let mut meta = vec![(10, serializer::PARTICLES, Value::Particles(particles))];
if all_ambient {
meta.push((11, serializer::BOOLEAN, Value::Bool(true)));
}
meta
}
fn player_state(
+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)]
+51 -30
View File
@@ -5,10 +5,6 @@
//! fly and amphibious node evaluators (`path`, `nav`), move/look/jump/body
//! controls (`control`), `LivingEntity` physics (`living`) and per-species
//! behaviour, models, animations and sounds.
mod mob_slot;
mod pending_pushes;
pub use mob_slot::MobSlot;
pub use pending_pushes::{PendingPushes, apply_mob_pushes};
pub mod ai;
pub mod allay;
pub mod ambient;
@@ -2927,6 +2923,28 @@ pub fn take_mob_pushes(events: &mut Vec<MobEvent>, from: usize, pending: &mut Pe
}
events.truncate(kept);
}
/// Pushes waiting for their mob's tick, in push order. A list while short;
/// past [`PendingPushes::LIST_MAX`] (a crowd pushing each other) one list
/// per mob, as scanning the whole list for each mob grows with its square.
#[derive(Default)]
pub struct PendingPushes {
list: Vec<(u32, Vec3)>,
by_mob: crate::fast_hash::FxHashMap<u32, Vec<Vec3>>,
}
impl PendingPushes {
const LIST_MAX: usize = 64;
pub fn push(&mut self, id: u32, v: Vec3) {
if self.by_mob.is_empty() && self.list.len() < Self::LIST_MAX {
self.list.push((id, v));
return;
}
// Older pushes first, so each mob's stay in order.
for (id, v) in self.list.drain(..) {
self.by_mob.entry(id).or_default().push(v);
}
self.by_mob.entry(id).or_default().push(v);
}
}
/// `ServerLevel.sendBlockUpdated`: for each write that changed a block's collision
/// shape, a mob whose path runs near it re-paths (`PathNavigation.shouldRecomputePath`).
/// The writes whose shape changed are found once per tick ([`collision_changes`]).
@@ -2954,28 +2972,50 @@ pub fn collision_changes(w: &MobWorld, writes: &[((i32, i32, i32), u32, u32)]) -
})
.collect()
}
/// Adds the pending pushes aimed at `m` to its velocity, in push order.
pub fn apply_mob_pushes(m: &mut Mob, pending: &mut PendingPushes) {
// Entity.push(x, y, z) sets needsSync.
let mut add = |v: Vec3| {
m.velocity = Vec3::new(m.velocity.x + v.x, m.velocity.y + v.y, m.velocity.z + v.z);
m.needs_sync = true;
};
if !pending.list.is_empty() {
pending.list.retain(|(id, v)| {
if *id != m.id {
return true;
}
add(*v);
false
});
}
if !pending.by_mob.is_empty()
&& let Some(pushes) = pending.by_mob.remove(&m.id)
{
pushes.into_iter().for_each(add);
}
}
/// Applies the brain memories a mob's tick set on other mobs (from event
/// index `from` on), at once: vanilla sets them on the other entity during
/// the tick, before the mobs after it tick.
pub fn apply_brain_events<M: MobSlot>(mobs: &mut [M], events: &mut Vec<MobEvent>, from: usize) {
pub fn apply_brain_events(mobs: &mut [Mob], events: &mut Vec<MobEvent>, from: usize) {
let mut i = from;
while i < events.len() {
match events[i] {
MobEvent::BreedTarget { mob, partner } => {
if let Some(m) = mobs.iter_mut().map(MobSlot::mob_mut).find(|m| m.id == mob) {
if let Some(m) = mobs.iter_mut().find(|m| m.id == mob) {
animal_brain::set_breed_partner(m, partner);
}
events.remove(i);
}
MobEvent::LockGaze { mob, partner } => {
if let Some(m) = mobs.iter_mut().map(MobSlot::mob_mut).find(|m| m.id == mob) {
if let Some(m) = mobs.iter_mut().find(|m| m.id == mob) {
animal_brain::lock_gaze(m, partner);
villager_brain::lock_gaze_half(m, Who::Mob(partner));
}
events.remove(i);
}
MobEvent::Retreat { mob, from, at } => {
if let Some(m) = mobs.iter_mut().map(MobSlot::mob_mut).find(|m| m.id == mob) {
if let Some(m) = mobs.iter_mut().find(|m| m.id == mob) {
piglin::receive_retreat(m, from, at);
}
events.remove(i);
@@ -2989,11 +3029,7 @@ pub fn apply_brain_events<M: MobSlot>(mobs: &mut [M], events: &mut Vec<MobEvent>
events.remove(i);
}
MobEvent::SchoolSize { leader, by } => {
if let Some(m) = mobs
.iter_mut()
.map(MobSlot::mob_mut)
.find(|m| m.id == leader)
{
if let Some(m) = mobs.iter_mut().find(|m| m.id == leader) {
m.extra.followers += by;
}
events.remove(i);
@@ -3003,11 +3039,7 @@ pub fn apply_brain_events<M: MobSlot>(mobs: &mut [M], events: &mut Vec<MobEvent>
MobEvent::Breed {
parent, partner, ..
} => {
if let Some(m) = mobs
.iter_mut()
.map(MobSlot::mob_mut)
.find(|m| m.id == partner)
{
if let Some(m) = mobs.iter_mut().find(|m| m.id == partner) {
m.extra.age = 6000;
m.extra.in_love = 0;
animal_brain::forget_breed_target(m, parent);
@@ -3029,7 +3061,6 @@ pub fn apply_brain_events<M: MobSlot>(mobs: &mut [M], events: &mut Vec<MobEvent>
let mut raised = Vec::new();
let (aim, fish_pos, fish_silent) = mobs
.iter()
.map(MobSlot::mob)
.find(|m| m.id == mob)
.map(|m| {
(
@@ -3044,10 +3075,7 @@ pub fn apply_brain_events<M: MobSlot>(mobs: &mut [M], events: &mut Vec<MobEvent>
})
.map(|(aim, pos, silent)| (Some(aim), Some(pos), silent))
.unwrap_or((None, None, true));
if let Some(t) = mobs
.iter_mut()
.map(MobSlot::mob_mut)
.find(|m| m.id == target)
if let Some(t) = mobs.iter_mut().find(|m| m.id == target)
&& t.damage_aimed(amount, source, aim)
{
t.add_effect("minecraft:poison", poison_ticks, 0);
@@ -3262,9 +3290,6 @@ pub struct MobWorld<'a> {
/// not in the level (a warden's anger, `AngerManagement.angerByUuid`).
/// None: this world keeps no entity UUIDs, so nothing is resolved.
pub who_lookup: Option<&'a dyn WhoLookup>,
/// Another thread's mobs, for a lookup by id that misses `others`: a group of the server's
/// parallel mob loop (`game/mob_islands.rs`) answers as the serial loop would.
pub foreign: Option<&'a dyn ForeignSeen>,
/// The level's world border, whose walls stop a mob's moves
/// (`Entity.collectColliders`); None: nothing stops them there.
pub border: Option<&'a horizon_core::geometry::border::BorderGeometry>,
@@ -3279,10 +3304,6 @@ pub trait ModEffectTicks {
/// names while it is in the level, never some other entity as a stand-in. Only
/// the game layer keeps entity UUIDs, so it answers this (`Game::who_by_uuid`)
/// and the mob layer reaches it through `MobWorld`.
/// A mob outside this thread's part of the mob loop, as the serial loop would see it now.
pub trait ForeignSeen {
fn seen(&self, id: u32) -> Option<&Seen>;
}
pub trait WhoLookup {
fn who_by_uuid(&self, uuid: [i32; 4]) -> Option<Who>;
}
+5 -22
View File
@@ -749,35 +749,18 @@ fn can_be_followed(s: Species, followers: i32) -> bool {
}
/// `startFollowing` on a recruit, applied after the recruiter's tick. A fish
/// another leader took this tick (its snapshot was older) stays with it.
pub fn start_following<M: super::MobSlot>(mobs: &mut [M], fish: u32, leader: u32) {
let alive = |mobs: &[M], id: u32| {
mobs.iter()
.map(super::MobSlot::mob)
.any(|m| m.id == id && m.alive() && !m.removed)
};
let Some(current) = mobs
.iter()
.map(super::MobSlot::mob)
.find(|m| m.id == fish)
.map(|m| m.extra.leader)
else {
pub fn start_following(mobs: &mut [Mob], fish: u32, leader: u32) {
let alive = |mobs: &[Mob], id: u32| mobs.iter().any(|m| m.id == id && m.alive() && !m.removed);
let Some(current) = mobs.iter().find(|m| m.id == fish).map(|m| m.extra.leader) else {
return;
};
if current.is_some_and(|l| alive(mobs, l)) {
return;
}
if let Some(m) = mobs
.iter_mut()
.map(super::MobSlot::mob_mut)
.find(|m| m.id == fish)
{
if let Some(m) = mobs.iter_mut().find(|m| m.id == fish) {
m.extra.leader = Some(leader);
}
if let Some(m) = mobs
.iter_mut()
.map(super::MobSlot::mob_mut)
.find(|m| m.id == leader)
{
if let Some(m) = mobs.iter_mut().find(|m| m.id == leader) {
m.extra.followers += 1;
}
}
+1 -1
View File
@@ -21,7 +21,7 @@ pub struct Cx<'a, 'w> {
pub lr: &'a mut LegacyRandom,
pub events: &'a mut Vec<MobEvent>,
}
pub trait Goal: std::fmt::Debug + Send + Sync {
pub trait Goal: std::fmt::Debug {
fn flags(&self) -> u8;
/// A goal that draws its first start delay in its constructor (a schooling fish's
/// `FollowFlockLeaderGoal`): the delay again, when the mob's random is seeded afterwards.
@@ -174,7 +174,6 @@ fn a_mob_bounces_off_a_bouncy_block_and_not_off_stone() {
dragon: None,
leash_boats: &[],
who_lookup: None,
foreign: None,
others_grid: None,
others_index: None,
others_villagers: None,
@@ -292,7 +291,6 @@ fn a_ridden_horse_floats_in_deep_water() {
dragon: None,
leash_boats: &[],
who_lookup: None,
foreign: None,
others_grid: None,
others_index: None,
others_villagers: None,
@@ -413,7 +411,6 @@ fn a_falling_mob_lands_on_a_shulker_or_a_held_happy_ghast() {
nearest_player: None,
others: &others,
who_lookup: None,
foreign: None,
dragon: None,
leash_boats: &[],
others_grid: None,
@@ -1,29 +0,0 @@
//! [`MobSlot`]: a mob a list hands out.
use super::Mob;
/// A mob a list hands out: the list's own, or a borrow of one in a larger list (a group of the
/// server's parallel mob loop, `game/mob_islands.rs`, works on its mobs in place).
pub trait MobSlot {
fn mob(&self) -> &Mob;
fn mob_mut(&mut self) -> &mut Mob;
}
impl MobSlot for Mob {
#[inline]
fn mob(&self) -> &Mob {
self
}
#[inline]
fn mob_mut(&mut self) -> &mut Mob {
self
}
}
impl MobSlot for &mut Mob {
#[inline]
fn mob(&self) -> &Mob {
self
}
#[inline]
fn mob_mut(&mut self) -> &mut Mob {
self
}
}
@@ -1,103 +0,0 @@
//! The pushes a mob's tick gives others, waiting for each one's own tick.
use super::Mob;
use horizon_core::geometry::Vec3;
/// Pushes waiting for their mob's tick, in push order. A list while short;
/// past [`PendingPushes::LIST_MAX`] (a crowd pushing each other) one list
/// per mob, as scanning the whole list for each mob grows with its square.
#[derive(Default)]
pub struct PendingPushes {
list: Vec<(u32, Vec3)>,
by_mob: crate::fast_hash::FxHashMap<u32, Vec<Vec3>>,
}
impl PendingPushes {
const LIST_MAX: usize = 64;
/// `other`'s pushes after these, each mob's in their order.
pub fn append(&mut self, other: PendingPushes) {
for (id, v) in other.list {
self.push(id, v);
}
for (id, list) in other.by_mob {
for v in list {
self.push(id, v);
}
}
}
pub fn push(&mut self, id: u32, v: Vec3) {
if self.by_mob.is_empty() && self.list.len() < Self::LIST_MAX {
self.list.push((id, v));
return;
}
// Older pushes first, so each mob's stay in order.
for (id, v) in self.list.drain(..) {
self.by_mob.entry(id).or_default().push(v);
}
self.by_mob.entry(id).or_default().push(v);
}
}
/// Adds the pending pushes aimed at `m` to its velocity, in push order.
pub fn apply_mob_pushes(m: &mut Mob, pending: &mut PendingPushes) {
// Entity.push(x, y, z) sets needsSync.
let mut add = |v: Vec3| {
m.velocity = Vec3::new(m.velocity.x + v.x, m.velocity.y + v.y, m.velocity.z + v.z);
m.needs_sync = true;
};
if !pending.list.is_empty() {
pending.list.retain(|(id, v)| {
if *id != m.id {
return true;
}
add(*v);
false
});
}
if !pending.by_mob.is_empty()
&& let Some(pushes) = pending.by_mob.remove(&m.id)
{
pushes.into_iter().for_each(add);
}
}
#[cfg(test)]
mod tests {
use super::PendingPushes;
use horizon_core::geometry::Vec3;
/// Each mob's pushes, in the order they apply.
fn per_mob(p: &PendingPushes) -> std::collections::BTreeMap<u32, Vec<[f64; 3]>> {
let mut out = std::collections::BTreeMap::<u32, Vec<[f64; 3]>>::new();
for (id, v) in &p.list {
out.entry(*id).or_default().push([v.x, v.y, v.z]);
}
for (id, list) in &p.by_mob {
out.entry(*id)
.or_default()
.extend(list.iter().map(|v| [v.x, v.y, v.z]));
}
out
}
/// Two lists of pushes, the second appended to the first, apply as one list of them all in
/// that order, on either side of the switch from one list to a list per mob.
#[test]
fn appended_pushes_apply_in_order() {
let pushes: Vec<(u32, Vec3)> = (0..200u32)
.map(|k| (k * 7 % 11, Vec3::new(f64::from(k), 0.0, -f64::from(k))))
.collect();
let mut all = PendingPushes::default();
for &(id, v) in &pushes {
all.push(id, v);
}
for split in [0, 30, 64, 70, 150, 200] {
let (mut first, mut second) = (PendingPushes::default(), PendingPushes::default());
for &(id, v) in &pushes[..split] {
first.push(id, v);
}
for &(id, v) in &pushes[split..] {
second.push(id, v);
}
first.append(second);
assert_eq!(per_mob(&first), per_mob(&all), "split at {split}");
}
}
}
+17 -40
View File
@@ -186,23 +186,16 @@ pub fn seat(attachment: [f64; 3], rider_offset: f64, at: Vec3, yaw: f32) -> Vec3
/// `Entity.startRiding`: the link it makes, `rider` on `vehicle` as its first
/// passenger (`addPassenger`).
pub fn link<M: super::MobSlot>(mobs: &mut [M], rider: u32, vehicle: u32) {
pub fn link(mobs: &mut [Mob], rider: u32, vehicle: u32) {
if rider == vehicle {
return;
}
if let Some(m) = mobs
.iter_mut()
.map(super::MobSlot::mob_mut)
.find(|m| m.id == rider)
{
if let Some(m) = mobs.iter_mut().find(|m| m.id == rider) {
super::shulker::on_start_riding(m);
m.extra.mount = Some(vehicle);
m.extra.passenger = true;
}
if let Some(m) = mobs
.iter_mut()
.map(super::MobSlot::mob_mut)
.find(|m| m.id == vehicle)
if let Some(m) = mobs.iter_mut().find(|m| m.id == vehicle)
&& m.extra.rider.is_none()
{
m.extra.rider = Some(rider);
@@ -220,10 +213,9 @@ pub fn can_add_passenger(v: &Mob, aboard: usize) -> bool {
}
/// `Piglin.getTopPassenger`: down the chain of first passengers from
/// `vehicle`, stopping at the `counter`th seat.
fn top_passenger<M: super::MobSlot>(mobs: &[M], vehicle: u32, counter: i32) -> u32 {
fn top_passenger(mobs: &[Mob], vehicle: u32, counter: i32) -> u32 {
let first = mobs
.iter()
.map(super::MobSlot::mob)
.find(|m| m.id == vehicle)
.and_then(|m| m.extra.rider);
match first {
@@ -236,22 +228,17 @@ fn top_passenger<M: super::MobSlot>(mobs: &[M], vehicle: u32, counter: i32) -> u
/// Not the vehicle it is on already, nor one it carries itself, and only
/// after its boarding cooldown, onto a vehicle with no passenger
/// (`canRide`, `canAddPassenger`). Returns whether it boarded.
pub fn start_riding<M: super::MobSlot>(mobs: &mut [M], rider: u32, vehicle: u32) -> bool {
pub fn start_riding(mobs: &mut [Mob], rider: u32, vehicle: u32) -> bool {
// `Shulker.startRiding` puts its attach face back before the ride is
// decided, so a shulker that is refused still ends up lid up.
if let Some(m) = mobs
.iter_mut()
.map(super::MobSlot::mob_mut)
.find(|m| m.id == rider)
{
if let Some(m) = mobs.iter_mut().find(|m| m.id == rider) {
super::shulker::on_start_riding(m);
}
let Some(r) = mobs.iter().map(super::MobSlot::mob).find(|m| m.id == rider) else {
let Some(r) = mobs.iter().find(|m| m.id == rider) else {
return false;
};
let on_hoglin = mobs
.iter()
.map(super::MobSlot::mob)
.any(|m| m.id == vehicle && m.species() == Species::Hoglin);
let vehicle = if r.species() == Species::Piglin && r.is_baby() && on_hoglin {
top_passenger(mobs, vehicle, MAX_PASSENGERS_ON_ONE_HOGLIN)
@@ -265,26 +252,17 @@ pub fn start_riding<M: super::MobSlot>(mobs: &mut [M], rider: u32, vehicle: u32)
// its vehicle.
let mut up = Some(vehicle);
while let Some(v) = up {
up = mobs
.iter()
.map(super::MobSlot::mob)
.find(|m| m.id == v)
.and_then(|m| m.extra.mount);
up = mobs.iter().find(|m| m.id == v).and_then(|m| m.extra.mount);
if up == Some(rider) {
return false;
}
}
let Some(v) = mobs
.iter()
.map(super::MobSlot::mob)
.find(|m| m.id == vehicle)
else {
let Some(v) = mobs.iter().find(|m| m.id == vehicle) else {
return false;
};
let count = usize::from(v.rider.is_some())
+ mobs
.iter()
.map(super::MobSlot::mob)
.filter(|m| m.extra.mount == Some(vehicle))
.count()
.max(usize::from(v.extra.rider.is_some()));
@@ -492,26 +470,25 @@ pub fn solve_body_rot(head: f32, vehicle_body: f32) -> f32 {
/// Tick a passenger with Mob.getNavigation's recursive vehicle redirection.
/// Navigation is lent, not copied: goal start/stop/path progress all address
/// the same object the vehicle ticks. The pathfinder sees the vehicle body.
pub fn tick<M: super::MobSlot>(
mobs: &mut [M],
pub fn tick(
mobs: &mut [Mob],
index: usize,
w: &MobWorld,
random: &mut horizon_core::random::LegacyRandom,
events: &mut Vec<super::MobEvent>,
) -> bool {
let mut owner = index;
if !mobs[index].mob().no_ai {
if !mobs[index].no_ai {
for _ in 0..mobs.len() {
let Some(vehicle) = mobs[owner]
.mob()
.extra
.mount
.and_then(|id| mobs.iter().position(|m| m.mob().id == id))
.and_then(|id| mobs.iter().position(|m| m.id == id))
else {
break;
};
if vehicle == index
|| controller(mobs[vehicle].mob(), w).is_none_or(|r| r.id != mobs[owner].mob().id)
|| controller(&mobs[vehicle], w).is_none_or(|r| r.id != mobs[owner].id)
{
break;
}
@@ -519,14 +496,14 @@ pub fn tick<M: super::MobSlot>(
}
}
if owner == index {
return mobs[index].mob_mut().tick(w, random, events);
return mobs[index].tick(w, random, events);
}
let (rider, vehicle) = if index < owner {
let (left, right) = mobs.split_at_mut(owner);
(left[index].mob_mut(), right[0].mob_mut())
(&mut left[index], &mut right[0])
} else {
let (left, right) = mobs.split_at_mut(index);
(right[0].mob_mut(), left[owner].mob_mut())
(&mut right[0], &mut left[owner])
};
let body = vehicle.path_mob();
std::mem::swap(&mut rider.ai.nav, &mut vehicle.ai.nav);
+1 -62
View File
@@ -15,9 +15,6 @@ pub struct SeenGrid {
/// The widest box (along x or z) the grid has held: no box reaches
/// further than this past its minimum corner.
reach: f64,
/// The entries a query counts when it decides to scan them all instead (at least the grid's
/// own): a grid of part of a list answers as the whole list's would.
entries: usize,
}
fn column(x: f64, z: f64) -> (i32, i32) {
@@ -42,22 +39,6 @@ impl SeenGrid {
}
grid
}
/// The widest box and the entries it has, for a grid of part of its entries that must meet
/// the same entries in the same order (the server's parallel mob tick).
pub fn bounds(&self) -> (f64, usize) {
(self.reach, self.cell_of.len().max(self.entries))
}
/// [`Self::bounds`] of the grid of `boxes`, without building it.
pub fn bounds_of(boxes: impl Iterator<Item = Aabb>) -> (f64, usize) {
boxes.fold((0.0_f64, 0), |(reach, n), b| (reach.max(width(b)), n + 1))
}
/// Answer queries as a grid of `bounds` would: as wide a margin, and a scan past as many
/// entries.
pub fn answering_as(mut self, (reach, entries): (f64, usize)) -> Self {
self.reach = self.reach.max(reach);
self.entries = entries;
self
}
/// Entry `i`'s box is now `b` (the mob moved or resized).
pub fn update(&mut self, i: usize, b: Aabb) {
self.reach = self.reach.max(width(b));
@@ -97,7 +78,7 @@ impl SeenGrid {
let hi = column(max_x, max_z);
let span = |a: i32, b: i32| i64::from(b) - i64::from(a) + 1;
let (nx, nz) = (span(lo.0, hi.0), span(lo.1, hi.1));
let max_cells = (self.cell_of.len().max(self.entries) as i64).max(64);
let max_cells = (self.cell_of.len() as i64).max(64);
if nx < 1 || nz < 1 || nx.saturating_mul(nz) > max_cells {
return false;
}
@@ -356,46 +337,4 @@ mod tests {
rounds * n
);
}
/// A grid of part of a list's boxes that answers as the whole list's grid decides when to scan
/// every entry and when to look in columns: the part meets its boxes as the whole would.
#[test]
fn a_part_answers_as_the_whole_list() {
let mut seed = 0x9e37_79b9_7f4a_7c15u64;
let mut next = |range: f64| {
seed = seed.wrapping_mul(6_364_136_223_846_793_005).wrapping_add(1);
(seed >> 11) as f64 / (1u64 << 53) as f64 * range
};
let boxes: Vec<Aabb> = (0..400)
.map(|_| boxed(next(400.0) - 200.0, next(8.0), next(400.0) - 200.0, 0.6))
.collect();
let whole = SeenGrid::new(boxes.iter().copied());
assert_eq!(SeenGrid::bounds_of(boxes.iter().copied()), whole.bounds());
let part: Vec<usize> = (0..boxes.len()).step_by(4).collect();
let grid = SeenGrid::new(part.iter().map(|&i| boxes[i])).answering_as(whole.bounds());
let (mut a, mut b) = (Vec::new(), Vec::new());
let mut scanned = 0;
for _ in 0..500 {
let side = next(100.0);
let area = boxed(next(400.0) - 200.0, next(8.0), next(400.0) - 200.0, side);
let indexed = whole.candidates(area, &mut a);
assert_eq!(grid.candidates(area, &mut b), indexed, "{side} blocks");
scanned += usize::from(!indexed);
if indexed {
let met = |out: &[u32]| -> Vec<usize> {
out.iter()
.map(|&j| part[j as usize])
.filter(|&i| boxes[i].intersects(area))
.collect()
};
let expected: Vec<usize> = a
.iter()
.map(|&j| j as usize)
.filter(|i| i % 4 == 0 && boxes[*i].intersects(area))
.collect();
assert_eq!(met(&b), expected);
}
}
assert!(scanned > 0 && scanned < 500, "{scanned} of 500 scanned");
}
}
+2 -4
View File
@@ -604,14 +604,13 @@ impl MobWorld<'_> {
}
}
pub fn seen(&self, id: u32) -> Option<&Seen> {
let own = match self.others_index {
match self.others_index {
Some(index) => index
.get(&id)
.and_then(|&i| self.others.get(i as usize))
.filter(|s| s.id == id),
None => self.others.iter().find(|s| s.id == id),
};
own.or_else(|| self.foreign?.seen(id))
}
}
/// Living mobs of the given species within `range` of `from`, nearest first.
pub fn nearby<'s>(
@@ -774,7 +773,6 @@ pub(crate) fn with_resource_test_world(
dragon: None,
leash_boats: &[],
who_lookup: None,
foreign: None,
others_grid: None,
others_index: None,
others_villagers: None,
@@ -261,7 +261,6 @@ mod tests {
dragon: None,
leash_boats: &[],
who_lookup: None,
foreign: None,
others_grid: None,
others_index: None,
others_villagers: 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(" "),
);
}
-146
View File
@@ -1,146 +0,0 @@
//! The helper threads of the server tick's parallel phases (`HORIZON_PARALLEL_TICK`): one pool, made
//! once, instead of threads started for every phase of every tick. A phase hands it work that gives
//! the same result in whatever order the threads take it, and each part's results stay in order.
use std::sync::atomic::{AtomicUsize, Ordering};
use std::sync::{Mutex, OnceLock, PoisonError};
/// `HORIZON_PARALLEL_TICK`: off when unset (0). `1` turns the server tick's parallel phases on (1),
/// and `verify` does too (2), also checking the phases that can be checked against the serial code.
/// Each phase gives the serial result either way.
pub fn parallel_tick() -> u8 {
static ON: OnceLock<u8> = OnceLock::new();
*ON.get_or_init(|| match std::env::var("HORIZON_PARALLEL_TICK").as_deref() {
Ok("1" | "on" | "true") => 1,
Ok("verify") => 2,
_ => 0,
})
}
/// How many threads a phase may share its work among: `horizon-threads`' count (at most 8) while
/// the parallel tick is on, else 1.
pub fn lanes() -> usize {
if parallel_tick() > 0 {
horizon_threads::worker_count().clamp(1, 8)
} else {
1
}
}
/// The pool: one thread fewer than a phase's lanes (the server thread takes parts too).
fn pool() -> &'static rayon::ThreadPool {
static POOL: OnceLock<rayon::ThreadPool> = OnceLock::new();
POOL.get_or_init(|| {
rayon::ThreadPoolBuilder::new()
.num_threads(
horizon_threads::worker_count()
.clamp(1, 8)
.saturating_sub(1)
.max(1),
)
.thread_name(|i| format!("tick-helper-{i}"))
.build()
.expect("the tick's helper threads start")
})
}
/// `f` over `items` in parts on the pool, or all on this thread while the parallel tick is off or
/// for fewer than `min` items: every part's results, in the order of `items`, as one thread would
/// give them.
pub fn par_map<T: Sync, R: Send>(
items: &[T],
min: usize,
f: impl Fn(&[T], &mut Vec<R>) + Sync,
) -> Vec<R> {
par_map_on(items, min, lanes(), f)
}
/// [`par_map`] on `lanes` threads.
///
/// The parts are small (four a thread) and go to whichever thread is free, this one too: a helper
/// the machine is slow to run (the chunk workers compete for the same cores) holds the phase up by
/// one part at most instead of an eighth of the work, and the server thread works instead of only
/// waiting.
fn par_map_on<T: Sync, R: Send>(
items: &[T],
min: usize,
lanes: usize,
f: impl Fn(&[T], &mut Vec<R>) + Sync,
) -> Vec<R> {
if lanes <= 1 || items.len() < min.max(2) {
let mut out = Vec::with_capacity(items.len());
f(items, &mut out);
return out;
}
let size = items.len().div_ceil(lanes * 4);
let count = items.len().div_ceil(size);
let next = AtomicUsize::new(0);
let slots: Vec<Mutex<Vec<R>>> = (0..count).map(|_| Mutex::new(Vec::new())).collect();
let work = || {
loop {
let k = next.fetch_add(1, Ordering::Relaxed);
if k >= count {
break;
}
let part = &items[k * size..((k + 1) * size).min(items.len())];
let mut done = Vec::with_capacity(part.len());
f(part, &mut done);
*slots[k].lock().unwrap_or_else(PoisonError::into_inner) = done;
}
};
pool().in_place_scope(|s| {
for _ in 1..lanes {
s.spawn(|_| work());
}
work();
});
let mut out = Vec::with_capacity(items.len());
for slot in slots {
out.extend(slot.into_inner().unwrap_or_else(PoisonError::into_inner));
}
out
}
#[cfg(test)]
mod tests {
use super::*;
/// On several threads, every item's result comes back once, in the items' order, for item
/// counts that don't divide into the parts evenly; a part may give several results or none.
#[test]
fn parts_come_back_in_order() {
for n in [0usize, 1, 7, 33, 1000, 4097] {
let items: Vec<u64> = (0..n as u64).collect();
let f = |part: &[u64], out: &mut Vec<u64>| {
for &x in part {
// Spin a little so the threads overlap.
let mut h = x;
for _ in 0..200 {
h = h.wrapping_mul(6364136223846793005).wrapping_add(1);
}
if x % 3 != 0 {
out.push(x * 10);
}
if x % 5 == 0 {
out.push(h);
}
}
};
let mut serial = Vec::new();
f(&items, &mut serial);
assert_eq!(par_map_on(&items, 1, 4, f), serial, "{n} items");
}
}
/// A panic in a part reaches the caller instead of being lost on a helper.
#[test]
fn a_panic_reaches_the_caller() {
let items: Vec<u32> = (0..256).collect();
let caught = std::panic::catch_unwind(|| {
par_map_on(&items, 1, 4, |part, out: &mut Vec<u32>| {
assert!(!part.contains(&200), "item 200");
out.extend(part);
})
});
assert!(caught.is_err());
}
}
@@ -117,44 +117,6 @@ impl RandomTickSections {
});
Some((y, mask.clone()))
}
/// Builds, on the tick's helper threads (`crate::parallel`), the masks [`Self::next_masked`]
/// will build at the first draw in each ticking section of the chunks `keys` names, from their
/// blocks now (`chunk_of`), so the random tick loop finds them built. A mask only tells the loop
/// which drawn blocks to read: the draws and the ticks are the same either way, and an edit
/// before the loop reaches a section drops its mask as usual.
pub fn prepare_masks(
&mut self,
keys: &[(i32, i32)],
chunk_of: impl Fn((i32, i32)) -> Option<Arc<Chunk>>,
facts: &BlockFacts,
candidate: &(impl Fn(u32) -> bool + Sync),
) {
let mut todo: Vec<((i32, i32), usize, Arc<Chunk>)> = Vec::new();
for &key in keys {
let Some(chunk) = chunk_of(key) else {
continue;
};
let cached = Self::refresh(&mut self.chunks, key, &chunk, &mut |id| {
facts.get(BlockStateId(id)).random_ticks
});
for i in cached.sections.mask.iter() {
if cached.masks.get(i).is_some_and(Option::is_none) {
todo.push((key, i, Arc::clone(&chunk)));
}
}
}
let masks = crate::parallel::par_map(&todo, 128, |part, out| {
out.extend(
(part.iter())
.map(|(_, i, chunk)| Self::candidate_mask(&chunk.sections[*i], candidate)),
);
});
for ((key, i, _), mask) in todo.into_iter().zip(masks) {
if let Some(cached) = self.chunks.get_mut(&key) {
cached.masks[i] = Some(Rc::new(mask));
}
}
}
fn candidate_mask(section: &Section, mut candidate: impl FnMut(u32) -> bool) -> RandomTickMask {
let blocks = &section.blocks;
let mut ids = [0u16; 4096];
@@ -519,41 +481,25 @@ mod tick_parity {
time: 0,
speed: 3,
};
// More than 128 ticking sections: prepare_masks must exercise its
// helper path when HORIZON_PARALLEL_TICK is enabled, not just defer work.
for x in 2..=65 {
original.chunks.insert((x, 0), chunk(x));
}
Arc::make_mut(original.chunks.get_mut(&(1, 0)).unwrap()).lit = true; // precipitation-only chunk
let mut cached = original.clone();
// The masked path reads only the drawn blocks that can tick.
let mut masked = original.clone();
// The masked path again, its masks built ahead of each tick (`prepare_masks`).
let mut prepared = original.clone();
let mut prep_cache = RandomTickSections::default();
let mut d = Ticks::new(12345);
let mut cache = RandomTickSections::default();
let mut mask_cache = RandomTickSections::default();
let (mut a, mut b, mut c) = (Ticks::new(12345), Ticks::new(12345), Ticks::new(12345));
for x in -1..=65 {
for x in -1..=1 {
a.add_empty_chunk(ChunkPos::new(x, 0)).unwrap();
b.add_empty_chunk(ChunkPos::new(x, 0)).unwrap();
c.add_empty_chunk(ChunkPos::new(x, 0)).unwrap();
d.add_empty_chunk(ChunkPos::new(x, 0)).unwrap();
}
for step in 0..128 {
for level in [&mut original, &mut cached, &mut masked, &mut prepared] {
for level in [&mut original, &mut cached, &mut masked] {
level.time = step;
level.speed = [0, 1, 3, 9][step as usize % 4];
level.reads.borrow_mut().clear();
level.events.clear();
match step {
4 => {
// The large fixture has exercised every tick speed and
// helper-mask invalidation; keep the remaining edit,
// unload and reload cases small as before.
level.chunks.retain(|&(x, _), _| x <= 1);
}
12 => {
level.chunks.remove(&(-1, 0));
}
@@ -582,17 +528,15 @@ mod tick_parity {
if step == 12 {
cache.remove(&(-1, 0));
mask_cache.remove(&(-1, 0));
prep_cache.remove(&(-1, 0));
}
if step == 50 || step == 60 {
facts.states[grass.0 as usize].random_ticks = step == 60;
cache.clear();
mask_cache.clear();
prep_cache.clear();
}
// A write between ticks.
if step == 20 {
for level in [&mut original, &mut cached, &mut masked, &mut prepared] {
for level in [&mut original, &mut cached, &mut masked] {
level.set_raw(BlockPos::new(0, MIN_Y + 16, 0), grass);
}
}
@@ -645,55 +589,6 @@ mod tick_parity {
};
behaviours.tick_chunk_random_masked(&mut masked, &mut c, ChunkPos::new(x, z), next);
}
let keys = lit(&prepared);
let candidate = |id| behaviours.random_tick_candidate(BlockStateId(id));
prep_cache.prepare_masks(
&keys,
|key| prepared.chunks.get(&key).cloned(),
&facts,
&candidate,
);
for &(x, z) in &keys {
// Every ticking section's mask is there before the tick asks.
let now = prepared.chunks[&(x, z)].clone();
let mut from = MIN_Y;
while let Some((y, _)) = prep_cache.next_masked(
(x, z),
&now,
&facts,
|_| unreachable!("built ahead"),
from,
) {
from = y + 16;
}
}
for (x, z) in keys {
let next = |level: &mut Level, from| {
let now = level.chunks[&(x, z)].clone();
prep_cache
.next_masked((x, z), &now, &facts, candidate, from)
.map(|(y, mask)| (y, Some(mask)))
};
behaviours.tick_chunk_random_masked(
&mut prepared,
&mut d,
ChunkPos::new(x, z),
next,
);
}
assert_eq!(
original.events, prepared.events,
"prepared events at tick {step}"
);
assert_eq!(
a.random_tick_value, d.random_tick_value,
"prepared position RNG at tick {step}"
);
assert_eq!(
original.random.clone().next_long(),
prepared.random.next_long(),
"prepared level RNG at tick {step}"
);
assert_eq!(original.reads, cached.reads, "ordered reads at tick {step}");
assert_eq!(
original.events, masked.events,
@@ -734,9 +629,6 @@ mod tick_parity {
for (s, t) in c.sections.iter().zip(&masked.chunks[key].sections) {
assert_eq!(s.blocks, t.blocks);
}
for (s, t) in c.sections.iter().zip(&prepared.chunks[key].sections) {
assert_eq!(s.blocks, t.blocks);
}
}
}
}
@@ -408,7 +408,7 @@ impl TimelineUi {
let tx = start_x + sec * px_per_sec;
if tx <= start_x + w {
// Major tick line
style::fill(batch, atlas, [tx, y + 8.0, 1.0, 8.0], style::HEAD_RULE);
style::fill(batch, atlas, [tx, y + 8.0, 1.0, 8.0], style::head_rule());
// Label every 2 or 5 seconds depending on width
let is_labeled = if total_secs > 60.0 {
(sec as u32).is_multiple_of(10)
@@ -424,7 +424,7 @@ impl TimelineUi {
"horizon.studio.timeline.seconds",
&[Text::literal(seconds)],
);
batch.text_styled(atlas, &label, tx + 2.0, y + 1.0, style::SOFT, true);
batch.text_styled(atlas, &label, tx + 2.0, y + 1.0, style::soft(), true);
}
}
sec += sec_step;
@@ -459,7 +459,7 @@ impl TimelineUi {
// Base lane well
let lane_rect = [start_x, y, track_w, bar_h];
style::fill(batch, atlas, lane_rect, 0xFF14_171F);
style::ring(batch, atlas, lane_rect, style::ROW_RULE);
style::ring(batch, atlas, lane_rect, style::row_rule());
let total_dur = self.duration_ms.max(1) as f32;
@@ -514,7 +514,7 @@ impl TimelineUi {
&scene_title,
seg_x + 5.0,
y + 5.0,
style::MUTED,
style::muted(),
true,
);
}
@@ -577,7 +577,7 @@ impl TimelineUi {
let px = start_x + norm * track_w;
// Scrubber line from ruler through bottom of tracks
style::fill(batch, atlas, [px - 1.0, y, 2.0, h], style::ACCENT);
style::fill(batch, atlas, [px - 1.0, y, 2.0, h], style::accent());
// Inverted triangle marker at ruler top
let tri_color = style::WHITE;
+8 -4
View File
@@ -178,7 +178,7 @@ impl TopBarUi {
);
}
let color = if open {
style::ACCENT
style::accent()
} else if hovered {
style::WHITE
} else {
@@ -220,7 +220,7 @@ impl TopBarUi {
if pause_hovered {
style::WHITE
} else {
style::MUTED
style::muted()
},
true,
);
@@ -339,7 +339,11 @@ impl TopBarUi {
&Text::translate(key, &[]),
x + 8.0,
y + 6.0,
if hovered { style::WHITE } else { style::MUTED },
if hovered {
style::WHITE
} else {
style::muted()
},
true,
);
if !shortcut.is_empty() {
@@ -350,7 +354,7 @@ impl TopBarUi {
&shortcut,
x + menu_width - shortcut_width - 8.0,
y + 6.0,
style::SOFT,
style::soft(),
true,
);
}
+111 -46
View File
@@ -1,6 +1,6 @@
//! The design system's widgets drawn in the game (`crates/horizon-theme`:
//! palette, type scale, radii, control sizes, motion and the bundled fonts;
//! `docs/design-system.md`). Every menu is vanilla's (`ui`); what is left
//! The design system's widgets drawn in the game: palette, type scale, radii,
//! control sizes and fonts from `menu_style`, given at startup (in the official
//! game `crates/horizon-theme`, `docs/design-system.md`). Every menu is vanilla's (`ui`); what is left
//! here draws Horizon's own overlays that vanilla has no screen for: the
//! on-screen keyboard a controller types with (`osk`), plus the
//! `horizon.txt` preferences.
@@ -39,44 +39,76 @@ pub(crate) fn set_preference(dir: &Path, key: &str, value: Option<&str>) {
let _ = std::fs::write(dir.join("horizon.txt"), out);
}
/// The dark palette (`horizon_theme::DARK`) in the HUD's colour encoding, plus the
/// The overlays' palette (`menu_style`) in the HUD's colour encoding, plus the
/// translucent plates the overlays draw over the game.
pub(crate) mod color {
use horizon_theme::DARK as P;
pub const fn hex(c: u32, a: u8) -> u32 {
(a as u32) << 24 | (c & 0xff) << 16 | (c >> 8 & 0xff) << 8 | (c >> 16 & 0xff)
use horizon_client_render::menu_style::{self, OverlayColours};
pub use menu_style::hud_colour as hex;
fn p() -> &'static OverlayColours {
&menu_style::get().overlay
}
/// The page, under every veil.
pub const BG: u32 = hex(P.page, 255);
pub fn bg() -> u32 {
hex(p().page, 255)
}
/// Hover plates, secondary buttons, fields, the segmented track: the
/// dark palette's soft plate, as ink over the veil so the plates read the
/// same over any backdrop (over the page colour they are `plate_soft`).
pub const RAISED: u32 = hex(P.ink, 20);
pub fn raised() -> u32 {
hex(p().ink, 20)
}
/// Pressed plates and a switch that is off (`plate`, the same way).
pub const HOVER: u32 = hex(P.ink, 36);
pub fn hover() -> u32 {
hex(p().ink, 36)
}
/// One-pixel rules between rows, slider tracks.
pub const BORDER: u32 = hex(P.rule, 255);
pub fn border() -> u32 {
hex(p().rule, 255)
}
/// Text and selection (`ink`).
pub const TEXT: u32 = hex(P.ink, 255);
pub fn text() -> u32 {
hex(p().ink, 255)
}
/// Values and secondary text (`ink_soft`).
pub const MUTED: u32 = hex(P.ink_soft, 255);
pub const ACCENT: u32 = hex(P.accent, 255);
pub const ON_ACCENT: u32 = hex(P.on_accent, 255);
pub fn muted() -> u32 {
hex(p().ink_soft, 255)
}
pub fn accent() -> u32 {
hex(p().accent, 255)
}
pub fn on_accent() -> u32 {
hex(p().on_accent, 255)
}
pub(crate) fn with_alpha(c: u32, a: f32) -> u32 {
let base = (c >> 24) as f32;
(c & 0x00ff_ffff) | ((base * a.clamp(0.0, 1.0)).round() as u32) << 24
}
}
/// The launcher's type scale and shapes (`horizon_theme`), in dp.
pub(crate) const SMALL: f32 = horizon_theme::CAPTION.px;
pub(crate) const BODY: f32 = horizon_theme::LABEL.px;
/// The boot screen's wordmark (`DISPLAY`) and console lines (`MICRO`).
pub(crate) const DISPLAY: f32 = horizon_theme::DISPLAY.px;
pub(crate) const MICRO: f32 = horizon_theme::MICRO.px;
/// Fields; plates (hover rows, selection); pills (buttons, segments).
pub(crate) const RADIUS: f32 = horizon_theme::RADIUS;
pub(crate) const RADIUS_PLATE: f32 = horizon_theme::RADIUS_PLATE;
/// The type scale and shapes (`menu_style`), in dp.
pub(crate) fn sizes() -> &'static horizon_client_render::menu_style::Sizes {
&horizon_client_render::menu_style::get().sizes
}
pub(crate) fn small() -> f32 {
sizes().caption
}
pub(crate) fn body() -> f32 {
sizes().label
}
/// The boot screen's wordmark (`display`) and console lines (`micro`).
fn display() -> f32 {
sizes().display
}
fn micro() -> f32 {
sizes().micro
}
/// Fields; plates (hover rows, selection).
pub(crate) fn radius() -> f32 {
sizes().radius
}
pub(crate) fn radius_plate() -> f32 {
sizes().radius_plate
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
pub(crate) enum Face {
@@ -87,20 +119,34 @@ pub(crate) enum Face {
/// Counts, timestamps and the boot screen's console lines.
Mono,
}
#[cfg(not(test))]
const TEST_FONTS: Option<horizon_client_render::menu_style::Fonts> = None;
#[cfg(test)]
const TEST_FONTS: Option<horizon_client_render::menu_style::Fonts> =
Some(horizon_client_render::menu_style::Fonts {
regular: horizon_theme::fonts::GEIST_REGULAR,
medium: horizon_theme::fonts::GEIST_MEDIUM,
semibold: horizon_theme::fonts::GEIST_SEMIBOLD,
mono: horizon_theme::fonts::GEIST_MONO,
});
struct Fonts {
faces: [FontRef<'static>; 4],
}
impl Fonts {
fn load() -> Self {
let f = |b: &'static [u8]| FontRef::try_from_slice(b).expect("bundled font");
Self {
/// The faces given in `menu_style`; none given, none (unit tests take the design system's).
fn load() -> Option<Self> {
let given = horizon_client_render::menu_style::get()
.fonts
.or(TEST_FONTS)?;
let f = |b: &'static [u8]| FontRef::try_from_slice(b).expect("given font");
Some(Self {
faces: [
f(horizon_theme::fonts::GEIST_REGULAR),
f(horizon_theme::fonts::GEIST_MEDIUM),
f(horizon_theme::fonts::GEIST_SEMIBOLD),
f(horizon_theme::fonts::GEIST_MONO),
f(given.regular),
f(given.medium),
f(given.semibold),
f(given.mono),
],
}
})
}
fn face(&self, f: Face) -> &FontRef<'static> {
&self.faces[f as usize]
@@ -142,7 +188,8 @@ struct Cached {
}
/// Glyphs and corner masks rasterized into the atlas region `horizon/ui-text`.
struct Cache {
fonts: Fonts,
/// None: no fonts were given, and text draws nothing.
fonts: Option<Fonts>,
entries: HashMap<Key, Cached>,
shelf: (u32, u32, u32),
pending: Vec<crate::font::Patch>,
@@ -230,9 +277,16 @@ impl Cache {
if let Some(c) = self.entries.get(&key) {
return *c;
}
let scale = self.fonts.scale(face, f32::from(size) / 4.0);
let Some(fonts) = &self.fonts else {
return Cached {
rect: None,
left: 0.0,
top: 0.0,
};
};
let scale = fonts.scale(face, f32::from(size) / 4.0);
let glyph = id.with_scale_and_position(scale, ab_glyph::point(0.0, 0.0));
let made = match self.fonts.face(face).outline_glyph(glyph) {
let made = match fonts.face(face).outline_glyph(glyph) {
Some(outline) => {
let b = outline.px_bounds();
let (w, h) = (b.width() as u32, b.height() as u32);
@@ -459,14 +513,17 @@ impl<'a> Ui<'a> {
let atlas = self.atlas;
let gui = self.scale;
with_cache(atlas, |c| {
let scale = c.fonts.scale(face, px);
let Some(fonts) = &c.fonts else {
return (0.0, 0.0, 0.0, Vec::new());
};
let scale = fonts.scale(face, px);
let (ascent, descent) = {
let f = c.fonts.face(face).as_scaled(scale);
let f = fonts.face(face).as_scaled(scale);
(f.ascent(), f.descent())
};
let chars: Vec<(char, Option<crate::font::Glyph>)> = unformatted(text)
.map(|ch| {
let pack = (!ch.is_whitespace() && c.fonts.face(face).glyph_id(ch).0 == 0)
let pack = (!ch.is_whitespace() && fonts.face(face).glyph_id(ch).0 == 0)
.then(|| atlas.font.glyph(ch))
// The pack lacks it too (its missing glyph): Geist's box, like vanilla's.
.filter(|g| g.rect.is_some() || g.right <= 0.0);
@@ -491,7 +548,10 @@ impl<'a> Ui<'a> {
continue;
}
let (id, advance, kern) = {
let font = c.fonts.face(face);
let Some(fonts) = &c.fonts else {
break;
};
let font = fonts.face(face);
let id = font.glyph_id(ch);
let f = font.as_scaled(scale);
(id, f.h_advance(id), prev.map_or(0.0, |p| f.kern(p, id)))
@@ -606,8 +666,8 @@ pub fn draw_boot(atlas: &HudAtlas, batch: &mut HudBatch, logs: &[String]) {
/// The wordmark with its tagline, centred.
fn wordmark(atlas: &HudAtlas, batch: &mut HudBatch) {
let mut ui = Ui::new(atlas, batch, (0.0, 0.0));
let size = (ui.w / 16.0).clamp(DISPLAY, 96.0);
let small = (size * 0.25).clamp(MICRO, 18.0);
let size = (ui.w / 16.0).clamp(display(), 96.0);
let small = (size * 0.25).clamp(micro(), 18.0);
let block = size * 1.45 + small * 0.6 + small * 1.5;
let top = (ui.h - block) / 2.0;
ui.text_centered(Face::SemiBold, size, ui.w / 2.0, top, BOOT_TEXT, "Horizon");
@@ -629,7 +689,7 @@ pub fn draw_boot_log(atlas: &HudAtlas, batch: &mut HudBatch, logs: &[String]) {
return;
}
let mut ui = Ui::new(atlas, batch, (0.0, 0.0));
let (size, line, pad) = (MICRO, MICRO * 1.5, 16.0);
let (size, line, pad) = (micro(), micro() * 1.5, 16.0);
let max_w = ui.w / 2.0;
let max_rows = (((ui.h - pad) / line) as usize).clamp(1, 24);
let mut y = ui.h - pad - line;
@@ -696,7 +756,12 @@ mod tests {
let atlas = HudAtlas::build(Path::new("/nonexistent/horizon-test-assets")).unwrap();
reset_atlas();
let draw = |cache: &mut Cache| {
let glyph = cache.fonts.face(Face::Regular).glyph_id('A');
let glyph = cache
.fonts
.as_ref()
.unwrap()
.face(Face::Regular)
.glyph_id('A');
assert!(
cache
.glyph(&atlas, Face::Regular, glyph, 16.0)
@@ -782,7 +847,7 @@ mod tests {
let lines = wrapping_lines(
&ui,
Face::Mono,
MICRO,
micro(),
"the quick brown fox jumps over the lazy dog",
90.0,
);
@@ -790,7 +855,7 @@ mod tests {
assert!(
lines
.iter()
.all(|l| ui.measure(Face::Mono, MICRO, l) <= 90.0),
.all(|l| ui.measure(Face::Mono, micro(), l) <= 90.0),
"every wrapped line fits"
);
assert_eq!(
@@ -812,7 +877,7 @@ mod tests {
fn drawn(atlas: &HudAtlas, text: &str) -> (f32, Vec<([f32; 2], [f32; 2])>) {
let mut batch = HudBatch::new([1280, 720]);
let mut ui = Ui::new(atlas, &mut batch, (0.0, 0.0));
let w = ui.text_line(Face::Medium, BODY, 20.0, 10.0, 10.0, color::TEXT, text);
let w = ui.text_line(Face::Medium, body(), 20.0, 10.0, 10.0, color::text(), text);
let quads = batch
.vertices
.iter()
+2 -53
View File
@@ -1,10 +1,8 @@
//! Vanilla's two seeded random streams. No platform/default Rust RNG is used.
mod hash;
mod ordered;
mod positional;
mod worldgen;
pub use hash::{seed_from_hash, seed_from_hash_utf16};
pub use ordered::{LaneDone, OrderedDraws};
pub use positional::{
LegacyPositionalRandom, PositionalRandom, XoroshiroPositionalRandom, java_string_hash,
position_seed,
@@ -106,23 +104,10 @@ pub trait RandomSource: Sized {
}
}
#[derive(Debug)]
#[derive(Clone, Debug)]
pub struct LegacyRandom {
state: u64,
gaussian: Option<f64>,
/// A lane of an [`OrderedDraws`] stream: each item's first draw waits for its turn.
gate: Option<Box<ordered::Gate>>,
}
/// A copy draws on its own, from the state it was copied at: it is never gated.
impl Clone for LegacyRandom {
fn clone(&self) -> Self {
Self {
state: self.state,
gaussian: self.gaussian,
gate: None,
}
}
}
impl LegacyRandom {
@@ -133,7 +118,6 @@ impl LegacyRandom {
Self {
state: (seed as u64 ^ Self::MULTIPLIER) & Self::MASK,
gaussian: None,
gate: None,
}
}
@@ -142,7 +126,6 @@ impl LegacyRandom {
Self {
state,
gaussian: None,
gate: None,
}
}
@@ -151,18 +134,7 @@ impl LegacyRandom {
self.state
}
/// A gated lane's first draw of an item waits for its turn and takes the shared state.
#[inline]
fn sync(&mut self) {
if let Some(gate) = self.gate.as_deref_mut()
&& !gate.held()
{
(self.state, self.gaussian) = gate.take();
}
}
fn bits(&mut self, count: u32) -> i32 {
self.sync();
self.state = self.state.wrapping_mul(Self::MULTIPLIER).wrapping_add(11) & Self::MASK;
let value = (self.state >> (48 - count)) as i32;
#[cfg(feature = "rng-trace")]
@@ -177,11 +149,7 @@ impl RandomSource for LegacyRandom {
LegacyPositionalRandom::new(self.next_long())
}
fn set_seed(&mut self, seed: i64) {
// LegacyRandomSource.setSeed resets the stream and its gaussian cache, not the lane.
// Reseeding is an ordered mutation even when this item makes no draws afterwards.
self.sync();
self.state = (seed as u64 ^ Self::MULTIPLIER) & Self::MASK;
self.gaussian = None;
*self = Self::new(seed);
}
fn next_int(&mut self) -> i32 {
@@ -222,7 +190,6 @@ impl RandomSource for LegacyRandom {
value as f64 * (1.0 / 9_007_199_254_740_992.0)
}
fn next_gaussian(&mut self) -> f64 {
self.sync();
let mut cache = self.gaussian.take();
let value = marsaglia_polar(&mut cache, || self.next_double());
self.gaussian = cache;
@@ -385,24 +352,6 @@ mod tests {
invalid_bounds_do_not_consume_state(XoroshiroRandom::new(0));
}
#[test]
fn legacy_reseed_resets_the_gaussian_cache_and_stream() {
let mut rng = LegacyRandom::new(42);
for seed in [i64::MIN, -1, 0, 42, i64::MAX] {
// Leave a cached second gaussian before reseeding.
rng.next_gaussian();
rng.set_seed(seed);
let mut fresh = LegacyRandom::new(seed);
for _ in 0..3 {
assert_eq!(
rng.next_gaussian().to_bits(),
fresh.next_gaussian().to_bits()
);
assert_eq!(rng.next_long(), fresh.next_long());
}
}
}
#[test]
fn zero_state_fallback_and_reseed() {
let mut a = XoroshiroRandom::from_state(0, 0);
-401
View File
@@ -1,401 +0,0 @@
//! One [`LegacyRandom`] stream shared by several threads that each run their part of a list in list
//! order (the server's parallel mob tick, `game/mob_islands.rs`): every draw comes out exactly as if
//! the whole list had run on one thread.
//!
//! Each thread (a lane) runs a subset of the list's items, in ascending item order, and says which
//! item it is on ([`LegacyRandom::begin_item`]). The first draw or reseed waits until every other
//! lane has passed that item, then takes the stream's state; the item's end gives it back
//! ([`LegacyRandom::end_item`]). So the items before it have made all their draws and the items after
//! it none. The lane on the smallest item never waits, so the lanes can't deadlock, as long as every
//! lane has its own thread. An item that never draws or reseeds never waits.
use super::LegacyRandom;
use std::sync::{
Arc, Mutex, PoisonError,
atomic::{AtomicUsize, Ordering},
};
/// The shared stream and where each lane is.
#[derive(Debug)]
pub struct OrderedDraws {
/// The stream's state between holders: the generator state and the gaussian cache.
state: Mutex<(u64, Option<f64>)>,
/// The item each lane is on; `usize::MAX` once it has finished.
lanes: Box<[AtomicUsize]>,
}
/// A lane's hold on the shared stream, inside its [`LegacyRandom`].
#[derive(Debug)]
pub(super) struct Gate {
draws: Arc<OrderedDraws>,
lane: usize,
item: usize,
held: bool,
}
impl OrderedDraws {
/// `random`'s stream shared by `lanes` lanes, none of them started.
pub fn new(random: &LegacyRandom, lanes: usize) -> Arc<Self> {
Arc::new(Self {
state: Mutex::new((random.state, random.gaussian)),
lanes: (0..lanes).map(|_| AtomicUsize::new(0)).collect(),
})
}
/// The random lane `lane` draws from: its draws wait for their turn.
pub fn lane(self: &Arc<Self>, lane: usize) -> LegacyRandom {
let mut random = LegacyRandom::from_state(0);
random.gate = Some(Box::new(Gate {
draws: Arc::clone(self),
lane,
item: 0,
held: false,
}));
random
}
/// The stream as the lanes left it, once they have all finished.
pub fn finish(&self) -> LegacyRandom {
let (state, gaussian) = *self.state.lock().unwrap_or_else(PoisonError::into_inner);
let mut random = LegacyRandom::from_state(state);
random.gaussian = gaussian;
random
}
/// Wait until lane `lane` has finished item `item` (it is on a later one, or done).
pub fn wait_past(&self, lane: usize, item: usize) {
let mut spins = 0u32;
while self.lanes[lane].load(Ordering::Acquire) <= item {
spins += 1;
if spins < 64 {
std::hint::spin_loop();
} else {
std::thread::yield_now();
}
}
}
fn wait_for_turn(&self, lane: usize, item: usize) {
let mut spins = 0u32;
loop {
let ready = self
.lanes
.iter()
.enumerate()
.all(|(k, at)| k == lane || at.load(Ordering::Acquire) > item);
if ready {
return;
}
spins += 1;
if spins < 64 {
std::hint::spin_loop();
} else {
std::thread::yield_now();
}
}
}
}
impl Gate {
/// The shared state for this item's first draw, after the items before it.
pub(super) fn take(&mut self) -> (u64, Option<f64>) {
self.draws.wait_for_turn(self.lane, self.item);
self.held = true;
*self
.draws
.state
.lock()
.unwrap_or_else(PoisonError::into_inner)
}
pub(super) fn held(&self) -> bool {
self.held
}
}
impl LegacyRandom {
/// A gated random's lane starts item `item` (ascending within the lane). Nothing for an
/// ungated one.
pub fn begin_item(&mut self, item: usize) {
if let Some(gate) = self.gate.as_deref_mut() {
gate.item = item;
gate.draws.lanes[gate.lane].store(item, Ordering::Release);
}
}
/// The item's draws are done: the shared stream goes on from where it left it.
pub fn end_item(&mut self) {
if let Some(gate) = self.gate.as_deref_mut()
&& gate.held
{
*gate
.draws
.state
.lock()
.unwrap_or_else(PoisonError::into_inner) = (self.state, self.gaussian);
gate.held = false;
}
}
/// The lane has run all its items: no other lane waits for it any more.
pub fn finish_lane(&mut self) {
self.end_item();
if let Some(gate) = self.gate.as_deref_mut() {
gate.draws.lanes[gate.lane].store(usize::MAX, Ordering::Release);
}
}
/// A gated random's [`LaneDone`], for its lane's thread to hold while it runs; None for an
/// ungated one.
pub fn lane_done(&self) -> Option<LaneDone> {
self.gate.as_deref().map(|gate| LaneDone {
draws: Arc::clone(&gate.draws),
lane: gate.lane,
})
}
}
/// Marks a lane finished when dropped, also while a panic unwinds its thread: the other lanes
/// then run to their end instead of waiting for a lane that stopped, and the panic reaches the
/// caller when it joins the threads.
#[must_use = "the lane is marked finished when this is dropped"]
pub struct LaneDone {
draws: Arc<OrderedDraws>,
lane: usize,
}
impl Drop for LaneDone {
fn drop(&mut self) {
self.draws.lanes[self.lane].store(usize::MAX, Ordering::Release);
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::random::RandomSource;
/// The timeout lives outside the scoped joins: a broken gate must fail, not hang the test suite.
fn bounded<T: Send + 'static>(run: impl FnOnce() -> T + Send + 'static) -> T {
let (sent, finished) = std::sync::mpsc::channel();
std::thread::spawn(move || {
let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(run));
let _ = sent.send(result);
});
match finished
.recv_timeout(std::time::Duration::from_secs(20))
.expect("ordered lanes must finish within the timeout")
{
Ok(value) => value,
Err(panic) => std::panic::resume_unwind(panic),
}
}
#[test]
fn reseeding_lanes_preserve_the_serial_stream() {
let mut initial = LegacyRandom::new(42);
initial.next_gaussian(); // A reseed before the first draw must also clear this cache.
// Independent serial expectation: replace an ungated generator, without calling set_seed.
let mut serial = LegacyRandom::new(11);
let mut expected = vec![vec![serial.next_gaussian().to_bits()]];
let before_reseed = serial.next_gaussian().to_bits();
serial = LegacyRandom::new(-7);
expected.push(vec![before_reseed, serial.next_gaussian().to_bits()]);
serial = LegacyRandom::new(i64::MIN);
expected.push(vec![]); // Reseed-only item must publish the state and clear the cache.
expected.push(vec![serial.next_gaussian().to_bits()]);
expected.push(vec![
serial.next_gaussian().to_bits(),
serial.next_int() as u64,
]);
let before_reseed = serial.next_int() as u64;
serial = LegacyRandom::new(0);
expected.push(vec![
before_reseed,
serial.next_long() as u64,
serial.next_gaussian().to_bits(),
]);
let (got, mut final_random) = bounded(move || {
let shared = OrderedDraws::new(&initial, 3);
let mut got: Vec<_> = std::thread::scope(|s| {
let handles: Vec<_> = (0..3)
.map(|lane| {
let shared = Arc::clone(&shared);
s.spawn(move || {
let mut random = shared.lane(lane);
let mut out = Vec::new();
// Intentionally no LaneDone: finish_lane itself must survive reseeding.
for item in (lane..6).step_by(3) {
random.begin_item(item);
let values = match item {
0 => {
random.set_seed(11);
vec![random.next_gaussian().to_bits()]
}
1 => {
let before = random.next_gaussian().to_bits();
random.set_seed(-7);
vec![before, random.next_gaussian().to_bits()]
}
2 => {
random.set_seed(i64::MIN);
vec![]
}
3 => vec![random.next_gaussian().to_bits()],
4 => vec![
random.next_gaussian().to_bits(),
random.next_int() as u64,
],
5 => {
let before = random.next_int() as u64;
random.set_seed(0);
vec![
before,
random.next_long() as u64,
random.next_gaussian().to_bits(),
]
}
_ => unreachable!(),
};
random.end_item();
out.push((item, values));
}
random.finish_lane();
out
})
})
.collect();
handles
.into_iter()
.flat_map(|h| h.join().unwrap())
.collect()
});
got.sort_by_key(|(item, _)| *item);
(
got.into_iter().map(|(_, v)| v).collect::<Vec<_>>(),
shared.finish(),
)
});
assert_eq!(got, expected);
assert_eq!(final_random.state(), serial.state());
// finish() must keep the cached gaussian as well as the LCG state.
assert_eq!(
final_random.next_gaussian().to_bits(),
serial.next_gaussian().to_bits()
);
assert_eq!(final_random.next_long(), serial.next_long());
}
#[test]
fn a_reseeded_lane_keeps_its_panic_guard_and_published_stream() {
bounded(|| {
let shared = OrderedDraws::new(&LegacyRandom::new(42), 2);
std::thread::scope(|s| {
let panicking = Arc::clone(&shared);
let handle = s.spawn(move || {
let mut random = panicking.lane(0);
random.begin_item(0);
random.set_seed(7);
let _done = random.lane_done().expect("reseed must keep the lane guard");
random.next_gaussian();
random.end_item();
panic!("after publishing the reseeded item");
});
let mut random = shared.lane(1);
random.begin_item(1);
let mut serial = LegacyRandom::new(7);
serial.next_gaussian();
assert_eq!(
random.next_gaussian().to_bits(),
serial.next_gaussian().to_bits()
);
random.finish_lane();
assert!(handle.join().is_err(), "the panic must reach the caller");
assert_eq!(shared.finish().next_long(), serial.next_long());
});
});
}
/// Items 0..40 over three lanes, each drawing on some items: the same values as one thread.
#[test]
fn lanes_draw_in_list_order() {
let draws_on = |item: usize| match item % 5 {
0 => 0,
1 => 1,
2 => 3,
_ => (item % 3) as i32,
};
let mut serial = LegacyRandom::new(42);
let expected: Vec<Vec<i32>> = (0..40)
.map(|i| {
(0..draws_on(i))
.map(|_| serial.next_int_bound(1000).unwrap())
.collect()
})
.collect();
let shared = OrderedDraws::new(&LegacyRandom::new(42), 3);
let got: Vec<(usize, Vec<i32>)> = std::thread::scope(|s| {
let handles: Vec<_> = (0..3)
.map(|lane| {
let shared = Arc::clone(&shared);
s.spawn(move || {
let mut random = shared.lane(lane);
let mut out = Vec::new();
for item in (0..40).filter(|i| (i * 7 + 3) % 3 == lane) {
random.begin_item(item);
let values = (0..draws_on(item))
.map(|_| random.next_int_bound(1000).unwrap())
.collect();
random.end_item();
out.push((item, values));
}
random.finish_lane();
out
})
})
.collect();
handles
.into_iter()
.flat_map(|h| h.join().unwrap())
.collect()
});
let mut got = got;
got.sort_by_key(|(i, _)| *i);
assert_eq!(
got.into_iter().map(|(_, v)| v).collect::<Vec<_>>(),
expected
);
assert_eq!(shared.finish().state(), serial.state());
}
/// A lane that panics part way, holding its [`LaneDone`], doesn't hold up a lane whose later
/// items wait for it: that lane finishes, and the panic reaches the caller.
#[test]
fn a_panicking_lane_lets_the_others_finish() {
let (sent, finished) = std::sync::mpsc::channel();
std::thread::spawn(move || {
let shared = OrderedDraws::new(&LegacyRandom::new(7), 2);
let caught = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
std::thread::scope(|s| {
let panicking = Arc::clone(&shared);
s.spawn(move || {
let mut random = panicking.lane(0);
let _done = random.lane_done();
for item in [0, 2, 4] {
random.begin_item(item);
random.next_int_bound(10).unwrap();
assert_ne!(item, 2, "item 2");
random.end_item();
}
random.finish_lane();
});
let mut random = shared.lane(1);
let _done = random.lane_done();
for item in [1, 3, 5] {
random.begin_item(item);
random.next_int_bound(10).unwrap();
random.end_item();
}
random.finish_lane();
});
}));
let _ = sent.send(caught.is_err());
});
let panicked = finished
.recv_timeout(std::time::Duration::from_secs(20))
.expect("the lanes finished instead of waiting for the one that panicked");
assert!(panicked, "the panic reached the caller");
}
}
+15
View File
@@ -0,0 +1,15 @@
[package]
name = "horizon-discord"
version = "0.1.0"
edition.workspace = true
license.workspace = true
rust-version.workspace = true
description = "Discord Rich Presence: \"Playing Horizon\" on the player's Discord profile (docs/native-client.md)"
[dependencies]
horizon-platform = { path = "../horizon-platform" }
log = "0.4"
serde_json = "1.0.151"
[lints]
workspace = true
@@ -1,21 +1,30 @@
//! Discord Rich Presence: "Playing Horizon" on the player's Discord profile, with what they are
//! doing (docs/native-client.md). A thread of its own talks to the Discord app on this computer
//! through its local IPC socket (Discord's RPC docs: `discord-ipc-N`, frames of opcode u32 LE +
//! length u32 LE + JSON). The game only calls [`set`] each frame: a change wakes the thread,
//! length u32 LE + JSON). The game says what the player does on each change ([`set`]), which wakes the thread,
//! which connects when Discord runs, retries every 15 s when it doesn't, keeps under Discord's
//! update limit and clears the activity on quit. Nothing is sent with the setting off,
//! or with `HORIZON_NO_DISCORD=1`. It uses nothing else of the game (docs/layers.md: Horizon layer):
//! `game/discord.rs` says what the player does.
//! or with `HORIZON_NO_DISCORD=1`.
//!
//! A Horizon crate (docs/layers.md): the game tells it what the player does through the
//! platform's hooks ([`HOOKS`]: `client_started`, `activity`, `shutdown`), which the official
//! executable (`horizon-official`) registers with the switch: its line of `horizon-extras.txt`
//! ([`SETTING`]) and its row in Extras' Multiplayer group ([`SECTION`]). horizon-client's own
//! executable has no presence.
#![forbid(unsafe_code)]
use std::collections::VecDeque;
use std::io::{self, Read, Write};
use std::path::PathBuf;
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::{Condvar, Mutex, OnceLock};
use std::thread::Thread;
use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
use serde_json::{Value, json};
use horizon_command::args::GameMode;
use horizon_platform::hooks::{self, Activity, Dimension, GameMode, Hooks, Play};
use horizon_platform::pages::{Act, Label, Page, Place, Row};
use horizon_platform::settings::Store;
/// The "Horizon" application on Discord's developer portal (public: it names the game). Its
/// Rich Presence assets: `horizon`, the logo.
@@ -40,24 +49,141 @@ const CLOSE: u32 = 2;
const PING: u32 = 3;
const PONG: u32 = 4;
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Dimension {
Overworld,
Nether,
End,
/// The presence's hooks, for `horizon_platform::hooks::register`.
pub static HOOKS: Presence = Presence;
pub struct Presence;
impl Hooks for Presence {
/// Starts the presence thread with the game's window (never for a dedicated server).
fn client_started(&self, client: &hooks::Client) {
start(client.automated);
}
fn activity(&self, now: Activity) {
set(match now {
Activity::Menus => Status::Menus,
Activity::World {
play,
dimension,
mode,
} => Status::World {
play,
dimension,
mode,
},
// Anything a later game says that this crate doesn't know yet.
_ => Status::Off,
});
}
/// On quit: the activity is cleared.
fn shutdown(&self) {
stop();
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Play {
Singleplayer,
/// A server or someone else's LAN world. Its address and name are never shown.
Multiplayer,
/// This world is open to LAN or friends; `players` counts the host, `max` is how many
/// may join.
Hosting {
players: usize,
max: usize,
},
/// The switch's line in `horizon-extras.txt`, where the engine's Extras kept it before.
const KEY: &str = "discordPresence";
/// The switch's row, and what it says on hover.
const LABEL: &str = "horizon.extras.discord_presence";
const ABOUT: &str = "horizon.extras.discord_presence.tooltip";
/// The switch (on by default). One presence per computer: the first splitscreen player's
/// switch, in their settings, on their Extras page; the other players have none.
static ON: AtomicBool = AtomicBool::new(true);
/// Whether the player whose turn it is owns the switch: the first one.
fn first_player() -> bool {
hooks::seat() == 0
}
/// Turns the presence on or off; off clears the activity at once.
pub fn set_enabled(on: bool) {
ON.store(on, Ordering::Relaxed);
let mut shared = lock();
let status = shown(shared.game);
let changed = shared.wanted.update(status, unix_now());
drop(shared);
if let Some(thread) = THREAD.get().filter(|_| changed) {
thread.unpark();
}
}
pub fn enabled() -> bool {
ON.load(Ordering::Relaxed)
}
/// What Discord is told for the game's `status`: nothing with the switch off.
fn shown(status: Status) -> Status {
if enabled() { status } else { Status::Off }
}
/// The switch's line of `horizon-extras.txt`, for `horizon_platform::settings::register`.
pub static SETTING: Setting = Setting;
pub struct Setting;
impl Store for Setting {
fn file(&self) -> &'static str {
"horizon-extras.txt"
}
fn owns(&self, key: &str) -> bool {
key == KEY
}
fn load(&self, _key: &str, value: &str) {
if !first_player() {
return;
}
match value {
"true" => set_enabled(true),
"false" => set_enabled(false),
_ => {}
}
}
fn save(&self, line: &mut dyn FnMut(&str, &str)) {
if first_player() {
line(KEY, if enabled() { "true" } else { "false" });
}
}
fn is_default(&self) -> bool {
enabled() || !first_player()
}
}
/// The switch's row in the Extras page's Multiplayer group, for
/// `horizon_platform::pages::register`.
pub static SECTION: Section = Section;
pub struct Section;
impl Page for Section {
fn id(&self) -> &'static str {
"horizon-discord.extras"
}
fn title(&self) -> &'static str {
"horizon.extras.header.multiplayer"
}
fn place(&self) -> Place {
Place::Extras
}
fn rows(&self, rows: &mut Vec<Row>) {
if !first_player() {
return;
}
rows.push(Row::Toggle {
label: LABEL,
on: enabled(),
});
}
fn act(&self, row: usize, act: Act) {
if row == 0 && first_player() && matches!(act, Act::Press | Act::PressBack) {
set_enabled(!enabled());
}
}
fn tooltip(&self, _row: usize) -> Option<Label> {
Some(Label::Key(ABOUT))
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
@@ -97,11 +223,15 @@ impl Wanted {
}
struct Shared {
/// What the game said last.
game: Status,
/// What Discord is to show: the game's, or nothing with the switch off.
wanted: Wanted,
stop: bool,
stopped: bool,
}
static SHARED: Mutex<Shared> = Mutex::new(Shared {
game: Status::Menus,
wanted: Wanted {
status: Status::Menus,
since: None,
@@ -114,7 +244,8 @@ static THREAD: OnceLock<Thread> = OnceLock::new();
/// Starts the presence thread, once: when the game starts, never for a dedicated server.
/// `automated` runs (offscreen, benchmarks) only talk to a test's fake Discord.
pub fn start(enabled: bool, automated: bool) {
/// The status [`set`] gave before, the switch included, is the first one sent.
pub fn start(automated: bool) {
let fake_discord = std::env::var_os(IPC_DIR_ENV).is_some();
if THREAD.get().is_some()
|| std::env::var_os("HORIZON_NO_DISCORD").is_some_and(|v| v != "0")
@@ -122,9 +253,6 @@ pub fn start(enabled: bool, automated: bool) {
{
return;
}
if !enabled {
lock().wanted.status = Status::Off;
}
match std::thread::Builder::new()
.name("Discord presence".into())
.spawn(run)
@@ -136,12 +264,13 @@ pub fn start(enabled: bool, automated: bool) {
}
}
/// What the player does now; only a change wakes the thread. Free without [`start`].
/// What the player does now; only a change wakes the thread. Kept for [`start`] before it.
pub fn set(status: Status) {
let Some(thread) = THREAD.get() else {
return;
};
if lock().wanted.update(status, unix_now()) {
let mut shared = lock();
shared.game = status;
let changed = shared.wanted.update(shown(status), unix_now());
drop(shared);
if let Some(thread) = THREAD.get().filter(|_| changed) {
thread.unpark();
}
}
@@ -582,6 +711,37 @@ mod tests {
/// A fake Discord: answers the handshake with READY, each command with its nonce, after a ping.
#[cfg(unix)]
/// The switch as the player's settings and the Extras page see it. The only test that
/// touches the presence's statics.
#[test]
fn the_switch_clears_the_activity_at_once() {
let wanted = || lock().wanted.status;
Setting.load(KEY, "false");
set(Status::Menus);
assert!(!enabled());
assert_eq!(wanted(), Status::Off);
let mut rows = Vec::new();
SECTION.rows(&mut rows);
assert_eq!(
rows,
[Row::Toggle {
label: LABEL,
on: false
}]
);
let mut lines = Vec::new();
Setting.save(&mut |k, v| lines.push(format!("{k}:{v}")));
assert_eq!(lines, ["discordPresence:false"]);
assert!(!Setting.is_default());
// On from the page: what the game said last is shown at once.
SECTION.act(0, Act::Press);
assert_eq!(wanted(), Status::Menus);
assert!(Setting.is_default());
SECTION.act(0, Act::Press);
assert_eq!(wanted(), Status::Off);
SECTION.act(0, Act::Press);
}
#[test]
fn talks_to_discord_over_its_socket() {
let dir = std::env::temp_dir().join(format!("horizon-discord-{}", std::process::id()));
+140 -5
View File
@@ -77,12 +77,79 @@ pub trait Hooks: Sync {
/// Draws on a player model, after its vanilla layers, each time it is drawn.
fn player_layers(&self, _model: &players::PlayerModel, _draw: &mut dyn players::PlayerDraw) {}
/// Draws around the window's own model while its body is out of view (first person), once a
/// frame after the first-person model: [`players::Pass::FirstPerson`].
fn first_person(&self, _own: &players::PlayerModel, _draw: &mut dyn players::PlayerDraw) {}
/// The resolver of `friend:` addresses this crate offers ([`tunnel`]); the first hook that
/// offers one is used. Asked on the network thread when joining and on the game thread when
/// an address is shown, never per frame.
fn tunnel(&self) -> Option<&dyn tunnel::Resolver> {
None
}
/// A game window opened: once, before the first frame, for every run that has a window,
/// automated ones included ([`Client::automated`]); never for a dedicated server.
fn client_started(&self, _client: &Client) {}
/// What the player is doing, for a status shown outside the game (Discord's): called when it
/// changes, not every frame. Splitscreen: the first player's only.
fn activity(&self, _now: Activity) {}
}
/// What [`Hooks::client_started`] gets.
#[non_exhaustive]
pub struct Client {
/// A run nobody plays: offscreen, a benchmark, a run that quits by itself after some frames.
pub automated: bool,
}
impl Client {
pub fn new(automated: bool) -> Self {
Self { automated }
}
}
/// What the player is doing ([`Hooks::activity`]).
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
#[non_exhaustive]
pub enum Activity {
/// On the title screen or another menu outside a world.
Menus,
World {
play: Play,
dimension: Dimension,
mode: GameMode,
},
}
/// How the player is in a world.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Play {
Singleplayer,
/// A server or someone else's LAN world.
Multiplayer,
/// Their world is open to LAN or friends; `players` counts the host, `max` is how many may
/// join.
Hosting {
players: usize,
max: usize,
},
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Dimension {
Overworld,
Nether,
End,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum GameMode {
Survival,
Creative,
Adventure,
Spectator,
}
/// What [`Hooks::started`] gets.
@@ -178,6 +245,32 @@ pub mod engine {
}
}
/// [`Hooks::client_started`].
pub fn client_started(client: &Client) {
for hooks in all() {
hooks.client_started(client);
}
}
/// The activity at the last call (`None` before the first).
static ACTIVITY: std::sync::Mutex<Option<Activity>> = std::sync::Mutex::new(None);
/// [`Hooks::activity`], when `now` differs from the last call's. Cheap enough to call every
/// frame: with no hook registered it only loads the empty list.
pub fn activity(now: Activity) {
let hooks = all();
if hooks.is_empty() {
return;
}
let mut last = ACTIVITY.lock().unwrap_or_else(|e| e.into_inner());
if last.replace(now) != Some(now) {
drop(last);
for h in hooks {
h.activity(now);
}
}
}
/// [`Hooks::shutdown`].
pub fn shutdown() {
for hooks in all() {
@@ -217,6 +310,13 @@ pub mod engine {
}
}
/// [`Hooks::first_person`].
pub fn first_person(own: &players::PlayerModel, draw: &mut dyn players::PlayerDraw) {
for hooks in all() {
hooks.first_person(own, draw);
}
}
/// [`Hooks::camera`].
pub fn camera(own: &players::CameraView) -> Option<players::CameraOverride> {
all().iter().find_map(|h| h.camera(own))
@@ -255,6 +355,9 @@ mod tests {
fn published(&self, open: bool) {
self.0.lock().unwrap().push(format!("published {open}"));
}
fn activity(&self, now: Activity) {
self.0.lock().unwrap().push(format!("{now:?}"));
}
fn frame(&self, frame: &players::Frame, sprites: &mut dyn players::SpriteStore) {
self.0
.lock()
@@ -285,7 +388,14 @@ mod tests {
return;
};
let turn = draw.ticks() / 100.0;
draw.draw(&HAT, head, uv, &mut |p| p.pose("brim")[4] = turn);
let look = players::Look::new(uv);
draw.draw(&HAT, head, look, &mut |p| p.pose("brim")[4] = turn);
}
/// A spark at the eyes, as bright as the head turns.
fn first_person(&self, _: &players::PlayerModel, draw: &mut dyn players::PlayerDraw) {
let alpha = draw.motion().head_yaw / 90.0;
let spark = players::Particle::new([[0.0; 3]; 4], [[0.0; 2]; 4], alpha);
draw.particles([0.0; 4], &[spark], players::Light::Full);
}
fn tick(&self, models: &mut dyn players::PlayerModels) {
let mut ids = Vec::new();
@@ -362,9 +472,10 @@ mod tests {
))
});
/// A player drawn at age 50, its head where the camera is; logs each draw.
/// A player drawn at age 50, its head where the camera is, turned 45 degrees; logs each draw
/// and its particles' alphas.
#[derive(Default)]
struct Canvas(Vec<(String, [[f32; 4]; 4], [f32; 4], f32)>);
struct Canvas(Vec<(String, [[f32; 4]; 4], [f32; 4], f32)>, Vec<f32>);
impl players::PlayerDraw for Canvas {
fn pass(&self) -> players::Pass {
players::Pass::World
@@ -372,6 +483,15 @@ mod tests {
fn ticks(&self) -> f32 {
50.0
}
fn base(&self) -> [[f32; 4]; 4] {
[[1.0, 0.0, 0.0, 0.0]; 4]
}
fn motion(&self) -> players::Motion {
players::Motion::new([0.0; 3], 45.0, 0.0)
}
fn view(&self) -> players::View {
players::View::default()
}
fn bone(&mut self, bone: players::Bone) -> Option<[[f32; 4]; 4]> {
(bone == players::Bone::Head).then_some([[1.0, 0.0, 0.0, 0.0]; 4])
}
@@ -385,7 +505,7 @@ mod tests {
&mut self,
mesh: &std::sync::Arc<players::Mesh>,
matrix: [[f32; 4]; 4],
uv: [f32; 4],
look: players::Look,
pose: &mut dyn FnMut(&mut dyn players::PartPoses),
) {
struct Rest([f32; 9]);
@@ -396,7 +516,10 @@ mod tests {
}
let mut brim = Rest(mesh.children[0].pose);
pose(&mut brim);
self.0.push((mesh.name.clone(), matrix, uv, brim.0[4]));
self.0.push((mesh.name.clone(), matrix, look.uv, brim.0[4]));
}
fn particles(&mut self, _: [f32; 4], particles: &[players::Particle], _: players::Light) {
self.1.extend(particles.iter().map(|p| p.alpha));
}
}
@@ -415,6 +538,14 @@ mod tests {
for open in [false, true, true, true, false, false] {
engine::published(open);
}
let world = Activity::World {
play: Play::Singleplayer,
dimension: Dimension::Nether,
mode: GameMode::Creative,
};
for now in [Activity::Menus, Activity::Menus, world, world] {
engine::activity(now);
}
engine::shutdown();
assert!(engine::any());
let mut atlas = Atlas::default();
@@ -445,6 +576,8 @@ mod tests {
0.5
)]
);
engine::first_person(&model(players::Kind::Own), &mut canvas);
assert_eq!(canvas.1, [0.5]);
let mut models = Two(Default::default());
engine::tick(&mut models);
assert!(models.body_pose(3).is_some() && models.body_pose(7).is_some());
@@ -454,6 +587,8 @@ mod tests {
"started 0.2.31",
"published true",
"published false",
"Menus",
"World { play: Singleplayer, dimension: Nether, mode: Creative }",
"frame 4"
]
);
+152 -5
View File
@@ -274,8 +274,149 @@ impl Bone {
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
#[non_exhaustive]
pub enum Pass {
/// In the world, seen by the camera.
/// In the world, seen by the camera (the window's own model too, in third person).
World,
/// The inventory's picture of the window's own model.
Picture,
/// The window's own model while its body is out of view (first person): no bone, only what
/// floats around it ([`crate::Hooks::first_person`]).
FirstPerson,
}
/// How a player model moves this frame ([`PlayerDraw::motion`]), with the frame's part of a tick.
#[derive(Clone, Copy, Debug, Default)]
#[non_exhaustive]
pub struct Motion {
/// Where its feet are, in the world.
pub feet: [f64; 3],
/// Where its head turns (degrees, the game's yaw and pitch).
pub head_yaw: f32,
pub head_pitch: f32,
pub crouching: bool,
/// Lies in a bed.
pub asleep: bool,
/// Flies with an elytra.
pub flying: bool,
/// Swims (any part of the swimming pose).
pub swimming: bool,
}
impl Motion {
pub fn new(feet: [f64; 3], head_yaw: f32, head_pitch: f32) -> Self {
Self {
feet,
head_yaw,
head_pitch,
..Self::default()
}
}
/// With its poses: crouching, asleep, flying, swimming.
pub fn poses(self, crouching: bool, asleep: bool, flying: bool, swimming: bool) -> Self {
Self {
crouching,
asleep,
flying,
swimming,
..self
}
}
}
/// The camera a player model is drawn for ([`PlayerDraw::view`]).
#[derive(Clone, Copy, Debug, Default)]
#[non_exhaustive]
pub struct View {
/// Where it is, in the world: matrices and [`Particle`] corners are relative to it.
pub camera: [f64; 3],
/// Its right and up, for what faces it.
pub right: [f32; 3],
pub up: [f32; 3],
/// Where the window's player looks, and their level left (first person).
pub forward: [f32; 3],
pub left: [f32; 3],
}
impl View {
pub fn new(camera: [f64; 3], right: [f32; 3], up: [f32; 3]) -> Self {
Self {
camera,
right,
up,
..Self::default()
}
}
/// With where the window's player looks and their level left.
pub fn looking(self, forward: [f32; 3], left: [f32; 3]) -> Self {
Self {
forward,
left,
..self
}
}
}
/// How a mesh or particles are lit.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
#[non_exhaustive]
pub enum Light {
/// As the player model is.
Model,
/// Fully lit: it glows.
Full,
/// By the world where the mesh's origin is (as particles: like [`Light::Model`]).
AtPlace,
}
/// How [`PlayerDraw::draw`] draws a mesh.
#[derive(Clone, Copy, Debug)]
#[non_exhaustive]
pub struct Look {
/// The sprite part it is textured by (u0, v0, u1, v1).
pub uv: [f32; 4],
pub light: Light,
/// Drawn see-through at this alpha, after every player, its back faces dropped.
pub see_through: Option<f32>,
}
impl Look {
/// Opaque, lit as the model.
pub fn new(uv: [f32; 4]) -> Self {
Self {
uv,
light: Light::Model,
see_through: None,
}
}
pub fn lit(self, light: Light) -> Self {
Self { light, ..self }
}
pub fn see_through(self, alpha: f32) -> Self {
Self {
see_through: Some(alpha),
..self
}
}
}
/// A quad that faces the camera ([`PlayerDraw::particles`]).
#[derive(Clone, Copy, Debug)]
#[non_exhaustive]
pub struct Particle {
/// Its corners, relative to the camera.
pub corners: [[f32; 3]; 4],
/// Each corner's place across the sprite part (0 to 1).
pub uv: [[f32; 2]; 4],
pub alpha: f32,
}
impl Particle {
pub fn new(corners: [[f32; 3]; 4], uv: [[f32; 2]; 4], alpha: f32) -> Self {
Self { corners, uv, alpha }
}
}
/// The poses of a [`Mesh`]'s parts for one [`PlayerDraw::draw`], each at rest to begin with.
@@ -291,19 +432,25 @@ pub trait PlayerDraw {
fn pass(&self) -> Pass;
/// The model's age in ticks, with the frame's part of a tick.
fn ticks(&self) -> f32;
/// Where `bone` is as the body is drawn this frame; None if the model has no such part.
/// The model's own matrix, its origin at the neck (y down, 16 units a block).
fn base(&self) -> [[f32; 4]; 4];
fn motion(&self) -> Motion;
fn view(&self) -> View;
/// Where `bone` is as the body is drawn this frame; None if the model has no such part (or
/// no body is drawn: [`Pass::FirstPerson`]).
fn bone(&mut self, bone: Bone) -> Option<[[f32; 4]; 4]>;
/// The pose laid over the model's body ([`PlayerModels::set_body_pose`]), if any.
fn body_pose(&self) -> Option<&Arc<dyn BodyPose>>;
/// As [`Sprites::get`].
fn sprite(&self, id: u64) -> Option<[f32; 4]>;
/// Draws `mesh` at `matrix`, textured by the sprite part `uv` (u0, v0, u1, v1) and lit as the
/// model is; `pose` poses its parts first.
/// Draws `mesh` at `matrix` as `look` says; `pose` poses its parts first.
fn draw(
&mut self,
mesh: &Arc<Mesh>,
matrix: [[f32; 4]; 4],
uv: [f32; 4],
look: Look,
pose: &mut dyn FnMut(&mut dyn PartPoses),
);
/// Draws `particles`, textured by the sprite part `uv`, see-through after every player.
fn particles(&mut self, uv: [f32; 4], particles: &[Particle], light: Light);
}
+1
View File
@@ -16,6 +16,7 @@ horizon-platform = { path = "../horizon-platform" }
horizon-heartbeat = { path = "../horizon-heartbeat" }
horizon-friends = { path = "../horizon-friends", features = ["game"] }
horizon-official-servers = { path = "../horizon-official-servers" }
horizon-discord = { path = "../horizon-discord" }
log = "0.4"
[build-dependencies]
+14 -5
View File
@@ -4,27 +4,36 @@
//! (`game-build.yml`); horizon-client keeps its own plain executable.
//!
//! Each Horizon crate that runs in the game gives a `&'static` value implementing
//! `horizon_platform::hooks::Hooks`, and [`register`] adds it. Nothing here depends on an engine
//! crate.
//! `horizon_platform::hooks::Hooks`, and its settings store and pages if it has any, and
//! [`register`] adds them. Nothing here depends on an engine crate.
#![forbid(unsafe_code)]
use horizon_platform::hooks::{self, Hooks};
use horizon_platform::{pages, settings};
/// Every Horizon part in the game, in the order its hooks run.
const PARTS: [&dyn Hooks; 3] = [
const PARTS: [&dyn Hooks; 4] = [
// The anonymous "I'm playing" ping.
&horizon_heartbeat::HOOKS,
// Play with Friends: `friend:` addresses join through its tunnel.
&horizon_friends::game::HOOKS,
// The official server in the player's server list.
&horizon_official_servers::HOOKS,
// "Playing Horizon" on the player's Discord profile.
&horizon_discord::HOOKS,
];
/// Their settings, in the player's settings files.
const STORES: [&dyn settings::Store; 1] = [&horizon_discord::SETTING];
/// Their rows and pages in the options screens.
const PAGES: [&dyn pages::Page; 1] = [&horizon_discord::SECTION];
/// Registers Horizon's in-game parts with the game. The executable calls it before it runs the
/// game, and so does the launcher's generated executable for native mods.
pub fn register() {
for part in PARTS {
if let Err(e) = hooks::register(part) {
let late = PARTS.into_iter().map(hooks::register);
let late = late.chain(STORES.into_iter().map(settings::register));
for result in late.chain(PAGES.into_iter().map(pages::register)) {
if let Err(e) = result {
// The executable registered after starting the game: a bug, not a player's problem.
log::warn!("Horizon: an in-game part did not register: {e}");
}
+7 -4
View File
@@ -50,11 +50,14 @@ pub mod hooks {
/// [`Hooks::tick`] get, the pose a crate lays over a player's body, what goes by their names,
/// the crate's sprites, the camera, and what a crate draws on them.
pub use horizon_hooks::players::{
BODY_PARTS, Badge, BodyPose, Bone, CameraOverride, CameraView, Frame, Gear, Kind, Mark,
Mesh, NameDecoration, PartPoses, Pass, PlayerDraw, PlayerModel, PlayerModels, Quad,
SpriteStore, Sprites,
BODY_PARTS, Badge, BodyPose, Bone, CameraOverride, CameraView, Frame, Gear, Kind, Light,
Look, Mark, Mesh, Motion, NameDecoration, PartPoses, Particle, Pass, PlayerDraw,
PlayerModel, PlayerModels, Quad, SpriteStore, Sprites, View,
};
pub use horizon_hooks::{
Activity, Client, Dimension, GameMode, Hooks, Play, Started, TooLate, register, seat,
tunnel,
};
pub use horizon_hooks::{Hooks, Started, TooLate, register, seat, tunnel};
}
/// The settings store (`horizon_hooks::settings`): a crate's own `key:value` lines in the game
+3 -3
View File
@@ -23,14 +23,14 @@ What changes in that build:
|---|---|---|
| Cosmetics, emotes and the emote wheel, "Show me as a Horizon player" | `game/cosmetics.rs` (+ `cosmetics/fx.rs`, `cosmetics/emotes.rs`, `cosmetics/emote_wheel.rs`), crate `horizon-cosmetics` | `game::cosmetics::enabled()`: the service is never asked, nothing is drawn, B opens no wheel |
| The official server added to the server list, the withdrawn partner server taken out | crate `horizon-official-servers` | registered only by the official executable (`horizon-official`); horizon-client's own executable has none |
| Horizon Settings (Extras, PvP & HUD, Splitscreen, Studio, AI Agent, Discord) and Mods (with the registry browser) | `options_ui.rs` (`Page::Horizon` and below), `extras_ui.rs`, `extras.rs`, `pvp.rs`, `game/extras.rs`, `game/tweaks.rs`, `extras_hud.rs` | the Options screen shows vanilla's greyed Telemetry Data instead (`options_ui::HORIZON_EXTRAS`, set in `entry.rs`) |
| Discord Rich Presence | `discord_presence.rs`, `game/discord.rs` | the `discord` feature, which `horizon-extras` turns on |
| Horizon Settings (Extras, PvP & HUD, Splitscreen, Studio, AI Agent) and Mods (with the registry browser) | `options_ui.rs` (`Page::Horizon` and below), `extras_ui.rs`, `extras.rs`, `pvp.rs`, `game/extras.rs`, `game/tweaks.rs`, `extras_hud.rs` | the Options screen shows vanilla's greyed Telemetry Data instead (`options_ui::HORIZON_EXTRAS`, set in `entry.rs`) |
| Discord Rich Presence and its switch | crate `horizon-discord` | registered only by the official executable (`horizon-official`); horizon-client's own executable has none |
| Play with Friends | hosting: `game/friends.rs`, `game/friends/screen.rs`; joining: crate `horizon-friends` (`game`) | hosting: the `friends` feature (`game/friends_off.rs` stands in); joining: only the official executable registers it ([layers.md](layers.md#how-a-horizon-crate-plugs-in)) |
| The anonymous "I'm playing" ping | crate `horizon-heartbeat` | not in horizon-client at all: only the official executable (`horizon-official`) registers it ([layers.md](layers.md#how-a-horizon-crate-plugs-in)) |
Credits & Attribution then says where the official build is (`horizon.plain.official`).
`grep -rn horizon-extras crates/` lists every gate. Apart from the three features above, the gates
`grep -rn horizon-extras crates/` lists every gate. Apart from the `friends` feature above, the gates
are `cfg!(feature = "horizon-extras")`: both sides compile in every build, so no stub has to follow
the code it replaces. To remove an extra for good, delete its files from the table and the lines its
gate guards.
+2 -2
View File
@@ -156,8 +156,8 @@ Horizon's own extras and its product, and every built-in address of an outside s
benchmark uploads, releases and updates, the hosting supervisor, the launcher.
- **Examples:** `horizon-addresses`, `horizon-cosmetics`, `horizon-friends`, `horizon-relay`, `horizon-telemetry`,
`horizon-bench`, `horizon-release-sign`, `horizon-server`, `horizon-mod-registry`, `horizon-heartbeat`,
`horizon-official`, `horizon-official-servers`, `launcher/`. Still in engine crates today: `game/cosmetics.rs`, `game/emotes.rs`,
`game/discord.rs`, `game/extras.rs`, `game/tweaks.rs`, `extras_ui.rs`, `pvp.rs`.
`horizon-official`, `horizon-official-servers`, `horizon-discord`, `launcher/`. Still in engine crates today:
`game/cosmetics.rs`, `game/emotes.rs`, `game/extras.rs`, `game/tweaks.rs`, `extras_ui.rs`, `pvp.rs`.
- **Rules:**
- It uses the platform's public API only, never the engine.
- Until it moves, every Horizon feature in the game sits behind the `horizon-extras` feature
+11 -6
View File
@@ -423,13 +423,18 @@ While Discord runs on the same computer, the player's Discord profile shows
"Playing Horizon" and what they are doing: "In the menus", "Singleplayer",
"Multiplayer" or "Hosting a world" (with the party size, of 8), the dimension
("In the Nether") and the time since they entered the world. It never shows a
world's name or a server's address. Horizon Settings has the switch
(**Discord Rich Presence**, on by default; `discordPresence` in
`horizon-extras.txt`), and `HORIZON_NO_DISCORD=1` turns it off for one run.
world's name or a server's address. The switch is on the Extras page, in its
Multiplayer group (**Discord Rich Presence**, on by default; `discordPresence`
in `horizon-extras.txt`; in splitscreen only the first player has it), and
`HORIZON_NO_DISCORD=1` turns it off for one run.
`crates/horizon-client/src/discord_presence.rs` speaks Discord's local IPC
itself (the `discord-ipc-N` socket or named pipe, no SDK) from a thread of its
own: the frame only hands it a status when it changes. It retries every 15 s
The crate `horizon-discord` (Horizon layer) speaks Discord's local IPC itself
(the `discord-ipc-N` socket or named pipe, no SDK) from a thread of its own.
The game hands it a status only when it changes, through the platform's
`activity` hook (`game/activity.rs` says what the player does), and keeps the
switch itself: its settings store and its Extras row (the platform's settings
and pages registries). Only the official executable (`horizon-official`)
registers it: horizon-client's own executable shows no presence and no switch. It retries every 15 s
while Discord isn't running and stays within Discord's 5 updates per 20 s.
Dedicated servers never start it, and automated runs (offscreen, benchmarks)
only talk to a fake Discord: `lab.game_env` sets `HORIZON_NO_DISCORD=1`, and
+2 -2
View File
@@ -427,8 +427,8 @@ game built in `Game::new`, the integrated server on its own thread. On that base
**Binaries.** The release archives hold `horizon-client`, `horizon-ctl`, `horizon-prepare` and
`horizon-server`. On Linux the release also has `horizon-server-linux-x86_64.tar.gz`: `horizon-server`,
`horizon-prepare` and `horizon-engine`, the game built with `--no-default-features` (no sound, no
controllers, no Discord presence or Play with Friends: the `discord` and `friends` features; nor the "I'm
playing" ping, which only the official executable has), which links neither `libasound.so.2` nor `libudev.so.1`. `release.yml` builds it after the
controllers, no Play with Friends: the `friends` feature; nor Discord presence or the "I'm playing" ping, which
only the official executable has), which links neither `libasound.so.2` nor `libudev.so.1`. `release.yml` builds it after the
client archive (`xtask package-server`) and fails if `tools/check_imports.py --server` finds either library;
CI lints the same build. `horizon-server` runs `horizon-engine` when it is beside it or on the PATH, else
`horizon-client`. Build it yourself with `cargo build --release -p horizon-client --no-default-features`
+20 -7
View File
@@ -9,7 +9,7 @@ The game goes title -> world -> Nether -> quit through horizon-ctl, and the acti
single single player on its server thread
lan a host opens its world to LAN and a guest joins: the guest shows "Multiplayer",
the host "Hosting a world" with a party of 2 of 8
setting off in horizon-extras.txt: nothing reaches Discord; the Horizon Settings switch
setting off in horizon-extras.txt: nothing reaches Discord; the Extras switch
turns it on (the activity appears) and off (it is cleared)
late Discord starts after the game: found within the 15 s retry
@@ -136,7 +136,7 @@ class Game:
self.discord.start()
extra = {'HORIZON_HOME': self.home, 'HORIZON_TELEMETRY_URL': 'off', 'HORIZON_DISCORD_IPC_DIR': self.discord.dir}
self.env = lab.game_env(**extra)
self.ctl_run('launch', '--offscreen', '--game', str(bins / 'horizon-client'), '--resources',
self.ctl_run('launch', '--offscreen', '--game', str(bins / 'horizon-official'), '--resources',
str(lab.RESOURCES.resolve()), *args)
def ctl_run(self, *words, timeout=600):
@@ -207,8 +207,21 @@ def lan(bins):
g.close()
def extras_row(game, label):
"""The Extras page's row whose label starts with `label`, scrolling the list down to it: only
the rows on screen are widgets."""
first = game.ctl_run('ui')['widgets'][0]['bounds']
for _ in range(20):
found = [w for w in game.ctl_run('ui')['widgets'] if (w.get('label') or '').startswith(label)]
if found:
return found[0]
game.ctl_run('scroll-at', str(first[0] + 5), str(first[1] + 5), '-5')
time.sleep(0.3)
raise AssertionError(f'no {label!r} row on the Extras page')
def setting(bins):
"""Off in horizon-extras.txt: Discord never hears from the game. Turned on in Horizon Settings: the
"""Off in horizon-extras.txt: Discord never hears from the game. Turned on in Extras: the
activity appears; off again: it is cleared."""
game = Game(bins, 'setting', extras='discordPresence:false\n')
d = game.discord
@@ -216,8 +229,8 @@ def setting(bins):
game.ctl_run('wait-for', 'screen', 'title')
time.sleep(3)
assert not d.handshakes and not d.activities, (d.handshakes, d.activities)
game.ctl_run('options', 'horizon')
switch = next(w for w in game.ctl_run('ui')['widgets'] if w.get('label', '').startswith('Discord Rich'))
game.ctl_run('options', 'extras')
switch = extras_row(game, 'Discord Rich')
assert switch['label'] == 'Discord Rich Presence: Off', switch
game.ctl_run('click', switch['id'])
d.wait('turned on', shows('In the menus'))
@@ -251,14 +264,14 @@ CHECKS = {'single': single, 'lan': lan, 'setting': setting, 'late': late}
def main():
parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
parser.add_argument('--check', choices=[*CHECKS, 'all'], default='all')
parser.add_argument('--bin-dir', help='prebuilt horizon-client and horizon-ctl (default: build with the fast profile)')
parser.add_argument('--bin-dir', help='prebuilt horizon-official and horizon-ctl (default: build with the fast profile)')
args = parser.parse_args()
if sys.platform == 'win32':
sys.exit('the fake Discord is a Unix socket: macOS or Linux only')
if args.bin_dir:
bins = Path(args.bin_dir).resolve()
else:
subprocess.run(['cargo', 'build', '--locked', '--profile', 'fast', '-p', 'horizon-client', '-p', 'horizon-client-lib', '--bins'],
subprocess.run(['cargo', 'build', '--locked', '--profile', 'fast', '-p', 'horizon-official', '-p', 'horizon-client-lib', '--bins'],
cwd=ROOT, check=True)
meta = json.loads(subprocess.run(['cargo', 'metadata', '--format-version', '1', '--no-deps'], cwd=ROOT,
capture_output=True, text=True, check=True).stdout)
+2 -5
View File
@@ -51,6 +51,7 @@
"horizon-addresses": "horizon",
"horizon-bench": "horizon",
"horizon-cosmetics": "horizon",
"horizon-discord": "horizon",
"horizon-friends": "horizon",
"horizon-heartbeat": "horizon",
"horizon-launcher": "horizon",
@@ -95,7 +96,6 @@
"horizon-client -> horizon-shaders",
"horizon-client -> horizon-telemetry",
"horizon-client -> horizon-theme",
"horizon-client-input -> horizon-theme",
"horizon-client-lib -> horizon-anticheat",
"horizon-client-lib -> horizon-bench",
"horizon-client-lib -> horizon-chat-filter",
@@ -106,13 +106,10 @@
"horizon-client-lib -> horizon-shader-api",
"horizon-client-lib -> horizon-shaders",
"horizon-client-lib -> horizon-telemetry",
"horizon-client-lib -> horizon-theme",
"horizon-client-render -> horizon-cosmetics",
"horizon-client-render -> horizon-hdr",
"horizon-client-render -> horizon-optifine",
"horizon-client-render -> horizon-polytone",
"horizon-client-render -> horizon-shader-api",
"horizon-client-render -> horizon-shaders",
"horizon-client-render -> horizon-theme"
"horizon-client-render -> horizon-shaders"
]
}