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
+12
-225
@@ -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;
|
||||
|
||||
Reference in new issue
Block a user