Script runs are resumable threads saved with the game; Rhai is gone. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
1082 lines
45 KiB
Rust
1082 lines
45 KiB
Rust
// Mission scripts at run time: the block scripts of a mission (campaign_doc::MissionScripts)
|
|
// are run directly from their blocks inside the fixed simulation step, so a co-op match runs
|
|
// them identically on every computer. Every run of a script is a small "thread" (where it is
|
|
// in which list of blocks); "wait" parks a thread until its time has come, and threads are
|
|
// saved with the game. Loops without a wait run up to a budget of steps per check and go on at
|
|
// the next check, so a script can never hang the game.
|
|
|
|
use std::collections::BTreeMap;
|
|
|
|
use serde::{Deserialize, Serialize};
|
|
|
|
use crate::blocks::{Block, Place, Val};
|
|
use crate::campaign_doc::{MissionScripts, TriggerKind, ZoneDoc};
|
|
use crate::game::*;
|
|
use crate::map::*;
|
|
|
|
const CHECK_STEPS: u64 = 5; // triggers are checked every 5 fixed steps (0.1 s)
|
|
const MAX_LOG: usize = 60;
|
|
const STEP_BUDGET: u32 = 2000; // blocks a thread may run per check before it yields
|
|
const MAX_THREADS: usize = 64;
|
|
|
|
// pending events the simulation reports (the rest is found by comparing two checks)
|
|
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
|
|
pub enum ScriptEvent {
|
|
Spell(String),
|
|
Slap,
|
|
}
|
|
|
|
// a unit as the last check saw it
|
|
#[derive(Clone, Debug, Serialize, Deserialize)]
|
|
struct Seen {
|
|
id: u32,
|
|
kind: EK,
|
|
side: u8,
|
|
x: f32,
|
|
y: f32,
|
|
}
|
|
|
|
// the scripts of a running mission and what they remember (saved with the game)
|
|
#[derive(Clone, Debug, Default, Serialize, Deserialize)]
|
|
pub struct ScriptState {
|
|
pub scripts: MissionScripts,
|
|
pub zones: Vec<ZoneDoc>,
|
|
pub vars: BTreeMap<String, f64>,
|
|
pub enabled: Vec<bool>,
|
|
pub done: Vec<bool>,
|
|
inside: Vec<Vec<u32>>, // zone and line triggers: units inside / on the far side
|
|
sides: Vec<Vec<(u32, i8)>>, // line triggers: the side of the line each unit was on
|
|
last: Vec<f64>, // per trigger: last measured value or the next timer time
|
|
alive: Vec<Seen>, // living units at the last check
|
|
towers: Vec<(u32, bool)>, // towers at the last check (id, knights')
|
|
rooms: Vec<usize>, // room tiles per room kind at the last check
|
|
known: usize, // tools known at the last check
|
|
pub events: Vec<ScriptEvent>,
|
|
pub names: Vec<(String, u32)>, // units named by spawn blocks
|
|
pub log: Vec<(f32, String)>, // what fired when (debug view)
|
|
started: bool,
|
|
next_second: f32,
|
|
rng: u32,
|
|
pub error: Option<String>,
|
|
#[serde(default)]
|
|
threads: Vec<Thread>, // scripts that are waiting (or yielded in a long loop)
|
|
#[serde(skip)]
|
|
pub camera: Option<(f32, f32)>, // a block asked the camera to look here (taken by main)
|
|
}
|
|
|
|
// which script a thread runs
|
|
#[derive(Clone, Copy, Debug, Serialize, Deserialize, PartialEq)]
|
|
enum Src {
|
|
Start,
|
|
Victory,
|
|
Defeat,
|
|
Tick,
|
|
Trigger(usize),
|
|
}
|
|
|
|
// a list of blocks being worked through: its path (pairs of block index and branch below the
|
|
// script's own list), the next block, and what kind of list it is
|
|
#[derive(Clone, Debug, Serialize, Deserialize)]
|
|
struct Frame {
|
|
lp: Vec<usize>,
|
|
idx: usize,
|
|
left: i64, // repeat: runs still to come after this one (-1: not a repeat)
|
|
loop_kind: u8, // 0 plain, 1 repeat, 2 repeat until (checked before), 3 repeat ... until (checked after)
|
|
}
|
|
|
|
#[derive(Clone, Debug, Serialize, Deserialize)]
|
|
struct Thread {
|
|
src: Src,
|
|
frames: Vec<Frame>,
|
|
wake: f32,
|
|
ev: EventInfo,
|
|
}
|
|
|
|
// what a running script asks for
|
|
#[derive(Clone, Debug, PartialEq)]
|
|
enum Act {
|
|
Message(String),
|
|
Objective(String),
|
|
Hint(String),
|
|
Spawn { side: i32, kind: String, count: i64, level: i64, place: String, zone: String, name: String },
|
|
Wave(i64),
|
|
Gold(f64),
|
|
Mana(f64),
|
|
Win,
|
|
Lose,
|
|
Reveal(String),
|
|
Camera(String),
|
|
Terrain(String, String),
|
|
Kill(String, i32),
|
|
Trigger(String, bool),
|
|
}
|
|
|
|
// the triggering unit (for "where the unit is" and "the unit is a ...")
|
|
#[derive(Clone, Debug, Default, Serialize, Deserialize)]
|
|
struct EventInfo {
|
|
kind: Option<EK>,
|
|
pos: Option<(f32, f32)>,
|
|
}
|
|
|
|
pub fn kind_by_name(name: &str) -> Option<EK> {
|
|
let n = name.trim().to_lowercase();
|
|
crate::blocks::KINDS.iter().position(|k| k.to_lowercase() == n).map(|i| match i {
|
|
0 => EK::Worker,
|
|
1 => EK::Kobold,
|
|
2 => EK::Spider,
|
|
3 => EK::Mage,
|
|
4 => EK::Ogre,
|
|
5 => EK::Jailer,
|
|
6 => EK::Priestess,
|
|
7 => EK::Chainbreaker,
|
|
8 => EK::Bat,
|
|
9 => EK::Gargoyle,
|
|
10 => EK::Owl,
|
|
11 => EK::Wyrm,
|
|
12 => EK::Cogling,
|
|
13 => EK::Revenant,
|
|
14 => EK::Knight,
|
|
15 => EK::Archer,
|
|
16 => EK::Priest,
|
|
17 => EK::Dwarf,
|
|
18 => EK::Paladin,
|
|
19 => EK::Giant,
|
|
20 => EK::Fairy,
|
|
_ => EK::BattleMage,
|
|
})
|
|
}
|
|
|
|
// does a unit match a side (-1 any) and a kind name ("" any)?
|
|
fn matches(e_side: u8, e_kind: EK, side: i32, kind: &str) -> bool {
|
|
(side < 0 || e_side as i32 == side) && (kind.is_empty() || kind_by_name(kind) == Some(e_kind))
|
|
}
|
|
|
|
// ------------------------------------------------------------------ zones
|
|
|
|
pub fn zone_contains(z: &ZoneDoc, x: f32, y: f32) -> bool {
|
|
match z.shape.as_str() {
|
|
"rect" => match (z.from, z.to) {
|
|
(Some(a), Some(b)) => x >= a.0.min(b.0) && x < a.0.max(b.0) + 1.0 && y >= a.1.min(b.1) && y < a.1.max(b.1) + 1.0,
|
|
_ => false,
|
|
},
|
|
"tiles" => z.tiles.as_ref().map_or(false, |t| t.contains(&(x.floor() as i32, y.floor() as i32))),
|
|
"line" => false,
|
|
_ => {
|
|
let (cx, cy) = z.at.unwrap_or((0.0, 0.0));
|
|
let r = z.radius.unwrap_or(2.0);
|
|
(x - cx - 0.5).powi(2) + (y - cy - 0.5).powi(2) <= r * r
|
|
}
|
|
}
|
|
}
|
|
|
|
// the tiles of a zone (a line: the tiles it passes)
|
|
pub fn zone_tiles(z: &ZoneDoc) -> Vec<usize> {
|
|
let mut out = Vec::new();
|
|
if z.shape == "line" {
|
|
if let (Some(a), Some(b)) = (z.from, z.to) {
|
|
let n = ((b.0 - a.0).abs().max((b.1 - a.1).abs()) * 2.0).ceil().max(1.0) as usize;
|
|
for k in 0..=n {
|
|
let t = k as f32 / n as f32;
|
|
let (x, y) = ((a.0 + (b.0 - a.0) * t + 0.5).floor() as i32, (a.1 + (b.1 - a.1) * t + 0.5).floor() as i32);
|
|
if inb(x, y) && !out.contains(&idx(x, y)) {
|
|
out.push(idx(x, y));
|
|
}
|
|
}
|
|
}
|
|
return out;
|
|
}
|
|
if let Some(t) = z.tiles.as_ref().filter(|_| z.shape == "tiles") {
|
|
return t.iter().filter(|&&(x, y)| inb(x, y)).map(|&(x, y)| idx(x, y)).collect();
|
|
}
|
|
let (x0, y0, x1, y1) = match (z.shape.as_str(), z.from, z.to) {
|
|
("rect", Some(a), Some(b)) => (a.0.min(b.0) as i32, a.1.min(b.1) as i32, a.0.max(b.0) as i32, a.1.max(b.1) as i32),
|
|
_ => {
|
|
let (cx, cy) = z.at.unwrap_or((0.0, 0.0));
|
|
let r = z.radius.unwrap_or(2.0).ceil() as i32;
|
|
(cx as i32 - r, cy as i32 - r, cx as i32 + r, cy as i32 + r)
|
|
}
|
|
};
|
|
for y in y0..=y1 {
|
|
for x in x0..=x1 {
|
|
if inb(x, y) && zone_contains(z, x as f32 + 0.5, y as f32 + 0.5) {
|
|
out.push(idx(x, y));
|
|
}
|
|
}
|
|
}
|
|
out
|
|
}
|
|
|
|
// the middle of a zone
|
|
pub fn zone_center(z: &ZoneDoc) -> (f32, f32) {
|
|
match (z.shape.as_str(), z.from, z.to, z.at) {
|
|
("rect", Some(a), Some(b), _) | ("line", Some(a), Some(b), _) => ((a.0 + b.0) * 0.5 + 0.5, (a.1 + b.1) * 0.5 + 0.5),
|
|
(_, _, _, Some(c)) => (c.0 + 0.5, c.1 + 0.5),
|
|
_ => {
|
|
let t = zone_tiles(z);
|
|
if t.is_empty() {
|
|
(0.0, 0.0)
|
|
} else {
|
|
let (sx, sy) = t.iter().map(|&i| xy(i)).fold((0.0, 0.0), |a, (x, y)| (a.0 + x as f32 + 0.5, a.1 + y as f32 + 0.5));
|
|
(sx / t.len() as f32, sy / t.len() as f32)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// which side of a line a point is on (0: beyond its ends)
|
|
fn line_side(z: &ZoneDoc, x: f32, y: f32) -> i8 {
|
|
let (Some(a), Some(b)) = (z.from, z.to) else { return 0 };
|
|
let (ax, ay, bx, by) = (a.0 + 0.5, a.1 + 0.5, b.0 + 0.5, b.1 + 0.5);
|
|
let (dx, dy) = (bx - ax, by - ay);
|
|
let len2 = dx * dx + dy * dy;
|
|
if len2 <= 0.0 {
|
|
return 0;
|
|
}
|
|
let t = ((x - ax) * dx + (y - ay) * dy) / len2;
|
|
if !(-0.05..=1.05).contains(&t) {
|
|
return 0;
|
|
}
|
|
let c = dx * (y - ay) - dy * (x - ax);
|
|
if c > 0.0 {
|
|
1
|
|
} else {
|
|
-1
|
|
}
|
|
}
|
|
|
|
// the list of blocks at a path inside a script
|
|
fn list_at<'a>(s: &'a MissionScripts, src: Src, lp: &[usize]) -> Option<&'a [Block]> {
|
|
let mut cur: &[Block] = match src {
|
|
Src::Start => &s.start,
|
|
Src::Victory => &s.victory,
|
|
Src::Defeat => &s.defeat,
|
|
Src::Tick => &s.tick,
|
|
Src::Trigger(k) => &s.triggers.get(k)?.script,
|
|
};
|
|
let mut k = 0;
|
|
while k + 1 < lp.len() + 1 && k < lp.len() {
|
|
let (i, branch) = (lp[k], *lp.get(k + 1)?);
|
|
cur = match cur.get(i)? {
|
|
Block::If { then, els, .. } => {
|
|
if branch == 0 {
|
|
then
|
|
} else {
|
|
els
|
|
}
|
|
}
|
|
Block::Repeat { body, .. } | Block::Until { body, .. } | Block::DoUntil { body, .. } => body,
|
|
_ => return None,
|
|
};
|
|
k += 2;
|
|
}
|
|
Some(cur)
|
|
}
|
|
|
|
fn xorshift(rng: &mut u32) -> u32 {
|
|
let mut x = (*rng).max(1);
|
|
x ^= x << 13;
|
|
x ^= x >> 17;
|
|
x ^= x << 5;
|
|
*rng = x;
|
|
x
|
|
}
|
|
|
|
// ------------------------------------------------------------------ the game side
|
|
|
|
impl ScriptState {
|
|
pub fn new(scripts: MissionScripts, zones: Vec<ZoneDoc>, seed: u32) -> ScriptState {
|
|
let n = scripts.triggers.len();
|
|
ScriptState {
|
|
enabled: scripts.triggers.iter().map(|t| t.enabled).collect(),
|
|
done: vec![false; n],
|
|
inside: vec![Vec::new(); n],
|
|
sides: vec![Vec::new(); n],
|
|
last: vec![f64::NAN; n],
|
|
scripts,
|
|
zones,
|
|
rng: seed | 1,
|
|
..ScriptState::default()
|
|
}
|
|
}
|
|
|
|
fn zone(&self, name: &str) -> Option<&ZoneDoc> {
|
|
self.zones.iter().find(|z| z.name == name)
|
|
}
|
|
|
|
fn log(&mut self, t: f32, s: String) {
|
|
self.log.push((t, s));
|
|
if self.log.len() > MAX_LOG {
|
|
self.log.remove(0);
|
|
}
|
|
}
|
|
}
|
|
|
|
fn cmp(op: &str, a: f64, b: f64) -> bool {
|
|
match op {
|
|
"<" => a < b,
|
|
"<=" => a <= b,
|
|
"=" | "==" => (a - b).abs() < 1e-9,
|
|
"!=" => (a - b).abs() >= 1e-9,
|
|
">" => a > b,
|
|
_ => a >= b,
|
|
}
|
|
}
|
|
|
|
// spell names for SpellCast triggers
|
|
pub fn spell_name(s: crate::spells::Spell) -> String {
|
|
format!("{:?}", s)
|
|
}
|
|
|
|
impl Game {
|
|
// the simulation reports an event to the scripts (spells, slaps)
|
|
pub(crate) fn script_event(&mut self, ev: ScriptEvent) {
|
|
if let Some(s) = self.script.as_mut() {
|
|
s.events.push(ev);
|
|
}
|
|
}
|
|
|
|
// a message every player sees (scripts speak to all masters of a co-op match)
|
|
fn script_msg(&mut self, key: &'static str, args: Vec<String>) {
|
|
self.msgs.push(Msg { key, args });
|
|
if self.msgs.len() > 50 {
|
|
self.msgs.remove(0);
|
|
}
|
|
}
|
|
|
|
// called from the fixed step with the player's side active
|
|
pub(crate) fn run_scripts(&mut self) {
|
|
if self.script.is_none() || self.state != 0 || self.sim_steps % CHECK_STEPS != 0 {
|
|
return;
|
|
}
|
|
let mut s = self.script.take().unwrap();
|
|
let mut fire: Vec<(String, EventInfo, Option<usize>)> = Vec::new();
|
|
let mut start: Vec<(Src, EventInfo)> = Vec::new();
|
|
if !s.started {
|
|
s.started = true;
|
|
s.next_second = self.time + 1.0;
|
|
self.remember(&mut s, true);
|
|
start.push((Src::Start, EventInfo::default()));
|
|
} else {
|
|
self.check_triggers(&mut s, &mut fire);
|
|
if self.time >= s.next_second {
|
|
s.next_second += 1.0;
|
|
// the checks run every second, but never twice at once (one may still be waiting)
|
|
for src in [Src::Tick, Src::Victory, Src::Defeat] {
|
|
if !s.threads.iter().any(|t| t.src == src) {
|
|
start.push((src, EventInfo::default()));
|
|
}
|
|
}
|
|
}
|
|
}
|
|
for (_, ev, trig) in fire {
|
|
let Some(k) = trig else { continue };
|
|
// a trigger fires: once-triggers only the first time, switched-off ones never
|
|
if !s.enabled.get(k).copied().unwrap_or(false) || (s.scripts.triggers[k].once && s.done[k]) {
|
|
continue;
|
|
}
|
|
s.done[k] = true;
|
|
let name = s.scripts.triggers[k].name.clone();
|
|
s.log(self.time, name);
|
|
start.push((Src::Trigger(k), ev));
|
|
}
|
|
let mut threads = std::mem::take(&mut s.threads);
|
|
for (src, ev) in start {
|
|
threads.push(Thread { src, frames: vec![Frame { lp: Vec::new(), idx: 0, left: -1, loop_kind: 0 }], wake: 0.0, ev });
|
|
}
|
|
// run every thread whose time has come; the waiting ones stay
|
|
let scripts = std::mem::take(&mut s.scripts);
|
|
let mut keep = Vec::new();
|
|
for mut t in threads {
|
|
if self.state == 0 && t.wake <= self.time && !self.run_thread(&mut s, &scripts, &mut t) {
|
|
keep.push(t);
|
|
} else if t.wake > self.time {
|
|
keep.push(t);
|
|
}
|
|
}
|
|
if keep.len() > MAX_THREADS {
|
|
keep.drain(0..keep.len() - MAX_THREADS);
|
|
}
|
|
s.scripts = scripts;
|
|
s.threads = keep;
|
|
s.events.clear();
|
|
self.script = Some(s);
|
|
}
|
|
|
|
// runs a thread until it ends (true), waits or used up its steps (false)
|
|
fn run_thread(&mut self, s: &mut ScriptState, scripts: &MissionScripts, t: &mut Thread) -> bool {
|
|
let mut budget = STEP_BUDGET;
|
|
loop {
|
|
if self.state != 0 {
|
|
return true;
|
|
}
|
|
if budget == 0 {
|
|
t.wake = self.time; // go on at the next check
|
|
return false;
|
|
}
|
|
budget -= 1;
|
|
let Some(fr) = t.frames.last() else { return true };
|
|
let Some(list) = list_at(scripts, t.src, &fr.lp) else { return true };
|
|
let (idx, lp, left, loop_kind) = (fr.idx, fr.lp.clone(), fr.left, fr.loop_kind);
|
|
if idx >= list.len() {
|
|
// the end of a list: loop again, or back to the list around it
|
|
let again = match loop_kind {
|
|
1 => left > 0,
|
|
2 | 3 => {
|
|
// the loop's condition sits in its block in the list around it
|
|
let outer = list_at(scripts, t.src, &lp[..lp.len() - 2]).and_then(|l| l.get(lp[lp.len() - 2]));
|
|
match outer {
|
|
Some(Block::Until { cond, .. }) | Some(Block::DoUntil { cond, .. }) => !self.cond(s, cond, &t.ev),
|
|
_ => false,
|
|
}
|
|
}
|
|
_ => false,
|
|
};
|
|
if again {
|
|
let fr = t.frames.last_mut().unwrap();
|
|
fr.idx = 0;
|
|
if loop_kind == 1 {
|
|
fr.left -= 1;
|
|
}
|
|
continue;
|
|
}
|
|
t.frames.pop();
|
|
match t.frames.last_mut() {
|
|
Some(parent) => parent.idx += 1,
|
|
None => return true,
|
|
}
|
|
continue;
|
|
}
|
|
let inner = |branch: usize, left: i64, kind: u8| {
|
|
let mut p = lp.clone();
|
|
p.push(idx);
|
|
p.push(branch);
|
|
Frame { lp: p, idx: 0, left, loop_kind: kind }
|
|
};
|
|
match &list[idx] {
|
|
Block::If { cond, .. } => {
|
|
let c = self.cond(s, cond, &t.ev);
|
|
t.frames.push(inner(if c { 0 } else { 1 }, -1, 0));
|
|
}
|
|
Block::Repeat { times, .. } => {
|
|
let n = self.eval(s, times, &t.ev).round().clamp(0.0, 100.0) as i64;
|
|
if n > 0 {
|
|
t.frames.push(inner(0, n - 1, 1));
|
|
} else {
|
|
t.frames.last_mut().unwrap().idx += 1;
|
|
}
|
|
}
|
|
Block::Until { cond, .. } => {
|
|
// checked before each run: already true means the inside never runs
|
|
if self.cond(s, cond, &t.ev) {
|
|
t.frames.last_mut().unwrap().idx += 1;
|
|
} else {
|
|
t.frames.push(inner(0, -1, 2));
|
|
}
|
|
}
|
|
Block::DoUntil { .. } => t.frames.push(inner(0, -1, 3)),
|
|
Block::Wait { secs } => {
|
|
let n = self.eval(s, secs, &t.ev).clamp(0.1, 36000.0) as f32;
|
|
t.frames.last_mut().unwrap().idx += 1;
|
|
t.wake = self.time + n;
|
|
return false;
|
|
}
|
|
b => {
|
|
let b = b.clone();
|
|
t.frames.last_mut().unwrap().idx += 1;
|
|
self.exec(s, &b, &t.ev);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// a value
|
|
fn eval(&self, s: &mut ScriptState, v: &Val, ev: &EventInfo) -> f64 {
|
|
let r = match v {
|
|
Val::Num { n } => *n,
|
|
Val::Bool { b } => *b as i32 as f64,
|
|
Val::Var { name } => s.vars.get(name).copied().unwrap_or(0.0),
|
|
Val::Gold => self.gold as f64,
|
|
Val::Mana => self.mana as f64,
|
|
Val::Time => self.time as f64,
|
|
Val::Creatures { side, kind } => self.ents.iter().filter(|e| e.alive && e.captive == 0 && !e.chained && matches(e.side(), e.kind, *side, kind)).count() as f64,
|
|
Val::InZone { zone, side, kind } => match s.zone(zone) {
|
|
Some(z) => self.ents.iter().filter(|e| e.alive && !e.held && e.captive == 0 && matches(e.side(), e.kind, *side, kind) && zone_contains(z, e.x, e.y)).count() as f64,
|
|
None => 0.0,
|
|
},
|
|
Val::Random { a, b } => {
|
|
let (a, b) = (self.eval(s, a, ev), self.eval(s, b, ev));
|
|
let (lo, hi) = (a.min(b).round(), a.max(b).round());
|
|
lo + (xorshift(&mut s.rng) as f64 % (hi - lo + 1.0)).floor()
|
|
}
|
|
Val::Math { op, a, b } => {
|
|
let (a, b) = (self.eval(s, a, ev), self.eval(s, b, ev));
|
|
match op.as_str() {
|
|
"-" => a - b,
|
|
"*" => a * b,
|
|
"/" => {
|
|
if b == 0.0 {
|
|
0.0
|
|
} else {
|
|
a / b
|
|
}
|
|
}
|
|
_ => a + b,
|
|
}
|
|
}
|
|
Val::Cmp { op, a, b } => {
|
|
let (a, b) = (self.eval(s, a, ev), self.eval(s, b, ev));
|
|
cmp(op, a, b) as i32 as f64
|
|
}
|
|
Val::And { a, b } => (self.cond(s, a, ev) && self.cond(s, b, ev)) as i32 as f64,
|
|
Val::Or { a, b } => (self.cond(s, a, ev) || self.cond(s, b, ev)) as i32 as f64,
|
|
Val::Not { a } => (!self.cond(s, a, ev)) as i32 as f64,
|
|
Val::EventKind { kind } => (ev.kind.is_some() && ev.kind == kind_by_name(kind)) as i32 as f64,
|
|
};
|
|
if r.is_finite() {
|
|
r
|
|
} else {
|
|
0.0
|
|
}
|
|
}
|
|
|
|
fn cond(&self, s: &mut ScriptState, v: &Val, ev: &EventInfo) -> bool {
|
|
self.eval(s, v, ev) != 0.0
|
|
}
|
|
|
|
// one block that does something
|
|
fn exec(&mut self, s: &mut ScriptState, b: &Block, ev: &EventInfo) {
|
|
let act = match b {
|
|
Block::Message { text } => Act::Message(text.clone()),
|
|
Block::Objective { text } => Act::Objective(text.clone()),
|
|
Block::Hint { text } => Act::Hint(text.clone()),
|
|
Block::Spawn { side, kind, count, level, place, name } => {
|
|
let (pk, pz) = match place {
|
|
Place::Zone(z) => ("zone", z.clone()),
|
|
Place::Event => ("event", String::new()),
|
|
Place::Core => ("core", String::new()),
|
|
};
|
|
Act::Spawn { side: *side, kind: kind.clone(), count: self.eval(s, count, ev).round() as i64, level: self.eval(s, level, ev).round() as i64, place: pk.into(), zone: pz, name: name.clone() }
|
|
}
|
|
Block::Wave { count } => Act::Wave(self.eval(s, count, ev).round() as i64),
|
|
Block::Gold { amount } => Act::Gold(self.eval(s, amount, ev)),
|
|
Block::Mana { amount } => Act::Mana(self.eval(s, amount, ev)),
|
|
Block::SetVar { name, value } => {
|
|
let v = self.eval(s, value, ev);
|
|
s.vars.insert(name.clone(), v);
|
|
return;
|
|
}
|
|
Block::AddVar { name, value } => {
|
|
let v = s.vars.get(name).copied().unwrap_or(0.0) + self.eval(s, value, ev);
|
|
s.vars.insert(name.clone(), v);
|
|
return;
|
|
}
|
|
Block::Win => Act::Win,
|
|
Block::Lose => Act::Lose,
|
|
Block::Reveal { zone } => Act::Reveal(zone.clone()),
|
|
Block::Camera { zone } => Act::Camera(zone.clone()),
|
|
Block::Terrain { zone, kind } => Act::Terrain(zone.clone(), kind.clone()),
|
|
Block::Kill { zone, side } => Act::Kill(zone.clone(), *side),
|
|
Block::Trigger { name, on } => Act::Trigger(name.clone(), *on),
|
|
// the flow blocks are handled by run_thread
|
|
Block::If { .. } | Block::Repeat { .. } | Block::Until { .. } | Block::DoUntil { .. } | Block::Wait { .. } => return,
|
|
};
|
|
self.apply_act(s, act, ev);
|
|
}
|
|
|
|
// what the next check compares against: living units, towers, rooms, research, zone counts
|
|
fn remember(&self, s: &mut ScriptState, first: bool) {
|
|
s.alive = self.ents.iter().filter(|e| e.alive && e.captive == 0).map(|e| Seen { id: e.id, kind: e.kind, side: e.side(), x: e.x, y: e.y }).collect();
|
|
s.towers = self.towers.iter().map(|t| (t.id, t.hero)).collect();
|
|
s.rooms = self.counts.to_vec();
|
|
s.known = self.known.iter().filter(|&&k| k).count();
|
|
if first {
|
|
for k in 0..s.scripts.triggers.len() {
|
|
let v = self.measure(s, k);
|
|
s.last[k] = v;
|
|
if let TriggerKind::Timer { secs, .. } = s.scripts.triggers[k].kind {
|
|
s.last[k] = secs.max(0.1);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// the value a trigger watches (claimed tiles, solid tiles, units in a zone, gold, ...);
|
|
// conditions as 1 / 0
|
|
fn measure(&self, s: &ScriptState, k: usize) -> f64 {
|
|
let player = Owner::of_side(0);
|
|
match &s.scripts.triggers[k].kind {
|
|
TriggerKind::ZoneClaimed { zone } => s.zone(zone).map_or(0.0, |z| zone_tiles(z).iter().filter(|&&i| self.map.t[i].owner == player).count() as f64),
|
|
TriggerKind::ZoneDug { zone } => s.zone(zone).map_or(0.0, |z| zone_tiles(z).iter().filter(|&&i| self.map.t[i].solid()).count() as f64),
|
|
TriggerKind::ZoneCount { zone, side, kind, op, n } => {
|
|
let c = s.zone(zone).map_or(0, |z| self.ents.iter().filter(|e| e.alive && !e.held && e.captive == 0 && matches(e.side(), e.kind, *side, kind) && zone_contains(z, e.x, e.y)).count());
|
|
cmp(op, c as f64, *n) as i32 as f64
|
|
}
|
|
TriggerKind::Value { what, var, op, n } => {
|
|
let v = match what.as_str() {
|
|
"mana" => self.mana as f64,
|
|
"creatures" => self.ents.iter().filter(|e| e.alive && e.creature()).count() as f64,
|
|
"heroes" => self.ents.iter().filter(|e| e.alive && e.active_hero()).count() as f64,
|
|
"var" => s.vars.get(var).copied().unwrap_or(0.0),
|
|
_ => self.gold as f64,
|
|
};
|
|
cmp(op, v, *n) as i32 as f64
|
|
}
|
|
_ => 0.0,
|
|
}
|
|
}
|
|
|
|
fn check_triggers(&mut self, s: &mut ScriptState, fire: &mut Vec<(String, EventInfo, Option<usize>)>) {
|
|
let info = |e: &Ent| EventInfo { kind: Some(e.kind), pos: Some((e.x, e.y)) };
|
|
let seen_info = |e: &Seen| EventInfo { kind: Some(e.kind), pos: Some((e.x, e.y)) };
|
|
// what changed since the last check
|
|
let died: Vec<Seen> = s.alive.iter().filter(|o| !self.ents.iter().any(|e| e.id == o.id && e.alive && e.captive == 0)).cloned().collect();
|
|
let fallen: Vec<bool> = s.towers.iter().filter(|(id, _)| !self.towers.iter().any(|t| t.id == *id)).map(|&(_, hero)| hero).collect();
|
|
let rooms_grown: Vec<usize> = (0..self.counts.len()).filter(|&r| self.counts[r] > s.rooms.get(r).copied().unwrap_or(0)).collect();
|
|
let researched = self.known.iter().filter(|&&k| k).count() > s.known;
|
|
for k in 0..s.scripts.triggers.len() {
|
|
let fn_name = format!("t_{}", k);
|
|
let mut go = |ev: EventInfo| fire.push((fn_name.clone(), ev, Some(k)));
|
|
let kind = s.scripts.triggers[k].kind.clone();
|
|
match &kind {
|
|
TriggerKind::ZoneEnter { zone, side, kind } | TriggerKind::ZoneLeave { zone, side, kind } => {
|
|
let Some(z) = s.zone(zone).cloned() else { continue };
|
|
let now: Vec<u32> = self.ents.iter().filter(|e| e.alive && !e.held && e.captive == 0 && matches(e.side(), e.kind, *side, kind) && zone_contains(&z, e.x, e.y)).map(|e| e.id).collect();
|
|
let enter = matches!(s.scripts.triggers[k].kind, TriggerKind::ZoneEnter { .. });
|
|
if enter {
|
|
for e in self.ents.iter().filter(|e| now.contains(&e.id) && !s.inside[k].contains(&e.id)) {
|
|
go(info(e));
|
|
}
|
|
} else {
|
|
for id in s.inside[k].iter().filter(|id| !now.contains(id)) {
|
|
match self.ents.iter().find(|e| e.id == *id) {
|
|
Some(e) => go(info(e)),
|
|
None => go(EventInfo::default()),
|
|
}
|
|
}
|
|
}
|
|
s.inside[k] = now;
|
|
}
|
|
TriggerKind::LineCross { zone, side, kind } => {
|
|
let Some(z) = s.zone(zone).cloned() else { continue };
|
|
let mut now = Vec::new();
|
|
for e in self.ents.iter().filter(|e| e.alive && !e.held && e.captive == 0 && matches(e.side(), e.kind, *side, kind)) {
|
|
let sd = line_side(&z, e.x, e.y);
|
|
if sd == 0 {
|
|
continue;
|
|
}
|
|
if let Some(&(_, before)) = s.sides[k].iter().find(|(id, _)| *id == e.id) {
|
|
if before != sd {
|
|
go(info(e));
|
|
}
|
|
}
|
|
now.push((e.id, sd));
|
|
}
|
|
s.sides[k] = now;
|
|
}
|
|
TriggerKind::ZoneClaimed { .. } | TriggerKind::ZoneDug { .. } => {
|
|
let v = self.measure(s, k);
|
|
let more = matches!(kind, TriggerKind::ZoneClaimed { .. });
|
|
if (more && v > s.last[k]) || (!more && v < s.last[k]) {
|
|
go(EventInfo::default());
|
|
}
|
|
s.last[k] = v;
|
|
}
|
|
TriggerKind::ZoneCount { .. } | TriggerKind::Value { .. } => {
|
|
// fires when the condition becomes true
|
|
let v = self.measure(s, k);
|
|
if v > 0.5 && !(s.last[k] > 0.5) {
|
|
go(EventInfo::default());
|
|
}
|
|
s.last[k] = v;
|
|
}
|
|
TriggerKind::Timer { secs, every } => {
|
|
if self.time as f64 >= s.last[k] {
|
|
go(EventInfo::default());
|
|
s.last[k] = if *every { s.last[k] + secs.max(0.1) } else { f64::INFINITY };
|
|
}
|
|
}
|
|
TriggerKind::UnitDies { side, kind, name } => {
|
|
for d in died.iter() {
|
|
let named = name.is_empty() || s.names.iter().any(|(n, id)| n == name && *id == d.id);
|
|
if named && matches(d.side, d.kind, *side, kind) {
|
|
go(seen_info(d));
|
|
}
|
|
}
|
|
}
|
|
TriggerKind::TowerFalls { side } => {
|
|
for &hero in fallen.iter() {
|
|
if *side < 0 || (*side == 1) == hero {
|
|
go(EventInfo::default());
|
|
}
|
|
}
|
|
}
|
|
TriggerKind::RoomBuilt { room } => {
|
|
for &r in rooms_grown.iter() {
|
|
if room.is_empty() || format!("{:?}", ROOM_KINDS[r]) == *room {
|
|
go(EventInfo::default());
|
|
}
|
|
}
|
|
}
|
|
TriggerKind::SpellCast { spell } => {
|
|
for e in s.events.iter() {
|
|
if let ScriptEvent::Spell(n) = e {
|
|
if spell.is_empty() || n == spell {
|
|
go(EventInfo::default());
|
|
}
|
|
}
|
|
}
|
|
}
|
|
TriggerKind::Slap => {
|
|
for e in s.events.iter() {
|
|
if *e == ScriptEvent::Slap {
|
|
go(EventInfo::default());
|
|
}
|
|
}
|
|
}
|
|
TriggerKind::Research => {
|
|
if researched {
|
|
go(EventInfo::default());
|
|
}
|
|
}
|
|
}
|
|
}
|
|
self.remember(s, false);
|
|
}
|
|
|
|
fn apply_act(&mut self, s: &mut ScriptState, a: Act, ev: &EventInfo) {
|
|
let fill = |text: &str, vars: &BTreeMap<String, f64>| -> String {
|
|
// {name} in a text shows the variable's value
|
|
let mut out = text.to_string();
|
|
for (k, v) in vars {
|
|
let shown = if v.fract() == 0.0 { format!("{}", *v as i64) } else { format!("{:.1}", v) };
|
|
out = out.replace(&format!("{{{}}}", k), &shown);
|
|
}
|
|
out
|
|
};
|
|
match a {
|
|
Act::Message(t) => {
|
|
let t = fill(&t, &s.vars);
|
|
self.script_msg("msg.script", vec![t]);
|
|
}
|
|
Act::Objective(t) => {
|
|
let t = fill(&t, &s.vars);
|
|
self.script_msg("msg.script_objective", vec![t.clone()]);
|
|
self.objective = Some(t);
|
|
}
|
|
Act::Hint(t) => {
|
|
let t = fill(&t, &s.vars);
|
|
self.script_msg("msg.script_hint", vec![t]);
|
|
self.sfx_ui(Sfx::Magic);
|
|
}
|
|
Act::Spawn { side, kind, count, level, place, zone, name } => {
|
|
let Some(k) = kind_by_name(&kind) else { return };
|
|
let count = count.clamp(1, 20) as usize;
|
|
let at: Vec<(f32, f32)> = match place.as_str() {
|
|
"zone" => match s.zone(&zone) {
|
|
Some(z) => {
|
|
let tiles: Vec<usize> = zone_tiles(z).into_iter().filter(|&i| self.map.t[i].walkable() || (flies(k) && self.map.t[i].liquid())).collect();
|
|
(0..count).filter_map(|n| tiles.get((n * 7919 + s.rng as usize) % tiles.len().max(1)).map(|&i| (xy(i).0 as f32 + 0.5, xy(i).1 as f32 + 0.5))).collect()
|
|
}
|
|
None => Vec::new(),
|
|
},
|
|
"event" => ev.pos.map(|p| vec![p; count]).unwrap_or_default(),
|
|
_ => {
|
|
let (cx, cy) = self.map.core;
|
|
(0..count).map(|n| {
|
|
let a = n as f32 * 1.1;
|
|
(cx as f32 + 1.5 + a.cos() * 3.0, cy as f32 + 1.5 + a.sin() * 3.0)
|
|
}).collect()
|
|
}
|
|
};
|
|
for (n, (x, y)) in at.into_iter().enumerate() {
|
|
if !inb(x as i32, y as i32) {
|
|
continue;
|
|
}
|
|
let id = self.spawn_for_side(side, k, x + (n % 3) as f32 * 0.15, y, level.clamp(1, 10) as u32);
|
|
if let (Some(id), false) = (id, name.is_empty()) {
|
|
s.names.push((name.clone(), id));
|
|
}
|
|
}
|
|
}
|
|
Act::Wave(n) => {
|
|
let n = n.clamp(1, 20) as u32;
|
|
if let Some(m) = self.mission.as_mut() {
|
|
m.waves = true;
|
|
if m.wave_limit != 0 {
|
|
m.wave_limit = m.wave_limit.max(self.wave.saturating_add(n));
|
|
}
|
|
}
|
|
self.wave_timer = 0.0;
|
|
}
|
|
Act::Gold(n) => {
|
|
if n >= 0.0 {
|
|
self.add_gold(n as f32);
|
|
} else {
|
|
self.gold = (self.gold + n as f32).max(0.0);
|
|
}
|
|
}
|
|
Act::Mana(n) => self.mana = (self.mana + n as f32).clamp(0.0, self.mana_max()),
|
|
Act::Win => {
|
|
self.state = 1;
|
|
self.script_msg("msg.mission_won", vec![]);
|
|
self.sfx_ui(Sfx::Victory);
|
|
s.log(self.time, "win".into());
|
|
}
|
|
Act::Lose => {
|
|
self.state = 2;
|
|
self.script_msg("msg.defeat", vec![]);
|
|
self.sfx_ui(Sfx::Defeat);
|
|
s.log(self.time, "lose".into());
|
|
}
|
|
Act::Reveal(z) => {
|
|
if let Some(z) = s.zone(&z) {
|
|
for i in zone_tiles(z) {
|
|
if let Some(e) = self.map.explored.get_mut(i) {
|
|
*e = true;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
Act::Camera(z) => s.camera = s.zone(&z).map(zone_center),
|
|
Act::Terrain(z, kind) => {
|
|
let Some(z) = s.zone(&z).cloned() else { return };
|
|
let k = match kind.as_str() {
|
|
"earth" => Kind::Earth,
|
|
"rock" => Kind::Rock,
|
|
"water" => Kind::Water,
|
|
"lava" => Kind::Lava,
|
|
"gold" => Kind::Gold,
|
|
_ => Kind::Floor,
|
|
};
|
|
for i in zone_tiles(&z) {
|
|
let t = self.map.t[i];
|
|
// the heart, portals, towers and the gate stay
|
|
if matches!(t.kind, Kind::Core | Kind::Portal | Kind::Tower | Kind::Gate) {
|
|
continue;
|
|
}
|
|
if k == Kind::Floor && t.solid() {
|
|
self.dig_out(i);
|
|
} else if k != Kind::Floor {
|
|
self.torches.retain(|tr| tr.wall != i);
|
|
let t = &mut self.map.t[i];
|
|
t.kind = k;
|
|
t.owner = Owner::None;
|
|
t.reinforced = false;
|
|
t.marked = false;
|
|
if k == Kind::Gold {
|
|
t.gold = 800;
|
|
}
|
|
if t.liquid() {
|
|
self.ground_lost(i);
|
|
}
|
|
}
|
|
}
|
|
self.recount();
|
|
}
|
|
Act::Kill(z, side) => {
|
|
let Some(z) = s.zone(&z).cloned() else { return };
|
|
for j in 0..self.ents.len() {
|
|
let e = &self.ents[j];
|
|
if e.alive && !e.held && (side < 0 || e.side() as i32 == side) && zone_contains(&z, e.x, e.y) {
|
|
self.ents[j].alive = false;
|
|
let (x, y) = (self.ents[j].x, self.ents[j].y);
|
|
self.fx(3, x, y, 0.0, 0.0, 0.7, 0xff505070);
|
|
}
|
|
}
|
|
}
|
|
Act::Trigger(name, on) => {
|
|
if let Some(k) = s.scripts.triggers.iter().position(|t| t.name == name) {
|
|
s.enabled[k] = on;
|
|
if on {
|
|
s.done[k] = false; // switched on again: it may fire once more
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// spawns a unit for a side (0 player, 1 heroes, 2-4 rival masters); None when not possible
|
|
pub fn spawn_for_side(&mut self, side: i32, kind: EK, x: f32, y: f32, level: u32) -> Option<u32> {
|
|
let i = idx(x as i32, y as i32);
|
|
if !inb(x as i32, y as i32) || !(self.map.t[i].walkable() || (flies(kind) && self.map.t[i].liquid())) {
|
|
return None;
|
|
}
|
|
let id = self.spawn(kind, side == 1, x, y, level.max(1));
|
|
if (2..=4).contains(&side) {
|
|
if let Some(e) = self.ents.iter_mut().find(|e| e.id == id) {
|
|
e.side = side as u8;
|
|
}
|
|
}
|
|
if let Some(e) = self.ents.iter_mut().find(|e| e.id == id) {
|
|
e.happy = 1.0;
|
|
e.settled = true;
|
|
}
|
|
Some(id)
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
use crate::blocks::{Block, Place, Val};
|
|
use crate::campaign_doc::TriggerDoc;
|
|
|
|
fn rect(name: &str, a: (f32, f32), b: (f32, f32)) -> ZoneDoc {
|
|
ZoneDoc { name: name.into(), shape: "rect".into(), at: None, radius: None, tiles: None, from: Some(a), to: Some(b), width: None }
|
|
}
|
|
|
|
fn trig(name: &str, kind: TriggerKind, script: Vec<Block>) -> TriggerDoc {
|
|
TriggerDoc { name: name.into(), kind, once: true, enabled: true, script }
|
|
}
|
|
|
|
fn run(g: &mut Game, secs: f32) {
|
|
for _ in 0..(secs * 20.0) as usize {
|
|
g.update(0.05);
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn start_script_timers_and_variables_run() {
|
|
let mut g = Game::new(3, 64, 64);
|
|
let scripts = MissionScripts {
|
|
start: vec![Block::Message { text: "Welcome".into() }, Block::SetVar { name: "n".into(), value: Val::num(2.0) }],
|
|
triggers: vec![TriggerDoc { once: false, ..trig("every", TriggerKind::Timer { secs: 1.0, every: true }, vec![Block::AddVar { name: "n".into(), value: Val::num(1.0) }]) }],
|
|
..Default::default()
|
|
};
|
|
g.script = Some(ScriptState::new(scripts, vec![], 7));
|
|
run(&mut g, 0.3);
|
|
assert!(g.msgs.iter().any(|m| m.key == "msg.script" && m.args[0] == "Welcome"));
|
|
run(&mut g, 3.2);
|
|
let n = g.script.as_ref().unwrap().vars["n"];
|
|
assert!(n >= 4.0 && n <= 6.0, "the timer fires every second: {}", n);
|
|
assert!(g.script.as_ref().unwrap().error.is_none(), "{:?}", g.script.as_ref().unwrap().error);
|
|
}
|
|
|
|
#[test]
|
|
fn a_zone_trigger_spawns_and_the_victory_script_wins() {
|
|
let mut g = Game::new(3, 64, 64);
|
|
let (cx, cy) = g.map.core;
|
|
let zone = rect("hall", (cx as f32 + 2.0, cy as f32 - 1.0), (cx as f32 + 4.0, cy as f32 + 1.0));
|
|
let scripts = MissionScripts {
|
|
victory: vec![Block::If {
|
|
cond: Val::Cmp { op: ">=".into(), a: Box::new(Val::Var { name: "seen".into() }), b: Box::new(Val::num(1.0)) },
|
|
then: vec![Block::Win],
|
|
els: vec![],
|
|
}],
|
|
triggers: vec![trig(
|
|
"enter",
|
|
TriggerKind::ZoneEnter { zone: "hall".into(), side: 1, kind: String::new() },
|
|
vec![Block::SetVar { name: "seen".into(), value: Val::num(1.0) }, Block::Spawn { side: 0, kind: "Ogre".into(), count: Val::num(1.0), level: Val::num(3.0), place: Place::Event, name: "guard".into() }],
|
|
)],
|
|
..Default::default()
|
|
};
|
|
g.script = Some(ScriptState::new(scripts, vec![zone], 7));
|
|
run(&mut g, 0.5);
|
|
let ogres = g.ents.iter().filter(|e| e.alive && e.kind == EK::Ogre).count();
|
|
// a knight walks in
|
|
g.spawn(EK::Knight, true, cx as f32 + 3.5, cy as f32 + 0.5, 1);
|
|
run(&mut g, 0.3);
|
|
assert_eq!(g.ents.iter().filter(|e| e.alive && e.kind == EK::Ogre).count(), ogres + 1, "the trigger spawned an ogre where the knight is");
|
|
assert!(g.script.as_ref().unwrap().log.iter().any(|(_, n)| n == "enter"));
|
|
run(&mut g, 1.5);
|
|
assert_eq!(g.state, 1, "the victory script won");
|
|
}
|
|
|
|
#[test]
|
|
fn deaths_rooms_and_values_fire_their_triggers() {
|
|
let mut g = Game::new(3, 64, 64);
|
|
let (cx, cy) = g.map.core;
|
|
let k = g.spawn(EK::Knight, true, cx as f32 + 3.5, cy as f32 + 0.5, 1);
|
|
let scripts = MissionScripts {
|
|
triggers: vec![
|
|
trig("dies", TriggerKind::UnitDies { side: 1, kind: "Knight".into(), name: String::new() }, vec![Block::SetVar { name: "dead".into(), value: Val::num(1.0) }]),
|
|
trig("rich", TriggerKind::Value { what: "gold".into(), var: String::new(), op: ">=".into(), n: 99999.0 }, vec![Block::SetVar { name: "rich".into(), value: Val::num(1.0) }]),
|
|
],
|
|
..Default::default()
|
|
};
|
|
g.script = Some(ScriptState::new(scripts, vec![], 7));
|
|
run(&mut g, 0.3);
|
|
let j = g.ents.iter().position(|e| e.id == k).unwrap();
|
|
g.ents[j].alive = false;
|
|
g.gold = 100000.0;
|
|
run(&mut g, 0.3);
|
|
let v = &g.script.as_ref().unwrap().vars;
|
|
assert_eq!(v.get("dead"), Some(&1.0));
|
|
assert_eq!(v.get("rich"), Some(&1.0));
|
|
}
|
|
|
|
#[test]
|
|
fn waits_and_both_kinds_of_until_loops() {
|
|
let mut g = Game::new(3, 64, 64);
|
|
let cmp = |op: &str, a: Val, b: f64| Val::Cmp { op: op.into(), a: Box::new(a), b: Box::new(Val::num(b)) };
|
|
let var = |n: &str| Val::Var { name: n.into() };
|
|
let scripts = MissionScripts {
|
|
start: vec![
|
|
Block::SetVar { name: "a".into(), value: Val::num(0.0) },
|
|
Block::SetVar { name: "pre".into(), value: Val::num(0.0) },
|
|
Block::SetVar { name: "post".into(), value: Val::num(0.0) },
|
|
// repeat until (checked before): already true, so the inside never runs
|
|
Block::Until { cond: cmp(">=", var("a"), 0.0), body: vec![Block::AddVar { name: "pre".into(), value: Val::num(1.0) }] },
|
|
// repeat ... until (checked after): runs once even though it is already true
|
|
Block::DoUntil { cond: cmp(">=", var("a"), 0.0), body: vec![Block::AddVar { name: "post".into(), value: Val::num(1.0) }] },
|
|
// count to three, one step every second
|
|
Block::Until {
|
|
cond: cmp(">=", var("a"), 3.0),
|
|
body: vec![Block::Wait { secs: Val::num(1.0) }, Block::AddVar { name: "a".into(), value: Val::num(1.0) }],
|
|
},
|
|
Block::SetVar { name: "done".into(), value: Val::num(1.0) },
|
|
],
|
|
..Default::default()
|
|
};
|
|
g.script = Some(ScriptState::new(scripts, vec![], 7));
|
|
run(&mut g, 0.3);
|
|
let v = |g: &Game, k: &str| g.script.as_ref().unwrap().vars.get(k).copied();
|
|
assert_eq!(v(&g, "pre"), Some(0.0), "repeat until checks first");
|
|
assert_eq!(v(&g, "post"), Some(1.0), "repeat ... until runs once");
|
|
assert_eq!(v(&g, "a"), Some(0.0), "waiting");
|
|
assert_eq!(v(&g, "done"), None);
|
|
run(&mut g, 1.5);
|
|
assert_eq!(v(&g, "a"), Some(1.0), "one step after a second");
|
|
run(&mut g, 2.2);
|
|
assert_eq!(v(&g, "a"), Some(3.0));
|
|
assert_eq!(v(&g, "done"), Some(1.0), "the script went on after the loop");
|
|
// a waiting script survives saving and loading
|
|
let mut g2 = Game::new(3, 64, 64);
|
|
g2.script = Some(ScriptState::new(
|
|
MissionScripts { start: vec![Block::Wait { secs: Val::num(2.0) }, Block::SetVar { name: "late".into(), value: Val::num(1.0) }], ..Default::default() },
|
|
vec![],
|
|
1,
|
|
));
|
|
run(&mut g2, 0.5);
|
|
let json = serde_json::to_string(&g2).unwrap();
|
|
let mut g3: Game = serde_json::from_str(&json).unwrap();
|
|
g3.map.activate();
|
|
run(&mut g3, 2.0);
|
|
assert_eq!(g3.script.as_ref().unwrap().vars.get("late"), Some(&1.0));
|
|
}
|
|
|
|
#[test]
|
|
fn an_endless_loop_does_not_hang_the_game() {
|
|
let mut g = Game::new(3, 64, 64);
|
|
let scripts = MissionScripts {
|
|
start: vec![Block::Until { cond: Val::Bool { b: false }, body: vec![Block::AddVar { name: "n".into(), value: Val::num(1.0) }] }],
|
|
..Default::default()
|
|
};
|
|
g.script = Some(ScriptState::new(scripts, vec![], 7));
|
|
run(&mut g, 1.0);
|
|
let n = g.script.as_ref().unwrap().vars["n"];
|
|
assert!(n > 100.0, "the loop runs: {}", n);
|
|
assert!(g.time > 0.9, "and the game goes on");
|
|
}
|
|
|
|
#[test]
|
|
fn zone_shapes() {
|
|
let r = rect("r", (2.0, 2.0), (4.0, 3.0));
|
|
assert_eq!(zone_tiles(&r).len(), 6);
|
|
assert!(zone_contains(&r, 4.9, 3.9) && !zone_contains(&r, 5.1, 3.0));
|
|
let line = ZoneDoc { name: "l".into(), shape: "line".into(), at: None, radius: None, tiles: None, from: Some((0.0, 5.0)), to: Some((10.0, 5.0)), width: None };
|
|
assert_eq!(line_side(&line, 3.0, 4.0), -line_side(&line, 3.0, 7.0));
|
|
assert_eq!(line_side(&line, 30.0, 4.0), 0, "beyond the end of the line");
|
|
}
|
|
}
|