Package RustRaptor and refine controls, menus, saves, sound and mod selection
This commit is contained in:
1 parent
0844f6dffa
commit
e17bc1003e
649 files changed
+8652
-2677
No files matched your search
@@ -1,452 +0,0 @@
|
||||
//! Enemy package tests use synthetic PNG/WAV fixtures, never original/generated art.
|
||||
use raptor_replica::{
|
||||
content::{ContentId, ContentPack, Registry},
|
||||
modding::{self, ActiveMods, Command},
|
||||
persistence::Pilot,
|
||||
simulation::{Input, Outcome, World},
|
||||
};
|
||||
use serde_json::json;
|
||||
use std::{fs, path::Path};
|
||||
|
||||
const ENEMIES: [&str; 3] = ["iron_coast_scout", "iron_coast_turret", "iron_coast_boss"];
|
||||
|
||||
fn base() -> Registry {
|
||||
let mut registry = Registry::default();
|
||||
let pack: ContentPack = serde_json::from_value(json!({
|
||||
"weapons": [{"id":"base:projectile_forward_guns", "name":"Forward Guns",
|
||||
"damage":1,"speed":8,"cooldown":2,"behavior":"straight","target":"all",
|
||||
"offsets":[[-8,0],[8,0]]}],
|
||||
"items":[{"id":"base:forward_guns","name":"Forward Guns","price":0,
|
||||
"weapon":"base:projectile_forward_guns"},
|
||||
{"id":"base:energy","name":"Shield","price":0,"max_count":100}],
|
||||
"missions":[{"id":"base:sector_1_wave_1","name":"Synthetic Range","duration":5000}]
|
||||
}))
|
||||
.unwrap();
|
||||
registry.apply(pack, "base").unwrap();
|
||||
registry.validate().unwrap();
|
||||
registry
|
||||
}
|
||||
|
||||
fn wav() -> Vec<u8> {
|
||||
let mut data = b"RIFF".to_vec();
|
||||
data.extend(40_u32.to_le_bytes());
|
||||
data.extend(b"WAVEfmt ");
|
||||
data.extend(16_u32.to_le_bytes());
|
||||
data.extend(1_u16.to_le_bytes());
|
||||
data.extend(1_u16.to_le_bytes());
|
||||
data.extend(22050_u32.to_le_bytes());
|
||||
data.extend(44100_u32.to_le_bytes());
|
||||
data.extend(2_u16.to_le_bytes());
|
||||
data.extend(16_u16.to_le_bytes());
|
||||
data.extend(b"data");
|
||||
data.extend(4_u32.to_le_bytes());
|
||||
data.extend([0, 0, 0, 0]);
|
||||
data
|
||||
}
|
||||
|
||||
fn install_fixture(root: &Path, id: &str) {
|
||||
let source = Path::new(env!("CARGO_MANIFEST_DIR"))
|
||||
.join("examples/mods")
|
||||
.join(id);
|
||||
let output = root.join(id);
|
||||
fs::create_dir_all(output.join("scripts")).unwrap();
|
||||
fs::create_dir_all(output.join("art")).unwrap();
|
||||
fs::create_dir_all(output.join("audio")).unwrap();
|
||||
for name in ["mod.toml", "content.json", "scripts/ai.rhai"] {
|
||||
fs::copy(source.join(name), output.join(name)).unwrap();
|
||||
}
|
||||
let content: ContentPack =
|
||||
serde_json::from_slice(&fs::read(source.join("content.json")).unwrap()).unwrap();
|
||||
for art in content
|
||||
.enemies
|
||||
.iter()
|
||||
.filter_map(|e| e.art.as_ref())
|
||||
.chain(content.weapons.iter().filter_map(|w| w.art.as_ref()))
|
||||
{
|
||||
for (path, scale) in [
|
||||
(art.png.as_ref().unwrap(), 1),
|
||||
(art.hd_png.as_ref().unwrap(), 2),
|
||||
] {
|
||||
let mut image = image::RgbaImage::new(
|
||||
art.logical_width * art.frames * scale,
|
||||
art.logical_height * scale,
|
||||
);
|
||||
for frame in 0..art.frames {
|
||||
for y in scale..(art.logical_height - 1) * scale {
|
||||
for x in scale..(art.logical_width - 1) * scale {
|
||||
image.put_pixel(
|
||||
frame * art.logical_width * scale + x,
|
||||
y,
|
||||
image::Rgba([80, 90, 100, 255]),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
image.save(output.join(path)).unwrap();
|
||||
}
|
||||
}
|
||||
for sound in content.sounds {
|
||||
fs::write(output.join(sound.file), wav()).unwrap();
|
||||
}
|
||||
}
|
||||
|
||||
fn active(ids: &[&str]) -> (tempfile::TempDir, ActiveMods) {
|
||||
let temp = tempfile::tempdir().unwrap();
|
||||
for id in ids {
|
||||
install_fixture(temp.path(), id);
|
||||
}
|
||||
let discovered = modding::discover(temp.path());
|
||||
assert!(discovered.errors.is_empty(), "{:?}", discovered.errors);
|
||||
let selected: Vec<String> = ids.iter().map(|id| id.to_string()).collect();
|
||||
let active = ActiveMods::build(&base(), &discovered, &selected, None).unwrap();
|
||||
(temp, active)
|
||||
}
|
||||
|
||||
fn world(active: &ActiveMods) -> World {
|
||||
World::new(
|
||||
&ContentId::from("base:sector_1_wave_1"),
|
||||
&Pilot::default(),
|
||||
&active.registry,
|
||||
)
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn all_enemy_packages_validate_independently_and_together() {
|
||||
for id in ENEMIES {
|
||||
let (_temp, a) = active(&[id]);
|
||||
assert_eq!(a.pins.len(), 1);
|
||||
assert_eq!(a.registry.enemies.len(), 1);
|
||||
assert!(
|
||||
a.registry
|
||||
.weapons
|
||||
.contains_key(&ContentId::new(format!("{id}:projectile")).unwrap())
|
||||
);
|
||||
assert_eq!(a.registry.sounds.len(), 2);
|
||||
assert_eq!(
|
||||
a.registry.enemies[&ContentId::new(format!("{id}:unit")).unwrap()]
|
||||
.script
|
||||
.as_deref(),
|
||||
Some("scripts/ai.rhai")
|
||||
);
|
||||
}
|
||||
let (_temp, a) = active(&ENEMIES);
|
||||
assert_eq!(a.pins.len(), 3);
|
||||
assert_eq!(a.registry.enemies.len(), 3);
|
||||
assert!(a.registry.overrides.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scout_weaves_in_both_directions_and_aims_hostile_fire() {
|
||||
let (_temp, mut a) = active(&["iron_coast_scout"]);
|
||||
let mut w = world(&a);
|
||||
w.player.x = 0;
|
||||
w.spawn(
|
||||
&ContentId::from("iron_coast_scout:unit"),
|
||||
120,
|
||||
0,
|
||||
&a.registry,
|
||||
)
|
||||
.unwrap();
|
||||
let mut right = false;
|
||||
let mut left = false;
|
||||
let mut sounds = 0;
|
||||
for _ in 0..95 {
|
||||
w.step(Input::default(), &a.registry, &mut a.scripts)
|
||||
.unwrap();
|
||||
let e = &w.enemies[0];
|
||||
right |= e.vx > 0;
|
||||
left |= e.vx < 0;
|
||||
assert!(e.x >= 24 && e.x <= 272);
|
||||
assert_eq!(e.vy, 1);
|
||||
sounds += w
|
||||
.mod_sounds
|
||||
.iter()
|
||||
.filter(|id| id.0 == "iron_coast_scout:shot")
|
||||
.count();
|
||||
}
|
||||
assert!(right && left);
|
||||
assert_eq!(w.enemies[0].y, 94);
|
||||
assert!(sounds >= 2);
|
||||
assert!(
|
||||
w.projectiles
|
||||
.iter()
|
||||
.any(|p| p.hostile && p.weapon.0 == "iron_coast_scout:projectile" && p.vx < 0)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn turret_scrolls_with_terrain_and_aims_at_player() {
|
||||
let (_temp, mut a) = active(&["iron_coast_turret"]);
|
||||
let mut w = world(&a);
|
||||
w.player.x = 0;
|
||||
w.spawn(
|
||||
&ContentId::from("iron_coast_turret:unit"),
|
||||
220,
|
||||
0,
|
||||
&a.registry,
|
||||
)
|
||||
.unwrap();
|
||||
let mut sounds = 0;
|
||||
for _ in 0..80 {
|
||||
w.step(Input::default(), &a.registry, &mut a.scripts)
|
||||
.unwrap();
|
||||
sounds += w
|
||||
.mod_sounds
|
||||
.iter()
|
||||
.filter(|id| id.0 == "iron_coast_turret:shot")
|
||||
.count();
|
||||
}
|
||||
assert_eq!((w.enemies[0].x, w.enemies[0].y), (220, 80));
|
||||
assert!(a.registry.enemies[&w.enemies[0].definition].ground);
|
||||
assert_eq!(sounds, 2);
|
||||
assert!(
|
||||
w.projectiles
|
||||
.iter()
|
||||
.any(|p| p.hostile && p.vx < 0 && p.vy >= 0)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn boss_enters_patrols_fires_fans_and_remains_on_screen() {
|
||||
let (_temp, mut a) = active(&["iron_coast_boss"]);
|
||||
let mut w = world(&a);
|
||||
w.player.x = 0;
|
||||
w.spawn(
|
||||
&ContentId::from("iron_coast_boss:unit"),
|
||||
112,
|
||||
-64,
|
||||
&a.registry,
|
||||
)
|
||||
.unwrap();
|
||||
let mut left = false;
|
||||
let mut right = false;
|
||||
let mut fans = 0;
|
||||
for tick in 0..1500 {
|
||||
w.step(Input::default(), &a.registry, &mut a.scripts)
|
||||
.unwrap();
|
||||
assert_eq!(w.outcome, Outcome::Playing);
|
||||
assert_eq!(w.enemies.len(), 1, "boss retired at{tick}");
|
||||
let e = &w.enemies[0];
|
||||
assert_eq!((e.width, e.height), (96, 64));
|
||||
if tick > 46 {
|
||||
assert_eq!(e.y, 24);
|
||||
assert!((80..=144).contains(&e.x));
|
||||
left |= e.vx < 0;
|
||||
right |= e.vx > 0;
|
||||
}
|
||||
if w.mod_sounds
|
||||
.contains(&ContentId::from("iron_coast_boss:shot"))
|
||||
{
|
||||
let bullets: Vec<_> = w
|
||||
.projectiles
|
||||
.iter()
|
||||
.filter(|p| p.age == 0 && p.hostile)
|
||||
.collect();
|
||||
assert_eq!(bullets.len(), 3);
|
||||
assert_eq!(bullets.iter().map(|p| p.vx).collect::<Vec<_>>(), [-2, 0, 2]);
|
||||
fans += 1;
|
||||
}
|
||||
}
|
||||
assert!(left && right);
|
||||
assert!(fans >= 25);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn boss_can_be_defeated_with_starter_guns_without_healing() {
|
||||
let (_temp, mut a) = active(&["iron_coast_boss"]);
|
||||
let mut w = world(&a);
|
||||
w.player.x = 128;
|
||||
w.spawn(
|
||||
&ContentId::from("iron_coast_boss:unit"),
|
||||
112,
|
||||
-64,
|
||||
&a.registry,
|
||||
)
|
||||
.unwrap();
|
||||
let mut defeated_at = None;
|
||||
for tick in 0..600 {
|
||||
w.step(
|
||||
Input {
|
||||
fire: true,
|
||||
..Input::default()
|
||||
},
|
||||
&a.registry,
|
||||
&mut a.scripts,
|
||||
)
|
||||
.unwrap();
|
||||
if w.objectives.get("iron_coast_boss:defeated") == Some(&1) {
|
||||
defeated_at = Some(tick);
|
||||
break;
|
||||
}
|
||||
assert_eq!(w.outcome, Outcome::Playing, "starter pilot died at{tick}");
|
||||
}
|
||||
assert!(
|
||||
defeated_at.is_some(),
|
||||
"boss cannot be killed with starter guns"
|
||||
);
|
||||
assert!(w.enemies.is_empty());
|
||||
assert!(w.player.shield > 0);
|
||||
assert_eq!(w.player.credits, 1800);
|
||||
assert!(
|
||||
w.mod_sounds
|
||||
.contains(&ContentId::from("iron_coast_boss:death"))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn death_rewards_objectives_and_sounds_are_emitted_exactly_once() {
|
||||
let (_temp, mut a) = active(&ENEMIES);
|
||||
let mut w = world(&a);
|
||||
for (index, id) in ENEMIES.iter().enumerate() {
|
||||
let unit = ContentId::new(format!("{id}:unit")).unwrap();
|
||||
let entity = w
|
||||
.spawn(&unit, 30 + index as i32 * 100, 0, &a.registry)
|
||||
.unwrap();
|
||||
w.apply_commands(
|
||||
&[Command::Damage {
|
||||
entity,
|
||||
amount: 100000,
|
||||
}],
|
||||
&a.registry,
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
w.step(Input::default(), &a.registry, &mut a.scripts)
|
||||
.unwrap();
|
||||
assert!(w.enemies.is_empty());
|
||||
assert_eq!(w.player.credits, 75 + 120 + 1800);
|
||||
for id in ENEMIES {
|
||||
assert_eq!(w.objectives.get(&format!("{id}:defeated")), Some(&1));
|
||||
assert!(
|
||||
w.mod_sounds
|
||||
.contains(&ContentId::new(format!("{id}:death")).unwrap())
|
||||
);
|
||||
}
|
||||
let previous = w.player.credits;
|
||||
w.step(Input::default(), &a.registry, &mut a.scripts)
|
||||
.unwrap();
|
||||
assert_eq!(w.player.credits, previous);
|
||||
assert!(w.mod_sounds.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn enemy_scripts_replay_identically_and_restore_serialized_state() {
|
||||
let (_temp, mut first) = active(&ENEMIES);
|
||||
let (_other, mut second) = active(&ENEMIES);
|
||||
let mut a = world(&first);
|
||||
for (index, id) in ENEMIES.iter().enumerate() {
|
||||
a.spawn(
|
||||
&ContentId::new(format!("{id}:unit")).unwrap(),
|
||||
60 + index as i32 * 30,
|
||||
-24,
|
||||
&first.registry,
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
a.player.x = 0;
|
||||
let mut b = a.clone();
|
||||
for _ in 0..75 {
|
||||
a.step(Input::default(), &first.registry, &mut first.scripts)
|
||||
.unwrap();
|
||||
b.step(Input::default(), &second.registry, &mut second.scripts)
|
||||
.unwrap();
|
||||
assert_eq!(a.digest(), b.digest());
|
||||
assert_eq!(first.scripts.checkpoints(), second.scripts.checkpoints());
|
||||
}
|
||||
let snapshot = serde_json::to_vec(&a).unwrap();
|
||||
b = serde_json::from_slice(&snapshot).unwrap();
|
||||
let checkpoint = first.scripts.checkpoints();
|
||||
second.scripts.restore(checkpoint);
|
||||
for _ in 0..25 {
|
||||
a.step(Input::default(), &first.registry, &mut first.scripts)
|
||||
.unwrap();
|
||||
b.step(Input::default(), &second.registry, &mut second.scripts)
|
||||
.unwrap();
|
||||
assert_eq!(a.digest(), b.digest());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mission_defeat_counts_reset_while_persistent_script_statistics_continue() {
|
||||
let (_temp, mut a) = active(&ENEMIES);
|
||||
for id in ENEMIES {
|
||||
let unit = ContentId::new(format!("{id}:unit")).unwrap();
|
||||
let mut first = world(&a);
|
||||
for _ in 0..2 {
|
||||
let entity = first.spawn(&unit, 20, 0, &a.registry).unwrap();
|
||||
first
|
||||
.step(Input::default(), &a.registry, &mut a.scripts)
|
||||
.unwrap();
|
||||
first
|
||||
.apply_commands(
|
||||
&[Command::Damage {
|
||||
entity,
|
||||
amount: 100000,
|
||||
}],
|
||||
&a.registry,
|
||||
)
|
||||
.unwrap();
|
||||
first
|
||||
.step(Input::default(), &a.registry, &mut a.scripts)
|
||||
.unwrap();
|
||||
}
|
||||
assert_eq!(first.objectives.get(&format!("{id}:defeated")), Some(&2));
|
||||
let mut next = world(&a);
|
||||
let entity = next.spawn(&unit, 20, 0, &a.registry).unwrap();
|
||||
next.step(Input::default(), &a.registry, &mut a.scripts)
|
||||
.unwrap();
|
||||
next.apply_commands(
|
||||
&[Command::Damage {
|
||||
entity,
|
||||
amount: 100000,
|
||||
}],
|
||||
&a.registry,
|
||||
)
|
||||
.unwrap();
|
||||
next.step(Input::default(), &a.registry, &mut a.scripts)
|
||||
.unwrap();
|
||||
assert_eq!(next.objectives.get(&format!("{id}:defeated")), Some(&1));
|
||||
let state = a.scripts.checkpoints().0;
|
||||
assert_eq!(state[&format!("{id}/scripts/ai.rhai")]["destroyed"], 3);
|
||||
if id == "iron_coast_boss" {
|
||||
assert!(
|
||||
state[&format!("{id}/scripts/ai.rhai")]["directions"]
|
||||
.as_object()
|
||||
.unwrap()
|
||||
.is_empty()
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shipped_enemy_assets_are_valid_and_aligned() {
|
||||
let root = Path::new(env!("CARGO_MANIFEST_DIR")).join("examples/mods");
|
||||
let selected: Vec<_> = ENEMIES.iter().map(|id| id.to_string()).collect();
|
||||
let discovery = modding::discover(&root);
|
||||
ActiveMods::build(&base(), &discovery, &selected, None).unwrap();
|
||||
for id in ENEMIES {
|
||||
let content: ContentPack =
|
||||
serde_json::from_slice(&fs::read(root.join(id).join("content.json")).unwrap()).unwrap();
|
||||
let art = content.enemies[0].art.as_ref().unwrap();
|
||||
for path in [art.png.as_ref().unwrap(), art.hd_png.as_ref().unwrap()] {
|
||||
let image = image::open(root.join(id).join(path)).unwrap().into_rgba8();
|
||||
assert_eq!(
|
||||
image.width() / art.frames * art.logical_height,
|
||||
image.height() * art.logical_width
|
||||
);
|
||||
for frame in 0..art.frames {
|
||||
let width = image.width() / art.frames;
|
||||
for x in frame * width..(frame + 1) * width {
|
||||
assert!(image.get_pixel(x, 0)[3] < 128);
|
||||
assert!(image.get_pixel(x, image.height() - 1)[3] < 128);
|
||||
}
|
||||
for y in 0..image.height() {
|
||||
assert!(image.get_pixel(frame * width, y)[3] < 128);
|
||||
assert!(image.get_pixel((frame + 1) * width - 1, y)[3] < 128);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user