mirror of
https://github.com/horizon-rust/horizon.git
synced 2026-11-09 10:39:46 -10:00
Compare commits
28
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4e22c1ad9a | ||
|
|
e1aa435188 | ||
|
|
5385996153 | ||
|
|
115f5235b9 | ||
|
|
ceff25fca1 | ||
|
|
36d0695742 | ||
|
|
94484675a8 | ||
|
|
9097d2973d | ||
|
|
00e573cf87 | ||
|
|
cef35e64e7 | ||
|
|
3a9b05d21d | ||
|
|
0f0ca381d4 | ||
|
|
c992f24496 | ||
|
|
005249b984 | ||
|
|
d577d49a78 | ||
|
|
af3c22b9f1 | ||
|
|
182eec2cb7 | ||
|
|
8ac6a79035 | ||
|
|
153e0d20a4 | ||
|
|
01343707af | ||
|
|
466201a864 | ||
|
|
ddb4f9df58 | ||
|
|
358456bb02 | ||
|
|
8e2780a8f0 | ||
|
|
2cd4ec9888 | ||
|
|
443a42f97a | ||
|
|
de9ab4646d | ||
|
|
2d2a2621f2 |
No files matched your search
Generated
+20
@@ -1650,6 +1650,15 @@ dependencies = [
|
||||
"ureq",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "horizon-discord"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"horizon-platform",
|
||||
"log",
|
||||
"serde_json",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "horizon-dualsense"
|
||||
version = "0.1.0"
|
||||
@@ -1793,14 +1802,25 @@ 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"
|
||||
|
||||
@@ -34,6 +34,8 @@ 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",
|
||||
|
||||
@@ -15,6 +15,10 @@ 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";
|
||||
|
||||
@@ -109,17 +109,12 @@ objc2-foundation = { version = "0.3", default-features = false, features = ["std
|
||||
wgpu = { version = "30.0.1", default-features = false, features = ["gles"] }
|
||||
|
||||
[features]
|
||||
default = ["audio", "controllers", "horizon-extras"]
|
||||
default = ["audio", "controllers"]
|
||||
# Sound output. Without it (and `controllers`) the program links neither libasound nor libudev on
|
||||
# Linux: the server build (`--no-default-features`, released as horizon-engine; docs/server.md).
|
||||
audio = ["horizon-client-render/audio"]
|
||||
# Controllers: gilrs, and the DualSense's own output. Without it only `horizon-ctl pad`'s virtual pad.
|
||||
controllers = ["dep:gilrs", "horizon-client-input/controllers"]
|
||||
# Horizon's own parts (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.
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
//! settings, kept in `horizon-extras.txt` next to `options.txt` so that file
|
||||
//! stays exactly vanilla's, and the logic behind them. Every extra is off by
|
||||
//! default; see `docs/extras.md`. Horizon's own switches outside the Extras page
|
||||
//! (No AI, Agent Control, Discord Rich Presence) are kept here too.
|
||||
//! (No AI, Agent Control) are kept here too.
|
||||
use std::{collections::VecDeque, fs, path::Path};
|
||||
|
||||
pub const FILE: &str = "horizon-extras.txt";
|
||||
@@ -145,8 +145,6 @@ pub struct Extras {
|
||||
/// F3+R and F3+E start/stop a repro recording and write its bundle (off by
|
||||
/// default: a recording copies the world).
|
||||
pub repro_keys: bool,
|
||||
/// "Playing Horizon" on the player's Discord profile (`discord_presence`; on by default).
|
||||
pub discord_presence: bool,
|
||||
/// Another controller can join P1's world (`splitscreen`, off by default).
|
||||
pub splitscreen: bool,
|
||||
/// Two players in one window: 0 auto (side by side when wider than tall), 1 side by side,
|
||||
@@ -233,7 +231,6 @@ impl Default for Extras {
|
||||
no_ai: false,
|
||||
agent_control: false,
|
||||
repro_keys: false,
|
||||
discord_presence: true,
|
||||
splitscreen: false,
|
||||
splitscreen_two: 0,
|
||||
splitscreen_three: 0,
|
||||
@@ -338,7 +335,6 @@ impl Extras {
|
||||
horizon_core::no_ai::KEY => e.no_ai = flag(e.no_ai),
|
||||
"agentControl" => e.agent_control = flag(e.agent_control),
|
||||
"reproKeys" => e.repro_keys = flag(e.repro_keys),
|
||||
"discordPresence" => e.discord_presence = flag(e.discord_presence),
|
||||
"splitscreen" => e.splitscreen = flag(e.splitscreen),
|
||||
"splitscreenTwo" => e.splitscreen_two = value.parse().unwrap_or(0).min(2),
|
||||
"splitscreenThree" => e.splitscreen_three = value.parse().unwrap_or(0).min(2),
|
||||
@@ -376,7 +372,7 @@ impl Extras {
|
||||
}
|
||||
pub fn serialize(&self) -> String {
|
||||
let mut text = format!(
|
||||
"packBrowser:{}\npregen:{}\nfullbright:{}\ndynamicLights:{}\nzoom:{}\nzoomKey:{}\nzoomLevel:{}\nzoomSmooth:{}\nzoomSensitivity:{}\nlodZoomDetail:{}\nfps:{}\nfpsCorner:{}\nfpsLows:{}\nkeystrokes:{}\nkeystrokesCorner:{}\nkeystrokesCps:{}\nmovedToMods:{}\nminimap:{}\nminimapCorner:{}\nminimapSize:{}\nminimapShape:{}\nminimapZoom:{}\nminimapRotate:{}\nautoReconnect:{}\nuiSoundReserve:{}\nownNameTag:{}\nhideBlockParticles:{}\nmouseTweaks:{}\nmouseTweaksWheel:{}\nsort:{}\nsortKey:{}\nchatAnimation:{}\nchatIndicator:{}\n{}:{}\nagentControl:{}\nreproKeys:{}\ndiscordPresence:{}\nsplitscreen:{}\nsplitscreenTwo:{}\nsplitscreenThree:{}\nsplitscreenNames:{}\nwheelCyclesListSettings:{}\nmusicOff:{}\nmusicPage:{}\ncosmeticsMine:{}\n{}cosmeticsHideFx:{}\ncosmeticsSolo:{}\n{}:{}\n{}:{}\n{}:{}\n",
|
||||
"packBrowser:{}\npregen:{}\nfullbright:{}\ndynamicLights:{}\nzoom:{}\nzoomKey:{}\nzoomLevel:{}\nzoomSmooth:{}\nzoomSensitivity:{}\nlodZoomDetail:{}\nfps:{}\nfpsCorner:{}\nfpsLows:{}\nkeystrokes:{}\nkeystrokesCorner:{}\nkeystrokesCps:{}\nmovedToMods:{}\nminimap:{}\nminimapCorner:{}\nminimapSize:{}\nminimapShape:{}\nminimapZoom:{}\nminimapRotate:{}\nautoReconnect:{}\nuiSoundReserve:{}\nownNameTag:{}\nhideBlockParticles:{}\nmouseTweaks:{}\nmouseTweaksWheel:{}\nsort:{}\nsortKey:{}\nchatAnimation:{}\nchatIndicator:{}\n{}:{}\nagentControl:{}\nreproKeys:{}\nsplitscreen:{}\nsplitscreenTwo:{}\nsplitscreenThree:{}\nsplitscreenNames:{}\nwheelCyclesListSettings:{}\nmusicOff:{}\nmusicPage:{}\ncosmeticsMine:{}\n{}cosmeticsHideFx:{}\ncosmeticsSolo:{}\n{}:{}\n{}:{}\n{}:{}\n",
|
||||
bool_text(self.pack_browser),
|
||||
bool_text(self.pregen),
|
||||
bool_text(self.fullbright),
|
||||
@@ -418,7 +414,6 @@ impl Extras {
|
||||
bool_text(self.no_ai),
|
||||
bool_text(self.agent_control),
|
||||
bool_text(self.repro_keys),
|
||||
bool_text(self.discord_presence),
|
||||
bool_text(self.splitscreen),
|
||||
self.splitscreen_two,
|
||||
self.splitscreen_three,
|
||||
@@ -613,8 +608,6 @@ pub enum Item {
|
||||
/// Not on the Extras page: Horizon Settings shows No AI, its AI Agent page Agent Control.
|
||||
NoAi,
|
||||
AgentControl,
|
||||
/// On the Horizon Settings page.
|
||||
DiscordPresence,
|
||||
/// On the AI Agent page, next to Agent Control: the F3 recording shortcuts.
|
||||
ReproKeys,
|
||||
/// On the Splitscreen settings page.
|
||||
@@ -691,7 +684,6 @@ 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",
|
||||
@@ -724,7 +716,6 @@ 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",
|
||||
@@ -768,7 +759,6 @@ 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",
|
||||
@@ -792,10 +782,9 @@ impl Item {
|
||||
ChatIndicator => ChatAnimation,
|
||||
Pvp(o) => return o.parent().map(Pvp),
|
||||
Fullbright | DynamicLights | OwnNameTag | HideBlockParticles | MouseTweaks | Sort
|
||||
| AutoReconnect | NoAi | AgentControl | ReproKeys | DiscordPresence | Splitscreen
|
||||
| ChatAnimation | WheelCyclesSettings | MusicPage | PackBrowser | CosmeticsMine
|
||||
| CosmeticsOthers | CosmeticsHideFx | CosmeticsSolo | HorizonMarks | HorizonMe
|
||||
| Pregen => {
|
||||
| AutoReconnect | NoAi | AgentControl | ReproKeys | Splitscreen | ChatAnimation
|
||||
| WheelCyclesSettings | MusicPage | PackBrowser | CosmeticsMine | CosmeticsOthers
|
||||
| CosmeticsHideFx | CosmeticsSolo | HorizonMarks | HorizonMe | Pregen => {
|
||||
return None;
|
||||
}
|
||||
})
|
||||
@@ -844,9 +833,6 @@ impl Item {
|
||||
Item::NoAi => {
|
||||
"Hides and turns off every AI feature: agent control, horizon-ctl and the MCP servers. The launcher's switch too."
|
||||
}
|
||||
Item::DiscordPresence => {
|
||||
"Shows Playing Horizon on your Discord profile while Discord runs: menus, single player or multiplayer, the dimension and the time played. Never a world's name or a server's address."
|
||||
}
|
||||
Item::Splitscreen => {
|
||||
"In a world, another controller can join as a new player: press A when it asks, then pick a profile and where to play."
|
||||
}
|
||||
@@ -927,7 +913,6 @@ 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,
|
||||
@@ -986,7 +971,6 @@ 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,
|
||||
@@ -1104,7 +1088,7 @@ mod tests {
|
||||
assert!(!d.mouse_tweaks && !d.sort && !d.own_name_tag && !d.ui_sound_reserve);
|
||||
assert!(!d.hide_block_particles && !d.pregen);
|
||||
assert!(!d.music_page && d.music_off.is_empty());
|
||||
assert!(d.discord_presence && !d.wheel_cycles_settings);
|
||||
assert!(!d.wheel_cycles_settings);
|
||||
assert_eq!(Extras::parse(""), d);
|
||||
assert_eq!(Extras::parse(&d.serialize()), d);
|
||||
let mut e = d.clone();
|
||||
@@ -1143,7 +1127,6 @@ mod tests {
|
||||
e.sort_key = "key.keyboard.r".into();
|
||||
e.chat_animation = true;
|
||||
e.chat_indicator = false;
|
||||
e.discord_presence = false;
|
||||
assert!(!e.splitscreen);
|
||||
Item::Splitscreen.cycle(&mut e, false);
|
||||
assert_eq!(Item::Splitscreen.switch(&e), Some(true));
|
||||
|
||||
@@ -239,6 +239,7 @@ 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;
|
||||
@@ -250,6 +251,7 @@ 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`).
|
||||
@@ -726,62 +728,6 @@ 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
|
||||
@@ -10116,7 +10062,11 @@ 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
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
//! `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,21 +124,15 @@ impl OptionsUi {
|
||||
Some(open("horizon.pages.open.studio", Page::Studio)),
|
||||
),
|
||||
// No AI hides the AI Agent page and everything AI.
|
||||
Row::Buttons(
|
||||
self.extra_button(s, crate::extras::Item::DiscordPresence),
|
||||
Some(if s.extras.no_ai {
|
||||
self.extra_button(s, crate::extras::Item::NoAi)
|
||||
} else {
|
||||
open("horizon.pages.open.agent", Page::Agent)
|
||||
}),
|
||||
),
|
||||
]
|
||||
.into_iter()
|
||||
.chain(
|
||||
(!s.extras.no_ai)
|
||||
.then(|| Row::Buttons(self.extra_button(s, crate::extras::Item::NoAi), None)),
|
||||
)
|
||||
.collect();
|
||||
if s.extras.no_ai {
|
||||
Row::Buttons(self.extra_button(s, crate::extras::Item::NoAi), None)
|
||||
} else {
|
||||
Row::Buttons(
|
||||
open("horizon.pages.open.agent", Page::Agent),
|
||||
Some(self.extra_button(s, crate::extras::Item::NoAi)),
|
||||
)
|
||||
},
|
||||
];
|
||||
self.added_entries(&mut rows);
|
||||
rows
|
||||
}
|
||||
|
||||
@@ -337,13 +337,6 @@ impl Settings {
|
||||
.and_then(|mode| self.video_modes.iter().position(|m| *m == mode))
|
||||
.map_or(-1, |i| i as i32)
|
||||
}
|
||||
/// Loads only the crates' settings stores (`horizon_hooks::settings`) from the files next to
|
||||
/// `path`, into the seat whose turn it is (`horizon_hooks::seat`): a splitscreen player whose
|
||||
/// settings were made from P1's rather than loaded.
|
||||
pub fn load_stores(path: &Path) {
|
||||
let dir = path.parent();
|
||||
horizon_hooks::settings::engine::load(&mut |file| fs::read_to_string(dir?.join(file)).ok());
|
||||
}
|
||||
pub fn load(path: &Path) -> Self {
|
||||
let mut horizon = path
|
||||
.parent()
|
||||
|
||||
@@ -119,13 +119,10 @@ audio = ["horizon-client-render/audio", "horizon-client-lib/audio"]
|
||||
controllers = ["horizon-client-lib/controllers", "dep:gilrs", "dep:horizon-dualsense", "horizon-client-input/controllers"]
|
||||
# Horizon's own extras over the vanilla game, for a fork that wants them out (docs/forking.md):
|
||||
# cosmetics and emotes, the "Horizon player" presence, the official server added to
|
||||
# the server list, the Horizon Settings and Mods pages, Discord and Play with Friends. Players always get them; there is no switch. `grep -rn horizon-extras crates/` lists
|
||||
# the server list, the Horizon Settings and Mods pages and Play with Friends. Players always get them; there is no switch. `grep -rn horizon-extras crates/` lists
|
||||
# every place it gates. Without it (`--no-default-features --features audio,controllers`) the
|
||||
# client is best effort: CI builds only the default.
|
||||
horizon-extras = ["horizon-client-lib/horizon-extras", "discord", "friends"]
|
||||
# Online extras a server or a port can leave out; the server build has none of them. "Playing Horizon"
|
||||
# on Discord (game/discord.rs).
|
||||
discord = ["horizon-client-lib/discord"]
|
||||
horizon-extras = ["friends"]
|
||||
# Play with Friends: sharing a world behind an invite code (game/friends.rs). Joining one is
|
||||
# horizon-friends' `game` side, which horizon-official registers (`horizon_hooks::tunnel`).
|
||||
friends = ["dep:horizon-friends"]
|
||||
|
||||
@@ -58,8 +58,6 @@ pub mod debug_overlay;
|
||||
pub mod debugworld;
|
||||
pub mod dialog;
|
||||
pub mod dialog_screen;
|
||||
#[cfg(feature = "discord")]
|
||||
pub mod discord_presence;
|
||||
pub mod end_crystal;
|
||||
pub mod end_gateway;
|
||||
pub mod entities;
|
||||
@@ -102,8 +100,6 @@ 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;
|
||||
|
||||
@@ -23,9 +23,8 @@ 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`),
|
||||
// then the Horizon parts still in this crate; none registers after this point.
|
||||
game::player_hooks::register();
|
||||
// Crates above the engine registered their hooks before this (horizon-official's `main`);
|
||||
// none registers after this point.
|
||||
horizon_hooks::engine::seal();
|
||||
horizon_client::spans::startup::init(std::time::Instant::now());
|
||||
horizon_client::spans::trace::start_from_env();
|
||||
|
||||
@@ -49,6 +49,7 @@ fn pack_files(world_tables: &Option<WorldTables>) -> horizon_world::loot::PackOv
|
||||
}
|
||||
}
|
||||
|
||||
mod activity;
|
||||
mod advancements;
|
||||
mod advancements_screen;
|
||||
mod agent;
|
||||
@@ -128,8 +129,6 @@ mod decorated_pot;
|
||||
mod dedicated;
|
||||
mod deflect;
|
||||
mod dialogs;
|
||||
#[cfg(feature = "discord")]
|
||||
mod discord;
|
||||
mod dispense_fixture;
|
||||
mod dispenser;
|
||||
mod dragon;
|
||||
@@ -373,7 +372,6 @@ 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)]
|
||||
@@ -6476,18 +6474,6 @@ 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
|
||||
@@ -6531,20 +6517,6 @@ 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 {
|
||||
@@ -7493,6 +7465,7 @@ impl Game<'_> {
|
||||
self.spawn_phantoms();
|
||||
self.tick_advancements();
|
||||
self.tick_player_model();
|
||||
self.tick_emotes();
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -11430,8 +11403,7 @@ impl Game<'_> {
|
||||
self.heal_hold_latches();
|
||||
// For the hooks above the engine (the "I'm playing" ping): said when it changes.
|
||||
horizon_hooks::engine::published(self.is_published());
|
||||
#[cfg(feature = "discord")]
|
||||
self.update_discord_presence();
|
||||
self.report_activity();
|
||||
let steps = self.clock.advance(elapsed);
|
||||
for _ in 0..steps {
|
||||
let started = Instant::now();
|
||||
@@ -12691,10 +12663,8 @@ 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();
|
||||
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());
|
||||
let game_dir = horizon_client::logging::player_dir();
|
||||
horizon_hooks::engine::started(&horizon_hooks::Started::new(&version, &game_dir));
|
||||
}
|
||||
mods::register_components(&vanilla_root)?;
|
||||
mods::register_standalone_models();
|
||||
@@ -12722,15 +12692,8 @@ pub fn run_with() -> Result<()> {
|
||||
if options.execution_role.is_client() {
|
||||
narrator::start(&early_settings);
|
||||
}
|
||||
// "Playing Horizon" on Discord: never for a server; an automated run only talks to a test's fake Discord.
|
||||
#[cfg(feature = "discord")]
|
||||
if options.dedicated.is_none() {
|
||||
let automated = options.benchmark.is_some()
|
||||
|| options.offscreen
|
||||
|| options.frames.is_some()
|
||||
|| options.world_frames.is_some();
|
||||
horizon_client::discord_presence::start(early_settings.extras.discord_presence, automated);
|
||||
}
|
||||
// A game window's hooks (Discord's presence): never for a server.
|
||||
activity::client_started(&options);
|
||||
// Snapshots of derived tables live in the game folder, never beside the
|
||||
// shared resources.
|
||||
Behaviours::set_cache_dir(std::env::current_dir()?.join(".horizon-cache"));
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
//! What the player is doing, for the hooks above the engine (`Hooks::activity`: the Horizon
|
||||
//! layer's Discord presence shows it).
|
||||
use super::*;
|
||||
use horizon_client::command::args::GameMode;
|
||||
use horizon_hooks::{Activity, Client, Dimension, Play};
|
||||
|
||||
/// At startup, for a game window (never a dedicated server's): the player's first activity, then
|
||||
/// `Hooks::client_started`.
|
||||
pub(super) fn client_started(options: &Options) {
|
||||
if options.dedicated.is_some() {
|
||||
return;
|
||||
}
|
||||
let automated = options.benchmark.is_some()
|
||||
|| options.offscreen
|
||||
|| options.frames.is_some()
|
||||
|| options.world_frames.is_some();
|
||||
horizon_hooks::engine::activity(Activity::Menus);
|
||||
horizon_hooks::engine::client_started(&Client::new(automated));
|
||||
}
|
||||
|
||||
impl Game<'_> {
|
||||
/// Each frame: only a change reaches the hooks.
|
||||
pub(super) fn report_activity(&self) {
|
||||
// One status per computer: P1's, not a splitscreen player's.
|
||||
if self.options.split_guest {
|
||||
return;
|
||||
}
|
||||
horizon_hooks::engine::activity(self.activity());
|
||||
}
|
||||
fn activity(&self) -> Activity {
|
||||
if self.title.is_some() {
|
||||
return Activity::Menus;
|
||||
}
|
||||
let max = horizon_client::lan::MAX_PLAYERS;
|
||||
let play = if let Some(host) = &self.lan {
|
||||
Play::Hosting {
|
||||
players: 1 + host.hub.guests.len(),
|
||||
max,
|
||||
}
|
||||
} else if let Some(server) = &self.integrated {
|
||||
match (server.published(), &self.remote) {
|
||||
(Some(_), Some(remote)) => Play::Hosting {
|
||||
players: remote.player_count(),
|
||||
max,
|
||||
},
|
||||
_ => Play::Singleplayer,
|
||||
}
|
||||
} else if self.remote.is_some() {
|
||||
Play::Multiplayer
|
||||
} else {
|
||||
Play::Singleplayer
|
||||
};
|
||||
Activity::World {
|
||||
play,
|
||||
dimension: match self.dim {
|
||||
Dim::Overworld => Dimension::Overworld,
|
||||
Dim::Nether => Dimension::Nether,
|
||||
Dim::End => Dimension::End,
|
||||
},
|
||||
mode: match self.mode {
|
||||
GameMode::Survival => horizon_hooks::GameMode::Survival,
|
||||
GameMode::Creative => horizon_hooks::GameMode::Creative,
|
||||
GameMode::Adventure => horizon_hooks::GameMode::Adventure,
|
||||
GameMode::Spectator => horizon_hooks::GameMode::Spectator,
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -20,10 +20,9 @@ use horizon_cosmetics::follow::Follow;
|
||||
use horizon_cosmetics::prefs::{self, Others};
|
||||
use horizon_cosmetics::trail::{Budget, Emitter};
|
||||
use horizon_cosmetics::{Item, Slot};
|
||||
use horizon_hooks::players::{Frame, Kind, PlayerModel, PlayerModels};
|
||||
use std::cell::{Cell, RefCell};
|
||||
use std::sync::Arc;
|
||||
use std::sync::mpsc::{Receiver, channel};
|
||||
use std::sync::{Arc, Mutex, PoisonError};
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
/// Horizon's mark before a Horizon player's name, over their name tag and in the player list.
|
||||
@@ -56,8 +55,7 @@ impl Baked {
|
||||
|
||||
#[derive(Default)]
|
||||
pub(super) struct Cosmetics {
|
||||
/// The hooks' client (`Seat`), taken once it started.
|
||||
client: Option<Arc<horizon_cosmetics::Cosmetics>>,
|
||||
client: Option<horizon_cosmetics::Cosmetics>,
|
||||
/// By the item's address; each entry holds its item, so the address isn't reused.
|
||||
baked: RefCell<FxHashMap<usize, Baked>>,
|
||||
/// Items drawn whose texture isn't in the atlas yet.
|
||||
@@ -67,6 +65,16 @@ pub(super) struct Cosmetics {
|
||||
poses: RefCell<(Poses, Poses)>,
|
||||
/// Item files from a folder (`HORIZON_COSMETICS_LOCAL`): mannequins wear them too.
|
||||
local: bool,
|
||||
/// Show my cosmetics on its way to the service.
|
||||
mine: AccountSync,
|
||||
/// Show me as a Horizon player: sent at start, on each change and every hour while on.
|
||||
listed: AccountSync,
|
||||
/// Whether the signed-in account owns a cosmetic.
|
||||
owner: OwnerCheck,
|
||||
/// Which other players play an emote (`horizon_cosmetics::Feed`), started with the client.
|
||||
feed: Option<horizon_cosmetics::Feed>,
|
||||
/// My emote on its way to the service.
|
||||
emote_out: EmoteOut,
|
||||
/// Companions, trails and auras.
|
||||
fx: RefCell<Fx>,
|
||||
/// Emotes: those the wheel offers and the one playing (`cosmetics/emotes.rs`).
|
||||
@@ -441,302 +449,115 @@ pub(super) fn enabled() -> bool {
|
||||
cfg!(feature = "horizon-extras") && horizon_cosmetics::enabled()
|
||||
}
|
||||
|
||||
/// What the cosmetics' hooks keep for one splitscreen player (`horizon_hooks::seat`): the
|
||||
/// service's client, the emote feed and what the account tells the service.
|
||||
#[derive(Default)]
|
||||
struct Seat {
|
||||
client: Option<Arc<horizon_cosmetics::Cosmetics>>,
|
||||
/// Item files from a folder (`HORIZON_COSMETICS_LOCAL`).
|
||||
local: bool,
|
||||
/// Show my cosmetics on its way to the service.
|
||||
mine: AccountSync,
|
||||
/// Show me as a Horizon player: sent at start, on each change and every hour while on.
|
||||
listed: AccountSync,
|
||||
/// Whether the signed-in account owns a cosmetic.
|
||||
owner: OwnerCheck,
|
||||
/// Which other players play an emote (`horizon_cosmetics::Feed`), started with the client.
|
||||
feed: Option<horizon_cosmetics::Feed>,
|
||||
/// My emote on its way to the service.
|
||||
emote_out: EmoteOut,
|
||||
/// What the game's settings and the player's emote say (`Game::cosmetics_say`).
|
||||
said: Said,
|
||||
/// Kept between ticks so a tick allocates nothing: the player models in view.
|
||||
models: Vec<PlayerModel>,
|
||||
}
|
||||
|
||||
/// What the game tells the hooks before each call, until the settings move here (spec #14091
|
||||
/// step 5a) and the player's emote with them (step 4).
|
||||
#[derive(Default)]
|
||||
struct Said {
|
||||
mine: bool,
|
||||
listed: bool,
|
||||
/// Other players' cosmetics are shown (not "name tags only", not hidden).
|
||||
others_shown: bool,
|
||||
/// My emote while it plays: its id and whether it loops.
|
||||
playing: Option<(String, bool)>,
|
||||
/// A folder's first emote, which mannequins play.
|
||||
first: Option<Arc<Item>>,
|
||||
}
|
||||
|
||||
/// One [`Seat`] per splitscreen player.
|
||||
static SERVICES: Mutex<Vec<Seat>> = Mutex::new(Vec::new());
|
||||
|
||||
/// Runs `f` on this seat's [`Seat`]. Never called inside itself.
|
||||
fn service<R>(f: impl FnOnce(&mut Seat) -> R) -> R {
|
||||
let seat = horizon_hooks::seat();
|
||||
let mut all = SERVICES.lock().unwrap_or_else(PoisonError::into_inner);
|
||||
if all.len() <= seat {
|
||||
all.resize_with(seat + 1, Seat::default);
|
||||
}
|
||||
f(&mut all[seat])
|
||||
}
|
||||
|
||||
/// The cosmetics' hooks, registered when [`enabled`].
|
||||
pub(super) struct Hooks;
|
||||
pub(super) static HOOKS: Hooks = Hooks;
|
||||
|
||||
impl horizon_hooks::Hooks for Hooks {
|
||||
fn frame(&self, _frame: &Frame) {
|
||||
service(Seat::frame);
|
||||
}
|
||||
|
||||
fn tick(&self, models: &mut dyn PlayerModels) {
|
||||
service(|s| s.tick(models));
|
||||
}
|
||||
}
|
||||
|
||||
impl Seat {
|
||||
/// Once a frame: starts the service's client on first use, asks whether the account owns a
|
||||
/// cosmetic, and tells the service what the account shows.
|
||||
fn frame(&mut self) {
|
||||
if self.client.is_none() {
|
||||
let (client, local) = Cosmetics::client();
|
||||
self.client = Some(Arc::new(client));
|
||||
self.local = local;
|
||||
// Others' emotes come from the service only (a local folder has no one playing).
|
||||
if !local {
|
||||
self.feed = Some(horizon_cosmetics::Feed::new(http()));
|
||||
}
|
||||
}
|
||||
// Item files from a folder (the art, the bench): shown as to an owner, nothing asked.
|
||||
if self.local {
|
||||
self.owner.owns = true;
|
||||
return;
|
||||
}
|
||||
// Whether the account owns a cosmetic: what the last run learnt, then the service.
|
||||
let o = &mut self.owner;
|
||||
if !o.started {
|
||||
o.started = true;
|
||||
o.owns = account()
|
||||
.is_some_and(|(_, uuid, home)| prefs::owned(&home, &uuid).unwrap_or(false));
|
||||
}
|
||||
if let Some(done) = o.asking.as_ref().and_then(|rx| rx.try_recv().ok()) {
|
||||
o.asking = None;
|
||||
match done {
|
||||
Ok(owns) => (o.owns, o.known, o.retry) = (owns, true, None),
|
||||
Err(_) => o.retry = backoff(o.retry),
|
||||
}
|
||||
}
|
||||
// One sign-in at a time: Mojang keeps one joined server id per account.
|
||||
let due = !o.known && o.retry.is_none_or(|(at, _)| Instant::now() >= at);
|
||||
if due && o.asking.is_none() && self.mine.sending.is_none() && self.listed.sending.is_none()
|
||||
{
|
||||
let (tx, rx) = channel();
|
||||
o.asking = Some(rx);
|
||||
std::thread::Builder::new()
|
||||
.name("Cosmetics account".into())
|
||||
.spawn(move || {
|
||||
let _ = tx.send(check_owner());
|
||||
})
|
||||
.ok();
|
||||
}
|
||||
// Show my cosmetics: sent once per change of the wish, then retried until confirmed.
|
||||
// Show me as a Horizon player: also said again every hour while on.
|
||||
let every = horizon_cosmetics::account::PRESENCE_EVERY;
|
||||
self.mine.settle(None);
|
||||
self.listed.settle(Some(every));
|
||||
// One sign-in at a time, as for the ownership check above.
|
||||
let busy = self.owner.asking.is_some()
|
||||
|| self.mine.sending.is_some()
|
||||
|| self.listed.sending.is_some();
|
||||
let said = &self.said;
|
||||
if let Some(done) = self
|
||||
.emote_out
|
||||
.sending
|
||||
.as_ref()
|
||||
.and_then(|rx| rx.try_recv().ok())
|
||||
{
|
||||
self.emote_out.sending = None;
|
||||
match done {
|
||||
Ok(()) => self.emote_out.retry = None,
|
||||
Err(_) => {
|
||||
self.emote_out.retry = backoff(self.emote_out.retry);
|
||||
self.emote_out.sent = None;
|
||||
}
|
||||
}
|
||||
}
|
||||
// My emote: only while I show myself as a Horizon player (playing one says so).
|
||||
let playing = said.playing.as_ref();
|
||||
let looping = playing.is_some_and(|(_, looping)| *looping);
|
||||
let wish = playing.map(|(id, _)| id.as_str()).filter(|_| said.listed);
|
||||
let o = &mut self.emote_out;
|
||||
let changed = o.sent.as_ref().map(Option::as_deref) != Some(wish);
|
||||
let again = looping && o.again.is_some_and(|at| Instant::now() >= at);
|
||||
let waited = o.retry.is_none_or(|(at, _)| Instant::now() >= at);
|
||||
// Nothing sent yet and nothing playing: nothing to say (a failed send forgets what
|
||||
// was sent: the service ends a one-shot by itself, a loop within a minute).
|
||||
let quiet = wish.is_none() && o.sent.is_none();
|
||||
let emote_due = !quiet && waited && (changed || again);
|
||||
// One sign-in at a time, the emote's included.
|
||||
if busy || o.sending.is_some() {
|
||||
} else if emote_due {
|
||||
let wish = wish.map(str::to_owned);
|
||||
o.sent = Some(wish.clone());
|
||||
o.again = looping.then(|| Instant::now() + LOOP_AGAIN);
|
||||
let (tx, rx) = channel();
|
||||
o.sending = Some(rx);
|
||||
let kept = o.kept.clone();
|
||||
std::thread::Builder::new()
|
||||
.name("Cosmetics emote".into())
|
||||
.spawn(move || {
|
||||
let _ = tx.send(send_emote(&kept, wish.as_deref()));
|
||||
})
|
||||
.ok();
|
||||
} else if self.mine.due(said.mine) {
|
||||
self.mine.start(said.mine, send_mine);
|
||||
} else if self.listed.due(said.listed) {
|
||||
self.listed.start(said.listed, send_listed);
|
||||
}
|
||||
}
|
||||
|
||||
/// Once a tick: names the Horizon players in view to the emote feed and plays what it says
|
||||
/// they play on their models; one that moved ends theirs here at once. Others' emotes show
|
||||
/// with "Other players' cosmetics" on Shown only; otherwise nothing is asked.
|
||||
fn tick(&mut self, models: &mut dyn PlayerModels) {
|
||||
let mut seen = std::mem::take(&mut self.models);
|
||||
seen.clear();
|
||||
models.for_each(&mut |m| {
|
||||
if m.kind != Kind::Own {
|
||||
seen.push(m);
|
||||
}
|
||||
});
|
||||
self.play_others(models, &mut seen);
|
||||
self.models = seen;
|
||||
}
|
||||
|
||||
fn play_others(&self, models: &mut dyn PlayerModels, view: &mut Vec<PlayerModel>) {
|
||||
let shown = self.said.others_shown;
|
||||
// Items from a folder (the art, the bench): every mannequin plays its first emote, again
|
||||
// and again.
|
||||
if self.local && shown {
|
||||
let Some(first) = &self.said.first else {
|
||||
return;
|
||||
};
|
||||
for m in view.iter().filter(|m| m.kind == Kind::Mannequin) {
|
||||
let age = m.age as f32;
|
||||
let now = emotes::pose_playing(models.body_pose(m.id));
|
||||
if now.is_none_or(|p| p.at(age).is_none()) {
|
||||
let playing = horizon_cosmetics::Playing {
|
||||
item: first.clone(),
|
||||
// Out of step, so a crowd doesn't move as one.
|
||||
start: age - (m.id % 40) as f32,
|
||||
stop: None,
|
||||
};
|
||||
emotes::set_model_playing(models, m.id, Some(playing));
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
let (Some(feed), Some(client)) = (&self.feed, &self.client) else {
|
||||
return;
|
||||
};
|
||||
if !shown {
|
||||
feed.ask([]);
|
||||
for m in view.iter() {
|
||||
emotes::set_model_playing(models, m.id, None);
|
||||
}
|
||||
return;
|
||||
}
|
||||
let mut frame = client.frame();
|
||||
view.retain(|m| m.kind == Kind::Player && m.uuid.is_some_and(|u| frame.horizon(u)));
|
||||
drop(frame);
|
||||
feed.ask(view.iter().filter_map(|m| m.uuid));
|
||||
let now = Instant::now();
|
||||
for m in view.iter() {
|
||||
let Some(uuid) = m.uuid else { continue };
|
||||
let seen = feed.playing(uuid);
|
||||
let age = m.age as f32;
|
||||
let mut next = emotes::pose_playing(models.body_pose(m.id)).cloned();
|
||||
match (seen, &mut next) {
|
||||
(Some(seen), Some(p)) if Arc::ptr_eq(&p.item, &seen.item) => {
|
||||
if m.moved && p.stop.is_none() {
|
||||
p.stop = Some(age);
|
||||
}
|
||||
}
|
||||
(Some(seen), slot) if !m.moved => {
|
||||
let ticks = now.duration_since(seen.started).as_secs_f32() * 20.0;
|
||||
*slot = Some(horizon_cosmetics::Playing {
|
||||
item: seen.item,
|
||||
start: age - ticks,
|
||||
stop: None,
|
||||
});
|
||||
}
|
||||
(_, Some(p)) if p.stop.is_none() => p.stop = Some(age),
|
||||
_ => {}
|
||||
}
|
||||
emotes::set_model_playing(models, m.id, next.filter(|p| p.at(age).is_some()));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Game<'_> {
|
||||
/// Hands the hooks what the game's settings and the player's emote say (allocating only when
|
||||
/// the emote changes).
|
||||
pub(super) fn cosmetics_say(&mut self) {
|
||||
if !enabled() {
|
||||
return;
|
||||
}
|
||||
let first = if self.cosmetics.local {
|
||||
service(|s| s.said.first.is_none())
|
||||
.then(|| self.owned_emotes().first().cloned())
|
||||
.flatten()
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let extras = &self.settings.extras;
|
||||
let playing = self.cosmetics.emotes.sending();
|
||||
service(|s| {
|
||||
let said = &mut s.said;
|
||||
said.mine = extras.cosmetics_mine;
|
||||
said.listed = extras.horizon_listed;
|
||||
said.others_shown = extras.effective_others() == Others::Shown;
|
||||
if said.playing.as_ref().map(|(id, l)| (id.as_str(), *l)) != playing {
|
||||
said.playing = playing.map(|(id, l)| (id.to_owned(), l));
|
||||
}
|
||||
if first.is_some() {
|
||||
said.first = first;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// Once a frame, after the hooks: takes the service's client for drawing and puts the
|
||||
/// Once a frame, before drawing: starts the service's client on first use and puts the
|
||||
/// textures the last frame wanted into the atlas.
|
||||
pub(super) fn cosmetics_frame(&mut self) {
|
||||
if !enabled() {
|
||||
return;
|
||||
}
|
||||
let c = &mut self.cosmetics;
|
||||
let owns = service(|s| {
|
||||
if c.client.is_none() {
|
||||
c.client = s.client.clone();
|
||||
c.local = s.local;
|
||||
if c.client.is_none() {
|
||||
let (client, local) = Cosmetics::client();
|
||||
c.client = Some(client);
|
||||
c.local = local;
|
||||
// Others' emotes come from the service only (a local folder has no one playing).
|
||||
if !local {
|
||||
c.feed = Some(horizon_cosmetics::Feed::new(http()));
|
||||
}
|
||||
s.owner.owns
|
||||
});
|
||||
// Read every frame: the settings button shows it, and it is never saved.
|
||||
self.settings.extras.cosmetics_owner = owns;
|
||||
}
|
||||
c.frame.set(c.frame.get().wrapping_add(1));
|
||||
c.fx.get_mut().frame(c.frame.get());
|
||||
// Item files from a folder (the art, the bench): shown as to an owner, nothing asked.
|
||||
if c.local {
|
||||
c.owner.owns = true;
|
||||
} else {
|
||||
// Whether the account owns a cosmetic: what the last run learnt, then the service.
|
||||
let o = &mut c.owner;
|
||||
if !o.started {
|
||||
o.started = true;
|
||||
o.owns = account()
|
||||
.is_some_and(|(_, uuid, home)| prefs::owned(&home, &uuid).unwrap_or(false));
|
||||
}
|
||||
if let Some(done) = o.asking.as_ref().and_then(|rx| rx.try_recv().ok()) {
|
||||
o.asking = None;
|
||||
match done {
|
||||
Ok(owns) => (o.owns, o.known, o.retry) = (owns, true, None),
|
||||
Err(_) => o.retry = backoff(o.retry),
|
||||
}
|
||||
}
|
||||
// One sign-in at a time: Mojang keeps one joined server id per account.
|
||||
let due = !o.known && o.retry.is_none_or(|(at, _)| Instant::now() >= at);
|
||||
if due && o.asking.is_none() && c.mine.sending.is_none() && c.listed.sending.is_none() {
|
||||
let (tx, rx) = channel();
|
||||
o.asking = Some(rx);
|
||||
std::thread::Builder::new()
|
||||
.name("Cosmetics account".into())
|
||||
.spawn(move || {
|
||||
let _ = tx.send(check_owner());
|
||||
})
|
||||
.ok();
|
||||
}
|
||||
}
|
||||
// Read every frame: the settings button shows it, and it is never saved.
|
||||
self.settings.extras.cosmetics_owner = c.owner.owns;
|
||||
// Show my cosmetics: sent once per change of the wish, then retried until confirmed.
|
||||
// Show me as a Horizon player: also said again every hour while on.
|
||||
if !c.local {
|
||||
let every = horizon_cosmetics::account::PRESENCE_EVERY;
|
||||
c.mine.settle(None);
|
||||
c.listed.settle(Some(every));
|
||||
// One sign-in at a time, as for the ownership check above.
|
||||
let busy =
|
||||
c.owner.asking.is_some() || c.mine.sending.is_some() || c.listed.sending.is_some();
|
||||
let extras = &self.settings.extras;
|
||||
if let Some(done) = c
|
||||
.emote_out
|
||||
.sending
|
||||
.as_ref()
|
||||
.and_then(|rx| rx.try_recv().ok())
|
||||
{
|
||||
c.emote_out.sending = None;
|
||||
match done {
|
||||
Ok(()) => c.emote_out.retry = None,
|
||||
Err(_) => {
|
||||
c.emote_out.retry = backoff(c.emote_out.retry);
|
||||
c.emote_out.sent = None;
|
||||
}
|
||||
}
|
||||
}
|
||||
// My emote: only while I show myself as a Horizon player (playing one says so).
|
||||
let playing = c.emotes.sending();
|
||||
let looping = playing.as_ref().is_some_and(|(_, looping)| *looping);
|
||||
let wish = playing.map(|(id, _)| id).filter(|_| extras.horizon_listed);
|
||||
let o = &mut c.emote_out;
|
||||
let changed = o.sent.as_ref() != Some(&wish);
|
||||
let again = looping && o.again.is_some_and(|at| Instant::now() >= at);
|
||||
let waited = o.retry.is_none_or(|(at, _)| Instant::now() >= at);
|
||||
// Nothing sent yet and nothing playing: nothing to say (a failed send forgets what
|
||||
// was sent: the service ends a one-shot by itself, a loop within a minute).
|
||||
let quiet = wish.is_none() && o.sent.is_none();
|
||||
let emote_due = !quiet && waited && (changed || again);
|
||||
// One sign-in at a time, the emote's included.
|
||||
if busy || o.sending.is_some() {
|
||||
} else if emote_due {
|
||||
o.sent = Some(wish.clone());
|
||||
o.again = looping.then(|| Instant::now() + LOOP_AGAIN);
|
||||
let (tx, rx) = channel();
|
||||
o.sending = Some(rx);
|
||||
let kept = o.kept.clone();
|
||||
std::thread::Builder::new()
|
||||
.name("Cosmetics emote".into())
|
||||
.spawn(move || {
|
||||
let _ = tx.send(send_emote(&kept, wish.as_deref()));
|
||||
})
|
||||
.ok();
|
||||
} else if c.mine.due(extras.cosmetics_mine) {
|
||||
c.mine.start(extras.cosmetics_mine, send_mine);
|
||||
} else if c.listed.due(extras.horizon_listed) {
|
||||
c.listed.start(extras.horizon_listed, send_listed);
|
||||
}
|
||||
}
|
||||
let uploads = std::mem::take(&mut *c.uploads.borrow_mut());
|
||||
for item in uploads {
|
||||
let location = format!("cosmetics/{:x}", Arc::as_ptr(&item) as usize);
|
||||
@@ -751,6 +572,88 @@ impl Game<'_> {
|
||||
.retain(|_, b| Arc::strong_count(&b.item) > 1);
|
||||
}
|
||||
|
||||
/// Once a tick: names the Horizon players in view to the emote feed and plays what it says
|
||||
/// they play on their models; one that moved ends theirs here at once. Others' emotes show
|
||||
/// with "Other players' cosmetics" on Shown only; otherwise nothing is asked.
|
||||
pub(super) fn tick_others_emotes(&mut self) {
|
||||
let shown = self.settings.extras.effective_others() == Others::Shown;
|
||||
// Items from a folder (the art, the bench): every mannequin plays its first emote, again
|
||||
// and again.
|
||||
if self.cosmetics.local && shown {
|
||||
let Some(first) = self.owned_emotes().first().cloned() else {
|
||||
return;
|
||||
};
|
||||
for m in self.mobs.iter_mut() {
|
||||
let age = m.age as f32;
|
||||
if &*m.kind.id == "minecraft:mannequin"
|
||||
&& emotes::playing(m).is_none_or(|p| p.at(age).is_none())
|
||||
{
|
||||
let playing = horizon_cosmetics::Playing {
|
||||
item: first.clone(),
|
||||
// Out of step, so a crowd doesn't move as one.
|
||||
start: age - (m.id % 40) as f32,
|
||||
stop: None,
|
||||
};
|
||||
emotes::set_playing(m, Some(playing));
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
let (Some(feed), Some(client)) = (&self.cosmetics.feed, &self.cosmetics.client) else {
|
||||
return;
|
||||
};
|
||||
if !shown {
|
||||
feed.ask([]);
|
||||
for m in self.mobs.iter_mut() {
|
||||
emotes::set_playing(m, None);
|
||||
}
|
||||
return;
|
||||
}
|
||||
let mut frame = client.frame();
|
||||
let mut horizon = Vec::new();
|
||||
for m in self.mobs.iter() {
|
||||
if &*m.kind.id == "minecraft:player"
|
||||
&& let Some(uuid) = self.remote_player_uuid(m.id)
|
||||
&& frame.horizon(uuid)
|
||||
{
|
||||
horizon.push((m.id, uuid));
|
||||
}
|
||||
}
|
||||
drop(frame);
|
||||
feed.ask(horizon.iter().map(|(_, u)| *u));
|
||||
let now = Instant::now();
|
||||
for (id, uuid) in horizon {
|
||||
let seen = feed.playing(uuid);
|
||||
let Some(m) = self.mobs.iter_mut().find(|m| m.id == id) else {
|
||||
continue;
|
||||
};
|
||||
let age = m.age as f32;
|
||||
let moved = {
|
||||
let (dx, dz) = (m.position.x - m.previous.x, m.position.z - m.previous.z);
|
||||
dx * dx + dz * dz > 1e-4
|
||||
};
|
||||
let mut next = emotes::playing(m).cloned();
|
||||
match (seen, &mut next) {
|
||||
(Some(seen), Some(p)) if Arc::ptr_eq(&p.item, &seen.item) => {
|
||||
if moved && p.stop.is_none() {
|
||||
p.stop = Some(age);
|
||||
}
|
||||
}
|
||||
(Some(seen), slot) if !moved => {
|
||||
let ticks = now.duration_since(seen.started).as_secs_f32() * 20.0;
|
||||
*slot = Some(horizon_cosmetics::Playing {
|
||||
item: seen.item,
|
||||
start: age - ticks,
|
||||
stop: None,
|
||||
});
|
||||
}
|
||||
(_, Some(p)) if p.stop.is_none() => p.stop = Some(age),
|
||||
_ => {}
|
||||
}
|
||||
emotes::set_playing(m, next.filter(|p| p.at(age).is_some()));
|
||||
}
|
||||
}
|
||||
|
||||
/// Asks the service which emotes the account owns, for the wheel: when it opens, at most
|
||||
/// every 10 minutes (a folder's emotes need no asking; an offline account has none).
|
||||
pub(super) fn refresh_owned_emotes(&mut self) {
|
||||
@@ -766,7 +669,7 @@ impl Game<'_> {
|
||||
}
|
||||
let (tx, rx) = channel();
|
||||
e.loading = Some(rx);
|
||||
let kept = service(|s| s.emote_out.kept.clone());
|
||||
let kept = self.cosmetics.emote_out.kept.clone();
|
||||
std::thread::Builder::new()
|
||||
.name("Cosmetics emotes owned".into())
|
||||
.spawn(move || {
|
||||
@@ -1059,7 +962,7 @@ impl Game<'_> {
|
||||
mod tests {
|
||||
use super::*;
|
||||
use horizon_client::extras::Extras;
|
||||
use horizon_client::mobs::anim::BodyPose;
|
||||
use std::sync::Mutex;
|
||||
|
||||
/// A name tag style with a 1×1 mark.
|
||||
const TAG: &str = r##"{"format": 1, "id": "tag", "slot": "name_tag", "parts": [],
|
||||
@@ -1283,66 +1186,4 @@ mod tests {
|
||||
assert_eq!(tag(top_of(&extras, patience)), VANILLA, "{others:?}");
|
||||
}
|
||||
}
|
||||
|
||||
/// Player models as the hooks see them, with the pose laid over each.
|
||||
struct View(Vec<(PlayerModel, Option<Arc<dyn BodyPose>>)>);
|
||||
|
||||
impl PlayerModels for View {
|
||||
fn connection(&self) -> u64 {
|
||||
1
|
||||
}
|
||||
fn for_each(&self, f: &mut dyn FnMut(PlayerModel)) {
|
||||
self.0.iter().for_each(|(m, _)| f(*m));
|
||||
}
|
||||
fn body_pose(&self, id: u32) -> Option<&Arc<dyn BodyPose>> {
|
||||
self.0.iter().find(|(m, _)| m.id == id)?.1.as_ref()
|
||||
}
|
||||
fn set_body_pose(&mut self, id: u32, pose: Option<Arc<dyn BodyPose>>) {
|
||||
if let Some((_, p)) = self.0.iter_mut().find(|(m, _)| m.id == id) {
|
||||
*p = pose;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn with_a_folder_mannequins_play_its_first_emote_out_of_step_and_players_do_not() {
|
||||
let item = Arc::new(
|
||||
Item::parse(
|
||||
br#"{"format": 1, "id": "up", "slot": "emote", "parts": [],
|
||||
"emote": {"length": 20, "blend": 0, "tracks": [
|
||||
{"part": "right_arm", "keys": [{"t": 10, "rotation": [0, 0, 90]}]}]}}"#,
|
||||
)
|
||||
.unwrap(),
|
||||
);
|
||||
let mut service = Seat {
|
||||
local: true,
|
||||
said: Said {
|
||||
others_shown: true,
|
||||
first: Some(item.clone()),
|
||||
..Said::default()
|
||||
},
|
||||
..Seat::default()
|
||||
};
|
||||
let model = |id, kind, uuid| (PlayerModel::new(id, kind, uuid, 50, false), None);
|
||||
let mut view = View(vec![
|
||||
model(1, Kind::Own, None),
|
||||
model(41, Kind::Mannequin, None),
|
||||
model(2, Kind::Player, Some(7)),
|
||||
]);
|
||||
service.tick(&mut view);
|
||||
let playing = emotes::pose_playing(view.0[1].1.as_ref()).expect("the mannequin plays");
|
||||
assert!(Arc::ptr_eq(&playing.item, &item));
|
||||
// Out of step by its id: 50 - 41 % 40.
|
||||
assert_eq!(playing.start, 49.0);
|
||||
assert!(view.0[0].1.is_none() && view.0[2].1.is_none());
|
||||
// Still playing: the same pose stays.
|
||||
let first = view.0[1].1.clone().unwrap();
|
||||
service.tick(&mut view);
|
||||
assert!(Arc::ptr_eq(&first, view.0[1].1.as_ref().unwrap()));
|
||||
// Others' cosmetics not shown: a new mannequin plays nothing.
|
||||
service.said.others_shown = false;
|
||||
view.0.push(model(3, Kind::Mannequin, None));
|
||||
service.tick(&mut view);
|
||||
assert!(view.0[3].1.is_none());
|
||||
}
|
||||
}
|
||||
@@ -10,7 +10,6 @@ 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.
|
||||
@@ -38,10 +37,10 @@ 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<(&str, bool)> {
|
||||
pub(in crate::game) fn sending(&self) -> Option<(String, 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, looping))
|
||||
Some((p.item.id.clone(), looping))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -60,48 +59,23 @@ impl BodyPose for EmotePose {
|
||||
}
|
||||
}
|
||||
|
||||
/// 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?;
|
||||
/// 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()?;
|
||||
any.downcast_ref::<EmotePose>().map(|e| &e.0)
|
||||
}
|
||||
|
||||
/// The emote `m` plays, if any.
|
||||
pub(in crate::game) fn playing(m: &Mob) -> Option<&Playing> {
|
||||
pose_playing(m.emote.as_ref())
|
||||
}
|
||||
|
||||
/// The pose that plays `next` where `now` is laid, unless it plays that already (None: keep
|
||||
/// it; a new one allocates).
|
||||
fn replaced(
|
||||
now: Option<&Arc<dyn BodyPose>>,
|
||||
next: Option<Playing>,
|
||||
) -> Option<Option<Arc<dyn BodyPose>>> {
|
||||
let same = match (pose_playing(now), &next) {
|
||||
/// 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) {
|
||||
(None, None) => true,
|
||||
(Some(a), Some(b)) => {
|
||||
Arc::ptr_eq(&a.item, &b.item) && a.start == b.start && a.stop == b.stop
|
||||
}
|
||||
_ => false,
|
||||
};
|
||||
(!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);
|
||||
if !same {
|
||||
m.emote = next.map(|p| Arc::new(EmotePose(p)) as Arc<dyn BodyPose>);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -255,7 +229,7 @@ impl Game<'_> {
|
||||
}
|
||||
}
|
||||
|
||||
/// Once a tick: my emote (the others' are `cosmetics::Seat::tick`'s, a hook's).
|
||||
/// Once a tick: my emote, then the other players'.
|
||||
pub(in crate::game) fn tick_emotes(&mut self) {
|
||||
if let Some(done) = self
|
||||
.cosmetics
|
||||
@@ -276,6 +250,7 @@ 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
|
||||
|
||||
@@ -1,49 +0,0 @@
|
||||
//! What the player is doing, for Discord Rich Presence (`horizon_client::discord_presence`).
|
||||
use super::*;
|
||||
use horizon_client::discord_presence::{self as presence, Dimension, Play, Status};
|
||||
|
||||
impl Game<'_> {
|
||||
/// Each frame: only a change reaches the presence thread.
|
||||
pub(super) fn update_discord_presence(&self) {
|
||||
// One Discord status per computer: P1's, not a splitscreen player's.
|
||||
if self.options.split_guest {
|
||||
return;
|
||||
}
|
||||
presence::set(self.discord_status());
|
||||
}
|
||||
fn discord_status(&self) -> Status {
|
||||
if !self.settings.extras.discord_presence {
|
||||
return Status::Off;
|
||||
}
|
||||
if self.title.is_some() {
|
||||
return Status::Menus;
|
||||
}
|
||||
let play = if let Some(host) = &self.lan {
|
||||
Play::Hosting {
|
||||
players: 1 + host.hub.guests.len(),
|
||||
max: horizon_client::lan::MAX_PLAYERS,
|
||||
}
|
||||
} else if let Some(server) = &self.integrated {
|
||||
match (server.published(), &self.remote) {
|
||||
(Some(_), Some(remote)) => Play::Hosting {
|
||||
players: remote.player_count(),
|
||||
max: horizon_client::lan::MAX_PLAYERS,
|
||||
},
|
||||
_ => Play::Singleplayer,
|
||||
}
|
||||
} else if self.remote.is_some() {
|
||||
Play::Multiplayer
|
||||
} else {
|
||||
Play::Singleplayer
|
||||
};
|
||||
Status::World {
|
||||
play,
|
||||
dimension: match self.dim {
|
||||
Dim::Overworld => Dimension::Overworld,
|
||||
Dim::Nether => Dimension::Nether,
|
||||
Dim::End => Dimension::End,
|
||||
},
|
||||
mode: self.mode,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -34,8 +34,6 @@ pub(super) fn run_event_loop(mut game: Game<'_>) -> Result<()> {
|
||||
narrator::stop();
|
||||
leave_fullscreen_after(&result, &mut game.settings);
|
||||
result?;
|
||||
#[cfg(feature = "discord")]
|
||||
horizon_client::discord_presence::stop();
|
||||
write_report(&game)?;
|
||||
if let Some(e) = game.error.take() {
|
||||
return Err(e);
|
||||
|
||||
@@ -899,4 +899,32 @@ 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);
|
||||
}
|
||||
}
|
||||
@@ -28,6 +28,10 @@ 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 {
|
||||
@@ -187,102 +191,6 @@ 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)> {
|
||||
@@ -2984,177 +2892,6 @@ 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.
|
||||
|
||||
@@ -0,0 +1,105 @@
|
||||
//! 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)
|
||||
}
|
||||
@@ -0,0 +1,173 @@
|
||||
//! 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(())
|
||||
}
|
||||
}
|
||||
@@ -1,131 +0,0 @@
|
||||
//! Where the game hands the player models to crates above the engine
|
||||
//! (`horizon_hooks::Hooks::frame` and `tick`, 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::join_seq::JoinList;
|
||||
use horizon_client::mobs::Mob;
|
||||
use horizon_client::mobs::anim::BodyPose;
|
||||
use horizon_hooks::engine;
|
||||
use horizon_hooks::players::{Frame, Kind, PlayerModel, PlayerModels};
|
||||
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,
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
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, None));
|
||||
}
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Game<'_> {
|
||||
/// 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());
|
||||
engine::frame(&Frame::new(connection));
|
||||
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,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -6731,7 +6731,7 @@ impl Game<'_> {
|
||||
if self.studio_replay.is_none() {
|
||||
self.tick_player_model();
|
||||
}
|
||||
self.client_hooks_tick();
|
||||
self.tick_emotes();
|
||||
if !dead {
|
||||
self.remote_tick_using();
|
||||
}
|
||||
@@ -10714,11 +10714,17 @@ 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> {
|
||||
self.remote.as_ref()?.player_uuid(id)
|
||||
let r = self.remote.as_ref()?;
|
||||
let uuid = *r.level_players.get(&(id as i32))?;
|
||||
(uuid != r.profile).then_some(uuid)
|
||||
}
|
||||
pub(super) fn remote_player_name(&self, id: u32) -> Option<&str> {
|
||||
let r = self.remote.as_ref()?;
|
||||
r.level_player_name(id as i32, &r.player_uuid(id)?)
|
||||
let uuid = r.level_players.get(&(id as i32))?;
|
||||
if *uuid == r.profile {
|
||||
return None;
|
||||
}
|
||||
r.level_player_name(id as i32, uuid)
|
||||
}
|
||||
/// Status for `horizon-ctl status`.
|
||||
/// The other players' entities in the level and their names (their
|
||||
@@ -11490,8 +11496,7 @@ impl Remote {
|
||||
pub(super) fn listed_player_count(&self) -> usize {
|
||||
self.listed.len()
|
||||
}
|
||||
/// Players in the server's player list, this one included (for Discord).
|
||||
#[cfg(feature = "discord")]
|
||||
/// Players in the server's player list, this one included (for the activity hooks).
|
||||
pub(super) fn player_count(&self) -> usize {
|
||||
self.players.len()
|
||||
}
|
||||
@@ -11499,11 +11504,6 @@ 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,8 +306,6 @@ pub(super) fn run_loading(
|
||||
App::Loading(loading) => leave_fullscreen_after(&result, &mut loading.settings),
|
||||
}
|
||||
result?;
|
||||
#[cfg(feature = "discord")]
|
||||
horizon_client::discord_presence::stop();
|
||||
match app {
|
||||
App::Ready(mut split) => {
|
||||
write_report(&split.host)?;
|
||||
|
||||
@@ -823,7 +823,7 @@ impl Game<'_> {
|
||||
for (location, rgba) in ready {
|
||||
self.store_skin(location, rgba);
|
||||
}
|
||||
self.client_hooks_frame();
|
||||
self.cosmetics_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`
|
||||
|
||||
@@ -261,8 +261,6 @@ impl<'a> Split<'a> {
|
||||
.get(i.wrapping_sub(1))
|
||||
.map(|g| g.options.clone()),
|
||||
);
|
||||
let seat = self.guests.get(i.wrapping_sub(1)).map_or(1, |g| g.seat);
|
||||
horizon_hooks::engine::set_seat(seat - 1);
|
||||
self.turn = i;
|
||||
}
|
||||
|
||||
|
||||
@@ -126,7 +126,6 @@ impl Split<'_> {
|
||||
// Until it has a profile, a scratch options file: never P1's.
|
||||
let options = Self::folder()?.join(".joining/options.txt");
|
||||
OPTIONS.set(Some(options.clone()));
|
||||
horizon_hooks::engine::set_seat(seat - 1);
|
||||
let mut settings = self.host.settings.clone();
|
||||
if pad.is_some() {
|
||||
// The Controller setting is P1's own: a player who pressed a pad to join plays
|
||||
@@ -136,7 +135,6 @@ impl Split<'_> {
|
||||
}
|
||||
let game = self.start_guest(seat, &options, settings);
|
||||
OPTIONS.set(None);
|
||||
horizon_hooks::engine::set_seat(0);
|
||||
let mut game = game?;
|
||||
if let Some(pad) = pad {
|
||||
// The press that joined it doesn't act in its game.
|
||||
@@ -301,31 +299,16 @@ impl Split<'_> {
|
||||
}
|
||||
let profile = profiles.join(&name);
|
||||
let options = profile.join("options.txt");
|
||||
let made = if options.exists() {
|
||||
None
|
||||
let mut settings = if options.exists() {
|
||||
horizon_client::settings::Settings::load(&options)
|
||||
} else {
|
||||
std::fs::create_dir_all(&profile)?;
|
||||
let mut new = self.host.settings.clone();
|
||||
// The Controller setting is P1's, as on the profile page (`join`).
|
||||
new.controller.enabled |= self.guests[i].pad.is_some();
|
||||
// On P1's seat, so the crates' settings stores write P1's values too.
|
||||
let turn = horizon_hooks::seat();
|
||||
horizon_hooks::engine::set_seat(0);
|
||||
new.save(&options);
|
||||
horizon_hooks::engine::set_seat(turn);
|
||||
Some(new)
|
||||
new
|
||||
};
|
||||
// The crates' stores load into the guest's seat, before its turn starts below.
|
||||
let turn = horizon_hooks::seat();
|
||||
horizon_hooks::engine::set_seat(self.guests[i].seat - 1);
|
||||
let mut settings = match made {
|
||||
Some(new) => {
|
||||
horizon_client::settings::Settings::load_stores(&options);
|
||||
new
|
||||
}
|
||||
None => horizon_client::settings::Settings::load(&options),
|
||||
};
|
||||
horizon_hooks::engine::set_seat(turn);
|
||||
// A profile an earlier join saved with P1's off, or the player's pad would be dead.
|
||||
settings.controller.enabled |= self.guests[i].pad.is_some();
|
||||
// P1's own world through memory (`Ok`), else P1's server over the network (`Err`).
|
||||
|
||||
@@ -771,9 +771,26 @@ fn swim(p: &mut Poses, pos: f32, h: &Hum) {
|
||||
lerp(amount, x, 0.3 * (pos * 0.333_333_34).mcos()),
|
||||
);
|
||||
}
|
||||
/// 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};
|
||||
/// 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;
|
||||
}
|
||||
|
||||
/// Apply the mob's model animation to `p` (at rest), then the pose laid over it, if any.
|
||||
pub fn setup(
|
||||
|
||||
@@ -149,20 +149,8 @@ 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(
|
||||
@@ -268,7 +256,6 @@ impl Session {
|
||||
debug: false,
|
||||
far: Arc::new(crate::lod_net::Inbox::default()),
|
||||
recorder: Arc::new(Mutex::new(None)),
|
||||
serial: next_serial(),
|
||||
}
|
||||
}
|
||||
fn start(
|
||||
@@ -346,7 +333,6 @@ impl Session {
|
||||
debug,
|
||||
far,
|
||||
recorder,
|
||||
serial: next_serial(),
|
||||
}
|
||||
}
|
||||
/// Everything received since the last poll.
|
||||
@@ -1843,7 +1829,6 @@ 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 {
|
||||
@@ -1947,7 +1932,6 @@ 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(),
|
||||
@@ -2231,7 +2215,6 @@ 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(), |_| {});
|
||||
|
||||
@@ -70,8 +70,7 @@ struct Shared {
|
||||
/// The game's handle. Dropping it stops the thread.
|
||||
pub struct Cosmetics {
|
||||
shared: Arc<Shared>,
|
||||
/// Behind a lock so one client can be shared (`Arc<Cosmetics>` is `Send + Sync`).
|
||||
worker: Mutex<Option<Box<dyn FnMut() + Send>>>,
|
||||
worker: Option<Box<dyn FnMut() + Send>>,
|
||||
}
|
||||
|
||||
impl Cosmetics {
|
||||
@@ -79,12 +78,7 @@ 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
|
||||
.get_mut()
|
||||
.unwrap_or_else(PoisonError::into_inner)
|
||||
.take()
|
||||
.expect("worker");
|
||||
let mut work = c.worker.take().expect("worker");
|
||||
let shared = c.shared.clone();
|
||||
let _ = std::thread::Builder::new()
|
||||
.name("Cosmetics".into())
|
||||
@@ -131,7 +125,7 @@ impl Cosmetics {
|
||||
});
|
||||
Self {
|
||||
shared,
|
||||
worker: Mutex::new(Some(worker)),
|
||||
worker: Some(worker),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -87,7 +87,7 @@ fn clock() -> (Clock, Arc<AtomicU64>) {
|
||||
}
|
||||
|
||||
fn run(c: &mut Cosmetics) {
|
||||
(c.worker.get_mut().unwrap().as_mut().unwrap())();
|
||||
(c.worker.as_mut().unwrap())();
|
||||
}
|
||||
|
||||
fn ids(worn: Option<Worn>) -> Vec<String> {
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
[package]
|
||||
name = "horizon-discord"
|
||||
version = "0.1.0"
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
rust-version.workspace = true
|
||||
description = "Discord Rich Presence: \"Playing Horizon\" on the player's Discord profile (docs/native-client.md)"
|
||||
|
||||
[dependencies]
|
||||
horizon-platform = { path = "../horizon-platform" }
|
||||
log = "0.4"
|
||||
serde_json = "1.0.151"
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
+184
-29
@@ -1,21 +1,30 @@
|
||||
//! Discord Rich Presence: "Playing Horizon" on the player's Discord profile, with what they are
|
||||
//! doing (docs/native-client.md). A thread of its own talks to the Discord app on this computer
|
||||
//! through its local IPC socket (Discord's RPC docs: `discord-ipc-N`, frames of opcode u32 LE +
|
||||
//! length u32 LE + JSON). The game only calls [`set`] each frame: a change wakes the thread,
|
||||
//! length u32 LE + JSON). The game says what the player does on each change ([`set`]), which wakes the thread,
|
||||
//! which connects when Discord runs, retries every 15 s when it doesn't, keeps under Discord's
|
||||
//! update limit and clears the activity on quit. Nothing is sent with the setting off,
|
||||
//! or with `HORIZON_NO_DISCORD=1`. It uses nothing else of the game (docs/layers.md: Horizon layer):
|
||||
//! `game/discord.rs` says what the player does.
|
||||
//! or with `HORIZON_NO_DISCORD=1`.
|
||||
//!
|
||||
//! A Horizon crate (docs/layers.md): the game tells it what the player does through the
|
||||
//! platform's hooks ([`HOOKS`]: `client_started`, `activity`, `shutdown`), which the official
|
||||
//! executable (`horizon-official`) registers with the switch: its line of `horizon-extras.txt`
|
||||
//! ([`SETTING`]) and its row in Extras' Multiplayer group ([`SECTION`]). horizon-client's own
|
||||
//! executable has no presence.
|
||||
#![forbid(unsafe_code)]
|
||||
use std::collections::VecDeque;
|
||||
use std::io::{self, Read, Write};
|
||||
use std::path::PathBuf;
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use std::sync::{Condvar, Mutex, OnceLock};
|
||||
use std::thread::Thread;
|
||||
use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
|
||||
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use horizon_command::args::GameMode;
|
||||
use horizon_platform::hooks::{self, Activity, Dimension, GameMode, Hooks, Play};
|
||||
use horizon_platform::pages::{Act, Label, Page, Place, Row};
|
||||
use horizon_platform::settings::Store;
|
||||
|
||||
/// The "Horizon" application on Discord's developer portal (public: it names the game). Its
|
||||
/// Rich Presence assets: `horizon`, the logo.
|
||||
@@ -40,24 +49,134 @@ const CLOSE: u32 = 2;
|
||||
const PING: u32 = 3;
|
||||
const PONG: u32 = 4;
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum Dimension {
|
||||
Overworld,
|
||||
Nether,
|
||||
End,
|
||||
/// The presence's hooks, for `horizon_platform::hooks::register`.
|
||||
pub static HOOKS: Presence = Presence;
|
||||
|
||||
pub struct Presence;
|
||||
|
||||
impl Hooks for Presence {
|
||||
/// Starts the presence thread with the game's window (never for a dedicated server).
|
||||
fn client_started(&self, client: &hooks::Client) {
|
||||
start(client.automated);
|
||||
}
|
||||
|
||||
fn activity(&self, now: Activity) {
|
||||
set(match now {
|
||||
Activity::Menus => Status::Menus,
|
||||
Activity::World {
|
||||
play,
|
||||
dimension,
|
||||
mode,
|
||||
} => Status::World {
|
||||
play,
|
||||
dimension,
|
||||
mode,
|
||||
},
|
||||
// Anything a later game says that this crate doesn't know yet.
|
||||
_ => Status::Off,
|
||||
});
|
||||
}
|
||||
|
||||
/// On quit: the activity is cleared.
|
||||
fn shutdown(&self) {
|
||||
stop();
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum Play {
|
||||
Singleplayer,
|
||||
/// A server or someone else's LAN world. Its address and name are never shown.
|
||||
Multiplayer,
|
||||
/// This world is open to LAN or friends; `players` counts the host, `max` is how many
|
||||
/// may join.
|
||||
Hosting {
|
||||
players: usize,
|
||||
max: usize,
|
||||
},
|
||||
/// The switch's line in `horizon-extras.txt`, where the engine's Extras kept it before.
|
||||
const KEY: &str = "discordPresence";
|
||||
/// The switch's row, and what it says on hover.
|
||||
const LABEL: &str = "horizon.extras.discord_presence";
|
||||
const ABOUT: &str = "horizon.extras.discord_presence.tooltip";
|
||||
|
||||
/// The switch (on by default): 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)]
|
||||
@@ -97,11 +216,15 @@ impl Wanted {
|
||||
}
|
||||
|
||||
struct Shared {
|
||||
/// What the game said last.
|
||||
game: Status,
|
||||
/// What Discord is to show: the game's, or nothing with the switch off.
|
||||
wanted: Wanted,
|
||||
stop: bool,
|
||||
stopped: bool,
|
||||
}
|
||||
static SHARED: Mutex<Shared> = Mutex::new(Shared {
|
||||
game: Status::Menus,
|
||||
wanted: Wanted {
|
||||
status: Status::Menus,
|
||||
since: None,
|
||||
@@ -114,7 +237,8 @@ static THREAD: OnceLock<Thread> = OnceLock::new();
|
||||
|
||||
/// Starts the presence thread, once: when the game starts, never for a dedicated server.
|
||||
/// `automated` runs (offscreen, benchmarks) only talk to a test's fake Discord.
|
||||
pub fn start(enabled: bool, automated: bool) {
|
||||
/// The status [`set`] gave before, the switch included, is the first one sent.
|
||||
pub fn start(automated: bool) {
|
||||
let fake_discord = std::env::var_os(IPC_DIR_ENV).is_some();
|
||||
if THREAD.get().is_some()
|
||||
|| std::env::var_os("HORIZON_NO_DISCORD").is_some_and(|v| v != "0")
|
||||
@@ -122,9 +246,6 @@ pub fn start(enabled: bool, automated: bool) {
|
||||
{
|
||||
return;
|
||||
}
|
||||
if !enabled {
|
||||
lock().wanted.status = Status::Off;
|
||||
}
|
||||
match std::thread::Builder::new()
|
||||
.name("Discord presence".into())
|
||||
.spawn(run)
|
||||
@@ -136,12 +257,13 @@ pub fn start(enabled: bool, automated: bool) {
|
||||
}
|
||||
}
|
||||
|
||||
/// What the player does now; only a change wakes the thread. Free without [`start`].
|
||||
/// What the player does now; only a change wakes the thread. Kept for [`start`] before it.
|
||||
pub fn set(status: Status) {
|
||||
let Some(thread) = THREAD.get() else {
|
||||
return;
|
||||
};
|
||||
if lock().wanted.update(status, unix_now()) {
|
||||
let mut shared = lock();
|
||||
shared.game = status;
|
||||
let changed = shared.wanted.update(shown(status), unix_now());
|
||||
drop(shared);
|
||||
if let Some(thread) = THREAD.get().filter(|_| changed) {
|
||||
thread.unpark();
|
||||
}
|
||||
}
|
||||
@@ -582,6 +704,39 @@ 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()));
|
||||
+107
-104
@@ -19,13 +19,11 @@
|
||||
|
||||
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:
|
||||
@@ -44,20 +42,75 @@ 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. Only a window's game calls it (each splitscreen player in
|
||||
/// turn, [`seat`]), never the integrated server's thread or a dedicated server.
|
||||
fn frame(&self, _frame: &players::Frame) {}
|
||||
|
||||
/// Once a client tick while a world is shown: the player models in view. Called as
|
||||
/// [`Hooks::frame`] is.
|
||||
fn tick(&self, _models: &mut dyn players::PlayerModels) {}
|
||||
|
||||
/// 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.
|
||||
@@ -65,11 +118,14 @@ pub trait Hooks: Sync {
|
||||
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) -> Self {
|
||||
Self { version }
|
||||
pub fn new(version: &'a str, game_dir: &'a std::path::Path) -> Self {
|
||||
Self { version, game_dir }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -99,27 +155,10 @@ 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() {
|
||||
@@ -150,6 +189,32 @@ pub mod engine {
|
||||
}
|
||||
}
|
||||
|
||||
/// [`Hooks::client_started`].
|
||||
pub fn client_started(client: &Client) {
|
||||
for hooks in all() {
|
||||
hooks.client_started(client);
|
||||
}
|
||||
}
|
||||
|
||||
/// The activity at the last call (`None` before the first).
|
||||
static ACTIVITY: std::sync::Mutex<Option<Activity>> = std::sync::Mutex::new(None);
|
||||
|
||||
/// [`Hooks::activity`], when `now` differs from the last call's. Cheap enough to call every
|
||||
/// frame: with no hook registered it only loads the empty list.
|
||||
pub fn activity(now: Activity) {
|
||||
let hooks = all();
|
||||
if hooks.is_empty() {
|
||||
return;
|
||||
}
|
||||
let mut last = ACTIVITY.lock().unwrap_or_else(|e| e.into_inner());
|
||||
if last.replace(now) != Some(now) {
|
||||
drop(last);
|
||||
for h in hooks {
|
||||
h.activity(now);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// [`Hooks::shutdown`].
|
||||
pub fn shutdown() {
|
||||
for hooks in all() {
|
||||
@@ -157,25 +222,6 @@ 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) {
|
||||
for hooks in all() {
|
||||
hooks.frame(frame);
|
||||
}
|
||||
}
|
||||
|
||||
/// [`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())
|
||||
@@ -185,7 +231,6 @@ 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
|
||||
@@ -202,54 +247,8 @@ mod tests {
|
||||
fn published(&self, open: bool) {
|
||||
self.0.lock().unwrap().push(format!("published {open}"));
|
||||
}
|
||||
fn frame(&self, frame: &players::Frame) {
|
||||
self.0
|
||||
.lock()
|
||||
.unwrap()
|
||||
.push(format!("frame {}", frame.connection));
|
||||
}
|
||||
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),
|
||||
};
|
||||
fn activity(&self, now: Activity) {
|
||||
self.0.lock().unwrap().push(format!("{now:?}"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -263,24 +262,28 @@ 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"));
|
||||
engine::started(&Started::new("0.2.31", std::path::Path::new("game")));
|
||||
// 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());
|
||||
engine::frame(&players::Frame::new(4));
|
||||
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",
|
||||
"frame 4"
|
||||
"Menus",
|
||||
"World { play: Singleplayer, dimension: Nether, mode: Creative }"
|
||||
]
|
||||
);
|
||||
assert_eq!(register(&Silent), Err(TooLate));
|
||||
|
||||
@@ -1,92 +0,0 @@
|
||||
//! 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`], and
|
||||
//! [`BodyPose`], a pose laid over a player's body after its vanilla animation. 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; None for the window's own model and 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,
|
||||
}
|
||||
|
||||
impl PlayerModel {
|
||||
pub fn new(id: u32, kind: Kind, uuid: Option<u128>, age: u32, moved: bool) -> Self {
|
||||
Self {
|
||||
id,
|
||||
kind,
|
||||
uuid,
|
||||
age,
|
||||
moved,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 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,
|
||||
}
|
||||
|
||||
impl Frame {
|
||||
pub fn new(connection: u64) -> Self {
|
||||
Self { connection }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
[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
|
||||
+31
-15
@@ -1,12 +1,29 @@
|
||||
//! 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};
|
||||
//! 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};
|
||||
use std::path::Path;
|
||||
|
||||
/// At startup of a player's game: the official server into the list in `game_dir` and the
|
||||
/// 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
|
||||
/// 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) {
|
||||
@@ -17,12 +34,8 @@ pub fn update(game_dir: &Path) {
|
||||
}
|
||||
}
|
||||
|
||||
/// 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,
|
||||
);
|
||||
/// 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);
|
||||
/// 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.
|
||||
@@ -55,7 +68,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_telemetry::server_status::OFFICIAL_PREVIOUS;
|
||||
let previous = horizon_addresses::OFFICIAL_SERVER_PREVIOUS;
|
||||
if move_official(&mut list, address, previous) {
|
||||
servers::save(&file, &list)?;
|
||||
}
|
||||
@@ -71,7 +84,10 @@ 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)", "afri-network.co.za");
|
||||
const WITHDRAWN_PARTNER: (&str, &str) = (
|
||||
"Afri-Network (South Africa)",
|
||||
horizon_addresses::WITHDRAWN_PARTNER_SERVER,
|
||||
);
|
||||
/// Beside servers.dat: the partner addresses the game offered, one per line (#9299).
|
||||
const PARTNER_MARKER: &str = "horizon_partner_servers.txt";
|
||||
|
||||
@@ -15,6 +15,8 @@ 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]
|
||||
|
||||
@@ -4,25 +4,36 @@
|
||||
//! (`game-build.yml`); horizon-client keeps its own plain executable.
|
||||
//!
|
||||
//! Each Horizon crate that runs in the game gives a `&'static` value implementing
|
||||
//! `horizon_platform::hooks::Hooks`, and [`register`] adds it. Nothing here depends on an engine
|
||||
//! crate.
|
||||
//! `horizon_platform::hooks::Hooks`, and its settings store and pages if it has any, and
|
||||
//! [`register`] adds them. Nothing here depends on an engine crate.
|
||||
#![forbid(unsafe_code)]
|
||||
|
||||
use horizon_platform::hooks::{self, Hooks};
|
||||
use horizon_platform::{pages, settings};
|
||||
|
||||
/// Every Horizon part in the game, in the order its hooks run.
|
||||
const PARTS: [&dyn Hooks; 2] = [
|
||||
const PARTS: [&dyn Hooks; 4] = [
|
||||
// The anonymous "I'm playing" ping.
|
||||
&horizon_heartbeat::HOOKS,
|
||||
// Play with Friends: `friend:` addresses join through its tunnel.
|
||||
&horizon_friends::game::HOOKS,
|
||||
// The official server in the player's server list.
|
||||
&horizon_official_servers::HOOKS,
|
||||
// "Playing Horizon" on the player's Discord profile.
|
||||
&horizon_discord::HOOKS,
|
||||
];
|
||||
/// Their settings, in the player's settings files.
|
||||
const STORES: [&dyn settings::Store; 1] = [&horizon_discord::SETTING];
|
||||
/// Their rows and pages in the options screens.
|
||||
const PAGES: [&dyn pages::Page; 1] = [&horizon_discord::SECTION];
|
||||
|
||||
/// Registers Horizon's in-game parts with the game. The executable calls it before it runs the
|
||||
/// game, and so does the launcher's generated executable for native mods.
|
||||
pub fn register() {
|
||||
for part in PARTS {
|
||||
if let Err(e) = hooks::register(part) {
|
||||
let late = PARTS.into_iter().map(hooks::register);
|
||||
let late = late.chain(STORES.into_iter().map(settings::register));
|
||||
for result in late.chain(PAGES.into_iter().map(pages::register)) {
|
||||
if let Err(e) = result {
|
||||
// The executable registered after starting the game: a bug, not a player's problem.
|
||||
log::warn!("Horizon: an in-game part did not register: {e}");
|
||||
}
|
||||
|
||||
@@ -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, and the offline-mode UUID of a name.
|
||||
# A server's address after its SRV record, the offline-mode UUID of a name, and servers.dat.
|
||||
protocol = ["world", "dep:horizon-protocol"]
|
||||
# Preparing the game's files from the player's own Minecraft install.
|
||||
prepare = ["dep:horizon-prepare"]
|
||||
|
||||
@@ -46,12 +46,9 @@ 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 {
|
||||
/// The players the game shows (`horizon_hooks::players`): the views [`Hooks::frame`] and
|
||||
/// [`Hooks::tick`] get, and the pose a crate lays over a player's body.
|
||||
pub use horizon_hooks::players::{
|
||||
BODY_PARTS, BodyPose, Frame, Kind, PlayerModel, PlayerModels,
|
||||
pub use horizon_hooks::{
|
||||
Activity, Client, Dimension, GameMode, Hooks, Play, Started, TooLate, register, tunnel,
|
||||
};
|
||||
pub use horizon_hooks::{Hooks, Started, TooLate, register, seat, tunnel};
|
||||
}
|
||||
|
||||
/// The settings store (`horizon_hooks::settings`): a crate's own `key:value` lines in the game
|
||||
@@ -69,3 +66,12 @@ 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
@@ -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 | `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 |
|
||||
| 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 |
|
||||
| Play with Friends | hosting: `game/friends.rs`, `game/friends/screen.rs`; joining: crate `horizon-friends` (`game`) | hosting: the `friends` feature (`game/friends_off.rs` stands in); joining: only the official executable registers it ([layers.md](layers.md#how-a-horizon-crate-plugs-in)) |
|
||||
| The anonymous "I'm playing" ping | crate `horizon-heartbeat` | not in horizon-client at all: only the official executable (`horizon-official`) registers it ([layers.md](layers.md#how-a-horizon-crate-plugs-in)) |
|
||||
|
||||
Credits & Attribution then says where the official build is (`horizon.plain.official`).
|
||||
|
||||
`grep -rn horizon-extras crates/` lists every gate. Apart from the three features above, the gates
|
||||
`grep -rn horizon-extras crates/` lists every gate. Apart from the `friends` feature above, the gates
|
||||
are `cfg!(feature = "horizon-extras")`: both sides compile in every build, so no stub has to follow
|
||||
the code it replaces. To remove an extra for good, delete its files from the table and the lines its
|
||||
gate guards.
|
||||
|
||||
+3
-4
@@ -156,9 +156,8 @@ Horizon's own extras and its product, and every built-in address of an outside s
|
||||
benchmark uploads, releases and updates, the hosting supervisor, the launcher.
|
||||
- **Examples:** `horizon-addresses`, `horizon-cosmetics`, `horizon-friends`, `horizon-relay`, `horizon-telemetry`,
|
||||
`horizon-bench`, `horizon-release-sign`, `horizon-server`, `horizon-mod-registry`, `horizon-heartbeat`,
|
||||
`horizon-official`, `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`.
|
||||
`horizon-official`, `horizon-official-servers`, `horizon-discord`, `launcher/`. Still in engine crates today:
|
||||
`game/cosmetics.rs`, `game/emotes.rs`, `game/extras.rs`, `game/tweaks.rs`, `extras_ui.rs`, `pvp.rs`.
|
||||
- **Rules:**
|
||||
- It uses the platform's public API only, never the engine.
|
||||
- Until it moves, every Horizon feature in the game sits behind the `horizon-extras` feature
|
||||
@@ -513,7 +512,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 | `multiplayer.rs` (`WITHDRAWN_PARTNER`) |
|
||||
| `afri-network.co.za` | the withdrawn partner server, so the game can remove the entry it added | `horizon-addresses` |
|
||||
| `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` |
|
||||
|
||||
+11
-6
@@ -423,13 +423,18 @@ While Discord runs on the same computer, the player's Discord profile shows
|
||||
"Playing Horizon" and what they are doing: "In the menus", "Singleplayer",
|
||||
"Multiplayer" or "Hosting a world" (with the party size, of 8), the dimension
|
||||
("In the Nether") and the time since they entered the world. It never shows a
|
||||
world's name or a server's address. Horizon Settings has the switch
|
||||
(**Discord Rich Presence**, on by default; `discordPresence` in
|
||||
`horizon-extras.txt`), and `HORIZON_NO_DISCORD=1` turns it off for one run.
|
||||
world's name or a server's address. The switch is on the Extras page, in its
|
||||
Multiplayer group (**Discord Rich Presence**, on by default; `discordPresence`
|
||||
in `horizon-extras.txt`, the first splitscreen player's), and
|
||||
`HORIZON_NO_DISCORD=1` turns it off for one run.
|
||||
|
||||
`crates/horizon-client/src/discord_presence.rs` speaks Discord's local IPC
|
||||
itself (the `discord-ipc-N` socket or named pipe, no SDK) from a thread of its
|
||||
own: the frame only hands it a status when it changes. It retries every 15 s
|
||||
The crate `horizon-discord` (Horizon layer) speaks Discord's local IPC itself
|
||||
(the `discord-ipc-N` socket or named pipe, no SDK) from a thread of its own.
|
||||
The game hands it a status only when it changes, through the platform's
|
||||
`activity` hook (`game/activity.rs` says what the player does), and keeps the
|
||||
switch itself: its settings store and its Extras row (the platform's settings
|
||||
and pages registries). Only the official executable (`horizon-official`)
|
||||
registers it: horizon-client's own executable shows no presence and no switch. It retries every 15 s
|
||||
while Discord isn't running and stays within Discord's 5 updates per 20 s.
|
||||
Dedicated servers never start it, and automated runs (offscreen, benchmarks)
|
||||
only talk to a fake Discord: `lab.game_env` sets `HORIZON_NO_DISCORD=1`, and
|
||||
|
||||
+2
-2
@@ -427,8 +427,8 @@ game built in `Game::new`, the integrated server on its own thread. On that base
|
||||
**Binaries.** The release archives hold `horizon-client`, `horizon-ctl`, `horizon-prepare` and
|
||||
`horizon-server`. On Linux the release also has `horizon-server-linux-x86_64.tar.gz`: `horizon-server`,
|
||||
`horizon-prepare` and `horizon-engine`, the game built with `--no-default-features` (no sound, no
|
||||
controllers, no Discord presence or Play with Friends: the `discord` and `friends` features; nor the "I'm
|
||||
playing" ping, which only the official executable has), which links neither `libasound.so.2` nor `libudev.so.1`. `release.yml` builds it after the
|
||||
controllers, no Play with Friends: the `friends` feature; nor Discord presence or the "I'm playing" ping, which
|
||||
only the official executable has), which links neither `libasound.so.2` nor `libudev.so.1`. `release.yml` builds it after the
|
||||
client archive (`xtask package-server`) and fails if `tools/check_imports.py --server` finds either library;
|
||||
CI lints the same build. `horizon-server` runs `horizon-engine` when it is beside it or on the PATH, else
|
||||
`horizon-client`. Build it yourself with `cargo build --release -p horizon-client --no-default-features`
|
||||
|
||||
@@ -49,7 +49,6 @@
|
||||
"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",
|
||||
|
||||
@@ -9,7 +9,7 @@ The game goes title -> world -> Nether -> quit through horizon-ctl, and the acti
|
||||
single single player on its server thread
|
||||
lan a host opens its world to LAN and a guest joins: the guest shows "Multiplayer",
|
||||
the host "Hosting a world" with a party of 2 of 8
|
||||
setting off in horizon-extras.txt: nothing reaches Discord; the Horizon Settings switch
|
||||
setting off in horizon-extras.txt: nothing reaches Discord; the Extras switch
|
||||
turns it on (the activity appears) and off (it is cleared)
|
||||
late Discord starts after the game: found within the 15 s retry
|
||||
|
||||
@@ -136,7 +136,7 @@ class Game:
|
||||
self.discord.start()
|
||||
extra = {'HORIZON_HOME': self.home, 'HORIZON_TELEMETRY_URL': 'off', 'HORIZON_DISCORD_IPC_DIR': self.discord.dir}
|
||||
self.env = lab.game_env(**extra)
|
||||
self.ctl_run('launch', '--offscreen', '--game', str(bins / 'horizon-client'), '--resources',
|
||||
self.ctl_run('launch', '--offscreen', '--game', str(bins / 'horizon-official'), '--resources',
|
||||
str(lab.RESOURCES.resolve()), *args)
|
||||
|
||||
def ctl_run(self, *words, timeout=600):
|
||||
@@ -208,7 +208,7 @@ def lan(bins):
|
||||
|
||||
|
||||
def setting(bins):
|
||||
"""Off in horizon-extras.txt: Discord never hears from the game. Turned on in Horizon Settings: the
|
||||
"""Off in horizon-extras.txt: Discord never hears from the game. Turned on in Extras: the
|
||||
activity appears; off again: it is cleared."""
|
||||
game = Game(bins, 'setting', extras='discordPresence:false\n')
|
||||
d = game.discord
|
||||
@@ -216,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', 'horizon')
|
||||
game.ctl_run('options', 'extras')
|
||||
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-client and horizon-ctl (default: build with the fast profile)')
|
||||
parser.add_argument('--bin-dir', help='prebuilt horizon-official and horizon-ctl (default: build with the fast profile)')
|
||||
args = parser.parse_args()
|
||||
if sys.platform == 'win32':
|
||||
sys.exit('the fake Discord is a Unix socket: macOS or Linux only')
|
||||
if args.bin_dir:
|
||||
bins = Path(args.bin_dir).resolve()
|
||||
else:
|
||||
subprocess.run(['cargo', 'build', '--locked', '--profile', 'fast', '-p', 'horizon-client', '-p', 'horizon-client-lib', '--bins'],
|
||||
subprocess.run(['cargo', 'build', '--locked', '--profile', 'fast', '-p', 'horizon-official', '-p', 'horizon-client-lib', '--bins'],
|
||||
cwd=ROOT, check=True)
|
||||
meta = json.loads(subprocess.run(['cargo', 'metadata', '--format-version', '1', '--no-deps'], cwd=ROOT,
|
||||
capture_output=True, text=True, check=True).stdout)
|
||||
|
||||
@@ -51,11 +51,13 @@
|
||||
"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",
|
||||
|
||||
@@ -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 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.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.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
|
||||
|
||||
|
@@ -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 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.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.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)
|
||||
|
||||
|
@@ -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.rs); 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/use_hand.rs::release_bow); 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
|
||||
|
||||
|
Reference in new issue
Block a user