Versus teams, gold for allies, fallen hearts as spectators, multi-mission editor co-op

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Richard ZinkandClaude Opus 5.5 committed 2026-11-05 19:59:43 +01:00
1 parent 811c955bad
commit fc9c49d4ca
16 files changed
+1896 -49

No files matched your search

+53
View File
@@ -734,6 +734,20 @@ pub fn editor_fort(g: &mut Game, x: i32, y: i32, level: u32, shard: bool) {
g.recount();
}
// an island in a lake (water or lava) at a map fraction, with a mana spring and/or treasure
pub fn editor_island(g: &mut Game, at: (f32, f32), lava: bool, spring: bool, treasure: i32, seed: u32) {
let (w, h) = (g.map.w, g.map.h);
place_island(g, &island(at, lava, spring, treasure), w, h, seed);
g.recount();
}
// a sealed cave at a map fraction, with a chained creature and/or treasure
pub fn editor_cave(g: &mut Game, at: (f32, f32), chained: Option<(EK, u32)>, treasure: i32) {
let (w, h) = (g.map.w, g.map.h);
place_cave(g, &cave(at, chained, treasure), w, h);
g.recount();
}
// a painted map: the game state saved by the map editor
pub fn load_fixed_map(json: &str) -> Option<Game> {
let mut g: Game = serde_json::from_str(json).ok()?;
@@ -774,6 +788,45 @@ pub fn carry_from(g: &Game) -> Carry {
Carry { creatures: c.iter().take(CARRY_CREATURES).map(|e| (e.kind, e.level, e.xp)).collect(), gold: (g.gold * CARRY_GOLD).floor(), rune_free: g.rune_free, rune_final: g.rune_final + g.rune_next }
}
// a co-op match of an editor campaign: what every master takes into the next mission - his
// strongest creatures and a fifth of his gold. It comes from the shared world, so it is the same on
// every client (the host sends it along with the next mission anyway).
pub fn coop_carry_from(g: &Game) -> Vec<(u8, Carry)> {
let power = |e: &Ent| e.max_hp * e.atk() / st(e.kind).interval;
g.master_sides()
.into_iter()
.map(|side| {
let mut c: Vec<&Ent> = g.ents.iter().filter(|e| e.alive && !e.hero && e.side == side && e.kind != EK::Worker && !e.chained && e.captive == 0).collect();
c.sort_by(|a, b| power(b).partial_cmp(&power(a)).unwrap_or(std::cmp::Ordering::Equal).then(a.id.cmp(&b.id)));
let creatures = c.iter().take(CARRY_CREATURES).map(|e| (e.kind, e.level, e.xp)).collect();
(side, Carry { creatures, gold: (g.gold_of(side).max(0.0) * CARRY_GOLD).floor(), rune_free: 0, rune_final: 0 })
})
.collect()
}
// the carry of every master put into a new co-op mission: gold for him, his creatures around his heart
pub fn apply_coop_carry(g: &mut Game, carry: &[(u8, Carry)]) {
let local = g.local_side;
for (side, c) in carry {
if !g.is_master(*side) {
continue; // the seat is played by the AI now
}
g.switch_to(*side);
g.gold += c.gold.max(0.0);
let (cx, cy) = g.map.core;
for (k, &(kind, level, xp)) in c.creatures.iter().take(CARRY_CREATURES).enumerate() {
let a = k as f32 * 1.1;
let (x, y) = (cx as f32 + 1.5 + a.cos() * 3.0, cy as f32 + 1.5 + a.sin() * 3.0);
let id = g.spawn(kind, false, x, y, level.clamp(1, 10));
if let Some(e) = g.ents.iter_mut().find(|e| e.id == id) {
e.xp = xp;
e.happy = 1.0;
}
}
}
g.switch_to(local);
}
#[cfg(test)]
mod tests {
use super::*;
+10 -2
View File
@@ -85,6 +85,11 @@ pub enum Act {
y: i32,
},
CancelOrders,
// gold for an allied master of a match (`crate::netplay::GOLD_ALL`: everything)
SendGold {
to: u8,
amount: u32,
},
}
#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)]
@@ -97,7 +102,8 @@ pub struct Command {
impl Game {
// the local master orders something: queued until the next step boundary
pub fn queue(&mut self, act: Act) {
if self.state != 0 || self.paused || self.demo.is_some() || !self.is_master(self.local_side) {
// a master whose heart fell only watches
if self.state != 0 || self.paused || self.demo.is_some() || !self.is_master(self.local_side) || self.is_out(self.local_side) {
return;
}
let seq = self.cmd_seq;
@@ -109,7 +115,7 @@ impl Game {
// one command of one master: that master's state is switched in, the action runs, the local
// master's state is switched back. Messages, hints and sounds only come from the local master.
pub fn apply(&mut self, c: &Command) {
if !self.is_master(c.side) {
if !self.is_master(c.side) || self.is_out(c.side) {
return;
}
self.switch_to(c.side);
@@ -131,6 +137,7 @@ impl Game {
fn mark_command(&mut self, act: Act, side: u8) {
let ((x, y), kind, dur) = match act {
Act::SendGold { .. } => return, // nothing to point at on the map
Act::Dig(r) => (rect_center(r), MARK_DIG, 4.0),
Act::Room { r, .. } => (rect_center(r), MARK_ROOM, 5.0),
Act::Sell(r) => (rect_center(r), MARK_SELL, 4.0),
@@ -203,6 +210,7 @@ impl Game {
}
Act::Guard { x, y } => self.order_guard(x, y),
Act::CancelOrders => self.cancel_orders(),
Act::SendGold { to, amount } => self.send_gold(to, amount),
}
}
}
+13 -3
View File
@@ -97,14 +97,23 @@ impl Game {
if self.map.visible.len() != n {
self.map.visible = vec![false; n];
}
// a master whose heart fell watches the rest of the match: the whole map is open to him
if self.spectating() {
self.map.visible = vec![true; n];
self.map.explored.iter_mut().for_each(|e| *e = true);
return;
}
let mut vis = vec![false; n];
let (w, h) = (mw() as i32, mh() as i32);
// the ground and the eyes of the master at this computer and of his allies (co-op partners,
// team mates); fog is local, so the local master decides
let allies: Vec<Owner> = std::iter::once(self.me).chain(Owner::MASTER_SIDES.iter().filter(|&&s| s != self.me_side && self.allied(self.me_side, s)).map(|&s| Owner::of_side(s))).collect();
let (cx, cy) = self.own_core_corner();
let core_start = (cx - 3, cy - 3, cx + 5, cy + 5);
let in_core_start = |x: i32, y: i32| x >= core_start.0 && x <= core_start.2 && y >= core_start.1 && y <= core_start.3;
// own ground and the ground around it, except straight outside the core's starting room
for i in 0..n {
if self.map.t[i].owner == self.me {
if allies.contains(&self.map.t[i].owner) {
let (x, y) = xy(i);
vis[i] = true;
let in_start = in_core_start(x, y);
@@ -128,8 +137,9 @@ impl Game {
}
}
// 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 && 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 eyes: Vec<(usize, i32)> = self.ents.iter().filter(|e| e.alive && !e.held && !e.hero && (e.side == self.me_side || self.allied(self.me_side, e.side)) && !e.chained).map(|e| (e.tile(), SIGHT)).collect();
let solo = self.masters.is_empty();
eyes.extend(self.towers.iter().filter(|t| !t.hero && (solo || allies.contains(&self.map.t[t.tile].owner))).map(|t| (t.tile, t.range() as i32 + 1)));
let mut stamp = vec![0u32; n];
let mut q = VecDeque::new();
for (sid, &(start, r)) in eyes.iter().enumerate() {
+33 -17
View File
@@ -617,6 +617,10 @@ pub struct Game {
#[serde(skip)]
pub coop: bool, // co-op match: the playable master sides are on one side against the heroes
#[serde(skip)]
pub teams: Vec<(u8, u8)>, // (side, team) of a versus match; a side without an entry is its own team
#[serde(skip)]
pub out_sides: Vec<u8>, // masters of a match whose soul core fell (their creatures fight on)
#[serde(skip)]
pub marks: Vec<Mark>, // short-lived markers for commands given by the masters
#[serde(skip)]
pub hints: bool, // show hint messages (setting)
@@ -859,6 +863,8 @@ impl Game {
ai_sides: Vec::new(),
ai_difficulty: 1,
coop: false,
teams: Vec::new(),
out_sides: Vec::new(),
marks: Vec::new(),
hint_t: hint_times(),
reserve: 0.0,
@@ -1367,15 +1373,8 @@ impl Game {
side == self.me_side || self.masters.iter().any(|m| m.side == side)
}
pub(crate) fn is_coop_ally_side(&self, side: u8) -> bool {
self.coop
&& Owner::MASTER_SIDES.contains(&self.local_side)
&& Owner::MASTER_SIDES.contains(&side)
&& (side == self.local_side || self.is_master(side) || self.ai_sides.iter().any(|&s| s == side))
}
pub(crate) fn is_hostile(&self, e: &Ent) -> bool {
e.alive && !e.held && e.captive == 0 && !e.chained && e.side() != self.me_side && !self.is_coop_ally_side(e.side())
e.alive && !e.held && e.captive == 0 && !e.chained && e.side() != self.me_side && !self.allied(self.me_side, e.side())
}
pub fn is_rival_side(&self, side: u8) -> bool {
@@ -1576,6 +1575,10 @@ impl Game {
}
}
for side in self.master_sides() {
// a master whose heart fell has no household any more: no mana, no new creatures, no payday
if self.is_out(side) {
continue;
}
self.switch_to(side);
self.economy(dt);
}
@@ -1647,9 +1650,15 @@ impl Game {
self.update_rivals(dt);
}
self.update_dwarves(dt);
// fallen hearts of a match are shared state: every client marks them in the same step
self.update_fallen_masters();
// the end of the game and the fog are about the master at this computer
self.switch_to(local);
self.check_end();
if self.netplay.is_some() {
self.check_match_end();
} else {
self.check_end();
}
self.update_fog(dt);
}
@@ -2432,8 +2441,8 @@ impl Game {
let mut best = None;
let mut bd = radius * radius;
for o in &self.ents {
// every other side is an enemy; co-op master sides fight the heroes, not each other
let coop_ally = self.is_coop_ally_side(o.side());
// every other side is an enemy; co-op partners and team mates fight the heroes, not each other
let coop_ally = self.allied(e.side(), o.side());
if !o.alive || o.held || o.side() == e.side() || coop_ally || o.captive != 0 || o.chained || o.invis > 0.0 {
continue;
}
@@ -2908,7 +2917,7 @@ impl Game {
for e in &self.ents {
// any enemy of the player (heroes and the rivals' units) keeps ground from being claimed.
// In co-op matches, allied master sides do not block the player's own ground claims.
if !e.alive || e.held || e.captive != 0 || e.side() == 0 || (self.coop && Owner::MASTER_SIDES.contains(&e.side())) {
if !e.alive || e.held || e.captive != 0 || e.side() == 0 || (self.coop && Owner::MASTER_SIDES.contains(&e.side())) || self.allied(0, e.side()) {
continue;
}
let (x, y) = (e.x as i32, e.y as i32);
@@ -3824,11 +3833,13 @@ impl Game {
let (fx, fy) = xy(f);
let (fx, fy) = (fx as f32 + 0.5, fy as f32 + 0.5);
// the flag next to a rival master's core: attack the core
let rival_near = self.rivals.iter().filter(|r| r.alive).find(|r| {
let rival_near = self.rivals.iter().filter(|r| r.alive && !self.allied(e.side(), r.side)).find(|r| {
let (cx, cy) = r.centre();
(fx - cx).powi(2) + (fy - cy).powi(2) <= 8.0 * 8.0
});
if let Some(side) = rival_near.map(|r| r.side) {
// in a match the heart of an enemy master counts as well
let master_near = self.enemy_master_near(e.side(), fx, fy);
if let Some(side) = rival_near.map(|r| r.side).or(master_near) {
e.target = Some(side);
if self.march_on(e, dt) {
return;
@@ -4423,11 +4434,12 @@ impl Game {
let cur = e.tile();
if e.path.is_empty() && self.map.adj_core(cur) {
e.working = true;
let (cx, cy) = self.map.core;
// the core next to it is the first master's (whoever is being processed in a match)
let (cx, cy) = self.core_of_side(0);
e.face = sgn(cx as f32 + 1.5 - e.x);
if e.cd <= 0.0 {
e.cd = st(e.kind).interval;
self.core_hp -= e.atk();
self.damage_master_core(0, e.atk());
let fx = (e.x + cx as f32 + 1.5) * 0.5;
let fy = (e.y + cy as f32 + 1.5) * 0.5;
self.fx(2, fx, fy, 0.0, 0.0, 0.3, 0xff4040ff);
@@ -5078,6 +5090,10 @@ impl Game {
e.side() != 1
} else if self.coop {
e.side() == 1
} else if !self.masters.is_empty() {
// a match: a tower belongs to the master whose ground it stands on and spares his allies
let owner = self.map.t.get(t.tile).map_or(0, |tl| crate::netplay::side_of_owner(tl.owner));
!self.allied(owner, e.side())
} else {
e.side() != 0
};
@@ -6496,7 +6512,7 @@ mod tests {
g.coop = true;
assert!(g.start_masters(&[0, 2, 3], 2), "a co-op match may keep an empty side as AI");
g.sync_ai_sides(&[3], 0);
assert!(g.is_coop_ally_side(3), "side 3 is an allied master seat, not a rival");
assert!(g.allied(g.me_side, 3), "side 3 is an allied master seat, not a rival");
g.ents.clear();
let (cx, cy) = g.map.core;
+193 -2
View File
@@ -30,6 +30,7 @@ pub struct SeatUi {
pub ready: bool,
pub connected: bool,
pub ai: bool,
pub team: u8, // 0..3 = A..D (versus)
}
#[derive(Clone, Debug, Default)]
@@ -39,6 +40,9 @@ pub struct RoomUi {
pub sides: Vec<u8>,
pub started: bool,
pub paused: bool,
pub mission: Option<usize>,
pub campaign: Option<String>, // the editor campaign of a co-op room
pub title: Option<String>, // and the title of its current mission
}
#[derive(Clone, Debug, Default)]
@@ -53,6 +57,7 @@ pub struct MatchUi {
pub sides: Vec<u8>,
pub seats: Vec<SeatUi>,
pub ai: Vec<u8>,
pub coop: bool, // the open room plays a co-op mission (no teams to choose)
pub rooms: Vec<RoomUi>,
pub started: bool,
pub paused: bool,
@@ -82,6 +87,9 @@ pub struct StartInfo {
// an editor campaign sent along by the host: `mission` is then one of its missions
pub doc: Option<crate::campaign_doc::CampaignDoc>,
pub editor: bool, // the start carried an editor campaign (even one that could not be read)
pub teams: Vec<(u8, u8)>, // (side, team) of a versus match
pub next: bool, // the next mission of a running editor campaign co-op match
pub carry: Vec<(u8, crate::campaign::Carry)>, // what every side brings into that mission
}
#[derive(Clone, Debug, Default)]
@@ -91,6 +99,7 @@ struct SeatInfo {
ready: bool,
connected: bool,
ai: bool,
team: u8,
}
#[derive(Clone, Debug, Default)]
@@ -103,6 +112,7 @@ struct RoomInfo {
started: bool,
paused: bool,
missing: bool,
mission: Option<usize>,
}
#[derive(Clone, Debug)]
@@ -119,6 +129,7 @@ pub struct MatchClient {
chat: Vec<(Option<u8>, String, String)>,
chat_seen: usize,
start: Option<StartInfo>,
doc: Option<crate::campaign_doc::CampaignDoc>, // the editor campaign of the running match
turns: Vec<(u32, Vec<Command>, Vec<u8>)>,
checks: Vec<(Option<u8>, u32, u64)>,
pub ping_ms: f32,
@@ -142,6 +153,7 @@ impl MatchClient {
chat: Vec::new(),
chat_seen: 0,
start: None,
doc: None,
turns: Vec::new(),
checks: Vec::new(),
ping_ms: 0.0,
@@ -179,6 +191,7 @@ impl MatchClient {
self.code = None;
self.local_side = None;
self.start = None;
self.doc = None;
self.turns.clear();
self.status = Some("offline".into());
self.ping_ms = 0.0;
@@ -272,6 +285,28 @@ impl MatchClient {
self.outbox.push_back(json!({"t":"set_ai","side":side,"ai":ai}).to_string());
}
// the team (0..3 = A..D) of the own seat, or - for the host - of a free or AI seat
pub fn set_team(&mut self, side: Option<u8>, team: u8) {
if !self.connected {
return;
}
let mut msg = json!({"t":"set_team","team":team});
if let Some(side) = side {
msg["side"] = json!(side);
}
self.outbox.push_back(msg.to_string());
}
// the host starts the next mission of the editor campaign for everybody: the run's seed and
// what every side carries along travel with it
pub fn next_mission(&mut self, mission: usize, seed: u32, carry: &[(u8, crate::campaign::Carry)]) {
if !self.connected {
return;
}
let carry = serde_json::to_value(carry).unwrap_or(Value::Array(Vec::new()));
self.outbox.push_back(json!({"t":"next","mission":mission,"seed":seed,"carry":carry}).to_string());
}
pub fn ready(&mut self, ready: bool) {
if !self.connected {
return;
@@ -358,6 +393,7 @@ impl MatchClient {
"rooms" => self.parse_rooms(&v),
"room" => self.parse_room(&v),
"start" => self.parse_start(&v),
"next" => self.parse_next(&v),
"turn" => self.parse_turn(&v),
"sum" => self.parse_sum(&v),
"chat" => {
@@ -378,6 +414,7 @@ impl MatchClient {
self.code = None;
self.local_side = None;
self.start = None;
self.doc = None;
self.turns.clear();
self.chat.clear();
self.chat_seen = 0;
@@ -420,6 +457,9 @@ impl MatchClient {
sides: array_bytes(r.get("sides")),
started: r.get("started").and_then(|x| x.as_bool()).unwrap_or(false),
paused: r.get("paused").and_then(|x| x.as_bool()).unwrap_or(false),
mission: r.get("mission").and_then(|x| x.as_u64()).map(|x| x as usize),
campaign: r.get("campaign").and_then(|x| x.as_str()).map(|x| x.to_string()),
title: r.get("title").and_then(|x| x.as_str()).map(|x| x.to_string()),
})
.collect();
}
@@ -451,6 +491,9 @@ impl MatchClient {
if let Some(x) = v.get("missing").and_then(|x| x.as_bool()) {
room.missing = x;
}
if v.get("mission").is_some() {
room.mission = v.get("mission").and_then(|x| x.as_u64()).map(|x| x as usize);
}
if let Some(why) = v.get("why").and_then(|x| x.as_str()) {
self.status = Some(why.to_string());
}
@@ -471,6 +514,8 @@ impl MatchClient {
let mission = v.get("mission").and_then(|x| x.as_u64()).map(|x| x as usize);
let editor = v.get("doc").map_or(false, |d| d.is_object());
let doc = v.get("doc").filter(|d| d.is_object()).and_then(|d| <crate::campaign_doc::CampaignDoc as serde::Deserialize>::deserialize(d).ok());
self.doc = doc.clone();
let teams = teams_from(&sides, v.get("teams"));
let mut ai = array_bytes(v.get("ai"));
if ai.is_empty() {
if let Some(r) = self.room.as_ref() {
@@ -489,11 +534,41 @@ impl MatchClient {
}
r.ai = ai.clone();
}
self.start = Some(StartInfo { seed, map_w, map_h, difficulty, sides, local_side, mission, ai, doc, editor });
self.start = Some(StartInfo { seed, map_w, map_h, difficulty, sides, local_side, mission, ai, doc, editor, teams, next: false, carry: Vec::new() });
self.error = None;
self.status = None;
}
// the next mission of the running editor campaign: built from the campaign of the start
fn parse_next(&mut self, v: &Value) {
let Some(doc) = self.doc.clone() else { return };
let Some(mission) = v.get("mission").and_then(|x| x.as_u64()).map(|x| x as usize) else { return };
let Some(m) = doc.missions.get(mission) else { return };
let (map_w, map_h) = m.size;
let seed = v.get("seed").and_then(|x| x.as_u64()).unwrap_or(0) as u32;
let difficulty = v.get("difficulty").and_then(|x| x.as_u64()).unwrap_or(1) as u8;
let sides = array_bytes(v.get("sides"));
let ai = array_bytes(v.get("ai"));
let teams = teams_from(&sides, v.get("teams"));
let carry: Vec<(u8, crate::campaign::Carry)> = v.get("carry").cloned().and_then(|c| serde_json::from_value(c).ok()).unwrap_or_default();
// turns that came before belong to the mission that ended
self.turns.clear();
let local_side = self.room.as_ref().and_then(|r| r.seats.iter().find(|s| s.name == self.name && s.connected).map(|s| s.side)).or(self.local_side);
self.local_side = local_side;
if let Some(r) = self.room.as_mut() {
r.turn = 0;
r.paused = false;
r.missing = false;
r.mission = Some(mission);
if !sides.is_empty() {
r.sides = sides.clone();
}
r.ai = ai.clone();
}
self.start = Some(StartInfo { seed, map_w, map_h, difficulty, sides, local_side, mission: Some(mission), ai, doc: Some(doc), editor: true, teams, next: true, carry });
self.error = None;
}
fn parse_turn(&mut self, v: &Value) {
let turn = v.get("turn").and_then(|x| x.as_u64()).unwrap_or(0) as u32;
if let Some(r) = self.room.as_mut() {
@@ -534,7 +609,8 @@ impl MatchClient {
is_host,
sides: room.sides.clone(),
ai: room.ai.clone(),
seats: room.seats.iter().map(|s| SeatUi { side: s.side, name: s.name.clone(), ready: s.ready, connected: s.connected, ai: s.ai }).collect(),
coop: room.mission.is_some(),
seats: room.seats.iter().map(|s| SeatUi { side: s.side, name: s.name.clone(), ready: s.ready, connected: s.connected, ai: s.ai, team: s.team }).collect(),
rooms: self.rooms.clone(),
started: room.started,
paused: room.paused,
@@ -547,12 +623,23 @@ impl MatchClient {
}
}
// every seat starts as a team of its own (the relay does the same)
fn default_team(side: u8) -> u8 {
crate::map::Owner::MASTER_SIDES.iter().position(|s| *s == side).unwrap_or(0) as u8
}
fn array_bytes(v: Option<&Value>) -> Vec<u8> {
v.and_then(|x| x.as_array())
.map(|a| a.iter().filter_map(|x| x.as_u64().map(|n| n as u8)).collect())
.unwrap_or_default()
}
// (side, team) pairs from the teams list that goes along with `sides`
fn teams_from(sides: &[u8], v: Option<&Value>) -> Vec<(u8, u8)> {
let teams = array_bytes(v);
sides.iter().copied().zip(teams).collect()
}
fn seats_from(v: Option<&Value>) -> Vec<SeatInfo> {
v.and_then(|x| x.as_array())
.map(|arr| {
@@ -563,6 +650,7 @@ fn seats_from(v: Option<&Value>) -> Vec<SeatInfo> {
ready: s.get("ready").and_then(|x| x.as_bool()).unwrap_or(false),
connected: s.get("connected").and_then(|x| x.as_bool()).unwrap_or(false),
ai: s.get("ai").and_then(|x| x.as_bool()).unwrap_or(false),
team: s.get("team").and_then(|x| x.as_u64()).unwrap_or_else(|| default_team(s.get("side").and_then(|x| x.as_u64()).unwrap_or(0) as u8) as u64) as u8,
})
.collect()
})
@@ -704,9 +792,112 @@ mod tests {
g.coop = true;
assert!(g.start_match(&info.sides, info.local_side.unwrap()), "the masters fit the map");
g.sync_ai_sides(&info.ai, info.difficulty);
crate::campaign::apply_coop_carry(&mut g, &info.carry);
g
}
#[test]
fn two_clients_play_two_editor_missions_in_a_row_with_carry() {
use crate::game::EK;
let doc = coop_doc();
let mut clients = [MatchClient::new(), MatchClient::new()];
let room = r#"{"t":"room","code":"ABC123","sides":[0,2],"seats":[{"side":0,"name":"red","ready":true,"connected":true},{"side":2,"name":"green","ready":true,"connected":true}],"ai":[],"turn":0,"started":false,"mission":0}"#;
let start = json!({"t":"start","seed":99,"map_w":80,"map_h":80,"difficulty":1,"sides":[0,2],"teams":[0,1],"mission":0,"ai":[],"doc":serde_json::to_value(&doc).unwrap()}).to_string();
let mut games = Vec::new();
for (c, name) in clients.iter_mut().zip(["red", "green"]) {
c.on_connect(name);
c.outbox.clear();
c.feed(room);
c.feed(&start);
let info = c.take_start().expect("start info");
assert!(!info.next && info.carry.is_empty());
games.push(coop_game(&info));
}
for t in 0..60u32 {
for g in games.iter_mut() {
g.receive_turn(&[]);
}
if t % 20 == 19 {
assert_eq!(games[0].checksum(), games[1].checksum(), "mission 1, turn {t}");
}
}
// green raised a strong creature, red has a weak one; then the goal is reached on both
for g in games.iter_mut() {
let (cx, cy) = g.core_of_side(2);
let local = g.local_side;
g.switch_to(2);
let ogre = g.spawn(EK::Ogre, false, cx as f32 + 4.5, cy as f32 + 1.5, 6);
g.switch_to(0);
let (rx, ry) = g.core_of_side(0);
g.spawn(EK::Kobold, false, rx as f32 + 4.5, ry as f32 + 1.5, 1);
g.switch_to(local);
assert!(g.ents.iter().any(|e| e.id == ogre && e.side == 2));
g.state = 1;
}
let (next, _) = doc.next_mission_choice(0, |goal| games[0].match_goal_done(goal));
assert_eq!(next, Some(1), "the run goes on with the next mission");
let carry = crate::campaign::coop_carry_from(&games[0]);
assert_eq!(serde_json::to_string(&carry).unwrap(), serde_json::to_string(&crate::campaign::coop_carry_from(&games[1])).unwrap(), "every client sees the same carry");
let green_carry = &carry.iter().find(|c| c.0 == 2).unwrap().1;
assert!(green_carry.creatures.iter().any(|c| c.0 == EK::Ogre && c.1 == 6), "green takes his ogre along");
assert_eq!(green_carry.gold, (games[0].gold_of(2) * crate::campaign::CARRY_GOLD).floor());
// the host asks the relay for the next mission; the relay hands it to everybody
clients[0].next_mission(1, 99, &carry);
let req = pop(&mut clients[0]);
assert_eq!(req["t"].as_str(), Some("next"));
assert_eq!(req["mission"].as_u64(), Some(1));
let next = json!({"t":"next","seed":req["seed"],"difficulty":1,"sides":[0,2],"teams":[0,1],"mission":1,"ai":[],"carry":req["carry"]}).to_string();
let mut games2 = Vec::new();
for c in clients.iter_mut() {
c.feed(r#"{"t":"turn","turn":61,"cmds":[]}"#); // a late turn of the old mission
c.feed(&next);
let info = c.take_start().expect("the next mission starts");
assert!(info.next && info.editor && info.doc.is_some());
assert_eq!(info.mission, Some(1));
assert_eq!(info.carry.len(), 2);
assert!(c.take_turns().is_empty(), "turns of the old mission are dropped");
games2.push(coop_game(&info));
}
let (a, b) = (&games2[0], &games2[1]);
assert_eq!(a.checksum(), b.checksum(), "mission 2: the same start with the carry");
assert!(a.ents.iter().any(|e| e.alive && e.side == 2 && e.kind == EK::Ogre && e.level == 6), "the ogre came along with green");
let mut games2 = games2;
let (cx, cy) = games2[0].core_of_side(0);
for t in 0..80u32 {
let cmds = if t % 10 == 0 { vec![Command { side: 0, seq: t, act: Act::Dig(Rect::new(cx + 5, cy + (t / 10) as i32 - 3, cx + 6, cy + (t / 10) as i32 - 2)) }] } else { Vec::new() };
for g in games2.iter_mut() {
g.receive_turn(&cmds);
}
if t % 10 == 9 {
assert_eq!(games2[0].checksum(), games2[1].checksum(), "mission 2: identical up to turn {t}");
}
}
}
#[test]
fn the_bridge_sends_teams_and_reads_them_back() {
let mut client = MatchClient::new();
client.on_connect("red");
pop(&mut client); // rooms
client.set_team(None, 1);
let req = pop(&mut client);
assert_eq!((req["t"].as_str(), req["team"].as_u64(), req.get("side")), (Some("set_team"), Some(1), None));
client.set_team(Some(3), 2);
assert_eq!(pop(&mut client)["side"].as_u64(), Some(3));
client.feed(r#"{"t":"room","code":"ABC123","sides":[0,2],"seats":[{"side":0,"name":"red","connected":true,"team":1},{"side":2,"name":"","connected":false,"team":1}],"mission":null}"#);
let ui = client.ui();
assert!(!ui.coop);
assert_eq!(ui.seats.iter().map(|s| s.team).collect::<Vec<_>>(), vec![1, 1]);
client.feed(r#"{"t":"rooms","rooms":[{"code":"ABC123","players":1,"sides":[0,2],"mission":0,"editor":true,"campaign":"Deep Run","title":"First"}]}"#);
let ui = client.ui();
assert_eq!(ui.rooms[0].campaign.as_deref(), Some("Deep Run"));
assert_eq!(ui.rooms[0].title.as_deref(), Some("First"));
client.feed(r#"{"t":"start","seed":7,"sides":[0,2],"teams":[1,1]}"#);
let start = client.take_start().unwrap();
assert_eq!(start.teams, vec![(0, 1), (2, 1)]);
}
#[test]
fn two_clients_play_the_same_editor_campaign_mission_in_coop() {
let doc = coop_doc();
+86 -3
View File
@@ -222,6 +222,53 @@ fn store_progress(p: &campaign::Progress) {
platform::storage_set(PROGRESS_KEY, &serde_json::to_string(p).unwrap_or_default());
}
// progress of editor campaigns played in co-op, kept by the host: campaign name -> progress
const COOP_PROGRESS_KEY: &str = "dc.coop_editor_progress";
fn coop_progress_all() -> std::collections::BTreeMap<String, campaign_doc::CampaignProgress> {
platform::storage_get(COOP_PROGRESS_KEY).and_then(|s| serde_json::from_str(&s).ok()).unwrap_or_default()
}
fn store_coop_progress(name: &str, p: &campaign_doc::CampaignProgress) {
let mut all = coop_progress_all();
all.insert(name.to_string(), p.clone());
platform::storage_set(COOP_PROGRESS_KEY, &serde_json::to_string(&all).unwrap_or_default());
}
// a co-op run through an editor campaign: the mission being played, the run's seed, what came
// along into it, and - once it is over - where the host goes next and what everybody takes along
struct CoopRun {
doc: campaign_doc::CampaignDoc,
mission: usize,
run_seed: u32,
carry_in: Vec<(u8, campaign::Carry)>,
next: Option<usize>,
carry_out: Vec<(u8, campaign::Carry)>,
sent: bool, // the host asked the relay for the next mission
}
impl CoopRun {
// the mission is over: the host decides where the run goes on (the branch of the reached goal,
// or the same mission again after a defeat) and stores the progress
fn finish(&mut self, g: &Game) {
let won = g.state == 1;
if won {
let (next, choice) = self.doc.next_mission_choice(self.mission, |goal| g.match_goal_done(goal));
self.next = next.filter(|n| self.doc.missions.get(*n).map_or(false, campaign::is_editor_coop_mission));
self.carry_out = campaign::coop_carry_from(g);
if g.local_side == 0 {
let mut p = coop_progress_all().remove(&self.doc.name).unwrap_or_else(|| campaign_doc::CampaignProgress::for_doc(&self.doc));
p.run_seed = self.run_seed;
p.mark_done(self.mission, self.doc.missions.len(), choice);
store_coop_progress(&self.doc.name, &p);
}
} else {
self.next = Some(self.mission);
self.carry_out = self.carry_in.clone();
}
}
}
// login, account and standalone actions of the menu
fn account_action(a: MenuAction, cloud: &mut Cloud, menu: &mut Menu, app: &mut App, now: f64) {
match a {
@@ -297,6 +344,7 @@ fn account_action(a: MenuAction, cloud: &mut Cloud, menu: &mut Menu, app: &mut A
}
MenuAction::MatchTakeSide(side) => app.client.take_side(side),
MenuAction::MatchSetAi(side, ai) => app.client.set_ai(side, ai),
MenuAction::MatchSetTeam(side, team, own) => app.client.set_team(if own { None } else { Some(side) }, team),
MenuAction::MatchReady(ready) => app.client.ready(ready),
MenuAction::MatchStart => app.client.start(),
MenuAction::MatchResume => app.client.resume(),
@@ -450,6 +498,7 @@ struct App {
match_mode: bool,
coop: bool,
coop_editor: bool, // the co-op match plays a mission of an editor campaign
coop_run: Option<CoopRun>, // ... and this is where the run through that campaign stands
fps: f32,
renderer: String,
frame_ms: f32,
@@ -586,6 +635,8 @@ impl App {
}
game.ai_difficulty = info.difficulty;
game.sync_ai_sides(&info.ai, info.difficulty);
// what the masters bring from the last mission of an editor campaign run (same on every client)
campaign::apply_coop_carry(&mut game, &info.carry);
apply_game_settings(&mut game, &self.settings);
self.game = game;
let (cx, cy) = self.game.map.core;
@@ -598,6 +649,7 @@ impl App {
fn start_match_game(&mut self, info: StartInfo) -> bool {
self.coop_editor = false;
self.coop_run = None;
if !info.sides.contains(&0) {
return false;
}
@@ -614,7 +666,16 @@ impl App {
return false;
}
self.coop_editor = true;
self.recorded = false;
self.pending_briefing = briefing_of(doc, mission);
// the host keeps the progress of the run (a new run starts with the room's seed)
if !info.next && info.local_side == Some(0) {
let mut p = coop_progress_all().remove(&doc.name).unwrap_or_else(|| campaign_doc::CampaignProgress::for_doc(doc));
p.ensure(doc.missions.len());
p.run_seed = info.seed;
store_coop_progress(&doc.name, &p);
}
self.coop_run = Some(CoopRun { doc: doc.clone(), mission, run_seed: info.seed, carry_in: info.carry.clone(), next: None, carry_out: Vec::new(), sent: false });
return true;
}
if let Some(mission) = info.mission {
@@ -633,6 +694,7 @@ impl App {
return false;
}
self.game.coop = false;
self.game.set_teams(&info.teams);
self.game.ai_difficulty = info.difficulty;
self.game.sync_ai_sides(&info.ai, info.difficulty);
apply_game_settings(&mut self.game, &self.settings);
@@ -695,7 +757,7 @@ async fn main() {
let seed = (miniquad::date::now() * 1000.0) as u32 | 1;
// behind the title menu: the endless bridge battle
let (game, cam) = Game::new_demo(seed);
let mut app = App { game, cam, settings, seed, status: None, recorded: false, client: MatchClient::new(), ws_id: 0, match_mode: false, coop: false, coop_editor: false, fps: 0.0, renderer: platform::renderer(), frame_ms: 0.0, frame_hist: [0.0; FRAME_HIST], frame_hist_pos: 0, custom_campaign: None, custom_progress: None, custom_mission_index: None, custom_next_index: None, editor: None, editor_testing: false, editor_test_only: false, editor_public_pending: false, editor_loading: None, pending_briefing: None };
let mut app = App { game, cam, settings, seed, status: None, recorded: false, client: MatchClient::new(), ws_id: 0, match_mode: false, coop: false, coop_editor: false, coop_run: None, fps: 0.0, renderer: platform::renderer(), frame_ms: 0.0, frame_hist: [0.0; FRAME_HIST], frame_hist_pos: 0, custom_campaign: None, custom_progress: None, custom_mission_index: None, custom_next_index: None, editor: None, editor_testing: false, editor_test_only: false, editor_public_pending: false, editor_loading: None, pending_briefing: None };
menu.progress = platform::storage_get(PROGRESS_KEY).and_then(|s| serde_json::from_str(&s).ok()).unwrap_or_default();
apply_game_settings(&mut app.game, &app.settings);
let mut inp = Input { ldown: None, rdown: None, rlast: (0.0, 0.0), rdrag: false, mlast: None, minimap_drag: false, tlast: None, touch_world: false, inspect: None };
@@ -852,6 +914,8 @@ async fn main() {
// ---------------- multiplayer relay: receive messages, send queued requests, update the menu state
app.poll_match(now);
menu.in_match = app.match_mode || app.game.in_match();
menu.gold_allies = app.game.gold_allies();
ui.coop_end = app.coop_run.as_ref().filter(|_| app.coop_editor).map(|r| (app.game.local_side == 0, r.next.is_some(), r.sent));
if matches!(menu.page, Some(Page::Match { .. })) {
menu.set_match_ui(app.client.ui());
}
@@ -1215,6 +1279,7 @@ async fn main() {
match menu.click(ux, uy, &mut app.settings, &cloud) {
MenuAction::SettingsChanged => settings_changed = true,
MenuAction::Resume => {}
MenuAction::SendGold(to, amount) => app.game.queue(Act::SendGold { to, amount }),
MenuAction::NewGame => {
app.seed = next_seed(app.seed);
(app.game, app.cam) = new_game(app.seed, &app.settings);
@@ -1398,6 +1463,7 @@ app.pending_briefing = briefing_of(&menu.campaign_doc, i);
| MenuAction::MatchCoopCreate(..)
| MenuAction::MatchTakeSide(..)
| MenuAction::MatchSetAi(..)
| MenuAction::MatchSetTeam(..)
| MenuAction::MatchReady(..)
| MenuAction::MatchStart
| MenuAction::MatchResume
@@ -1447,7 +1513,18 @@ app.pending_briefing = briefing_of(&menu.campaign_doc, i);
}
// end of a mission: next mission (won) or try again (lost)
UiAction::EndNext | UiAction::EndCampaign if app.editor_testing && app.editor_test_only => back_to_editor(&mut app, &mut menu, &mut r3d),
// a co-op mission of an editor campaign is over: the match ends, back to the lobby
// a co-op mission of an editor campaign is over: the host starts the next one (or the
// same one again) for everybody; the others wait for it
UiAction::EndNext if app.coop_editor && app.game.local_side == 0 && app.coop_run.as_ref().map_or(false, |r| r.next.is_some() && !r.sent) => {
if let Some(run) = app.coop_run.as_mut() {
if let Some(next) = run.next {
app.client.next_mission(next, run.run_seed, &run.carry_out);
run.sent = true;
}
}
}
UiAction::EndNext if app.coop_editor && app.coop_run.as_ref().map_or(false, |r| r.next.is_some()) => {}
// ... or the run is over (or the player leaves): the match ends, back to the lobby
UiAction::EndNext | UiAction::EndCampaign if app.coop_editor => {
app.leave_match();
app.seed = next_seed(app.seed);
@@ -1521,6 +1598,7 @@ app.pending_briefing = briefing_of(&campaign, i);
}
}
UiAction::OpenMenu => menu.page = Some(Page::Pause),
UiAction::SendGold(to, amount) => app.game.queue(Act::SendGold { to, amount }),
UiAction::Locked => {
let locked = ui.last_locked.unwrap_or(usize::MAX);
app.game.msg(if app.game.can_research(locked) { "msg.tool_research" } else { "msg.tool_locked" }, vec![]);
@@ -1670,7 +1748,12 @@ app.pending_briefing = briefing_of(&campaign, i);
// a won mission goes into the campaign progress (with the carry-over); a co-op mission of an
// editor campaign keeps no progress (its index is not a built-in mission)
if app.coop_editor {
app.recorded = app.game.state != 0;
if app.game.state != 0 && !app.recorded {
app.recorded = true;
if let Some(run) = app.coop_run.as_mut() {
run.finish(&app.game);
}
}
} else if app.custom_campaign.is_none() {
if let (Some(i), false) = (app.game.mission.as_ref().map(|m| m.index), app.recorded) {
if app.game.state != 0 {
+69 -2
View File
@@ -281,6 +281,9 @@ enum Btn {
MatchCoopSource, // switch between the built-in campaign and the editor campaign
MatchTake(usize),
MatchAi(usize),
MatchTeam(usize), // next team for that seat
GoldTo, // pause menu: the next ally to receive gold
Gold(u32), // pause menu: send that much gold (GOLD_ALL: everything)
MatchReady,
MatchStart,
MatchResume,
@@ -396,6 +399,8 @@ pub enum MenuAction {
MatchCoopEditorCreate(usize), // co-op match of a mission of `campaign_doc`
MatchTakeSide(u8),
MatchSetAi(u8, bool),
MatchSetTeam(u8, u8, bool), // side, team, the own seat
SendGold(u8, u32), // ally side, amount
MatchReady(bool),
MatchStart,
MatchResume,
@@ -419,6 +424,8 @@ pub struct Menu {
pub coop_editor: bool, // the Match page offers the editor campaign instead of the built-in one
pub coop_editor_mission: usize, // mission of `campaign_doc` selected for co-op
pub in_match: bool, // a match is being played; the pause menu changes its shape
pub gold_allies: Vec<u8>, // allies of the local master that can get gold (pause menu)
pub gold_to: usize, // the one chosen among them
pub editor_test: bool, // a mission of the editor is being tested: back to the editor
pub custom_briefing: (String, String), // title and text of an editor mission's briefing
pub campaign_doc: CampaignDoc,
@@ -458,6 +465,8 @@ impl Menu {
coop_editor: false,
coop_editor_mission: 0,
in_match: false,
gold_allies: Vec::new(),
gold_to: 0,
editor_test: false,
custom_briefing: (String::new(), String::new()),
campaign_doc: CampaignDoc::default(),
@@ -791,6 +800,18 @@ impl Menu {
Some(s) => MenuAction::MatchTakeSide(s.side),
None => MenuAction::Consumed,
},
Some(Btn::MatchTeam(i)) => match self.match_ui.seats.get(i) {
Some(s) => MenuAction::MatchSetTeam(s.side, (s.team + 1) % 4, Some(s.side) == self.match_ui.local_side),
None => MenuAction::Consumed,
},
Some(Btn::GoldTo) => {
self.gold_to = (self.gold_to + 1) % self.gold_allies.len().max(1);
MenuAction::Consumed
}
Some(Btn::Gold(amount)) => match self.gold_allies.get(self.gold_to.min(self.gold_allies.len().saturating_sub(1))) {
Some(&to) => MenuAction::SendGold(to, amount),
None => MenuAction::Consumed,
},
Some(Btn::MatchAi(i)) => match self.match_ui.seats.get(i) {
Some(s) if s.side != 0 => MenuAction::MatchSetAi(s.side, !s.ai),
_ => MenuAction::Consumed,
@@ -1156,13 +1177,32 @@ impl Menu {
(Btn::MainMenu, tr("menu.main")),
]);
}
let cols = 32;
let rows = items.len() as i32 * 3 + 4;
// a match with allies: gold for one of them (the ally button switches between them)
let gold = self.in_match && !self.gold_allies.is_empty();
let cols = if gold { 40 } else { 32 };
let rows = items.len() as i32 * 3 + 4 + if gold { 6 } else { 0 };
let x = grid_center(sw, cols as f32 * CW());
let y = (grid_center_y(sh, rows as f32 * CH())).max(0.0);
gfx.window_titled(x, y, cols, rows, S(), true, FRAME_HI, Color::new(0.05, 0.03, 0.04, 0.97), &tr("menu.paused"), FRAME_HI);
let w = (cols - 6) as f32 * CW();
self.column(gfx, x + CW() * 3.0, y + CH() * 2.0, w, &items);
if gold {
let (mx, my) = mouse();
self.gold_to = self.gold_to.min(self.gold_allies.len() - 1);
let to = self.gold_allies[self.gold_to];
let gy = y + CH() * (2.0 + items.len() as f32 * 3.0);
let label = trf("gold.send_to", &[tr(&format!("net.side{to}"))]);
let r = Rect::new(x + CW() * 3.0, gy, w, CH() * 2.0);
button(gfx, r, &label, r.contains(vec2(mx, my)), side_color(Some(to)));
self.buttons.push((r, Btn::GoldTo));
let bw = (w - CW() * 3.0) / 4.0;
for (k, amount) in crate::netplay::GOLD_AMOUNTS.iter().enumerate() {
let label = if *amount == crate::netplay::GOLD_ALL { tr("gold.all") } else { amount.to_string() };
let r = Rect::new(x + CW() * 3.0 + k as f32 * (bw + CW()), gy + CH() * 2.6, bw, CH() * 2.0);
button(gfx, r, &label, r.contains(vec2(mx, my)), GOLD);
self.buttons.push((r, Btn::Gold(*amount)));
}
}
let ver = tr("menu.version");
let line = format!("{} {}", ver, version());
gfx.text(&line, x + (cols as f32 * CW() - gfx.measure(&line, SMALL())) * 0.5, y + (rows as f32 - 1.7) * CH(), SMALL(), DIM);
@@ -1970,6 +2010,21 @@ impl Menu {
let r = Rect::new(ix, yy, 24.0 * CW(), CH() * 2.0);
button(gfx, r, &label, r.contains(vec2(mx, my)), FRAME);
self.buttons.push((r, Btn::MatchJoinRoom(k)));
// what the room plays: an editor campaign (name and mission), a co-op mission or versus
let what = match (&room.campaign, room.mission) {
(Some(name), _) => {
let name = if name.trim().is_empty() { "?".to_string() } else { name.clone() };
match &room.title {
Some(t) if !t.trim().is_empty() => format!("{name}: {t}"),
_ => name,
}
}
(None, Some(m)) => missions().get(m).map_or_else(|| tr("net.coop_create"), |d| tr(&format!("camp.{}.title", d.key))),
(None, None) => tr("net.versus"),
};
let maxc = ((w - 25.0 * CW()) / (GW * SMALL())).max(4.0) as usize;
let what: String = what.chars().take(maxc).collect();
gfx.text(&what, ix + 25.0 * CW(), yy + (CH() * 2.0 - GH * SMALL()) * 0.5, SMALL(), if room.campaign.is_some() { GOLD } else { DIM });
yy += CH() * 2.2;
}
} else if ui.rooms.is_empty() && !ui.started {
@@ -1999,6 +2054,18 @@ impl Menu {
let ready = if s.ready || s.ai { "✓" } else { "—" };
let line = format!("{} {} {} {} {}", side_label, name, status, ready, if s.side == 0 { tr("net.host") } else { String::new() });
gfx.text(&line, ix, yy, SMALL(), if s.connected || s.ai { TXT } else { DIM });
// versus: every seat chooses its team (the host also for free and AI seats)
if !ui.coop {
let team = trf("net.team", &[((b'A' + s.team.min(3)) as char).to_string()]);
let r = Rect::new(ix + 49.0 * CW(), yy, 10.0 * CW(), CH() * 2.0);
let may = !ui.started && (is_local || (ui.is_host && !s.connected));
if may {
button(gfx, r, &team, r.contains(vec2(mx, my)), FRAME);
self.buttons.push((r, Btn::MatchTeam(k)));
} else {
gfx.text(&team, r.x + CW(), yy + CH() * 0.5, SMALL(), DIM);
}
}
if !ui.started && ui.local_side.is_some() && !s.connected {
if ui.is_host && s.side != 0 {
if s.ai {
+446 -1
View File
@@ -6,6 +6,7 @@
use crate::commands::Command;
use crate::game::Game;
use crate::map::{Kind, Owner};
// one server turn of 100 ms: five fixed simulation steps
pub const STEPS_PER_TURN: usize = 5;
@@ -46,7 +47,281 @@ fn mixdbg(h: &mut u64, v: impl std::fmt::Debug) {
}
}
// the side whose ground a tile of `owner` is (unowned ground counts as the first player's)
pub fn side_of_owner(owner: Owner) -> u8 {
match owner {
Owner::Hero => 1,
Owner::Rival1 => 2,
Owner::Rival2 => 3,
Owner::Rival3 => 4,
Owner::Player | Owner::None => 0,
}
}
// gold amounts a master can send to an ally; `GOLD_ALL` sends everything he has
pub const GOLD_ALL: u32 = u32::MAX;
pub const GOLD_AMOUNTS: [u32; 4] = [100, 500, 1000, GOLD_ALL];
impl Game {
// ------------------------------------------------------------ teams and allies
// the team of a master seat in a versus match (sides without an entry are a team of their own)
pub fn team_of(&self, side: u8) -> u8 {
self.teams.iter().find(|t| t.0 == side).map_or(10 + side, |t| t.1)
}
// a master seat of the match: a human master or an AI seat
pub fn is_seat_side(&self, side: u8) -> bool {
Owner::MASTER_SIDES.contains(&side) && (self.is_master(side) || self.ai_sides.contains(&side))
}
// sides that never fight each other: the same side, co-op partners and team mates. Only shared
// state goes in, so every client of a match decides the same.
pub fn allied(&self, a: u8, b: u8) -> bool {
if a == b {
return true;
}
if !self.is_seat_side(a) || !self.is_seat_side(b) {
return false;
}
self.coop || self.team_of(a) == self.team_of(b)
}
// the teams of a versus match: (side, team) for the master seats
pub fn set_teams(&mut self, teams: &[(u8, u8)]) {
self.teams = teams.iter().copied().filter(|(s, _)| Owner::MASTER_SIDES.contains(s)).collect();
}
// ------------------------------------------------------------ fallen hearts
pub fn is_out(&self, side: u8) -> bool {
self.out_sides.contains(&side)
}
// the master at this computer lost his heart: he watches the rest of the match
pub fn spectating(&self) -> bool {
self.netplay.is_some() && self.is_out(self.local_side)
}
fn master_state(&self, side: u8) -> Option<&crate::game::MasterState> {
self.masters.iter().find(|m| m.side == side)
}
pub fn core_hp_of(&self, side: u8) -> f32 {
if side == self.me_side {
self.core_hp
} else {
self.master_state(side).map_or(0.0, |m| m.core_hp)
}
}
pub fn core_of_side(&self, side: u8) -> (i32, i32) {
if side == self.me_side {
self.map.core
} else {
self.master_state(side).map_or(self.map.core, |m| m.core)
}
}
pub fn gold_of(&self, side: u8) -> f32 {
if side == self.me_side {
self.gold
} else {
self.master_state(side).map_or(0.0, |m| m.gold)
}
}
// damage to the soul core of a master (whichever master is being processed)
pub fn damage_master_core(&mut self, side: u8, dmg: f32) {
if side == self.me_side {
self.core_hp -= dmg;
} else if let Some(m) = self.masters.iter_mut().find(|m| m.side == side) {
m.core_hp -= dmg;
}
}
// the heart of a master of the match that is no ally of `side`, near (x, y)
pub(crate) fn enemy_master_near(&self, side: u8, x: f32, y: f32) -> Option<u8> {
if self.masters.is_empty() {
return None;
}
self.master_sides().into_iter().filter(|&s| s != side && !self.is_out(s) && !self.allied(side, s)).find(|&s| {
let (cx, cy) = self.core_of_side(s);
(x - cx as f32 - 1.5).powi(2) + (y - cy as f32 - 1.5).powi(2) <= 8.0 * 8.0
})
}
// a message and a sound for the player at this computer, whichever master is being processed
fn tell_local(&mut self, key: &'static str, args: Vec<String>, sfx: Option<crate::game::Sfx>) {
let me = self.me_side;
let local = self.local_side;
self.switch_to(local);
self.msg(key, args);
if let Some(sfx) = sfx {
self.sfx_ui(sfx);
}
self.switch_to(me);
}
// a master of a match whose core has no strength left is out. His creatures fight on without a
// heart until they fall; he keeps watching. Runs in the shared part of the step.
pub(crate) fn update_fallen_masters(&mut self) {
if self.netplay.is_none() {
return;
}
for side in self.master_sides() {
if self.is_out(side) || self.core_hp_of(side) > 0.0 {
continue;
}
self.out_sides.push(side);
self.master_fell(side);
}
}
fn master_fell(&mut self, side: u8) {
let me = self.me_side;
self.switch_to(side);
self.core_hp = 0.0;
self.flag = None;
// what he held in his hand is put down beside the broken heart
let (cx, cy) = self.map.core;
let hand = std::mem::take(&mut self.hand);
for (k, id) in hand.iter().enumerate() {
if let Some(e) = self.ents.iter_mut().find(|e| e.id == *id && e.held) {
e.held = false;
e.x = cx as f32 + 1.5 + (k % 3) as f32 * 0.6 - 0.6;
e.y = cy as f32 + 3.5;
e.disp_x = e.x;
e.disp_y = e.y;
e.job = crate::game::Job::None;
e.path.clear();
}
}
// the heart is gone: its tiles become plain ground of that master
let owner = Owner::of_side(side);
for t in self.map.t.iter_mut() {
if t.kind == Kind::Core && t.owner == owner {
t.kind = Kind::Floor;
}
}
self.switch_to(me);
if side == self.local_side {
self.tell_local("msg.master_out_you", vec![], Some(crate::game::Sfx::Defeat));
} else {
self.tell_local("msg.master_out", vec![format!("@net.side{side}")], Some(crate::game::Sfx::CoreHit));
}
}
// goals of a mission played in a match: what one master holds (mana springs) counts for any of
// the masters still in the game, everything else is shared
pub fn match_goal_done(&self, goal: crate::game::Goal) -> bool {
match goal {
crate::game::Goal::Springs(n) => self.master_sides().into_iter().filter(|s| !self.is_out(*s)).any(|s| {
let springs = if s == self.me_side { self.springs } else { self.master_state(s).map_or(0, |m| m.springs) };
springs >= n as usize
}),
g => self.goal_done(g),
}
}
// The end of a match, decided from shared state only so that all clients stop in the same step:
// a mission (co-op) is lost when every master's heart fell; a versus match is over when only one
// team is left - the masters of that team win, the others lose.
pub(crate) fn check_match_end(&mut self) {
if self.state != 0 {
return;
}
let alive: Vec<u8> = self.master_sides().into_iter().filter(|s| !self.is_out(*s)).collect();
let lost = |g: &mut Game, key: &'static str| {
g.state = 2;
g.msg(key, vec![]);
g.sfx_ui(crate::game::Sfx::Defeat);
};
if let Some(m) = &self.mission {
let (limit, goals) = (m.time_limit, m.goals.clone());
if alive.is_empty() {
return lost(self, "msg.defeat");
}
if limit > 0.0 && self.time > limit {
return lost(self, "msg.time_up");
}
if !goals.is_empty() && goals.iter().all(|&g| self.match_goal_done(g)) {
self.state = 1;
self.msg("msg.mission_won", vec![]);
self.sfx_ui(crate::game::Sfx::Victory);
}
return;
}
// the teams that started (masters and AI seats) and the ones still standing
let mut sides: Vec<u8> = self.master_sides();
sides.extend(self.rivals.iter().map(|r| r.side));
let mut teams: Vec<u8> = sides.iter().map(|&s| self.team_of(s)).collect();
teams.sort_unstable();
teams.dedup();
let mut standing: Vec<u8> = alive.iter().copied().chain(self.rivals.iter().filter(|r| r.alive).map(|r| r.side)).map(|s| self.team_of(s)).collect();
standing.sort_unstable();
standing.dedup();
if alive.is_empty() {
return lost(self, "msg.defeat");
}
let over = if teams.len() >= 2 {
standing.len() <= 1
} else {
// everybody on one team: the humans' gate is the enemy
self.gate_on && self.gate_hp <= 0.0
};
if !over {
return;
}
if standing.contains(&self.team_of(self.local_side)) {
self.state = 1;
self.msg("msg.match_won", vec![]);
self.sfx_ui(crate::game::Sfx::Victory);
} else {
lost(self, "msg.match_lost");
}
}
// ------------------------------------------------------------ gold for allies
// the masters the local master can send gold to: human allies whose heart still beats
pub fn gold_allies(&self) -> Vec<u8> {
if self.netplay.is_none() || self.is_out(self.local_side) {
return Vec::new();
}
self.master_sides().into_iter().filter(|&s| s != self.local_side && !self.is_out(s) && self.allied(self.local_side, s)).collect()
}
// the master being processed sends gold to an ally (never more than he has, never more than
// the ally's treasury takes)
pub(crate) fn send_gold(&mut self, to: u8, amount: u32) {
let from = self.me_side;
if to == from || !self.is_master(to) || !self.allied(from, to) || self.is_out(to) || self.is_out(from) {
return;
}
let want = (amount as f32).min(self.gold.floor());
if want < 1.0 {
self.msg("msg.gold_none", vec![]);
self.sfx_ui(crate::game::Sfx::Error);
return;
}
self.switch_to(to);
let got = self.add_gold(want).floor();
if got >= 1.0 {
self.msg("msg.gold_received", vec![(got as i32).to_string(), format!("@net.side{from}")]);
self.sfx_ui(crate::game::Sfx::Payday);
}
self.switch_to(from);
if got < 1.0 {
self.msg("msg.gold_full", vec![format!("@net.side{to}")]);
self.sfx_ui(crate::game::Sfx::Error);
return;
}
self.gold -= got;
self.msg("msg.gold_sent", vec![(got as i32).to_string(), format!("@net.side{to}")]);
self.sfx_ui(crate::game::Sfx::Deposit);
}
// a match with masters on `sides` (of 0, 2, 3, 4), `local` is the master who plays at this computer
pub fn start_match(&mut self, sides: &[u8], local: u8) -> bool {
if !self.start_masters(sides, local) {
@@ -81,6 +356,9 @@ impl Game {
.filter(|side| *side != self.local_side)
.collect();
for side in self.ai_sides.clone() {
if self.is_out(side) {
continue; // a fallen master who left has nothing left for an AI to play
}
let core = if side == self.me_side {
continue;
} else if let Some(m) = self.masters.iter().find(|m| m.side == side) {
@@ -169,7 +447,8 @@ impl Game {
pub fn checksum(&self) -> u64 {
let mut h = FNV_OFFSET;
mix(&mut h, self.sim_steps);
mix(&mut h, self.state as u64);
// whether the world is still running: the end itself is told per team (won or lost)
mix(&mut h, (self.state != 0) as u64);
mix(&mut h, self.next_id as u64);
mix(&mut h, self.rng.0 as u64);
mix(&mut h, self.wave as u64);
@@ -269,6 +548,9 @@ impl Game {
for side in self.ai_sides.iter() {
mix(&mut h, *side as u64);
}
for side in self.out_sides.iter() {
mix(&mut h, 100 + *side as u64);
}
h
}
}
@@ -371,6 +653,169 @@ mod tests {
assert_eq!(g.netplay.as_ref().unwrap().turn, 1);
}
// every client of one match: the same world, seen from another seat
fn clients(seed: u32, locals: &[u8], teams: &[(u8, u8)]) -> Vec<Game> {
locals
.iter()
.map(|&local| {
let mut g = client(seed, local);
g.set_teams(teams);
g
})
.collect()
}
fn turn_all(games: &mut [Game], cmds: &[Command]) {
for g in games.iter_mut() {
g.receive_turn(cmds);
}
}
fn same_world(games: &[Game], why: &str) {
let sum = games[0].checksum();
for g in games.iter().skip(1) {
assert_eq!(g.checksum(), sum, "{why}");
}
}
#[test]
fn a_fallen_master_watches_while_the_match_goes_on() {
let mut games = clients(41, &[0, 2, 3], &[]);
for g in games.iter_mut() {
g.damage_master_core(2, crate::game::CORE_HP * 2.0);
}
turn_all(&mut games, &[]);
same_world(&games, "the heart fell in the same step everywhere");
for g in games.iter() {
assert!(g.is_out(2) && !g.is_out(0) && !g.is_out(3));
assert_eq!(g.state, 0, "the match goes on for the others");
}
let green = &games[1];
assert!(green.spectating() && !games[0].spectating());
assert!(green.msgs.iter().any(|m| m.key == "msg.master_out_you"));
assert!(games[0].msgs.iter().any(|m| m.key == "msg.master_out"));
assert!(!green.map.t.iter().any(|t| t.kind == Kind::Core && t.owner == Owner::Rival1), "the heart is broken");
let workers = green.ents.iter().filter(|e| e.alive && e.side == 2).count();
assert!(workers > 0, "his creatures go on without a heart");
// the spectator sees everything and cannot order anything
let mut games = games;
for _ in 0..30 {
turn_all(&mut games, &[]);
}
assert!(games[1].map.visible.iter().all(|v| *v), "no fog for the spectator");
assert!(!games[0].map.visible.iter().all(|v| *v), "the others still have fog");
let (gx, gy) = games[1].core_of_side(2);
games[1].queue(Act::Dig(Rect::new(gx + 7, gy, gx + 8, gy + 1)));
assert!(games[1].take_orders().is_empty(), "a spectator gives no orders");
// an order that still comes in for the fallen side changes nothing
let stray = vec![Command { side: 2, seq: 0, act: Act::Dig(Rect::new(gx + 7, gy, gx + 8, gy + 1)) }];
turn_all(&mut games, &stray);
assert!(!games[0].map.t[crate::map::idx(gx + 7, gy)].marked);
for _ in 0..50 {
turn_all(&mut games, &[]);
}
same_world(&games, "the spectator stays in step");
assert!(games.iter().all(|g| g.state == 0));
// the last enemy falls: red wins, blue (and the spectator) lose, in the same step
for g in games.iter_mut() {
g.damage_master_core(3, crate::game::CORE_HP * 2.0);
}
turn_all(&mut games, &[]);
same_world(&games, "the end came in the same step everywhere");
assert_eq!(games[0].state, 1, "red is the last master standing");
assert_eq!(games[1].state, 2);
assert_eq!(games[2].state, 2);
assert!(games[0].msgs.iter().any(|m| m.key == "msg.match_won"));
}
#[test]
fn team_mates_do_not_fight_and_win_together() {
// red and green are team A, blue is team B
let teams = [(0u8, 0u8), (2, 0), (3, 1)];
let mut games = clients(43, &[0, 2, 3], &teams);
let g = &games[0];
assert!(g.allied(0, 2) && g.allied(2, 0) && !g.allied(0, 3) && !g.allied(2, 3));
assert_eq!(games[1].gold_allies(), vec![0u8], "green can send gold to red");
assert!(games[2].gold_allies().is_empty(), "blue has no team mate");
// a creature of red next to one of green: no target; one of blue is
for g in games.iter_mut() {
let (cx, cy) = g.core_of_side(2);
let (x, y) = (cx as f32 + 4.5, cy as f32 + 4.5);
let red = g.spawn(crate::game::EK::Kobold, false, x, y, 3);
let green = g.spawn(crate::game::EK::Kobold, false, x + 1.0, y, 3);
for (id, side) in [(red, 0u8), (green, 2u8)] {
if let Some(e) = g.ents.iter_mut().find(|e| e.id == id) {
e.side = side;
}
}
let red_e = g.ents.iter().find(|e| e.id == red).unwrap().clone();
assert_eq!(g.nearest_enemy(&red_e, 3.0), None, "team mates are no targets");
let blue = g.spawn(crate::game::EK::Kobold, false, x, y + 1.0, 3);
if let Some(e) = g.ents.iter_mut().find(|e| e.id == blue) {
e.side = 3;
}
assert_eq!(g.nearest_enemy(&red_e, 3.0), Some(blue), "the other team is");
}
for _ in 0..40 {
turn_all(&mut games, &[]);
}
same_world(&games, "teams play the same on every client");
// the fog shows the team mate's ground
let (gx, gy) = games[0].core_of_side(2);
assert!(games[0].map.visible[crate::map::idx(gx + 1, gy + 1)], "red sees green's heart");
assert!(!games[2].map.visible[crate::map::idx(gx + 1, gy + 1)], "blue does not");
// one of team A falls: the match goes on; team B falls: team A wins together
for g in games.iter_mut() {
g.damage_master_core(0, crate::game::CORE_HP * 2.0);
}
turn_all(&mut games, &[]);
assert!(games.iter().all(|g| g.state == 0), "green still stands for team A");
for g in games.iter_mut() {
g.damage_master_core(3, crate::game::CORE_HP * 2.0);
}
turn_all(&mut games, &[]);
same_world(&games, "the end came in the same step everywhere");
assert_eq!(games[0].state, 1, "red was out, but his team won");
assert_eq!(games[1].state, 1);
assert_eq!(games[2].state, 2);
}
#[test]
fn gold_goes_to_an_ally_on_every_client() {
let mut games = clients(45, &[0, 2, 3], &[(0, 0), (2, 0), (3, 1)]);
let red0 = games[0].gold_of(0);
let green0 = games[0].gold_of(2);
games[0].queue(Act::SendGold { to: 2, amount: 500 });
let orders = games[0].take_orders();
assert_eq!(orders.len(), 1);
turn_all(&mut games, &orders);
for g in games.iter() {
assert_eq!(g.gold_of(0), red0 - 500.0, "red paid");
assert_eq!(g.gold_of(2), green0 + 500.0, "green got it");
}
assert!(games[1].msgs.iter().any(|m| m.key == "msg.gold_received"), "the receiver hears of it");
assert!(games[1].events.iter().any(|e| e.sfx == crate::game::Sfx::Payday));
assert!(games[0].msgs.iter().any(|m| m.key == "msg.gold_sent"));
same_world(&games, "both clients have the same gold");
// not more than he has (and not more than the ally's treasury takes); never to an enemy
let all = vec![
Command { side: 2, seq: 0, act: Act::SendGold { to: 0, amount: crate::netplay::GOLD_ALL } },
Command { side: 0, seq: 1, act: Act::SendGold { to: 3, amount: 100 } },
Command { side: 3, seq: 0, act: Act::SendGold { to: 0, amount: 100 } },
];
let blue = games[0].gold_of(3);
let (red1, green1) = (games[0].gold_of(0), games[0].gold_of(2));
turn_all(&mut games, &all);
for g in games.iter() {
let sent = green1 - g.gold_of(2);
assert!(sent > 0.0 && sent <= green1, "green sends what he has: {sent}");
assert_eq!(g.gold_of(0), red1 + sent, "red gets all of it");
assert!(g.gold_of(0) <= g.gold_cap() + 0.5 || g.local_side != 0);
assert!((g.gold_of(3) - blue).abs() < 1.0, "blue got nothing from red and gave nothing");
}
same_world(&games, "still the same gold everywhere");
}
#[test]
fn a_world_that_develops_differently_is_caught_by_the_checksum() {
let (mut a, mut b) = (client(23, 0), client(23, 0));
+12 -7
View File
@@ -202,9 +202,14 @@ impl Game {
targets.push((1, 3));
}
} else {
targets.push((0, 3));
// the masters (the player, or the human masters of a match) that are not allies
for s in self.master_sides() {
if !self.is_out(s) && !self.allied(side, s) {
targets.push((s, 3));
}
}
for o in &self.rivals {
if o.alive && o.side != side && !self.is_coop_ally_side(o.side) {
if o.alive && o.side != side && !self.allied(side, o.side) {
targets.push((o.side, 1));
}
}
@@ -319,8 +324,8 @@ impl Game {
fn target_alive(&self, target: u8) -> bool {
match target {
0 => self.core_hp > 0.0,
1 => self.has_gate() && self.gate_hp > 0.0,
s if self.is_master(s) => !self.is_out(s) && self.core_hp_of(s) > 0.0,
s => self.rival_alive(s),
}
}
@@ -330,9 +335,9 @@ impl Game {
let dmg = e.atk();
e.cd = st(e.kind).interval;
match target {
0 => {
self.core_hp -= dmg;
let (cx, cy) = self.map.core;
s if self.is_master(s) => {
self.damage_master_core(s, dmg);
let (cx, cy) = self.core_of_side(s);
self.fx(2, (e.x + cx as f32 + 1.5) * 0.5, (e.y + cy as f32 + 1.5) * 0.5, 0.0, 0.0, 0.3, 0xff4040ff);
self.sfx(Sfx::CoreHit, e.x, e.y);
self.hint(6, "hint.core_attacked");
@@ -366,7 +371,7 @@ impl Game {
e.repath = 1.5;
// attackers dig their way like heroes; the player's creatures only walk
let goal = |i: usize| self.target_core_adjacent(i, target);
let found = if e.side() == 0 { self.map.search(cur, self.map.creature_cost(flies(e.kind)), goal, true) } else { self.map.search(cur, self.map.hero_cost_as(flies(e.kind)), goal, true) };
let found = if !e.hero && self.is_master(e.side()) { self.map.search(cur, self.map.creature_cost(flies(e.kind)), goal, true) } else { self.map.search(cur, self.map.hero_cost_as(flies(e.kind)), goal, true) };
match found {
Some(p) => e.path = p,
None => {
+68 -5
View File
@@ -346,6 +346,8 @@ enum TBtn {
Overview,
OvRow(u32),
OvScroll(i32),
GoldTo, // the next ally to receive gold
Gold(u32), // send that much gold to him
}
pub enum UiAction {
@@ -362,6 +364,7 @@ pub enum UiAction {
SelectLanguage(String),
ChatSetTeam(bool),
ChatSend,
SendGold(u8, u32), // gold for an ally of a match (side, amount)
Consumed,
}
@@ -369,6 +372,8 @@ pub struct Ui {
pub tool: Tool,
pub show_help: bool,
pub show_overview: bool, // list of all creatures
pub coop_end: Option<(bool, bool, bool)>, // end of an editor co-op mission: (host, a next mission, asked for it)
gold_to: usize, // the ally chosen in the creature overview
ov_scroll: usize,
ov_rect: Option<Rect>, // where the creature overview is (mouse wheel, swipe)
pub show_langsel: bool,
@@ -431,6 +436,8 @@ impl Ui {
tool: Tool::Dig,
show_help: true,
show_overview: false,
coop_end: None,
gold_to: 0,
ov_scroll: 0,
ov_rect: None,
show_langsel: false,
@@ -527,6 +534,17 @@ impl Ui {
self.ov_scroll = (self.ov_scroll as i32 + d).max(0) as usize;
UiAction::Consumed
}
TBtn::GoldTo => {
self.gold_to += 1;
UiAction::Consumed
}
TBtn::Gold(amount) => {
let allies = g.gold_allies();
match allies.get(self.gold_to % allies.len().max(1)) {
Some(&to) => UiAction::SendGold(to, amount),
None => UiAction::Consumed,
}
}
TBtn::OvRow(id) => match g.ents.iter().find(|e| e.id == id) {
Some(e) => UiAction::MinimapJump(e.disp_x, e.disp_y),
None => UiAction::Consumed,
@@ -1036,6 +1054,32 @@ impl Ui {
let mood = if mine.is_empty() { 0.0 } else { mine.iter().map(|e| e.happy.clamp(0.0, 1.0)).sum::<f32>() / mine.len() as f32 };
let head = trf("ov.summary", &[mine.len().to_string(), (wage as i32).to_string(), ((mood * 100.0) as i32).to_string()]);
gfx.text(&head, x + CW() * 2.0, y + CH(), S(), GOLD);
// a match with allies: a row to send them gold (the name switches between the allies)
let allies = g.gold_allies();
let gold_row = !allies.is_empty();
if gold_row {
let to = allies[self.gold_to % allies.len()];
let gy = y + CH() * 2.2;
let name = trf("gold.send_to", &[tr(&format!("net.side{to}"))]);
let mut bx = x + CW() * 2.0;
let r = Rect::new(bx, gy, gfx.measure(&name, SMALL()) + CW() * 2.0, CH() * 1.6);
let hov = r.contains(vec2(mx, my));
draw_rectangle(r.x, r.y, r.w, r.h, if hov { Color::new(0.35, 0.22, 0.1, 1.0) } else { Color::new(0.15, 0.1, 0.08, 1.0) });
draw_rectangle_lines(r.x, r.y, r.w, r.h, 2.0, side_color(Some(to)));
gfx.text(&name, r.x + CW(), r.y + (r.h - GH * SMALL()) * 0.5, SMALL(), side_color(Some(to)));
self.touch_btns.push((r, TBtn::GoldTo));
bx += r.w + CW();
for amount in crate::netplay::GOLD_AMOUNTS {
let label = if amount == crate::netplay::GOLD_ALL { tr("gold.all") } else { amount.to_string() };
let r = Rect::new(bx, gy, gfx.measure(&label, SMALL()) + CW() * 2.0, CH() * 1.6);
let hov = r.contains(vec2(mx, my));
draw_rectangle(r.x, r.y, r.w, r.h, if hov { Color::new(0.35, 0.22, 0.1, 1.0) } else { Color::new(0.15, 0.1, 0.08, 1.0) });
draw_rectangle_lines(r.x, r.y, r.w, r.h, 2.0, GOLD);
gfx.text(&label, r.x + CW(), r.y + (r.h - GH * SMALL()) * 0.5, SMALL(), GOLD);
self.touch_btns.push((r, TBtn::Gold(amount)));
bx += r.w + CW() * 0.5;
}
}
// lines: kind headers and creatures
let mut kinds: Vec<EK> = Vec::new();
for e in &mine {
@@ -1059,8 +1103,8 @@ impl Ui {
lines.push((line, col, Some(e.id), e.hp / e.max_hp));
}
}
let first = y + CH() * 3.0;
let visible = (rows - 5).max(1) as usize;
let first = y + CH() * if gold_row { 4.2 } else { 3.0 };
let visible = (rows - if gold_row { 7 } else { 5 }).max(1) as usize;
self.ov_scroll = self.ov_scroll.min(lines.len().saturating_sub(visible));
let lw = (cols - 4) as f32 * CW();
for (n, (text, col, id, hp)) in lines.iter().enumerate().skip(self.ov_scroll).take(visible) {
@@ -1094,6 +1138,15 @@ impl Ui {
pub fn draw_dialogs(&mut self, g: &Game, gfx: &Gfx) {
let sw = sw();
let sh = sh();
if g.spectating() && g.state == 0 && !self.show_langsel {
// the heart fell: the player watches the rest of the match
let t = tr("net.spectating");
let w = ((gfx.measure(&t, S()) / CW()).ceil() as i32 + 4).min((sw / CW()) as i32);
let x = grid_center(sw, w as f32 * CW());
let y = self.hud_h + CH() * 0.5;
gfx.window(x, y, w, 3, S(), false, RED, BG);
gfx.text(&t, grid_center(sw, gfx.measure(&t, S())), y + CH(), S(), RED);
}
if self.show_langsel {
self.draw_langsel(gfx);
} else if g.state != 0 {
@@ -1258,10 +1311,20 @@ impl Ui {
let st = trf("end.stats", &[mmss(g.time), g.wave.to_string(), (g.army_value() as i32).to_string()]);
gfx.text(&st, grid_center(sw, gfx.measure(&st, S())), y + CH() * (4.5 + lines.len() as f32), S(), DIM);
// buttons: free game: new game; mission: next / again and back to the campaign
let labels: Vec<(String, u8)> = match mission {
None => vec![(tr("end.new"), 0)],
Some(_) => vec![(tr(if !win { "end.again" } else if last { "end.to_campaign" } else { "end.next" }), 1), (tr("end.campaign"), 2)],
let labels: Vec<(String, u8)> = match (mission, self.coop_end) {
// an editor campaign in co-op: the host goes on for everybody, the others wait or leave
(_, Some((true, true, false))) => vec![(tr(if win { "end.next" } else { "end.again" }), 1), (tr("net.leave"), 2)],
(_, Some((true, true, true))) | (_, Some((false, true, _))) => vec![(tr("net.leave"), 2)],
(_, Some((_, false, _))) => vec![(tr("net.coop_run_over"), 2)],
(None, None) => vec![(tr("end.new"), 0)],
(Some(_), None) => vec![(tr(if !win { "end.again" } else if last { "end.to_campaign" } else { "end.next" }), 1), (tr("end.campaign"), 2)],
};
if let Some((host, true, sent)) = self.coop_end {
if !host || sent {
let t = tr("net.coop_wait_host");
gfx.text(&t, grid_center(sw, gfx.measure(&t, SMALL())), y + CH() * (5.3 + lines.len() as f32), SMALL(), GOLD);
}
}
let by = y + CH() * (6.0 + lines.len() as f32);
let widths: Vec<f32> = labels.iter().map(|(l, _)| gfx.measure(l, S()) + CW() * 4.0).collect();
let total: f32 = widths.iter().sum::<f32>() + CW() * 2.0 * (labels.len() as f32 - 1.0);