Compare commits

...
Author SHA1 Message Date
Horizon agent ffaaa63836 Merge remote-tracking branch 'origin/main' into tmp/share-view-merge
# Conflicts:
#	crates/horizon-client/src/entry.rs
#	crates/horizon-hooks/src/lib.rs
#	crates/horizon-platform/src/lib.rs
2026-11-09 23:45:52 +01:00
Horizon agentandClaude Opus 5.5 2719b22258 Friends reads its code while the screen is drawn, as before
The invite code and the host's events are polled while the Friends screen
is drawn or horizon-ctl friends status asks, as on main: Hooks::share stays
dispatched for the screen's page (#14167 step 2a), which is its first user.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 20:50:04 +01:00
Horizon agentandClaude Opus 5.5 3da374ef18 Play with Friends hosts through Hooks::share and Hooks::lan_closed (spec #14167 step 1)
The engine shows the window's world to crates that share it past the LAN
through horizon_hooks::share::ShareView, ticks them next to social::tick()
(also while paused) and tells them when the world closes to LAN. Only the
window's own player dispatches: never the serving Game or a splitscreen guest.
game/friends.rs keeps its logic but goes through the view, its state in its
own static instead of a Game field, ready to move to horizon-friends.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 20:40:22 +01:00
12 changed files with 578 additions and 227 deletions

No files matched your search

+3
View File
@@ -24,6 +24,9 @@ pub fn main_with(mods: crate::mod_api::Mods) {
horizon_client::telemetry::mark_start();
// Opt-in telemetry keeps each crash report (docs/telemetry.md), whatever the mode.
crash::on_report(horizon_client::telemetry::keep_crash);
// Play with Friends' host side, until it moves to horizon-friends (spec #14167).
#[cfg(feature = "friends")]
game::register_friends();
// Crates above the engine registered their hooks before this (horizon-official's `main`),
// then the Horizon parts still in this crate; none registers after this point.
game::player_hooks::register();
+4 -6
View File
@@ -422,6 +422,7 @@ mod server_links;
mod server_packs;
mod server_travel;
mod shader_packs;
mod share_view;
mod sheep_dye;
mod shelf;
mod shield;
@@ -598,6 +599,8 @@ use resource_loader::{
EntityModels, GameCpuResources, Loaded, LoaderMsg, PendingWorldEntry, WorldRestLoader,
run_loading, write_report,
};
#[cfg(feature = "friends")]
pub(crate) use share_view::register_friends;
use std::{
cell::RefCell,
collections::{HashMap, HashSet},
@@ -2894,9 +2897,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>,
@@ -5728,7 +5728,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();
@@ -11260,6 +11259,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 {
@@ -12013,8 +12013,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(),
+368 -209
View File
@@ -7,10 +7,21 @@
//! 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};
//!
//! It sees the game only through `horizon_hooks::share` (the engine's `ShareView`) and
//! `Hooks::lan_closed`, and keeps what it shares in its own static,
//! so it can leave the engine for horizon-friends (spec #14167). The screen and the
//! `horizon-ctl friends` command below still reach it from `Game` until then.
use super::Game;
use horizon_client::{Result, hud::HudBatch};
use horizon_friends::{Host, HostConfig, HostEvent, PathMode, host::DEFAULT_PORT, reach, stun};
use horizon_hooks::{
Hooks,
share::{LanOptions, ShareView},
};
use horizon_protocol::{GAME_VERSION, PROTOCOL_VERSION};
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::{Mutex, MutexGuard, PoisonError};
use std::time::{Duration, Instant};
mod screen;
@@ -18,7 +29,7 @@ mod screen;
use screen::{self as friends_screen, Action, FriendsView};
pub use screen::{TITLE, title};
pub(super) struct Sharing {
struct Sharing {
host: Host,
/// Sharing opened the world to LAN, so stopping closes it again.
opened_lan: bool,
@@ -31,12 +42,52 @@ pub(super) struct Sharing {
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);
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
}
/// Registered by the engine's entry while this module is part of it.
pub struct Friends;
pub static HOOKS: Friends = Friends;
/// No `Hooks::share` yet: the code and sessions are read while the screen is drawn or
/// `horizon-ctl friends status` asks, as before. The screen's page uses it (#14167 step 2a).
impl Hooks for Friends {
/// 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();
}
}
}
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",
];
/// A string in the player's language, or the English one.
fn tr(world: &dyn ShareView, key: &str, english: &str) -> String {
world.tr(key).unwrap_or_else(|| english.to_owned())
}
/// `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() {
@@ -53,101 +104,99 @@ 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());
/// 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: 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),
},
});
let host = match host {
Ok(h) => h,
Err(e) => {
if opened_lan {
world.close_lan();
}
};
self.friends = Some(Sharing {
host,
opened_lan,
code: None,
wrong: 0,
notice: None,
copied: None,
paths: (0, 0),
});
Ok(())
}
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.
pub(super) fn friends_stop(&mut self) {
let Some(mut sharing) = self.friends.take() else {
/// 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(
world,
"horizon.friends.stopped",
"Stopped sharing the invite code.",
);
world.chat(&text);
}
}
/// New codes and sessions, while the screen is drawn and when `friends status` asks.
fn tick(world: &mut dyn ShareView) {
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;
};
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(
sharing.notice = Some(tr(
world,
"horizon.friends.short_code_changed",
"Someone kept trying a wrong short code, so it changed.",
));
@@ -158,7 +207,8 @@ impl Game<'_> {
}
HostEvent::WrongCode { count } => {
sharing.wrong = count;
sharing.notice = Some(lang::get_or(
sharing.notice = Some(tr(
world,
"horizon.friends.wrong_code",
"Someone tried a wrong code.",
));
@@ -167,7 +217,8 @@ impl Game<'_> {
HostEvent::Connected { direct: false, .. } => sharing.paths.1 += 1,
HostEvent::Disconnected { .. } => {}
HostEvent::RelayStopped => {
let text = lang::get_or(
let text = tr(
world,
"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.",
);
@@ -176,164 +227,272 @@ impl Game<'_> {
}
}
}
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.",
)));
}
}
if let Some(text) = stopped {
world.chat(&text);
}
if announce {
let text = tr(
world,
"horizon.friends.code_ready",
"Your invite code is ready: Game Menu, Play with Friends. Copy it and send it to your friends.",
);
world.chat(&text);
}
}
pub(super) fn friends_copy(&mut self) {
let Some(code) = self.friends.as_ref().and_then(|s| s.code.clone()) else {
return;
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.
fn status(world: &dyn ShareView) -> FriendsView {
// Another server, or a splitscreen guest in P1's world: nothing to share.
if !world.can_share() {
return FriendsView::default();
}
let guard = sharing();
let Some(s) = guard.as_ref() else {
return FriendsView {
shown: true,
..FriendsView::default()
};
use horizon_client::clipboard::Clipboard;
self.clipboard.set(&code);
if let Some(s) = self.friends.as_mut() {
s.copied = Some(Instant::now());
}
};
let mut guests = Vec::new();
world.players(&mut |name| guests.push(name.to_owned()));
let state = s.host.state();
let mut status = if s.code.is_none() {
tr(
world,
"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 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") => {
tick(world);
let view = status(world);
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()),
}
}
impl Game<'_> {
/// 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();
let world = &mut self.share_view();
tick(world);
let view = status(world);
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 view = status(&self.share_view());
let Some(action) =
friends_screen::click(&self.gui.atlas, self.gui_size(), self.cursor, &view)
else {
return;
};
self.audio.play_button_click();
if action == Action::Done {
self.screen = super::Screen::Paused;
return;
}
let world = &mut self.share_view();
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),
));
if let Err(e) = start(world) {
let text = tr(
world,
"horizon.friends.share_failed",
"Couldn't share the world: %s",
)
.replacen("%s", &e, 1);
world.chat(&text);
}
}
Action::Copy => self.friends_copy(),
Action::Stop => self.friends_stop(),
// The system browser, as the folder buttons open their folders.
Action::Copy => copy(world),
Action::Stop => stop(world),
Action::Help => {
if let Some(url) = &view.help {
super::packs::open_uri(url);
world.open_url(url);
}
}
Action::Done => self.screen = super::Screen::Paused,
Action::Done => {}
}
}
/// 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)),
}
self.can_share()
}
/// `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() })))
Ok(Some(control(
words.get(1).copied(),
&mut self.share_view(),
)?))
}
}
#[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());
}
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()),
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);
assert!(!status(&guest).shown);
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 show once sharing does.
let view = status(&world);
assert!(view.shown && !view.sharing && view.guests.is_empty());
assert!(!ON.load(Ordering::Relaxed));
}
}
@@ -13,10 +13,6 @@ pub fn title() -> String {
}
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;
+2 -2
View File
@@ -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;
+1 -1
View File
@@ -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) => {
@@ -0,0 +1,115 @@
//! 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
}
/// Play with Friends' host side (game/friends.rs) hears the hooks, until it moves to
/// horizon-friends (spec #14167). Before the hooks are sealed.
#[cfg(feature = "friends")]
pub(crate) fn register_friends() {
if horizon_hooks::register(&super::friends::HOOKS).is_err() {
log::warn!("Play with Friends registered after the game started: sharing is off");
}
}
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();
}
}
/// 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())
}
}
impl<'g> Game<'g> {
/// The view of this window's world, for the engine's own Friends screen and command until
/// they move to the crate.
#[cfg(feature = "friends")]
pub(super) fn share_view(&mut self) -> World<'_, 'g> {
World(self)
}
}
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))
}
}
+23
View File
@@ -22,6 +22,7 @@ pub mod pages;
pub mod players;
mod registry;
pub mod settings;
pub mod share;
pub mod tunnel;
use registry::Registry;
@@ -88,6 +89,14 @@ pub trait Hooks: Sync {
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) {}
/// A game window opened: once, before the first frame, for every run that has a window,
/// automated ones included ([`Client::automated`]); never for a dedicated server.
fn client_started(&self, _client: &Client) {}
@@ -278,6 +287,20 @@ 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();
}
}
/// Whether any hook is registered: without one the engine builds no view to hand them.
pub fn any() -> bool {
!all().is_empty()
+53
View File
@@ -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>;
}
+1 -1
View File
@@ -56,7 +56,7 @@ pub mod hooks {
};
pub use horizon_hooks::{
Activity, Client, Dimension, GameMode, Hooks, Play, Started, TooLate, register, seat,
tunnel,
share, tunnel,
};
}
+3 -1
View File
@@ -287,7 +287,9 @@ 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). **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
+5 -3
View File
@@ -54,9 +54,11 @@ 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::share::ShareView` (open the world to LAN, its players, chat, the clipboard) and
`Hooks::lan_closed`, and keeps what it shares in its own static. 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 (#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