//! 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, } #[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, pub credits: u32, pub inventory: BTreeMap, pub selected: Option, 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, 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, pub legacy_enemy: Option, } #[derive(Debug, Clone, Serialize, Deserialize)] pub struct Pickup { pub entity: u64, pub item: ContentId, pub x: i32, pub y: i32, pub legacy: Option, } #[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, #[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, 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, 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, pub to_anchor: Option, } #[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) { if effects.len() + existing < 100 { effects.push(effect); } } fn death_effects( visual_count: usize, effects: &mut Vec, 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, pub projectiles: Vec, pub pickups: Vec, pub effects: Vec, #[serde(skip)] pub visual_effects: Vec, #[serde(skip)] pub lightning_arcs: Vec, #[serde(skip)] terrain_spark_delay: u32, #[serde(skip)] terrain_flare_delay: u32, pub terrain_health: Vec, pub terrain_dead: Vec, pub terrain_settled: Vec, pub terrain_delays: Vec, pub rng: LegacyRng, pub difficulty: u32, pub outcome: Outcome, pub end_timer: Option, #[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, pub sounds: Vec, pub sound_events: Vec, pub mod_sounds: Vec, pub cooldowns: BTreeMap, 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, } #[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 { 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::().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 { 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 = ®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 { 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 = ®istry.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 = ®istry.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 = ®istry.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 = ®istry.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 = ®istry.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 = ®istry.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 = ®istry.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 = ®istry.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 = ®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.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 { let mission = ®istry.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 = ®istry.owners[&event.content]; let mut e = event.event; 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(); } 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, x: i32, y: i32, ) -> Result> { 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 = ®istry.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 { 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::>(),"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::>()}) } } 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 { 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, events: &mut Vec, 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, events: &mut Vec, visuals: &mut Vec, 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![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)); } }