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:
1 parent
848ab0f677
commit
583c3fcdaf
9 files changed
+1063
-6
No files matched your search
+206
-2
@@ -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)
|
||||
|
||||
Reference in new issue
Block a user