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:
1 parent
5315709776
commit
5701124c4f
13 files changed
+1835
-483
No files matched your search
+920
@@ -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");
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user