Multiplayer client relay bridge and match lobby menu: WebSocket bridge, match lobby page, order relay, confirmed turns, checksums, chat, abort/resume
This commit is contained in:
1 parent
848ab0f677
commit
583c3fcdaf
9 files changed
+1063
-6
No files matched your search
@@ -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
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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 {
|
||||||
|
|||||||
+206
-2
@@ -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,7 +574,11 @@ 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;
|
||||||
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::DoLogin) => MenuAction::Login(self.fields[0].trim().to_string(), self.fields[1].clone()),
|
||||||
Some(Btn::Account) => {
|
Some(Btn::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)
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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::*;
|
||||||
|
|||||||
@@ -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) { }
|
||||||
|
|||||||
Reference in new issue
Block a user