Compare commits

...
Author SHA1 Message Date
Horizon agentandClaude Opus 5.5 3646df3e57 Join a Friend is horizon-friends' page at the title screen's Friends icon (spec #14167 step 4)
The engine's multiplayer::Screen::Friend goes. horizon-friends' game/join.rs is a
Place::Title page (code and relay fields up to 256 characters, Join active while the
resolver reads the code, Cancel), drawn by the engine's form exactly where the old
screen drew it. Join keeps the relay in horizon.txt (friendsRelay, empty forgets it)
and asks the game to connect to friend:<code> through pages::connect.

- horizon-hooks: Page::opened, called each time a form opens, so it starts over.
- The Friends icon is active while a Title page is registered; net::joins_friends,
  net::save_friends_relay and ui_theme::set_preference go, and so does the server
  list's hidden id-47 entry to Join a Friend.
- horizon-official registers the page.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 23:34:23 +01:00
Horizon agentandClaude Opus 5.5 72cf34866e Pages v2: text fields, and a page in the title screen's Friends icon slot
horizon_hooks::pages gains Row::Field (Row::field(label, text).max(n)),
vanilla's EditBox on a page drawn as its own screen, with Act::Edit (each
change, before any other act or redraw reaches the page) and Act::Submit
(Enter); Place::Title, the title screen's Friends icon slot, unchanged in
place and look; Page::back, the label of the button that leaves such a
page; and pages::connect, for a Title page to join a server.

The icon opens the first Title page as multiplayer::Screen::Page, a form
laid out as Join a Friend (multiplayer/page.rs); its fields go through the
title screen's caret like every field of its own (focus, typing, caret
keys, selection, copy, paste, the max length), Tab walks fields then
buttons. Without a Title page the icon opens Join a Friend while a
resolver is registered, else it stays inactive. The options shell skips
fields. title.rs loses lines.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 23:15:19 +01:00
Horizon agentandClaude Opus 5.5 aad4d8892f The title screen's Friends icon is inactive without an invite-code resolver (spec #14167 step 4)
Join a Friend opens from vanilla's Friends icon slot, unchanged in place
and size at every GUI scale; it is now drawn inactive, and takes no click,
key or controller press, when no Hooks::tunnel resolver is registered
(net::joins_friends), as the pause menu's icon is without a sharing page.
horizon-official registers horizon-friends' resolver, so players of the
official game see no change; the plain executable, which refuses every
friend: address, no longer opens a screen that cannot join.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 23:15:01 +01:00
15 changed files with 853 additions and 240 deletions

No files matched your search

