Files
RustRaptor/src/simulation.rs
T

3642 lines
137 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! SDL-independent fixed-step simulation and checked mod command application.
use crate::{
content::{ContentId, Registry, Target, WeaponBehavior},
modding::{Command, ScriptRuntime},
persistence::Pilot,
};
use anyhow::{Context, Result, ensure};
use serde::{Deserialize, Serialize};
use std::collections::BTreeMap;
pub const TICK_NUMERATOR: u64 = 3;
pub const TICK_DENOMINATOR: u64 = 70;
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
pub struct LegacyRng {
pub seed: i32,
}
impl LegacyRng {
pub fn new(seed: i32) -> Self {
Self { seed }
}
pub fn next_value(&mut self) -> i32 {
self.seed = self.seed.wrapping_mul(1103515245).wrapping_add(12345);
(self.seed >> 16) & 0x7fff
}
}
#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize)]
pub struct Input {
pub dx: i32,
pub dy: i32,
pub fire: bool,
pub special: bool,
pub cycle: bool,
pub bomb: bool,
pub absolute: Option<[i32; 2]>,
pub demo_bank: Option<i32>,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct LineMove {
pub x: i32,
pub y: i32,
pub tx: i32,
pub ty: i32,
pub dx: i32,
pub dy: i32,
pub ax: i32,
pub ay: i32,
pub error: i32,
pub remaining: i32,
pub done: bool,
}
impl LineMove {
pub fn new(x: i32, y: i32, tx: i32, ty: i32) -> Self {
let dx = (tx - x).abs();
let dy = (ty - y).abs();
let mut m = Self {
x,
y,
tx,
ty,
dx,
dy,
ax: if tx < x { -1 } else { 1 },
ay: if ty < y { -1 } else { 1 },
error: if dx >= dy { -(dy >> 1) } else { dx >> 1 },
remaining: dx.max(dy) + 1,
done: false,
};
if dx == 0 && dy == 0 {
m.done = true;
}
m
}
pub fn initial(&mut self) {
if self.remaining == 0 {
self.done = true;
return;
}
self.point();
self.remaining -= 1;
}
fn point(&mut self) {
if self.dx >= self.dy {
self.x += self.ax;
self.error += self.dy;
if self.error > 0 {
self.y += self.ay;
self.error -= self.dx;
}
} else {
self.y += self.ay;
self.error += self.dx;
if self.error > 0 {
self.x += self.ax;
self.error -= self.dy;
}
}
}
pub fn advance_shot(&mut self, speed: i32) {
// MoveSobj crosses the endpoint before marking done; enemies instead stop at it.
for _ in 0..speed.max(0) {
self.remaining -= 1;
self.point();
}
if self.remaining < 1 {
self.done = true;
}
}
pub fn advance(&mut self, speed: i32) -> i32 {
if self.done {
return speed;
}
let mut left = speed;
while left > 0 {
left -= 1;
self.remaining -= 1;
if self.remaining == 0 {
self.done = true;
return left;
}
if self.dx >= self.dy {
self.x += self.ax;
self.error += self.dy;
if self.error > 0 {
self.y += self.ay;
self.error -= self.dx;
}
} else {
self.y += self.ay;
self.error += self.dx;
if self.error > 0 {
self.x += self.ax;
self.error -= self.dy;
}
}
}
left
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Player {
pub x: i32,
pub y: i32,
pub px: i32,
pub py: i32,
pub bank: i32,
pub shield: i32,
pub super_shield: i32,
#[serde(default)]
pub super_shield_reserves: Vec<u32>,
pub credits: u32,
pub inventory: BTreeMap<ContentId, u32>,
pub selected: Option<ContentId>,
pub vx: i32,
pub vy: i32,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Enemy {
pub entity: u64,
pub definition: ContentId,
pub x: i32,
pub y: i32,
pub px: i32,
pub py: i32,
pub sx: i32,
pub sy: i32,
pub width: i32,
pub height: i32,
pub health: i32,
pub age: u32,
pub cooldown: u32,
pub path_index: usize,
pub reversed: bool,
pub movement: LineMove,
pub frame: u32,
pub art_frame: u32,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub pinned_art_frame: Option<u32>,
pub animation_on: bool,
pub animation_frames: u32,
pub multi: u32,
pub frame_timer: i32,
pub countdown: i32,
pub shoot_on: bool,
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,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct LegacyProjectile {
pub movement: LineMove,
pub speed: i32,
pub frame: u32,
pub art_frame: u32,
pub delay: bool,
pub hit: bool,
pub start: [i32; 2],
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct EnemyProjectile {
pub movement: LineMove,
pub kind: i16,
pub asset: u32,
pub frames: u32,
pub frame: u32,
pub art_frame: u32,
pub speed: i32,
pub maximum_speed: i32,
pub damage: i32,
pub position: usize,
pub enemy: u64,
pub gun: usize,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Projectile {
pub entity: u64,
pub weapon: ContentId,
pub x: i32,
pub y: i32,
pub px: i32,
pub py: i32,
pub vx: i32,
pub vy: i32,
pub hostile: bool,
pub age: u32,
pub legacy: Option<LegacyProjectile>,
pub legacy_enemy: Option<EnemyProjectile>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Pickup {
pub entity: u64,
pub item: ContentId,
pub x: i32,
pub y: i32,
pub legacy: Option<LegacyPickup>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct LegacyPickup {
pub x: i32,
pub y: i32,
pub position: usize,
pub frame: u32,
pub art_frame: u32,
pub countdown: Option<u32>,
#[serde(skip)]
pub glow_frame: u32,
#[serde(skip)]
pub previous_art_position: Option<[i32; 2]>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Effect {
pub x: i32,
pub y: i32,
pub px: i32,
pub py: i32,
pub age: u32,
pub kind: EffectKind,
pub ground: bool,
}
/// Secondary native animations are presentation only. Keeping their queue out
/// of saved/replayed gameplay state also prevents render rates affecting RNG.
#[derive(Debug, Clone)]
pub struct VisualEffect {
pub x: i32,
pub y: i32,
pub px: i32,
pub py: i32,
pub age: u32,
pub kind: VisualEffectKind,
pub anchor: Option<u64>,
pub offset: [i32; 2],
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum VisualEffectKind {
LaserImpact,
SmallAir,
SmallGround,
GroundBlueSpark,
GroundOrangeSpark,
GroundSparkle,
GroundFlare,
SmokeDown,
SmokeTrail,
BlueSpark,
OrangeSpark,
EnergyGrab,
Muzzle,
Casing,
Shield,
}
impl VisualEffectKind {
pub fn artwork(self) -> (u32, u32) {
match self {
Self::LaserImpact => (0x10229, 4),
Self::SmallAir => (0x101fc, 14),
Self::SmallGround => (0x1027d, 35),
Self::GroundBlueSpark => (0x1019c, 9),
Self::GroundOrangeSpark => (0x101a5, 9),
Self::GroundSparkle => (0x1026c, 17),
Self::GroundFlare => (0x10252, 26),
Self::SmokeDown => (0x1020e, 5),
Self::SmokeTrail => (0x1022d, 4),
Self::BlueSpark => (0x1019c, 9),
Self::OrangeSpark => (0x101a5, 9),
Self::EnergyGrab => (0x1018e, 14),
Self::Muzzle | Self::Casing => (0x101c7, 4),
Self::Shield => (0x10249, 4),
}
}
/// ANIMS_DisplayGround, ANIMS_DisplaySky, ANIMS_DisplayHigh order.
pub fn layer(self) -> u8 {
match self {
Self::Casing
| Self::SmallGround
| Self::GroundBlueSpark
| Self::GroundOrangeSpark
| Self::GroundSparkle
| Self::GroundFlare => 0,
Self::SmokeDown | Self::BlueSpark | Self::OrangeSpark | Self::Muzzle => 1,
Self::LaserImpact
| Self::SmallAir
| Self::SmokeTrail
| Self::EnergyGrab
| Self::Shield => 2,
}
}
pub fn lighting_mask(self) -> bool {
matches!(self, Self::SmokeDown | Self::SmokeTrail | Self::Shield)
}
pub fn top_left(self) -> bool {
matches!(self, Self::GroundBlueSpark | Self::GroundOrangeSpark)
}
}
impl VisualEffect {
pub fn new(kind: VisualEffectKind, x: i32, y: i32) -> Self {
Self {
x,
y,
px: x,
py: y,
age: 0,
kind,
anchor: None,
offset: [x, y],
}
}
fn attached(mut self, entity: u64) -> Self {
self.anchor = Some(entity);
self
}
}
fn visual_effect(existing: usize, effects: &mut Vec<VisualEffect>, effect: VisualEffect) {
if effects.len() + existing < 100 {
effects.push(effect);
}
}
/// Presentation-only lightning, with a lifetime measured in simulation ticks.
/// The queue is excluded from saved gameplay state and the deterministic digest.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct LightningArc {
pub x: i32,
pub y: i32,
pub to_x: i32,
pub to_y: i32,
pub ticks: u32,
pub age: u32,
/// Cosmetic variation between discharges, without advancing gameplay RNG.
pub seed: u32,
pub anchor: Option<LightningAnchor>,
pub to_anchor: Option<LightningAnchor>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct LightningAnchor {
pub entity: u64,
/// Endpoint displacement from the entity's top-left origin at creation.
pub offset: [i32; 2],
}
pub const MAX_LIGHTNING_ARCS: usize = 256;
#[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(existing: usize, effects: &mut Vec<Effect>, effect: Effect) {
if effects.len() + existing < 100 {
effects.push(effect);
}
}
fn death_effects(
visual_count: usize,
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(visual_count, effects, Effect::new(kind, x, y));
}
original_effect(visual_count, 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(visual_count, effects, Effect::new(kind, x, y));
original_effect(visual_count, effects, Effect::new(SmallGround, x, y));
} else if explosion == 6 {
original_effect(visual_count, effects, Effect::new(Flare, x, y).airborne());
original_effect(
visual_count,
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(visual_count, effects, effect);
}
}
}
}
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
pub enum Outcome {
Playing,
Complete,
Failed,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SoundEvent {
pub asset: u32,
pub pan: i32,
pub pitch: i32,
pub volume: i32,
pub priority: i32,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct TerrainDelay {
pub position: usize,
pub frames: i32,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct World {
pub tick: u64,
pub mission: ContentId,
pub player: Player,
pub enemies: Vec<Enemy>,
pub projectiles: Vec<Projectile>,
pub pickups: Vec<Pickup>,
pub effects: Vec<Effect>,
#[serde(skip)]
pub visual_effects: Vec<VisualEffect>,
#[serde(skip)]
pub lightning_arcs: Vec<LightningArc>,
#[serde(skip)]
terrain_spark_delay: u32,
#[serde(skip)]
terrain_flare_delay: u32,
pub terrain_health: Vec<i32>,
pub terrain_dead: Vec<bool>,
pub terrain_settled: Vec<bool>,
pub terrain_delays: Vec<TerrainDelay>,
pub rng: LegacyRng,
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>,
pub sound_events: Vec<SoundEvent>,
pub mod_sounds: Vec<ContentId>,
pub cooldowns: BTreeMap<ContentId, u32>,
pub next_entity: u64,
pub spawn_cursor: usize,
pub cycle_held: bool,
pub bomb_held: bool,
pub palette_blue: bool,
pub special_held: bool,
pub player_flash: bool,
#[serde(default = "initial_shield_latch")]
pub previous_shield: i32,
#[serde(default)]
pub shield_recharge_ticks: u32,
/// Charge retained from a save whose equipment capacity changed. It does
/// not increase the original 100-point combat shield.
#[serde(default, skip_serializing_if = "is_zero")]
pub energy_overflow: u32,
#[serde(default, skip_serializing_if = "is_zero")]
pub super_shield_overflow: u32,
#[serde(skip)]
pub events: Vec<HookEvent>,
}
#[derive(Debug, Clone)]
pub struct HookEvent {
pub content: ContentId,
pub hook: &'static str,
pub event: serde_json::Value,
}
impl World {
pub fn new(mission: &ContentId, pilot: &Pilot, registry: &Registry) -> Result<Self> {
let m = registry.missions.get(mission).context("unknown mission")?;
let energy = pilot
.inventory
.get(&ContentId::from("base:energy"))
.copied()
.unwrap_or(100) as i32;
let super_shield = pilot
.inventory
.get(&ContentId::from("base:super_shield"))
.copied()
.unwrap_or(0);
let wave = mission
.0
.rsplit('_')
.next()
.and_then(|s| s.parse::<i32>().ok())
.unwrap_or(1)
- 1;
let mut out = Self {
tick: 0,
mission: mission.clone(),
player: Player {
x: 144,
y: 160,
px: 144,
py: 160,
bank: 3,
shield: energy.clamp(1, 100),
super_shield: super_shield.min(100) as i32,
super_shield_reserves: pilot.super_shield_reserves.clone(),
credits: pilot.credits,
inventory: pilot.inventory.clone(),
selected: pilot.selected.clone(),
vx: 0,
vy: 0,
},
enemies: vec![],
projectiles: vec![],
pickups: vec![],
effects: vec![],
visual_effects: vec![],
lightning_arcs: vec![],
terrain_spark_delay: 0,
terrain_flare_delay: 0,
terrain_health: m.layout.iter().map(|i| m.tiles[*i].health).collect(),
terrain_dead: vec![false; m.layout.len()],
terrain_settled: vec![false; m.layout.len()],
terrain_delays: vec![],
rng: LegacyRng::new(wave << 10),
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![],
sound_events: vec![],
mod_sounds: vec![],
cooldowns: BTreeMap::new(),
next_entity: 1,
spawn_cursor: 0,
cycle_held: false,
bomb_held: false,
palette_blue: false,
special_held: false,
player_flash: false,
previous_shield: initial_shield_latch(),
shield_recharge_ticks: pilot.shield_recharge_ticks,
energy_overflow: (energy.max(0) as u32).saturating_sub(100),
super_shield_overflow: super_shield.saturating_sub(100),
events: vec![],
};
out.event(
mission.clone(),
"on_mission_start",
serde_json::json!({"tick":0}),
);
Ok(out)
}
pub fn pilot(&self, prior: &Pilot) -> Pilot {
let mut p = prior.clone();
p.credits = self.player.credits;
p.inventory = self.player.inventory.clone();
p.super_shield_reserves = self.player.super_shield_reserves.clone();
p.shield_recharge_ticks = self.shield_recharge_ticks;
p.inventory.insert(
"base:energy".into(),
(self.player.shield.max(0) as u32)
.saturating_add(self.energy_overflow)
.max(1),
);
if p.inventory
.contains_key(&ContentId::from("base:super_shield"))
{
p.inventory.insert(
"base:super_shield".into(),
(self.player.super_shield.max(0) as u32).saturating_add(self.super_shield_overflow),
);
}
p.selected = self.player.selected.clone();
p.mission = self.mission.clone();
p
}
fn sync_retained_inventory(&mut self) {
if self.energy_overflow > 0 {
if let Some(charge) = self
.player
.inventory
.get_mut(&ContentId::from("base:energy"))
{
*charge = (self.player.shield.max(0) as u32).saturating_add(self.energy_overflow);
}
}
if self.super_shield_overflow > 0 {
if let Some(charge) = self
.player
.inventory
.get_mut(&ContentId::from("base:super_shield"))
{
*charge = (self.player.super_shield.max(0) as u32)
.saturating_add(self.super_shield_overflow);
} else {
self.super_shield_overflow = 0;
}
}
}
fn entity(&mut self) -> u64 {
let n = self.next_entity;
self.next_entity += 1;
n
}
fn event(&mut self, content: ContentId, hook: &'static str, event: serde_json::Value) {
self.events.push(HookEvent {
content,
hook,
event,
});
}
pub fn spawn(&mut self, id: &ContentId, x: i32, y: i32, registry: &Registry) -> Result<u64> {
let e = registry.enemies.get(id).context("unknown enemy")?;
ensure!(self.enemies.len() < 4096, "enemy capacity exceeded");
let entity = self.entity();
let (w, h) = e
.art
.as_ref()
.map(|a| (a.logical_width as i32, a.logical_height as i32))
.unwrap_or((32, 32));
let f = e.legacy.as_ref().map(|l| &l.fields);
let x = match f.map(|f| f[21]) {
Some(4) => x + w,
Some(5) => x - w,
_ => x,
};
let sx = x;
let sy = if f.is_some() { 100 - h / 2 } else { y };
let first = e.path.first().copied().unwrap_or([0, 300]);
let mut movement = LineMove::new(x, y, sx + first[0], sy + first[1]);
if f.is_some() && !e.ground {
movement.initial();
}
let health = if e.boss && self.difficulty <= 1 {
e.health - (e.health >> 1)
} else {
e.health
};
self.enemies.push(Enemy {
entity,
definition: id.clone(),
x,
y,
px: x,
py: y,
sx,
sy,
width: w,
height: h,
health,
age: 0,
cooldown: 0,
path_index: 1,
reversed: false,
movement,
frame: 0,
art_frame: 0,
pinned_art_frame: None,
animation_on: f.is_none_or(|f| f[10] != 1),
animation_frames: f.map_or(1, |f| if f[10] == 2 { f[9] } else { f[7] }.max(1) as u32),
multi: 0,
frame_timer: f.map_or(0, |f| f[6]),
countdown: f.map_or(0, |f| f[8] - y),
shoot_on: false,
shootagain: -1,
shootcount: f.map_or(1, |f| {
if e.boss && self.difficulty <= 1 {
f[16] - (f[16] >> 2)
} else {
f[16]
}
}),
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,
});
self.event(
id.clone(),
"on_spawn",
serde_json::json!({"entity":entity,"x":x,"y":y}),
);
Ok(entity)
}
fn projectile(
&mut self,
id: &ContentId,
x: i32,
y: i32,
vx: i32,
vy: i32,
hostile: bool,
) -> Result<()> {
ensure!(
self.projectiles.len() < 8192,
"projectile capacity exceeded"
);
let entity = self.entity();
self.projectiles.push(Projectile {
entity,
weapon: id.clone(),
x,
y,
px: x,
py: y,
vx,
vy,
hostile,
age: 0,
legacy: None,
legacy_enemy: None,
});
Ok(())
}
pub fn step(
&mut self,
input: Input,
registry: &Registry,
scripts: &mut ScriptRuntime,
) -> Result<()> {
if self.outcome != Outcome::Playing {
return Ok(());
}
scripts.begin_tick();
let checkpoint = self.clone();
let script_checkpoint = scripts.checkpoints();
let result = self
.advance(input, registry)
.and_then(|_| self.dispatch(registry, scripts));
if let Err(err) = result {
*self = checkpoint;
scripts.restore(script_checkpoint);
return Err(err);
}
Ok(())
}
fn advance(&mut self, input: Input, registry: &Registry) -> Result<()> {
self.sounds.clear();
self.sound_events.clear();
self.mod_sounds.clear();
self.player_flash = false;
self.player.px = self.player.x;
self.player.py = self.player.y;
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.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 {
self.player.bank = bank.clamp(0, 6);
} else if self.player.x < self.player.px {
if self.player.bank < 3 + delta {
self.player.bank += 1;
}
} else if self.player.x > self.player.px {
if self.player.bank > 3 - delta {
self.player.bank -= 1;
}
} else {
self.player.bank += (3 - self.player.bank).signum();
}
let mut equipment_used = false;
if input.fire {
let mut weapons: Vec<_> = self
.player
.inventory
.iter()
.filter_map(|(id, n)| {
if *n > 0 {
registry
.items
.get(id)
.filter(|i| !i.special && i.legacy != Some(11))
.and_then(|i| i.weapon.clone())
} else {
None
}
})
.chain(
self.player
.selected
.as_ref()
.and_then(|id| registry.items.get(id))
.and_then(|i| i.weapon.clone()),
)
.collect();
weapons.sort_by_key(|id| (registry.weapons[id].legacy.unwrap_or(u32::MAX), id.clone()));
// Original primary weapons fire in numeric order, followed by the selected special.
if let Some(selected) = self
.player
.selected
.as_ref()
.and_then(|id| registry.items.get(id))
.and_then(|i| i.weapon.as_ref())
{
weapons.retain(|id| id != selected);
weapons.push(selected.clone());
}
equipment_used |= !weapons.is_empty();
for id in weapons {
self.fire(&id, registry)?;
}
}
if input.special {
if let Some(id) = self.player.selected.clone() {
if let Some(item) = registry.items.get(&id) {
equipment_used |= self.player.inventory.get(&id).is_some_and(|n| *n > 0);
if let Some(weapon) = item.weapon.as_ref() {
self.fire(weapon, registry)?;
} else if !self.special_held
&& self.player.inventory.get(&id).copied().unwrap_or(0) > 0
{
if item.consumable {
*self.player.inventory.get_mut(&id).unwrap() -= 1;
if self.player.inventory[&id] == 0 {
self.player.selected = None;
}
}
self.player.shield = (self.player.shield + item.shield).clamp(0, 100);
self.player.credits = self.player.credits.saturating_add(item.credits);
self.event(id,"on_use",serde_json::json!({"entity":0,"x":self.player.x,"y":self.player.y,"tick":self.tick}));
}
}
}
}
self.special_held = input.special;
if input.cycle && !self.cycle_held {
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 {
let id = ContentId::from("base:mega_bomb");
if self.player.inventory.get(&id).copied().unwrap_or(0) > 0 {
equipment_used = true;
if let Some(weapon) = registry.items.get(&id).and_then(|i| i.weapon.clone()) {
let before = self.projectiles.len();
self.fire(&weapon, registry)?;
if self.projectiles.len() > before {
*self.player.inventory.get_mut(&id).unwrap() -= 1;
}
}
}
}
self.bomb_held = input.bomb;
// 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 = &registry.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
{
let spawn = &mission.spawns[self.spawn_cursor];
let enabled = if mission.legacy_map.is_some() {
match spawn.difficulty {
3 => true,
4 => self.difficulty >= 2,
5 => self.difficulty >= 3,
_ => false,
}
} else {
self.difficulty >= spawn.difficulty
};
if enabled {
let (x, y) = if mission.legacy_map.is_some() {
let art = registry.enemies[&spawn.enemy].art.as_ref().unwrap();
(
spawn.x + 16 - art.logical_width as i32 / 2,
spawn.y - art.logical_height as i32 / 2,
)
} else {
(spawn.x, spawn.y)
};
self.spawn(&spawn.enemy, x, y, registry)?;
}
self.spawn_cursor += 1;
}
// Ground/air enemy updates precede hostile shots, bonuses and player shots.
let mut shots = Vec::new();
let mut retired = Vec::new();
let mut events = Vec::new();
for enemy in &mut self.enemies {
let def = &registry.enemies[&enemy.definition];
enemy.px = enemy.x;
enemy.py = enemy.y;
enemy.age += 1;
if enemy.vx != 0 || enemy.vy != 0 {
enemy.x += enemy.vx;
enemy.y += enemy.vy;
} else if def.ground {
enemy.y += 1;
if let Some(l) = &def.legacy {
match l.fields[21] {
4 if enemy.y >= 0 => enemy.x -= def.speed,
5 if enemy.y >= 0 => enemy.x += def.speed,
_ => {}
}
}
} else {
enemy.x = enemy.movement.x;
enemy.y = enemy.movement.y;
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 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 {
retired.push(enemy.entity);
}
}
let target = if enemy.kami == 2 {
[self.player.x + 16 - enemy.sx, self.player.y + 16 - enemy.sy]
} else {
def.path[enemy.path_index.min(count - 1)]
};
enemy.movement = LineMove::new(
enemy.movement.tx,
enemy.movement.ty,
enemy.sx + target[0],
enemy.sy + target[1],
);
if def.legacy.is_some() {
enemy.movement.initial();
}
enemy.movement.advance(left);
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
.as_ref()
.map_or(0, |l| l.fields[20].max(0) as usize);
if enemy.path_index <= min {
enemy.reversed = false;
}
} else {
enemy.path_index += 1;
}
} else if def.path.is_empty() {
enemy.y += def.speed;
}
}
if let Some(l) = &def.legacy {
update_legacy_animation(enemy, l);
} else {
enemy.frame = enemy.age / 3;
enemy.art_frame = enemy.frame;
enemy.shoot_on = true;
}
if let Some(frame) = enemy.pinned_art_frame {
enemy.art_frame = frame;
}
if enemy.shoot_on
&& (def.legacy.is_some()
|| (enemy.health > 0 && enemy.y > -enemy.height && enemy.y < 200))
{
if let Some(weapon) = &def.weapon {
if let Some(l) = &def.legacy {
match enemy.shootagain {
-1 => {
enemy.shootflag -= 1;
if enemy.shootflag < 0 {
enemy.shootflag = l.fields[3];
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,
enemy.y + l.gun_y[gun] as i32,
Some((l.shoot_type[gun], enemy.entity, def.speed, gun)),
));
let sx = enemy.x + l.gun_x[gun] as i32;
let sy = enemy.y + l.gun_y[gun] as i32;
if (0..320).contains(&sx) && (0..200).contains(&sy) {
play_legacy_sound(
&mut self.rng,
&mut self.sounds,
&mut self.sound_events,
match l.shoot_type[gun] {
4 => "FX_ENEMYMISSLE",
5 => "FX_ENEMYLASER",
7 => "FX_ENEMYPLASMA",
_ => "FX_ENEMYSHOT",
},
);
position_sound(
&mut self.sound_events,
sx,
sy,
self.player.x + 16,
self.player.y + 16,
);
}
}
enemy.shootcount -= 1;
if enemy.shootcount < 1 {
enemy.shootagain = l.fields[17];
}
}
}
0 => {
enemy.shootagain = -1;
enemy.shootcount = l.fields[16];
enemy.shootflag = l.fields[3];
}
_ => enemy.shootagain -= 1,
}
} else if enemy.cooldown == 0 {
shots.push((
weapon.clone(),
enemy.x + enemy.width / 2,
enemy.y + enemy.height,
Some((-1, enemy.entity, def.speed, 0)),
));
enemy.cooldown = def.cooldown;
} else {
enemy.cooldown -= 1;
}
}
}
if def.ground
&& def.legacy.as_ref().is_some_and(|l| {
enemy.y > 211
|| (l.fields[21] == 4 && enemy.x < -enemy.width / 2)
|| (l.fields[21] == 5 && enemy.x > 335)
})
{
retired.push(enemy.entity);
continue;
}
if def.boss && enemy.health < 50 && (self.tick + 1) & 2 != 0 && def.legacy.is_some() {
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(
self.visual_effects.len(),
&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;
damage_player(
&mut self.player,
self.energy_overflow,
&mut self.super_shield_overflow,
enemy.width.max(enemy.height) >> 2,
self.difficulty,
&mut self.rng,
&mut self.sounds,
&mut self.sound_events,
&mut self.visual_effects,
self.effects.len(),
self.legacy_transition && self.end_timer.is_some(),
);
if def.legacy.is_some() {
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(
self.visual_effects.len(),
&mut self.effects,
Effect::new(EffectKind::SmallAir, x, y),
);
play_legacy_sound(
&mut self.rng,
&mut self.sounds,
&mut self.sound_events,
"FX_CRASH",
);
}
}
if enemy.health <= 0 {
if let Some(legacy) = &def.legacy {
play_legacy_sound(
&mut self.rng,
&mut self.sounds,
&mut self.sound_events,
"FX_AIREXPLO",
);
position_sound(
&mut self.sound_events,
enemy.x + enemy.width / 2,
enemy.x + enemy.width / 2,
self.player.x + 16,
self.player.y + 16,
);
death_effects(
self.visual_effects.len(),
&mut self.effects,
&mut self.rng,
enemy,
legacy.fields[2],
);
} else {
original_effect(
self.visual_effects.len(),
&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);
events.push(HookEvent {
content: enemy.definition.clone(),
hook: "on_death",
event: serde_json::json!({"entity":enemy.entity,"x":enemy.x,"y":enemy.y}),
});
let mut drops: Vec<_> = def.drop.iter().cloned().collect();
if def.legacy.as_ref().is_some_and(|l| l.fields[2] == 8) {
drops.insert(0, ContentId::from("base:itembuy6"));
}
for drop in drops {
let legacy = if def.legacy.is_some() {
if self.pickups.len() >= 12
|| (drop == ContentId::from("base:itembuy6")
&& self.pickups.iter().filter(|p| p.item == drop).count() > 9)
{
continue;
}
let position = self.rng.next_value() as usize % 16;
Some(LegacyPickup {
x: enemy.x + 16,
y: enemy.y,
position,
frame: 0,
art_frame: 0,
countdown: None,
glow_frame: 0,
previous_art_position: None,
})
} else {
None
};
let id = self.next_entity;
self.next_entity += 1;
self.pickups.push(Pickup {
entity: id,
item: drop,
x: enemy.x,
y: enemy.y,
legacy,
});
}
retired.push(enemy.entity);
} else if enemy.y > 240 || enemy.x < -enemy.width - 40 || enemy.x > 360 {
retired.push(enemy.entity);
}
if !retired.contains(&enemy.entity) {
events.push(HookEvent{content:enemy.definition.clone(),hook:"on_tick",event:serde_json::json!({"entity":enemy.entity,"x":enemy.x,"y":enemy.y,"health":enemy.health,"tick":self.tick,"player_x":self.player.x,"player_y":self.player.y})});
}
}
self.events.extend(events);
// Capture the final position before retiring an anchor's enemy.
self.update_lightning_arcs();
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))
{
let entity = legacy.map_or(0, |(_, entity, _, _)| entity);
self.event(id.clone(),"on_fire",serde_json::json!({"entity":entity,"x":x,"y":y,"hostile":true,"tick":self.tick}));
if let Some(enemy) = self.enemies.iter().find(|e| e.entity == entity) {
let definition = enemy.definition.clone();
self.event(definition,"on_fire",serde_json::json!({"entity":entity,"weapon":id,"x":x,"y":y,"hostile":true,"tick":self.tick}));
}
}
if matches!(registry.weapons[&id].behavior, WeaponBehavior::Scripted) {
continue;
}
if let Some((kind, entity, speed, gun)) = legacy.filter(|(kind, _, _, _)| *kind >= 0) {
self.enemy_projectile(&id, x, y, kind, entity, speed, gun)?;
continue;
}
let speed = registry.weapons[&id].speed;
let dx = self.player.x + 16 - x;
let dy = self.player.y + 16 - y;
let norm = dx.abs().max(dy.abs()).max(1);
self.projectile(&id, x, y, dx * speed / norm, dy * speed / norm, true)?;
}
for cooldown in self.cooldowns.values_mut() {
*cooldown = cooldown.saturating_sub(1);
}
let mut dead_shots = Vec::new();
let mut hit_events = Vec::new();
self.think_enemy_projectiles(registry)?;
for p in self
.projectiles
.iter_mut()
.filter(|p| p.hostile && p.legacy_enemy.is_none())
{
p.px = p.x;
p.py = p.y;
p.x += p.vx;
p.y += p.vy;
p.age += 1;
if (p.x - (self.player.x + 16)).abs() < 10 && (p.y - (self.player.y + 16)).abs() < 10 {
damage_player(
&mut self.player,
self.energy_overflow,
&mut self.super_shield_overflow,
registry.weapons[&p.weapon].damage,
self.difficulty,
&mut self.rng,
&mut self.sounds,
&mut self.sound_events,
&mut self.visual_effects,
self.effects.len(),
self.legacy_transition && self.end_timer.is_some(),
);
dead_shots.push(p.entity);
}
}
let mut picked = Vec::new();
for pickup in &mut self.pickups {
let collision = if let Some(legacy) = &mut pickup.legacy {
const X: [i32; 16] = [-1, 0, 1, 2, 3, 3, 3, 2, 1, 0, -1, -2, -3, -3, -3, -2];
const Y: [i32; 16] = [-3, -3, -3, -2, -1, 0, 1, 2, 3, 3, 3, 2, 1, 0, -1, -2];
legacy.art_frame = legacy.frame;
if legacy.previous_art_position.is_some() {
legacy.previous_art_position = Some([pickup.x, pickup.y]);
}
pickup.x = legacy.x - 8 + X[legacy.position];
pickup.y = legacy.y - 8 + Y[legacy.position];
if legacy.previous_art_position.is_none() {
legacy.previous_art_position = Some([pickup.x, pickup.y]);
}
legacy.glow_frame = (legacy.glow_frame + 1) % 4;
legacy.y += 1;
if self.tick & 1 != 0 {
legacy.position = (legacy.position + 1) % 16;
legacy.frame = (legacy.frame + 1)
% registry.items[&pickup.item]
.art
.as_ref()
.map_or(1, |a| a.frames.max(1));
}
legacy.countdown.is_none()
&& self.player.shield > 0
&& legacy.x > self.player.x
&& legacy.x < self.player.x + 32
&& legacy.y > self.player.y
&& legacy.y < self.player.y + 32
} else {
pickup.y += 1;
(pickup.x - self.player.x).abs() < 24 && (pickup.y - self.player.y).abs() < 24
};
if collision {
let item = &registry.items[&pickup.item];
if pickup.item.0 == "base:super_shield" {
if inventory_slots(&self.player) < 20 {
if self.player.inventory.contains_key(&pickup.item) {
self.player.super_shield_reserves.push(item.initial_count);
} else {
self.player.super_shield = item.initial_count.min(100) as i32;
self.super_shield_overflow = item.initial_count.saturating_sub(100);
self.player
.inventory
.insert(pickup.item.clone(), item.initial_count);
}
}
} else if pickup.item.0 == "base:energy" && item.legacy == Some(16) {
add_energy(
&mut self.player,
item.initial_count as i32,
self.energy_overflow,
self.super_shield_overflow,
);
} else if item.credits == 0
&& (self.player.inventory.contains_key(&pickup.item)
|| inventory_slots(&self.player) < 20)
{
let amount = self
.player
.inventory
.entry(pickup.item.clone())
.or_default();
*amount = add_with_capacity(*amount, item.initial_count, item.max_count);
if item.special && self.player.selected.is_none() {
self.player.selected = Some(pickup.item.clone());
}
}
if pickup.item.0 != "base:super_shield"
&& !(pickup.item.0 == "base:energy" && item.legacy == Some(16))
{
self.player.shield = (self.player.shield + item.shield).min(100);
}
self.player.credits = self.player.credits.saturating_add(item.credits);
if pickup.legacy.is_some() {
play_legacy_sound(
&mut self.rng,
&mut self.sounds,
&mut self.sound_events,
"FX_BONUS",
);
}
if item.credits > 0 && pickup.legacy.is_some() {
pickup.legacy.as_mut().unwrap().countdown = Some(50);
} else {
picked.push(pickup.entity);
}
hit_events.push(HookEvent {
content: pickup.item.clone(),
hook: "on_pickup",
event: serde_json::json!({"entity":pickup.entity}),
});
}
if let Some(legacy) = &mut pickup.legacy {
if let Some(countdown) = &mut legacy.countdown {
*countdown = countdown.saturating_sub(1);
if *countdown == 0 {
picked.push(pickup.entity);
}
}
if legacy.y - 8 > 200 {
picked.push(pickup.entity);
}
}
}
self.pickups
.retain(|p| p.y < 220 && !picked.contains(&p.entity));
self.think_legacy_projectiles(registry)?;
for p in self
.projectiles
.iter_mut()
.filter(|p| !p.hostile && p.legacy.is_none())
{
p.px = p.x;
p.py = p.y;
p.age += 1;
let weapon = &registry.weapons[&p.weapon];
if matches!(weapon.behavior, WeaponBehavior::Homing) {
if let Some(target) = self.enemies.iter().find(|e| {
e.health > 0
&& target_matches(&weapon.target, registry.enemies[&e.definition].ground)
}) {
p.vx = (target.x + target.width / 2 - p.x)
.clamp(-weapon.speed.abs(), weapon.speed.abs());
}
}
p.x += p.vx;
p.y += p.vy;
if matches!(weapon.behavior, WeaponBehavior::Beam) {
p.x = self.player.x + 16;
for e in &mut self.enemies {
if target_matches(&weapon.target, registry.enemies[&e.definition].ground)
&& p.x > e.x
&& p.x < e.x + e.width
&& e.y < self.player.y
{
e.health -= weapon.damage;
hit_events.push(HookEvent{content:e.definition.clone(),hook:"on_hit",event:serde_json::json!({"entity":e.entity,"projectile":p.entity,"weapon":p.weapon,"damage":weapon.damage,"x":p.x,"y":e.y,"health":e.health})});
hit_events.push(HookEvent{content:p.weapon.clone(),hook:"on_hit",event:serde_json::json!({"entity":e.entity,"projectile":p.entity,"damage":weapon.damage,"x":p.x,"y":e.y})});
}
}
if p.age > 1 {
dead_shots.push(p.entity);
}
} else if let Some(e) = self.enemies.iter_mut().find(|e| {
e.health > 0
&& target_matches(&weapon.target, registry.enemies[&e.definition].ground)
&& contains(e, p.x, p.y)
}) {
e.health -= weapon.damage;
hit_events.push(HookEvent{content:e.definition.clone(),hook:"on_hit",event:serde_json::json!({"entity":e.entity,"projectile":p.entity,"weapon":p.weapon,"damage":weapon.damage,"x":p.x,"y":p.y,"health":e.health})});
hit_events.push(HookEvent{content:p.weapon.clone(),hook:"on_hit",event:serde_json::json!({"entity":e.entity,"projectile":p.entity,"damage":weapon.damage,"x":p.x,"y":p.y})});
dead_shots.push(p.entity);
} else if !matches!(weapon.target, Target::Air) {
let col = (p.x - 16) / 32;
let row = (p.y + mission.layout.len() as i32 / mission.columns.max(1) as i32 * 32
- 200
- self.scroll)
/ 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()
&& self.terrain_health[pos] > 0
&& !self.terrain_dead[pos]
{
self.terrain_health[pos] -= weapon.damage;
dead_shots.push(p.entity);
if self.terrain_health[pos] <= 0 {
self.terrain_dead[pos] = true;
self.player.credits = self
.player
.credits
.saturating_add(mission.tiles[mission.layout[pos]].reward);
original_effect(
self.visual_effects.len(),
&mut self.effects,
Effect::new(EffectKind::GroundBlast, p.x, p.y),
);
}
}
}
}
}
self.events.extend(hit_events);
self.projectiles.retain(|p| {
!dead_shots.contains(&p.entity)
&& (p.legacy.is_some()
|| (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 <= e.kind.artwork().1);
for effect in &mut self.visual_effects {
effect.px = effect.x;
effect.py = effect.y;
match effect.anchor {
Some(0) => {
effect.x = self.player.x + 16 + effect.offset[0];
effect.y = self.player.y + 16 + effect.offset[1];
}
Some(entity) => {
if let Some(enemy) = self.enemies.iter().find(|e| e.entity == entity) {
effect.x = enemy.movement.x + effect.offset[0];
effect.y = enemy.movement.y + effect.offset[1];
}
}
None => {
effect.x = effect.offset[0];
effect.y = effect.offset[1];
}
}
if effect.age == 0 {
effect.px = effect.x;
effect.py = effect.y;
}
effect.age += 1;
// ANIMS_Think adjusts both the direction switch and adir table.
effect.offset[1] += match effect.kind {
VisualEffectKind::SmokeDown => 2,
VisualEffectKind::SmokeTrail => -2,
kind if kind.layer() == 0 && self.scroll != previous_scroll => 1,
_ => 0,
};
}
self.visual_effects
.retain(|effect| effect.age <= effect.kind.artwork().1);
for arc in &mut self.lightning_arcs {
arc.age += 1;
}
self.lightning_arcs.retain(|arc| arc.age < arc.ticks);
if self.difficulty < 3 {
if equipment_used {
self.shield_recharge_ticks = 0;
} else {
self.shield_recharge_ticks += 1;
if self.shield_recharge_ticks > 96 {
if self.end_timer.is_none()
&& registry
.items
.get(&ContentId::from("base:energy"))
.is_some_and(|item| item.legacy == Some(16))
{
add_energy(
&mut self.player,
1,
self.energy_overflow,
self.super_shield_overflow,
);
}
self.shield_recharge_ticks = 0;
}
}
}
self.tick += 1;
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 self.tick >= mission.duration
&& self.spawn_cursor == mission.spawns.len()
&& self
.enemies
.iter()
.all(|e| !registry.enemies[&e.definition].boss)
{
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()
.filter(|id| registry.items.contains_key(id))
.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.sync_retained_inventory();
self.update_lightning_arcs();
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(
self.mission.clone(),
"on_mission_end",
serde_json::json!({"success":self.outcome==Outcome::Complete,"tick":self.tick}),
);
}
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(
self.visual_effects.len(),
&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(
self.visual_effects.len(),
&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(
self.visual_effects.len(),
&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(
self.visual_effects.len(),
&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(());
};
if self.cooldowns.get(id).copied().unwrap_or(0) > 0 {
return Ok(());
}
self.cooldowns.insert(id.clone(), w.cooldown);
if matches!(w.behavior, WeaponBehavior::Scripted) {
self.event(
id.clone(),
"on_fire",
serde_json::json!({"entity":0,"hostile":false,"x":self.player.x+16,"y":self.player.y,"tick":self.tick}),
);
return Ok(());
}
if let Some(index) = w.legacy {
if let Some(legacy) = crate::legacy_tables::weapons()
.into_iter()
.find(|w| w.index == index)
{
self.fire_legacy(id, &legacy, registry)?;
return Ok(());
}
}
let offsets = if w.offsets.is_empty() {
vec![[0, 0]]
} else {
w.offsets.clone()
};
for [x, y] in offsets {
self.projectile(
id,
self.player.x + 16 + x,
self.player.y + y,
0,
-w.speed.max(1),
false,
)?;
}
self.event(
id.clone(),
"on_fire",
serde_json::json!({"entity":0,"hostile":false,"x":self.player.x+16,"y":self.player.y,"tick":self.tick}),
);
Ok(())
}
fn enemy_projectile(
&mut self,
id: &ContentId,
x: i32,
y: i32,
kind: i16,
enemy: u64,
enemy_speed: i32,
gun: usize,
) -> Result<()> {
if !(0..320).contains(&x)
|| !(0..200).contains(&y)
|| self
.projectiles
.iter()
.filter(|p| p.legacy_enemy.is_some())
.count()
>= 80
{
return Ok(());
}
let (asset, frames, maximum_speed, damage, offset_y) = match kind {
0 => (0x101d6, 2, 6, 1, 4),
1..=3 => (0x101d6, 2, 6, 2, 4),
4 => (0x101d4, 2, 10, 4, 0),
5 => (0x10241, 4, 6, 12, 0),
6 => (0x101d1, 2, 0, 16, 0),
7 => (0x10251, 1, 10, 15, 0),
8 => (0x1024d, 4, 6, 1, 4),
_ => {
return Err(anyhow::anyhow!(
"unknown original enemy projectile type {kind}"
));
}
};
let start = [x - if kind == 6 { 0 } else { 4 }, y - offset_y];
let mut target = [start[0], 200];
let speed = match kind {
0 | 8 => 1,
1..=3 => 3,
4 => enemy_speed + 1,
5 => enemy_speed,
6 => 150,
7 => 8,
_ => 1,
};
match kind {
0 | 8 => target = [self.player.x + 16, self.player.y + 16],
2 => target = [start[0] - 32, start[1] + 32],
3 => target = [start[0] + 32, start[1] + 32],
6 => target = [320, 200],
_ => {}
}
let mut movement = LineMove::new(start[0], start[1], target[0], target[1]);
movement.advance_shot(1);
if !(0..320).contains(&movement.x) || !(0..200).contains(&movement.y) {
return Ok(());
}
self.projectile(id, movement.x, movement.y, 0, 0, true)?;
self.projectiles.last_mut().unwrap().legacy_enemy = Some(EnemyProjectile {
movement,
kind,
asset,
frames,
frame: 0,
art_frame: 0,
speed,
maximum_speed,
damage,
position: if kind == 6 {
self.rng.next_value() as usize % 16
} else {
0
},
enemy,
gun,
});
Ok(())
}
fn think_enemy_projectiles(&mut self, registry: &Registry) -> Result<()> {
const X: [i32; 16] = [-1, 0, 1, 2, 3, 3, 3, 2, 1, 0, -1, -2, -3, -3, -3, -2];
const Y: [i32; 16] = [-3, -3, -3, -2, -1, 0, 1, 2, 3, 3, 3, 2, 1, 0, -1, -2];
let mut retired = Vec::new();
for p in &mut self.projectiles {
let Some(state) = p.legacy_enemy.as_mut() else {
continue;
};
p.px = p.x;
p.py = p.y;
p.age += 1;
state.art_frame = state.frame;
state.frame += 1;
if state.kind == 5 {
if let Some(enemy) = self
.enemies
.iter()
.find(|e| e.entity == state.enemy)
.filter(|_| state.frame < state.frames)
{
let legacy = registry.enemies[&enemy.definition].legacy.as_ref().unwrap();
p.x = enemy.x + legacy.gun_x[state.gun] as i32 - 4;
p.y = enemy.y + legacy.gun_y[state.gun] as i32;
state.movement.ty = 200;
if (p.x - self.player.x - 16).abs() < 16 && p.y < self.player.y + 16 {
state.movement.ty = self.player.y + 16 + self.rng.next_value() % 4 - 2;
damage_player(
&mut self.player,
self.energy_overflow,
&mut self.super_shield_overflow,
state.damage,
self.difficulty,
&mut self.rng,
&mut self.sounds,
&mut self.sound_events,
&mut self.visual_effects,
self.effects.len(),
self.legacy_transition && self.end_timer.is_some(),
);
}
} else {
retired.push(p.entity);
}
} else {
state.frame %= state.frames;
if state.maximum_speed != 0 {
p.x = state.movement.x;
p.y = state.movement.y;
state.movement.advance_shot(state.speed);
if state.speed < state.maximum_speed {
state.speed += 1;
}
} else {
state.speed -= 1;
if state.speed > 0 {
p.x = state.movement.x + X[state.position];
p.y = state.movement.y + Y[state.position];
state.movement.y += 1;
state.position = (state.position + 1) % 16;
} else {
visual_effect(
self.effects.len(),
&mut self.visual_effects,
VisualEffect::new(VisualEffectKind::SmallAir, p.x + 4, p.y + 4),
);
retired.push(p.entity);
}
}
if !(0..320).contains(&p.x) || !(0..200).contains(&p.y) {
retired.push(p.entity);
}
if (p.x - self.player.x - 16).abs() < 16 && (p.y - self.player.y - 16).abs() < 16 {
visual_effect(
self.effects.len(),
&mut self.visual_effects,
VisualEffect::new(VisualEffectKind::SmallAir, p.x, p.y),
);
damage_player(
&mut self.player,
self.energy_overflow,
&mut self.super_shield_overflow,
state.damage,
self.difficulty,
&mut self.rng,
&mut self.sounds,
&mut self.sound_events,
&mut self.visual_effects,
self.effects.len(),
self.legacy_transition && self.end_timer.is_some(),
);
retired.push(p.entity);
}
// Native enemy missiles trail smoke on odd surviving updates.
if state.kind == 4 && p.age & 1 != 0 && !retired.contains(&p.entity) {
visual_effect(
self.effects.len(),
&mut self.visual_effects,
VisualEffect::new(VisualEffectKind::SmokeTrail, p.x + 4, p.y),
);
}
}
}
self.projectiles.retain(|p| !retired.contains(&p.entity));
Ok(())
}
fn terrain_window(&self, registry: &Registry) -> (usize, i32) {
let mission = &registry.missions[&self.mission];
let rows = mission.layout.len() / mission.columns.max(1) as usize;
let initial = rows.saturating_sub(8);
let wraps = if self.scroll <= 56 {
0
} else {
1 + (self.scroll - 57) / 32
};
(
initial.saturating_sub(wraps as usize),
-56 + self.scroll - wraps * 32,
)
}
fn terrain_damage_neighbors(&mut self, position: usize, damage: i32, registry: &Registry) {
let mission = &registry.missions[&self.mission];
let columns = mission.columns.max(1) as usize;
let column = position % columns;
let neighbors = [
if column > 0 {
position.checked_sub(1)
} else {
None
},
position.checked_sub(columns),
if column + 1 < columns {
Some(position + 1)
} else {
None
},
];
for position in neighbors.into_iter().flatten() {
if position < mission.layout.len()
&& (mission.legacy_map.is_none()
|| mission.tiles[mission.layout[position]].destroyed.is_some())
&& !self.terrain_dead[position]
{
self.damage_terrain(position, damage, registry);
}
}
}
fn damage_terrain(&mut self, position: usize, damage: i32, registry: &Registry) {
let mission = &registry.missions[&self.mission];
if self.terrain_dead[position]
|| (mission.legacy_map.is_none() && self.terrain_health[position] <= 0)
{
return;
}
self.terrain_health[position] -= damage;
// Native maps defer explosions to TILE_Think's following pass. JSON
// maps use immediate destruction, including shots from original guns.
if mission.legacy_map.is_none() && self.terrain_health[position] <= 0 {
self.terrain_dead[position] = true;
self.terrain_settled[position] = true;
self.player.credits = self
.player
.credits
.saturating_add(mission.tiles[mission.layout[position]].reward);
let columns = mission.columns.max(1) as usize;
let rows = mission.layout.len() / columns;
let x = 32 + (position % columns) as i32 * 32;
let y = 216 - rows as i32 * 32 + self.scroll + (position / columns) as i32 * 32;
original_effect(
self.visual_effects.len(),
&mut self.effects,
Effect::new(EffectKind::GroundBlast, x, y),
);
play_legacy_sound(
&mut self.rng,
&mut self.sounds,
&mut self.sound_events,
"FX_GEXPLO",
);
position_sound(
&mut self.sound_events,
x,
y,
self.player.x + 16,
self.player.y + 16,
);
}
}
fn think_legacy_terrain(&mut self, registry: &Registry) {
let mission = &registry.missions[&self.mission];
if mission.legacy_map.is_none() || mission.layout.is_empty() {
return;
}
let columns = mission.columns.max(1) as usize;
let (row, y) = self.terrain_window(registry);
let start = row * columns;
let end = (start + 8 * columns).min(mission.layout.len());
for position in start..end {
if self.terrain_health[position] < 0 && !self.terrain_dead[position] {
let x = 16 + (position % columns) as i32 * 32;
let y = y + ((position - start) / columns) as i32 * 32;
play_legacy_sound(
&mut self.rng,
&mut self.sounds,
&mut self.sound_events,
"FX_GEXPLO",
);
position_sound(
&mut self.sound_events,
x + 16,
y + 16,
self.player.x + 16,
self.player.y + 16,
);
self.terrain_damage_neighbors(position, 5, registry);
self.player.credits = self
.player
.credits
.saturating_add(mission.tiles[mission.layout[position]].reward);
self.terrain_delays.push(TerrainDelay {
position,
frames: 10,
});
original_effect(
self.visual_effects.len(),
&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;
}
}
let delays = std::mem::take(&mut self.terrain_delays);
for mut delay in delays {
if delay.position.saturating_sub(start) > 8 * columns {
continue;
}
if delay.frames < 0 {
let random = self.rng.next_value(); // Ground sparkle horizontal offset.
let x = 16 + (delay.position % columns) as i32 * 32 + 8 + random % 8;
let y = y + (delay.position as i32 - start as i32) / columns as i32 * 32 + 26;
self.terrain_spark_delay += 1;
self.terrain_flare_delay += 1;
if self.terrain_spark_delay > 2 {
visual_effect(
self.effects.len(),
&mut self.visual_effects,
VisualEffect::new(VisualEffectKind::GroundSparkle, x, y),
);
self.terrain_spark_delay = 0;
}
if self.terrain_flare_delay > 4 {
visual_effect(
self.effects.len(),
&mut self.visual_effects,
VisualEffect::new(VisualEffectKind::GroundFlare, x, y),
);
self.terrain_flare_delay = 0;
}
self.terrain_damage_neighbors(delay.position, 20, registry);
self.terrain_settled[delay.position] = true;
} else {
delay.frames -= 1;
self.terrain_delays.push(delay);
}
}
}
fn think_legacy_projectiles(&mut self, registry: &Registry) -> Result<()> {
let mut retired = Vec::new();
let libs = crate::legacy_tables::weapons();
for n in 0..self.projectiles.len() {
let mut p = self.projectiles[n].clone();
let Some(mut state) = p.legacy.take() else {
continue;
};
if p.hostile {
continue;
}
let weapon = &registry.weapons[&p.weapon];
let Some(lib) = libs.iter().find(|l| Some(l.index) == weapon.legacy) else {
continue;
};
p.px = p.x;
p.py = p.y;
p.age += 1;
state.art_frame = state.frame;
if lib.beam == 1 && p.age > 1 {
retired.push(p.entity);
continue;
}
let (half_width, half_height) = weapon
.art
.as_ref()
.map(|a| (a.logical_width as i32 / 2, a.logical_height as i32 / 2))
.unwrap_or((0, 0));
p.x = state.movement.x - if lib.beam == 1 { 0 } else { half_width };
p.y = state.movement.y
- if lib.beam == 0 && lib.moving {
half_height
} else {
0
};
if matches!(lib.index, 6..=8) {
visual_effect(
self.effects.len(),
&mut self.visual_effects,
VisualEffect::new(
VisualEffectKind::SmokeDown,
p.x + half_width,
p.y + half_height * 2,
),
);
}
// The original S_BEAM damage pass tests the uncorrected launch
// column, then applies player-follow offsets to presentation.
if lib.beam == 2 {
for enemy in &mut self.enemies {
if p.x > enemy.x
&& p.x < enemy.x + enemy.width - 1
&& enemy.y < self.player.y + 16
&& enemy.y > -30
{
enemy.health -= weapon.damage;
if enemy.health != -1 {
state.movement.ty = enemy.y + enemy.height / 2;
break;
}
}
}
}
if lib.follow_x {
p.x += self.player.x + 16 - state.start[0];
}
if lib.follow_y {
p.y += self.player.y + 16 - state.start[1];
}
let mut skip_collision = false;
if lib.moving && (p.y + 16 < 0 || p.x < 0 || p.x > 320 || p.y > 200) {
state.movement.done = true;
skip_collision = true;
}
if !skip_collision && !state.delay {
if state.speed < lib.maxspeed {
state.speed += 1;
}
state.frame += 1;
if state.frame >= lib.frames {
if lib.moving {
state.frame = lib.startframe;
} else {
state.movement.done = true;
skip_collision = true;
}
}
}
if state.hit {
state.movement.done = true;
skip_collision = true;
}
// 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.damage_terrain(position, weapon.damage, registry);
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()
.filter(|e| visible(e))
.take(30)
.find(|e| {
let ground = registry.enemies[&e.definition].ground;
contains(e, p.x, p.y)
&& match lib.target {
1 | 5 => !ground,
2 | 4 => ground,
_ => true,
}
});
if let Some(enemy) = enemy {
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;
}
state.hit = true;
let spark = match lib.target {
0 | 1 | 3 => {
if self.rng.next_value() % 2 != 0 {
VisualEffectKind::BlueSpark
} else {
VisualEffectKind::OrangeSpark
}
}
2 => VisualEffectKind::OrangeSpark,
4 => VisualEffectKind::SmallGround,
_ => VisualEffectKind::BlueSpark,
};
visual_effect(
self.effects.len(),
&mut self.visual_effects,
VisualEffect::new(spark, p.x, p.y),
);
if lib.target == 5 {
visual_effect(
self.effects.len(),
&mut self.visual_effects,
VisualEffect::new(
VisualEffectKind::EnergyGrab,
enemy.width / 2,
enemy.height / 2,
)
.attached(enemy.entity),
);
}
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) {
if let Some(position) = self.legacy_tile_hit(p.x, p.y, registry) {
self.damage_terrain(position, weapon.damage, registry);
let kind = if self.rng.next_value() % 2 == 0 {
VisualEffectKind::GroundBlueSpark
} else {
VisualEffectKind::GroundOrangeSpark
};
visual_effect(
self.effects.len(),
&mut self.visual_effects,
VisualEffect::new(kind, p.x, p.y),
);
state.movement.done = true;
}
}
}
if state.movement.done {
if state.delay {
state.delay = false;
visual_effect(
self.effects.len(),
&mut self.visual_effects,
VisualEffect::new(
VisualEffectKind::SmokeDown,
state.movement.x,
state.movement.y,
),
);
state.movement = LineMove::new(
state.movement.x,
state.movement.y,
state.movement.x + self.rng.next_value() % 32 - 16,
0,
);
} else if lib.index == 11 {
visual_effect(
self.effects.len(),
&mut self.visual_effects,
VisualEffect::new(VisualEffectKind::Shield, 0, 0).attached(0),
);
for enemy in &mut self.enemies {
enemy.health -= weapon.damage;
}
let mission = &registry.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.damage_terrain(position, 20, registry);
}
}
retired.extend(
self.projectiles
.iter()
.filter(|p| p.hostile)
.map(|p| p.entity),
);
retired.push(p.entity);
continue;
} else if lib.index != 5 {
retired.push(p.entity);
continue;
}
}
if lib.moving {
if lib.plot {
state.movement.advance_shot(state.speed);
} else {
state.movement.y -= state.speed;
if state.movement.y < 0 {
state.movement.done = true;
state.hit = true;
}
}
}
p.legacy = Some(state);
self.projectiles[n] = p;
}
self.projectiles.retain(|p| !retired.contains(&p.entity));
Ok(())
}
fn legacy_tile_hit(&self, x: i32, y: i32, registry: &Registry) -> Option<usize> {
let mission = &registry.missions[&self.mission];
let columns = mission.columns.max(1) as usize;
if mission.legacy_map.is_none() {
// JSON terrain keeps scrolling past the last row. Use exactly its
// presentation coordinates instead of the native clamped window.
let rows = mission.layout.len() / columns;
let column = (x - 16).div_euclid(32);
let row = (y - (200 - rows as i32 * 32 + self.scroll)).div_euclid(32);
if column < 0 || column >= columns as i32 || row < 0 || row >= rows as i32 {
return None;
}
let position = row as usize * columns + column as usize;
return (self.terrain_health[position] > 0 && !self.terrain_dead[position])
.then_some(position);
}
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,
lib: &crate::legacy_tables::LegacyWeapon,
registry: &Registry,
) -> Result<()> {
use crate::legacy_tables::{O_GUN1, O_GUN2, O_GUN3};
if self
.projectiles
.iter()
.filter(|p| !p.hostile && p.legacy.is_some())
.count()
>= 70
{
return Ok(());
}
let center = [self.player.x + 16, self.player.y + 16];
let bank = self.player.bank.clamp(0, 6) as usize;
let mut start = center;
let mut offsets = vec![0];
let mut target = [center[0], 0];
let mut initial_frame = None;
match lib.index {
0 => {
self.player_flash = true;
play_legacy_sound(
&mut self.rng,
&mut self.sounds,
&mut self.sound_events,
"FX_GUN",
);
offsets = vec![O_GUN1[bank], -O_GUN1[bank] - 1];
}
1 => play_legacy_sound(
&mut self.rng,
&mut self.sounds,
&mut self.sound_events,
"FX_GUN",
),
2 => {
self.player_flash = true;
play_legacy_sound(
&mut self.rng,
&mut self.sounds,
&mut self.sound_events,
"FX_GUN",
);
offsets = vec![O_GUN3[bank], -O_GUN3[bank]];
}
3 => {
play_legacy_sound(
&mut self.rng,
&mut self.sounds,
&mut self.sound_events,
"FX_MISSLE",
);
offsets = vec![0, 0];
}
4 | 5 => {
let candidates: Vec<_> = self
.enemies
.iter()
.enumerate()
.filter(|(_, e)| {
visible(e) && (lib.index == 4 || !registry.enemies[&e.definition].ground)
})
.map(|(n, _)| n)
.collect();
if candidates.is_empty() {
if lib.index == 5 {
play_legacy_sound(
&mut self.rng,
&mut self.sounds,
&mut self.sound_events,
"FX_NOSHOOT",
);
}
return Ok(());
}
let pos = candidates[self.rng.next_value() as usize % candidates.len()];
play_legacy_sound(
&mut self.rng,
&mut self.sounds,
&mut self.sound_events,
if lib.index == 5 {
"FX_TURRET"
} else {
"FX_GUN"
},
);
if lib.index == 4 {
initial_frame = Some(self.rng.next_value() as u32 % lib.frames);
}
let enemy = &mut self.enemies[pos];
if lib.index == 4 {
target = [
self.rng.next_value() % enemy.width + enemy.x - 1,
self.rng.next_value() % enemy.height + enemy.height / 2 + enemy.y - 1,
];
} else {
enemy.health -= lib.damage;
start = [
self.rng.next_value() % enemy.width + enemy.movement.x - 1,
self.rng.next_value() % enemy.height + enemy.movement.y - 1,
];
target = center;
}
}
6..=8 => {
play_legacy_sound(
&mut self.rng,
&mut self.sounds,
&mut self.sound_events,
if lib.index == 6 {
"FX_GUN"
} else {
"FX_MISSLE"
},
);
offsets = vec![O_GUN2[bank], -O_GUN2[bank]];
}
9 => play_legacy_sound(
&mut self.rng,
&mut self.sounds,
&mut self.sound_events,
"FX_MISSLE",
),
10 => {
play_legacy_sound(
&mut self.rng,
&mut self.sounds,
&mut self.sound_events,
"FX_GUN",
);
start[0] -= 4;
}
11 => {
play_legacy_sound(
&mut self.rng,
&mut self.sounds,
&mut self.sound_events,
"FX_GUN",
);
target = [160, 75];
}
12 => play_legacy_sound(
&mut self.rng,
&mut self.sounds,
&mut self.sound_events,
"FX_PULSE",
),
13 => {
play_legacy_sound(
&mut self.rng,
&mut self.sounds,
&mut self.sound_events,
"FX_LASER",
);
offsets = vec![O_GUN3[bank], -O_GUN3[bank]];
target[1] = -24;
}
14 => {
play_legacy_sound(
&mut self.rng,
&mut self.sounds,
&mut self.sound_events,
"FX_LASER",
);
start[1] -= 24;
target[1] = -24;
}
_ => {}
}
for (shot_number, offset) in offsets.into_iter().enumerate() {
if self
.projectiles
.iter()
.filter(|p| !p.hostile && p.legacy.is_some())
.count()
>= 70
{
break;
}
let x = start[0] + offset;
let y = start[1];
let frame = if lib.index <= 1 {
self.rng.next_value() as u32 % lib.frames.max(1)
} else {
initial_frame.unwrap_or(0)
};
let mut target = target;
if lib.index == 3 {
target = [
x + if shot_number == 0 {
self.rng.next_value() % 16 + 10
} else {
-self.rng.next_value() % 16 - 10
},
y + 5,
];
} else if lib.index != 4 && lib.index != 5 && lib.index != 11 {
target[0] = x;
}
self.projectile(id, x, y, 0, -lib.speed, false)?;
match lib.index {
0 => visual_effect(
self.effects.len(),
&mut self.visual_effects,
VisualEffect::new(VisualEffectKind::Casing, x, y),
),
5 => visual_effect(
self.effects.len(),
&mut self.visual_effects,
VisualEffect::new(VisualEffectKind::LaserImpact, x, y),
),
6 => visual_effect(
self.effects.len(),
&mut self.visual_effects,
VisualEffect::new(
VisualEffectKind::Muzzle,
offset - i32::from(shot_number != 0),
1,
)
.attached(0),
),
_ => (),
}
self.projectiles.last_mut().unwrap().legacy = Some(LegacyProjectile {
movement: LineMove::new(x, y, target[0], target[1]),
speed: lib.speed,
frame,
art_frame: frame,
delay: lib.delay,
hit: false,
start: center,
});
}
self.event(
id.clone(),
"on_fire",
serde_json::json!({"entity":0,"hostile":false,"x":center[0],"y":center[1],"tick":self.tick}),
);
Ok(())
}
fn dispatch(&mut self, registry: &Registry, scripts: &mut ScriptRuntime) -> Result<()> {
let mut processed = 0usize;
while !self.events.is_empty() {
let events = std::mem::take(&mut self.events);
processed += events.len();
ensure!(
processed <= 8192,
"too many cascading script events in one tick"
);
for event in events {
let script = if matches!(event.hook, "on_use" | "on_pickup") {
registry
.items
.get(&event.content)
.and_then(|v| v.script.as_ref())
} else {
registry
.weapons
.get(&event.content)
.and_then(|v| v.script.as_ref())
.or_else(|| {
registry
.enemies
.get(&event.content)
.and_then(|v| v.script.as_ref())
})
.or_else(|| {
registry
.missions
.get(&event.content)
.and_then(|v| v.script.as_ref())
})
.or_else(|| {
registry
.items
.get(&event.content)
.and_then(|v| v.script.as_ref())
})
};
if let Some(path) = script {
let owner = &registry.owners[&event.content];
let mut e = event.event;
e["difficulty"] = self.difficulty.into();
let actor_entity = if registry.missions.contains_key(&event.content) {
None
} else {
e.get("entity")
.and_then(serde_json::Value::as_u64)
.filter(|entity| *entity != 0)
};
let mut actor = serde_json::Value::Null;
e["entities"] = serde_json::to_value(&self.enemies)?;
for (snapshot, enemy) in e["entities"]
.as_array_mut()
.expect("enemy snapshot array")
.iter_mut()
.zip(&self.enemies)
{
snapshot["ground"] = registry.enemies[&enemy.definition].ground.into();
if actor_entity == Some(enemy.entity) {
actor = snapshot.clone();
}
}
e["actor"] = actor;
e["player"] = serde_json::to_value(&self.player)?;
e["objectives"] = serde_json::to_value(&self.objectives)?;
e["projectiles"] = serde_json::to_value(&self.projectiles)?;
e["pickups"] = serde_json::to_value(&self.pickups)?;
if e.get("tick").is_none() {
e["tick"] = self.tick.into();
}
let commands = scripts.invoke(owner, path, event.hook, &e, self.rng.seed)?;
self.apply_commands(&commands, registry).map_err(|error| {
let context = format!("{owner}/{path}::{}: {error}", event.hook);
error.context(context)
})?;
}
}
}
Ok(())
}
pub fn apply_commands(&mut self, commands: &[Command], registry: &Registry) -> Result<()> {
let mut candidate = self.clone();
for command in commands {
candidate.command(command, registry)?;
if matches!(command, Command::Move { .. } | Command::Despawn { .. }) {
candidate.update_lightning_arcs();
}
}
*self = candidate;
Ok(())
}
fn lightning_anchor(
&self,
entity: Option<u64>,
x: i32,
y: i32,
) -> Result<Option<LightningAnchor>> {
let Some(entity) = entity else {
return Ok(None);
};
let [origin_x, origin_y] = if entity == 0 {
[self.player.x, self.player.y]
} else {
let enemy = self
.enemies
.iter()
.find(|enemy| enemy.entity == entity)
.context("arc anchor does not exist")?;
[enemy.x, enemy.y]
};
Ok(Some(LightningAnchor {
entity,
offset: [
x.checked_sub(origin_x)
.context("arc anchor offset overflow")?,
y.checked_sub(origin_y)
.context("arc anchor offset overflow")?,
],
}))
}
fn update_lightning_arcs(&mut self) {
let player = [self.player.x, self.player.y];
let enemies = &self.enemies;
for arc in &mut self.lightning_arcs {
for (anchor, x, y) in [
(&mut arc.anchor, &mut arc.x, &mut arc.y),
(&mut arc.to_anchor, &mut arc.to_x, &mut arc.to_y),
] {
let Some(attachment) = *anchor else {
continue;
};
let origin = if attachment.entity == 0 {
Some(player)
} else {
enemies
.iter()
.find(|enemy| enemy.entity == attachment.entity)
.map(|enemy| [enemy.x, enemy.y])
};
if let Some([origin_x, origin_y]) = origin {
// An offscreen anchor cannot expand the cosmetic API's
// coordinate bounds or make movement fail gameplay.
*x = origin_x
.saturating_add(attachment.offset[0])
.clamp(-32768, 32768);
*y = origin_y
.saturating_add(attachment.offset[1])
.clamp(-32768, 32768);
} else {
// Retain the last visible position when its ship disappears.
*anchor = None;
}
}
}
}
fn command(&mut self, c: &Command, registry: &Registry) -> Result<()> {
match c {
Command::Spawn { enemy, x, y } => {
ensure!(
x.unsigned_abs() <= 32768 && y.unsigned_abs() <= 32768,
"spawn position exceeds world bounds"
);
self.spawn(&ContentId::new(enemy)?, *x, *y, registry)?;
}
Command::Projectile {
weapon,
x,
y,
vx,
vy,
hostile,
} => {
let id = ContentId::new(weapon)?;
ensure!(
registry.weapons.contains_key(&id)
&& vx.unsigned_abs() <= 128
&& vy.unsigned_abs() <= 128
&& x.unsigned_abs() <= 32768
&& y.unsigned_abs() <= 32768,
"invalid projectile command"
);
self.projectile(&id, *x, *y, *vx, *vy, *hostile)?;
}
Command::Damage { entity, amount } => {
ensure!((0..=100000).contains(amount), "invalid damage");
if *entity == 0 {
damage_player(
&mut self.player,
self.energy_overflow,
&mut self.super_shield_overflow,
*amount,
self.difficulty,
&mut self.rng,
&mut self.sounds,
&mut self.sound_events,
&mut self.visual_effects,
self.effects.len(),
self.legacy_transition && self.end_timer.is_some(),
);
} else {
let e = self
.enemies
.iter_mut()
.find(|e| e.entity == *entity)
.context("damage target does not exist")?;
e.health -= amount;
let definition = e.definition.clone();
let health = e.health;
self.event(definition,"on_hit",serde_json::json!({"entity":entity,"damage":amount,"health":health,"scripted":true,"tick":self.tick}));
}
}
Command::Despawn { entity } => {
ensure!(
self.enemies.iter().any(|e| e.entity == *entity)
|| self.projectiles.iter().any(|e| e.entity == *entity),
"despawn target does not exist"
);
self.enemies.retain(|e| e.entity != *entity);
self.projectiles.retain(|e| e.entity != *entity);
}
Command::Move { entity, x, y } => {
ensure!(
x.unsigned_abs() <= 32768 && y.unsigned_abs() <= 32768,
"invalid position"
);
let e = self
.enemies
.iter_mut()
.find(|e| e.entity == *entity)
.context("move target does not exist")?;
let dx = *x - e.x;
let dy = *y - e.y;
e.x = *x;
e.y = *y;
e.sx += dx;
e.sy += dy;
e.movement = LineMove::new(*x, *y, e.movement.tx + dx, e.movement.ty + dy);
}
Command::Velocity { entity, vx, vy } => {
ensure!(
vx.unsigned_abs() <= 128 && vy.unsigned_abs() <= 128,
"invalid velocity"
);
let e = self
.enemies
.iter_mut()
.find(|e| e.entity == *entity)
.context("velocity target does not exist")?;
e.vx = *vx;
e.vy = *vy;
}
Command::Animation { entity, frame } => {
let e = self
.enemies
.iter_mut()
.find(|e| e.entity == *entity)
.context("animation target does not exist")?;
let definition = &registry.enemies[&e.definition];
ensure!(
definition.legacy.is_none(),
"scripted animation requires a nonlegacy enemy"
);
let art = definition
.art
.as_ref()
.context("animation target has no artwork")?;
ensure!(
frame.is_none_or(|frame| frame < art.frames),
"animation frame exceeds declared artwork frames"
);
e.pinned_art_frame = *frame;
e.art_frame = frame.unwrap_or(e.frame);
}
Command::GiveItem { item, count } => {
let id = ContentId::new(item)?;
let def = registry.items.get(&id).context("unknown item")?;
ensure!(
self.player.inventory.contains_key(&id) || inventory_slots(&self.player) < 20,
"inventory capacity exceeded"
);
let amount = self.player.inventory.entry(id.clone()).or_default();
let previous = *amount;
*amount = add_with_capacity(previous, *count, def.max_count);
if id.0 == "base:super_shield" {
if self.super_shield_overflow > 0 {
add_retained_charge(
&mut self.player.super_shield,
&mut self.super_shield_overflow,
amount.saturating_sub(previous),
);
*amount = (self.player.super_shield.max(0) as u32)
.saturating_add(self.super_shield_overflow);
} else if *amount > 100 {
self.player.super_shield = 100;
self.super_shield_overflow = amount.saturating_sub(100);
} else {
self.player.super_shield = *amount as i32;
}
}
if id.0 == "base:energy" {
if self.energy_overflow > 0 {
add_retained_charge(
&mut self.player.shield,
&mut self.energy_overflow,
amount.saturating_sub(previous),
);
*amount =
(self.player.shield.max(0) as u32).saturating_add(self.energy_overflow);
} else if *amount > 100 {
self.player.shield = 100;
self.energy_overflow = amount.saturating_sub(100);
} else {
self.player.shield = *amount as i32;
}
}
}
Command::ConsumeItem { item, count } => {
let id = ContentId::new(item)?;
let amount = self
.player
.inventory
.get_mut(&id)
.context("item is not owned")?;
ensure!(*amount >= *count, "insufficient item quantity");
*amount -= count;
if id.0 == "base:super_shield" {
if self.super_shield_overflow > 0 {
consume_retained_charge(
&mut self.player.super_shield,
&mut self.super_shield_overflow,
*count,
);
} else {
self.player.super_shield = *amount as i32;
}
}
if id.0 == "base:energy" {
if self.energy_overflow > 0 {
consume_retained_charge(
&mut self.player.shield,
&mut self.energy_overflow,
*count,
);
} else {
self.player.shield = *amount as i32;
}
} else if id.0 != "base:super_shield" && *amount == 0 {
self.player.inventory.remove(&id);
if self.player.selected.as_ref() == Some(&id) {
self.player.selected = next_special(&self.player, registry);
}
}
}
Command::Credits { amount } => {
self.player.credits = self.player.credits.saturating_add_signed(*amount);
}
Command::Shield { amount } => {
ensure!(amount.unsigned_abs() <= 100000, "invalid shield adjustment");
self.player.shield = (self.player.shield + amount).clamp(0, 100);
if self.energy_overflow > 0 {
if let Some(charge) = self
.player
.inventory
.get_mut(&ContentId::from("base:energy"))
{
*charge = (self.player.shield as u32).saturating_add(self.energy_overflow);
}
}
}
Command::Effect { x, y, kind } => {
ensure!(
x.unsigned_abs() <= 32768 && y.unsigned_abs() <= 32768,
"effect position exceeds world bounds"
);
ensure!(self.effects.len() < 4096, "too many effects");
self.effects.push(Effect::new((*kind).into(), *x, *y));
}
Command::Arc {
x,
y,
to_x,
to_y,
ticks,
anchor,
to_anchor,
} => {
ensure!(
[x, y, to_x, to_y].iter().all(|n| n.unsigned_abs() <= 32768),
"arc position exceeds world bounds"
);
ensure!((1..=12).contains(ticks), "arc lifetime must be 1–12 ticks");
ensure!(
self.lightning_arcs.len() < MAX_LIGHTNING_ARCS,
"too many lightning arcs"
);
let anchor = self.lightning_anchor(*anchor, *x, *y)?;
let to_anchor = self.lightning_anchor(*to_anchor, *to_x, *to_y)?;
self.lightning_arcs.push(LightningArc {
x: *x,
y: *y,
to_x: *to_x,
to_y: *to_y,
ticks: *ticks,
age: 0,
seed: (self.tick as u32).wrapping_mul(0x1656_67b1)
^ ((self.tick >> 32) as u32)
^ (self.lightning_arcs.len() as u32).wrapping_mul(0x9e37_79b9),
anchor,
to_anchor,
});
}
Command::Sound { asset } => {
self.sounds.push(*asset);
self.sound_events.push(SoundEvent {
asset: *asset,
pan: 127,
pitch: 128,
volume: 127,
priority: 1,
});
}
Command::Objective { key, value } => {
ensure!(
key.len() <= 128
&& (self.objectives.contains_key(key) || self.objectives.len() < 1024),
"invalid objective key"
);
self.objectives.insert(key.clone(), *value);
}
Command::PlaySound { sound } => {
let id = ContentId::new(sound)?;
ensure!(
registry.sounds.contains_key(&id),
"sound {id} is unavailable"
);
ensure!(self.mod_sounds.len() < 1024, "too many sounds in one tick");
self.mod_sounds.push(id);
}
Command::Complete => {
if self.outcome == Outcome::Playing {
self.outcome = Outcome::Complete;
self.event(
self.mission.clone(),
"on_mission_end",
serde_json::json!({"success":true,"tick":self.tick}),
);
}
}
Command::Fail => {
if self.outcome == Outcome::Playing {
self.outcome = Outcome::Failed;
self.event(
self.mission.clone(),
"on_mission_end",
serde_json::json!({"success":false,"tick":self.tick}),
);
}
}
}
Ok(())
}
pub fn digest(&self) -> String {
use sha2::{Digest, Sha256};
format!(
"{:x}",
Sha256::digest(serde_json::to_vec(self).expect("world serialization"))
)
}
pub fn projectile_asset(p: &Projectile, registry: &Registry) -> Option<u32> {
p.legacy_enemy.as_ref().map(|s| s.asset).or_else(|| {
registry.weapons[&p.weapon]
.art
.as_ref()
.and_then(|a| a.asset)
})
}
pub fn reference_state(&self, registry: &Registry) -> serde_json::Value {
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;
if f[7] > 1 {
if enemy.frame_timer < 1 {
enemy.frame_timer = f[6];
if enemy.animation_on {
enemy.frame += 1;
if enemy.frame >= enemy.animation_frames {
enemy.frame = enemy.frame.saturating_sub(f[9].max(0) as u32);
match f[10] {
1 => {
enemy.animation_on = false;
enemy.shoot_on = true;
}
2 => match enemy.multi {
1 => {
enemy.animation_frames = f[7].max(1) as u32;
enemy.multi = 2;
}
2 => enemy.shoot_on = true,
_ => {}
},
_ => {}
}
}
}
} else {
enemy.frame_timer -= 1;
}
if enemy.countdown < 1 {
match f[10] {
0 => enemy.shoot_on = true,
1 => enemy.animation_on = true,
2 if enemy.multi == 0 => enemy.multi = 1,
_ => {}
}
} else {
enemy.countdown -= f[18];
}
} else if enemy.countdown < 1 {
enemy.countdown = -1;
enemy.shoot_on = true;
} else {
enemy.countdown -= f[18];
}
}
fn visible(e: &Enemy) -> bool {
e.y + e.height > 0 && e.y < 200 && e.x + e.width > 0 && e.x < 320
}
fn play_legacy_sound(
rng: &mut LegacyRng,
sounds: &mut Vec<u32>,
events: &mut Vec<SoundEvent>,
name: &str,
) {
if let Some(sound) = crate::legacy_tables::sounds()
.into_iter()
.find(|s| s.name == name)
{
let pitch = sound.pitch
+ if sound.random {
rng.next_value() % 40 - 20
} else {
0
};
sounds.push(sound.asset);
events.push(SoundEvent {
asset: sound.asset,
pan: 127,
pitch,
volume: sound.volume,
priority: sound.priority,
});
}
}
fn position_sound(events: &mut [SoundEvent], x: i32, y: i32, player_x: i32, player_y: i32) {
if let Some(event) = events.last_mut() {
let dx = x - player_x;
let dy = y - player_y;
event.pan = (dx + 127).clamp(1, 255);
let dx = dx.abs();
let dy = dy.abs();
let distance = dx + dy - dx.min(dy) / 2;
let volume = if distance < 40 {
127
} else if distance > 500 {
1
} else {
127 - (distance - 40) * 127 / 460
};
event.volume = event.volume * volume / 127;
}
}
fn accelerate(v: i32, direction: i32, cap: i32) -> i32 {
match direction.signum() {
-1 => (if v >= 0 { -1 } else { v } - 1).max(-cap),
1 => (if v <= 0 { 1 } else { v } + 1).min(cap),
_ => v / 2,
}
}
fn damage_player(
p: &mut Player,
energy_overflow: u32,
super_shield_overflow: &mut u32,
damage: i32,
difficulty: u32,
rng: &mut LegacyRng,
sounds: &mut Vec<u32>,
events: &mut Vec<SoundEvent>,
visuals: &mut Vec<VisualEffect>,
effect_count: usize,
immune: bool,
) {
if immune {
return;
}
let damage = if difficulty == 0 && damage > 1 {
damage >> 1
} else {
damage
};
let super_id = ContentId::from("base:super_shield");
if p.inventory.contains_key(&super_id) {
visual_effect(
effect_count,
visuals,
VisualEffect::new(VisualEffectKind::Shield, 0, 0).attached(0),
);
// Original super shields absorb the whole hit, even when it exhausts them.
// A zero-charge shield remains equipped until another hit removes it.
p.super_shield -= damage;
play_legacy_sound(rng, sounds, events, "FX_SHIT");
if p.super_shield < 0 {
// The exhausted module is actually destroyed. Its retained charge
// must not migrate into a replacement module or revive this one.
*super_shield_overflow = 0;
if p.super_shield_reserves.is_empty() {
p.super_shield = 0;
p.inventory.remove(&super_id);
} else {
let charge = p.super_shield_reserves.remove(0);
p.super_shield = charge.min(100) as i32;
*super_shield_overflow = charge.saturating_sub(100);
p.inventory.insert(super_id.clone(), charge);
}
} else {
p.inventory.insert(
super_id,
(p.super_shield as u32).saturating_add(*super_shield_overflow),
);
}
} else {
p.shield = (p.shield - damage).max(0);
if let Some(charge) = p.inventory.get_mut(&ContentId::from("base:energy")) {
*charge = (p.shield as u32).saturating_add(energy_overflow);
}
play_legacy_sound(rng, sounds, events, "FX_HIT");
}
}
fn add_energy(p: &mut Player, amount: i32, energy_overflow: u32, super_shield_overflow: u32) {
if !p.inventory.contains_key(&ContentId::from("base:energy")) {
return;
}
if p.shield < 100 {
p.shield = (p.shield + amount).clamp(0, 100);
p.inventory.insert(
"base:energy".into(),
(p.shield as u32).saturating_add(energy_overflow),
);
} else if p
.inventory
.contains_key(&ContentId::from("base:super_shield"))
&& p.super_shield > 0
{
p.super_shield = (p.super_shield + (amount >> 2)).min(100);
p.inventory.insert(
"base:super_shield".into(),
(p.super_shield as u32).saturating_add(super_shield_overflow),
);
}
}
fn is_zero(value: &u32) -> bool {
*value == 0
}
fn add_with_capacity(current: u32, additional: u32, capacity: u32) -> u32 {
// A smaller replacement definition can prevent new acquisitions, but an
// additive action cannot destroy quantities already accepted from a save.
current.max(current.saturating_add(additional).min(capacity))
}
fn add_retained_charge(usable: &mut i32, overflow: &mut u32, additional: u32) {
let before = (*usable).max(0) as u32;
let after = before.saturating_add(additional).min(100);
*usable = after as i32;
*overflow = overflow.saturating_add(additional.saturating_sub(after.saturating_sub(before)));
}
fn consume_retained_charge(usable: &mut i32, overflow: &mut u32, count: u32) {
let before = (*usable).max(0) as u32;
*usable = before.saturating_sub(count) as i32;
*overflow = overflow.saturating_sub(count.saturating_sub(before));
}
fn inventory_slots(p: &Player) -> usize {
p.inventory.len() + p.super_shield_reserves.len()
}
fn target_matches(t: &Target, ground: bool) -> bool {
match t {
Target::All => true,
Target::Air => !ground,
Target::Ground => ground,
}
}
fn contains(e: &Enemy, x: i32, y: i32) -> bool {
x > e.x && x < e.x + e.width - 1 && y > e.y && y < e.y + e.height - 1
}
/// Accumulate exact rational time without rounding the original 3/70-second tick.
#[derive(Default)]
pub struct Clock {
units: u128,
}
impl Clock {
pub fn advance(&mut self, nanos: u64) -> usize {
self.units += nanos as u128 * 70;
let steps = self.units / 3_000_000_000;
self.units %= 3_000_000_000;
steps as usize
}
pub fn alpha(&self) -> f32 {
self.units as f32 / 3_000_000_000.0
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn rng_wraps_like_legacy() {
let mut r = LegacyRng::new(1);
assert_eq!(
(0..5).map(|_| r.next_value()).collect::<Vec<_>>(),
vec![16838, 5758, 10113, 17515, 31051]
);
}
#[test]
fn clock_is_independent_of_frame_rate() {
for hz in [30, 60, 144] {
let mut c = Clock::default();
let mut ticks = 0;
let mut previous = 0;
for frame in 1..=hz * 3 {
let now = frame as u64 * 1_000_000_000 / hz as u64;
ticks += c.advance(now - previous);
previous = now;
}
assert_eq!(ticks, 70);
}
}
#[test]
fn line_reaches_target() {
let mut m = LineMove::new(0, 0, 10, 7);
for _ in 0..20 {
m.advance(1);
}
assert_eq!((m.x, m.y), (10, 7));
}
}