//! 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, 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, 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, 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, } #[derive(Debug, Clone, Serialize, Deserialize)] pub struct Effect { pub x: i32, pub y: i32, pub age: u32, } #[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, 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, 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(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 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: pilot .inventory .get(&ContentId::from("base:super_shield")) .copied() .unwrap_or(0) .min(100) as i32, credits: pilot.credits, inventory: pilot.inventory.clone(), selected: pilot.selected.clone(), vx: 0, vy: 0, }, enemies: vec![], projectiles: vec![], pickups: vec![], effects: vec![], 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, 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, 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.inventory .insert("base:energy".into(), self.player.shield.max(1) as u32); 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, ); } p.selected = self.player.selected.clone(); p.mission = self.mission.clone(); p } 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, 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]), 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; if let Some([x, y]) = input.absolute { self.player.x = x.clamp(5, 282); self.player.y = y.clamp(0, 160); } else { self.player.vx = accelerate(self.player.vx, input.dx, 10); self.player.vy = accelerate(self.player.vy, input.dy, 8); self.player.x = (self.player.x + self.player.vx).clamp(5, 282); self.player.y = (self.player.y + self.player.vy).clamp(0, 160); } 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(); } 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()); } 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) { 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 { let mut specials: Vec<_> = self .player .inventory .keys() .filter(|id| registry.items.get(*id).is_some_and(|i| i.special)) .cloned() .collect(); specials.sort_by_key(|id| (registry.items[id].legacy.unwrap_or(u32::MAX), id.clone())); if !specials.is_empty() { let next = self .player .selected .as_ref() .and_then(|id| specials.iter().position(|s| s == id)) .map_or(0, |i| (i + 1) % specials.len()); self.player.selected = Some(specials[next].clone()); } } 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 { 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; self.scroll += 1; self.think_legacy_terrain(registry); let mission = ®istry.missions[&self.mission]; 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 left = enemy.movement.advance(def.speed); if enemy.movement.done && !def.path.is_empty() { let count = def.path.len(); if enemy.path_index >= count { if def.repeat { enemy.reversed = true; enemy.path_index = count.saturating_sub(1); } else if def.legacy.as_ref().is_some_and(|l| l.fields[21] == 2) && enemy.kami < 2 { enemy.kami += 1; } else { retired.push(enemy.entity); } } 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 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 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..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() { self.rng.next_value(); self.rng.next_value(); } if !def.ground && contains(enemy, self.player.x + 16, self.player.y + 16) { enemy.health -= 16; damage_player( &mut self.player, enemy.width.max(enemy.height) >> 2, self.difficulty, &mut self.rng, &mut self.sounds, &mut self.sound_events, ); if def.legacy.is_some() { self.rng.next_value(); self.rng.next_value(); 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, ); let random_count = match legacy.fields[2] { 2 | 6 => 2 * (enemy.width >> 4) * (enemy.height >> 4), 4 => 3, 5 => 3 * (enemy.width >> 4) * (enemy.height >> 4), _ => 0, }; for _ in 0..random_count { self.rng.next_value(); } } self.player.credits = self.player.credits.saturating_add(def.reward); self.effects.push(Effect { x: enemy.x + enemy.width / 2, y: enemy.y + enemy.height / 2, age: 0, }); events.push(HookEvent { content: enemy.definition.clone(), hook: "on_death", 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, }) } 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); self.enemies.retain(|e| !retired.contains(&e.entity)); 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 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, registry.weapons[&p.weapon].damage, self.difficulty, &mut self.rng, &mut self.sounds, &mut self.sound_events, ); 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; pickup.x = legacy.x - 8 + X[legacy.position]; pickup.y = legacy.y - 8 + Y[legacy.position]; 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 item.credits == 0 && (self.player.inventory.contains_key(&pickup.item) || self.player.inventory.len() < 20) { let amount = self .player .inventory .entry(pickup.item.clone()) .or_default(); *amount = amount .saturating_add(item.initial_count) .min(item.max_count); if item.special && self.player.selected.is_none() { self.player.selected = Some(pickup.item.clone()); } } if item.legacy == Some(15) { self.player.super_shield = 100; } else { 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); self.effects.push(Effect { x: p.x, y: p.y, age: 0, }); } } } } } 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.age += 1; } self.effects.retain(|e| e.age < 20); self.tick += 1; if self.player.shield <= 0 { self.outcome = Outcome::Failed; } else if mission.auto_complete { if mission.legacy_map.is_some() { // ENEMY_Remove starts the original 60-tick departure only // after the final spawn has been visited and all ships left. if self.spawn_cursor == mission.spawns.len() && self.enemies.is_empty() { match self.end_timer { None => self.end_timer = Some(60), Some(0) => self.outcome = Outcome::Complete, Some(n) => self.end_timer = Some(n - 1), } } } else if self.tick >= mission.duration && self.spawn_cursor == mission.spawns.len() && self .enemies .iter() .all(|e| !registry.enemies[&e.definition].boss) { self.outcome = Outcome::Complete; } } 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}), ); } if mission.legacy_map.is_some() && self.tick >= 3 && self.tick % 2 == 1 { // Palette index 254 consumes the original shared RNG, even when no frame is rendered. self.palette_blue = self.rng.next_value() % 3 == 0; } Ok(()) } fn 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 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, state.damage, self.difficulty, &mut self.rng, &mut self.sounds, &mut self.sound_events, ); } } 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 { 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 { damage_player( &mut self.player, state.damage, self.difficulty, &mut self.rng, &mut self.sounds, &mut self.sound_events, ); retired.push(p.entity); } } } 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.tiles[mission.layout[position]].destroyed.is_some() && !self.terrain_dead[position] { self.terrain_health[position] -= damage; } } } 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, }); self.effects.push(Effect { x: x + 16, y: y + 16, age: 0, }); // 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 { self.rng.next_value(); // Ground sparkle horizontal offset. 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 }; // 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 { 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 => !ground, 2 | 4 => ground, _ => true, } }); if let Some(enemy) = enemy { let damage = if lib.target == 4 { 5 } else { weapon.damage }; enemy.health -= damage; if self.difficulty == 0 && matches!(lib.target, 1 | 2) { enemy.health -= damage; } state.hit = true; if matches!(lib.target, 0 | 1 | 3) { self.rng.next_value(); } let entity = enemy.entity; let enemy_id = enemy.definition.clone(); let health = enemy.health; self.event(enemy_id,"on_hit",serde_json::json!({"entity":entity,"projectile":p.entity,"weapon":p.weapon,"damage":damage,"x":p.x,"y":p.y,"health":health})); self.event(p.weapon.clone(),"on_hit",serde_json::json!({"entity":entity,"projectile":p.entity,"damage":damage,"x":p.x,"y":p.y})); } else if matches!(lib.target, 0 | 2 | 4) { let mission = ®istry.missions[&self.mission]; let col = (p.x - 16).div_euclid(32); let row = (p.y + mission.layout.len() as i32 / mission.columns.max(1) as i32 * 32 - 200 - self.scroll) .div_euclid(32); if col >= 0 && col < mission.columns as i32 && row >= 0 { let pos = row as usize * mission.columns as usize + col as usize; if pos < self.terrain_health.len() && mission.tiles[mission.layout[pos]].destroyed.is_some() && !self.terrain_dead[pos] { self.terrain_health[pos] -= weapon.damage; if lib.target != 4 { self.rng.next_value(); } state.movement.done = true; } } } } if state.movement.done { if state.delay { state.delay = false; 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 { for enemy in &mut self.enemies { enemy.health -= weapon.damage; } for (health, dead) in self.terrain_health.iter_mut().zip(&self.terrain_dead) { if !dead { *health -= weapon.damage; } } 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 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)?; 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)?; 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)?; } } } 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)?; } *self = candidate; Ok(()) } 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, *amount, self.difficulty, &mut self.rng, &mut self.sounds, &mut self.sound_events, ); } 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::GiveItem { item, count } => { let id = ContentId::new(item)?; let def = registry.items.get(&id).context("unknown item")?; ensure!( self.player.inventory.contains_key(&id) || self.player.inventory.len() < 20, "inventory capacity exceeded" ); let amount = self.player.inventory.entry(id).or_default(); *amount = amount.saturating_add(*count).min(def.max_count); } Command::ConsumeItem { item, count } => { let amount = self .player .inventory .get_mut(&ContentId::new(item)?) .context("item is not owned")?; ensure!(*amount >= *count, "insufficient item quantity"); *amount -= count; } 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); } Command::Effect { x, y } => { ensure!(self.effects.len() < 4096, "too many effects"); self.effects.push(Effect { x: *x, y: *y, age: 0, }); } 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 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, damage: i32, difficulty: u32, rng: &mut LegacyRng, sounds: &mut Vec, events: &mut Vec, ) { 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) { // 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 { p.super_shield = 0; p.inventory.remove(&super_id); } } else { p.shield = (p.shield - damage).max(0); play_legacy_sound(rng, sounds, events, "FX_HIT"); } } 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)); } }