Script blocks for weather and talent experience, groves as ground; the co-op campaign brings rogues and a storm

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Richard ZinkandClaude Opus 5.5 committed 2026-11-06 13:29:44 +01:00
1 parent 4e3158cc19
commit eb219f6b01
23 files changed
+233 -8

No files matched your search

+26 -5
View File
@@ -16,7 +16,7 @@ pub const KINDS: [&str; 30] = [
];
// ground a Terrain block can make
pub const TERRAIN: [&str; 6] = ["floor", "earth", "rock", "water", "lava", "gold"];
pub const TERRAIN: [&str; 7] = ["floor", "earth", "rock", "water", "lava", "gold", "grove"];
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
#[serde(tag = "b")]
@@ -42,6 +42,8 @@ pub enum Block {
Terrain { zone: String, kind: String },
Kill { zone: String, side: i32 },
Trigger { name: String, on: bool },
Weather { kind: String, secs: Val }, // the weather above ground (secs 0: until changed again)
TalentXp { amount: Val }, // experience for the player's talents
}
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
@@ -70,6 +72,7 @@ pub enum 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
WeatherIs { kind: String }, // the weather above ground is ...
}
pub const MATH_OPS: [&str; 4] = ["+", "-", "*", "/"];
@@ -89,7 +92,7 @@ impl Val {
pub fn ty(&self) -> Ty {
match self {
Val::Bool { .. } | Val::Cmp { .. } | Val::And { .. } | Val::Or { .. } | Val::Not { .. } | Val::EventKind { .. } => Ty::Bool,
Val::Bool { .. } | Val::Cmp { .. } | Val::And { .. } | Val::Or { .. } | Val::Not { .. } | Val::EventKind { .. } | Val::WeatherIs { .. } => Ty::Bool,
_ => Ty::Num,
}
}
@@ -113,6 +116,7 @@ impl Val {
"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() },
"weatheris" => Val::WeatherIs { kind: "rain".into() },
_ => return None,
})
}
@@ -135,16 +139,20 @@ impl Val {
Val::Or { .. } => "or",
Val::Not { .. } => "not",
Val::EventKind { .. } => "eventkind",
Val::WeatherIs { .. } => "weatheris",
}
}
}
// 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"];
pub const BOOL_VALUES: [&str; 7] = ["cmp", "and", "or", "not", "bool", "eventkind", "weatheris"];
// the weather a block can set or ask for (weather.rs: FAIR, RAIN, FOG, STORM)
pub const WEATHER: [&str; 4] = ["fair", "rain", "fog", "storm"];
// statement blocks in the palette: (category, kind)
pub const PALETTE: [(&str, &str); 21] = [
pub const PALETTE: [(&str, &str); 23] = [
("say", "message"),
("say", "objective"),
("say", "hint"),
@@ -156,6 +164,8 @@ pub const PALETTE: [(&str, &str); 21] = [
("world", "camera"),
("world", "gold"),
("world", "mana"),
("world", "weather"),
("world", "talentxp"),
("logic", "if"),
("logic", "repeat"),
("logic", "until"),
@@ -185,6 +195,8 @@ impl Block {
"camera" => Block::Camera { zone: String::new() },
"gold" => Block::Gold { amount: n(500.0) },
"mana" => Block::Mana { amount: n(100.0) },
"weather" => Block::Weather { kind: "storm".into(), secs: n(0.0) },
"talentxp" => Block::TalentXp { amount: n(300.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() },
"until" => Block::Until { cond: Val::template("cmp").unwrap(), body: Vec::new() },
@@ -212,6 +224,8 @@ impl Block {
Block::Camera { .. } => "camera",
Block::Gold { .. } => "gold",
Block::Mana { .. } => "mana",
Block::Weather { .. } => "weather",
Block::TalentXp { .. } => "talentxp",
Block::If { .. } => "if",
Block::Repeat { .. } => "repeat",
Block::Until { .. } => "until",
@@ -258,6 +272,7 @@ fn check_val(v: &Val, want: Ty, n: &Names, path: &[usize], out: &mut Vec<Problem
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::WeatherIs { kind } if !WEATHER.contains(&kind.as_str()) => out.push(p("blk.err_weather")),
Val::Random { a, b } | Val::Math { a, b, .. } => {
check_val(a, Ty::Num, n, path, out);
check_val(b, Ty::Num, n, path, out);
@@ -295,7 +310,13 @@ fn check_list(list: &[Block], n: &Names, path: &mut Vec<usize>, out: &mut Vec<Pr
}
}
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::Gold { amount } | Block::Mana { amount } | Block::TalentXp { amount } => check_val(amount, Ty::Num, n, path, out),
Block::Weather { kind, secs } => {
if !WEATHER.contains(&kind.as_str()) {
out.push(p("blk.err_weather", path));
}
check_val(secs, Ty::Num, n, path, out);
}
Block::SetVar { name, value } | Block::AddVar { name, value } => {
if !valid_var(name) {
out.push(p("blk.err_var", path));
+21 -1
View File
@@ -543,7 +543,13 @@ impl BlockEdit {
let r = lay.take(CW() * 10.0);
im.text_field(gfx, fid(path, 6), r, name, 16);
}
Block::Wave { count } | Block::Gold { amount: count } | Block::Mana { amount: count } => self.value(im, gfx, count, Ty::Num, path, 1, lay, ctx),
Block::Wave { count } | Block::Gold { amount: count } | Block::Mana { amount: count } | Block::TalentXp { amount: count } => self.value(im, gfx, count, Ty::Num, path, 1, lay, ctx),
Block::Weather { kind, secs } => {
weather_dd(im, gfx, kind, fid(path, 2), lay);
lay.word(gfx, &tr("blk.w_for"));
self.value(im, gfx, secs, Ty::Num, path, 1, lay, ctx);
lay.word(gfx, &tr("blk.w_seconds"));
}
Block::SetVar { name, value } | Block::AddVar { name, value } => {
let r = lay.take(CW() * 12.0);
im.text_field(gfx, fid(path, 0), r, name, 24);
@@ -713,10 +719,24 @@ impl BlockEdit {
lay.word(gfx, &tr("blk.v.eventkind"));
self.kind_dd(im, gfx, kind, false, path, sub(0), lay);
}
Val::WeatherIs { kind } => {
lay.word(gfx, &tr("blk.v.weatheris"));
weather_dd(im, gfx, kind, fid(path, sub(0)), lay);
}
}
}
}
// a dropdown with the kinds of weather
fn weather_dd(im: &mut Im, gfx: &Gfx, kind: &mut String, id: u64, lay: &mut Lay) {
let opts: Vec<String> = WEATHER.iter().map(|w| tr(&format!("weather.{}", w))).collect();
let cur = tr(&format!("weather.{}", kind));
let r = lay.take(CW() * 12.0);
if let Some(c) = im.dropdown(gfx, id, r, &cur, &opts) {
*kind = WEATHER[c].to_string();
}
}
// where the next field of a block goes: left to right, into a new row at the block's edge
struct Lay {
x: f32,
+34
View File
@@ -143,6 +143,40 @@ pub fn coop_campaign() -> CampaignDoc {
],
});
}
// the rogues of the Order in the later missions: thieves from mission 7, an inquisitor
// from mission 15; the last mission (above ground) brews a storm
let lv = n((1 + i / 3) as f64);
if i >= 6 {
sc.triggers.push(TriggerDoc {
name: "thieves".into(),
kind: TriggerKind::Timer { secs: 420.0, every: false },
once: true,
enabled: true,
script: vec![Block::Message { text: tr("coop.raid") }, Block::Spawn { side: 1, kind: "Thief".into(), count: n(2.0), level: lv.clone(), place: Place::Zone(ZONES[i % 3].0.into()), name: String::new() }],
});
}
if i >= 14 {
sc.triggers.push(TriggerDoc {
name: "inquisitor".into(),
kind: TriggerKind::Timer { secs: 600.0, every: false },
once: true,
enabled: true,
script: vec![
Block::Message { text: tr("coop.raid") },
Block::Spawn { side: 1, kind: "Inquisitor".into(), count: n(1.0), level: lv.clone(), place: Place::Zone(ZONES[(i + 1) % 3].0.into()), name: String::new() },
Block::Spawn { side: 1, kind: "Monk".into(), count: n(2.0), level: lv.clone(), place: Place::Zone(ZONES[(i + 1) % 3].0.into()), name: String::new() },
],
});
}
if i + 1 == specs.len() {
sc.triggers.push(TriggerDoc {
name: "storm".into(),
kind: TriggerKind::Timer { secs: 480.0, every: false },
once: true,
enabled: true,
script: vec![Block::Weather { kind: "storm".into(), secs: n(120.0) }],
});
}
if let Some((kind, level, name, zone)) = sp.boss {
sc.start.push(Block::Spawn { side: 1, kind: kind.into(), count: n(1.0), level: n(level as f64), place: Place::Zone(ZONES[zone].0.into()), name: name.into() });
// the boss falls: the mission is won once the goals (if any) are done too
+40
View File
@@ -109,6 +109,8 @@ enum Act {
Terrain(String, String),
Kill(String, i32),
Trigger(String, bool),
Weather(u8, f64),
TalentXp(f64),
}
// the triggering unit (for "where the unit is" and "the unit is a ...")
@@ -539,6 +541,12 @@ impl Game {
Val::Or { a, b } => (self.cond(s, a, ev) || self.cond(s, b, ev)) as i32 as f64,
Val::Not { a } => (!self.cond(s, a, ev)) as i32 as f64,
Val::EventKind { kind } => (ev.kind.is_some() && ev.kind == kind_by_name(kind)) as i32 as f64,
Val::WeatherIs { kind } => {
// the weather felt now (a change counts once it has come in)
let w = &self.weather;
let now = if w.level > 0.5 { w.shown } else { crate::weather::FAIR };
(crate::blocks::WEATHER.iter().position(|k| k == kind) == Some(now as usize)) as i32 as f64
}
};
if r.is_finite() {
r
@@ -568,6 +576,8 @@ impl Game {
Block::Wave { count } => Act::Wave(self.eval(s, count, ev).round() as i64),
Block::Gold { amount } => Act::Gold(self.eval(s, amount, ev)),
Block::Mana { amount } => Act::Mana(self.eval(s, amount, ev)),
Block::Weather { kind, secs } => Act::Weather(crate::blocks::WEATHER.iter().position(|k| k == kind).unwrap_or(0) as u8, self.eval(s, secs, ev)),
Block::TalentXp { amount } => Act::TalentXp(self.eval(s, amount, ev)),
Block::SetVar { name, value } => {
let v = self.eval(s, value, ev);
s.vars.insert(name.clone(), v);
@@ -824,6 +834,13 @@ impl Game {
}
}
Act::Mana(n) => self.mana = (self.mana + n as f32).clamp(0.0, self.mana_max()),
// the weather comes (softly) and stays the given seconds (0: until a block changes it)
Act::Weather(k, secs) => {
self.weather.kind = k;
self.weather.left = if secs > 0.0 { secs as f32 } else { 1.0e9 };
self.weather.strike_t = self.weather.strike_t.min(5.0);
}
Act::TalentXp(n) => self.add_master_xp(n.max(0.0) as f32),
Act::Win => {
self.state = 1;
self.script_msg("msg.mission_won", vec![]);
@@ -854,6 +871,7 @@ impl Game {
"water" => Kind::Water,
"lava" => Kind::Lava,
"gold" => Kind::Gold,
"grove" => Kind::Grove,
_ => Kind::Floor,
};
for i in zone_tiles(&z) {
@@ -960,6 +978,28 @@ mod tests {
assert!(g.script.as_ref().unwrap().error.is_none(), "{:?}", g.script.as_ref().unwrap().error);
}
#[test]
fn weather_and_talent_blocks_work() {
let mut g = Game::new(3, 64, 64);
g.add_surface(3, 0.5, 2);
let scripts = MissionScripts {
start: vec![
Block::Weather { kind: "storm".into(), secs: Val::num(0.0) },
Block::TalentXp { amount: Val::num(400.0) },
Block::Wait { secs: Val::num(25.0) },
Block::If { cond: Val::WeatherIs { kind: "storm".into() }, then: vec![Block::SetVar { name: "storm".into(), value: Val::num(1.0) }], els: vec![] },
],
..Default::default()
};
g.script = Some(ScriptState::new(scripts, vec![], 7));
run(&mut g, 30.0);
assert_eq!(g.weather.kind, crate::weather::STORM);
assert!(g.weather.left > 1.0e8, "it stays");
assert!(g.talents.xp >= 400.0 && g.talents.free() >= 1);
assert_eq!(g.script.as_ref().unwrap().vars.get("storm"), Some(&1.0), "the condition sees the storm");
assert!(crate::blocks::check(&g.script.as_ref().unwrap().scripts.start, &crate::blocks::Names { zones: &[], triggers: &[], vars: &[] }).iter().all(|p| p.key != "blk.err_weather"));
}
#[test]
fn a_zone_trigger_spawns_and_the_victory_script_wins() {
let mut g = Game::new(3, 64, 64);