1016 lines
36 KiB
Rust
1016 lines
36 KiB
Rust
//! Versioned, namespaced content shared by the base game and mods.
|
||
use crate::assets::{Catalog, LegacyEnemy, LegacyMap, Sprite};
|
||
use anyhow::{Context, Result, ensure};
|
||
use schemars::JsonSchema;
|
||
use serde::{Deserialize, Serialize};
|
||
use std::{collections::BTreeMap, path::PathBuf};
|
||
|
||
pub const API_VERSION: u32 = 1;
|
||
#[derive(
|
||
Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize, JsonSchema,
|
||
)]
|
||
#[serde(transparent)]
|
||
pub struct ContentId(pub String);
|
||
impl ContentId {
|
||
pub fn new(value: impl Into<String>) -> Result<Self> {
|
||
let id = Self(value.into());
|
||
id.validate()?;
|
||
Ok(id)
|
||
}
|
||
pub fn validate(&self) -> Result<()> {
|
||
let (ns, name) = self
|
||
.0
|
||
.split_once(':')
|
||
.context("content IDs require namespace:name")?;
|
||
ensure!(
|
||
!ns.is_empty()
|
||
&& !name.is_empty()
|
||
&& !name.contains(':')
|
||
&& self.0.len() <= 128
|
||
&& self
|
||
.0
|
||
.chars()
|
||
.all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || ":_-/.".contains(c)),
|
||
"invalid content ID {}",
|
||
self.0
|
||
);
|
||
Ok(())
|
||
}
|
||
}
|
||
impl From<&str> for ContentId {
|
||
fn from(s: &str) -> Self {
|
||
Self(s.into())
|
||
}
|
||
}
|
||
impl std::fmt::Display for ContentId {
|
||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||
self.0.fmt(f)
|
||
}
|
||
}
|
||
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
|
||
#[serde(deny_unknown_fields)]
|
||
pub struct Art {
|
||
pub asset: Option<u32>,
|
||
pub png: Option<String>,
|
||
#[serde(default)]
|
||
pub hd_png: Option<String>,
|
||
pub logical_width: u32,
|
||
pub logical_height: u32,
|
||
#[serde(default = "one")]
|
||
pub frames: u32,
|
||
#[serde(default)]
|
||
pub nearest: bool,
|
||
}
|
||
fn one() -> u32 {
|
||
1
|
||
}
|
||
fn yes() -> bool {
|
||
true
|
||
}
|
||
fn shield() -> i32 {
|
||
100
|
||
}
|
||
fn speed() -> i32 {
|
||
2
|
||
}
|
||
fn rate() -> u32 {
|
||
10
|
||
}
|
||
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
|
||
#[serde(rename_all = "snake_case")]
|
||
pub enum Target {
|
||
Air,
|
||
Ground,
|
||
All,
|
||
}
|
||
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
|
||
#[serde(rename_all = "snake_case")]
|
||
pub enum WeaponBehavior {
|
||
Straight,
|
||
Homing,
|
||
Beam,
|
||
Spread,
|
||
Bomb,
|
||
EnergyGrab,
|
||
/// Fire through on_fire only, without an automatic engine projectile.
|
||
Scripted,
|
||
}
|
||
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
|
||
#[serde(deny_unknown_fields)]
|
||
pub struct WeaponDef {
|
||
pub id: ContentId,
|
||
pub name: String,
|
||
pub damage: i32,
|
||
pub speed: i32,
|
||
pub cooldown: u32,
|
||
pub behavior: WeaponBehavior,
|
||
pub target: Target,
|
||
#[serde(default)]
|
||
pub art: Option<Art>,
|
||
#[serde(default)]
|
||
pub offsets: Vec<[i32; 2]>,
|
||
#[serde(default)]
|
||
pub script: Option<String>,
|
||
#[serde(default)]
|
||
pub legacy: Option<u32>,
|
||
}
|
||
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
|
||
#[serde(deny_unknown_fields)]
|
||
pub struct ItemDef {
|
||
pub id: ContentId,
|
||
pub name: String,
|
||
/// Optional shop information, up to 4096 UTF-8 bytes.
|
||
#[serde(default)]
|
||
pub description: String,
|
||
pub price: u32,
|
||
#[serde(default = "one")]
|
||
pub max_count: u32,
|
||
#[serde(default = "one")]
|
||
pub initial_count: u32,
|
||
#[serde(default)]
|
||
pub weapon: Option<ContentId>,
|
||
#[serde(default)]
|
||
pub shield: i32,
|
||
#[serde(default)]
|
||
pub credits: u32,
|
||
#[serde(default)]
|
||
pub consumable: bool,
|
||
#[serde(default)]
|
||
pub special: bool,
|
||
#[serde(default)]
|
||
pub art: Option<Art>,
|
||
#[serde(default)]
|
||
pub script: Option<String>,
|
||
#[serde(default)]
|
||
pub legacy: Option<u32>,
|
||
}
|
||
impl ItemDef {
|
||
/// Sales remove one ordinary unit, or one original shield refill/module.
|
||
pub fn resale_price(&self) -> u32 {
|
||
if matches!(self.id.0.as_str(), "base:energy" | "base:super_shield") {
|
||
self.price / 2
|
||
} else {
|
||
self.price / self.initial_count.max(1) / 2
|
||
}
|
||
}
|
||
}
|
||
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
|
||
#[serde(deny_unknown_fields)]
|
||
pub struct EnemyDef {
|
||
pub id: ContentId,
|
||
pub name: String,
|
||
#[serde(default = "shield")]
|
||
pub health: i32,
|
||
#[serde(default = "speed")]
|
||
pub speed: i32,
|
||
#[serde(default)]
|
||
pub reward: u32,
|
||
#[serde(default)]
|
||
pub ground: bool,
|
||
#[serde(default)]
|
||
pub boss: bool,
|
||
#[serde(default = "rate")]
|
||
pub cooldown: u32,
|
||
#[serde(default)]
|
||
pub path: Vec<[i32; 2]>,
|
||
#[serde(default)]
|
||
pub repeat: bool,
|
||
#[serde(default)]
|
||
pub weapon: Option<ContentId>,
|
||
#[serde(default)]
|
||
pub drop: Option<ContentId>,
|
||
#[serde(default)]
|
||
pub art: Option<Art>,
|
||
#[serde(default)]
|
||
pub script: Option<String>,
|
||
#[serde(skip)]
|
||
#[schemars(skip)]
|
||
pub legacy: Option<LegacyEnemy>,
|
||
}
|
||
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
|
||
#[serde(deny_unknown_fields)]
|
||
pub struct SpawnDef {
|
||
pub tick: u64,
|
||
pub enemy: ContentId,
|
||
pub x: i32,
|
||
pub y: i32,
|
||
#[serde(default)]
|
||
pub difficulty: u32,
|
||
}
|
||
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
|
||
#[serde(deny_unknown_fields)]
|
||
pub struct TileDef {
|
||
pub art: Art,
|
||
#[serde(default)]
|
||
pub health: i32,
|
||
#[serde(default)]
|
||
pub reward: u32,
|
||
#[serde(default)]
|
||
pub destroyed: Option<Art>,
|
||
}
|
||
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
|
||
#[serde(deny_unknown_fields)]
|
||
pub struct MissionDef {
|
||
pub id: ContentId,
|
||
pub name: String,
|
||
#[serde(default)]
|
||
pub art: Option<Art>,
|
||
pub duration: u64,
|
||
#[serde(default = "yes")]
|
||
pub auto_complete: bool,
|
||
#[serde(default)]
|
||
pub spawns: Vec<SpawnDef>,
|
||
#[serde(default)]
|
||
pub tiles: Vec<TileDef>,
|
||
#[serde(default)]
|
||
pub columns: u32,
|
||
#[serde(default)]
|
||
pub layout: Vec<usize>,
|
||
#[serde(default)]
|
||
pub next: Option<ContentId>,
|
||
#[serde(default)]
|
||
pub music: Option<u32>,
|
||
/// Package music registry ID. Mutually exclusive with legacy `music`.
|
||
#[serde(default)]
|
||
pub music_track: Option<ContentId>,
|
||
#[serde(default)]
|
||
pub script: Option<String>,
|
||
#[serde(default)]
|
||
pub legacy_map: Option<u32>,
|
||
}
|
||
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
|
||
#[serde(deny_unknown_fields)]
|
||
pub struct CampaignDef {
|
||
pub id: ContentId,
|
||
pub name: String,
|
||
#[serde(default)]
|
||
pub art: Option<Art>,
|
||
pub missions: Vec<ContentId>,
|
||
}
|
||
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
|
||
#[serde(deny_unknown_fields)]
|
||
pub struct SoundDef {
|
||
pub id: ContentId,
|
||
pub name: String,
|
||
pub file: String,
|
||
#[serde(default = "sound_volume")]
|
||
pub volume: i32,
|
||
#[serde(default = "sound_volume")]
|
||
pub pan: i32,
|
||
#[serde(default = "sound_pitch")]
|
||
pub pitch: i32,
|
||
#[serde(default = "one")]
|
||
pub priority: u32,
|
||
}
|
||
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
|
||
#[serde(deny_unknown_fields)]
|
||
pub struct MusicDef {
|
||
pub id: ContentId,
|
||
pub name: String,
|
||
/// Package-local checked MUS score, played through original GENMIDI FM.
|
||
pub file: String,
|
||
#[serde(default = "yes")]
|
||
pub looping: bool,
|
||
}
|
||
fn sound_volume() -> i32 {
|
||
127
|
||
}
|
||
fn sound_pitch() -> i32 {
|
||
128
|
||
}
|
||
#[derive(Debug, Clone, Default, Serialize, Deserialize, JsonSchema)]
|
||
#[serde(deny_unknown_fields)]
|
||
pub struct ContentPack {
|
||
#[serde(default)]
|
||
pub music: Vec<MusicDef>,
|
||
#[serde(default)]
|
||
pub sounds: Vec<SoundDef>,
|
||
#[serde(default)]
|
||
pub weapons: Vec<WeaponDef>,
|
||
#[serde(default)]
|
||
pub items: Vec<ItemDef>,
|
||
#[serde(default)]
|
||
pub enemies: Vec<EnemyDef>,
|
||
#[serde(default)]
|
||
pub missions: Vec<MissionDef>,
|
||
#[serde(default)]
|
||
pub campaigns: Vec<CampaignDef>,
|
||
#[serde(default)]
|
||
pub overrides: Vec<ContentId>,
|
||
}
|
||
#[derive(Debug, Clone, Default)]
|
||
pub struct Registry {
|
||
pub music: BTreeMap<ContentId, MusicDef>,
|
||
pub sounds: BTreeMap<ContentId, SoundDef>,
|
||
pub weapons: BTreeMap<ContentId, WeaponDef>,
|
||
pub items: BTreeMap<ContentId, ItemDef>,
|
||
pub enemies: BTreeMap<ContentId, EnemyDef>,
|
||
pub missions: BTreeMap<ContentId, MissionDef>,
|
||
pub campaigns: BTreeMap<ContentId, CampaignDef>,
|
||
pub owners: BTreeMap<ContentId, String>,
|
||
pub asset_roots: BTreeMap<String, PathBuf>,
|
||
pub overrides: Vec<String>,
|
||
}
|
||
impl Registry {
|
||
pub fn apply(&mut self, pack: ContentPack, owner: &str) -> Result<()> {
|
||
let mut candidate = self.clone();
|
||
candidate.apply_inner(pack, owner)?;
|
||
*self = candidate;
|
||
Ok(())
|
||
}
|
||
fn apply_inner(&mut self, pack: ContentPack, owner: &str) -> Result<()> {
|
||
let overrides: std::collections::BTreeSet<_> = pack.overrides.into_iter().collect();
|
||
let mut supplied = std::collections::BTreeSet::new();
|
||
macro_rules! insert {
|
||
($values:expr,$map:ident) => {
|
||
for value in $values {
|
||
value.id.validate()?;
|
||
ensure!(
|
||
supplied.insert(value.id.clone()),
|
||
"duplicate definition {}",
|
||
value.id
|
||
);
|
||
let exists = self.owners.contains_key(&value.id);
|
||
ensure!(
|
||
!exists || self.$map.contains_key(&value.id),
|
||
"override cannot change content type: {}",
|
||
value.id
|
||
);
|
||
ensure!(
|
||
exists == overrides.contains(&value.id),
|
||
"{}: {} requires an explicit override and override targets must exist",
|
||
owner,
|
||
value.id
|
||
);
|
||
if !exists {
|
||
ensure!(
|
||
value.id.0.starts_with(&format!("{owner}:")),
|
||
"{} does not belong to namespace {owner}",
|
||
value.id
|
||
);
|
||
}
|
||
if exists {
|
||
self.overrides.push(format!(
|
||
"{}: {} -> {owner}",
|
||
value.id, self.owners[&value.id]
|
||
));
|
||
}
|
||
self.owners.insert(value.id.clone(), owner.into());
|
||
self.$map.insert(value.id.clone(), value);
|
||
}
|
||
};
|
||
}
|
||
insert!(pack.weapons, weapons);
|
||
insert!(pack.items, items);
|
||
insert!(pack.enemies, enemies);
|
||
insert!(pack.missions, missions);
|
||
insert!(pack.campaigns, campaigns);
|
||
insert!(pack.sounds, sounds);
|
||
insert!(pack.music, music);
|
||
ensure!(
|
||
overrides.is_subset(&supplied),
|
||
"override has no replacement definition"
|
||
);
|
||
Ok(())
|
||
}
|
||
pub fn validate(&self) -> Result<()> {
|
||
let label = |name: &str, script: &Option<String>| -> Result<()> {
|
||
ensure!(
|
||
!name.trim().is_empty() && name.len() <= 128,
|
||
"content name must contain 1–128 bytes"
|
||
);
|
||
ensure!(
|
||
script
|
||
.as_ref()
|
||
.is_none_or(|s| !s.is_empty() && s.len() <= 4096),
|
||
"invalid script path length"
|
||
);
|
||
Ok(())
|
||
};
|
||
let art = |a: &Option<Art>| -> Result<()> {
|
||
if let Some(a) = a {
|
||
ensure!(
|
||
a.logical_width > 0
|
||
&& a.logical_height > 0
|
||
&& a.logical_width <= 4096
|
||
&& a.logical_height <= 4096
|
||
&& a.frames > 0
|
||
&& a.frames <= 256
|
||
&& (a.logical_width as u64 * a.logical_height as u64) <= 1_048_576,
|
||
"invalid artwork dimensions/frames"
|
||
);
|
||
ensure!(
|
||
a.asset.is_some() != a.png.is_some(),
|
||
"art requires exactly one of asset or png"
|
||
);
|
||
ensure!(
|
||
a.png
|
||
.as_ref()
|
||
.is_none_or(|p| !p.is_empty() && p.len() <= 4096),
|
||
"invalid artwork path length"
|
||
);
|
||
ensure!(
|
||
a.hd_png
|
||
.as_ref()
|
||
.is_none_or(|p| !p.is_empty() && p.len() <= 4096),
|
||
"invalid HD artwork path length"
|
||
);
|
||
ensure!(
|
||
a.asset
|
||
.is_none_or(|id| id.checked_add(a.frames - 1).is_some()),
|
||
"art animation asset ID overflow"
|
||
);
|
||
}
|
||
Ok(())
|
||
};
|
||
for w in self.weapons.values() {
|
||
label(&w.name, &w.script)?;
|
||
ensure!(
|
||
!matches!(w.behavior, WeaponBehavior::Scripted) || w.script.is_some(),
|
||
"scripted weapon {} requires a script",
|
||
w.id
|
||
);
|
||
ensure!(
|
||
w.damage > 0
|
||
&& w.damage <= 100000
|
||
&& w.cooldown > 0
|
||
&& w.speed.unsigned_abs() <= 128
|
||
&& w.offsets.len() <= 64,
|
||
"invalid weapon {}",
|
||
w.id
|
||
);
|
||
ensure!(
|
||
w.offsets
|
||
.iter()
|
||
.flatten()
|
||
.all(|n| n.unsigned_abs() <= 32768),
|
||
"weapon offset outside world bounds"
|
||
);
|
||
ensure!(
|
||
w.legacy.is_none_or(|n| n < 15),
|
||
"invalid original projectile ID"
|
||
);
|
||
art(&w.art)?;
|
||
}
|
||
for i in self.items.values() {
|
||
label(&i.name, &i.script)?;
|
||
ensure!(
|
||
i.description.len() <= 4096,
|
||
"item {} description exceeds 4096 bytes",
|
||
i.id
|
||
);
|
||
ensure!(
|
||
i.max_count > 0
|
||
&& i.max_count <= 1_000_000
|
||
&& i.initial_count > 0
|
||
&& i.initial_count <= i.max_count,
|
||
"invalid item {}",
|
||
i.id
|
||
);
|
||
ensure!(
|
||
i.shield.unsigned_abs() <= 100000 && i.legacy.is_none_or(|n| n < 24),
|
||
"invalid item effects/original ID"
|
||
);
|
||
if let Some(w) = &i.weapon {
|
||
ensure!(
|
||
self.weapons.contains_key(w),
|
||
"item {} references missing weapon {w}",
|
||
i.id
|
||
);
|
||
}
|
||
art(&i.art)?;
|
||
}
|
||
for e in self.enemies.values() {
|
||
label(&e.name, &e.script)?;
|
||
ensure!(
|
||
e.health > 0
|
||
&& e.health <= 100000
|
||
&& e.cooldown > 0
|
||
&& e.speed.unsigned_abs() <= 128
|
||
&& e.path.len() <= 1024,
|
||
"invalid enemy {}",
|
||
e.id
|
||
);
|
||
ensure!(
|
||
e.path.iter().flatten().all(|v| v.unsigned_abs() <= 32768),
|
||
"enemy path outside world bounds"
|
||
);
|
||
if let Some(w) = &e.weapon {
|
||
ensure!(
|
||
self.weapons.contains_key(w),
|
||
"enemy {} references missing weapon {w}",
|
||
e.id
|
||
);
|
||
}
|
||
if let Some(i) = &e.drop {
|
||
ensure!(
|
||
self.items.contains_key(i),
|
||
"enemy {} references missing drop {i}",
|
||
e.id
|
||
);
|
||
}
|
||
art(&e.art)?;
|
||
}
|
||
for m in self.missions.values() {
|
||
label(&m.name, &m.script)?;
|
||
ensure!(
|
||
m.music.is_none() || m.music_track.is_none(),
|
||
"mission {} cannot select both music and music_track",
|
||
m.id
|
||
);
|
||
if let Some(track) = &m.music_track {
|
||
ensure!(
|
||
self.music.contains_key(track),
|
||
"mission {} references missing music track {track}",
|
||
m.id
|
||
);
|
||
}
|
||
art(&m.art)?;
|
||
ensure!(
|
||
m.duration > 0
|
||
&& m.duration <= 1_000_000
|
||
&& m.spawns.len() <= 100000
|
||
&& m.tiles.len() <= 65536
|
||
&& m.layout.len() <= 2_000_000,
|
||
"invalid mission {}",
|
||
m.id
|
||
);
|
||
if !m.layout.is_empty() {
|
||
ensure!(
|
||
(1..=128).contains(&m.columns)
|
||
&& m.layout.len() % m.columns as usize == 0
|
||
&& m.layout.iter().all(|i| *i < m.tiles.len()),
|
||
"invalid mission terrain {}",
|
||
m.id
|
||
);
|
||
}
|
||
for tile in &m.tiles {
|
||
ensure!(
|
||
(if m.legacy_map.is_some() { -32768 } else { 0 }..=100000)
|
||
.contains(&tile.health),
|
||
"invalid terrain health"
|
||
);
|
||
art(&Some(tile.art.clone()))?;
|
||
art(&tile.destroyed)?;
|
||
}
|
||
ensure!(
|
||
m.spawns.windows(2).all(|w| w[0].tick <= w[1].tick),
|
||
"mission {} spawns must be sorted by tick",
|
||
m.id
|
||
);
|
||
for s in &m.spawns {
|
||
ensure!(
|
||
s.tick <= 1_000_000
|
||
&& s.difficulty <= if m.legacy_map.is_some() { 5 } else { 3 },
|
||
"invalid spawn tick/difficulty"
|
||
);
|
||
ensure!(
|
||
s.x.unsigned_abs() <= 32768 && s.y.unsigned_abs() <= 32768,
|
||
"spawn coordinate outside world bounds"
|
||
);
|
||
ensure!(
|
||
self.enemies.contains_key(&s.enemy),
|
||
"mission {} references missing enemy {}",
|
||
m.id,
|
||
s.enemy
|
||
);
|
||
}
|
||
if let Some(next) = &m.next {
|
||
ensure!(
|
||
self.missions.contains_key(next),
|
||
"missing next mission {next}"
|
||
);
|
||
}
|
||
}
|
||
for c in self.campaigns.values() {
|
||
label(&c.name, &None)?;
|
||
art(&c.art)?;
|
||
ensure!(c.missions.len() <= 100000, "campaign exceeds mission limit");
|
||
ensure!(!c.missions.is_empty(), "empty campaign {}", c.id);
|
||
for m in &c.missions {
|
||
ensure!(
|
||
self.missions.contains_key(m),
|
||
"campaign {} references missing mission {m}",
|
||
c.id
|
||
);
|
||
}
|
||
}
|
||
for sound in self.sounds.values() {
|
||
label(&sound.name, &None)?;
|
||
ensure!(
|
||
!sound.file.is_empty()
|
||
&& sound.file.len() <= 4096
|
||
&& (0..=127).contains(&sound.volume)
|
||
&& (0..=255).contains(&sound.pan)
|
||
&& (0..=255).contains(&sound.pitch)
|
||
&& sound.priority <= 100000,
|
||
"invalid sound definition {}",
|
||
sound.id
|
||
);
|
||
}
|
||
for music in self.music.values() {
|
||
label(&music.name, &None)?;
|
||
let path = std::path::Path::new(&music.file);
|
||
ensure!(
|
||
!music.file.is_empty()
|
||
&& music.file.len() <= 512
|
||
&& !path.is_absolute()
|
||
&& path
|
||
.components()
|
||
.all(|component| matches!(component, std::path::Component::Normal(_)))
|
||
&& path
|
||
.extension()
|
||
.is_some_and(|extension| extension.eq_ignore_ascii_case("mus")),
|
||
"music {} requires a package-local .mus file without parent traversal",
|
||
music.id
|
||
);
|
||
}
|
||
Ok(())
|
||
}
|
||
pub fn base(catalog: &Catalog) -> Result<Self> {
|
||
let mut reg = Self::default();
|
||
reg.asset_roots.insert("base".into(), catalog.root.clone());
|
||
let mut pack = ContentPack::default();
|
||
for o in crate::legacy_tables::objects() {
|
||
let id = ContentId::from(o.id);
|
||
let weapon = if o.index < 15 {
|
||
Some(ContentId(format!(
|
||
"base:projectile_{}",
|
||
o.id.strip_prefix("base:").unwrap()
|
||
)))
|
||
} else {
|
||
None
|
||
};
|
||
let art = if o.asset > 0 {
|
||
let s = catalog.sprite(o.asset)?;
|
||
Some(Art {
|
||
asset: Some(o.asset),
|
||
png: None,
|
||
hd_png: None,
|
||
logical_width: s.width as u32,
|
||
logical_height: s.height as u32,
|
||
frames: o.frames.max(1),
|
||
nearest: false,
|
||
})
|
||
} else {
|
||
None
|
||
};
|
||
pack.items.push(ItemDef {
|
||
id: id.clone(),
|
||
name: o.name.into(),
|
||
description: match o.index {
|
||
0 => "Paired forward guns. Fire automatically with the main fire control and strike air or ground targets.",
|
||
1 => "Twin plasma guns. An always-equipped upgrade that fires alongside the forward guns at airborne targets.",
|
||
2 => "Automatic micro missiles. Fire alongside the forward guns and seek airborne or ground enemies.",
|
||
3 => "Selectable unguided missile launcher. Direct its forward shots at airborne or ground targets.",
|
||
4 => "Selectable rapid-fire cannon. A concentrated stream of rounds for air and ground targets.",
|
||
5 => "Selectable tracking turret. Instantly aims its gunfire at airborne targets; it cannot damage ground targets.",
|
||
6 => "Selectable missile pods. Launch a spread of missiles against airborne targets.",
|
||
7 => "Selectable air-to-air missiles. Seek airborne enemies; they cannot damage ground targets.",
|
||
8 => "Selectable air-to-ground missiles. Seek ground targets; they cannot damage aircraft.",
|
||
9 => "Selectable ground bomb. Damages ground targets and neighboring destructible terrain.",
|
||
10 => "Selectable Energy Grab. Drains susceptible airborne enemies and suppresses their weapons.",
|
||
11 => "One-use Mega Bomb. The bomb control damages visible enemies and terrain and clears hostile shots.",
|
||
12 => "Selectable pulse cannon. A powerful forward energy weapon for air and ground targets.",
|
||
13 => "Selectable forward laser. A high-end forward energy beam for airborne and ground targets.",
|
||
14 => "Selectable Death Ray. Tracks airborne or ground targets with an energy beam.",
|
||
15 => "Extra shielding with 100 charge per module. The equipped module absorbs hits first; a reserve replaces it when destroyed.",
|
||
16 => "Restores 25 energy. With normal energy full, a smaller recharge goes to the equipped super shield.",
|
||
17 => "Always-equipped damage detector. Shows a scanning target-health display during flight.",
|
||
_ => "Valuable salvage collected during a mission for credits.",
|
||
}.into(),
|
||
price: o.cost,
|
||
max_count: o.max_count.max(1),
|
||
initial_count: o.initial_count,
|
||
weapon,
|
||
shield: if o.index == 16 {
|
||
25
|
||
} else if o.index == 15 {
|
||
100
|
||
} else {
|
||
0
|
||
},
|
||
credits: if o.index >= 18 { o.cost } else { 0 },
|
||
consumable: o.index == 11,
|
||
special: o.special,
|
||
art,
|
||
script: None,
|
||
legacy: Some(o.index),
|
||
});
|
||
}
|
||
for w in crate::legacy_tables::weapons() {
|
||
let sprite = if w.asset > 0 {
|
||
Some(catalog.sprite(w.asset)?)
|
||
} else {
|
||
None
|
||
};
|
||
pack.weapons.push(WeaponDef {
|
||
id: ContentId(format!(
|
||
"base:projectile_{}",
|
||
w.id.strip_prefix("base:").unwrap()
|
||
)),
|
||
name: w.name.into(),
|
||
damage: w.damage.max(1),
|
||
speed: w.speed,
|
||
cooldown: w.cooldown.max(1),
|
||
behavior: match w.index {
|
||
4 | 8 | 7 | 2 => WeaponBehavior::Homing,
|
||
5 | 12 | 13 | 14 => WeaponBehavior::Beam,
|
||
9 | 11 => WeaponBehavior::Bomb,
|
||
10 => WeaponBehavior::EnergyGrab,
|
||
6 => WeaponBehavior::Spread,
|
||
_ => WeaponBehavior::Straight,
|
||
},
|
||
// Describe effective gameplay, including native firing/beam
|
||
// exceptions to the historical hit-type metadata.
|
||
target: if w.index == 5 {
|
||
Target::Air
|
||
} else if w.index == 13 {
|
||
Target::All
|
||
} else {
|
||
match w.target {
|
||
1 | 5 => Target::Air,
|
||
2 | 4 => Target::Ground,
|
||
_ => Target::All,
|
||
}
|
||
},
|
||
art: sprite.map(|sprite| Art {
|
||
asset: Some(w.asset),
|
||
png: None,
|
||
hd_png: None,
|
||
logical_width: sprite.width as u32,
|
||
logical_height: sprite.height as u32,
|
||
frames: w.frames.max(1),
|
||
nearest: false,
|
||
}),
|
||
offsets: if w.index == 0 {
|
||
vec![[-13, 0], [13, 0]]
|
||
} else {
|
||
vec![[0, 0]]
|
||
},
|
||
script: None,
|
||
legacy: Some(w.index),
|
||
});
|
||
}
|
||
pack.weapons.push(WeaponDef {
|
||
id: "base:enemy_bullet".into(),
|
||
name: "Enemy cannon".into(),
|
||
damage: 3,
|
||
speed: 4,
|
||
cooldown: 12,
|
||
behavior: WeaponBehavior::Straight,
|
||
target: Target::All,
|
||
art: Some(Art {
|
||
asset: Some(0x101d6),
|
||
png: None,
|
||
hd_png: None,
|
||
logical_width: 8,
|
||
logical_height: 8,
|
||
frames: 2,
|
||
nearest: false,
|
||
}),
|
||
offsets: vec![[0, 0]],
|
||
script: None,
|
||
legacy: None,
|
||
});
|
||
for game in 0..4 {
|
||
let Ok(bytes) = catalog.named(&format!("SPRITE{}_ITM", game + 1)) else {
|
||
continue;
|
||
};
|
||
for (n, legacy) in LegacyEnemy::library(&bytes)?.into_iter().enumerate() {
|
||
let Ok(asset) = catalog.id(&legacy.name) else {
|
||
continue;
|
||
};
|
||
let Ok(s) = catalog.sprite(asset) else {
|
||
continue;
|
||
};
|
||
let f = &legacy.fields;
|
||
pack.enemies.push(EnemyDef {
|
||
id: ContentId(format!("base:enemy_{game}_{n}")),
|
||
name: legacy.name.clone(),
|
||
health: f[13].max(1),
|
||
speed: f[18].clamp(0, 128),
|
||
reward: f[14].max(0) as u32,
|
||
ground: f[21] >= 3,
|
||
boss: f[12] != 0,
|
||
cooldown: f[3].max(1) as u32,
|
||
path: legacy
|
||
.flight_x
|
||
.iter()
|
||
.zip(&legacy.flight_y)
|
||
.take(f[19].max(0) as usize)
|
||
.map(|(x, y)| [*x as i32, *y as i32])
|
||
.collect(),
|
||
repeat: f[21] == 0,
|
||
weapon: if f[22] > 0 {
|
||
Some("base:enemy_bullet".into())
|
||
} else {
|
||
None
|
||
},
|
||
drop: pack
|
||
.items
|
||
.iter()
|
||
.find(|i| i.legacy == Some(f[1] as u32))
|
||
.map(|i| i.id.clone()),
|
||
art: Some(Art {
|
||
asset: Some(asset),
|
||
png: None,
|
||
hd_png: None,
|
||
logical_width: s.width as u32,
|
||
logical_height: s.height as u32,
|
||
frames: f[7].max(1) as u32,
|
||
nearest: false,
|
||
}),
|
||
script: None,
|
||
legacy: Some(legacy),
|
||
});
|
||
}
|
||
}
|
||
for sector in 1..=3 {
|
||
let mut missions = Vec::new();
|
||
for wave in 1..=9 {
|
||
let Ok(id) = catalog.id(&format!("MAP{wave}G{sector}_MAP")) else {
|
||
continue;
|
||
};
|
||
let map = LegacyMap::decode(&catalog.read(id)?)?;
|
||
let mut spawns = Vec::new();
|
||
let mut group_row = None;
|
||
for s in &map.spawns {
|
||
let row = *group_row.get_or_insert(s.row);
|
||
let eid = ContentId(format!("base:enemy_{}_{}", s.game, s.library));
|
||
if pack.enemies.iter().any(|e| e.id == eid) {
|
||
spawns.push(SpawnDef {
|
||
tick: if row >= 139 {
|
||
0
|
||
} else {
|
||
(56 + (138 - row) * 32) as u64
|
||
},
|
||
enemy: eid,
|
||
x: 16 + s.x * 32,
|
||
y: (if row >= 139 { -136 } else { -112 }) - (row - s.row) * 32,
|
||
difficulty: s.difficulty.max(0) as u32,
|
||
});
|
||
}
|
||
if s.link == 1 || s.link == -1 {
|
||
group_row = None;
|
||
}
|
||
}
|
||
spawns.sort_by_key(|s| s.tick);
|
||
let mut tiles = Vec::new();
|
||
let mut layout = Vec::new();
|
||
let mut lookup = BTreeMap::new();
|
||
for (flat, game) in map.tiles {
|
||
let key = (flat, game);
|
||
let pos = if let Some(p) = lookup.get(&key) {
|
||
*p
|
||
} else {
|
||
let start = catalog.id(&format!("STARTG{}TILES", game + 1))? + 1;
|
||
let lib = catalog.named(&format!("FLATSG{}_ITM", game + 1))?;
|
||
let o = flat as usize * 8;
|
||
let destroyed = crate::assets::i32_at(&lib, o)?;
|
||
let health = crate::assets::i16_at(&lib, o + 4)?;
|
||
let bounty = crate::assets::i16_at(&lib, o + 6)?;
|
||
let make = |asset| Art {
|
||
asset: Some(asset),
|
||
png: None,
|
||
hd_png: None,
|
||
logical_width: 32,
|
||
logical_height: 32,
|
||
frames: 1,
|
||
nearest: false,
|
||
};
|
||
let n = tiles.len();
|
||
tiles.push(TileDef {
|
||
art: make(start + flat as u32),
|
||
health: if destroyed != flat as i32 {
|
||
health as i32
|
||
} else {
|
||
0
|
||
},
|
||
reward: bounty.max(0) as u32,
|
||
destroyed: if destroyed != flat as i32 {
|
||
Some(make(start + destroyed as u32))
|
||
} else {
|
||
None
|
||
},
|
||
});
|
||
lookup.insert(key, n);
|
||
n
|
||
};
|
||
layout.push(pos);
|
||
}
|
||
let mid = ContentId(format!("base:sector_{sector}_wave_{wave}"));
|
||
missions.push(mid.clone());
|
||
pack.missions.push(MissionDef {
|
||
id: mid,
|
||
name: format!("Sector {sector} / Wave {wave}"),
|
||
art: Some(Art {
|
||
asset: None,
|
||
png: Some(format!("briefings/sector_{sector}_wave_{wave}_sd.png")),
|
||
hd_png: Some(format!("briefings/sector_{sector}_wave_{wave}_hd.png")),
|
||
logical_width: 320,
|
||
logical_height: 200,
|
||
frames: 1,
|
||
nearest: false,
|
||
}),
|
||
duration: 4700,
|
||
auto_complete: true,
|
||
spawns,
|
||
tiles,
|
||
columns: 9,
|
||
layout,
|
||
next: if wave < 9 {
|
||
Some(ContentId(format!("base:sector_{sector}_wave_{}", wave + 1)))
|
||
} else {
|
||
None
|
||
},
|
||
music: catalog
|
||
.id(&format!(
|
||
"RAP{}_MUS",
|
||
[
|
||
[8, 2, 4, 7, 6, 2, 3, 4, 6],
|
||
[3, 2, 4, 8, 6, 2, 8, 1, 6],
|
||
[8, 4, 1, 7, 6, 2, 3, 4, 6],
|
||
][sector - 1][wave - 1]
|
||
))
|
||
.ok(),
|
||
music_track: None,
|
||
script: None,
|
||
legacy_map: Some(id),
|
||
});
|
||
}
|
||
if !missions.is_empty() {
|
||
pack.campaigns.push(CampaignDef {
|
||
id: ContentId(format!("base:sector_{sector}")),
|
||
name: ["Bravo Sector", "Tango Sector", "Outer Regions"][sector - 1].into(),
|
||
art: None,
|
||
missions,
|
||
});
|
||
}
|
||
}
|
||
reg.apply(pack, "base")?;
|
||
reg.validate()?;
|
||
Ok(reg)
|
||
}
|
||
pub fn art(&self, id: &ContentId) -> Option<&Art> {
|
||
self.enemies
|
||
.get(id)
|
||
.and_then(|e| e.art.as_ref())
|
||
.or_else(|| self.items.get(id).and_then(|e| e.art.as_ref()))
|
||
.or_else(|| self.weapons.get(id).and_then(|e| e.art.as_ref()))
|
||
.or_else(|| self.missions.get(id).and_then(|e| e.art.as_ref()))
|
||
.or_else(|| self.campaigns.get(id).and_then(|e| e.art.as_ref()))
|
||
}
|
||
}
|
||
pub fn art_sprite(
|
||
catalog: &Catalog,
|
||
registry: &Registry,
|
||
owner: &str,
|
||
art: &Art,
|
||
frame: u32,
|
||
) -> Result<Sprite> {
|
||
art_sprite_variant(catalog, registry, owner, art, frame, false)
|
||
}
|
||
pub fn art_sprite_variant(
|
||
catalog: &Catalog,
|
||
registry: &Registry,
|
||
owner: &str,
|
||
art: &Art,
|
||
frame: u32,
|
||
hd: bool,
|
||
) -> Result<Sprite> {
|
||
ensure!(art.frames > 0, "art animation must have frames");
|
||
let png = if hd {
|
||
art.hd_png.as_ref().or(art.png.as_ref())
|
||
} else {
|
||
art.png.as_ref()
|
||
};
|
||
if let Some(id) = art.asset.filter(|_| !(hd && art.hd_png.is_some())) {
|
||
catalog.sprite(
|
||
id.checked_add(frame % art.frames)
|
||
.context("art asset ID overflow")?,
|
||
)
|
||
} else {
|
||
let root = registry
|
||
.asset_roots
|
||
.get(owner)
|
||
.context("unknown artwork owner")?;
|
||
let path =
|
||
crate::assets::contained(root, std::path::Path::new(png.context("missing PNG")?))?;
|
||
let sprite = Sprite::png(&path, &catalog.palette()?)?;
|
||
ensure!(
|
||
sprite.width % art.frames as usize == 0,
|
||
"PNG strip width must be divisible by frame count"
|
||
);
|
||
let width = sprite.width / art.frames as usize;
|
||
let x = (frame % art.frames) as usize * width;
|
||
let mut out = Sprite::blank(width, sprite.height);
|
||
for y in 0..out.height {
|
||
out.pixels[y * width..(y + 1) * width].copy_from_slice(
|
||
&sprite.pixels[y * sprite.width + x..y * sprite.width + x + width],
|
||
);
|
||
}
|
||
Ok(out)
|
||
}
|
||
}
|