Block scripts and triggers: data model, Rhai translation and runtime

Editor missions keep a painted map or roll a random map per campaign run,
leave out goals they cannot have, and run their triggers in the fixed step.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Richard ZinkandClaude Opus 5.5 committed 2026-11-05 16:51:43 +01:00
1 parent 5315709776
commit 5701124c4f
13 files changed
+1835 -483

No files matched your search

+254 -11
View File
@@ -1,19 +1,23 @@
// Data model for shareable campaign files (.dccamp).
// This is the first editor layer: JSON missions, branch goals, scripts and trigger zones.
// The gameplay-side campaign start for custom missions comes in a later step.
// Data model for shareable campaign files (.dccamp): missions with their map (a fixed,
// painted map or generator settings for a random map), goals and branches, trigger zones,
// and the block scripts (special scripts and triggers, see blocks.rs and mscript.rs).
use crate::blocks::Block;
use crate::game::Goal;
use serde::{Deserialize, Serialize};
use std::collections::HashSet;
pub const CAMPAIGN_FORMAT: &str = "dungeoncraft.campaign";
pub const CAMPAIGN_VERSION: u32 = 1;
pub const CAMPAIGN_VERSION: u32 = 2; // 2: maps, block scripts and triggers
pub const CAMPAIGN_EXTENSION: &str = "dccamp";
#[derive(Clone, Debug, Default, Serialize, Deserialize)]
pub struct CampaignProgress {
pub done: Vec<bool>,
pub completed_choice: Vec<Option<String>>,
// random maps are rolled once per run of the campaign: this seed is fixed for the run
#[serde(default)]
pub run_seed: u32,
}
impl CampaignProgress {
@@ -22,6 +26,7 @@ impl CampaignProgress {
CampaignProgress {
done: vec![false; n],
completed_choice: vec![None; n],
run_seed: (macroquad::miniquad::date::now() * 1000.0) as u64 as u32 | 1,
}
}
@@ -57,6 +62,9 @@ pub struct CampaignDoc {
pub author: String,
#[serde(default)]
pub missions: Vec<MissionDoc>,
// the one language the author writes the texts in (a language code)
#[serde(default)]
pub language: String,
}
#[derive(Clone, Debug, Serialize, Deserialize)]
@@ -94,7 +102,162 @@ pub struct MissionDoc {
#[serde(default)]
pub zones: Vec<ZoneDoc>,
#[serde(default)]
pub script: Option<ScriptDoc>,
pub script: Option<ScriptDoc>, // version 1 text scripts (no longer run)
#[serde(default)]
pub map: MapDoc,
#[serde(default)]
pub gen: GenDoc,
#[serde(default)]
pub scripts: MissionScripts,
// position of the mission's card in the campaign graph
#[serde(default)]
pub pos: (f32, f32),
}
// the map of a mission: generated anew for every run of the campaign, or painted and fixed
#[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq)]
#[serde(tag = "kind")]
pub enum MapDoc {
#[default]
Random,
// the whole game state of the edited map as savegame JSON (tiles packed)
Fixed { game: String },
}
// generator settings (random maps, and the start of a painted map)
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
pub struct GenDoc {
#[serde(default)]
pub rivers: u8, // 0..3 water rivers
#[serde(default)]
pub lava: u8, // 0..2 lava rivers
#[serde(default)]
pub forts: u8, // 0..3 knight fortresses
#[serde(default)]
pub rivals: u8, // 0..2 rival masters
#[serde(default = "default_difficulty")]
pub difficulty: u8, // 0 easy, 1 medium, 2 hard
}
impl Default for GenDoc {
fn default() -> Self {
GenDoc { rivers: 1, lava: 0, forts: 1, rivals: 0, difficulty: 1 }
}
}
fn default_difficulty() -> u8 {
1
}
// the block scripts of a mission
#[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq)]
pub struct MissionScripts {
#[serde(default)]
pub start: Vec<Block>, // once when the mission begins
#[serde(default)]
pub victory: Vec<Block>, // every second: may call win
#[serde(default)]
pub defeat: Vec<Block>, // every second: may call lose
#[serde(default)]
pub tick: Vec<Block>, // every second
#[serde(default)]
pub triggers: Vec<TriggerDoc>,
}
impl MissionScripts {
pub fn is_empty(&self) -> bool {
self.start.is_empty() && self.victory.is_empty() && self.defeat.is_empty() && self.tick.is_empty() && self.triggers.is_empty()
}
}
// a trigger: a condition (set up in a form) and the blocks that run when it is met
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
pub struct TriggerDoc {
pub name: String,
pub kind: TriggerKind,
#[serde(default = "default_true")]
pub once: bool,
#[serde(default = "default_true")]
pub enabled: bool, // active from the start (blocks can switch triggers on and off)
#[serde(default)]
pub script: Vec<Block>,
}
fn default_true() -> bool {
true
}
// what sets a trigger off. side: -1 any, 0 player, 1 heroes, 2-4 rival masters; kind "" = any
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
#[serde(tag = "t")]
pub enum TriggerKind {
ZoneEnter { zone: String, side: i32, kind: String },
ZoneLeave { zone: String, side: i32, kind: String },
LineCross { zone: String, side: i32, kind: String },
ZoneClaimed { zone: String },
ZoneDug { zone: String },
ZoneCount { zone: String, side: i32, kind: String, op: String, n: f64 },
Timer { secs: f64, every: bool },
UnitDies { side: i32, kind: String, #[serde(default)] name: String },
TowerFalls { side: i32 }, // 0 the dungeon's towers, 1 the knights' towers, -1 any
Value { what: String, #[serde(default)] var: String, op: String, n: f64 }, // what: gold, mana, creatures, heroes, var
RoomBuilt { room: String }, // a room kind ("Lair", ...) or "" for any
SpellCast { spell: String }, // a spell ("Lightning", ...) or "" for any
Slap,
Research,
}
// trigger kinds in the order of the editor's list
pub const TRIGGER_KINDS: [&str; 14] = ["zone_enter", "zone_leave", "line_cross", "zone_claimed", "zone_dug", "zone_count", "timer", "unit_dies", "tower_falls", "value", "room_built", "spell_cast", "slap", "research"];
impl TriggerKind {
pub fn template(kind: &str) -> Option<TriggerKind> {
let z = String::new;
Some(match kind {
"zone_enter" => TriggerKind::ZoneEnter { zone: z(), side: 0, kind: z() },
"zone_leave" => TriggerKind::ZoneLeave { zone: z(), side: 0, kind: z() },
"line_cross" => TriggerKind::LineCross { zone: z(), side: -1, kind: z() },
"zone_claimed" => TriggerKind::ZoneClaimed { zone: z() },
"zone_dug" => TriggerKind::ZoneDug { zone: z() },
"zone_count" => TriggerKind::ZoneCount { zone: z(), side: 1, kind: z(), op: ">=".into(), n: 3.0 },
"timer" => TriggerKind::Timer { secs: 60.0, every: false },
"unit_dies" => TriggerKind::UnitDies { side: 1, kind: z(), name: z() },
"tower_falls" => TriggerKind::TowerFalls { side: 1 },
"value" => TriggerKind::Value { what: "gold".into(), var: z(), op: ">=".into(), n: 5000.0 },
"room_built" => TriggerKind::RoomBuilt { room: z() },
"spell_cast" => TriggerKind::SpellCast { spell: z() },
"slap" => TriggerKind::Slap,
"research" => TriggerKind::Research,
_ => return None,
})
}
pub fn kind_name(&self) -> &'static str {
match self {
TriggerKind::ZoneEnter { .. } => "zone_enter",
TriggerKind::ZoneLeave { .. } => "zone_leave",
TriggerKind::LineCross { .. } => "line_cross",
TriggerKind::ZoneClaimed { .. } => "zone_claimed",
TriggerKind::ZoneDug { .. } => "zone_dug",
TriggerKind::ZoneCount { .. } => "zone_count",
TriggerKind::Timer { .. } => "timer",
TriggerKind::UnitDies { .. } => "unit_dies",
TriggerKind::TowerFalls { .. } => "tower_falls",
TriggerKind::Value { .. } => "value",
TriggerKind::RoomBuilt { .. } => "room_built",
TriggerKind::SpellCast { .. } => "spell_cast",
TriggerKind::Slap => "slap",
TriggerKind::Research => "research",
}
}
// the zone a trigger watches, if any
pub fn zone(&self) -> Option<&str> {
match self {
TriggerKind::ZoneEnter { zone, .. } | TriggerKind::ZoneLeave { zone, .. } | TriggerKind::LineCross { zone, .. } | TriggerKind::ZoneClaimed { zone } | TriggerKind::ZoneDug { zone } | TriggerKind::ZoneCount { zone, .. } => Some(zone),
_ => None,
}
}
}
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
@@ -111,7 +274,7 @@ pub struct BranchDoc {
pub priority: Option<u32>,
}
#[derive(Clone, Debug, Serialize, Deserialize)]
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
pub struct ZoneDoc {
#[serde(default)]
pub name: String,
@@ -144,9 +307,10 @@ impl Default for CampaignDoc {
CampaignDoc {
format: default_format(),
version: default_version(),
name: "Neue Kampagne".into(),
name: "Campaign".into(),
author: String::new(),
missions: Vec::new(),
language: String::new(),
}
}
}
@@ -171,6 +335,10 @@ impl Default for MissionDoc {
branches: Vec::new(),
zones: Vec::new(),
script: None,
map: MapDoc::Random,
gen: GenDoc::default(),
scripts: MissionScripts::default(),
pos: (0.0, 0.0),
}
}
}
@@ -186,11 +354,17 @@ impl CampaignDoc {
pub fn add_mission(&mut self) -> &mut MissionDoc {
let n = self.missions.len() + 1;
// a free key (missions can be deleted, so the count alone may be taken)
let mut k = n;
while self.missions.iter().any(|m| m.key == format!("m{k:02}")) {
k += 1;
}
self.missions.push(MissionDoc {
key: format!("m{n:02}"),
key: format!("m{k:02}"),
title: format!("Mission {n}"),
seed: (n as u32) * 101,
goals: vec!["Shards".into()],
seed: (k as u32) * 101,
goals: vec!["KillAll".into()],
pos: (40.0 + ((n - 1) % 5) as f32 * 220.0, 40.0 + ((n - 1) / 5) as f32 * 140.0),
..Default::default()
});
self.missions.last_mut().expect("the new mission is last")
@@ -245,6 +419,22 @@ impl CampaignDoc {
errors.push(format!("mission {} has an unknown next mission: {}", mission.key, mission.next.clone().unwrap_or_default()));
}
let mut zone_names = HashSet::new();
for z in &mission.zones {
if z.name.trim().is_empty() || !zone_names.insert(z.name.clone()) {
errors.push(format!("mission {} has an unnamed or duplicate zone: {:?}", mission.key, z.name));
}
}
let mut trig_names = HashSet::new();
for t in &mission.scripts.triggers {
if t.name.trim().is_empty() || !trig_names.insert(t.name.clone()) {
errors.push(format!("mission {} has an unnamed or duplicate trigger: {:?}", mission.key, t.name));
}
}
for (what, n) in mission.script_problems() {
errors.push(format!("mission {} {}: {} problem(s)", mission.key, what, n));
}
for branch in &mission.branches {
if !valid_key(&branch.id) {
errors.push(format!("mission {} has an invalid branch id: {:?}", mission.key, branch.id));
@@ -320,6 +510,59 @@ impl CampaignDoc {
}
}
impl MissionDoc {
pub fn zone_names(&self) -> Vec<String> {
self.zones.iter().map(|z| z.name.clone()).collect()
}
pub fn trigger_names(&self) -> Vec<String> {
self.scripts.triggers.iter().map(|t| t.name.clone()).collect()
}
// every variable some script of the mission sets
pub fn vars(&self) -> Vec<String> {
let mut v = Vec::new();
let s = &self.scripts;
for list in [&s.start, &s.victory, &s.defeat, &s.tick] {
crate::blocks::vars_set(list, &mut v);
}
for t in &s.triggers {
crate::blocks::vars_set(&t.script, &mut v);
}
v
}
// problems in a list of blocks of this mission
pub fn problems(&self, list: &[Block]) -> Vec<crate::blocks::Problem> {
let (zones, triggers, vars) = (self.zone_names(), self.trigger_names(), self.vars());
crate::blocks::check(list, &crate::blocks::Names { zones: &zones, triggers: &triggers, vars: &vars })
}
// (script name, number of problems) for every script with problems, and triggers whose
// zone is missing
pub fn script_problems(&self) -> Vec<(String, usize)> {
let mut out = Vec::new();
let s = &self.scripts;
for (name, list) in [("start", &s.start), ("victory", &s.victory), ("defeat", &s.defeat), ("tick", &s.tick)] {
let n = self.problems(list).len();
if n > 0 {
out.push((name.to_string(), n));
}
}
let zones = self.zone_names();
for t in &s.triggers {
let mut n = self.problems(&t.script).len();
if t.kind.zone().map_or(false, |z| !zones.iter().any(|x| x == z)) {
n += 1;
}
if n > 0 {
out.push((format!("trigger {}", t.name), n));
}
}
out
}
}
pub fn parse_list(text: &str) -> Vec<String> {
text.split(',').map(|s| s.trim().to_string()).filter(|s| !s.is_empty()).collect()
}
@@ -527,7 +770,7 @@ mod tests {
campaign.add_mission();
assert_eq!(campaign.missions.len(), 1);
assert_eq!(campaign.missions[0].key, "m01");
assert_eq!(parse_goal(campaign.missions[0].goals[0].as_str()), Some(Goal::Shards));
assert_eq!(parse_goal(campaign.missions[0].goals[0].as_str()), Some(Goal::KillAll));
assert!(campaign.validate().is_empty());
}