Add independent Rust engine, deterministic mod API and asset pipeline
This commit is contained in:
1 parent
f2cce6f51b
commit
dbb1d4596b
53 files changed
+18314
-56
No files matched your search
+905
@@ -0,0 +1,905 @@
|
||||
//! 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,
|
||||
}
|
||||
#[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,
|
||||
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>,
|
||||
}
|
||||
#[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>,
|
||||
#[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,
|
||||
}
|
||||
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 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 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);
|
||||
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!(
|
||||
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.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)?;
|
||||
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
|
||||
);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
pub fn base(catalog: &Catalog) -> Result<Self> {
|
||||
let mut reg = Self::default();
|
||||
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(),
|
||||
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,
|
||||
},
|
||||
target: match w.target {
|
||||
1 => Target::Air,
|
||||
2 | 3 | 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: None,
|
||||
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(),
|
||||
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)
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user