diff --git a/lang/de.lang b/lang/de.lang index 3a4b5a7..a60d466 100644 --- a/lang/de.lang +++ b/lang/de.lang @@ -882,6 +882,8 @@ msg.tool_research = Noch nicht erforscht - Kreaturen in der Bibliothek arbeiten msg.script = {0} msg.script_objective = Ziel: {0} msg.script_error = Skriptfehler: {0} +msg.script_hint = Krummfinger: {0} +msg.mission_start_custom = Mission: {0} msg.script_language = Unbekannte Missionssprache: {0} # spells diff --git a/lang/en.lang b/lang/en.lang index 097d41d..3ac6235 100644 --- a/lang/en.lang +++ b/lang/en.lang @@ -883,6 +883,8 @@ msg.tool_research = Not researched yet - creatures studying in the library work msg.script = {0} msg.script_objective = Objective: {0} msg.script_error = Script error: {0} +msg.script_hint = Crookfinger: {0} +msg.mission_start_custom = Mission: {0} msg.script_language = Unsupported mission script language: {0} # spells diff --git a/lang/es.lang b/lang/es.lang index a79e303..055e623 100644 --- a/lang/es.lang +++ b/lang/es.lang @@ -883,6 +883,8 @@ msg.tool_research = Aún no investigado: las criaturas que estudian en la biblio msg.script = {0} msg.script_objective = Objetivo: {0} msg.script_error = Error de script: {0} +msg.script_hint = Dedotuerto: {0} +msg.mission_start_custom = Misión: {0} msg.script_language = Lenguaje de script de misión no admitido: {0} # spells diff --git a/lang/it.lang b/lang/it.lang index 539a362..229a233 100644 --- a/lang/it.lang +++ b/lang/it.lang @@ -883,6 +883,8 @@ msg.tool_research = Non ancora ricercato - ci lavorano le creature che studiano msg.script = {0} msg.script_objective = Obiettivo: {0} msg.script_error = Errore di script: {0} +msg.script_hint = Ditastorte: {0} +msg.mission_start_custom = Missione: {0} msg.script_language = Linguaggio di script della missione non supportato: {0} # spells diff --git a/lang/ru.lang b/lang/ru.lang index ce3d1aa..85d8e1e 100644 --- a/lang/ru.lang +++ b/lang/ru.lang @@ -883,6 +883,8 @@ msg.tool_research = Ещё не исследовано - над этим раб msg.script = {0} msg.script_objective = Задача: {0} msg.script_error = Ошибка скрипта: {0} +msg.script_hint = Кривопал: {0} +msg.mission_start_custom = Миссия: {0} msg.script_language = Неподдерживаемый язык скрипта миссии: {0} # spells diff --git a/src/blocks.rs b/src/blocks.rs new file mode 100644 index 0000000..e43776b --- /dev/null +++ b/src/blocks.rs @@ -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, #[serde(default)] els: Vec }, + Repeat { times: Val, body: Vec }, + 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, b: Box }, + Math { op: String, a: Box, b: Box }, + Cmp { op: String, a: Box, b: Box }, + And { a: Box, b: Box }, + Or { a: Box, b: Box }, + Not { a: Box }, + 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 { + 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 { + 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, + pub key: &'static str, +} + +fn check_val(v: &Val, want: Ty, n: &Names, path: &[usize], out: &mut Vec) { + 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, out: &mut Vec) { + for (k, b) in list.iter().enumerate() { + path.push(k); + let p = |key, path: &Vec| 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 { + 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) { + 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 (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::(&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); + } + } +} diff --git a/src/campaign.rs b/src/campaign.rs index ee3e24b..fdc9983 100644 --- a/src/campaign.rs +++ b/src/campaign.rs @@ -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 = 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 { + 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 { diff --git a/src/campaign_doc.rs b/src/campaign_doc.rs index 462c4cd..d7ac62d 100644 --- a/src/campaign_doc.rs +++ b/src/campaign_doc.rs @@ -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, pub completed_choice: Vec>, + // 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, + // 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, #[serde(default)] - pub script: Option, + pub script: Option, // 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, // once when the mission begins + #[serde(default)] + pub victory: Vec, // every second: may call win + #[serde(default)] + pub defeat: Vec, // every second: may call lose + #[serde(default)] + pub tick: Vec, // every second + #[serde(default)] + pub triggers: Vec, +} + +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, +} + +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 { + 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, } -#[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 { + self.zones.iter().map(|z| z.name.clone()).collect() + } + + pub fn trigger_names(&self) -> Vec { + self.scripts.triggers.iter().map(|t| t.name.clone()).collect() + } + + // every variable some script of the mission sets + pub fn vars(&self) -> Vec { + 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 { + 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 { 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()); } diff --git a/src/game.rs b/src/game.rs index 64c936c..f254e9b 100644 --- a/src/game.rs +++ b/src/game.rs @@ -541,6 +541,10 @@ pub struct Game { #[serde(default)] pub mission: Option, // campaign mission (None: free game) #[serde(default)] + pub script: Option, // block scripts of an editor mission + #[serde(default)] + pub objective: Option, // the objective a mission script set + #[serde(default)] pub rivals: Vec, // 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 { diff --git a/src/main.rs b/src/main.rs index 4f635fa..90c34d5 100644 --- a/src/main.rs +++ b/src/main.rs @@ -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; diff --git a/src/mscript.rs b/src/mscript.rs new file mode 100644 index 0000000..3160ef1 --- /dev/null +++ b/src/mscript.rs @@ -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, + pub vars: BTreeMap, + pub enabled: Vec, + pub done: Vec, + inside: Vec>, // zone and line triggers: units inside / on the far side + sides: Vec>, // line triggers: the side of the line each unit was on + last: Vec, // per trigger: last measured value or the next timer time + alive: Vec, // living units at the last check + towers: Vec<(u32, bool)>, // towers at the last check (id, knights') + rooms: Vec, // room tiles per room kind at the last check + known: usize, // tools known at the last check + pub events: Vec, + 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, + #[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, + pos: Option<(f32, f32)>, +} + +struct Ctx { + game: *const Game, + vars: BTreeMap, + acts: Vec, + event: EventInfo, + rng: u32, +} + +thread_local! { + static CTX: RefCell = 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> = RefCell::new(None); +} + +// reads the game while a script runs (the pointer is set by run_fn around every call) +fn with_game(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 { + 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 { + 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::("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, rng: &mut u32, event: EventInfo) -> Result, 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::(&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, 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) { + 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)> = 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::())); + 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)>) { + 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 = s.alive.iter().filter(|o| !self.ents.iter().any(|e| e.id == o.id && e.alive && e.captive == 0)).cloned().collect(); + let fallen: Vec = s.towers.iter().filter(|(id, _)| !self.towers.iter().any(|t| t.id == *id)).map(|&(_, hero)| hero).collect(); + let rooms_grown: Vec = (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 = 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 { + // {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 = 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 { + 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) -> 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"); + } +} diff --git a/src/script.rs b/src/script.rs deleted file mode 100644 index cc91f69..0000000 --- a/src/script.rs +++ /dev/null @@ -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, - objective: Option, - actions: Vec, -} - -impl MissionScript { - pub fn new() -> MissionScript { - MissionScript { messages: Vec::new(), objective: None, actions: Vec::new() } - } - - pub fn messages(&self) -> Vec { - self.messages.clone() - } - - pub fn objective(&self) -> Option { - self.objective.clone() - } - - pub fn actions(&self) -> Vec { - 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 { - 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 { - 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 { - run_hook(source, "on_start", MissionScript::new()) -} - -pub fn run_hook(source: &str, hook: &str, initial: MissionScript) -> Result { - 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::(&mut scope, &code).map_err(|e| e.to_string())?; - if let Some(returned) = returned.try_cast::() { - 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>) -> Engine { - let mut engine = Engine::new(); - engine.set_max_operations(200_000); - engine.register_type_with_name::("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()); - } -} diff --git a/src/spells.rs b/src/spells.rs index 0e58e03..32ddc89 100644 --- a/src/spells.rs +++ b/src/spells.rs @@ -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 {