mirror of
https://github.com/horizon-rust/horizon.git
synced 2026-11-10 10:31:29 -10:00
Compare commits
19
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3548e4d802 | ||
|
|
cb3983803e | ||
|
|
ee60188af7 | ||
|
|
f518ef4c07 | ||
|
|
7311870047 | ||
|
|
2719b22258 | ||
|
|
12578352dc | ||
|
|
11ebc3f199 | ||
|
|
3da374ef18 | ||
|
|
3f1a50ccb2 | ||
|
|
75071c8b9b | ||
|
|
63a6a92bef | ||
|
|
cf93d0dd3a | ||
|
|
e39ec11e59 | ||
|
|
9fd16efb45 | ||
|
|
93ecc8d0c1 | ||
|
|
2e45a0750d | ||
|
|
8ad2c758ad | ||
|
|
cbba6ead3a |
No files matched your search
@@ -76,6 +76,10 @@ wgpu = { version = "30.0.1", default-features = false, features = ["gles"] }
|
||||
audio = ["dep:cpal"]
|
||||
# Helpers horizon-client's tests build their fixtures with.
|
||||
test-support = []
|
||||
# horizon-client's `horizon-extras`: asking the device for hardware ray tracing on request
|
||||
# (`gfx/ray_query.rs`, `HORIZON_RAY_TRACING=1`). Without it the device is requested as without
|
||||
# the variable.
|
||||
horizon-extras = []
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
|
||||
@@ -382,8 +382,8 @@ impl RenderAssets {
|
||||
};
|
||||
// After the connected tiles: a tile identical to a block shares its layer, and the
|
||||
// overlays skip shared layers.
|
||||
if draws {
|
||||
emissive = crate::native_emissive::load_optifine(root, &mut sprites);
|
||||
if draws && let Some(suffix) = crate::pack_hooks::formats().emissive_suffix(root) {
|
||||
emissive = crate::native_emissive::load_overlays(root, &suffix, &mut sprites);
|
||||
}
|
||||
sprites.save_disk_cache();
|
||||
if draws {
|
||||
|
||||
@@ -233,11 +233,11 @@ pub fn texture_path(root: &Path, texture: &str, directory: &str) -> PathBuf {
|
||||
}
|
||||
let (namespace, path) = texture.split_once(':').unwrap_or(("minecraft", texture));
|
||||
let rel = format!("assets/{namespace}/{directory}/{path}.png");
|
||||
root.join(if horizon_optifine::paths::valid(&rel) {
|
||||
&rel
|
||||
} else {
|
||||
NOWHERE
|
||||
})
|
||||
// No `.`, `..` or empty segment, and no `\` (a separator on Windows, where `..\..` climbs).
|
||||
let stays = rel
|
||||
.split('/')
|
||||
.all(|segment| !matches!(segment, "" | "." | "..") && !segment.contains('\\'));
|
||||
root.join(if stays { &rel } else { NOWHERE })
|
||||
}
|
||||
|
||||
/// `SpriteSourceList.list`: every sprite id of the atlas and its origin.
|
||||
|
||||
@@ -112,7 +112,11 @@ fn add_sections(groups: &mut Vec<Group<ExtraRow>>) {
|
||||
};
|
||||
Row::new(id, lang::get(label), value)
|
||||
}
|
||||
added::Row::Button { label } => Row::new(id, lang::get(label), Value::Press),
|
||||
added::Row::Button {
|
||||
label,
|
||||
active: true,
|
||||
..
|
||||
} => Row::new(id, lang::get(label), Value::Press),
|
||||
added::Row::Key { label, key } => {
|
||||
let key = crate::keybinds::display_name(&key).plain();
|
||||
Row::new(id, lang::get(label), Value::Read(key))
|
||||
@@ -490,9 +494,7 @@ mod tests {
|
||||
added::Place::HorizonSettings
|
||||
}
|
||||
fn rows(&self, rows: &mut Vec<added::Row>) {
|
||||
rows.push(added::Row::Button {
|
||||
label: "test.elsewhere.button",
|
||||
});
|
||||
rows.push(added::Row::button("test.elsewhere.button"));
|
||||
}
|
||||
fn act(&self, _: usize, _: Act) {}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
//! Hardware ray queries, only on request (#13780, after the #1180 spike): `HORIZON_RAY_TRACING=1`
|
||||
//! at startup asks the device for wgpu's `EXPERIMENTAL_RAY_QUERY` when the GPU has it. Nothing
|
||||
//! draws with it yet. Without the variable, and in a build without `horizon-extras` (a Horizon
|
||||
//! extra, docs/layers.md), the device is opened exactly as before.
|
||||
|
||||
/// The ray query feature.
|
||||
const RAY_QUERY: wgpu::Features = wgpu::Features::EXPERIMENTAL_RAY_QUERY;
|
||||
|
||||
/// Whether the player asked for ray tracing (the device needs the feature from its creation).
|
||||
fn requested() -> bool {
|
||||
asked(std::env::var("HORIZON_RAY_TRACING").ok().as_deref())
|
||||
}
|
||||
|
||||
/// Whether `HORIZON_RAY_TRACING` set to `var` asks for ray tracing: only in a `horizon-extras`
|
||||
/// build.
|
||||
fn asked(var: Option<&str>) -> bool {
|
||||
cfg!(feature = "horizon-extras") && var == Some("1")
|
||||
}
|
||||
|
||||
/// The features to request on `adapter`: ray queries, when asked for and offered.
|
||||
pub(super) fn features(adapter: &wgpu::Adapter) -> wgpu::Features {
|
||||
let features = pick(requested(), adapter.features());
|
||||
if requested() {
|
||||
let info = adapter.get_info();
|
||||
if features.is_empty() {
|
||||
log::warn!(
|
||||
"Horizon renderer: ray tracing asked for, but {} on {:?} has no hardware ray \
|
||||
queries; it stays off",
|
||||
info.name,
|
||||
info.backend
|
||||
);
|
||||
} else {
|
||||
log::info!(
|
||||
"Horizon renderer: ray queries on ({} on {:?})",
|
||||
info.name,
|
||||
info.backend
|
||||
);
|
||||
}
|
||||
}
|
||||
features
|
||||
}
|
||||
|
||||
fn pick(requested: bool, offered: wgpu::Features) -> wgpu::Features {
|
||||
if requested {
|
||||
offered & RAY_QUERY
|
||||
} else {
|
||||
wgpu::Features::empty()
|
||||
}
|
||||
}
|
||||
|
||||
/// The device's opt-in to `features`' experimental ones.
|
||||
pub(super) fn experimental(features: wgpu::Features) -> wgpu::ExperimentalFeatures {
|
||||
if features.contains(RAY_QUERY) {
|
||||
horizon_hdr::experimental_features()
|
||||
} else {
|
||||
wgpu::ExperimentalFeatures::disabled()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn only_an_extras_build_asks_for_ray_tracing() {
|
||||
assert_eq!(asked(Some("1")), cfg!(feature = "horizon-extras"));
|
||||
assert!(!asked(None));
|
||||
assert!(!asked(Some("0")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ray_queries_only_when_asked_and_offered() {
|
||||
let all = wgpu::Features::all();
|
||||
assert_eq!(pick(true, all), RAY_QUERY);
|
||||
assert!(pick(false, all).is_empty());
|
||||
assert!(pick(true, all - RAY_QUERY).is_empty());
|
||||
assert!(!experimental(wgpu::Features::empty()).is_enabled());
|
||||
}
|
||||
}
|
||||
@@ -5,6 +5,9 @@ use super::{
|
||||
Renderer, check_layers, cull, describe_adapter, is_software, oit, startup_step, surface_format,
|
||||
timing,
|
||||
};
|
||||
// Ray tracing on request, which the device needs from its creation (gfx.rs may not grow).
|
||||
#[path = "ray_query.rs"]
|
||||
mod ray_query;
|
||||
use std::sync::Arc;
|
||||
use winit::window::Window;
|
||||
|
||||
@@ -134,6 +137,7 @@ pub(super) async fn open_adapter(
|
||||
let features = timing::GpuTimer::features(adapter)
|
||||
| cull::Cull::features(adapter)
|
||||
| oit::features(adapter)
|
||||
| ray_query::features(adapter)
|
||||
| (adapter.features()
|
||||
& (wgpu::Features::INDIRECT_FIRST_INSTANCE
|
||||
| wgpu::Features::MULTI_DRAW_INDIRECT_COUNT
|
||||
@@ -166,6 +170,7 @@ pub(super) async fn open_adapter(
|
||||
// with the larger blocks a few small buffers kept whole blocks the arena's pages had
|
||||
// left, and over half the video memory reserved was unused (1,344 MiB for 500).
|
||||
memory_hints: wgpu::MemoryHints::MemoryUsage,
|
||||
experimental_features: ray_query::experimental(features),
|
||||
..Default::default()
|
||||
};
|
||||
#[cfg(not(target_os = "macos"))]
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
//! Optional, texture-matched overlays drawn full bright over their base texture in the same
|
||||
//! terrain fragment: native mods' (`<ns>:emissive/...`) and OptiFine's emissive block textures
|
||||
//! (`block/stone_e.png` beside `block/stone.png`). No extra draw calls or world light changes.
|
||||
//! terrain fragment: native mods' (`<ns>:emissive/...`) and a pack's emissive block textures
|
||||
//! (OptiFine's `block/stone_e.png` beside `block/stone.png`, the suffix from the pack formats,
|
||||
//! [`crate::pack_hooks`]). No extra draw calls or world light changes.
|
||||
use crate::sprites::Sprites;
|
||||
use std::{collections::BTreeMap, path::Path};
|
||||
|
||||
@@ -35,14 +36,12 @@ fn tile_file(name: &str) -> Option<&str> {
|
||||
name.strip_suffix(".png")
|
||||
}
|
||||
|
||||
/// OptiFine's emissive overlays of the block sprites already loaded (what the terrain draws;
|
||||
/// items draw from the HUD atlas): for each
|
||||
/// `<ns>:block/<name>`, or connected-texture tile `assets/<ns>/.../<name>.png`, whose
|
||||
/// `<name><suffix>.png` sits beside it, the overlay is loaded too and paired with it, blocks
|
||||
/// first, then tiles, each by name, at most [`MAX`] - [`NATIVE`] (native mods keep their share of
|
||||
/// the table). A pack without such files adds nothing.
|
||||
pub fn load_optifine(root: &Path, sprites: &mut Sprites) -> Vec<(String, String)> {
|
||||
let suffix = horizon_optifine::emissive::suffix(root);
|
||||
/// A pack's emissive overlays of the block sprites already loaded (what the terrain draws;
|
||||
/// items draw from the HUD atlas): for each `<ns>:block/<name>`, or connected-texture tile
|
||||
/// `assets/<ns>/.../<name>.png`, whose `<name><suffix>.png` sits beside it, the overlay is loaded
|
||||
/// too and paired with it, blocks first, then tiles, each by name, at most [`MAX`] - [`NATIVE`]
|
||||
/// (native mods keep their share of the table). A pack without such files adds nothing.
|
||||
pub fn load_overlays(root: &Path, suffix: &str, sprites: &mut Sprites) -> Vec<(String, String)> {
|
||||
// Blocks with identical pixels share a layer (and the shader pairs by layer): a shared
|
||||
// base would glow on every block that shares it, so it gets no overlay.
|
||||
let mut sharing: BTreeMap<u32, usize> = BTreeMap::new();
|
||||
@@ -56,8 +55,8 @@ pub fn load_optifine(root: &Path, sprites: &mut Sprites) -> Vec<(String, String)
|
||||
sharing[layer] == 1
|
||||
&& (name
|
||||
.split_once(':')
|
||||
.is_some_and(|(_, p)| p.starts_with("block/") && !p.ends_with(&suffix))
|
||||
|| tile_file(name).is_some_and(|stem| !stem.ends_with(&suffix)))
|
||||
.is_some_and(|(_, p)| p.starts_with("block/") && !p.ends_with(suffix))
|
||||
|| tile_file(name).is_some_and(|stem| !stem.ends_with(suffix)))
|
||||
})
|
||||
.map(|(name, _)| name.clone())
|
||||
.collect();
|
||||
@@ -110,7 +109,7 @@ pub fn load_optifine(root: &Path, sprites: &mut Sprites) -> Vec<(String, String)
|
||||
pub const CUTOUT: u32 = 1 << 31;
|
||||
|
||||
/// The frame's overlay table: native mods' pairs (at most [`NATIVE`] requested), then the packs'
|
||||
/// OptiFine pairs flagged [`CUTOUT`] (at most [`MAX`] - [`NATIVE`], as [`load_optifine`] loads),
|
||||
/// OptiFine pairs flagged [`CUTOUT`] (at most [`MAX`] - [`NATIVE`], as [`load_overlays`] loads),
|
||||
/// one per base layer with native mods' first, sorted by base layer for the shader's early exit.
|
||||
pub fn table<'a>(
|
||||
layers: &BTreeMap<String, u32>,
|
||||
@@ -204,7 +203,7 @@ mod tests {
|
||||
// Items draw from the HUD atlas, not the terrain: no pair.
|
||||
sprites.insert(&root, "minecraft:item/stick").unwrap();
|
||||
// glowstone isn't loaded: its overlay pairs with nothing.
|
||||
let pairs = load_optifine(&root, &mut sprites);
|
||||
let pairs = load_overlays(&root, "_e", &mut sprites);
|
||||
assert_eq!(
|
||||
pairs,
|
||||
[(
|
||||
@@ -215,9 +214,6 @@ mod tests {
|
||||
assert!(sprites.layers.contains_key("minecraft:block/stone_e"));
|
||||
assert!(!sprites.layers.contains_key("minecraft:block/granite_e"));
|
||||
// The pack's own suffix.
|
||||
let props = root.join("assets/minecraft/optifine/emissive.properties");
|
||||
std::fs::create_dir_all(props.parent().unwrap()).unwrap();
|
||||
std::fs::write(&props, "suffix.emissive=_glow").unwrap();
|
||||
// Connected-texture tiles pair with the overlay beside them.
|
||||
let tiles = root.join("assets/minecraft/optifine/ctm/lamp");
|
||||
png(&tiles.join("0.png"), 230);
|
||||
@@ -234,7 +230,7 @@ mod tests {
|
||||
.insert_file(&root, &format!("assets/minecraft/optifine/{tile}.png"))
|
||||
.unwrap();
|
||||
}
|
||||
let pairs = load_optifine(&root, &mut sprites);
|
||||
let pairs = load_overlays(&root, "_glow", &mut sprites);
|
||||
assert_eq!(
|
||||
pairs,
|
||||
// Blocks first, then tiles.
|
||||
|
||||
@@ -162,10 +162,38 @@ impl OptionsUi {
|
||||
};
|
||||
let mut rows = Vec::new();
|
||||
registered.rows(&mut rows);
|
||||
rows.into_iter()
|
||||
.enumerate()
|
||||
.map(|(r, row)| self.added_row(page, r, row))
|
||||
.collect()
|
||||
let mut out = Vec::new();
|
||||
// The row a half-width button started, waiting for the next to sit beside it.
|
||||
let mut half_open = false;
|
||||
for (r, row) in rows.into_iter().enumerate() {
|
||||
match row {
|
||||
// Wrapped as the engine's own notes; space is for pages drawn as a screen.
|
||||
added::Row::Text { text, .. } => {
|
||||
out.extend(wrap(&text, 64).into_iter().map(Row::Note))
|
||||
}
|
||||
added::Row::Space(_) => continue,
|
||||
added::Row::Button {
|
||||
label,
|
||||
active,
|
||||
half: true,
|
||||
..
|
||||
} => {
|
||||
let mut half = button(0.0, 0.0, 150.0, tr(label), Act::PageRow(page, r));
|
||||
if !active {
|
||||
half = inactive(half);
|
||||
}
|
||||
match out.last_mut() {
|
||||
Some(Row::Buttons(_, second @ None)) if half_open => *second = Some(half),
|
||||
_ => out.push(Row::Buttons(half, None)),
|
||||
}
|
||||
half_open = !half_open;
|
||||
continue;
|
||||
}
|
||||
row => out.push(self.added_row(page, r, row)),
|
||||
}
|
||||
half_open = false;
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
fn added_row(&self, page: usize, r: usize, row: added::Row) -> Row {
|
||||
@@ -177,7 +205,10 @@ impl OptionsUi {
|
||||
added::Row::Toggle { label, on } => wide(generic(label, on_off(on))),
|
||||
added::Row::Cycle { label, value } => wide(generic(label, label_text(value))),
|
||||
added::Row::Slider { .. } => Row::Big(Opt::Page(page, r)),
|
||||
added::Row::Button { label } => wide(tr(label)),
|
||||
added::Row::Button { label, active, .. } => {
|
||||
let wide = button(0.0, 0.0, 310.0, tr(label), act);
|
||||
Row::Buttons(if active { wide } else { inactive(wide) }, None)
|
||||
}
|
||||
added::Row::Key { label, key } => {
|
||||
let mut message = keybinds::display_name(&key);
|
||||
if self.selected_key == Some(PAGE_KEY)
|
||||
@@ -461,4 +492,84 @@ mod tests {
|
||||
Some(tr("test.sample.switch.tooltip").plain())
|
||||
);
|
||||
}
|
||||
|
||||
/// Text, a grey note, space, two half buttons (the second inactive) and a footer button.
|
||||
struct Shaped(Mutex<Vec<usize>>);
|
||||
|
||||
impl added::Page for Shaped {
|
||||
fn id(&self) -> &'static str {
|
||||
"test.shaped"
|
||||
}
|
||||
fn title(&self) -> &'static str {
|
||||
"test.shaped.title"
|
||||
}
|
||||
fn place(&self) -> added::Place {
|
||||
added::Place::HorizonSettings
|
||||
}
|
||||
fn rows(&self, rows: &mut Vec<added::Row>) {
|
||||
rows.push(added::Row::text("ABCDE-FGHIJ").groups());
|
||||
rows.push(added::Row::text("grey").quiet());
|
||||
rows.push(added::Row::Space(6));
|
||||
rows.push(added::Row::button("test.shaped.copy").half());
|
||||
rows.push(added::Row::button("test.shaped.stop").half().active(false));
|
||||
rows.push(added::Row::button("test.shaped.help").footer());
|
||||
}
|
||||
fn act(&self, row: usize, _: added::Act) {
|
||||
self.0.lock().unwrap().push(row);
|
||||
}
|
||||
}
|
||||
|
||||
static SHAPED: Shaped = Shaped(Mutex::new(Vec::new()));
|
||||
static SHAPED_PAGES: [&dyn added::Page; 1] = [&SHAPED];
|
||||
|
||||
#[test]
|
||||
fn half_buttons_share_a_row_and_an_inactive_one_is_never_pressed() {
|
||||
let mut s = Settings::default();
|
||||
TEST_PAGES.set(Some(&SHAPED_PAGES));
|
||||
let ui = OptionsUi::default();
|
||||
let rows = ui.added_rows(0);
|
||||
// The two texts as notes (space shows nothing here), the halves on one row, the
|
||||
// footer button below them (the settings shell has no footer of its own).
|
||||
assert!(matches!(&rows[0], Row::Note(t) if t == "ABCDE-FGHIJ"));
|
||||
assert!(matches!(&rows[1], Row::Note(t) if t == "grey"));
|
||||
let Row::Buttons(copy, Some(stop)) = &rows[2] else {
|
||||
panic!("the halves sit side by side: {:?}", rows.len());
|
||||
};
|
||||
assert_eq!((copy.w, stop.w), (150.0, 150.0));
|
||||
assert!(matches!(&rows[3], Row::Buttons(help, None) if help.w == 310.0));
|
||||
assert_eq!(rows.len(), 4);
|
||||
let mut ui = ui;
|
||||
ui.show(Page::Added(0));
|
||||
press(&mut ui, &mut s, Act::PageRow(0, 3), false);
|
||||
press(&mut ui, &mut s, Act::PageRow(0, 5), false);
|
||||
let (widgets, ..) = ui.layout(&s, 480.0, 240.0);
|
||||
let stop = widgets
|
||||
.iter()
|
||||
.find(|w| {
|
||||
matches!(
|
||||
&w.kind,
|
||||
WKind::Button {
|
||||
act: Act::PageRow(0, 4),
|
||||
..
|
||||
}
|
||||
)
|
||||
})
|
||||
.expect("Stop is drawn");
|
||||
let atlas = super::super::tests::atlas();
|
||||
ui.click(
|
||||
&mut s,
|
||||
&atlas,
|
||||
stop.x + 1.0,
|
||||
stop.y + 1.0,
|
||||
(480.0, 240.0),
|
||||
[480, 240],
|
||||
false,
|
||||
false,
|
||||
);
|
||||
assert_eq!(
|
||||
*SHAPED.0.lock().unwrap(),
|
||||
[3, 5],
|
||||
"the inactive half heard nothing"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -20,6 +20,10 @@ impl PackFormats for Formats {
|
||||
Some(colors::read(root))
|
||||
}
|
||||
|
||||
fn emissive_suffix(&self, root: &Path) -> Option<String> {
|
||||
Some(horizon_optifine::emissive::suffix(root))
|
||||
}
|
||||
|
||||
fn custom_sky(&self, root: &Path) -> Option<Arc<dyn crate::custom_sky::SkyLayers>> {
|
||||
let sky = custom_sky::CustomSky::load(root);
|
||||
(!sky.is_empty()).then(|| Arc::new(sky) as _)
|
||||
|
||||
@@ -15,6 +15,12 @@ pub trait PackFormats: Send + Sync {
|
||||
None
|
||||
}
|
||||
|
||||
/// The name ending of the overlay's emissive textures (`stone_e.png` beside `stone.png`),
|
||||
/// if its format has them.
|
||||
fn emissive_suffix(&self, _root: &Path) -> Option<String> {
|
||||
None
|
||||
}
|
||||
|
||||
/// The sky layers the overlay draws over the vanilla sky, if it has any.
|
||||
fn custom_sky(&self, _root: &Path) -> Option<Arc<dyn crate::custom_sky::SkyLayers>> {
|
||||
None
|
||||
|
||||
@@ -119,10 +119,11 @@ audio = ["horizon-client-render/audio", "horizon-client-lib/audio"]
|
||||
controllers = ["horizon-client-lib/controllers", "dep:gilrs", "dep:horizon-dualsense", "horizon-client-input/controllers"]
|
||||
# Horizon's own extras over the vanilla game, for a fork that wants them out (docs/forking.md):
|
||||
# cosmetics and emotes, the "Horizon player" presence, the official server added to
|
||||
# the server list, the Horizon Settings and Mods pages, Discord and Play with Friends. Players always get them; there is no switch. `grep -rn horizon-extras crates/` lists
|
||||
# the server list, the Horizon Settings and Mods pages, Discord and Play with Friends, and ray
|
||||
# tracing on request (`HORIZON_RAY_TRACING=1`). Players always get them; there is no switch. `grep -rn horizon-extras crates/` lists
|
||||
# every place it gates. Without it (`--no-default-features --features audio,controllers`) the
|
||||
# client is best effort: CI builds only the default.
|
||||
horizon-extras = ["horizon-client-lib/horizon-extras", "discord", "friends"]
|
||||
horizon-extras = ["horizon-client-lib/horizon-extras", "horizon-client-render/horizon-extras", "discord", "friends"]
|
||||
# Online extras a server or a port can leave out; the server build has none of them. "Playing Horizon"
|
||||
# on Discord (game/discord.rs).
|
||||
discord = ["horizon-client-lib/discord"]
|
||||
|
||||
@@ -23,6 +23,13 @@ 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))
|
||||
});
|
||||
// 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`);
|
||||
// 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;
|
||||
@@ -162,15 +164,6 @@ 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 +174,7 @@ mod golems;
|
||||
mod growth;
|
||||
mod hand;
|
||||
mod headless;
|
||||
mod held_items;
|
||||
mod hud_editor;
|
||||
mod hud_room;
|
||||
mod ime;
|
||||
@@ -357,6 +351,7 @@ mod pad;
|
||||
mod pad_actions;
|
||||
mod pad_craft;
|
||||
mod pad_menu;
|
||||
mod page_screen;
|
||||
mod paintings;
|
||||
mod pandas;
|
||||
mod particle_culling;
|
||||
@@ -420,7 +415,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;
|
||||
@@ -597,6 +595,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},
|
||||
@@ -649,9 +649,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 +695,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 +2903,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 +5734,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 +6237,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 +11010,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 +11409,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 +12177,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,5 +1,5 @@
|
||||
//! 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
|
||||
//! 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
|
||||
@@ -7,18 +7,23 @@
|
||||
//! 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` (the `ShareView` its `Hooks::share` tick
|
||||
//! gets, `Hooks::lan_closed`, its page, `tr`) and keeps what it shares in its own statics, so
|
||||
//! it can leave the engine for horizon-friends (spec #14167). `horizon-ctl friends` reaches it
|
||||
//! through `Hooks::control`.
|
||||
use horizon_friends::{Host, HostConfig, HostEvent, PathMode, host::DEFAULT_PORT, reach, stun};
|
||||
use horizon_hooks::{
|
||||
Hooks,
|
||||
pages::{Act, Page, Place, Row},
|
||||
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;
|
||||
|
||||
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 +36,126 @@ 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);
|
||||
|
||||
/// 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
|
||||
}
|
||||
|
||||
/// Registered by the engine's entry while this module is part of it.
|
||||
pub struct Friends;
|
||||
pub static HOOKS: Friends = Friends;
|
||||
|
||||
impl Hooks for Friends {
|
||||
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();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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(key: &str, english: &str) -> String {
|
||||
horizon_hooks::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 +172,98 @@ 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(
|
||||
"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;
|
||||
};
|
||||
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(
|
||||
"horizon.friends.short_code_changed",
|
||||
"Someone kept trying a wrong short code, so it changed.",
|
||||
));
|
||||
@@ -158,7 +274,7 @@ impl Game<'_> {
|
||||
}
|
||||
HostEvent::WrongCode { count } => {
|
||||
sharing.wrong = count;
|
||||
sharing.notice = Some(lang::get_or(
|
||||
sharing.notice = Some(tr(
|
||||
"horizon.friends.wrong_code",
|
||||
"Someone tried a wrong code.",
|
||||
));
|
||||
@@ -167,7 +283,7 @@ impl Game<'_> {
|
||||
HostEvent::Connected { direct: false, .. } => sharing.paths.1 += 1,
|
||||
HostEvent::Disconnected { .. } => {}
|
||||
HostEvent::RelayStopped => {
|
||||
let text = lang::get_or(
|
||||
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.",
|
||||
);
|
||||
@@ -176,164 +292,481 @@ 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.",
|
||||
)));
|
||||
}
|
||||
}
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
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());
|
||||
}
|
||||
/// 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);
|
||||
}
|
||||
}
|
||||
|
||||
/// 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),
|
||||
));
|
||||
}
|
||||
/// 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 => 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,
|
||||
}
|
||||
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),
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether the pause menu offers Play with Friends (a singleplayer world).
|
||||
pub(super) fn friends_shown(&self) -> bool {
|
||||
self.friends_view().shown
|
||||
fn copy(world: &mut dyn ShareView) {
|
||||
if !world.can_share() {
|
||||
return;
|
||||
}
|
||||
|
||||
/// 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)),
|
||||
}
|
||||
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());
|
||||
}
|
||||
}
|
||||
|
||||
/// `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()),
|
||||
/// 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);
|
||||
}
|
||||
}
|
||||
@@ -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,120 @@
|
||||
//! 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()
|
||||
|| horizon_hooks::pages::register(&super::friends::PAGE).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();
|
||||
}
|
||||
}
|
||||
|
||||
/// `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))
|
||||
}
|
||||
}
|
||||
@@ -47,6 +47,15 @@ pub fn open_vulkan_device(
|
||||
Some(unsafe { adapter.create_device_from_hal(open, desc) })
|
||||
}
|
||||
|
||||
/// wgpu's opt-in to its `EXPERIMENTAL_*` features, which the crates that forbid unsafe code
|
||||
/// can't give: hardware ray queries, asked for only when the player turns ray tracing on
|
||||
/// (`gfx/ray_query.rs` in horizon-client-render).
|
||||
pub fn experimental_features() -> wgpu::ExperimentalFeatures {
|
||||
// Safety: wgpu warns that its experimental features may have bugs that reach undefined
|
||||
// behaviour through safe calls. Horizon accepts that only on request, for ray queries.
|
||||
unsafe { wgpu::ExperimentalFeatures::enabled() }
|
||||
}
|
||||
|
||||
/// Tells the compositor, through `VK_EXT_hdr_metadata`, that the swapchain's
|
||||
/// content is BT.709, peaks at `peak` nits and is no darker than `black`. Needs a
|
||||
/// device from `open_vulkan_device` and a configured surface; does nothing otherwise.
|
||||
|
||||
@@ -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`]);
|
||||
@@ -61,13 +67,93 @@ pub enum Row {
|
||||
value: f32,
|
||||
text: Label,
|
||||
},
|
||||
/// A button that does something.
|
||||
Button { label: &'static str },
|
||||
/// A button that does something ([`Row::button`]). An inactive one is drawn greyed and
|
||||
/// never pressed; two half-width ones in a row sit side by side; a footer one sits at the
|
||||
/// bottom beside Done on a page drawn as its own screen, and with the others elsewhere.
|
||||
/// Horizon Settings' Extras shows only active ones.
|
||||
#[non_exhaustive]
|
||||
Button {
|
||||
label: &'static str,
|
||||
active: bool,
|
||||
half: bool,
|
||||
footer: bool,
|
||||
},
|
||||
/// Text shown as it is, wrapped to the page's width ([`Row::text`]): white, or grey when
|
||||
/// `quiet` (secondary); `groups` breaks it only between its `-`-separated groups (a code).
|
||||
#[non_exhaustive]
|
||||
Text {
|
||||
text: String,
|
||||
quiet: bool,
|
||||
groups: bool,
|
||||
},
|
||||
/// Empty space, in GUI pixels, on a page drawn as its own screen; nothing elsewhere.
|
||||
Space(u8),
|
||||
/// A key binding, as Key Binds shows one: `key` is its key name (`key.keyboard.z`,
|
||||
/// `key.mouse.left`). A press waits for the next key or mouse button ([`Act::Bind`]).
|
||||
Key { label: &'static str, key: String },
|
||||
}
|
||||
|
||||
impl Row {
|
||||
/// An active, full-width button in the page's body.
|
||||
pub fn button(label: &'static str) -> Self {
|
||||
Self::Button {
|
||||
label,
|
||||
active: true,
|
||||
half: false,
|
||||
footer: false,
|
||||
}
|
||||
}
|
||||
|
||||
/// White text in the page's body, wrapped at spaces.
|
||||
pub fn text(text: impl Into<String>) -> Self {
|
||||
Self::Text {
|
||||
text: text.into(),
|
||||
quiet: false,
|
||||
groups: false,
|
||||
}
|
||||
}
|
||||
|
||||
/// A button greyed out (`false`): drawn, never pressed.
|
||||
pub fn active(mut self, on: bool) -> Self {
|
||||
if let Self::Button { active, .. } = &mut self {
|
||||
*active = on;
|
||||
}
|
||||
self
|
||||
}
|
||||
|
||||
/// A button half as wide: two in a row sit side by side.
|
||||
pub fn half(mut self) -> Self {
|
||||
if let Self::Button { half, .. } = &mut self {
|
||||
*half = true;
|
||||
}
|
||||
self
|
||||
}
|
||||
|
||||
/// A button in the footer, beside Done, on a page drawn as its own screen.
|
||||
pub fn footer(mut self) -> Self {
|
||||
if let Self::Button { footer, .. } = &mut self {
|
||||
*footer = true;
|
||||
}
|
||||
self
|
||||
}
|
||||
|
||||
/// Text in grey: secondary.
|
||||
pub fn quiet(mut self) -> Self {
|
||||
if let Self::Text { quiet, .. } = &mut self {
|
||||
*quiet = true;
|
||||
}
|
||||
self
|
||||
}
|
||||
|
||||
/// Text broken only between its `-`-separated groups, never inside one (a code).
|
||||
pub fn groups(mut self) -> Self {
|
||||
if let Self::Text { groups, .. } = &mut self {
|
||||
*groups = true;
|
||||
}
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
/// A value shown on a row: a translation key, or text shown as it is (a number, a name).
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub enum Label {
|
||||
|
||||
@@ -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>;
|
||||
}
|
||||
@@ -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
|
||||
|
||||
+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 | hosting: `game/friends.rs` (its page, drawn by `game/page_screen.rs`); joining: crate `horizon-friends` (`game`) | hosting: the `friends` feature (`game/friends_off.rs` stands in); joining: only the official executable registers it ([layers.md](layers.md#how-a-horizon-crate-plugs-in)) |
|
||||
| The anonymous "I'm playing" ping | crate `horizon-heartbeat` | not in horizon-client at all: only the official executable (`horizon-official`) registers it ([layers.md](layers.md#how-a-horizon-crate-plugs-in)) |
|
||||
|
||||
Credits & Attribution then says where the official build is (`horizon.plain.official`).
|
||||
|
||||
+15
-3
@@ -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
|
||||
@@ -379,8 +382,10 @@ pub enum Row {
|
||||
Toggle { label: &'static str, on: bool },
|
||||
Cycle { label: &'static str, value: Label },
|
||||
Slider { label: &'static str, value: f32, text: Label }, // value in 0..=1
|
||||
Button { label: &'static str },
|
||||
Button { label: &'static str, active: bool, half: bool, footer: bool }, // Row::button(..).half()...
|
||||
Key { label: &'static str, key: String }, // a binding: `key.keyboard.z`
|
||||
Text { text: String, quiet: bool, groups: bool }, // Row::text(..): white or grey, wrapped
|
||||
Space(u8), // GUI pixels, on a page drawn as a screen
|
||||
}
|
||||
pub enum Label { Key(&'static str), Value(String) }
|
||||
pub enum Act { Press, PressBack, Slide(f32), Bind(String) }
|
||||
@@ -390,6 +395,13 @@ 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
|
||||
shell, text is notes and space is nothing.
|
||||
- Rows are rebuilt when the page is drawn or acted on, like the engine's own pages: no cost while no page is
|
||||
open.
|
||||
- The engine keeps native mods' pages (`Page::NativeMod`) as they are. Both kinds sit in one module that
|
||||
|
||||
@@ -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** (game/friends.rs's page at `Place::PauseFriends`, drawn by `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,13 @@ 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. It still lives in horizon-client (`game/friends.rs`, the `friends` feature, which comes with
|
||||
`horizon-extras`), and answers `horizon-ctl friends` through `Hooks::control`, until it moves to horizon-friends
|
||||
(#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
|
||||
|
||||
Reference in new issue
Block a user