Multiplayer step B: every player order is a command (dig, build, cast, hand, orders) and the simulation runs in fixed 50 ms steps, so single player and a match take the same path

This commit is contained in:
Richard Zink committed 2026-11-04 09:57:02 +01:00
1 parent d6bcce3a76
commit 1ec7492189
8 files changed
+634 -100

No files matched your search

+24 -3
View File
@@ -29,13 +29,34 @@ The server does not simulate; it relays:
Only the commands travel over the network, so bandwidth is tiny and no game state has to be serialised
per turn. Fog of war, particles and the camera are local and not part of the shared state.
## What is there already
- **Per-master state** (`game.rs`): every master has his own gold, mana, hand, research, payday, flag,
core... `Game::switch_to(side)` puts one master in the active fields, the simulation walks through all
masters step by step. A single player game has no stored masters and is unaffected.
- **Commands** (`commands.rs`): every order a player gives is an `Act` (`Dig`, `Room`, `Tower`, `Trap`,
`Cast`, `Flag`, `Pickup`, `Drop`, `Slap`, `Attack`, `Guard`, `CancelOrders`, ...) wrapped in a
`Command { side, seq, act }`. Creature targets are *ids*, so they mean the same on every client.
The tools in `main.rs` only queue commands now - nothing touches the world directly.
- **Steps**: the simulation runs in fixed steps of `STEP` = 50 ms (`Game::update` feeds real time into
them, `Game::apply_turn(cmds, steps)` is the lockstep entry point). Commands are applied at step
boundaries, so single player and a match run exactly the same path. Particles, effects and the camera
keep running in real time: they are local and not part of the shared state.
- **Who speaks**: messages, hints and interface sounds belong to the master who is being processed
(`Game::speaking`); what another master orders never shows up on my screen.
- Savegames, tests and the standalone file are unchanged: `cargo test --release` covers the command path
(including "the same actions on both paths build the same world").
## Steps
| # | Step | Status |
|---|---|---|
| A | Simulation per master: gold, mana, research, rooms, hand, flag, traps, payday... per side; up to four masters (red, green, blue, yellow) | planned |
| B | All player actions as commands (`Command` + `Game::apply`), single player uses the same path; fixed time step | planned |
| C | Server: lobby and match relay over WebSocket (axum), turns, pause on disconnect, AI takeover on leave, chat | planned |
| A | Simulation per master: gold, mana, research, rooms, hand, flag, traps, payday... per side; up to four masters (red, green, blue, yellow) | built |
| B | All player actions as commands (`Command` + `Game::apply`), single player uses the same path; fixed time step | built |
| C | Server: lobby and match relay over WebSocket (axum), turns, pause on disconnect, AI takeover on leave, chat | next |
| D | Client: *Multiplayer* menu (lobby, create, join, ready, teams), WebSocket bridge in `dc_plugin.js`, chat, gold sending | planned |
| E | Alliances and coop rules, AI masters for free and abandoned slots | planned |
| F | Coop campaign: 20 missions with story (EN/DE) | planned |
Still open on the way to C: player owners (today side 1 is the humans' and 2/3 the rivals' - four
player sides need their own `Owner`s), the turn/turn-boundary client loop, and the state checksum.
+275
View File
@@ -0,0 +1,275 @@
// Multiplayer step B: everything a master orders becomes a Command. The simulation only changes
// through Game::apply_turn (commands, then a fixed number of steps), so a single player game and a
// 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::spells::Spell;
use crate::traps::TrapKind;
use serde::{Deserialize, Serialize};
// a rectangle of tiles, as dragged with the dig, room, sell, bridge and steel tools
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct Rect {
pub x0: i32,
pub y0: i32,
pub x1: i32,
pub y1: i32,
}
impl Rect {
pub fn new(x0: i32, y0: i32, x1: i32, y1: i32) -> Rect {
Rect { x0, y0, x1, y1 }
}
}
// one ordered action of a master; entity targets are ids, so they mean the same on every client
#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)]
pub enum Act {
Dig(Rect),
Room {
room: u8,
r: Rect,
},
Sell(Rect),
Bridge(Rect),
Steel(Rect),
Tower {
kind: TowerKind,
x: i32,
y: i32,
},
Trap {
kind: TrapKind,
x: i32,
y: i32,
},
Cast {
spell: Spell,
x: f32,
y: f32,
},
Lightning {
x: f32,
y: f32,
},
Heal {
x: f32,
y: f32,
},
Summon {
x: f32,
y: f32,
},
Flag {
x: i32,
y: i32,
},
Pickup {
ent: u32,
},
Drop {
x: f32,
y: f32,
},
Slap {
ent: u32,
},
Attack {
ent: Option<u32>,
x: f32,
y: f32,
},
Guard {
x: i32,
y: i32,
},
CancelOrders,
}
#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)]
pub struct Command {
pub side: u8, // the master who gave the order
pub seq: u32, // order within that master's commands (the server numbers them)
pub act: Act,
}
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) {
return;
}
let seq = self.cmd_seq;
self.cmd_seq = self.cmd_seq.wrapping_add(1);
self.pending.push(Command { side: self.local_side, seq, act });
}
// 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) {
return;
}
self.switch_to(c.side);
self.run_act(c.act);
let local = self.local_side;
self.switch_to(local);
}
// a turn: the commands of all masters, then a fixed number of simulation steps
pub fn apply_turn(&mut self, cmds: &[Command], steps: usize) {
for c in cmds {
self.apply(c);
}
for _ in 0..steps {
self.step_fixed();
}
}
fn run_act(&mut self, act: Act) {
match act {
Act::Dig(r) => {
let on = !self.map.get(r.x0, r.y0).map_or(true, |t| t.marked);
self.mark_rect(r.x0, r.y0, r.x1, r.y1, on);
}
Act::Room { room, r } => self.build_rect(room as usize, r.x0, r.y0, r.x1, r.y1),
Act::Sell(r) => self.sell_rect(r.x0, r.y0, r.x1, r.y1),
Act::Bridge(r) => self.build_bridges(r.x0, r.y0, r.x1, r.y1),
Act::Steel(r) => self.build_steel(r.x0, r.y0, r.x1, r.y1),
Act::Tower { kind, x, y } => self.build_tower(kind, x, y),
Act::Trap { kind, x, y } => self.place_trap(kind, x, y),
Act::Cast { spell, x, y } => self.cast(spell, x, y),
Act::Lightning { x, y } => self.lightning(x, y),
Act::Heal { x, y } => self.heal(x, y),
Act::Summon { x, y } => self.summon(x, y),
Act::Flag { x, y } => self.set_flag(x, y),
Act::Pickup { ent } => {
let j = self.find(ent);
self.pickup(j);
}
Act::Drop { x, y } => {
self.drop(x, y);
}
Act::Slap { ent } => {
let j = self.find(ent);
self.slap(j);
}
Act::Attack { ent, x, y } => {
let j = ent.and_then(|id| self.find(id));
self.order_attack(j, x, y);
}
Act::Guard { x, y } => self.order_guard(x, y),
Act::CancelOrders => self.cancel_orders(),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::game::{EK, Job, STEP};
use crate::map::idx;
fn game(seed: u32) -> Game {
let mut g = Game::new(seed, 64, 64);
g.init_fog();
g
}
#[test]
fn a_queued_command_is_applied_at_the_next_step() {
let mut g = game(7);
let (cx, cy) = g.map.core;
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");
g.update(STEP);
assert!(g.map.t[idx(x, y)].marked, "applied at the step boundary");
assert!(g.pending.is_empty());
}
#[test]
fn a_command_of_another_master_leaves_the_local_one_alone() {
let mut g = game(8);
let (cx, cy) = g.map.core;
g.add_master(2, (cx + 20, cy + 20));
let (gold, hand) = (g.gold, g.hand.len());
let (msgs, events) = (g.msgs.len(), g.events.len());
// an order far away, given by another master, reaches his state only
g.apply(&Command { side: 2, seq: 0, act: Act::Attack { ent: None, x: (cx + 21) as f32, y: (cy + 21) as f32 } });
assert_eq!(g.gold, gold, "his gold, not mine");
assert_eq!(g.hand.len(), hand);
assert_eq!(g.msgs.len(), msgs, "he hears his own order, not I");
assert_eq!(g.events.len(), events, "and it makes no sound here");
assert_eq!(g.me_side, g.local_side, "I am back in the saddle");
assert_eq!(g.map.core, (cx, cy), "my heart is still mine");
// while it was in the saddle, that master had his own heart and his own ground
g.switch_to(2);
assert_eq!(g.map.core, (cx + 20, cy + 20));
assert_eq!(g.me, crate::map::Owner::of_side(2));
g.switch_to(0);
// the same order from the local master is heard here
g.apply(&Command { side: 0, seq: 1, act: Act::Attack { ent: None, x: (cx + 21) as f32, y: (cy + 21) as f32 } });
assert!(g.msgs.len() > msgs, "my own orders speak to me");
assert!(g.master_sides().contains(&2));
}
#[test]
fn unknown_sides_cannot_order_anything() {
let mut g = game(9);
let (cx, cy) = g.map.core;
let (x, y) = (cx + 6, cy);
g.apply(&Command { side: 3, seq: 0, act: Act::Dig(Rect::new(x, y, x, y)) });
g.update(STEP);
assert!(!g.map.t[idx(x, y)].marked);
}
#[test]
fn the_same_actions_on_both_paths_build_the_same_world() {
let dig = Rect::new(20, 20, 22, 22);
let room = Act::Room { room: 0, r: Rect::new(30, 30, 31, 31) };
let drop_at = (21.4f32, 22.6f32);
// one game driven like a single player session (queue + update)
let mut a = game(11);
let ogre = a.spawn(EK::Ogre, false, 21.5, 21.5, 3);
a.queue(Act::Dig(dig));
a.queue(room);
a.queue(Act::Pickup { ent: ogre });
a.queue(Act::Drop { x: drop_at.0, y: drop_at.1 });
for _ in 0..20 {
a.update(0.05);
}
// the same game driven like a match (one turn with all commands, then the same steps)
let mut b = game(11);
assert_eq!(b.spawn(EK::Ogre, false, 21.5, 21.5, 3), ogre);
let turn = vec![
Command { side: 0, seq: 0, act: Act::Dig(dig) },
Command { side: 0, seq: 1, act: room },
Command { side: 0, seq: 2, act: Act::Pickup { ent: ogre } },
Command { side: 0, seq: 3, act: Act::Drop { x: drop_at.0, y: drop_at.1 } },
];
b.apply_turn(&turn, 20);
assert_eq!(a.time, b.time, "the same number of fixed steps");
assert_eq!(a.sim_steps, b.sim_steps);
assert_eq!(a.gold, b.gold);
assert_eq!(a.hand, b.hand);
assert!(a.map.t.iter().zip(b.map.t.iter()).all(|(x, y)| x.kind == y.kind && x.owner == y.owner && x.marked == y.marked && x.hp == y.hp && x.gold == y.gold && x.reserved == y.reserved), "the maps diverged");
assert_eq!(fingerprint(&a), fingerprint(&b), "the creatures diverged");
}
// what the simulation has to agree on for two clients of one match
fn fingerprint(g: &Game) -> Vec<(u32, EK, bool, u8, f32, f32, f32, Job)> {
g.ents.iter().map(|e| (e.id, e.kind, e.alive, e.side, e.x, e.y, e.hp, e.job)).collect()
}
#[test]
fn commands_survive_a_savegame_round_trip() {
let c = Command { side: 1, seq: 4, act: Act::Attack { ent: Some(9), x: 3.5, y: 4.5 } };
let json = serde_json::to_string(&c).unwrap();
assert_eq!(serde_json::from_str::<Command>(&json).unwrap(), c);
}
}
+1 -1
View File
@@ -80,7 +80,7 @@ impl Game {
let (w, h) = (mw() as i32, mh() as i32);
// own ground and everything around it
for i in 0..n {
if self.map.t[i].owner == Owner::Player {
if self.map.t[i].owner == self.me {
let (x, y) = xy(i);
for dy in -1..=1 {
for dx in -1..=1 {
+293 -59
View File
@@ -1,5 +1,6 @@
// Game logic: units, AI, economy, hero waves.
use crate::commands::Command;
use crate::map::*;
use serde::{Deserialize, Serialize};
@@ -359,7 +360,7 @@ impl Ent {
}
// the player's creatures (not imps, heroes or the rivals' units)
pub fn creature(&self) -> bool {
!self.hero && self.side == 0 && self.kind != EK::Worker && !self.chained
!self.hero && self.side == me_side() && self.kind != EK::Worker && !self.chained
}
// 0 player, 1 humans, 2 and 3 rival masters
pub fn side(&self) -> u8 {
@@ -426,6 +427,50 @@ pub struct Fx {
pub style: u8, // projectiles: arrow, cannonball, fireball, ... (see particles.rs)
}
thread_local! {
// the master whose turn it is in the simulation (see Game::switch_to)
static ME_SIDE: std::cell::Cell<u8> = std::cell::Cell::new(0);
}
pub fn me_side() -> u8 {
ME_SIDE.with(|s| s.get())
}
fn player_owner() -> Owner {
Owner::Player
}
// The state of a dungeon master that is not being processed right now. The Game's own fields
// (gold, mana, hand, ...) always belong to the master whose side is `me_side`; switching swaps
// them with the stored ones. A single player game has no stored masters at all.
#[derive(Clone, Debug, Default)]
pub struct MasterState {
pub side: u8,
pub owner: Option<Owner>,
pub core: (i32, i32),
pub core_hp: f32,
pub gold: f32,
pub mana: f32,
pub payday: f32,
pub payday_warned: bool,
pub hand: Vec<u32>,
pub flag: Option<usize>,
pub reserve: f32,
pub incoming: Vec<(u8, f32, Option<usize>)>,
pub training_bill: f32,
pub counts: [usize; N_ROOMS],
pub springs: usize,
pub known: Vec<bool>,
pub researchable: Vec<bool>,
pub research: f32,
pub forge_work: f32,
pub rune_free: u32,
pub rune_final: u32,
pub rune_next: u32,
pub portal_timer: f32,
pub punch_ready: f32,
}
#[derive(Serialize, Deserialize)]
pub struct Game {
pub map: Map,
@@ -532,6 +577,22 @@ pub struct Game {
pub rune_next: u32, // sold rune forge towers: back as a chest in the next mission
#[serde(default)]
pub freed: u32, // chained creatures freed // tiles next to fighting heroes: knight ground there cannot be claimed
#[serde(skip, default = "player_owner")]
pub me: Owner, // owner of the ground of the master being processed
#[serde(skip)]
pub me_side: u8, // that master's side (0 = the first player)
#[serde(skip)]
pub local_side: u8, // the master who plays at this computer
#[serde(skip)]
pub masters: Vec<MasterState>, // the other masters (multiplayer)
#[serde(skip)]
pub pending: Vec<Command>, // orders of this master, applied at the next step boundary
#[serde(skip)]
pub cmd_seq: u32, // number of the next command queued here
#[serde(skip)]
pub sim_steps: u64, // fixed simulation steps done so far (the lockstep clock)
#[serde(skip)]
step_acc: f32, // real time not yet paid for with a step
#[serde(skip)]
pub hints: bool, // show hint messages (setting)
#[serde(skip, default = "hint_times")]
@@ -542,6 +603,12 @@ fn hint_times() -> [f32; 32] {
[-1000.0; 32]
}
// every simulation step has the same length: on every client of a match the same commands are
// applied and then the same number of steps runs, so the worlds stay identical
pub const STEP: f32 = 0.05;
// one frame never runs away into the future (a lagging tab catches up at most this much)
const MAX_STEPS_PER_FRAME: usize = 4;
// torch on a wall face: wall tile + direction (index into DIR4) towards the open side
#[derive(Clone, Copy, PartialEq, Serialize, Deserialize)]
pub struct Torch {
@@ -699,6 +766,14 @@ impl Game {
entry_history: Vec::new(),
planned_entries: Vec::new(),
hints: true,
me: Owner::Player,
me_side: 0,
local_side: 0,
masters: Vec::new(),
pending: Vec::new(),
cmd_seq: 0,
sim_steps: 0,
step_acc: 0.0,
hint_t: hint_times(),
reserve: 0.0,
incoming: Vec::new(),
@@ -758,7 +833,7 @@ impl Game {
let map = &self.map;
self.torches.retain(|t| map.t[t.wall].solid());
for i in 0..n() {
if self.map.t[i].owner == Owner::Player {
if self.map.t[i].owner == self.me {
self.place_torches(i);
}
}
@@ -800,7 +875,16 @@ impl Game {
self.mission.as_ref().map_or(self.gate_on, |m| m.heart)
}
// messages, hints and sounds are meant for the master who plays at this computer: while
// another master is being simulated (his economy, his orders) nothing of that reaches the screen
fn speaking(&self) -> bool {
self.me_side == self.local_side
}
pub fn msg(&mut self, key: &'static str, args: Vec<String>) {
if !self.speaking() {
return; // a foreign master ordered something: only he hears about it
}
self.msgs.push(Msg { key, args });
if self.msgs.len() > 50 {
self.msgs.remove(0);
@@ -808,12 +892,18 @@ impl Game {
}
pub fn sfx(&mut self, sfx: Sfx, x: f32, y: f32) {
if !self.speaking() {
return;
}
if self.events.len() < 64 {
self.events.push(SfxEvent { sfx, pos: Some((x, y)) });
}
}
pub fn sfx_ui(&mut self, sfx: Sfx) {
if !self.speaking() {
return;
}
if self.events.len() < 64 {
self.events.push(SfxEvent { sfx, pos: None });
}
@@ -862,6 +952,9 @@ impl Game {
}
pub(crate) fn hint(&mut self, slot: usize, key: &'static str) {
if !self.speaking() {
return; // another master ordered something: he keeps his hints for himself
}
if self.hints && self.time - self.hint_t[slot] > 30.0 {
self.hint_t[slot] = self.time;
self.msg(key, vec![]);
@@ -870,9 +963,9 @@ impl Game {
pub fn recount(&mut self) {
let mut c = [0usize; N_ROOMS];
self.springs = self.map.t.iter().filter(|t| t.kind == Kind::ManaSpring && t.owner == Owner::Player).count();
self.springs = self.map.t.iter().filter(|t| t.kind == Kind::ManaSpring && t.owner == self.me).count();
for t in &self.map.t {
if t.owner == Owner::Player {
if t.owner == self.me {
if let Some(r) = room_index(t.kind) {
c[r] += 1;
}
@@ -923,7 +1016,7 @@ impl Game {
let mut stored = 0.0;
for i in 0..self.map.t.len() {
let t = &mut self.map.t[i];
if t.kind == Kind::Treasury && t.owner == Owner::Player {
if t.kind == Kind::Treasury && t.owner == self.me {
tiles.push(i);
stored += t.gold as f32;
} else if t.gold != 0 && t.vein().is_none() && t.kind != Kind::Treasury {
@@ -1010,7 +1103,7 @@ impl Game {
#[allow(dead_code)]
pub fn portal_owned(&self) -> bool {
let Some(&(px, py)) = self.map.portals.first() else { return false };
self.map.t[idx(px, py)].owner == Owner::Player
self.map.t[idx(px, py)].owner == self.me
}
pub fn creature_count(&self) -> usize {
@@ -1083,11 +1176,15 @@ impl Game {
// ---------------------------------------------------------------- update
pub fn update(&mut self, dt: f32) {
let dt = dt.min(0.1);
if self.state == 0 && !self.paused {
self.step(dt);
self.step_acc = (self.step_acc + dt).min(STEP * MAX_STEPS_PER_FRAME as f32);
let mut steps = 0usize;
while self.step_acc >= STEP {
self.step_acc -= STEP;
steps += 1;
}
self.sync_treasury();
let cmds = std::mem::take(&mut self.pending);
self.apply_turn(&cmds, steps);
// 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() {
f.t += dt;
@@ -1095,11 +1192,134 @@ impl Game {
self.fx.retain(|f| f.t < f.max);
}
// one fixed step: the world of every master, then the shared bookkeeping
pub(crate) fn step_fixed(&mut self) {
self.sim_steps += 1;
if self.state == 0 && !self.paused {
self.step(STEP);
}
for side in self.master_sides() {
self.switch_to(side);
self.sync_treasury();
}
let local = self.local_side;
self.switch_to(local);
}
// ---------------------------------------------------------------- masters
pub fn master_sides(&self) -> Vec<u8> {
let mut v: Vec<u8> = std::iter::once(self.me_side).chain(self.masters.iter().map(|m| m.side)).collect();
v.sort_unstable();
v
}
pub fn is_master(&self, side: u8) -> bool {
side == self.me_side || self.masters.iter().any(|m| m.side == side)
}
fn take_active(&mut self) -> MasterState {
MasterState {
side: self.me_side,
owner: Some(self.me),
core: self.map.core,
core_hp: self.core_hp,
gold: self.gold,
mana: self.mana,
payday: self.payday,
payday_warned: self.payday_warned,
hand: std::mem::take(&mut self.hand),
flag: self.flag,
reserve: self.reserve,
incoming: std::mem::take(&mut self.incoming),
training_bill: self.training_bill,
counts: self.counts,
springs: self.springs,
known: std::mem::take(&mut self.known),
researchable: std::mem::take(&mut self.researchable),
research: self.research,
forge_work: self.forge_work,
rune_free: self.rune_free,
rune_final: self.rune_final,
rune_next: self.rune_next,
portal_timer: self.portal_timer,
punch_ready: self.punch_ready,
}
}
fn put_active(&mut self, m: MasterState) {
self.me_side = m.side;
self.me = m.owner.unwrap_or(Owner::of_side(m.side));
ME_SIDE.with(|s| s.set(m.side));
self.map.core = m.core;
self.core_hp = m.core_hp;
self.gold = m.gold;
self.mana = m.mana;
self.payday = m.payday;
self.payday_warned = m.payday_warned;
self.hand = m.hand;
self.flag = m.flag;
self.reserve = m.reserve;
self.incoming = m.incoming;
self.training_bill = m.training_bill;
self.counts = m.counts;
self.springs = m.springs;
self.known = m.known;
self.researchable = m.researchable;
self.research = m.research;
self.forge_work = m.forge_work;
self.rune_free = m.rune_free;
self.rune_final = m.rune_final;
self.rune_next = m.rune_next;
self.portal_timer = m.portal_timer;
self.punch_ready = m.punch_ready;
}
// make `side` the master being processed (no-op for sides that are no masters)
pub fn switch_to(&mut self, side: u8) {
if side == self.me_side {
return;
}
let Some(k) = self.masters.iter().position(|m| m.side == side) else { return };
let target = self.masters.swap_remove(k);
let cur = self.take_active();
self.masters.push(cur);
self.put_active(target);
}
// a new master on `side` with its core at (cx, cy) (top-left of the 3x3 core). Used by the
// multiplayer lobby (and its tests); single player games have no stored masters.
#[allow(dead_code)]
pub fn add_master(&mut self, side: u8, core: (i32, i32)) {
let mut m = self.take_active();
let me = std::mem::replace(&mut m, MasterState::default());
// the copy keeps the economy settings; the new one starts fresh
let fresh = MasterState {
side,
owner: Some(Owner::of_side(side)),
core,
core_hp: CORE_HP,
gold: 1500.0,
mana: 100.0,
payday: PAYDAY,
known: me.known.clone(),
researchable: me.researchable.clone(),
portal_timer: 10.0,
..MasterState::default()
};
self.put_active(me);
self.masters.push(fresh);
}
pub(crate) fn update_units(&mut self, dt: f32) {
for i in 0..self.ents.len() {
if !self.ents[i].alive || self.ents[i].held {
continue;
}
let side = self.ents[i].side;
if !self.ents[i].hero && side != self.me_side && !self.masters.is_empty() {
self.switch_to(side);
}
let mut e = std::mem::replace(&mut self.ents[i], Ent::dummy());
let (x0, y0) = (e.x, e.y);
self.update_ent(&mut e, dt);
@@ -1121,14 +1341,23 @@ impl Game {
return;
}
self.time += dt;
self.recount();
self.mana = (self.mana + (1.0 + SPRING_MANA * self.springs as f32) * dt).min(self.mana_max());
self.core_hp = (self.core_hp + 0.5 * dt).min(CORE_HP);
for t in self.map.t.iter_mut() {
if t.kind == Kind::Farm {
t.food = (t.food + dt / 18.0).min(3.0);
}
}
for side in self.master_sides() {
self.switch_to(side);
self.economy(dt);
}
self.after_economy(dt);
}
// a master's household: mana, core, portals, payday
fn economy(&mut self, dt: f32) {
self.recount();
self.mana = (self.mana + (1.0 + SPRING_MANA * self.springs as f32) * dt).min(self.mana_max());
self.core_hp = (self.core_hp + 0.5 * dt).min(CORE_HP);
let owned = self.portals_owned();
if owned > 0 {
@@ -1156,7 +1385,18 @@ impl Game {
self.payday_warned = false;
self.do_payday();
}
// every two seconds: corners between reinforced walls are closed
if (self.time / 2.0).floor() != ((self.time - dt) / 2.0).floor() {
self.reinforce_corners();
}
self.update_forge(dt);
self.update_research();
}
fn after_economy(&mut self, dt: f32) {
// from here on it is about the local master and the humans: that side is switched in first
let local = self.local_side;
self.switch_to(local);
if self.waves_on() {
self.wave_tick(dt);
}
@@ -1164,23 +1404,18 @@ impl Game {
self.gate_hp = (self.gate_hp + 8.0 * dt).min(GATE_HP);
}
// every two seconds: corners between reinforced walls are closed
if (self.time / 2.0).floor() != ((self.time - dt) / 2.0).floor() {
self.reinforce_corners();
}
self.update_contested();
self.update_units(dt);
self.switch_to(local); // update_units worked master by master
self.update_towers(dt);
self.update_forge(dt);
self.update_research();
self.update_reveals(dt);
self.update_graves(dt);
self.update_fog(dt);
if !self.rivals.is_empty() {
self.update_rivals(dt);
}
self.update_dwarves(dt);
self.check_end();
self.update_fog(dt);
}
fn check_end(&mut self) {
@@ -1313,7 +1548,7 @@ impl Game {
let (px, py) = self.map.portals[0];
for y in 0..3 {
for x in 0..3 {
self.map.t[idx(px + x, py + y)].owner = Owner::Player;
self.map.t[idx(px + x, py + y)].owner = self.me;
}
}
self.try_portal();
@@ -1455,7 +1690,7 @@ impl Game {
let cur = e.tile();
let goal = |i: usize| {
let t = &self.map.t[i];
(t.kind == Kind::Treasury && t.owner == Owner::Player && t.gold > 0) || (self.counts[0] == 0 && self.map.adj_core(i))
(t.kind == Kind::Treasury && t.owner == self.me && t.gold > 0) || (self.counts[0] == 0 && self.map.adj_core(i))
};
let found = if flies(e.kind) { self.map.search(cur, self.map.fly_cost(), goal, true) } else { self.map.search(cur, self.map.walk_cost(), goal, true) };
match found {
@@ -1760,7 +1995,7 @@ impl Game {
// waits in chains until the player's imps claim its cave
e.moving = false;
let (x, y) = xy(e.tile());
let reached = std::iter::once((0, 0)).chain(DIR8.iter().copied()).any(|(dx, dy)| self.map.get(x + dx, y + dy).map_or(false, |t| t.owner == Owner::Player));
let reached = std::iter::once((0, 0)).chain(DIR8.iter().copied()).any(|(dx, dy)| self.map.get(x + dx, y + dy).map_or(false, |t| t.owner == self.me));
if reached {
e.chained = false;
e.settled = false;
@@ -2314,7 +2549,7 @@ impl Game {
if t.kind != Kind::Earth || t.owner == Owner::Hero {
continue;
}
let own = DIR4.iter().any(|&(dx, dy)| self.map.get(x + dx, y + dy).map_or(false, |n| n.owner == Owner::Player && n.walkable()));
let own = DIR4.iter().any(|&(dx, dy)| self.map.get(x + dx, y + dy).map_or(false, |n| n.owner == self.me && n.walkable()));
if !own {
continue;
}
@@ -2354,7 +2589,7 @@ impl Game {
continue;
}
any_river = true;
let own = DIR4.iter().any(|&(dx, dy)| self.map.get(x + dx, y + dy).map_or(false, |n| n.owner == Owner::Player && n.walkable()));
let own = DIR4.iter().any(|&(dx, dy)| self.map.get(x + dx, y + dy).map_or(false, |n| n.owner == self.me && n.walkable()));
if own {
add.push(idx(x, y));
}
@@ -2374,7 +2609,7 @@ impl Game {
self.gold -= cost;
let t = &mut self.map.t[i];
t.kind = if lava { Kind::LavaBridge } else { Kind::Bridge };
t.owner = Owner::Player;
t.owner = self.me;
built += 1;
progress = true;
let (x, y) = xy(i);
@@ -2401,7 +2636,7 @@ impl Game {
pub fn reinforce_corners(&mut self) {
let (w, h) = (mw() as i32, mh() as i32);
let wall = |m: &Map, x: i32, y: i32| m.get(x, y).map_or(false, |n| n.kind == Kind::Earth && n.reinforced && n.owner != Owner::Hero);
let floor = |m: &Map, x: i32, y: i32| m.get(x, y).map_or(false, |n| n.owner == Owner::Player && n.walkable());
let floor = |m: &Map, x: i32, y: i32| m.get(x, y).map_or(false, |n| n.owner == self.me && n.walkable());
let mut add = Vec::new();
for y in 1..h - 1 {
for x in 1..w - 1 {
@@ -2514,7 +2749,7 @@ impl Game {
continue;
}
if let Some(t) = self.map.get(cx + dx, cy + dy) {
if t.bridge() && t.owner == Owner::Player {
if t.bridge() && t.owner == self.me {
return Some(idx(cx + dx, cy + dy));
}
}
@@ -2527,7 +2762,7 @@ impl Game {
// a knight walks next to the bridge tile and tears it down
fn tear_bridge(&mut self, e: &mut Ent, i: usize, dt: f32) -> bool {
let t = self.map.t[i];
if !t.bridge() || t.owner != Owner::Player {
if !t.bridge() || t.owner != self.me {
return false;
}
let (bx, by) = xy(i);
@@ -2596,7 +2831,7 @@ impl Game {
// an earth wall next to own floor that can be reinforced from tile i
fn reinforce_neighbor(&self, i: usize, me: u32) -> Option<usize> {
if self.map.t[i].owner != Owner::Player {
if self.map.t[i].owner != self.me {
return None;
}
let (x, y) = xy(i);
@@ -2612,14 +2847,13 @@ impl Game {
fn claimable(&self, i: usize, me: u32) -> bool {
let t = &self.map.t[i];
let ok = match t.owner {
Owner::None => matches!(t.kind, Kind::Floor | Kind::Portal | Kind::Bridge | Kind::LavaBridge | Kind::Shard | Kind::Treasury | Kind::ManaSpring | Kind::RuneChest),
// knight ground (also vaults and shard chambers), but not while heroes fight there
// knight and rival ground (also vaults and shard chambers), but not while enemies fight there
Owner::Hero | Owner::Rival1 | Owner::Rival2 => {
let ok = if t.owner == self.me {
false
} else if t.owner == Owner::None {
matches!(t.kind, Kind::Floor | Kind::Portal | Kind::Bridge | Kind::LavaBridge | Kind::Shard | Kind::Treasury | Kind::ManaSpring | Kind::RuneChest)
} else {
// knight and other masters' ground (also vaults and shard chambers), but not while enemies fight there
matches!(t.kind, Kind::Floor | Kind::Treasury | Kind::Bridge | Kind::LavaBridge | Kind::Shard | Kind::Lair | Kind::Training) && !self.contested.get(i).copied().unwrap_or(false)
}
Owner::Player => false,
};
if !ok {
return false;
@@ -2628,12 +2862,12 @@ impl Game {
return false;
}
let (x, y) = xy(i);
DIR4.iter().any(|&(dx, dy)| self.map.get(x + dx, y + dy).map_or(false, |n| n.owner == Owner::Player))
DIR4.iter().any(|&(dx, dy)| self.map.get(x + dx, y + dy).map_or(false, |n| n.owner == self.me))
}
fn is_deposit(&self, i: usize) -> bool {
let t = &self.map.t[i];
(t.kind == Kind::Treasury && t.owner == Owner::Player) || (self.counts[0] == 0 && self.map.adj_core(i))
(t.kind == Kind::Treasury && t.owner == self.me) || (self.counts[0] == 0 && self.map.adj_core(i))
}
fn start_deliver(&mut self, e: &mut Ent) -> bool {
@@ -2683,7 +2917,7 @@ impl Game {
for _ in 0..6 {
let nx = x + self.rng.range(-radius, radius);
let ny = y + self.rng.range(-radius, radius);
if self.map.walk(nx, ny) && self.map.t[idx(nx, ny)].owner == Owner::Player {
if self.map.walk(nx, ny) && self.map.t[idx(nx, ny)].owner == self.me {
if let Some(p) = self.map.path_to(e.tile(), idx(nx, ny)) {
if p.len() as i32 <= radius * 2 + 2 {
e.path = p;
@@ -2840,13 +3074,13 @@ impl Game {
let (px, py) = self.map.portals[pk];
for y in 0..3 {
for x in 0..3 {
self.map.t[idx(px + x, py + y)].owner = Owner::Player;
self.map.t[idx(px + x, py + y)].owner = self.me;
}
}
self.msg("msg.portal_claimed", vec![]);
self.fx(7, px as f32 + 1.5, py as f32 + 1.5, 0.0, 0.0, 1.0, 0xffff66cc);
} else {
self.map.t[t].owner = Owner::Player;
self.map.t[t].owner = self.me;
}
self.place_torches(t);
let (x, y) = xy(t);
@@ -2982,7 +3216,7 @@ impl Game {
self.map.walk_cost(),
|i| {
let t = &self.map.t[i];
t.kind == kind && t.owner == Owner::Player && (t.reserved == 0 || t.reserved == me) && (!need_food || t.food >= 1.0)
t.kind == kind && t.owner == self.me && (t.reserved == 0 || t.reserved == me) && (!need_food || t.food >= 1.0)
},
true,
)
@@ -3213,7 +3447,7 @@ impl Game {
Job::Guard(t) => {
// a guard post, or a guard order in any room
e.timer -= dt;
if cur != t || !self.map.t[t].walkable() || self.map.t[t].owner != Owner::Player || e.timer <= 0.0 {
if cur != t || !self.map.t[t].walkable() || self.map.t[t].owner != self.me || e.timer <= 0.0 {
self.unreserve(me);
e.job = Job::None;
}
@@ -3979,11 +4213,11 @@ impl Game {
let mut cands = Vec::new();
for i in 0..self.map.t.len() {
let t = &self.map.t[i];
if t.owner != Owner::Player || !t.walkable() {
if t.owner != self.me || !t.walkable() {
continue;
}
let (x, y) = xy(i);
let border = DIR4.iter().any(|&(dx, dy)| self.map.get(x + dx, y + dy).map_or(false, |n| n.owner != Owner::Player));
let border = DIR4.iter().any(|&(dx, dy)| self.map.get(x + dx, y + dy).map_or(false, |n| n.owner != self.me));
if !border {
continue;
}
@@ -4061,7 +4295,7 @@ impl Game {
}
let i = idx(x, y);
let t = self.map.t[i];
if t.kind == Kind::Floor && t.owner == Owner::Player {
if t.kind == Kind::Floor && t.owner == self.me {
if self.gold < cost {
broke = true;
continue;
@@ -4124,7 +4358,7 @@ impl Game {
refund += STEEL_COST[1] as f32 * 0.5;
continue;
}
if t.owner == Owner::Player && t.bridge() {
if t.owner == self.me && t.bridge() {
let lava = t.kind == Kind::LavaBridge;
t.kind = if lava { Kind::Lava } else { Kind::Water };
t.owner = Owner::None;
@@ -4134,7 +4368,7 @@ impl Game {
continue;
}
let t = &mut self.map.t[i];
if t.owner == Owner::Player {
if t.owner == self.me {
if let Some(r) = room_index(t.kind) {
t.kind = Kind::Floor;
t.bed = 0;
@@ -4197,7 +4431,7 @@ impl Game {
return;
}
let t = self.map.t[i];
if t.kind != Kind::Floor || t.owner != Owner::Player {
if t.kind != Kind::Floor || t.owner != self.me {
self.hint(16, "hint.tower_where");
self.sfx_ui(Sfx::Error);
return;
@@ -4626,7 +4860,7 @@ impl Game {
}
fn on_own_ground(&self, x: f32, y: f32) -> bool {
self.map.get(x as i32, y as i32).map_or(false, |t| t.owner == Owner::Player)
self.map.get(x as i32, y as i32).map_or(false, |t| t.owner == self.me)
}
// Left click with the hand: own units anywhere, unconscious heroes only on own ground
@@ -4685,7 +4919,7 @@ impl Game {
}
let i = idx(x, y);
let t = self.map.t[i];
if !t.walkable() || t.owner != Owner::Player {
if !t.walkable() || t.owner != self.me {
self.hint(9, "hint.drop_where");
self.sfx_ui(Sfx::Error);
return false;
@@ -4829,7 +5063,7 @@ impl Game {
pub fn summon(&mut self, wx: f32, wy: f32) {
let cost = self.summon_cost();
let (x, y) = (wx as i32, wy as i32);
if !self.map.walk(x, y) || self.map.t[idx(x, y)].owner != Owner::Player {
if !self.map.walk(x, y) || self.map.t[idx(x, y)].owner != self.me {
self.hint(13, "hint.summon_where");
return;
}
@@ -5137,23 +5371,23 @@ mod tests {
#[test]
fn treasury_shows_its_treasures() {
let mut g = Game::new(3, 64, 64);
g.update(0.01);
g.update(STEP);
// the starting gold lies in the treasury ring
assert!((treasury_total(&g) - g.gold).abs() < 2.0);
assert_eq!(g.reserve, 0.0);
// rubies fill their own tiles
g.add_res(1200.0, 2);
g.update(0.01);
g.update(STEP);
let rubies: i32 = g.map.t.iter().filter(|t| t.kind == Kind::Treasury && t.owner == Owner::Player && t.res == 2).map(|t| t.gold).sum();
assert_eq!(rubies, 1200);
assert!(g.map.t.iter().all(|t| t.gold <= TREASURY_TILE as i32 || t.vein().is_some()));
// spending empties tiles (randomly), the total always matches the gold counter
g.gold -= 1700.0;
g.update(0.01);
g.update(STEP);
assert!((treasury_total(&g) - g.gold).abs() < 2.0);
// more than the treasury holds: the core keeps the rest
g.gold = g.gold_cap();
g.update(0.01);
g.update(STEP);
assert!((treasury_total(&g) - g.gold).abs() < 2.0);
// (tiles partly filled with rubies take no gold, so the core may hold a little more)
let gem_room: f32 = g.map.t.iter().filter(|t| t.kind == Kind::Treasury && t.owner == Owner::Player && t.res != 0).map(|t| TREASURY_TILE - t.gold as f32).sum();
@@ -5174,12 +5408,12 @@ mod tests {
}
}
g.recount();
g.update(0.01);
g.update(STEP);
let far = |g: &Game| -> i32 { (cy - 9..=cy - 7).flat_map(|y| (cx + 10..=cx + 12).map(move |x| (x, y))).map(|(x, y)| g.map.t[idx(x, y)].gold).sum() };
let before = far(&g);
let at = idx(cx + 11, cy - 8);
g.add_res_at(900.0, 2, Some(at));
g.update(0.01);
g.update(STEP);
assert_eq!(far(&g) - before, 900, "the rubies lie where they were delivered");
}
+33 -29
View File
@@ -3,6 +3,7 @@
mod audio;
mod campaign;
mod cloud;
mod commands;
mod demo;
mod dwarves;
mod fog;
@@ -35,10 +36,11 @@ mod wilds;
mod xm;
use cloud::{Cloud, CloudEvent};
use commands::Act;
use game::{Game, Msg};
use gfx::Gfx;
use macroquad::prelude::*;
use map::{idx, inb, mh, mw};
use map::{inb, mh, mw};
use menu::{Menu, MenuAction, Page};
use render3d::{Cam, Overlay, R3d};
use settings::{bindable_keys, Action, Settings};
@@ -110,27 +112,32 @@ fn world_action(app: &mut App, ui: &mut Ui, r3d: &R3d, inp: &mut Input, mx: f32,
(Tool::Dig | Tool::Room(_) | Tool::Sell | Tool::Bridge | Tool::Steel, Some(t)) => inp.ldown = Some(t),
(Tool::Hand, _) => {
let j = r3d.pick_ent(&app.game, &app.cam, mx, my, false);
let Some(j) = j else { return };
let (id, hero, dwarf) = (app.game.ents[j].id, app.game.ents[j].hero, app.game.ents[j].dwarf());
if dwarf {
return;
}
if slap {
let kind = app.game.slap(j);
if kind > 0 {
ui.hand_anim = Some((kind, now));
}
// the punch/backhand animation at the pointer starts at once, the order follows at the next step
ui.hand_anim = Some((if hero { 2 } else { 1 }, now));
app.game.queue(Act::Slap { ent: id });
} else {
app.game.pickup(j);
app.game.queue(Act::Pickup { ent: id });
}
}
(Tool::Flag, Some((x, z))) => app.game.set_flag(x, z),
(Tool::Lightning, _) => app.game.lightning(wx, wy),
(Tool::Heal, _) => app.game.heal(wx, wy),
(Tool::Summon, _) => app.game.summon(wx, wy),
(Tool::Tower(k), Some((x, z))) => app.game.build_tower(k, x, z),
(Tool::Trap(k), Some((x, z))) => app.game.place_trap(k, x, z),
(Tool::Spell(sp), _) => app.game.cast(sp, wx, wy),
(Tool::Flag, Some((x, z))) => app.game.queue(Act::Flag { x, y: z }),
(Tool::Lightning, _) => app.game.queue(Act::Lightning { x: wx, y: wy }),
(Tool::Heal, _) => app.game.queue(Act::Heal { x: wx, y: wy }),
(Tool::Summon, _) => app.game.queue(Act::Summon { x: wx, y: wy }),
(Tool::Tower(k), Some((x, z))) => app.game.queue(Act::Tower { kind: k, x, y: z }),
(Tool::Trap(k), Some((x, z))) => app.game.queue(Act::Trap { kind: k, x, y: z }),
(Tool::Spell(sp), _) => app.game.queue(Act::Cast { spell: sp, x: wx, y: wy }),
(Tool::Order(0), _) => {
let j = r3d.pick_ent(&app.game, &app.cam, mx, my, false);
app.game.order_attack(j, wx, wy);
let ent = j.filter(|&j| j < app.game.ents.len()).map(|j| app.game.ents[j].id);
app.game.queue(Act::Attack { ent, x: wx, y: wy });
}
(Tool::Order(_), Some((x, z))) => app.game.order_guard(x, z),
(Tool::Order(_), Some((x, z))) => app.game.queue(Act::Guard { x, y: z }),
_ => {}
}
}
@@ -139,15 +146,13 @@ fn world_action(app: &mut App, ui: &mut Ui, r3d: &R3d, inp: &mut Input, mx: f32,
fn finish_rect(app: &mut App, tool: Tool, start: (i32, i32), mx: f32, my: f32) {
let Some((ex, ey)) = tool_tile(&app.cam, &app.game.map, tool, mx, my) else { return };
let (sx, sy) = start;
let r = commands::Rect::new(sx, sy, ex, ey);
match tool {
Tool::Dig => {
let on = !app.game.map.t[idx(sx, sy)].marked;
app.game.mark_rect(sx, sy, ex, ey, on);
}
Tool::Room(r) => app.game.build_rect(r, sx, sy, ex, ey),
Tool::Sell => app.game.sell_rect(sx, sy, ex, ey),
Tool::Bridge => app.game.build_bridges(sx, sy, ex, ey),
Tool::Steel => app.game.build_steel(sx, sy, ex, ey),
Tool::Dig => app.game.queue(Act::Dig(r)),
Tool::Room(room) => app.game.queue(Act::Room { room: room as u8, r }),
Tool::Sell => app.game.queue(Act::Sell(r)),
Tool::Bridge => app.game.queue(Act::Bridge(r)),
Tool::Steel => app.game.queue(Act::Steel(r)),
_ => {}
}
}
@@ -731,9 +736,9 @@ async fn main() {
if inp.ldown.is_some() {
inp.ldown = None;
} else if app.game.hand.is_empty() && matches!(ui.tool, Tool::Order(_)) {
app.game.cancel_orders();
app.game.queue(Act::CancelOrders);
} else {
app.game.drop(wx, wy);
app.game.queue(Act::Drop { x: wx, y: wy });
}
}
inp.rdown = None;
@@ -929,7 +934,7 @@ async fn main() {
inp.ldown = None;
if ui.tool == Tool::Hand && !app.game.hand.is_empty() {
let gp = app.cam.ground_point(p.x / d, p.y / d);
app.game.drop(gp.x, gp.y);
app.game.queue(Act::Drop { x: gp.x, y: gp.y });
} else {
inp.inspect = Some((p.x / d, p.y / d, now));
}
@@ -983,9 +988,8 @@ async fn main() {
app.game.events.retain(|ev| ev.pos.is_none());
}
if playing {
for _ in 0..(app.settings.game_speed.round().max(1.0) as usize) {
app.game.update(dt);
}
// the world runs in fixed steps: the speed setting only feeds more time into them
app.game.update(dt * app.settings.game_speed.max(1.0));
// autosave: snapshot for leaving the tab + regular autosave
if app.game.state == 0 {
cache_t += dt;
+2 -2
View File
@@ -66,7 +66,7 @@ impl Game {
}
let i = idx(x, y);
let kind = self.map.t[i].kind;
if self.map.t[i].owner != Owner::Player || room_index(kind).is_none() && kind != Kind::Floor {
if self.map.t[i].owner != self.me || room_index(kind).is_none() && kind != Kind::Floor {
self.hint(31, "hint.order_guard");
self.sfx_ui(Sfx::Error);
return;
@@ -77,7 +77,7 @@ impl Game {
for dx in -3..=3 {
if let Some(t) = self.map.get(x + dx, y + dy) {
let j = idx(x + dx, y + dy);
if t.kind == kind && t.owner == Owner::Player && t.reserved == 0 && !self.ents.iter().any(|e| e.job == Job::Guard(j)) {
if t.kind == kind && t.owner == self.me && t.reserved == 0 && !self.ents.iter().any(|e| e.job == Job::Guard(j)) {
spots.push(j);
}
}
+3 -3
View File
@@ -36,7 +36,7 @@ impl Game {
// ---- prison
pub(crate) fn free_cell(&self, i: usize) -> bool {
let t = &self.map.t[i];
matches!(t.kind, Kind::Torture | Kind::Prison) && t.owner == Owner::Player && t.bed == 0
matches!(t.kind, Kind::Torture | Kind::Prison) && t.owner == self.me && t.bed == 0
}
// ---- graveyard: a fallen unit is buried in a free grave
@@ -50,7 +50,7 @@ impl Game {
.t
.iter()
.enumerate()
.filter(|(i, t)| t.kind == Kind::Graveyard && t.owner == Owner::Player && !taken.contains(i))
.filter(|(i, t)| t.kind == Kind::Graveyard && t.owner == self.me && !taken.contains(i))
.min_by_key(|(i, _)| {
let (tx, ty) = xy(*i);
((tx as f32 - x).powi(2) + (ty as f32 - y).powi(2)) as i32
@@ -96,7 +96,7 @@ impl Game {
// ---- guard post: a free post for a creature
pub(crate) fn free_post(&self, i: usize, me: u32) -> bool {
let t = &self.map.t[i];
t.kind == Kind::GuardPost && t.owner == Owner::Player && (t.reserved == 0 || t.reserved == me)
t.kind == Kind::GuardPost && t.owner == self.me && (t.reserved == 0 || t.reserved == me)
}
// ---- arena: sparring with a partner nearby; fast experience, some bruises
+3 -3
View File
@@ -166,7 +166,7 @@ impl Game {
return;
}
let t = self.map.t[i];
if t.kind != Kind::Floor || t.owner != Owner::Player {
if t.kind != Kind::Floor || t.owner != self.me {
self.hint(25, "hint.trap_where");
self.sfx_ui(Sfx::Error);
return;
@@ -201,7 +201,7 @@ impl Game {
self.traps[k].progress += work;
if self.traps[k].progress >= self.traps[k].kind.work() {
// finished: a crate on a forge tile
let spot = self.map.t.iter().enumerate().filter(|(_, t)| t.kind == Kind::Forge && t.owner == Owner::Player).map(|(i, _)| i).nth(self.traps[k].id as usize % self.forge_tiles().max(1));
let spot = self.map.t.iter().enumerate().filter(|(_, t)| t.kind == Kind::Forge && t.owner == self.me).map(|(i, _)| i).nth(self.traps[k].id as usize % self.forge_tiles().max(1));
match spot {
Some(s) => {
let t = &mut self.traps[k];
@@ -219,7 +219,7 @@ impl Game {
}
// orders on lost ground are dropped
let map = &self.map;
self.traps.retain(|t| t.stage == ARMED || map.t[t.tile].owner == Owner::Player);
self.traps.retain(|t| t.stage == ARMED || map.t[t.tile].owner == self.me);
for t in self.traps.iter_mut() {
t.fired += dt;
t.cd -= dt;