@@ -171,7 +171,7 @@ impl OptionsUi {
added::Row::Text { text, .. } => {
out.extend(wrap(&text, 64).into_iter().map(Row::Note))
}
added::Row::Space(_) => continue,
added::Row::Space(_) | added::Row::Field { .. } => continue,
added::Row::Button {
label,
active,
+46 -145
View File
@@ -7,6 +7,9 @@ use crate::lan::discovery::Scanner;
use crate::text::{DARK_GRAY, GRAY, Text, format_translation};
use crate::title::confirm_top;
use horizon_protocol::servers::{self, ServerEntry};
pub use page::Form;
#[cfg(test)]
pub use page::stand_in_title_page;
use std::{
collections::HashMap,
path::{Path, PathBuf},
@@ -17,8 +20,8 @@ use std::{
const SERVER_NAME: crate::clipboard::FieldId = ("server.name", 0);
const SERVER_ADDRESS: crate::clipboard::FieldId = ("server.address", 0);
const SERVER_DIRECT: crate::clipboard::FieldId = ("server.direct", 0);
const FRIEND_CODE: crate::clipboard::FieldId = ("friend.code", 0);
const FRIEND_RELAY: crate::clipboard::FieldId = ("friend.relay", 0);
/// A server address's field takes 128 characters (`ManageServerScreen`).
const MAX_LENGTH: usize = 128;
/// The result of pinging one address.
#[derive(Clone, Debug)]
@@ -128,16 +131,8 @@ pub enum Screen {
name: String,
lines: std::cell::Cell<usize>,
},
/// Join a friend's world with its invite code (Horizon's own, behind the
/// title screen's Friends button).
Friend {
/// The title and server list both open this page; cancellation returns to its parent.
from_title: bool,
code: String,
/// The relay for short codes (optional; saved on Join).
relay: String,
focus: u8,
},
/// A page a crate registered at the title screen's Friends icon, as a form.
Page(Form),
Disconnected {
reason: String,
/// Auto-reconnect's next attempt, until it is cancelled.
@@ -146,6 +141,18 @@ pub enum Screen {
lines: std::cell::Cell<usize>,
},
}
mod page;
/// What the title screen's Friends icon opens: the page a crate registered there
/// (`Place::Title`; horizon-friends' Join a Friend). None: it is inactive.
pub fn title_entry() -> Option<Screen> {
page::title_page().map(|page| Screen::Page(Form::open(page)))
}
/// Whether the title screen's Friends icon opens something ([`title_entry`]).
pub fn has_title_entry() -> bool {
page::title_page().is_some()
}
/// What a click asks the title screen to do.
pub enum Outcome {
None,
@@ -175,7 +182,7 @@ impl Screen {
Self::Edit { index: Some(_), .. } => "edit_server",
Self::Direct { .. } => "direct_connect",
Self::ConfirmDelete { .. } => "server_delete",
Self::Friend { .. } => "friends_join",
Self::Page(form) => form.id(),
Self::Disconnected { .. } => "disconnected",
}
}
@@ -744,24 +751,7 @@ impl Screen {
53,
),
],
Self::Friend { code, .. } => vec![
b(
half(w) - 100.0,
quarter(h) + 96.0 + 18.0,
200.0,
&crate::lang::get_or("horizon.multiplayer.join", "Join"),
crate::net::is_invite(code, false),
56,
),
b(
half(w) - 100.0,
quarter(h) + 120.0 + 18.0,
200.0,
&crate::lang::get_or("gui.cancel", "Cancel"),
true,
57,
),
],
Self::Page(form) => form.buttons(w, h),
Self::Disconnected { retry, lines, .. } => {
let btn = disconnected_layout(h, lines.get(), retry.is_some()).buttons;
let mut v = vec![b(
@@ -864,10 +854,7 @@ impl Screen {
]),
)
}
Self::Friend { .. } => ScreenInfo::new(
"multiplayer/friend",
get("horizon.multiplayer.join_friend", "Join a Friend"),
),
Self::Page(form) => form.narration(),
// DisconnectedScreen: its title and the reason.
Self::Disconnected { reason, .. } => ScreenInfo::new(
"multiplayer/disconnected",
@@ -945,7 +932,7 @@ impl Screen {
hide_address: bool,
) {
let (w, h) = (batch.width, batch.height);
let max = self.max_length();
let max = MAX_LENGTH;
crate::widgets::begin_screen(|| self.narration());
crate::ui::menu_background(batch, atlas, 0.0, 0.0, w, h, false);
// GuiGraphicsExtractor.centeredText at width / 2: x - width(text) / 2.
@@ -1318,40 +1305,7 @@ impl Screen {
centered(batch, &line.plain(), top + 17.0 + i as f32 * 9.0, u32::MAX);
}
}
Self::Friend {
code, relay, focus, ..
} => {
centered(
batch,
&crate::lang::get_or("horizon.multiplayer.join_friend", "Join a Friend"),
20.0,
u32::MAX,
);
Self::field(
atlas,
batch,
caret,
focused_at,
&crate::lang::get_or("horizon.multiplayer.invite_code", "Invite Code"),
code,
FRIEND_CODE,
max,
(53.0, 64.0),
*focus == 0,
);
Self::field(
atlas,
batch,
caret,
focused_at,
&crate::lang::get_or("horizon.multiplayer.relay", "Relay (optional)"),
relay,
FRIEND_RELAY,
max,
(94.0, 105.0),
*focus == 1,
);
}
Self::Page(form) => form.draw(atlas, batch, caret, focused_at),
Self::Disconnected {
reason,
retry,
@@ -1383,6 +1337,7 @@ impl Screen {
/// selected server or LAN game, as a click on it does.
pub fn confirm(&self) -> Outcome {
match self {
Self::Page(form) => form.confirm(),
Self::Direct { address } if servers::parse_address(address).is_some() => {
Outcome::Connect {
address: direct_address(address),
@@ -1431,6 +1386,9 @@ impl Screen {
saves: &Path,
) -> Outcome {
let (w, h) = size;
if let Self::Page(form) = self {
return form.click(id, x, y);
}
match (&mut *self, id) {
(
Self::List {
@@ -1451,7 +1409,6 @@ impl Screen {
address: String::new(),
}
}
(Self::List { .. }, Some(47)) => *self = Self::friend(false),
(Self::List { .. }, Some(42)) => {
*self = Self::Edit {
index: None,
@@ -1644,16 +1601,7 @@ impl Screen {
*self = Self::list(saves);
}
(
Self::Friend {
from_title: true, ..
},
Some(57),
) => return Outcome::Back,
(
Self::Edit { .. }
| Self::Direct { .. }
| Self::Friend { .. }
| Self::Disconnected { .. },
Self::Edit { .. } | Self::Direct { .. } | Self::Disconnected { .. },
Some(51 | 53 | 54 | 57),
) => *self = Self::list(saves),
(Self::Disconnected { retry, .. }, Some(55)) => *retry = None,
@@ -1665,26 +1613,8 @@ impl Screen {
lan: false,
};
}
(Self::Friend { code, relay, .. }, Some(56)) => {
if crate::net::is_invite(code, false) {
crate::net::save_friends_relay(relay.trim());
let code: String = code.chars().filter(|c| !c.is_whitespace()).collect();
return Outcome::Connect {
address: format!("{}{code}", crate::net::FRIEND),
lan: false,
};
}
}
(Self::Edit { .. } | Self::Friend { .. }, None) => {
let boxes = if matches!(self, Self::Edit { .. }) {
[66.0, 106.0]
} else {
[64.0, 105.0]
};
let (Self::Edit { focus, .. } | Self::Friend { focus, .. }) = self else {
unreachable!()
};
for (i, fy) in [(0u8, boxes[0]), (1, boxes[1])] {
(Self::Edit { focus, .. }, None) => {
for (i, fy) in [(0u8, 66.0), (1, 106.0)] {
if y >= fy && y < fy + 20.0 && (half(w) - 100.0..half(w) + 100.0).contains(&x) {
*focus = i;
}
@@ -1813,16 +1743,14 @@ impl Screen {
}
}
/// The focused box, in GUI pixels: name at 66, address at 106
/// (`ManageServerScreen`), direct address at 116, friend fields at 64 and 105.
/// (`ManageServerScreen`), direct address at 116.
pub fn focused_box(&self, (w, _h): (f32, f32)) -> Option<[f32; 4]> {
let y = match self {
Self::Edit { focus: 0, .. } => 66.0,
Self::Edit { focus: 1, .. } => 106.0,
Self::Edit { .. } => return None,
Self::Direct { .. } => 116.0,
Self::Friend { focus: 0, .. } => 64.0,
Self::Friend { focus: 1, .. } => 105.0,
Self::Friend { .. } => return None,
Self::Page(form) => return form.focused_box(),
_ => return None,
};
Some([(w / 2.0).floor() - 100.0, y, 200.0, 20.0])
@@ -1841,24 +1769,12 @@ impl Screen {
_ => None,
},
Self::Direct { address } => Some(address),
Self::Friend {
code, relay, focus, ..
} => match *focus {
0 => Some(code),
1 => Some(relay),
_ => None,
},
_ => None,
}
}
/// Join a friend, with the relay the player set before.
pub fn friend(from_title: bool) -> Self {
Self::Friend {
from_title,
code: String::new(),
relay: crate::net::friends_relay_setting().unwrap_or_default(),
focus: 0,
}
/// Whether a press off the buttons may land on one of its text fields.
pub fn has_fields(&self) -> bool {
matches!(self, Self::Edit { .. } | Self::Page(_))
}
/// Auto-reconnect's attempt, once its countdown is over.
pub fn due_retry(&mut self) -> Option<crate::reconnect::Retry> {
@@ -1910,31 +1826,26 @@ impl Screen {
}
/// The focused text field, its id and maximum length (copy and paste).
pub fn focused_text(&mut self) -> Option<(crate::clipboard::FieldId, &mut String, usize)> {
if let Self::Page(form) = self {
return form.focused_text();
}
let id = match self {
Self::Edit { focus: 0, .. } => SERVER_NAME,
Self::Edit { .. } => SERVER_ADDRESS,
Self::Direct { .. } => SERVER_DIRECT,
Self::Friend { focus: 0, .. } => FRIEND_CODE,
Self::Friend { .. } => FRIEND_RELAY,
_ => return None,
};
let max = self.max_length();
self.focused_field().map(|f| (id, f, max))
}
/// Invite codes with IPv6 and a relay run past a server address's 128.
fn max_length(&self) -> usize {
if matches!(self, Self::Friend { .. }) {
256
} else {
128
}
self.focused_field().map(|f| (id, f, MAX_LENGTH))
}
/// Tab (`reverse`: Shift+Tab) through the focus order of `ManageServerScreen`
/// and the friend screen: the text fields, then the enabled `buttons`, then
/// and of a form: the text fields, then the enabled `buttons`, then
/// round to the first field. `button` is the focused button, if any; a field
/// has the focus (`focus` below 2) only while no button does.
pub fn tab_dir(&mut self, reverse: bool, buttons: &[u8], button: &mut Option<u8>) {
let (Self::Edit { focus, .. } | Self::Friend { focus, .. }) = self else {
if let Self::Page(form) = self {
return form.tab_dir(reverse, buttons, button);
}
let Self::Edit { focus, .. } = self else {
return;
};
let stops = 2 + buttons.len();
@@ -1959,10 +1870,7 @@ impl Screen {
/// Escape: back to the list, or out of it (true).
pub fn escape(&mut self, saves: &Path) -> bool {
match self {
Self::List { .. }
| Self::Friend {
from_title: true, ..
} => true,
Self::List { .. } | Self::Page(_) => true,
// ConfirmScreen.keyPressed: Escape answers no.
Self::ConfirmDelete { list, .. } => {
let list = std::mem::replace(
@@ -2265,13 +2173,6 @@ mod tests {
};
assert_eq!(at(&direct, 427.0, 334.0), [(113.0, 191.0), (113.0, 215.0)]);
assert_eq!(at(&direct, 320.0, 240.0), [(60.0, 168.0), (60.0, 192.0)]);
let friend = Screen::Friend {
from_title: false,
code: String::new(),
relay: String::new(),
focus: 0,
};
assert_eq!(at(&friend, 427.0, 253.0), [(113.0, 177.0), (113.0, 201.0)]);
let lost = Screen::Disconnected {
reason: String::new(),
retry: None,
@@ -0,0 +1,487 @@
//! A page a crate registers at `horizon_hooks::pages::Place::Title`, which the title screen's
//! Friends icon opens: a form drawn as Join a Friend and `ManageServerScreen` are. The title at
//! the top, then the rows from y 53 (a field's grey label, its 200-wide `EditBox` 11 below, the
//! next 41 lower; text 10 a line), and the page's buttons in a column under them from a quarter
//! of the height + 114, 24 apart, the one that leaves it last.
//!
//! The engine keeps each field's text while the form is open (the title screen's caret edits
//! it, as every field of its own) and tells the page each change before anything else reaches
//! it ([`Act::Edit`]), then Enter in a field ([`Act::Submit`]) and the presses. A page joins a
//! server with `horizon_hooks::pages::connect`.
use super::{ButtonSpec, Outcome, centered_x, half, quarter};
use crate::hud::{HudAtlas, HudBatch, rgba};
use horizon_hooks::pages::{self, Act, Page, Place, Row};
use std::cell::RefCell;
use std::time::Instant;
/// The first button's id; button `k` of the page is `FIRST + k`.
const FIRST: u8 = 100;
/// The text a line of the form wraps at.
const WIDTH: f32 = 300.0;
/// The page the title screen's Friends icon opens: the first registered there.
pub(super) fn title_page() -> Option<&'static dyn Page> {
#[cfg(test)]
if let Some(page) = TITLE_PAGE_IN_TEST.get() {
return Some(page);
}
pages::engine::all()
.iter()
.copied()
.find(|page| page.place() == Place::Title)
}
#[cfg(test)]
thread_local! {
/// A test's stand-in for a page registered at `Place::Title`: the registry is the
/// process's and sealed.
static TITLE_PAGE_IN_TEST: std::cell::Cell<Option<&'static dyn Page>> =
const { std::cell::Cell::new(None) };
}
/// Tests: whether the title screen's Friends icon has a page (a join form) on this thread.
#[cfg(test)]
pub fn stand_in_title_page(on: bool) {
TITLE_PAGE_IN_TEST.set(on.then(|| tests::join() as &dyn Page));
}
/// An open form: the page, and what the engine keeps while it is open.
#[derive(Clone)]
pub struct Form {
page: &'static dyn Page,
/// Each field's text, in the order the rows have them.
texts: Vec<String>,
/// The text the page last heard of each field.
sent: RefCell<Vec<String>>,
/// The focused field; `texts.len()` (or more) while a button has the focus.
pub(super) focus: usize,
/// Each field's box where the last draw put it.
boxes: RefCell<Vec<[f32; 4]>>,
}
impl std::fmt::Debug for Form {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("Form")
.field("page", &self.page.id())
.field("texts", &self.texts)
.field("focus", &self.focus)
.finish()
}
}
/// A button of the form: its row (None: the one that leaves), label key, and whether it acts.
struct Button {
row: Option<usize>,
label: &'static str,
active: bool,
half: bool,
}
impl Form {
/// The form of `page`, which hears it opens, its fields holding the text its rows start
/// with, the first focused.
pub fn open(page: &'static dyn Page) -> Self {
page.opened();
let mut rows = Vec::new();
page.rows(&mut rows);
let texts: Vec<String> = rows
.iter()
.filter_map(|row| match row {
Row::Field { text, .. } => Some(text.clone()),
_ => None,
})
.collect();
Self {
page,
sent: RefCell::new(texts.clone()),
boxes: RefCell::new(vec![[0.0; 4]; texts.len()]),
texts,
focus: 0,
}
}
pub fn id(&self) -> &'static str {
self.page.id()
}
/// The page's rows, after it heard every change to its fields.
fn rows(&self) -> Vec<Row> {
let mut rows = Vec::new();
self.page.rows(&mut rows);
let fields = field_rows(&rows);
let mut sent = self.sent.borrow_mut();
let mut told = false;
for ((text, sent), row) in self.texts.iter().zip(sent.iter_mut()).zip(fields) {
if text != sent {
sent.clone_from(text);
self.page.act(row, Act::Edit(text.clone()));
told = true;
}
}
if told {
rows.clear();
self.page.rows(&mut rows);
}
rows
}
fn buttons_of(rows: &[Row], back: &'static str) -> Vec<Button> {
let mut buttons: Vec<Button> = rows
.iter()
.enumerate()
.filter_map(|(r, row)| match row {
Row::Button {
label,
active,
half,
..
} => Some(Button {
row: Some(r),
label,
active: *active,
half: *half,
}),
_ => None,
})
.collect();
buttons.push(Button {
row: None,
label: back,
active: true,
half: false,
});
buttons
}
/// The id of the button that leaves: Cancel's or Done's, for the controller.
fn back_id(&self) -> u8 {
if self.page.back() == "gui.cancel" {
57
} else {
50
}
}
pub fn buttons(&self, w: f32, h: f32) -> Vec<ButtonSpec> {
let rows = self.rows();
let cx = half(w);
let mut y = quarter(h) + 114.0;
let mut beside = false;
let mut out = Vec::new();
for (k, b) in Self::buttons_of(&rows, self.page.back())
.into_iter()
.enumerate()
{
let id = match b.row {
Some(_) => FIRST + k as u8,
None => self.back_id(),
};
let label = crate::lang::get(b.label);
let (x, bw) = match (b.half, beside) {
(true, true) => {
y -= 24.0;
beside = false;
(cx + 2.0, 98.0)
}
(true, false) => {
beside = true;
(cx - 100.0, 98.0)
}
(false, _) => {
beside = false;
(cx - 100.0, 200.0)
}
};
out.push((x, y, bw, label, b.active, id));
y += 24.0;
}
out
}
pub fn draw(
&self,
atlas: &HudAtlas,
batch: &mut HudBatch,
caret: &mut crate::ui::Caret,
focused_at: Instant,
) {
let w = batch.width;
let rows = self.rows();
let title = crate::lang::get(self.page.title());
let tw = HudBatch::text_width(atlas, &title);
batch.text(atlas, &title, centered_x(w, tw), 20.0, u32::MAX, true);
let mut y = 53.0;
let mut field = 0;
let mut boxes = self.boxes.borrow_mut();
for (r, row) in rows.iter().enumerate() {
let (text, color) = match row {
Row::Field { label, max, .. } => {
let Some(value) = self.texts.get(field) else {
break;
};
super::Screen::field(
atlas,
batch,
caret,
focused_at,
&crate::lang::get(label),
value,
("page.field", r),
*max,
(y, y + 11.0),
self.focus == field,
);
boxes[field] = [half(w) - 100.0, y + 11.0, 200.0, 20.0];
field += 1;
y += 41.0;
continue;
}
Row::Space(px) => {
y += f32::from(*px);
continue;
}
Row::Heading(key) => (crate::lang::get(key), u32::MAX),
Row::Note(text) => (text.clone(), rgba(160, 160, 160, 255)),
Row::Text { text, quiet, .. } => {
let grey = rgba(160, 160, 160, 255);
(text.clone(), if *quiet { grey } else { u32::MAX })
}
_ => continue,
};
for line in super::wrap(&text, WIDTH, atlas) {
let lw = HudBatch::text_width(atlas, &line);
batch.text(atlas, &line, centered_x(w, lw), y, color, true);
y += 10.0;
}
}
}
pub fn narration(&self) -> crate::widgets::ScreenInfo {
crate::widgets::ScreenInfo::new(self.page.id(), crate::lang::get(self.page.title()))
}
/// A press on button `id`, or on a field (`id` None).
pub fn click(&mut self, id: Option<u8>, x: f32, y: f32) -> Outcome {
match id {
Some(id) if id == self.back_id() => Outcome::Back,
Some(id) if id >= FIRST => {
let rows = self.rows();
let buttons = Self::buttons_of(&rows, self.page.back());
match buttons.get(usize::from(id - FIRST)) {
Some(Button {
row: Some(r),
active: true,
..
}) => self.act(*r, Act::Press),
_ => Outcome::None,
}
}
Some(_) => Outcome::None,
None => {
let hit = self
.boxes
.borrow()
.iter()
.position(|&[bx, by, bw, bh]| crate::ui::hovered((x, y), bx, by, bw, bh));
if let Some(field) = hit {
self.focus = field;
}
Outcome::None
}
}
}
/// Enter: the focused field submits.
pub fn confirm(&self) -> Outcome {
let rows = self.rows();
match field_rows(&rows).nth(self.focus) {
Some(r) => self.act(r, Act::Submit),
None => Outcome::None,
}
}
/// The page acts; a server it asked to join is joined.
fn act(&self, row: usize, act: Act) -> Outcome {
self.page.act(row, act);
match pages::engine::take_connect() {
Some(address) => Outcome::Connect {
address,
lan: false,
},
None => Outcome::None,
}
}
pub fn focused_box(&self) -> Option<[f32; 4]> {
self.boxes.borrow().get(self.focus).copied()
}
/// The focused field, its id and maximum length.
pub fn focused_text(&mut self) -> Option<(crate::clipboard::FieldId, &mut String, usize)> {
let mut rows = Vec::new();
self.page.rows(&mut rows);
let (r, max) = rows
.iter()
.enumerate()
.filter_map(|(r, row)| match row {
Row::Field { max, .. } => Some((r, *max)),
_ => None,
})
.nth(self.focus)?;
let text = self.texts.get_mut(self.focus)?;
Some((("page.field", r), text, max))
}
/// Tab through the fields, then the enabled `buttons`, then round to the first field.
pub fn tab_dir(&mut self, reverse: bool, buttons: &[u8], button: &mut Option<u8>) {
let fields = self.texts.len();
let stops = fields + buttons.len();
if stops == 0 {
return;
}
let at = match *button {
Some(id) => buttons.iter().position(|&b| b == id).map(|i| fields + i),
None => Some(self.focus.min(fields.saturating_sub(1))),
}
.unwrap_or(0);
let next = if reverse {
(at + stops - 1) % stops
} else {
(at + 1) % stops
};
if next < fields {
self.focus = next;
*button = None;
} else {
self.focus = fields;
*button = Some(buttons[next - fields]);
}
}
}
/// The rows that are fields, in order.
fn field_rows(rows: &[Row]) -> impl Iterator<Item = usize> + '_ {
rows.iter()
.enumerate()
.filter(|(_, row)| matches!(row, Row::Field { .. }))
.map(|(r, _)| r)
}
#[cfg(test)]
mod tests {
use super::*;
use std::sync::Mutex;
/// A join form: a code and a relay, Join while the code has 4 characters or more.
pub(super) struct Join {
heard: Mutex<Vec<(usize, Act)>>,
code: Mutex<String>,
}
impl Page for Join {
fn id(&self) -> &'static str {
"test.join"
}
fn title(&self) -> &'static str {
"horizon.multiplayer.join_friend"
}
fn place(&self) -> Place {
Place::Title
}
fn rows(&self, rows: &mut Vec<Row>) {
let code = self.code.lock().unwrap().clone();
rows.push(Row::field("horizon.multiplayer.invite_code", code.clone()).max(8));
rows.push(Row::field(
"horizon.multiplayer.relay",
"relay.example:25599",
));
rows.push(Row::button("horizon.multiplayer.join").active(code.len() >= 4));
}
fn act(&self, row: usize, act: Act) {
if let (0, Act::Edit(text)) = (row, &act) {
text.clone_into(&mut self.code.lock().unwrap());
}
if matches!((row, &act), (2, Act::Press) | (0, Act::Submit)) {
pages::connect(format!("friend:{}", self.code.lock().unwrap()));
}
self.heard.lock().unwrap().push((row, act));
}
fn back(&self) -> &'static str {
"gui.cancel"
}
}
pub(super) fn join() -> &'static Join {
Box::leak(Box::new(Join {
heard: Mutex::new(Vec::new()),
code: Mutex::new(String::new()),
}))
}
#[test]
fn a_form_lays_out_as_join_a_friend() {
let form = Form::open(join());
let buttons = form.buttons(427.0, 240.0);
// Join at a quarter of the height + 114, Cancel 24 below, both 200 wide and centred.
assert_eq!(
buttons
.iter()
.map(|(x, y, w, _, on, id)| (*x, *y, *w, *on, *id))
.collect::<Vec<_>>(),
[
(113.0, 174.0, 200.0, false, FIRST),
(113.0, 198.0, 200.0, true, 57)
]
);
assert_eq!(form.texts, ["", "relay.example:25599"]);
}
#[test]
fn typing_reaches_the_page_before_a_press_and_enter_submits() {
let page = join();
let mut form = Form::open(page);
let (id, text, max) = form.focused_text().unwrap();
assert_eq!((id, max), (("page.field", 0), 8));
text.push_str("AB");
// Join is greyed: the page heard "AB" before the buttons were laid out.
assert!(!form.buttons(427.0, 240.0)[0].4);
form.focused_text().unwrap().1.push_str("CD");
assert!(form.buttons(427.0, 240.0)[0].4);
match form.click(Some(FIRST), 0.0, 0.0) {
Outcome::Connect { address, lan } => {
assert_eq!((address.as_str(), lan), ("friend:ABCD", false))
}
_ => panic!("Join did not connect"),
}
match form.confirm() {
Outcome::Connect { address, .. } => assert_eq!(address, "friend:ABCD"),
_ => panic!("Enter did not submit"),
}
let heard = page.heard.lock().unwrap();
assert_eq!(
*heard,
[
(0, Act::Edit("AB".into())),
(0, Act::Edit("ABCD".into())),
(2, Act::Press),
(0, Act::Submit)
]
);
assert!(matches!(form.click(Some(57), 0.0, 0.0), Outcome::Back));
}
#[test]
fn tab_goes_through_the_fields_then_the_buttons() {
let mut form = Form::open(join());
let mut button = None;
form.tab_dir(false, &[57], &mut button);
assert_eq!((form.focus, button), (1, None));
form.tab_dir(false, &[57], &mut button);
assert_eq!((form.focus, button), (2, Some(57)));
form.tab_dir(false, &[57], &mut button);
assert_eq!((form.focus, button), (0, None));
form.tab_dir(true, &[57], &mut button);
assert_eq!(button, Some(57));
assert!(form.focused_text().is_none());
}
}
-6
View File
@@ -529,12 +529,6 @@ pub fn friends_relay_setting() -> Option<String> {
.filter(|r| !r.is_empty())
.or_else(|| horizon_hooks::engine::tunnel().and_then(|r| r.relay_from_env()))
}
/// Remember the relay typed on Join a friend (empty forgets it).
pub fn save_friends_relay(relay: &str) {
if let Ok(dir) = std::env::current_dir() {
crate::ui_theme::set_preference(&dir, "friendsRelay", (!relay.is_empty()).then_some(relay));
}
}
/// Reach the friend's world from the code (directly, or through a relay)
/// and open the tunnel: the loopback address to connect to, and the token to send on it first.
+8 -11
View File
@@ -1940,8 +1940,8 @@ impl Title {
)
.does(crate::widgets::Action::Exit),
];
// CommonButtons' icon buttons; Horizon's Join a Friend (an
// invite code) sits behind vanilla's Friends button.
// CommonButtons' icon buttons: vanilla's Friends button opens a crate's page or
// Join a Friend, inactive with neither (`multiplayer::title_entry`).
for (i, (key, plain, id, icon)) in [
("gui.friends.open", "Friends", 5, "friends/friends"),
("options.language", "Language...", 6, "icon/language"),
@@ -1960,7 +1960,7 @@ impl Title {
y + 72.0,
20.0,
&lang::get_or(key, plain),
true,
id != 5 || crate::multiplayer::has_title_entry(),
id,
);
button.icon = Some((icon, 15.0));
@@ -3658,12 +3658,7 @@ Would you like to enable the Narrator or visit the Accessibility Settings?",
.map(|b| b.id);
if hit.is_some() {
self.focused_button = hit;
} else if matches!(
self.mode,
Mode::Multiplayer(
crate::multiplayer::Screen::Edit { .. } | crate::multiplayer::Screen::Friend { .. }
)
) {
} else if matches!(&self.mode, Mode::Multiplayer(screen) if screen.has_fields()) {
// A press on a text field takes the focus from the button.
self.focused_button = None;
}
@@ -3782,7 +3777,7 @@ Would you like to enable the Narrator or visit the Accessibility Settings?",
crate::multiplayer::Outcome::None => Action::None,
},
(Mode::Main, Some(5)) => {
self.mode = Mode::Multiplayer(crate::multiplayer::Screen::friend(true));
self.mode = crate::multiplayer::title_entry().map_or(Mode::Main, Mode::Multiplayer);
Action::None
}
(Mode::Onboarding(_), Some(onboarding::SOUNDS)) => Action::Sounds,
@@ -5306,6 +5301,8 @@ mod classic_tests {
#[test]
fn classic_main_menu_icons_route_language_and_accessibility() {
// The Friends icon opens a page registered there (title/tests.rs tests it without one).
crate::multiplayer::stand_in_title_page(true);
let mut title = Title::new(
PathBuf::new(),
Path::new("/nonexistent/horizon-test-resources"),
@@ -5335,7 +5332,7 @@ mod classic_tests {
if id == 5 {
assert!(matches!(
title.mode,
Mode::Multiplayer(crate::multiplayer::Screen::Friend { .. })
Mode::Multiplayer(crate::multiplayer::Screen::Page(_))
));
title.mode = Mode::Main;
}
+56 -50
View File
@@ -100,62 +100,78 @@ impl Drop for Fixture {
}
}
/// The Friends icon opens a page registered at `Place::Title` (horizon-friends' Join a Friend);
/// these tests stand one in.
fn with_title_page() -> Fixture {
crate::multiplayer::stand_in_title_page(true);
Fixture::new()
}
#[test]
fn without_a_title_page_the_friends_icon_is_inactive_and_opens_nothing() {
crate::multiplayer::stand_in_title_page(false);
let mut f = Fixture::new();
let icons: Vec<_> = f
.title
.buttons(480.0, 262.0)
.into_iter()
.filter(|b| b.icon.is_some())
.map(|b| (b.label.clone(), b.enabled, b.x))
.collect();
// Same place and look as with one, only greyed; Language and Accessibility stay active.
let friends = icons.iter().find(|(label, ..)| label == "Friends").unwrap();
assert!(!friends.1);
assert!(
icons
.iter()
.filter(|(label, ..)| label != "Friends")
.all(|(_, on, _)| *on)
);
assert_eq!(f.click("Friends"), Action::None);
assert!(matches!(f.title.mode, Mode::Main));
crate::multiplayer::stand_in_title_page(true);
let with: Vec<_> = f
.title
.buttons(480.0, 262.0)
.into_iter()
.filter(|b| b.icon.is_some())
.map(|b| (b.label.clone(), b.x, b.y))
.collect();
assert_eq!(
with.iter()
.map(|(l, x, _)| (l.clone(), *x))
.collect::<Vec<_>>(),
icons
.iter()
.map(|(l, _, x)| (l.clone(), *x))
.collect::<Vec<_>>()
);
crate::multiplayer::stand_in_title_page(false);
}
mod multiplayer_parent_navigation {
use super::*;
use crate::multiplayer::{Outcome, Screen};
use crate::multiplayer::Screen;
#[test]
fn friend_cancel_returns_to_title() {
let mut f = Fixture::new();
let mut f = with_title_page();
f.click("Friends");
assert!(matches!(
f.title.mode,
Mode::Multiplayer(Screen::Friend { .. })
));
assert_eq!(f.click("Cancel"), Action::None);
assert!(matches!(f.title.mode, Mode::Multiplayer(Screen::Page(_))));
// The form's back button: the page's `gui.cancel`, as the player's language reads it.
assert_eq!(f.click(&crate::lang::get("gui.cancel")), Action::None);
assert!(matches!(f.title.mode, Mode::Main));
}
#[test]
fn friend_escape_returns_to_title() {
let mut f = Fixture::new();
let mut f = with_title_page();
f.click("Friends");
assert!(matches!(
f.title.mode,
Mode::Multiplayer(Screen::Friend { .. })
));
assert!(matches!(f.title.mode, Mode::Multiplayer(Screen::Page(_))));
f.title.escape();
assert!(matches!(f.title.mode, Mode::Main));
}
#[test]
fn friend_from_server_list_cancel_and_escape_return_to_list() {
for escape in [false, true] {
let mut f = Fixture::new();
f.click("Multiplayer");
f.click("Proceed");
// The legacy entry has a click handler but no visible button.
assert!(matches!(
f.title
.multiplayer_click(Some(47), 0.0, 0.0, (480.0, 262.0)),
Outcome::None
));
assert!(matches!(
f.title.mode,
Mode::Multiplayer(Screen::Friend { .. })
));
if escape {
f.title.escape();
} else {
assert_eq!(f.click("Cancel"), Action::None);
}
assert!(matches!(
f.title.mode,
Mode::Multiplayer(Screen::List { .. })
));
}
}
#[test]
fn direct_and_add_edit_cancel_and_escape_return_to_list() {
for screen in [
@@ -2701,16 +2717,6 @@ fn the_multiplayer_screens_have_their_own_ids() {
.id(),
"edit_server"
);
assert_eq!(
M::Friend {
from_title: false,
code: String::new(),
relay: String::new(),
focus: 0,
}
.id(),
"friends_join"
);
assert_eq!(
M::Disconnected {
reason: String::new(),
-15
View File
@@ -23,21 +23,6 @@ pub fn preference(dir: &Path, key: &str) -> Option<String> {
(k.trim() == key).then(|| v.trim().to_owned())
})
}
/// Set (or with None remove) one preference, keeping the others.
pub(crate) fn set_preference(dir: &Path, key: &str, value: Option<&str>) {
let text = std::fs::read_to_string(dir.join("horizon.txt")).unwrap_or_default();
let mut lines: Vec<String> = text
.lines()
.filter(|l| l.split_once(':').is_none_or(|(k, _)| k.trim() != key))
.map(str::to_owned)
.collect();
if let Some(v) = value {
lines.push(format!("{key}:{v}"));
}
let mut out = lines.join("\n");
out.push('\n');
let _ = std::fs::write(dir.join("horizon.txt"), out);
}
/// The dark palette (`horizon_theme::DARK`) in the HUD's colour encoding, plus the
/// translucent plates the overlays draw over the game.
+6 -4
View File
@@ -1,9 +1,10 @@
//! Play with Friends in the game (the `game` feature). Joining: the engine's `friend:`
//! addresses (`horizon_platform::hooks::tunnel`) read as invite codes, and joined through this
//! crate's tunnel. Hosting ([`share`]): the pause menu's Friends page shares the open world
//! behind an invite code. `horizon-official` registers [`HOOKS`], [`PAGE`] and its text
//! ([`LANG`]); the game's own executable has none of them, so there no invite code joins and
//! nothing is shared.
//! crate's tunnel, from Join a Friend ([`JOIN`], what the title screen's Friends icon opens).
//! Hosting ([`share`]): the pause menu's Friends page ([`PAGE`]) shares the open world behind an
//! invite code. `horizon-official` registers [`HOOKS`], both pages and their text ([`LANG`]); the
//! game's own executable has none of them, so there no invite code joins and nothing is shared.
mod join;
mod lang;
mod share;
@@ -20,6 +21,7 @@ use horizon_platform::{
},
version::{PROTOCOL_VERSION, VANILLA_VERSION},
};
pub use join::PAGE as JOIN;
pub use lang::LANG;
pub use share::PAGE;
use std::net::SocketAddr;
+149
View File
@@ -0,0 +1,149 @@
//! Join a Friend: the page the title screen's Friends icon opens (`Place::Title`), drawn as the
//! engine's form. The invite code, the relay a short code goes through (`friendsRelay` in
//! horizon.txt, kept on Join; empty forgets it), and Join, active while the code reads as one.
//! Join asks the game to connect to the code's `friend:` address, which this crate's
//! resolver ([`super::Invites`]) opens.
use super::{Invites, share};
use horizon_platform::{
hooks::tunnel::Resolver,
pages::{self, Act, Page, Place, Row},
};
use std::path::Path;
use std::sync::{Mutex, MutexGuard};
/// What `horizon-official` registers at the title screen's Friends icon.
pub static PAGE: Join = Join {
code: Mutex::new(String::new()),
relay: Mutex::new(String::new()),
};
/// Its rows, in order.
const CODE: usize = 0;
const RELAY: usize = 1;
const JOIN: usize = 2;
/// Invite codes with IPv6 addresses and a relay run past a server address's 128.
const MAX: usize = 256;
/// The page: what its fields hold while it is open.
pub struct Join {
code: Mutex<String>,
relay: Mutex<String>,
}
fn lock(text: &Mutex<String>) -> MutexGuard<'_, String> {
text.lock().unwrap_or_else(|e| e.into_inner())
}
impl Join {
/// Join: remember the relay in `dir`'s horizon.txt and give the address to connect to, or
/// None while the code is no invite code.
fn join(&self, dir: &Path) -> Option<String> {
let code = lock(&self.code).clone();
if !Invites.is_code(&code, false) {
return None;
}
let relay = lock(&self.relay).trim().to_owned();
share::set_preference(dir, "friendsRelay", (!relay.is_empty()).then_some(&relay));
let code: String = code.chars().filter(|c| !c.is_whitespace()).collect();
Some(format!("{}{code}", crate::ADDRESS_PREFIX))
}
}
impl Page for Join {
fn id(&self) -> &'static str {
"horizon_friends.join"
}
fn title(&self) -> &'static str {
"horizon.multiplayer.join_friend"
}
fn place(&self) -> Place {
Place::Title
}
/// An empty code, and the relay the player set before.
fn opened(&self) {
lock(&self.code).clear();
*lock(&self.relay) = share::relay().unwrap_or_default();
}
fn rows(&self, rows: &mut Vec<Row>) {
let code = lock(&self.code).clone();
rows.push(Row::field("horizon.multiplayer.invite_code", code.clone()).max(MAX));
rows.push(Row::field("horizon.multiplayer.relay", lock(&self.relay).clone()).max(MAX));
rows.push(Row::button("horizon.multiplayer.join").active(Invites.is_code(&code, false)));
}
/// Enter in a field does nothing, as on the screen this page replaces.
fn act(&self, row: usize, act: Act) {
match (row, act) {
(CODE, Act::Edit(text)) => *lock(&self.code) = text,
(RELAY, Act::Edit(text)) => *lock(&self.relay) = text,
(JOIN, Act::Press) => {
let dir = std::env::current_dir().unwrap_or_default();
if let Some(address) = self.join(&dir) {
pages::connect(address);
}
}
_ => {}
}
}
fn back(&self) -> &'static str {
"gui.cancel"
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::code::Code;
fn page() -> Join {
Join {
code: Mutex::new(String::new()),
relay: Mutex::new(String::new()),
}
}
fn join_active(page: &Join) -> bool {
let mut rows = Vec::new();
page.rows(&mut rows);
matches!(rows[JOIN], Row::Button { active: true, .. })
}
#[test]
fn join_takes_an_invite_code_and_keeps_the_relay() {
let dir = std::env::temp_dir().join(format!("horizon-friends-join-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
std::fs::write(
dir.join("horizon.txt"),
"friendsRelay:old.example:1\nother:1\n",
)
.unwrap();
let page = page();
// A nameplate alone is no code.
page.act(CODE, Act::Edit("K7QM".into()));
assert!(!join_active(&page));
assert_eq!(page.join(&dir), None);
let code = Code {
nameplate: "K7QM".into(),
secret: "3XPD".into(),
};
page.act(CODE, Act::Edit(format!(" {code} ")));
page.act(RELAY, Act::Edit(" relay.example:443 ".into()));
assert!(join_active(&page));
assert_eq!(page.join(&dir), Some(format!("friend:{code}")));
let saved = std::fs::read_to_string(dir.join("horizon.txt")).unwrap();
assert_eq!(saved, "other:1\nfriendsRelay:relay.example:443\n");
// An empty relay forgets it.
page.act(RELAY, Act::Edit(String::new()));
assert!(page.join(&dir).is_some());
assert_eq!(
std::fs::read_to_string(dir.join("horizon.txt")).unwrap(),
"other:1\n"
);
let _ = std::fs::remove_dir_all(&dir);
}
}
+15 -1
View File
@@ -167,9 +167,23 @@ fn preference(key: &str) -> Option<String> {
})
}
/// Set (or, with None, remove) one of them in `dir`'s horizon.txt, keeping the others.
pub(super) fn set_preference(dir: &std::path::Path, key: &str, value: Option<&str>) {
let text = std::fs::read_to_string(dir.join("horizon.txt")).unwrap_or_default();
let mut lines: Vec<&str> = text
.lines()
.filter(|l| l.split_once(':').is_none_or(|(k, _)| k.trim() != key))
.collect();
let line = value.map(|v| format!("{key}:{v}"));
lines.extend(line.as_deref());
let mut out = lines.join("\n");
out.push('\n');
let _ = std::fs::write(dir.join("horizon.txt"), out);
}
/// The relay the player set on Join a Friend (`friendsRelay`), else `HORIZON_RELAY_URL`. None
/// by default.
fn relay() -> Option<String> {
pub(super) fn relay() -> Option<String> {
preference("friendsRelay")
.filter(|r| !r.is_empty())
.or_else(crate::relay_url)
+58
View File
@@ -25,6 +25,11 @@ pub enum Place {
/// 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,
/// The title screen's Friends icon slot (vanilla's `CommonButtons.friends`, beside Language
/// and Accessibility): with a page registered here the icon opens the first one as a form of
/// its own, as Join a Friend is drawn ([`Row::Field`]). It shows heading, note, text, space,
/// field and button rows; the others belong in the options shell.
Title,
}
/// One page. Its values live in the crate (statics, often its settings [`crate::settings::Store`]);
@@ -46,6 +51,14 @@ pub trait Page: Sync {
fn tooltip(&self, _row: usize) -> Option<Label> {
None
}
/// The label of the button the engine adds to leave a page drawn as its own screen: a
/// translation key, `gui.done` by default (`gui.cancel` for a form that does something).
fn back(&self) -> &'static str {
"gui.done"
}
/// The page opens as a form (at [`Place::Title`]), before its rows are read: a form starts
/// over each time, as vanilla's screens do. Nothing by default.
fn opened(&self) {}
}
/// A row of a page. Labels are translation keys.
@@ -91,6 +104,16 @@ pub enum Row {
/// 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 },
/// A text field ([`Row::field`]) on a page drawn as its own screen, as vanilla's `EditBox`:
/// its label above it, focus by click or Tab, typing, the caret keys, selection, copy and
/// paste, at most `max` characters. `text` is its value when the page opens; the engine then
/// keeps it, and sends each change ([`Act::Edit`]) and Enter ([`Act::Submit`]).
#[non_exhaustive]
Field {
label: &'static str,
text: String,
max: usize,
},
}
impl Row {
@@ -113,6 +136,23 @@ impl Row {
}
}
/// A text field starting with `text`, at most 32 characters (`EditBox`'s default).
pub fn field(label: &'static str, text: impl Into<String>) -> Self {
Self::Field {
label,
text: text.into(),
max: 32,
}
}
/// A text field taking at most `chars` characters.
pub fn max(mut self, chars: usize) -> Self {
if let Self::Field { max, .. } = &mut self {
*max = chars;
}
self
}
/// A button greyed out (`false`): drawn, never pressed.
pub fn active(mut self, on: bool) -> Self {
if let Self::Button { active, .. } = &mut self {
@@ -173,6 +213,19 @@ pub enum Act {
Slide(f32),
/// A key row got its new key: its key name (`key.keyboard.z`).
Bind(String),
/// A field's text changed: all of it.
Edit(String),
/// Enter in a field.
Submit,
}
/// The server a page asked to join ([`connect`]).
static CONNECT: std::sync::Mutex<Option<String>> = std::sync::Mutex::new(None);
/// From a [`Place::Title`] page's [`Page::act`]: join the server at `address` as Direct
/// Connection does (the engine's addresses, `friend:<code>` among them), once the act returns.
pub fn connect(address: impl Into<String>) {
*CONNECT.lock().unwrap_or_else(|e| e.into_inner()) = Some(address.into());
}
static PAGES: Registry<dyn Page> = Registry::new();
@@ -191,4 +244,9 @@ pub mod engine {
pub fn all() -> &'static [&'static dyn Page] {
PAGES.all()
}
/// The server a page asked to join since the last call ([`connect`]).
pub fn take_connect() -> Option<String> {
CONNECT.lock().unwrap_or_else(|e| e.into_inner()).take()
}
}
+3 -1
View File
@@ -27,9 +27,11 @@ const PARTS: [&dyn Hooks; 2] = [
const LANG: [&lang::Files; 1] = [horizon_friends::game::LANG];
/// The pages Horizon's parts add to the game's menus.
const PAGES: [&dyn Page; 1] = [
const PAGES: [&dyn Page; 2] = [
// Play with Friends, what the pause menu's Friends icon opens.
&horizon_friends::game::PAGE,
// Join a Friend, what the title screen's Friends icon opens.
&horizon_friends::game::JOIN,
];
/// Registers Horizon's in-game parts with the game. The executable calls it before it runs the
+1 -1
View File
@@ -57,7 +57,7 @@ pub mod settings {
/// Settings pages a crate adds to the options screens (docs/layers.md, "Platform registries").
pub mod pages {
pub use horizon_hooks::pages::{Act, Label, Page, Place, Row, register};
pub use horizon_hooks::pages::{Act, Label, Page, Place, Row, connect, register};
}
/// The lang files with a crate's text, loaded with the game's own (docs/layers.md, "Platform
+19 -4
View File
@@ -367,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, Extras, PauseFriends }
pub enum Place { HorizonSettings, Pause, Extras, PauseFriends, Title }
pub trait Page: Sync {
fn id(&self) -> &'static str; // stable, for horizon-ctl and the page stack
fn title(&self) -> &'static str; // translation key
@@ -375,6 +375,8 @@ pub trait Page: Sync {
fn rows(&self, rows: &mut Vec<Row>); // asked when the page is drawn, not every frame elsewhere
fn act(&self, row: usize, act: Act); // a click, a reverse click, a slider's value, a key
fn tooltip(&self, row: usize) -> Option<Label> { None } // hover text, may follow the value
fn back(&self) -> &'static str { "gui.done" } // the leave button of a page drawn as a screen
fn opened(&self) {} // a form (Place::Title) opens: start over
}
pub enum Row {
Heading(&'static str), // translation key
@@ -386,13 +388,15 @@ pub enum Row {
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
Field { label: &'static str, text: String, max: usize }, // Row::field(..).max(n): an EditBox, on a page drawn as a screen
}
pub enum Label { Key(&'static str), Value(String) }
pub enum Act { Press, PressBack, Slide(f32), Bind(String) }
pub enum Act { Press, PressBack, Slide(f32), Bind(String), Edit(String), Submit }
pub fn connect(address: impl Into<String>); // from a Title page's act: join that server
```
- `HorizonSettings`: a row of the Horizon Settings page, after the engine's own pages. `Title` and `Pause`: a
button on the title screen or the pause menu that opens the page. These buttons exist only when something
- `HorizonSettings`: a row of the Horizon Settings page, after the engine's own pages. `Pause`: a
button on 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
@@ -402,6 +406,17 @@ pub enum Act { Press, PressBack, Slide(f32), Bind(String) }
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.
- `Title` is the title screen's Friends icon slot (vanilla's `CommonButtons.friends`), not a new row: the icon
keeps its place and look at every GUI scale, and opens the first page registered there as a form drawn as Join a
Friend is (`multiplayer/page.rs`): the title, the rows from y 53 (a field's grey label, its 200-wide box 11
below, 41 a field), the buttons in a column from a quarter of the height + 114, the back button last
(`Page::back`, `gui.cancel` for a form). It shows heading, note, text, space, field and button rows, and calls
`Page::opened` each time it opens. With no page there (horizon-client's own executable) the icon is inactive.
horizon-friends' Join a Friend is the official executable's.
- `Field` is vanilla's `EditBox` on a page drawn as its own screen: focus by click or Tab, typing, the caret keys,
selection, copy and paste, at most `max` characters (32 by default). The engine keeps its text while the page is
open and sends each change (`Act::Edit`, before any other act or redraw reaches the page) and Enter
(`Act::Submit`). The options shell has no field.
- 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
+4 -1
View File
@@ -53,7 +53,10 @@ router that opened the port, or with a forwarded port, that's it: no server took
(`horizon_hooks::tunnel`, `horizon_platform::hooks::tunnel` for the Horizon layer): the resolver a hook offers
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
horizon-client's own executable has none, so there a `friend:` address is refused. Join a Friend is
horizon-friends' page at the title screen's Friends icon (`pages::Place::Title`, `game/join.rs`): its Join asks
the game to connect to the code's `friend:` address (`pages::connect`), and it keeps the relay in `horizon.txt`;
without it the icon is inactive. Hosting needs no hook: the
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