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
+1062 -5

No files matched your search

+1 -1
View File
@@ -1 +1 @@
v202611-00006 v202611-00007
+32
View File
@@ -605,6 +605,38 @@ owner.rival1 = gehört Mossgrim, dem grünen Meister
owner.rival2 = gehört Lady Nightwell, der blauen Meisterin owner.rival2 = gehört Lady Nightwell, der blauen Meisterin
owner.rival3 = gehört dem gelben Meister owner.rival3 = gehört dem gelben Meister
net.desync = Die Partie ist auseinandergegangen: dieser Rechner sieht einen anderen Dungeon als die anderen. 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) # rival masters (free game)
newgame.difficulty = Gegner newgame.difficulty = Gegner
+32
View File
@@ -606,6 +606,38 @@ owner.rival1 = held by Mossgrim, the green master
owner.rival2 = held by Lady Nightwell, the blue master owner.rival2 = held by Lady Nightwell, the blue master
owner.rival3 = held by the yellow master owner.rival3 = held by the yellow master
net.desync = The match has diverged: this computer sees a different dungeon than the others. 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) # rival masters (free game)
newgame.difficulty = Opponents newgame.difficulty = Opponents
+493
View File
@@ -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<u8>,
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<String>,
pub local_side: Option<u8>,
pub ready: bool,
pub is_host: bool,
pub sides: Vec<u8>,
pub seats: Vec<SeatUi>,
pub rooms: Vec<RoomUi>,
pub started: bool,
pub paused: bool,
pub missing: bool,
pub turn: u32,
pub error: Option<String>,
pub status: Option<String>,
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<u8>,
pub local_side: Option<u8>,
}
#[derive(Clone, Debug, Default)]
struct SeatInfo {
side: u8,
name: String,
ready: bool,
connected: bool,
}
#[derive(Clone, Debug, Default)]
struct RoomInfo {
code: String,
sides: Vec<u8>,
seats: Vec<SeatInfo>,
turn: u32,
started: bool,
paused: bool,
missing: bool,
}
#[derive(Clone, Debug)]
pub struct MatchClient {
pub name: String,
pub connected: bool,
pub outbox: VecDeque<String>,
code: Option<String>,
room: Option<RoomInfo>,
rooms: Vec<RoomUi>,
local_side: Option<u8>,
error: Option<String>,
status: Option<String>,
chat: Vec<(String, String)>,
start: Option<StartInfo>,
turns: Vec<(u32, Vec<Command>)>,
checks: Vec<(Option<u8>, 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<StartInfo> {
self.start.take()
}
pub fn take_turns(&mut self) -> Vec<(u32, Vec<Command>)> {
std::mem::take(&mut self.turns)
}
pub fn take_checks(&mut self) -> Vec<(Option<u8>, 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::<Vec<Command>>(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<u8> {
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<SeatInfo> {
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));
}
}
+164 -3
View File
@@ -11,6 +11,7 @@ mod fortress;
mod game; mod game;
mod gfx; mod gfx;
mod i18n; mod i18n;
mod lobby;
mod map; mod map;
mod menu; mod menu;
mod netplay; mod netplay;
@@ -43,6 +44,7 @@ use gfx::Gfx;
use macroquad::prelude::*; use macroquad::prelude::*;
use map::{inb, mh, mw}; use map::{inb, mh, mw};
use menu::{Menu, MenuAction, Page}; use menu::{Menu, MenuAction, Page};
use lobby::{MatchClient, Phase, StartInfo};
use render3d::{Cam, Overlay, R3d}; use render3d::{Cam, Overlay, R3d};
use settings::{bindable_keys, Action, Settings}; use settings::{bindable_keys, Action, Settings};
use sound::SoundBank; 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 { fn next_seed(seed: u32) -> u32 {
seed.wrapping_mul(1103515245).wrapping_add(12345) | 1 seed.wrapping_mul(1103515245).wrapping_add(12345) | 1
} }
@@ -336,6 +377,9 @@ struct App {
seed: u32, seed: u32,
status: Option<(String, f64)>, status: Option<(String, f64)>,
recorded: bool, // the end of the current mission has been recorded in the campaign progress 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 { impl App {
@@ -371,6 +415,92 @@ impl App {
fn autosave_json(&self) -> String { fn autosave_json(&self) -> String {
save::to_json(&self.game, &self.cam, &self.settings) 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)] #[macroquad::main(conf)]
@@ -400,7 +530,7 @@ async fn main() {
let seed = (miniquad::date::now() * 1000.0) as u32 | 1; let seed = (miniquad::date::now() * 1000.0) as u32 | 1;
// behind the title menu: the endless bridge battle // behind the title menu: the endless bridge battle
let (game, cam) = Game::new_demo(seed); 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(); 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; 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 }; 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) // ---------------- text fields (savegame name, login, account)
if menu.typing() { if menu.typing() {
// Tab switches the field right away (the following characters go to the next one) // Tab switches the field right away (the following characters go to the next one)
@@ -831,7 +980,18 @@ async fn main() {
| MenuAction::CreateUser(..) | MenuAction::CreateUser(..)
| MenuAction::DeleteUser(..) | MenuAction::DeleteUser(..)
| MenuAction::DownloadStandalone | 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 => { MenuAction::EditName => {
if let Some(n) = platform::prompt(&i18n::tr("saves.name"), &menu.save_name) { 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(); menu.save_name = n.chars().filter(|c| !c.is_control()).take(40).collect();
@@ -991,8 +1151,9 @@ async fn main() {
if playing { if playing {
// the world runs in fixed steps: the speed setting only feeds more time into them // 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.game.update(dt * app.settings.game_speed.max(1.0));
app.tick_match_game();
// autosave: snapshot for leaving the tab + regular autosave // autosave: snapshot for leaving the tab + regular autosave
if app.game.state == 0 { if app.game.state == 0 && !app.match_mode {
cache_t += dt; cache_t += dt;
autosave_t += dt; autosave_t += dt;
if cache_t >= AUTOSAVE_CACHE_EVERY { if cache_t >= AUTOSAVE_CACHE_EVERY {
+205 -1
View File
@@ -7,6 +7,7 @@ use crate::cloud::{date_string, duration_string, Cloud};
use crate::game::Goal; use crate::game::Goal;
use crate::gfx::*; use crate::gfx::*;
use crate::i18n::{languages, tr, trf}; use crate::i18n::{languages, tr, trf};
use crate::lobby::MatchUi;
use crate::map::{MAX_SIZE, MIN_SIZE}; use crate::map::{MAX_SIZE, MIN_SIZE};
use crate::settings::{all_actions, key_name, Action, Settings}; 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}; 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 Briefing { mission: usize, ingame: bool }, // story and goals of a mission
Login, // login to the savegame service (before the title) Login, // login to the savegame service (before the title)
Account, // password, logout, users (admin) Account, // password, logout, users (admin)
Match, // multiplayer lobby and match status
Bestiary { tab: usize, sel: usize, level: u32, from_title: bool }, // units, buildings, ranks Bestiary { tab: usize, sel: usize, level: u32, from_title: bool }, // units, buildings, ranks
} }
@@ -221,6 +223,17 @@ enum Btn {
Logout, Logout,
CreateUser, CreateUser,
DeleteUser(usize), DeleteUser(usize),
Match,
MatchJoin,
MatchJoinRoom(usize),
MatchCreate,
MatchTake(usize),
MatchReady,
MatchStart,
MatchResume,
MatchAbort,
MatchLeave,
MatchChat,
Bestiary, Bestiary,
BestTab(usize), BestTab(usize),
BestSel(i32), BestSel(i32),
@@ -287,6 +300,17 @@ pub enum MenuAction {
DeleteUser(String), DeleteUser(String),
DownloadStandalone, DownloadStandalone,
EditField(usize), // touch: ask for the text with the browser dialog 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 { pub struct Menu {
@@ -298,6 +322,7 @@ pub struct Menu {
pub fields: [String; 4], // text fields of the login and account pages pub fields: [String; 4], // text fields of the login and account pages
pub focus: usize, // field that gets the typed text pub focus: usize, // field that gets the typed text
pub error: Option<String>, // message on the login and account pages 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 confirm: Option<Btn>, // overwrite / delete waiting for a second click
scroll: usize, scroll: usize,
rows_visible: usize, rows_visible: usize,
@@ -322,6 +347,7 @@ impl Menu {
fields: Default::default(), fields: Default::default(),
focus: 0, focus: 0,
error: None, error: None,
match_ui: MatchUi::default(),
confirm: None, confirm: None,
scroll: 0, scroll: 0,
rows_visible: 1, rows_visible: 1,
@@ -345,6 +371,7 @@ impl Menu {
Some(Page::Campaign) => Some(Page::Title), Some(Page::Campaign) => Some(Page::Title),
Some(Page::Login) => Some(Page::Login), Some(Page::Login) => Some(Page::Login),
Some(Page::Account) => Some(Page::Title), 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: true, .. }) => Some(Page::Title),
Some(Page::Bestiary { from_title: false, .. }) => Some(Page::Pause), Some(Page::Bestiary { from_title: false, .. }) => Some(Page::Pause),
Some(Page::Briefing { ingame: true, .. }) => Some(Page::Pause), Some(Page::Briefing { ingame: true, .. }) => Some(Page::Pause),
@@ -365,7 +392,7 @@ impl Menu {
// pages with text fields take keyboard input // pages with text fields take keyboard input
pub fn typing(&self) -> bool { 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 { fn input(&mut self) -> &mut String {
@@ -392,6 +419,7 @@ impl Menu {
let n = match self.page { let n = match self.page {
Some(Page::Login) => 2, Some(Page::Login) => 2,
Some(Page::Account) => 4, Some(Page::Account) => 4,
Some(Page::Match) => 2,
_ => 1, _ => 1,
}; };
self.focus = (self.focus + 1) % n; self.focus = (self.focus + 1) % n;
@@ -401,12 +429,32 @@ impl Menu {
pub fn submit(&mut self) -> MenuAction { pub fn submit(&mut self) -> MenuAction {
match self.page { match self.page {
Some(Page::Login) => MenuAction::Login(self.fields[0].trim().to_string(), self.fields[1].clone()), 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) 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()), Some(Page::Account) => MenuAction::CreateUser(self.fields[2].trim().to_string(), self.fields[3].clone()),
_ => MenuAction::None, _ => 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) { pub fn open_login(&mut self) {
self.page = Some(Page::Login); self.page = Some(Page::Login);
self.fields = Default::default(); self.fields = Default::default();
@@ -526,8 +574,12 @@ impl Menu {
Some(Btn::ClaudeCode) => MenuAction::OpenClaudeCode, Some(Btn::ClaudeCode) => MenuAction::OpenClaudeCode,
Some(Btn::Field(i)) => { Some(Btn::Field(i)) => {
self.focus = i; self.focus = i;
if matches!(self.page, Some(Page::Match)) {
MenuAction::MatchEditField(i)
} else {
MenuAction::EditField(i) MenuAction::EditField(i)
} }
}
Some(Btn::DoLogin) => MenuAction::Login(self.fields[0].trim().to_string(), self.fields[1].clone()), Some(Btn::DoLogin) => MenuAction::Login(self.fields[0].trim().to_string(), self.fields[1].clone()),
Some(Btn::Account) => { Some(Btn::Account) => {
self.page = Some(Page::Account); self.page = Some(Page::Account);
@@ -548,6 +600,37 @@ impl Menu {
} }
None => MenuAction::Consumed, 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) => { Some(Btn::Campaign) => {
self.page = Some(Page::Campaign); self.page = Some(Page::Campaign);
self.scroll = 0; self.scroll = 0;
@@ -597,6 +680,7 @@ impl Menu {
Page::Campaign => self.draw_campaign(gfx), Page::Campaign => self.draw_campaign(gfx),
Page::Login => self.draw_login(gfx, cloud), Page::Login => self.draw_login(gfx, cloud),
Page::Account => self.draw_account(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::Briefing { mission, ingame } => self.draw_briefing(gfx, mission, ingame),
Page::Bestiary { tab, sel, level, .. } => self.draw_bestiary(gfx, tab, sel, level), Page::Bestiary { tab, sel, level, .. } => self.draw_bestiary(gfx, tab, sel, level),
Page::KeyCapture { action, .. } => { Page::KeyCapture { action, .. } => {
@@ -653,6 +737,7 @@ impl Menu {
items.push((Btn::Continue, tr("menu.continue"))); items.push((Btn::Continue, tr("menu.continue")));
} }
items.push((Btn::Campaign, tr("menu.campaign"))); items.push((Btn::Campaign, tr("menu.campaign")));
items.push((Btn::Match, tr("menu.multiplayer")));
items.push((Btn::New, tr("menu.new"))); items.push((Btn::New, tr("menu.new")));
items.push((Btn::Load, tr("menu.load"))); items.push((Btn::Load, tr("menu.load")));
items.push((Btn::Settings, tr("menu.settings"))); items.push((Btn::Settings, tr("menu.settings")));
@@ -759,6 +844,7 @@ impl Menu {
items.extend([ items.extend([
(Btn::Save, tr("menu.save")), (Btn::Save, tr("menu.save")),
(Btn::Load, tr("menu.load")), (Btn::Load, tr("menu.load")),
(Btn::Match, tr("menu.multiplayer")),
(Btn::Settings, tr("menu.settings")), (Btn::Settings, tr("menu.settings")),
(Btn::Bestiary, tr("menu.bestiary")), (Btn::Bestiary, tr("menu.bestiary")),
(Btn::Help, tr("menu.help")), (Btn::Help, tr("menu.help")),
@@ -1239,6 +1325,124 @@ impl Menu {
button(gfx, lr, &lo, lr.contains(vec2(mx, my)), FRAME); button(gfx, lr, &lo, lr.contains(vec2(mx, my)), FRAME);
self.buttons.push((lr, Btn::Logout)); 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) // description of a mission goal (without progress)
+27
View File
@@ -14,6 +14,7 @@ pub const STEPS_PER_TURN: usize = 5;
pub struct Netplay { pub struct Netplay {
pub turn: u32, // turns applied so far pub turn: u32, // turns applied so far
pub desync: bool, // a peer reported a different world 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; const FNV_OFFSET: u64 = 0xcbf2_9ce4_8422_2325;
@@ -59,6 +60,11 @@ impl Game {
self.netplay.is_some() 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 // what the local master ordered since the last turn: these go to the server
pub fn take_orders(&mut self) -> Vec<Command> { pub fn take_orders(&mut self) -> Vec<Command> {
std::mem::take(&mut self.pending) std::mem::take(&mut self.pending)
@@ -98,6 +104,27 @@ impl Game {
false false
} }
pub fn checksum_due(&mut self) -> Option<u32> {
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 // 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 // economy of every master, the wave and the random state. Fog, particles and the camera are
// local and stay out of it. // local and stay out of it.
+62
View File
@@ -30,6 +30,11 @@ mod imp {
fn dc_http_poll(id: u32) -> i32; fn dc_http_poll(id: u32) -> i32;
fn dc_http_len(id: u32) -> u32; fn dc_http_len(id: u32) -> u32;
fn dc_http_take(id: u32, dst: *mut u8); 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() // starts a request; poll the returned id with http_poll()
@@ -127,6 +132,45 @@ mod imp {
Some(buf) 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<String> {
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"))] #[cfg(not(target_arch = "wasm32"))]
@@ -171,6 +215,24 @@ mod imp {
pub fn http_poll(_id: u32) -> Option<super::HttpResult> { pub fn http_poll(_id: u32) -> Option<super::HttpResult> {
Some(super::HttpResult { status: 0, body: Vec::new() }) 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<String> {
None
}
pub fn ws_close(_id: i32) {}
} }
pub use imp::*; pub use imp::*;
+46
View File
@@ -9,6 +9,8 @@
var httpNext = 1; var httpNext = 1;
var token = ""; // login session of the savegame service (sent with every request) 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 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) { function bytes(ptr, len) {
return new Uint8Array(wasm_memory.buffer, ptr, len); return new Uint8Array(wasm_memory.buffer, ptr, len);
@@ -120,6 +122,50 @@
bytes(ptr, r.body.length).set(r.body); bytes(ptr, r.body.length).set(r.body);
delete http[id]; 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) { env.dc_storage_get = function (kp, kl) {
var v = null; var v = null;
try { v = window.localStorage.getItem(str(kp, kl)); } catch (e) { } try { v = window.localStorage.getItem(str(kp, kl)); } catch (e) { }