Add scripted boss phase artwork and native impact effects

This commit is contained in:
raptorreplica committed 2026-10-11 19:23:30 +11:00
1 parent e1a6b54712
commit f9b6caf4d4
6 files changed
+519 -3

No files matched your search

+122
View File
@@ -7901,6 +7901,128 @@ mod tests {
println!("Shop captures: {}", output.display());
}
#[test]
#[ignore = "requires original catalog and complete Stage2 artwork; writes isolated visual previews"]
fn iron_coast_stage2_art_and_shop_preview_captures_preserve_simulation() {
let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR"));
let catalog_root = std::env::var_os("RAPTOR_TEST_DATA")
.map(PathBuf::from)
.unwrap_or_else(|| root.join("data"));
let catalog = Catalog::open(catalog_root).unwrap();
let registry = Registry::base(&catalog).unwrap();
let temp = tempfile::tempdir().unwrap();
let paths = Paths {
data: temp.path().join("saves"),
config: temp.path().join("config"),
cache: temp.path().join("cache"),
legacy: temp.path().join("legacy"),
};
let mut host = Host::new(catalog, registry, paths, root.join("mods"), true).unwrap();
let enabled =
crate::mod_selection::plan_enable(&host.discovery, &[], "iron_coast_stage2_campaign")
.unwrap();
host.active =
ActiveMods::build(&host.base, &host.discovery, &enabled, Some(&host.catalog)).unwrap();
host.config.scale = 4;
host.pilot.mods = host.active.pins.clone();
let output = root.join(".reference/iron-coast-stage2/visual");
std::fs::create_dir_all(&output).unwrap();
let capture = |host: &mut Host, name: &str| {
host.message.clear();
let before = serde_json::to_value(&host.pilot).unwrap();
let digest = host.world.as_ref().map(World::digest);
let canvas = host.draw().unwrap();
let rgba = host.present_rgba(&canvas);
image::save_buffer(
output.join(format!("{name}.png")),
&rgba,
canvas.image.width as u32,
canvas.image.height as u32,
image::ColorType::Rgba8,
)
.unwrap();
assert_eq!(serde_json::to_value(&host.pilot).unwrap(), before);
assert_eq!(host.world.as_ref().map(World::digest), digest);
rgba
};
for n in 1..=8 {
let mission: ContentId = format!("iron_coast_stage2_mission_{n}:mission_{n:02}")
.as_str()
.into();
host.pilot.mission = mission.clone();
host.start(mission).unwrap();
for _ in 0..180 {
host.world
.as_mut()
.unwrap()
.step(
Input {
fire: true,
..Input::default()
},
&host.active.registry,
&mut host.active.scripts,
)
.unwrap();
}
// Preview each declared boss artwork state on actual mission
// terrain. Combat completion/phase timing is tested separately.
let boss: ContentId = format!("iron_coast_stage2_boss_{n}:unit").as_str().into();
let definition = &host.active.registry.enemies[&boss];
let art = definition.art.as_ref().unwrap();
let width = art.logical_width;
let frames = art.frames;
let entity = host
.world
.as_mut()
.unwrap()
.spawn(&boss, (320 - width as i32) / 2, 12, &host.active.registry)
.unwrap();
host.screen = Screen::Game;
for frame in 0..frames {
host.world
.as_mut()
.unwrap()
.apply_commands(
&[crate::modding::Command::Animation {
entity,
frame: Some(frame),
}],
&host.active.registry,
)
.unwrap();
host.config.hd_mode = false;
let sd = capture(&mut host, &format!("mission-{n:02}-boss-{frame}-sd"));
host.config.hd_mode = true;
let hd = capture(&mut host, &format!("mission-{n:02}-boss-{frame}-hd"));
assert_ne!(sd, hd, "Stage2 supplied SD and HD art should differ");
}
host.world = None;
}
for namespace in ["iron_coast_railburst", "iron_coast_depth_charge"] {
let id: ContentId = format!("{namespace}:equipment").as_str().into();
host.pilot.credits = 4_000_000;
host.go(Screen::Shop);
host.selected = host
.shop_ids()
.iter()
.position(|candidate| candidate == &id)
.unwrap()
+ 1;
assert!(!host.shop_info(&id).unwrap().description.is_empty());
host.config.hd_mode = false;
let sd = capture(&mut host, &format!("{namespace}-shop-sd"));
host.config.hd_mode = true;
let hd = capture(&mut host, &format!("{namespace}-shop-hd"));
assert_ne!(sd, hd);
host.open_shop_details();
capture(&mut host, &format!("{namespace}-details"));
}
host.go(Screen::Missions);
capture(&mut host, "mission-selection");
assert!(!host.paths.data.exists());
println!("Stage2 artwork preview captures: {}", output.display());
}
#[test]
fn disabling_a_single_unneeded_mod_toggles_without_confirmation() {
let (_dir, mut host) = ui_fixture();
install_dependency_fixture(&mut host);
+43
View File
@@ -535,6 +535,42 @@ impl ActiveMods {
})
}
}
/// Script names are independent of the legacy effect serialization format.
#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize, JsonSchema)]
#[serde(rename_all = "snake_case")]
pub enum ScriptEffectKind {
GroundBlast,
SmallGround,
LargeAir,
MediumAir,
SmallAir,
#[default]
AirBlast,
Energy,
Person,
Platoon,
Flare,
Sparkle,
}
impl From<ScriptEffectKind> for crate::simulation::EffectKind {
fn from(kind: ScriptEffectKind) -> Self {
match kind {
ScriptEffectKind::GroundBlast => Self::GroundBlast,
ScriptEffectKind::SmallGround => Self::SmallGround,
ScriptEffectKind::LargeAir => Self::LargeAir,
ScriptEffectKind::MediumAir => Self::MediumAir,
ScriptEffectKind::SmallAir => Self::SmallAir,
ScriptEffectKind::AirBlast => Self::AirBlast,
ScriptEffectKind::Energy => Self::Energy,
ScriptEffectKind::Person => Self::Person,
ScriptEffectKind::Platoon => Self::Platoon,
ScriptEffectKind::Flare => Self::Flare,
ScriptEffectKind::Sparkle => Self::Sparkle,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
#[serde(tag = "op", rename_all = "snake_case", deny_unknown_fields)]
pub enum Command {
@@ -572,6 +608,11 @@ pub enum Command {
vx: i32,
vy: i32,
},
/// Pin a nonlegacy enemy's artwork frame; null resumes automatic animation.
Animation {
entity: u64,
frame: Option<u32>,
},
GiveItem {
item: String,
count: u32,
@@ -589,6 +630,8 @@ pub enum Command {
Effect {
x: i32,
y: i32,
#[serde(default)]
kind: ScriptEffectKind,
},
/// Cosmetic lightning; pair with damage commands for gameplay effects.
Arc {
+34 -2
View File
@@ -173,6 +173,8 @@ pub struct Enemy {
pub movement: LineMove,
pub frame: u32,
pub art_frame: u32,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub pinned_art_frame: Option<u32>,
pub animation_on: bool,
pub animation_frames: u32,
pub multi: u32,
@@ -777,6 +779,7 @@ impl World {
movement,
frame: 0,
art_frame: 0,
pinned_art_frame: None,
animation_on: f.is_none_or(|f| f[10] != 1),
animation_frames: f.map_or(1, |f| if f[10] == 2 { f[9] } else { f[7] }.max(1) as u32),
multi: 0,
@@ -1143,6 +1146,9 @@ impl World {
enemy.art_frame = enemy.frame;
enemy.shoot_on = true;
}
if let Some(frame) = enemy.pinned_art_frame {
enemy.art_frame = frame;
}
if enemy.shoot_on
&& (def.legacy.is_some()
|| (enemy.health > 0 && enemy.y > -enemy.height && enemy.y < 200))
@@ -3078,6 +3084,28 @@ impl World {
e.vx = *vx;
e.vy = *vy;
}
Command::Animation { entity, frame } => {
let e = self
.enemies
.iter_mut()
.find(|e| e.entity == *entity)
.context("animation target does not exist")?;
let definition = &registry.enemies[&e.definition];
ensure!(
definition.legacy.is_none(),
"scripted animation requires a nonlegacy enemy"
);
let art = definition
.art
.as_ref()
.context("animation target has no artwork")?;
ensure!(
frame.is_none_or(|frame| frame < art.frames),
"animation frame exceeds declared artwork frames"
);
e.pinned_art_frame = *frame;
e.art_frame = frame.unwrap_or(e.frame);
}
Command::GiveItem { item, count } => {
let id = ContentId::new(item)?;
let def = registry.items.get(&id).context("unknown item")?;
@@ -3174,9 +3202,13 @@ impl World {
}
}
}
Command::Effect { x, y } => {
Command::Effect { x, y, kind } => {
ensure!(
x.unsigned_abs() <= 32768 && y.unsigned_abs() <= 32768,
"effect position exceeds world bounds"
);
ensure!(self.effects.len() < 4096, "too many effects");
self.effects.push(Effect::new(EffectKind::AirBlast, *x, *y));
self.effects.push(Effect::new((*kind).into(), *x, *y));
}
Command::Arc {
x,