From 5928acf7f639ec7a23f394bc7196f08ac587981c Mon Sep 17 00:00:00 2001 From: Richard Zink Date: Wed, 4 Nov 2026 20:07:06 +0100 Subject: [PATCH] Co-op campaign matches and command markers: mission relay, allied masters, temporary flags --- VERSION | 2 +- lang/de.lang | 2 ++ lang/en.lang | 2 ++ server/src/netplay.rs | 48 +++++++++++++++++++++++--- src/campaign.rs | 67 ++++++++++++++++++++++++++++++++---- src/commands.rs | 62 ++++++++++++++++++++++++++++++++- src/game.rs | 79 ++++++++++++++++++++++++++++++++++++++++--- src/lobby.rs | 23 +++++++++++-- src/main.rs | 57 +++++++++++++++++++++++++++++-- src/menu.rs | 37 ++++++++++++++++++-- src/orders.rs | 4 +-- src/render3d.rs | 77 +++++++++++++++++++++++++++++++++++++++++ 12 files changed, 433 insertions(+), 27 deletions(-) diff --git a/VERSION b/VERSION index aabb259..ed0afb5 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -v202611-00011 +v202611-00012 diff --git a/lang/de.lang b/lang/de.lang index c8ab8c7..ae2bb99 100644 --- a/lang/de.lang +++ b/lang/de.lang @@ -618,6 +618,8 @@ net.error = {0} net.rooms = Offene Partien net.no_rooms = Keine offenen Partien. net.create = Partie erstellen +net.coop_mission = Koop-Kampagne: Mission {0} ({1}) +net.coop_create = Koop-Kampagne net.join = Beitreten net.join_hint = Partiecode net.seats = Plätze diff --git a/lang/en.lang b/lang/en.lang index 04d2921..a99f623 100644 --- a/lang/en.lang +++ b/lang/en.lang @@ -619,6 +619,8 @@ net.error = {0} net.rooms = Open matches net.no_rooms = No open matches. net.create = Create match +net.coop_mission = Co-op campaign: mission {0} ({1}) +net.coop_create = Co-op campaign net.join = Join net.join_hint = Match code net.seats = Seats diff --git a/server/src/netplay.rs b/server/src/netplay.rs index e0c94d7..6857a58 100644 --- a/server/src/netplay.rs +++ b/server/src/netplay.rs @@ -6,7 +6,7 @@ // // Lobby messages (JSON, one object per WebSocket frame): // {"t":"rooms"} -> list of open matches -// {"t":"create","sides":[0,2,3,4]?,"map_w":96?,"map_h":128?,"difficulty":1?} +// {"t":"create","sides":[0,2,3,4]?,"map_w":96?,"map_h":128?,"difficulty":1?,"mission":12?} // {"t":"join","code":"ABC123"} // {"t":"take_side","side":2} // {"t":"ready","ready":true} @@ -41,6 +41,7 @@ const CHAT_MAX: usize = 80; const MAX_PENDING: usize = 200; const MAX_ROOMS: usize = 64; const MASTER_SIDES: [u8; 4] = [0, 2, 3, 4]; +const MAX_MISSIONS: usize = 30; // keep this in step with the campaign mission count in the client #[derive(Debug, Default)] pub struct Netplay { @@ -72,6 +73,7 @@ struct Room { map_w: usize, map_h: usize, difficulty: u8, + mission: Option, } #[derive(Debug)] @@ -136,6 +138,13 @@ fn sides_of(v: Option<&Value>) -> Vec { out } +fn mission_of(v: Option<&Value>) -> Option { + match v { + None | Some(Value::Null) => None, + Some(x) => x.as_u64().map(|m| m as usize), + } +} + pub async fn netplay_ws(ws: WebSocketUpgrade, Query(q): Query, State(st): State) -> Response { ws.on_upgrade(move |socket| handle_socket(socket, st, q)) } @@ -279,9 +288,14 @@ impl Netplay { let map_w = msg.get("map_w").and_then(|v| v.as_u64()).unwrap_or(96).clamp(72, 256) as usize; let map_h = msg.get("map_h").and_then(|v| v.as_u64()).unwrap_or(128).clamp(72, 256) as usize; let difficulty = msg.get("difficulty").and_then(|v| v.as_u64()).unwrap_or(1).clamp(0, 2) as u8; + let mission = mission_of(msg.get("mission")); + if mission.map_or(false, |m| m >= MAX_MISSIONS) { + self.error(conn, "unknown campaign mission"); + return None; + } let name = self.clients.get(&conn).map(|c| c.name.clone()).unwrap_or_else(|| "Guest".into()); let seats = sides.iter().map(|s| (*s, Seat { conn: None, name: String::new(), ready: false })).collect::>(); - let room = Room { code: code.clone(), sides, seats, pending: HashMap::new(), turn: 0, started: false, paused: false, missing: false, seed: None, map_w, map_h, difficulty }; + let room = Room { code: code.clone(), sides, seats, pending: HashMap::new(), turn: 0, started: false, paused: false, missing: false, seed: None, map_w, map_h, difficulty, mission }; self.rooms.insert(code.clone(), room); if let Some(c) = self.clients.get_mut(&conn) { c.room = Some(code.clone()); @@ -422,7 +436,7 @@ impl Netplay { r.turn = 0; } let msg = match self.rooms.get(&code) { - Some(r) => json!({"t":"start","seed":r.seed.unwrap_or(0),"map_w":r.map_w,"map_h":r.map_h,"difficulty":r.difficulty,"sides":r.sides}), + Some(r) => json!({"t":"start","seed":r.seed.unwrap_or(0),"map_w":r.map_w,"map_h":r.map_h,"difficulty":r.difficulty,"sides":r.sides,"mission":r.mission}), None => return, }; self.room_msg(conn, &code); @@ -648,7 +662,8 @@ impl Netplay { "map_w":r.map_w, "map_h":r.map_h, "difficulty":r.difficulty, - "seed":r.seed + "seed":r.seed, + "mission":r.mission })) } @@ -658,7 +673,7 @@ impl Netplay { .values() .map(|r| { let players = r.seats.values().filter(|s| s.conn.is_some()).count(); - json!({"code":r.code,"players":players,"sides":r.sides,"started":r.started,"paused":r.paused}) + json!({"code":r.code,"players":players,"sides":r.sides,"started":r.started,"paused":r.paused,"mission":r.mission}) }) .collect(); self.send(conn, json!({"t":"rooms","rooms":rooms})); @@ -720,6 +735,29 @@ mod tests { assert_eq!(sum["sum"].as_u64(), Some(12345678901234567890u64)); } + #[test] + fn a_room_remembers_a_coop_campaign_mission() { + 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],"mission":2})); + let code = rooms[0].clone(); + let room = until(&mut rx, "room"); + assert_eq!(room["mission"].as_u64(), Some(2)); + + 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"})); + + let start_red = until(&mut rx, "start"); + let start_green = until(&mut rx2, "start"); + assert_eq!(start_red["mission"], start_green["mission"]); + assert_eq!(start_red["mission"].as_u64(), Some(2)); + } + #[test] fn a_non_host_cannot_start_a_match() { let mut net = Netplay::new(); diff --git a/src/campaign.rs b/src/campaign.rs index f126c5b..ea3ed72 100644 --- a/src/campaign.rs +++ b/src/campaign.rs @@ -476,6 +476,15 @@ fn place_cave(g: &mut Game, d: &CaveDef, w: usize, h: usize) { // Builds the game of mission i (carry: what the last mission handed over) pub fn start_mission(i: usize, carry: Option<&Carry>) -> Game { + start_mission_mode(i, carry, false) +} + +// A co-op campaign mission: the same mission, but without rival masters on playable sides. +pub fn start_coop_mission(i: usize, carry: Option<&Carry>) -> Game { + start_mission_mode(i, carry, true) +} + +fn start_mission_mode(i: usize, carry: Option<&Carry>, coop: bool) -> Game { let all = missions(); let d = &all[i.min(all.len() - 1)]; let (w, h) = d.size; @@ -498,10 +507,12 @@ pub fn start_mission(i: usize, carry: Option<&Carry>) -> Game { for c in &d.caves { place_cave(&mut g, c, w, h); } - for &(rx, ry, diff, side) in &d.rivals { - let x = ((rx * w as f32) as i32).clamp(10, w as i32 - 10); - let y = ((ry * h as f32) as i32).clamp(10, h as i32 - 10); - g.add_rival(side, x, y, diff); + if !coop { + for &(rx, ry, diff, side) in &d.rivals { + let x = ((rx * w as f32) as i32).clamp(10, w as i32 - 10); + let y = ((ry * h as f32) as i32).clamp(10, h as i32 - 10); + g.add_rival(side, x, y, diff); + } } g.place_dwarf_caves(d.seed); g.add_wilds(d.seed, false); @@ -514,9 +525,14 @@ pub fn start_mission(i: usize, carry: Option<&Carry>) -> Game { g.gate_on = heart; g.guard_treasures(); g.place_all_torches(); + let goals: Vec = if coop { + d.goals.iter().filter(|&g| *g != Goal::Rivals).cloned().collect() + } else { + d.goals.clone() + }; g.mission = Some(MissionState { index: i, - goals: d.goals.clone(), + goals, tools: TOOL_UNLOCK.iter().map(|&u| u <= i).collect(), waves: d.waves, wave_limit: d.wave_limit, @@ -548,6 +564,30 @@ pub fn start_mission(i: usize, carry: Option<&Carry>) -> Game { g } +// missions that can be played in co-op: they fit the multiplayer master areas and do not +// depend on rival masters as their only goal +pub fn is_coop_mission(i: usize) -> bool { + missions().get(i).map_or(false, |d| d.size.0 >= 72 && d.size.1 >= 72 && d.goals.iter().any(|&g| g != Goal::Rivals)) +} + +pub fn coop_mission_indexes() -> Vec { + missions() + .iter() + .enumerate() + .filter(|(_, d)| d.size.0 >= 72 && d.size.1 >= 72 && d.goals.iter().any(|&g| g != Goal::Rivals)) + .map(|(i, _)| i) + .collect() +} + +pub fn next_coop_mission(i: usize, dir: i32) -> usize { + let list = coop_mission_indexes(); + if list.is_empty() { + return 0; + } + let pos = list.iter().position(|m| *m == i).unwrap_or(0); + list[((pos as i32 + dir).rem_euclid(list.len() as i32)) as usize] +} + // the strongest creatures and a fifth of the gold go along into the next mission pub fn carry_from(g: &Game) -> Carry { let mut c: Vec<&Ent> = g.ents.iter().filter(|e| e.alive && e.creature()).collect(); @@ -635,6 +675,21 @@ mod tests { } } + #[test] + fn coop_missions_drop_rivals_and_are_not_decided_at_once() { + let list = coop_mission_indexes(); + assert!(list.len() > 3, "co-op campaign needs selectable missions"); + for &i in &list { + assert!(is_coop_mission(i), "mission {} selectable in co-op", i); + let g = start_coop_mission(i, None); + assert!(g.rivals.is_empty(), "mission {} must not spawn rival masters", i); + let m = g.mission.as_ref().unwrap(); + assert!(!m.goals.iter().any(|g| *g == Goal::Rivals), "mission {} keeps the rival goal", i); + assert!(!m.goals.is_empty(), "mission {} has a playable goal after dropping rivals", i); + assert!(!m.goals.iter().all(|&goal| g.goal_done(goal)), "mission {} is decided at the start", i); + } + } + // A simple scripted player: digs a corridor to the shard, bridges rivers on the way and // sends a few strong creatures along with the flag. fn play_to_shard(i: usize) -> (u8, f32) { @@ -648,7 +703,7 @@ mod tests { match t.kind { Kind::Rock | Kind::Core | Kind::Tower => None, Kind::Water | Kind::Lava => Some(3), - k if t.solid() => Some(if t.reinforced { 6 } else { 2 }), + _ if t.solid() => Some(if t.reinforced { 6 } else { 2 }), _ => Some(1), } }, |j| j == shard, false) diff --git a/src/commands.rs b/src/commands.rs index 50e04c9..d0e211b 100644 --- a/src/commands.rs +++ b/src/commands.rs @@ -3,7 +3,7 @@ // network match run exactly the same path. Commands are applied at step boundaries on every client, // in the order the server confirmed them. -use crate::game::{Game, TowerKind}; +use crate::game::{Game, TowerKind, MARK_ATTACK, MARK_BRIDGE, MARK_CANCEL, MARK_DIG, MARK_DROP, MARK_FLAG, MARK_GUARD, MARK_HEAL, MARK_LIGHTNING, MARK_PICKUP, MARK_ROOM, MARK_SELL, MARK_SLAP, MARK_SPELL, MARK_STEEL, MARK_SUMMON, MARK_TOWER, MARK_TRAP}; use crate::spells::Spell; use crate::traps::TrapKind; use serde::{Deserialize, Serialize}; @@ -103,6 +103,7 @@ impl Game { let seq = self.cmd_seq; self.cmd_seq = self.cmd_seq.wrapping_add(1); self.pending.push(Command { side: self.local_side, seq, act }); + self.mark_command(act, self.local_side); } // one command of one master: that master's state is switched in, the action runs, the local @@ -113,6 +114,7 @@ impl Game { } self.switch_to(c.side); self.run_act(c.act); + self.mark_command(c.act, c.side); let local = self.local_side; self.switch_to(local); } @@ -127,6 +129,39 @@ impl Game { } } + fn mark_command(&mut self, act: Act, side: u8) { + let ((x, y), kind, dur) = match act { + 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), + Act::Bridge(r) => (rect_center(r), MARK_BRIDGE, 4.0), + Act::Steel(r) => (rect_center(r), MARK_STEEL, 4.0), + Act::Tower { x, y, .. } => (point(x as f32, y as f32), MARK_TOWER, 6.0), + Act::Trap { x, y, .. } => (point(x as f32, y as f32), MARK_TRAP, 6.0), + Act::Cast { x, y, .. } => (point(x, y), MARK_SPELL, 5.0), + Act::Lightning { x, y } => (point(x, y), MARK_LIGHTNING, 5.0), + Act::Heal { x, y } => (point(x, y), MARK_HEAL, 5.0), + Act::Summon { x, y } => (point(x, y), MARK_SUMMON, 6.0), + Act::Flag { x, y } => (point(x as f32, y as f32), MARK_FLAG, 4.0), + Act::Pickup { ent } => match self.find(ent) { + Some(j) => (point(self.ents[j].x as f32, self.ents[j].y as f32), MARK_PICKUP, 3.5), + None => (point(self.map.core.0 as f32, self.map.core.1 as f32), MARK_PICKUP, 3.5), + }, + Act::Drop { x, y } => (point(x, y), MARK_DROP, 3.5), + Act::Slap { ent } => match self.find(ent) { + Some(j) => (point(self.ents[j].x as f32, self.ents[j].y as f32), MARK_SLAP, 4.0), + None => (point(self.map.core.0 as f32, self.map.core.1 as f32), MARK_SLAP, 4.0), + }, + Act::Attack { ent, x, y } => match ent.and_then(|id| self.find(id)) { + Some(j) => (point(self.ents[j].x as f32, self.ents[j].y as f32), MARK_ATTACK, 10.0), + None => (point(x, y), MARK_ATTACK, 10.0), + }, + Act::Guard { x, y } => (point(x as f32, y as f32), MARK_GUARD, 10.0), + Act::CancelOrders => (point(self.map.core.0 as f32, self.map.core.1 as f32), MARK_CANCEL, 4.0), + }; + self.add_mark(x, y, kind, side, dur); + } + fn run_act(&mut self, act: Act) { match act { Act::Dig(r) => { @@ -165,6 +200,17 @@ impl Game { } } +fn rect_center(r: Rect) -> (f32, f32) { + ( + (r.x0 as f32 + r.x1 as f32 + 1.0) * 0.5, + (r.y0 as f32 + r.y1 as f32 + 1.0) * 0.5, + ) +} + +fn point(x: f32, y: f32) -> (f32, f32) { + (x.floor() + 0.5, y.floor() + 0.5) +} + #[cfg(test)] mod tests { use super::*; @@ -184,11 +230,25 @@ mod tests { let (x, y) = (cx + 6, cy); // outside the treasury ring: rock that can be marked g.queue(Act::Dig(Rect::new(x, y, x + 1, y + 1))); assert!(!g.map.t[idx(x, y)].marked, "queued, not applied yet"); + assert!(g.marks.iter().any(|m| m.kind == MARK_DIG), "queued command has a marker"); g.update(STEP); assert!(g.map.t[idx(x, y)].marked, "applied at the step boundary"); assert!(g.pending.is_empty()); } + #[test] + fn commands_leave_visible_markers() { + let mut g = game(12); + let (cx, cy) = g.map.core; + g.queue(Act::Dig(Rect::new(cx + 6, cy, cx + 7, cy + 1))); + g.queue(Act::Guard { x: cx, y: cy }); + g.queue(Act::Attack { ent: None, x: (cx + 20) as f32, y: (cy + 20) as f32 }); + g.update(STEP); + assert!(g.marks.iter().any(|m| m.kind == MARK_DIG && m.side == 0), "dig marker"); + assert!(g.marks.iter().any(|m| m.kind == MARK_GUARD && m.side == 0), "guard marker"); + assert!(g.marks.iter().any(|m| m.kind == MARK_ATTACK && m.side == 0), "attack marker"); + } + #[test] fn a_command_of_another_master_leaves_the_local_one_alone() { let mut g = game(8); diff --git a/src/game.rs b/src/game.rs index 68d3444..9f1ca16 100644 --- a/src/game.rs +++ b/src/game.rs @@ -611,11 +611,43 @@ pub struct Game { #[serde(skip)] pub ai_difficulty: u8, // difficulty used when a left side becomes an AI rival #[serde(skip)] + pub coop: bool, // co-op match: the playable master sides are on one side against the heroes + #[serde(skip)] + pub marks: Vec, // short-lived markers for commands given by the masters + #[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 } +pub const MARK_DIG: u8 = 0; +pub const MARK_ROOM: u8 = 1; +pub const MARK_SELL: u8 = 2; +pub const MARK_BRIDGE: u8 = 3; +pub const MARK_STEEL: u8 = 4; +pub const MARK_TOWER: u8 = 5; +pub const MARK_TRAP: u8 = 6; +pub const MARK_SPELL: u8 = 7; +pub const MARK_LIGHTNING: u8 = 8; +pub const MARK_HEAL: u8 = 9; +pub const MARK_SUMMON: u8 = 10; +pub const MARK_FLAG: u8 = 11; +pub const MARK_PICKUP: u8 = 12; +pub const MARK_DROP: u8 = 13; +pub const MARK_SLAP: u8 = 14; +pub const MARK_ATTACK: u8 = 15; +pub const MARK_GUARD: u8 = 16; +pub const MARK_CANCEL: u8 = 17; + +#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)] +pub struct Mark { + pub x: f32, + pub y: f32, + pub kind: u8, + pub side: u8, + pub until: u64, +} + fn hint_times() -> [f32; 32] { [-1000.0; 32] } @@ -794,6 +826,8 @@ impl Game { netplay: None, ai_sides: Vec::new(), ai_difficulty: 1, + coop: false, + marks: Vec::new(), hint_t: hint_times(), reserve: 0.0, incoming: Vec::new(), @@ -1237,6 +1271,9 @@ impl Game { std::mem::take(&mut self.pending) }; self.apply_turn(&cmds, steps); + if !self.marks.is_empty() { + self.marks.retain(|m| self.sim_steps < m.until); + } // what only the camera cares about runs in real time: it is not part of the shared state self.update_particles(dt); for f in self.fx.iter_mut() { @@ -1271,6 +1308,14 @@ impl Game { side == self.me_side || self.masters.iter().any(|m| m.side == side) } + fn is_hostile_master(&self, side: u8) -> bool { + self.coop && Owner::MASTER_SIDES.contains(&self.me_side) && Owner::MASTER_SIDES.contains(&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_hostile_master(e.side()) + } + pub fn is_rival_side(&self, side: u8) -> bool { self.rivals.iter().any(|r| r.alive && r.side == side) } @@ -2316,8 +2361,9 @@ impl Game { let mut best = None; let mut bd = radius * radius; for o in &self.ents { - // every other side is an enemy - if !o.alive || o.held || o.side() == e.side() || o.captive != 0 || o.chained || o.invis > 0.0 { + // every other side is an enemy; co-op master sides fight the heroes, not each other + let coop_allies = self.coop && Owner::MASTER_SIDES.contains(&e.side()) && Owner::MASTER_SIDES.contains(&o.side()); + if !o.alive || o.held || o.side() == e.side() || coop_allies || o.captive != 0 || o.chained || o.invis > 0.0 { continue; } let d = (o.x - e.x).powi(2) + (o.y - e.y).powi(2); @@ -2789,8 +2835,9 @@ impl Game { self.contested.iter_mut().for_each(|c| *c = false); } for e in &self.ents { - // any enemy of the player (heroes and the rivals' units) keeps ground from being claimed - if !e.alive || e.held || e.captive != 0 || e.side() == 0 { + // 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())) { continue; } let (x, y) = (e.x as i32, e.y as i32); @@ -3788,6 +3835,21 @@ impl Game { } } + pub fn add_mark(&mut self, x: f32, y: f32, kind: u8, side: u8, duration: f32) { + if duration <= 0.0 || !x.is_finite() || !y.is_finite() { + return; + } + let until = self.sim_steps + (duration / STEP).ceil() as u64; + if let Some(m) = self.marks.iter_mut().find(|m| (m.x - x).abs() < 0.75 && (m.y - y).abs() < 0.75 && m.kind == kind && m.side == side) { + m.until = m.until.max(until); + return; + } + if self.marks.len() >= 80 { + self.marks.remove(0); + } + self.marks.push(Mark { x, y, kind, side, until }); + } + fn creature_choose(&mut self, e: &mut Ent) { let me = e.id + 1; let cur = e.tile(); @@ -4928,7 +4990,14 @@ impl Game { // a unit the tower shoots at fn tower_enemy(&self, t: &Tower, e: &Ent) -> bool { - e.captive == 0 && !e.chained && if t.hero { e.side() != 1 } else { e.side() != 0 } + let target = if t.hero { + e.side() != 1 + } else if self.coop { + e.side() == 1 + } else { + e.side() != 0 + }; + e.captive == 0 && !e.chained && target } // a hero attacks a tower; false = tower gone or unreachable diff --git a/src/lobby.rs b/src/lobby.rs index 751d8b3..350addd 100644 --- a/src/lobby.rs +++ b/src/lobby.rs @@ -75,6 +75,7 @@ pub struct StartInfo { pub difficulty: u8, pub sides: Vec, pub local_side: Option, + pub mission: Option, } #[derive(Clone, Debug, Default)] @@ -169,6 +170,14 @@ impl MatchClient { } pub fn create(&mut self, sides: &[u8], map_w: usize, map_h: usize, difficulty: u8) { + self.send_create(sides, map_w, map_h, difficulty, None); + } + + pub fn create_coop(&mut self, mission: usize, sides: &[u8], map_w: usize, map_h: usize, difficulty: u8) { + self.send_create(sides, map_w, map_h, difficulty, Some(mission)); + } + + fn send_create(&mut self, sides: &[u8], map_w: usize, map_h: usize, difficulty: u8, mission: Option) { if !self.connected { return; } @@ -176,7 +185,7 @@ impl MatchClient { 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()); + self.outbox.push_back(json!({"t":"create","sides":sides,"map_w":map_w,"map_h":map_h,"difficulty":difficulty,"mission":mission}).to_string()); } pub fn join(&mut self, code: &str) { @@ -384,6 +393,7 @@ impl MatchClient { let map_h = v.get("map_h").and_then(|x| x.as_u64()).unwrap_or(128) as usize; let difficulty = v.get("difficulty").and_then(|x| x.as_u64()).unwrap_or(1) as u8; let sides = array_bytes(v.get("sides")); + let mission = v.get("mission").and_then(|x| x.as_u64()).map(|x| x as usize); let local_side = self.room.as_ref().and_then(|r| r.seats.iter().find(|s| s.name == self.name && s.connected).map(|s| s.side)); self.local_side = local_side; if let Some(r) = self.room.as_mut() { @@ -395,7 +405,7 @@ impl MatchClient { r.sides = sides.clone(); } } - self.start = Some(StartInfo { seed, map_w, map_h, difficulty, sides, local_side }); + self.start = Some(StartInfo { seed, map_w, map_h, difficulty, sides, local_side, mission }); self.error = None; self.status = None; } @@ -499,6 +509,15 @@ mod tests { let start = client.take_start().expect("start info"); assert_eq!(start.seed, 7); assert_eq!(start.local_side, Some(0)); + assert_eq!(start.mission, None); + + client.create_coop(2, &[0, 2], 80, 72, 1); + let coop = pop(&mut client); + assert_eq!(coop["t"].as_str(), Some("create")); + assert_eq!(coop["mission"].as_u64(), Some(2)); + client.feed(r#"{"t":"start","seed":8,"map_w":80,"map_h":72,"difficulty":1,"sides":[0,2],"mission":2}"#); + let coop_start = client.take_start().expect("co-op start info"); + assert_eq!(coop_start.mission, Some(2)); client.send_commands(&[Command { side: 0, seq: 0, act: Act::Dig(Rect::new(1, 2, 3, 4)) }]); let req = pop(&mut client); diff --git a/src/main.rs b/src/main.rs index 0b1d9ff..341bb16 100644 --- a/src/main.rs +++ b/src/main.rs @@ -239,17 +239,29 @@ fn account_action(a: MenuAction, cloud: &mut Cloud, menu: &mut Menu, app: &mut A } MenuAction::MatchJoin(code) => app.client.join(&code), MenuAction::MatchCreate => app.client.create(&[0, 2, 3, 4], app.settings.map_w, app.settings.map_h, app.settings.difficulty), + MenuAction::MatchCoopCreate(mission) => { + let all = campaign::missions(); + if let Some(d) = all.get(mission) { + app.client.create_coop(mission, &[0, 2, 3, 4], d.size.0, d.size.1, app.settings.difficulty); + } else { + menu.error = Some(i18n::tr("net.status_closed")); + } + } MenuAction::MatchTakeSide(side) => app.client.take_side(side), MenuAction::MatchReady(ready) => app.client.ready(ready), MenuAction::MatchStart => app.client.start(), MenuAction::MatchResume => app.client.resume(), MenuAction::MatchAbort => { app.client.abort(); + app.game.coop = false; app.match_mode = false; + app.coop = false; } MenuAction::MatchLeave => { app.client.leave(); + app.game.coop = false; app.match_mode = false; + app.coop = false; } MenuAction::MatchChat(text) => app.client.chat(&text, false), MenuAction::MatchEditField(i) => { @@ -380,6 +392,7 @@ struct App { client: MatchClient, ws_id: i32, match_mode: bool, + coop: bool, } impl App { @@ -402,6 +415,8 @@ impl App { store_settings(&self.settings); } self.game.hints = self.settings.hints; + self.game.coop = false; + self.coop = false; r3d.invalidate(); menu.page = None; ui.show_help = false; @@ -482,12 +497,37 @@ impl App { self.ws_id = 0; } self.game.end_match(); + self.game.coop = false; self.match_mode = false; + self.coop = 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) { + if !info.sides.contains(&0) { + return false; + } + if let Some(mission) = info.mission { + if !campaign::is_coop_mission(mission) { + return false; + } + let local = info.local_side.unwrap_or(0); + let mut game = campaign::start_coop_mission(mission, None); + game.coop = true; + if !game.start_match(&info.sides, local) { + return false; + } + game.ai_difficulty = info.difficulty; + game.hints = self.settings.hints; + self.game = game; + let (cx, cy) = self.game.map.core; + self.cam = Cam::new(cx as f32 + 1.5, cy as f32 + 1.5); + self.match_mode = true; + self.coop = true; + self.seed = info.seed.wrapping_mul(31) | 1; + return true; + } + if info.map_w < 72 || info.map_h < 72 { return false; } self.game = Game::new(info.seed, info.map_w, info.map_h); @@ -495,12 +535,14 @@ impl App { if !self.game.start_match(&info.sides, local) { return false; } + self.game.coop = 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; + self.coop = false; self.seed = info.seed.wrapping_mul(31) | 1; true } @@ -510,7 +552,11 @@ impl App { return; } for (_, cmds, ai) in self.client.take_turns() { - self.game.sync_ai_sides(&ai, self.settings.difficulty); + if self.coop { + self.game.ai_sides.clear(); + } else { + self.game.sync_ai_sides(&ai, self.settings.difficulty); + } self.game.receive_turn(&cmds); } for (_, turn, sum) in self.client.take_checks() { @@ -554,7 +600,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 }; + let mut app = App { game, cam, settings, seed, status: None, recorded: false, client: MatchClient::new(), ws_id: 0, match_mode: false, coop: false }; menu.progress = platform::storage_get(PROGRESS_KEY).and_then(|s| serde_json::from_str(&s).ok()).unwrap_or_default(); app.game.hints = app.settings.hints; 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 }; @@ -1029,6 +1075,8 @@ async fn main() { MenuAction::StartMission(i) => { (app.game, app.cam) = mission_game(i, &menu.progress); app.game.hints = app.settings.hints; + app.game.coop = false; + app.coop = false; app.recorded = false; menu.page = None; ui.log.clear(); @@ -1048,6 +1096,7 @@ async fn main() { | MenuAction::OpenMatch | MenuAction::MatchJoin(..) | MenuAction::MatchCreate + | MenuAction::MatchCoopCreate(..) | MenuAction::MatchTakeSide(..) | MenuAction::MatchReady(..) | MenuAction::MatchStart @@ -1090,6 +1139,8 @@ async fn main() { app.seed = next_seed(app.seed); (app.game, app.cam) = new_game(app.seed, &app.settings); app.game.hints = app.settings.hints; + app.game.coop = false; + app.coop = false; } // end of a mission: next mission (won) or try again (lost) UiAction::EndNext => { diff --git a/src/menu.rs b/src/menu.rs index 1ebe19e..ea7b16f 100644 --- a/src/menu.rs +++ b/src/menu.rs @@ -231,6 +231,9 @@ enum Btn { MatchJoin, MatchJoinRoom(usize), MatchCreate, + MatchCoopPrev, + MatchCoopNext, + MatchCoopCreate, MatchTake(usize), MatchReady, MatchStart, @@ -307,6 +310,7 @@ pub enum MenuAction { OpenMatch, MatchJoin(String), MatchCreate, + MatchCoopCreate(usize), MatchTakeSide(u8), MatchReady(bool), MatchStart, @@ -327,6 +331,7 @@ pub struct Menu { pub focus: usize, // field that gets the typed text pub error: Option, // message on the login and account pages pub match_ui: MatchUi, // copied state of the multiplayer relay, shown on the Match page + pub coop_mission: usize, // co-op campaign mission selected on the Match page pub in_match: bool, // a match is being played; the pause menu changes its shape confirm: Option, // overwrite / delete waiting for a second click scroll: usize, @@ -353,6 +358,7 @@ impl Menu { focus: 0, error: None, match_ui: MatchUi::default(), + coop_mission: crate::campaign::coop_mission_indexes().first().copied().unwrap_or(0), in_match: false, confirm: None, scroll: 0, @@ -616,6 +622,17 @@ impl Menu { None => MenuAction::Consumed, }, Some(Btn::MatchCreate) => MenuAction::MatchCreate, + Some(Btn::MatchCoopPrev) => { + self.coop_mission = crate::campaign::next_coop_mission(self.coop_mission, -1); + self.confirm = None; + MenuAction::Consumed + } + Some(Btn::MatchCoopNext) => { + self.coop_mission = crate::campaign::next_coop_mission(self.coop_mission, 1); + self.confirm = None; + MenuAction::Consumed + } + Some(Btn::MatchCoopCreate) => MenuAction::MatchCoopCreate(self.coop_mission), Some(Btn::MatchReady) => MenuAction::MatchReady(!self.match_ui.ready), Some(Btn::MatchStart) => MenuAction::MatchStart, Some(Btn::MatchResume) => MenuAction::MatchResume, @@ -1374,6 +1391,16 @@ impl Menu { yy += CH() * 1.6; } + if !ui.started && !ui.room_open() { + if let Some(d) = missions().get(self.coop_mission) { + let line = trf("net.coop_mission", &[(self.coop_mission + 1).to_string(), tr(&format!("camp.{}.title", d.key))]); + let maxc = ((cols - 6) as f32 * CW() / (GW * SMALL())) as usize; + for (_k, text) in wrap(&line, maxc).iter().take(2).enumerate() { + gfx.text(text, ix, yy, SMALL(), GOLD); + yy += GH * SMALL() * 1.35; + } + } + } self.field(gfx, ix, yy, w, &tr("net.join_hint"), 0, false); self.field(gfx, ix, yy + CH() * 2.4, w, &tr("net.chat"), 1, false); yy += CH() * 4.8; @@ -1382,6 +1409,11 @@ impl Menu { if !ui.started { items.push((Btn::MatchCreate, tr("net.create"))); items.push((Btn::MatchJoin, tr("net.join"))); + if !ui.room_open() { + items.push((Btn::MatchCoopPrev, "<".to_string())); + items.push((Btn::MatchCoopCreate, tr("net.coop_create"))); + items.push((Btn::MatchCoopNext, ">".to_string())); + } if ui.room_open() { items.push((Btn::MatchReady, tr(if ui.ready { "net.unready" } else { "net.ready" }))); if ui.is_host { @@ -1402,12 +1434,13 @@ impl Menu { } items.push((Btn::Back, tr("menu.back"))); let bw = 14.0 * CW(); - for (k, (b, label)) in items.iter().take(6).enumerate() { + let shown = items.iter().take(9).count(); + for (k, (b, label)) in items.iter().take(shown).enumerate() { let r = Rect::new(ix + (k % 3) as f32 * (bw + CW()), yy + (k / 3) as f32 * CH() * 2.4, bw, CH() * 2.0); button(gfx, r, label, r.contains(vec2(mx, my)), FRAME); self.buttons.push((r, *b)); } - yy += CH() * 5.4; + yy += CH() * 2.4 * ((shown + 2) / 3) as f32; if !ui.rooms.is_empty() && !ui.started { gfx.text(&tr("net.rooms"), ix, yy, S(), GOLD); diff --git a/src/orders.rs b/src/orders.rs index 08277e5..5924742 100644 --- a/src/orders.rs +++ b/src/orders.rs @@ -14,8 +14,8 @@ fn ready(e: &Ent) -> bool { impl Game { // attack: the enemy (unit, or tower) at (wx, wy) pub fn order_attack(&mut self, picked: Option, wx: f32, wy: f32) { - let unit = picked.filter(|&j| j < self.ents.len() && self.ents[j].alive && self.ents[j].side() != 0 && self.ents[j].captive == 0).or_else(|| { - self.ents.iter().position(|e| e.alive && !e.held && e.side() != 0 && e.captive == 0 && !e.chained && (e.x - wx).powi(2) + (e.y - wy).powi(2) < 1.5 * 1.5) + let unit = picked.filter(|&j| j < self.ents.len() && self.is_hostile(&self.ents[j])).or_else(|| { + self.ents.iter().position(|e| self.is_hostile(e) && (e.x - wx).powi(2) + (e.y - wy).powi(2) < 1.5 * 1.5) }); let tower = if unit.is_none() { self.towers.iter().find(|t| t.hero && (t.pos().0 - wx).powi(2) + (t.pos().1 - wy).powi(2) < 1.2 * 1.2).map(|t| (t.id, t.pos())) diff --git a/src/render3d.rs b/src/render3d.rs index c5a4b11..6b54cc8 100644 --- a/src/render3d.rs +++ b/src/render3d.rs @@ -1121,6 +1121,7 @@ impl R3d { if let Some(f) = g.flag { draw_flag(f, t); } + draw_marks(g, t); draw_overlay(&g.map, ov, self.wall_sub); draw_entry_warnings(g, t, self.wall_sub); // translucent glows and effects last (they write depth, see glow_material) @@ -2165,6 +2166,82 @@ fn draw_flag(f: usize, t: f32) { draw_mesh(&Mesh { vertices: v, indices: vec![0, 1, 2], texture: None }); } +fn mark_color(kind: u8) -> Color { + match kind { + MARK_DIG => Color::new(0.45, 0.95, 0.55, 1.0), + MARK_ROOM => Color::new(0.35, 0.85, 1.0, 1.0), + MARK_SELL => Color::new(0.95, 0.78, 0.22, 1.0), + MARK_BRIDGE => Color::new(0.70, 0.48, 0.24, 1.0), + MARK_STEEL => Color::new(0.78, 0.84, 0.92, 1.0), + MARK_TOWER => Color::new(0.74, 0.42, 0.95, 1.0), + MARK_TRAP => Color::new(1.0, 0.58, 0.18, 1.0), + MARK_SPELL => Color::new(0.42, 0.88, 0.98, 1.0), + MARK_LIGHTNING => Color::new(0.98, 0.94, 0.35, 1.0), + MARK_HEAL => Color::new(0.48, 0.96, 0.66, 1.0), + MARK_SUMMON => Color::new(0.95, 0.36, 0.24, 1.0), + MARK_FLAG => Color::new(0.88, 0.18, 0.18, 1.0), + MARK_PICKUP => Color::new(0.72, 0.76, 0.82, 1.0), + MARK_DROP => Color::new(0.72, 0.76, 0.82, 1.0), + MARK_SLAP => Color::new(1.0, 0.46, 0.24, 1.0), + MARK_ATTACK => Color::new(0.94, 0.18, 0.20, 1.0), + MARK_GUARD => Color::new(0.98, 0.82, 0.22, 1.0), + MARK_CANCEL => Color::new(0.58, 0.64, 0.78, 1.0), + _ => Color::new(0.88, 0.18, 0.18, 1.0), + } +} + +fn flag_rgba(c: Color) -> [u8; 4] { + [ + (c.r.clamp(0.0, 1.0) * 255.0) as u8, + (c.g.clamp(0.0, 1.0) * 255.0) as u8, + (c.b.clamp(0.0, 1.0) * 255.0) as u8, + (c.a.clamp(0.0, 1.0) * 255.0) as u8, + ] +} + +fn mark_side_color(side: u8) -> Color { + match side { + 0 => Color::new(1.0, 0.85, 0.45, 1.0), + 1 => Color::new(0.85, 0.55, 0.25, 1.0), + 2 => Color::new(0.35, 0.95, 0.40, 1.0), + 3 => Color::new(0.40, 0.60, 1.00, 1.0), + 4 => Color::new(0.85, 0.75, 0.30, 1.0), + _ => Color::new(0.82, 0.84, 0.88, 1.0), + } +} + +fn draw_command_flag(x: f32, z: f32, cloth: Color, pole: Color, t: f32) { + let base = vec3(x - 0.04, 0.0, z); + draw_cube(base + vec3(0.0, 0.65, 0.0), vec3(0.035, 1.30, 0.035), None, Color::new(pole.r, pole.g, pole.b, pole.a)); + let wave = (t * 6.0).sin() * 0.07; + let p1 = base + vec3(0.0, 1.28, 0.0); + let p2 = base + vec3(0.48, 1.14 + wave, 0.08); + let p3 = base + vec3(0.0, 0.92, 0.0); + let col = flag_rgba(cloth); + let v = vec![ + Vertex { position: p1, uv: Vec2::ZERO, color: col, normal: Vec4::Y }, + Vertex { position: p2, uv: Vec2::ZERO, color: col, normal: Vec4::Y }, + Vertex { position: p3, uv: Vec2::ZERO, color: col, normal: Vec4::Y }, + ]; + draw_mesh(&Mesh { vertices: v, indices: vec![0, 1, 2], texture: None }); +} + +fn draw_marks(g: &Game, t: f32) { + if g.marks.is_empty() { + return; + } + for m in &g.marks { + let left = m.until.saturating_sub(g.sim_steps) as f32 * STEP; + if left <= 0.0 { + continue; + } + let a = (left / 2.0).min(1.0).max(0.18); + let base = mark_color(m.kind); + let side = mark_side_color(m.side); + draw_command_flag(m.x, m.y, Color::new(base.r, base.g, base.b, a), Color::new(side.r, side.g, side.b, a), t); + } +} + // projectiles with a look of their own (styles in particles.rs) fn draw_projectile(f: &Fx, p: f32, t: f32) { use crate::particles::*;