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

+12 -225
View File
@@ -26,7 +26,8 @@ mod rival;
mod render3d;
mod rooms;
mod save;
mod script;
mod blocks;
mod mscript;
mod settings;
mod sfx;
mod tracks;
@@ -199,8 +200,9 @@ fn sanitize_campaign_key(value: &str) -> String {
value.trim().chars().filter(|c| c.is_ascii_alphanumeric() || *c == '_' || *c == '-').take(48).collect()
}
fn custom_mission_game(def: &campaign::MissionDef, i: usize) -> (Game, Cam) {
let g = campaign::start_mission_doc(def, i, None);
// a mission of an editor campaign (its map is painted, or rolled from the run's seed)
fn custom_mission_game(doc: &campaign_doc::CampaignDoc, i: usize, run_seed: u32) -> (Game, Cam) {
let g = campaign::start_editor_mission(&doc.missions[i], i, run_seed);
let (cx, cy) = g.map.core;
let cam = Cam::new(cx as f32 + 1.5, cy as f32 + 1.5);
(g, cam)
@@ -412,192 +414,6 @@ fn apply_game_settings(g: &mut Game, s: &Settings) {
g.debug_markers = s.debug_command_markers;
}
fn start_custom_script(g: &mut Game, doc: &campaign_doc::MissionDoc) {
let Some(script) = doc.script.as_ref() else {
return;
};
if script.source.trim().is_empty() {
return;
}
if !script.language.is_empty() && !script.language.eq_ignore_ascii_case("rhai") {
g.msg("msg.script_language", vec![script.language.clone()]);
return;
}
match script::run_start(&script.source) {
Ok(m) => {
for message in m.messages() {
g.msg("msg.script", vec![message]);
}
if let Some(objective) = m.objective() {
g.msg("msg.script_objective", vec![objective]);
}
for action in m.actions() {
apply_script_action(g, doc, action);
}
}
Err(error) => {
g.msg("msg.script_error", vec![error]);
}
}
}
fn validate_script_source(doc: &campaign_doc::MissionDoc, source: &str) -> Result<(), String> {
match script::run_start(source) {
Ok(m) => {
for action in m.actions() {
match action {
script::ScriptAction::Spawn { kind, .. } => {
if script::parse_spawn_kind(&kind).is_none() {
return Err(format!("unknown creature: {kind}"));
}
}
script::ScriptAction::SpawnInZone { zone, kind, .. } => {
if script::parse_spawn_kind(&kind).is_none() {
return Err(format!("unknown creature: {kind}"));
}
if !doc.zones.iter().any(|z| z.name == zone) {
return Err(format!("unknown zone: {zone}"));
}
}
script::ScriptAction::Wave { .. } => {}
}
}
Ok(())
}
Err(error) => Err(error),
}
}
fn apply_script_action(g: &mut Game, doc: &campaign_doc::MissionDoc, action: script::ScriptAction) {
match action {
script::ScriptAction::Spawn { side, kind, x, y, count, level } => {
let Some(kind) = script::parse_spawn_kind(&kind) else {
g.msg("msg.script_error", vec![format!("unknown creature: {kind}")]);
return;
};
spawn_script_units(g, side, kind, &vec![(x, y); count.max(0) as usize], level);
}
script::ScriptAction::SpawnInZone { zone, kind, side, count, level } => {
let Some(kind) = script::parse_spawn_kind(&kind) else {
g.msg("msg.script_error", vec![format!("unknown creature: {kind}")]);
return;
};
let Some(zone_doc) = doc.zones.iter().find(|z| z.name == zone) else {
g.msg("msg.script_error", vec![format!("unknown zone: {zone}")]);
return;
};
let positions = zone_spawn_positions(g, zone_doc, count);
if positions.is_empty() {
g.msg("msg.script_error", vec![format!("no usable spawn position in zone: {zone}")]);
return;
}
spawn_script_units(g, side, kind, &positions, level);
}
script::ScriptAction::Wave { count } => {
apply_script_wave(g, count);
}
}
}
fn apply_script_wave(g: &mut Game, count: i32) {
let count = count.max(0) as u32;
if count == 0 {
return;
}
if let Some(m) = g.mission.as_mut() {
m.waves = true;
if m.wave_limit != 0 {
m.wave_limit = m.wave_limit.max(g.wave.saturating_add(count));
}
}
g.wave_timer = 0.0;
}
fn spawn_script_units(g: &mut Game, side: i32, kind: game::EK, positions: &[(f32, f32)], level: i32) {
let positions: Vec<(f32, f32)> = positions.iter().copied().filter(|(x, y)| valid_script_spawn_pos(g, *x, *y)).collect();
if positions.is_empty() {
return;
}
let old_side = g.me_side;
match side {
0 => g.me_side = 0,
1 => g.me_side = 1,
2 | 3 | 4 => g.me_side = side as u8,
_ => {
g.msg("msg.script_error", vec![format!("unknown script side: {side}")]);
return;
}
}
for (x, y) in positions {
g.spawn(kind, side == 1, x, y, level.max(1) as u32);
}
g.me_side = old_side;
}
fn valid_script_spawn_pos(_g: &Game, x: f32, y: f32) -> bool {
x.is_finite() && y.is_finite() && x >= 0.0 && y >= 0.0 && x < mw() as f32 && y < mh() as f32 && inb(x as i32, y as i32)
}
fn zone_spawn_positions(g: &Game, zone: &campaign_doc::ZoneDoc, count: i32) -> Vec<(f32, f32)> {
let count = count.clamp(1, 10) as usize;
let core = g.map.core;
let center = zone.at.unwrap_or((core.0 as f32 + 1.5, core.1 as f32 + 1.5));
let mut out = Vec::new();
match zone.shape.as_str() {
"rect" | "rectangle" | "square" => {
if let (Some(from), Some(to)) = (zone.from, zone.to) {
let x0 = from.0.min(to.0);
let x1 = from.0.max(to.0);
let y0 = from.1.min(to.1);
let y1 = from.1.max(to.1);
let cols = ((count as f32).sqrt().ceil()) as usize;
for k in 0..count {
let r = k / cols;
let c = k % cols;
let tx = if cols > 1 { c as f32 / (cols as f32 - 1.0) } else { 0.5 };
let rows = ((count as f32) / cols as f32).ceil() as usize;
let ty = if rows > 1 { r as f32 / (rows as f32 - 1.0) } else { 0.5 };
out.push((x0 + 0.5 + tx * (x1 - x0), y0 + 0.5 + ty * (y1 - y0)));
}
}
}
"line" | "path" => {
if let (Some(from), Some(to)) = (zone.from, zone.to) {
let x0 = from.0 + 0.5;
let y0 = from.1 + 0.5;
let x1 = to.0 + 0.5;
let y1 = to.1 + 0.5;
for k in 0..count {
let t = (k as f32 + 0.5) / count as f32;
out.push((x0 + t * (x1 - x0), y0 + t * (y1 - y0)));
}
}
}
"tiles" => {
if let Some(tiles) = zone.tiles.as_ref() {
for (x, y) in tiles.iter().take(count) {
out.push((*x as f32 + 0.5, *y as f32 + 0.5));
}
}
}
_ => {
let radius = zone.radius.unwrap_or(2.0).max(0.0);
let cx = center.0 + 0.5;
let cy = center.1 + 0.5;
if count == 1 || radius <= 0.5 {
out.push((cx, cy));
} else {
let r = radius.min(radius.max(0.5) * 0.75);
for k in 0..count {
let angle = (k as f32) * std::f32::consts::TAU / count as f32;
out.push((cx + angle.cos() * r, cy + angle.sin() * r));
}
}
}
}
out.into_iter().filter(|(x, y)| valid_script_spawn_pos(g, *x, *y)).take(count).collect()
}
// "dungeoncraft_2026-11-02_2145.dcsave" (UTC)
fn save_file_name() -> String {
let (y, m, d, h, mi) = cloud::civil(miniquad::date::now() as i64);
@@ -1464,32 +1280,7 @@ async fn main() {
}
}
}
MenuAction::CampaignEditScript => {
let i = menu.campaign_selected;
let initial = menu.campaign_doc.missions.get(i).and_then(|m| m.script.as_ref()).map(|s| s.source.clone()).unwrap_or_default();
if let Some(source) = prompt_optional_line(&i18n::tr("campaign.prompt_script"), &initial, 600) {
if source.trim().is_empty() {
if let Some(mission) = menu.campaign_doc.missions.get_mut(i) {
mission.script = None;
}
app.set_status(i18n::tr("campaign.updated"), now);
} else if let Some(mission) = menu.campaign_doc.missions.get(i).cloned() {
match validate_script_source(&mission, &source) {
Ok(()) => {
if let Some(mission) = menu.campaign_doc.missions.get_mut(i) {
mission.script = Some(campaign_doc::ScriptDoc { language: "rhai".into(), source });
}
app.set_status(i18n::tr("campaign.updated"), now);
}
Err(error) => {
app.set_status(i18n::trf("msg.script_error", &vec![error]), now);
}
}
} else {
app.set_status(i18n::tr("campaign.error_no_mission"), now);
}
}
}
MenuAction::CampaignEditScript => {}
MenuAction::CampaignDelete(i) => {
if i < menu.campaign_doc.missions.len() {
menu.campaign_doc.missions.remove(i);
@@ -1506,15 +1297,15 @@ async fn main() {
MenuAction::CampaignStart(i) => {
if let Some(mission_doc) = menu.campaign_doc.missions.get(i) {
if menu.campaign_doc.validate().is_empty() {
let def = campaign::mission_def_from_doc(mission_doc);
(app.game, app.cam) = custom_mission_game(&def, i);
let _ = mission_doc;
let progress = campaign_doc::CampaignProgress::for_doc(&menu.campaign_doc);
(app.game, app.cam) = custom_mission_game(&menu.campaign_doc, i, progress.run_seed);
apply_game_settings(&mut app.game, &app.settings);
start_custom_script(&mut app.game, mission_doc);
app.game.coop = false;
app.coop = false;
app.recorded = false;
app.custom_campaign = Some(menu.campaign_doc.clone());
app.custom_progress = Some(campaign_doc::CampaignProgress::for_doc(&menu.campaign_doc));
app.custom_progress = Some(progress);
app.custom_mission_index = Some(i);
menu.page = None;
ui.log.clear();
@@ -1595,10 +1386,8 @@ async fn main() {
if let Some(next) = app.custom_next_index {
if let Some(campaign) = campaign {
if next < campaign.missions.len() {
let def = campaign::mission_def_from_doc(&campaign.missions[next]);
(app.game, app.cam) = custom_mission_game(&def, next);
(app.game, app.cam) = custom_mission_game(&campaign, next, progress.as_ref().map_or(1, |p| p.run_seed));
apply_game_settings(&mut app.game, &app.settings);
start_custom_script(&mut app.game, &campaign.missions[next]);
app.game.coop = false;
app.coop = false;
app.recorded = false;
@@ -1620,10 +1409,8 @@ async fn main() {
}
} else if let (Some(campaign), Some(i)) = (campaign, app.custom_mission_index) {
if i < campaign.missions.len() {
let def = campaign::mission_def_from_doc(&campaign.missions[i]);
(app.game, app.cam) = custom_mission_game(&def, i);
(app.game, app.cam) = custom_mission_game(&campaign, i, progress.as_ref().map_or(1, |p| p.run_seed));
apply_game_settings(&mut app.game, &app.settings);
start_custom_script(&mut app.game, &campaign.missions[i]);
app.game.coop = false;
app.coop = false;
app.recorded = false;