3597 lines
135 KiB
Rust
3597 lines
135 KiB
Rust
//! SDL-independent fixed-step simulation and checked mod command application.
|
||
use crate::{
|
||
content::{ContentId, Registry, Target, WeaponBehavior},
|
||
modding::{Command, ScriptRuntime},
|
||
persistence::Pilot,
|
||
};
|
||
use anyhow::{Context, Result, ensure};
|
||
use serde::{Deserialize, Serialize};
|
||
use std::collections::BTreeMap;
|
||
|
||
pub const TICK_NUMERATOR: u64 = 3;
|
||
pub const TICK_DENOMINATOR: u64 = 70;
|
||
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
|
||
pub struct LegacyRng {
|
||
pub seed: i32,
|
||
}
|
||
impl LegacyRng {
|
||
pub fn new(seed: i32) -> Self {
|
||
Self { seed }
|
||
}
|
||
pub fn next_value(&mut self) -> i32 {
|
||
self.seed = self.seed.wrapping_mul(1103515245).wrapping_add(12345);
|
||
(self.seed >> 16) & 0x7fff
|
||
}
|
||
}
|
||
#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize)]
|
||
pub struct Input {
|
||
pub dx: i32,
|
||
pub dy: i32,
|
||
pub fire: bool,
|
||
pub special: bool,
|
||
pub cycle: bool,
|
||
pub bomb: bool,
|
||
pub absolute: Option<[i32; 2]>,
|
||
pub demo_bank: Option<i32>,
|
||
}
|
||
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
||
pub struct LineMove {
|
||
pub x: i32,
|
||
pub y: i32,
|
||
pub tx: i32,
|
||
pub ty: i32,
|
||
pub dx: i32,
|
||
pub dy: i32,
|
||
pub ax: i32,
|
||
pub ay: i32,
|
||
pub error: i32,
|
||
pub remaining: i32,
|
||
pub done: bool,
|
||
}
|
||
impl LineMove {
|
||
pub fn new(x: i32, y: i32, tx: i32, ty: i32) -> Self {
|
||
let dx = (tx - x).abs();
|
||
let dy = (ty - y).abs();
|
||
let mut m = Self {
|
||
x,
|
||
y,
|
||
tx,
|
||
ty,
|
||
dx,
|
||
dy,
|
||
ax: if tx < x { -1 } else { 1 },
|
||
ay: if ty < y { -1 } else { 1 },
|
||
error: if dx >= dy { -(dy >> 1) } else { dx >> 1 },
|
||
remaining: dx.max(dy) + 1,
|
||
done: false,
|
||
};
|
||
if dx == 0 && dy == 0 {
|
||
m.done = true;
|
||
}
|
||
m
|
||
}
|
||
pub fn initial(&mut self) {
|
||
if self.remaining == 0 {
|
||
self.done = true;
|
||
return;
|
||
}
|
||
self.point();
|
||
self.remaining -= 1;
|
||
}
|
||
fn point(&mut self) {
|
||
if self.dx >= self.dy {
|
||
self.x += self.ax;
|
||
self.error += self.dy;
|
||
if self.error > 0 {
|
||
self.y += self.ay;
|
||
self.error -= self.dx;
|
||
}
|
||
} else {
|
||
self.y += self.ay;
|
||
self.error += self.dx;
|
||
if self.error > 0 {
|
||
self.x += self.ax;
|
||
self.error -= self.dy;
|
||
}
|
||
}
|
||
}
|
||
pub fn advance_shot(&mut self, speed: i32) {
|
||
// MoveSobj crosses the endpoint before marking done; enemies instead stop at it.
|
||
for _ in 0..speed.max(0) {
|
||
self.remaining -= 1;
|
||
self.point();
|
||
}
|
||
if self.remaining < 1 {
|
||
self.done = true;
|
||
}
|
||
}
|
||
pub fn advance(&mut self, speed: i32) -> i32 {
|
||
if self.done {
|
||
return speed;
|
||
}
|
||
let mut left = speed;
|
||
while left > 0 {
|
||
left -= 1;
|
||
self.remaining -= 1;
|
||
if self.remaining == 0 {
|
||
self.done = true;
|
||
return left;
|
||
}
|
||
if self.dx >= self.dy {
|
||
self.x += self.ax;
|
||
self.error += self.dy;
|
||
if self.error > 0 {
|
||
self.y += self.ay;
|
||
self.error -= self.dx;
|
||
}
|
||
} else {
|
||
self.y += self.ay;
|
||
self.error += self.dx;
|
||
if self.error > 0 {
|
||
self.x += self.ax;
|
||
self.error -= self.dy;
|
||
}
|
||
}
|
||
}
|
||
left
|
||
}
|
||
}
|
||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||
pub struct Player {
|
||
pub x: i32,
|
||
pub y: i32,
|
||
pub px: i32,
|
||
pub py: i32,
|
||
pub bank: i32,
|
||
pub shield: i32,
|
||
pub super_shield: i32,
|
||
#[serde(default)]
|
||
pub super_shield_reserves: Vec<u32>,
|
||
pub credits: u32,
|
||
pub inventory: BTreeMap<ContentId, u32>,
|
||
pub selected: Option<ContentId>,
|
||
pub vx: i32,
|
||
pub vy: i32,
|
||
}
|
||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||
pub struct Enemy {
|
||
pub entity: u64,
|
||
pub definition: ContentId,
|
||
pub x: i32,
|
||
pub y: i32,
|
||
pub px: i32,
|
||
pub py: i32,
|
||
pub sx: i32,
|
||
pub sy: i32,
|
||
pub width: i32,
|
||
pub height: i32,
|
||
pub health: i32,
|
||
pub age: u32,
|
||
pub cooldown: u32,
|
||
pub path_index: usize,
|
||
pub reversed: bool,
|
||
pub movement: LineMove,
|
||
pub frame: u32,
|
||
pub art_frame: u32,
|
||
pub animation_on: bool,
|
||
pub animation_frames: u32,
|
||
pub multi: u32,
|
||
pub frame_timer: i32,
|
||
pub countdown: i32,
|
||
pub shoot_on: bool,
|
||
pub shootagain: i32,
|
||
pub shootcount: i32,
|
||
pub shootflag: i32,
|
||
#[serde(default)]
|
||
pub energy_resistance: i32,
|
||
#[serde(default)]
|
||
pub shoot_disabled: bool,
|
||
pub kami: u32,
|
||
pub vx: i32,
|
||
pub vy: i32,
|
||
}
|
||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||
pub struct LegacyProjectile {
|
||
pub movement: LineMove,
|
||
pub speed: i32,
|
||
pub frame: u32,
|
||
pub art_frame: u32,
|
||
pub delay: bool,
|
||
pub hit: bool,
|
||
pub start: [i32; 2],
|
||
}
|
||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||
pub struct EnemyProjectile {
|
||
pub movement: LineMove,
|
||
pub kind: i16,
|
||
pub asset: u32,
|
||
pub frames: u32,
|
||
pub frame: u32,
|
||
pub art_frame: u32,
|
||
pub speed: i32,
|
||
pub maximum_speed: i32,
|
||
pub damage: i32,
|
||
pub position: usize,
|
||
pub enemy: u64,
|
||
pub gun: usize,
|
||
}
|
||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||
pub struct Projectile {
|
||
pub entity: u64,
|
||
pub weapon: ContentId,
|
||
pub x: i32,
|
||
pub y: i32,
|
||
pub px: i32,
|
||
pub py: i32,
|
||
pub vx: i32,
|
||
pub vy: i32,
|
||
pub hostile: bool,
|
||
pub age: u32,
|
||
pub legacy: Option<LegacyProjectile>,
|
||
pub legacy_enemy: Option<EnemyProjectile>,
|
||
}
|
||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||
pub struct Pickup {
|
||
pub entity: u64,
|
||
pub item: ContentId,
|
||
pub x: i32,
|
||
pub y: i32,
|
||
pub legacy: Option<LegacyPickup>,
|
||
}
|
||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||
pub struct LegacyPickup {
|
||
pub x: i32,
|
||
pub y: i32,
|
||
pub position: usize,
|
||
pub frame: u32,
|
||
pub art_frame: u32,
|
||
pub countdown: Option<u32>,
|
||
#[serde(skip)]
|
||
pub glow_frame: u32,
|
||
#[serde(skip)]
|
||
pub previous_art_position: Option<[i32; 2]>,
|
||
}
|
||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||
pub struct Effect {
|
||
pub x: i32,
|
||
pub y: i32,
|
||
pub px: i32,
|
||
pub py: i32,
|
||
pub age: u32,
|
||
pub kind: EffectKind,
|
||
pub ground: bool,
|
||
}
|
||
/// Secondary native animations are presentation only. Keeping their queue out
|
||
/// of saved/replayed gameplay state also prevents render rates affecting RNG.
|
||
#[derive(Debug, Clone)]
|
||
pub struct VisualEffect {
|
||
pub x: i32,
|
||
pub y: i32,
|
||
pub px: i32,
|
||
pub py: i32,
|
||
pub age: u32,
|
||
pub kind: VisualEffectKind,
|
||
pub anchor: Option<u64>,
|
||
pub offset: [i32; 2],
|
||
}
|
||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||
pub enum VisualEffectKind {
|
||
LaserImpact,
|
||
SmallAir,
|
||
SmallGround,
|
||
GroundBlueSpark,
|
||
GroundOrangeSpark,
|
||
GroundSparkle,
|
||
GroundFlare,
|
||
SmokeDown,
|
||
SmokeTrail,
|
||
BlueSpark,
|
||
OrangeSpark,
|
||
EnergyGrab,
|
||
Muzzle,
|
||
Casing,
|
||
Shield,
|
||
}
|
||
impl VisualEffectKind {
|
||
pub fn artwork(self) -> (u32, u32) {
|
||
match self {
|
||
Self::LaserImpact => (0x10229, 4),
|
||
Self::SmallAir => (0x101fc, 14),
|
||
Self::SmallGround => (0x1027d, 35),
|
||
Self::GroundBlueSpark => (0x1019c, 9),
|
||
Self::GroundOrangeSpark => (0x101a5, 9),
|
||
Self::GroundSparkle => (0x1026c, 17),
|
||
Self::GroundFlare => (0x10252, 26),
|
||
Self::SmokeDown => (0x1020e, 5),
|
||
Self::SmokeTrail => (0x1022d, 4),
|
||
Self::BlueSpark => (0x1019c, 9),
|
||
Self::OrangeSpark => (0x101a5, 9),
|
||
Self::EnergyGrab => (0x1018e, 14),
|
||
Self::Muzzle | Self::Casing => (0x101c7, 4),
|
||
Self::Shield => (0x10249, 4),
|
||
}
|
||
}
|
||
/// ANIMS_DisplayGround, ANIMS_DisplaySky, ANIMS_DisplayHigh order.
|
||
pub fn layer(self) -> u8 {
|
||
match self {
|
||
Self::Casing
|
||
| Self::SmallGround
|
||
| Self::GroundBlueSpark
|
||
| Self::GroundOrangeSpark
|
||
| Self::GroundSparkle
|
||
| Self::GroundFlare => 0,
|
||
Self::SmokeDown | Self::BlueSpark | Self::OrangeSpark | Self::Muzzle => 1,
|
||
Self::LaserImpact
|
||
| Self::SmallAir
|
||
| Self::SmokeTrail
|
||
| Self::EnergyGrab
|
||
| Self::Shield => 2,
|
||
}
|
||
}
|
||
pub fn lighting_mask(self) -> bool {
|
||
matches!(self, Self::SmokeDown | Self::SmokeTrail | Self::Shield)
|
||
}
|
||
pub fn top_left(self) -> bool {
|
||
matches!(self, Self::GroundBlueSpark | Self::GroundOrangeSpark)
|
||
}
|
||
}
|
||
impl VisualEffect {
|
||
pub fn new(kind: VisualEffectKind, x: i32, y: i32) -> Self {
|
||
Self {
|
||
x,
|
||
y,
|
||
px: x,
|
||
py: y,
|
||
age: 0,
|
||
kind,
|
||
anchor: None,
|
||
offset: [x, y],
|
||
}
|
||
}
|
||
fn attached(mut self, entity: u64) -> Self {
|
||
self.anchor = Some(entity);
|
||
self
|
||
}
|
||
}
|
||
fn visual_effect(existing: usize, effects: &mut Vec<VisualEffect>, effect: VisualEffect) {
|
||
if effects.len() + existing < 100 {
|
||
effects.push(effect);
|
||
}
|
||
}
|
||
/// Presentation-only lightning, with a lifetime measured in simulation ticks.
|
||
/// The queue is excluded from saved gameplay state and the deterministic digest.
|
||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||
pub struct LightningArc {
|
||
pub x: i32,
|
||
pub y: i32,
|
||
pub to_x: i32,
|
||
pub to_y: i32,
|
||
pub ticks: u32,
|
||
pub age: u32,
|
||
/// Cosmetic variation between discharges, without advancing gameplay RNG.
|
||
pub seed: u32,
|
||
pub anchor: Option<LightningAnchor>,
|
||
pub to_anchor: Option<LightningAnchor>,
|
||
}
|
||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||
pub struct LightningAnchor {
|
||
pub entity: u64,
|
||
/// Endpoint displacement from the entity's top-left origin at creation.
|
||
pub offset: [i32; 2],
|
||
}
|
||
pub const MAX_LIGHTNING_ARCS: usize = 256;
|
||
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
|
||
pub enum EffectKind {
|
||
GroundBlast,
|
||
SmallGround,
|
||
LargeAir,
|
||
MediumAir,
|
||
SmallAir,
|
||
AirBlast,
|
||
Energy,
|
||
Person,
|
||
Platoon,
|
||
Flare,
|
||
Sparkle,
|
||
}
|
||
impl EffectKind {
|
||
pub fn artwork(self) -> (u32, u32) {
|
||
match self {
|
||
Self::GroundBlast => (0x10164, 42),
|
||
Self::SmallGround => (0x1027d, 35),
|
||
Self::LargeAir => (0x101e5, 12),
|
||
Self::MediumAir => (0x101d8, 13),
|
||
Self::SmallAir => (0x101fc, 14),
|
||
Self::AirBlast => (0x102a0, 16),
|
||
Self::Energy => (0x101ae, 12),
|
||
Self::Person => (0x101f5, 7),
|
||
Self::Platoon => (0x10213, 10),
|
||
Self::Flare => (0x10252, 26),
|
||
Self::Sparkle => (0x1026c, 17),
|
||
}
|
||
}
|
||
}
|
||
impl Effect {
|
||
fn new(kind: EffectKind, x: i32, y: i32) -> Self {
|
||
Self {
|
||
x,
|
||
y,
|
||
px: x,
|
||
py: y,
|
||
age: 0,
|
||
kind,
|
||
ground: matches!(
|
||
kind,
|
||
EffectKind::GroundBlast
|
||
| EffectKind::SmallGround
|
||
| EffectKind::MediumAir
|
||
| EffectKind::Person
|
||
| EffectKind::Platoon
|
||
| EffectKind::Flare
|
||
| EffectKind::Sparkle
|
||
),
|
||
}
|
||
}
|
||
fn airborne(mut self) -> Self {
|
||
self.ground = false;
|
||
self
|
||
}
|
||
}
|
||
fn original_effect(existing: usize, effects: &mut Vec<Effect>, effect: Effect) {
|
||
if effects.len() + existing < 100 {
|
||
effects.push(effect);
|
||
}
|
||
}
|
||
fn death_effects(
|
||
visual_count: usize,
|
||
effects: &mut Vec<Effect>,
|
||
rng: &mut LegacyRng,
|
||
enemy: &Enemy,
|
||
explosion: i32,
|
||
) {
|
||
use EffectKind::*;
|
||
let cx = enemy.x + enemy.width / 2;
|
||
let cy = enemy.y + enemy.height / 2;
|
||
let main = match explosion {
|
||
0 => MediumAir,
|
||
1 | 2 | 6 => LargeAir,
|
||
3 => SmallGround,
|
||
4 | 5 => GroundBlast,
|
||
7 => Person,
|
||
8 => Energy,
|
||
9 => Platoon,
|
||
_ => AirBlast,
|
||
};
|
||
if explosion == 4 {
|
||
let x = enemy.x + rng.next_value() % enemy.width.max(1);
|
||
let y = enemy.y + rng.next_value() % enemy.height.max(1);
|
||
let kind = if rng.next_value() % 2 == 0 {
|
||
Sparkle
|
||
} else {
|
||
Flare
|
||
};
|
||
original_effect(visual_count, effects, Effect::new(kind, x, y));
|
||
}
|
||
original_effect(visual_count, effects, Effect::new(main, cx, cy));
|
||
if matches!(explosion, 2 | 5 | 6) {
|
||
let area = (enemy.width >> 4) * (enemy.height >> 4);
|
||
for n in 0..area {
|
||
let x = enemy.x + rng.next_value() % enemy.width.max(1);
|
||
let y = enemy.y + rng.next_value() % enemy.height.max(1);
|
||
if explosion == 5 {
|
||
let kind = if rng.next_value() % 2 == 0 {
|
||
Flare
|
||
} else {
|
||
Sparkle
|
||
};
|
||
original_effect(visual_count, effects, Effect::new(kind, x, y));
|
||
original_effect(visual_count, effects, Effect::new(SmallGround, x, y));
|
||
} else if explosion == 6 {
|
||
original_effect(visual_count, effects, Effect::new(Flare, x, y).airborne());
|
||
original_effect(
|
||
visual_count,
|
||
effects,
|
||
Effect::new(if n & 1 != 0 { LargeAir } else { AirBlast }, x, y),
|
||
);
|
||
} else {
|
||
let effect = if n & 1 != 0 {
|
||
Effect::new(MediumAir, x, y)
|
||
} else {
|
||
Effect::new(AirBlast, x, y).airborne()
|
||
};
|
||
original_effect(visual_count, effects, effect);
|
||
}
|
||
}
|
||
}
|
||
}
|
||
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
|
||
pub enum Outcome {
|
||
Playing,
|
||
Complete,
|
||
Failed,
|
||
}
|
||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||
pub struct SoundEvent {
|
||
pub asset: u32,
|
||
pub pan: i32,
|
||
pub pitch: i32,
|
||
pub volume: i32,
|
||
pub priority: i32,
|
||
}
|
||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||
pub struct TerrainDelay {
|
||
pub position: usize,
|
||
pub frames: i32,
|
||
}
|
||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||
pub struct World {
|
||
pub tick: u64,
|
||
pub mission: ContentId,
|
||
pub player: Player,
|
||
pub enemies: Vec<Enemy>,
|
||
pub projectiles: Vec<Projectile>,
|
||
pub pickups: Vec<Pickup>,
|
||
pub effects: Vec<Effect>,
|
||
#[serde(skip)]
|
||
pub visual_effects: Vec<VisualEffect>,
|
||
#[serde(skip)]
|
||
pub lightning_arcs: Vec<LightningArc>,
|
||
#[serde(skip)]
|
||
terrain_spark_delay: u32,
|
||
#[serde(skip)]
|
||
terrain_flare_delay: u32,
|
||
pub terrain_health: Vec<i32>,
|
||
pub terrain_dead: Vec<bool>,
|
||
pub terrain_settled: Vec<bool>,
|
||
pub terrain_delays: Vec<TerrainDelay>,
|
||
pub rng: LegacyRng,
|
||
pub difficulty: u32,
|
||
pub outcome: Outcome,
|
||
pub end_timer: Option<u32>,
|
||
#[serde(default)]
|
||
pub legacy_transition: bool,
|
||
#[serde(default = "visible_player")]
|
||
pub draw_player: bool,
|
||
#[serde(default)]
|
||
pub control_paused: bool,
|
||
#[serde(default)]
|
||
pub departure_delta: [i32; 2],
|
||
pub scroll: i32,
|
||
pub objectives: BTreeMap<String, i32>,
|
||
pub sounds: Vec<u32>,
|
||
pub sound_events: Vec<SoundEvent>,
|
||
pub mod_sounds: Vec<ContentId>,
|
||
pub cooldowns: BTreeMap<ContentId, u32>,
|
||
pub next_entity: u64,
|
||
pub spawn_cursor: usize,
|
||
pub cycle_held: bool,
|
||
pub bomb_held: bool,
|
||
pub palette_blue: bool,
|
||
pub special_held: bool,
|
||
pub player_flash: bool,
|
||
#[serde(default = "initial_shield_latch")]
|
||
pub previous_shield: i32,
|
||
#[serde(default)]
|
||
pub shield_recharge_ticks: u32,
|
||
/// Charge retained from a save whose equipment capacity changed. It does
|
||
/// not increase the original 100-point combat shield.
|
||
#[serde(default, skip_serializing_if = "is_zero")]
|
||
pub energy_overflow: u32,
|
||
#[serde(default, skip_serializing_if = "is_zero")]
|
||
pub super_shield_overflow: u32,
|
||
#[serde(skip)]
|
||
pub events: Vec<HookEvent>,
|
||
}
|
||
#[derive(Debug, Clone)]
|
||
pub struct HookEvent {
|
||
pub content: ContentId,
|
||
pub hook: &'static str,
|
||
pub event: serde_json::Value,
|
||
}
|
||
impl World {
|
||
pub fn new(mission: &ContentId, pilot: &Pilot, registry: &Registry) -> Result<Self> {
|
||
let m = registry.missions.get(mission).context("unknown mission")?;
|
||
let energy = pilot
|
||
.inventory
|
||
.get(&ContentId::from("base:energy"))
|
||
.copied()
|
||
.unwrap_or(100) as i32;
|
||
let super_shield = pilot
|
||
.inventory
|
||
.get(&ContentId::from("base:super_shield"))
|
||
.copied()
|
||
.unwrap_or(0);
|
||
let wave = mission
|
||
.0
|
||
.rsplit('_')
|
||
.next()
|
||
.and_then(|s| s.parse::<i32>().ok())
|
||
.unwrap_or(1)
|
||
- 1;
|
||
let mut out = Self {
|
||
tick: 0,
|
||
mission: mission.clone(),
|
||
player: Player {
|
||
x: 144,
|
||
y: 160,
|
||
px: 144,
|
||
py: 160,
|
||
bank: 3,
|
||
shield: energy.clamp(1, 100),
|
||
super_shield: super_shield.min(100) as i32,
|
||
super_shield_reserves: pilot.super_shield_reserves.clone(),
|
||
credits: pilot.credits,
|
||
inventory: pilot.inventory.clone(),
|
||
selected: pilot.selected.clone(),
|
||
vx: 0,
|
||
vy: 0,
|
||
},
|
||
enemies: vec![],
|
||
projectiles: vec![],
|
||
pickups: vec![],
|
||
effects: vec![],
|
||
visual_effects: vec![],
|
||
lightning_arcs: vec![],
|
||
terrain_spark_delay: 0,
|
||
terrain_flare_delay: 0,
|
||
terrain_health: m.layout.iter().map(|i| m.tiles[*i].health).collect(),
|
||
terrain_dead: vec![false; m.layout.len()],
|
||
terrain_settled: vec![false; m.layout.len()],
|
||
terrain_delays: vec![],
|
||
rng: LegacyRng::new(wave << 10),
|
||
difficulty: pilot.difficulty,
|
||
outcome: Outcome::Playing,
|
||
end_timer: None,
|
||
legacy_transition: m.legacy_map.is_some(),
|
||
draw_player: true,
|
||
control_paused: false,
|
||
departure_delta: [0, 0],
|
||
scroll: 0,
|
||
objectives: BTreeMap::new(),
|
||
sounds: vec![],
|
||
sound_events: vec![],
|
||
mod_sounds: vec![],
|
||
cooldowns: BTreeMap::new(),
|
||
next_entity: 1,
|
||
spawn_cursor: 0,
|
||
cycle_held: false,
|
||
bomb_held: false,
|
||
palette_blue: false,
|
||
special_held: false,
|
||
player_flash: false,
|
||
previous_shield: initial_shield_latch(),
|
||
shield_recharge_ticks: pilot.shield_recharge_ticks,
|
||
energy_overflow: (energy.max(0) as u32).saturating_sub(100),
|
||
super_shield_overflow: super_shield.saturating_sub(100),
|
||
events: vec![],
|
||
};
|
||
out.event(
|
||
mission.clone(),
|
||
"on_mission_start",
|
||
serde_json::json!({"tick":0}),
|
||
);
|
||
Ok(out)
|
||
}
|
||
pub fn pilot(&self, prior: &Pilot) -> Pilot {
|
||
let mut p = prior.clone();
|
||
p.credits = self.player.credits;
|
||
p.inventory = self.player.inventory.clone();
|
||
p.super_shield_reserves = self.player.super_shield_reserves.clone();
|
||
p.shield_recharge_ticks = self.shield_recharge_ticks;
|
||
p.inventory.insert(
|
||
"base:energy".into(),
|
||
(self.player.shield.max(0) as u32)
|
||
.saturating_add(self.energy_overflow)
|
||
.max(1),
|
||
);
|
||
if p.inventory
|
||
.contains_key(&ContentId::from("base:super_shield"))
|
||
{
|
||
p.inventory.insert(
|
||
"base:super_shield".into(),
|
||
(self.player.super_shield.max(0) as u32).saturating_add(self.super_shield_overflow),
|
||
);
|
||
}
|
||
p.selected = self.player.selected.clone();
|
||
p.mission = self.mission.clone();
|
||
p
|
||
}
|
||
fn sync_retained_inventory(&mut self) {
|
||
if self.energy_overflow > 0 {
|
||
if let Some(charge) = self
|
||
.player
|
||
.inventory
|
||
.get_mut(&ContentId::from("base:energy"))
|
||
{
|
||
*charge = (self.player.shield.max(0) as u32).saturating_add(self.energy_overflow);
|
||
}
|
||
}
|
||
if self.super_shield_overflow > 0 {
|
||
if let Some(charge) = self
|
||
.player
|
||
.inventory
|
||
.get_mut(&ContentId::from("base:super_shield"))
|
||
{
|
||
*charge = (self.player.super_shield.max(0) as u32)
|
||
.saturating_add(self.super_shield_overflow);
|
||
} else {
|
||
self.super_shield_overflow = 0;
|
||
}
|
||
}
|
||
}
|
||
fn entity(&mut self) -> u64 {
|
||
let n = self.next_entity;
|
||
self.next_entity += 1;
|
||
n
|
||
}
|
||
fn event(&mut self, content: ContentId, hook: &'static str, event: serde_json::Value) {
|
||
self.events.push(HookEvent {
|
||
content,
|
||
hook,
|
||
event,
|
||
});
|
||
}
|
||
pub fn spawn(&mut self, id: &ContentId, x: i32, y: i32, registry: &Registry) -> Result<u64> {
|
||
let e = registry.enemies.get(id).context("unknown enemy")?;
|
||
ensure!(self.enemies.len() < 4096, "enemy capacity exceeded");
|
||
let entity = self.entity();
|
||
let (w, h) = e
|
||
.art
|
||
.as_ref()
|
||
.map(|a| (a.logical_width as i32, a.logical_height as i32))
|
||
.unwrap_or((32, 32));
|
||
let f = e.legacy.as_ref().map(|l| &l.fields);
|
||
let x = match f.map(|f| f[21]) {
|
||
Some(4) => x + w,
|
||
Some(5) => x - w,
|
||
_ => x,
|
||
};
|
||
let sx = x;
|
||
let sy = if f.is_some() { 100 - h / 2 } else { y };
|
||
let first = e.path.first().copied().unwrap_or([0, 300]);
|
||
let mut movement = LineMove::new(x, y, sx + first[0], sy + first[1]);
|
||
if f.is_some() && !e.ground {
|
||
movement.initial();
|
||
}
|
||
let health = if e.boss && self.difficulty <= 1 {
|
||
e.health - (e.health >> 1)
|
||
} else {
|
||
e.health
|
||
};
|
||
self.enemies.push(Enemy {
|
||
entity,
|
||
definition: id.clone(),
|
||
x,
|
||
y,
|
||
px: x,
|
||
py: y,
|
||
sx,
|
||
sy,
|
||
width: w,
|
||
height: h,
|
||
health,
|
||
age: 0,
|
||
cooldown: 0,
|
||
path_index: 1,
|
||
reversed: false,
|
||
movement,
|
||
frame: 0,
|
||
art_frame: 0,
|
||
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 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<usize> {
|
||
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<u64>,
|
||
x: i32,
|
||
y: i32,
|
||
) -> Result<Option<LightningAnchor>> {
|
||
let Some(entity) = entity else {
|
||
return Ok(None);
|
||
};
|
||
let [origin_x, origin_y] = if entity == 0 {
|
||
[self.player.x, self.player.y]
|
||
} else {
|
||
let enemy = self
|
||
.enemies
|
||
.iter()
|
||
.find(|enemy| enemy.entity == entity)
|
||
.context("arc anchor does not exist")?;
|
||
[enemy.x, enemy.y]
|
||
};
|
||
Ok(Some(LightningAnchor {
|
||
entity,
|
||
offset: [
|
||
x.checked_sub(origin_x)
|
||
.context("arc anchor offset overflow")?,
|
||
y.checked_sub(origin_y)
|
||
.context("arc anchor offset overflow")?,
|
||
],
|
||
}))
|
||
}
|
||
fn update_lightning_arcs(&mut self) {
|
||
let player = [self.player.x, self.player.y];
|
||
let enemies = &self.enemies;
|
||
for arc in &mut self.lightning_arcs {
|
||
for (anchor, x, y) in [
|
||
(&mut arc.anchor, &mut arc.x, &mut arc.y),
|
||
(&mut arc.to_anchor, &mut arc.to_x, &mut arc.to_y),
|
||
] {
|
||
let Some(attachment) = *anchor else {
|
||
continue;
|
||
};
|
||
let origin = if attachment.entity == 0 {
|
||
Some(player)
|
||
} else {
|
||
enemies
|
||
.iter()
|
||
.find(|enemy| enemy.entity == attachment.entity)
|
||
.map(|enemy| [enemy.x, enemy.y])
|
||
};
|
||
if let Some([origin_x, origin_y]) = origin {
|
||
// An offscreen anchor cannot expand the cosmetic API's
|
||
// coordinate bounds or make movement fail gameplay.
|
||
*x = origin_x
|
||
.saturating_add(attachment.offset[0])
|
||
.clamp(-32768, 32768);
|
||
*y = origin_y
|
||
.saturating_add(attachment.offset[1])
|
||
.clamp(-32768, 32768);
|
||
} else {
|
||
// Retain the last visible position when its ship disappears.
|
||
*anchor = None;
|
||
}
|
||
}
|
||
}
|
||
}
|
||
fn command(&mut self, c: &Command, registry: &Registry) -> Result<()> {
|
||
match c {
|
||
Command::Spawn { enemy, x, y } => {
|
||
ensure!(
|
||
x.unsigned_abs() <= 32768 && y.unsigned_abs() <= 32768,
|
||
"spawn position exceeds world bounds"
|
||
);
|
||
self.spawn(&ContentId::new(enemy)?, *x, *y, registry)?;
|
||
}
|
||
Command::Projectile {
|
||
weapon,
|
||
x,
|
||
y,
|
||
vx,
|
||
vy,
|
||
hostile,
|
||
} => {
|
||
let id = ContentId::new(weapon)?;
|
||
ensure!(
|
||
registry.weapons.contains_key(&id)
|
||
&& vx.unsigned_abs() <= 128
|
||
&& vy.unsigned_abs() <= 128
|
||
&& x.unsigned_abs() <= 32768
|
||
&& y.unsigned_abs() <= 32768,
|
||
"invalid projectile command"
|
||
);
|
||
self.projectile(&id, *x, *y, *vx, *vy, *hostile)?;
|
||
}
|
||
Command::Damage { entity, amount } => {
|
||
ensure!((0..=100000).contains(amount), "invalid damage");
|
||
if *entity == 0 {
|
||
damage_player(
|
||
&mut self.player,
|
||
self.energy_overflow,
|
||
&mut self.super_shield_overflow,
|
||
*amount,
|
||
self.difficulty,
|
||
&mut self.rng,
|
||
&mut self.sounds,
|
||
&mut self.sound_events,
|
||
&mut self.visual_effects,
|
||
self.effects.len(),
|
||
self.legacy_transition && self.end_timer.is_some(),
|
||
);
|
||
} else {
|
||
let e = self
|
||
.enemies
|
||
.iter_mut()
|
||
.find(|e| e.entity == *entity)
|
||
.context("damage target does not exist")?;
|
||
e.health -= amount;
|
||
let definition = e.definition.clone();
|
||
let health = e.health;
|
||
self.event(definition,"on_hit",serde_json::json!({"entity":entity,"damage":amount,"health":health,"scripted":true,"tick":self.tick}));
|
||
}
|
||
}
|
||
Command::Despawn { entity } => {
|
||
ensure!(
|
||
self.enemies.iter().any(|e| e.entity == *entity)
|
||
|| self.projectiles.iter().any(|e| e.entity == *entity),
|
||
"despawn target does not exist"
|
||
);
|
||
self.enemies.retain(|e| e.entity != *entity);
|
||
self.projectiles.retain(|e| e.entity != *entity);
|
||
}
|
||
Command::Move { entity, x, y } => {
|
||
ensure!(
|
||
x.unsigned_abs() <= 32768 && y.unsigned_abs() <= 32768,
|
||
"invalid position"
|
||
);
|
||
let e = self
|
||
.enemies
|
||
.iter_mut()
|
||
.find(|e| e.entity == *entity)
|
||
.context("move target does not exist")?;
|
||
let dx = *x - e.x;
|
||
let dy = *y - e.y;
|
||
e.x = *x;
|
||
e.y = *y;
|
||
e.sx += dx;
|
||
e.sy += dy;
|
||
e.movement = LineMove::new(*x, *y, e.movement.tx + dx, e.movement.ty + dy);
|
||
}
|
||
Command::Velocity { entity, vx, vy } => {
|
||
ensure!(
|
||
vx.unsigned_abs() <= 128 && vy.unsigned_abs() <= 128,
|
||
"invalid velocity"
|
||
);
|
||
let e = self
|
||
.enemies
|
||
.iter_mut()
|
||
.find(|e| e.entity == *entity)
|
||
.context("velocity target does not exist")?;
|
||
e.vx = *vx;
|
||
e.vy = *vy;
|
||
}
|
||
Command::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 } => {
|
||
ensure!(self.effects.len() < 4096, "too many effects");
|
||
self.effects.push(Effect::new(EffectKind::AirBlast, *x, *y));
|
||
}
|
||
Command::Arc {
|
||
x,
|
||
y,
|
||
to_x,
|
||
to_y,
|
||
ticks,
|
||
anchor,
|
||
to_anchor,
|
||
} => {
|
||
ensure!(
|
||
[x, y, to_x, to_y].iter().all(|n| n.unsigned_abs() <= 32768),
|
||
"arc position exceeds world bounds"
|
||
);
|
||
ensure!((1..=12).contains(ticks), "arc lifetime must be 1–12 ticks");
|
||
ensure!(
|
||
self.lightning_arcs.len() < MAX_LIGHTNING_ARCS,
|
||
"too many lightning arcs"
|
||
);
|
||
let anchor = self.lightning_anchor(*anchor, *x, *y)?;
|
||
let to_anchor = self.lightning_anchor(*to_anchor, *to_x, *to_y)?;
|
||
self.lightning_arcs.push(LightningArc {
|
||
x: *x,
|
||
y: *y,
|
||
to_x: *to_x,
|
||
to_y: *to_y,
|
||
ticks: *ticks,
|
||
age: 0,
|
||
seed: (self.tick as u32).wrapping_mul(0x1656_67b1)
|
||
^ ((self.tick >> 32) as u32)
|
||
^ (self.lightning_arcs.len() as u32).wrapping_mul(0x9e37_79b9),
|
||
anchor,
|
||
to_anchor,
|
||
});
|
||
}
|
||
Command::Sound { asset } => {
|
||
self.sounds.push(*asset);
|
||
self.sound_events.push(SoundEvent {
|
||
asset: *asset,
|
||
pan: 127,
|
||
pitch: 128,
|
||
volume: 127,
|
||
priority: 1,
|
||
});
|
||
}
|
||
Command::Objective { key, value } => {
|
||
ensure!(
|
||
key.len() <= 128
|
||
&& (self.objectives.contains_key(key) || self.objectives.len() < 1024),
|
||
"invalid objective key"
|
||
);
|
||
self.objectives.insert(key.clone(), *value);
|
||
}
|
||
Command::PlaySound { sound } => {
|
||
let id = ContentId::new(sound)?;
|
||
ensure!(
|
||
registry.sounds.contains_key(&id),
|
||
"sound {id} is unavailable"
|
||
);
|
||
ensure!(self.mod_sounds.len() < 1024, "too many sounds in one tick");
|
||
self.mod_sounds.push(id);
|
||
}
|
||
Command::Complete => {
|
||
if self.outcome == Outcome::Playing {
|
||
self.outcome = Outcome::Complete;
|
||
self.event(
|
||
self.mission.clone(),
|
||
"on_mission_end",
|
||
serde_json::json!({"success":true,"tick":self.tick}),
|
||
);
|
||
}
|
||
}
|
||
Command::Fail => {
|
||
if self.outcome == Outcome::Playing {
|
||
self.outcome = Outcome::Failed;
|
||
self.event(
|
||
self.mission.clone(),
|
||
"on_mission_end",
|
||
serde_json::json!({"success":false,"tick":self.tick}),
|
||
);
|
||
}
|
||
}
|
||
}
|
||
Ok(())
|
||
}
|
||
pub fn digest(&self) -> String {
|
||
use sha2::{Digest, Sha256};
|
||
format!(
|
||
"{:x}",
|
||
Sha256::digest(serde_json::to_vec(self).expect("world serialization"))
|
||
)
|
||
}
|
||
pub fn projectile_asset(p: &Projectile, registry: &Registry) -> Option<u32> {
|
||
p.legacy_enemy.as_ref().map(|s| s.asset).or_else(|| {
|
||
registry.weapons[&p.weapon]
|
||
.art
|
||
.as_ref()
|
||
.and_then(|a| a.asset)
|
||
})
|
||
}
|
||
pub fn reference_state(&self, registry: &Registry) -> serde_json::Value {
|
||
serde_json::json!({"tick":self.tick,"rng":self.rng.seed,"player":[self.player.x,self.player.y,self.player.bank,self.player.shield,self.player.super_shield,self.player.credits],"enemies":self.enemies.iter().map(|e|serde_json::json!([registry.enemies[&e.definition].art.as_ref().and_then(|a|a.asset).unwrap_or(0),e.x,e.y,e.health,e.frame,e.movement.x,e.movement.y])).collect::<Vec<_>>(),"projectiles":self.projectiles.iter().filter(|p|p.hostile).chain(self.projectiles.iter().filter(|p|!p.hostile)).map(|p|serde_json::json!([Self::projectile_asset(p,registry).map(i64::from).unwrap_or(-1),p.x,p.y,p.hostile as u32])).collect::<Vec<_>>()})
|
||
}
|
||
}
|
||
fn initial_shield_latch() -> i32 {
|
||
-1
|
||
}
|
||
fn visible_player() -> bool {
|
||
true
|
||
}
|
||
fn bank_player(player: &mut Player, old_x: i32) {
|
||
let delta = ((player.x - old_x).abs() >> 2).min(3);
|
||
if player.x < old_x {
|
||
if player.bank < 3 + delta {
|
||
player.bank += 1;
|
||
}
|
||
} else if player.x > old_x {
|
||
if player.bank > 3 - delta {
|
||
player.bank -= 1;
|
||
}
|
||
} else {
|
||
player.bank += (3 - player.bank).signum();
|
||
}
|
||
}
|
||
fn next_special(player: &Player, registry: &Registry) -> Option<ContentId> {
|
||
let mut specials: Vec<_> = player
|
||
.inventory
|
||
.iter()
|
||
.filter_map(|(id, count)| {
|
||
registry
|
||
.items
|
||
.get(id)
|
||
.filter(|item| *count > 0 && item.special)
|
||
.map(|item| ((item.legacy.unwrap_or(u32::MAX), id.clone()), id.clone()))
|
||
})
|
||
.collect();
|
||
specials.sort_by(|a, b| a.0.cmp(&b.0));
|
||
let after = player.selected.as_ref().map(|id| {
|
||
(
|
||
registry
|
||
.items
|
||
.get(id)
|
||
.and_then(|item| item.legacy)
|
||
.unwrap_or(u32::MAX),
|
||
id.clone(),
|
||
)
|
||
});
|
||
specials
|
||
.iter()
|
||
.find(|(key, _)| after.as_ref().is_none_or(|after| key > after))
|
||
.or_else(|| specials.first())
|
||
.map(|(_, id)| id.clone())
|
||
}
|
||
fn update_legacy_animation(enemy: &mut Enemy, lib: &crate::assets::LegacyEnemy) {
|
||
let f = &lib.fields;
|
||
enemy.art_frame = enemy.frame;
|
||
if f[7] > 1 {
|
||
if enemy.frame_timer < 1 {
|
||
enemy.frame_timer = f[6];
|
||
if enemy.animation_on {
|
||
enemy.frame += 1;
|
||
if enemy.frame >= enemy.animation_frames {
|
||
enemy.frame = enemy.frame.saturating_sub(f[9].max(0) as u32);
|
||
match f[10] {
|
||
1 => {
|
||
enemy.animation_on = false;
|
||
enemy.shoot_on = true;
|
||
}
|
||
2 => match enemy.multi {
|
||
1 => {
|
||
enemy.animation_frames = f[7].max(1) as u32;
|
||
enemy.multi = 2;
|
||
}
|
||
2 => enemy.shoot_on = true,
|
||
_ => {}
|
||
},
|
||
_ => {}
|
||
}
|
||
}
|
||
}
|
||
} else {
|
||
enemy.frame_timer -= 1;
|
||
}
|
||
if enemy.countdown < 1 {
|
||
match f[10] {
|
||
0 => enemy.shoot_on = true,
|
||
1 => enemy.animation_on = true,
|
||
2 if enemy.multi == 0 => enemy.multi = 1,
|
||
_ => {}
|
||
}
|
||
} else {
|
||
enemy.countdown -= f[18];
|
||
}
|
||
} else if enemy.countdown < 1 {
|
||
enemy.countdown = -1;
|
||
enemy.shoot_on = true;
|
||
} else {
|
||
enemy.countdown -= f[18];
|
||
}
|
||
}
|
||
fn visible(e: &Enemy) -> bool {
|
||
e.y + e.height > 0 && e.y < 200 && e.x + e.width > 0 && e.x < 320
|
||
}
|
||
fn play_legacy_sound(
|
||
rng: &mut LegacyRng,
|
||
sounds: &mut Vec<u32>,
|
||
events: &mut Vec<SoundEvent>,
|
||
name: &str,
|
||
) {
|
||
if let Some(sound) = crate::legacy_tables::sounds()
|
||
.into_iter()
|
||
.find(|s| s.name == name)
|
||
{
|
||
let pitch = sound.pitch
|
||
+ if sound.random {
|
||
rng.next_value() % 40 - 20
|
||
} else {
|
||
0
|
||
};
|
||
sounds.push(sound.asset);
|
||
events.push(SoundEvent {
|
||
asset: sound.asset,
|
||
pan: 127,
|
||
pitch,
|
||
volume: sound.volume,
|
||
priority: sound.priority,
|
||
});
|
||
}
|
||
}
|
||
fn position_sound(events: &mut [SoundEvent], x: i32, y: i32, player_x: i32, player_y: i32) {
|
||
if let Some(event) = events.last_mut() {
|
||
let dx = x - player_x;
|
||
let dy = y - player_y;
|
||
event.pan = (dx + 127).clamp(1, 255);
|
||
let dx = dx.abs();
|
||
let dy = dy.abs();
|
||
let distance = dx + dy - dx.min(dy) / 2;
|
||
let volume = if distance < 40 {
|
||
127
|
||
} else if distance > 500 {
|
||
1
|
||
} else {
|
||
127 - (distance - 40) * 127 / 460
|
||
};
|
||
event.volume = event.volume * volume / 127;
|
||
}
|
||
}
|
||
fn accelerate(v: i32, direction: i32, cap: i32) -> i32 {
|
||
match direction.signum() {
|
||
-1 => (if v >= 0 { -1 } else { v } - 1).max(-cap),
|
||
1 => (if v <= 0 { 1 } else { v } + 1).min(cap),
|
||
_ => v / 2,
|
||
}
|
||
}
|
||
fn damage_player(
|
||
p: &mut Player,
|
||
energy_overflow: u32,
|
||
super_shield_overflow: &mut u32,
|
||
damage: i32,
|
||
difficulty: u32,
|
||
rng: &mut LegacyRng,
|
||
sounds: &mut Vec<u32>,
|
||
events: &mut Vec<SoundEvent>,
|
||
visuals: &mut Vec<VisualEffect>,
|
||
effect_count: usize,
|
||
immune: bool,
|
||
) {
|
||
if immune {
|
||
return;
|
||
}
|
||
let damage = if difficulty == 0 && damage > 1 {
|
||
damage >> 1
|
||
} else {
|
||
damage
|
||
};
|
||
let super_id = ContentId::from("base:super_shield");
|
||
if p.inventory.contains_key(&super_id) {
|
||
visual_effect(
|
||
effect_count,
|
||
visuals,
|
||
VisualEffect::new(VisualEffectKind::Shield, 0, 0).attached(0),
|
||
);
|
||
// Original super shields absorb the whole hit, even when it exhausts them.
|
||
// A zero-charge shield remains equipped until another hit removes it.
|
||
p.super_shield -= damage;
|
||
play_legacy_sound(rng, sounds, events, "FX_SHIT");
|
||
if p.super_shield < 0 {
|
||
// The exhausted module is actually destroyed. Its retained charge
|
||
// must not migrate into a replacement module or revive this one.
|
||
*super_shield_overflow = 0;
|
||
if p.super_shield_reserves.is_empty() {
|
||
p.super_shield = 0;
|
||
p.inventory.remove(&super_id);
|
||
} else {
|
||
let charge = p.super_shield_reserves.remove(0);
|
||
p.super_shield = charge.min(100) as i32;
|
||
*super_shield_overflow = charge.saturating_sub(100);
|
||
p.inventory.insert(super_id.clone(), charge);
|
||
}
|
||
} else {
|
||
p.inventory.insert(
|
||
super_id,
|
||
(p.super_shield as u32).saturating_add(*super_shield_overflow),
|
||
);
|
||
}
|
||
} else {
|
||
p.shield = (p.shield - damage).max(0);
|
||
if let Some(charge) = p.inventory.get_mut(&ContentId::from("base:energy")) {
|
||
*charge = (p.shield as u32).saturating_add(energy_overflow);
|
||
}
|
||
play_legacy_sound(rng, sounds, events, "FX_HIT");
|
||
}
|
||
}
|
||
fn add_energy(p: &mut Player, amount: i32, energy_overflow: u32, super_shield_overflow: u32) {
|
||
if !p.inventory.contains_key(&ContentId::from("base:energy")) {
|
||
return;
|
||
}
|
||
if p.shield < 100 {
|
||
p.shield = (p.shield + amount).clamp(0, 100);
|
||
p.inventory.insert(
|
||
"base:energy".into(),
|
||
(p.shield as u32).saturating_add(energy_overflow),
|
||
);
|
||
} else if p
|
||
.inventory
|
||
.contains_key(&ContentId::from("base:super_shield"))
|
||
&& p.super_shield > 0
|
||
{
|
||
p.super_shield = (p.super_shield + (amount >> 2)).min(100);
|
||
p.inventory.insert(
|
||
"base:super_shield".into(),
|
||
(p.super_shield as u32).saturating_add(super_shield_overflow),
|
||
);
|
||
}
|
||
}
|
||
fn is_zero(value: &u32) -> bool {
|
||
*value == 0
|
||
}
|
||
fn add_with_capacity(current: u32, additional: u32, capacity: u32) -> u32 {
|
||
// A smaller replacement definition can prevent new acquisitions, but an
|
||
// additive action cannot destroy quantities already accepted from a save.
|
||
current.max(current.saturating_add(additional).min(capacity))
|
||
}
|
||
fn add_retained_charge(usable: &mut i32, overflow: &mut u32, additional: u32) {
|
||
let before = (*usable).max(0) as u32;
|
||
let after = before.saturating_add(additional).min(100);
|
||
*usable = after as i32;
|
||
*overflow = overflow.saturating_add(additional.saturating_sub(after.saturating_sub(before)));
|
||
}
|
||
fn consume_retained_charge(usable: &mut i32, overflow: &mut u32, count: u32) {
|
||
let before = (*usable).max(0) as u32;
|
||
*usable = before.saturating_sub(count) as i32;
|
||
*overflow = overflow.saturating_sub(count.saturating_sub(before));
|
||
}
|
||
fn inventory_slots(p: &Player) -> usize {
|
||
p.inventory.len() + p.super_shield_reserves.len()
|
||
}
|
||
fn target_matches(t: &Target, ground: bool) -> bool {
|
||
match t {
|
||
Target::All => true,
|
||
Target::Air => !ground,
|
||
Target::Ground => ground,
|
||
}
|
||
}
|
||
fn contains(e: &Enemy, x: i32, y: i32) -> bool {
|
||
x > e.x && x < e.x + e.width - 1 && y > e.y && y < e.y + e.height - 1
|
||
}
|
||
/// Accumulate exact rational time without rounding the original 3/70-second tick.
|
||
#[derive(Default)]
|
||
pub struct Clock {
|
||
units: u128,
|
||
}
|
||
impl Clock {
|
||
pub fn advance(&mut self, nanos: u64) -> usize {
|
||
self.units += nanos as u128 * 70;
|
||
let steps = self.units / 3_000_000_000;
|
||
self.units %= 3_000_000_000;
|
||
steps as usize
|
||
}
|
||
pub fn alpha(&self) -> f32 {
|
||
self.units as f32 / 3_000_000_000.0
|
||
}
|
||
}
|
||
#[cfg(test)]
|
||
mod tests {
|
||
use super::*;
|
||
#[test]
|
||
fn rng_wraps_like_legacy() {
|
||
let mut r = LegacyRng::new(1);
|
||
assert_eq!(
|
||
(0..5).map(|_| r.next_value()).collect::<Vec<_>>(),
|
||
vec![16838, 5758, 10113, 17515, 31051]
|
||
);
|
||
}
|
||
#[test]
|
||
fn clock_is_independent_of_frame_rate() {
|
||
for hz in [30, 60, 144] {
|
||
let mut c = Clock::default();
|
||
let mut ticks = 0;
|
||
let mut previous = 0;
|
||
for frame in 1..=hz * 3 {
|
||
let now = frame as u64 * 1_000_000_000 / hz as u64;
|
||
ticks += c.advance(now - previous);
|
||
previous = now;
|
||
}
|
||
assert_eq!(ticks, 70);
|
||
}
|
||
}
|
||
#[test]
|
||
fn line_reaches_target() {
|
||
let mut m = LineMove::new(0, 0, 10, 7);
|
||
for _ in 0..20 {
|
||
m.advance(1);
|
||
}
|
||
assert_eq!((m.x, m.y), (10, 7));
|
||
}
|
||
}
|