Compare commits

..
Author SHA1 Message Date
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 agent e2afd13715 Merge branch 'split/cosmetics-names' into split/cosmetics-draw
# Conflicts:
#	crates/horizon-client/src/game/creatures.rs
#	crates/horizon-hooks/src/lib.rs
2026-11-09 20:19:51 +01:00
Horizon agent 00aba43d81 Merge branch 'split/cosmetics-sprites' into split/cosmetics-names 2026-11-09 20:19:06 +01:00
Horizon agentandClaude Opus 5.5 e893fd3eac Hooks doc: the frame rule is the player methods', not tunnel's
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 20:18:59 +01:00
Horizon agent ee986c3e62 Merge branch 'split/cosmetics-hooks' into split/cosmetics-sprites
# Conflicts:
#	crates/horizon-hooks/src/lib.rs
2026-11-09 20:18:48 +01:00
Horizon agent 19aabc08c2 Merge remote-tracking branch 'origin/layers/seat' into split/cosmetics-hooks
# Conflicts:
#	crates/horizon-hooks/src/lib.rs
#	crates/horizon-platform/src/lib.rs
2026-11-09 20:18:27 +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 agent ca3649534d Merge remote-tracking branch 'origin/main' into layers/seat
# Conflicts:
#	crates/horizon-platform/src/lib.rs
2026-11-09 20:15:33 +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 agent 0c77cd5898 Merge branch 'split/cosmetics-names' into split/cosmetics-draw 2026-11-09 19:22:00 +01:00
Horizon agent 4848a9eff5 Merge branch 'split/cosmetics-sprites' into split/cosmetics-names 2026-11-09 19:21:59 +01:00
Horizon agentandClaude Opus 5.5 95b0cb05e7 horizon-hooks test: call SpriteStore::has by its trait
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 19:21:58 +01:00
Horizon agentandClaude Opus 5.5 641a5859a2 horizon-platform re-exports the PlayerDraw types
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 19:18:34 +01:00
Horizon agentandClaude Opus 5.5 7d17f516e5 Hooks: PlayerDraw, crates draw on player models in the world pass (spec #14091 step 2a-i)
horizon-hooks gains Hooks::player_layers with a plain PlayerDraw (bone
matrices, the frame's ticks, the body pose, sprites, draw of an Arc<Mesh>
with its parts posed) and the Mesh/Quad/Bone/Pass types. The engine
implements it on the GPU skin path (each mesh made a Part once, cached by
its address while a crate holds it) with the vertex path as fallback, and
dispatches it from creatures.rs's world pass one call for one call; the
cosmetics still draw there until 2a-ii.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 19:15:18 +01:00
Horizon agentandClaude Opus 5.5 8902d94a19 Cosmetics: name tags, the tab mark and the camera pull through the hooks (spec #14091 step 3b)
names.rs, scoreboard.rs and perspective.rs call the engine dispatch
(name_decoration, player_mark, camera) instead of the cosmetics; the
cosmetics answer from their per-seat state: badges become sprites by id
put in the atlas from the frame hook, covered slots read PlayerModel's
gear, the camera pull comes from the emote that pulled it.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 19:07:06 +01:00
Horizon agentandClaude Opus 5.5 ca6140d05e Hooks: name decoration, player-list mark, camera and sprites by id (spec #14091 step 3a)
horizon-hooks gains Hooks::name_decoration (a Copy NameDecoration: mark
rectangles, a badge cut from the crate's sprite, the lift over the body),
player_mark for the player list, camera (a share of the third-person
distance), and sprites by id: Sprites read while drawing, SpriteStore
filled in Hooks::frame (skin slots, hooks/<hex>). PlayerModel carries
gear and invisibility; Frame says whether the world is the window's own.
horizon-platform re-exports them. The engine fills the store; no crate
answers the new hooks yet (step 3b).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 19:05:10 +01:00
Horizon agentandClaude Opus 5.5 a7f07a08de rustfmt
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 19:00:49 +01:00
Horizon agentandClaude Opus 5.5 0e528f7287 Cosmetics: name the per-seat state Seat (the tests' Service is a transport)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 18:58:25 +01:00
Horizon agent 7ca4fd68bd Merge remote-tracking branch 'origin/layers/seat' into split/cosmetics-hooks 2026-11-09 18:48:40 +01:00
Horizon agent 547b1d4959 Merge branch 'layers/hud-layers' into layers/seat 2026-11-09 18:40:43 +01:00
Horizon agentandClaude Opus 5.5 e2918de5aa Seat: a splitscreen guest's profile loads into its own seat
Split::enter loaded a guest's profile before its turn began, so the
crates' settings stores read the guest's lines into P1's seat. The load
now runs on the guest's seat; a new profile is saved on P1's seat (the
stores write P1's values, as the rest of the profile copies P1's) and
its stores are read back into the guest's (Settings::load_stores).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 18:40:35 +01:00
Horizon agentandClaude Opus 5.5 e6b0a03270 Cosmetics: emote feed and presence behind frame/tick hooks (spec #14091 step 1)
horizon-hooks gains Hooks::frame and Hooks::tick with a plain player-model
view (PlayerModels, BodyPose moves here), re-exported by horizon-platform.
The cosmetics' client, emote feed, owner check and presence live in a
per-seat static behind those hooks; only a window's game calls them, so
the server thread no longer runs tick_emotes. net::Session gets a serial
that names the connection.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 18:40:24 +01:00
Ludovic Marie 8f198d9cbd Merge origin/main into split/cosmetics-hooks 2026-11-09 18:30:32 +01:00
Horizon agent bd90908a72 Merge branch 'layers/hud-layers' into layers/seat 2026-11-09 17:44:40 +01:00
Horizon agentandClaude Opus 5.5 a8d445f56c Hooks: seat(), the splitscreen player whose turn it is
Splitscreen players share one process and thread, so a crate's statics
are shared by them. The engine now says whose turn it is
(horizon_hooks::seat(), re-exported in horizon_platform::hooks; 0 for
P1), set where split.rs switches turns and while a joining player's game
starts, so settings stores, pages and HUD layers can keep per-player
state.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 17:33:09 +01:00
56 changed files with 2859 additions and 1392 deletions

No files matched your search

Generated
-20
View File
@@ -1650,15 +1650,6 @@ dependencies = [
"ureq",
]
[[package]]
name = "horizon-discord"
version = "0.1.0"
dependencies = [
"horizon-platform",
"log",
"serde_json",
]
[[package]]
name = "horizon-dualsense"
version = "0.1.0"
@@ -1802,25 +1793,14 @@ dependencies = [
name = "horizon-official"
version = "0.1.0"
dependencies = [
"horizon-discord",
"horizon-friends",
"horizon-game",
"horizon-heartbeat",
"horizon-official-servers",
"horizon-platform",
"log",
"winresource",
]
[[package]]
name = "horizon-official-servers"
version = "0.1.0"
dependencies = [
"horizon-addresses",
"horizon-platform",
"log",
]
[[package]]
name = "horizon-optifine"
version = "0.1.0"
-2
View File
@@ -34,8 +34,6 @@ members = [
"crates/horizon-game",
"crates/horizon-official",
"crates/horizon-heartbeat",
"crates/horizon-discord",
"crates/horizon-official-servers",
"crates/horizon-bench",
"crates/horizon-friends",
"crates/horizon-relay",
-4
View File
@@ -15,10 +15,6 @@ pub const OFFICIAL_SERVER: &str = "play.playhorizon.dev";
/// `official_server_previous` in `launcher/links.json`).
pub const OFFICIAL_SERVER_PREVIOUS: &[&str] = &["horizon.bunchy7s.dev"];
/// Afri-Network, a partner server the game added once below the official server (#9299) until
/// the partnership ended: kept so the game can take back out the entry it added.
pub const WITHDRAWN_PARTNER_SERVER: &str = "afri-network.co.za";
/// [`OFFICIAL_SERVER`] with the port its SRV record gives, for askers that don't look SRV up
/// (the telemetry server's status checks).
pub const OFFICIAL_SERVER_STATUS: &str = "play.playhorizon.dev:24454";
+6 -1
View File
@@ -109,12 +109,17 @@ objc2-foundation = { version = "0.3", default-features = false, features = ["std
wgpu = { version = "30.0.1", default-features = false, features = ["gles"] }
[features]
default = ["audio", "controllers"]
default = ["audio", "controllers", "horizon-extras"]
# Sound output. Without it (and `controllers`) the program links neither libasound nor libudev on
# Linux: the server build (`--no-default-features`, released as horizon-engine; docs/server.md).
audio = ["horizon-client-render/audio"]
# Controllers: gilrs, and the DualSense's own output. Without it only `horizon-ctl pad`'s virtual pad.
controllers = ["dep:gilrs", "horizon-client-input/controllers"]
# Horizon's own parts (docs/forking.md, docs/layers.md): the features below, and the official
# server in the list (`official_servers`).
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.
+23 -6
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) are kept here too.
//! (No AI, Agent Control, Discord Rich Presence) are kept here too.
use std::{collections::VecDeque, fs, path::Path};
pub const FILE: &str = "horizon-extras.txt";
@@ -145,6 +145,8 @@ 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,
@@ -231,6 +233,7 @@ impl Default for Extras {
no_ai: false,
agent_control: false,
repro_keys: false,
discord_presence: true,
splitscreen: false,
splitscreen_two: 0,
splitscreen_three: 0,
@@ -335,6 +338,7 @@ 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),
@@ -372,7 +376,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:{}\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:{}\ndiscordPresence:{}\nsplitscreen:{}\nsplitscreenTwo:{}\nsplitscreenThree:{}\nsplitscreenNames:{}\nwheelCyclesListSettings:{}\nmusicOff:{}\nmusicPage:{}\ncosmeticsMine:{}\n{}cosmeticsHideFx:{}\ncosmeticsSolo:{}\n{}:{}\n{}:{}\n{}:{}\n",
bool_text(self.pack_browser),
bool_text(self.pregen),
bool_text(self.fullbright),
@@ -414,6 +418,7 @@ 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,
@@ -608,6 +613,8 @@ 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.
@@ -684,6 +691,7 @@ 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",
@@ -716,6 +724,7 @@ 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",
@@ -759,6 +768,7 @@ 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",
@@ -782,9 +792,10 @@ impl Item {
ChatIndicator => ChatAnimation,
Pvp(o) => return o.parent().map(Pvp),
Fullbright | DynamicLights | OwnNameTag | HideBlockParticles | MouseTweaks | Sort
| AutoReconnect | NoAi | AgentControl | ReproKeys | Splitscreen | ChatAnimation
| WheelCyclesSettings | MusicPage | PackBrowser | CosmeticsMine | CosmeticsOthers
| CosmeticsHideFx | CosmeticsSolo | HorizonMarks | HorizonMe | Pregen => {
| AutoReconnect | NoAi | AgentControl | ReproKeys | DiscordPresence | Splitscreen
| ChatAnimation | WheelCyclesSettings | MusicPage | PackBrowser | CosmeticsMine
| CosmeticsOthers | CosmeticsHideFx | CosmeticsSolo | HorizonMarks | HorizonMe
| Pregen => {
return None;
}
})
@@ -833,6 +844,9 @@ 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."
}
@@ -913,6 +927,7 @@ 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,
@@ -971,6 +986,7 @@ 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,
@@ -1088,7 +1104,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.wheel_cycles_settings);
assert!(d.discord_presence && !d.wheel_cycles_settings);
assert_eq!(Extras::parse(""), d);
assert_eq!(Extras::parse(&d.serialize()), d);
let mut e = d.clone();
@@ -1127,6 +1143,7 @@ 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));
+56 -6
View File
@@ -239,7 +239,6 @@ mod reuse;
mod stars;
mod surface_setup;
use surface_setup::{AdapterChoice, AdapterFailure, open_adapter, pick_adapter, set_up_surface};
mod off_page;
pub mod submit;
mod sunrise_fan;
mod timing;
@@ -251,7 +250,6 @@ mod wgsl_prefix;
mod world_post;
pub mod world_primitives;
mod world_warp;
use off_page::move_off_page;
/// A section's instance: its camera-relative origin and, in `w`, the step of
/// its fixed-point UVs (`terrain.wgsl`'s `terrain_uv`).
@@ -728,6 +726,62 @@ impl Part {
}
}
}
/// `Renderer::compact_arena`'s and `shrink_translucent`'s moves: what the meshes
/// hold on `page` of `arena` (`part`) goes to its other pages in video memory,
/// until the next range would pass `budget` bytes (it keeps what is left; the
/// first range moves whatever its size, so none keeps the page closed), the
/// copies into `encoder`, the ranges' flat draw ids (`flat`) and, in the
/// section arena, GPU culling's records (`records`) along. False when the other
/// pages have no room left.
fn move_off_page<'a>(
arena: &mut arena::Arena,
encoder: &mut wgpu::CommandEncoder,
mut flat: Option<(&mut flat::Flat, &wgpu::Device, &mut upload::Uploads)>,
mut records: Option<&mut gpu_culling::Records>,
meshes: impl Iterator<Item = &'a mut SectionGpu>,
page: u32,
part: Part,
budget: &mut u64,
) -> bool {
debug_assert!(
records.is_none() || matches!(part, Part::Section { .. }),
"records follow the section arena's ranges"
);
let mut moved = false;
for s in meshes {
// Nothing of a mesh here: split, one without solid quads (its (0, 0) is no
// range of page 0); in the other arenas, one without translucent quads.
let Some((_, start, vertices)) = part.of(s).filter(|r| r.0 == page) else {
continue;
};
let bytes = vertices * arena.stride();
if moved && bytes > *budget {
break;
}
let Some(to) = arena.alloc_moved(vertices) else {
return false;
};
arena.copy(encoder, (page, start), to, vertices);
arena.release(page, start, vertices);
if let (Some((flat, device, uploads)), Some(id)) = (&mut flat, s.flat_id) {
let key = match part {
Part::Translucent => to.0 + flat::TRANSLUCENT_KEYS,
_ => to.0,
};
flat.place(device, encoder, uploads, id, key, to.1, vertices);
}
part.set(s, to);
// Its solid quads start the range in either layout.
if let (Part::Section { .. }, Some(records), Some(id)) =
(part, records.as_deref_mut(), s.gpu_id)
{
records.relocate(id, to.0, u64::from(s.solid_base()));
}
*budget = budget.saturating_sub(bytes);
moved = true;
}
true
}
/// Starts moving a page of a large arena far larger than its ranges in use
/// (`Arena::oversized`) into a new page of the size they get (`Arena::open_page`),
/// for `Renderer::shrink_translucent`: the page closes and `move_off_page` empties
@@ -10062,11 +10116,7 @@ impl Renderer {
let pack_reads_world = pack_post || native_post;
laps.lap("r_uploads");
// Phase 2 of occlusion culling draws over the depth, and a shader pack or TAA reads it.
// Post effects (a resource pack's chains, `use_depth_buffer`) read the world's depth
// after the world pass, as `GameRenderer.render` hands them the main target's.
let post_reads_depth = !frame.title && !frame.post_effects.effects.is_empty();
let keep_depth = pack_reads_world
|| post_reads_depth
|| (taa && self.world_post.taa)
|| oit_on
|| native_render::render_needs().world_depth
@@ -1,59 +0,0 @@
//! `move_off_page`, moved out of `gfx.rs` unchanged.
use super::*;
/// `Renderer::compact_arena`'s and `shrink_translucent`'s moves: what the meshes
/// hold on `page` of `arena` (`part`) goes to its other pages in video memory,
/// until the next range would pass `budget` bytes (it keeps what is left; the
/// first range moves whatever its size, so none keeps the page closed), the
/// copies into `encoder`, the ranges' flat draw ids (`flat`) and, in the
/// section arena, GPU culling's records (`records`) along. False when the other
/// pages have no room left.
pub(super) fn move_off_page<'a>(
arena: &mut arena::Arena,
encoder: &mut wgpu::CommandEncoder,
mut flat: Option<(&mut flat::Flat, &wgpu::Device, &mut upload::Uploads)>,
mut records: Option<&mut gpu_culling::Records>,
meshes: impl Iterator<Item = &'a mut SectionGpu>,
page: u32,
part: Part,
budget: &mut u64,
) -> bool {
debug_assert!(
records.is_none() || matches!(part, Part::Section { .. }),
"records follow the section arena's ranges"
);
let mut moved = false;
for s in meshes {
// Nothing of a mesh here: split, one without solid quads (its (0, 0) is no
// range of page 0); in the other arenas, one without translucent quads.
let Some((_, start, vertices)) = part.of(s).filter(|r| r.0 == page) else {
continue;
};
let bytes = vertices * arena.stride();
if moved && bytes > *budget {
break;
}
let Some(to) = arena.alloc_moved(vertices) else {
return false;
};
arena.copy(encoder, (page, start), to, vertices);
arena.release(page, start, vertices);
if let (Some((flat, device, uploads)), Some(id)) = (&mut flat, s.flat_id) {
let key = match part {
Part::Translucent => to.0 + flat::TRANSLUCENT_KEYS,
_ => to.0,
};
flat.place(device, encoder, uploads, id, key, to.1, vertices);
}
part.set(s, to);
// Its solid quads start the range in either layout.
if let (Part::Section { .. }, Some(records), Some(id)) =
(part, records.as_deref_mut(), s.gpu_id)
{
records.relocate(id, to.0, u64::from(s.solid_base()));
}
*budget = budget.saturating_sub(bytes);
moved = true;
}
true
}
@@ -124,15 +124,21 @@ impl OptionsUi {
Some(open("horizon.pages.open.studio", Page::Studio)),
),
// No AI hides the AI Agent page and everything AI.
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)),
)
},
];
Row::Buttons(
self.extra_button(s, crate::extras::Item::DiscordPresence),
Some(if s.extras.no_ai {
self.extra_button(s, crate::extras::Item::NoAi)
} else {
open("horizon.pages.open.agent", Page::Agent)
}),
),
]
.into_iter()
.chain(
(!s.extras.no_ai)
.then(|| Row::Buttons(self.extra_button(s, crate::extras::Item::NoAi), None)),
)
.collect();
self.added_entries(&mut rows);
rows
}
@@ -337,6 +337,13 @@ impl Settings {
.and_then(|mode| self.video_modes.iter().position(|m| *m == mode))
.map_or(-1, |i| i as i32)
}
/// Loads only the crates' settings stores (`horizon_hooks::settings`) from the files next to
/// `path`, into the seat whose turn it is (`horizon_hooks::seat`): a splitscreen player whose
/// settings were made from P1's rather than loaded.
pub fn load_stores(path: &Path) {
let dir = path.parent();
horizon_hooks::settings::engine::load(&mut |file| fs::read_to_string(dir?.join(file)).ok());
}
pub fn load(path: &Path) -> Self {
let mut horizon = path
.parent()
+5 -2
View File
@@ -119,10 +119,13 @@ audio = ["horizon-client-render/audio", "horizon-client-lib/audio"]
controllers = ["horizon-client-lib/controllers", "dep:gilrs", "dep:horizon-dualsense", "horizon-client-input/controllers"]
# Horizon's own extras over the vanilla game, for a fork that wants them out (docs/forking.md):
# cosmetics and emotes, the "Horizon player" presence, the official server added to
# the server list, the Horizon Settings and Mods pages and Play with Friends. Players always get them; there is no switch. `grep -rn horizon-extras crates/` lists
# the server list, the Horizon Settings and Mods pages, Discord and Play with Friends. 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 = ["friends"]
horizon-extras = ["horizon-client-lib/horizon-extras", "discord", "friends"]
# Online extras a server or a port can leave out; the server build has none of them. "Playing Horizon"
# on Discord (game/discord.rs).
discord = ["horizon-client-lib/discord"]
# Play with Friends: sharing a world behind an invite code (game/friends.rs). Joining one is
# horizon-friends' `game` side, which horizon-official registers (`horizon_hooks::tunnel`).
friends = ["dep:horizon-friends"]
+4
View File
@@ -58,6 +58,8 @@ pub mod debug_overlay;
pub mod debugworld;
pub mod dialog;
pub mod dialog_screen;
#[cfg(feature = "discord")]
pub mod discord_presence;
pub mod end_crystal;
pub mod end_gateway;
pub mod entities;
@@ -100,6 +102,8 @@ pub mod native_data;
pub mod net;
pub mod nether_portal;
pub mod no_resources;
#[cfg(feature = "horizon-extras")]
pub mod official_servers;
pub mod offline_name;
pub mod old_world;
pub mod osk;
@@ -1,30 +1,21 @@
//! Discord Rich Presence: "Playing Horizon" on the player's Discord profile, with what they are
//! doing (docs/native-client.md). A thread of its own talks to the Discord app on this computer
//! through its local IPC socket (Discord's RPC docs: `discord-ipc-N`, frames of opcode u32 LE +
//! length u32 LE + JSON). The game says what the player does on each change ([`set`]), which wakes the thread,
//! length u32 LE + JSON). The game only calls [`set`] each frame: a change wakes the thread,
//! which connects when Discord runs, retries every 15 s when it doesn't, keeps under Discord's
//! update limit and clears the activity on quit. Nothing is sent with the setting off,
//! or with `HORIZON_NO_DISCORD=1`.
//!
//! A Horizon crate (docs/layers.md): the game tells it what the player does through the
//! platform's hooks ([`HOOKS`]: `client_started`, `activity`, `shutdown`), which the official
//! executable (`horizon-official`) registers 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)]
//! or with `HORIZON_NO_DISCORD=1`. It uses nothing else of the game (docs/layers.md: Horizon layer):
//! `game/discord.rs` says what the player does.
use std::collections::VecDeque;
use std::io::{self, Read, Write};
use std::path::PathBuf;
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_platform::hooks::{self, Activity, Dimension, GameMode, Hooks, Play};
use horizon_platform::pages::{Act, Label, Page, Place, Row};
use horizon_platform::settings::Store;
use horizon_command::args::GameMode;
/// The "Horizon" application on Discord's developer portal (public: it names the game). Its
/// Rich Presence assets: `horizon`, the logo.
@@ -49,134 +40,24 @@ const CLOSE: u32 = 2;
const PING: u32 = 3;
const PONG: u32 = 4;
/// The presence's hooks, for `horizon_platform::hooks::register`.
pub static HOOKS: Presence = Presence;
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 Dimension {
Overworld,
Nether,
End,
}
/// 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): the player's own settings' line, the game's first player's. A
/// splitscreen player's settings file loads after the first player's and keeps it.
static ON: AtomicBool = AtomicBool::new(true);
/// Whether the player's settings were loaded once: the later loads are splitscreen players'.
static LOADED: AtomicBool = AtomicBool::new(false);
/// 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) {
// The first player's settings load first; the first load is theirs.
if LOADED.swap(true, Ordering::Relaxed) {
return;
}
match value {
"true" => set_enabled(true),
"false" => set_enabled(false),
_ => {}
}
}
fn save(&self, line: &mut dyn FnMut(&str, &str)) {
line(KEY, if enabled() { "true" } else { "false" });
}
fn is_default(&self) -> bool {
enabled()
}
}
/// 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>) {
rows.push(Row::Toggle {
label: LABEL,
on: enabled(),
});
}
fn act(&self, row: usize, act: Act) {
if row == 0 && 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)]
pub enum Play {
Singleplayer,
/// A server or someone else's LAN world. Its address and name are never shown.
Multiplayer,
/// This world is open to LAN or friends; `players` counts the host, `max` is how many
/// may join.
Hosting {
players: usize,
max: usize,
},
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
@@ -216,15 +97,11 @@ 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,
@@ -237,8 +114,7 @@ static THREAD: OnceLock<Thread> = OnceLock::new();
/// Starts the presence thread, once: when the game starts, never for a dedicated server.
/// `automated` runs (offscreen, benchmarks) only talk to a test's fake Discord.
/// The status [`set`] gave before, the switch included, is the first one sent.
pub fn start(automated: bool) {
pub fn start(enabled: bool, automated: bool) {
let fake_discord = std::env::var_os(IPC_DIR_ENV).is_some();
if THREAD.get().is_some()
|| std::env::var_os("HORIZON_NO_DISCORD").is_some_and(|v| v != "0")
@@ -246,6 +122,9 @@ pub fn start(automated: bool) {
{
return;
}
if !enabled {
lock().wanted.status = Status::Off;
}
match std::thread::Builder::new()
.name("Discord presence".into())
.spawn(run)
@@ -257,13 +136,12 @@ pub fn start(automated: bool) {
}
}
/// What the player does now; only a change wakes the thread. Kept for [`start`] before it.
/// What the player does now; only a change wakes the thread. Free without [`start`].
pub fn set(status: Status) {
let 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) {
let Some(thread) = THREAD.get() else {
return;
};
if lock().wanted.update(status, unix_now()) {
thread.unpark();
}
}
@@ -704,39 +582,6 @@ 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_and_keeps_the_first_players_line() {
let wanted = || lock().wanted.status;
Setting.load(KEY, "false");
// A splitscreen player's settings file loads later and keeps the first player's.
Setting.load(KEY, "true");
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()));
+3 -2
View File
@@ -23,8 +23,9 @@ pub fn main_with(mods: crate::mod_api::Mods) {
horizon_client::telemetry::mark_start();
// Opt-in telemetry keeps each crash report (docs/telemetry.md), whatever the mode.
crash::on_report(horizon_client::telemetry::keep_crash);
// Crates above the engine registered their hooks before this (horizon-official's `main`);
// none registers after this point.
// Crates above the engine registered their hooks before this (horizon-official's `main`),
// then the Horizon parts still in this crate; none registers after this point.
game::player_hooks::register();
horizon_hooks::engine::seal();
horizon_client::spans::startup::init(std::time::Instant::now());
horizon_client::spans::trace::start_from_env();
+46 -9
View File
@@ -49,7 +49,6 @@ fn pack_files(world_tables: &Option<WorldTables>) -> horizon_world::loot::PackOv
}
}
mod activity;
mod advancements;
mod advancements_screen;
mod agent;
@@ -129,6 +128,8 @@ mod decorated_pot;
mod dedicated;
mod deflect;
mod dialogs;
#[cfg(feature = "discord")]
mod discord;
mod dispense_fixture;
mod dispenser;
mod dragon;
@@ -372,6 +373,7 @@ mod place_feature_tests;
mod play_input;
mod play_keys;
mod player_equip;
pub(crate) mod player_hooks;
mod player_spawn;
mod pointer_capture;
#[cfg(test)]
@@ -6474,6 +6476,18 @@ impl Game<'_> {
self.draw_tooltip_lines(batch, &lines, self.cursor.0, self.cursor.1);
}
}
/// `AbstractContainerScreen` keeps `isQuickCrafting` and the drag it
/// belongs to on the screen. Horizon keeps both on `Game` (`inv_clicks`,
/// `inv_drag`, `last_inv_click`), so they are dropped when the pointer
/// changes hands: a screen that opens or closes starts with its own
/// state, and a press whose release never reached the screen -- the
/// pointer left the window, or the screen closed first -- would
/// otherwise leave every later press on every later screen swallowed.
pub(super) fn reset_inventory_clicks(&mut self) {
self.inv_clicks.cancel_drag();
self.inv_drag = None;
self.last_inv_click = None;
}
pub(super) fn close_container(&mut self) {
// `Gui.setScreen` clears the old screen's focus before it goes: the
// key that closed the container (Tab bound to the inventory) has
@@ -6517,6 +6531,20 @@ impl Game<'_> {
}
self.close_ender_chest();
}
/// A sound a menu click made the player play (a bundle's insert or
/// remove): `Entity.playSound` at the player.
pub(super) fn player_menu_sound(&mut self, sound: &str) {
let Some(p) = self.player.as_ref() else {
return;
};
let at = [p.position.x, p.position.y, p.position.z];
let (volume, pitch) = if sound == horizon_client::bundle::INSERT_FAIL {
(1.0, 1.0)
} else {
(0.8, 0.8 + self.world.random.next_float() * 0.4)
};
self.audio.play(sound, Some(at), volume, pitch);
}
/// `Camera.setRotation`: the camera's orientation, turned round in
/// third-person front view.
pub(super) fn camera_orientation(&self) -> glam::Quat {
@@ -6745,8 +6773,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);
@@ -7465,7 +7493,6 @@ impl Game<'_> {
self.spawn_phantoms();
self.tick_advancements();
self.tick_player_model();
self.tick_emotes();
Ok(())
}
@@ -11403,7 +11430,8 @@ impl Game<'_> {
self.heal_hold_latches();
// For the hooks above the engine (the "I'm playing" ping): said when it changes.
horizon_hooks::engine::published(self.is_published());
self.report_activity();
#[cfg(feature = "discord")]
self.update_discord_presence();
let steps = self.clock.advance(elapsed);
for _ in 0..steps {
let started = Instant::now();
@@ -12663,8 +12691,10 @@ pub fn run_with() -> Result<()> {
// horizon-ctl drives: those start in a source checkout and wrote the list there.
if !options.offscreen && !synchronous_startup(&options) {
let version = horizon_client::telemetry::version();
let game_dir = horizon_client::logging::player_dir();
horizon_hooks::engine::started(&horizon_hooks::Started::new(&version, &game_dir));
horizon_hooks::engine::started(&horizon_hooks::Started::new(&version));
// horizon-extras: the official server is Horizon's (docs/forking.md).
#[cfg(feature = "horizon-extras")]
horizon_client::official_servers::update(&horizon_client::logging::player_dir());
}
mods::register_components(&vanilla_root)?;
mods::register_standalone_models();
@@ -12692,8 +12722,15 @@ pub fn run_with() -> Result<()> {
if options.execution_role.is_client() {
narrator::start(&early_settings);
}
// A game window's hooks (Discord's presence): never for a server.
activity::client_started(&options);
// "Playing Horizon" on Discord: never for a server; an automated run only talks to a test's fake Discord.
#[cfg(feature = "discord")]
if options.dedicated.is_none() {
let automated = options.benchmark.is_some()
|| options.offscreen
|| options.frames.is_some()
|| options.world_frames.is_some();
horizon_client::discord_presence::start(early_settings.extras.discord_presence, automated);
}
// Snapshots of derived tables live in the game folder, never beside the
// shared resources.
Behaviours::set_cache_dir(std::env::current_dir()?.join(".horizon-cache"));
@@ -1,68 +0,0 @@
//! What the player is doing, for the hooks above the engine (`Hooks::activity`: the Horizon
//! layer's Discord presence shows it).
use super::*;
use horizon_client::command::args::GameMode;
use horizon_hooks::{Activity, Client, Dimension, Play};
/// At startup, for a game window (never a dedicated server's): the player's first activity, then
/// `Hooks::client_started`.
pub(super) fn client_started(options: &Options) {
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,
},
}
}
}
File diff suppressed because it is too large. Load diff
@@ -10,6 +10,7 @@ use horizon_client::mobs::Mob;
use horizon_client::mobs::anim::BodyPose;
use horizon_cosmetics::account::OwnedEmote;
use horizon_cosmetics::{Item, Playing, Slot};
use horizon_hooks::players::PlayerModels;
use std::sync::Arc;
/// How far the player may drift (blocks) before the emote stops: a nudge, not a step.
@@ -37,10 +38,15 @@ pub(in crate::game) struct Emotes {
impl Emotes {
/// The emote to tell the service about, while it plays: its id and whether it loops.
pub(in crate::game) fn sending(&self) -> Option<(String, bool)> {
pub(in crate::game) fn sending(&self) -> Option<(&str, bool)> {
let p = self.playing.as_ref().filter(|p| p.stop.is_none())?;
let looping = p.item.emote.as_ref().is_some_and(|e| e.looping);
Some((p.item.id.clone(), looping))
Some((&p.item.id, looping))
}
/// When the emote that pulled the camera back started (the own model's age), while it plays.
pub(in crate::game) fn pull(&self) -> Option<f32> {
self.camera_from.and(self.playing.as_ref()).map(|p| p.start)
}
}
@@ -59,23 +65,43 @@ impl BodyPose for EmotePose {
}
}
/// The emote `m` plays, if any.
pub(in crate::game) fn playing(m: &Mob) -> Option<&Playing> {
let any: &dyn std::any::Any = &**m.emote.as_ref()?;
/// The emote a model's pose plays, if it is one.
pub(in crate::game) fn pose_playing(pose: Option<&Arc<dyn BodyPose>>) -> Option<&Playing> {
let any: &dyn std::any::Any = &**pose?;
any.downcast_ref::<EmotePose>().map(|e| &e.0)
}
/// Makes `m` play `next`, unless it plays that already (a new one allocates).
pub(in crate::game) fn set_playing(m: &mut Mob, next: Option<Playing>) {
let same = match (playing(m), &next) {
/// The pose that plays `next` where `now` is laid, unless it plays that already (None: keep
/// it; a new one allocates).
fn replaced(
now: Option<&Arc<dyn BodyPose>>,
next: Option<Playing>,
) -> Option<Option<Arc<dyn BodyPose>>> {
let same = match (pose_playing(now), &next) {
(None, None) => true,
(Some(a), Some(b)) => {
Arc::ptr_eq(&a.item, &b.item) && a.start == b.start && a.stop == b.stop
}
_ => false,
};
if !same {
m.emote = next.map(|p| Arc::new(EmotePose(p)) as Arc<dyn BodyPose>);
(!same).then(|| next.map(|p| Arc::new(EmotePose(p)) as Arc<dyn BodyPose>))
}
/// Makes `m` play `next`, unless it plays that already.
pub(in crate::game) fn set_playing(m: &mut Mob, next: Option<Playing>) {
if let Some(pose) = replaced(m.emote.as_ref(), next) {
m.emote = pose;
}
}
/// Makes model `id` play `next`, unless it plays that already.
pub(in crate::game) fn set_model_playing(
models: &mut dyn PlayerModels,
id: u32,
next: Option<Playing>,
) {
if let Some(pose) = replaced(models.body_pose(id), next) {
models.set_body_pose(id, pose);
}
}
@@ -229,7 +255,7 @@ impl Game<'_> {
}
}
/// Once a tick: my emote, then the other players'.
/// Once a tick: my emote (the others' are `cosmetics::Seat::tick`'s, a hook's).
pub(in crate::game) fn tick_emotes(&mut self) {
if let Some(done) = self
.cosmetics
@@ -250,20 +276,6 @@ impl Game<'_> {
}
}
self.tick_emote();
self.tick_others_emotes();
}
/// How far the camera stands back while an emote pulls it (1: the usual distance): it eases
/// out over the emote's first quarter second.
pub(in crate::game) fn emote_camera_pull(&self, partial: f32) -> f32 {
let (Some(p), Some(m)) = (&self.cosmetics.emotes.playing, &self.player_model) else {
return 1.0;
};
if self.cosmetics.emotes.camera_from.is_none() {
return 1.0;
}
let t = ((m.age as f32 + partial - p.start) / 5.0).clamp(0.0, 1.0);
0.35 + 0.65 * t * t * (3.0 - 2.0 * t)
}
}
@@ -314,8 +314,7 @@ impl Game<'_> {
light = FULL_LIGHT;
}
let mut poses = self.cosmetics.poses.borrow_mut();
let poses = &mut poses.1;
self.pose_companion(item, m, alpha, poses, &model);
self.cosmetics_model(&model, matrix, poses, uv, light, false, None, out);
self.pose_companion(item, m, alpha, &mut poses, &model);
self.cosmetics_model(&model, matrix, &poses, uv, light, false, None, out);
}
}
@@ -280,25 +280,18 @@ impl Game<'_> {
self.spin_attack_geometry(mob, camera, alpha as f32, packed, out);
self.stuck_in_body_geometry(mob, mob.id as i32, camera, alpha as f32, packed, out);
}
// Cosmetics (game/cosmetics.rs), posed on the GPU with the mob's own jobs: culled
// with it.
// What crates draw on players and mannequins (`game/player_hooks.rs`: cosmetics),
// posed on the GPU with the mob's own jobs: culled with it.
if !layers_only
&& super::super::cosmetics::enabled()
&& horizon_hooks::engine::any()
&& (is_player || &*mob.kind.id == "minecraft:mannequin")
{
let mark = self.skin_frame.borrow().mark();
let base = horizon_client::mobs::render::base_transform(mob, alpha as f32, camera);
let gpu = skinning;
if let Some((lo, hi)) = self.cosmetics_geometry(
mob,
false,
base,
Some(camera),
alpha as f32,
packed,
gpu,
out,
) {
if let Some((lo, hi)) =
self.player_layers_geometry(mob, base, camera, alpha as f32, packed, gpu, out)
{
skinned = Some(match skinned {
Some((first, Some((l, h)))) => (first, Some((l.min(lo), h.max(hi)))),
Some((first, None)) => (first, Some((lo, hi))),
+49
View File
@@ -0,0 +1,49 @@
//! What the player is doing, for Discord Rich Presence (`horizon_client::discord_presence`).
use super::*;
use horizon_client::discord_presence::{self as presence, Dimension, Play, Status};
impl Game<'_> {
/// Each frame: only a change reaches the presence thread.
pub(super) fn update_discord_presence(&self) {
// One Discord status per computer: P1's, not a splitscreen player's.
if self.options.split_guest {
return;
}
presence::set(self.discord_status());
}
fn discord_status(&self) -> Status {
if !self.settings.extras.discord_presence {
return Status::Off;
}
if self.title.is_some() {
return Status::Menus;
}
let play = if let Some(host) = &self.lan {
Play::Hosting {
players: 1 + host.hub.guests.len(),
max: horizon_client::lan::MAX_PLAYERS,
}
} else if let Some(server) = &self.integrated {
match (server.published(), &self.remote) {
(Some(_), Some(remote)) => Play::Hosting {
players: remote.player_count(),
max: horizon_client::lan::MAX_PLAYERS,
},
_ => Play::Singleplayer,
}
} else if self.remote.is_some() {
Play::Multiplayer
} else {
Play::Singleplayer
};
Status::World {
play,
dimension: match self.dim {
Dim::Overworld => Dimension::Overworld,
Dim::Nether => Dimension::Nether,
Dim::End => Dimension::End,
},
mode: self.mode,
}
}
}
@@ -34,6 +34,8 @@ pub(super) fn run_event_loop(mut game: Game<'_>) -> Result<()> {
narrator::stop();
leave_fullscreen_after(&result, &mut game.settings);
result?;
#[cfg(feature = "discord")]
horizon_client::discord_presence::stop();
write_report(&game)?;
if let Some(e) = game.error.take() {
return Err(e);
@@ -899,32 +899,4 @@ impl Game<'_> {
f.input_clicked(world_recipes!(self), before);
}
}
/// `AbstractContainerScreen` keeps `isQuickCrafting` and the drag it
/// belongs to on the screen. Horizon keeps both on `Game` (`inv_clicks`,
/// `inv_drag`, `last_inv_click`), so they are dropped when the pointer
/// changes hands: a screen that opens or closes starts with its own
/// state, and a press whose release never reached the screen -- the
/// pointer left the window, or the screen closed first -- would
/// otherwise leave every later press on every later screen swallowed.
pub(super) fn reset_inventory_clicks(&mut self) {
self.inv_clicks.cancel_drag();
self.inv_drag = None;
self.last_inv_click = None;
}
/// A sound a menu click made the player play (a bundle's insert or
/// remove): `Entity.playSound` at the player.
pub(super) fn player_menu_sound(&mut self, sound: &str) {
let Some(p) = self.player.as_ref() else {
return;
};
let at = [p.position.x, p.position.y, p.position.z];
let (volume, pitch) = if sound == horizon_client::bundle::INSERT_FAIL {
(1.0, 1.0)
} else {
(0.8, 0.8 + self.world.random.next_float() * 0.4)
};
self.audio.play(sound, Some(at), volume, pitch);
}
}
+267 -4
View File
@@ -28,10 +28,6 @@ use horizon_core::{
};
use horizon_world::registry::BlockStateId;
use std::{collections::HashMap, sync::Arc};
mod index;
mod nbt;
pub(super) use index::CartIndex;
use index::cart_at;
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
pub(super) enum CartKind {
@@ -191,6 +187,102 @@ pub(super) const TNT_PRIME_EVENT: i8 = 70;
mod display;
use display::{cart_render_bounds, display_offset, shown_unless_air};
pub(super) use display::{default_display_offset, forget_unreadable_display};
/// The carts' and mobs' boxes for one tick's cart pushes (`push_from_cart`,
/// `pickup_from_cart`), so a cart reads its neighbours instead of every cart and
/// every mob of the level. Both answer in slot order, as the scans did.
///
/// The cart grid follows each ticked cart ([`CartIndex::moved`]); it is
/// rebuilt once the list's length changed (a cart gone or added), and dropped
/// after a command, which can move or replace any cart
/// ([`CartIndex::forget_carts`]). The mob grid holds until the mob
/// list's next write ([`ListStamp`]). A native mod's hook can move either kind
/// unseen, so with mods loaded both scans stay.
#[derive(Default)]
pub(super) struct CartIndex {
carts: Option<(SeenGrid, Vec<u32>)>,
/// The grid, and the carts a mob rides.
mobs: Option<(ListStamp, SeenGrid, FxHashSet<u32>)>,
near: Vec<u32>,
}
impl CartIndex {
fn usable() -> bool {
horizon_client::mod_api::mods().is_empty()
}
/// The cart in slot `i` has these bounds now.
fn moved(&mut self, i: usize, bounds: Aabb) {
if let Some((grid, ids)) = &mut self.carts
&& i < ids.len()
{
grid.update(i, bounds);
}
}
fn forget_carts(&mut self) {
self.carts = None;
}
/// The slots of the carts whose box may meet `area`, for the cart in slot
/// `i` (just moved); false: scan them all.
fn carts_near(&mut self, carts: &[Minecart], i: usize, area: Aabb, out: &mut Vec<u32>) -> bool {
if !Self::usable() {
return false;
}
let fresh = |ids: &[u32]| ids.len() == carts.len() && ids[i] == carts[i].id;
if !self.carts.as_ref().is_some_and(|(_, ids)| fresh(ids)) {
self.carts = Some((
SeenGrid::new(carts.iter().map(Minecart::bounds)),
carts.iter().map(|c| c.id).collect(),
));
}
let Some((grid, ids)) = &mut self.carts else {
return false;
};
grid.update(i, carts[i].bounds());
if !grid.candidates(area, out) {
return false;
}
if out.iter().any(|&j| ids[j as usize] != carts[j as usize].id) {
// A cart was replaced in place: start over next time.
self.carts = None;
return false;
}
true
}
/// The slots of the mobs whose box may meet `area` into `out`, and
/// whether a mob rides cart `cart`; None: scan them all.
fn mobs_near(
&mut self,
mobs: &JoinList<Mob>,
cart: u32,
area: Aabb,
out: &mut Vec<u32>,
) -> Option<bool> {
if !Self::usable() {
return None;
}
if !self
.mobs
.as_ref()
.is_some_and(|(stamp, ..)| *stamp == mobs.stamp())
{
self.mobs = Some((
mobs.stamp(),
SeenGrid::new(mobs.iter().map(Mob::bounds)),
mobs.iter().filter_map(|m| m.extra.mount).collect(),
));
}
let (_, grid, mounted) = self.mobs.as_ref()?;
grid.candidates(area, out).then(|| mounted.contains(&cart))
}
}
/// Where the cart `id` is in `carts`: at `hint` (where it was a moment ago,
/// the usual case while carts tick in order) or found by a scan. A world
/// with hundreds of mineshaft chest carts asked this of every cart several
/// times a tick.
fn cart_at(carts: &[Minecart], id: u32, hint: usize) -> Option<usize> {
if carts.get(hint).is_some_and(|c| c.id == id) {
return Some(hint);
}
carts.iter().position(|c| c.id == id)
}
/// `AbstractMinecart.push(entity)` for a non-cart `entity` at `feet`:
/// the push the cart loses (the entity gets a quarter of it back).
pub(super) fn cart_push_from(feet: Vec3, cart: Vec3) -> Option<(f64, f64)> {
@@ -2892,6 +2984,177 @@ impl Game<'_> {
.render_state(&scene, state.0, m, light, out);
}
}
/// Saving and loading.
impl Minecart {
pub fn to_nbt(
&self,
uuid: [i32; 4],
raw: Option<&horizon_world::nbt::Tag>,
) -> horizon_world::nbt::Tag {
use horizon_world::nbt::{JavaString, Tag};
let mut tag = horizon_client::entity_nbt::vehicle_to_nbt(
self.kind.id(),
self.position,
self.velocity,
[self.yaw, self.pitch],
(self.on_ground, self.fall_distance),
uuid,
raw,
);
horizon_client::entity_nbt::write_saved_flags(&mut tag, self.on_ground, self.no_gravity);
if let Tag::Compound(map) = &mut tag {
// ContainerEntity.addChestVehicleSaveData belongs only to the
// container subclasses, never to a plain or specialized cart.
if self.kind.slots() == 0 {
map.remove(&JavaString::from("Items"));
}
if self.kind == CartKind::CommandBlock {
for key in ["LastOutput", "LastExecution"] {
map.remove(&JavaString::from(key));
}
}
map.remove(&JavaString::from("DisplayOffset"));
map.remove(&JavaString::from("DisplayState"));
let mut put = |k: &str, v: Tag| {
map.insert(JavaString::from(k), v);
};
put("FlippedRotation", Tag::Byte(i8::from(self.flipped)));
// AbstractMinecart saves firstTick as HasTicked. A cart never burns:
// Entity.move sets -getFireImmuneTicks(), 0 for all but players.
put("HasTicked", Tag::Byte(i8::from(self.first_tick)));
put("Fire", Tag::Short(0));
// addAdditionalSaveData: the offset only off its default, the state only when set.
if let Some(offset) = self.custom_offset
&& f64::from(offset) != default_display_offset(self.kind)
{
put("DisplayOffset", Tag::Int(offset));
}
if let Some(state) = &self.custom_display {
put("DisplayState", state.clone());
}
match self.kind {
CartKind::Chest | CartKind::Hopper => {
put("Items", horizon_client::save::encode_items(&self.items));
if self.kind == CartKind::Hopper {
put("Enabled", Tag::Byte(i8::from(self.enabled)));
}
}
CartKind::Furnace => {
put("PushX", Tag::Double(self.push.x.to_bits()));
put("PushZ", Tag::Double(self.push.z.to_bits()));
put("Fuel", Tag::Short(self.fuel as i16));
}
CartKind::Tnt => put("fuse", Tag::Int(self.fuse)),
// BaseCommandBlock.save (its CustomName shares the entity's key).
CartKind::CommandBlock => {
for (key, value) in self.command.save() {
if !matches!(key, "CustomName" | "powered" | "conditionMet" | "auto") {
put(key, value);
}
}
}
// BaseSpawner.save: the delays, ranges, SpawnData and SpawnPotentials.
CartKind::Spawner => {
if let Some(spawner) = &self.spawner {
for (key, value) in
horizon_client::mobs::spawner::SpawnerExt::save(&**spawner)
{
put(key, value);
}
}
}
CartKind::Rideable => {}
}
// A loaded loot table goes back while the items are still its roll;
// otherwise the items replace it, as vanilla's unpackLootTable does.
if self.loot.as_ref().is_some_and(|l| l.restore(map)) {
if let Some(table) = horizon_client::save::text(map.get(&"LootTable".into()))
.and_then(|id| horizon_client_core::identifier::normalize_id(&id))
{
map.insert("LootTable".into(), Tag::String(table.as_str().into()));
}
} else {
map.remove(&JavaString::from("LootTable"));
map.remove(&JavaString::from("LootTableSeed"));
}
}
tag
}
pub fn from_nbt(tag: &horizon_world::nbt::Tag, id: u32, kind: CartKind) -> Option<Self> {
let (position, _, _) = horizon_client::entity_nbt::boat_from_nbt(tag)?;
let mut c = Self::new(id, kind, position);
c.load(tag)?;
Some(c)
}
/// `Entity.load` on this cart: the saved fields are read over the live
/// ones, the rest (riders, lerp, hurt state) stays; its kind never changes.
/// `None`, with nothing changed, when the compound has no position.
pub fn load(&mut self, tag: &horizon_world::nbt::Tag) -> Option<()> {
use horizon_world::nbt::Tag;
let (position, velocity, yaw) = horizon_client::entity_nbt::boat_from_nbt(tag)?;
let kind = self.kind;
let c = self;
c.position = position;
c.previous = position;
c.velocity = velocity;
c.yaw = yaw;
c.previous_yaw = yaw;
c.no_gravity = horizon_client::entity_nbt::flag(tag, "NoGravity");
c.on_ground = horizon_client::entity_nbt::flag(tag, "OnGround");
c.pitch = horizon_client::entity_nbt::rotation(tag)[1];
c.previous_pitch = c.pitch;
// ValueInput's numeric getters take any NumericTag: getIntOr is its
// intValue, getShortOr the low 16 bits of it, getBooleanOr
// `byteValue() != 0` (the low 8), getDoubleOr its doubleValue.
let int = |k: &str| {
tag.get(k)
.and_then(horizon_client_core::nbt_path::int_value)
};
let flag = |k: &str, default: bool| int(k).map_or(default, |v| v as i8 != 0);
let double = |k: &str| match tag.get(k) {
Some(Tag::Byte(v)) => f64::from(*v),
Some(Tag::Short(v)) => f64::from(*v),
Some(Tag::Int(v)) => f64::from(*v),
Some(Tag::Long(v)) => *v as f64,
Some(Tag::Float(v)) => f64::from(f32::from_bits(*v)),
Some(Tag::Double(v)) => f64::from_bits(*v),
_ => 0.0,
};
// Entity.load: OnGround and fall_distance, as saved.
(c.on_ground, c.fall_distance) = horizon_client::entity_nbt::ground_from_nbt(tag);
c.flipped = flag("FlippedRotation", false);
c.first_tick = flag("HasTicked", false);
if kind.slots() > 0 {
c.items = horizon_client::save::decode_items(tag.get("Items"), kind.slots());
}
c.enabled = flag("Enabled", true);
c.fuel = int("Fuel").map_or(0, |v| i32::from(v as i16));
// readAdditionalSaveData: the saved push has no y.
c.push = Vec3::new(double("PushX"), 0.0, double("PushZ"));
c.fuse = int("fuse").unwrap_or(-1);
// readAdditionalSaveData: DisplayState, DisplayOffset (default offset when absent).
c.custom_display = tag.get("DisplayState").cloned();
c.custom_offset = int("DisplayOffset");
if kind == CartKind::CommandBlock {
c.command = horizon_client::command_block::CommandBlock::load(tag);
// MinecartCommandBlock.readAdditionalSaveData sets the synched command.
// A changed value invokes onSyncedDataUpdated -> setCommand, resetting successes.
if c.synced_command != c.command.command {
c.synced_command.clone_from(&c.command.command);
c.command.set_command(&c.synced_command);
}
c.synced_output = c.command.last_output_string();
}
// MinecartSpawner.readAdditionalSaveData: BaseSpawner.load.
if kind == CartKind::Spawner {
c.spawner = Some(Box::new(horizon_client::mobs::spawner::SpawnerExt::load(
tag,
)));
}
Some(())
}
}
/// `VehicleEntity.hurtServer`'s destroy test: a creative player, damage past
/// 40, or (`MinecartTNT.shouldSourceDestroy`) fire or an explosion hitting a
/// TNT cart.
@@ -1,105 +0,0 @@
//! The carts' and mobs' boxes for a tick's cart pushes ([`CartIndex`]).
use super::*;
/// The carts' and mobs' boxes for one tick's cart pushes (`push_from_cart`,
/// `pickup_from_cart`), so a cart reads its neighbours instead of every cart and
/// every mob of the level. Both answer in slot order, as the scans did.
///
/// The cart grid follows each ticked cart ([`CartIndex::moved`]); it is
/// rebuilt once the list's length changed (a cart gone or added), and dropped
/// after a command, which can move or replace any cart
/// ([`CartIndex::forget_carts`]). The mob grid holds until the mob
/// list's next write ([`ListStamp`]). A native mod's hook can move either kind
/// unseen, so with mods loaded both scans stay.
#[derive(Default)]
pub(in crate::game) struct CartIndex {
pub(super) carts: Option<(SeenGrid, Vec<u32>)>,
/// The grid, and the carts a mob rides.
pub(super) mobs: Option<(ListStamp, SeenGrid, FxHashSet<u32>)>,
pub(super) near: Vec<u32>,
}
impl CartIndex {
pub(super) fn usable() -> bool {
horizon_client::mod_api::mods().is_empty()
}
/// The cart in slot `i` has these bounds now.
pub(super) fn moved(&mut self, i: usize, bounds: Aabb) {
if let Some((grid, ids)) = &mut self.carts
&& i < ids.len()
{
grid.update(i, bounds);
}
}
pub(super) fn forget_carts(&mut self) {
self.carts = None;
}
/// The slots of the carts whose box may meet `area`, for the cart in slot
/// `i` (just moved); false: scan them all.
pub(super) fn carts_near(
&mut self,
carts: &[Minecart],
i: usize,
area: Aabb,
out: &mut Vec<u32>,
) -> bool {
if !Self::usable() {
return false;
}
let fresh = |ids: &[u32]| ids.len() == carts.len() && ids[i] == carts[i].id;
if !self.carts.as_ref().is_some_and(|(_, ids)| fresh(ids)) {
self.carts = Some((
SeenGrid::new(carts.iter().map(Minecart::bounds)),
carts.iter().map(|c| c.id).collect(),
));
}
let Some((grid, ids)) = &mut self.carts else {
return false;
};
grid.update(i, carts[i].bounds());
if !grid.candidates(area, out) {
return false;
}
if out.iter().any(|&j| ids[j as usize] != carts[j as usize].id) {
// A cart was replaced in place: start over next time.
self.carts = None;
return false;
}
true
}
/// The slots of the mobs whose box may meet `area` into `out`, and
/// whether a mob rides cart `cart`; None: scan them all.
pub(super) fn mobs_near(
&mut self,
mobs: &JoinList<Mob>,
cart: u32,
area: Aabb,
out: &mut Vec<u32>,
) -> Option<bool> {
if !Self::usable() {
return None;
}
if !self
.mobs
.as_ref()
.is_some_and(|(stamp, ..)| *stamp == mobs.stamp())
{
self.mobs = Some((
mobs.stamp(),
SeenGrid::new(mobs.iter().map(Mob::bounds)),
mobs.iter().filter_map(|m| m.extra.mount).collect(),
));
}
let (_, grid, mounted) = self.mobs.as_ref()?;
grid.candidates(area, out).then(|| mounted.contains(&cart))
}
}
/// Where the cart `id` is in `carts`: at `hint` (where it was a moment ago,
/// the usual case while carts tick in order) or found by a scan. A world
/// with hundreds of mineshaft chest carts asked this of every cart several
/// times a tick.
pub(super) fn cart_at(carts: &[Minecart], id: u32, hint: usize) -> Option<usize> {
if carts.get(hint).is_some_and(|c| c.id == id) {
return Some(hint);
}
carts.iter().position(|c| c.id == id)
}
@@ -1,173 +0,0 @@
//! Saving and loading a minecart.
use super::*;
/// Saving and loading.
impl Minecart {
pub fn to_nbt(
&self,
uuid: [i32; 4],
raw: Option<&horizon_world::nbt::Tag>,
) -> horizon_world::nbt::Tag {
use horizon_world::nbt::{JavaString, Tag};
let mut tag = horizon_client::entity_nbt::vehicle_to_nbt(
self.kind.id(),
self.position,
self.velocity,
[self.yaw, self.pitch],
(self.on_ground, self.fall_distance),
uuid,
raw,
);
horizon_client::entity_nbt::write_saved_flags(&mut tag, self.on_ground, self.no_gravity);
if let Tag::Compound(map) = &mut tag {
// ContainerEntity.addChestVehicleSaveData belongs only to the
// container subclasses, never to a plain or specialized cart.
if self.kind.slots() == 0 {
map.remove(&JavaString::from("Items"));
}
if self.kind == CartKind::CommandBlock {
for key in ["LastOutput", "LastExecution"] {
map.remove(&JavaString::from(key));
}
}
map.remove(&JavaString::from("DisplayOffset"));
map.remove(&JavaString::from("DisplayState"));
let mut put = |k: &str, v: Tag| {
map.insert(JavaString::from(k), v);
};
put("FlippedRotation", Tag::Byte(i8::from(self.flipped)));
// AbstractMinecart saves firstTick as HasTicked. A cart never burns:
// Entity.move sets -getFireImmuneTicks(), 0 for all but players.
put("HasTicked", Tag::Byte(i8::from(self.first_tick)));
put("Fire", Tag::Short(0));
// addAdditionalSaveData: the offset only off its default, the state only when set.
if let Some(offset) = self.custom_offset
&& f64::from(offset) != default_display_offset(self.kind)
{
put("DisplayOffset", Tag::Int(offset));
}
if let Some(state) = &self.custom_display {
put("DisplayState", state.clone());
}
match self.kind {
CartKind::Chest | CartKind::Hopper => {
put("Items", horizon_client::save::encode_items(&self.items));
if self.kind == CartKind::Hopper {
put("Enabled", Tag::Byte(i8::from(self.enabled)));
}
}
CartKind::Furnace => {
put("PushX", Tag::Double(self.push.x.to_bits()));
put("PushZ", Tag::Double(self.push.z.to_bits()));
put("Fuel", Tag::Short(self.fuel as i16));
}
CartKind::Tnt => put("fuse", Tag::Int(self.fuse)),
// BaseCommandBlock.save (its CustomName shares the entity's key).
CartKind::CommandBlock => {
for (key, value) in self.command.save() {
if !matches!(key, "CustomName" | "powered" | "conditionMet" | "auto") {
put(key, value);
}
}
}
// BaseSpawner.save: the delays, ranges, SpawnData and SpawnPotentials.
CartKind::Spawner => {
if let Some(spawner) = &self.spawner {
for (key, value) in
horizon_client::mobs::spawner::SpawnerExt::save(&**spawner)
{
put(key, value);
}
}
}
CartKind::Rideable => {}
}
// A loaded loot table goes back while the items are still its roll;
// otherwise the items replace it, as vanilla's unpackLootTable does.
if self.loot.as_ref().is_some_and(|l| l.restore(map)) {
if let Some(table) = horizon_client::save::text(map.get(&"LootTable".into()))
.and_then(|id| horizon_client_core::identifier::normalize_id(&id))
{
map.insert("LootTable".into(), Tag::String(table.as_str().into()));
}
} else {
map.remove(&JavaString::from("LootTable"));
map.remove(&JavaString::from("LootTableSeed"));
}
}
tag
}
pub fn from_nbt(tag: &horizon_world::nbt::Tag, id: u32, kind: CartKind) -> Option<Self> {
let (position, _, _) = horizon_client::entity_nbt::boat_from_nbt(tag)?;
let mut c = Self::new(id, kind, position);
c.load(tag)?;
Some(c)
}
/// `Entity.load` on this cart: the saved fields are read over the live
/// ones, the rest (riders, lerp, hurt state) stays; its kind never changes.
/// `None`, with nothing changed, when the compound has no position.
pub fn load(&mut self, tag: &horizon_world::nbt::Tag) -> Option<()> {
use horizon_world::nbt::Tag;
let (position, velocity, yaw) = horizon_client::entity_nbt::boat_from_nbt(tag)?;
let kind = self.kind;
let c = self;
c.position = position;
c.previous = position;
c.velocity = velocity;
c.yaw = yaw;
c.previous_yaw = yaw;
c.no_gravity = horizon_client::entity_nbt::flag(tag, "NoGravity");
c.on_ground = horizon_client::entity_nbt::flag(tag, "OnGround");
c.pitch = horizon_client::entity_nbt::rotation(tag)[1];
c.previous_pitch = c.pitch;
// ValueInput's numeric getters take any NumericTag: getIntOr is its
// intValue, getShortOr the low 16 bits of it, getBooleanOr
// `byteValue() != 0` (the low 8), getDoubleOr its doubleValue.
let int = |k: &str| {
tag.get(k)
.and_then(horizon_client_core::nbt_path::int_value)
};
let flag = |k: &str, default: bool| int(k).map_or(default, |v| v as i8 != 0);
let double = |k: &str| match tag.get(k) {
Some(Tag::Byte(v)) => f64::from(*v),
Some(Tag::Short(v)) => f64::from(*v),
Some(Tag::Int(v)) => f64::from(*v),
Some(Tag::Long(v)) => *v as f64,
Some(Tag::Float(v)) => f64::from(f32::from_bits(*v)),
Some(Tag::Double(v)) => f64::from_bits(*v),
_ => 0.0,
};
// Entity.load: OnGround and fall_distance, as saved.
(c.on_ground, c.fall_distance) = horizon_client::entity_nbt::ground_from_nbt(tag);
c.flipped = flag("FlippedRotation", false);
c.first_tick = flag("HasTicked", false);
if kind.slots() > 0 {
c.items = horizon_client::save::decode_items(tag.get("Items"), kind.slots());
}
c.enabled = flag("Enabled", true);
c.fuel = int("Fuel").map_or(0, |v| i32::from(v as i16));
// readAdditionalSaveData: the saved push has no y.
c.push = Vec3::new(double("PushX"), 0.0, double("PushZ"));
c.fuse = int("fuse").unwrap_or(-1);
// readAdditionalSaveData: DisplayState, DisplayOffset (default offset when absent).
c.custom_display = tag.get("DisplayState").cloned();
c.custom_offset = int("DisplayOffset");
if kind == CartKind::CommandBlock {
c.command = horizon_client::command_block::CommandBlock::load(tag);
// MinecartCommandBlock.readAdditionalSaveData sets the synched command.
// A changed value invokes onSyncedDataUpdated -> setCommand, resetting successes.
if c.synced_command != c.command.command {
c.synced_command.clone_from(&c.command.command);
c.command.set_command(&c.synced_command);
}
c.synced_output = c.command.last_output_string();
}
// MinecartSpawner.readAdditionalSaveData: BaseSpawner.load.
if kind == CartKind::Spawner {
c.spawner = Some(Box::new(horizon_client::mobs::spawner::SpawnerExt::load(
tag,
)));
}
Some(())
}
}
+6 -9
View File
@@ -300,11 +300,7 @@ impl Game<'_> {
) {
continue;
}
let marks = if player || self.cosmetics_local() {
self.cosmetics_marks(m, Some(m.id) == own)
} else {
Marks::default()
};
let marks = self.hooks_name_marks(m, Some(m.id) == own);
let at = match marks.top {
Some(top) => p + glam::DVec3::Y * cosmetic_lift(top, m.model_scale()),
None => p,
@@ -770,10 +766,10 @@ impl Game<'_> {
if background { BACKGROUND } else { 0 },
),
};
if n == last && marks.horizon {
if let Some(mark) = marks.mark.filter(|_| n == last) {
let x = -self.gui.atlas.font.width_text(text) / 2.0 - 10.0;
// 8 text pixels square, a pixel left of the background.
for &(mx, my, w, h, argb) in super::cosmetics::MARK {
for &(mx, my, w, h, argb) in mark {
let a = text_alpha * (argb >> 24) / 255;
batch.fill(
&self.gui.atlas,
@@ -937,10 +933,11 @@ struct NameTag {
marks: Marks,
}
/// What Horizon draws by a player's name: its mark (they play Horizon) and their name tag style.
/// What a hook draws by a player's name (`horizon_hooks::players::NameDecoration`): a mark
/// before it (Horizon's: they play Horizon) and a name tag style.
#[derive(Clone, Copy, Default)]
pub(super) struct Marks {
pub horizon: bool,
pub mark: Option<horizon_hooks::players::Mark>,
pub style: Option<TagStyle>,
/// How high the cosmetics drawn on their body get above the neck, in model pixels: the tag
/// goes up over a tall one (`cosmetic_lift`).
+6 -19
View File
@@ -311,8 +311,8 @@ impl Game<'_> {
}
};
let mut view = self.view;
// An emote pulls the camera back from the player's face (`game/cosmetics/emotes.rs`).
let pull = self.emote_camera_pull(partial);
// A hook may pull the camera back (an emote, from the player's face).
let pull = self.hooks_camera_distance(partial);
let kind = match self.camera_type {
CameraType::FirstPerson => horizon_client::camera::CameraType::FirstPerson,
CameraType::ThirdPersonBack => horizon_client::camera::CameraType::ThirdPersonBack,
@@ -800,19 +800,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);
@@ -926,10 +915,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).
@@ -0,0 +1,634 @@
//! Where the game hands the player models to crates above the engine (`horizon_hooks::Hooks`'s
//! `frame`, `tick`, `name_decoration`, `player_mark`, `camera`; spec #14091): only a window's
//! game calls them, never the integrated server's thread or a dedicated server. With no hook
//! registered nothing is built.
use super::Game;
use horizon_client::fast_hash::FxHashMap;
use horizon_client::join_seq::JoinList;
use horizon_client::mesher::Vertex;
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, 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)
{
log::error!("Cosmetics: their hooks came too late: {e}");
}
}
/// The models of the world a window shows: its own first, then the other players and mannequins.
struct Models<'a> {
own: Option<&'a mut Mob>,
mobs: &'a mut JoinList<Mob>,
remote: &'a super::remote::Remote,
}
impl Models<'_> {
fn find(&mut self, id: u32) -> Option<&mut Mob> {
match self.own.as_deref_mut() {
Some(m) if m.id == id => Some(m),
_ => self
.mobs
.iter_mut()
.find(|m| m.id == id && kind(m).is_some()),
}
}
}
/// What kind of player model `m` is, if it is one (the window's own model aside).
fn kind(m: &Mob) -> Option<Kind> {
match &*m.kind.id {
"minecraft:player" => Some(Kind::Player),
"minecraft:mannequin" => Some(Kind::Mannequin),
_ => None,
}
}
/// What `m` wears where a crate may draw: a helmet or head item, a chestplate, a cape or elytra.
pub(super) fn gear(m: &Mob) -> Gear {
let elytra = m.extra.chest.as_deref() == Some("minecraft:elytra");
Gear::new(
m.extra.head.is_some(),
m.extra.chest.is_some() && !elytra,
m.cape.is_some() || elytra,
)
}
fn model(m: &Mob, kind: Kind, uuid: Option<u128>) -> PlayerModel {
let (dx, dz) = (m.position.x - m.previous.x, m.position.z - m.previous.z);
PlayerModel::new(m.id, kind, uuid, m.age, dx * dx + dz * dz > 1e-4)
.looks(gear(m), horizon_client::mobs::trader::is_invisible(m))
}
impl PlayerModels for Models<'_> {
fn connection(&self) -> u64 {
self.remote.session.serial()
}
fn for_each(&self, f: &mut dyn FnMut(PlayerModel)) {
if let Some(m) = self.own.as_deref() {
f(model(m, Kind::Own, Some(self.remote.profile())));
}
for m in self.mobs.iter() {
if let Some(kind) = kind(m) {
let uuid = (kind == Kind::Player)
.then(|| self.remote.player_uuid(m.id))
.flatten();
f(model(m, kind, uuid));
}
}
}
fn body_pose(&self, id: u32) -> Option<&Arc<dyn BodyPose>> {
match self.own.as_deref() {
Some(m) if m.id == id => m.emote.as_ref(),
_ => self
.mobs
.iter()
.find(|m| m.id == id && kind(m).is_some())?
.emote
.as_ref(),
}
}
fn set_body_pose(&mut self, id: u32, pose: Option<Arc<dyn BodyPose>>) {
if let Some(m) = self.find(id) {
m.emote = pose;
}
}
}
/// Sprite `id`'s place in the atlas's names (`hooks/<hex>`), written into `buf`: no allocation.
fn location(id: u64, buf: &mut [u8; 22]) -> &str {
use std::io::Write;
let mut out = std::io::Cursor::new(&mut buf[..]);
let _ = write!(out, "hooks/{id:x}");
let len = out.position() as usize;
std::str::from_utf8(&buf[..len]).unwrap_or_default()
}
/// The crates' sprites in the GUI atlas, in the skin slots: read while drawing.
struct HookSprites<'a, 'g>(&'a Game<'g>);
impl Sprites for HookSprites<'_, '_> {
fn get(&self, id: u64) -> Option<[f32; 4]> {
let mut buf = [0; 22];
let at = location(id, &mut buf);
let r = self.0.gui.atlas.sprites.get(at)?;
// Kept this frame (`skin_slots.next_frame`).
self.0.skin_downloads.borrow_mut().drawn(at);
Some([r.u0, r.v0, r.u1, r.v1])
}
}
/// Where a crate's frame hook puts its sprites.
struct HookStore<'a, 'g>(&'a mut Game<'g>);
impl SpriteStore for HookStore<'_, '_> {
fn has(&self, id: u64) -> bool {
self.0
.gui
.atlas
.sprites
.contains_key(location(id, &mut [0; 22]))
}
fn put(&mut self, id: u64, rgba: Vec<u8>) {
let at = location(id, &mut [0; 22]).to_owned();
self.0.store_skin(at, rgba);
}
}
thread_local! {
/// The crates' meshes as the renderer takes them, by the mesh's address (each entry holds its
/// mesh, so the address isn't reused); render thread only.
static MESHES: std::cell::RefCell<FxHashMap<usize, (Arc<Mesh>, Arc<Part>)>> =
Default::default();
}
/// The game's model of `mesh`, made once.
fn part_of(mesh: &Arc<Mesh>) -> Arc<Part> {
fn part(m: &Mesh) -> Part {
Part {
name: m.name.clone(),
pose: m.pose,
polygons: m
.quads
.iter()
.map(|q| Polygon {
normal: q.normal,
vertices: q.vertices,
cull_back: false,
})
.collect(),
children: m.children.iter().map(part).collect(),
}
}
MESHES.with_borrow_mut(|meshes| {
let (_, part_of) = meshes
.entry(Arc::as_ptr(mesh) as usize)
.or_insert_with(|| (mesh.clone(), Arc::new(part(mesh))));
part_of.clone()
})
}
/// 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| {
m.borrow_mut()
.retain(|_, (mesh, _)| Arc::strong_count(mesh) > 1)
});
}
/// `Poses` as a crate poses a mesh's parts.
struct PosesOf<'a>(&'a mut Poses);
impl PartPoses for PosesOf<'_> {
fn pose(&mut self, name: &str) -> &mut [f32; 9] {
self.0.get(name)
}
}
/// 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> {
game: &'a Game<'g>,
m: &'a Mob,
pass: Pass,
/// The model's matrix (`render::base_transform`).
base: glam::Mat4,
camera: glam::DVec3,
alpha: f32,
light: u32,
gpu: bool,
out: &'a mut Vec<Vertex>,
bounds: Option<(glam::Vec3, glam::Vec3)>,
/// The body as it is drawn, posed on the first `bone`.
body: Option<PooledPoses>,
poses: PooledPoses,
}
impl DrawPlayer<'_, '_> {
fn body(&self) -> &Part {
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<'_, '_> {
fn pass(&self) -> Pass {
self.pass
}
fn ticks(&self) -> f32 {
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());
let data = self.game.res.mob_data();
let state = anim::state(self.m, self.alpha);
anim::setup(self.m, &state, &mut poses, &data.animations);
self.body = Some(poses);
}
let poses = self.body.as_ref()?;
let at = render::part_matrix(self.body(), self.base, poses, bone.part(), 0.0)?;
Some(at.to_cols_array_2d())
}
fn body_pose(&self) -> Option<&Arc<dyn BodyPose>> {
self.m.emote.as_ref()
}
fn sprite(&self, id: u64) -> Option<[f32; 4]> {
HookSprites(self.game).get(id)
}
fn draw(
&mut self,
mesh: &Arc<Mesh>,
matrix: [[f32; 4]; 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();
let mut skin = horizon_client::mobs::skin::Skin::new(&mut models, &mut frame);
skin.bounds = self.bounds;
let paint = horizon_client::mobs::skin::Paint {
uv,
scroll: 0.0,
color: 0xFFFF_FFFF,
light,
overlay: 0,
};
if skin.draw(&model, matrix, poses, paint) {
self.bounds = skin.bounds;
return;
}
}
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.
#[allow(clippy::too_many_arguments)]
pub(super) fn player_layers_geometry(
&self,
m: &Mob,
base: glam::Mat4,
camera: glam::DVec3,
alpha: f32,
light: u32,
gpu: bool,
out: &mut Vec<Vertex>,
) -> Option<(glam::Vec3, glam::Vec3)> {
debug_assert!(
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 mut draw = DrawPlayer {
game: self,
m,
pass: Pass::World,
base,
camera,
alpha,
light,
gpu,
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_fx_of(m, false, base, camera, alpha, light, gpu, out))
.flatten();
match (bounds, worn) {
(Some((l, h)), Some((lo, hi))) => Some((l.min(lo), h.max(hi))),
(b, w) => b.or(w),
}
}
/// What crates draw on the window's own model `m` (its vertices to `out`): in the world in
/// third person (`Pass::World`, then the cosmetics' companion, trail and aura still drawn
/// here), or in the inventory's picture of it (`Pass::Picture`). `camera`: the world view's
/// camera, or the picture's.
pub(super) fn own_layers_geometry(
&self,
m: &Mob,
pass: Pass,
camera: glam::DVec3,
alpha: f32,
light: u32,
out: &mut Vec<Vertex>,
) {
debug_assert!(
self.serving.is_none(),
"a server thread calls no client hook"
);
let fx = pass == Pass::World && super::cosmetics::enabled();
if !engine::any() && !fx {
return;
}
let base = render::base_transform(m, alpha, camera);
if engine::any() {
let model = model(m, Kind::Own, self.remote.as_ref().map(|r| r.profile()));
let mut draw = DrawPlayer {
game: self,
m,
pass,
base,
camera,
alpha,
light,
gpu: false,
out: &mut *out,
bounds: None,
body: None,
poses: Poses::pooled(),
};
engine::player_layers(&model, &mut draw);
}
if fx {
self.cosmetics_fx_of(m, true, base, camera, alpha, light, false, out);
}
}
/// After every player (`game.rs`): what crates draw around the window's own model in first
/// person, the cosmetics' fx still drawn here, 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::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::first_person(&own, &mut draw);
}
self.cosmetics_fx_geometry(camera, alpha, out);
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.
pub(super) fn client_hooks_frame(&mut self) {
debug_assert!(
self.serving.is_none(),
"a server thread calls no client hook"
);
if !engine::any() {
return;
}
self.cosmetics_say();
let connection = self.remote.as_ref().map_or(0, |r| r.session.serial());
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();
}
/// Once a client tick in a world: the player's own emote, then the hooks.
pub(super) fn client_hooks_tick(&mut self) {
debug_assert!(
self.serving.is_none(),
"a server thread calls no client hook"
);
self.tick_emotes();
if !engine::any() {
return;
}
self.cosmetics_say();
let Some(remote) = self.remote.as_ref() else {
return;
};
engine::tick(&mut Models {
own: self.player_model.as_mut(),
mobs: &mut self.mobs,
remote,
});
}
/// What goes by `m`'s name (`own`: the window's own player), from the hooks.
pub(super) fn hooks_name_marks(&self, m: &Mob, own: bool) -> super::names::Marks {
debug_assert!(
self.serving.is_none(),
"a server thread calls no client hook"
);
let mut marks = super::names::Marks::default();
if !engine::any() {
return marks;
}
let model = match (own, kind(m)) {
(true, _) => model(m, Kind::Own, self.remote.as_ref().map(|r| r.profile())),
(false, Some(kind)) => model(m, kind, self.remote_player_uuid(m.id)),
(false, None) => return marks,
};
if let Some(d) = engine::name_decoration(&model, &HookSprites(self)) {
marks.mark = d.mark;
marks.top = d.top;
marks.style = d.badge.map(|b| {
let [u0, v0, u1, v1] = b.uv;
super::names::TagStyle {
mark: horizon_client::hud::Rect {
u0,
v0,
u1,
v1,
w: 8,
h: 8,
},
line: b.line,
}
});
}
marks
}
/// How far the third-person camera stands back, as a share of the usual distance: a hook's
/// (an emote pulls it back), else 1.
pub(super) fn hooks_camera_distance(&self, partial: f32) -> f32 {
debug_assert!(
self.serving.is_none(),
"a server thread calls no client hook"
);
match &self.player_model {
Some(m) if engine::any() => {
engine::camera(&CameraView::new(m.age as f32 + partial)).map_or(1.0, |c| c.distance)
}
_ => 1.0,
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn sprite_locations_fit_the_buffer_and_differ_by_id() {
let mut a = [0; 22];
let mut b = [0; 22];
assert_eq!(location(u64::MAX, &mut a), "hooks/ffffffffffffffff");
assert_eq!(location(10, &mut b), "hooks/a");
}
}
+10 -10
View File
@@ -6731,7 +6731,7 @@ impl Game<'_> {
if self.studio_replay.is_none() {
self.tick_player_model();
}
self.tick_emotes();
self.client_hooks_tick();
if !dead {
self.remote_tick_using();
}
@@ -10714,17 +10714,11 @@ impl Game<'_> {
/// The profile name of another player the server sent, by entity id.
/// The UUID of another player's entity in the level.
pub(super) fn remote_player_uuid(&self, id: u32) -> Option<u128> {
let r = self.remote.as_ref()?;
let uuid = *r.level_players.get(&(id as i32))?;
(uuid != r.profile).then_some(uuid)
self.remote.as_ref()?.player_uuid(id)
}
pub(super) fn remote_player_name(&self, id: u32) -> Option<&str> {
let r = self.remote.as_ref()?;
let uuid = r.level_players.get(&(id as i32))?;
if *uuid == r.profile {
return None;
}
r.level_player_name(id as i32, uuid)
r.level_player_name(id as i32, &r.player_uuid(id)?)
}
/// Status for `horizon-ctl status`.
/// The other players' entities in the level and their names (their
@@ -11496,7 +11490,8 @@ impl Remote {
pub(super) fn listed_player_count(&self) -> usize {
self.listed.len()
}
/// Players in the server's player list, this one included (for the activity hooks).
/// Players in the server's player list, this one included (for Discord).
#[cfg(feature = "discord")]
pub(super) fn player_count(&self) -> usize {
self.players.len()
}
@@ -11504,6 +11499,11 @@ impl Remote {
pub(super) fn profile(&self) -> u128 {
self.profile
}
/// Another player's profile UUID, by their entity id (None for the local player).
pub(super) fn player_uuid(&self, id: u32) -> Option<u128> {
let uuid = *self.level_players.get(&(id as i32))?;
(uuid != self.profile).then_some(uuid)
}
/// The entity id of a player in the level, by profile UUID.
pub(super) fn player_entity(&self, uuid: u128) -> Option<u32> {
self.level_players
@@ -306,6 +306,8 @@ pub(super) fn run_loading(
App::Loading(loading) => leave_fullscreen_after(&result, &mut loading.settings),
}
result?;
#[cfg(feature = "discord")]
horizon_client::discord_presence::stop();
match app {
App::Ready(mut split) => {
write_report(&split.host)?;
+7 -5
View File
@@ -823,7 +823,7 @@ impl Game<'_> {
for (location, rgba) in ready {
self.store_skin(location, rgba);
}
self.cosmetics_frame();
self.client_hooks_frame();
}
/// `Hud.extractTabList`: the player list while the player-list key is
/// held. A local world shows it with a joined LAN guest or a `list`
@@ -900,10 +900,12 @@ impl Game<'_> {
let board = &self.score.board;
let objective = board.display_objective(sb::SLOT_LIST);
let up = |id: u128| upside_down.contains(&id);
// Horizon's mark before the names of Horizon players (the cosmetics service's cache).
let horizon = self.horizon_players(players.iter().map(|p| p.uuid));
let horizon_mark = super::cosmetics::MARK;
let marked = |id: u128| horizon.contains(&id).then_some(horizon_mark);
// A hook's mark before a name (Horizon's: they play Horizon).
debug_assert!(
self.serving.is_none(),
"a server thread calls no client hook"
);
let marked = horizon_hooks::engine::player_mark;
let inputs = TabInputs {
players: &players,
board,
+2
View File
@@ -261,6 +261,8 @@ impl<'a> Split<'a> {
.get(i.wrapping_sub(1))
.map(|g| g.options.clone()),
);
let seat = self.guests.get(i.wrapping_sub(1)).map_or(1, |g| g.seat);
horizon_hooks::engine::set_seat(seat - 1);
self.turn = i;
}
@@ -126,6 +126,7 @@ impl Split<'_> {
// Until it has a profile, a scratch options file: never P1's.
let options = Self::folder()?.join(".joining/options.txt");
OPTIONS.set(Some(options.clone()));
horizon_hooks::engine::set_seat(seat - 1);
let mut settings = self.host.settings.clone();
if pad.is_some() {
// The Controller setting is P1's own: a player who pressed a pad to join plays
@@ -135,6 +136,7 @@ impl Split<'_> {
}
let game = self.start_guest(seat, &options, settings);
OPTIONS.set(None);
horizon_hooks::engine::set_seat(0);
let mut game = game?;
if let Some(pad) = pad {
// The press that joined it doesn't act in its game.
@@ -299,16 +301,31 @@ impl Split<'_> {
}
let profile = profiles.join(&name);
let options = profile.join("options.txt");
let mut settings = if options.exists() {
horizon_client::settings::Settings::load(&options)
let made = if options.exists() {
None
} else {
std::fs::create_dir_all(&profile)?;
let mut new = self.host.settings.clone();
// The Controller setting is P1's, as on the profile page (`join`).
new.controller.enabled |= self.guests[i].pad.is_some();
// On P1's seat, so the crates' settings stores write P1's values too.
let turn = horizon_hooks::seat();
horizon_hooks::engine::set_seat(0);
new.save(&options);
new
horizon_hooks::engine::set_seat(turn);
Some(new)
};
// The crates' stores load into the guest's seat, before its turn starts below.
let turn = horizon_hooks::seat();
horizon_hooks::engine::set_seat(self.guests[i].seat - 1);
let mut settings = match made {
Some(new) => {
horizon_client::settings::Settings::load_stores(&options);
new
}
None => horizon_client::settings::Settings::load(&options),
};
horizon_hooks::engine::set_seat(turn);
// A profile an earlier join saved with P1's off, or the player's pad would be dead.
settings.controller.enabled |= self.guests[i].pad.is_some();
// P1's own world through memory (`Ok`), else P1's server over the network (`Err`).
+3 -20
View File
@@ -771,26 +771,9 @@ fn swim(p: &mut Poses, pos: f32, h: &Hum) {
lerp(amount, x, 0.3 * (pos * 0.333_333_34).mcos()),
);
}
/// The player model's parts a [`BodyPose`] poses, in its order.
pub const BODY_PARTS: [&str; 6] = [
"head",
"body",
"right_arm",
"left_arm",
"right_leg",
"left_leg",
];
/// A pose a higher layer lays over the player model's body after its vanilla animation (Horizon's
/// emotes, docs/cosmetics.md), handed to the model through [`Mob::emote`]. The engine names no
/// type of that layer (docs/layers.md); it downcasts its own back through [`std::any::Any`].
pub trait BodyPose: std::any::Any + std::fmt::Debug + Send + Sync {
/// Poses the [`BODY_PARTS`] at `age` (the mob's ticks): `at_rest` holds each part before the
/// animation, `poses` as the animation left it; a part the model lacks holds the identity in
/// both and is not written back. False when it poses nothing at `age` (`poses` is then
/// ignored).
fn pose(&self, age: f32, at_rest: &[[f32; 9]; 6], poses: &mut [[f32; 9]; 6]) -> bool;
}
/// A pose laid over the player model's body after its vanilla animation, and the parts it poses:
/// the hook's own types (`horizon_hooks::players`), kept here under their old path.
pub use horizon_hooks::players::{BODY_PARTS, BodyPose};
/// Apply the mob's model animation to `p` (at rest), then the pose laid over it, if any.
pub fn setup(
+17
View File
@@ -149,8 +149,20 @@ pub struct Session {
far: Arc<crate::lod_net::Inbox>,
/// The recording running, if any.
recorder: RecorderSlot,
/// This connection's number, new for each one ([`Session::serial`]).
serial: u64,
}
/// The next [`Session::serial`].
static NEXT_SERIAL: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(1);
fn next_serial() -> u64 {
NEXT_SERIAL.fetch_add(1, Ordering::Relaxed)
}
impl Session {
/// A number of its own for each connection, never 0: entity ids are per connection, so what
/// is kept by id goes when it changes (`horizon_hooks::players::PlayerModels::connection`).
pub fn serial(&self) -> u64 {
self.serial
}
/// The negotiated wire release, once the connection has been established.
pub fn server_version(&self) -> horizon_protocol::adapter::ServerVersion {
self.writer.lock().unwrap().as_ref().map_or(
@@ -256,6 +268,7 @@ impl Session {
debug: false,
far: Arc::new(crate::lod_net::Inbox::default()),
recorder: Arc::new(Mutex::new(None)),
serial: next_serial(),
}
}
fn start(
@@ -333,6 +346,7 @@ impl Session {
debug,
far,
recorder,
serial: next_serial(),
}
}
/// Everything received since the last poll.
@@ -1829,6 +1843,7 @@ mod tests {
debug: false,
far: Arc::default(),
recorder: Arc::new(Mutex::new(None)),
serial: next_serial(),
};
let started = std::time::Instant::now();
if explicit_disconnect {
@@ -1932,6 +1947,7 @@ mod tests {
debug: false,
far: Arc::default(),
recorder: Arc::new(Mutex::new(None)),
serial: next_serial(),
};
let (thread_writer, thread_configuring, thread_recorder) = (
session.writer.clone(),
@@ -2215,6 +2231,7 @@ mod tests {
debug: false,
far: Arc::default(),
recorder: Arc::new(Mutex::new(None)),
serial: next_serial(),
};
let recorder = TestRecorder::default();
session.start_recording(recorder.clone(), |_| {});
@@ -1,29 +1,12 @@
//! Horizon's own entries in the multiplayer list: the official server, added once at the top,
//! and the withdrawn partner server, taken back out once.
//!
//! A Horizon crate (docs/layers.md): it plugs into the game through the platform's hooks
//! ([`HOOKS`], when the game starts), which the official executable (`horizon-official`)
//! registers, and reads servers.dat through `horizon_platform::servers`. horizon-client's plain
//! executable adds no server. Its addresses are in `horizon-addresses`.
#![forbid(unsafe_code)]
use horizon_platform::hooks::{Hooks, Started};
use horizon_platform::servers::{self, ServerEntry};
//! Horizon's own entries in the multiplayer list (docs/layers.md: Horizon layer): the official
//! server, added once at the top, and the withdrawn partner server, taken back out once. Behind
//! `horizon-extras` (docs/forking.md), so a plain build adds no server and holds no Horizon
//! address. It reads and writes servers.dat only through `horizon_protocol::servers`, ready to
//! leave the engine crates for a Horizon crate.
use horizon_protocol::servers::{self, ServerEntry};
use std::path::Path;
/// The server list's hooks, for `horizon_platform::hooks::register`.
pub static HOOKS: OfficialServers = OfficialServers;
pub struct OfficialServers;
impl Hooks for OfficialServers {
/// Someone is playing (not a server, a benchmark or an automated run): their list, once.
fn started(&self, game: &Started<'_>) {
update(game.game_dir);
}
}
/// At the start of a player's game: the official server into the list in `game_dir` and the
/// At startup of a player's game: the official server into the list in `game_dir` and the
/// withdrawn partner server out of it, a failure only logged.
pub fn update(game_dir: &Path) {
if let Err(e) = add_official_server(game_dir) {
@@ -34,8 +17,12 @@ pub fn update(game_dir: &Path) {
}
}
/// Horizon's official survival server: a name and an address like any entry.
pub const OFFICIAL_SERVER: (&str, &str) = ("Horizon Official", horizon_addresses::OFFICIAL_SERVER);
/// Horizon's official survival server: a name and an address like any entry. The address is
/// `horizon_telemetry::server_status::OFFICIAL_ADDRESS`, the one place it is kept.
pub const OFFICIAL_SERVER: (&str, &str) = (
"Horizon Official",
horizon_telemetry::server_status::OFFICIAL_ADDRESS,
);
/// Beside servers.dat once the official server was offered, so a player who
/// deletes it never gets it back: the address offered, so a new one moves the
/// list once. The launcher writes the same file.
@@ -68,7 +55,7 @@ pub fn add_official_server(game_dir: &Path) -> std::io::Result<()> {
let file = game_dir.join("servers.dat");
let _guard = servers::lock();
let mut list = servers::load(&file);
let previous = horizon_addresses::OFFICIAL_SERVER_PREVIOUS;
let previous = horizon_telemetry::server_status::OFFICIAL_PREVIOUS;
if move_official(&mut list, address, previous) {
servers::save(&file, &list)?;
}
@@ -84,10 +71,7 @@ pub fn add_official_server(game_dir: &Path) -> std::io::Result<()> {
/// Afri-Network, which the game added once below the official server (#9299) until the
/// partnership ended: the name and address the game gave its entry.
const WITHDRAWN_PARTNER: (&str, &str) = (
"Afri-Network (South Africa)",
horizon_addresses::WITHDRAWN_PARTNER_SERVER,
);
const WITHDRAWN_PARTNER: (&str, &str) = ("Afri-Network (South Africa)", "afri-network.co.za");
/// Beside servers.dat: the partner addresses the game offered, one per line (#9299).
const PARTNER_MARKER: &str = "horizon_partner_servers.txt";
+9 -3
View File
@@ -70,7 +70,8 @@ struct Shared {
/// The game's handle. Dropping it stops the thread.
pub struct Cosmetics {
shared: Arc<Shared>,
worker: Option<Box<dyn FnMut() + Send>>,
/// Behind a lock so one client can be shared (`Arc<Cosmetics>` is `Send + Sync`).
worker: Mutex<Option<Box<dyn FnMut() + Send>>>,
}
impl Cosmetics {
@@ -78,7 +79,12 @@ impl Cosmetics {
/// `cache` when given.
pub fn new(transport: impl Transport, cache: Option<PathBuf>) -> Self {
let mut c = Self::manual(transport, cache, Arc::new(Instant::now));
let mut work = c.worker.take().expect("worker");
let mut work = c
.worker
.get_mut()
.unwrap_or_else(PoisonError::into_inner)
.take()
.expect("worker");
let shared = c.shared.clone();
let _ = std::thread::Builder::new()
.name("Cosmetics".into())
@@ -125,7 +131,7 @@ impl Cosmetics {
});
Self {
shared,
worker: Some(worker),
worker: Mutex::new(Some(worker)),
}
}
+1 -1
View File
@@ -87,7 +87,7 @@ fn clock() -> (Clock, Arc<AtomicU64>) {
}
fn run(c: &mut Cosmetics) {
(c.worker.as_mut().unwrap())();
(c.worker.get_mut().unwrap().as_mut().unwrap())();
}
fn ids(worn: Option<Worn>) -> Vec<String> {
-15
View File
@@ -1,15 +0,0 @@
[package]
name = "horizon-discord"
version = "0.1.0"
edition.workspace = true
license.workspace = true
rust-version.workspace = true
description = "Discord Rich Presence: \"Playing Horizon\" on the player's Discord profile (docs/native-client.md)"
[dependencies]
horizon-platform = { path = "../horizon-platform" }
log = "0.4"
serde_json = "1.0.151"
[lints]
workspace = true
+308 -107
View File
@@ -19,11 +19,13 @@
pub mod hud;
pub mod pages;
pub mod players;
mod registry;
pub mod settings;
pub mod tunnel;
use registry::Registry;
use std::cell::Cell;
use std::sync::atomic::{AtomicBool, Ordering};
/// What a crate above the engine hears from the game. Every method has an empty default:
@@ -42,75 +44,49 @@ pub trait Hooks: Sync {
/// The game is ending normally: its window closed and the world is saved. Not after a crash.
fn shutdown(&self) {}
/// Once a frame, before drawing: `sprites` takes the pictures the crate's
/// [`players::Sprites`] lacked. Only a window's game calls it (each splitscreen player in
/// turn, [`seat`]), never the integrated server's thread or a dedicated server; so are the
/// player methods below (not [`Hooks::tunnel`]).
fn frame(&self, _frame: &players::Frame, _sprites: &mut dyn players::SpriteStore) {}
/// Once a client tick while a world is shown: the player models in view.
fn tick(&self, _models: &mut dyn players::PlayerModels) {}
/// What goes by a player model's name over its head, each frame its name tag is drawn. The
/// first hook that answers is used.
fn name_decoration(
&self,
_model: &players::PlayerModel,
_sprites: &dyn players::Sprites,
) -> Option<players::NameDecoration> {
None
}
/// The mark before the name of the player `uuid` in the player list, each frame it shows.
/// The first hook that answers is used.
fn player_mark(&self, _uuid: u128) -> Option<players::Mark> {
None
}
/// How the camera stands in third person, each frame. The first hook that answers is used.
fn camera(&self, _own: &players::CameraView) -> Option<players::CameraOverride> {
None
}
/// 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.
@@ -118,14 +94,11 @@ pub enum GameMode {
pub struct Started<'a> {
/// The build: the release tag (`0.2.31`), or `dev <crate version>` for a build from source.
pub version: &'a str,
/// The player's game folder: servers.dat, options.txt and saves (the working directory unless
/// `--game-dir` names another).
pub game_dir: &'a std::path::Path,
}
impl<'a> Started<'a> {
pub fn new(version: &'a str, game_dir: &'a std::path::Path) -> Self {
Self { version, game_dir }
pub fn new(version: &'a str) -> Self {
Self { version }
}
}
@@ -155,10 +128,27 @@ fn all() -> &'static [&'static dyn Hooks] {
HOOKS.all()
}
thread_local! {
static SEAT: Cell<usize> = const { Cell::new(0) };
}
/// The splitscreen player whose turn it is: 0 for P1 (and without splitscreen), 1 for P2...
/// Splitscreen players are one process, each `Game` in turn on the game's thread, so a crate's
/// statics are shared by them: a crate that keeps per-player state (settings, pages, HUD) indexes
/// it by this. A player keeps its seat while it plays. Other threads always see 0.
pub fn seat() -> usize {
SEAT.get()
}
/// The engine's side: where it calls the hooks. Only the engine calls these.
pub mod engine {
use super::*;
/// The player whose turn starts on this thread ([`seat`]).
pub fn set_seat(seat: usize) {
SEAT.set(seat);
}
/// Seals every list (hooks and the registries); their first use does it anyway. Call where
/// registration ends.
pub fn seal() {
@@ -189,32 +179,6 @@ pub mod engine {
}
}
/// [`Hooks::client_started`].
pub fn client_started(client: &Client) {
for hooks in all() {
hooks.client_started(client);
}
}
/// The activity at the last call (`None` before the first).
static ACTIVITY: std::sync::Mutex<Option<Activity>> = std::sync::Mutex::new(None);
/// [`Hooks::activity`], when `now` differs from the last call's. Cheap enough to call every
/// frame: with no hook registered it only loads the empty list.
pub fn activity(now: Activity) {
let hooks = all();
if hooks.is_empty() {
return;
}
let mut last = ACTIVITY.lock().unwrap_or_else(|e| e.into_inner());
if last.replace(now) != Some(now) {
drop(last);
for h in hooks {
h.activity(now);
}
}
}
/// [`Hooks::shutdown`].
pub fn shutdown() {
for hooks in all() {
@@ -222,6 +186,57 @@ pub mod engine {
}
}
/// Whether any hook is registered: without one the engine builds no view to hand them.
pub fn any() -> bool {
!all().is_empty()
}
/// [`Hooks::frame`].
pub fn frame(frame: &players::Frame, sprites: &mut dyn players::SpriteStore) {
for hooks in all() {
hooks.frame(frame, sprites);
}
}
/// [`Hooks::name_decoration`].
pub fn name_decoration(
model: &players::PlayerModel,
sprites: &dyn players::Sprites,
) -> Option<players::NameDecoration> {
all().iter().find_map(|h| h.name_decoration(model, sprites))
}
/// [`Hooks::player_mark`].
pub fn player_mark(uuid: u128) -> Option<players::Mark> {
all().iter().find_map(|h| h.player_mark(uuid))
}
/// [`Hooks::player_layers`].
pub fn player_layers(model: &players::PlayerModel, draw: &mut dyn players::PlayerDraw) {
for hooks in all() {
hooks.player_layers(model, draw);
}
}
/// [`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))
}
/// [`Hooks::tick`].
pub fn tick(models: &mut dyn players::PlayerModels) {
for hooks in all() {
hooks.tick(models);
}
}
/// [`Hooks::tunnel`]: the first registered hook's resolver.
pub fn tunnel() -> Option<&'static dyn tunnel::Resolver> {
all().iter().find_map(|hooks| hooks.tunnel())
@@ -231,6 +246,7 @@ pub mod engine {
#[cfg(test)]
mod tests {
use super::*;
use players::PlayerModels as _;
use std::sync::Mutex as StdMutex;
/// Records what it heard. One process-wide list, as the game has: this test is the only
@@ -247,8 +263,168 @@ 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()
.unwrap()
.push(format!("frame {}", frame.connection));
if !sprites.has(1) {
sprites.put(1, vec![0; 64 * 64 * 4]);
}
}
fn name_decoration(
&self,
model: &players::PlayerModel,
sprites: &dyn players::Sprites,
) -> Option<players::NameDecoration> {
let badge = sprites.get(1).map(|uv| players::Badge::new(uv, 0xFF0000));
(model.kind == players::Kind::Player)
.then(|| players::NameDecoration::new(Some(MARK), badge, Some(2.0)))
}
fn player_mark(&self, uuid: u128) -> Option<players::Mark> {
(uuid == 9).then_some(MARK)
}
fn camera(&self, own: &players::CameraView) -> Option<players::CameraOverride> {
Some(players::CameraOverride::new(own.age / 10.0))
}
/// A hat on the head, its brim turned by the model's age.
fn player_layers(&self, _: &players::PlayerModel, draw: &mut dyn players::PlayerDraw) {
let (Some(head), Some(uv)) = (draw.bone(players::Bone::Head), draw.sprite(1)) else {
return;
};
let turn = draw.ticks() / 100.0;
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();
models.for_each(&mut |m| ids.push(m.id));
for id in ids {
models.set_body_pose(id, Some(std::sync::Arc::new(Still)));
}
}
}
#[derive(Debug)]
struct Still;
impl players::BodyPose for Still {
fn pose(&self, _: f32, _: &[[f32; 9]; 6], _: &mut [[f32; 9]; 6]) -> bool {
false
}
}
/// Two models, poses by id.
struct Two(std::collections::BTreeMap<u32, std::sync::Arc<dyn players::BodyPose>>);
impl players::PlayerModels for Two {
fn connection(&self) -> u64 {
1
}
fn for_each(&self, f: &mut dyn FnMut(players::PlayerModel)) {
for id in [3, 7] {
f(players::PlayerModel::new(
id,
players::Kind::Player,
None,
0,
false,
));
}
}
fn body_pose(&self, id: u32) -> Option<&std::sync::Arc<dyn players::BodyPose>> {
self.0.get(&id)
}
fn set_body_pose(&mut self, id: u32, pose: Option<std::sync::Arc<dyn players::BodyPose>>) {
match pose {
Some(p) => self.0.insert(id, p),
None => self.0.remove(&id),
};
}
}
const MARK: players::Mark = &[(0, 0, 8, 8, 0xFF00_00FF)];
/// One sprite slot, as the game's atlas has many.
#[derive(Default)]
struct Atlas(Option<(u64, usize)>);
impl players::SpriteStore for Atlas {
fn has(&self, id: u64) -> bool {
self.0.is_some_and(|(i, _)| i == id)
}
fn put(&mut self, id: u64, rgba: Vec<u8>) {
self.0 = Some((id, rgba.len()));
}
}
impl players::Sprites for Atlas {
fn get(&self, id: u64) -> Option<[f32; 4]> {
players::SpriteStore::has(self, id).then_some([0.0, 0.0, 0.5, 0.5])
}
}
static HAT: std::sync::LazyLock<std::sync::Arc<players::Mesh>> =
std::sync::LazyLock::new(|| {
let brim = players::Mesh::new("brim".into(), [0.0; 9], Vec::new(), Vec::new());
std::sync::Arc::new(players::Mesh::new(
"hat".into(),
[0.0; 9],
Vec::new(),
vec![brim],
))
});
/// 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)>, Vec<f32>);
impl players::PlayerDraw for Canvas {
fn pass(&self) -> players::Pass {
players::Pass::World
}
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])
}
fn body_pose(&self) -> Option<&std::sync::Arc<dyn players::BodyPose>> {
None
}
fn sprite(&self, id: u64) -> Option<[f32; 4]> {
(id == 1).then_some([0.0, 0.0, 0.5, 0.5])
}
fn draw(
&mut self,
mesh: &std::sync::Arc<players::Mesh>,
matrix: [[f32; 4]; 4],
look: players::Look,
pose: &mut dyn FnMut(&mut dyn players::PartPoses),
) {
struct Rest([f32; 9]);
impl players::PartPoses for Rest {
fn pose(&mut self, _: &str) -> &mut [f32; 9] {
&mut self.0
}
}
let mut brim = Rest(mesh.children[0].pose);
pose(&mut brim);
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));
}
}
@@ -262,28 +438,53 @@ mod tests {
fn registered_hooks_hear_the_game_and_late_ones_are_refused() {
register(&RECORDER).unwrap();
register(&Silent).unwrap();
engine::started(&Started::new("0.2.31", std::path::Path::new("game")));
engine::started(&Started::new("0.2.31"));
// Changes only: the same state every frame is said once.
for open in [false, true, true, true, false, false] {
engine::published(open);
}
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();
let model = |kind| players::PlayerModel::new(5, kind, Some(9), 0, false);
// Before the frame put the sprite in: the decoration without its badge.
let before = engine::name_decoration(&model(players::Kind::Player), &atlas).unwrap();
assert!(before.badge.is_none() && before.top == Some(2.0));
engine::frame(&players::Frame::new(4, false), &mut atlas);
assert_eq!(atlas.0, Some((1, 64 * 64 * 4)));
let after = engine::name_decoration(&model(players::Kind::Player), &atlas).unwrap();
assert_eq!(
after.badge.map(|b| (b.uv, b.line)),
Some(([0.0, 0.0, 0.5, 0.5], 0xFF0000))
);
assert!(engine::name_decoration(&model(players::Kind::Mannequin), &atlas).is_none());
assert_eq!(engine::player_mark(9), Some(MARK));
assert_eq!(engine::player_mark(8), None);
let camera = engine::camera(&players::CameraView::new(5.0));
assert_eq!(camera.map(|c| c.distance), Some(0.5));
let mut canvas = Canvas::default();
engine::player_layers(&model(players::Kind::Player), &mut canvas);
assert_eq!(
canvas.0,
[(
"hat".to_owned(),
[[1.0, 0.0, 0.0, 0.0]; 4],
[0.0, 0.0, 0.5, 0.5],
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());
assert_eq!(
*RECORDER.0.lock().unwrap(),
[
"started 0.2.31",
"published true",
"published false",
"Menus",
"World { play: Singleplayer, dimension: Nether, mode: Creative }"
"frame 4"
]
);
assert_eq!(register(&Silent), Err(TooLate));
+456
View File
@@ -0,0 +1,456 @@
//! The players the game shows, as a crate above the engine sees them (Horizon's emotes, spec
//! #14091): [`PlayerModels`] in [`crate::Hooks::tick`], [`Frame`] in [`crate::Hooks::frame`],
//! [`BodyPose`], a pose laid over a player's body after its vanilla animation, what goes by their
//! names, and the crate's [`Sprites`]. Plain types only: no engine type crosses this line.
use std::sync::Arc;
/// The player model's parts a [`BodyPose`] poses, in its order.
pub const BODY_PARTS: [&str; 6] = [
"head",
"body",
"right_arm",
"left_arm",
"right_leg",
"left_leg",
];
/// A pose a crate lays over a player model's body after its vanilla animation (Horizon's emotes,
/// docs/cosmetics.md), handed to the model with [`PlayerModels::set_body_pose`]. The crate
/// downcasts its own back through [`std::any::Any`].
pub trait BodyPose: std::any::Any + std::fmt::Debug + Send + Sync {
/// Poses the [`BODY_PARTS`] at `age` (the model's ticks): `at_rest` holds each part before the
/// animation, `poses` as the animation left it (x, y, z, x/y/z rotation, x/y/z scale); a part
/// the model lacks holds the identity in both and is not written back. False when it poses
/// nothing at `age` (`poses` is then ignored).
fn pose(&self, age: f32, at_rest: &[[f32; 9]; 6], poses: &mut [[f32; 9]; 6]) -> bool;
}
/// What a player model is.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
#[non_exhaustive]
pub enum Kind {
/// The player of this window, seen from outside (third person, the inventory picture).
Own,
/// Another player.
Player,
/// A mannequin: a player model with no player.
Mannequin,
}
/// One player model, as [`PlayerModels::for_each`] shows it.
#[derive(Clone, Copy, Debug)]
#[non_exhaustive]
pub struct PlayerModel {
/// Its entity id on this connection ([`PlayerModels::connection`]).
pub id: u32,
pub kind: Kind,
/// The player's UUID as the server gave it (the window's own: its profile); None for
/// mannequins.
pub uuid: Option<u128>,
/// Its age in ticks, the clock a [`BodyPose`] is timed on.
pub age: u32,
/// It moved sideways since the last tick (more than 0.01 blocks).
pub moved: bool,
/// What it wears that covers a crate's drawing.
pub gear: Gear,
/// It is drawn invisible (the effect, or a trader's disguise).
pub invisible: bool,
}
impl PlayerModel {
pub fn new(id: u32, kind: Kind, uuid: Option<u128>, age: u32, moved: bool) -> Self {
Self {
id,
kind,
uuid,
age,
moved,
gear: Gear::default(),
invisible: false,
}
}
/// With what it wears and whether it is invisible.
pub fn looks(self, gear: Gear, invisible: bool) -> Self {
Self {
gear,
invisible,
..self
}
}
}
/// What a player model wears where a crate may draw.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
#[non_exhaustive]
pub struct Gear {
/// A helmet or a head item.
pub head: bool,
/// A chestplate (an elytra is on the back).
pub chest: bool,
/// A cape or an elytra.
pub back: bool,
}
impl Gear {
pub fn new(head: bool, chest: bool, back: bool) -> Self {
Self { head, chest, back }
}
}
/// The player models of the world this window shows, once a tick ([`crate::Hooks::tick`]).
pub trait PlayerModels {
/// Changes on every join: entity ids are per connection, so what a crate keeps by id goes
/// when this changes.
fn connection(&self) -> u64;
/// Each player model in view (other players and mannequins; the window's own model first).
fn for_each(&self, f: &mut dyn FnMut(PlayerModel));
/// The pose laid over model `id`, if any.
fn body_pose(&self, id: u32) -> Option<&Arc<dyn BodyPose>>;
/// Lays `pose` over model `id` (None: its vanilla animation alone). Unknown ids are ignored.
fn set_body_pose(&mut self, id: u32, pose: Option<Arc<dyn BodyPose>>);
}
/// What [`crate::Hooks::frame`] gets, once a frame before drawing.
#[derive(Clone, Copy, Debug)]
#[non_exhaustive]
pub struct Frame {
/// As [`PlayerModels::connection`]; 0 with no world.
pub connection: u64,
/// The world is the window's own single-player one: its server names the player by an
/// offline profile.
pub solo: bool,
}
impl Frame {
pub fn new(connection: u64, solo: bool) -> Self {
Self { connection, solo }
}
}
/// Rectangles drawn before a name: x, y, width, height in text pixels from the name line's top
/// left corner (a pixel left of its background), and `0xAARRGGBB`.
pub type Mark = &'static [(u8, u8, u8, u8, u32)];
/// What a crate draws by a player's name over their head ([`crate::Hooks::name_decoration`]).
#[derive(Clone, Copy, Debug, Default)]
#[non_exhaustive]
pub struct NameDecoration {
/// Before the name.
pub mark: Option<Mark>,
/// After the name, and a line under it.
pub badge: Option<Badge>,
/// How high the crate's drawing on the body gets above the neck, in model pixels: the tag
/// goes up over it.
pub top: Option<f32>,
}
impl NameDecoration {
pub fn new(mark: Option<Mark>, badge: Option<Badge>, top: Option<f32>) -> Self {
Self { mark, badge, top }
}
}
/// A badge after a name: 8 text pixels square, a pixel right of the background.
#[derive(Clone, Copy, Debug)]
#[non_exhaustive]
pub struct Badge {
/// Its picture: a part of a [`Sprites`] sprite, in atlas coordinates (u0, v0, u1, v1).
pub uv: [f32; 4],
/// The line under the name, `0xRRGGBB`.
pub line: u32,
}
impl Badge {
pub fn new(uv: [f32; 4], line: u32) -> Self {
Self { uv, line }
}
}
/// A crate's pictures in the game's atlas, by ids of its own: 64×64 RGBA each, in the slots skins
/// use (one no frame drew for a while may go). Read while drawing.
pub trait Sprites {
/// Where sprite `id` is (u0, v0, u1, v1), and it is kept this frame. None until
/// [`SpriteStore::put`] put it there.
fn get(&self, id: u64) -> Option<[f32; 4]>;
}
/// Where a crate puts its [`Sprites`], in [`crate::Hooks::frame`].
pub trait SpriteStore {
/// Sprite `id` is in the atlas.
fn has(&self, id: u64) -> bool;
/// Puts sprite `id` in the atlas (64×64 RGBA), if a slot is free.
fn put(&mut self, id: u64, rgba: Vec<u8>);
}
/// The window's own model, as [`crate::Hooks::camera`] sees it.
#[derive(Clone, Copy, Debug)]
#[non_exhaustive]
pub struct CameraView {
/// Its age in ticks, with the frame's part of a tick.
pub age: f32,
}
impl CameraView {
pub fn new(age: f32) -> Self {
Self { age }
}
}
/// How a crate moves the camera in third person.
#[derive(Clone, Copy, Debug)]
#[non_exhaustive]
pub struct CameraOverride {
/// How far it stands back, as a share of the usual distance.
pub distance: f32,
}
impl CameraOverride {
pub fn new(distance: f32) -> Self {
Self { distance }
}
}
/// A model a crate draws on players, as the game's own models are: a tree of parts, 16 units a
/// block, y down. Build it once and keep the `Arc`: the engine prepares it for the GPU once, by
/// the `Arc`'s address, while the crate holds it.
#[derive(Clone, Debug, Default)]
#[non_exhaustive]
pub struct Mesh {
/// What [`PartPoses::pose`] names it by.
pub name: String,
/// At rest: x, y, z, x/y/z rotation (radians), x/y/z scale.
pub pose: [f32; 9],
pub quads: Vec<Quad>,
pub children: Vec<Mesh>,
}
impl Mesh {
pub fn new(name: String, pose: [f32; 9], quads: Vec<Quad>, children: Vec<Mesh>) -> Self {
Self {
name,
pose,
quads,
children,
}
}
}
/// One face of a [`Mesh`].
#[derive(Clone, Copy, Debug)]
#[non_exhaustive]
pub struct Quad {
pub normal: [f32; 3],
/// Its corners: x, y, z, then u, v across the sprite (0 to 1).
pub vertices: [[f32; 5]; 4],
}
impl Quad {
pub fn new(normal: [f32; 3], vertices: [[f32; 5]; 4]) -> Self {
Self { normal, vertices }
}
}
/// A part of the player's body a crate draws on.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
#[non_exhaustive]
pub enum Bone {
Head,
Body,
RightArm,
LeftArm,
RightLeg,
LeftLeg,
}
impl Bone {
/// Its name among [`BODY_PARTS`].
pub fn part(self) -> &'static str {
BODY_PARTS[self as usize]
}
}
/// Which picture of a player is drawn.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
#[non_exhaustive]
pub enum Pass {
/// In the world, seen by the camera (the window's own model too, in third person).
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.
pub trait PartPoses {
/// Part `name`'s pose, to change (see [`Mesh::pose`]); a name the mesh lacks gets a pose that
/// is not used.
fn pose(&mut self, name: &str) -> &mut [f32; 9];
}
/// Where a crate draws on a player model ([`crate::Hooks::player_layers`]). Matrices are
/// column-major, relative to the camera, in blocks.
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;
/// 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` as `look` says; `pose` poses its parts first.
fn draw(
&mut self,
mesh: &Arc<Mesh>,
matrix: [[f32; 4]; 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,15 +0,0 @@
[package]
name = "horizon-official-servers"
version = "0.1.0"
edition.workspace = true
license.workspace = true
rust-version.workspace = true
description = "Horizon's official server in the multiplayer list, and the withdrawn partner server taken out of it (docs/layers.md)"
[dependencies]
horizon-addresses = { path = "../horizon-addresses" }
horizon-platform = { path = "../horizon-platform", features = ["protocol"] }
log = "0.4"
[lints]
workspace = true
-2
View File
@@ -15,8 +15,6 @@ horizon-game = { path = "../horizon-game" }
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]
+5 -16
View File
@@ -4,36 +4,25 @@
//! (`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 its settings store and pages if it has any, and
//! [`register`] adds them. Nothing here depends on an engine crate.
//! `horizon_platform::hooks::Hooks`, and [`register`] adds it. 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; 4] = [
const PARTS: [&dyn Hooks; 2] = [
// 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() {
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 {
for part in PARTS {
if let Err(e) = hooks::register(part) {
// 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}");
}
+1 -1
View File
@@ -10,7 +10,7 @@ description = "The platform's public API: what the Horizon layer and mods may us
# Each part of the engine a Horizon crate may reach, so a crate builds only the engine it uses.
# level.dat, servers.dat and region files: NBT, compression, region read and write.
world = ["dep:horizon-world"]
# A server's address after its SRV record, the offline-mode UUID of a name, and servers.dat.
# A server's address after its SRV record, and the offline-mode UUID of a name.
protocol = ["world", "dep:horizon-protocol"]
# Preparing the game's files from the player's own Minecraft install.
prepare = ["dep:horizon-prepare"]
+8 -11
View File
@@ -46,9 +46,15 @@ pub use horizon_server_files as server_files;
/// [`hooks::register`] it before the game starts (docs/layers.md, "How a Horizon crate plugs
/// in"). The engine's dispatch side is not part of the API.
pub mod hooks {
pub use horizon_hooks::{
Activity, Client, Dimension, GameMode, Hooks, Play, Started, TooLate, register, tunnel,
/// The players the game shows (`horizon_hooks::players`): the views [`Hooks::frame`] and
/// [`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, Light,
Look, Mark, Mesh, Motion, NameDecoration, PartPoses, Particle, Pass, PlayerDraw,
PlayerModel, PlayerModels, Quad, SpriteStore, Sprites, View,
};
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
@@ -66,12 +72,3 @@ pub mod pages {
pub mod hud {
pub use horizon_hooks::hud::{Canvas, Corner, Layer, register};
}
/// The multiplayer server list as vanilla stores it (`servers.dat`): read, write, add, remove,
/// and the lock held while it is read and written back.
#[cfg(feature = "protocol")]
pub mod servers {
pub use horizon_protocol::servers::{
ServerEntry, load, lock, parse_address, prepend, remove_where, save,
};
}
+4 -4
View File
@@ -22,15 +22,15 @@ What changes in that build:
| Extra | Where it lives | The gate |
|---|---|---|
| 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) 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 |
| The official server added to the server list, the withdrawn partner server taken out | `official_servers.rs` | the module and its call at startup (`game.rs`, `fn run`) |
| Horizon Settings (Extras, PvP & HUD, Splitscreen, Studio, AI Agent, Discord) and Mods (with the registry browser) | `options_ui.rs` (`Page::Horizon` and below), `extras_ui.rs`, `extras.rs`, `pvp.rs`, `game/extras.rs`, `game/tweaks.rs`, `extras_hud.rs` | the Options screen shows vanilla's greyed Telemetry Data instead (`options_ui::HORIZON_EXTRAS`, set in `entry.rs`) |
| Discord Rich Presence | `discord_presence.rs`, `game/discord.rs` | the `discord` feature, which `horizon-extras` turns on |
| Play with Friends | hosting: `game/friends.rs`, `game/friends/screen.rs`; joining: crate `horizon-friends` (`game`) | hosting: the `friends` feature (`game/friends_off.rs` stands in); joining: only the official executable registers it ([layers.md](layers.md#how-a-horizon-crate-plugs-in)) |
| The anonymous "I'm playing" ping | crate `horizon-heartbeat` | not in horizon-client at all: only the official executable (`horizon-official`) registers it ([layers.md](layers.md#how-a-horizon-crate-plugs-in)) |
Credits & Attribution then says where the official build is (`horizon.plain.official`).
`grep -rn horizon-extras crates/` lists every gate. Apart from the `friends` feature above, the gates
`grep -rn horizon-extras crates/` lists every gate. Apart from the three features above, the gates
are `cfg!(feature = "horizon-extras")`: both sides compile in every build, so no stub has to follow
the code it replaces. To remove an extra for good, delete its files from the table and the lines its
gate guards.
+4 -3
View File
@@ -156,8 +156,9 @@ Horizon's own extras and its product, and every built-in address of an outside s
benchmark uploads, releases and updates, the hosting supervisor, the launcher.
- **Examples:** `horizon-addresses`, `horizon-cosmetics`, `horizon-friends`, `horizon-relay`, `horizon-telemetry`,
`horizon-bench`, `horizon-release-sign`, `horizon-server`, `horizon-mod-registry`, `horizon-heartbeat`,
`horizon-official`, `horizon-official-servers`, `horizon-discord`, `launcher/`. Still in engine crates today:
`game/cosmetics.rs`, `game/emotes.rs`, `game/extras.rs`, `game/tweaks.rs`, `extras_ui.rs`, `pvp.rs`.
`horizon-official`, `launcher/`. Still in engine crates today: `game/cosmetics.rs`, `game/emotes.rs`,
`game/discord.rs`, `game/extras.rs`, `game/tweaks.rs`, `multiplayer.rs` (`add_official_server`), `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
@@ -512,7 +513,7 @@ Horizon's own and third-party addresses (Horizon layer):
| `cosmetics.playhorizon.dev` | cosmetics, emotes, presence | `horizon-addresses` (#12993) |
| `telemetry.playhorizon.dev` | opt-in telemetry, the "I'm playing" ping, benchmark uploads | `horizon-addresses` (#12993) |
| `stun.cloudflare.com`, `stun.l.google.com` | Play with Friends NAT discovery | `horizon-addresses` (#12993) |
| `afri-network.co.za` | the withdrawn partner server, so the game can remove the entry it added | `horizon-addresses` |
| `afri-network.co.za` | the withdrawn partner server, so the game can remove the entry it added | `multiplayer.rs` (`WITHDRAWN_PARTNER`) |
| `horizon-rust.github.io/horizon-mods`, `github.com/horizon-rust/horizon-mods` | the mod registry index and files: a build of this repository alone asks the player for a registry | `mod_registry.rs`, `horizon-mods/src/index*.rs`, `cargo-horizon-mod` |
| `api.modrinth.com`, `cdn.modrinth.com` | the in-game resource pack browser, modpack porting | `pack_browser.rs`, `horizon-port-pack` |
| `github.com`, `raw.githubusercontent.com`, `gitlab.com`, `maven.neoforged.net` | modpack porting's sources | `horizon-port-pack` |
+6 -11
View File
@@ -423,18 +423,13 @@ 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. The switch is on the Extras page, in its
Multiplayer group (**Discord Rich Presence**, on by default; `discordPresence`
in `horizon-extras.txt`, the first splitscreen player's), and
`HORIZON_NO_DISCORD=1` turns it off for one run.
world's name or a server's address. Horizon Settings has the switch
(**Discord Rich Presence**, on by default; `discordPresence` in
`horizon-extras.txt`), and `HORIZON_NO_DISCORD=1` turns it off for one run.
The crate `horizon-discord` (Horizon layer) speaks Discord's local IPC itself
(the `discord-ipc-N` socket or named pipe, no SDK) from a thread of its own.
The game hands it a status only when it changes, through the platform's
`activity` hook (`game/activity.rs` says what the player does), 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
`crates/horizon-client/src/discord_presence.rs` speaks Discord's local IPC
itself (the `discord-ipc-N` socket or named pipe, no SDK) from a thread of its
own: the frame only hands it a status when it changes. It retries every 15 s
while Discord isn't running and stays within Discord's 5 updates per 20 s.
Dedicated servers never start it, and automated runs (offscreen, benchmarks)
only talk to a fake Discord: `lab.game_env` sets `HORIZON_NO_DISCORD=1`, and
+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 Play with Friends: the `friends` feature; nor Discord presence or the "I'm playing" ping, which
only the official executable has), which links neither `libasound.so.2` nor `libudev.so.1`. `release.yml` builds it after the
controllers, no Discord presence or Play with Friends: the `discord` and `friends` features; nor the "I'm
playing" ping, which only the official executable has), which links neither `libasound.so.2` nor `libudev.so.1`. `release.yml` builds it after the
client archive (`xtask package-server`) and fails if `tools/check_imports.py --server` finds either library;
CI lints the same build. `horizon-server` runs `horizon-engine` when it is beside it or on the PATH, else
`horizon-client`. Build it yourself with `cargo build --release -p horizon-client --no-default-features`
+1
View File
@@ -49,6 +49,7 @@
"crates/horizon-client/src/mod_registry.rs horizon-rust.github.io",
"crates/horizon-client/src/mod_registry.rs playhorizon.dev",
"crates/horizon-client/src/no_resources.rs playhorizon.dev",
"crates/horizon-client/src/official_servers.rs afri-network.co.za",
"crates/horizon-mod-registry/src/github.rs api.github.com",
"crates/horizon-mod-registry/src/github.rs github.com",
"crates/horizon-mod-registry/src/github.rs uploads.github.com",
+6 -6
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 Extras switch
setting off in horizon-extras.txt: nothing reaches Discord; the Horizon Settings 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-official'), '--resources',
self.ctl_run('launch', '--offscreen', '--game', str(bins / 'horizon-client'), '--resources',
str(lab.RESOURCES.resolve()), *args)
def ctl_run(self, *words, timeout=600):
@@ -208,7 +208,7 @@ def lan(bins):
def setting(bins):
"""Off in horizon-extras.txt: Discord never hears from the game. Turned on in Extras: the
"""Off in horizon-extras.txt: Discord never hears from the game. Turned on in Horizon Settings: the
activity appears; off again: it is cleared."""
game = Game(bins, 'setting', extras='discordPresence:false\n')
d = game.discord
@@ -216,7 +216,7 @@ 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', 'extras')
game.ctl_run('options', 'horizon')
switch = next(w for w in game.ctl_run('ui')['widgets'] if w.get('label', '').startswith('Discord Rich'))
assert switch['label'] == 'Discord Rich Presence: Off', switch
game.ctl_run('click', switch['id'])
@@ -251,14 +251,14 @@ CHECKS = {'single': single, 'lan': lan, 'setting': setting, 'late': late}
def main():
parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
parser.add_argument('--check', choices=[*CHECKS, 'all'], default='all')
parser.add_argument('--bin-dir', help='prebuilt horizon-official and horizon-ctl (default: build with the fast profile)')
parser.add_argument('--bin-dir', help='prebuilt horizon-client and horizon-ctl (default: build with the fast profile)')
args = parser.parse_args()
if sys.platform == 'win32':
sys.exit('the fake Discord is a Unix socket: macOS or Linux only')
if args.bin_dir:
bins = Path(args.bin_dir).resolve()
else:
subprocess.run(['cargo', 'build', '--locked', '--profile', 'fast', '-p', 'horizon-official', '-p', 'horizon-client-lib', '--bins'],
subprocess.run(['cargo', 'build', '--locked', '--profile', 'fast', '-p', 'horizon-client', '-p', 'horizon-client-lib', '--bins'],
cwd=ROOT, check=True)
meta = json.loads(subprocess.run(['cargo', 'metadata', '--format-version', '1', '--no-deps'], cwd=ROOT,
capture_output=True, text=True, check=True).stdout)
-2
View File
@@ -51,13 +51,11 @@
"horizon-addresses": "horizon",
"horizon-bench": "horizon",
"horizon-cosmetics": "horizon",
"horizon-discord": "horizon",
"horizon-friends": "horizon",
"horizon-heartbeat": "horizon",
"horizon-launcher": "horizon",
"horizon-mod-registry": "horizon",
"horizon-official": "horizon",
"horizon-official-servers": "horizon",
"horizon-relay": "horizon",
"horizon-release-sign": "horizon",
"horizon-server": "horizon",
+1 -1
View File
@@ -46,7 +46,7 @@ Entity.dropAllLeashConnections port crates/horizon-client/src/game/leads.rs::Gam
Entity.canCollideWith inline crates/horizon-client/src/game/ghasts.rs::Game::player_entity_collisions gap: player: the entities a player cannot walk through are boats and happy ghasts only; a live shulker (canBeCollidedWith) is not solid, so the player walks through it
Entity.getDefaultPassengerAttachmentPoint inline crates/horizon-client/src/mobs/riders.rs::passenger_attachment one point per vehicle, so the passenger index is always 0; default is the top of the box
Entity.ejectPassengers port crates/horizon-client/src/game/boat_passengers.rs::Game::eject_boat_mobs gap: minor: boats place riders at the dismount spot; when a mob vehicle dies sync_riders only lets go and the rider stays where it was seated
Entity.removeVehicle port crates/horizon-client/src/game/sculk.rs::Game::mount_events `startRiding`/`removeVehicle`: `entity_mount` at the new vehicle and `entity_dismount` at the old one, derived from the vehicle the rider had last tick, for players and mobs
Entity.removeVehicle inline crates/horizon-client/src/game/commands/world_cmds.rs::Game::dismount_mob gap: minor: no entity_dismount game event, so sculk sensors and wardens never hear a dismount
Entity.moveOrInterpolateTo port crates/horizon-world/src/stepped_interpolation.rs::SteppedInterpolation::interpolate_to living entities from a server; boats and other objects use a three tick Lerp
Entity.getHandHoldingItemAngle port crates/horizon-client/src/game/fireworks.rs::Game::rocket_hand_angle ticked in #3514; `Entity.getHandHoldingItemAngle` is cited at crates/horizon-client/src/game/fireworks.rs:241
Entity.teleportToPortalDestination inline crates/horizon-client/src/game/nether_portal.rs::Game::send_through_portal may_enter checks isAllowedToEnterPortal; the entity is saved and waits for its dimension
1 Entity.<init> port crates/horizon-client/src/mobs.rs::Mob::new mobs only; players and projectiles are built by their own structs and the synched defaults (flags, air, name, pose) are plain fields
46 Entity.canCollideWith inline crates/horizon-client/src/game/ghasts.rs::Game::player_entity_collisions gap: player: the entities a player cannot walk through are boats and happy ghasts only; a live shulker (canBeCollidedWith) is not solid, so the player walks through it
47 Entity.getDefaultPassengerAttachmentPoint inline crates/horizon-client/src/mobs/riders.rs::passenger_attachment one point per vehicle, so the passenger index is always 0; default is the top of the box
48 Entity.ejectPassengers port crates/horizon-client/src/game/boat_passengers.rs::Game::eject_boat_mobs gap: minor: boats place riders at the dismount spot; when a mob vehicle dies sync_riders only lets go and the rider stays where it was seated
49 Entity.removeVehicle port inline crates/horizon-client/src/game/sculk.rs::Game::mount_events crates/horizon-client/src/game/commands/world_cmds.rs::Game::dismount_mob `startRiding`/`removeVehicle`: `entity_mount` at the new vehicle and `entity_dismount` at the old one, derived from the vehicle the rider had last tick, for players and mobs gap: minor: no entity_dismount game event, so sculk sensors and wardens never hear a dismount
50 Entity.moveOrInterpolateTo port crates/horizon-world/src/stepped_interpolation.rs::SteppedInterpolation::interpolate_to living entities from a server; boats and other objects use a three tick Lerp
51 Entity.getHandHoldingItemAngle port crates/horizon-client/src/game/fireworks.rs::Game::rocket_hand_angle ticked in #3514; `Entity.getHandHoldingItemAngle` is cited at crates/horizon-client/src/game/fireworks.rs:241
52 Entity.teleportToPortalDestination inline crates/horizon-client/src/game/nether_portal.rs::Game::send_through_portal may_enter checks isAllowedToEnterPortal; the entity is saved and waits for its dimension
+1 -1
View File
@@ -62,7 +62,7 @@ LivingEntity.dropFromShearingLootTable port crates/horizon-client/src/game/creat
LivingEntity.getFallDamageSound inline crates/horizon-client/src/mobs/landing.rs::MobLanding::living_fall_sound a monster gets entity.hostile.big_fall and entity.hostile.small_fall, anything else the generic pair; a player's comes from landing.rs
LivingEntity.getHitbox missing minor: a rider's hitbox is not cut at its seat height; spear and melee tests use the full box
LivingEntity.postPiercingAttack port crates/horizon-client/src/game/enchant_effects.rs::Game::post_piercing_attack Lunge's dash after a jab
LivingEntity.trapdoorUsableAsLadder port crates/horizon-world/src/climbing.rs::on_climbable the climbable tag, or an open trapdoor directly over a ladder with the same facing: `climbing::on_climbable` for the player and `Mob::on_climbable` for mobs
LivingEntity.trapdoorUsableAsLadder port crates/horizon-world/src/climbing.rs::on_climbable gap: minor: the player only; a mob's ai.rs::on_climbable checks the climbable tag at its feet, no open trapdoor over a ladder
LivingEntity.isAlive port crates/horizon-client/src/mobs.rs::Mob::alive not removed and health above zero
LivingEntity.setIgnoreFallDamageFromCurrentImpulse port crates/horizon-world/src/living_fall.rs::ImpulseContext::set_ignore_fall_damage 40 ticks of grace
LivingEntity.getDamageAfterArmorAbsorb inline crates/horizon-client/src/mobs.rs::Mob::damage_with the player's armor wear is health.rs hurt_armor; the mob path also scales armor effectiveness (breach)
1 Interaction.defineSynchedData port crates/horizon-client/src/game/interactions.rs::RemoteInteraction::new width 1.0, height 1.0, response false
62 LivingEntity.getFallDamageSound inline crates/horizon-client/src/mobs/landing.rs::MobLanding::living_fall_sound a monster gets entity.hostile.big_fall and entity.hostile.small_fall, anything else the generic pair; a player's comes from landing.rs
63 LivingEntity.getHitbox missing minor: a rider's hitbox is not cut at its seat height; spear and melee tests use the full box
64 LivingEntity.postPiercingAttack port crates/horizon-client/src/game/enchant_effects.rs::Game::post_piercing_attack Lunge's dash after a jab
65 LivingEntity.trapdoorUsableAsLadder port crates/horizon-world/src/climbing.rs::on_climbable the climbable tag, or an open trapdoor directly over a ladder with the same facing: `climbing::on_climbable` for the player and `Mob::on_climbable` for mobs gap: minor: the player only; a mob's ai.rs::on_climbable checks the climbable tag at its feet, no open trapdoor over a ladder
66 LivingEntity.isAlive port crates/horizon-client/src/mobs.rs::Mob::alive not removed and health above zero
67 LivingEntity.setIgnoreFallDamageFromCurrentImpulse port crates/horizon-world/src/living_fall.rs::ImpulseContext::set_ignore_fall_damage 40 ticks of grace
68 LivingEntity.getDamageAfterArmorAbsorb inline crates/horizon-client/src/mobs.rs::Mob::damage_with the player's armor wear is health.rs hurt_armor; the mob path also scales armor effectiveness (breach)
+1 -1
View File
@@ -31,7 +31,7 @@ Projectile.onAboveBubbleColumn port crates/horizon-client/src/projectiles.rs::Pr
Projectile.onInsideBubbleColumn port crates/horizon-client/src/projectiles.rs::Projectile::bubble_column_push unclamped 0.06 up (0.03 down) inside a column; arrows in arrows.rs::Ticker::bubble_column
Projectile.canBreakBlockInAdventureMode port crates/horizon-client/src/game/creatures.rs::Game::projectile_breaks_block gap: minor: the shooter's gamemode and kind decide it; a stack's `can_break` predicate is not consulted
Projectile.spawnProjectileFromRotation port crates/horizon-client/src/projectiles.rs::Projectile::shoot_from_rotation gap: minor: the aim and inherited velocity only; the add-to-world step is the caller's
Projectile.spawnProjectile inline crates/horizon-client/src/game/creatures.rs::Game::throw_item each shooter adds its own projectile (throw_item, shoot_crossbow, shoot_projectile_item, the bow in game/use_hand.rs::release_bow); throw_item is the player throw path
Projectile.spawnProjectile inline crates/horizon-client/src/game/creatures.rs::Game::throw_item each shooter adds its own projectile (throw_item, shoot_crossbow, shoot_projectile_item, the bow in game.rs); throw_item is the player throw path
Projectile.applyOnProjectileSpawned port crates/horizon-client/src/game/enchant_effects.rs::Game::ignite_spawned_arrow gap: minor: Flame ignite only; Piercing and Punch go through Projectile::set_bonus
Projectile.hitTargetOrDeflectSelf port crates/horizon-client/src/game/creatures.rs::Game::tick_projectiles gap: minor: deflection is checked for breezes only; the onHit dispatch is creatures.rs projectile_hit
Projectile.onRedirectProjectile port crates/horizon-client/src/game/deflect.rs::Game::deflect gap: minor: the player's attack turns the projectile; a projectile hitting another redirectable one is not modelled
1 FireworkRocketEntity.<init> port crates/horizon-client/src/game/fireworks.rs::Game::new_rocket launch velocity triangle and lifetime 10*(1+flight)+randoms; the owner and attached target are set by the callers
31 Projectile.onInsideBubbleColumn port crates/horizon-client/src/projectiles.rs::Projectile::bubble_column_push unclamped 0.06 up (0.03 down) inside a column; arrows in arrows.rs::Ticker::bubble_column
32 Projectile.canBreakBlockInAdventureMode port crates/horizon-client/src/game/creatures.rs::Game::projectile_breaks_block gap: minor: the shooter's gamemode and kind decide it; a stack's `can_break` predicate is not consulted
33 Projectile.spawnProjectileFromRotation port crates/horizon-client/src/projectiles.rs::Projectile::shoot_from_rotation gap: minor: the aim and inherited velocity only; the add-to-world step is the caller's
34 Projectile.spawnProjectile inline crates/horizon-client/src/game/creatures.rs::Game::throw_item each shooter adds its own projectile (throw_item, shoot_crossbow, shoot_projectile_item, the bow in game/use_hand.rs::release_bow); throw_item is the player throw path each shooter adds its own projectile (throw_item, shoot_crossbow, shoot_projectile_item, the bow in game.rs); throw_item is the player throw path
35 Projectile.applyOnProjectileSpawned port crates/horizon-client/src/game/enchant_effects.rs::Game::ignite_spawned_arrow gap: minor: Flame ignite only; Piercing and Punch go through Projectile::set_bonus
36 Projectile.hitTargetOrDeflectSelf port crates/horizon-client/src/game/creatures.rs::Game::tick_projectiles gap: minor: deflection is checked for breezes only; the onHit dispatch is creatures.rs projectile_hit
37 Projectile.onRedirectProjectile port crates/horizon-client/src/game/deflect.rs::Game::deflect gap: minor: the player's attack turns the projectile; a projectile hitting another redirectable one is not modelled