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

+13 -1
View File
@@ -188,11 +188,12 @@ Use the `op` field shown below. All fields listed are required unless stated oth
| `despawn` | `entity` | Remove an existing enemy or projectile. |
| `move` | `entity`, `x`, `y` | Move an enemy and rebase its movement path. |
| `velocity` | `entity`, `vx`, `vy` | Set enemy velocity. |
| `animation` | `entity`, `frame` (integer or null) | Pin the displayed frame of an existing nonlegacy enemy. The zero-based frame must be below its artwork's declared `frames`; null resumes automatic animation. |
| `give_item` | `item`, `count` | Add registered inventory, clamped to item capacity; adding a new entry beyond the 20-item limit fails. |
| `consume_item` | `item`, `count` | Remove owned inventory; insufficient quantity fails. |
| `credits` | signed `amount` | Adjust credits with saturation at valid limits. |
| `shield` | signed `amount` | Adjust ordinary shield, clamped to 0–100. |
| `effect` | `x`, `y` | Add an explosion effect. |
| `effect` | `x`, `y`, optional `kind` (`air_blast`) | Add a native effect at a bounded position. Kinds: `ground_blast`, `small_ground`, `large_air`, `medium_air`, `small_air`, `air_blast`, `energy`, `person`, `platoon`, `flare`, `sparkle`. |
| `arc` | `x`, `y`, `to_x`, `to_y`, optional `ticks` (3), `anchor`, `to_anchor` | Draw cosmetic branching lightning with a hot white core, blue glow and flicker between two positions. Optional anchors are player (`0`) or existing enemy entity IDs. Endpoints must be within ±32,768; lifetime must be 1–12 simulation ticks; at most 256 arcs may be active. Pair with `damage` to attack. |
| `sound` | numeric `asset` | Queue an original digital sound asset for playback. |
| `play_sound` | namespaced `sound` ID | Queue a validated package-local sound. |
@@ -200,6 +201,17 @@ Use the `op` field shown below. All fields listed are required unless stated oth
| `complete` | none | Complete the mission. |
| `fail` | none | Fail the mission. |
`animation` supports visible boss phases without changing their collision
canvas, movement, firing cadence or gameplay RNG. It requires an enemy with
declared artwork and rejects original legacy enemies. A selected frame stays
visible across simulation ticks until another command changes it or supplies
null. Enemy snapshots include `pinned_art_frame` while a frame is selected.
The override survives serialization of a world; pilots save the script state
that determines the phase, and mission scripts should reset/reapply it at the
appropriate spawn or mission-start callback. No live mission snapshot is
implied by pilot saving. Invalid entities or frames roll back the whole command
batch and callback tick like other command failures.
Lightning arcs render in both SD and HD using irregular fractal channels, thin tapered forks and glow that brightens the scene. Geometry and brightness change with simulation age; each discharge receives a cosmetic seed derived from its creation tick and queue position so repeated shots vary. Drawing the same simulation state again gives the same image, and pausing freezes the effect. Arcs persist until their simulation lifetime expires. Forks are visual only and do not hit additional targets. Arcs use a separate cosmetic pool and consume no gameplay RNG, entity IDs or projectile capacity. The pool is excluded from saves and the gameplay digest. Invalid endpoints, lifetime or capacity reject the complete command batch and roll back the tick like other command failures.
`anchor` attaches the starting endpoint, and `to_anchor` independently attaches the destination. Supply absolute world coordinates at creation; each endpoint preserves its offset from the anchor's top-left `x/y` origin as the ship moves. Omitting an anchor keeps that endpoint fixed. Both anchors may be `0` to make a ring segment follow the player, or use `anchor: 0` and `to_anchor: enemy.entity` for a ship-to-ship bolt. Anchor IDs must exist when the command runs; a missing anchor rejects the batch. Endpoints update after simulation movement and API movement commands, and freeze at their last position when the anchored enemy disappears. Cosmetic coordinates clamp to ±32,768 when an attached ship moves beyond those bounds.
+55
View File
@@ -204,6 +204,36 @@
},
"additionalProperties": false
},
{
"description": "Pin a nonlegacy enemy's artwork frame; null resumes automatic animation.",
"type": "object",
"required": [
"entity",
"op"
],
"properties": {
"entity": {
"type": "integer",
"format": "uint64",
"minimum": 0.0
},
"frame": {
"type": [
"integer",
"null"
],
"format": "uint32",
"minimum": 0.0
},
"op": {
"type": "string",
"enum": [
"animation"
]
}
},
"additionalProperties": false
},
{
"type": "object",
"required": [
@@ -302,6 +332,14 @@
"y"
],
"properties": {
"kind": {
"default": "air_blast",
"allOf": [
{
"$ref": "#/definitions/ScriptEffectKind"
}
]
},
"op": {
"type": "string",
"enum": [
@@ -478,6 +516,23 @@
"additionalProperties": false
}
]
},
"ScriptEffectKind": {
"description": "Script names are independent of the legacy effect serialization format.",
"type": "string",
"enum": [
"ground_blast",
"small_ground",
"large_air",
"medium_air",
"small_air",
"air_blast",
"energy",
"person",
"platoon",
"flare",
"sparkle"
]
}
}
}
+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,
+252
View File
@@ -0,0 +1,252 @@
use raptor_replica::{
content::{ContentPack, Registry},
modding::{Command, ScriptEffectKind, ScriptRuntime},
persistence::Pilot,
simulation::{EffectKind, Input, World},
};
use serde_json::json;
fn fixture() -> Registry {
let mut registry = Registry::default();
let pack: ContentPack = serde_json::from_value(json!({
"weapons":[{"id":"base:projectile_forward_guns","name":"Guns","damage":1,"speed":8,"cooldown":2,"behavior":"straight","target":"all"}],
"items":[
{"id":"base:forward_guns","name":"Guns","price":0,"weapon":"base:projectile_forward_guns"},
{"id":"base:energy","name":"Shield","price":0,"max_count":100}
],
"enemies":[
{"id":"base:phase_boss","name":"Phase boss","health":100,"speed":0,
"art":{"asset":42,"logical_width":32,"logical_height":32,"frames":3}},
{"id":"base:invisible","name":"No artwork","health":100,"speed":0}
],
"missions":[{"id":"base:sector_1_wave_1","name":"Fixture","duration":1000}]
})).unwrap();
registry.apply(pack, "base").unwrap();
registry.validate().unwrap();
registry
}
#[test]
fn boss_phase_frame_remains_selected_and_resumes_normal_animation() {
let registry = fixture();
let pilot = Pilot::default();
let mut world = World::new(&pilot.mission, &pilot, &registry).unwrap();
let entity = world
.spawn(&"base:phase_boss".into(), 100, 20, &registry)
.unwrap();
assert!(
serde_json::to_value(&world.enemies[0])
.unwrap()
.get("pinned_art_frame")
.is_none()
);
let rng = world.rng.clone();
world
.apply_commands(
&[Command::Animation {
entity,
frame: Some(2),
}],
&registry,
)
.unwrap();
assert_eq!(world.rng, rng);
let mut runtime = ScriptRuntime::new();
for _ in 0..17 {
world
.step(Input::default(), &registry, &mut runtime)
.unwrap();
assert_eq!(world.enemies[0].art_frame, 2);
assert_eq!(world.enemies[0].pinned_art_frame, Some(2));
}
let serialized = serde_json::to_vec(&world).unwrap();
let mut recovered: World = serde_json::from_slice(&serialized).unwrap();
recovered
.step(Input::default(), &registry, &mut runtime)
.unwrap();
assert_eq!(recovered.enemies[0].art_frame, 2);
recovered
.apply_commands(
&[Command::Animation {
entity,
frame: None,
}],
&registry,
)
.unwrap();
recovered
.step(Input::default(), &registry, &mut runtime)
.unwrap();
assert_eq!(recovered.enemies[0].art_frame, recovered.enemies[0].age / 3);
assert!(recovered.enemies[0].pinned_art_frame.is_none());
}
#[test]
fn invalid_phase_frame_rolls_back_the_entire_batch() {
let registry = fixture();
let pilot = Pilot::default();
let mut world = World::new(&pilot.mission, &pilot, &registry).unwrap();
let entity = world
.spawn(&"base:phase_boss".into(), 100, 20, &registry)
.unwrap();
let invisible = world
.spawn(&"base:invisible".into(), 20, 20, &registry)
.unwrap();
for (target, frame) in [
(entity, 3),
(entity, u32::MAX),
(invisible, 0),
(u64::MAX, 0),
(0, 0),
] {
let before = world.digest();
let commands = [
Command::Credits { amount: 100 },
Command::Animation {
entity: target,
frame: Some(frame),
},
];
assert!(world.apply_commands(&commands, &registry).is_err());
assert_eq!(world.digest(), before);
}
}
#[test]
fn scripts_cannot_pin_or_reset_original_enemy_animation() {
let mut registry = fixture();
let pilot = Pilot::default();
let mut world = World::new(&pilot.mission, &pilot, &registry).unwrap();
let entity = world
.spawn(&"base:phase_boss".into(), 100, 20, &registry)
.unwrap();
registry
.enemies
.get_mut(&"base:phase_boss".into())
.unwrap()
.legacy = Some(raptor_replica::assets::LegacyEnemy {
name: "Native category fixture".into(),
fields: vec![0; 26],
shoot_type: vec![0; 24],
engine_x: vec![0; 24],
engine_y: vec![0; 24],
engine_width: vec![0; 24],
gun_x: vec![0; 24],
gun_y: vec![0; 24],
flight_x: vec![0; 32],
flight_y: vec![0; 32],
});
let before = world.digest();
for frame in [None, Some(1)] {
assert!(
world
.apply_commands(&[Command::Animation { entity, frame }], &registry)
.is_err()
);
assert_eq!(world.digest(), before);
}
}
#[test]
fn phase_callbacks_use_serializable_state_and_restore_the_tick_on_failure() {
let mut registry = fixture();
registry
.enemies
.get_mut(&"base:phase_boss".into())
.unwrap()
.script = Some("phase.rhai".into());
let mut runtime = ScriptRuntime::new();
runtime.add("base", "phase.rhai", r#"
fn on_spawn(e,s,r) { #{commands:[#{op:"animation",entity:e.entity,frame:1}],state:#{phase:1}} }
fn on_tick(e,s,r) { #{commands:[#{op:"animation",entity:e.entity,frame:s.phase}],state:s} }
"#).unwrap();
let pilot = Pilot::default();
let mut world = World::new(&pilot.mission, &pilot, &registry).unwrap();
world
.spawn(&"base:phase_boss".into(), 100, 20, &registry)
.unwrap();
world
.step(Input::default(), &registry, &mut runtime)
.unwrap();
assert_eq!(world.enemies[0].art_frame, 1);
runtime
.states
.insert("base/phase.rhai".into(), json!({"phase":99}));
let world_before = world.digest();
let states_before = runtime.states.clone();
let rng_before = runtime.rng.clone();
let error = world
.step(Input::default(), &registry, &mut runtime)
.unwrap_err();
assert!(format!("{error:#}").contains("base/phase.rhai::on_tick"));
assert_eq!(world.digest(), world_before);
assert_eq!(runtime.states, states_before);
assert_eq!(runtime.rng, rng_before);
}
#[test]
fn script_ground_impact_scrolls_with_terrain_and_legacy_effect_defaults_stay_airborne() {
let registry = fixture();
let pilot = Pilot::default();
let mut world = World::new(&pilot.mission, &pilot, &registry).unwrap();
let default: Command = serde_json::from_value(json!({"op":"effect","x":80,"y":40})).unwrap();
world
.apply_commands(
&[
default,
Command::Effect {
x: 80,
y: 40,
kind: ScriptEffectKind::GroundBlast,
},
Command::Effect {
x: 80,
y: 40,
kind: ScriptEffectKind::SmallGround,
},
Command::Effect {
x: 80,
y: 40,
kind: ScriptEffectKind::SmallAir,
},
],
&registry,
)
.unwrap();
assert_eq!(world.effects[0].kind, EffectKind::AirBlast);
let mut runtime = ScriptRuntime::new();
for _ in 0..8 {
world
.step(Input::default(), &registry, &mut runtime)
.unwrap();
}
assert_eq!(world.effects.len(), 4);
assert_eq!(world.effects[0].y, 40);
assert!(world.effects[1].y > 40);
assert_eq!(world.effects[1].y, world.effects[2].y);
assert_eq!(world.effects[3].y, 40);
}
#[test]
fn invalid_effect_kind_and_out_of_bounds_positions_cannot_partially_commit() {
assert!(
serde_json::from_value::<Command>(json!({"op":"effect","x":0,"y":0,"kind":"unknown"}))
.is_err()
);
let registry = fixture();
let pilot = Pilot::default();
let mut world = World::new(&pilot.mission, &pilot, &registry).unwrap();
for (x, y) in [(32769, 0), (0, -32769), (i32::MIN, 0), (0, i32::MAX)] {
let before = world.digest();
let commands = [
Command::Credits { amount: 100 },
Command::Effect {
x,
y,
kind: ScriptEffectKind::GroundBlast,
},
];
assert!(world.apply_commands(&commands, &registry).is_err());
assert_eq!(world.digest(), before);
}
}