diff --git a/VERSION b/VERSION index 869c71b..096f277 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -v202611-00006 +v202611-00007 diff --git a/lang/de.lang b/lang/de.lang index c7d9dcf..1712c50 100644 --- a/lang/de.lang +++ b/lang/de.lang @@ -605,6 +605,38 @@ owner.rival1 = gehört Mossgrim, dem grünen Meister owner.rival2 = gehört Lady Nightwell, der blauen Meisterin owner.rival3 = gehört dem gelben Meister net.desync = Die Partie ist auseinandergegangen: dieser Rechner sieht einen anderen Dungeon als die anderen. +menu.multiplayer = Mehrspieler +net.title = Mehrspielerpartien +net.status_offline = Keine Verbindung zum Partie-Dienst. +net.status_connecting = Verbindung zum Partie-Dienst wird aufgebaut... +net.status_lobby = Lobby +net.status_lobby_room = Partie {0} +net.status_playing = Partie {0} läuft +net.status_closed = Partie beendet: {0} +net.error = {0} +net.rooms = Offene Partien +net.no_rooms = Keine offenen Partien. +net.create = Partie erstellen +net.join = Beitreten +net.join_hint = Partiecode +net.seats = Plätze +net.turn = Runde {0} +net.paused = pausiert +net.missing = eine Seite fehlt +net.ready = Bereit +net.unready = Nicht bereit +net.start = Partie starten +net.resume = Fortsetzen +net.abort = Abbrechen +net.leave = Verlassen +net.chat = Chat +net.send = Senden +net.you = du +net.host = Gastgeber +net.side0 = Rot +net.side2 = Blau +net.side3 = Grün +net.side4 = Gelb # rival masters (free game) newgame.difficulty = Gegner diff --git a/lang/en.lang b/lang/en.lang index 52da546..b83f072 100644 --- a/lang/en.lang +++ b/lang/en.lang @@ -606,6 +606,38 @@ owner.rival1 = held by Mossgrim, the green master owner.rival2 = held by Lady Nightwell, the blue master owner.rival3 = held by the yellow master net.desync = The match has diverged: this computer sees a different dungeon than the others. +menu.multiplayer = Multiplayer +net.title = Multiplayer matches +net.status_offline = No relay connection. +net.status_connecting = Connecting to the match relay... +net.status_lobby = Lobby +net.status_lobby_room = Match {0} +net.status_playing = Playing match {0} +net.status_closed = Match closed: {0} +net.error = {0} +net.rooms = Open matches +net.no_rooms = No open matches. +net.create = Create match +net.join = Join +net.join_hint = Match code +net.seats = Seats +net.turn = Turn {0} +net.paused = paused +net.missing = a side is missing +net.ready = Ready +net.unready = Not ready +net.start = Start match +net.resume = Resume +net.abort = Abort +net.leave = Leave +net.chat = Chat +net.send = Send +net.you = you +net.host = host +net.side0 = Red +net.side2 = Blue +net.side3 = Green +net.side4 = Yellow # rival masters (free game) newgame.difficulty = Opponents diff --git a/src/lobby.rs b/src/lobby.rs new file mode 100644 index 0000000..905602f --- /dev/null +++ b/src/lobby.rs @@ -0,0 +1,493 @@ +// Multiplayer step C (client): the browser-side lobby and turn bridge. +// +// The WebSocket itself is opened by the browser plugin (`dc_ws_*` in www/dc_plugin.js). This module +// only speaks the relay protocol from server/src/netplay.rs, turns `Command`s into JSON and feeds the +// game with confirmed turns. It can also run without a socket in tests: requests go into `outbox`, +// server answers are put back with `feed`. + +#![allow(dead_code)] + +use crate::commands::Command; +use serde_json::{json, Value}; +use std::collections::VecDeque; + +const MAX_CHAT: usize = 80; + +#[derive(Clone, Copy, PartialEq, Eq, Debug, Default)] +pub enum Phase { + #[default] + Offline, + Lobby, + InLobby, + Playing, + Closed, +} + +#[derive(Clone, Debug, Default)] +pub struct SeatUi { + pub side: u8, + pub name: String, + pub ready: bool, + pub connected: bool, +} + +#[derive(Clone, Debug, Default)] +pub struct RoomUi { + pub code: String, + pub players: usize, + pub sides: Vec, + pub started: bool, + pub paused: bool, +} + +#[derive(Clone, Debug, Default)] +pub struct MatchUi { + pub phase: Phase, + pub connected: bool, + pub name: String, + pub code: Option, + pub local_side: Option, + pub ready: bool, + pub is_host: bool, + pub sides: Vec, + pub seats: Vec, + pub rooms: Vec, + pub started: bool, + pub paused: bool, + pub missing: bool, + pub turn: u32, + pub error: Option, + pub status: Option, + pub chat: Vec<(String, String)>, +} + +impl MatchUi { + pub fn room_open(&self) -> bool { + self.code.is_some() || !self.seats.is_empty() + } +} + +#[derive(Clone, Debug)] +pub struct StartInfo { + pub seed: u32, + pub map_w: usize, + pub map_h: usize, + pub difficulty: u8, + pub sides: Vec, + pub local_side: Option, +} + +#[derive(Clone, Debug, Default)] +struct SeatInfo { + side: u8, + name: String, + ready: bool, + connected: bool, +} + +#[derive(Clone, Debug, Default)] +struct RoomInfo { + code: String, + sides: Vec, + seats: Vec, + turn: u32, + started: bool, + paused: bool, + missing: bool, +} + +#[derive(Clone, Debug)] +pub struct MatchClient { + pub name: String, + pub connected: bool, + pub outbox: VecDeque, + code: Option, + room: Option, + rooms: Vec, + local_side: Option, + error: Option, + status: Option, + chat: Vec<(String, String)>, + start: Option, + turns: Vec<(u32, Vec)>, + checks: Vec<(Option, u32, u64)>, +} + +impl MatchClient { + pub fn new() -> Self { + MatchClient { + name: "Guest".into(), + connected: false, + outbox: VecDeque::new(), + code: None, + room: None, + rooms: Vec::new(), + local_side: None, + error: None, + status: None, + chat: Vec::new(), + start: None, + turns: Vec::new(), + checks: Vec::new(), + } + } + + pub fn phase(&self) -> Phase { + if !self.connected { + return Phase::Offline; + } + match &self.room { + Some(r) if r.started => Phase::Playing, + Some(_) => Phase::InLobby, + None if self.error.is_some() || self.status.as_deref().map(|s| s != "offline").unwrap_or(false) => Phase::Closed, + None => Phase::Lobby, + } + } + + pub fn on_connect(&mut self, name: &str) { + self.connected = true; + self.name = name.to_string(); + self.status = None; + self.rooms_request(); + } + + pub fn on_disconnect(&mut self) { + self.connected = false; + self.room = None; + self.code = None; + self.local_side = None; + self.start = None; + self.turns.clear(); + self.status = Some("offline".into()); + } + + pub fn rooms_request(&mut self) { + self.outbox.push_back(json!({"t":"rooms"}).to_string()); + } + + pub fn create(&mut self, sides: &[u8], map_w: usize, map_h: usize, difficulty: u8) { + if !self.connected { + return; + } + self.error = None; + self.status = None; + self.chat.clear(); + self.outbox.push_back(json!({"t":"create","sides":sides,"map_w":map_w,"map_h":map_h,"difficulty":difficulty}).to_string()); + } + + pub fn join(&mut self, code: &str) { + if !self.connected { + return; + } + let code = code.trim().to_uppercase(); + if code.len() != 6 { + self.error = Some("match code needs 6 characters".into()); + return; + } + self.error = None; + self.status = None; + self.outbox.push_back(json!({"t":"join","code":code}).to_string()); + } + + pub fn take_side(&mut self, side: u8) { + if !self.connected { + return; + } + self.outbox.push_back(json!({"t":"take_side","side":side}).to_string()); + } + + pub fn ready(&mut self, ready: bool) { + if !self.connected { + return; + } + self.outbox.push_back(json!({"t":"ready","ready":ready}).to_string()); + } + + pub fn start(&mut self) { + if !self.connected { + return; + } + self.outbox.push_back(json!({"t":"start"}).to_string()); + } + + pub fn resume(&mut self) { + if !self.connected { + return; + } + self.outbox.push_back(json!({"t":"resume"}).to_string()); + } + + pub fn abort(&mut self) { + if !self.connected { + return; + } + self.outbox.push_back(json!({"t":"abort"}).to_string()); + } + + pub fn leave(&mut self) { + if !self.connected { + return; + } + self.outbox.push_back(json!({"t":"leave"}).to_string()); + } + + pub fn chat(&mut self, text: &str) { + let text: String = text.trim().chars().take(MAX_CHAT).collect(); + if !self.connected || text.is_empty() { + return; + } + self.outbox.push_back(json!({"t":"chat","text":text}).to_string()); + } + + pub fn send_commands(&mut self, cmds: &[Command]) { + if !self.connected || cmds.is_empty() { + return; + } + self.outbox.push_back(json!({"t":"cmds","cmds":cmds}).to_string()); + } + + pub fn send_checksum(&mut self, turn: u32, sum: u64) { + if !self.connected { + return; + } + self.outbox.push_back(json!({"t":"sum","turn":turn,"sum":sum}).to_string()); + } + + pub fn feed(&mut self, text: &str) { + let v: Value = match serde_json::from_str(text) { + Ok(v) => v, + Err(_) => return, + }; + match v.get("t").and_then(|x| x.as_str()).unwrap_or("") { + "hello" => { + if let Some(n) = v.get("name").and_then(|x| x.as_str()) { + self.name = n.to_string(); + } + } + "rooms" => self.parse_rooms(&v), + "room" => self.parse_room(&v), + "start" => self.parse_start(&v), + "turn" => self.parse_turn(&v), + "sum" => self.parse_sum(&v), + "chat" => { + let name = v.get("name").and_then(|x| x.as_str()).unwrap_or("?").to_string(); + let text = v.get("text").and_then(|x| x.as_str()).unwrap_or("").to_string(); + self.chat.push((name, text)); + while self.chat.len() > 40 { + self.chat.remove(0); + } + } + "closed" => { + self.status = Some(v.get("why").and_then(|x| x.as_str()).unwrap_or("closed").to_string()); + self.room = None; + self.code = None; + self.local_side = None; + self.start = None; + self.turns.clear(); + } + "error" => { + self.error = Some(v.get("why").and_then(|x| x.as_str()).unwrap_or("error").to_string()); + } + _ => {} + } + } + + pub fn take_start(&mut self) -> Option { + self.start.take() + } + + pub fn take_turns(&mut self) -> Vec<(u32, Vec)> { + std::mem::take(&mut self.turns) + } + + pub fn take_checks(&mut self) -> Vec<(Option, u32, u64)> { + std::mem::take(&mut self.checks) + } + + fn parse_rooms(&mut self, v: &Value) { + let Some(arr) = v.get("rooms").and_then(|x| x.as_array()) else { return }; + self.rooms = arr + .iter() + .map(|r| RoomUi { + code: r.get("code").and_then(|x| x.as_str()).unwrap_or("").to_string(), + players: r.get("players").and_then(|x| x.as_u64()).unwrap_or(0) as usize, + sides: array_bytes(r.get("sides")), + started: r.get("started").and_then(|x| x.as_bool()).unwrap_or(false), + paused: r.get("paused").and_then(|x| x.as_bool()).unwrap_or(false), + }) + .collect(); + } + + fn parse_room(&mut self, v: &Value) { + let mut room = self.room.take().unwrap_or_default(); + if let Some(code) = v.get("code").and_then(|x| x.as_str()) { + room.code = code.to_string(); + self.code = Some(code.to_string()); + } + let sides = array_bytes(v.get("sides")); + if !sides.is_empty() { + room.sides = sides; + } + let seats = seats_from(v.get("seats")); + if !seats.is_empty() { + room.seats = seats; + } + if let Some(x) = v.get("turn").and_then(|x| x.as_u64()) { + room.turn = x as u32; + } + if let Some(x) = v.get("started").and_then(|x| x.as_bool()) { + room.started = x; + } + if let Some(x) = v.get("paused").and_then(|x| x.as_bool()) { + room.paused = x; + } + if let Some(x) = v.get("missing").and_then(|x| x.as_bool()) { + room.missing = x; + } + if let Some(why) = v.get("why").and_then(|x| x.as_str()) { + self.status = Some(why.to_string()); + } + self.local_side = room.seats.iter().find(|s| s.name == self.name && s.connected).map(|s| s.side); + if !room.code.is_empty() || !room.seats.is_empty() { + self.room = Some(room); + } else { + self.room = None; + } + } + + fn parse_start(&mut self, v: &Value) { + let seed = v.get("seed").and_then(|x| x.as_u64()).unwrap_or(0) as u32; + let map_w = v.get("map_w").and_then(|x| x.as_u64()).unwrap_or(96) as usize; + let map_h = v.get("map_h").and_then(|x| x.as_u64()).unwrap_or(128) as usize; + let difficulty = v.get("difficulty").and_then(|x| x.as_u64()).unwrap_or(1) as u8; + let sides = array_bytes(v.get("sides")); + let local_side = self.room.as_ref().and_then(|r| r.seats.iter().find(|s| s.name == self.name && s.connected).map(|s| s.side)); + self.local_side = local_side; + if let Some(r) = self.room.as_mut() { + r.started = true; + r.paused = false; + r.missing = false; + r.turn = 0; + if !sides.is_empty() { + r.sides = sides.clone(); + } + } + self.start = Some(StartInfo { seed, map_w, map_h, difficulty, sides, local_side }); + self.error = None; + self.status = None; + } + + fn parse_turn(&mut self, v: &Value) { + let turn = v.get("turn").and_then(|x| x.as_u64()).unwrap_or(0) as u32; + if let Some(r) = self.room.as_mut() { + r.turn = turn; + } + let cmds = match v.get("cmds").cloned() { + Some(v) => serde_json::from_value::>(v).unwrap_or_default(), + None => Vec::new(), + }; + self.turns.push((turn, cmds)); + } + + fn parse_sum(&mut self, v: &Value) { + let turn = v.get("turn").and_then(|x| x.as_u64()).unwrap_or(0) as u32; + let sum = match v.get("sum").and_then(|x| x.as_u64()) { + Some(s) => s, + None => return, + }; + let from = v.get("from").and_then(|x| x.as_u64()).map(|n| n as u8); + self.checks.push((from, turn, sum)); + } + + pub fn ui(&self) -> MatchUi { + let room = self.room.clone().unwrap_or_default(); + let local = self.local_side; + let ready = room.seats.iter().find(|s| Some(s.side) == local).map(|s| s.ready).unwrap_or(false); + let host = room.seats.iter().find(|s| s.side == 0); + let is_host = local == Some(0) && host.map(|s| s.connected).unwrap_or(false); + MatchUi { + phase: self.phase(), + connected: self.connected, + name: self.name.clone(), + code: room.code.is_empty().then(|| self.code.clone()).unwrap_or(Some(room.code.clone())).clone(), + local_side: local, + ready, + is_host, + sides: room.sides.clone(), + seats: room.seats.iter().map(|s| SeatUi { side: s.side, name: s.name.clone(), ready: s.ready, connected: s.connected }).collect(), + rooms: self.rooms.clone(), + started: room.started, + paused: room.paused, + missing: room.missing, + turn: room.turn, + error: self.error.clone(), + status: self.status.clone(), + chat: self.chat.clone(), + } + } +} + +fn array_bytes(v: Option<&Value>) -> Vec { + v.and_then(|x| x.as_array()) + .map(|a| a.iter().filter_map(|x| x.as_u64().map(|n| n as u8)).collect()) + .unwrap_or_default() +} + +fn seats_from(v: Option<&Value>) -> Vec { + v.and_then(|x| x.as_array()) + .map(|arr| { + arr.iter() + .map(|s| SeatInfo { + side: s.get("side").and_then(|x| x.as_u64()).unwrap_or(0) as u8, + name: s.get("name").and_then(|x| x.as_str()).unwrap_or("").to_string(), + ready: s.get("ready").and_then(|x| x.as_bool()).unwrap_or(false), + connected: s.get("connected").and_then(|x| x.as_bool()).unwrap_or(false), + }) + .collect() + }) + .unwrap_or_default() +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::commands::{Act, Rect}; + + fn pop(client: &mut MatchClient) -> Value { + let s = client.outbox.pop_front().expect("no request"); + serde_json::from_str(&s).expect("bad json") + } + + #[test] + fn the_bridge_turns_orders_into_json_and_turns_back_into_commands() { + let mut client = MatchClient::new(); + client.on_connect("red"); + let req = pop(&mut client); // rooms + assert_eq!(req["t"].as_str(), Some("rooms")); + + client.create(&[0, 2], 96, 128, 1); + let req = pop(&mut client); + assert_eq!(req["t"].as_str(), Some("create")); + + client.feed(r#"{"t":"room","code":"ABC123","sides":[0,2],"seats":[{"side":0,"name":"red","ready":false,"connected":true}],"turn":0,"started":false,"paused":false,"missing":false}"#); + client.feed(r#"{"t":"start","seed":7,"map_w":96,"map_h":128,"difficulty":1,"sides":[0,2]}"#); + let start = client.take_start().expect("start info"); + assert_eq!(start.seed, 7); + assert_eq!(start.local_side, Some(0)); + + client.send_commands(&[Command { side: 0, seq: 0, act: Act::Dig(Rect::new(1, 2, 3, 4)) }]); + let req = pop(&mut client); + assert_eq!(req["t"].as_str(), Some("cmds")); + assert_eq!(req["cmds"][0]["side"].as_u64(), Some(0)); + + client.feed(r#"{"t":"turn","turn":1,"cmds":[{"side":0,"seq":0,"act":{"Dig":{"x0":1,"y0":2,"x1":3,"y1":4}}}]}"#); + let turns = client.take_turns(); + assert_eq!(turns.len(), 1); + assert_eq!(turns[0].1[0].side, 0); + assert!(matches!(turns[0].1[0].act, Act::Dig(_))); + + client.feed(r#"{"t":"sum","from":2,"turn":1,"sum":42}"#); + let checks = client.take_checks(); + assert_eq!(checks[0], (Some(2), 1, 42)); + } +} diff --git a/src/main.rs b/src/main.rs index c433ea2..b739055 100644 --- a/src/main.rs +++ b/src/main.rs @@ -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 { diff --git a/src/menu.rs b/src/menu.rs index 837de20..54c1c8f 100644 --- a/src/menu.rs +++ b/src/menu.rs @@ -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, // message on the login and account pages + pub match_ui: MatchUi, // copied state of the multiplayer relay, shown on the Match page confirm: Option, // 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) diff --git a/src/netplay.rs b/src/netplay.rs index 5a044e1..a27eb92 100644 --- a/src/netplay.rs +++ b/src/netplay.rs @@ -14,6 +14,7 @@ pub const STEPS_PER_TURN: usize = 5; pub struct Netplay { pub turn: u32, // turns applied so far pub desync: bool, // a peer reported a different world + pub last_sum: u32, // last turn whose checksum was sent } const FNV_OFFSET: u64 = 0xcbf2_9ce4_8422_2325; @@ -59,6 +60,11 @@ impl Game { self.netplay.is_some() } + // the relay ended a match, or the player leaves it: the world stops being a turn-based match + pub fn end_match(&mut self) { + self.netplay = None; + } + // what the local master ordered since the last turn: these go to the server pub fn take_orders(&mut self) -> Vec { std::mem::take(&mut self.pending) @@ -98,6 +104,27 @@ impl Game { false } + pub fn checksum_due(&mut self) -> Option { + let turn = match &self.netplay { + Some(np) => np.turn, + None => return None, + }; + if turn < 10 { + return None; + } + let due = match &self.netplay { + Some(np) => turn.saturating_sub(np.last_sum) >= 10, + None => false, + }; + if !due { + return None; + } + if let Some(np) = self.netplay.as_mut() { + np.last_sum = turn; + } + Some(turn) + } + // the part of the world every client of a match has to agree on: the map, the creatures, the // economy of every master, the wave and the random state. Fog, particles and the camera are // local and stay out of it. diff --git a/src/platform.rs b/src/platform.rs index 2252fb8..167da92 100644 --- a/src/platform.rs +++ b/src/platform.rs @@ -30,6 +30,11 @@ mod imp { fn dc_http_poll(id: u32) -> i32; fn dc_http_len(id: u32) -> u32; fn dc_http_take(id: u32, dst: *mut u8); + fn dc_ws_connect(u: *const u8, ul: u32) -> i32; + fn dc_ws_state(id: i32) -> i32; + fn dc_ws_send(id: i32, p: *const u8, pl: u32) -> i32; + fn dc_ws_take(id: i32) -> i32; + fn dc_ws_close(id: i32); } // starts a request; poll the returned id with http_poll() @@ -127,6 +132,45 @@ mod imp { Some(buf) } } + + pub fn ws_connect(url: &str) -> i32 { + unsafe { dc_ws_connect(url.as_ptr(), url.len() as u32) } + } + + pub fn ws_state(id: i32) -> i32 { + if id <= 0 { + return 4; + } + unsafe { dc_ws_state(id) } + } + + pub fn ws_send(id: i32, text: &str) -> bool { + if id <= 0 { + return false; + } + unsafe { dc_ws_send(id, text.as_ptr(), text.len() as u32) != 0 } + } + + pub fn ws_recv(id: i32) -> Option { + if id <= 0 { + return None; + } + unsafe { + let n = dc_ws_take(id); + if n <= 0 { + return None; + } + let mut buf = vec![0u8; n as usize]; + dc_storage_take(buf.as_mut_ptr()); + String::from_utf8(buf).ok() + } + } + + pub fn ws_close(id: i32) { + if id > 0 { + unsafe { dc_ws_close(id) } + } + } } #[cfg(not(target_arch = "wasm32"))] @@ -171,6 +215,24 @@ mod imp { pub fn http_poll(_id: u32) -> Option { Some(super::HttpResult { status: 0, body: Vec::new() }) } + // no WebSocket in native builds: multi-user matches need the browser build + pub fn ws_connect(_url: &str) -> i32 { + 0 + } + pub fn ws_state(id: i32) -> i32 { + if id <= 0 { + 0 + } else { + 4 + } + } + pub fn ws_send(_id: i32, _text: &str) -> bool { + false + } + pub fn ws_recv(_id: i32) -> Option { + None + } + pub fn ws_close(_id: i32) {} } pub use imp::*; diff --git a/www/dc_plugin.js b/www/dc_plugin.js index 35c532c..5f750ae 100644 --- a/www/dc_plugin.js +++ b/www/dc_plugin.js @@ -9,6 +9,8 @@ var httpNext = 1; var token = ""; // login session of the savegame service (sent with every request) var fetched = { state: 0 }; // download of a file from the server: 0 idle, 1 running, 2 done, 3 failed + var ws = {}; // socket id -> { state, queue, sock } + var wsNext = 1; function bytes(ptr, len) { return new Uint8Array(wasm_memory.buffer, ptr, len); @@ -120,6 +122,50 @@ bytes(ptr, r.body.length).set(r.body); delete http[id]; }; + // WebSocket for multiplayer matches. state: 0 connecting, 1 open, 2 closed, 3 failed, 4 gone + env.dc_ws_connect = function (up, ul) { + var url = str(up, ul); + if (url.charAt(0) === "/") { + var proto = window.location.protocol === "https:" ? "wss://" : "ws://"; + url = proto + window.location.host + url; + } + var id = wsNext++; + var s = { state: 0, queue: [], sock: null }; + ws[id] = s; + try { + s.sock = new WebSocket(url); + s.sock.onopen = function () { s.state = 1; }; + s.sock.onmessage = function (e) { + if (typeof e.data === "string" && s.queue.length < 2000) s.queue.push(e.data); + }; + s.sock.onclose = function () { s.state = 2; }; + s.sock.onerror = function () { s.state = 3; }; + } catch (e) { + s.state = 3; + } + return id; + }; + env.dc_ws_state = function (id) { + var s = ws[id]; + return s ? s.state : 4; + }; + env.dc_ws_send = function (id, pp, pl) { + var s = ws[id]; + if (!s || s.state !== 1 || !s.sock) return 0; + try { s.sock.send(str(pp, pl)); return 1; } catch (e) { return 0; } + }; + env.dc_ws_take = function (id) { + var s = ws[id]; + if (!s || !s.queue.length) return -1; + pending = new TextEncoder().encode(s.queue.shift()); + return pending.length; + }; + env.dc_ws_close = function (id) { + var s = ws[id]; + if (!s) return; + try { if (s.sock) s.sock.close(); } catch (e) { } + delete ws[id]; + }; env.dc_storage_get = function (kp, kl) { var v = null; try { v = window.localStorage.getItem(str(kp, kl)); } catch (e) { }