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:
Richard Zink committed 2026-11-04 18:07:02 +01:00
1 parent 2721638910
commit dc22e27184
11 files changed
+436 -70

No files matched your search

+12 -1
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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);
+93 -14
View File
@@ -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 {