mirror of
https://github.com/horizon-rust/horizon.git
synced 2026-11-10 02:21:33 -10:00
Compare commits
8
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4e1ae724d7 | ||
|
|
6098ce91c2 | ||
|
|
3548e4d802 | ||
|
|
cb3983803e | ||
|
|
ee60188af7 | ||
|
|
f518ef4c07 | ||
|
|
2719b22258 | ||
|
|
3da374ef18 |
No files matched your search
Generated
-1
@@ -1449,7 +1449,6 @@ dependencies = [
|
||||
"horizon-core",
|
||||
"horizon-cosmetics",
|
||||
"horizon-dualsense",
|
||||
"horizon-friends",
|
||||
"horizon-hdr",
|
||||
"horizon-hooks",
|
||||
"horizon-items",
|
||||
|
||||
@@ -39,8 +39,6 @@ horizon-chat-filter = { path = "../horizon-chat-filter" }
|
||||
horizon-shaders = { path = "../horizon-shaders" }
|
||||
# Horizon's own shader packs: the format, the stages and the check (docs/shaders.md).
|
||||
horizon-shader-api = { path = "../horizon-shader-api" }
|
||||
# Play with Friends: invite codes and their tunnel; the `friends` feature.
|
||||
horizon-friends = { path = "../horizon-friends", optional = true }
|
||||
# The narrator (GameNarrator): text-to-speech of chat, system messages and screens.
|
||||
horizon-narrator = { path = "../horizon-narrator" }
|
||||
# Horizon mods: sandboxed WebAssembly components, off unless installed and turned on (docs/mods.md).
|
||||
@@ -123,13 +121,10 @@ controllers = ["horizon-client-lib/controllers", "dep:gilrs", "dep:horizon-duals
|
||||
# tracing on request (`HORIZON_RAY_TRACING=1`). Players always get them; there is no switch. `grep -rn horizon-extras crates/` lists
|
||||
# every place it gates. Without it (`--no-default-features --features audio,controllers`) the
|
||||
# client is best effort: CI builds only the default.
|
||||
horizon-extras = ["horizon-client-lib/horizon-extras", "horizon-client-render/horizon-extras", "discord", "friends"]
|
||||
horizon-extras = ["horizon-client-lib/horizon-extras", "horizon-client-render/horizon-extras", "discord"]
|
||||
# Online extras a server or a port can leave out; the server build has none of them. "Playing Horizon"
|
||||
# on Discord (game/discord.rs).
|
||||
discord = ["horizon-client-lib/discord"]
|
||||
# Play with Friends: sharing a world behind an invite code (game/friends.rs). Joining one is
|
||||
# horizon-friends' `game` side, which horizon-official registers (`horizon_hooks::tunnel`).
|
||||
friends = ["dep:horizon-friends"]
|
||||
# Records the call sites of mob random draws (`mob_ai_probe` traces).
|
||||
rng-trace = ["horizon-core/rng-trace", "horizon-client-lib/rng-trace"]
|
||||
# Rust's allocations go to the system heap instead of mimalloc, where heap profilers that replace
|
||||
|
||||
@@ -23,6 +23,10 @@ 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' text in the player's language (`horizon_hooks::tr`).
|
||||
horizon_hooks::engine::set_language(|key| {
|
||||
horizon_client::lang::has(key).then(|| horizon_client::lang::get(key))
|
||||
});
|
||||
// Crates above the engine registered their hooks before this (horizon-official's `main`);
|
||||
// none registers after this point.
|
||||
horizon_hooks::engine::seal();
|
||||
|
||||
@@ -143,6 +143,8 @@ mod enderman_hurt_teleport_tests;
|
||||
mod enderman_placement_tests;
|
||||
mod entity;
|
||||
mod entity_culling;
|
||||
#[cfg(test)]
|
||||
mod entity_inside_tests;
|
||||
mod entity_sounds;
|
||||
mod event_loop;
|
||||
mod events;
|
||||
@@ -160,17 +162,6 @@ mod fov;
|
||||
mod foxes;
|
||||
mod frame_geometry;
|
||||
mod frames;
|
||||
#[cfg(feature = "friends")]
|
||||
mod friends;
|
||||
mod held_items;
|
||||
#[cfg(test)]
|
||||
mod setblock_order_tests;
|
||||
// Without Play with Friends: nothing shared, no Friends button (game/friends_off.rs).
|
||||
#[cfg(test)]
|
||||
mod entity_inside_tests;
|
||||
#[cfg(not(feature = "friends"))]
|
||||
#[path = "game/friends_off.rs"]
|
||||
mod friends;
|
||||
mod functions;
|
||||
mod game_mode;
|
||||
mod game_tests;
|
||||
@@ -181,6 +172,7 @@ mod golems;
|
||||
mod growth;
|
||||
mod hand;
|
||||
mod headless;
|
||||
mod held_items;
|
||||
mod hud_editor;
|
||||
mod hud_room;
|
||||
mod ime;
|
||||
@@ -357,6 +349,7 @@ mod pad;
|
||||
mod pad_actions;
|
||||
mod pad_craft;
|
||||
mod pad_menu;
|
||||
mod page_screen;
|
||||
mod paintings;
|
||||
mod pandas;
|
||||
mod particle_culling;
|
||||
@@ -420,7 +413,10 @@ mod seats;
|
||||
mod server_links;
|
||||
mod server_packs;
|
||||
mod server_travel;
|
||||
#[cfg(test)]
|
||||
mod setblock_order_tests;
|
||||
mod shader_packs;
|
||||
mod share_view;
|
||||
mod sheep_dye;
|
||||
mod shelf;
|
||||
mod shield;
|
||||
@@ -649,9 +645,9 @@ enum Screen {
|
||||
Advancements,
|
||||
/// WorldOptionsScreen (game/lan_screen.rs).
|
||||
WorldOptions,
|
||||
/// Play with Friends, from the pause menu's Friends button
|
||||
/// (game/friends.rs, game/friends/screen.rs).
|
||||
Friends,
|
||||
/// The page a crate registered at `Place::PauseFriends` (Play with Friends in the official
|
||||
/// build), from the pause menu's Friends icon, drawn as its own screen (game/page_screen.rs).
|
||||
AddedPage,
|
||||
/// `SocialInteractionsScreen` (`social_screen`, game/social.rs).
|
||||
Social,
|
||||
/// CommandBlockEditScreen (game/command_blocks.rs).
|
||||
@@ -695,7 +691,7 @@ impl Screen {
|
||||
Screen::Stats => "stats",
|
||||
Screen::Advancements => "advancements",
|
||||
Screen::WorldOptions => "world_options",
|
||||
Screen::Friends => "friends",
|
||||
Screen::AddedPage => "friends",
|
||||
Screen::Social => "social",
|
||||
Screen::CommandBlock => "command_block",
|
||||
Screen::BlockEditor => "block_editor",
|
||||
@@ -2903,9 +2899,6 @@ struct Game<'a> {
|
||||
level_shapes: horizon_client::chunk::LevelShapes,
|
||||
/// This game is that server thread's (game/integrated.rs).
|
||||
serving: Option<integrated::Serving>,
|
||||
/// The open world is shared behind an invite code (game/friends.rs).
|
||||
#[cfg(feature = "friends")]
|
||||
friends: Option<friends::Sharing>,
|
||||
/// The Social Interactions screen and the players whose chat is hidden (game/social.rs).
|
||||
social: social::Social,
|
||||
world_options: Option<lan_screen::WorldOptions>,
|
||||
@@ -5737,7 +5730,6 @@ impl Game<'_> {
|
||||
if let Some(log) = self.world.block_log.as_mut() {
|
||||
log.extend(held);
|
||||
}
|
||||
self.tick_friends();
|
||||
horizon_client::spans::time("tick_hand", || self.tick_hand());
|
||||
horizon_client::spans::time("tick_sleep", || self.tick_sleep());
|
||||
self.announce_sleep_status();
|
||||
@@ -6241,7 +6233,7 @@ impl Game<'_> {
|
||||
Screen::Advancements => self.draw_advancements(&mut batch),
|
||||
Screen::Mod => {}
|
||||
Screen::WorldOptions => self.draw_world_options(&mut batch),
|
||||
Screen::Friends => self.draw_friends_screen(&mut batch),
|
||||
Screen::AddedPage => self.draw_added_page(&mut batch),
|
||||
Screen::Social => self.draw_social_screen(&mut batch),
|
||||
Screen::ServerLinks => self.draw_server_links(&mut batch),
|
||||
Screen::ConfirmLink => self.draw_confirm_link(&mut batch),
|
||||
@@ -11014,8 +11006,8 @@ impl Game<'_> {
|
||||
Screen::WorldOptions if button == MouseButton::Left => {
|
||||
self.world_options_release()
|
||||
}
|
||||
Screen::Friends if pressed && button == MouseButton::Left => {
|
||||
self.friends_screen_click()
|
||||
Screen::AddedPage if pressed && button == MouseButton::Left => {
|
||||
self.added_page_click()
|
||||
}
|
||||
Screen::Social if pressed && button == MouseButton::Left => {
|
||||
self.social_click()
|
||||
@@ -11413,6 +11405,7 @@ impl Game<'_> {
|
||||
// the world's own ticks.
|
||||
horizon_client::social::set_presence_status(self.presence_status());
|
||||
horizon_client::social::tick();
|
||||
self.share_tick();
|
||||
// Minecraft.tick: ClientTickEvent.Pre before any of it.
|
||||
self.native_client_tick_pre();
|
||||
if self.title.is_none() && !self.pause {
|
||||
@@ -12180,8 +12173,6 @@ impl<'a> Game<'a> {
|
||||
test_defs_snapshot: false,
|
||||
test_highlights: HashMap::new(),
|
||||
game_tests: game_tests::GameTests::default(),
|
||||
#[cfg(feature = "friends")]
|
||||
friends: None,
|
||||
social: Default::default(),
|
||||
debug_keys: Default::default(),
|
||||
debug_charts: Default::default(),
|
||||
|
||||
@@ -479,7 +479,7 @@ impl Game<'_> {
|
||||
Screen::Stats => "stats",
|
||||
Screen::Advancements => "advancements",
|
||||
Screen::WorldOptions => "world-options",
|
||||
Screen::Friends => "friends",
|
||||
Screen::AddedPage => "friends",
|
||||
Screen::Social => "social",
|
||||
Screen::ServerLinks => "server-links",
|
||||
Screen::ConfirmLink => "confirm-link",
|
||||
|
||||
@@ -17,16 +17,19 @@ const PROTOCOL: &str = "0.1.0";
|
||||
|
||||
/// The command groups the game serves, as `help` chains them: what `hello` reports as its features.
|
||||
const FEATURES: &[&str] = &[
|
||||
"game", "station", "blocks", "lan", "friends", "render", "sculk", "agent", "ui", "options",
|
||||
"events", "saves", "pad", "studio",
|
||||
"game", "station", "blocks", "lan", "render", "sculk", "agent", "ui", "options", "events",
|
||||
"saves", "pad", "studio",
|
||||
];
|
||||
|
||||
/// `hello`: the protocol the socket speaks, the game's release and the command groups it serves.
|
||||
/// `hello`: the protocol the socket speaks, the game's release and the command groups it serves
|
||||
/// (the engine's, then those of the crates that answer commands, `Hooks::control_commands`).
|
||||
fn hello() -> Value {
|
||||
let groups = horizon_hooks::engine::control_commands().map(|(group, _)| group);
|
||||
let features: Vec<&str> = FEATURES.iter().copied().chain(groups).collect();
|
||||
json!({
|
||||
"protocol": PROTOCOL,
|
||||
"release": horizon_core::release::VERSION,
|
||||
"features": FEATURES,
|
||||
"features": features,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -400,7 +403,7 @@ impl Game<'_> {
|
||||
if let Some(value) = self.lan_control(&words)? {
|
||||
return Ok(Some(value));
|
||||
}
|
||||
if let Some(value) = self.friends_control(&words)? {
|
||||
if let Some(value) = self.hooks_control(&words)? {
|
||||
return Ok(Some(value));
|
||||
}
|
||||
if let Some(value) = self.studio_control(&words)? {
|
||||
@@ -417,7 +420,7 @@ impl Game<'_> {
|
||||
.chain(super::station_control::HELP)
|
||||
.chain(super::blockctl::HELP)
|
||||
.chain(super::lan::HELP)
|
||||
.chain(super::friends::HELP)
|
||||
.chain(horizon_hooks::engine::control_commands().flat_map(|(_, help)| help))
|
||||
.chain(super::render_control::HELP)
|
||||
.chain(super::sculk::HELP)
|
||||
.chain(super::agent::HELP)
|
||||
@@ -1370,7 +1373,7 @@ impl Game<'_> {
|
||||
Screen::Stats => "stats",
|
||||
Screen::Advancements => "advancements",
|
||||
Screen::WorldOptions => "world-options",
|
||||
Screen::Friends => "friends",
|
||||
Screen::AddedPage => "friends",
|
||||
Screen::Social => "social",
|
||||
Screen::ServerLinks => "server-links",
|
||||
Screen::ConfirmLink => "confirm-link",
|
||||
|
||||
@@ -123,7 +123,7 @@ fn screen_ids_are_distinct_and_stable() {
|
||||
Screen::Stats,
|
||||
Screen::Advancements,
|
||||
Screen::WorldOptions,
|
||||
Screen::Friends,
|
||||
Screen::AddedPage,
|
||||
Screen::Social,
|
||||
Screen::CommandBlock,
|
||||
Screen::BlockEditor,
|
||||
|
||||
@@ -523,7 +523,7 @@ fn debug_options_interrupts(screen: Screen, playing_overlay: bool) -> bool {
|
||||
| Screen::EmoteWheel
|
||||
| Screen::Stats
|
||||
| Screen::Advancements
|
||||
| Screen::Friends
|
||||
| Screen::AddedPage
|
||||
| Screen::Social
|
||||
| Screen::ServerLinks
|
||||
| Screen::ConfirmLink
|
||||
@@ -584,7 +584,7 @@ mod tests {
|
||||
Screen::GameModeSwitcher,
|
||||
Screen::Stats,
|
||||
Screen::Advancements,
|
||||
Screen::Friends,
|
||||
Screen::AddedPage,
|
||||
Screen::Social,
|
||||
Screen::ServerLinks,
|
||||
Screen::ConfirmLink,
|
||||
|
||||
@@ -1,339 +0,0 @@
|
||||
//! Play with friends, host side: "Start Sharing" on the Play with Friends
|
||||
//! screen (`screen`, the pause menu's Friends button) opens the world to LAN if
|
||||
//! it isn't (with Open to LAN's defaults) and shares that port behind an
|
||||
//! invite code (horizon-friends). The code holds this computer's addresses
|
||||
//! and the key, so friends join with no server of ours; a relay the player
|
||||
//! configured adds a short code and a fallback. The integrated server is the
|
||||
//! LAN one, unchanged: tunnelled guests are LAN guests that connect from the
|
||||
//! loopback address. The joining side is horizon-friends' (`game`), registered by
|
||||
//! horizon-official.
|
||||
use super::{Game, lan::LanSettings};
|
||||
use horizon_client::{Result, hud::HudBatch, lang, text::Text};
|
||||
use horizon_friends::{Host, HostConfig, HostEvent, PathMode, host::DEFAULT_PORT, reach, stun};
|
||||
use horizon_protocol::{GAME_VERSION, PROTOCOL_VERSION};
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
mod screen;
|
||||
|
||||
use screen::{self as friends_screen, Action, FriendsView};
|
||||
pub use screen::{TITLE, title};
|
||||
|
||||
pub(super) struct Sharing {
|
||||
host: Host,
|
||||
/// Sharing opened the world to LAN, so stopping closes it again.
|
||||
opened_lan: bool,
|
||||
code: Option<String>,
|
||||
/// Wrong codes tried so far.
|
||||
wrong: u32,
|
||||
notice: Option<String>,
|
||||
copied: Option<Instant>,
|
||||
/// Sessions carried directly and through the relay so far.
|
||||
paths: (u32, u32),
|
||||
}
|
||||
|
||||
pub const HELP: &[&str] = &[
|
||||
"friends host share the open world behind an invite code",
|
||||
"friends stop stop sharing",
|
||||
"friends status the code, how friends reach this host, the guests",
|
||||
];
|
||||
|
||||
/// `HORIZON_STUN_SERVERS`, else `friendsStun` in horizon.txt, else the defaults.
|
||||
fn stun_servers() -> Vec<String> {
|
||||
if std::env::var_os("HORIZON_STUN_SERVERS").is_some() {
|
||||
return stun::servers_from_env();
|
||||
}
|
||||
let dir = std::env::current_dir().unwrap_or_default();
|
||||
match horizon_client::ui_theme::preference(&dir, "friendsStun") {
|
||||
Some(list) => stun::parse_servers(&list),
|
||||
None => stun::servers_from_env(),
|
||||
}
|
||||
}
|
||||
|
||||
fn env_flag(name: &str, default: bool) -> bool {
|
||||
std::env::var(name).map_or(default, |v| v != "0")
|
||||
}
|
||||
|
||||
impl Game<'_> {
|
||||
/// Start sharing the open world.
|
||||
pub(super) fn friends_start(&mut self) -> Result<()> {
|
||||
if self.friends.is_some() {
|
||||
return Ok(());
|
||||
}
|
||||
// Single player on its server thread shares that server, as Open to
|
||||
// LAN publishes it; another server can't be shared.
|
||||
if self.title.is_some() || (self.remote.is_some() && self.integrated.is_none()) {
|
||||
return Err("open a singleplayer world first".into());
|
||||
}
|
||||
let opened_lan = self.lan_port().is_none();
|
||||
if opened_lan {
|
||||
// ShareToLanScreen's defaults, as the world options screen offers them.
|
||||
self.lan_open(LanSettings {
|
||||
port: horizon_client::lan::available_port(),
|
||||
guest_commands: false,
|
||||
force_game_mode: true,
|
||||
})?;
|
||||
}
|
||||
let port = self.lan_port().ok_or("the world isn't open to LAN")?;
|
||||
let host = match Host::start(HostConfig {
|
||||
lan: std::net::SocketAddr::from(([127, 0, 0, 1], port)),
|
||||
protocol: PROTOCOL_VERSION,
|
||||
game: GAME_VERSION.to_owned(),
|
||||
relay: horizon_client::net::friends_relay_setting(),
|
||||
path: PathMode::from_env(),
|
||||
port: std::env::var("HORIZON_FRIENDS_PORT")
|
||||
.ok()
|
||||
.and_then(|p| p.parse().ok())
|
||||
.unwrap_or(DEFAULT_PORT),
|
||||
reach: reach::Options {
|
||||
// HORIZON_FRIENDS_UPNP=0 leaves the router alone.
|
||||
map_port: env_flag("HORIZON_FRIENDS_UPNP", true),
|
||||
stun: stun_servers(),
|
||||
// Both ends on one machine (tests).
|
||||
loopback: env_flag("HORIZON_FRIENDS_LOOPBACK", false),
|
||||
},
|
||||
}) {
|
||||
Ok(h) => h,
|
||||
Err(e) => {
|
||||
if opened_lan {
|
||||
self.lan_close(true);
|
||||
}
|
||||
return Err(e.into());
|
||||
}
|
||||
};
|
||||
self.friends = Some(Sharing {
|
||||
host,
|
||||
opened_lan,
|
||||
code: None,
|
||||
wrong: 0,
|
||||
notice: None,
|
||||
copied: None,
|
||||
paths: (0, 0),
|
||||
});
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Stop sharing: the code ends (direct port closed, router mapping
|
||||
/// deleted); the world closes to LAN again if sharing opened it.
|
||||
pub(super) fn friends_stop(&mut self) {
|
||||
let Some(mut sharing) = self.friends.take() else {
|
||||
return;
|
||||
};
|
||||
sharing.host.stop();
|
||||
if sharing.opened_lan {
|
||||
self.lan_close(true);
|
||||
} else {
|
||||
self.system_message(Text::literal(lang::get_or(
|
||||
"horizon.friends.stopped",
|
||||
"Stopped sharing the invite code.",
|
||||
)));
|
||||
}
|
||||
}
|
||||
|
||||
/// The LAN server is closing: the code goes with it.
|
||||
pub(super) fn friends_lan_closed(&mut self) {
|
||||
if let Some(mut sharing) = self.friends.take() {
|
||||
sharing.host.stop();
|
||||
}
|
||||
}
|
||||
|
||||
/// New codes and sessions, once a tick and while the pause menu is drawn.
|
||||
pub(super) fn tick_friends(&mut self) {
|
||||
let Some(sharing) = self.friends.as_mut() else {
|
||||
return;
|
||||
};
|
||||
let mut announce = false;
|
||||
let mut stopped = None;
|
||||
for event in sharing.host.poll() {
|
||||
match event {
|
||||
HostEvent::Code(code) => {
|
||||
if sharing.code.is_some() && sharing.wrong >= horizon_friends::host::MAX_WRONG {
|
||||
sharing.notice = Some(lang::get_or(
|
||||
"horizon.friends.short_code_changed",
|
||||
"Someone kept trying a wrong short code, so it changed.",
|
||||
));
|
||||
sharing.wrong = 0;
|
||||
}
|
||||
announce = sharing.code.is_none();
|
||||
sharing.code = Some(code);
|
||||
}
|
||||
HostEvent::WrongCode { count } => {
|
||||
sharing.wrong = count;
|
||||
sharing.notice = Some(lang::get_or(
|
||||
"horizon.friends.wrong_code",
|
||||
"Someone tried a wrong code.",
|
||||
));
|
||||
}
|
||||
HostEvent::Connected { direct: true, .. } => sharing.paths.0 += 1,
|
||||
HostEvent::Connected { direct: false, .. } => sharing.paths.1 += 1,
|
||||
HostEvent::Disconnected { .. } => {}
|
||||
HostEvent::RelayStopped => {
|
||||
let text = lang::get_or(
|
||||
"horizon.friends.relay_stopped",
|
||||
"Too many wrong short codes, so the relay is off until you share again. Friends can still join with the full code.",
|
||||
);
|
||||
sharing.notice = Some(text.clone());
|
||||
stopped = Some(text);
|
||||
}
|
||||
}
|
||||
}
|
||||
if let Some(text) = stopped {
|
||||
self.system_message(Text::literal(text));
|
||||
}
|
||||
if announce {
|
||||
self.system_message(Text::literal(lang::get_or(
|
||||
"horizon.friends.code_ready",
|
||||
"Your invite code is ready: Game Menu, Play with Friends. Copy it and send it to your friends.",
|
||||
)));
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn friends_copy(&mut self) {
|
||||
let Some(code) = self.friends.as_ref().and_then(|s| s.code.clone()) else {
|
||||
return;
|
||||
};
|
||||
use horizon_client::clipboard::Clipboard;
|
||||
self.clipboard.set(&code);
|
||||
if let Some(s) = self.friends.as_mut() {
|
||||
s.copied = Some(Instant::now());
|
||||
}
|
||||
}
|
||||
|
||||
/// Play with Friends. The world is paused under it in singleplayer, so
|
||||
/// new codes and sessions are read while it is drawn.
|
||||
pub(super) fn draw_friends_screen(&mut self, batch: &mut HudBatch) {
|
||||
self.tick_friends();
|
||||
let view = self.friends_view();
|
||||
horizon_client::widgets::begin_screen(|| {
|
||||
horizon_client::widgets::ScreenInfo::new("friends", friends_screen::TITLE)
|
||||
});
|
||||
friends_screen::draw(batch, &self.gui.atlas, self.cursor, &view, true);
|
||||
}
|
||||
pub(super) fn friends_screen_click(&mut self) {
|
||||
let view = self.friends_view();
|
||||
let Some(action) =
|
||||
friends_screen::click(&self.gui.atlas, self.gui_size(), self.cursor, &view)
|
||||
else {
|
||||
return;
|
||||
};
|
||||
self.audio.play_button_click();
|
||||
match action {
|
||||
Action::Share => {
|
||||
if let Err(e) = self.friends_start() {
|
||||
self.system_message(Text::literal(
|
||||
lang::get_or(
|
||||
"horizon.friends.share_failed",
|
||||
"Couldn't share the world: %s",
|
||||
)
|
||||
.replacen("%s", &e.to_string(), 1),
|
||||
));
|
||||
}
|
||||
}
|
||||
Action::Copy => self.friends_copy(),
|
||||
Action::Stop => self.friends_stop(),
|
||||
// The system browser, as the folder buttons open their folders.
|
||||
Action::Help => {
|
||||
if let Some(url) = &view.help {
|
||||
super::packs::open_uri(url);
|
||||
}
|
||||
}
|
||||
Action::Done => self.screen = super::Screen::Paused,
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether the pause menu offers Play with Friends (a singleplayer world).
|
||||
pub(super) fn friends_shown(&self) -> bool {
|
||||
self.friends_view().shown
|
||||
}
|
||||
|
||||
/// What Play with Friends shows.
|
||||
pub(super) fn friends_view(&self) -> FriendsView {
|
||||
let guests = match &self.lan {
|
||||
Some(h) => h.hub.guests.iter().map(|g| g.name.clone()).collect(),
|
||||
// On the server thread: the other players the server lists.
|
||||
None if self.integrated.is_some() => {
|
||||
let me = self.options.player_name();
|
||||
let mut names = self.remote_online_names().unwrap_or_default();
|
||||
names.retain(|n| *n != me);
|
||||
names
|
||||
}
|
||||
None => Vec::new(),
|
||||
};
|
||||
let Some(s) = &self.friends else {
|
||||
return FriendsView {
|
||||
shown: self.remote.is_none() || self.integrated.is_some(),
|
||||
..FriendsView::default()
|
||||
};
|
||||
};
|
||||
let state = s.host.state();
|
||||
let mut status = if s.code.is_none() {
|
||||
lang::get_or(
|
||||
"horizon.friends.finding_route",
|
||||
"Finding how friends can reach you…",
|
||||
)
|
||||
} else {
|
||||
state.summary.clone()
|
||||
};
|
||||
if let Some(short) = &state.short {
|
||||
status.push_str(&format!(" Short code for the relay: {short}."));
|
||||
}
|
||||
if let Some(problem) = &state.relay_problem {
|
||||
status.push(' ');
|
||||
status.push_str(problem);
|
||||
}
|
||||
let reachable = matches!(
|
||||
state.verdict,
|
||||
Some(reach::Verdict::Mapped(_) | reach::Verdict::Public)
|
||||
) || state.short.is_some();
|
||||
FriendsView {
|
||||
shown: true,
|
||||
sharing: true,
|
||||
code: s.code.clone(),
|
||||
status,
|
||||
reachable,
|
||||
help: (!reachable && s.code.is_some()).then(|| reach::HELP_URL.to_owned()),
|
||||
guests,
|
||||
notice: s.notice.clone(),
|
||||
copied: s
|
||||
.copied
|
||||
.is_some_and(|t| t.elapsed() < Duration::from_secs(2)),
|
||||
}
|
||||
}
|
||||
|
||||
/// `horizon-ctl friends ...`.
|
||||
pub(super) fn friends_control(&mut self, words: &[&str]) -> Result<Option<serde_json::Value>> {
|
||||
use serde_json::json;
|
||||
if words[0] != "friends" {
|
||||
return Ok(None);
|
||||
}
|
||||
match words.get(1).copied() {
|
||||
Some("host") => {
|
||||
self.friends_start()?;
|
||||
Ok(Some(json!({ "ok": true, "lan_port": self.lan_port() })))
|
||||
}
|
||||
Some("stop") => {
|
||||
self.friends_stop();
|
||||
Ok(Some(json!({ "ok": true })))
|
||||
}
|
||||
Some("status") => {
|
||||
self.tick_friends();
|
||||
let view = self.friends_view();
|
||||
let state = self.friends.as_ref().map(|s| s.host.state());
|
||||
Ok(Some(json!({
|
||||
"sharing": view.sharing,
|
||||
"code": view.code,
|
||||
"short": state.as_ref().and_then(|s| s.short.as_ref().map(ToString::to_string)),
|
||||
"summary": view.status,
|
||||
"reachable": view.reachable,
|
||||
"verdict": state.as_ref().and_then(|s| s.verdict.as_ref().map(|v| format!("{v:?}"))),
|
||||
"direct_port": state.as_ref().map(|s| s.port),
|
||||
"sessions": self.friends.as_ref().map_or(0, |s| s.host.sessions()),
|
||||
"direct": self.friends.as_ref().map_or(0, |s| s.paths.0),
|
||||
"relayed": self.friends.as_ref().map_or(0, |s| s.paths.1),
|
||||
"guests": view.guests,
|
||||
"lan_port": self.lan_port(),
|
||||
})))
|
||||
}
|
||||
_ => Err("friends host|stop|status".into()),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,340 +0,0 @@
|
||||
//! Play with Friends: Horizon's own sharing of the open world behind an
|
||||
//! invite code (`game/friends.rs`), with the rest of Play with Friends' game glue. The pause menu's Friends button, the icon
|
||||
//! vanilla's PauseScreen already has, opens it. It is drawn the way a mod adds
|
||||
//! a screen to vanilla: the menu background, a centred title and text,
|
||||
//! vanilla's buttons and Done in the footer.
|
||||
use horizon_client::{
|
||||
hud::{HudAtlas, HudBatch, rgba},
|
||||
multiplayer::wrap,
|
||||
text::Text,
|
||||
ui,
|
||||
};
|
||||
|
||||
/// The English title: the fallback of [`title`], and what the screen's widgets are named by.
|
||||
pub const TITLE: &str = "Play with Friends";
|
||||
/// A string of this screen: the language table's, or the English text.
|
||||
fn tr(key: &str, english: &str) -> String {
|
||||
horizon_client::lang::get_or(key, english)
|
||||
}
|
||||
/// The title in the current language.
|
||||
pub fn title() -> String {
|
||||
tr("horizon.friends.title", TITLE)
|
||||
}
|
||||
/// What the screen shows (game/friends.rs fills it).
|
||||
#[derive(Clone, Debug, Default)]
|
||||
pub struct FriendsView {
|
||||
/// A singleplayer world (not a server): sharing is offered.
|
||||
pub shown: bool,
|
||||
pub sharing: bool,
|
||||
pub code: Option<String>,
|
||||
/// Who can join and what to do when friends on the internet can't (or,
|
||||
/// before the code, what the game is doing).
|
||||
pub status: String,
|
||||
/// Friends on the internet can reach the host.
|
||||
pub reachable: bool,
|
||||
/// Where the troubleshooting is, when they can't.
|
||||
pub help: Option<String>,
|
||||
/// The players in the world besides the host.
|
||||
pub guests: Vec<String>,
|
||||
pub notice: Option<String>,
|
||||
/// The code was just copied.
|
||||
pub copied: bool,
|
||||
}
|
||||
|
||||
/// A button's action.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum Action {
|
||||
Share,
|
||||
Copy,
|
||||
Stop,
|
||||
/// Open the troubleshooting page.
|
||||
Help,
|
||||
Done,
|
||||
}
|
||||
|
||||
fn grey() -> u32 {
|
||||
rgba(160, 160, 160, 255)
|
||||
}
|
||||
const WHITE: u32 = u32::MAX;
|
||||
fn hint() -> String {
|
||||
tr(
|
||||
"horizon.friends.hint",
|
||||
"Friends paste the code after the title screen's Friends button, in Direct Connection, or on the launcher's Play page.",
|
||||
)
|
||||
}
|
||||
|
||||
struct Button {
|
||||
x: f32,
|
||||
y: f32,
|
||||
w: f32,
|
||||
label: String,
|
||||
action: Action,
|
||||
active: bool,
|
||||
}
|
||||
|
||||
/// The centred lines (text, colour, y) and the buttons.
|
||||
fn layout(
|
||||
atlas: &HudAtlas,
|
||||
(w, h): (f32, f32),
|
||||
view: &FriendsView,
|
||||
) -> (Vec<(String, u32, f32)>, Vec<Button>) {
|
||||
let width = (w - 40.0).min(310.0);
|
||||
let cx = (w / 2.0).floor();
|
||||
let mut lines = Vec::new();
|
||||
let mut y = 40.0;
|
||||
let add = |lines: &mut Vec<(String, u32, f32)>, text: &str, color: u32, y: &mut f32| {
|
||||
for line in wrap(text, width, atlas) {
|
||||
lines.push((line, color, *y));
|
||||
*y += 10.0;
|
||||
}
|
||||
};
|
||||
let mut buttons = Vec::new();
|
||||
if view.sharing {
|
||||
lines.push((
|
||||
tr("horizon.multiplayer.invite_code", "Invite Code"),
|
||||
grey(),
|
||||
y,
|
||||
));
|
||||
y += 12.0;
|
||||
match &view.code {
|
||||
Some(code) => {
|
||||
for line in code_lines(atlas, code, width) {
|
||||
lines.push((line, WHITE, y));
|
||||
y += 10.0;
|
||||
}
|
||||
}
|
||||
None => add(&mut lines, &view.status, WHITE, &mut y),
|
||||
}
|
||||
y += 6.0;
|
||||
buttons.push(Button {
|
||||
x: cx - 154.0,
|
||||
y,
|
||||
w: 150.0,
|
||||
label: if view.copied {
|
||||
tr("horizon.friends.copied", "Copied")
|
||||
} else {
|
||||
tr("horizon.friends.copy_code", "Copy Code")
|
||||
},
|
||||
action: Action::Copy,
|
||||
active: view.code.is_some(),
|
||||
});
|
||||
buttons.push(Button {
|
||||
x: cx + 4.0,
|
||||
y,
|
||||
w: 150.0,
|
||||
label: tr("horizon.friends.stop_sharing", "Stop Sharing"),
|
||||
action: Action::Stop,
|
||||
active: true,
|
||||
});
|
||||
y += 30.0;
|
||||
if view.code.is_some() {
|
||||
// Who can join: grey when friends anywhere can, white (it asks
|
||||
// for something) when only the LAN and VPNs can.
|
||||
let tint = if view.reachable { grey() } else { WHITE };
|
||||
add(&mut lines, &view.status, tint, &mut y);
|
||||
y += 4.0;
|
||||
}
|
||||
let here = if view.guests.is_empty() {
|
||||
tr("horizon.friends.none_yet", "No friends here yet.")
|
||||
} else {
|
||||
tr("horizon.friends.here_now", "Here now: %s").replacen(
|
||||
"%s",
|
||||
&view.guests.join(", "),
|
||||
1,
|
||||
)
|
||||
};
|
||||
add(&mut lines, &here, grey(), &mut y);
|
||||
if let Some(notice) = &view.notice {
|
||||
add(&mut lines, notice, WHITE, &mut y);
|
||||
}
|
||||
// Where friends paste the code, while it fits above the footer.
|
||||
let hint = hint();
|
||||
if y + 10.0 * wrap(&hint, width, atlas).len() as f32 <= h - 33.0 {
|
||||
add(&mut lines, &hint, grey(), &mut y);
|
||||
}
|
||||
} else {
|
||||
add(
|
||||
&mut lines,
|
||||
&tr(
|
||||
"horizon.friends.share_intro",
|
||||
"Share this world behind an invite code: it opens to LAN, and friends on the internet join with the code. No server of ours is involved.",
|
||||
),
|
||||
WHITE,
|
||||
&mut y,
|
||||
);
|
||||
y += 4.0;
|
||||
add(&mut lines, &hint(), grey(), &mut y);
|
||||
buttons.push(Button {
|
||||
x: cx - 100.0,
|
||||
y: y + 8.0,
|
||||
w: 200.0,
|
||||
label: tr("horizon.friends.start_sharing", "Start Sharing"),
|
||||
action: Action::Share,
|
||||
active: view.shown,
|
||||
});
|
||||
}
|
||||
// HeaderAndFooterLayout's footer: Done, beside Troubleshooting when
|
||||
// friends on the internet can't reach the host.
|
||||
if view.sharing && view.help.is_some() {
|
||||
buttons.push(Button {
|
||||
x: cx - 154.0,
|
||||
y: h - 27.0,
|
||||
w: 150.0,
|
||||
label: tr("horizon.friends.troubleshooting", "Troubleshooting..."),
|
||||
action: Action::Help,
|
||||
active: true,
|
||||
});
|
||||
buttons.push(Button {
|
||||
x: cx + 4.0,
|
||||
y: h - 27.0,
|
||||
w: 150.0,
|
||||
label: "gui.done".to_owned(),
|
||||
action: Action::Done,
|
||||
active: true,
|
||||
});
|
||||
} else {
|
||||
buttons.push(Button {
|
||||
x: cx - 100.0,
|
||||
y: h - 27.0,
|
||||
w: 200.0,
|
||||
label: "gui.done".to_owned(),
|
||||
action: Action::Done,
|
||||
active: true,
|
||||
});
|
||||
}
|
||||
(lines, buttons)
|
||||
}
|
||||
/// The code's groups packed into lines of at most `width`.
|
||||
fn code_lines(atlas: &HudAtlas, code: &str, width: f32) -> Vec<String> {
|
||||
let mut lines: Vec<String> = Vec::new();
|
||||
for group in code.split('-') {
|
||||
match lines.last_mut() {
|
||||
Some(l) if HudBatch::text_width(atlas, &format!("{l}-{group}")) <= width => {
|
||||
l.push('-');
|
||||
l.push_str(group);
|
||||
}
|
||||
_ => lines.push(group.to_owned()),
|
||||
}
|
||||
}
|
||||
lines
|
||||
}
|
||||
fn label(key: &str) -> Text {
|
||||
if key.starts_with("gui.") {
|
||||
Text::translate(key, &[])
|
||||
} else {
|
||||
Text::literal(key)
|
||||
}
|
||||
}
|
||||
pub fn draw(
|
||||
batch: &mut HudBatch,
|
||||
atlas: &HudAtlas,
|
||||
cursor: (f32, f32),
|
||||
view: &FriendsView,
|
||||
in_world: bool,
|
||||
) {
|
||||
let (w, h) = (batch.width, batch.height);
|
||||
ui::menu_background(batch, atlas, 0.0, 0.0, w, h, in_world);
|
||||
let title = Text::literal(title());
|
||||
let tw = atlas.font.width_text(&title);
|
||||
batch.text_styled(atlas, &title, ((w - tw) / 2.0).floor(), 15.0, WHITE, true);
|
||||
let (lines, buttons) = layout(atlas, (w, h), view);
|
||||
for (line, color, y) in lines {
|
||||
let lw = HudBatch::text_width(atlas, &line);
|
||||
batch.text(atlas, &line, ((w - lw) / 2.0).floor(), y, color, true);
|
||||
}
|
||||
for b in buttons {
|
||||
let hover = b.active && ui::hovered(cursor, b.x, b.y, b.w, 20.0);
|
||||
if matches!(b.action, Action::Done) {
|
||||
horizon_client::widgets::declare_action(
|
||||
horizon_client::widgets::Action::Done,
|
||||
[b.x, b.y, b.w, 20.0],
|
||||
b.active,
|
||||
);
|
||||
}
|
||||
ui::button(
|
||||
batch,
|
||||
atlas,
|
||||
b.x,
|
||||
b.y,
|
||||
b.w,
|
||||
20.0,
|
||||
&label(&b.label),
|
||||
b.active,
|
||||
hover,
|
||||
);
|
||||
}
|
||||
}
|
||||
/// The button under a click, if it is active.
|
||||
pub fn click(
|
||||
atlas: &HudAtlas,
|
||||
size: (f32, f32),
|
||||
cursor: (f32, f32),
|
||||
view: &FriendsView,
|
||||
) -> Option<Action> {
|
||||
layout(atlas, size, view)
|
||||
.1
|
||||
.into_iter()
|
||||
.find(|b| b.active && ui::hovered(cursor, b.x, b.y, b.w, 20.0))
|
||||
.map(|b| b.action)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn atlas() -> HudAtlas {
|
||||
HudAtlas::build(std::path::Path::new("/nonexistent/horizon-test-assets")).unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn share_then_copy_and_stop() {
|
||||
let atlas = atlas();
|
||||
let size = (427.0, 240.0);
|
||||
let idle = FriendsView {
|
||||
shown: true,
|
||||
..FriendsView::default()
|
||||
};
|
||||
let (_, buttons) = layout(&atlas, size, &idle);
|
||||
let share = buttons.iter().find(|b| b.action == Action::Share).unwrap();
|
||||
assert_eq!(
|
||||
click(&atlas, size, (share.x + 1.0, share.y + 1.0), &idle),
|
||||
Some(Action::Share)
|
||||
);
|
||||
let sharing = FriendsView {
|
||||
shown: true,
|
||||
sharing: true,
|
||||
status: "Finding how friends can reach you…".into(),
|
||||
..FriendsView::default()
|
||||
};
|
||||
let (_, buttons) = layout(&atlas, size, &sharing);
|
||||
let copy = buttons.iter().find(|b| b.action == Action::Copy).unwrap();
|
||||
assert!(!copy.active, "nothing to copy before the code");
|
||||
let with_code = FriendsView {
|
||||
code: Some("0B33K-Q405Q-1Z5CV-X6NHY-R12N0-VK8T1-60N00-RA134-952PE-1HA08-910P0-K5M01-ST1NB-JDTB6-K4W1P-0".into()),
|
||||
status: "Friends on your network or VPN can join. For friends on the internet: turn on UPnP on your router, or forward TCP port 25580 to 10.0.0.2, or use a relay, or a VPN such as Tailscale or ZeroTier (the code already holds your VPN address).".into(),
|
||||
help: Some("https://example.invalid/help".into()),
|
||||
..sharing
|
||||
};
|
||||
let (lines, buttons) = layout(&atlas, size, &with_code);
|
||||
assert!(
|
||||
lines.iter().all(|(_, _, y)| y + 9.0 <= size.1 - 27.0),
|
||||
"the text stays above the footer"
|
||||
);
|
||||
for action in [Action::Copy, Action::Stop, Action::Help, Action::Done] {
|
||||
let b = buttons.iter().find(|b| b.action == action).unwrap();
|
||||
assert_eq!(
|
||||
click(&atlas, size, (b.x + 1.0, b.y + 1.0), &with_code),
|
||||
Some(action)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_server_offers_no_sharing() {
|
||||
let atlas = atlas();
|
||||
let view = FriendsView::default();
|
||||
let (_, buttons) = layout(&atlas, (427.0, 240.0), &view);
|
||||
let share = buttons.iter().find(|b| b.action == Action::Share).unwrap();
|
||||
assert!(!share.active);
|
||||
}
|
||||
}
|
||||
@@ -1,38 +0,0 @@
|
||||
//! Play with Friends left out of the build (the `friends` feature off, as in the server build):
|
||||
//! no world is shared, the pause menu has no Friends button (`friends_shown` is false, as on
|
||||
//! another server) and `horizon-ctl friends` says it isn't there. game/friends.rs is the real one.
|
||||
use super::{Game, Screen};
|
||||
use horizon_client::{Result, hud::HudBatch};
|
||||
|
||||
pub const HELP: &[&str] = &[];
|
||||
|
||||
/// The pause menu's Friends icon is vanilla's, drawn inactive: its name for the widget tree.
|
||||
pub const TITLE: &str = "Play with Friends";
|
||||
pub fn title() -> String {
|
||||
horizon_client::lang::get_or("horizon.friends.title", TITLE)
|
||||
}
|
||||
|
||||
impl Game<'_> {
|
||||
pub(super) fn friends_lan_closed(&mut self) {}
|
||||
|
||||
pub(super) fn tick_friends(&mut self) {}
|
||||
|
||||
/// Only reached by naming the screen (`horizon-ctl`): back to the pause menu.
|
||||
pub(super) fn draw_friends_screen(&mut self, _batch: &mut HudBatch) {
|
||||
self.screen = Screen::Paused;
|
||||
}
|
||||
|
||||
pub(super) fn friends_screen_click(&mut self) {}
|
||||
|
||||
pub(super) fn friends_shown(&self) -> bool {
|
||||
false
|
||||
}
|
||||
|
||||
/// `horizon-ctl friends ...`.
|
||||
pub(super) fn friends_control(&mut self, words: &[&str]) -> Result<Option<serde_json::Value>> {
|
||||
if words[0] != "friends" {
|
||||
return Ok(None);
|
||||
}
|
||||
Err("this build has no Play with Friends (the `friends` feature)".into())
|
||||
}
|
||||
}
|
||||
@@ -1489,7 +1489,7 @@ impl Game<'_> {
|
||||
None => self.unpublish(),
|
||||
};
|
||||
if closed {
|
||||
self.friends_lan_closed();
|
||||
self.lan_closed_hooks();
|
||||
}
|
||||
if closed && announce && self.integrated.is_some() {
|
||||
self.system_message(Text::translate(
|
||||
@@ -1499,7 +1499,7 @@ impl Game<'_> {
|
||||
}
|
||||
return;
|
||||
}
|
||||
self.friends_lan_closed();
|
||||
self.lan_closed_hooks();
|
||||
self.native_players_leaving();
|
||||
let Some(mut host) = self.lan.take() else {
|
||||
return;
|
||||
|
||||
@@ -0,0 +1,330 @@
|
||||
//! A page drawn as its own screen (`horizon_hooks::pages::Place::PauseFriends`, what the pause
|
||||
//! menu's vanilla Friends icon opens), the way a mod adds a screen to vanilla: the menu
|
||||
//! background over the paused world, the title centred at the top, the rows centred down the
|
||||
//! middle, footer buttons beside Done at the bottom (`HeaderAndFooterLayout`).
|
||||
use super::{Game, Screen};
|
||||
use horizon_client::{
|
||||
hud::{HudAtlas, HudBatch, rgba},
|
||||
multiplayer::wrap,
|
||||
text::Text,
|
||||
ui,
|
||||
};
|
||||
use horizon_hooks::pages::{self, Act, Page, Place, Row};
|
||||
|
||||
/// What a click on the screen hit.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
enum Hit {
|
||||
/// The page's row with this index.
|
||||
Row(usize),
|
||||
Done,
|
||||
}
|
||||
|
||||
struct Button {
|
||||
x: f32,
|
||||
y: f32,
|
||||
w: f32,
|
||||
label: &'static str,
|
||||
hit: Hit,
|
||||
active: bool,
|
||||
}
|
||||
|
||||
fn grey() -> u32 {
|
||||
rgba(160, 160, 160, 255)
|
||||
}
|
||||
const WHITE: u32 = u32::MAX;
|
||||
|
||||
/// The centred lines (text, colour, y) and the buttons.
|
||||
fn layout(
|
||||
atlas: &HudAtlas,
|
||||
(w, h): (f32, f32),
|
||||
rows: &[Row],
|
||||
) -> (Vec<(String, u32, f32)>, Vec<Button>) {
|
||||
let width = (w - 40.0).min(310.0);
|
||||
let cx = (w / 2.0).floor();
|
||||
let mut lines = Vec::new();
|
||||
let mut buttons = Vec::new();
|
||||
let mut footer = Vec::new();
|
||||
let mut y = 40.0;
|
||||
// A half-width button waiting for the next to sit beside it.
|
||||
let mut half_open = false;
|
||||
for (r, row) in rows.iter().enumerate() {
|
||||
let beside = std::mem::take(&mut half_open);
|
||||
let (text, quiet, groups) = match row {
|
||||
Row::Text {
|
||||
text,
|
||||
quiet,
|
||||
groups,
|
||||
..
|
||||
} => (text.clone(), *quiet, *groups),
|
||||
Row::Note(text) => (text.clone(), true, false),
|
||||
Row::Heading(key) => (horizon_client::lang::get(key), true, false),
|
||||
Row::Space(px) => {
|
||||
y += f32::from(*px);
|
||||
continue;
|
||||
}
|
||||
Row::Button {
|
||||
label,
|
||||
active,
|
||||
footer: true,
|
||||
..
|
||||
} => {
|
||||
footer.push((r, *label, *active));
|
||||
continue;
|
||||
}
|
||||
Row::Button {
|
||||
label,
|
||||
active,
|
||||
half,
|
||||
..
|
||||
} => {
|
||||
let (x, w) = match (*half, beside) {
|
||||
(true, true) => {
|
||||
y -= 30.0;
|
||||
(cx + 4.0, 150.0)
|
||||
}
|
||||
(true, false) => {
|
||||
half_open = true;
|
||||
(cx - 154.0, 150.0)
|
||||
}
|
||||
(false, _) => (cx - 100.0, 200.0),
|
||||
};
|
||||
buttons.push(Button {
|
||||
x,
|
||||
y,
|
||||
w,
|
||||
label,
|
||||
hit: Hit::Row(r),
|
||||
active: *active,
|
||||
});
|
||||
y += 30.0;
|
||||
continue;
|
||||
}
|
||||
// Settings rows belong in the options shell.
|
||||
_ => continue,
|
||||
};
|
||||
let wrapped = if groups {
|
||||
groups_lines(atlas, &text, width)
|
||||
} else {
|
||||
wrap(&text, width, atlas)
|
||||
};
|
||||
// Text that would run into the footer is left out.
|
||||
if y + 10.0 * wrapped.len() as f32 > h - 33.0 {
|
||||
continue;
|
||||
}
|
||||
let color = if quiet { grey() } else { WHITE };
|
||||
for line in wrapped {
|
||||
lines.push((line, color, y));
|
||||
y += 10.0;
|
||||
}
|
||||
}
|
||||
// The footer: the page's buttons, then Done; Done alone is wider.
|
||||
footer.push((usize::MAX, "gui.done", true));
|
||||
let (each, gap) = if footer.len() == 1 {
|
||||
(200.0, 0.0)
|
||||
} else {
|
||||
(150.0, 8.0)
|
||||
};
|
||||
let total = footer.len() as f32 * (each + gap) - gap;
|
||||
let mut x = cx - total / 2.0;
|
||||
for (r, label, active) in footer {
|
||||
let hit = if r == usize::MAX {
|
||||
Hit::Done
|
||||
} else {
|
||||
Hit::Row(r)
|
||||
};
|
||||
buttons.push(Button {
|
||||
x,
|
||||
y: h - 27.0,
|
||||
w: each,
|
||||
label,
|
||||
hit,
|
||||
active,
|
||||
});
|
||||
x += each + gap;
|
||||
}
|
||||
(lines, buttons)
|
||||
}
|
||||
|
||||
/// `-`-separated groups packed into lines of at most `width`, never broken inside a group.
|
||||
fn groups_lines(atlas: &HudAtlas, text: &str, width: f32) -> Vec<String> {
|
||||
let mut lines: Vec<String> = Vec::new();
|
||||
for group in text.split('-') {
|
||||
match lines.last_mut() {
|
||||
Some(l) if HudBatch::text_width(atlas, &format!("{l}-{group}")) <= width => {
|
||||
l.push('-');
|
||||
l.push_str(group);
|
||||
}
|
||||
_ => lines.push(group.to_owned()),
|
||||
}
|
||||
}
|
||||
lines
|
||||
}
|
||||
|
||||
fn draw(batch: &mut HudBatch, atlas: &HudAtlas, cursor: (f32, f32), title: &str, rows: &[Row]) {
|
||||
let (w, h) = (batch.width, batch.height);
|
||||
ui::menu_background(batch, atlas, 0.0, 0.0, w, h, true);
|
||||
let title = Text::translate(title, &[]);
|
||||
let tw = atlas.font.width_text(&title);
|
||||
batch.text_styled(atlas, &title, ((w - tw) / 2.0).floor(), 15.0, WHITE, true);
|
||||
let (lines, buttons) = layout(atlas, (w, h), rows);
|
||||
for (line, color, y) in lines {
|
||||
let lw = HudBatch::text_width(atlas, &line);
|
||||
batch.text(atlas, &line, ((w - lw) / 2.0).floor(), y, color, true);
|
||||
}
|
||||
for b in buttons {
|
||||
let hover = b.active && ui::hovered(cursor, b.x, b.y, b.w, 20.0);
|
||||
if b.hit == Hit::Done {
|
||||
horizon_client::widgets::declare_action(
|
||||
horizon_client::widgets::Action::Done,
|
||||
[b.x, b.y, b.w, 20.0],
|
||||
b.active,
|
||||
);
|
||||
}
|
||||
let label = Text::translate(b.label, &[]);
|
||||
ui::button(batch, atlas, b.x, b.y, b.w, 20.0, &label, b.active, hover);
|
||||
}
|
||||
}
|
||||
|
||||
/// The active button under a click.
|
||||
fn click(atlas: &HudAtlas, size: (f32, f32), cursor: (f32, f32), rows: &[Row]) -> Option<Hit> {
|
||||
layout(atlas, size, rows)
|
||||
.1
|
||||
.into_iter()
|
||||
.find(|b| b.active && ui::hovered(cursor, b.x, b.y, b.w, 20.0))
|
||||
.map(|b| b.hit)
|
||||
}
|
||||
|
||||
/// The page the Friends icon opens: the first registered there.
|
||||
fn friends_page() -> Option<&'static dyn Page> {
|
||||
pages::engine::all()
|
||||
.iter()
|
||||
.copied()
|
||||
.find(|page| page.place() == Place::PauseFriends)
|
||||
}
|
||||
|
||||
/// What the Friends icon and its screen are named, for its tooltip, the narrator, controller
|
||||
/// focus and the control socket's widget tree: the page's title, else vanilla's tooltip.
|
||||
pub(super) fn friends_name() -> String {
|
||||
friends_page().map_or_else(
|
||||
|| horizon_client::lang::get("gui.friends.open"),
|
||||
|page| horizon_client::lang::get(page.title()),
|
||||
)
|
||||
}
|
||||
|
||||
impl Game<'_> {
|
||||
/// Whether the pause menu's Friends icon is active: a page is registered for it and this
|
||||
/// window plays a singleplayer world (another server's world can't be shared).
|
||||
pub(super) fn friends_shown(&self) -> bool {
|
||||
self.can_share() && friends_page().is_some()
|
||||
}
|
||||
|
||||
/// The Friends icon's page. Its rows are asked each time it is drawn.
|
||||
pub(super) fn draw_added_page(&mut self, batch: &mut HudBatch) {
|
||||
let Some(page) = friends_page() else {
|
||||
// Only reached by naming the screen (`horizon-ctl`): back to the pause menu.
|
||||
self.screen = Screen::Paused;
|
||||
return;
|
||||
};
|
||||
let mut rows = Vec::new();
|
||||
page.rows(&mut rows);
|
||||
horizon_client::widgets::begin_screen(|| {
|
||||
horizon_client::widgets::ScreenInfo::new("friends", friends_name())
|
||||
});
|
||||
draw(batch, &self.gui.atlas, self.cursor, page.title(), &rows);
|
||||
}
|
||||
|
||||
pub(super) fn added_page_click(&mut self) {
|
||||
let Some(page) = friends_page() else {
|
||||
return;
|
||||
};
|
||||
let mut rows = Vec::new();
|
||||
page.rows(&mut rows);
|
||||
let Some(hit) = click(&self.gui.atlas, self.gui_size(), self.cursor, &rows) else {
|
||||
return;
|
||||
};
|
||||
self.audio.play_button_click();
|
||||
match hit {
|
||||
Hit::Row(r) => page.act(r, Act::Press),
|
||||
Hit::Done => self.screen = Screen::Paused,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn atlas() -> HudAtlas {
|
||||
HudAtlas::build(std::path::Path::new("/nonexistent/horizon-test-assets")).unwrap()
|
||||
}
|
||||
|
||||
fn at(buttons: &[Button], hit: Hit) -> &Button {
|
||||
buttons.iter().find(|b| b.hit == hit).unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn halves_sit_side_by_side_and_footer_buttons_beside_done() {
|
||||
let atlas = atlas();
|
||||
let size = (427.0, 240.0);
|
||||
let rows = [
|
||||
Row::text("Invite Code").quiet(),
|
||||
Row::Space(2),
|
||||
Row::text("0B33K-Q405Q-1Z5CV-X6NHY-R12N0-VK8T1-60N00-RA134-952PE-1HA08-910P0-K5M01-ST1NB-JDTB6-K4W1P-0").groups(),
|
||||
Row::Space(6),
|
||||
Row::button("copy").half().active(false),
|
||||
Row::button("stop").half(),
|
||||
Row::text("Friends on your network or VPN can join. For friends on the internet: turn on UPnP on your router, or forward TCP port 25580 to 10.0.0.2, or use a relay, or a VPN such as Tailscale or ZeroTier (the code already holds your VPN address)."),
|
||||
Row::button("help").footer(),
|
||||
];
|
||||
let (lines, buttons) = layout(&atlas, size, &rows);
|
||||
let (copy, stop) = (at(&buttons, Hit::Row(4)), at(&buttons, Hit::Row(5)));
|
||||
// 213 is the middle: two 150-wide halves 8 apart around it, as vanilla's pairs.
|
||||
assert_eq!(
|
||||
(copy.x, copy.w, stop.x, stop.w),
|
||||
(59.0, 150.0, 217.0, 150.0)
|
||||
);
|
||||
assert_eq!(copy.y, stop.y);
|
||||
let (help, done) = (at(&buttons, Hit::Row(7)), at(&buttons, Hit::Done));
|
||||
assert_eq!(
|
||||
(help.x, done.x, help.y, done.y),
|
||||
(59.0, 217.0, 213.0, 213.0)
|
||||
);
|
||||
// The code breaks between its groups only.
|
||||
let code: Vec<_> = lines
|
||||
.iter()
|
||||
.filter(|(_, c, _)| *c == WHITE)
|
||||
.map(|(l, ..)| l)
|
||||
.collect();
|
||||
assert!(code[0].starts_with("0B33K-") && code.iter().all(|l| !l.ends_with('-')));
|
||||
assert!(
|
||||
lines.iter().all(|(_, _, y)| y + 9.0 <= size.1 - 27.0),
|
||||
"above the footer"
|
||||
);
|
||||
// An inactive button takes no click; Done alone is 200 wide.
|
||||
assert_eq!(
|
||||
click(&atlas, size, (copy.x + 1.0, copy.y + 1.0), &rows),
|
||||
None
|
||||
);
|
||||
assert_eq!(
|
||||
click(&atlas, size, (stop.x + 1.0, stop.y + 1.0), &rows),
|
||||
Some(Hit::Row(5))
|
||||
);
|
||||
let (_, alone) = layout(&atlas, size, &[Row::button("share")]);
|
||||
assert_eq!(at(&alone, Hit::Done).w, 200.0);
|
||||
assert_eq!(at(&alone, Hit::Row(0)).x, 113.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn text_that_would_reach_the_footer_is_left_out() {
|
||||
let atlas = atlas();
|
||||
let rows = [
|
||||
Row::Space(150),
|
||||
Row::text("first"),
|
||||
Row::text("too low").quiet(),
|
||||
];
|
||||
let (lines, _) = layout(&atlas, (427.0, 240.0), &rows);
|
||||
// 40 + 150 = 190: one line fits above 207, the next (200..210) doesn't.
|
||||
assert_eq!(lines.len(), 1);
|
||||
assert_eq!(lines[0].0, "first");
|
||||
}
|
||||
}
|
||||
@@ -108,7 +108,7 @@ impl Game<'_> {
|
||||
self.death_confirm_answer(false)?;
|
||||
}
|
||||
}
|
||||
Screen::Stats | Screen::Friends => self.screen = Screen::Paused,
|
||||
Screen::Stats | Screen::AddedPage => self.screen = Screen::Paused,
|
||||
Screen::Social => self.close_social(),
|
||||
Screen::ServerLinks => self.screen = Screen::Paused,
|
||||
// ConfirmScreen.keyPressed: Escape answers no; both return to
|
||||
|
||||
@@ -1569,7 +1569,7 @@ impl Game<'_> {
|
||||
self.left = false;
|
||||
self.right = false;
|
||||
// The shared world is closing: its invite code ends with it.
|
||||
self.friends_lan_closed();
|
||||
self.lan_closed_hooks();
|
||||
// Single player's server thread saves the world before the title shows.
|
||||
let single_player = match self.integrated.take() {
|
||||
Some(server) => {
|
||||
|
||||
@@ -73,7 +73,7 @@ impl Screen {
|
||||
| Self::Stats
|
||||
| Self::Advancements
|
||||
| Self::WorldOptions
|
||||
| Self::Friends
|
||||
| Self::AddedPage
|
||||
| Self::ServerLinks
|
||||
| Self::ConfirmLink
|
||||
| Self::DialogWarning
|
||||
@@ -92,7 +92,7 @@ impl Screen {
|
||||
| Self::Stats
|
||||
| Self::Advancements
|
||||
| Self::WorldOptions
|
||||
| Self::Friends
|
||||
| Self::AddedPage
|
||||
| Self::Social
|
||||
| Self::ServerLinks
|
||||
| Self::ConfirmLink
|
||||
@@ -191,8 +191,8 @@ fn pause_buttons(
|
||||
}
|
||||
|
||||
/// The icon row: report bugs, feedback, friends, player reporting.
|
||||
/// `FRIENDS_ICON` (vanilla's `CommonButtons.friends`) opens Horizon's
|
||||
/// Play with Friends; the first two open their link through
|
||||
/// `FRIENDS_ICON` (vanilla's `CommonButtons.friends`) opens the page registered at
|
||||
/// `Place::PauseFriends` (Play with Friends in the official build); the first two open their link through
|
||||
/// `ConfirmLinkScreen`; player reporting opens `SocialInteractionsScreen`.
|
||||
const FRIENDS_ICON: usize = 2;
|
||||
/// `CommonLinks.SNAPSHOT_BUGS_FEEDBACK`, behind `PauseScreen.REPORT_BUGS`.
|
||||
@@ -312,6 +312,7 @@ impl Game<'_> {
|
||||
}
|
||||
pub(super) fn draw_pause_menu(&mut self, batch: &mut HudBatch) {
|
||||
let friends = self.friends_shown();
|
||||
let friends_name = super::page_screen::friends_name();
|
||||
let atlas = &self.gui.atlas;
|
||||
let (w, h) = (batch.width, batch.height);
|
||||
ui::menu_background(batch, atlas, 0.0, 0.0, w, h, true);
|
||||
@@ -373,7 +374,7 @@ impl Game<'_> {
|
||||
// The icon has no label: name it for controller focus and the
|
||||
// control socket's widget tree.
|
||||
if i == FRIENDS_ICON {
|
||||
horizon_client::widgets::record(super::friends::TITLE, [x, y, 20.0, 20.0], active);
|
||||
horizon_client::widgets::record(&friends_name, [x, y, 20.0, 20.0], active);
|
||||
}
|
||||
ui::button(
|
||||
batch,
|
||||
@@ -410,7 +411,7 @@ impl Game<'_> {
|
||||
batch.sprite_scaled(atlas, sprite, x + 3.0, y + 3.0, 15.0, 15.0, u32::MAX);
|
||||
if hover {
|
||||
tooltip = Some(if i == FRIENDS_ICON {
|
||||
Text::literal(super::friends::title())
|
||||
Text::literal(friends_name.clone())
|
||||
} else {
|
||||
Text::translate(ICON_LABELS[i], &[])
|
||||
});
|
||||
@@ -438,7 +439,7 @@ impl Game<'_> {
|
||||
3 => self.open_stats(),
|
||||
4 => self.open_advancements(true),
|
||||
9 => self.open_world_options(),
|
||||
10 => self.screen = Screen::Friends,
|
||||
10 => self.screen = Screen::AddedPage,
|
||||
// DialogConnectionAccess.openDialog(Dialogs.SERVER_LINKS).
|
||||
11 => {
|
||||
self.links_ui.scroll = 0.0;
|
||||
@@ -993,7 +994,7 @@ mod tests {
|
||||
Screen::Stats,
|
||||
Screen::Advancements,
|
||||
Screen::WorldOptions,
|
||||
Screen::Friends,
|
||||
Screen::AddedPage,
|
||||
Screen::ServerLinks,
|
||||
Screen::ConfirmLink,
|
||||
Screen::DialogWarning,
|
||||
|
||||
@@ -0,0 +1,109 @@
|
||||
//! The window's world as `horizon_hooks::share` shows it to the crates that share it past the
|
||||
//! LAN (Play with Friends), and where the engine calls `Hooks::share` and `Hooks::lan_closed`.
|
||||
use super::{Game, lan::LanSettings};
|
||||
use horizon_client::text::Text;
|
||||
use horizon_hooks::share::{LanOptions, ShareView};
|
||||
|
||||
/// The window's own player: not the integrated server's thread, not a splitscreen guest (it
|
||||
/// plays P1's world and can't share it; its leaving doesn't close it).
|
||||
fn shares(game: &Game<'_>) -> bool {
|
||||
!game.serving() && !game.options.split_guest
|
||||
}
|
||||
|
||||
impl Game<'_> {
|
||||
/// `Hooks::share`, once a client tick (`Minecraft.runTick`, so also while paused).
|
||||
pub(super) fn share_tick(&mut self) {
|
||||
if shares(self) {
|
||||
horizon_hooks::engine::share(&mut World(self));
|
||||
}
|
||||
}
|
||||
|
||||
/// `Hooks::lan_closed`: the world stopped being open to LAN, or the player left it.
|
||||
pub(super) fn lan_closed_hooks(&self) {
|
||||
if shares(self) {
|
||||
horizon_hooks::engine::lan_closed();
|
||||
}
|
||||
}
|
||||
|
||||
/// `Hooks::control`: a `horizon-ctl` command a crate answers, with this window's world.
|
||||
pub(super) fn hooks_control(
|
||||
&mut self,
|
||||
words: &[&str],
|
||||
) -> horizon_client::Result<Option<serde_json::Value>> {
|
||||
match horizon_hooks::engine::control(words, &mut World(self)) {
|
||||
None => Ok(None),
|
||||
Some(Ok(json)) => Ok(Some(serde_json::from_str(&json)?)),
|
||||
Some(Err(why)) => Err(why.into()),
|
||||
}
|
||||
}
|
||||
|
||||
/// A singleplayer world runs on this window's server thread, so it can be shared, as Open
|
||||
/// to LAN publishes it; another server can't be.
|
||||
pub(super) fn can_share(&self) -> bool {
|
||||
self.title.is_none() && (self.remote.is_none() || self.integrated.is_some())
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) struct World<'a, 'g>(&'a mut Game<'g>);
|
||||
|
||||
impl ShareView for World<'_, '_> {
|
||||
fn can_share(&self) -> bool {
|
||||
self.0.can_share()
|
||||
}
|
||||
|
||||
fn lan_port(&self) -> Option<u16> {
|
||||
self.0.lan_port()
|
||||
}
|
||||
|
||||
fn open_lan(&mut self, options: LanOptions) -> Result<u16, String> {
|
||||
self.0
|
||||
.lan_open(LanSettings {
|
||||
port: horizon_client::lan::available_port(),
|
||||
guest_commands: options.guest_commands,
|
||||
force_game_mode: options.force_game_mode,
|
||||
})
|
||||
.map_err(|e| e.to_string())?;
|
||||
self.0
|
||||
.lan_port()
|
||||
.ok_or_else(|| "the world isn't open to LAN".to_owned())
|
||||
}
|
||||
|
||||
fn close_lan(&mut self) {
|
||||
self.0.lan_close(true);
|
||||
}
|
||||
|
||||
fn players(&self, each: &mut dyn FnMut(&str)) {
|
||||
let g = &self.0;
|
||||
if let Some(host) = &g.lan {
|
||||
for guest in &host.hub.guests {
|
||||
each(&guest.name);
|
||||
}
|
||||
} else if g.integrated.is_some() {
|
||||
// On the server thread: the other players the server lists.
|
||||
let me = g.options.player_name();
|
||||
for name in g.remote_online_names().unwrap_or_default() {
|
||||
if name != me {
|
||||
each(&name);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn chat(&mut self, text: &str) {
|
||||
self.0.system_message(Text::literal(text));
|
||||
}
|
||||
|
||||
fn copy(&mut self, text: &str) {
|
||||
use horizon_client::clipboard::Clipboard;
|
||||
self.0.clipboard.set(text);
|
||||
}
|
||||
|
||||
fn open_url(&mut self, url: &str) {
|
||||
// The system browser, as the folder buttons open their folders.
|
||||
super::packs::open_uri(url);
|
||||
}
|
||||
|
||||
fn tr(&self, key: &str) -> Option<String> {
|
||||
horizon_client::lang::has(key).then(|| horizon_client::lang::get(key))
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,10 @@
|
||||
//! Play with Friends in the game, joining side (the `game` feature): the engine's `friend:`
|
||||
//! Play with Friends in the game (the `game` feature). Joining: the engine's `friend:`
|
||||
//! addresses (`horizon_platform::hooks::tunnel`) read as invite codes, and joined through this
|
||||
//! crate's tunnel. `horizon-official` registers [`HOOKS`]; the game's own executable has none, so
|
||||
//! there no invite code joins.
|
||||
//! crate's tunnel. Hosting ([`share`]): the pause menu's Friends page shares the open world
|
||||
//! behind an invite code. `horizon-official` registers [`HOOKS`] and [`PAGE`]; the game's own
|
||||
//! executable has neither, so there no invite code joins and nothing is shared.
|
||||
mod share;
|
||||
|
||||
use crate::{
|
||||
PathMode,
|
||||
guest::{self, GuestConfig},
|
||||
@@ -10,10 +13,12 @@ use crate::{
|
||||
use horizon_platform::{
|
||||
hooks::{
|
||||
Hooks,
|
||||
share::ShareView,
|
||||
tunnel::{Opened, Progress, Resolver},
|
||||
},
|
||||
version::{PROTOCOL_VERSION, VANILLA_VERSION},
|
||||
};
|
||||
pub use share::PAGE;
|
||||
use std::net::SocketAddr;
|
||||
|
||||
/// What `horizon-official` registers: Play with Friends' hooks.
|
||||
@@ -25,6 +30,22 @@ impl Hooks for Friends {
|
||||
fn tunnel(&self) -> Option<&dyn Resolver> {
|
||||
Some(&Invites)
|
||||
}
|
||||
|
||||
fn share(&self, world: &mut dyn ShareView) {
|
||||
share::HOOKS.share(world);
|
||||
}
|
||||
|
||||
fn lan_closed(&self) {
|
||||
share::HOOKS.lan_closed();
|
||||
}
|
||||
|
||||
fn control_commands(&self) -> Option<(&'static str, &'static [&'static str])> {
|
||||
share::HOOKS.control_commands()
|
||||
}
|
||||
|
||||
fn control(&self, words: &[&str], world: &mut dyn ShareView) -> Option<Result<String, String>> {
|
||||
share::HOOKS.control(words, world)
|
||||
}
|
||||
}
|
||||
|
||||
struct Invites;
|
||||
|
||||
@@ -0,0 +1,790 @@
|
||||
//! Play with friends, host side: "Start Sharing" on the Play with Friends
|
||||
//! page (`PAGE`, what the pause menu's Friends icon opens) opens the world to LAN if
|
||||
//! it isn't (with Open to LAN's defaults) and shares that port behind an
|
||||
//! invite code (horizon-friends). The code holds this computer's addresses
|
||||
//! and the key, so friends join with no server of ours; a relay the player
|
||||
//! configured adds a short code and a fallback. The integrated server is the
|
||||
//! LAN one, unchanged: tunnelled guests are LAN guests that connect from the
|
||||
//! loopback address. The joining side is [`super`]'s resolver; horizon-official registers
|
||||
//! both ([`super::HOOKS`], [`PAGE`]).
|
||||
//!
|
||||
//! It sees the game only through the platform's hooks (the `ShareView` its `Hooks::share` tick
|
||||
//! gets, `Hooks::lan_closed`, `Hooks::control` for `horizon-ctl friends`, its page, `tr`) and
|
||||
//! keeps what it shares in its own statics.
|
||||
use crate::{Host, HostConfig, HostEvent, PathMode, host::DEFAULT_PORT, reach, stun};
|
||||
use horizon_platform::{
|
||||
hooks::{
|
||||
Hooks,
|
||||
share::{LanOptions, ShareView},
|
||||
},
|
||||
pages::{Act, Page, Place, Row},
|
||||
version::{PROTOCOL_VERSION, VANILLA_VERSION},
|
||||
};
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use std::sync::{Mutex, MutexGuard, PoisonError};
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
struct Sharing {
|
||||
host: Host,
|
||||
/// Sharing opened the world to LAN, so stopping closes it again.
|
||||
opened_lan: bool,
|
||||
code: Option<String>,
|
||||
/// Wrong codes tried so far.
|
||||
wrong: u32,
|
||||
notice: Option<String>,
|
||||
copied: Option<Instant>,
|
||||
/// Sessions carried directly and through the relay so far.
|
||||
paths: (u32, u32),
|
||||
}
|
||||
|
||||
/// The world shared, if one is. The game has one window player who can share (the engine
|
||||
/// calls the hooks for it only), so one is all there is.
|
||||
static SHARING: Mutex<Option<Sharing>> = Mutex::new(None);
|
||||
/// Whether [`SHARING`] holds one: a tick with nothing shared is this one load.
|
||||
static ON: AtomicBool = AtomicBool::new(false);
|
||||
|
||||
/// What the page left for the next [`Hooks::share`] tick, which has the game's view.
|
||||
#[derive(Default)]
|
||||
struct PageState {
|
||||
/// Chat lines (new codes, the relay stopping), said in order.
|
||||
chat: Vec<String>,
|
||||
/// A button pressed on the page.
|
||||
pressed: Option<Action>,
|
||||
/// The page was drawn since the last tick: its guests are read again.
|
||||
open: bool,
|
||||
/// The other players, as the last tick while the page was open read them.
|
||||
guests: Vec<String>,
|
||||
/// The action of each row the page last showed.
|
||||
acts: Vec<Option<Action>>,
|
||||
}
|
||||
static PAGE_STATE: Mutex<PageState> = Mutex::new(PageState {
|
||||
chat: Vec::new(),
|
||||
pressed: None,
|
||||
open: false,
|
||||
guests: Vec::new(),
|
||||
acts: Vec::new(),
|
||||
});
|
||||
/// Whether [`PAGE_STATE`] has something for the tick.
|
||||
static WORK: AtomicBool = AtomicBool::new(false);
|
||||
|
||||
fn page() -> MutexGuard<'static, PageState> {
|
||||
PAGE_STATE.lock().unwrap_or_else(PoisonError::into_inner)
|
||||
}
|
||||
|
||||
/// A button of the page.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
enum Action {
|
||||
Share,
|
||||
Copy,
|
||||
Stop,
|
||||
/// Open the troubleshooting page.
|
||||
Help,
|
||||
}
|
||||
|
||||
fn sharing() -> MutexGuard<'static, Option<Sharing>> {
|
||||
SHARING.lock().unwrap_or_else(PoisonError::into_inner)
|
||||
}
|
||||
|
||||
/// Ends the sharing, if there is one. The lock is let go before the caller touches the
|
||||
/// game: closing LAN calls [`Hooks::lan_closed`], which takes it again.
|
||||
fn take() -> Option<Sharing> {
|
||||
let taken = sharing().take();
|
||||
ON.store(false, Ordering::Relaxed);
|
||||
taken
|
||||
}
|
||||
|
||||
/// The host side's hooks, which [`super::HOOKS`] passes on.
|
||||
pub(super) struct Share;
|
||||
pub(super) static HOOKS: Share = Share;
|
||||
|
||||
impl Hooks for Share {
|
||||
fn control_commands(&self) -> Option<(&'static str, &'static [&'static str])> {
|
||||
Some(("friends", HELP))
|
||||
}
|
||||
|
||||
/// `horizon-ctl friends host|stop|status`.
|
||||
fn control(
|
||||
&self,
|
||||
words: &[&str],
|
||||
world: &mut dyn ShareView,
|
||||
) -> Option<std::result::Result<String, String>> {
|
||||
(words.first() == Some(&"friends"))
|
||||
.then(|| control(words.get(1).copied(), world).map(|answer| answer.to_string()))
|
||||
}
|
||||
|
||||
/// What the page left for the game: chat lines, a button's press, the guests while it is
|
||||
/// open. With nothing left, one relaxed load.
|
||||
fn share(&self, world: &mut dyn ShareView) {
|
||||
if !WORK.swap(false, Ordering::Relaxed) {
|
||||
return;
|
||||
}
|
||||
let (lines, pressed, open) = {
|
||||
let mut page = page();
|
||||
let open = std::mem::take(&mut page.open);
|
||||
(std::mem::take(&mut page.chat), page.pressed.take(), open)
|
||||
};
|
||||
for line in lines {
|
||||
world.chat(&line);
|
||||
}
|
||||
if let Some(action) = pressed {
|
||||
press(action, world);
|
||||
}
|
||||
if open {
|
||||
let mut guests = Vec::new();
|
||||
world.players(&mut |name| guests.push(name.to_owned()));
|
||||
page().guests = guests;
|
||||
}
|
||||
}
|
||||
|
||||
/// The LAN server is closing: the code goes with it.
|
||||
fn lan_closed(&self) {
|
||||
if ON.load(Ordering::Relaxed)
|
||||
&& let Some(mut sharing) = take()
|
||||
{
|
||||
sharing.host.stop();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const HELP: &[&str] = &[
|
||||
"friends host share the open world behind an invite code",
|
||||
"friends stop stop sharing",
|
||||
"friends status the code, how friends reach this host, the guests",
|
||||
];
|
||||
|
||||
/// A string in the player's language, or the English one.
|
||||
fn tr(key: &str, english: &str) -> String {
|
||||
horizon_platform::hooks::tr(key).unwrap_or_else(|| english.to_owned())
|
||||
}
|
||||
|
||||
/// One of the player's Horizon preferences, `key:value` lines in `horizon.txt` beside
|
||||
/// `options.txt` in the game folder (the working directory), where the game keeps its own.
|
||||
fn preference(key: &str) -> Option<String> {
|
||||
let text = std::fs::read_to_string(std::env::current_dir().ok()?.join("horizon.txt")).ok()?;
|
||||
text.lines().find_map(|l| {
|
||||
let (k, v) = l.split_once(':')?;
|
||||
(k.trim() == key).then(|| v.trim().to_owned())
|
||||
})
|
||||
}
|
||||
|
||||
/// The relay the player set on Join a Friend (`friendsRelay`), else `HORIZON_RELAY_URL`. None
|
||||
/// by default.
|
||||
fn relay() -> Option<String> {
|
||||
preference("friendsRelay")
|
||||
.filter(|r| !r.is_empty())
|
||||
.or_else(crate::relay_url)
|
||||
}
|
||||
|
||||
/// `HORIZON_STUN_SERVERS`, else `friendsStun` in horizon.txt, else the defaults.
|
||||
fn stun_servers() -> Vec<String> {
|
||||
if std::env::var_os("HORIZON_STUN_SERVERS").is_some() {
|
||||
return stun::servers_from_env();
|
||||
}
|
||||
match preference("friendsStun") {
|
||||
Some(list) => stun::parse_servers(&list),
|
||||
None => stun::servers_from_env(),
|
||||
}
|
||||
}
|
||||
|
||||
fn env_flag(name: &str, default: bool) -> bool {
|
||||
std::env::var(name).map_or(default, |v| v != "0")
|
||||
}
|
||||
|
||||
/// Start sharing the open world.
|
||||
fn start(world: &mut dyn ShareView) -> std::result::Result<(), String> {
|
||||
if ON.load(Ordering::Relaxed) {
|
||||
return Ok(());
|
||||
}
|
||||
if !world.can_share() {
|
||||
return Err("open a singleplayer world first".into());
|
||||
}
|
||||
let (port, opened_lan) = match world.lan_port() {
|
||||
Some(port) => (port, false),
|
||||
// ShareToLanScreen's defaults, as the world options screen offers them.
|
||||
None => (world.open_lan(LanOptions::default())?, true),
|
||||
};
|
||||
let host = Host::start(HostConfig {
|
||||
lan: std::net::SocketAddr::from(([127, 0, 0, 1], port)),
|
||||
protocol: PROTOCOL_VERSION,
|
||||
game: VANILLA_VERSION.to_owned(),
|
||||
relay: relay(),
|
||||
path: PathMode::from_env(),
|
||||
port: std::env::var("HORIZON_FRIENDS_PORT")
|
||||
.ok()
|
||||
.and_then(|p| p.parse().ok())
|
||||
.unwrap_or(DEFAULT_PORT),
|
||||
reach: reach::Options {
|
||||
// HORIZON_FRIENDS_UPNP=0 leaves the router alone.
|
||||
map_port: env_flag("HORIZON_FRIENDS_UPNP", true),
|
||||
stun: stun_servers(),
|
||||
// Both ends on one machine (tests).
|
||||
loopback: env_flag("HORIZON_FRIENDS_LOOPBACK", false),
|
||||
},
|
||||
});
|
||||
let host = match host {
|
||||
Ok(h) => h,
|
||||
Err(e) => {
|
||||
if opened_lan {
|
||||
world.close_lan();
|
||||
}
|
||||
return Err(e.to_string());
|
||||
}
|
||||
};
|
||||
*sharing() = Some(Sharing {
|
||||
host,
|
||||
opened_lan,
|
||||
code: None,
|
||||
wrong: 0,
|
||||
notice: None,
|
||||
copied: None,
|
||||
paths: (0, 0),
|
||||
});
|
||||
ON.store(true, Ordering::Relaxed);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Stop sharing: the code ends (direct port closed, router mapping
|
||||
/// deleted); the world closes to LAN again if sharing opened it.
|
||||
fn stop(world: &mut dyn ShareView) {
|
||||
if !world.can_share() {
|
||||
return;
|
||||
}
|
||||
let Some(mut sharing) = take() else {
|
||||
return;
|
||||
};
|
||||
sharing.host.stop();
|
||||
if sharing.opened_lan {
|
||||
world.close_lan();
|
||||
} else {
|
||||
let text = tr(
|
||||
"horizon.friends.stopped",
|
||||
"Stopped sharing the invite code.",
|
||||
);
|
||||
world.chat(&text);
|
||||
}
|
||||
}
|
||||
|
||||
/// New codes and sessions, while the page is drawn and when `friends status` asks. Its chat
|
||||
/// lines wait for the next tick ([`Hooks::share`]), or for [`say`].
|
||||
fn poll() {
|
||||
if !ON.load(Ordering::Relaxed) {
|
||||
return;
|
||||
}
|
||||
let mut announce = false;
|
||||
let mut stopped = None;
|
||||
{
|
||||
let mut guard = sharing();
|
||||
let Some(sharing) = guard.as_mut() else {
|
||||
return;
|
||||
};
|
||||
for event in sharing.host.poll() {
|
||||
match event {
|
||||
HostEvent::Code(code) => {
|
||||
if sharing.code.is_some() && sharing.wrong >= crate::host::MAX_WRONG {
|
||||
sharing.notice = Some(tr(
|
||||
"horizon.friends.short_code_changed",
|
||||
"Someone kept trying a wrong short code, so it changed.",
|
||||
));
|
||||
sharing.wrong = 0;
|
||||
}
|
||||
announce = sharing.code.is_none();
|
||||
sharing.code = Some(code);
|
||||
}
|
||||
HostEvent::WrongCode { count } => {
|
||||
sharing.wrong = count;
|
||||
sharing.notice = Some(tr(
|
||||
"horizon.friends.wrong_code",
|
||||
"Someone tried a wrong code.",
|
||||
));
|
||||
}
|
||||
HostEvent::Connected { direct: true, .. } => sharing.paths.0 += 1,
|
||||
HostEvent::Connected { direct: false, .. } => sharing.paths.1 += 1,
|
||||
HostEvent::Disconnected { .. } => {}
|
||||
HostEvent::RelayStopped => {
|
||||
let text = tr(
|
||||
"horizon.friends.relay_stopped",
|
||||
"Too many wrong short codes, so the relay is off until you share again. Friends can still join with the full code.",
|
||||
);
|
||||
sharing.notice = Some(text.clone());
|
||||
stopped = Some(text);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
let mut page = page();
|
||||
page.chat.extend(stopped);
|
||||
if announce {
|
||||
page.chat.push(tr(
|
||||
"horizon.friends.code_ready",
|
||||
"Your invite code is ready: Game Menu, Play with Friends. Copy it and send it to your friends.",
|
||||
));
|
||||
}
|
||||
if !page.chat.is_empty() {
|
||||
WORK.store(true, Ordering::Relaxed);
|
||||
}
|
||||
}
|
||||
|
||||
/// The chat lines waiting, said now.
|
||||
fn say(world: &mut dyn ShareView) {
|
||||
let lines = std::mem::take(&mut page().chat);
|
||||
for line in lines {
|
||||
world.chat(&line);
|
||||
}
|
||||
}
|
||||
|
||||
/// A button of the page, on the tick after its press.
|
||||
fn press(action: Action, world: &mut dyn ShareView) {
|
||||
match action {
|
||||
Action::Share => {
|
||||
if let Err(e) = start(world) {
|
||||
let text = tr(
|
||||
"horizon.friends.share_failed",
|
||||
"Couldn't share the world: %s",
|
||||
)
|
||||
.replacen("%s", &e, 1);
|
||||
world.chat(&text);
|
||||
}
|
||||
}
|
||||
Action::Copy => copy(world),
|
||||
Action::Stop => stop(world),
|
||||
// The system browser, as the folder buttons open their folders.
|
||||
Action::Help => world.open_url(reach::HELP_URL),
|
||||
}
|
||||
}
|
||||
|
||||
fn copy(world: &mut dyn ShareView) {
|
||||
if !world.can_share() {
|
||||
return;
|
||||
}
|
||||
let Some(code) = sharing().as_ref().and_then(|s| s.code.clone()) else {
|
||||
return;
|
||||
};
|
||||
world.copy(&code);
|
||||
if let Some(s) = sharing().as_mut() {
|
||||
s.copied = Some(Instant::now());
|
||||
}
|
||||
}
|
||||
|
||||
/// What Play with Friends shows.
|
||||
#[derive(Clone, Debug, Default)]
|
||||
struct View {
|
||||
sharing: bool,
|
||||
code: Option<String>,
|
||||
/// Who can join and what to do when friends on the internet can't (or,
|
||||
/// before the code, what the game is doing).
|
||||
status: String,
|
||||
/// Friends on the internet can reach the host.
|
||||
reachable: bool,
|
||||
/// The players in the world besides the host.
|
||||
guests: Vec<String>,
|
||||
notice: Option<String>,
|
||||
/// The code was just copied.
|
||||
copied: bool,
|
||||
}
|
||||
|
||||
/// What Play with Friends shows; `guests` is asked only while sharing.
|
||||
fn view(guests: impl FnOnce() -> Vec<String>) -> View {
|
||||
let guard = sharing();
|
||||
let Some(s) = guard.as_ref() else {
|
||||
return View::default();
|
||||
};
|
||||
let state = s.host.state();
|
||||
let mut status = if s.code.is_none() {
|
||||
tr(
|
||||
"horizon.friends.finding_route",
|
||||
"Finding how friends can reach you…",
|
||||
)
|
||||
} else {
|
||||
state.summary.clone()
|
||||
};
|
||||
if let Some(short) = &state.short {
|
||||
status.push_str(&format!(" Short code for the relay: {short}."));
|
||||
}
|
||||
if let Some(problem) = &state.relay_problem {
|
||||
status.push(' ');
|
||||
status.push_str(problem);
|
||||
}
|
||||
let reachable = matches!(
|
||||
state.verdict,
|
||||
Some(reach::Verdict::Mapped(_) | reach::Verdict::Public)
|
||||
) || state.short.is_some();
|
||||
View {
|
||||
sharing: true,
|
||||
code: s.code.clone(),
|
||||
status,
|
||||
reachable,
|
||||
guests: guests(),
|
||||
notice: s.notice.clone(),
|
||||
copied: s
|
||||
.copied
|
||||
.is_some_and(|t| t.elapsed() < Duration::from_secs(2)),
|
||||
}
|
||||
}
|
||||
|
||||
/// The page's rows for `view`, each with the button's action: the layout today's Friends
|
||||
/// screen had (the engine draws it, `game/page_screen.rs`).
|
||||
fn rows(view: &View, rows: &mut Vec<Row>, acts: &mut Vec<Option<Action>>) {
|
||||
let mut row = |row: Row, act: Option<Action>| {
|
||||
rows.push(row);
|
||||
acts.push(act);
|
||||
};
|
||||
let hint = || {
|
||||
tr(
|
||||
"horizon.friends.hint",
|
||||
"Friends paste the code after the title screen's Friends button, in Direct Connection, or on the launcher's Play page.",
|
||||
)
|
||||
};
|
||||
if !view.sharing {
|
||||
row(
|
||||
Row::text(tr(
|
||||
"horizon.friends.share_intro",
|
||||
"Share this world behind an invite code: it opens to LAN, and friends on the internet join with the code. No server of ours is involved.",
|
||||
)),
|
||||
None,
|
||||
);
|
||||
row(Row::Space(4), None);
|
||||
row(Row::text(hint()).quiet(), None);
|
||||
row(Row::Space(8), None);
|
||||
row(
|
||||
Row::button("horizon.friends.start_sharing"),
|
||||
Some(Action::Share),
|
||||
);
|
||||
return;
|
||||
}
|
||||
row(
|
||||
Row::text(tr("horizon.multiplayer.invite_code", "Invite Code")).quiet(),
|
||||
None,
|
||||
);
|
||||
row(Row::Space(2), None);
|
||||
match &view.code {
|
||||
Some(code) => row(Row::text(code.clone()).groups(), None),
|
||||
None => row(Row::text(view.status.clone()), None),
|
||||
}
|
||||
row(Row::Space(6), None);
|
||||
let copy = if view.copied {
|
||||
"horizon.friends.copied"
|
||||
} else {
|
||||
"horizon.friends.copy_code"
|
||||
};
|
||||
row(
|
||||
Row::button(copy).half().active(view.code.is_some()),
|
||||
Some(Action::Copy),
|
||||
);
|
||||
row(
|
||||
Row::button("horizon.friends.stop_sharing").half(),
|
||||
Some(Action::Stop),
|
||||
);
|
||||
if view.code.is_some() {
|
||||
// Who can join: grey when friends anywhere can, white (it asks
|
||||
// for something) when only the LAN and VPNs can.
|
||||
let status = Row::text(view.status.clone());
|
||||
row(
|
||||
if view.reachable {
|
||||
status.quiet()
|
||||
} else {
|
||||
status
|
||||
},
|
||||
None,
|
||||
);
|
||||
row(Row::Space(4), None);
|
||||
}
|
||||
let here = if view.guests.is_empty() {
|
||||
tr("horizon.friends.none_yet", "No friends here yet.")
|
||||
} else {
|
||||
tr("horizon.friends.here_now", "Here now: %s").replacen("%s", &view.guests.join(", "), 1)
|
||||
};
|
||||
row(Row::text(here).quiet(), None);
|
||||
if let Some(notice) = &view.notice {
|
||||
row(Row::text(notice.clone()), None);
|
||||
}
|
||||
// Where friends paste the code, while it fits above the footer.
|
||||
row(Row::text(hint()).quiet(), None);
|
||||
// Troubleshooting beside Done when friends on the internet can't reach the host.
|
||||
if !view.reachable && view.code.is_some() {
|
||||
row(
|
||||
Row::button("horizon.friends.troubleshooting").footer(),
|
||||
Some(Action::Help),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Play with Friends' page: what the pause menu's Friends icon opens. Its rows are read when
|
||||
/// it is drawn; a press waits for the next tick, which has the game's view.
|
||||
pub struct FriendsPage;
|
||||
pub static PAGE: FriendsPage = FriendsPage;
|
||||
|
||||
impl Page for FriendsPage {
|
||||
fn id(&self) -> &'static str {
|
||||
"horizon_friends.share"
|
||||
}
|
||||
fn title(&self) -> &'static str {
|
||||
"horizon.friends.title"
|
||||
}
|
||||
fn place(&self) -> Place {
|
||||
Place::PauseFriends
|
||||
}
|
||||
fn rows(&self, out: &mut Vec<Row>) {
|
||||
// The world is paused under the page in singleplayer, so new codes and sessions are
|
||||
// read while it is drawn.
|
||||
poll();
|
||||
let guests = std::mem::take(&mut page().guests);
|
||||
let view = view(|| guests);
|
||||
let mut acts = Vec::new();
|
||||
rows(&view, out, &mut acts);
|
||||
let mut page = page();
|
||||
page.guests = view.guests;
|
||||
page.acts = acts;
|
||||
page.open = true;
|
||||
WORK.store(true, Ordering::Relaxed);
|
||||
}
|
||||
fn act(&self, row: usize, act: Act) {
|
||||
let mut page = page();
|
||||
if act == Act::Press
|
||||
&& let Some(Some(action)) = page.acts.get(row).copied()
|
||||
{
|
||||
page.pressed = Some(action);
|
||||
WORK.store(true, Ordering::Relaxed);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// `horizon-ctl friends host|stop|status`.
|
||||
fn control(
|
||||
word: Option<&str>,
|
||||
world: &mut dyn ShareView,
|
||||
) -> std::result::Result<serde_json::Value, String> {
|
||||
use serde_json::json;
|
||||
match word {
|
||||
Some("host") => {
|
||||
start(world)?;
|
||||
Ok(json!({ "ok": true, "lan_port": world.lan_port() }))
|
||||
}
|
||||
Some("stop") => {
|
||||
stop(world);
|
||||
Ok(json!({ "ok": true }))
|
||||
}
|
||||
Some("status") => {
|
||||
// A splitscreen guest asking hears nothing of P1's sharing, and its chat stays its own.
|
||||
let view = if world.can_share() {
|
||||
poll();
|
||||
say(world);
|
||||
view(|| {
|
||||
let mut guests = Vec::new();
|
||||
world.players(&mut |name| guests.push(name.to_owned()));
|
||||
guests
|
||||
})
|
||||
} else {
|
||||
View::default()
|
||||
};
|
||||
let guard = sharing();
|
||||
let sharing = guard.as_ref().filter(|_| view.sharing);
|
||||
let state = sharing.map(|s| s.host.state());
|
||||
Ok(json!({
|
||||
"sharing": view.sharing,
|
||||
"code": view.code,
|
||||
"short": state.as_ref().and_then(|s| s.short.as_ref().map(ToString::to_string)),
|
||||
"summary": view.status,
|
||||
"reachable": view.reachable,
|
||||
"verdict": state.as_ref().and_then(|s| s.verdict.as_ref().map(|v| format!("{v:?}"))),
|
||||
"direct_port": state.as_ref().map(|s| s.port),
|
||||
"sessions": sharing.map_or(0, |s| s.host.sessions()),
|
||||
"direct": sharing.map_or(0, |s| s.paths.0),
|
||||
"relayed": sharing.map_or(0, |s| s.paths.1),
|
||||
"guests": view.guests,
|
||||
"lan_port": world.lan_port(),
|
||||
}))
|
||||
}
|
||||
_ => Err("friends host|stop|status".into()),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// A world as the engine shows it, recording what the crate did to it.
|
||||
#[derive(Default)]
|
||||
struct Fake {
|
||||
can_share: bool,
|
||||
lan: Option<u16>,
|
||||
refuse_lan: bool,
|
||||
did: Vec<String>,
|
||||
}
|
||||
|
||||
impl ShareView for Fake {
|
||||
fn can_share(&self) -> bool {
|
||||
self.can_share
|
||||
}
|
||||
fn lan_port(&self) -> Option<u16> {
|
||||
self.lan
|
||||
}
|
||||
fn open_lan(&mut self, options: LanOptions) -> std::result::Result<u16, String> {
|
||||
self.did.push(format!("open_lan {options:?}"));
|
||||
if self.refuse_lan {
|
||||
return Err("no free port".into());
|
||||
}
|
||||
self.lan = Some(25565);
|
||||
Ok(25565)
|
||||
}
|
||||
fn close_lan(&mut self) {
|
||||
self.did.push("close_lan".into());
|
||||
self.lan = None;
|
||||
}
|
||||
fn players(&self, each: &mut dyn FnMut(&str)) {
|
||||
each("Alex");
|
||||
}
|
||||
fn chat(&mut self, text: &str) {
|
||||
self.did.push(format!("chat {text}"));
|
||||
}
|
||||
fn copy(&mut self, text: &str) {
|
||||
self.did.push(format!("copy {text}"));
|
||||
}
|
||||
fn open_url(&mut self, url: &str) {
|
||||
self.did.push(format!("open_url {url}"));
|
||||
}
|
||||
fn tr(&self, _key: &str) -> Option<String> {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_world_that_cannot_be_shared_is_left_alone() {
|
||||
// Another server, or a splitscreen guest in P1's world.
|
||||
let mut guest = Fake {
|
||||
lan: Some(25565),
|
||||
..Fake::default()
|
||||
};
|
||||
assert_eq!(
|
||||
control(Some("host"), &mut guest).unwrap_err(),
|
||||
"open a singleplayer world first"
|
||||
);
|
||||
stop(&mut guest);
|
||||
copy(&mut guest);
|
||||
assert!(guest.did.is_empty(), "{:?}", guest.did);
|
||||
// horizon-ctl reaches the same through the hook; other commands aren't its.
|
||||
let status = HOOKS
|
||||
.control(&["friends", "status"], &mut guest)
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
let status: serde_json::Value = serde_json::from_str(&status).unwrap();
|
||||
assert_eq!(status["sharing"], false);
|
||||
assert!(HOOKS.control(&["lan", "status"], &mut guest).is_none());
|
||||
assert!(guest.did.is_empty(), "{:?}", guest.did);
|
||||
assert!(!ON.load(Ordering::Relaxed));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sharing_opens_lan_as_open_to_lan_does_and_a_refusal_shares_nothing() {
|
||||
let mut world = Fake {
|
||||
can_share: true,
|
||||
refuse_lan: true,
|
||||
..Fake::default()
|
||||
};
|
||||
assert_eq!(start(&mut world).unwrap_err(), "no free port");
|
||||
assert_eq!(
|
||||
world.did,
|
||||
["open_lan LanOptions { guest_commands: false, force_game_mode: true }"]
|
||||
);
|
||||
// Offered, not shared: the guests aren't asked for.
|
||||
let view = view(|| unreachable!("the guests of a world not shared"));
|
||||
assert!(!view.sharing && view.guests.is_empty());
|
||||
assert!(!ON.load(Ordering::Relaxed));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_page_shows_todays_screen_and_a_press_runs_on_the_next_tick() {
|
||||
// Sharing, with a code friends on the internet can't reach yet.
|
||||
let shared = View {
|
||||
sharing: true,
|
||||
code: Some("K7QM2-AAAAA".into()),
|
||||
status: "Friends on your network can join.".into(),
|
||||
..View::default()
|
||||
};
|
||||
let (mut out, mut acts) = (Vec::new(), Vec::new());
|
||||
rows(&shared, &mut out, &mut acts);
|
||||
let buttons: Vec<_> = out
|
||||
.iter()
|
||||
.zip(&acts)
|
||||
.filter_map(|(row, act)| match row {
|
||||
Row::Button {
|
||||
label,
|
||||
active,
|
||||
half,
|
||||
footer,
|
||||
..
|
||||
} => Some((*label, *active, *half, *footer, *act)),
|
||||
_ => None,
|
||||
})
|
||||
.collect();
|
||||
assert_eq!(
|
||||
buttons,
|
||||
[
|
||||
(
|
||||
"horizon.friends.copy_code",
|
||||
true,
|
||||
true,
|
||||
false,
|
||||
Some(Action::Copy)
|
||||
),
|
||||
(
|
||||
"horizon.friends.stop_sharing",
|
||||
true,
|
||||
true,
|
||||
false,
|
||||
Some(Action::Stop)
|
||||
),
|
||||
(
|
||||
"horizon.friends.troubleshooting",
|
||||
true,
|
||||
false,
|
||||
true,
|
||||
Some(Action::Help)
|
||||
),
|
||||
]
|
||||
);
|
||||
assert!(out.iter().any(
|
||||
|row| matches!(row, Row::Text { text, groups: true, .. } if text == "K7QM2-AAAAA")
|
||||
));
|
||||
// Before the code there is nothing to copy.
|
||||
let (mut out, mut acts) = (Vec::new(), Vec::new());
|
||||
rows(
|
||||
&View {
|
||||
code: None,
|
||||
..shared
|
||||
},
|
||||
&mut out,
|
||||
&mut acts,
|
||||
);
|
||||
assert!(out.iter().any(|row| matches!(
|
||||
row,
|
||||
Row::Button {
|
||||
label: "horizon.friends.copy_code",
|
||||
active: false,
|
||||
..
|
||||
}
|
||||
)));
|
||||
|
||||
// Not sharing: Start Sharing. Its press waits for the tick, which has the world.
|
||||
let (mut out, mut acts) = (Vec::new(), Vec::new());
|
||||
rows(&View::default(), &mut out, &mut acts);
|
||||
let share = acts.iter().position(|a| *a == Some(Action::Share)).unwrap();
|
||||
page().acts = acts;
|
||||
PAGE.act(share, Act::Press);
|
||||
let mut world = Fake {
|
||||
can_share: true,
|
||||
refuse_lan: true,
|
||||
..Fake::default()
|
||||
};
|
||||
HOOKS.share(&mut world);
|
||||
assert_eq!(
|
||||
world.did,
|
||||
[
|
||||
"open_lan LanOptions { guest_commands: false, force_game_mode: true }",
|
||||
"chat Couldn't share the world: no free port",
|
||||
]
|
||||
);
|
||||
// Pressed once, run once.
|
||||
HOOKS.share(&mut world);
|
||||
assert_eq!(world.did.len(), 2);
|
||||
}
|
||||
}
|
||||
@@ -21,6 +21,7 @@ pub mod hud;
|
||||
pub mod pages;
|
||||
mod registry;
|
||||
pub mod settings;
|
||||
pub mod share;
|
||||
pub mod tunnel;
|
||||
|
||||
use registry::Registry;
|
||||
@@ -48,6 +49,31 @@ pub trait Hooks: Sync {
|
||||
fn tunnel(&self) -> Option<&dyn tunnel::Resolver> {
|
||||
None
|
||||
}
|
||||
|
||||
/// Once a client tick (20 a second, also while the game is paused or on the title screen),
|
||||
/// with the window's world ([`share`]). Only the window's own player calls it.
|
||||
fn share(&self, _world: &mut dyn share::ShareView) {}
|
||||
|
||||
/// The window's world stopped being open to LAN: Open to LAN's stop, or the player left the
|
||||
/// world. Called at that moment, by the window's own player only.
|
||||
fn lan_closed(&self) {}
|
||||
|
||||
/// The `horizon-ctl` commands this crate answers in the game: their group, as `hello` lists
|
||||
/// it, and the lines `help` adds for them.
|
||||
fn control_commands(&self) -> Option<(&'static str, &'static [&'static str])> {
|
||||
None
|
||||
}
|
||||
|
||||
/// A `horizon-ctl` command (its words) on the window's game: `Some` with the answer as JSON
|
||||
/// text, or why it failed, when it is this crate's. The first hook that answers wins; the
|
||||
/// hooks are asked before most of the engine's own commands.
|
||||
fn control(
|
||||
&self,
|
||||
_words: &[&str],
|
||||
_world: &mut dyn share::ShareView,
|
||||
) -> Option<Result<String, String>> {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
/// What [`Hooks::started`] gets.
|
||||
@@ -85,6 +111,15 @@ pub fn register(hooks: &'static dyn Hooks) -> Result<(), TooLate> {
|
||||
}
|
||||
|
||||
/// The registered hooks, sealing the list on the first call.
|
||||
/// The game's translations, which it installs at start ([`engine::set_language`]).
|
||||
static LANGUAGE: std::sync::OnceLock<fn(&str) -> Option<String>> = std::sync::OnceLock::new();
|
||||
|
||||
/// The text of a translation key in the player's language, if the game has it. For text a crate
|
||||
/// makes outside a view, such as a page's rows. None before the game installs its languages.
|
||||
pub fn tr(key: &str) -> Option<String> {
|
||||
LANGUAGE.get().and_then(|language| language(key))
|
||||
}
|
||||
|
||||
fn all() -> &'static [&'static dyn Hooks] {
|
||||
HOOKS.all()
|
||||
}
|
||||
@@ -102,6 +137,11 @@ pub mod engine {
|
||||
crate::hud::engine::all();
|
||||
}
|
||||
|
||||
/// Where [`crate::tr`] reads translations from; the first call wins.
|
||||
pub fn set_language(language: fn(&str) -> Option<String>) {
|
||||
let _ = LANGUAGE.set(language);
|
||||
}
|
||||
|
||||
/// [`Hooks::started`].
|
||||
pub fn started(game: &Started<'_>) {
|
||||
for hooks in all() {
|
||||
@@ -130,6 +170,33 @@ pub mod engine {
|
||||
}
|
||||
}
|
||||
|
||||
/// [`Hooks::share`].
|
||||
pub fn share(world: &mut dyn share::ShareView) {
|
||||
for hooks in all() {
|
||||
hooks.share(world);
|
||||
}
|
||||
}
|
||||
|
||||
/// [`Hooks::lan_closed`].
|
||||
pub fn lan_closed() {
|
||||
for hooks in all() {
|
||||
hooks.lan_closed();
|
||||
}
|
||||
}
|
||||
|
||||
/// [`Hooks::control`]: the first hook's answer.
|
||||
pub fn control(
|
||||
words: &[&str],
|
||||
world: &mut dyn share::ShareView,
|
||||
) -> Option<Result<String, String>> {
|
||||
all().iter().find_map(|hooks| hooks.control(words, world))
|
||||
}
|
||||
|
||||
/// [`Hooks::control_commands`] of every hook, in registration order.
|
||||
pub fn control_commands() -> impl Iterator<Item = (&'static str, &'static [&'static str])> {
|
||||
all().iter().filter_map(|hooks| hooks.control_commands())
|
||||
}
|
||||
|
||||
/// [`Hooks::tunnel`]: the first registered hook's resolver.
|
||||
pub fn tunnel() -> Option<&'static dyn tunnel::Resolver> {
|
||||
all().iter().find_map(|hooks| hooks.tunnel())
|
||||
|
||||
@@ -19,6 +19,12 @@ pub enum Place {
|
||||
/// start a group of their own after the page's. A [`Row::Heading`] starts another group. A
|
||||
/// [`Row::Key`] shows its key there, without changing it: give a page of its own to a key.
|
||||
Extras,
|
||||
/// What the pause menu's Friends icon opens (vanilla's `CommonButtons.friends`): the icon is
|
||||
/// active while a page is registered here and the world can be shared (singleplayer). The
|
||||
/// first page registered here is drawn as its own screen over the paused world, as a mod's
|
||||
/// screen is: the title at the top, the rows centred down the middle, footer buttons beside
|
||||
/// Done at the bottom. Text that would reach the footer is left out.
|
||||
PauseFriends,
|
||||
}
|
||||
|
||||
/// One page. Its values live in the crate (statics, often its settings [`crate::settings::Store`]);
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
//! Sharing the open world past the LAN (Play with Friends' host side): what the engine lets a
|
||||
//! crate do with the world a window plays. The engine implements [`ShareView`] and hands it to
|
||||
//! [`Hooks::share`](crate::Hooks::share) once a client tick; [`Hooks::lan_closed`](crate::Hooks::lan_closed)
|
||||
//! says the world stopped being open to LAN.
|
||||
//!
|
||||
//! Only the window's own player calls them (a splitscreen guest can't share P1's world, and
|
||||
//! its leaving doesn't close it), never the integrated server's thread or a dedicated server.
|
||||
|
||||
/// How [`ShareView::open_lan`] opens the world, as Open to LAN's screen does. The default is
|
||||
/// what `ShareToLanScreen` offers: no commands for guests, the world's game mode forced on them.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
#[non_exhaustive]
|
||||
pub struct LanOptions {
|
||||
/// Guests may run commands (`ShareToLanScreen`'s "Allow Cheats").
|
||||
pub guest_commands: bool,
|
||||
/// Guests play in the world's game mode.
|
||||
pub force_game_mode: bool,
|
||||
}
|
||||
|
||||
impl Default for LanOptions {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
guest_commands: false,
|
||||
force_game_mode: true,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The window's world, as a crate sharing it sees it. Nothing here allocates per call unless a
|
||||
/// method says so.
|
||||
pub trait ShareView {
|
||||
/// A singleplayer world runs on this window's server thread, so it can be shared. False on
|
||||
/// the title screen and on another server.
|
||||
fn can_share(&self) -> bool;
|
||||
/// The port the world is open to LAN on, if it is.
|
||||
fn lan_port(&self) -> Option<u16>;
|
||||
/// Opens the world to LAN on a free port, as Open to LAN does; the port, or why not.
|
||||
fn open_lan(&mut self, options: LanOptions) -> Result<u16, String>;
|
||||
/// Closes the world to LAN, saying so in chat as Open to LAN's stop does.
|
||||
fn close_lan(&mut self);
|
||||
/// The names of the other players in the world (not this one's). For a screen or a command,
|
||||
/// not every tick.
|
||||
fn players(&self, each: &mut dyn FnMut(&str));
|
||||
/// A line in this player's chat, as the game's own system messages; `text` is already in
|
||||
/// the player's language ([`ShareView::tr`]).
|
||||
fn chat(&mut self, text: &str);
|
||||
/// Puts `text` on the system clipboard.
|
||||
fn copy(&mut self, text: &str);
|
||||
/// Opens `url` in the system's browser.
|
||||
fn open_url(&mut self, url: &str);
|
||||
/// The translation of `key` in the player's language, if the game has one.
|
||||
fn tr(&self, key: &str) -> Option<String>;
|
||||
}
|
||||
@@ -8,16 +8,26 @@
|
||||
//! crate.
|
||||
#![forbid(unsafe_code)]
|
||||
|
||||
use horizon_platform::hooks::{self, Hooks};
|
||||
use horizon_platform::{
|
||||
hooks::{self, Hooks},
|
||||
pages::{self, Page},
|
||||
};
|
||||
|
||||
/// Every Horizon part in the game, in the order its hooks run.
|
||||
const PARTS: [&dyn Hooks; 2] = [
|
||||
// The anonymous "I'm playing" ping.
|
||||
&horizon_heartbeat::HOOKS,
|
||||
// Play with Friends: `friend:` addresses join through its tunnel.
|
||||
// Play with Friends: `friend:` addresses join through its tunnel, and the open world is
|
||||
// shared behind an invite code.
|
||||
&horizon_friends::game::HOOKS,
|
||||
];
|
||||
|
||||
/// The pages Horizon's parts add to the game's menus.
|
||||
const PAGES: [&dyn Page; 1] = [
|
||||
// Play with Friends, what the pause menu's Friends icon opens.
|
||||
&horizon_friends::game::PAGE,
|
||||
];
|
||||
|
||||
/// 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() {
|
||||
@@ -27,4 +37,9 @@ pub fn register() {
|
||||
log::warn!("Horizon: an in-game part did not register: {e}");
|
||||
}
|
||||
}
|
||||
for page in PAGES {
|
||||
if let Err(e) = pages::register(page) {
|
||||
log::warn!("Horizon: a page did not register: {e}");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -46,7 +46,7 @@ pub use horizon_server_files as server_files;
|
||||
/// [`hooks::register`] it before the game starts (docs/layers.md, "How a Horizon crate plugs
|
||||
/// in"). The engine's dispatch side is not part of the API.
|
||||
pub mod hooks {
|
||||
pub use horizon_hooks::{Hooks, Started, TooLate, register, tunnel};
|
||||
pub use horizon_hooks::{Hooks, Started, TooLate, register, share, tr, tunnel};
|
||||
}
|
||||
|
||||
/// The settings store (`horizon_hooks::settings`): a crate's own `key:value` lines in the game
|
||||
|
||||
@@ -60,7 +60,7 @@ command above to inspect the selection rather than relying on historical counts.
|
||||
| 31 | Particles and client effects (46) | `R/particles.rs`, `G/render_tick.rs`, `G/level_particles.rs`, `L/animate_tick.rs` | particle_providers, block_particles, entity_particles, emitter_particles, marker_particles |
|
||||
| 32 | Bosses and the End (45) | `G/dragon.rs`, `G/wither.rs`, `G/end.rs` | server_dragon, forced_end, end_podium, end_gateway |
|
||||
| 33 | Protocol and packets (44) | `crates/horizon-protocol/src/` (`packets/play.rs`, `entity_data.rs`, `connection.rs`) | dedicated_server, online_mode, secure_chat; `cargo test -p horizon-protocol` |
|
||||
| 34 | Multiplayer and LAN screens, friends (44) | `L/multiplayer.rs`, `G/lan_screen.rs`, `G/friends.rs` + `G/friends/` (screen, tunnel), `crates/horizon-friends/`, `crates/horizon-relay/` | lan, world_options; `python3 tools/test_friends.py` |
|
||||
| 34 | Multiplayer and LAN screens, friends (44) | `L/multiplayer.rs`, `G/lan_screen.rs`, `G/page_screen.rs` (its screen), `crates/horizon-friends/` (`src/game.rs` joining, `src/game/share.rs` hosting), `crates/horizon-relay/` | lan, world_options; `python3 tools/test_friends.py` |
|
||||
| 35 | Dispensers (43) | `G/dispenser.rs`, `G/dispense_fixture.rs` (the harness side) | dispenser |
|
||||
| 36 | Audio and music (38) | `R/audio.rs`, `R/sound_engine.rs`, `R/sound_events.rs`, `L/music.rs`, `G/music.rs`, `L/ambience.rs`, `G/subtitle_hud.rs` | sound_engine, sound_event, music, situational_music, ambient_sound, subtitle |
|
||||
| 37 | Integrated server and levels (34) | `G/integrated.rs`, `G/levels.rs`, `G/server_travel.rs` | server_dimension_players, `lan_e2e` (server-thread by default; portals, levels, distance, lava) |
|
||||
|
||||
+1
-1
@@ -25,7 +25,7 @@ What changes in that build:
|
||||
| The official server added to the server list, the withdrawn partner server taken out | `official_servers.rs` | the module and its call at startup (`game.rs`, `fn run`) |
|
||||
| Horizon Settings (Extras, PvP & HUD, Splitscreen, Studio, AI Agent, Discord) and Mods (with the registry browser) | `options_ui.rs` (`Page::Horizon` and below), `extras_ui.rs`, `extras.rs`, `pvp.rs`, `game/extras.rs`, `game/tweaks.rs`, `extras_hud.rs` | the Options screen shows vanilla's greyed Telemetry Data instead (`options_ui::HORIZON_EXTRAS`, set in `entry.rs`) |
|
||||
| Discord Rich Presence | `discord_presence.rs`, `game/discord.rs` | the `discord` feature, which `horizon-extras` turns on |
|
||||
| Play with Friends | hosting: `game/friends.rs`, `game/friends/screen.rs`; joining: crate `horizon-friends` (`game`) | hosting: the `friends` feature (`game/friends_off.rs` stands in); joining: only the official executable registers it ([layers.md](layers.md#how-a-horizon-crate-plugs-in)) |
|
||||
| Play with Friends | crate `horizon-friends` (`game`): hosting in `src/game/share.rs` (its page, drawn by the engine's `game/page_screen.rs`), joining in `src/game.rs` | 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`).
|
||||
|
||||
+10
-4
@@ -288,7 +288,10 @@ build pays nothing.
|
||||
|
||||
The hooks today: `started` (once, when someone plays: not a dedicated server, a benchmark or an automated run),
|
||||
`published` (the world opened to or closed from LAN or friends, on change), `shutdown` (the window closed, the
|
||||
world saved) and `tunnel` (the resolver of `friend:` addresses, Play with Friends'). **A hook you need and don't find:** add a method with an empty default to `Hooks` and a function in
|
||||
world saved), `tunnel` (the resolver of `friend:` addresses, Play with Friends'), `share` (each client tick, the window's world
|
||||
through `share::ShareView`: open it to LAN, its players, chat, the clipboard) and `lan_closed` (it stopped being open
|
||||
to LAN), `control_commands` and `control` (the `horizon-ctl` commands a crate answers in the game, with that view).
|
||||
**A hook you need and don't find:** add a method with an empty default to `Hooks` and a function in
|
||||
`horizon_hooks::engine` that the engine calls where vanilla does the matching thing; give it plain data or a type
|
||||
the engine owns, never one from above. A hook that needs engine types the platform doesn't expose yet (a draw
|
||||
context for HUD and chat overlays, a settings page) comes with that type's platform API (M6). An extension point
|
||||
@@ -364,7 +367,7 @@ pub trait Store: Sync {
|
||||
A page lives in the options screen's shell, and its entry sits on one of the game's menus.
|
||||
|
||||
```rust
|
||||
pub enum Place { HorizonSettings, Title, Pause }
|
||||
pub enum Place { HorizonSettings, Title, Pause, Extras, PauseFriends }
|
||||
pub trait Page: Sync {
|
||||
fn id(&self) -> &'static str; // stable, for horizon-ctl and the page stack
|
||||
fn title(&self) -> &'static str; // translation key
|
||||
@@ -392,6 +395,9 @@ pub enum Act { Press, PressBack, Slide(f32), Bind(String) }
|
||||
button on the title screen or the pause menu that opens the page. These buttons exist only when something
|
||||
registered, so the vanilla menus keep vanilla's look. The page is drawn with the same widgets, tooltips,
|
||||
narration, controller focus and `horizon-ctl` ids as the engine's pages.
|
||||
- `PauseFriends`: what the pause menu's vanilla Friends icon opens. The icon is active while a page is registered
|
||||
there and the world can be shared; the page is drawn as its own screen over the paused world (title, rows down the
|
||||
middle, footer buttons beside Done), the way Play with Friends' screen always looked.
|
||||
- `Button` and `Text` are built with `Row::button` and `Row::text` and their options: `.active(false)` greys a
|
||||
button, `.half()` puts two side by side, `.footer()` sets one beside Done on a page drawn as its own screen,
|
||||
`.quiet()` greys text, `.groups()` breaks it only between `-`-separated groups (an invite code). In the options
|
||||
@@ -477,8 +483,8 @@ All three run in CI's verify job and in `tools/pr_ready.py`.
|
||||
|
||||
| Crate | Uses, against the rule |
|
||||
|---|---|
|
||||
| `horizon-client` | platform: `horizon-anticheat`, `-chat-filter`, `-dualsense`, `-hdr`, `-mod-api-gen`, `-mods`, `-optifine`, `-shader-api`, `-shaders`, `-theme`; Horizon: `horizon-bench`, `-cosmetics`, `-friends`, `-telemetry` |
|
||||
| `horizon-client-lib` | the same, plus `horizon-mcp`, minus `horizon-mod-api-gen` and `horizon-friends` (left through `Hooks::tunnel`) |
|
||||
| `horizon-client` | platform: `horizon-anticheat`, `-chat-filter`, `-dualsense`, `-hdr`, `-mod-api-gen`, `-mods`, `-optifine`, `-shader-api`, `-shaders`, `-theme`; Horizon: `horizon-bench`, `-cosmetics`, `-telemetry` (`horizon-friends` left through `Hooks::tunnel`, `share`, `control` and its page, #14167) |
|
||||
| `horizon-client-lib` | the same, plus `horizon-mcp`, minus `horizon-mod-api-gen` |
|
||||
| `horizon-client-render` | platform: `horizon-hdr`, `-mod-api-gen`, `-optifine`, `-polytone`, `-shader-api`, `-shaders`, `-theme`; Horizon: `horizon-bench`, `-cosmetics`, `-telemetry` |
|
||||
| `horizon-client-input` | platform: `horizon-dualsense`, `horizon-theme` |
|
||||
|
||||
|
||||
@@ -26,7 +26,7 @@ that finds the port takes the session.
|
||||
## How it works
|
||||
|
||||
**Hosting.** The pause menu's Friends button (vanilla's icon, beside Player Reporting) opens **Play
|
||||
with Friends** (`game/friends/screen.rs`); its **Start Sharing** opens the world to LAN if it isn't (Open to
|
||||
with Friends** (horizon-friends' page at `Place::PauseFriends`, `src/game/share.rs`, drawn by the engine's `game/page_screen.rs`); its **Start Sharing** opens the world to LAN if it isn't (Open to
|
||||
LAN's defaults), then the host's game, on its own:
|
||||
|
||||
1. listens on its **direct port**, TCP 25580 by default (the same port every time, so a port forwarded
|
||||
@@ -54,9 +54,12 @@ router that opened the port, or with a forwarded port, that's it: no server took
|
||||
reads codes and opens the tunnel, which the join (`net.rs`) then reaches on a loopback port like any server.
|
||||
horizon-friends' `game` side offers it, and the official executable (`horizon-official`) registers it;
|
||||
horizon-client's own executable has none, so there a `friend:` address is refused. Hosting needs no hook: the
|
||||
host end connects to the world's LAN port over loopback, so tunnelled guests are LAN guests. It still lives in
|
||||
horizon-client (`game/friends.rs`, `game/friends/screen.rs`, the `friends` feature, which comes with
|
||||
`horizon-extras`) until the pause menu and `horizon-ctl` offer entries to crates above the engine.
|
||||
host end connects to the world's LAN port over loopback, so tunnelled guests are LAN guests. Hosting sees the
|
||||
game only through `horizon_hooks`: `Hooks::share` each client tick with a `share::ShareView` (open the world to
|
||||
LAN, its players, chat, the clipboard, a link), `Hooks::lan_closed`, its page (`pages::Place::PauseFriends`, what
|
||||
the pause menu's Friends icon opens; a press runs on the next tick) and `tr`. It keeps what it shares in its own
|
||||
statics, and answers `horizon-ctl friends` through `Hooks::control`. Both sides are horizon-friends' `game`
|
||||
feature, which only the official executable registers: horizon-client's own executable shares nothing (#14167).
|
||||
|
||||
**Codes.** Crockford base32 (`0-9 A-Z` without `I L O U`; input ignores case, spaces and dashes and reads
|
||||
`O` as `0`, `I`/`L` as `1`) in groups of five, with a 16-bit checksum so a typo is caught before
|
||||
|
||||
+1
-1
@@ -427,7 +427,7 @@ 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
|
||||
controllers, no Discord presence: the `discord` feature; nor Play with Friends 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
|
||||
|
||||
@@ -172,6 +172,9 @@ class PrReadyTests(unittest.TestCase):
|
||||
self.assertEqual(pr_ready.mod_features(["horizon-client"]), "")
|
||||
self.assertEqual(pr_ready.mod_features(["horizon-mods"]),
|
||||
" --features horizon-mods/index")
|
||||
# Play with Friends in the game is horizon-friends' `game` side, which only horizon-official turns on.
|
||||
self.assertEqual(pr_ready.mod_features(["horizon-friends", "horizon-mods"]),
|
||||
" --features horizon-mods/index,horizon-friends/game")
|
||||
self.assertEqual(pr_ready.mod_features(None), MODS)
|
||||
|
||||
def test_mod_api_data_inputs_build_and_test_the_consuming_crates(self):
|
||||
|
||||
@@ -85,7 +85,6 @@
|
||||
"horizon-client -> horizon-chat-filter",
|
||||
"horizon-client -> horizon-cosmetics",
|
||||
"horizon-client -> horizon-dualsense",
|
||||
"horizon-client -> horizon-friends",
|
||||
"horizon-client -> horizon-hdr",
|
||||
"horizon-client -> horizon-mod-api-gen",
|
||||
"horizon-client -> horizon-mods",
|
||||
|
||||
+8
-2
@@ -335,8 +335,14 @@ def mark_old_rust(steps, warning):
|
||||
|
||||
def mod_features(crates):
|
||||
"""The features CI's workspace-wide test run gives these crates. Cargo unifies features over the packages it
|
||||
builds, so a crate tested alone needs the ones the workspace would have turned on for it: horizon-mods' index."""
|
||||
return " --features horizon-mods/index" if crates is not None and "horizon-mods" in crates else ""
|
||||
builds, so a crate tested alone needs the ones the workspace would have turned on for it: horizon-mods' index,
|
||||
horizon-friends' game."""
|
||||
if crates is None:
|
||||
return ""
|
||||
# horizon-official turns on horizon-friends' `game` side (Play with Friends in the game).
|
||||
wanted = {"horizon-mods": "horizon-mods/index", "horizon-friends": "horizon-friends/game"}
|
||||
features = [f for crate, f in wanted.items() if crate in crates]
|
||||
return f" --features {','.join(features)}" if features else ""
|
||||
|
||||
|
||||
# What horizon-client's full content-root tests load (mod_content_tests.rs). They take minutes on prepared data, so
|
||||
|
||||
Reference in new issue
Block a user