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

+533
View File
@@ -0,0 +1,533 @@
// Mission scripts as blocks: the block editor builds these trees, they are stored in the
// campaign file and translated into Rhai (internally only; the player never sees code).
//
// A script is a list of statement blocks. Values (numbers, yes/no conditions) are small
// expression trees. Every block has a fixed shape, so the editor can draw and check it.
use serde::{Deserialize, Serialize};
// side numbers in blocks: -1 any, 0 player, 1 heroes, 2 Mossgrim, 3 Lady Nightwell, 4 rival 3
pub const SIDES: [i32; 6] = [-1, 0, 1, 2, 3, 4];
// creature and hero kinds a block may name ("" = any)
pub const KINDS: [&str; 22] = [
"Worker", "Kobold", "Spider", "Mage", "Ogre", "Jailer", "Priestess", "Chainbreaker", "Bat", "Gargoyle", "Owl", "Wyrm", "Cogling", "Revenant", "Knight", "Archer", "Priest", "Dwarf", "Paladin", "Giant", "Fairy",
"BattleMage",
];
// ground a Terrain block can make
pub const TERRAIN: [&str; 6] = ["floor", "earth", "rock", "water", "lava", "gold"];
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
#[serde(tag = "b")]
pub enum Block {
Message { text: String },
Objective { text: String },
Hint { text: String },
Spawn { side: i32, kind: String, count: Val, level: Val, place: Place, #[serde(default)] name: String },
Wave { count: Val },
Gold { amount: Val },
Mana { amount: Val },
SetVar { name: String, value: Val },
AddVar { name: String, value: Val },
If { cond: Val, then: Vec<Block>, #[serde(default)] els: Vec<Block> },
Repeat { times: Val, body: Vec<Block> },
Win,
Lose,
Reveal { zone: String },
Camera { zone: String },
Terrain { zone: String, kind: String },
Kill { zone: String, side: i32 },
Trigger { name: String, on: bool },
}
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
#[serde(tag = "p", content = "zone")]
pub enum Place {
Zone(String),
Event, // where the unit that set off the trigger is
Core, // next to the player's soul core
}
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
#[serde(tag = "v")]
pub enum Val {
Num { n: f64 },
Bool { b: bool },
Var { name: String },
Gold,
Mana,
Time,
Creatures { side: i32, kind: String },
InZone { zone: String, side: i32, kind: String },
Random { a: Box<Val>, b: Box<Val> },
Math { op: String, a: Box<Val>, b: Box<Val> },
Cmp { op: String, a: Box<Val>, b: Box<Val> },
And { a: Box<Val>, b: Box<Val> },
Or { a: Box<Val>, b: Box<Val> },
Not { a: Box<Val> },
EventKind { kind: String }, // the unit that set off the trigger is of this kind
}
pub const MATH_OPS: [&str; 4] = ["+", "-", "*", "/"];
pub const CMP_OPS: [&str; 6] = ["<", "<=", "=", "!=", ">=", ">"];
// what a value slot expects
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Ty {
Num,
Bool,
}
impl Val {
pub fn num(n: f64) -> Val {
Val::Num { n }
}
pub fn ty(&self) -> Ty {
match self {
Val::Bool { .. } | Val::Cmp { .. } | Val::And { .. } | Val::Or { .. } | Val::Not { .. } | Val::EventKind { .. } => Ty::Bool,
_ => Ty::Num,
}
}
// a fresh value of a kind (for the block palette)
pub fn template(kind: &str) -> Option<Val> {
let n = |x: f64| Box::new(Val::num(x));
Some(match kind {
"num" => Val::num(0.0),
"bool" => Val::Bool { b: true },
"var" => Val::Var { name: String::new() },
"gold" => Val::Gold,
"mana" => Val::Mana,
"time" => Val::Time,
"creatures" => Val::Creatures { side: 0, kind: String::new() },
"inzone" => Val::InZone { zone: String::new(), side: -1, kind: String::new() },
"random" => Val::Random { a: n(1.0), b: n(6.0) },
"math" => Val::Math { op: "+".into(), a: n(0.0), b: n(1.0) },
"cmp" => Val::Cmp { op: ">".into(), a: Box::new(Val::Gold), b: n(1000.0) },
"and" => Val::And { a: Box::new(Val::Bool { b: true }), b: Box::new(Val::Bool { b: true }) },
"or" => Val::Or { a: Box::new(Val::Bool { b: true }), b: Box::new(Val::Bool { b: false }) },
"not" => Val::Not { a: Box::new(Val::Bool { b: true }) },
"eventkind" => Val::EventKind { kind: "Knight".into() },
_ => return None,
})
}
// the palette name of this value's kind (see template)
pub fn kind_name(&self) -> &'static str {
match self {
Val::Num { .. } => "num",
Val::Bool { .. } => "bool",
Val::Var { .. } => "var",
Val::Gold => "gold",
Val::Mana => "mana",
Val::Time => "time",
Val::Creatures { .. } => "creatures",
Val::InZone { .. } => "inzone",
Val::Random { .. } => "random",
Val::Math { .. } => "math",
Val::Cmp { .. } => "cmp",
Val::And { .. } => "and",
Val::Or { .. } => "or",
Val::Not { .. } => "not",
Val::EventKind { .. } => "eventkind",
}
}
}
// value kinds by type, for the value picker
pub const NUM_VALUES: [&str; 9] = ["num", "var", "gold", "mana", "time", "creatures", "inzone", "random", "math"];
pub const BOOL_VALUES: [&str; 6] = ["cmp", "and", "or", "not", "bool", "eventkind"];
// statement blocks in the palette: (category, kind)
pub const PALETTE: [(&str, &str); 18] = [
("say", "message"),
("say", "objective"),
("say", "hint"),
("units", "spawn"),
("units", "wave"),
("units", "kill"),
("world", "terrain"),
("world", "reveal"),
("world", "camera"),
("world", "gold"),
("world", "mana"),
("logic", "if"),
("logic", "repeat"),
("logic", "trigger"),
("vars", "setvar"),
("vars", "addvar"),
("mission", "win"),
("mission", "lose"),
];
pub const CATEGORIES: [&str; 6] = ["say", "units", "world", "logic", "vars", "mission"];
impl Block {
pub fn template(kind: &str) -> Option<Block> {
let n = |x: f64| Val::num(x);
Some(match kind {
"message" => Block::Message { text: String::new() },
"objective" => Block::Objective { text: String::new() },
"hint" => Block::Hint { text: String::new() },
"spawn" => Block::Spawn { side: 1, kind: "Knight".into(), count: n(1.0), level: n(1.0), place: Place::Core, name: String::new() },
"wave" => Block::Wave { count: n(1.0) },
"kill" => Block::Kill { zone: String::new(), side: 1 },
"terrain" => Block::Terrain { zone: String::new(), kind: "floor".into() },
"reveal" => Block::Reveal { zone: String::new() },
"camera" => Block::Camera { zone: String::new() },
"gold" => Block::Gold { amount: n(500.0) },
"mana" => Block::Mana { amount: n(100.0) },
"if" => Block::If { cond: Val::template("cmp").unwrap(), then: Vec::new(), els: Vec::new() },
"repeat" => Block::Repeat { times: n(3.0), body: Vec::new() },
"trigger" => Block::Trigger { name: String::new(), on: true },
"setvar" => Block::SetVar { name: String::new(), value: n(0.0) },
"addvar" => Block::AddVar { name: String::new(), value: n(1.0) },
"win" => Block::Win,
"lose" => Block::Lose,
_ => return None,
})
}
pub fn kind_name(&self) -> &'static str {
match self {
Block::Message { .. } => "message",
Block::Objective { .. } => "objective",
Block::Hint { .. } => "hint",
Block::Spawn { .. } => "spawn",
Block::Wave { .. } => "wave",
Block::Kill { .. } => "kill",
Block::Terrain { .. } => "terrain",
Block::Reveal { .. } => "reveal",
Block::Camera { .. } => "camera",
Block::Gold { .. } => "gold",
Block::Mana { .. } => "mana",
Block::If { .. } => "if",
Block::Repeat { .. } => "repeat",
Block::Trigger { .. } => "trigger",
Block::SetVar { .. } => "setvar",
Block::AddVar { .. } => "addvar",
Block::Win => "win",
Block::Lose => "lose",
}
}
pub fn category(&self) -> &'static str {
let k = self.kind_name();
PALETTE.iter().find(|(_, b)| *b == k).map_or("logic", |(c, _)| c)
}
}
// ------------------------------------------------------------------ checking
// what a script may refer to
pub struct Names<'a> {
pub zones: &'a [String],
pub triggers: &'a [String],
pub vars: &'a [String], // variables set somewhere in the mission
}
// a problem in a script: the path to the block (indices into nested lists) and a language key
#[derive(Clone, Debug, PartialEq)]
pub struct Problem {
pub path: Vec<usize>,
pub key: &'static str,
}
fn check_val(v: &Val, want: Ty, n: &Names, path: &[usize], out: &mut Vec<Problem>) {
let p = |key| Problem { path: path.to_vec(), key };
if v.ty() != want {
out.push(p("blk.err_type"));
}
match v {
Val::Var { name } if name.is_empty() => out.push(p("blk.err_var")),
Val::Var { name } if !n.vars.iter().any(|x| x == name) => out.push(p("blk.err_var_unset")),
Val::Creatures { kind, .. } if !kind.is_empty() && !KINDS.contains(&kind.as_str()) => out.push(p("blk.err_kind")),
Val::InZone { zone, .. } if !n.zones.iter().any(|z| z == zone) => out.push(p("blk.err_zone")),
Val::EventKind { kind } if !KINDS.contains(&kind.as_str()) => out.push(p("blk.err_kind")),
Val::Random { a, b } | Val::Math { a, b, .. } => {
check_val(a, Ty::Num, n, path, out);
check_val(b, Ty::Num, n, path, out);
}
Val::Cmp { a, b, .. } => {
check_val(a, Ty::Num, n, path, out);
check_val(b, Ty::Num, n, path, out);
}
Val::And { a, b } | Val::Or { a, b } => {
check_val(a, Ty::Bool, n, path, out);
check_val(b, Ty::Bool, n, path, out);
}
Val::Not { a } => check_val(a, Ty::Bool, n, path, out),
_ => {}
}
}
fn check_list(list: &[Block], n: &Names, path: &mut Vec<usize>, out: &mut Vec<Problem>) {
for (k, b) in list.iter().enumerate() {
path.push(k);
let p = |key, path: &Vec<usize>| Problem { path: path.clone(), key };
let zone_ok = |z: &String| n.zones.iter().any(|x| x == z);
match b {
Block::Message { text } | Block::Objective { text } | Block::Hint { text } if text.trim().is_empty() => out.push(p("blk.err_text", path)),
Block::Spawn { kind, count, level, place, .. } => {
if !KINDS.contains(&kind.as_str()) {
out.push(p("blk.err_kind", path));
}
check_val(count, Ty::Num, n, path, out);
check_val(level, Ty::Num, n, path, out);
if let Place::Zone(z) = place {
if !zone_ok(z) {
out.push(p("blk.err_zone", path));
}
}
}
Block::Wave { count } => check_val(count, Ty::Num, n, path, out),
Block::Gold { amount } | Block::Mana { amount } => check_val(amount, Ty::Num, n, path, out),
Block::SetVar { name, value } | Block::AddVar { name, value } => {
if !valid_var(name) {
out.push(p("blk.err_var", path));
}
check_val(value, Ty::Num, n, path, out);
}
Block::If { cond, then, els } => {
check_val(cond, Ty::Bool, n, path, out);
path.push(0);
check_list(then, n, path, out);
path.pop();
path.push(1);
check_list(els, n, path, out);
path.pop();
}
Block::Repeat { times, body } => {
check_val(times, Ty::Num, n, path, out);
path.push(0);
check_list(body, n, path, out);
path.pop();
}
Block::Reveal { zone } | Block::Camera { zone } | Block::Kill { zone, .. } if !zone_ok(zone) => out.push(p("blk.err_zone", path)),
Block::Terrain { zone, kind } => {
if !zone_ok(zone) {
out.push(p("blk.err_zone", path));
}
if !TERRAIN.contains(&kind.as_str()) {
out.push(p("blk.err_terrain", path));
}
}
Block::Trigger { name, .. } if !n.triggers.iter().any(|t| t == name) => out.push(p("blk.err_trigger", path)),
_ => {}
}
path.pop();
}
}
pub fn check(list: &[Block], n: &Names) -> Vec<Problem> {
let mut out = Vec::new();
check_list(list, n, &mut Vec::new(), &mut out);
out
}
// variable names: letters, digits and _, not starting with a digit
pub fn valid_var(name: &str) -> bool {
!name.is_empty() && name.len() <= 24 && name.chars().all(|c| c.is_ascii_alphanumeric() || c == '_') && !name.chars().next().unwrap().is_ascii_digit()
}
// every variable a list of blocks sets
pub fn vars_set(list: &[Block], out: &mut Vec<String>) {
for b in list {
match b {
Block::SetVar { name, .. } | Block::AddVar { name, .. } => {
if valid_var(name) && !out.contains(name) {
out.push(name.clone());
}
}
Block::If { then, els, .. } => {
vars_set(then, out);
vars_set(els, out);
}
Block::Repeat { body, .. } => vars_set(body, out),
_ => {}
}
}
}
// ------------------------------------------------------------------ Rhai code
fn q(s: &str) -> String {
let mut o = String::from("\"");
for c in s.chars() {
match c {
'"' => o.push_str("\\\""),
'\\' => o.push_str("\\\\"),
'\n' => o.push_str("\\n"),
'\r' | '\t' => o.push(' '),
c => o.push(c),
}
}
o.push('"');
o
}
fn f(n: f64) -> String {
if n.is_finite() {
format!("{:?}", n) // always with a decimal point: Rhai keeps it a float
} else {
"0.0".into()
}
}
pub fn val_code(v: &Val) -> String {
match v {
Val::Num { n } => f(*n),
Val::Bool { b } => b.to_string(),
Val::Var { name } => format!("m.get({})", q(name)),
Val::Gold => "m.gold()".into(),
Val::Mana => "m.mana()".into(),
Val::Time => "m.time()".into(),
Val::Creatures { side, kind } => format!("m.count({}, {})", side, q(kind)),
Val::InZone { zone, side, kind } => format!("m.in_zone({}, {}, {})", q(zone), side, q(kind)),
Val::Random { a, b } => format!("m.random({}, {})", val_code(a), val_code(b)),
Val::Math { op, a, b } => {
let op = if MATH_OPS.contains(&op.as_str()) { op.as_str() } else { "+" };
if op == "/" {
format!("m.div({}, {})", val_code(a), val_code(b))
} else {
format!("({} {} {})", val_code(a), op, val_code(b))
}
}
Val::Cmp { op, a, b } => {
let op = match op.as_str() {
"=" => "==",
o if CMP_OPS.contains(&o) => o,
_ => ">",
};
format!("({} {} {})", val_code(a), op, val_code(b))
}
Val::And { a, b } => format!("({} && {})", val_code(a), val_code(b)),
Val::Or { a, b } => format!("({} || {})", val_code(a), val_code(b)),
Val::Not { a } => format!("(!{})", val_code(a)),
Val::EventKind { kind } => format!("m.event_is({})", q(kind)),
}
}
fn cond_code(v: &Val) -> String {
if v.ty() == Ty::Bool {
val_code(v)
} else {
format!("({} != 0.0)", val_code(v))
}
}
fn list_code(list: &[Block], depth: usize, out: &mut String) {
let ind = " ".repeat(depth + 1);
for b in list {
let line = match b {
Block::Message { text } => format!("m.message({});", q(text)),
Block::Objective { text } => format!("m.objective({});", q(text)),
Block::Hint { text } => format!("m.hint({});", q(text)),
Block::Spawn { side, kind, count, level, place, name } => {
let (pk, pz) = match place {
Place::Zone(z) => ("zone", z.as_str()),
Place::Event => ("event", ""),
Place::Core => ("core", ""),
};
format!("m.spawn_unit({}, {}, {}, {}, {}, {}, {});", side, q(kind), val_code(count), val_code(level), q(pk), q(pz), q(name))
}
Block::Wave { count } => format!("m.wave({});", val_code(count)),
Block::Gold { amount } => format!("m.add_gold({});", val_code(amount)),
Block::Mana { amount } => format!("m.add_mana({});", val_code(amount)),
Block::SetVar { name, value } => format!("m.set({}, {});", q(name), val_code(value)),
Block::AddVar { name, value } => format!("m.set({}, m.get({}) + {});", q(name), q(name), val_code(value)),
Block::If { cond, then, els } => {
out.push_str(&format!("{}if {} {{\n", ind, cond_code(cond)));
list_code(then, depth + 1, out);
if !els.is_empty() {
out.push_str(&format!("{}}} else {{\n", ind));
list_code(els, depth + 1, out);
}
out.push_str(&format!("{}}}\n", ind));
continue;
}
Block::Repeat { times, body } => {
out.push_str(&format!("{}for _i{} in 0..m.times({}) {{\n", ind, depth, val_code(times)));
list_code(body, depth + 1, out);
out.push_str(&format!("{}}}\n", ind));
continue;
}
Block::Win => "m.win();".into(),
Block::Lose => "m.lose();".into(),
Block::Reveal { zone } => format!("m.reveal({});", q(zone)),
Block::Camera { zone } => format!("m.camera({});", q(zone)),
Block::Terrain { zone, kind } => format!("m.terrain({}, {});", q(zone), q(kind)),
Block::Kill { zone, side } => format!("m.kill({}, {});", q(zone), side),
Block::Trigger { name, on } => format!("m.trigger({}, {});", q(name), on),
};
out.push_str(&ind);
out.push_str(&line);
out.push('\n');
}
}
// a Rhai function `fn <name>(m) { ... }` for a list of blocks
pub fn function_code(name: &str, list: &[Block]) -> String {
let mut out = format!("fn {}(m) {{\n", name);
list_code(list, 0, &mut out);
out.push_str("}\n");
out
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn blocks_become_rhai_code() {
let list = vec![
Block::Message { text: "Hello \"master\"".into() },
Block::If {
cond: Val::Cmp { op: "=".into(), a: Box::new(Val::Var { name: "wave".into() }), b: Box::new(Val::num(2.0)) },
then: vec![Block::Spawn { side: 1, kind: "Knight".into(), count: Val::num(3.0), level: Val::num(2.0), place: Place::Zone("gate".into()), name: String::new() }],
els: vec![Block::Win],
},
Block::Repeat { times: Val::num(2.0), body: vec![Block::AddVar { name: "wave".into(), value: Val::num(1.0) }] },
];
let code = function_code("t_0", &list);
assert!(code.contains("m.message(\"Hello \\\"master\\\"\");"), "{}", code);
assert!(code.contains("if (m.get(\"wave\") == 2.0) {"), "{}", code);
assert!(code.contains("m.spawn_unit(1, \"Knight\", 3.0, 2.0, \"zone\", \"gate\", \"\");"), "{}", code);
assert!(code.contains("} else {"));
assert!(code.contains("for _i0 in 0..m.times(2.0) {"));
}
#[test]
fn checking_finds_missing_zones_vars_and_wrong_types() {
let zones = vec!["gate".to_string()];
let names = Names { zones: &zones, triggers: &[], vars: &[] };
let list = vec![
Block::Reveal { zone: "nowhere".into() },
Block::If { cond: Val::num(1.0), then: vec![Block::Message { text: " ".into() }], els: vec![] },
Block::Gold { amount: Val::Var { name: "loot".into() } },
];
let p = check(&list, &names);
assert!(p.contains(&Problem { path: vec![0], key: "blk.err_zone" }));
assert!(p.contains(&Problem { path: vec![1], key: "blk.err_type" }));
assert!(p.contains(&Problem { path: vec![1, 0, 0], key: "blk.err_text" }));
assert!(p.contains(&Problem { path: vec![2], key: "blk.err_var_unset" }));
let ok = vec![Block::Reveal { zone: "gate".into() }];
assert!(check(&ok, &names).is_empty());
}
#[test]
fn every_palette_entry_has_a_template_and_round_trips() {
for (_, k) in PALETTE {
let b = Block::template(k).unwrap_or_else(|| panic!("{}", k));
assert_eq!(b.kind_name(), k);
let json = serde_json::to_string(&b).unwrap();
assert_eq!(serde_json::from_str::<Block>(&json).unwrap(), b);
}
for k in NUM_VALUES.iter().chain(BOOL_VALUES.iter()) {
let v = Val::template(k).unwrap();
assert_eq!(v.kind_name(), *k);
}
}
}
+92
View File
@@ -589,6 +589,98 @@ fn start_mission_def(d: &MissionDef, i: usize, carry: Option<&Carry>, coop: bool
g
}
// ------------------------------------------------------------------ editor missions
// a mission definition from editor generator settings (random maps and new painted maps)
pub fn generated_def(d: &crate::campaign_doc::MissionDoc, seed: u32) -> MissionDef {
let mut out = mission_def_from_doc(d);
out.seed = seed;
let g = &d.gen;
let mut rng = Rng(seed | 1);
for k in 0..g.rivers.min(3) {
out.rivers.push(river(false, k % 2 == 0, 0.3 + 0.2 * k as f32 + rng.f() * 0.08, 4, 1 + (rng.next() % 2)));
}
for k in 0..g.lava.min(2) {
out.rivers.push(river(true, k % 2 == 1, 0.62 - 0.25 * k as f32, 4, 1));
}
let lvl = 1 + g.difficulty.min(2) as u32;
for k in 0..g.forts.min(3) {
let at = [(0.8, 0.5), (0.75, 0.2), (0.75, 0.8)][k as usize];
out.forts.push(fort(at, (17, 15), &[3], &[TowerKind::Arrow, TowerKind::Arrow], lvl, 3 + 2 * lvl, lvl, lvl, 1, 1000 * lvl as i32));
}
for k in 0..g.rivals.min(2) {
let (x, y) = [(0.82, 0.22), (0.82, 0.78)][k as usize];
out.rivals.push((x, y, g.difficulty.min(2), 2 + k));
}
out
}
// the seed of a random map in one run of a campaign
pub fn run_map_seed(run_seed: u32, mission: usize) -> u32 {
run_seed.wrapping_mul(2654435761).wrapping_add(mission as u32 * 40503) | 1
}
// a mission of an editor campaign: its painted map or a random map (rolled from the run's
// seed), the goals it can actually have, and its block scripts
pub fn start_editor_mission(doc: &crate::campaign_doc::MissionDoc, i: usize, run_seed: u32) -> Game {
use crate::campaign_doc::MapDoc;
let mut g = match &doc.map {
MapDoc::Fixed { game } => match load_fixed_map(game) {
Some(g) => g,
None => start_mission_def(&generated_def(doc, run_map_seed(run_seed, i)), i, None, false),
},
MapDoc::Random => start_mission_def(&generated_def(doc, run_map_seed(run_seed, i)), i, None, false),
};
g.msgs.clear();
g.state = 0;
g.time = 0.0;
// goals the map cannot have would be won at once: they are left out
let shards = g.map.t.iter().filter(|t| t.kind == Kind::Shard).count() as u32;
let heart = g.gate_hp > 0.0 && g.gate_on;
let knight_towers = g.towers.iter().any(|t| t.hero);
let heroes = g.ents.iter().any(|e| e.alive && e.active_hero()) || doc.waves;
let goals: Vec<Goal> = crate::campaign_doc::parse_goals(&doc.goals)
.into_iter()
.filter(|goal| match goal {
Goal::Shards => shards > 0,
Goal::Heart => heart,
Goal::Towers => knight_towers,
Goal::KillAll => heroes,
Goal::Rivals => !g.rivals.is_empty(),
Goal::Survive(_) => doc.waves,
_ => true,
})
.collect();
g.mission = Some(MissionState {
index: i,
goals,
tools: vec![true; crate::ui::TOOLS.len()],
waves: doc.waves,
wave_limit: doc.wave_limit,
gate_defenders: doc.waves,
heart,
time_limit: doc.time_limit,
shards,
});
g.wave_timer = doc.first_wave;
g.gold = doc.gold;
if !doc.scripts.is_empty() {
g.script = Some(crate::mscript::ScriptState::new(doc.scripts.clone(), doc.zones.clone(), run_map_seed(run_seed, i)));
}
if !doc.title.is_empty() {
g.msg("msg.mission_start_custom", vec![doc.title.clone()]);
}
g
}
// a painted map: the game state saved by the map editor
pub fn load_fixed_map(json: &str) -> Option<Game> {
let mut g: Game = serde_json::from_str(json).ok()?;
g.map.activate();
g.recount();
Some(g)
}
// missions that can be played in co-op: they fit the multiplayer master areas and do not
// depend on rival masters as their only goal
pub fn is_coop_mission(i: usize) -> bool {
+254 -11
View File
@@ -1,19 +1,23 @@
// Data model for shareable campaign files (.dccamp).
// This is the first editor layer: JSON missions, branch goals, scripts and trigger zones.
// The gameplay-side campaign start for custom missions comes in a later step.
// Data model for shareable campaign files (.dccamp): missions with their map (a fixed,
// painted map or generator settings for a random map), goals and branches, trigger zones,
// and the block scripts (special scripts and triggers, see blocks.rs and mscript.rs).
use crate::blocks::Block;
use crate::game::Goal;
use serde::{Deserialize, Serialize};
use std::collections::HashSet;
pub const CAMPAIGN_FORMAT: &str = "dungeoncraft.campaign";
pub const CAMPAIGN_VERSION: u32 = 1;
pub const CAMPAIGN_VERSION: u32 = 2; // 2: maps, block scripts and triggers
pub const CAMPAIGN_EXTENSION: &str = "dccamp";
#[derive(Clone, Debug, Default, Serialize, Deserialize)]
pub struct CampaignProgress {
pub done: Vec<bool>,
pub completed_choice: Vec<Option<String>>,
// random maps are rolled once per run of the campaign: this seed is fixed for the run
#[serde(default)]
pub run_seed: u32,
}
impl CampaignProgress {
@@ -22,6 +26,7 @@ impl CampaignProgress {
CampaignProgress {
done: vec![false; n],
completed_choice: vec![None; n],
run_seed: (macroquad::miniquad::date::now() * 1000.0) as u64 as u32 | 1,
}
}
@@ -57,6 +62,9 @@ pub struct CampaignDoc {
pub author: String,
#[serde(default)]
pub missions: Vec<MissionDoc>,
// the one language the author writes the texts in (a language code)
#[serde(default)]
pub language: String,
}
#[derive(Clone, Debug, Serialize, Deserialize)]
@@ -94,7 +102,162 @@ pub struct MissionDoc {
#[serde(default)]
pub zones: Vec<ZoneDoc>,
#[serde(default)]
pub script: Option<ScriptDoc>,
pub script: Option<ScriptDoc>, // version 1 text scripts (no longer run)
#[serde(default)]
pub map: MapDoc,
#[serde(default)]
pub gen: GenDoc,
#[serde(default)]
pub scripts: MissionScripts,
// position of the mission's card in the campaign graph
#[serde(default)]
pub pos: (f32, f32),
}
// the map of a mission: generated anew for every run of the campaign, or painted and fixed
#[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq)]
#[serde(tag = "kind")]
pub enum MapDoc {
#[default]
Random,
// the whole game state of the edited map as savegame JSON (tiles packed)
Fixed { game: String },
}
// generator settings (random maps, and the start of a painted map)
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
pub struct GenDoc {
#[serde(default)]
pub rivers: u8, // 0..3 water rivers
#[serde(default)]
pub lava: u8, // 0..2 lava rivers
#[serde(default)]
pub forts: u8, // 0..3 knight fortresses
#[serde(default)]
pub rivals: u8, // 0..2 rival masters
#[serde(default = "default_difficulty")]
pub difficulty: u8, // 0 easy, 1 medium, 2 hard
}
impl Default for GenDoc {
fn default() -> Self {
GenDoc { rivers: 1, lava: 0, forts: 1, rivals: 0, difficulty: 1 }
}
}
fn default_difficulty() -> u8 {
1
}
// the block scripts of a mission
#[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq)]
pub struct MissionScripts {
#[serde(default)]
pub start: Vec<Block>, // once when the mission begins
#[serde(default)]
pub victory: Vec<Block>, // every second: may call win
#[serde(default)]
pub defeat: Vec<Block>, // every second: may call lose
#[serde(default)]
pub tick: Vec<Block>, // every second
#[serde(default)]
pub triggers: Vec<TriggerDoc>,
}
impl MissionScripts {
pub fn is_empty(&self) -> bool {
self.start.is_empty() && self.victory.is_empty() && self.defeat.is_empty() && self.tick.is_empty() && self.triggers.is_empty()
}
}
// a trigger: a condition (set up in a form) and the blocks that run when it is met
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
pub struct TriggerDoc {
pub name: String,
pub kind: TriggerKind,
#[serde(default = "default_true")]
pub once: bool,
#[serde(default = "default_true")]
pub enabled: bool, // active from the start (blocks can switch triggers on and off)
#[serde(default)]
pub script: Vec<Block>,
}
fn default_true() -> bool {
true
}
// what sets a trigger off. side: -1 any, 0 player, 1 heroes, 2-4 rival masters; kind "" = any
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
#[serde(tag = "t")]
pub enum TriggerKind {
ZoneEnter { zone: String, side: i32, kind: String },
ZoneLeave { zone: String, side: i32, kind: String },
LineCross { zone: String, side: i32, kind: String },
ZoneClaimed { zone: String },
ZoneDug { zone: String },
ZoneCount { zone: String, side: i32, kind: String, op: String, n: f64 },
Timer { secs: f64, every: bool },
UnitDies { side: i32, kind: String, #[serde(default)] name: String },
TowerFalls { side: i32 }, // 0 the dungeon's towers, 1 the knights' towers, -1 any
Value { what: String, #[serde(default)] var: String, op: String, n: f64 }, // what: gold, mana, creatures, heroes, var
RoomBuilt { room: String }, // a room kind ("Lair", ...) or "" for any
SpellCast { spell: String }, // a spell ("Lightning", ...) or "" for any
Slap,
Research,
}
// trigger kinds in the order of the editor's list
pub const TRIGGER_KINDS: [&str; 14] = ["zone_enter", "zone_leave", "line_cross", "zone_claimed", "zone_dug", "zone_count", "timer", "unit_dies", "tower_falls", "value", "room_built", "spell_cast", "slap", "research"];
impl TriggerKind {
pub fn template(kind: &str) -> Option<TriggerKind> {
let z = String::new;
Some(match kind {
"zone_enter" => TriggerKind::ZoneEnter { zone: z(), side: 0, kind: z() },
"zone_leave" => TriggerKind::ZoneLeave { zone: z(), side: 0, kind: z() },
"line_cross" => TriggerKind::LineCross { zone: z(), side: -1, kind: z() },
"zone_claimed" => TriggerKind::ZoneClaimed { zone: z() },
"zone_dug" => TriggerKind::ZoneDug { zone: z() },
"zone_count" => TriggerKind::ZoneCount { zone: z(), side: 1, kind: z(), op: ">=".into(), n: 3.0 },
"timer" => TriggerKind::Timer { secs: 60.0, every: false },
"unit_dies" => TriggerKind::UnitDies { side: 1, kind: z(), name: z() },
"tower_falls" => TriggerKind::TowerFalls { side: 1 },
"value" => TriggerKind::Value { what: "gold".into(), var: z(), op: ">=".into(), n: 5000.0 },
"room_built" => TriggerKind::RoomBuilt { room: z() },
"spell_cast" => TriggerKind::SpellCast { spell: z() },
"slap" => TriggerKind::Slap,
"research" => TriggerKind::Research,
_ => return None,
})
}
pub fn kind_name(&self) -> &'static str {
match self {
TriggerKind::ZoneEnter { .. } => "zone_enter",
TriggerKind::ZoneLeave { .. } => "zone_leave",
TriggerKind::LineCross { .. } => "line_cross",
TriggerKind::ZoneClaimed { .. } => "zone_claimed",
TriggerKind::ZoneDug { .. } => "zone_dug",
TriggerKind::ZoneCount { .. } => "zone_count",
TriggerKind::Timer { .. } => "timer",
TriggerKind::UnitDies { .. } => "unit_dies",
TriggerKind::TowerFalls { .. } => "tower_falls",
TriggerKind::Value { .. } => "value",
TriggerKind::RoomBuilt { .. } => "room_built",
TriggerKind::SpellCast { .. } => "spell_cast",
TriggerKind::Slap => "slap",
TriggerKind::Research => "research",
}
}
// the zone a trigger watches, if any
pub fn zone(&self) -> Option<&str> {
match self {
TriggerKind::ZoneEnter { zone, .. } | TriggerKind::ZoneLeave { zone, .. } | TriggerKind::LineCross { zone, .. } | TriggerKind::ZoneClaimed { zone } | TriggerKind::ZoneDug { zone } | TriggerKind::ZoneCount { zone, .. } => Some(zone),
_ => None,
}
}
}
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
@@ -111,7 +274,7 @@ pub struct BranchDoc {
pub priority: Option<u32>,
}
#[derive(Clone, Debug, Serialize, Deserialize)]
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
pub struct ZoneDoc {
#[serde(default)]
pub name: String,
@@ -144,9 +307,10 @@ impl Default for CampaignDoc {
CampaignDoc {
format: default_format(),
version: default_version(),
name: "Neue Kampagne".into(),
name: "Campaign".into(),
author: String::new(),
missions: Vec::new(),
language: String::new(),
}
}
}
@@ -171,6 +335,10 @@ impl Default for MissionDoc {
branches: Vec::new(),
zones: Vec::new(),
script: None,
map: MapDoc::Random,
gen: GenDoc::default(),
scripts: MissionScripts::default(),
pos: (0.0, 0.0),
}
}
}
@@ -186,11 +354,17 @@ impl CampaignDoc {
pub fn add_mission(&mut self) -> &mut MissionDoc {
let n = self.missions.len() + 1;
// a free key (missions can be deleted, so the count alone may be taken)
let mut k = n;
while self.missions.iter().any(|m| m.key == format!("m{k:02}")) {
k += 1;
}
self.missions.push(MissionDoc {
key: format!("m{n:02}"),
key: format!("m{k:02}"),
title: format!("Mission {n}"),
seed: (n as u32) * 101,
goals: vec!["Shards".into()],
seed: (k as u32) * 101,
goals: vec!["KillAll".into()],
pos: (40.0 + ((n - 1) % 5) as f32 * 220.0, 40.0 + ((n - 1) / 5) as f32 * 140.0),
..Default::default()
});
self.missions.last_mut().expect("the new mission is last")
@@ -245,6 +419,22 @@ impl CampaignDoc {
errors.push(format!("mission {} has an unknown next mission: {}", mission.key, mission.next.clone().unwrap_or_default()));
}
let mut zone_names = HashSet::new();
for z in &mission.zones {
if z.name.trim().is_empty() || !zone_names.insert(z.name.clone()) {
errors.push(format!("mission {} has an unnamed or duplicate zone: {:?}", mission.key, z.name));
}
}
let mut trig_names = HashSet::new();
for t in &mission.scripts.triggers {
if t.name.trim().is_empty() || !trig_names.insert(t.name.clone()) {
errors.push(format!("mission {} has an unnamed or duplicate trigger: {:?}", mission.key, t.name));
}
}
for (what, n) in mission.script_problems() {
errors.push(format!("mission {} {}: {} problem(s)", mission.key, what, n));
}
for branch in &mission.branches {
if !valid_key(&branch.id) {
errors.push(format!("mission {} has an invalid branch id: {:?}", mission.key, branch.id));
@@ -320,6 +510,59 @@ impl CampaignDoc {
}
}
impl MissionDoc {
pub fn zone_names(&self) -> Vec<String> {
self.zones.iter().map(|z| z.name.clone()).collect()
}
pub fn trigger_names(&self) -> Vec<String> {
self.scripts.triggers.iter().map(|t| t.name.clone()).collect()
}
// every variable some script of the mission sets
pub fn vars(&self) -> Vec<String> {
let mut v = Vec::new();
let s = &self.scripts;
for list in [&s.start, &s.victory, &s.defeat, &s.tick] {
crate::blocks::vars_set(list, &mut v);
}
for t in &s.triggers {
crate::blocks::vars_set(&t.script, &mut v);
}
v
}
// problems in a list of blocks of this mission
pub fn problems(&self, list: &[Block]) -> Vec<crate::blocks::Problem> {
let (zones, triggers, vars) = (self.zone_names(), self.trigger_names(), self.vars());
crate::blocks::check(list, &crate::blocks::Names { zones: &zones, triggers: &triggers, vars: &vars })
}
// (script name, number of problems) for every script with problems, and triggers whose
// zone is missing
pub fn script_problems(&self) -> Vec<(String, usize)> {
let mut out = Vec::new();
let s = &self.scripts;
for (name, list) in [("start", &s.start), ("victory", &s.victory), ("defeat", &s.defeat), ("tick", &s.tick)] {
let n = self.problems(list).len();
if n > 0 {
out.push((name.to_string(), n));
}
}
let zones = self.zone_names();
for t in &s.triggers {
let mut n = self.problems(&t.script).len();
if t.kind.zone().map_or(false, |z| !zones.iter().any(|x| x == z)) {
n += 1;
}
if n > 0 {
out.push((format!("trigger {}", t.name), n));
}
}
out
}
}
pub fn parse_list(text: &str) -> Vec<String> {
text.split(',').map(|s| s.trim().to_string()).filter(|s| !s.is_empty()).collect()
}
@@ -527,7 +770,7 @@ mod tests {
campaign.add_mission();
assert_eq!(campaign.missions.len(), 1);
assert_eq!(campaign.missions[0].key, "m01");
assert_eq!(parse_goal(campaign.missions[0].goals[0].as_str()), Some(Goal::Shards));
assert_eq!(parse_goal(campaign.missions[0].goals[0].as_str()), Some(Goal::KillAll));
assert!(campaign.validate().is_empty());
}
+13 -1
View File
@@ -541,6 +541,10 @@ pub struct Game {
#[serde(default)]
pub mission: Option<MissionState>, // campaign mission (None: free game)
#[serde(default)]
pub script: Option<crate::mscript::ScriptState>, // block scripts of an editor mission
#[serde(default)]
pub objective: Option<String>, // the objective a mission script set
#[serde(default)]
pub rivals: Vec<crate::rival::Rival>, // AI dungeon masters
#[serde(default = "yes")]
pub gate_on: bool, // free game: the humans' gate exists
@@ -866,6 +870,8 @@ impl Game {
towers_razed: 0,
contested: Vec::new(),
mission: None,
script: None,
objective: None,
rivals: Vec::new(),
gate_on: true,
springs: 0,
@@ -1340,6 +1346,11 @@ impl Game {
self.switch_to(side);
self.sync_treasury();
}
// mission scripts: always from the first player's side, the same on every computer
if self.script.is_some() {
self.switch_to(0);
self.run_scripts();
}
let local = self.local_side;
self.switch_to(local);
}
@@ -2729,7 +2740,7 @@ impl Game {
}
// the ground of tile i turned into river (bridge removed): units on it fall in
fn ground_lost(&mut self, i: usize) {
pub(crate) fn ground_lost(&mut self, i: usize) {
let lava = self.map.t[i].kind == Kind::Lava;
let (x, y) = xy(i);
for j in 0..self.ents.len() {
@@ -5231,6 +5242,7 @@ impl Game {
}
self.fx(2, x, y, 0.0, 0.0, 0.3, 0xffffffff);
self.sfx(Sfx::Slap, x, y);
self.script_event(crate::mscript::ScriptEvent::Slap);
return 1;
}
if self.ents[j].captive != 0 {
+12 -225
View File
@@ -26,7 +26,8 @@ mod rival;
mod render3d;
mod rooms;
mod save;
mod script;
mod blocks;
mod mscript;
mod settings;
mod sfx;
mod tracks;
@@ -199,8 +200,9 @@ fn sanitize_campaign_key(value: &str) -> String {
value.trim().chars().filter(|c| c.is_ascii_alphanumeric() || *c == '_' || *c == '-').take(48).collect()
}
fn custom_mission_game(def: &campaign::MissionDef, i: usize) -> (Game, Cam) {
let g = campaign::start_mission_doc(def, i, None);
// a mission of an editor campaign (its map is painted, or rolled from the run's seed)
fn custom_mission_game(doc: &campaign_doc::CampaignDoc, i: usize, run_seed: u32) -> (Game, Cam) {
let g = campaign::start_editor_mission(&doc.missions[i], i, run_seed);
let (cx, cy) = g.map.core;
let cam = Cam::new(cx as f32 + 1.5, cy as f32 + 1.5);
(g, cam)
@@ -412,192 +414,6 @@ fn apply_game_settings(g: &mut Game, s: &Settings) {
g.debug_markers = s.debug_command_markers;
}
fn start_custom_script(g: &mut Game, doc: &campaign_doc::MissionDoc) {
let Some(script) = doc.script.as_ref() else {
return;
};
if script.source.trim().is_empty() {
return;
}
if !script.language.is_empty() && !script.language.eq_ignore_ascii_case("rhai") {
g.msg("msg.script_language", vec![script.language.clone()]);
return;
}
match script::run_start(&script.source) {
Ok(m) => {
for message in m.messages() {
g.msg("msg.script", vec![message]);
}
if let Some(objective) = m.objective() {
g.msg("msg.script_objective", vec![objective]);
}
for action in m.actions() {
apply_script_action(g, doc, action);
}
}
Err(error) => {
g.msg("msg.script_error", vec![error]);
}
}
}
fn validate_script_source(doc: &campaign_doc::MissionDoc, source: &str) -> Result<(), String> {
match script::run_start(source) {
Ok(m) => {
for action in m.actions() {
match action {
script::ScriptAction::Spawn { kind, .. } => {
if script::parse_spawn_kind(&kind).is_none() {
return Err(format!("unknown creature: {kind}"));
}
}
script::ScriptAction::SpawnInZone { zone, kind, .. } => {
if script::parse_spawn_kind(&kind).is_none() {
return Err(format!("unknown creature: {kind}"));
}
if !doc.zones.iter().any(|z| z.name == zone) {
return Err(format!("unknown zone: {zone}"));
}
}
script::ScriptAction::Wave { .. } => {}
}
}
Ok(())
}
Err(error) => Err(error),
}
}
fn apply_script_action(g: &mut Game, doc: &campaign_doc::MissionDoc, action: script::ScriptAction) {
match action {
script::ScriptAction::Spawn { side, kind, x, y, count, level } => {
let Some(kind) = script::parse_spawn_kind(&kind) else {
g.msg("msg.script_error", vec![format!("unknown creature: {kind}")]);
return;
};
spawn_script_units(g, side, kind, &vec![(x, y); count.max(0) as usize], level);
}
script::ScriptAction::SpawnInZone { zone, kind, side, count, level } => {
let Some(kind) = script::parse_spawn_kind(&kind) else {
g.msg("msg.script_error", vec![format!("unknown creature: {kind}")]);
return;
};
let Some(zone_doc) = doc.zones.iter().find(|z| z.name == zone) else {
g.msg("msg.script_error", vec![format!("unknown zone: {zone}")]);
return;
};
let positions = zone_spawn_positions(g, zone_doc, count);
if positions.is_empty() {
g.msg("msg.script_error", vec![format!("no usable spawn position in zone: {zone}")]);
return;
}
spawn_script_units(g, side, kind, &positions, level);
}
script::ScriptAction::Wave { count } => {
apply_script_wave(g, count);
}
}
}
fn apply_script_wave(g: &mut Game, count: i32) {
let count = count.max(0) as u32;
if count == 0 {
return;
}
if let Some(m) = g.mission.as_mut() {
m.waves = true;
if m.wave_limit != 0 {
m.wave_limit = m.wave_limit.max(g.wave.saturating_add(count));
}
}
g.wave_timer = 0.0;
}
fn spawn_script_units(g: &mut Game, side: i32, kind: game::EK, positions: &[(f32, f32)], level: i32) {
let positions: Vec<(f32, f32)> = positions.iter().copied().filter(|(x, y)| valid_script_spawn_pos(g, *x, *y)).collect();
if positions.is_empty() {
return;
}
let old_side = g.me_side;
match side {
0 => g.me_side = 0,
1 => g.me_side = 1,
2 | 3 | 4 => g.me_side = side as u8,
_ => {
g.msg("msg.script_error", vec![format!("unknown script side: {side}")]);
return;
}
}
for (x, y) in positions {
g.spawn(kind, side == 1, x, y, level.max(1) as u32);
}
g.me_side = old_side;
}
fn valid_script_spawn_pos(_g: &Game, x: f32, y: f32) -> bool {
x.is_finite() && y.is_finite() && x >= 0.0 && y >= 0.0 && x < mw() as f32 && y < mh() as f32 && inb(x as i32, y as i32)
}
fn zone_spawn_positions(g: &Game, zone: &campaign_doc::ZoneDoc, count: i32) -> Vec<(f32, f32)> {
let count = count.clamp(1, 10) as usize;
let core = g.map.core;
let center = zone.at.unwrap_or((core.0 as f32 + 1.5, core.1 as f32 + 1.5));
let mut out = Vec::new();
match zone.shape.as_str() {
"rect" | "rectangle" | "square" => {
if let (Some(from), Some(to)) = (zone.from, zone.to) {
let x0 = from.0.min(to.0);
let x1 = from.0.max(to.0);
let y0 = from.1.min(to.1);
let y1 = from.1.max(to.1);
let cols = ((count as f32).sqrt().ceil()) as usize;
for k in 0..count {
let r = k / cols;
let c = k % cols;
let tx = if cols > 1 { c as f32 / (cols as f32 - 1.0) } else { 0.5 };
let rows = ((count as f32) / cols as f32).ceil() as usize;
let ty = if rows > 1 { r as f32 / (rows as f32 - 1.0) } else { 0.5 };
out.push((x0 + 0.5 + tx * (x1 - x0), y0 + 0.5 + ty * (y1 - y0)));
}
}
}
"line" | "path" => {
if let (Some(from), Some(to)) = (zone.from, zone.to) {
let x0 = from.0 + 0.5;
let y0 = from.1 + 0.5;
let x1 = to.0 + 0.5;
let y1 = to.1 + 0.5;
for k in 0..count {
let t = (k as f32 + 0.5) / count as f32;
out.push((x0 + t * (x1 - x0), y0 + t * (y1 - y0)));
}
}
}
"tiles" => {
if let Some(tiles) = zone.tiles.as_ref() {
for (x, y) in tiles.iter().take(count) {
out.push((*x as f32 + 0.5, *y as f32 + 0.5));
}
}
}
_ => {
let radius = zone.radius.unwrap_or(2.0).max(0.0);
let cx = center.0 + 0.5;
let cy = center.1 + 0.5;
if count == 1 || radius <= 0.5 {
out.push((cx, cy));
} else {
let r = radius.min(radius.max(0.5) * 0.75);
for k in 0..count {
let angle = (k as f32) * std::f32::consts::TAU / count as f32;
out.push((cx + angle.cos() * r, cy + angle.sin() * r));
}
}
}
}
out.into_iter().filter(|(x, y)| valid_script_spawn_pos(g, *x, *y)).take(count).collect()
}
// "dungeoncraft_2026-11-02_2145.dcsave" (UTC)
fn save_file_name() -> String {
let (y, m, d, h, mi) = cloud::civil(miniquad::date::now() as i64);
@@ -1464,32 +1280,7 @@ async fn main() {
}
}
}
MenuAction::CampaignEditScript => {
let i = menu.campaign_selected;
let initial = menu.campaign_doc.missions.get(i).and_then(|m| m.script.as_ref()).map(|s| s.source.clone()).unwrap_or_default();
if let Some(source) = prompt_optional_line(&i18n::tr("campaign.prompt_script"), &initial, 600) {
if source.trim().is_empty() {
if let Some(mission) = menu.campaign_doc.missions.get_mut(i) {
mission.script = None;
}
app.set_status(i18n::tr("campaign.updated"), now);
} else if let Some(mission) = menu.campaign_doc.missions.get(i).cloned() {
match validate_script_source(&mission, &source) {
Ok(()) => {
if let Some(mission) = menu.campaign_doc.missions.get_mut(i) {
mission.script = Some(campaign_doc::ScriptDoc { language: "rhai".into(), source });
}
app.set_status(i18n::tr("campaign.updated"), now);
}
Err(error) => {
app.set_status(i18n::trf("msg.script_error", &vec![error]), now);
}
}
} else {
app.set_status(i18n::tr("campaign.error_no_mission"), now);
}
}
}
MenuAction::CampaignEditScript => {}
MenuAction::CampaignDelete(i) => {
if i < menu.campaign_doc.missions.len() {
menu.campaign_doc.missions.remove(i);
@@ -1506,15 +1297,15 @@ async fn main() {
MenuAction::CampaignStart(i) => {
if let Some(mission_doc) = menu.campaign_doc.missions.get(i) {
if menu.campaign_doc.validate().is_empty() {
let def = campaign::mission_def_from_doc(mission_doc);
(app.game, app.cam) = custom_mission_game(&def, i);
let _ = mission_doc;
let progress = campaign_doc::CampaignProgress::for_doc(&menu.campaign_doc);
(app.game, app.cam) = custom_mission_game(&menu.campaign_doc, i, progress.run_seed);
apply_game_settings(&mut app.game, &app.settings);
start_custom_script(&mut app.game, mission_doc);
app.game.coop = false;
app.coop = false;
app.recorded = false;
app.custom_campaign = Some(menu.campaign_doc.clone());
app.custom_progress = Some(campaign_doc::CampaignProgress::for_doc(&menu.campaign_doc));
app.custom_progress = Some(progress);
app.custom_mission_index = Some(i);
menu.page = None;
ui.log.clear();
@@ -1595,10 +1386,8 @@ async fn main() {
if let Some(next) = app.custom_next_index {
if let Some(campaign) = campaign {
if next < campaign.missions.len() {
let def = campaign::mission_def_from_doc(&campaign.missions[next]);
(app.game, app.cam) = custom_mission_game(&def, next);
(app.game, app.cam) = custom_mission_game(&campaign, next, progress.as_ref().map_or(1, |p| p.run_seed));
apply_game_settings(&mut app.game, &app.settings);
start_custom_script(&mut app.game, &campaign.missions[next]);
app.game.coop = false;
app.coop = false;
app.recorded = false;
@@ -1620,10 +1409,8 @@ async fn main() {
}
} else if let (Some(campaign), Some(i)) = (campaign, app.custom_mission_index) {
if i < campaign.missions.len() {
let def = campaign::mission_def_from_doc(&campaign.missions[i]);
(app.game, app.cam) = custom_mission_game(&def, i);
(app.game, app.cam) = custom_mission_game(&campaign, i, progress.as_ref().map_or(1, |p| p.run_seed));
apply_game_settings(&mut app.game, &app.settings);
start_custom_script(&mut app.game, &campaign.missions[i]);
app.game.coop = false;
app.coop = false;
app.recorded = false;
+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");
}
}
-246
View File
@@ -1,246 +0,0 @@
// First Rhai mission-script runtime. This step supports log/objective/spawn hooks.
// Zone/branch/gameplay hooks come in the next implementation steps.
use std::sync::{Arc, Mutex};
use rhai::{Dynamic, Engine, Scope};
use crate::game::EK;
#[derive(Clone, Debug, PartialEq)]
pub enum ScriptAction {
Spawn { side: i32, kind: String, x: f32, y: f32, count: i32, level: i32 },
SpawnInZone { zone: String, kind: String, side: i32, count: i32, level: i32 },
Wave { count: i32 },
}
#[derive(Clone, Debug, PartialEq)]
pub struct MissionScript {
messages: Vec<String>,
objective: Option<String>,
actions: Vec<ScriptAction>,
}
impl MissionScript {
pub fn new() -> MissionScript {
MissionScript { messages: Vec::new(), objective: None, actions: Vec::new() }
}
pub fn messages(&self) -> Vec<String> {
self.messages.clone()
}
pub fn objective(&self) -> Option<String> {
self.objective.clone()
}
pub fn actions(&self) -> Vec<ScriptAction> {
self.actions.clone()
}
fn merge(&mut self, other: MissionScript) {
self.messages.extend(other.messages);
self.actions.extend(other.actions);
if other.objective.is_some() {
self.objective = other.objective;
}
}
}
impl Default for MissionScript {
fn default() -> Self {
MissionScript::new()
}
}
pub fn parse_side_name(name: &str) -> Option<i32> {
match name.trim().to_lowercase().as_str() {
"player" => Some(0),
"heroes" | "hero" => Some(1),
"mossgrim" => Some(2),
"nightwell" => Some(3),
"rival3" | "rival" => Some(4),
_ => None,
}
}
pub fn parse_spawn_kind(name: &str) -> Option<EK> {
match name.trim().to_lowercase().as_str() {
"worker" => Some(EK::Worker),
"kobold" => Some(EK::Kobold),
"spider" => Some(EK::Spider),
"mage" => Some(EK::Mage),
"ogre" => Some(EK::Ogre),
"knight" => Some(EK::Knight),
"archer" => Some(EK::Archer),
"priest" => Some(EK::Priest),
"dwarf" => Some(EK::Dwarf),
"paladin" => Some(EK::Paladin),
"jailer" => Some(EK::Jailer),
"priestess" => Some(EK::Priestess),
"chainbreaker" => Some(EK::Chainbreaker),
"bat" => Some(EK::Bat),
"gargoyle" => Some(EK::Gargoyle),
"owl" => Some(EK::Owl),
"wyrm" => Some(EK::Wyrm),
"cogling" => Some(EK::Cogling),
"revenant" => Some(EK::Revenant),
"giant" => Some(EK::Giant),
"fairy" => Some(EK::Fairy),
"battlemage" => Some(EK::BattleMage),
_ => None,
}
}
pub fn run_start(source: &str) -> Result<MissionScript, String> {
run_hook(source, "on_start", MissionScript::new())
}
pub fn run_hook(source: &str, hook: &str, initial: MissionScript) -> Result<MissionScript, String> {
let needle = format!("fn {hook}(");
if !source.contains(&needle) {
return Ok(initial);
}
let output = Arc::new(Mutex::new(initial));
let engine = script_engine(output.clone());
let code = format!("{}\n{hook}(m)", source);
let mut scope = Scope::new();
scope.push("m", MissionScript::new());
let returned = engine.eval_with_scope::<Dynamic>(&mut scope, &code).map_err(|e| e.to_string())?;
if let Some(returned) = returned.try_cast::<MissionScript>() {
if !returned.messages.is_empty() || !returned.actions.is_empty() || returned.objective.is_some() {
output.lock().map_err(|e| e.to_string())?.merge(returned);
}
}
let done = output.lock().map_err(|e| e.to_string())?.clone();
Ok(done)
}
fn script_engine(output: Arc<Mutex<MissionScript>>) -> Engine {
let mut engine = Engine::new();
engine.set_max_operations(200_000);
engine.register_type_with_name::<MissionScript>("mission");
let log_output = output.clone();
engine.register_fn("log", move |_: &mut MissionScript, s: &str| {
if let Ok(mut m) = log_output.lock() {
m.messages.push(s.to_string());
}
});
let objective_output = output.clone();
engine.register_fn("objective", move |_: &mut MissionScript, s: &str| {
if let Ok(mut m) = objective_output.lock() {
m.objective = Some(s.to_string());
}
});
let spawn_output = output.clone();
engine.register_fn("spawn_at", move |_: &mut MissionScript, side: i64, kind: &str, x: f64, y: f64, count: i64, level: i64| {
if let Ok(mut m) = spawn_output.lock() {
m.actions.push(ScriptAction::Spawn { side: side as i32, kind: kind.to_string(), x: x as f32, y: y as f32, count: count.clamp(1, 10) as i32, level: level.clamp(1, 10) as i32 });
}
});
let spawn_side_output = output.clone();
engine.register_fn("spawn_at", move |_: &mut MissionScript, side: &str, kind: &str, x: f64, y: f64, count: i64, level: i64| {
if let Some(side) = parse_side_name(side) {
if let Ok(mut m) = spawn_side_output.lock() {
m.actions.push(ScriptAction::Spawn { side, kind: kind.to_string(), x: x as f32, y: y as f32, count: count.clamp(1, 10) as i32, level: level.clamp(1, 10) as i32 });
}
}
});
let zone_output = output.clone();
engine.register_fn("spawn_count_in_zone", move |_: &mut MissionScript, zone: &str, kind: &str, count: i64, side: i64, level: i64| {
if let Ok(mut m) = zone_output.lock() {
m.actions.push(ScriptAction::SpawnInZone { zone: zone.to_string(), kind: kind.to_string(), side: side as i32, count: count.clamp(1, 10) as i32, level: level.clamp(1, 10) as i32 });
}
});
let zone_side_output = output.clone();
engine.register_fn("spawn_count_in_zone", move |_: &mut MissionScript, zone: &str, kind: &str, count: i64, side: &str, level: i64| {
if let Some(side) = parse_side_name(side) {
if let Ok(mut m) = zone_side_output.lock() {
m.actions.push(ScriptAction::SpawnInZone { zone: zone.to_string(), kind: kind.to_string(), side, count: count.clamp(1, 10) as i32, level: level.clamp(1, 10) as i32 });
}
}
});
let wave_output = output.clone();
engine.register_fn("spawn_wave", move |_: &mut MissionScript, count: i64| {
if let Ok(mut m) = wave_output.lock() {
m.actions.push(ScriptAction::Wave { count: count.clamp(1, 20) as i32 });
}
});
engine
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn a_mission_script_can_log_and_change_the_objective() {
let source = r#"
fn on_start(m) {
m.log("Die Wachstube ist offen.");
m.objective("Finde drei Scherben.");
}
"#;
let m = run_start(source).expect("the script runs");
assert_eq!(m.messages(), vec!["Die Wachstube ist offen.".to_string()]);
assert_eq!(m.objective().as_deref(), Some("Finde drei Scherben."));
}
#[test]
fn a_mission_script_can_record_spawn_actions_in_zones() {
let source = r#"
fn on_start(m) {
m.spawn_count_in_zone("north_gate", "Knight", 3, "HEROES", 2);
}
"#;
let m = run_start(source).expect("the script runs");
assert_eq!(m.actions(), vec![ScriptAction::SpawnInZone { zone: "north_gate".into(), kind: "Knight".into(), side: 1, count: 3, level: 2 }]);
}
#[test]
fn a_mission_script_can_record_spawn_actions() {
let source = r#"
fn on_start(m) {
m.spawn_at("HEROES", "Knight", 21.5, 21.5, 3, 2);
}
"#;
let m = run_start(source).expect("the script runs");
assert_eq!(m.actions(), vec![ScriptAction::Spawn { side: 1, kind: "Knight".into(), x: 21.5, y: 21.5, count: 3, level: 2 }]);
}
#[test]
fn a_mission_script_can_request_a_wave() {
let source = r#"
fn on_start(m) {
m.spawn_wave(3);
}
"#;
let m = run_start(source).expect("the script runs");
assert_eq!(m.actions(), vec![ScriptAction::Wave { count: 3 }]);
}
#[test]
fn a_script_without_the_hook_is_allowed() {
let m = run_hook("fn unrelated(m) { m.log('x'); }", "on_start", MissionScript::new()).expect("no hook call");
assert!(m.messages().is_empty());
}
#[test]
fn a_broken_script_returns_an_error() {
let source = r#"
fn on_start(m) {
m.log("broken
}
"#;
let error = run_start(source).expect_err("the parser rejects this script");
assert!(!error.is_empty());
}
}
+1
View File
@@ -86,6 +86,7 @@ impl Game {
self.sfx_ui(Sfx::Error);
return;
}
self.script_event(crate::mscript::ScriptEvent::Spell(crate::mscript::spell_name(spell)));
let r2 = spell.radius() * spell.radius();
let near = |e: &Ent| (e.x - wx).powi(2) + (e.y - wy).powi(2) < r2;
match spell {