Multiplayer continues after a player leaves: AI side takeover, background match while menus are open, match chat, five-tile fog start
This commit is contained in:
1 parent
2721638910
commit
dc22e27184
11 files changed
+436
-70
No files matched your search
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
+104
-20
@@ -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<u8> {
|
||||
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<String>,
|
||||
@@ -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<u64> = 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<u8> = 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());
|
||||
}
|
||||
}
|
||||
+12
-1
@@ -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();
|
||||
|
||||
+12
-2
@@ -607,6 +607,10 @@ pub struct Game {
|
||||
#[serde(skip)]
|
||||
pub netplay: Option<crate::netplay::Netplay>, // a match: turns come in, the world steps per turn
|
||||
#[serde(skip)]
|
||||
pub ai_sides: Vec<u8>, // 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);
|
||||
|
||||
+41
-8
@@ -58,7 +58,7 @@ pub struct MatchUi {
|
||||
pub turn: u32,
|
||||
pub error: Option<String>,
|
||||
pub status: Option<String>,
|
||||
pub chat: Vec<(String, String)>,
|
||||
pub chat: Vec<(Option<u8>, String, String)>,
|
||||
}
|
||||
|
||||
impl MatchUi {
|
||||
@@ -90,6 +90,7 @@ struct RoomInfo {
|
||||
code: String,
|
||||
sides: Vec<u8>,
|
||||
seats: Vec<SeatInfo>,
|
||||
ai: Vec<u8>,
|
||||
turn: u32,
|
||||
started: bool,
|
||||
paused: bool,
|
||||
@@ -107,9 +108,10 @@ pub struct MatchClient {
|
||||
local_side: Option<u8>,
|
||||
error: Option<String>,
|
||||
status: Option<String>,
|
||||
chat: Vec<(String, String)>,
|
||||
chat: Vec<(Option<u8>, String, String)>,
|
||||
chat_seen: usize,
|
||||
start: Option<StartInfo>,
|
||||
turns: Vec<(u32, Vec<Command>)>,
|
||||
turns: Vec<(u32, Vec<Command>, Vec<u8>)>,
|
||||
checks: Vec<(Option<u8>, 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<Command>)> {
|
||||
pub fn take_turns(&mut self) -> Vec<(u32, Vec<Command>, Vec<u8>)> {
|
||||
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<u8>, 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::<Vec<Command>>(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()));
|
||||
}
|
||||
}
|
||||
+86
-11
@@ -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;
|
||||
|
||||
+23
-12
@@ -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<String>, // 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<Btn>, // 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;
|
||||
}
|
||||
}
|
||||
|
||||
+58
-1
@@ -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);
|
||||
|
||||
@@ -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<u8>) -> 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<u8>, 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<u8>, String, String, f64)>,
|
||||
pub chat_open: bool,
|
||||
pub chat_text: String,
|
||||
pub chat_team: bool,
|
||||
chat_rects: [(Rect, Option<bool>); 2],
|
||||
chat_send_rect: Rect,
|
||||
pub rects: Vec<Rect>,
|
||||
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 {
|
||||
|
||||
Reference in new issue
Block a user