Match full reference demo and validate original missions, transitions and sprite rendering
This commit is contained in:
1 parent
967ba69ee8
commit
0844f6dffa
21 files changed
+3984
-219
No files matched your search
+260
-94
@@ -7,7 +7,10 @@ use crate::{
|
||||
hd::HdAssets,
|
||||
modding::{self, ActiveMods, Discovery},
|
||||
persistence::{Config, Paths, Pilot},
|
||||
render::{BitmapFont, Canvas, SpriteCache, nearest, upscale},
|
||||
render::{
|
||||
BitmapFont, Canvas, SpriteCache, legacy_light_table, nearest, prefers_nearest,
|
||||
shade_air_shadow, shade_ground_shadow, upscale,
|
||||
},
|
||||
simulation::{Clock, Input, Outcome, World},
|
||||
};
|
||||
use anyhow::{Context, Result, anyhow};
|
||||
@@ -79,6 +82,8 @@ struct Host {
|
||||
raw: BTreeMap<u32, Sprite>,
|
||||
mod_art: BTreeMap<String, Sprite>,
|
||||
palette: [[u8; 3]; 256],
|
||||
shadow_table: [u8; 256],
|
||||
air_shadow_table: [u8; 256],
|
||||
audio: Option<AudioDevice<Mixer>>,
|
||||
mod_audio: BTreeMap<ContentId, DigitalSample>,
|
||||
desired_music: Option<u32>,
|
||||
@@ -133,6 +138,9 @@ impl Host {
|
||||
restore_scripts(&mut active.scripts, &pilot);
|
||||
let hd = HdAssets::load(&catalog.root.join("hd"), &catalog);
|
||||
let palette = catalog.palette()?;
|
||||
let vga_palette = palette.map(|color| color.map(|component| component >> 2));
|
||||
let shadow_table = legacy_light_table(&vga_palette, -9);
|
||||
let air_shadow_table = legacy_light_table(&vga_palette, -6);
|
||||
let font = BitmapFont::decode(&catalog.named("SYSTEM_FNT")?)?;
|
||||
let cache = SpriteCache::new(Some(paths.cache.join("sprites")));
|
||||
let pending = config.enabled_mods.clone();
|
||||
@@ -165,6 +173,8 @@ impl Host {
|
||||
raw: BTreeMap::new(),
|
||||
mod_art: BTreeMap::new(),
|
||||
palette,
|
||||
shadow_table,
|
||||
air_shadow_table,
|
||||
audio: None,
|
||||
mod_audio: BTreeMap::new(),
|
||||
desired_music: None,
|
||||
@@ -190,6 +200,7 @@ impl Host {
|
||||
self.pilot.script_rng = self.active.scripts.rng.clone();
|
||||
self.pilot
|
||||
.validate(&self.active.registry, &self.active.pins)?;
|
||||
self.pilot.mods = self.active.pins.clone();
|
||||
self.pilot.save(&self.paths.save_path(self.slot))?;
|
||||
self.save_config()
|
||||
}
|
||||
@@ -903,7 +914,11 @@ impl Host {
|
||||
let scaled = if sprite.width == art.logical_width as usize
|
||||
&& sprite.height == art.logical_height as usize
|
||||
{
|
||||
self.cache.get(&sprite, canvas.scale, art.nearest)?.clone()
|
||||
let pixelated = art.nearest
|
||||
|| art
|
||||
.asset
|
||||
.is_some_and(|id| prefers_nearest(id + frame % art.frames));
|
||||
self.cache.get(&sprite, canvas.scale, pixelated)?.clone()
|
||||
} else {
|
||||
let mut out = Sprite::blank(
|
||||
art.logical_width as usize * canvas.scale,
|
||||
@@ -938,6 +953,143 @@ impl Host {
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
fn shadow(
|
||||
&mut self,
|
||||
canvas: &mut Canvas,
|
||||
art: &Art,
|
||||
owner: &str,
|
||||
frame: u32,
|
||||
x: f32,
|
||||
y: f32,
|
||||
ground: bool,
|
||||
) -> Result<()> {
|
||||
// Original masks retain their native canvas even when HD art is enabled.
|
||||
// Mod masks use the supplied SD artwork and explicit logical dimensions.
|
||||
let source = if let Some(asset) = art.asset {
|
||||
self.raw(asset + frame % art.frames)?
|
||||
} else {
|
||||
art_sprite_variant(
|
||||
&self.catalog,
|
||||
&self.active.registry,
|
||||
owner,
|
||||
art,
|
||||
frame,
|
||||
false,
|
||||
)?
|
||||
};
|
||||
let mask = if (source.width, source.height)
|
||||
== (art.logical_width as usize, art.logical_height as usize)
|
||||
{
|
||||
source
|
||||
} else {
|
||||
let mut logical =
|
||||
Sprite::blank(art.logical_width as usize, art.logical_height as usize);
|
||||
for row in 0..logical.height {
|
||||
for column in 0..logical.width {
|
||||
logical.pixels[row * logical.width + column] =
|
||||
source.pixels[(row * source.height / logical.height) * source.width
|
||||
+ column * source.width / logical.width];
|
||||
}
|
||||
}
|
||||
logical
|
||||
};
|
||||
if ground {
|
||||
shade_ground_shadow(canvas, &mask, x, y, &self.shadow_table);
|
||||
} else {
|
||||
shade_air_shadow(canvas, &mask, x, y, &self.air_shadow_table);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
fn projectiles(
|
||||
&mut self,
|
||||
canvas: &mut Canvas,
|
||||
world: &World,
|
||||
hostile: bool,
|
||||
alpha: f32,
|
||||
) -> Result<()> {
|
||||
for p in world.projectiles.iter().filter(|p| p.hostile == hostile) {
|
||||
let def = self.active.registry.weapons[&p.weapon].clone();
|
||||
let x = p.px as f32 + (p.x - p.px) as f32 * alpha;
|
||||
let y = p.py as f32 + (p.y - p.py) as f32 * alpha;
|
||||
if p.legacy.is_some() || p.legacy_enemy.is_some() {
|
||||
if let Some(asset) = World::projectile_asset(p, &self.active.registry) {
|
||||
let frame = if let Some(shot) = &p.legacy_enemy {
|
||||
shot.art_frame % shot.frames.max(1)
|
||||
} else {
|
||||
p.legacy.as_ref().map_or(0, |shot| shot.art_frame)
|
||||
};
|
||||
let original = self.raw(asset + frame)?;
|
||||
let hd = if self.config.hd_mode {
|
||||
self.hd.sprite(asset + frame, canvas.scale, &self.palette)?
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let sprite = match hd {
|
||||
Some(sprite) => sprite,
|
||||
None => self
|
||||
.cache
|
||||
.get(&original, canvas.scale, prefers_nearest(asset + frame))?
|
||||
.clone(),
|
||||
};
|
||||
canvas.blit(
|
||||
&sprite,
|
||||
(x * canvas.scale as f32).round() as i32,
|
||||
(y * canvas.scale as f32).round() as i32,
|
||||
);
|
||||
}
|
||||
} else if matches!(def.behavior, crate::content::WeaponBehavior::Beam) {
|
||||
canvas.rect(x as i32, 0, 2, world.player.y, 255);
|
||||
} else if let Some(art) = def.art {
|
||||
let owner = self.active.registry.owners[&p.weapon].clone();
|
||||
self.art(
|
||||
canvas,
|
||||
&art,
|
||||
&owner,
|
||||
p.age / 2,
|
||||
x - art.logical_width as f32 / 2.0,
|
||||
y - art.logical_height as f32 / 2.0,
|
||||
)?;
|
||||
} else {
|
||||
canvas.rect(x as i32, y as i32, 2, 4, if p.hostile { 34 } else { 255 });
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
fn effects(
|
||||
&mut self,
|
||||
canvas: &mut Canvas,
|
||||
world: &World,
|
||||
ground: bool,
|
||||
alpha: f32,
|
||||
) -> Result<()> {
|
||||
for effect in world.effects.iter().filter(|e| e.ground == ground) {
|
||||
let (asset, frames) = effect.kind.artwork();
|
||||
let frame = effect.age.saturating_sub(1).min(frames - 1);
|
||||
let original = self.raw(asset + frame)?;
|
||||
let hd = if self.config.hd_mode {
|
||||
self.hd.sprite(asset + frame, canvas.scale, &self.palette)?
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let sprite = match hd {
|
||||
Some(sprite) => sprite,
|
||||
None => self
|
||||
.cache
|
||||
.get(&original, canvas.scale, prefers_nearest(asset + frame))?
|
||||
.clone(),
|
||||
};
|
||||
let x = effect.px as f32 + (effect.x - effect.px) as f32 * alpha
|
||||
- original.width as f32 / 2.0;
|
||||
let y = effect.py as f32 + (effect.y - effect.py) as f32 * alpha
|
||||
- original.height as f32 / 2.0;
|
||||
canvas.blit(
|
||||
&sprite,
|
||||
(x * canvas.scale as f32).round() as i32,
|
||||
(y * canvas.scale as f32).round() as i32,
|
||||
);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
fn scene(&mut self, canvas: &mut Canvas) -> Result<()> {
|
||||
let Some(world) = self.world.clone() else {
|
||||
return Ok(());
|
||||
@@ -1039,7 +1191,66 @@ impl Host {
|
||||
}
|
||||
}
|
||||
}
|
||||
let dark = matches!(
|
||||
world.mission.0.as_str(),
|
||||
"base:sector_2_wave_8" | "base:sector_3_wave_3"
|
||||
);
|
||||
let player = self.catalog.id(if dark {
|
||||
if world.player_flash {
|
||||
"FPLAYER_PIC"
|
||||
} else {
|
||||
"DPLAYER_PIC"
|
||||
}
|
||||
} else {
|
||||
"LPLAYER_PIC"
|
||||
})?;
|
||||
let player_frame = player + world.player.bank.clamp(0, 6) as u32;
|
||||
for ground in [true, false] {
|
||||
let mut shadows = 0;
|
||||
for e in &world.enemies {
|
||||
let def = self.active.registry.enemies[&e.definition].clone();
|
||||
if def.ground != ground
|
||||
|| def
|
||||
.legacy
|
||||
.as_ref()
|
||||
.is_some_and(|legacy| legacy.fields[11] == 0)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
if shadows >= if ground { 25 } else { 50 } {
|
||||
break;
|
||||
}
|
||||
if let Some(art) = def.art {
|
||||
let owner = self.active.registry.owners[&e.definition].clone();
|
||||
self.shadow(
|
||||
canvas,
|
||||
&art,
|
||||
&owner,
|
||||
if def.legacy.is_some() {
|
||||
e.art_frame
|
||||
} else {
|
||||
e.frame
|
||||
},
|
||||
interp(e.x, e.px),
|
||||
interp(e.y, e.py),
|
||||
ground,
|
||||
)?;
|
||||
shadows += 1;
|
||||
}
|
||||
}
|
||||
if !ground && shadows < 50 && world.draw_player {
|
||||
let mask = self.raw(player_frame)?;
|
||||
shade_air_shadow(
|
||||
canvas,
|
||||
&mask,
|
||||
interp(world.player.x, world.player.px),
|
||||
interp(world.player.y, world.player.py),
|
||||
&self.air_shadow_table,
|
||||
);
|
||||
}
|
||||
if !ground {
|
||||
self.effects(canvas, &world, true, alpha)?;
|
||||
}
|
||||
for e in &world.enemies {
|
||||
let def = self.active.registry.enemies[&e.definition].clone();
|
||||
if def.ground != ground {
|
||||
@@ -1062,80 +1273,24 @@ impl Host {
|
||||
}
|
||||
}
|
||||
}
|
||||
for p in &world.projectiles {
|
||||
let def = self.active.registry.weapons[&p.weapon].clone();
|
||||
let x = interp(p.x, p.px);
|
||||
let y = interp(p.y, p.py);
|
||||
if p.legacy.is_some() || p.legacy_enemy.is_some() {
|
||||
if let Some(asset) = World::projectile_asset(p, &self.active.registry) {
|
||||
let frame = if let Some(shot) = &p.legacy_enemy {
|
||||
shot.art_frame % shot.frames.max(1)
|
||||
} else {
|
||||
p.legacy.as_ref().map_or(0, |shot| shot.art_frame)
|
||||
};
|
||||
let original = self.raw(asset + frame)?;
|
||||
let hd = if self.config.hd_mode {
|
||||
self.hd.sprite(asset + frame, canvas.scale, &self.palette)?
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let sprite = match hd {
|
||||
Some(sprite) => sprite,
|
||||
None => self.cache.get(&original, canvas.scale, false)?.clone(),
|
||||
};
|
||||
canvas.blit(
|
||||
&sprite,
|
||||
(x * canvas.scale as f32).round() as i32,
|
||||
(y * canvas.scale as f32).round() as i32,
|
||||
);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if matches!(def.behavior, crate::content::WeaponBehavior::Beam) {
|
||||
canvas.rect(x as i32, 0, 2, world.player.y, 255);
|
||||
} else if let Some(art) = def.art {
|
||||
let owner = self.active.registry.owners[&p.weapon].clone();
|
||||
self.art(
|
||||
canvas,
|
||||
&art,
|
||||
&owner,
|
||||
p.age / 2,
|
||||
x - art.logical_width as f32 / 2.0,
|
||||
y - art.logical_height as f32 / 2.0,
|
||||
)?;
|
||||
self.projectiles(canvas, &world, false, alpha)?;
|
||||
if world.draw_player {
|
||||
let s = self.raw(player_frame)?;
|
||||
let hd = if self.config.hd_mode {
|
||||
self.hd.sprite(player_frame, canvas.scale, &self.palette)?
|
||||
} else {
|
||||
canvas.rect(x as i32, y as i32, 2, 4, if p.hostile { 34 } else { 255 });
|
||||
}
|
||||
None
|
||||
};
|
||||
let scaled = match hd {
|
||||
Some(sprite) => sprite,
|
||||
None => self.cache.get(&s, canvas.scale, false)?.clone(),
|
||||
};
|
||||
canvas.blit(
|
||||
&scaled,
|
||||
(interp(world.player.x, world.player.px) * canvas.scale as f32) as i32,
|
||||
(interp(world.player.y, world.player.py) * canvas.scale as f32) as i32,
|
||||
);
|
||||
}
|
||||
let dark = matches!(
|
||||
world.mission.0.as_str(),
|
||||
"base:sector_2_wave_8" | "base:sector_3_wave_3"
|
||||
);
|
||||
let player = self.catalog.id(if dark {
|
||||
if world.player_flash {
|
||||
"FPLAYER_PIC"
|
||||
} else {
|
||||
"DPLAYER_PIC"
|
||||
}
|
||||
} else {
|
||||
"LPLAYER_PIC"
|
||||
})?;
|
||||
let player_frame = player + world.player.bank.clamp(0, 6) as u32;
|
||||
let s = self.raw(player_frame)?;
|
||||
let hd = if self.config.hd_mode {
|
||||
self.hd.sprite(player_frame, canvas.scale, &self.palette)?
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let scaled = match hd {
|
||||
Some(sprite) => sprite,
|
||||
None => self.cache.get(&s, canvas.scale, false)?.clone(),
|
||||
};
|
||||
canvas.blit(
|
||||
&scaled,
|
||||
(interp(world.player.x, world.player.px) * canvas.scale as f32) as i32,
|
||||
(interp(world.player.y, world.player.py) * canvas.scale as f32) as i32,
|
||||
);
|
||||
for pickup in &world.pickups {
|
||||
if pickup
|
||||
.legacy
|
||||
@@ -1168,26 +1323,8 @@ impl Host {
|
||||
)?;
|
||||
}
|
||||
}
|
||||
for effect in &world.effects {
|
||||
if let Ok(id) = self.catalog.id("AIRMED_BLK") {
|
||||
if let Ok(s) = self.raw(id + effect.age as u32 / 2) {
|
||||
let scaled = self.cache.get(&s, canvas.scale, false)?.clone();
|
||||
canvas.blit(
|
||||
&scaled,
|
||||
(effect.x - s.width as i32 / 2) * canvas.scale as i32,
|
||||
(effect.y - s.height as i32 / 2) * canvas.scale as i32,
|
||||
);
|
||||
}
|
||||
} else {
|
||||
canvas.rect(
|
||||
effect.x - effect.age as i32 / 2,
|
||||
effect.y - 2,
|
||||
effect.age as i32 + 2,
|
||||
4,
|
||||
50,
|
||||
);
|
||||
}
|
||||
}
|
||||
self.effects(canvas, &world, false, alpha)?;
|
||||
self.projectiles(canvas, &world, true, alpha)?;
|
||||
// The original shield bars sit beside the playfield. A full-width
|
||||
// bottom panel would cover the player's engines at its initial y=160.
|
||||
canvas.rect(0, 0, 16, 200, 0);
|
||||
@@ -1463,7 +1600,9 @@ pub fn run(
|
||||
screenshot: Option<PathBuf>,
|
||||
preview: Option<ContentId>,
|
||||
movie_preview: Option<MovieKind>,
|
||||
benchmark: bool,
|
||||
) -> Result<()> {
|
||||
let setup_start = Instant::now();
|
||||
let mut host = Host::new(catalog, base, paths, mods_root, frames.is_some())?;
|
||||
if let Some(id) = preview {
|
||||
if frames.is_some() {
|
||||
@@ -1541,11 +1680,15 @@ pub fn run(
|
||||
host.music("MAINMENU_MUS");
|
||||
}
|
||||
let mut previous = Instant::now();
|
||||
let setup_ms = setup_start.elapsed().as_secs_f64() * 1000.0;
|
||||
let mut work_times = Vec::new();
|
||||
let mut frame_times = Vec::new();
|
||||
let mut count = 0u32;
|
||||
let mut cycle = false;
|
||||
let mut bomb = false;
|
||||
let mut quit = false;
|
||||
while !quit {
|
||||
let frame_start = Instant::now();
|
||||
for event in events.poll_iter() {
|
||||
match event {
|
||||
Event::Quit { .. } => quit = true,
|
||||
@@ -1868,7 +2011,12 @@ pub fn run(
|
||||
image::save_buffer(path, &presented, w, h, image::ColorType::Rgba8)?;
|
||||
}
|
||||
}
|
||||
let work_ms = frame_start.elapsed().as_secs_f64() * 1000.0;
|
||||
canvas.present();
|
||||
if benchmark && count >= 5 && !frames.is_some_and(|n| count + 1 >= n) {
|
||||
work_times.push(work_ms);
|
||||
frame_times.push(frame_start.elapsed().as_secs_f64() * 1000.0);
|
||||
}
|
||||
count += 1;
|
||||
if frames.is_some_and(|n| count >= n) {
|
||||
quit = true;
|
||||
@@ -1877,6 +2025,24 @@ pub fn run(
|
||||
std::thread::sleep(Duration::from_millis(1));
|
||||
}
|
||||
}
|
||||
if benchmark && !work_times.is_empty() {
|
||||
work_times.sort_by(f64::total_cmp);
|
||||
frame_times.sort_by(f64::total_cmp);
|
||||
let percentile = |values: &[f64], p: usize| values[(values.len() - 1) * p / 100];
|
||||
println!(
|
||||
"benchmark renderer={} size={}x{} scale={} hd={} startup_ms={setup_ms:.2} samples={} work_p50_ms={:.2} work_p95_ms={:.2} frame_p50_ms={:.2} frame_p95_ms={:.2}",
|
||||
canvas.info().name,
|
||||
host.config.width,
|
||||
host.config.height,
|
||||
host.config.scale,
|
||||
host.config.hd_mode,
|
||||
work_times.len(),
|
||||
percentile(&work_times, 50),
|
||||
percentile(&work_times, 95),
|
||||
percentile(&frame_times, 50),
|
||||
percentile(&frame_times, 95)
|
||||
);
|
||||
}
|
||||
if frames.is_none() {
|
||||
host.config.save(&host.paths)?;
|
||||
}
|
||||
|
||||
+7
-1
@@ -20,9 +20,14 @@ fn run() -> Result<()> {
|
||||
.position(|s| s == key)
|
||||
.and_then(|i| args.get(i + 1))
|
||||
};
|
||||
let benchmark = args.iter().any(|a| a == "--benchmark");
|
||||
anyhow::ensure!(
|
||||
!benchmark || arg("--smoke-frames").is_some(),
|
||||
"--benchmark requires --smoke-frames"
|
||||
);
|
||||
if args.iter().any(|a| a == "--help" || a == "-h") {
|
||||
println!(
|
||||
"raptor-rust [--data DIR] [--mods DIR]\n --verify-assets Check catalog and all original mission definitions\n --validate-mods ID,... Validate selected local mod packages\n --schemas DIR Write mod API JSON schemas\n --headless MISSION --ticks N Deterministic simulation smoke run\n --replay ASSET_NAME --trace FILE Record legacy demo simulation states\n --import-legacy FILE --output FILE\n --smoke-frames N Exit desktop after N rendered frames\n --screenshot FILE Save the final desktop frame as PNG\n --preview-mission ID Start a mission directly\n --preview-cinematic KIND intro, landing, death, end1, end2, end3\n --profile-root DIR Use a separate pilot/config/cache directory\n --enabled ID,... Enable mods for headless/replay commands"
|
||||
"raptor-rust [--data DIR] [--mods DIR]\n --verify-assets Check catalog and all original mission definitions\n --validate-mods ID,... Validate selected local mod packages\n --schemas DIR Write mod API JSON schemas\n --headless MISSION --ticks N Deterministic simulation smoke run\n --replay ASSET_NAME --trace FILE Record legacy demo simulation states\n --import-legacy FILE --output FILE\n --smoke-frames N Exit desktop after N rendered frames\n --benchmark Report startup and frame timings (with --smoke-frames)\n --screenshot FILE Save the final desktop frame as PNG\n --preview-mission ID Start a mission directly\n --preview-cinematic KIND intro, landing, death, end1, end2, end3\n --profile-root DIR Use a separate pilot/config/cache directory\n --enabled ID,... Enable mods for headless/replay commands"
|
||||
);
|
||||
return Ok(());
|
||||
}
|
||||
@@ -187,6 +192,7 @@ fn run() -> Result<()> {
|
||||
.map(|s| ContentId::new(s))
|
||||
.transpose()?,
|
||||
movie,
|
||||
benchmark,
|
||||
)?;
|
||||
}
|
||||
#[cfg(not(feature = "desktop"))]
|
||||
|
||||
+13
-5
@@ -402,11 +402,19 @@ impl Pilot {
|
||||
p.difficulty <= 3 && p.inventory.len() <= 20,
|
||||
"invalid pilot difficulty/inventory"
|
||||
);
|
||||
ensure!(
|
||||
p.mods == pins,
|
||||
"save requires these mod versions/content: {:?}; enable the matching mods in the Mods screen",
|
||||
p.mods
|
||||
);
|
||||
let mut cursor = 0;
|
||||
for required in &p.mods {
|
||||
let position = pins[cursor..].iter().position(|pin| pin.id == required.id)
|
||||
.with_context(|| format!("save requires mod {} {}; enable the matching mods in the saved load order in the Mods screen", required.id, required.version))? + cursor;
|
||||
ensure!(
|
||||
pins[position] == *required,
|
||||
"save requires unchanged mod {} {} (content hash {}); restore that version/content or select another pilot",
|
||||
required.id,
|
||||
required.version,
|
||||
required.hash
|
||||
);
|
||||
cursor = position + 1;
|
||||
}
|
||||
ensure!(
|
||||
registry.missions.contains_key(&p.mission),
|
||||
"save mission {} is unavailable",
|
||||
|
||||
@@ -4,6 +4,12 @@ use anyhow::{Result, ensure};
|
||||
use sha2::{Digest, Sha256};
|
||||
use std::{collections::BTreeMap, fs, path::PathBuf};
|
||||
|
||||
/// Original tiny gun rounds and sparse final ground-explosion embers lose
|
||||
/// important pixels under edge filtering; preserve their exact pixel area.
|
||||
pub fn prefers_nearest(asset: u32) -> bool {
|
||||
matches!(asset, 0x101c3..=0x101c6 | 0x10178..=0x1018d)
|
||||
}
|
||||
|
||||
pub fn upscale(sprite: &Sprite, factor: usize) -> Result<Sprite> {
|
||||
ensure!(
|
||||
matches!(factor, 1 | 2 | 3 | 4),
|
||||
@@ -110,6 +116,95 @@ pub fn nearest(sprite: &Sprite, factor: usize) -> Sprite {
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Original VGA light/dark lookup. Inputs are six-bit RGB components, not SDL
|
||||
/// eight-bit colors. GFX_Remap uses Manhattan distance and the last equal match.
|
||||
pub fn legacy_light_table(palette: &[[u8; 3]; 256], level: i32) -> [u8; 256] {
|
||||
std::array::from_fn(|index| {
|
||||
let target = palette[index].map(|component| (component as i32 + level).clamp(0, 63));
|
||||
let mut best = 0;
|
||||
let mut distance = i32::MAX;
|
||||
for (candidate, color) in palette.iter().enumerate() {
|
||||
let difference: i32 = (0..3)
|
||||
.map(|channel| (color[channel] as i32 - target[channel]).abs())
|
||||
.sum();
|
||||
if difference <= distance {
|
||||
best = candidate as u8;
|
||||
distance = difference;
|
||||
}
|
||||
}
|
||||
best
|
||||
})
|
||||
}
|
||||
|
||||
/// SHADOW_GAdd / GFX_ShadeShape: preserve native mask spans at offset (-3,+4).
|
||||
pub fn shade_ground_shadow(canvas: &mut Canvas, mask: &Sprite, x: f32, y: f32, table: &[u8; 256]) {
|
||||
let scaled = nearest(mask, canvas.scale);
|
||||
canvas.shade(
|
||||
&scaled,
|
||||
((x - 3.0) * canvas.scale as f32).round() as i32,
|
||||
((y + 4.0) * canvas.scale as f32).round() as i32,
|
||||
table,
|
||||
);
|
||||
}
|
||||
|
||||
fn shadow_project(coordinate: i32, center: i32) -> i32 {
|
||||
// GFX_3DPoint: view=(160,100,1000), objectZ=1280, distance=200.
|
||||
// Keep division before arithmetic shift; negative coordinates round down
|
||||
// after the original signed integer division.
|
||||
((((coordinate as i64 - center as i64) * (200 << 11)) / 280) >> 11) as i32 + center
|
||||
}
|
||||
|
||||
/// SHADOW_Draw's projected horizontal spans, with duplicate projected rows
|
||||
/// suppressed. Fractional positions translate the indexed mask smoothly on the
|
||||
/// high-resolution canvas; integer positions retain the original projection.
|
||||
pub fn shade_air_shadow(canvas: &mut Canvas, mask: &Sprite, x: f32, y: f32, table: &[u8; 256]) {
|
||||
let base_x = x.floor() as i32 - 10;
|
||||
let base_y = y.floor() as i32 + 20;
|
||||
let fraction_x = (x - x.floor()) * (5.0 / 7.0);
|
||||
let fraction_y = (y - y.floor()) * (5.0 / 7.0);
|
||||
let scale = canvas.scale as f32;
|
||||
let mut previous_row = None;
|
||||
for row in 0..mask.height {
|
||||
let source = &mask.pixels[row * mask.width..(row + 1) * mask.width];
|
||||
if source.iter().all(|pixel| *pixel == TRANSPARENT) {
|
||||
continue;
|
||||
}
|
||||
let projected_y = shadow_project(base_y + row as i32, 100);
|
||||
if previous_row == Some(projected_y) {
|
||||
continue;
|
||||
}
|
||||
previous_row = Some(projected_y);
|
||||
let top = ((projected_y as f32 + fraction_y) * scale).round() as i32;
|
||||
let bottom = ((projected_y as f32 + 1.0 + fraction_y) * scale).round() as i32;
|
||||
let mut column = 0;
|
||||
while column < mask.width {
|
||||
if source[column] == TRANSPARENT {
|
||||
column += 1;
|
||||
continue;
|
||||
}
|
||||
let start = column;
|
||||
while column < mask.width && source[column] != TRANSPARENT {
|
||||
column += 1;
|
||||
}
|
||||
let left = ((shadow_project(base_x + start as i32, 160) as f32 + fraction_x) * scale)
|
||||
.round() as i32;
|
||||
let right =
|
||||
((shadow_project(base_x + column as i32 - 1, 160) as f32 + 1.0 + fraction_x)
|
||||
* scale)
|
||||
.round() as i32;
|
||||
for yy in top.max(0)..bottom.min(canvas.image.height as i32) {
|
||||
for xx in left.max(0)..right.min(canvas.image.width as i32) {
|
||||
let pixel =
|
||||
&mut canvas.image.pixels[yy as usize * canvas.image.width + xx as usize];
|
||||
if *pixel < 256 {
|
||||
*pixel = table[*pixel as usize] as u16;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
pub struct SpriteCache {
|
||||
memory: BTreeMap<String, Sprite>,
|
||||
root: Option<PathBuf>,
|
||||
|
||||
+577
-108
@@ -180,6 +180,10 @@ pub struct Enemy {
|
||||
pub shootagain: i32,
|
||||
pub shootcount: i32,
|
||||
pub shootflag: i32,
|
||||
#[serde(default)]
|
||||
pub energy_resistance: i32,
|
||||
#[serde(default)]
|
||||
pub shoot_disabled: bool,
|
||||
pub kami: u32,
|
||||
pub vx: i32,
|
||||
pub vy: i32,
|
||||
@@ -245,7 +249,128 @@ pub struct LegacyPickup {
|
||||
pub struct Effect {
|
||||
pub x: i32,
|
||||
pub y: i32,
|
||||
pub px: i32,
|
||||
pub py: i32,
|
||||
pub age: u32,
|
||||
pub kind: EffectKind,
|
||||
pub ground: bool,
|
||||
}
|
||||
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
|
||||
pub enum EffectKind {
|
||||
GroundBlast,
|
||||
SmallGround,
|
||||
LargeAir,
|
||||
MediumAir,
|
||||
SmallAir,
|
||||
AirBlast,
|
||||
Energy,
|
||||
Person,
|
||||
Platoon,
|
||||
Flare,
|
||||
Sparkle,
|
||||
}
|
||||
impl EffectKind {
|
||||
pub fn artwork(self) -> (u32, u32) {
|
||||
match self {
|
||||
Self::GroundBlast => (0x10164, 42),
|
||||
Self::SmallGround => (0x1027d, 35),
|
||||
Self::LargeAir => (0x101e5, 12),
|
||||
Self::MediumAir => (0x101d8, 13),
|
||||
Self::SmallAir => (0x101fc, 14),
|
||||
Self::AirBlast => (0x102a0, 16),
|
||||
Self::Energy => (0x101ae, 12),
|
||||
Self::Person => (0x101f5, 7),
|
||||
Self::Platoon => (0x10213, 10),
|
||||
Self::Flare => (0x10252, 26),
|
||||
Self::Sparkle => (0x1026c, 17),
|
||||
}
|
||||
}
|
||||
}
|
||||
impl Effect {
|
||||
fn new(kind: EffectKind, x: i32, y: i32) -> Self {
|
||||
Self {
|
||||
x,
|
||||
y,
|
||||
px: x,
|
||||
py: y,
|
||||
age: 0,
|
||||
kind,
|
||||
ground: matches!(
|
||||
kind,
|
||||
EffectKind::GroundBlast
|
||||
| EffectKind::SmallGround
|
||||
| EffectKind::MediumAir
|
||||
| EffectKind::Person
|
||||
| EffectKind::Platoon
|
||||
| EffectKind::Flare
|
||||
| EffectKind::Sparkle
|
||||
),
|
||||
}
|
||||
}
|
||||
fn airborne(mut self) -> Self {
|
||||
self.ground = false;
|
||||
self
|
||||
}
|
||||
}
|
||||
fn original_effect(effects: &mut Vec<Effect>, effect: Effect) {
|
||||
if effects.len() < 100 {
|
||||
effects.push(effect);
|
||||
}
|
||||
}
|
||||
fn death_effects(effects: &mut Vec<Effect>, rng: &mut LegacyRng, enemy: &Enemy, explosion: i32) {
|
||||
use EffectKind::*;
|
||||
let cx = enemy.x + enemy.width / 2;
|
||||
let cy = enemy.y + enemy.height / 2;
|
||||
let main = match explosion {
|
||||
0 => MediumAir,
|
||||
1 | 2 | 6 => LargeAir,
|
||||
3 => SmallGround,
|
||||
4 | 5 => GroundBlast,
|
||||
7 => Person,
|
||||
8 => Energy,
|
||||
9 => Platoon,
|
||||
_ => AirBlast,
|
||||
};
|
||||
if explosion == 4 {
|
||||
let x = enemy.x + rng.next_value() % enemy.width.max(1);
|
||||
let y = enemy.y + rng.next_value() % enemy.height.max(1);
|
||||
let kind = if rng.next_value() % 2 == 0 {
|
||||
Sparkle
|
||||
} else {
|
||||
Flare
|
||||
};
|
||||
original_effect(effects, Effect::new(kind, x, y));
|
||||
}
|
||||
original_effect(effects, Effect::new(main, cx, cy));
|
||||
if matches!(explosion, 2 | 5 | 6) {
|
||||
let area = (enemy.width >> 4) * (enemy.height >> 4);
|
||||
for n in 0..area {
|
||||
let x = enemy.x + rng.next_value() % enemy.width.max(1);
|
||||
let y = enemy.y + rng.next_value() % enemy.height.max(1);
|
||||
if explosion == 5 {
|
||||
let kind = if rng.next_value() % 2 == 0 {
|
||||
Flare
|
||||
} else {
|
||||
Sparkle
|
||||
};
|
||||
original_effect(effects, Effect::new(kind, x, y));
|
||||
original_effect(effects, Effect::new(SmallGround, x, y));
|
||||
} else if explosion == 6 {
|
||||
original_effect(effects, Effect::new(Flare, x, y).airborne());
|
||||
original_effect(
|
||||
effects,
|
||||
Effect::new(if n & 1 != 0 { LargeAir } else { AirBlast }, x, y),
|
||||
);
|
||||
} else {
|
||||
let effect = if n & 1 != 0 {
|
||||
Effect::new(MediumAir, x, y)
|
||||
} else {
|
||||
Effect::new(AirBlast, x, y).airborne()
|
||||
};
|
||||
original_effect(effects, effect);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
|
||||
pub enum Outcome {
|
||||
@@ -283,6 +408,14 @@ pub struct World {
|
||||
pub difficulty: u32,
|
||||
pub outcome: Outcome,
|
||||
pub end_timer: Option<u32>,
|
||||
#[serde(default)]
|
||||
pub legacy_transition: bool,
|
||||
#[serde(default = "visible_player")]
|
||||
pub draw_player: bool,
|
||||
#[serde(default)]
|
||||
pub control_paused: bool,
|
||||
#[serde(default)]
|
||||
pub departure_delta: [i32; 2],
|
||||
pub scroll: i32,
|
||||
pub objectives: BTreeMap<String, i32>,
|
||||
pub sounds: Vec<u32>,
|
||||
@@ -296,6 +429,8 @@ pub struct World {
|
||||
pub palette_blue: bool,
|
||||
pub special_held: bool,
|
||||
pub player_flash: bool,
|
||||
#[serde(default = "initial_shield_latch")]
|
||||
pub previous_shield: i32,
|
||||
#[serde(skip)]
|
||||
pub events: Vec<HookEvent>,
|
||||
}
|
||||
@@ -354,6 +489,10 @@ impl World {
|
||||
difficulty: pilot.difficulty,
|
||||
outcome: Outcome::Playing,
|
||||
end_timer: None,
|
||||
legacy_transition: m.legacy_map.is_some(),
|
||||
draw_player: true,
|
||||
control_paused: false,
|
||||
departure_delta: [0, 0],
|
||||
scroll: 0,
|
||||
objectives: BTreeMap::new(),
|
||||
sounds: vec![],
|
||||
@@ -367,6 +506,7 @@ impl World {
|
||||
palette_blue: false,
|
||||
special_held: false,
|
||||
player_flash: false,
|
||||
previous_shield: initial_shield_latch(),
|
||||
events: vec![],
|
||||
};
|
||||
out.event(
|
||||
@@ -467,6 +607,8 @@ impl World {
|
||||
}
|
||||
}),
|
||||
shootflag: f.map_or(0, |f| f[15]),
|
||||
energy_resistance: f.map_or(0, |f| f[13] >> 4),
|
||||
shoot_disabled: false,
|
||||
kami: 0,
|
||||
vx: 0,
|
||||
vy: 0,
|
||||
@@ -537,14 +679,37 @@ impl World {
|
||||
self.player_flash = false;
|
||||
self.player.px = self.player.x;
|
||||
self.player.py = self.player.y;
|
||||
if let Some([x, y]) = input.absolute {
|
||||
self.player.x = x.clamp(5, 282);
|
||||
self.player.y = y.clamp(0, 160);
|
||||
let input = if self.legacy_transition && self.control_paused && input.demo_bank.is_none() {
|
||||
Input::default()
|
||||
} else {
|
||||
input
|
||||
};
|
||||
let ending = self.legacy_transition && self.end_timer.is_some();
|
||||
if self.legacy_transition && self.control_paused && input.demo_bank.is_none() {
|
||||
self.player.x += self.departure_delta[0];
|
||||
self.player.y += self.departure_delta[1];
|
||||
} else if let Some([x, y]) = input.absolute {
|
||||
// Legacy demo playback writes recorded positions directly; the
|
||||
// normal input boundary correction is bypassed by DEMO_GetNext.
|
||||
self.player.x = if input.demo_bank.is_some() || ending {
|
||||
x
|
||||
} else {
|
||||
x.clamp(5, 282)
|
||||
};
|
||||
self.player.y = if input.demo_bank.is_some() || ending {
|
||||
y
|
||||
} else {
|
||||
y.clamp(0, 160)
|
||||
};
|
||||
} else {
|
||||
self.player.vx = accelerate(self.player.vx, input.dx, 10);
|
||||
self.player.vy = accelerate(self.player.vy, input.dy, 8);
|
||||
self.player.x = (self.player.x + self.player.vx).clamp(5, 282);
|
||||
self.player.y = (self.player.y + self.player.vy).clamp(0, 160);
|
||||
self.player.x += self.player.vx;
|
||||
self.player.y += self.player.vy;
|
||||
if !ending {
|
||||
self.player.x = self.player.x.clamp(5, 282);
|
||||
self.player.y = self.player.y.clamp(0, 160);
|
||||
}
|
||||
}
|
||||
let delta = ((self.player.x - self.player.px).abs() >> 2).min(3);
|
||||
if let Some(bank) = input.demo_bank {
|
||||
@@ -623,23 +788,13 @@ impl World {
|
||||
}
|
||||
self.special_held = input.special;
|
||||
if input.cycle && !self.cycle_held {
|
||||
let mut specials: Vec<_> = self
|
||||
.player
|
||||
.inventory
|
||||
.keys()
|
||||
.filter(|id| registry.items.get(*id).is_some_and(|i| i.special))
|
||||
.cloned()
|
||||
.collect();
|
||||
specials.sort_by_key(|id| (registry.items[id].legacy.unwrap_or(u32::MAX), id.clone()));
|
||||
if !specials.is_empty() {
|
||||
let next = self
|
||||
.player
|
||||
.selected
|
||||
.as_ref()
|
||||
.and_then(|id| specials.iter().position(|s| s == id))
|
||||
.map_or(0, |i| (i + 1) % specials.len());
|
||||
self.player.selected = Some(specials[next].clone());
|
||||
}
|
||||
play_legacy_sound(
|
||||
&mut self.rng,
|
||||
&mut self.sounds,
|
||||
&mut self.sound_events,
|
||||
"FX_SWEP",
|
||||
);
|
||||
self.player.selected = next_special(&self.player, registry);
|
||||
}
|
||||
self.cycle_held = input.cycle;
|
||||
if input.bomb && !self.bomb_held {
|
||||
@@ -655,9 +810,20 @@ impl World {
|
||||
}
|
||||
}
|
||||
self.bomb_held = input.bomb;
|
||||
self.scroll += 1;
|
||||
self.think_legacy_terrain(registry);
|
||||
// Do_Game decrements startendwave after input, before world updates.
|
||||
// At zero it still performs one final simulation/statistics pass.
|
||||
let transition_finished = self.legacy_transition && self.end_timer == Some(0);
|
||||
if self.legacy_transition {
|
||||
self.end_timer = self.end_timer.and_then(|timer| timer.checked_sub(1));
|
||||
}
|
||||
let mission = ®istry.missions[&self.mission];
|
||||
let previous_scroll = self.scroll;
|
||||
self.scroll += 1;
|
||||
if mission.legacy_map.is_some() {
|
||||
let rows = mission.layout.len() / mission.columns.max(1) as usize;
|
||||
self.scroll = self.scroll.min(rows.saturating_sub(8) as i32 * 32 + 56);
|
||||
}
|
||||
self.think_legacy_terrain(registry);
|
||||
while self.spawn_cursor < mission.spawns.len()
|
||||
&& mission.spawns[self.spawn_cursor].tick <= self.tick
|
||||
{
|
||||
@@ -710,17 +876,34 @@ impl World {
|
||||
} else {
|
||||
enemy.x = enemy.movement.x;
|
||||
enemy.y = enemy.movement.y;
|
||||
let left = enemy.movement.advance(def.speed);
|
||||
if enemy.movement.done && !def.path.is_empty() {
|
||||
let kamikaze = def.legacy.as_ref().is_some_and(|l| l.fields[21] == 2);
|
||||
let terminal_kami = kamikaze && enemy.kami == 2;
|
||||
// MoveEobj keeps a terminal kamikaze moving past its target
|
||||
// on subsequent steps; ordinary paths stop and retarget.
|
||||
let left = if terminal_kami && enemy.movement.done {
|
||||
enemy.movement.advance_shot(def.speed);
|
||||
0
|
||||
} else {
|
||||
enemy.movement.advance(def.speed)
|
||||
};
|
||||
if terminal_kami {
|
||||
if enemy.movement.y > 201
|
||||
|| enemy.movement.x > 320 + enemy.width / 2
|
||||
|| enemy.movement.y + enemy.width < 0
|
||||
|| enemy.movement.x + enemy.width < 0
|
||||
{
|
||||
retired.push(enemy.entity);
|
||||
}
|
||||
} else if enemy.movement.done && !def.path.is_empty() {
|
||||
let count = def.path.len();
|
||||
if enemy.path_index >= count {
|
||||
if kamikaze {
|
||||
if enemy.kami == 1 {
|
||||
enemy.kami = 2;
|
||||
}
|
||||
} else if enemy.path_index >= count {
|
||||
if def.repeat {
|
||||
enemy.reversed = true;
|
||||
enemy.path_index = count.saturating_sub(1);
|
||||
} else if def.legacy.as_ref().is_some_and(|l| l.fields[21] == 2)
|
||||
&& enemy.kami < 2
|
||||
{
|
||||
enemy.kami += 1;
|
||||
} else {
|
||||
retired.push(enemy.entity);
|
||||
}
|
||||
@@ -740,7 +923,15 @@ impl World {
|
||||
enemy.movement.initial();
|
||||
}
|
||||
enemy.movement.advance(left);
|
||||
if enemy.reversed {
|
||||
if kamikaze {
|
||||
// F_KAMI arms the chase when the last authored leg
|
||||
// starts, then aims at the player when that leg ends.
|
||||
if enemy.path_index < count - 1 {
|
||||
enemy.path_index += 1;
|
||||
} else if enemy.kami == 0 {
|
||||
enemy.kami = 1;
|
||||
}
|
||||
} else if enemy.reversed {
|
||||
enemy.path_index = enemy.path_index.saturating_sub(1);
|
||||
let min = def
|
||||
.legacy
|
||||
@@ -774,7 +965,11 @@ impl World {
|
||||
enemy.shootflag -= 1;
|
||||
if enemy.shootflag < 0 {
|
||||
enemy.shootflag = l.fields[3];
|
||||
for gun in 0..l.fields[22].max(0) as usize {
|
||||
for gun in 0..if enemy.shoot_disabled {
|
||||
0
|
||||
} else {
|
||||
l.fields[22].max(0) as usize
|
||||
} {
|
||||
shots.push((
|
||||
weapon.clone(),
|
||||
enemy.x + l.gun_x[gun] as i32,
|
||||
@@ -841,8 +1036,9 @@ impl World {
|
||||
continue;
|
||||
}
|
||||
if def.boss && enemy.health < 50 && (self.tick + 1) & 2 != 0 && def.legacy.is_some() {
|
||||
self.rng.next_value();
|
||||
self.rng.next_value();
|
||||
let x = enemy.x + self.rng.next_value() % enemy.width.max(1);
|
||||
let y = enemy.y + self.rng.next_value() % enemy.height.max(1);
|
||||
original_effect(&mut self.effects, Effect::new(EffectKind::SmallAir, x, y));
|
||||
}
|
||||
if !def.ground && contains(enemy, self.player.x + 16, self.player.y + 16) {
|
||||
enemy.health -= 16;
|
||||
@@ -853,10 +1049,12 @@ impl World {
|
||||
&mut self.rng,
|
||||
&mut self.sounds,
|
||||
&mut self.sound_events,
|
||||
self.legacy_transition && self.end_timer.is_some(),
|
||||
);
|
||||
if def.legacy.is_some() {
|
||||
self.rng.next_value();
|
||||
self.rng.next_value();
|
||||
let x = self.player.x + 16 + self.rng.next_value() % 8 - 4;
|
||||
let y = self.player.y + 16 + self.rng.next_value() % 8 - 4;
|
||||
original_effect(&mut self.effects, Effect::new(EffectKind::SmallAir, x, y));
|
||||
play_legacy_sound(
|
||||
&mut self.rng,
|
||||
&mut self.sounds,
|
||||
@@ -880,22 +1078,22 @@ impl World {
|
||||
self.player.x + 16,
|
||||
self.player.y + 16,
|
||||
);
|
||||
let random_count = match legacy.fields[2] {
|
||||
2 | 6 => 2 * (enemy.width >> 4) * (enemy.height >> 4),
|
||||
4 => 3,
|
||||
5 => 3 * (enemy.width >> 4) * (enemy.height >> 4),
|
||||
_ => 0,
|
||||
};
|
||||
for _ in 0..random_count {
|
||||
self.rng.next_value();
|
||||
}
|
||||
death_effects(&mut self.effects, &mut self.rng, enemy, legacy.fields[2]);
|
||||
} else {
|
||||
original_effect(
|
||||
&mut self.effects,
|
||||
Effect::new(
|
||||
if def.ground {
|
||||
EffectKind::GroundBlast
|
||||
} else {
|
||||
EffectKind::AirBlast
|
||||
},
|
||||
enemy.x + enemy.width / 2,
|
||||
enemy.y + enemy.height / 2,
|
||||
),
|
||||
);
|
||||
}
|
||||
self.player.credits = self.player.credits.saturating_add(def.reward);
|
||||
self.effects.push(Effect {
|
||||
x: enemy.x + enemy.width / 2,
|
||||
y: enemy.y + enemy.height / 2,
|
||||
age: 0,
|
||||
});
|
||||
events.push(HookEvent {
|
||||
content: enemy.definition.clone(),
|
||||
hook: "on_death",
|
||||
@@ -945,6 +1143,16 @@ impl World {
|
||||
}
|
||||
self.events.extend(events);
|
||||
self.enemies.retain(|e| !retired.contains(&e.entity));
|
||||
if self.legacy_transition
|
||||
&& mission.auto_complete
|
||||
&& !retired.is_empty()
|
||||
&& self.spawn_cursor == mission.spawns.len()
|
||||
&& self.enemies.is_empty()
|
||||
{
|
||||
// ENEMY_Remove arms departure before ESHOT_Think, so hostile
|
||||
// shots on the final removal step cannot subtract energy.
|
||||
self.end_timer = Some(60);
|
||||
}
|
||||
for (id, x, y, legacy) in shots {
|
||||
if legacy.is_none_or(|(kind, _, _, _)| kind < 0)
|
||||
|| ((0..320).contains(&x) && (0..200).contains(&y))
|
||||
@@ -990,6 +1198,7 @@ impl World {
|
||||
&mut self.rng,
|
||||
&mut self.sounds,
|
||||
&mut self.sound_events,
|
||||
self.legacy_transition && self.end_timer.is_some(),
|
||||
);
|
||||
dead_shots.push(p.entity);
|
||||
}
|
||||
@@ -1144,11 +1353,10 @@ impl World {
|
||||
.player
|
||||
.credits
|
||||
.saturating_add(mission.tiles[mission.layout[pos]].reward);
|
||||
self.effects.push(Effect {
|
||||
x: p.x,
|
||||
y: p.y,
|
||||
age: 0,
|
||||
});
|
||||
original_effect(
|
||||
&mut self.effects,
|
||||
Effect::new(EffectKind::GroundBlast, p.x, p.y),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1161,24 +1369,28 @@ impl World {
|
||||
|| (p.x >= -32 && p.x <= 352 && p.y >= -64 && p.y <= 240 && p.age < 300))
|
||||
});
|
||||
for e in &mut self.effects {
|
||||
e.px = e.x;
|
||||
e.py = e.y;
|
||||
e.age += 1;
|
||||
if e.ground && self.scroll != previous_scroll && e.age > 1 {
|
||||
e.y += 1;
|
||||
}
|
||||
}
|
||||
self.effects.retain(|e| e.age < 20);
|
||||
self.effects.retain(|e| e.age <= e.kind.artwork().1);
|
||||
self.tick += 1;
|
||||
if self.player.shield <= 0 {
|
||||
if self.legacy_transition {
|
||||
if mission.auto_complete
|
||||
&& self.spawn_cursor == mission.spawns.len()
|
||||
&& self.enemies.is_empty()
|
||||
&& self.end_timer.is_none()
|
||||
&& !transition_finished
|
||||
{
|
||||
self.end_timer = Some(60);
|
||||
}
|
||||
} else if self.player.shield <= 0 {
|
||||
self.outcome = Outcome::Failed;
|
||||
} else if mission.auto_complete {
|
||||
if mission.legacy_map.is_some() {
|
||||
// ENEMY_Remove starts the original 60-tick departure only
|
||||
// after the final spawn has been visited and all ships left.
|
||||
if self.spawn_cursor == mission.spawns.len() && self.enemies.is_empty() {
|
||||
match self.end_timer {
|
||||
None => self.end_timer = Some(60),
|
||||
Some(0) => self.outcome = Outcome::Complete,
|
||||
Some(n) => self.end_timer = Some(n - 1),
|
||||
}
|
||||
}
|
||||
} else if self.tick >= mission.duration
|
||||
if self.tick >= mission.duration
|
||||
&& self.spawn_cursor == mission.spawns.len()
|
||||
&& self
|
||||
.enemies
|
||||
@@ -1188,6 +1400,49 @@ impl World {
|
||||
self.outcome = Outcome::Complete;
|
||||
}
|
||||
}
|
||||
if mission.legacy_map.is_some() && self.tick >= 3 && self.tick % 2 == 1 {
|
||||
// Palette index 254 consumes the original shared RNG, even when no frame is rendered.
|
||||
self.palette_blue = self.rng.next_value() % 3 == 0;
|
||||
}
|
||||
if self.legacy_transition {
|
||||
self.think_legacy_transition(transition_finished, input.demo_bank.is_some());
|
||||
}
|
||||
// RAP_ThinkStats follows palette simulation. A new hit at low energy
|
||||
// destroys the equipped special, or the highest-numbered losable item.
|
||||
// OBJS_LoseObj reports success even when no eligible item remains.
|
||||
if self.player.shield <= 10
|
||||
&& self.player.shield < self.previous_shield
|
||||
&& self.player.super_shield < 1
|
||||
{
|
||||
let lost = self.player.selected.clone().or_else(|| {
|
||||
self.player
|
||||
.inventory
|
||||
.keys()
|
||||
.filter_map(|id| {
|
||||
registry
|
||||
.items
|
||||
.get(id)
|
||||
.and_then(|item| item.legacy)
|
||||
.filter(|legacy| matches!(legacy, 1..=14 | 18..=23))
|
||||
.map(|legacy| (legacy, id.clone()))
|
||||
})
|
||||
.max()
|
||||
.map(|(_, id)| id)
|
||||
});
|
||||
if let Some(id) = lost {
|
||||
let removed = self.player.inventory.remove(&id).is_some();
|
||||
if removed && self.player.selected.as_ref() == Some(&id) {
|
||||
self.player.selected = next_special(&self.player, registry);
|
||||
}
|
||||
}
|
||||
play_legacy_sound(
|
||||
&mut self.rng,
|
||||
&mut self.sounds,
|
||||
&mut self.sound_events,
|
||||
"FX_CRASH",
|
||||
);
|
||||
}
|
||||
self.previous_shield = self.player.shield;
|
||||
self.event(self.mission.clone(),"on_tick",serde_json::json!({"tick":self.tick,"player_x":self.player.x,"player_y":self.player.y,"enemies":self.enemies.len()}));
|
||||
if self.outcome != Outcome::Playing {
|
||||
self.event(
|
||||
@@ -1196,12 +1451,113 @@ impl World {
|
||||
serde_json::json!({"success":self.outcome==Outcome::Complete,"tick":self.tick}),
|
||||
);
|
||||
}
|
||||
if mission.legacy_map.is_some() && self.tick >= 3 && self.tick % 2 == 1 {
|
||||
// Palette index 254 consumes the original shared RNG, even when no frame is rendered.
|
||||
self.palette_blue = self.rng.next_value() % 3 == 0;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
fn think_legacy_transition(&mut self, finished: bool, demo: bool) {
|
||||
if self.player.shield <= 0 {
|
||||
// GCC evaluates the original ANIMS_StartAnim position arguments
|
||||
// right-to-left: y consumes the first shared random value.
|
||||
let y = self.player.y + self.rng.next_value() % 32;
|
||||
let x = self.player.x + self.rng.next_value() % 32;
|
||||
original_effect(&mut self.effects, Effect::new(EffectKind::MediumAir, x, y));
|
||||
let y = self.player.y + self.rng.next_value() % 32;
|
||||
let x = self.player.x + self.rng.next_value() % 32;
|
||||
original_effect(&mut self.effects, Effect::new(EffectKind::SmallAir, x, y));
|
||||
if self.end_timer.is_some_and(|timer| timer > 24) {
|
||||
let pan = if self.rng.next_value() % 2 == 0 {
|
||||
30
|
||||
} else {
|
||||
225
|
||||
};
|
||||
play_legacy_sound(
|
||||
&mut self.rng,
|
||||
&mut self.sounds,
|
||||
&mut self.sound_events,
|
||||
"FX_AIREXPLO",
|
||||
);
|
||||
if let Some(event) = self.sound_events.last_mut() {
|
||||
event.pan = pan;
|
||||
}
|
||||
}
|
||||
if self.end_timer.is_none() {
|
||||
self.end_timer = Some(60);
|
||||
}
|
||||
if self.end_timer == Some(24) {
|
||||
self.draw_player = false;
|
||||
play_legacy_sound(
|
||||
&mut self.rng,
|
||||
&mut self.sounds,
|
||||
&mut self.sound_events,
|
||||
"FX_AIREXPLO",
|
||||
);
|
||||
play_legacy_sound(
|
||||
&mut self.rng,
|
||||
&mut self.sounds,
|
||||
&mut self.sound_events,
|
||||
"FX_AIREXPLO2",
|
||||
);
|
||||
original_effect(
|
||||
&mut self.effects,
|
||||
Effect::new(EffectKind::LargeAir, self.player.x + 16, self.player.y + 16),
|
||||
);
|
||||
// Random positions are evaluated even after the animation pool
|
||||
// fills, exactly as the original 512-effect request burst.
|
||||
for index in 0..512 {
|
||||
let x = self.player.x - 16 + self.rng.next_value() % 32 * 2;
|
||||
let y = self.player.y - 16 + self.rng.next_value() % 32 * 2;
|
||||
let kind = if index & 1 != 0 {
|
||||
EffectKind::LargeAir
|
||||
} else {
|
||||
EffectKind::AirBlast
|
||||
};
|
||||
original_effect(&mut self.effects, Effect::new(kind, x, y).airborne());
|
||||
}
|
||||
play_legacy_sound(
|
||||
&mut self.rng,
|
||||
&mut self.sounds,
|
||||
&mut self.sound_events,
|
||||
"FX_AIREXPLO2",
|
||||
);
|
||||
}
|
||||
} else if let Some(timer) = self.end_timer {
|
||||
if timer == 40 {
|
||||
self.control_paused = true;
|
||||
self.departure_delta = [
|
||||
self.player.x - self.player.px,
|
||||
self.player.y - self.player.py,
|
||||
];
|
||||
play_legacy_sound(
|
||||
&mut self.rng,
|
||||
&mut self.sounds,
|
||||
&mut self.sound_events,
|
||||
"FX_FLYBY",
|
||||
);
|
||||
}
|
||||
if timer < 40 {
|
||||
let dx = if self.player.x < 152 {
|
||||
8
|
||||
} else if self.player.x > 168 {
|
||||
-8
|
||||
} else {
|
||||
0
|
||||
};
|
||||
self.departure_delta = [dx, -4];
|
||||
if !demo {
|
||||
let old_x = self.player.x;
|
||||
self.player.x += dx;
|
||||
self.player.y -= 4;
|
||||
bank_player(&mut self.player, old_x);
|
||||
}
|
||||
}
|
||||
}
|
||||
if finished {
|
||||
self.outcome = if self.player.shield > 0 {
|
||||
Outcome::Complete
|
||||
} else {
|
||||
Outcome::Failed
|
||||
};
|
||||
}
|
||||
}
|
||||
fn fire(&mut self, id: &ContentId, registry: &Registry) -> Result<()> {
|
||||
let Some(w) = registry.weapons.get(id) else {
|
||||
return Ok(());
|
||||
@@ -1353,6 +1709,7 @@ impl World {
|
||||
&mut self.rng,
|
||||
&mut self.sounds,
|
||||
&mut self.sound_events,
|
||||
self.legacy_transition && self.end_timer.is_some(),
|
||||
);
|
||||
}
|
||||
} else {
|
||||
@@ -1389,6 +1746,7 @@ impl World {
|
||||
&mut self.rng,
|
||||
&mut self.sounds,
|
||||
&mut self.sound_events,
|
||||
self.legacy_transition && self.end_timer.is_some(),
|
||||
);
|
||||
retired.push(p.entity);
|
||||
}
|
||||
@@ -1472,11 +1830,10 @@ impl World {
|
||||
position,
|
||||
frames: 10,
|
||||
});
|
||||
self.effects.push(Effect {
|
||||
x: x + 16,
|
||||
y: y + 16,
|
||||
age: 0,
|
||||
});
|
||||
original_effect(
|
||||
&mut self.effects,
|
||||
Effect::new(EffectKind::GroundBlast, x + 16, y + 16),
|
||||
);
|
||||
// TILE_Explode changes its collision item immediately. Artwork waits
|
||||
// for the delayed explosion and its second chain-damage pass.
|
||||
self.terrain_dead[position] = true;
|
||||
@@ -1581,6 +1938,23 @@ impl World {
|
||||
// A plotted shot tests its endpoint before retiring, even if MoveSobj
|
||||
// marked it done in the preceding step.
|
||||
if !skip_collision && !lib.effect {
|
||||
// TILE_Bomb runs before ENEMY_DamageGround and affects both
|
||||
// the hit tile and the original left/up/right neighbors.
|
||||
if lib.target == 4 {
|
||||
if let Some(position) = self.legacy_tile_hit(p.x, p.y, registry) {
|
||||
self.terrain_health[position] -= weapon.damage;
|
||||
self.terrain_damage_neighbors(position, weapon.damage, registry);
|
||||
let columns = registry.missions[&self.mission].columns.max(1) as usize;
|
||||
if position > columns {
|
||||
self.terrain_damage_neighbors(
|
||||
position - columns,
|
||||
weapon.damage >> 1,
|
||||
registry,
|
||||
);
|
||||
}
|
||||
state.movement.done = true;
|
||||
}
|
||||
}
|
||||
let enemy = self
|
||||
.enemies
|
||||
.iter_mut()
|
||||
@@ -1590,13 +1964,17 @@ impl World {
|
||||
let ground = registry.enemies[&e.definition].ground;
|
||||
contains(e, p.x, p.y)
|
||||
&& match lib.target {
|
||||
1 => !ground,
|
||||
1 | 5 => !ground,
|
||||
2 | 4 => ground,
|
||||
_ => true,
|
||||
}
|
||||
});
|
||||
if let Some(enemy) = enemy {
|
||||
let damage = if lib.target == 4 { 5 } else { weapon.damage };
|
||||
let damage = match lib.target {
|
||||
4 => 5,
|
||||
5 => 1,
|
||||
_ => weapon.damage,
|
||||
};
|
||||
enemy.health -= damage;
|
||||
if self.difficulty == 0 && matches!(lib.target, 1 | 2) {
|
||||
enemy.health -= damage;
|
||||
@@ -1605,31 +1983,43 @@ impl World {
|
||||
if matches!(lib.target, 0 | 1 | 3) {
|
||||
self.rng.next_value();
|
||||
}
|
||||
if lib.target == 5
|
||||
&& registry.enemies[&enemy.definition]
|
||||
.legacy
|
||||
.as_ref()
|
||||
.is_some_and(|l| l.fields[5] != 0)
|
||||
{
|
||||
if enemy.energy_resistance > 0 {
|
||||
enemy.energy_resistance -= weapon.damage;
|
||||
} else {
|
||||
enemy.shoot_on = false;
|
||||
enemy.shoot_disabled = true;
|
||||
enemy.shootagain = -1;
|
||||
play_legacy_sound(
|
||||
&mut self.rng,
|
||||
&mut self.sounds,
|
||||
&mut self.sound_events,
|
||||
"FX_EGRAB",
|
||||
);
|
||||
position_sound(
|
||||
&mut self.sound_events,
|
||||
enemy.x + enemy.width / 2,
|
||||
enemy.y + enemy.width / 2,
|
||||
self.player.x + 16,
|
||||
self.player.y + 16,
|
||||
);
|
||||
}
|
||||
}
|
||||
let entity = enemy.entity;
|
||||
let enemy_id = enemy.definition.clone();
|
||||
let health = enemy.health;
|
||||
self.event(enemy_id,"on_hit",serde_json::json!({"entity":entity,"projectile":p.entity,"weapon":p.weapon,"damage":damage,"x":p.x,"y":p.y,"health":health}));
|
||||
self.event(p.weapon.clone(),"on_hit",serde_json::json!({"entity":entity,"projectile":p.entity,"damage":damage,"x":p.x,"y":p.y}));
|
||||
} else if matches!(lib.target, 0 | 2 | 4) {
|
||||
let mission = ®istry.missions[&self.mission];
|
||||
let col = (p.x - 16).div_euclid(32);
|
||||
let row = (p.y
|
||||
+ mission.layout.len() as i32 / mission.columns.max(1) as i32 * 32
|
||||
- 200
|
||||
- self.scroll)
|
||||
.div_euclid(32);
|
||||
if col >= 0 && col < mission.columns as i32 && row >= 0 {
|
||||
let pos = row as usize * mission.columns as usize + col as usize;
|
||||
if pos < self.terrain_health.len()
|
||||
&& mission.tiles[mission.layout[pos]].destroyed.is_some()
|
||||
&& !self.terrain_dead[pos]
|
||||
{
|
||||
self.terrain_health[pos] -= weapon.damage;
|
||||
if lib.target != 4 {
|
||||
self.rng.next_value();
|
||||
}
|
||||
state.movement.done = true;
|
||||
}
|
||||
} else if matches!(lib.target, 0 | 2) {
|
||||
if let Some(position) = self.legacy_tile_hit(p.x, p.y, registry) {
|
||||
self.terrain_health[position] -= weapon.damage;
|
||||
self.rng.next_value();
|
||||
state.movement.done = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1646,9 +2036,18 @@ impl World {
|
||||
for enemy in &mut self.enemies {
|
||||
enemy.health -= weapon.damage;
|
||||
}
|
||||
for (health, dead) in self.terrain_health.iter_mut().zip(&self.terrain_dead) {
|
||||
if !dead {
|
||||
*health -= weapon.damage;
|
||||
let mission = ®istry.missions[&self.mission];
|
||||
let columns = mission.columns.max(1) as usize;
|
||||
let (row, _) = self.terrain_window(registry);
|
||||
let start = row * columns;
|
||||
let end = (start + 8 * columns).min(mission.layout.len());
|
||||
// TILE_DamageAll uses a fixed 20 terrain damage and
|
||||
// includes the final visible tile, unlike TILE_IsHit.
|
||||
for position in start..end {
|
||||
if mission.tiles[mission.layout[position]].destroyed.is_some()
|
||||
&& !self.terrain_dead[position]
|
||||
{
|
||||
self.terrain_health[position] -= 20;
|
||||
}
|
||||
}
|
||||
retired.extend(
|
||||
@@ -1681,6 +2080,26 @@ impl World {
|
||||
self.projectiles.retain(|p| !retired.contains(&p.entity));
|
||||
Ok(())
|
||||
}
|
||||
fn legacy_tile_hit(&self, x: i32, y: i32, registry: &Registry) -> Option<usize> {
|
||||
let mission = ®istry.missions[&self.mission];
|
||||
let columns = mission.columns.max(1) as usize;
|
||||
let (row, y_offset) = self.terrain_window(registry);
|
||||
let column = (x - 16).div_euclid(32);
|
||||
let local_row = (y - y_offset).div_euclid(32);
|
||||
if column < 0 || column >= columns as i32 || !(0..8).contains(&local_row) {
|
||||
return None;
|
||||
}
|
||||
let slot = local_row as usize * columns + column as usize;
|
||||
// The original TILE_IsHit/TILE_Bomb loop excludes lastspot itself.
|
||||
if slot >= 8 * columns - 1 {
|
||||
return None;
|
||||
}
|
||||
let position = row * columns + slot;
|
||||
(position < mission.layout.len()
|
||||
&& mission.tiles[mission.layout[position]].destroyed.is_some()
|
||||
&& !self.terrain_dead[position])
|
||||
.then_some(position)
|
||||
}
|
||||
fn fire_legacy(
|
||||
&mut self,
|
||||
id: &ContentId,
|
||||
@@ -2005,6 +2424,7 @@ impl World {
|
||||
&mut self.rng,
|
||||
&mut self.sounds,
|
||||
&mut self.sound_events,
|
||||
self.legacy_transition && self.end_timer.is_some(),
|
||||
);
|
||||
} else {
|
||||
let e = self
|
||||
@@ -2086,11 +2506,7 @@ impl World {
|
||||
}
|
||||
Command::Effect { x, y } => {
|
||||
ensure!(self.effects.len() < 4096, "too many effects");
|
||||
self.effects.push(Effect {
|
||||
x: *x,
|
||||
y: *y,
|
||||
age: 0,
|
||||
});
|
||||
self.effects.push(Effect::new(EffectKind::AirBlast, *x, *y));
|
||||
}
|
||||
Command::Sound { asset } => {
|
||||
self.sounds.push(*asset);
|
||||
@@ -2161,6 +2577,55 @@ impl World {
|
||||
serde_json::json!({"tick":self.tick,"rng":self.rng.seed,"player":[self.player.x,self.player.y,self.player.bank,self.player.shield,self.player.super_shield,self.player.credits],"enemies":self.enemies.iter().map(|e|serde_json::json!([registry.enemies[&e.definition].art.as_ref().and_then(|a|a.asset).unwrap_or(0),e.x,e.y,e.health,e.frame,e.movement.x,e.movement.y])).collect::<Vec<_>>(),"projectiles":self.projectiles.iter().filter(|p|p.hostile).chain(self.projectiles.iter().filter(|p|!p.hostile)).map(|p|serde_json::json!([Self::projectile_asset(p,registry).map(i64::from).unwrap_or(-1),p.x,p.y,p.hostile as u32])).collect::<Vec<_>>()})
|
||||
}
|
||||
}
|
||||
fn initial_shield_latch() -> i32 {
|
||||
-1
|
||||
}
|
||||
fn visible_player() -> bool {
|
||||
true
|
||||
}
|
||||
fn bank_player(player: &mut Player, old_x: i32) {
|
||||
let delta = ((player.x - old_x).abs() >> 2).min(3);
|
||||
if player.x < old_x {
|
||||
if player.bank < 3 + delta {
|
||||
player.bank += 1;
|
||||
}
|
||||
} else if player.x > old_x {
|
||||
if player.bank > 3 - delta {
|
||||
player.bank -= 1;
|
||||
}
|
||||
} else {
|
||||
player.bank += (3 - player.bank).signum();
|
||||
}
|
||||
}
|
||||
fn next_special(player: &Player, registry: &Registry) -> Option<ContentId> {
|
||||
let mut specials: Vec<_> = player
|
||||
.inventory
|
||||
.iter()
|
||||
.filter_map(|(id, count)| {
|
||||
registry
|
||||
.items
|
||||
.get(id)
|
||||
.filter(|item| *count > 0 && item.special)
|
||||
.map(|item| ((item.legacy.unwrap_or(u32::MAX), id.clone()), id.clone()))
|
||||
})
|
||||
.collect();
|
||||
specials.sort_by(|a, b| a.0.cmp(&b.0));
|
||||
let after = player.selected.as_ref().map(|id| {
|
||||
(
|
||||
registry
|
||||
.items
|
||||
.get(id)
|
||||
.and_then(|item| item.legacy)
|
||||
.unwrap_or(u32::MAX),
|
||||
id.clone(),
|
||||
)
|
||||
});
|
||||
specials
|
||||
.iter()
|
||||
.find(|(key, _)| after.as_ref().is_none_or(|after| key > after))
|
||||
.or_else(|| specials.first())
|
||||
.map(|(_, id)| id.clone())
|
||||
}
|
||||
fn update_legacy_animation(enemy: &mut Enemy, lib: &crate::assets::LegacyEnemy) {
|
||||
let f = &lib.fields;
|
||||
enemy.art_frame = enemy.frame;
|
||||
@@ -2269,7 +2734,11 @@ fn damage_player(
|
||||
rng: &mut LegacyRng,
|
||||
sounds: &mut Vec<u32>,
|
||||
events: &mut Vec<SoundEvent>,
|
||||
immune: bool,
|
||||
) {
|
||||
if immune {
|
||||
return;
|
||||
}
|
||||
let damage = if difficulty == 0 && damage > 1 {
|
||||
damage >> 1
|
||||
} else {
|
||||
|
||||
Reference in new issue
Block a user