Multiplayer client relay bridge and match lobby menu: WebSocket bridge, match lobby page, order relay, confirmed turns, checksums, chat, abort/resume

This commit is contained in:
Richard Zink committed 2026-11-04 12:47:34 +01:00
1 parent 848ab0f677
commit 583c3fcdaf
9 files changed
+1063 -6

No files matched your search

+206 -2
View File
@@ -7,6 +7,7 @@ use crate::cloud::{date_string, duration_string, Cloud};
use crate::game::Goal;
use crate::gfx::*;
use crate::i18n::{languages, tr, trf};
use crate::lobby::MatchUi;
use crate::map::{MAX_SIZE, MIN_SIZE};
use crate::settings::{all_actions, key_name, Action, Settings};
use crate::ui::{grid_center, grid_center_y, mouse, sh, sw, tool_info, wrap, CH, CW, DIM, FRAME, FRAME_HI, GOLD, S, SMALL, TITLE, TOOLS, TXT};
@@ -26,6 +27,7 @@ pub enum Page {
Briefing { mission: usize, ingame: bool }, // story and goals of a mission
Login, // login to the savegame service (before the title)
Account, // password, logout, users (admin)
Match, // multiplayer lobby and match status
Bestiary { tab: usize, sel: usize, level: u32, from_title: bool }, // units, buildings, ranks
}
@@ -221,6 +223,17 @@ enum Btn {
Logout,
CreateUser,
DeleteUser(usize),
Match,
MatchJoin,
MatchJoinRoom(usize),
MatchCreate,
MatchTake(usize),
MatchReady,
MatchStart,
MatchResume,
MatchAbort,
MatchLeave,
MatchChat,
Bestiary,
BestTab(usize),
BestSel(i32),
@@ -287,6 +300,17 @@ pub enum MenuAction {
DeleteUser(String),
DownloadStandalone,
EditField(usize), // touch: ask for the text with the browser dialog
OpenMatch,
MatchJoin(String),
MatchCreate,
MatchTakeSide(u8),
MatchReady(bool),
MatchStart,
MatchResume,
MatchAbort,
MatchLeave,
MatchChat(String),
MatchEditField(usize),
}
pub struct Menu {
@@ -298,6 +322,7 @@ pub struct Menu {
pub fields: [String; 4], // text fields of the login and account pages
pub focus: usize, // field that gets the typed text
pub error: Option<String>, // message on the login and account pages
pub match_ui: MatchUi, // copied state of the multiplayer relay, shown on the Match page
confirm: Option<Btn>, // overwrite / delete waiting for a second click
scroll: usize,
rows_visible: usize,
@@ -322,6 +347,7 @@ impl Menu {
fields: Default::default(),
focus: 0,
error: None,
match_ui: MatchUi::default(),
confirm: None,
scroll: 0,
rows_visible: 1,
@@ -345,6 +371,7 @@ impl Menu {
Some(Page::Campaign) => Some(Page::Title),
Some(Page::Login) => Some(Page::Login),
Some(Page::Account) => Some(Page::Title),
Some(Page::Match) => Some(Page::Title),
Some(Page::Bestiary { from_title: true, .. }) => Some(Page::Title),
Some(Page::Bestiary { from_title: false, .. }) => Some(Page::Pause),
Some(Page::Briefing { ingame: true, .. }) => Some(Page::Pause),
@@ -365,7 +392,7 @@ impl Menu {
// pages with text fields take keyboard input
pub fn typing(&self) -> bool {
matches!(self.page, Some(Page::Saves { save: true, .. }) | Some(Page::Login) | Some(Page::Account))
matches!(self.page, Some(Page::Saves { save: true, .. }) | Some(Page::Login) | Some(Page::Account) | Some(Page::Match))
}
fn input(&mut self) -> &mut String {
@@ -392,6 +419,7 @@ impl Menu {
let n = match self.page {
Some(Page::Login) => 2,
Some(Page::Account) => 4,
Some(Page::Match) => 2,
_ => 1,
};
self.focus = (self.focus + 1) % n;
@@ -401,12 +429,32 @@ impl Menu {
pub fn submit(&mut self) -> MenuAction {
match self.page {
Some(Page::Login) => MenuAction::Login(self.fields[0].trim().to_string(), self.fields[1].clone()),
Some(Page::Match) => {
let action = if self.focus == 1 { MenuAction::MatchChat(self.fields[1].clone()) } else { MenuAction::MatchJoin(self.fields[0].trim().to_string()) };
if self.focus == 1 {
self.fields[1].clear();
}
action
}
Some(Page::Account) if self.focus < 2 => MenuAction::ChangePassword(self.fields[0].clone(), self.fields[1].clone()),
Some(Page::Account) => MenuAction::CreateUser(self.fields[2].trim().to_string(), self.fields[3].clone()),
_ => MenuAction::None,
}
}
pub fn open_match(&mut self) {
self.page = Some(Page::Match);
self.fields[1].clear();
self.focus = 0;
self.error = None;
self.confirm = None;
self.scroll = 0;
}
pub fn set_match_ui(&mut self, ui: MatchUi) {
self.match_ui = ui;
}
pub fn open_login(&mut self) {
self.page = Some(Page::Login);
self.fields = Default::default();
@@ -526,7 +574,11 @@ impl Menu {
Some(Btn::ClaudeCode) => MenuAction::OpenClaudeCode,
Some(Btn::Field(i)) => {
self.focus = i;
MenuAction::EditField(i)
if matches!(self.page, Some(Page::Match)) {
MenuAction::MatchEditField(i)
} else {
MenuAction::EditField(i)
}
}
Some(Btn::DoLogin) => MenuAction::Login(self.fields[0].trim().to_string(), self.fields[1].clone()),
Some(Btn::Account) => {
@@ -548,6 +600,37 @@ impl Menu {
}
None => MenuAction::Consumed,
},
Some(Btn::Match) => {
self.open_match();
MenuAction::OpenMatch
}
Some(Btn::MatchJoin) => MenuAction::MatchJoin(self.fields[0].trim().to_string()),
Some(Btn::MatchJoinRoom(i)) => match self.match_ui.rooms.get(i) {
Some(room) => MenuAction::MatchJoin(room.code.clone()),
None => MenuAction::Consumed,
},
Some(Btn::MatchCreate) => MenuAction::MatchCreate,
Some(Btn::MatchReady) => MenuAction::MatchReady(!self.match_ui.ready),
Some(Btn::MatchStart) => MenuAction::MatchStart,
Some(Btn::MatchResume) => MenuAction::MatchResume,
Some(b @ Btn::MatchAbort) => {
if confirm == Some(b) {
MenuAction::MatchAbort
} else {
self.confirm = Some(b);
MenuAction::Consumed
}
}
Some(Btn::MatchLeave) => MenuAction::MatchLeave,
Some(Btn::MatchChat) => {
let text = self.fields[1].clone();
self.fields[1].clear();
MenuAction::MatchChat(text)
}
Some(Btn::MatchTake(i)) => match self.match_ui.seats.get(i) {
Some(s) => MenuAction::MatchTakeSide(s.side),
None => MenuAction::Consumed,
},
Some(Btn::Campaign) => {
self.page = Some(Page::Campaign);
self.scroll = 0;
@@ -597,6 +680,7 @@ impl Menu {
Page::Campaign => self.draw_campaign(gfx),
Page::Login => self.draw_login(gfx, cloud),
Page::Account => self.draw_account(gfx, cloud),
Page::Match => self.draw_match(gfx, cloud),
Page::Briefing { mission, ingame } => self.draw_briefing(gfx, mission, ingame),
Page::Bestiary { tab, sel, level, .. } => self.draw_bestiary(gfx, tab, sel, level),
Page::KeyCapture { action, .. } => {
@@ -653,6 +737,7 @@ impl Menu {
items.push((Btn::Continue, tr("menu.continue")));
}
items.push((Btn::Campaign, tr("menu.campaign")));
items.push((Btn::Match, tr("menu.multiplayer")));
items.push((Btn::New, tr("menu.new")));
items.push((Btn::Load, tr("menu.load")));
items.push((Btn::Settings, tr("menu.settings")));
@@ -759,6 +844,7 @@ impl Menu {
items.extend([
(Btn::Save, tr("menu.save")),
(Btn::Load, tr("menu.load")),
(Btn::Match, tr("menu.multiplayer")),
(Btn::Settings, tr("menu.settings")),
(Btn::Bestiary, tr("menu.bestiary")),
(Btn::Help, tr("menu.help")),
@@ -1239,6 +1325,124 @@ impl Menu {
button(gfx, lr, &lo, lr.contains(vec2(mx, my)), FRAME);
self.buttons.push((lr, Btn::Logout));
}
fn draw_match(&mut self, gfx: &Gfx, _cloud: &Cloud) {
let sw = sw();
let sh = sh();
let (mx, my) = mouse();
let ui = self.match_ui.clone();
let cols = ((sw / CW()) as i32 - 2).min(72);
let rows = ((sh / CH()) as i32 - 2).min(38);
let x = grid_center(sw, cols as f32 * CW()).max(0.0);
let y = grid_center_y(sh, rows as f32 * CH()).max(0.0);
let ix = x + CW() * 3.0;
let w = (cols - 6) as f32 * CW();
gfx.window_titled(x, y, cols, rows, S(), true, FRAME_HI, Color::new(0.05, 0.03, 0.04, 0.97), &tr("net.title"), FRAME_HI);
let head = match ui.phase {
crate::lobby::Phase::Offline => tr("net.status_offline"),
crate::lobby::Phase::Lobby => tr("net.status_lobby"),
crate::lobby::Phase::InLobby => trf("net.status_lobby_room", &[ui.code.clone().unwrap_or_default()]),
crate::lobby::Phase::Playing => trf("net.status_playing", &[ui.code.clone().unwrap_or_default()]),
crate::lobby::Phase::Closed => trf("net.status_closed", &[ui.status.clone().unwrap_or_else(|| "?".into())]),
};
gfx.text(&head, ix, y + CH() * 1.6, S(), GOLD);
let state = format!("{} · {} · {}", ui.name, ui.turn.to_string(), if ui.paused { tr("net.paused") } else if ui.missing { tr("net.missing") } else { String::new() });
gfx.text(&state, ix, y + CH() * 3.0, SMALL(), DIM);
let mut yy = y + CH() * 4.5;
if let Some(e) = &self.error {
gfx.text(e, ix, yy, SMALL(), Color::new(0.95, 0.4, 0.35, 1.0));
yy += CH() * 1.6;
} else if let Some(s) = &ui.status {
gfx.text(s, ix, yy, SMALL(), DIM);
yy += CH() * 1.6;
}
self.field(gfx, ix, yy, w, &tr("net.join_hint"), 0, false);
self.field(gfx, ix, yy + CH() * 2.4, w, &tr("net.chat"), 1, false);
yy += CH() * 4.8;
let mut items: Vec<(Btn, String)> = Vec::new();
if !ui.started {
items.push((Btn::MatchCreate, tr("net.create")));
items.push((Btn::MatchJoin, tr("net.join")));
if ui.room_open() {
items.push((Btn::MatchReady, tr(if ui.ready { "net.unready" } else { "net.ready" })));
if ui.is_host {
items.push((Btn::MatchStart, tr("net.start")));
}
}
} else {
items.push((Btn::MatchChat, tr("net.send")));
if ui.is_host {
if ui.paused || ui.missing {
items.push((Btn::MatchResume, tr("net.resume")));
}
items.push((Btn::MatchAbort, tr("net.abort")));
}
}
if ui.room_open() {
items.push((Btn::MatchLeave, tr("net.leave")));
}
items.push((Btn::Back, tr("menu.back")));
let bw = 14.0 * CW();
for (k, (b, label)) in items.iter().take(6).enumerate() {
let r = Rect::new(ix + (k % 3) as f32 * (bw + CW()), yy + (k / 3) as f32 * CH() * 2.4, bw, CH() * 2.0);
button(gfx, r, label, r.contains(vec2(mx, my)), FRAME);
self.buttons.push((r, *b));
}
yy += CH() * 5.4;
if !ui.rooms.is_empty() && !ui.started {
gfx.text(&tr("net.rooms"), ix, yy, S(), GOLD);
yy += CH() * 1.4;
for (k, room) in ui.rooms.iter().take(4).enumerate() {
let label = format!("{} {}{}{}", room.code, room.players, if room.started { " *" } else { "" }, if room.paused { " p" } else { "" });
let r = Rect::new(ix, yy, 24.0 * CW(), CH() * 2.0);
button(gfx, r, &label, r.contains(vec2(mx, my)), FRAME);
self.buttons.push((r, Btn::MatchJoinRoom(k)));
yy += CH() * 2.2;
}
} else if ui.rooms.is_empty() && !ui.started {
gfx.text(&tr("net.no_rooms"), ix, yy, SMALL(), DIM);
yy += CH() * 2.0;
}
if ui.room_open() {
gfx.text(&tr("net.seats"), ix, yy, S(), GOLD);
yy += CH() * 1.4;
let seat_cols = (cols - 6) as usize;
for (k, s) in ui.seats.iter().take(4).enumerate() {
let name = if Some(s.side) == ui.local_side { format!("{} ({})", s.name, tr("net.you")) } else { s.name.clone() };
let side_label = match s.side {
0 => tr("net.side0"),
2 => tr("net.side2"),
3 => tr("net.side3"),
4 => tr("net.side4"),
_ => s.side.to_string(),
};
let line = format!("{} {} {} {} {}", side_label, name, if s.connected { "●" } else { "○" }, if s.ready { "✓" } else { "—" }, if s.side == 0 { tr("net.host") } else { String::new() });
gfx.text(&line, ix, yy, SMALL(), if s.connected { TXT } else { DIM });
if !ui.started && ui.local_side.is_some() && !s.connected {
let r = Rect::new(ix + 34.0 * CW(), yy, 12.0 * CW(), CH() * 2.0);
let label = if Some(s.side) == ui.local_side { tr("net.you") } else { tr("net.join") };
button(gfx, r, &label, r.contains(vec2(mx, my)), FRAME);
self.buttons.push((r, Btn::MatchTake(k)));
}
yy += CH() * 2.0;
}
let _ = seat_cols;
}
if !ui.chat.is_empty() {
yy = (y + (rows - 5) as f32 * CH()).max(yy);
gfx.text(&tr("net.chat"), ix, yy, SMALL(), DIM);
for (name, text) in ui.chat.iter().rev().take(3) {
gfx.text(&format!("{}: {}", name, text), ix, yy + SMALL() * GH * 1.2, SMALL(), TXT);
yy += SMALL() * GH * 1.2;
}
}
}
}
// description of a mission goal (without progress)