Add campaign editor script runtime and debug overlays

This commit is contained in:
Richard Zink committed 2026-11-05 12:22:59 +01:00
1 parent cd35ca8e3d
commit b06c48172b
13 files changed
+2211 -66

No files matched your search

+246
View File
@@ -0,0 +1,246 @@
// 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());
}
}