From dc22e27184df83ddbaf7c48344974ca3bc8c0366 Mon Sep 17 00:00:00 2001 From: Richard Zink Date: Wed, 4 Nov 2026 18:07:02 +0100 Subject: [PATCH] Multiplayer continues after a player leaves: AI side takeover, background match while menus are open, match chat, five-tile fog start --- VERSION | 2 +- lang/de.lang | 3 + lang/en.lang | 3 + server/src/netplay.rs | 124 +++++++++++++++++++++++++++++++++++------- src/fog.rs | 13 ++++- src/game.rs | 14 ++++- src/lobby.rs | 49 ++++++++++++++--- src/main.rs | 97 +++++++++++++++++++++++++++++---- src/menu.rs | 35 ++++++++---- src/netplay.rs | 59 +++++++++++++++++++- src/ui.rs | 107 +++++++++++++++++++++++++++++++----- 11 files changed, 436 insertions(+), 70 deletions(-) diff --git a/VERSION b/VERSION index 581e05c..aabb259 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -v202611-00010 +v202611-00011 diff --git a/lang/de.lang b/lang/de.lang index 11c4cb4..c8ab8c7 100644 --- a/lang/de.lang +++ b/lang/de.lang @@ -632,6 +632,9 @@ net.abort = Abbrechen net.leave = Verlassen net.chat = Chat net.send = Senden +chat.all = Alle +chat.team = Team +chat.send = Senden net.you = du net.host = Gastgeber net.side0 = Rot diff --git a/lang/en.lang b/lang/en.lang index 60cfb0a..04d2921 100644 --- a/lang/en.lang +++ b/lang/en.lang @@ -633,6 +633,9 @@ net.abort = Abort net.leave = Leave net.chat = Chat net.send = Send +chat.all = All +chat.team = Team +chat.send = Send net.you = you net.host = host net.side0 = Red diff --git a/server/src/netplay.rs b/server/src/netplay.rs index f78edb3..e0c94d7 100644 --- a/server/src/netplay.rs +++ b/server/src/netplay.rs @@ -14,9 +14,9 @@ // {"t":"cmds","cmds":[Command]} -> commands of the sender's side // {"t":"sum","turn":1,"sum":1234} -> checksum for a peer // {"t":"chat","text":"hello"} -// {"t":"resume"} -> side 0 resumes after a side went missing +// {"t":"resume"} -> side 0 resumes a paused match // {"t":"abort"} -> side 0 closes the match -// {"t":"leave"} +// {"t":"leave"} -> a player leaves; empty seats are AI-controlled while anyone is connected // // Server messages include: hello, rooms, room, start, turn, sum, chat, closed, error. @@ -81,6 +81,20 @@ struct Seat { ready: bool, } +impl Room { + fn has_connection(&self) -> bool { + self.seats.values().any(|s| s.conn.is_some()) + } + + fn ai_sides(&self) -> Vec { + self.sides + .iter() + .filter(|side| self.seats.get(side).map(|seat| seat.conn.is_none()).unwrap_or(true)) + .copied() + .collect() + } +} + #[derive(Debug, Deserialize)] pub struct WsQuery { name: Option, @@ -238,15 +252,15 @@ impl Netplay { } } r.turn += 1; - Some((r.turn, cmds)) + Some((r.turn, cmds, r.ai_sides())) } else { None } } None => return, }; - if let Some((turn, cmds)) = action { - self.broadcast_room(code, json!({"t":"turn","turn":turn,"cmds":cmds})); + if let Some((turn, cmds, ai)) = action { + self.broadcast_room(code, json!({"t":"turn","turn":turn,"cmds":cmds,"ai":ai})); } } @@ -469,7 +483,17 @@ impl Netplay { None => return, }; let name = self.clients.get(&conn).map(|c| c.name.clone()).unwrap_or_else(|| "Guest".into()); - self.broadcast_room(&code, json!({"t":"chat","name":name,"text":text})); + let side = self.side_of(&code, conn); + let channel = msg.get("channel").and_then(|v| v.as_str()).unwrap_or("all"); + let chat = json!({"t":"chat","name":name,"text":text,"side":side,"channel":channel}); + if channel == "team" { + match side { + Some(side) => self.broadcast_room_side(&code, side, chat), + None => self.broadcast_room(&code, chat), + } + } else { + self.broadcast_room(&code, chat); + } } fn resume(&mut self, conn: u64) { @@ -507,10 +531,9 @@ impl Netplay { self.rooms_msg(conn); } - fn leave_room(&mut self, conn: u64, code: &str, disconnect: bool) { + fn leave_room(&mut self, conn: u64, code: &str, _disconnect: bool) { let (remove_room, messages) = match self.rooms.get_mut(code) { Some(r) => { - let side0_left = r.seats.iter().any(|(&side, s)| s.conn == Some(conn) && side == 0); for s in r.seats.values_mut() { if s.conn == Some(conn) { s.conn = None; @@ -519,19 +542,17 @@ impl Netplay { } } if r.started { - if side0_left { - (true, vec![json!({"t":"closed","why":"host left"})]) + if r.has_connection() { + r.paused = false; + r.missing = false; + (false, Vec::new()) } else { - r.paused = true; - r.missing = true; - (false, vec![json!({"t":"room","why":"missing","code":code,"turn":r.turn,"started":r.started,"paused":r.paused,"missing":r.missing})]) + (true, vec![json!({"t":"closed","why":"last player left"})]) } } else { let empty = r.seats.values().all(|s| s.conn.is_none()); if empty { (true, Vec::new()) - } else if disconnect { - (false, vec![json!({"t":"room","why":"left","code":code,"turn":r.turn,"started":r.started,"paused":r.paused,"missing":r.missing})]) } else { (false, Vec::new()) } @@ -542,6 +563,9 @@ impl Netplay { for msg in messages { self.broadcast_room(code, msg); } + if !remove_room && self.rooms.contains_key(code) { + self.room_msg_all(code); + } if remove_room { if let Some(c) = self.clients.get_mut(&conn) { c.room = None; @@ -580,6 +604,17 @@ impl Netplay { } } + fn broadcast_room_side(&mut self, code: &str, side: u8, v: Value) { + let targets: Vec = self + .rooms + .get(code) + .map(|r| r.seats.iter().filter(|(s, seat)| **s == side && seat.conn.is_some()).filter_map(|(_, seat)| seat.conn).collect()) + .unwrap_or_default(); + for conn in targets { + self.send(conn, v.clone()); + } + } + fn room_msg(&mut self, conn: u64, code: &str) { if let Some(v) = self.room_json(code) { self.send(conn, v); @@ -599,11 +634,13 @@ impl Netplay { .iter() .filter_map(|side| r.seats.get(side).map(|s| json!({"side":side,"name":s.name,"ready":s.ready,"connected":s.conn.is_some()}))) .collect(); + let ai: Vec = if r.started { r.ai_sides() } else { Vec::new() }; Some(json!({ "t":"room", "code":r.code, "sides":r.sides, "seats":seats, + "ai":ai, "turn":r.turn, "started":r.started, "paused":r.paused, @@ -701,7 +738,7 @@ mod tests { } #[test] - fn a_started_match_pauses_when_a_side_goes_missing() { + fn a_started_match_continues_with_an_ai_side_when_a_player_leaves() { let mut net = Netplay::new(); let (tx, mut rx) = mpsc::unbounded_channel(); let red = net.connect(tx, Some("red".into()), None); @@ -717,11 +754,58 @@ mod tests { until(&mut rx2, "start"); net.disconnect(green); - let missing = until(&mut rx, "room"); - assert_eq!(missing["missing"].as_bool(), Some(true)); - net.handle(red, json!({"t":"resume"})); + let room = until(&mut rx, "room"); + assert_eq!(room["ai"].as_array().unwrap()[0].as_u64(), Some(2)); net.tick(&code); let turn = until(&mut rx, "turn"); - assert_eq!(turn["turn"].as_u64(), Some(1)); + assert_eq!(turn["ai"].as_array().unwrap()[0].as_u64(), Some(2)); + } + + #[test] + fn the_relay_closes_a_started_match_after_the_last_player_leaves() { + let mut net = Netplay::new(); + let (tx, mut rx) = mpsc::unbounded_channel(); + let red = net.connect(tx, Some("red".into()), None); + let rooms = net.handle(red, json!({"t":"create","sides":[0,2]})); + let code = rooms[0].clone(); + let (tx2, mut rx2) = mpsc::unbounded_channel(); + let green = net.connect(tx2, Some("green".into()), None); + net.handle(green, json!({"t":"join","code":&code})); + net.handle(red, json!({"t":"ready"})); + net.handle(green, json!({"t":"ready"})); + net.handle(red, json!({"t":"start"})); + until(&mut rx, "start"); + until(&mut rx2, "start"); + + net.disconnect(green); + until(&mut rx, "room"); + net.disconnect(red); + assert!(net.rooms.get(&code).is_none()); + } + + #[test] + fn team_chat_only_reaches_the_same_side() { + let mut net = Netplay::new(); + let (tx, mut rx) = mpsc::unbounded_channel(); + let red = net.connect(tx, Some("red".into()), None); + let rooms = net.handle(red, json!({"t":"create","sides":[0,2]})); + let code = rooms[0].clone(); + let (tx2, mut rx2) = mpsc::unbounded_channel(); + let green = net.connect(tx2, Some("green".into()), None); + net.handle(green, json!({"t":"join","code":&code})); + + net.handle(red, json!({"t":"chat","text":"hello","channel":"all"})); + let all_red = until(&mut rx, "chat"); + assert_eq!(all_red["text"].as_str(), Some("hello")); + assert_eq!(all_red["side"].as_u64(), Some(0)); + let all_green = until(&mut rx2, "chat"); + assert_eq!(all_green["text"].as_str(), Some("hello")); + assert_eq!(all_green["side"].as_u64(), Some(0)); + + net.handle(red, json!({"t":"chat","text":"secret","channel":"team"})); + let team_red = until(&mut rx, "chat"); + assert_eq!(team_red["text"].as_str(), Some("secret")); + assert_eq!(team_red["side"].as_u64(), Some(0)); + assert!(rx2.try_recv().is_err()); } } diff --git a/src/fog.rs b/src/fog.rs index efea7b7..f54f750 100644 --- a/src/fog.rs +++ b/src/fog.rs @@ -7,6 +7,7 @@ use crate::map::*; use std::collections::VecDeque; const SIGHT: i32 = 6; // tiles a creature sees along open ground +const START_LIGHT: i32 = 5; // tiles of known ground around a master's core const FOG_EVERY: f32 = 0.4; // explored tiles in savegames: one bit per tile, base64 @@ -91,8 +92,18 @@ impl Game { } } } + // the master is never blind at home: the shadow starts away from the core + let (cx, cy) = self.map.core; + for dy in -START_LIGHT..=START_LIGHT { + for dx in -START_LIGHT..=START_LIGHT { + let (x, y) = (cx + 1 + dx, cy + 1 + dy); + if inb(x, y) { + vis[idx(x, y)] = true; + } + } + } // what the creatures and towers see along open ground (walls stop the view) - let mut eyes: Vec<(usize, i32)> = self.ents.iter().filter(|e| e.alive && !e.held && !e.hero && e.side == 0 && !e.chained).map(|e| (e.tile(), SIGHT)).collect(); + let mut eyes: Vec<(usize, i32)> = self.ents.iter().filter(|e| e.alive && !e.held && !e.hero && self.is_master(e.side) && !e.chained).map(|e| (e.tile(), SIGHT)).collect(); eyes.extend(self.towers.iter().filter(|t| !t.hero).map(|t| (t.tile, t.range() as i32 + 1))); let mut stamp = vec![0u32; n]; let mut q = VecDeque::new(); diff --git a/src/game.rs b/src/game.rs index e29356f..68d3444 100644 --- a/src/game.rs +++ b/src/game.rs @@ -607,6 +607,10 @@ pub struct Game { #[serde(skip)] pub netplay: Option, // a match: turns come in, the world steps per turn #[serde(skip)] + pub ai_sides: Vec, // match sides whose human master left: AI keeps them playing + #[serde(skip)] + pub ai_difficulty: u8, // difficulty used when a left side becomes an AI rival + #[serde(skip)] pub hints: bool, // show hint messages (setting) #[serde(skip, default = "hint_times")] hint_t: [f32; 32], // last time each hint slot was shown @@ -788,6 +792,8 @@ impl Game { sim_steps: 0, step_acc: 0.0, netplay: None, + ai_sides: Vec::new(), + ai_difficulty: 1, hint_t: hint_times(), reserve: 0.0, incoming: Vec::new(), @@ -1265,6 +1271,10 @@ impl Game { side == self.me_side || self.masters.iter().any(|m| m.side == side) } + pub fn is_rival_side(&self, side: u8) -> bool { + self.rivals.iter().any(|r| r.alive && r.side == side) + } + fn take_active(&mut self) -> MasterState { MasterState { side: self.me_side, @@ -2120,7 +2130,7 @@ impl Game { self.demo_ai(e, dt); } else if e.hero && e.captive != 0 { self.captive_update(e, dt); - } else if e.rival() { + } else if self.is_rival_side(e.side) || (e.rival() && !self.is_master(e.side)) { self.rival_ai(e, dt); } else if e.dwarf() { self.dwarf_ai(e, dt); @@ -2181,7 +2191,7 @@ impl Game { } } if !tile.walkable() && !(flies(e.kind) && tile.liquid()) { - if (e.hero || e.rival()) && tile.diggable() { + if (e.hero || self.is_rival_side(e.side) || (e.rival() && !self.is_master(e.side))) && tile.diggable() { // heroes dig their way through e.working = true; let (tx, ty) = xy(next); diff --git a/src/lobby.rs b/src/lobby.rs index 905602f..751d8b3 100644 --- a/src/lobby.rs +++ b/src/lobby.rs @@ -58,7 +58,7 @@ pub struct MatchUi { pub turn: u32, pub error: Option, pub status: Option, - pub chat: Vec<(String, String)>, + pub chat: Vec<(Option, String, String)>, } impl MatchUi { @@ -90,6 +90,7 @@ struct RoomInfo { code: String, sides: Vec, seats: Vec, + ai: Vec, turn: u32, started: bool, paused: bool, @@ -107,9 +108,10 @@ pub struct MatchClient { local_side: Option, error: Option, status: Option, - chat: Vec<(String, String)>, + chat: Vec<(Option, String, String)>, + chat_seen: usize, start: Option, - turns: Vec<(u32, Vec)>, + turns: Vec<(u32, Vec, Vec)>, checks: Vec<(Option, u32, u64)>, } @@ -126,6 +128,7 @@ impl MatchClient { error: None, status: None, chat: Vec::new(), + chat_seen: 0, start: None, turns: Vec::new(), checks: Vec::new(), @@ -172,6 +175,7 @@ impl MatchClient { self.error = None; self.status = None; self.chat.clear(); + self.chat_seen = 0; self.outbox.push_back(json!({"t":"create","sides":sides,"map_w":map_w,"map_h":map_h,"difficulty":difficulty}).to_string()); } @@ -186,6 +190,8 @@ impl MatchClient { } self.error = None; self.status = None; + self.chat.clear(); + self.chat_seen = 0; self.outbox.push_back(json!({"t":"join","code":code}).to_string()); } @@ -231,12 +237,13 @@ impl MatchClient { self.outbox.push_back(json!({"t":"leave"}).to_string()); } - pub fn chat(&mut self, text: &str) { + pub fn chat(&mut self, text: &str, team: bool) { 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()); + let msg = if team { json!({"t":"chat","text":text,"channel":"team"}) } else { json!({"t":"chat","text":text,"channel":"all"}) }; + self.outbox.push_back(msg.to_string()); } pub fn send_commands(&mut self, cmds: &[Command]) { @@ -272,9 +279,13 @@ impl MatchClient { "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)); + let side = v.get("side").and_then(|x| x.as_u64()).map(|x| x as u8); + self.chat.push((side, name, text)); while self.chat.len() > 40 { self.chat.remove(0); + if self.chat_seen > 0 { + self.chat_seen -= 1; + } } } "closed" => { @@ -284,6 +295,8 @@ impl MatchClient { self.local_side = None; self.start = None; self.turns.clear(); + self.chat.clear(); + self.chat_seen = 0; } "error" => { self.error = Some(v.get("why").and_then(|x| x.as_str()).unwrap_or("error").to_string()); @@ -296,7 +309,7 @@ impl MatchClient { self.start.take() } - pub fn take_turns(&mut self) -> Vec<(u32, Vec)> { + pub fn take_turns(&mut self) -> Vec<(u32, Vec, Vec)> { std::mem::take(&mut self.turns) } @@ -304,6 +317,15 @@ impl MatchClient { std::mem::take(&mut self.checks) } + pub fn take_new_chat(&mut self) -> Vec<(Option, String, String)> { + if self.chat_seen >= self.chat.len() { + return Vec::new(); + } + let out = self.chat[self.chat_seen..].to_vec(); + self.chat_seen = self.chat.len(); + out + } + fn parse_rooms(&mut self, v: &Value) { let Some(arr) = v.get("rooms").and_then(|x| x.as_array()) else { return }; self.rooms = arr @@ -335,6 +357,7 @@ impl MatchClient { if let Some(x) = v.get("turn").and_then(|x| x.as_u64()) { room.turn = x as u32; } + room.ai = array_bytes(v.get("ai")); if let Some(x) = v.get("started").and_then(|x| x.as_bool()) { room.started = x; } @@ -381,12 +404,14 @@ impl MatchClient { 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; + r.ai = array_bytes(v.get("ai")); } let cmds = match v.get("cmds").cloned() { Some(v) => serde_json::from_value::>(v).unwrap_or_default(), None => Vec::new(), }; - self.turns.push((turn, cmds)); + let ai = array_bytes(v.get("ai")); + self.turns.push((turn, cmds, ai)); } fn parse_sum(&mut self, v: &Value) { @@ -489,5 +514,13 @@ mod tests { client.feed(r#"{"t":"sum","from":2,"turn":1,"sum":42}"#); let checks = client.take_checks(); assert_eq!(checks[0], (Some(2), 1, 42)); + + client.chat("hello", true); + let req = pop(&mut client); + assert_eq!(req["t"].as_str(), Some("chat")); + assert_eq!(req["channel"].as_str(), Some("team")); + client.feed(r#"{"t":"chat","name":"red","text":"hello","side":0,"channel":"team"}"#); + let chat = client.take_new_chat(); + assert_eq!(chat[0], (Some(0), "red".to_string(), "hello".to_string())); } } diff --git a/src/main.rs b/src/main.rs index 4763174..0b1d9ff 100644 --- a/src/main.rs +++ b/src/main.rs @@ -251,7 +251,7 @@ fn account_action(a: MenuAction, cloud: &mut Cloud, menu: &mut Menu, app: &mut A app.client.leave(); app.match_mode = false; } - MenuAction::MatchChat(text) => app.client.chat(&text), + MenuAction::MatchChat(text) => app.client.chat(&text, false), MenuAction::MatchEditField(i) => { if ui::touch_ui() { let title = i18n::tr(if i == 0 { "net.join_hint" } else { "net.chat" }); @@ -466,6 +466,26 @@ impl App { self.client.on_disconnect(); } + fn leave_match(&mut self) { + if !self.match_mode && !self.game.in_match() { + return; + } + self.client.leave(); + if self.ws_id > 0 { + while let Some(text) = self.client.outbox.pop_front() { + if !platform::ws_send(self.ws_id, &text) { + self.client.outbox.push_front(text); + break; + } + } + platform::ws_close(self.ws_id); + self.ws_id = 0; + } + self.game.end_match(); + 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; @@ -475,7 +495,9 @@ impl App { if !self.game.start_match(&info.sides, local) { return false; } + self.game.ai_difficulty = info.difficulty; self.game.hints = self.settings.hints; + self.game.init_fog(); 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; @@ -487,7 +509,8 @@ impl App { if !self.match_mode || !self.game.in_match() { return; } - for (_, cmds) in self.client.take_turns() { + for (_, cmds, ai) in self.client.take_turns() { + self.game.sync_ai_sides(&ai, self.settings.difficulty); self.game.receive_turn(&cmds); } for (_, turn, sum) in self.client.take_checks() { @@ -639,14 +662,22 @@ async fn main() { // ---------------- multiplayer relay: receive messages, send queued requests, update the menu state app.poll_match(); + menu.in_match = app.match_mode || app.game.in_match(); if matches!(menu.page, Some(Page::Match { .. })) { menu.set_match_ui(app.client.ui()); } + for (side, name, text) in app.client.take_new_chat() { + ui.chat_log.push((side, name, text, now)); + while ui.chat_log.len() > 100 { + ui.chat_log.remove(0); + } + } if let Some(info) = app.client.take_start() { if app.start_match_game(info) { menu.page = None; menu.error = None; ui.log.clear(); + ui.chat_log.clear(); autosave_t = 0.0; cache_t = 0.0; app.set_status(i18n::tr("net.title"), now); @@ -712,8 +743,12 @@ async fn main() { } let dialog = ui.show_langsel || ui.show_help; + if ui.chat_open && (menu.open() || dialog) { + ui.chat_open = false; + ui.chat_text.clear(); + } let in_menu = menu.open(); - let playing = !in_menu && !dialog; + let playing = !in_menu && !dialog && !ui.chat_open; let over_ui = ui.over_ui(ux, uy); let hover_tile = tool_tile(&app.cam, &app.game.map, ui.tool, mx, my); let ground = app.cam.ground_point(mx, my); @@ -721,9 +756,12 @@ async fn main() { let keys = app.settings.clone(); let key = |a: Action| keys.key(a); - // ---------------- Esc: dialogs, menus, pause menu + // ---------------- Esc: chat, dialogs, menus, pause menu if is_key_pressed(KeyCode::Escape) { - if ui.show_langsel { + if ui.chat_open { + ui.chat_open = false; + ui.chat_text.clear(); + } else if ui.show_langsel { } else if ui.show_help { ui.show_help = false; } else if in_menu { @@ -735,6 +773,31 @@ async fn main() { } } + // ---------------- in-match chat input + if ui.chat_open { + while let Some(c) = get_char_pressed() { + if !c.is_control() && c != '\t' && ui.chat_text.chars().count() < 80 { + ui.chat_text.push(c); + } + } + if is_key_pressed(KeyCode::Backspace) { + ui.chat_text.pop(); + } + if is_key_pressed(KeyCode::Enter) || is_key_pressed(KeyCode::KpEnter) { + let text: String = ui.chat_text.trim().chars().take(80).collect(); + if !text.is_empty() { + app.client.chat(&text, ui.chat_team); + } + ui.chat_text.clear(); + ui.chat_open = false; + } + } else if playing && app.game.state == 0 && (app.match_mode || app.game.in_match()) { + if is_key_pressed(KeyCode::Enter) || is_key_pressed(KeyCode::KpEnter) { + ui.chat_open = true; + ui.chat_text.clear(); + } + } + // ---------------- keyboard (only while playing) if playing { for i in 0..TOOLS.len() { @@ -1000,7 +1063,9 @@ async fn main() { } MenuAction::Help => ui.show_help = true, MenuAction::MainMenu => { - if app.game.state == 0 { + if app.match_mode || app.game.in_match() { + app.leave_match(); + } else if app.game.state == 0 { platform::storage_set(save::AUTOSAVE_KEY, &app.autosave_json()); menu.has_autosave = true; } @@ -1052,6 +1117,15 @@ async fn main() { app.game.sfx_ui(game::Sfx::Error); } UiAction::CloseHelp | UiAction::SelectLanguage(_) | UiAction::Consumed => {} + UiAction::ChatSetTeam(team) => ui.chat_team = team, + UiAction::ChatSend => { + let text: String = ui.chat_text.trim().chars().take(80).collect(); + if !text.is_empty() { + app.client.chat(&text, ui.chat_team); + } + ui.chat_text.clear(); + ui.chat_open = false; + } UiAction::None => { if touch_mode { // touch: area tools start their rectangle, everything else acts on the tap @@ -1145,17 +1219,18 @@ async fn main() { app.game.hints = app.settings.hints; } - // ---------------- update (the world stands still while menus or dialogs are open; - // the bridge battle behind the title menu goes on, the camera slowly circles it) + // ---------------- update (single-player worlds stand still while menus or dialogs are open; + // matches keep running in the background, the bridge battle behind the title menu goes on) if app.game.demo.is_some() && !playing { app.game.update(dt); app.cam.yaw += dt * 0.03; app.game.events.retain(|ev| ev.pos.is_none()); - } - if playing { - // the world runs in fixed steps: the speed setting only feeds more time into them + } else if app.game.in_match() { app.game.update(dt * app.settings.game_speed.max(1.0)); app.tick_match_game(); + } else if playing { + // the world runs in fixed steps: the speed setting only feeds more time into them + app.game.update(dt * app.settings.game_speed.max(1.0)); // autosave: snapshot for leaving the tab + regular autosave if app.game.state == 0 && !app.match_mode { cache_t += dt; diff --git a/src/menu.rs b/src/menu.rs index 4a6175c..1ebe19e 100644 --- a/src/menu.rs +++ b/src/menu.rs @@ -10,7 +10,7 @@ use crate::i18n::{languages, tr, trf}; use crate::lobby::MatchUi; use crate::map::{MAX_SIZE, MIN_SIZE}; use crate::settings::{all_actions, key_name, Action, Settings}; -use crate::ui::{grid_center, grid_center_y, mouse, sh, sw, tool_info, wrap, CH, CW, DIM, FRAME, FRAME_HI, GOLD, S, SMALL, TITLE, TOOLS, TXT}; +use crate::ui::{grid_center, grid_center_y, mouse, sh, sw, tool_info, wrap, side_color, CH, CW, DIM, FRAME, FRAME_HI, GOLD, S, SMALL, TITLE, TOOLS, TXT}; use macroquad::prelude::*; mod bestiary; @@ -327,6 +327,7 @@ pub struct Menu { pub focus: usize, // field that gets the typed text pub error: Option, // message on the login and account pages pub match_ui: MatchUi, // copied state of the multiplayer relay, shown on the Match page + pub in_match: bool, // a match is being played; the pause menu changes its shape confirm: Option, // overwrite / delete waiting for a second click scroll: usize, rows_visible: usize, @@ -352,6 +353,7 @@ impl Menu { focus: 0, error: None, match_ui: MatchUi::default(), + in_match: false, confirm: None, scroll: 0, rows_visible: 1, @@ -845,15 +847,24 @@ impl Menu { if self.mission.is_some() { items.push((Btn::Goals, tr("menu.goals"))); } - items.extend([ - (Btn::Save, tr("menu.save")), - (Btn::Load, tr("menu.load")), - (Btn::Match, tr("menu.multiplayer")), - (Btn::Settings, tr("menu.settings")), - (Btn::Bestiary, tr("menu.bestiary")), - (Btn::Help, tr("menu.help")), - (Btn::MainMenu, tr("menu.main")), - ]); + if self.in_match { + items.extend([ + (Btn::Settings, tr("menu.settings")), + (Btn::Bestiary, tr("menu.bestiary")), + (Btn::Help, tr("menu.help")), + (Btn::MainMenu, tr("net.leave")), + ]); + } else { + items.extend([ + (Btn::Save, tr("menu.save")), + (Btn::Load, tr("menu.load")), + (Btn::Match, tr("menu.multiplayer")), + (Btn::Settings, tr("menu.settings")), + (Btn::Bestiary, tr("menu.bestiary")), + (Btn::Help, tr("menu.help")), + (Btn::MainMenu, tr("menu.main")), + ]); + } let cols = 32; let rows = items.len() as i32 * 3 + 4; let x = grid_center(sw, cols as f32 * CW()); @@ -1441,8 +1452,8 @@ impl Menu { 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); + for (side, name, text) in ui.chat.iter().rev().take(3) { + gfx.text(&format!("{}: {}", name, text), ix, yy + SMALL() * GH * 1.2, SMALL(), side_color(*side)); yy += SMALL() * GH * 1.2; } } diff --git a/src/netplay.rs b/src/netplay.rs index c6c6e55..effd931 100644 --- a/src/netplay.rs +++ b/src/netplay.rs @@ -69,6 +69,35 @@ impl Game { self.netplay.is_some() } + // a seat without a human in a started match: that side keeps playing as a rival-style AI + pub fn sync_ai_sides(&mut self, ai: &[u8], difficulty: u8) { + if self.netplay.is_none() { + return; + } + self.ai_difficulty = difficulty; + self.ai_sides = ai + .iter() + .copied() + .filter(|side| *side != self.local_side) + .collect(); + for side in self.ai_sides.clone() { + let core = if side == self.me_side { + continue; + } else if let Some(m) = self.masters.iter().find(|m| m.side == side) { + Some(m.core) + } else { + None + }; + let Some(core) = core else { continue }; + if let Some(k) = self.masters.iter().position(|m| m.side == side) { + self.masters.remove(k); + } + if !self.is_rival_side(side) { + self.add_rival(side, core.0 + 1, core.1 + 1, self.ai_difficulty); + } + } + } + // 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; @@ -80,7 +109,7 @@ impl Game { } // the server confirmed a turn: run the orders of all masters, then the fixed steps of the turn. - // A client that is paused or finished leaves the turn out (a match pauses while somebody is away). + // A local client that is paused or finished leaves the turn out; empty match seats are AI sides. pub fn receive_turn(&mut self, cmds: &[Command]) -> u32 { if self.netplay.is_none() || self.state != 0 || self.paused { return 0; @@ -225,6 +254,21 @@ impl Game { mix(&mut h, *springs as u64); mixp(&mut h, Some(*core)); } + for r in self.rivals.iter() { + mixp(&mut h, Some(r.core)); + mix(&mut h, r.side as u64); + mix(&mut h, r.alive as u64); + mix(&mut h, r.radius as u64); + mix(&mut h, r.difficulty as u64); + mixf(&mut h, r.hp); + mixf(&mut h, r.grow_t); + mixf(&mut h, r.spawn_t); + mixf(&mut h, r.attack_t); + mixf(&mut h, r.power); + } + for side in self.ai_sides.iter() { + mix(&mut h, *side as u64); + } h } } @@ -296,6 +340,19 @@ mod tests { assert_eq!(g.worker_count(), 5, "the summon counts as the master's own worker"); } + #[test] + fn a_lost_seat_in_a_match_is_converted_to_the_same_ai_side() { + let (mut a, mut b) = (client(31, 0), client(31, 2)); + a.sync_ai_sides(&[3], 1); + b.sync_ai_sides(&[3], 1); + assert_eq!(a.checksum(), b.checksum(), "both clients convert side 3 the same way"); + assert!(a.is_rival_side(3) && b.is_rival_side(3)); + assert!(!a.is_master(3) && !b.is_master(3)); + a.receive_turn(&[]); + b.receive_turn(&[]); + assert_eq!(a.checksum(), b.checksum(), "and keep playing it identically"); + } + #[test] fn a_client_without_the_next_turn_stands_still_and_its_orders_wait() { let mut g = client(22, 0); diff --git a/src/ui.rs b/src/ui.rs index 8d47cd0..43e05f0 100644 --- a/src/ui.rs +++ b/src/ui.rs @@ -138,6 +138,22 @@ pub const FRAME_HI: Color = Color::new(0.95, 0.8, 0.4, 1.0); pub const BG: Color = Color::new(0.06, 0.04, 0.05, 0.92); pub const TXT: Color = Color::new(0.9, 0.86, 0.78, 1.0); pub const DIM: Color = Color::new(0.6, 0.56, 0.5, 1.0); + +pub fn side_color(side: Option) -> Color { + match side { + Some(0) => Color::new(1.0, 0.85, 0.45, 1.0), + Some(1) => Color::new(0.85, 0.55, 0.25, 1.0), + Some(2) => Color::new(0.35, 0.95, 0.40, 1.0), + Some(3) => Color::new(0.40, 0.60, 1.00, 1.0), + Some(4) => Color::new(0.85, 0.75, 0.30, 1.0), + _ => TXT, + } +} + +pub fn chat_color(side: Option, alpha: f32) -> Color { + let c = side_color(side); + Color::new(c.r, c.g, c.b, alpha) +} pub const GOLD: Color = Color::new(1.0, 0.84, 0.25, 1.0); pub const MANA: Color = Color::new(0.7, 0.45, 1.0, 1.0); pub const RED: Color = Color::new(0.95, 0.3, 0.3, 1.0); @@ -343,6 +359,8 @@ pub enum UiAction { CloseHelp, NewGame, SelectLanguage(String), + ChatSetTeam(bool), + ChatSend, Consumed, } @@ -353,6 +371,12 @@ pub struct Ui { ov_scroll: usize, pub show_langsel: bool, pub log: Vec<(Msg, f64)>, + pub chat_log: Vec<(Option, String, String, f64)>, + pub chat_open: bool, + pub chat_text: String, + pub chat_team: bool, + chat_rects: [(Rect, Option); 2], + chat_send_rect: Rect, pub rects: Vec, minimap_img: Image, minimap_tex: Texture2D, @@ -407,6 +431,12 @@ impl Ui { ov_scroll: 0, show_langsel: false, log: Vec::new(), + chat_log: Vec::new(), + chat_open: false, + chat_text: String::new(), + chat_team: false, + chat_rects: [(Rect::new(0.0, 0.0, 0.0, 0.0), Some(false)); 2], + chat_send_rect: Rect::new(0.0, 0.0, 0.0, 0.0), rects: Vec::new(), minimap_img: img, minimap_tex: tex, @@ -425,7 +455,7 @@ impl Ui { } pub fn over_ui(&self, x: f32, y: f32) -> bool { - self.show_help || self.show_langsel || self.rects.iter().any(|r| r.contains(vec2(x, y))) + self.chat_open || self.show_help || self.show_langsel || self.rects.iter().any(|r| r.contains(vec2(x, y))) } pub fn minimap_contains(&self, x: f32, y: f32) -> bool { @@ -439,6 +469,20 @@ impl Ui { pub fn click(&mut self, x: f32, y: f32, g: &Game) -> UiAction { let p = vec2(x, y); + if self.chat_open { + if self.chat_rects[0].0.contains(p) { + self.chat_team = false; + return UiAction::ChatSetTeam(false); + } + if self.chat_rects[1].0.contains(p) { + self.chat_team = true; + return UiAction::ChatSetTeam(true); + } + if self.chat_send_rect.contains(p) { + return UiAction::ChatSend; + } + return UiAction::Consumed; + } if self.show_langsel { for (r, code) in &self.lang_rects { if r.contains(p) { @@ -853,29 +897,64 @@ impl Ui { } } - // ---------------- messages (translated at display time, so a language switch applies at once) - let recent: Vec<&(Msg, f64)> = self.log.iter().filter(|(_, t)| now - t < 14.0).collect(); - if !recent.is_empty() { - let mcols = (((sw - mm_w) / CW()) as i32 - 2).clamp(20, 70); - let mut lines: Vec<(String, f32)> = Vec::new(); + // ---------------- messages + chat (translated at display time, so a language switch applies at once) + let mut recent: Vec<(f64, String, Color)> = Vec::new(); + for (m, t) in self.log.iter().filter(|(_, t)| now - t < 14.0) { + let a = (1.0 - ((now - t) as f32 - 11.0) / 3.0).clamp(0.2, 1.0); + recent.push((*t, trf(m.key, &m.args), Color::new(TXT.r, TXT.g, TXT.b, a))); + } + for (side, name, text, t) in self.chat_log.iter().filter(|(_, _, _, t)| now - t < 14.0) { + let a = (1.0 - ((now - t) as f32 - 11.0) / 3.0).clamp(0.2, 1.0); + recent.push((*t, format!("{name}: {text}"), chat_color(*side, a))); + } + recent.sort_by(|a, b| a.0.partial_cmp(&b.0).unwrap_or(std::cmp::Ordering::Equal)); + if !recent.is_empty() || self.chat_open { + let mcols = (((sw - mm_w) / CW()) as i32 - 2).clamp(34, 70); + let mut lines: Vec<(String, Color)> = Vec::new(); let keep = if touch_ui() { 3 } else { 5 }; - for (m, t) in recent.iter().rev().take(keep).rev() { - let a = (1.0 - ((now - t) as f32 - 11.0) / 3.0).clamp(0.2, 1.0); - for l in wrap(&trf(m.key, &m.args), mcols as usize - 4) { - lines.push((l, a)); + for (_, line, col) in recent.iter().rev().take(keep).rev() { + for l in wrap(line, mcols as usize - 4) { + lines.push((l.clone(), *col)); } } - let mrows = lines.len() as i32 + 2; + let chat_rows = if self.chat_open { 2 } else { 0 }; + let mrows = lines.len() as i32 + chat_rows + 2; let my0 = tb_y - mrows as f32 * CH(); - gfx.window(0.0, my0, mcols, mrows, S(), false, FRAME, Color::new(0.06, 0.04, 0.05, 0.75)); + gfx.window(0.0, my0, mcols, mrows, S(), false, FRAME, Color::new(0.06, 0.04, 0.05, 0.82)); self.rects.push(Rect::new(0.0, my0, mcols as f32 * CW(), mrows as f32 * CH())); - for (k, (l, a)) in lines.iter().enumerate() { - gfx.text(l, CW() * 2.0, my0 + CH() * (k as f32 + 1.0), S(), Color::new(TXT.r, TXT.g, TXT.b, *a)); + for (k, (l, col)) in lines.iter().enumerate() { + gfx.text(l, CW() * 2.0, my0 + CH() * (k as f32 + 1.0), S(), *col); + } + if self.chat_open { + let cy = my0 + CH() * (lines.len() as f32 + 1.0); + let all = tr("chat.all"); + let team = tr("chat.team"); + let send = tr("chat.send"); + let r0 = Rect::new(CW() * 2.0, cy, 10.0 * CW(), CH()); + let r1 = Rect::new(CW() * 14.0, cy, 10.0 * CW(), CH()); + let r2 = Rect::new(mcols as f32 * CW() - 8.0 * CW(), cy + CH(), 6.0 * CW(), CH()); + self.chat_rects = [(r0, Some(false)), (r1, Some(true))]; + self.chat_send_rect = r2; + self.rects.push(r0); + self.rects.push(r1); + self.rects.push(r2); + let all_line = format!("{} {all}", if self.chat_team { " " } else { "o" }); + let team_line = format!("{} {team}", if self.chat_team { "o" } else { " " }); + gfx.text(&all_line, r0.x + CW(), cy + 4.0 * px, SMALL(), if !self.chat_team { FRAME_HI } else { DIM }); + gfx.text(&team_line, r1.x + CW(), cy + 4.0 * px, SMALL(), if self.chat_team { FRAME_HI } else { DIM }); + let shown = format!("> {}_", self.chat_text); + let maxc = (((mcols as f32 * CW() - 4.0 * CW() - 8.0 * CW()) / (GW * SMALL())) as usize).max(8); + let clipped: String = shown.chars().take(maxc).collect(); + gfx.text(&clipped, CW() * 2.0, cy + CH() + 4.0 * px, SMALL(), TXT); + gfx.text(&send, r2.x + CW() * 1.5, cy + CH() + 4.0 * px, SMALL(), TXT); } } if self.log.len() > 100 { self.log.drain(0..50); } + if self.chat_log.len() > 100 { + self.chat_log.drain(0..50); + } // the open row of tools (wraps into several rows if needed) if let Some((c, x0, bw, bcols)) = popup {