Add campaign editor script runtime and debug overlays
This commit is contained in:
1 parent
cd35ca8e3d
commit
b06c48172b
13 files changed
+2211
-66
No files matched your search
+246
@@ -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());
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user