Add scripted lightning weapon mods with interpolated anchored effects

This commit is contained in:
raptorreplica committed 2026-10-11 16:45:22 +11:00
1 parent 98465133aa
commit c0a6fd3431
50 files changed
+3500 -65

No files matched your search

+514 -7
View File
@@ -9,7 +9,14 @@ use raptor_replica::{
use serde_json::{Value, json};
use std::{fs, path::Path};
const PACKAGES: [&str; 3] = ["pulse_cannon", "seeker_swarm", "ion_lance"];
const PROJECTILE_PACKAGES: [&str; 3] = ["pulse_cannon", "seeker_swarm", "ion_lance"];
const PACKAGES: [&str; 5] = [
"pulse_cannon",
"seeker_swarm",
"ion_lance",
"lightning_cannon",
"lightning_shield",
];
fn content(value: Value) -> ContentPack {
serde_json::from_value(value).unwrap()
}
@@ -64,6 +71,9 @@ fn install_synthetic(root: &Path, id: &str) {
.chain(definitions["items"].as_array().unwrap())
{
let art = &entry["art"];
if art.is_null() {
continue;
}
for (property, scale) in [("png", 1), ("hd_png", 4)] {
let image = image::RgbaImage::from_pixel(
art["logical_width"].as_u64().unwrap() as u32 * scale,
@@ -114,7 +124,7 @@ fn state<'a>(active: &'a ActiveMods, id: &str) -> &'a Value {
}
#[test]
fn each_weapon_package_is_independent_and_the_three_can_coexist() {
fn each_weapon_package_is_independent_and_all_five_can_coexist() {
for id in PACKAGES {
let (_root, active) = activate(&[id]);
assert_eq!(active.pins.len(), 1);
@@ -135,9 +145,9 @@ fn each_weapon_package_is_independent_and_the_three_can_coexist() {
);
}
let (_root, all) = activate(&PACKAGES);
assert_eq!(all.pins.len(), 3);
assert_eq!(all.registry.weapons.len(), 3);
assert_eq!(all.registry.sounds.len(), 6);
assert_eq!(all.pins.len(), 5);
assert_eq!(all.registry.weapons.len(), 5);
assert_eq!(all.registry.sounds.len(), 10);
assert!(all.registry.overrides.is_empty());
}
@@ -190,7 +200,7 @@ fn pulse_cannon_emits_paired_rounds_and_every_third_burst_is_scripted() {
#[test]
fn continuous_firing_has_audio_and_projectiles_without_explosions_at_the_muzzle() {
for id in PACKAGES {
for id in PROJECTILE_PACKAGES {
let (_root, mut active) = activate(&[id]);
let mut world = world(id, &active.registry);
let mut heard_fire = false;
@@ -347,7 +357,7 @@ fn equipped_specials_and_primary_upgrade_fire_without_consuming_equipment() {
.states
.contains_key("seeker_swarm/scripts/weapon.rhai")
);
for id in PACKAGES {
for id in PROJECTILE_PACKAGES {
assert_eq!(
world.player.inventory[&format!("{id}:equipment").as_str().into()],
1
@@ -406,3 +416,500 @@ fn scripted_bursts_and_state_resume_deterministically() {
serde_json::to_value(&other.scripts.rng).unwrap()
);
}
fn fire_once(world: &mut World, active: &mut ActiveMods) {
world
.step(
Input {
fire: true,
..Input::default()
},
&active.registry,
&mut active.scripts,
)
.unwrap();
}
#[test]
fn lightning_cannon_chains_from_each_hit_with_falling_damage_and_a_four_target_cap() {
let (_root, mut active) = activate(&["lightning_cannon"]);
let mut world = world("lightning_cannon", &active.registry);
for (enemy, x, y) in [
("base:target", 144, 80),
("base:ground_target", 184, 80),
("base:target", 224, 80),
("base:target", 264, 80),
("base:target", 264, 120),
("base:target", 32, 80),
] {
world.spawn(&enemy.into(), x, y, &active.registry).unwrap();
}
fire_once(&mut world, &mut active);
assert_eq!(
world.enemies.iter().map(|e| e.health).collect::<Vec<_>>(),
[68, 80, 88, 92, 100, 100]
);
assert_eq!(state(&active, "lightning_cannon")["hits"], 4);
assert_eq!(state(&active, "lightning_cannon")["bursts"], 1);
assert!(world.projectiles.is_empty(), "no damaging carrier shot");
assert!(
world.effects.is_empty(),
"electrical arcs are not explosions"
);
assert!(world.mod_sounds.contains(&"lightning_cannon:fire".into()));
assert!(world.mod_sounds.contains(&"lightning_cannon:impact".into()));
assert_eq!(world.player.shield, 100);
}
#[test]
fn lightning_cannon_cannot_rehit_a_target_or_jump_across_a_gap() {
let (_root, mut active) = activate(&["lightning_cannon"]);
let mut world = world("lightning_cannon", &active.registry);
for x in [144, 184, 234] {
world
.spawn(&"base:target".into(), x, 80, &active.registry)
.unwrap();
}
// The initial victim dies, but its position still launches the next arc.
world.enemies[0].health = 10;
fire_once(&mut world, &mut active);
assert_eq!(
world.enemies.iter().map(|e| e.health).collect::<Vec<_>>(),
[-22, 80, 100]
);
assert_eq!(state(&active, "lightning_cannon")["hits"], 2);
}
#[test]
fn lightning_cannon_requires_forward_aim_and_respects_its_cooldown() {
let (_root, mut active) = activate(&["lightning_cannon"]);
let mut world = world("lightning_cannon", &active.registry);
for (x, y) in [(190, 80), (144, 196)] {
world
.spawn(&"base:target".into(), x, y, &active.registry)
.unwrap();
}
fire_once(&mut world, &mut active);
assert!(world.enemies.iter().all(|e| e.health == 100));
assert_eq!(state(&active, "lightning_cannon")["hits"], 0);
assert!(world.mod_sounds.contains(&"lightning_cannon:fire".into()));
assert!(!world.mod_sounds.contains(&"lightning_cannon:impact".into()));
let aligned = world
.spawn(&"base:target".into(), 144, 80, &active.registry)
.unwrap();
for _ in 0..17 {
fire_once(&mut world, &mut active);
}
assert_eq!(
world
.enemies
.iter()
.find(|e| e.entity == aligned)
.unwrap()
.health,
100
);
assert_eq!(state(&active, "lightning_cannon")["bursts"], 1);
fire_once(&mut world, &mut active);
assert_eq!(
world
.enemies
.iter()
.find(|e| e.entity == aligned)
.unwrap()
.health,
68
);
assert_eq!(state(&active, "lightning_cannon")["bursts"], 2);
}
#[test]
fn lightning_shield_zaps_close_aircraft_in_all_directions_only_while_firing() {
let (_root, mut active) = activate(&["lightning_shield"]);
let mut world = world("lightning_shield", &active.registry);
for (enemy, x, y) in [
("base:target", 104, 160),
("base:target", 184, 160),
("base:target", 144, 120),
("base:target", 144, 196),
("base:target", 192, 160),
("base:ground_target", 184, 160),
] {
world.spawn(&enemy.into(), x, y, &active.registry).unwrap();
}
world
.step(Input::default(), &active.registry, &mut active.scripts)
.unwrap();
assert!(world.enemies.iter().all(|e| e.health == 100));
assert!(
!active
.scripts
.states
.contains_key("lightning_shield/scripts/weapon.rhai")
);
assert!(world.player.selected.is_none());
assert!(world.lightning_arcs.is_empty());
fire_once(&mut world, &mut active);
assert_eq!(
world.enemies.iter().map(|e| e.health).collect::<Vec<_>>(),
[98, 98, 98, 98, 100, 100]
);
assert_eq!(state(&active, "lightning_shield")["hits"], 4);
assert!(world.mod_sounds.contains(&"lightning_shield:fire".into()));
assert_eq!(
world.player.inventory[&"lightning_shield:equipment".into()],
1
);
assert_eq!(world.player.shield, 100);
assert!(world.projectiles.is_empty());
assert!(world.effects.is_empty());
let center = (world.player.x + 16, world.player.y + 16);
let zaps: Vec<_> = world
.lightning_arcs
.iter()
.filter(|arc| arc.ticks == 3)
.collect();
assert_eq!(zaps.len(), 4);
for arc in zaps {
let radius_squared = (arc.x - center.0).pow(2) + (arc.y - center.1).pow(2);
assert!((18 * 18..=20 * 20).contains(&radius_squared));
assert_eq!(arc.anchor.as_ref().unwrap().entity, 0);
assert_ne!(arc.to_anchor.as_ref().unwrap().entity, 0);
}
for _ in 0..7 {
fire_once(&mut world, &mut active);
}
assert_eq!(state(&active, "lightning_shield")["bursts"], 1);
fire_once(&mut world, &mut active);
assert_eq!(state(&active, "lightning_shield")["bursts"], 2);
assert_eq!(state(&active, "lightning_shield")["hits"], 8);
}
#[test]
fn lightning_shield_has_a_strict_range_boundary_and_a_bounded_pulse() {
let (_root, mut active) = activate(&["lightning_shield"]);
let mut world = world("lightning_shield", &active.registry);
// Centers are far away, but the hull is exactly 28px from the pilot center.
world
.spawn(&"base:target".into(), 188, 160, &active.registry)
.unwrap();
world
.spawn(&"base:target".into(), 189, 160, &active.registry)
.unwrap();
fire_once(&mut world, &mut active);
assert_eq!(
world.enemies.iter().map(|e| e.health).collect::<Vec<_>>(),
[98, 100]
);
for _ in 0..4 {
world
.spawn(&"base:target".into(), 184, 160, &active.registry)
.unwrap();
}
for _ in 0..8 {
fire_once(&mut world, &mut active);
}
assert_eq!(state(&active, "lightning_shield")["hits"], 5);
assert_eq!(world.enemies[0].health, 98);
assert_eq!(world.enemies[1].health, 100);
}
#[test]
fn lightning_shield_is_quiet_when_no_aircraft_are_close_and_when_sold() {
let (_root, mut active) = activate(&["lightning_shield"]);
let mut world = world("lightning_shield", &active.registry);
world
.spawn(&"base:ground_target".into(), 184, 160, &active.registry)
.unwrap();
world
.spawn(&"base:target".into(), 144, 80, &active.registry)
.unwrap();
fire_once(&mut world, &mut active);
assert_eq!(state(&active, "lightning_shield")["hits"], 0);
assert!(world.mod_sounds.is_empty());
world
.player
.inventory
.remove(&"lightning_shield:equipment".into());
world
.spawn(&"base:target".into(), 184, 160, &active.registry)
.unwrap();
for _ in 0..10 {
fire_once(&mut world, &mut active);
}
assert!(world.enemies.iter().all(|e| e.health == 100));
assert_eq!(state(&active, "lightning_shield")["bursts"], 1);
assert!(world.lightning_arcs.is_empty());
}
#[test]
fn lightning_shield_halo_closes_around_hull_follows_movement_and_expires_after_firing() {
let (_root, mut active) = activate(&["lightning_shield"]);
let mut world = world("lightning_shield", &active.registry);
fire_once(&mut world, &mut active);
let ring = world.lightning_arcs.clone();
assert_eq!(ring.len(), 16);
assert!(world.mod_sounds.is_empty());
assert_eq!(state(&active, "lightning_shield")["hits"], 0);
let center = (world.player.x + 16, world.player.y + 16);
for (i, arc) in ring.iter().enumerate() {
assert_eq!(
(arc.to_x, arc.to_y),
(ring[(i + 1) % ring.len()].x, ring[(i + 1) % ring.len()].y)
);
let radius_squared = (arc.x - center.0).pow(2) + (arc.y - center.1).pow(2);
assert!((18 * 18..=20 * 20).contains(&radius_squared));
assert_eq!(arc.anchor.as_ref().unwrap().entity, 0);
assert_eq!(arc.to_anchor.as_ref().unwrap().entity, 0);
}
let old_position = (world.player.x, world.player.y);
world
.step(
Input {
dx: 1,
..Input::default()
},
&active.registry,
&mut active.scripts,
)
.unwrap();
let movement = (
world.player.x - old_position.0,
world.player.y - old_position.1,
);
assert_ne!(movement, (0, 0));
for (old, arc) in ring.iter().zip(&world.lightning_arcs) {
assert_eq!((arc.x - old.x, arc.y - old.y), movement);
assert_eq!((arc.to_x - old.to_x, arc.to_y - old.to_y), movement);
}
for _ in 0..8 {
world
.step(Input::default(), &active.registry, &mut active.scripts)
.unwrap();
}
assert!(world.lightning_arcs.is_empty());
assert_eq!(state(&active, "lightning_shield")["bursts"], 1);
}
#[test]
fn lightning_shield_fires_with_other_primaries_and_the_selected_cannon() {
let (_root, mut active) = activate(&PACKAGES);
let mut pilot = pilot("lightning_cannon", &active.registry);
pilot
.inventory
.insert("lightning_shield:equipment".into(), 1);
pilot.inventory.insert("pulse_cannon:equipment".into(), 1);
let mut world = World::new(&pilot.mission, &pilot, &active.registry).unwrap();
world
.spawn(&"base:target".into(), 144, 80, &active.registry)
.unwrap();
world
.spawn(&"base:target".into(), 184, 160, &active.registry)
.unwrap();
fire_once(&mut world, &mut active);
for id in ["lightning_cannon", "lightning_shield", "pulse_cannon"] {
assert_eq!(state(&active, id)["bursts"], 1);
assert_eq!(
world.player.inventory[&format!("{id}:equipment").as_str().into()],
1
);
}
assert_eq!(world.enemies[0].health, 68);
assert_eq!(world.enemies[1].health, 98);
assert_eq!(world.projectiles.len(), 2);
world.player.selected = Some("ion_lance:equipment".into());
world
.player
.inventory
.insert("ion_lance:equipment".into(), 1);
for _ in 0..8 {
fire_once(&mut world, &mut active);
}
assert_eq!(state(&active, "lightning_shield")["bursts"], 2);
assert_eq!(state(&active, "lightning_cannon")["bursts"], 1);
}
#[test]
fn lightning_loadout_state_and_damage_resume_deterministically() {
let ids = ["lightning_cannon", "lightning_shield"];
let (_root, mut active) = activate(&ids);
let mut pilot = pilot("lightning_cannon", &active.registry);
pilot
.inventory
.insert("lightning_shield:equipment".into(), 1);
let mut world = World::new(&pilot.mission, &pilot, &active.registry).unwrap();
for (x, y) in [(144, 80), (184, 80), (184, 160)] {
world
.spawn(&"base:target".into(), x, y, &active.registry)
.unwrap();
}
for _ in 0..10 {
fire_once(&mut world, &mut active);
}
let mut resumed = world.clone();
let (_other_root, mut other) = activate(&ids);
other.scripts.states = active.scripts.states.clone();
other.scripts.rng = active.scripts.rng.clone();
for tick in 0..24 {
let input = Input {
fire: tick % 3 != 0,
..Input::default()
};
world
.step(input, &active.registry, &mut active.scripts)
.unwrap();
resumed
.step(input, &other.registry, &mut other.scripts)
.unwrap();
}
assert_eq!(world.digest(), resumed.digest());
assert_eq!(active.scripts.states, other.scripts.states);
assert_eq!(
serde_json::to_value(&active.scripts.rng).unwrap(),
serde_json::to_value(&other.scripts.rng).unwrap()
);
}
#[test]
#[ignore = "requires original local palette/player artwork; writes ignored SD/HD visual proofs"]
fn lightning_mods_render_real_icons_and_discharge_arcs_in_sd_and_hd() {
use raptor_replica::{
assets::{Catalog, Sprite},
render::{BitmapFont, Canvas, draw_lightning_arcs, nearest},
};
let package = Path::new(env!("CARGO_MANIFEST_DIR"));
let data = std::env::var_os("RAPTOR_TEST_DATA")
.map(std::path::PathBuf::from)
.unwrap_or_else(|| package.join("../data"));
let catalog = Catalog::open(data).unwrap();
let palette = catalog.palette().unwrap();
let font = BitmapFont::decode(&catalog.named("SYSTEM_FNT").unwrap()).unwrap();
let player = catalog
.sprite(catalog.id("LPLAYER_PIC").unwrap() + 3)
.unwrap();
let discovery = modding::discover(&package.join("mods"));
let output = package.join("../.reference/lightning-mods");
fs::create_dir_all(&output).unwrap();
for id in ["lightning_cannon", "lightning_shield"] {
let mut active =
ActiveMods::build(&base(), &discovery, &[id.to_string()], Some(&catalog)).unwrap();
let mut world = world(id, &active.registry);
let positions = if id == "lightning_cannon" {
vec![(144, 80), (184, 80), (224, 80), (264, 80)]
} else {
vec![(104, 160), (184, 160), (144, 120), (144, 196)]
};
for (x, y) in positions {
world
.spawn(&"base:target".into(), x, y, &active.registry)
.unwrap();
}
fire_once(&mut world, &mut active);
assert_eq!(
world.lightning_arcs.len(),
if id == "lightning_shield" { 20 } else { 4 }
);
let digest = world.digest();
let script_rng = serde_json::to_value(&active.scripts.rng).unwrap();
for (mode, scale) in [("sd", 1), ("hd", 4)] {
let mut canvas = Canvas::new(scale);
canvas.clear(0);
font.text(
&mut canvas,
8,
8,
&format!(
"{} / {mode}",
active.registry.items[&format!("{id}:equipment").as_str().into()].name
),
8,
);
let equipment = Sprite::png(
&package
.join("mods")
.join(id)
.join(format!("art/equipment-{mode}.png")),
&palette,
)
.unwrap();
assert!(
equipment
.pixels
.iter()
.all(|pixel| *pixel < 240 || *pixel == raptor_replica::assets::TRANSPARENT)
);
canvas.blit(&equipment, 8 * scale as i32, 28 * scale as i32);
let price = active.registry.items[&format!("{id}:equipment").as_str().into()].price;
font.text(&mut canvas, 8, 48, &format!("${price}"), 8);
let unit = Sprite::png(
&package.join(format!("mods/iron_coast_scout/art/unit-{mode}.png")),
&palette,
)
.unwrap();
// The scout's HD master is 8x; fit to the same 24px logical canvas
// as the game renderer rather than drawing the raw master size.
let mut fitted_unit = Sprite::blank(24 * scale, 24 * scale);
for y in 0..fitted_unit.height {
for x in 0..fitted_unit.width {
fitted_unit.pixels[y * fitted_unit.width + x] =
unit.pixels[(y * unit.height / fitted_unit.height) * unit.width
+ x * unit.width / fitted_unit.width];
}
}
for enemy in &world.enemies {
canvas.blit(
&fitted_unit,
(enemy.x + 4) * scale as i32,
(enemy.y + 4) * scale as i32,
);
}
canvas.blit(
&nearest(&player, scale),
world.player.x * scale as i32,
world.player.y * scale as i32,
);
let scene = canvas.image.clone();
// Show successive discharges at their actual firing cadence, not
// just a single still: the cosmetic seed changes between bursts.
let weapon = active.registry.items[&format!("{id}:equipment").as_str().into()]
.weapon
.as_ref()
.unwrap();
let cooldown = active.registry.weapons[weapon].cooldown;
for frame in 0..cooldown * 3 {
canvas.image = scene.clone();
let mut arcs = world.lightning_arcs.clone();
for arc in &mut arcs {
arc.age = frame % cooldown;
arc.seed = arc
.seed
.wrapping_add((frame / cooldown * cooldown).wrapping_mul(0x1656_67b1));
}
draw_lightning_arcs(&mut canvas, &arcs, &palette);
image::save_buffer(
output.join(format!("{id}-{mode}-{frame:02}.png")),
&canvas.rgba(&palette, false),
canvas.image.width as u32,
canvas.image.height as u32,
image::ColorType::Rgba8,
)
.unwrap();
if frame == 0 {
image::save_buffer(
output.join(format!("{id}-{mode}.png")),
&canvas.rgba(&palette, false),
canvas.image.width as u32,
canvas.image.height as u32,
image::ColorType::Rgba8,
)
.unwrap();
}
}
assert_eq!(world.digest(), digest);
assert_eq!(
serde_json::to_value(&active.scripts.rng).unwrap(),
script_rng
);
}
}
}