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

+164 -3
View File
@@ -11,6 +11,7 @@ mod fortress;
mod game;
mod gfx;
mod i18n;
mod lobby;
mod map;
mod menu;
mod netplay;
@@ -43,6 +44,7 @@ use gfx::Gfx;
use macroquad::prelude::*;
use map::{inb, mh, mw};
use menu::{Menu, MenuAction, Page};
use lobby::{MatchClient, Phase, StartInfo};
use render3d::{Cam, Overlay, R3d};
use settings::{bindable_keys, Action, Settings};
use sound::SoundBank;
@@ -230,10 +232,49 @@ fn account_action(a: MenuAction, cloud: &mut Cloud, menu: &mut Menu, app: &mut A
}
}
}
MenuAction::OpenMatch => {
if !app.connect_match(cloud) {
menu.error = Some(i18n::tr("net.status_offline"));
}
}
MenuAction::MatchJoin(code) => app.client.join(&code),
MenuAction::MatchCreate => app.client.create(&[0, 2, 3, 4], app.settings.map_w, app.settings.map_h, app.settings.difficulty),
MenuAction::MatchTakeSide(side) => app.client.take_side(side),
MenuAction::MatchReady(ready) => app.client.ready(ready),
MenuAction::MatchStart => app.client.start(),
MenuAction::MatchResume => app.client.resume(),
MenuAction::MatchAbort => {
app.client.abort();
app.match_mode = false;
}
MenuAction::MatchLeave => {
app.client.leave();
app.match_mode = false;
}
MenuAction::MatchChat(text) => app.client.chat(&text),
MenuAction::MatchEditField(i) => {
if ui::touch_ui() {
let title = i18n::tr(if i == 0 { "net.join_hint" } else { "net.chat" });
if let Some(t) = platform::prompt(&title, &menu.fields[i]) {
menu.fields[i] = t.chars().filter(|c| !c.is_control()).take(if i == 0 { 16 } else { 80 }).collect();
}
}
}
_ => {}
}
}
fn url_encode(s: &str) -> String {
let mut out = String::new();
for b in s.bytes() {
match b {
b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'.' | b'_' => out.push(b as char),
_ => out.push_str(&format!("%{b:02X}")),
}
}
out
}
fn next_seed(seed: u32) -> u32 {
seed.wrapping_mul(1103515245).wrapping_add(12345) | 1
}
@@ -336,6 +377,9 @@ struct App {
seed: u32,
status: Option<(String, f64)>,
recorded: bool, // the end of the current mission has been recorded in the campaign progress
client: MatchClient,
ws_id: i32,
match_mode: bool,
}
impl App {
@@ -371,6 +415,92 @@ impl App {
fn autosave_json(&self) -> String {
save::to_json(&self.game, &self.cam, &self.settings)
}
fn connect_match(&mut self, cloud: &Cloud) -> bool {
if self.ws_id > 0 || self.client.connected {
return true;
}
let name = cloud.session.as_ref().map(|s| s.name.clone()).unwrap_or_else(|| "Guest".into());
let token = cloud.session.as_ref().map(|s| s.token.clone()).unwrap_or_default();
let mut url = format!("/api/netplay/ws?name={}", url_encode(&name));
if !token.is_empty() {
url.push_str("&token=");
url.push_str(&url_encode(&token));
}
self.ws_id = platform::ws_connect(&url);
if self.ws_id <= 0 {
return false;
}
self.client.on_connect(&name);
true
}
fn poll_match(&mut self) {
if self.ws_id <= 0 {
if self.client.connected {
self.client.on_disconnect();
}
return;
}
while let Some(msg) = platform::ws_recv(self.ws_id) {
self.client.feed(&msg);
}
if self.client.phase() == Phase::Closed && self.game.in_match() {
self.game.end_match();
self.match_mode = false;
}
let state = platform::ws_state(self.ws_id);
if state == 0 || state == 1 {
while let Some(text) = self.client.outbox.pop_front() {
if !platform::ws_send(self.ws_id, &text) {
self.client.outbox.push_front(text);
break;
}
}
return;
}
platform::ws_close(self.ws_id);
self.ws_id = 0;
self.match_mode = false;
self.client.on_disconnect();
}
fn start_match_game(&mut self, info: StartInfo) -> bool {
if info.map_w < 72 || info.map_h < 72 || !info.sides.contains(&0) {
return false;
}
self.game = Game::new(info.seed, info.map_w, info.map_h);
let local = info.local_side.unwrap_or(0);
if !self.game.start_match(&info.sides, local) {
return false;
}
self.game.hints = self.settings.hints;
let (cx, cy) = self.game.map.core;
self.cam = Cam::new(cx as f32 + 1.5, cy as f32 + 1.5);
self.match_mode = true;
self.seed = info.seed.wrapping_mul(31) | 1;
true
}
fn tick_match_game(&mut self) {
if !self.match_mode || !self.game.in_match() {
return;
}
for (_, cmds) in self.client.take_turns() {
self.game.receive_turn(&cmds);
}
for (_, turn, sum) in self.client.take_checks() {
self.game.check_peer(turn, sum);
}
let cmds = self.game.take_orders();
if !cmds.is_empty() {
self.client.send_commands(&cmds);
}
if let Some(turn) = self.game.checksum_due() {
let sum = self.game.checksum();
self.client.send_checksum(turn, sum);
}
}
}
#[macroquad::main(conf)]
@@ -400,7 +530,7 @@ async fn main() {
let seed = (miniquad::date::now() * 1000.0) as u32 | 1;
// behind the title menu: the endless bridge battle
let (game, cam) = Game::new_demo(seed);
let mut app = App { game, cam, settings, seed, status: None, recorded: false };
let mut app = App { game, cam, settings, seed, status: None, recorded: false, client: MatchClient::new(), ws_id: 0, match_mode: false };
menu.progress = platform::storage_get(PROGRESS_KEY).and_then(|s| serde_json::from_str(&s).ok()).unwrap_or_default();
app.game.hints = app.settings.hints;
let mut inp = Input { ldown: None, rdown: None, rlast: (0.0, 0.0), rdrag: false, mlast: None, minimap_drag: false, tlast: None, touch_world: false, inspect: None };
@@ -506,6 +636,25 @@ async fn main() {
_ => {}
}
// ---------------- multiplayer relay: receive messages, send queued requests, update the menu state
app.poll_match();
if matches!(menu.page, Some(Page::Match)) {
menu.set_match_ui(app.client.ui());
}
if let Some(info) = app.client.take_start() {
if app.start_match_game(info) {
menu.page = None;
menu.error = None;
ui.log.clear();
autosave_t = 0.0;
cache_t = 0.0;
app.set_status(i18n::tr("net.title"), now);
} else {
menu.error = Some(i18n::tr("net.status_closed"));
app.match_mode = false;
}
}
// ---------------- text fields (savegame name, login, account)
if menu.typing() {
// Tab switches the field right away (the following characters go to the next one)
@@ -831,7 +980,18 @@ async fn main() {
| MenuAction::CreateUser(..)
| MenuAction::DeleteUser(..)
| MenuAction::DownloadStandalone
| MenuAction::EditField(..)) => account_action(a, &mut cloud, &mut menu, &mut app, now),
| MenuAction::EditField(..)
| MenuAction::OpenMatch
| MenuAction::MatchJoin(..)
| MenuAction::MatchCreate
| MenuAction::MatchTakeSide(..)
| MenuAction::MatchReady(..)
| MenuAction::MatchStart
| MenuAction::MatchResume
| MenuAction::MatchAbort
| MenuAction::MatchLeave
| MenuAction::MatchChat(..)
| MenuAction::MatchEditField(..)) => account_action(a, &mut cloud, &mut menu, &mut app, now),
MenuAction::EditName => {
if let Some(n) = platform::prompt(&i18n::tr("saves.name"), &menu.save_name) {
menu.save_name = n.chars().filter(|c| !c.is_control()).take(40).collect();
@@ -991,8 +1151,9 @@ async fn main() {
if playing {
// the world runs in fixed steps: the speed setting only feeds more time into them
app.game.update(dt * app.settings.game_speed.max(1.0));
app.tick_match_game();
// autosave: snapshot for leaving the tab + regular autosave
if app.game.state == 0 {
if app.game.state == 0 && !app.match_mode {
cache_t += dt;
autosave_t += dt;
if cache_t >= AUTOSAVE_CACHE_EVERY {