Block scripts and triggers: data model, Rhai translation and runtime

Editor missions keep a painted map or roll a random map per campaign run,
leave out goals they cannot have, and run their triggers in the fixed step.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Richard ZinkandClaude Opus 5.5 committed 2026-11-05 16:51:43 +01:00
1 parent 5315709776
commit 5701124c4f
13 files changed
+1835 -483

No files matched your search

+920
View File
@@ -0,0 +1,920 @@
// Mission scripts at run time: the block scripts of a mission (campaign_doc::MissionScripts)
// become one Rhai program; the triggers are checked ten times a second inside the fixed
// simulation step, so a co-op match runs them identically on every computer.
//
// The program never touches the game directly: reading functions (m.gold(), m.in_zone(..))
// look at the game through a pointer that is valid while a script runs, and everything a
// script does is collected as actions that are applied afterwards.
use std::cell::RefCell;
use std::collections::BTreeMap;
use rhai::{Engine, Scope, AST};
use serde::{Deserialize, Serialize};
use crate::blocks::function_code;
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;
// 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(skip)]
pub camera: Option<(f32, f32)>, // a block asked the camera to look here (taken by main)
}
// 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)]
struct EventInfo {
kind: Option<EK>,
pos: Option<(f32, f32)>,
}
struct Ctx {
game: *const Game,
vars: BTreeMap<String, f64>,
acts: Vec<Act>,
event: EventInfo,
rng: u32,
}
thread_local! {
static CTX: RefCell<Ctx> = RefCell::new(Ctx { game: std::ptr::null(), vars: BTreeMap::new(), acts: Vec::new(), event: EventInfo::default(), rng: 1 });
// the compiled program of the running mission (key: the program text)
static PROGRAM: RefCell<Option<(String, Engine, AST)>> = RefCell::new(None);
}
// reads the game while a script runs (the pointer is set by run_fn around every call)
fn with_game<R>(f: impl FnOnce(&Game) -> R, fallback: R) -> R {
CTX.with(|c| {
let p = c.borrow().game;
if p.is_null() {
fallback
} else {
// SAFETY: run_fn sets the pointer from a live &Game for the duration of the call
// and clears it afterwards; scripts run on this thread only
f(unsafe { &*p })
}
})
}
fn act(a: Act) {
CTX.with(|c| c.borrow_mut().acts.push(a));
}
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 program
// the Rhai program of a mission: one function per script and trigger
pub fn program(s: &MissionScripts) -> String {
let mut code = String::new();
code.push_str(&function_code("s_start", &s.start));
code.push_str(&function_code("s_victory", &s.victory));
code.push_str(&function_code("s_defeat", &s.defeat));
code.push_str(&function_code("s_tick", &s.tick));
for (k, t) in s.triggers.iter().enumerate() {
code.push_str(&function_code(&format!("t_{}", k), &t.script));
}
code
}
#[derive(Clone)]
struct M;
fn engine() -> Engine {
let mut e = Engine::new();
e.set_max_operations(100_000);
e.set_max_call_levels(16);
e.register_type_with_name::<M>("mission");
// reading
e.register_fn("gold", |_: &mut M| with_game(|g| g.gold as f64, 0.0));
e.register_fn("mana", |_: &mut M| with_game(|g| g.mana as f64, 0.0));
e.register_fn("time", |_: &mut M| with_game(|g| g.time as f64, 0.0));
e.register_fn("get", |_: &mut M, name: &str| CTX.with(|c| c.borrow().vars.get(name).copied().unwrap_or(0.0)));
e.register_fn("set", |_: &mut M, name: &str, v: f64| {
CTX.with(|c| {
c.borrow_mut().vars.insert(name.to_string(), if v.is_finite() { v } else { 0.0 });
})
});
e.register_fn("count", |_: &mut M, side: i64, kind: &str| {
with_game(|g| g.ents.iter().filter(|e| e.alive && e.captive == 0 && !e.chained && matches(e.side(), e.kind, side as i32, kind)).count() as f64, 0.0)
});
e.register_fn("in_zone", |_: &mut M, zone: &str, side: i64, kind: &str| {
with_game(
|g| {
let Some(z) = g.script.as_ref().and_then(|s| s.zones.iter().find(|z| z.name == zone)) else { return 0.0 };
g.ents.iter().filter(|e| e.alive && !e.held && e.captive == 0 && matches(e.side(), e.kind, side as i32, kind) && zone_contains(z, e.x, e.y)).count() as f64
},
0.0,
)
});
e.register_fn("random", |_: &mut M, a: f64, b: f64| {
CTX.with(|c| {
let mut c = c.borrow_mut();
// xorshift: deterministic, part of the saved script state
let mut x = c.rng.max(1);
x ^= x << 13;
x ^= x >> 17;
x ^= x << 5;
c.rng = x;
let (lo, hi) = (a.min(b).round(), a.max(b).round());
lo + (x as f64 % (hi - lo + 1.0)).floor()
})
});
e.register_fn("div", |_: &mut M, a: f64, b: f64| if b == 0.0 { 0.0 } else { a / b });
e.register_fn("times", |_: &mut M, n: f64| if n.is_finite() { (n.round() as i64).clamp(0, 100) } else { 0 });
e.register_fn("event_is", |_: &mut M, kind: &str| CTX.with(|c| c.borrow().event.kind.is_some() && c.borrow().event.kind == kind_by_name(kind)));
// doing
e.register_fn("message", |_: &mut M, s: &str| act(Act::Message(s.to_string())));
e.register_fn("objective", |_: &mut M, s: &str| act(Act::Objective(s.to_string())));
e.register_fn("hint", |_: &mut M, s: &str| act(Act::Hint(s.to_string())));
e.register_fn("spawn_unit", |_: &mut M, side: i64, kind: &str, count: f64, level: f64, place: &str, zone: &str, name: &str| {
act(Act::Spawn { side: side as i32, kind: kind.to_string(), count: count.round() as i64, level: level.round() as i64, place: place.to_string(), zone: zone.to_string(), name: name.to_string() })
});
e.register_fn("wave", |_: &mut M, n: f64| act(Act::Wave(n.round() as i64)));
e.register_fn("add_gold", |_: &mut M, n: f64| act(Act::Gold(n)));
e.register_fn("add_mana", |_: &mut M, n: f64| act(Act::Mana(n)));
e.register_fn("win", |_: &mut M| act(Act::Win));
e.register_fn("lose", |_: &mut M| act(Act::Lose));
e.register_fn("reveal", |_: &mut M, z: &str| act(Act::Reveal(z.to_string())));
e.register_fn("camera", |_: &mut M, z: &str| act(Act::Camera(z.to_string())));
e.register_fn("terrain", |_: &mut M, z: &str, k: &str| act(Act::Terrain(z.to_string(), k.to_string())));
e.register_fn("kill", |_: &mut M, z: &str, side: i64| act(Act::Kill(z.to_string(), side as i32)));
e.register_fn("trigger", |_: &mut M, name: &str, on: bool| act(Act::Trigger(name.to_string(), on)));
e
}
// compiles a program (for the editor's check and for running); Err: the error text
pub fn compile(code: &str) -> Result<(), String> {
engine().compile(code).map(|_| ()).map_err(|e| e.to_string())
}
// runs one function of the mission's program; returns the actions or an error
fn run_fn(g: &Game, code: &str, name: &str, vars: &mut BTreeMap<String, f64>, rng: &mut u32, event: EventInfo) -> Result<Vec<Act>, String> {
PROGRAM.with(|p| {
let mut p = p.borrow_mut();
if p.as_ref().map_or(true, |(c, _, _)| c != code) {
let e = engine();
let ast = e.compile(code).map_err(|e| e.to_string())?;
*p = Some((code.to_string(), e, ast));
}
let (_, e, ast) = p.as_ref().unwrap();
CTX.with(|c| {
let mut c = c.borrow_mut();
c.game = g as *const Game;
c.vars = std::mem::take(vars);
c.acts.clear();
c.event = event;
c.rng = *rng;
});
let mut scope = Scope::new();
let r = e.call_fn::<rhai::Dynamic>(&mut scope, ast, name, (M,));
CTX.with(|c| {
let mut c = c.borrow_mut();
c.game = std::ptr::null();
*vars = std::mem::take(&mut c.vars);
*rng = c.rng;
match r {
Ok(_) => Ok(std::mem::take(&mut c.acts)),
Err(err) => Err(err.to_string()),
}
})
})
}
// ------------------------------------------------------------------ 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 code = program(&s.scripts);
let mut fire: Vec<(String, EventInfo, Option<usize>)> = Vec::new(); // function, event, trigger
if !s.started {
s.started = true;
s.next_second = self.time + 1.0;
self.remember(&mut s, true);
fire.push(("s_start".into(), EventInfo::default(), None));
} else {
self.check_triggers(&mut s, &mut fire);
if self.time >= s.next_second {
s.next_second += 1.0;
for f in ["s_tick", "s_victory", "s_defeat"] {
fire.push((f.into(), EventInfo::default(), None));
}
}
}
for (f, ev, trig) in fire {
if let Some(k) = trig {
// 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);
}
let (mut vars, mut rng) = (std::mem::take(&mut s.vars), s.rng);
let r = run_fn(self, &code, &f, &mut vars, &mut rng, ev.clone());
s.vars = vars;
s.rng = rng;
match r {
Ok(acts) => {
for a in acts {
self.apply_act(&mut s, a, &ev);
}
}
Err(e) => {
if s.error.is_none() {
self.script_msg("msg.script_error", vec![e.chars().take(120).collect()]);
}
s.log(self.time, format!("! {}", e.chars().take(80).collect::<String>()));
s.error = Some(e);
}
}
if self.state != 0 {
break;
}
}
s.events.clear();
self.script = Some(s);
}
// 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 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");
}
}