Co-op campaign matches and command markers: mission relay, allied masters, temporary flags

This commit is contained in:
Richard Zink committed 2026-11-04 20:07:06 +01:00
1 parent dc22e27184
commit 5928acf7f6
12 files changed
+433 -27

No files matched your search

+1 -1
View File
@@ -1 +1 @@
v202611-00011
v202611-00012
+2
View File
@@ -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
+2
View File
@@ -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
+43 -5
View File
@@ -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<usize>,
}
#[derive(Debug)]
@@ -136,6 +138,13 @@ fn sides_of(v: Option<&Value>) -> Vec<u8> {
out
}
fn mission_of(v: Option<&Value>) -> Option<usize> {
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<WsQuery>, State(st): State<AppState>) -> 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::<HashMap<_, _>>();
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();
+61 -6
View File
@@ -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<Goal> = 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<usize> {
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)
+61 -1
View File
@@ -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);
+74 -5
View File
@@ -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<Mark>, // 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
+21 -2
View File
@@ -75,6 +75,7 @@ pub struct StartInfo {
pub difficulty: u8,
pub sides: Vec<u8>,
pub local_side: Option<u8>,
pub mission: Option<usize>,
}
#[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<usize>) {
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);
+54 -3
View File
@@ -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 => {
+35 -2
View File
@@ -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<String>, // 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<Btn>, // 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);
+2 -2
View File
@@ -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<usize>, 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()))
+77
View File
@@ -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::*;