mirror of
https://github.com/horizon-rust/horizon.git
synced 2026-11-10 02:21:33 -10:00
Compare commits
3
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3646df3e57 | ||
|
|
72cf34866e | ||
|
|
aad4d8892f |
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,
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in new issue
Block a user