//! Validate the shipped weapon definitions/scripts using synthetic PNG and WAV inputs. //! No original or locally generated game artwork is needed in CI. use raptor_replica::{ content::{ContentId, ContentPack, Registry}, modding::{self, ActiveMods}, persistence::Pilot, simulation::{Input, World}, }; use serde_json::{Value, json}; use std::{fs, path::Path}; 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() } fn base() -> Registry { let mut registry = Registry::default(); registry.apply(content(json!({ "items": [{"id":"base:energy","name":"Shield","price":100,"max_count":100}], "missions": [{"id":"base:practice","name":"Practice","duration":2000}], "enemies": [ {"id":"base:target","name":"Stationary target","health":100,"speed":0}, {"id":"base:ground_target","name":"Ground target","health":100,"speed":0,"ground":true} ] })), "base").unwrap(); registry } fn wav() -> Vec { let mut bytes = b"RIFF".to_vec(); bytes.extend(164u32.to_le_bytes()); bytes.extend(b"WAVEfmt "); bytes.extend(16u32.to_le_bytes()); bytes.extend(1u16.to_le_bytes()); bytes.extend(1u16.to_le_bytes()); bytes.extend(11025u32.to_le_bytes()); bytes.extend(22050u32.to_le_bytes()); bytes.extend(2u16.to_le_bytes()); bytes.extend(16u16.to_le_bytes()); bytes.extend(b"data"); bytes.extend(128u32.to_le_bytes()); bytes.extend([0; 128]); bytes } fn install_synthetic(root: &Path, id: &str) { let source = Path::new(env!("CARGO_MANIFEST_DIR")).join("mods").join(id); let target = root.join(id); fs::create_dir_all(target.join("scripts")).unwrap(); fs::create_dir_all(target.join("art")).unwrap(); fs::create_dir_all(target.join("audio")).unwrap(); for file in [ "mod.toml", "content.json", "scripts/weapon.rhai", "scripts/equipment.rhai", ] { fs::copy(source.join(file), target.join(file)).unwrap(); } let definitions: Value = serde_json::from_slice(&fs::read(target.join("content.json")).unwrap()).unwrap(); for entry in definitions["weapons"] .as_array() .unwrap() .iter() .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, art["logical_height"].as_u64().unwrap() as u32 * scale, image::Rgba([60, 90, 120, 255]), ); image .save(target.join(art[property].as_str().unwrap())) .unwrap(); } } for sound in definitions["sounds"].as_array().unwrap() { fs::write(target.join(sound["file"].as_str().unwrap()), wav()).unwrap(); } } fn activate(ids: &[&str]) -> (tempfile::TempDir, ActiveMods) { let root = tempfile::tempdir().unwrap(); for id in ids { install_synthetic(root.path(), id); } let discovery = modding::discover(root.path()); assert!(discovery.errors.is_empty(), "{:?}", discovery.errors); let active = ActiveMods::build( &base(), &discovery, &ids.iter().map(|id| id.to_string()).collect::>(), None, ) .unwrap(); (root, active) } fn pilot(id: &str, registry: &Registry) -> Pilot { let equipment = ContentId::new(format!("{id}:equipment")).unwrap(); Pilot { inventory: [(equipment.clone(), 1), ("base:energy".into(), 100)] .into_iter() .collect(), selected: registry.items[&equipment].special.then_some(equipment), mission: "base:practice".into(), ..Pilot::default() } } fn world(id: &str, registry: &Registry) -> World { World::new(&"base:practice".into(), &pilot(id, registry), registry).unwrap() } fn state<'a>(active: &'a ActiveMods, id: &str) -> &'a Value { &active.scripts.states[&format!("{id}/scripts/weapon.rhai")] } #[test] 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); assert_eq!(active.pins[0].version, "1.0.0"); assert_eq!(active.registry.weapons.len(), 1); assert_eq!(active.registry.sounds.len(), 2); assert!( active .registry .items .contains_key(&format!("{id}:equipment").as_str().into()) ); assert!( active .registry .weapons .contains_key(&format!("{id}:projectile").as_str().into()) ); } let (_root, all) = activate(&PACKAGES); 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()); } #[test] fn shop_charges_each_price_and_rejects_duplicate_equipment() { let (_root, active) = activate(&PACKAGES); for id in PACKAGES { let item: ContentId = format!("{id}:equipment").as_str().into(); let price = active.registry.items[&item].price; let mut pilot = Pilot { credits: price, inventory: Default::default(), ..Pilot::default() }; pilot.buy(&item, &active.registry).unwrap(); assert_eq!(pilot.credits, 0); assert_eq!(pilot.inventory[&item], 1); assert!(pilot.buy(&item, &active.registry).is_err()); pilot.sell(&item, &active.registry).unwrap(); assert!(pilot.credits > 0); assert_eq!(pilot.inventory.get(&item).copied().unwrap_or(0), 0); } } #[test] fn pulse_cannon_emits_paired_rounds_and_every_third_burst_is_scripted() { let (_root, mut active) = activate(&["pulse_cannon"]); let mut world = world("pulse_cannon", &active.registry); for _ in 0..19 { world .step( Input { fire: true, ..Input::default() }, &active.registry, &mut active.scripts, ) .unwrap(); } assert_eq!(state(&active, "pulse_cannon")["bursts"], 3); assert_eq!(world.projectiles.len(), 8); assert_eq!(world.projectiles.iter().filter(|p| p.vx != 0).count(), 2); assert_eq!(world.mod_sounds, vec![ContentId::from("pulse_cannon:fire")]); assert!( world.effects.is_empty(), "firing into empty space must not create muzzle explosions" ); } #[test] fn continuous_firing_has_audio_and_projectiles_without_explosions_at_the_muzzle() { for id in PROJECTILE_PACKAGES { let (_root, mut active) = activate(&[id]); let mut world = world(id, &active.registry); let mut heard_fire = false; let mut saw_projectile = false; for _ in 0..80 { world .step( Input { fire: true, ..Input::default() }, &active.registry, &mut active.scripts, ) .unwrap(); heard_fire |= world .mod_sounds .contains(&format!("{id}:fire").as_str().into()); saw_projectile |= !world.projectiles.is_empty(); assert!( world.effects.is_empty(), "{id} created an explosion while firing into empty space" ); } assert!( heard_fire && saw_projectile, "{id} lost its firing sound or projectiles" ); } } #[test] fn pulse_hits_deal_damage_and_run_effect_audio_and_hit_state_callbacks() { let (_root, mut active) = activate(&["pulse_cannon"]); let mut world = world("pulse_cannon", &active.registry); world .spawn(&"base:target".into(), 140, 100, &active.registry) .unwrap(); world .step( Input { fire: true, ..Input::default() }, &active.registry, &mut active.scripts, ) .unwrap(); let mut hit_sound = false; for _ in 0..8 { world .step(Input::default(), &active.registry, &mut active.scripts) .unwrap(); hit_sound |= world.mod_sounds.contains(&"pulse_cannon:impact".into()); } assert_eq!(world.enemies[0].health, 92); assert_eq!(state(&active, "pulse_cannon")["hits"], 2); assert!(hit_sound); assert!(!world.effects.is_empty()); } #[test] fn seeker_swarm_homes_and_fragmentation_damages_nearby_enemies() { let (_root, mut active) = activate(&["seeker_swarm"]); let mut world = world("seeker_swarm", &active.registry); let primary = world .spawn(&"base:target".into(), 180, 110, &active.registry) .unwrap(); let neighbor = world .spawn(&"base:ground_target".into(), 180, 110, &active.registry) .unwrap(); world .step( Input { fire: true, ..Input::default() }, &active.registry, &mut active.scripts, ) .unwrap(); assert_eq!(world.projectiles.len(), 3); assert!(world.projectiles.iter().all(|p| p.vx > 0)); for _ in 0..12 { world .step(Input::default(), &active.registry, &mut active.scripts) .unwrap(); } let target = world.enemies.iter().find(|e| e.entity == primary).unwrap(); let nearby = world.enemies.iter().find(|e| e.entity == neighbor).unwrap(); assert_eq!(target.health, 82); assert_eq!(nearby.health, 94); assert_eq!(state(&active, "seeker_swarm")["hits"], 3); } #[test] fn ion_lance_pierces_air_and_ground_targets_in_its_column_only() { let (_root, mut active) = activate(&["ion_lance"]); let mut world = world("ion_lance", &active.registry); world .spawn(&"base:target".into(), 144, 100, &active.registry) .unwrap(); world .spawn(&"base:ground_target".into(), 144, 60, &active.registry) .unwrap(); world .spawn(&"base:target".into(), 220, 60, &active.registry) .unwrap(); world .step( Input { fire: true, ..Input::default() }, &active.registry, &mut active.scripts, ) .unwrap(); world .step(Input::default(), &active.registry, &mut active.scripts) .unwrap(); assert_eq!( world.enemies.iter().map(|e| e.health).collect::>(), [84, 84, 100] ); assert_eq!(state(&active, "ion_lance")["hits"], 4); assert!(world.mod_sounds.contains(&"ion_lance:impact".into())); assert!(!world.effects.is_empty()); } #[test] fn equipped_specials_and_primary_upgrade_fire_without_consuming_equipment() { let (_root, mut active) = activate(&PACKAGES); let mut pilot = pilot("pulse_cannon", &active.registry); pilot.inventory.insert("seeker_swarm:equipment".into(), 1); pilot.inventory.insert("ion_lance:equipment".into(), 1); pilot.selected = Some("ion_lance:equipment".into()); let mut world = World::new(&pilot.mission, &pilot, &active.registry).unwrap(); world .step( Input { fire: true, ..Input::default() }, &active.registry, &mut active.scripts, ) .unwrap(); assert_eq!(state(&active, "pulse_cannon")["bursts"], 1); assert_eq!(state(&active, "ion_lance")["bursts"], 1); assert!( !active .scripts .states .contains_key("seeker_swarm/scripts/weapon.rhai") ); for id in PROJECTILE_PACKAGES { assert_eq!( world.player.inventory[&format!("{id}:equipment").as_str().into()], 1 ); } world.player.selected = Some("seeker_swarm:equipment".into()); world .step( Input { special: true, ..Input::default() }, &active.registry, &mut active.scripts, ) .unwrap(); assert_eq!(state(&active, "seeker_swarm")["bursts"], 1); } #[test] fn scripted_bursts_and_state_resume_deterministically() { let (_root, mut active) = activate(&["pulse_cannon"]); let mut world = world("pulse_cannon", &active.registry); for _ in 0..10 { world .step( Input { fire: true, ..Input::default() }, &active.registry, &mut active.scripts, ) .unwrap(); } let mut resumed = world.clone(); let (_other_root, mut other) = activate(&["pulse_cannon"]); other.scripts.states = active.scripts.states.clone(); other.scripts.rng = active.scripts.rng.clone(); for _ in 0..20 { let input = Input { fire: true, ..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() ); } 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::>(), [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::>(), [-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::>(), [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::>(), [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 ); } } }