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
+254
-11
@@ -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());
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user