Block scripts and triggers: data model, Rhai translation and runtime
Editor missions keep a painted map or roll a random map per campaign run, leave out goals they cannot have, and run their triggers in the fixed step. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
5315709776
commit
5701124c4f
13 files changed
+1835
-483
No files matched your search
@@ -882,6 +882,8 @@ msg.tool_research = Noch nicht erforscht - Kreaturen in der Bibliothek arbeiten
|
|||||||
msg.script = {0}
|
msg.script = {0}
|
||||||
msg.script_objective = Ziel: {0}
|
msg.script_objective = Ziel: {0}
|
||||||
msg.script_error = Skriptfehler: {0}
|
msg.script_error = Skriptfehler: {0}
|
||||||
|
msg.script_hint = Krummfinger: {0}
|
||||||
|
msg.mission_start_custom = Mission: {0}
|
||||||
msg.script_language = Unbekannte Missionssprache: {0}
|
msg.script_language = Unbekannte Missionssprache: {0}
|
||||||
|
|
||||||
# spells
|
# spells
|
||||||
|
|||||||
@@ -883,6 +883,8 @@ msg.tool_research = Not researched yet - creatures studying in the library work
|
|||||||
msg.script = {0}
|
msg.script = {0}
|
||||||
msg.script_objective = Objective: {0}
|
msg.script_objective = Objective: {0}
|
||||||
msg.script_error = Script error: {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}
|
msg.script_language = Unsupported mission script language: {0}
|
||||||
|
|
||||||
# spells
|
# spells
|
||||||
|
|||||||
@@ -883,6 +883,8 @@ msg.tool_research = Aún no investigado: las criaturas que estudian en la biblio
|
|||||||
msg.script = {0}
|
msg.script = {0}
|
||||||
msg.script_objective = Objetivo: {0}
|
msg.script_objective = Objetivo: {0}
|
||||||
msg.script_error = Error de script: {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}
|
msg.script_language = Lenguaje de script de misión no admitido: {0}
|
||||||
|
|
||||||
# spells
|
# spells
|
||||||
|
|||||||
@@ -883,6 +883,8 @@ msg.tool_research = Non ancora ricercato - ci lavorano le creature che studiano
|
|||||||
msg.script = {0}
|
msg.script = {0}
|
||||||
msg.script_objective = Obiettivo: {0}
|
msg.script_objective = Obiettivo: {0}
|
||||||
msg.script_error = Errore di script: {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}
|
msg.script_language = Linguaggio di script della missione non supportato: {0}
|
||||||
|
|
||||||
# spells
|
# spells
|
||||||
|
|||||||
@@ -883,6 +883,8 @@ msg.tool_research = Ещё не исследовано - над этим раб
|
|||||||
msg.script = {0}
|
msg.script = {0}
|
||||||
msg.script_objective = Задача: {0}
|
msg.script_objective = Задача: {0}
|
||||||
msg.script_error = Ошибка скрипта: {0}
|
msg.script_error = Ошибка скрипта: {0}
|
||||||
|
msg.script_hint = Кривопал: {0}
|
||||||
|
msg.mission_start_custom = Миссия: {0}
|
||||||
msg.script_language = Неподдерживаемый язык скрипта миссии: {0}
|
msg.script_language = Неподдерживаемый язык скрипта миссии: {0}
|
||||||
|
|
||||||
# spells
|
# spells
|
||||||
|
|||||||
+533
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -589,6 +589,98 @@ fn start_mission_def(d: &MissionDef, i: usize, carry: Option<&Carry>, coop: bool
|
|||||||
g
|
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
|
// 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
|
// depend on rival masters as their only goal
|
||||||
pub fn is_coop_mission(i: usize) -> bool {
|
pub fn is_coop_mission(i: usize) -> bool {
|
||||||
|
|||||||
+254
-11
@@ -1,19 +1,23 @@
|
|||||||
// Data model for shareable campaign files (.dccamp).
|
// Data model for shareable campaign files (.dccamp): missions with their map (a fixed,
|
||||||
// This is the first editor layer: JSON missions, branch goals, scripts and trigger zones.
|
// painted map or generator settings for a random map), goals and branches, trigger zones,
|
||||||
// The gameplay-side campaign start for custom missions comes in a later step.
|
// and the block scripts (special scripts and triggers, see blocks.rs and mscript.rs).
|
||||||
|
|
||||||
|
use crate::blocks::Block;
|
||||||
use crate::game::Goal;
|
use crate::game::Goal;
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use std::collections::HashSet;
|
use std::collections::HashSet;
|
||||||
|
|
||||||
pub const CAMPAIGN_FORMAT: &str = "dungeoncraft.campaign";
|
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";
|
pub const CAMPAIGN_EXTENSION: &str = "dccamp";
|
||||||
|
|
||||||
#[derive(Clone, Debug, Default, Serialize, Deserialize)]
|
#[derive(Clone, Debug, Default, Serialize, Deserialize)]
|
||||||
pub struct CampaignProgress {
|
pub struct CampaignProgress {
|
||||||
pub done: Vec<bool>,
|
pub done: Vec<bool>,
|
||||||
pub completed_choice: Vec<Option<String>>,
|
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 {
|
impl CampaignProgress {
|
||||||
@@ -22,6 +26,7 @@ impl CampaignProgress {
|
|||||||
CampaignProgress {
|
CampaignProgress {
|
||||||
done: vec![false; n],
|
done: vec![false; n],
|
||||||
completed_choice: vec![None; 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,
|
pub author: String,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub missions: Vec<MissionDoc>,
|
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)]
|
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||||
@@ -94,7 +102,162 @@ pub struct MissionDoc {
|
|||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub zones: Vec<ZoneDoc>,
|
pub zones: Vec<ZoneDoc>,
|
||||||
#[serde(default)]
|
#[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)]
|
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
@@ -111,7 +274,7 @@ pub struct BranchDoc {
|
|||||||
pub priority: Option<u32>,
|
pub priority: Option<u32>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
|
||||||
pub struct ZoneDoc {
|
pub struct ZoneDoc {
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub name: String,
|
pub name: String,
|
||||||
@@ -144,9 +307,10 @@ impl Default for CampaignDoc {
|
|||||||
CampaignDoc {
|
CampaignDoc {
|
||||||
format: default_format(),
|
format: default_format(),
|
||||||
version: default_version(),
|
version: default_version(),
|
||||||
name: "Neue Kampagne".into(),
|
name: "Campaign".into(),
|
||||||
author: String::new(),
|
author: String::new(),
|
||||||
missions: Vec::new(),
|
missions: Vec::new(),
|
||||||
|
language: String::new(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -171,6 +335,10 @@ impl Default for MissionDoc {
|
|||||||
branches: Vec::new(),
|
branches: Vec::new(),
|
||||||
zones: Vec::new(),
|
zones: Vec::new(),
|
||||||
script: None,
|
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 {
|
pub fn add_mission(&mut self) -> &mut MissionDoc {
|
||||||
let n = self.missions.len() + 1;
|
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 {
|
self.missions.push(MissionDoc {
|
||||||
key: format!("m{n:02}"),
|
key: format!("m{k:02}"),
|
||||||
title: format!("Mission {n}"),
|
title: format!("Mission {n}"),
|
||||||
seed: (n as u32) * 101,
|
seed: (k as u32) * 101,
|
||||||
goals: vec!["Shards".into()],
|
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()
|
..Default::default()
|
||||||
});
|
});
|
||||||
self.missions.last_mut().expect("the new mission is last")
|
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()));
|
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 {
|
for branch in &mission.branches {
|
||||||
if !valid_key(&branch.id) {
|
if !valid_key(&branch.id) {
|
||||||
errors.push(format!("mission {} has an invalid branch id: {:?}", mission.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> {
|
pub fn parse_list(text: &str) -> Vec<String> {
|
||||||
text.split(',').map(|s| s.trim().to_string()).filter(|s| !s.is_empty()).collect()
|
text.split(',').map(|s| s.trim().to_string()).filter(|s| !s.is_empty()).collect()
|
||||||
}
|
}
|
||||||
@@ -527,7 +770,7 @@ mod tests {
|
|||||||
campaign.add_mission();
|
campaign.add_mission();
|
||||||
assert_eq!(campaign.missions.len(), 1);
|
assert_eq!(campaign.missions.len(), 1);
|
||||||
assert_eq!(campaign.missions[0].key, "m01");
|
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());
|
assert!(campaign.validate().is_empty());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+13
-1
@@ -541,6 +541,10 @@ pub struct Game {
|
|||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub mission: Option<MissionState>, // campaign mission (None: free game)
|
pub mission: Option<MissionState>, // campaign mission (None: free game)
|
||||||
#[serde(default)]
|
#[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
|
pub rivals: Vec<crate::rival::Rival>, // AI dungeon masters
|
||||||
#[serde(default = "yes")]
|
#[serde(default = "yes")]
|
||||||
pub gate_on: bool, // free game: the humans' gate exists
|
pub gate_on: bool, // free game: the humans' gate exists
|
||||||
@@ -866,6 +870,8 @@ impl Game {
|
|||||||
towers_razed: 0,
|
towers_razed: 0,
|
||||||
contested: Vec::new(),
|
contested: Vec::new(),
|
||||||
mission: None,
|
mission: None,
|
||||||
|
script: None,
|
||||||
|
objective: None,
|
||||||
rivals: Vec::new(),
|
rivals: Vec::new(),
|
||||||
gate_on: true,
|
gate_on: true,
|
||||||
springs: 0,
|
springs: 0,
|
||||||
@@ -1340,6 +1346,11 @@ impl Game {
|
|||||||
self.switch_to(side);
|
self.switch_to(side);
|
||||||
self.sync_treasury();
|
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;
|
let local = self.local_side;
|
||||||
self.switch_to(local);
|
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
|
// 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 lava = self.map.t[i].kind == Kind::Lava;
|
||||||
let (x, y) = xy(i);
|
let (x, y) = xy(i);
|
||||||
for j in 0..self.ents.len() {
|
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.fx(2, x, y, 0.0, 0.0, 0.3, 0xffffffff);
|
||||||
self.sfx(Sfx::Slap, x, y);
|
self.sfx(Sfx::Slap, x, y);
|
||||||
|
self.script_event(crate::mscript::ScriptEvent::Slap);
|
||||||
return 1;
|
return 1;
|
||||||
}
|
}
|
||||||
if self.ents[j].captive != 0 {
|
if self.ents[j].captive != 0 {
|
||||||
|
|||||||
+12
-225
@@ -26,7 +26,8 @@ mod rival;
|
|||||||
mod render3d;
|
mod render3d;
|
||||||
mod rooms;
|
mod rooms;
|
||||||
mod save;
|
mod save;
|
||||||
mod script;
|
mod blocks;
|
||||||
|
mod mscript;
|
||||||
mod settings;
|
mod settings;
|
||||||
mod sfx;
|
mod sfx;
|
||||||
mod tracks;
|
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()
|
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) {
|
// a mission of an editor campaign (its map is painted, or rolled from the run's seed)
|
||||||
let g = campaign::start_mission_doc(def, i, None);
|
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 (cx, cy) = g.map.core;
|
||||||
let cam = Cam::new(cx as f32 + 1.5, cy as f32 + 1.5);
|
let cam = Cam::new(cx as f32 + 1.5, cy as f32 + 1.5);
|
||||||
(g, cam)
|
(g, cam)
|
||||||
@@ -412,192 +414,6 @@ fn apply_game_settings(g: &mut Game, s: &Settings) {
|
|||||||
g.debug_markers = s.debug_command_markers;
|
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)
|
// "dungeoncraft_2026-11-02_2145.dcsave" (UTC)
|
||||||
fn save_file_name() -> String {
|
fn save_file_name() -> String {
|
||||||
let (y, m, d, h, mi) = cloud::civil(miniquad::date::now() as i64);
|
let (y, m, d, h, mi) = cloud::civil(miniquad::date::now() as i64);
|
||||||
@@ -1464,32 +1280,7 @@ async fn main() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
MenuAction::CampaignEditScript => {
|
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::CampaignDelete(i) => {
|
MenuAction::CampaignDelete(i) => {
|
||||||
if i < menu.campaign_doc.missions.len() {
|
if i < menu.campaign_doc.missions.len() {
|
||||||
menu.campaign_doc.missions.remove(i);
|
menu.campaign_doc.missions.remove(i);
|
||||||
@@ -1506,15 +1297,15 @@ async fn main() {
|
|||||||
MenuAction::CampaignStart(i) => {
|
MenuAction::CampaignStart(i) => {
|
||||||
if let Some(mission_doc) = menu.campaign_doc.missions.get(i) {
|
if let Some(mission_doc) = menu.campaign_doc.missions.get(i) {
|
||||||
if menu.campaign_doc.validate().is_empty() {
|
if menu.campaign_doc.validate().is_empty() {
|
||||||
let def = campaign::mission_def_from_doc(mission_doc);
|
let _ = mission_doc;
|
||||||
(app.game, app.cam) = custom_mission_game(&def, i);
|
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);
|
apply_game_settings(&mut app.game, &app.settings);
|
||||||
start_custom_script(&mut app.game, mission_doc);
|
|
||||||
app.game.coop = false;
|
app.game.coop = false;
|
||||||
app.coop = false;
|
app.coop = false;
|
||||||
app.recorded = false;
|
app.recorded = false;
|
||||||
app.custom_campaign = Some(menu.campaign_doc.clone());
|
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);
|
app.custom_mission_index = Some(i);
|
||||||
menu.page = None;
|
menu.page = None;
|
||||||
ui.log.clear();
|
ui.log.clear();
|
||||||
@@ -1595,10 +1386,8 @@ async fn main() {
|
|||||||
if let Some(next) = app.custom_next_index {
|
if let Some(next) = app.custom_next_index {
|
||||||
if let Some(campaign) = campaign {
|
if let Some(campaign) = campaign {
|
||||||
if next < campaign.missions.len() {
|
if next < campaign.missions.len() {
|
||||||
let def = campaign::mission_def_from_doc(&campaign.missions[next]);
|
(app.game, app.cam) = custom_mission_game(&campaign, next, progress.as_ref().map_or(1, |p| p.run_seed));
|
||||||
(app.game, app.cam) = custom_mission_game(&def, next);
|
|
||||||
apply_game_settings(&mut app.game, &app.settings);
|
apply_game_settings(&mut app.game, &app.settings);
|
||||||
start_custom_script(&mut app.game, &campaign.missions[next]);
|
|
||||||
app.game.coop = false;
|
app.game.coop = false;
|
||||||
app.coop = false;
|
app.coop = false;
|
||||||
app.recorded = false;
|
app.recorded = false;
|
||||||
@@ -1620,10 +1409,8 @@ async fn main() {
|
|||||||
}
|
}
|
||||||
} else if let (Some(campaign), Some(i)) = (campaign, app.custom_mission_index) {
|
} else if let (Some(campaign), Some(i)) = (campaign, app.custom_mission_index) {
|
||||||
if i < campaign.missions.len() {
|
if i < campaign.missions.len() {
|
||||||
let def = campaign::mission_def_from_doc(&campaign.missions[i]);
|
(app.game, app.cam) = custom_mission_game(&campaign, i, progress.as_ref().map_or(1, |p| p.run_seed));
|
||||||
(app.game, app.cam) = custom_mission_game(&def, i);
|
|
||||||
apply_game_settings(&mut app.game, &app.settings);
|
apply_game_settings(&mut app.game, &app.settings);
|
||||||
start_custom_script(&mut app.game, &campaign.missions[i]);
|
|
||||||
app.game.coop = false;
|
app.game.coop = false;
|
||||||
app.coop = false;
|
app.coop = false;
|
||||||
app.recorded = false;
|
app.recorded = false;
|
||||||
|
|||||||
+920
@@ -0,0 +1,920 @@
|
|||||||
|
// Mission scripts at run time: the block scripts of a mission (campaign_doc::MissionScripts)
|
||||||
|
// become one Rhai program; the triggers are checked ten times a second inside the fixed
|
||||||
|
// simulation step, so a co-op match runs them identically on every computer.
|
||||||
|
//
|
||||||
|
// The program never touches the game directly: reading functions (m.gold(), m.in_zone(..))
|
||||||
|
// look at the game through a pointer that is valid while a script runs, and everything a
|
||||||
|
// script does is collected as actions that are applied afterwards.
|
||||||
|
|
||||||
|
use std::cell::RefCell;
|
||||||
|
use std::collections::BTreeMap;
|
||||||
|
|
||||||
|
use rhai::{Engine, Scope, AST};
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
use crate::blocks::function_code;
|
||||||
|
use crate::campaign_doc::{MissionScripts, TriggerKind, ZoneDoc};
|
||||||
|
use crate::game::*;
|
||||||
|
use crate::map::*;
|
||||||
|
|
||||||
|
const CHECK_STEPS: u64 = 5; // triggers are checked every 5 fixed steps (0.1 s)
|
||||||
|
const MAX_LOG: usize = 60;
|
||||||
|
|
||||||
|
// pending events the simulation reports (the rest is found by comparing two checks)
|
||||||
|
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
|
||||||
|
pub enum ScriptEvent {
|
||||||
|
Spell(String),
|
||||||
|
Slap,
|
||||||
|
}
|
||||||
|
|
||||||
|
// a unit as the last check saw it
|
||||||
|
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||||
|
struct Seen {
|
||||||
|
id: u32,
|
||||||
|
kind: EK,
|
||||||
|
side: u8,
|
||||||
|
x: f32,
|
||||||
|
y: f32,
|
||||||
|
}
|
||||||
|
|
||||||
|
// the scripts of a running mission and what they remember (saved with the game)
|
||||||
|
#[derive(Clone, Debug, Default, Serialize, Deserialize)]
|
||||||
|
pub struct ScriptState {
|
||||||
|
pub scripts: MissionScripts,
|
||||||
|
pub zones: Vec<ZoneDoc>,
|
||||||
|
pub vars: BTreeMap<String, f64>,
|
||||||
|
pub enabled: Vec<bool>,
|
||||||
|
pub done: Vec<bool>,
|
||||||
|
inside: Vec<Vec<u32>>, // zone and line triggers: units inside / on the far side
|
||||||
|
sides: Vec<Vec<(u32, i8)>>, // line triggers: the side of the line each unit was on
|
||||||
|
last: Vec<f64>, // per trigger: last measured value or the next timer time
|
||||||
|
alive: Vec<Seen>, // living units at the last check
|
||||||
|
towers: Vec<(u32, bool)>, // towers at the last check (id, knights')
|
||||||
|
rooms: Vec<usize>, // room tiles per room kind at the last check
|
||||||
|
known: usize, // tools known at the last check
|
||||||
|
pub events: Vec<ScriptEvent>,
|
||||||
|
pub names: Vec<(String, u32)>, // units named by spawn blocks
|
||||||
|
pub log: Vec<(f32, String)>, // what fired when (debug view)
|
||||||
|
started: bool,
|
||||||
|
next_second: f32,
|
||||||
|
rng: u32,
|
||||||
|
pub error: Option<String>,
|
||||||
|
#[serde(skip)]
|
||||||
|
pub camera: Option<(f32, f32)>, // a block asked the camera to look here (taken by main)
|
||||||
|
}
|
||||||
|
|
||||||
|
// what a running script asks for
|
||||||
|
#[derive(Clone, Debug, PartialEq)]
|
||||||
|
enum Act {
|
||||||
|
Message(String),
|
||||||
|
Objective(String),
|
||||||
|
Hint(String),
|
||||||
|
Spawn { side: i32, kind: String, count: i64, level: i64, place: String, zone: String, name: String },
|
||||||
|
Wave(i64),
|
||||||
|
Gold(f64),
|
||||||
|
Mana(f64),
|
||||||
|
Win,
|
||||||
|
Lose,
|
||||||
|
Reveal(String),
|
||||||
|
Camera(String),
|
||||||
|
Terrain(String, String),
|
||||||
|
Kill(String, i32),
|
||||||
|
Trigger(String, bool),
|
||||||
|
}
|
||||||
|
|
||||||
|
// the triggering unit (for "where the unit is" and "the unit is a ...")
|
||||||
|
#[derive(Clone, Debug, Default)]
|
||||||
|
struct EventInfo {
|
||||||
|
kind: Option<EK>,
|
||||||
|
pos: Option<(f32, f32)>,
|
||||||
|
}
|
||||||
|
|
||||||
|
struct Ctx {
|
||||||
|
game: *const Game,
|
||||||
|
vars: BTreeMap<String, f64>,
|
||||||
|
acts: Vec<Act>,
|
||||||
|
event: EventInfo,
|
||||||
|
rng: u32,
|
||||||
|
}
|
||||||
|
|
||||||
|
thread_local! {
|
||||||
|
static CTX: RefCell<Ctx> = RefCell::new(Ctx { game: std::ptr::null(), vars: BTreeMap::new(), acts: Vec::new(), event: EventInfo::default(), rng: 1 });
|
||||||
|
// the compiled program of the running mission (key: the program text)
|
||||||
|
static PROGRAM: RefCell<Option<(String, Engine, AST)>> = RefCell::new(None);
|
||||||
|
}
|
||||||
|
|
||||||
|
// reads the game while a script runs (the pointer is set by run_fn around every call)
|
||||||
|
fn with_game<R>(f: impl FnOnce(&Game) -> R, fallback: R) -> R {
|
||||||
|
CTX.with(|c| {
|
||||||
|
let p = c.borrow().game;
|
||||||
|
if p.is_null() {
|
||||||
|
fallback
|
||||||
|
} else {
|
||||||
|
// SAFETY: run_fn sets the pointer from a live &Game for the duration of the call
|
||||||
|
// and clears it afterwards; scripts run on this thread only
|
||||||
|
f(unsafe { &*p })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn act(a: Act) {
|
||||||
|
CTX.with(|c| c.borrow_mut().acts.push(a));
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn kind_by_name(name: &str) -> Option<EK> {
|
||||||
|
let n = name.trim().to_lowercase();
|
||||||
|
crate::blocks::KINDS.iter().position(|k| k.to_lowercase() == n).map(|i| match i {
|
||||||
|
0 => EK::Worker,
|
||||||
|
1 => EK::Kobold,
|
||||||
|
2 => EK::Spider,
|
||||||
|
3 => EK::Mage,
|
||||||
|
4 => EK::Ogre,
|
||||||
|
5 => EK::Jailer,
|
||||||
|
6 => EK::Priestess,
|
||||||
|
7 => EK::Chainbreaker,
|
||||||
|
8 => EK::Bat,
|
||||||
|
9 => EK::Gargoyle,
|
||||||
|
10 => EK::Owl,
|
||||||
|
11 => EK::Wyrm,
|
||||||
|
12 => EK::Cogling,
|
||||||
|
13 => EK::Revenant,
|
||||||
|
14 => EK::Knight,
|
||||||
|
15 => EK::Archer,
|
||||||
|
16 => EK::Priest,
|
||||||
|
17 => EK::Dwarf,
|
||||||
|
18 => EK::Paladin,
|
||||||
|
19 => EK::Giant,
|
||||||
|
20 => EK::Fairy,
|
||||||
|
_ => EK::BattleMage,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// does a unit match a side (-1 any) and a kind name ("" any)?
|
||||||
|
fn matches(e_side: u8, e_kind: EK, side: i32, kind: &str) -> bool {
|
||||||
|
(side < 0 || e_side as i32 == side) && (kind.is_empty() || kind_by_name(kind) == Some(e_kind))
|
||||||
|
}
|
||||||
|
|
||||||
|
// ------------------------------------------------------------------ zones
|
||||||
|
|
||||||
|
pub fn zone_contains(z: &ZoneDoc, x: f32, y: f32) -> bool {
|
||||||
|
match z.shape.as_str() {
|
||||||
|
"rect" => match (z.from, z.to) {
|
||||||
|
(Some(a), Some(b)) => x >= a.0.min(b.0) && x < a.0.max(b.0) + 1.0 && y >= a.1.min(b.1) && y < a.1.max(b.1) + 1.0,
|
||||||
|
_ => false,
|
||||||
|
},
|
||||||
|
"tiles" => z.tiles.as_ref().map_or(false, |t| t.contains(&(x.floor() as i32, y.floor() as i32))),
|
||||||
|
"line" => false,
|
||||||
|
_ => {
|
||||||
|
let (cx, cy) = z.at.unwrap_or((0.0, 0.0));
|
||||||
|
let r = z.radius.unwrap_or(2.0);
|
||||||
|
(x - cx - 0.5).powi(2) + (y - cy - 0.5).powi(2) <= r * r
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// the tiles of a zone (a line: the tiles it passes)
|
||||||
|
pub fn zone_tiles(z: &ZoneDoc) -> Vec<usize> {
|
||||||
|
let mut out = Vec::new();
|
||||||
|
if z.shape == "line" {
|
||||||
|
if let (Some(a), Some(b)) = (z.from, z.to) {
|
||||||
|
let n = ((b.0 - a.0).abs().max((b.1 - a.1).abs()) * 2.0).ceil().max(1.0) as usize;
|
||||||
|
for k in 0..=n {
|
||||||
|
let t = k as f32 / n as f32;
|
||||||
|
let (x, y) = ((a.0 + (b.0 - a.0) * t + 0.5).floor() as i32, (a.1 + (b.1 - a.1) * t + 0.5).floor() as i32);
|
||||||
|
if inb(x, y) && !out.contains(&idx(x, y)) {
|
||||||
|
out.push(idx(x, y));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
if let Some(t) = z.tiles.as_ref().filter(|_| z.shape == "tiles") {
|
||||||
|
return t.iter().filter(|&&(x, y)| inb(x, y)).map(|&(x, y)| idx(x, y)).collect();
|
||||||
|
}
|
||||||
|
let (x0, y0, x1, y1) = match (z.shape.as_str(), z.from, z.to) {
|
||||||
|
("rect", Some(a), Some(b)) => (a.0.min(b.0) as i32, a.1.min(b.1) as i32, a.0.max(b.0) as i32, a.1.max(b.1) as i32),
|
||||||
|
_ => {
|
||||||
|
let (cx, cy) = z.at.unwrap_or((0.0, 0.0));
|
||||||
|
let r = z.radius.unwrap_or(2.0).ceil() as i32;
|
||||||
|
(cx as i32 - r, cy as i32 - r, cx as i32 + r, cy as i32 + r)
|
||||||
|
}
|
||||||
|
};
|
||||||
|
for y in y0..=y1 {
|
||||||
|
for x in x0..=x1 {
|
||||||
|
if inb(x, y) && zone_contains(z, x as f32 + 0.5, y as f32 + 0.5) {
|
||||||
|
out.push(idx(x, y));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
// the middle of a zone
|
||||||
|
pub fn zone_center(z: &ZoneDoc) -> (f32, f32) {
|
||||||
|
match (z.shape.as_str(), z.from, z.to, z.at) {
|
||||||
|
("rect", Some(a), Some(b), _) | ("line", Some(a), Some(b), _) => ((a.0 + b.0) * 0.5 + 0.5, (a.1 + b.1) * 0.5 + 0.5),
|
||||||
|
(_, _, _, Some(c)) => (c.0 + 0.5, c.1 + 0.5),
|
||||||
|
_ => {
|
||||||
|
let t = zone_tiles(z);
|
||||||
|
if t.is_empty() {
|
||||||
|
(0.0, 0.0)
|
||||||
|
} else {
|
||||||
|
let (sx, sy) = t.iter().map(|&i| xy(i)).fold((0.0, 0.0), |a, (x, y)| (a.0 + x as f32 + 0.5, a.1 + y as f32 + 0.5));
|
||||||
|
(sx / t.len() as f32, sy / t.len() as f32)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// which side of a line a point is on (0: beyond its ends)
|
||||||
|
fn line_side(z: &ZoneDoc, x: f32, y: f32) -> i8 {
|
||||||
|
let (Some(a), Some(b)) = (z.from, z.to) else { return 0 };
|
||||||
|
let (ax, ay, bx, by) = (a.0 + 0.5, a.1 + 0.5, b.0 + 0.5, b.1 + 0.5);
|
||||||
|
let (dx, dy) = (bx - ax, by - ay);
|
||||||
|
let len2 = dx * dx + dy * dy;
|
||||||
|
if len2 <= 0.0 {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
let t = ((x - ax) * dx + (y - ay) * dy) / len2;
|
||||||
|
if !(-0.05..=1.05).contains(&t) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
let c = dx * (y - ay) - dy * (x - ax);
|
||||||
|
if c > 0.0 {
|
||||||
|
1
|
||||||
|
} else {
|
||||||
|
-1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ------------------------------------------------------------------ the program
|
||||||
|
|
||||||
|
// the Rhai program of a mission: one function per script and trigger
|
||||||
|
pub fn program(s: &MissionScripts) -> String {
|
||||||
|
let mut code = String::new();
|
||||||
|
code.push_str(&function_code("s_start", &s.start));
|
||||||
|
code.push_str(&function_code("s_victory", &s.victory));
|
||||||
|
code.push_str(&function_code("s_defeat", &s.defeat));
|
||||||
|
code.push_str(&function_code("s_tick", &s.tick));
|
||||||
|
for (k, t) in s.triggers.iter().enumerate() {
|
||||||
|
code.push_str(&function_code(&format!("t_{}", k), &t.script));
|
||||||
|
}
|
||||||
|
code
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone)]
|
||||||
|
struct M;
|
||||||
|
|
||||||
|
fn engine() -> Engine {
|
||||||
|
let mut e = Engine::new();
|
||||||
|
e.set_max_operations(100_000);
|
||||||
|
e.set_max_call_levels(16);
|
||||||
|
e.register_type_with_name::<M>("mission");
|
||||||
|
// reading
|
||||||
|
e.register_fn("gold", |_: &mut M| with_game(|g| g.gold as f64, 0.0));
|
||||||
|
e.register_fn("mana", |_: &mut M| with_game(|g| g.mana as f64, 0.0));
|
||||||
|
e.register_fn("time", |_: &mut M| with_game(|g| g.time as f64, 0.0));
|
||||||
|
e.register_fn("get", |_: &mut M, name: &str| CTX.with(|c| c.borrow().vars.get(name).copied().unwrap_or(0.0)));
|
||||||
|
e.register_fn("set", |_: &mut M, name: &str, v: f64| {
|
||||||
|
CTX.with(|c| {
|
||||||
|
c.borrow_mut().vars.insert(name.to_string(), if v.is_finite() { v } else { 0.0 });
|
||||||
|
})
|
||||||
|
});
|
||||||
|
e.register_fn("count", |_: &mut M, side: i64, kind: &str| {
|
||||||
|
with_game(|g| g.ents.iter().filter(|e| e.alive && e.captive == 0 && !e.chained && matches(e.side(), e.kind, side as i32, kind)).count() as f64, 0.0)
|
||||||
|
});
|
||||||
|
e.register_fn("in_zone", |_: &mut M, zone: &str, side: i64, kind: &str| {
|
||||||
|
with_game(
|
||||||
|
|g| {
|
||||||
|
let Some(z) = g.script.as_ref().and_then(|s| s.zones.iter().find(|z| z.name == zone)) else { return 0.0 };
|
||||||
|
g.ents.iter().filter(|e| e.alive && !e.held && e.captive == 0 && matches(e.side(), e.kind, side as i32, kind) && zone_contains(z, e.x, e.y)).count() as f64
|
||||||
|
},
|
||||||
|
0.0,
|
||||||
|
)
|
||||||
|
});
|
||||||
|
e.register_fn("random", |_: &mut M, a: f64, b: f64| {
|
||||||
|
CTX.with(|c| {
|
||||||
|
let mut c = c.borrow_mut();
|
||||||
|
// xorshift: deterministic, part of the saved script state
|
||||||
|
let mut x = c.rng.max(1);
|
||||||
|
x ^= x << 13;
|
||||||
|
x ^= x >> 17;
|
||||||
|
x ^= x << 5;
|
||||||
|
c.rng = x;
|
||||||
|
let (lo, hi) = (a.min(b).round(), a.max(b).round());
|
||||||
|
lo + (x as f64 % (hi - lo + 1.0)).floor()
|
||||||
|
})
|
||||||
|
});
|
||||||
|
e.register_fn("div", |_: &mut M, a: f64, b: f64| if b == 0.0 { 0.0 } else { a / b });
|
||||||
|
e.register_fn("times", |_: &mut M, n: f64| if n.is_finite() { (n.round() as i64).clamp(0, 100) } else { 0 });
|
||||||
|
e.register_fn("event_is", |_: &mut M, kind: &str| CTX.with(|c| c.borrow().event.kind.is_some() && c.borrow().event.kind == kind_by_name(kind)));
|
||||||
|
// doing
|
||||||
|
e.register_fn("message", |_: &mut M, s: &str| act(Act::Message(s.to_string())));
|
||||||
|
e.register_fn("objective", |_: &mut M, s: &str| act(Act::Objective(s.to_string())));
|
||||||
|
e.register_fn("hint", |_: &mut M, s: &str| act(Act::Hint(s.to_string())));
|
||||||
|
e.register_fn("spawn_unit", |_: &mut M, side: i64, kind: &str, count: f64, level: f64, place: &str, zone: &str, name: &str| {
|
||||||
|
act(Act::Spawn { side: side as i32, kind: kind.to_string(), count: count.round() as i64, level: level.round() as i64, place: place.to_string(), zone: zone.to_string(), name: name.to_string() })
|
||||||
|
});
|
||||||
|
e.register_fn("wave", |_: &mut M, n: f64| act(Act::Wave(n.round() as i64)));
|
||||||
|
e.register_fn("add_gold", |_: &mut M, n: f64| act(Act::Gold(n)));
|
||||||
|
e.register_fn("add_mana", |_: &mut M, n: f64| act(Act::Mana(n)));
|
||||||
|
e.register_fn("win", |_: &mut M| act(Act::Win));
|
||||||
|
e.register_fn("lose", |_: &mut M| act(Act::Lose));
|
||||||
|
e.register_fn("reveal", |_: &mut M, z: &str| act(Act::Reveal(z.to_string())));
|
||||||
|
e.register_fn("camera", |_: &mut M, z: &str| act(Act::Camera(z.to_string())));
|
||||||
|
e.register_fn("terrain", |_: &mut M, z: &str, k: &str| act(Act::Terrain(z.to_string(), k.to_string())));
|
||||||
|
e.register_fn("kill", |_: &mut M, z: &str, side: i64| act(Act::Kill(z.to_string(), side as i32)));
|
||||||
|
e.register_fn("trigger", |_: &mut M, name: &str, on: bool| act(Act::Trigger(name.to_string(), on)));
|
||||||
|
e
|
||||||
|
}
|
||||||
|
|
||||||
|
// compiles a program (for the editor's check and for running); Err: the error text
|
||||||
|
pub fn compile(code: &str) -> Result<(), String> {
|
||||||
|
engine().compile(code).map(|_| ()).map_err(|e| e.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
// runs one function of the mission's program; returns the actions or an error
|
||||||
|
fn run_fn(g: &Game, code: &str, name: &str, vars: &mut BTreeMap<String, f64>, rng: &mut u32, event: EventInfo) -> Result<Vec<Act>, String> {
|
||||||
|
PROGRAM.with(|p| {
|
||||||
|
let mut p = p.borrow_mut();
|
||||||
|
if p.as_ref().map_or(true, |(c, _, _)| c != code) {
|
||||||
|
let e = engine();
|
||||||
|
let ast = e.compile(code).map_err(|e| e.to_string())?;
|
||||||
|
*p = Some((code.to_string(), e, ast));
|
||||||
|
}
|
||||||
|
let (_, e, ast) = p.as_ref().unwrap();
|
||||||
|
CTX.with(|c| {
|
||||||
|
let mut c = c.borrow_mut();
|
||||||
|
c.game = g as *const Game;
|
||||||
|
c.vars = std::mem::take(vars);
|
||||||
|
c.acts.clear();
|
||||||
|
c.event = event;
|
||||||
|
c.rng = *rng;
|
||||||
|
});
|
||||||
|
let mut scope = Scope::new();
|
||||||
|
let r = e.call_fn::<rhai::Dynamic>(&mut scope, ast, name, (M,));
|
||||||
|
CTX.with(|c| {
|
||||||
|
let mut c = c.borrow_mut();
|
||||||
|
c.game = std::ptr::null();
|
||||||
|
*vars = std::mem::take(&mut c.vars);
|
||||||
|
*rng = c.rng;
|
||||||
|
match r {
|
||||||
|
Ok(_) => Ok(std::mem::take(&mut c.acts)),
|
||||||
|
Err(err) => Err(err.to_string()),
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ------------------------------------------------------------------ the game side
|
||||||
|
|
||||||
|
impl ScriptState {
|
||||||
|
pub fn new(scripts: MissionScripts, zones: Vec<ZoneDoc>, seed: u32) -> ScriptState {
|
||||||
|
let n = scripts.triggers.len();
|
||||||
|
ScriptState {
|
||||||
|
enabled: scripts.triggers.iter().map(|t| t.enabled).collect(),
|
||||||
|
done: vec![false; n],
|
||||||
|
inside: vec![Vec::new(); n],
|
||||||
|
sides: vec![Vec::new(); n],
|
||||||
|
last: vec![f64::NAN; n],
|
||||||
|
scripts,
|
||||||
|
zones,
|
||||||
|
rng: seed | 1,
|
||||||
|
..ScriptState::default()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn zone(&self, name: &str) -> Option<&ZoneDoc> {
|
||||||
|
self.zones.iter().find(|z| z.name == name)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn log(&mut self, t: f32, s: String) {
|
||||||
|
self.log.push((t, s));
|
||||||
|
if self.log.len() > MAX_LOG {
|
||||||
|
self.log.remove(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn cmp(op: &str, a: f64, b: f64) -> bool {
|
||||||
|
match op {
|
||||||
|
"<" => a < b,
|
||||||
|
"<=" => a <= b,
|
||||||
|
"=" | "==" => (a - b).abs() < 1e-9,
|
||||||
|
"!=" => (a - b).abs() >= 1e-9,
|
||||||
|
">" => a > b,
|
||||||
|
_ => a >= b,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// spell names for SpellCast triggers
|
||||||
|
pub fn spell_name(s: crate::spells::Spell) -> String {
|
||||||
|
format!("{:?}", s)
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Game {
|
||||||
|
// the simulation reports an event to the scripts (spells, slaps)
|
||||||
|
pub(crate) fn script_event(&mut self, ev: ScriptEvent) {
|
||||||
|
if let Some(s) = self.script.as_mut() {
|
||||||
|
s.events.push(ev);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// a message every player sees (scripts speak to all masters of a co-op match)
|
||||||
|
fn script_msg(&mut self, key: &'static str, args: Vec<String>) {
|
||||||
|
self.msgs.push(Msg { key, args });
|
||||||
|
if self.msgs.len() > 50 {
|
||||||
|
self.msgs.remove(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// called from the fixed step with the player's side active
|
||||||
|
pub(crate) fn run_scripts(&mut self) {
|
||||||
|
if self.script.is_none() || self.state != 0 || self.sim_steps % CHECK_STEPS != 0 {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let mut s = self.script.take().unwrap();
|
||||||
|
let code = program(&s.scripts);
|
||||||
|
let mut fire: Vec<(String, EventInfo, Option<usize>)> = Vec::new(); // function, event, trigger
|
||||||
|
if !s.started {
|
||||||
|
s.started = true;
|
||||||
|
s.next_second = self.time + 1.0;
|
||||||
|
self.remember(&mut s, true);
|
||||||
|
fire.push(("s_start".into(), EventInfo::default(), None));
|
||||||
|
} else {
|
||||||
|
self.check_triggers(&mut s, &mut fire);
|
||||||
|
if self.time >= s.next_second {
|
||||||
|
s.next_second += 1.0;
|
||||||
|
for f in ["s_tick", "s_victory", "s_defeat"] {
|
||||||
|
fire.push((f.into(), EventInfo::default(), None));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (f, ev, trig) in fire {
|
||||||
|
if let Some(k) = trig {
|
||||||
|
// a trigger fires: once-triggers only the first time, switched-off ones never
|
||||||
|
if !s.enabled.get(k).copied().unwrap_or(false) || (s.scripts.triggers[k].once && s.done[k]) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
s.done[k] = true;
|
||||||
|
let name = s.scripts.triggers[k].name.clone();
|
||||||
|
s.log(self.time, name);
|
||||||
|
}
|
||||||
|
let (mut vars, mut rng) = (std::mem::take(&mut s.vars), s.rng);
|
||||||
|
let r = run_fn(self, &code, &f, &mut vars, &mut rng, ev.clone());
|
||||||
|
s.vars = vars;
|
||||||
|
s.rng = rng;
|
||||||
|
match r {
|
||||||
|
Ok(acts) => {
|
||||||
|
for a in acts {
|
||||||
|
self.apply_act(&mut s, a, &ev);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
if s.error.is_none() {
|
||||||
|
self.script_msg("msg.script_error", vec![e.chars().take(120).collect()]);
|
||||||
|
}
|
||||||
|
s.log(self.time, format!("! {}", e.chars().take(80).collect::<String>()));
|
||||||
|
s.error = Some(e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if self.state != 0 {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
s.events.clear();
|
||||||
|
self.script = Some(s);
|
||||||
|
}
|
||||||
|
|
||||||
|
// what the next check compares against: living units, towers, rooms, research, zone counts
|
||||||
|
fn remember(&self, s: &mut ScriptState, first: bool) {
|
||||||
|
s.alive = self.ents.iter().filter(|e| e.alive && e.captive == 0).map(|e| Seen { id: e.id, kind: e.kind, side: e.side(), x: e.x, y: e.y }).collect();
|
||||||
|
s.towers = self.towers.iter().map(|t| (t.id, t.hero)).collect();
|
||||||
|
s.rooms = self.counts.to_vec();
|
||||||
|
s.known = self.known.iter().filter(|&&k| k).count();
|
||||||
|
if first {
|
||||||
|
for k in 0..s.scripts.triggers.len() {
|
||||||
|
let v = self.measure(s, k);
|
||||||
|
s.last[k] = v;
|
||||||
|
if let TriggerKind::Timer { secs, .. } = s.scripts.triggers[k].kind {
|
||||||
|
s.last[k] = secs.max(0.1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// the value a trigger watches (claimed tiles, solid tiles, units in a zone, gold, ...);
|
||||||
|
// conditions as 1 / 0
|
||||||
|
fn measure(&self, s: &ScriptState, k: usize) -> f64 {
|
||||||
|
let player = Owner::of_side(0);
|
||||||
|
match &s.scripts.triggers[k].kind {
|
||||||
|
TriggerKind::ZoneClaimed { zone } => s.zone(zone).map_or(0.0, |z| zone_tiles(z).iter().filter(|&&i| self.map.t[i].owner == player).count() as f64),
|
||||||
|
TriggerKind::ZoneDug { zone } => s.zone(zone).map_or(0.0, |z| zone_tiles(z).iter().filter(|&&i| self.map.t[i].solid()).count() as f64),
|
||||||
|
TriggerKind::ZoneCount { zone, side, kind, op, n } => {
|
||||||
|
let c = s.zone(zone).map_or(0, |z| self.ents.iter().filter(|e| e.alive && !e.held && e.captive == 0 && matches(e.side(), e.kind, *side, kind) && zone_contains(z, e.x, e.y)).count());
|
||||||
|
cmp(op, c as f64, *n) as i32 as f64
|
||||||
|
}
|
||||||
|
TriggerKind::Value { what, var, op, n } => {
|
||||||
|
let v = match what.as_str() {
|
||||||
|
"mana" => self.mana as f64,
|
||||||
|
"creatures" => self.ents.iter().filter(|e| e.alive && e.creature()).count() as f64,
|
||||||
|
"heroes" => self.ents.iter().filter(|e| e.alive && e.active_hero()).count() as f64,
|
||||||
|
"var" => s.vars.get(var).copied().unwrap_or(0.0),
|
||||||
|
_ => self.gold as f64,
|
||||||
|
};
|
||||||
|
cmp(op, v, *n) as i32 as f64
|
||||||
|
}
|
||||||
|
_ => 0.0,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn check_triggers(&mut self, s: &mut ScriptState, fire: &mut Vec<(String, EventInfo, Option<usize>)>) {
|
||||||
|
let info = |e: &Ent| EventInfo { kind: Some(e.kind), pos: Some((e.x, e.y)) };
|
||||||
|
let seen_info = |e: &Seen| EventInfo { kind: Some(e.kind), pos: Some((e.x, e.y)) };
|
||||||
|
// what changed since the last check
|
||||||
|
let died: Vec<Seen> = s.alive.iter().filter(|o| !self.ents.iter().any(|e| e.id == o.id && e.alive && e.captive == 0)).cloned().collect();
|
||||||
|
let fallen: Vec<bool> = s.towers.iter().filter(|(id, _)| !self.towers.iter().any(|t| t.id == *id)).map(|&(_, hero)| hero).collect();
|
||||||
|
let rooms_grown: Vec<usize> = (0..self.counts.len()).filter(|&r| self.counts[r] > s.rooms.get(r).copied().unwrap_or(0)).collect();
|
||||||
|
let researched = self.known.iter().filter(|&&k| k).count() > s.known;
|
||||||
|
for k in 0..s.scripts.triggers.len() {
|
||||||
|
let fn_name = format!("t_{}", k);
|
||||||
|
let mut go = |ev: EventInfo| fire.push((fn_name.clone(), ev, Some(k)));
|
||||||
|
let kind = s.scripts.triggers[k].kind.clone();
|
||||||
|
match &kind {
|
||||||
|
TriggerKind::ZoneEnter { zone, side, kind } | TriggerKind::ZoneLeave { zone, side, kind } => {
|
||||||
|
let Some(z) = s.zone(zone).cloned() else { continue };
|
||||||
|
let now: Vec<u32> = self.ents.iter().filter(|e| e.alive && !e.held && e.captive == 0 && matches(e.side(), e.kind, *side, kind) && zone_contains(&z, e.x, e.y)).map(|e| e.id).collect();
|
||||||
|
let enter = matches!(s.scripts.triggers[k].kind, TriggerKind::ZoneEnter { .. });
|
||||||
|
if enter {
|
||||||
|
for e in self.ents.iter().filter(|e| now.contains(&e.id) && !s.inside[k].contains(&e.id)) {
|
||||||
|
go(info(e));
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
for id in s.inside[k].iter().filter(|id| !now.contains(id)) {
|
||||||
|
match self.ents.iter().find(|e| e.id == *id) {
|
||||||
|
Some(e) => go(info(e)),
|
||||||
|
None => go(EventInfo::default()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
s.inside[k] = now;
|
||||||
|
}
|
||||||
|
TriggerKind::LineCross { zone, side, kind } => {
|
||||||
|
let Some(z) = s.zone(zone).cloned() else { continue };
|
||||||
|
let mut now = Vec::new();
|
||||||
|
for e in self.ents.iter().filter(|e| e.alive && !e.held && e.captive == 0 && matches(e.side(), e.kind, *side, kind)) {
|
||||||
|
let sd = line_side(&z, e.x, e.y);
|
||||||
|
if sd == 0 {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if let Some(&(_, before)) = s.sides[k].iter().find(|(id, _)| *id == e.id) {
|
||||||
|
if before != sd {
|
||||||
|
go(info(e));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
now.push((e.id, sd));
|
||||||
|
}
|
||||||
|
s.sides[k] = now;
|
||||||
|
}
|
||||||
|
TriggerKind::ZoneClaimed { .. } | TriggerKind::ZoneDug { .. } => {
|
||||||
|
let v = self.measure(s, k);
|
||||||
|
let more = matches!(kind, TriggerKind::ZoneClaimed { .. });
|
||||||
|
if (more && v > s.last[k]) || (!more && v < s.last[k]) {
|
||||||
|
go(EventInfo::default());
|
||||||
|
}
|
||||||
|
s.last[k] = v;
|
||||||
|
}
|
||||||
|
TriggerKind::ZoneCount { .. } | TriggerKind::Value { .. } => {
|
||||||
|
// fires when the condition becomes true
|
||||||
|
let v = self.measure(s, k);
|
||||||
|
if v > 0.5 && !(s.last[k] > 0.5) {
|
||||||
|
go(EventInfo::default());
|
||||||
|
}
|
||||||
|
s.last[k] = v;
|
||||||
|
}
|
||||||
|
TriggerKind::Timer { secs, every } => {
|
||||||
|
if self.time as f64 >= s.last[k] {
|
||||||
|
go(EventInfo::default());
|
||||||
|
s.last[k] = if *every { s.last[k] + secs.max(0.1) } else { f64::INFINITY };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
TriggerKind::UnitDies { side, kind, name } => {
|
||||||
|
for d in died.iter() {
|
||||||
|
let named = name.is_empty() || s.names.iter().any(|(n, id)| n == name && *id == d.id);
|
||||||
|
if named && matches(d.side, d.kind, *side, kind) {
|
||||||
|
go(seen_info(d));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
TriggerKind::TowerFalls { side } => {
|
||||||
|
for &hero in fallen.iter() {
|
||||||
|
if *side < 0 || (*side == 1) == hero {
|
||||||
|
go(EventInfo::default());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
TriggerKind::RoomBuilt { room } => {
|
||||||
|
for &r in rooms_grown.iter() {
|
||||||
|
if room.is_empty() || format!("{:?}", ROOM_KINDS[r]) == *room {
|
||||||
|
go(EventInfo::default());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
TriggerKind::SpellCast { spell } => {
|
||||||
|
for e in s.events.iter() {
|
||||||
|
if let ScriptEvent::Spell(n) = e {
|
||||||
|
if spell.is_empty() || n == spell {
|
||||||
|
go(EventInfo::default());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
TriggerKind::Slap => {
|
||||||
|
for e in s.events.iter() {
|
||||||
|
if *e == ScriptEvent::Slap {
|
||||||
|
go(EventInfo::default());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
TriggerKind::Research => {
|
||||||
|
if researched {
|
||||||
|
go(EventInfo::default());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
self.remember(s, false);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn apply_act(&mut self, s: &mut ScriptState, a: Act, ev: &EventInfo) {
|
||||||
|
let fill = |text: &str, vars: &BTreeMap<String, f64>| -> String {
|
||||||
|
// {name} in a text shows the variable's value
|
||||||
|
let mut out = text.to_string();
|
||||||
|
for (k, v) in vars {
|
||||||
|
let shown = if v.fract() == 0.0 { format!("{}", *v as i64) } else { format!("{:.1}", v) };
|
||||||
|
out = out.replace(&format!("{{{}}}", k), &shown);
|
||||||
|
}
|
||||||
|
out
|
||||||
|
};
|
||||||
|
match a {
|
||||||
|
Act::Message(t) => {
|
||||||
|
let t = fill(&t, &s.vars);
|
||||||
|
self.script_msg("msg.script", vec![t]);
|
||||||
|
}
|
||||||
|
Act::Objective(t) => {
|
||||||
|
let t = fill(&t, &s.vars);
|
||||||
|
self.script_msg("msg.script_objective", vec![t.clone()]);
|
||||||
|
self.objective = Some(t);
|
||||||
|
}
|
||||||
|
Act::Hint(t) => {
|
||||||
|
let t = fill(&t, &s.vars);
|
||||||
|
self.script_msg("msg.script_hint", vec![t]);
|
||||||
|
self.sfx_ui(Sfx::Magic);
|
||||||
|
}
|
||||||
|
Act::Spawn { side, kind, count, level, place, zone, name } => {
|
||||||
|
let Some(k) = kind_by_name(&kind) else { return };
|
||||||
|
let count = count.clamp(1, 20) as usize;
|
||||||
|
let at: Vec<(f32, f32)> = match place.as_str() {
|
||||||
|
"zone" => match s.zone(&zone) {
|
||||||
|
Some(z) => {
|
||||||
|
let tiles: Vec<usize> = zone_tiles(z).into_iter().filter(|&i| self.map.t[i].walkable() || (flies(k) && self.map.t[i].liquid())).collect();
|
||||||
|
(0..count).filter_map(|n| tiles.get((n * 7919 + s.rng as usize) % tiles.len().max(1)).map(|&i| (xy(i).0 as f32 + 0.5, xy(i).1 as f32 + 0.5))).collect()
|
||||||
|
}
|
||||||
|
None => Vec::new(),
|
||||||
|
},
|
||||||
|
"event" => ev.pos.map(|p| vec![p; count]).unwrap_or_default(),
|
||||||
|
_ => {
|
||||||
|
let (cx, cy) = self.map.core;
|
||||||
|
(0..count).map(|n| {
|
||||||
|
let a = n as f32 * 1.1;
|
||||||
|
(cx as f32 + 1.5 + a.cos() * 3.0, cy as f32 + 1.5 + a.sin() * 3.0)
|
||||||
|
}).collect()
|
||||||
|
}
|
||||||
|
};
|
||||||
|
for (n, (x, y)) in at.into_iter().enumerate() {
|
||||||
|
if !inb(x as i32, y as i32) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let id = self.spawn_for_side(side, k, x + (n % 3) as f32 * 0.15, y, level.clamp(1, 10) as u32);
|
||||||
|
if let (Some(id), false) = (id, name.is_empty()) {
|
||||||
|
s.names.push((name.clone(), id));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Act::Wave(n) => {
|
||||||
|
let n = n.clamp(1, 20) as u32;
|
||||||
|
if let Some(m) = self.mission.as_mut() {
|
||||||
|
m.waves = true;
|
||||||
|
if m.wave_limit != 0 {
|
||||||
|
m.wave_limit = m.wave_limit.max(self.wave.saturating_add(n));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
self.wave_timer = 0.0;
|
||||||
|
}
|
||||||
|
Act::Gold(n) => {
|
||||||
|
if n >= 0.0 {
|
||||||
|
self.add_gold(n as f32);
|
||||||
|
} else {
|
||||||
|
self.gold = (self.gold + n as f32).max(0.0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Act::Mana(n) => self.mana = (self.mana + n as f32).clamp(0.0, self.mana_max()),
|
||||||
|
Act::Win => {
|
||||||
|
self.state = 1;
|
||||||
|
self.script_msg("msg.mission_won", vec![]);
|
||||||
|
self.sfx_ui(Sfx::Victory);
|
||||||
|
s.log(self.time, "win".into());
|
||||||
|
}
|
||||||
|
Act::Lose => {
|
||||||
|
self.state = 2;
|
||||||
|
self.script_msg("msg.defeat", vec![]);
|
||||||
|
self.sfx_ui(Sfx::Defeat);
|
||||||
|
s.log(self.time, "lose".into());
|
||||||
|
}
|
||||||
|
Act::Reveal(z) => {
|
||||||
|
if let Some(z) = s.zone(&z) {
|
||||||
|
for i in zone_tiles(z) {
|
||||||
|
if let Some(e) = self.map.explored.get_mut(i) {
|
||||||
|
*e = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Act::Camera(z) => s.camera = s.zone(&z).map(zone_center),
|
||||||
|
Act::Terrain(z, kind) => {
|
||||||
|
let Some(z) = s.zone(&z).cloned() else { return };
|
||||||
|
let k = match kind.as_str() {
|
||||||
|
"earth" => Kind::Earth,
|
||||||
|
"rock" => Kind::Rock,
|
||||||
|
"water" => Kind::Water,
|
||||||
|
"lava" => Kind::Lava,
|
||||||
|
"gold" => Kind::Gold,
|
||||||
|
_ => Kind::Floor,
|
||||||
|
};
|
||||||
|
for i in zone_tiles(&z) {
|
||||||
|
let t = self.map.t[i];
|
||||||
|
// the heart, portals, towers and the gate stay
|
||||||
|
if matches!(t.kind, Kind::Core | Kind::Portal | Kind::Tower | Kind::Gate) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if k == Kind::Floor && t.solid() {
|
||||||
|
self.dig_out(i);
|
||||||
|
} else if k != Kind::Floor {
|
||||||
|
self.torches.retain(|tr| tr.wall != i);
|
||||||
|
let t = &mut self.map.t[i];
|
||||||
|
t.kind = k;
|
||||||
|
t.owner = Owner::None;
|
||||||
|
t.reinforced = false;
|
||||||
|
t.marked = false;
|
||||||
|
if k == Kind::Gold {
|
||||||
|
t.gold = 800;
|
||||||
|
}
|
||||||
|
if t.liquid() {
|
||||||
|
self.ground_lost(i);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
self.recount();
|
||||||
|
}
|
||||||
|
Act::Kill(z, side) => {
|
||||||
|
let Some(z) = s.zone(&z).cloned() else { return };
|
||||||
|
for j in 0..self.ents.len() {
|
||||||
|
let e = &self.ents[j];
|
||||||
|
if e.alive && !e.held && (side < 0 || e.side() as i32 == side) && zone_contains(&z, e.x, e.y) {
|
||||||
|
self.ents[j].alive = false;
|
||||||
|
let (x, y) = (self.ents[j].x, self.ents[j].y);
|
||||||
|
self.fx(3, x, y, 0.0, 0.0, 0.7, 0xff505070);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Act::Trigger(name, on) => {
|
||||||
|
if let Some(k) = s.scripts.triggers.iter().position(|t| t.name == name) {
|
||||||
|
s.enabled[k] = on;
|
||||||
|
if on {
|
||||||
|
s.done[k] = false; // switched on again: it may fire once more
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// spawns a unit for a side (0 player, 1 heroes, 2-4 rival masters); None when not possible
|
||||||
|
pub fn spawn_for_side(&mut self, side: i32, kind: EK, x: f32, y: f32, level: u32) -> Option<u32> {
|
||||||
|
let i = idx(x as i32, y as i32);
|
||||||
|
if !inb(x as i32, y as i32) || !(self.map.t[i].walkable() || (flies(kind) && self.map.t[i].liquid())) {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let id = self.spawn(kind, side == 1, x, y, level.max(1));
|
||||||
|
if (2..=4).contains(&side) {
|
||||||
|
if let Some(e) = self.ents.iter_mut().find(|e| e.id == id) {
|
||||||
|
e.side = side as u8;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if let Some(e) = self.ents.iter_mut().find(|e| e.id == id) {
|
||||||
|
e.happy = 1.0;
|
||||||
|
e.settled = true;
|
||||||
|
}
|
||||||
|
Some(id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use crate::blocks::{Block, Place, Val};
|
||||||
|
use crate::campaign_doc::TriggerDoc;
|
||||||
|
|
||||||
|
fn rect(name: &str, a: (f32, f32), b: (f32, f32)) -> ZoneDoc {
|
||||||
|
ZoneDoc { name: name.into(), shape: "rect".into(), at: None, radius: None, tiles: None, from: Some(a), to: Some(b), width: None }
|
||||||
|
}
|
||||||
|
|
||||||
|
fn trig(name: &str, kind: TriggerKind, script: Vec<Block>) -> TriggerDoc {
|
||||||
|
TriggerDoc { name: name.into(), kind, once: true, enabled: true, script }
|
||||||
|
}
|
||||||
|
|
||||||
|
fn run(g: &mut Game, secs: f32) {
|
||||||
|
for _ in 0..(secs * 20.0) as usize {
|
||||||
|
g.update(0.05);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn start_script_timers_and_variables_run() {
|
||||||
|
let mut g = Game::new(3, 64, 64);
|
||||||
|
let scripts = MissionScripts {
|
||||||
|
start: vec![Block::Message { text: "Welcome".into() }, Block::SetVar { name: "n".into(), value: Val::num(2.0) }],
|
||||||
|
triggers: vec![TriggerDoc { once: false, ..trig("every", TriggerKind::Timer { secs: 1.0, every: true }, vec![Block::AddVar { name: "n".into(), value: Val::num(1.0) }]) }],
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
g.script = Some(ScriptState::new(scripts, vec![], 7));
|
||||||
|
run(&mut g, 0.3);
|
||||||
|
assert!(g.msgs.iter().any(|m| m.key == "msg.script" && m.args[0] == "Welcome"));
|
||||||
|
run(&mut g, 3.2);
|
||||||
|
let n = g.script.as_ref().unwrap().vars["n"];
|
||||||
|
assert!(n >= 4.0 && n <= 6.0, "the timer fires every second: {}", n);
|
||||||
|
assert!(g.script.as_ref().unwrap().error.is_none(), "{:?}", g.script.as_ref().unwrap().error);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_zone_trigger_spawns_and_the_victory_script_wins() {
|
||||||
|
let mut g = Game::new(3, 64, 64);
|
||||||
|
let (cx, cy) = g.map.core;
|
||||||
|
let zone = rect("hall", (cx as f32 + 2.0, cy as f32 - 1.0), (cx as f32 + 4.0, cy as f32 + 1.0));
|
||||||
|
let scripts = MissionScripts {
|
||||||
|
victory: vec![Block::If {
|
||||||
|
cond: Val::Cmp { op: ">=".into(), a: Box::new(Val::Var { name: "seen".into() }), b: Box::new(Val::num(1.0)) },
|
||||||
|
then: vec![Block::Win],
|
||||||
|
els: vec![],
|
||||||
|
}],
|
||||||
|
triggers: vec![trig(
|
||||||
|
"enter",
|
||||||
|
TriggerKind::ZoneEnter { zone: "hall".into(), side: 1, kind: String::new() },
|
||||||
|
vec![Block::SetVar { name: "seen".into(), value: Val::num(1.0) }, Block::Spawn { side: 0, kind: "Ogre".into(), count: Val::num(1.0), level: Val::num(3.0), place: Place::Event, name: "guard".into() }],
|
||||||
|
)],
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
g.script = Some(ScriptState::new(scripts, vec![zone], 7));
|
||||||
|
run(&mut g, 0.5);
|
||||||
|
let ogres = g.ents.iter().filter(|e| e.alive && e.kind == EK::Ogre).count();
|
||||||
|
// a knight walks in
|
||||||
|
g.spawn(EK::Knight, true, cx as f32 + 3.5, cy as f32 + 0.5, 1);
|
||||||
|
run(&mut g, 0.3);
|
||||||
|
assert_eq!(g.ents.iter().filter(|e| e.alive && e.kind == EK::Ogre).count(), ogres + 1, "the trigger spawned an ogre where the knight is");
|
||||||
|
assert!(g.script.as_ref().unwrap().log.iter().any(|(_, n)| n == "enter"));
|
||||||
|
run(&mut g, 1.5);
|
||||||
|
assert_eq!(g.state, 1, "the victory script won");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn deaths_rooms_and_values_fire_their_triggers() {
|
||||||
|
let mut g = Game::new(3, 64, 64);
|
||||||
|
let (cx, cy) = g.map.core;
|
||||||
|
let k = g.spawn(EK::Knight, true, cx as f32 + 3.5, cy as f32 + 0.5, 1);
|
||||||
|
let scripts = MissionScripts {
|
||||||
|
triggers: vec![
|
||||||
|
trig("dies", TriggerKind::UnitDies { side: 1, kind: "Knight".into(), name: String::new() }, vec![Block::SetVar { name: "dead".into(), value: Val::num(1.0) }]),
|
||||||
|
trig("rich", TriggerKind::Value { what: "gold".into(), var: String::new(), op: ">=".into(), n: 99999.0 }, vec![Block::SetVar { name: "rich".into(), value: Val::num(1.0) }]),
|
||||||
|
],
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
g.script = Some(ScriptState::new(scripts, vec![], 7));
|
||||||
|
run(&mut g, 0.3);
|
||||||
|
let j = g.ents.iter().position(|e| e.id == k).unwrap();
|
||||||
|
g.ents[j].alive = false;
|
||||||
|
g.gold = 100000.0;
|
||||||
|
run(&mut g, 0.3);
|
||||||
|
let v = &g.script.as_ref().unwrap().vars;
|
||||||
|
assert_eq!(v.get("dead"), Some(&1.0));
|
||||||
|
assert_eq!(v.get("rich"), Some(&1.0));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn zone_shapes() {
|
||||||
|
let r = rect("r", (2.0, 2.0), (4.0, 3.0));
|
||||||
|
assert_eq!(zone_tiles(&r).len(), 6);
|
||||||
|
assert!(zone_contains(&r, 4.9, 3.9) && !zone_contains(&r, 5.1, 3.0));
|
||||||
|
let line = ZoneDoc { name: "l".into(), shape: "line".into(), at: None, radius: None, tiles: None, from: Some((0.0, 5.0)), to: Some((10.0, 5.0)), width: None };
|
||||||
|
assert_eq!(line_side(&line, 3.0, 4.0), -line_side(&line, 3.0, 7.0));
|
||||||
|
assert_eq!(line_side(&line, 30.0, 4.0), 0, "beyond the end of the line");
|
||||||
|
}
|
||||||
|
}
|
||||||
-246
@@ -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());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -86,6 +86,7 @@ impl Game {
|
|||||||
self.sfx_ui(Sfx::Error);
|
self.sfx_ui(Sfx::Error);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
self.script_event(crate::mscript::ScriptEvent::Spell(crate::mscript::spell_name(spell)));
|
||||||
let r2 = spell.radius() * spell.radius();
|
let r2 = spell.radius() * spell.radius();
|
||||||
let near = |e: &Ent| (e.x - wx).powi(2) + (e.y - wy).powi(2) < r2;
|
let near = |e: &Ent| (e.x - wx).powi(2) + (e.y - wy).powi(2) < r2;
|
||||||
match spell {
|
match spell {
|
||||||
|
|||||||
Reference in new issue
Block a user