Add scripted boss phase artwork and native impact effects
This commit is contained in:
1 parent
e1a6b54712
commit
f9b6caf4d4
6 files changed
+519
-3
No files matched your search
+122
@@ -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);
|
||||
|
||||
@@ -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
@@ -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 = ®istry.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,
|
||||
|
||||
Reference in new issue
Block a user