Match full reference demo and validate original missions, transitions and sprite rendering
This commit is contained in:
1 parent
967ba69ee8
commit
0844f6dffa
21 files changed
+3984
-219
No files matched your search
+577
-108
@@ -180,6 +180,10 @@ pub struct Enemy {
|
||||
pub shootagain: i32,
|
||||
pub shootcount: i32,
|
||||
pub shootflag: i32,
|
||||
#[serde(default)]
|
||||
pub energy_resistance: i32,
|
||||
#[serde(default)]
|
||||
pub shoot_disabled: bool,
|
||||
pub kami: u32,
|
||||
pub vx: i32,
|
||||
pub vy: i32,
|
||||
@@ -245,7 +249,128 @@ pub struct LegacyPickup {
|
||||
pub struct Effect {
|
||||
pub x: i32,
|
||||
pub y: i32,
|
||||
pub px: i32,
|
||||
pub py: i32,
|
||||
pub age: u32,
|
||||
pub kind: EffectKind,
|
||||
pub ground: bool,
|
||||
}
|
||||
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
|
||||
pub enum EffectKind {
|
||||
GroundBlast,
|
||||
SmallGround,
|
||||
LargeAir,
|
||||
MediumAir,
|
||||
SmallAir,
|
||||
AirBlast,
|
||||
Energy,
|
||||
Person,
|
||||
Platoon,
|
||||
Flare,
|
||||
Sparkle,
|
||||
}
|
||||
impl EffectKind {
|
||||
pub fn artwork(self) -> (u32, u32) {
|
||||
match self {
|
||||
Self::GroundBlast => (0x10164, 42),
|
||||
Self::SmallGround => (0x1027d, 35),
|
||||
Self::LargeAir => (0x101e5, 12),
|
||||
Self::MediumAir => (0x101d8, 13),
|
||||
Self::SmallAir => (0x101fc, 14),
|
||||
Self::AirBlast => (0x102a0, 16),
|
||||
Self::Energy => (0x101ae, 12),
|
||||
Self::Person => (0x101f5, 7),
|
||||
Self::Platoon => (0x10213, 10),
|
||||
Self::Flare => (0x10252, 26),
|
||||
Self::Sparkle => (0x1026c, 17),
|
||||
}
|
||||
}
|
||||
}
|
||||
impl Effect {
|
||||
fn new(kind: EffectKind, x: i32, y: i32) -> Self {
|
||||
Self {
|
||||
x,
|
||||
y,
|
||||
px: x,
|
||||
py: y,
|
||||
age: 0,
|
||||
kind,
|
||||
ground: matches!(
|
||||
kind,
|
||||
EffectKind::GroundBlast
|
||||
| EffectKind::SmallGround
|
||||
| EffectKind::MediumAir
|
||||
| EffectKind::Person
|
||||
| EffectKind::Platoon
|
||||
| EffectKind::Flare
|
||||
| EffectKind::Sparkle
|
||||
),
|
||||
}
|
||||
}
|
||||
fn airborne(mut self) -> Self {
|
||||
self.ground = false;
|
||||
self
|
||||
}
|
||||
}
|
||||
fn original_effect(effects: &mut Vec<Effect>, effect: Effect) {
|
||||
if effects.len() < 100 {
|
||||
effects.push(effect);
|
||||
}
|
||||
}
|
||||
fn death_effects(effects: &mut Vec<Effect>, rng: &mut LegacyRng, enemy: &Enemy, explosion: i32) {
|
||||
use EffectKind::*;
|
||||
let cx = enemy.x + enemy.width / 2;
|
||||
let cy = enemy.y + enemy.height / 2;
|
||||
let main = match explosion {
|
||||
0 => MediumAir,
|
||||
1 | 2 | 6 => LargeAir,
|
||||
3 => SmallGround,
|
||||
4 | 5 => GroundBlast,
|
||||
7 => Person,
|
||||
8 => Energy,
|
||||
9 => Platoon,
|
||||
_ => AirBlast,
|
||||
};
|
||||
if explosion == 4 {
|
||||
let x = enemy.x + rng.next_value() % enemy.width.max(1);
|
||||
let y = enemy.y + rng.next_value() % enemy.height.max(1);
|
||||
let kind = if rng.next_value() % 2 == 0 {
|
||||
Sparkle
|
||||
} else {
|
||||
Flare
|
||||
};
|
||||
original_effect(effects, Effect::new(kind, x, y));
|
||||
}
|
||||
original_effect(effects, Effect::new(main, cx, cy));
|
||||
if matches!(explosion, 2 | 5 | 6) {
|
||||
let area = (enemy.width >> 4) * (enemy.height >> 4);
|
||||
for n in 0..area {
|
||||
let x = enemy.x + rng.next_value() % enemy.width.max(1);
|
||||
let y = enemy.y + rng.next_value() % enemy.height.max(1);
|
||||
if explosion == 5 {
|
||||
let kind = if rng.next_value() % 2 == 0 {
|
||||
Flare
|
||||
} else {
|
||||
Sparkle
|
||||
};
|
||||
original_effect(effects, Effect::new(kind, x, y));
|
||||
original_effect(effects, Effect::new(SmallGround, x, y));
|
||||
} else if explosion == 6 {
|
||||
original_effect(effects, Effect::new(Flare, x, y).airborne());
|
||||
original_effect(
|
||||
effects,
|
||||
Effect::new(if n & 1 != 0 { LargeAir } else { AirBlast }, x, y),
|
||||
);
|
||||
} else {
|
||||
let effect = if n & 1 != 0 {
|
||||
Effect::new(MediumAir, x, y)
|
||||
} else {
|
||||
Effect::new(AirBlast, x, y).airborne()
|
||||
};
|
||||
original_effect(effects, effect);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
|
||||
pub enum Outcome {
|
||||
@@ -283,6 +408,14 @@ pub struct World {
|
||||
pub difficulty: u32,
|
||||
pub outcome: Outcome,
|
||||
pub end_timer: Option<u32>,
|
||||
#[serde(default)]
|
||||
pub legacy_transition: bool,
|
||||
#[serde(default = "visible_player")]
|
||||
pub draw_player: bool,
|
||||
#[serde(default)]
|
||||
pub control_paused: bool,
|
||||
#[serde(default)]
|
||||
pub departure_delta: [i32; 2],
|
||||
pub scroll: i32,
|
||||
pub objectives: BTreeMap<String, i32>,
|
||||
pub sounds: Vec<u32>,
|
||||
@@ -296,6 +429,8 @@ pub struct World {
|
||||
pub palette_blue: bool,
|
||||
pub special_held: bool,
|
||||
pub player_flash: bool,
|
||||
#[serde(default = "initial_shield_latch")]
|
||||
pub previous_shield: i32,
|
||||
#[serde(skip)]
|
||||
pub events: Vec<HookEvent>,
|
||||
}
|
||||
@@ -354,6 +489,10 @@ impl World {
|
||||
difficulty: pilot.difficulty,
|
||||
outcome: Outcome::Playing,
|
||||
end_timer: None,
|
||||
legacy_transition: m.legacy_map.is_some(),
|
||||
draw_player: true,
|
||||
control_paused: false,
|
||||
departure_delta: [0, 0],
|
||||
scroll: 0,
|
||||
objectives: BTreeMap::new(),
|
||||
sounds: vec![],
|
||||
@@ -367,6 +506,7 @@ impl World {
|
||||
palette_blue: false,
|
||||
special_held: false,
|
||||
player_flash: false,
|
||||
previous_shield: initial_shield_latch(),
|
||||
events: vec![],
|
||||
};
|
||||
out.event(
|
||||
@@ -467,6 +607,8 @@ impl World {
|
||||
}
|
||||
}),
|
||||
shootflag: f.map_or(0, |f| f[15]),
|
||||
energy_resistance: f.map_or(0, |f| f[13] >> 4),
|
||||
shoot_disabled: false,
|
||||
kami: 0,
|
||||
vx: 0,
|
||||
vy: 0,
|
||||
@@ -537,14 +679,37 @@ impl World {
|
||||
self.player_flash = false;
|
||||
self.player.px = self.player.x;
|
||||
self.player.py = self.player.y;
|
||||
if let Some([x, y]) = input.absolute {
|
||||
self.player.x = x.clamp(5, 282);
|
||||
self.player.y = y.clamp(0, 160);
|
||||
let input = if self.legacy_transition && self.control_paused && input.demo_bank.is_none() {
|
||||
Input::default()
|
||||
} else {
|
||||
input
|
||||
};
|
||||
let ending = self.legacy_transition && self.end_timer.is_some();
|
||||
if self.legacy_transition && self.control_paused && input.demo_bank.is_none() {
|
||||
self.player.x += self.departure_delta[0];
|
||||
self.player.y += self.departure_delta[1];
|
||||
} else if let Some([x, y]) = input.absolute {
|
||||
// Legacy demo playback writes recorded positions directly; the
|
||||
// normal input boundary correction is bypassed by DEMO_GetNext.
|
||||
self.player.x = if input.demo_bank.is_some() || ending {
|
||||
x
|
||||
} else {
|
||||
x.clamp(5, 282)
|
||||
};
|
||||
self.player.y = if input.demo_bank.is_some() || ending {
|
||||
y
|
||||
} else {
|
||||
y.clamp(0, 160)
|
||||
};
|
||||
} else {
|
||||
self.player.vx = accelerate(self.player.vx, input.dx, 10);
|
||||
self.player.vy = accelerate(self.player.vy, input.dy, 8);
|
||||
self.player.x = (self.player.x + self.player.vx).clamp(5, 282);
|
||||
self.player.y = (self.player.y + self.player.vy).clamp(0, 160);
|
||||
self.player.x += self.player.vx;
|
||||
self.player.y += self.player.vy;
|
||||
if !ending {
|
||||
self.player.x = self.player.x.clamp(5, 282);
|
||||
self.player.y = self.player.y.clamp(0, 160);
|
||||
}
|
||||
}
|
||||
let delta = ((self.player.x - self.player.px).abs() >> 2).min(3);
|
||||
if let Some(bank) = input.demo_bank {
|
||||
@@ -623,23 +788,13 @@ impl World {
|
||||
}
|
||||
self.special_held = input.special;
|
||||
if input.cycle && !self.cycle_held {
|
||||
let mut specials: Vec<_> = self
|
||||
.player
|
||||
.inventory
|
||||
.keys()
|
||||
.filter(|id| registry.items.get(*id).is_some_and(|i| i.special))
|
||||
.cloned()
|
||||
.collect();
|
||||
specials.sort_by_key(|id| (registry.items[id].legacy.unwrap_or(u32::MAX), id.clone()));
|
||||
if !specials.is_empty() {
|
||||
let next = self
|
||||
.player
|
||||
.selected
|
||||
.as_ref()
|
||||
.and_then(|id| specials.iter().position(|s| s == id))
|
||||
.map_or(0, |i| (i + 1) % specials.len());
|
||||
self.player.selected = Some(specials[next].clone());
|
||||
}
|
||||
play_legacy_sound(
|
||||
&mut self.rng,
|
||||
&mut self.sounds,
|
||||
&mut self.sound_events,
|
||||
"FX_SWEP",
|
||||
);
|
||||
self.player.selected = next_special(&self.player, registry);
|
||||
}
|
||||
self.cycle_held = input.cycle;
|
||||
if input.bomb && !self.bomb_held {
|
||||
@@ -655,9 +810,20 @@ impl World {
|
||||
}
|
||||
}
|
||||
self.bomb_held = input.bomb;
|
||||
self.scroll += 1;
|
||||
self.think_legacy_terrain(registry);
|
||||
// Do_Game decrements startendwave after input, before world updates.
|
||||
// At zero it still performs one final simulation/statistics pass.
|
||||
let transition_finished = self.legacy_transition && self.end_timer == Some(0);
|
||||
if self.legacy_transition {
|
||||
self.end_timer = self.end_timer.and_then(|timer| timer.checked_sub(1));
|
||||
}
|
||||
let mission = ®istry.missions[&self.mission];
|
||||
let previous_scroll = self.scroll;
|
||||
self.scroll += 1;
|
||||
if mission.legacy_map.is_some() {
|
||||
let rows = mission.layout.len() / mission.columns.max(1) as usize;
|
||||
self.scroll = self.scroll.min(rows.saturating_sub(8) as i32 * 32 + 56);
|
||||
}
|
||||
self.think_legacy_terrain(registry);
|
||||
while self.spawn_cursor < mission.spawns.len()
|
||||
&& mission.spawns[self.spawn_cursor].tick <= self.tick
|
||||
{
|
||||
@@ -710,17 +876,34 @@ impl World {
|
||||
} else {
|
||||
enemy.x = enemy.movement.x;
|
||||
enemy.y = enemy.movement.y;
|
||||
let left = enemy.movement.advance(def.speed);
|
||||
if enemy.movement.done && !def.path.is_empty() {
|
||||
let kamikaze = def.legacy.as_ref().is_some_and(|l| l.fields[21] == 2);
|
||||
let terminal_kami = kamikaze && enemy.kami == 2;
|
||||
// MoveEobj keeps a terminal kamikaze moving past its target
|
||||
// on subsequent steps; ordinary paths stop and retarget.
|
||||
let left = if terminal_kami && enemy.movement.done {
|
||||
enemy.movement.advance_shot(def.speed);
|
||||
0
|
||||
} else {
|
||||
enemy.movement.advance(def.speed)
|
||||
};
|
||||
if terminal_kami {
|
||||
if enemy.movement.y > 201
|
||||
|| enemy.movement.x > 320 + enemy.width / 2
|
||||
|| enemy.movement.y + enemy.width < 0
|
||||
|| enemy.movement.x + enemy.width < 0
|
||||
{
|
||||
retired.push(enemy.entity);
|
||||
}
|
||||
} else if enemy.movement.done && !def.path.is_empty() {
|
||||
let count = def.path.len();
|
||||
if enemy.path_index >= count {
|
||||
if kamikaze {
|
||||
if enemy.kami == 1 {
|
||||
enemy.kami = 2;
|
||||
}
|
||||
} else if enemy.path_index >= count {
|
||||
if def.repeat {
|
||||
enemy.reversed = true;
|
||||
enemy.path_index = count.saturating_sub(1);
|
||||
} else if def.legacy.as_ref().is_some_and(|l| l.fields[21] == 2)
|
||||
&& enemy.kami < 2
|
||||
{
|
||||
enemy.kami += 1;
|
||||
} else {
|
||||
retired.push(enemy.entity);
|
||||
}
|
||||
@@ -740,7 +923,15 @@ impl World {
|
||||
enemy.movement.initial();
|
||||
}
|
||||
enemy.movement.advance(left);
|
||||
if enemy.reversed {
|
||||
if kamikaze {
|
||||
// F_KAMI arms the chase when the last authored leg
|
||||
// starts, then aims at the player when that leg ends.
|
||||
if enemy.path_index < count - 1 {
|
||||
enemy.path_index += 1;
|
||||
} else if enemy.kami == 0 {
|
||||
enemy.kami = 1;
|
||||
}
|
||||
} else if enemy.reversed {
|
||||
enemy.path_index = enemy.path_index.saturating_sub(1);
|
||||
let min = def
|
||||
.legacy
|
||||
@@ -774,7 +965,11 @@ impl World {
|
||||
enemy.shootflag -= 1;
|
||||
if enemy.shootflag < 0 {
|
||||
enemy.shootflag = l.fields[3];
|
||||
for gun in 0..l.fields[22].max(0) as usize {
|
||||
for gun in 0..if enemy.shoot_disabled {
|
||||
0
|
||||
} else {
|
||||
l.fields[22].max(0) as usize
|
||||
} {
|
||||
shots.push((
|
||||
weapon.clone(),
|
||||
enemy.x + l.gun_x[gun] as i32,
|
||||
@@ -841,8 +1036,9 @@ impl World {
|
||||
continue;
|
||||
}
|
||||
if def.boss && enemy.health < 50 && (self.tick + 1) & 2 != 0 && def.legacy.is_some() {
|
||||
self.rng.next_value();
|
||||
self.rng.next_value();
|
||||
let x = enemy.x + self.rng.next_value() % enemy.width.max(1);
|
||||
let y = enemy.y + self.rng.next_value() % enemy.height.max(1);
|
||||
original_effect(&mut self.effects, Effect::new(EffectKind::SmallAir, x, y));
|
||||
}
|
||||
if !def.ground && contains(enemy, self.player.x + 16, self.player.y + 16) {
|
||||
enemy.health -= 16;
|
||||
@@ -853,10 +1049,12 @@ impl World {
|
||||
&mut self.rng,
|
||||
&mut self.sounds,
|
||||
&mut self.sound_events,
|
||||
self.legacy_transition && self.end_timer.is_some(),
|
||||
);
|
||||
if def.legacy.is_some() {
|
||||
self.rng.next_value();
|
||||
self.rng.next_value();
|
||||
let x = self.player.x + 16 + self.rng.next_value() % 8 - 4;
|
||||
let y = self.player.y + 16 + self.rng.next_value() % 8 - 4;
|
||||
original_effect(&mut self.effects, Effect::new(EffectKind::SmallAir, x, y));
|
||||
play_legacy_sound(
|
||||
&mut self.rng,
|
||||
&mut self.sounds,
|
||||
@@ -880,22 +1078,22 @@ impl World {
|
||||
self.player.x + 16,
|
||||
self.player.y + 16,
|
||||
);
|
||||
let random_count = match legacy.fields[2] {
|
||||
2 | 6 => 2 * (enemy.width >> 4) * (enemy.height >> 4),
|
||||
4 => 3,
|
||||
5 => 3 * (enemy.width >> 4) * (enemy.height >> 4),
|
||||
_ => 0,
|
||||
};
|
||||
for _ in 0..random_count {
|
||||
self.rng.next_value();
|
||||
}
|
||||
death_effects(&mut self.effects, &mut self.rng, enemy, legacy.fields[2]);
|
||||
} else {
|
||||
original_effect(
|
||||
&mut self.effects,
|
||||
Effect::new(
|
||||
if def.ground {
|
||||
EffectKind::GroundBlast
|
||||
} else {
|
||||
EffectKind::AirBlast
|
||||
},
|
||||
enemy.x + enemy.width / 2,
|
||||
enemy.y + enemy.height / 2,
|
||||
),
|
||||
);
|
||||
}
|
||||
self.player.credits = self.player.credits.saturating_add(def.reward);
|
||||
self.effects.push(Effect {
|
||||
x: enemy.x + enemy.width / 2,
|
||||
y: enemy.y + enemy.height / 2,
|
||||
age: 0,
|
||||
});
|
||||
events.push(HookEvent {
|
||||
content: enemy.definition.clone(),
|
||||
hook: "on_death",
|
||||
@@ -945,6 +1143,16 @@ impl World {
|
||||
}
|
||||
self.events.extend(events);
|
||||
self.enemies.retain(|e| !retired.contains(&e.entity));
|
||||
if self.legacy_transition
|
||||
&& mission.auto_complete
|
||||
&& !retired.is_empty()
|
||||
&& self.spawn_cursor == mission.spawns.len()
|
||||
&& self.enemies.is_empty()
|
||||
{
|
||||
// ENEMY_Remove arms departure before ESHOT_Think, so hostile
|
||||
// shots on the final removal step cannot subtract energy.
|
||||
self.end_timer = Some(60);
|
||||
}
|
||||
for (id, x, y, legacy) in shots {
|
||||
if legacy.is_none_or(|(kind, _, _, _)| kind < 0)
|
||||
|| ((0..320).contains(&x) && (0..200).contains(&y))
|
||||
@@ -990,6 +1198,7 @@ impl World {
|
||||
&mut self.rng,
|
||||
&mut self.sounds,
|
||||
&mut self.sound_events,
|
||||
self.legacy_transition && self.end_timer.is_some(),
|
||||
);
|
||||
dead_shots.push(p.entity);
|
||||
}
|
||||
@@ -1144,11 +1353,10 @@ impl World {
|
||||
.player
|
||||
.credits
|
||||
.saturating_add(mission.tiles[mission.layout[pos]].reward);
|
||||
self.effects.push(Effect {
|
||||
x: p.x,
|
||||
y: p.y,
|
||||
age: 0,
|
||||
});
|
||||
original_effect(
|
||||
&mut self.effects,
|
||||
Effect::new(EffectKind::GroundBlast, p.x, p.y),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1161,24 +1369,28 @@ impl World {
|
||||
|| (p.x >= -32 && p.x <= 352 && p.y >= -64 && p.y <= 240 && p.age < 300))
|
||||
});
|
||||
for e in &mut self.effects {
|
||||
e.px = e.x;
|
||||
e.py = e.y;
|
||||
e.age += 1;
|
||||
if e.ground && self.scroll != previous_scroll && e.age > 1 {
|
||||
e.y += 1;
|
||||
}
|
||||
}
|
||||
self.effects.retain(|e| e.age < 20);
|
||||
self.effects.retain(|e| e.age <= e.kind.artwork().1);
|
||||
self.tick += 1;
|
||||
if self.player.shield <= 0 {
|
||||
if self.legacy_transition {
|
||||
if mission.auto_complete
|
||||
&& self.spawn_cursor == mission.spawns.len()
|
||||
&& self.enemies.is_empty()
|
||||
&& self.end_timer.is_none()
|
||||
&& !transition_finished
|
||||
{
|
||||
self.end_timer = Some(60);
|
||||
}
|
||||
} else if self.player.shield <= 0 {
|
||||
self.outcome = Outcome::Failed;
|
||||
} else if mission.auto_complete {
|
||||
if mission.legacy_map.is_some() {
|
||||
// ENEMY_Remove starts the original 60-tick departure only
|
||||
// after the final spawn has been visited and all ships left.
|
||||
if self.spawn_cursor == mission.spawns.len() && self.enemies.is_empty() {
|
||||
match self.end_timer {
|
||||
None => self.end_timer = Some(60),
|
||||
Some(0) => self.outcome = Outcome::Complete,
|
||||
Some(n) => self.end_timer = Some(n - 1),
|
||||
}
|
||||
}
|
||||
} else if self.tick >= mission.duration
|
||||
if self.tick >= mission.duration
|
||||
&& self.spawn_cursor == mission.spawns.len()
|
||||
&& self
|
||||
.enemies
|
||||
@@ -1188,6 +1400,49 @@ impl World {
|
||||
self.outcome = Outcome::Complete;
|
||||
}
|
||||
}
|
||||
if mission.legacy_map.is_some() && self.tick >= 3 && self.tick % 2 == 1 {
|
||||
// Palette index 254 consumes the original shared RNG, even when no frame is rendered.
|
||||
self.palette_blue = self.rng.next_value() % 3 == 0;
|
||||
}
|
||||
if self.legacy_transition {
|
||||
self.think_legacy_transition(transition_finished, input.demo_bank.is_some());
|
||||
}
|
||||
// RAP_ThinkStats follows palette simulation. A new hit at low energy
|
||||
// destroys the equipped special, or the highest-numbered losable item.
|
||||
// OBJS_LoseObj reports success even when no eligible item remains.
|
||||
if self.player.shield <= 10
|
||||
&& self.player.shield < self.previous_shield
|
||||
&& self.player.super_shield < 1
|
||||
{
|
||||
let lost = self.player.selected.clone().or_else(|| {
|
||||
self.player
|
||||
.inventory
|
||||
.keys()
|
||||
.filter_map(|id| {
|
||||
registry
|
||||
.items
|
||||
.get(id)
|
||||
.and_then(|item| item.legacy)
|
||||
.filter(|legacy| matches!(legacy, 1..=14 | 18..=23))
|
||||
.map(|legacy| (legacy, id.clone()))
|
||||
})
|
||||
.max()
|
||||
.map(|(_, id)| id)
|
||||
});
|
||||
if let Some(id) = lost {
|
||||
let removed = self.player.inventory.remove(&id).is_some();
|
||||
if removed && self.player.selected.as_ref() == Some(&id) {
|
||||
self.player.selected = next_special(&self.player, registry);
|
||||
}
|
||||
}
|
||||
play_legacy_sound(
|
||||
&mut self.rng,
|
||||
&mut self.sounds,
|
||||
&mut self.sound_events,
|
||||
"FX_CRASH",
|
||||
);
|
||||
}
|
||||
self.previous_shield = self.player.shield;
|
||||
self.event(self.mission.clone(),"on_tick",serde_json::json!({"tick":self.tick,"player_x":self.player.x,"player_y":self.player.y,"enemies":self.enemies.len()}));
|
||||
if self.outcome != Outcome::Playing {
|
||||
self.event(
|
||||
@@ -1196,12 +1451,113 @@ impl World {
|
||||
serde_json::json!({"success":self.outcome==Outcome::Complete,"tick":self.tick}),
|
||||
);
|
||||
}
|
||||
if mission.legacy_map.is_some() && self.tick >= 3 && self.tick % 2 == 1 {
|
||||
// Palette index 254 consumes the original shared RNG, even when no frame is rendered.
|
||||
self.palette_blue = self.rng.next_value() % 3 == 0;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
fn think_legacy_transition(&mut self, finished: bool, demo: bool) {
|
||||
if self.player.shield <= 0 {
|
||||
// GCC evaluates the original ANIMS_StartAnim position arguments
|
||||
// right-to-left: y consumes the first shared random value.
|
||||
let y = self.player.y + self.rng.next_value() % 32;
|
||||
let x = self.player.x + self.rng.next_value() % 32;
|
||||
original_effect(&mut self.effects, Effect::new(EffectKind::MediumAir, x, y));
|
||||
let y = self.player.y + self.rng.next_value() % 32;
|
||||
let x = self.player.x + self.rng.next_value() % 32;
|
||||
original_effect(&mut self.effects, Effect::new(EffectKind::SmallAir, x, y));
|
||||
if self.end_timer.is_some_and(|timer| timer > 24) {
|
||||
let pan = if self.rng.next_value() % 2 == 0 {
|
||||
30
|
||||
} else {
|
||||
225
|
||||
};
|
||||
play_legacy_sound(
|
||||
&mut self.rng,
|
||||
&mut self.sounds,
|
||||
&mut self.sound_events,
|
||||
"FX_AIREXPLO",
|
||||
);
|
||||
if let Some(event) = self.sound_events.last_mut() {
|
||||
event.pan = pan;
|
||||
}
|
||||
}
|
||||
if self.end_timer.is_none() {
|
||||
self.end_timer = Some(60);
|
||||
}
|
||||
if self.end_timer == Some(24) {
|
||||
self.draw_player = false;
|
||||
play_legacy_sound(
|
||||
&mut self.rng,
|
||||
&mut self.sounds,
|
||||
&mut self.sound_events,
|
||||
"FX_AIREXPLO",
|
||||
);
|
||||
play_legacy_sound(
|
||||
&mut self.rng,
|
||||
&mut self.sounds,
|
||||
&mut self.sound_events,
|
||||
"FX_AIREXPLO2",
|
||||
);
|
||||
original_effect(
|
||||
&mut self.effects,
|
||||
Effect::new(EffectKind::LargeAir, self.player.x + 16, self.player.y + 16),
|
||||
);
|
||||
// Random positions are evaluated even after the animation pool
|
||||
// fills, exactly as the original 512-effect request burst.
|
||||
for index in 0..512 {
|
||||
let x = self.player.x - 16 + self.rng.next_value() % 32 * 2;
|
||||
let y = self.player.y - 16 + self.rng.next_value() % 32 * 2;
|
||||
let kind = if index & 1 != 0 {
|
||||
EffectKind::LargeAir
|
||||
} else {
|
||||
EffectKind::AirBlast
|
||||
};
|
||||
original_effect(&mut self.effects, Effect::new(kind, x, y).airborne());
|
||||
}
|
||||
play_legacy_sound(
|
||||
&mut self.rng,
|
||||
&mut self.sounds,
|
||||
&mut self.sound_events,
|
||||
"FX_AIREXPLO2",
|
||||
);
|
||||
}
|
||||
} else if let Some(timer) = self.end_timer {
|
||||
if timer == 40 {
|
||||
self.control_paused = true;
|
||||
self.departure_delta = [
|
||||
self.player.x - self.player.px,
|
||||
self.player.y - self.player.py,
|
||||
];
|
||||
play_legacy_sound(
|
||||
&mut self.rng,
|
||||
&mut self.sounds,
|
||||
&mut self.sound_events,
|
||||
"FX_FLYBY",
|
||||
);
|
||||
}
|
||||
if timer < 40 {
|
||||
let dx = if self.player.x < 152 {
|
||||
8
|
||||
} else if self.player.x > 168 {
|
||||
-8
|
||||
} else {
|
||||
0
|
||||
};
|
||||
self.departure_delta = [dx, -4];
|
||||
if !demo {
|
||||
let old_x = self.player.x;
|
||||
self.player.x += dx;
|
||||
self.player.y -= 4;
|
||||
bank_player(&mut self.player, old_x);
|
||||
}
|
||||
}
|
||||
}
|
||||
if finished {
|
||||
self.outcome = if self.player.shield > 0 {
|
||||
Outcome::Complete
|
||||
} else {
|
||||
Outcome::Failed
|
||||
};
|
||||
}
|
||||
}
|
||||
fn fire(&mut self, id: &ContentId, registry: &Registry) -> Result<()> {
|
||||
let Some(w) = registry.weapons.get(id) else {
|
||||
return Ok(());
|
||||
@@ -1353,6 +1709,7 @@ impl World {
|
||||
&mut self.rng,
|
||||
&mut self.sounds,
|
||||
&mut self.sound_events,
|
||||
self.legacy_transition && self.end_timer.is_some(),
|
||||
);
|
||||
}
|
||||
} else {
|
||||
@@ -1389,6 +1746,7 @@ impl World {
|
||||
&mut self.rng,
|
||||
&mut self.sounds,
|
||||
&mut self.sound_events,
|
||||
self.legacy_transition && self.end_timer.is_some(),
|
||||
);
|
||||
retired.push(p.entity);
|
||||
}
|
||||
@@ -1472,11 +1830,10 @@ impl World {
|
||||
position,
|
||||
frames: 10,
|
||||
});
|
||||
self.effects.push(Effect {
|
||||
x: x + 16,
|
||||
y: y + 16,
|
||||
age: 0,
|
||||
});
|
||||
original_effect(
|
||||
&mut self.effects,
|
||||
Effect::new(EffectKind::GroundBlast, x + 16, y + 16),
|
||||
);
|
||||
// TILE_Explode changes its collision item immediately. Artwork waits
|
||||
// for the delayed explosion and its second chain-damage pass.
|
||||
self.terrain_dead[position] = true;
|
||||
@@ -1581,6 +1938,23 @@ impl World {
|
||||
// A plotted shot tests its endpoint before retiring, even if MoveSobj
|
||||
// marked it done in the preceding step.
|
||||
if !skip_collision && !lib.effect {
|
||||
// TILE_Bomb runs before ENEMY_DamageGround and affects both
|
||||
// the hit tile and the original left/up/right neighbors.
|
||||
if lib.target == 4 {
|
||||
if let Some(position) = self.legacy_tile_hit(p.x, p.y, registry) {
|
||||
self.terrain_health[position] -= weapon.damage;
|
||||
self.terrain_damage_neighbors(position, weapon.damage, registry);
|
||||
let columns = registry.missions[&self.mission].columns.max(1) as usize;
|
||||
if position > columns {
|
||||
self.terrain_damage_neighbors(
|
||||
position - columns,
|
||||
weapon.damage >> 1,
|
||||
registry,
|
||||
);
|
||||
}
|
||||
state.movement.done = true;
|
||||
}
|
||||
}
|
||||
let enemy = self
|
||||
.enemies
|
||||
.iter_mut()
|
||||
@@ -1590,13 +1964,17 @@ impl World {
|
||||
let ground = registry.enemies[&e.definition].ground;
|
||||
contains(e, p.x, p.y)
|
||||
&& match lib.target {
|
||||
1 => !ground,
|
||||
1 | 5 => !ground,
|
||||
2 | 4 => ground,
|
||||
_ => true,
|
||||
}
|
||||
});
|
||||
if let Some(enemy) = enemy {
|
||||
let damage = if lib.target == 4 { 5 } else { weapon.damage };
|
||||
let damage = match lib.target {
|
||||
4 => 5,
|
||||
5 => 1,
|
||||
_ => weapon.damage,
|
||||
};
|
||||
enemy.health -= damage;
|
||||
if self.difficulty == 0 && matches!(lib.target, 1 | 2) {
|
||||
enemy.health -= damage;
|
||||
@@ -1605,31 +1983,43 @@ impl World {
|
||||
if matches!(lib.target, 0 | 1 | 3) {
|
||||
self.rng.next_value();
|
||||
}
|
||||
if lib.target == 5
|
||||
&& registry.enemies[&enemy.definition]
|
||||
.legacy
|
||||
.as_ref()
|
||||
.is_some_and(|l| l.fields[5] != 0)
|
||||
{
|
||||
if enemy.energy_resistance > 0 {
|
||||
enemy.energy_resistance -= weapon.damage;
|
||||
} else {
|
||||
enemy.shoot_on = false;
|
||||
enemy.shoot_disabled = true;
|
||||
enemy.shootagain = -1;
|
||||
play_legacy_sound(
|
||||
&mut self.rng,
|
||||
&mut self.sounds,
|
||||
&mut self.sound_events,
|
||||
"FX_EGRAB",
|
||||
);
|
||||
position_sound(
|
||||
&mut self.sound_events,
|
||||
enemy.x + enemy.width / 2,
|
||||
enemy.y + enemy.width / 2,
|
||||
self.player.x + 16,
|
||||
self.player.y + 16,
|
||||
);
|
||||
}
|
||||
}
|
||||
let entity = enemy.entity;
|
||||
let enemy_id = enemy.definition.clone();
|
||||
let health = enemy.health;
|
||||
self.event(enemy_id,"on_hit",serde_json::json!({"entity":entity,"projectile":p.entity,"weapon":p.weapon,"damage":damage,"x":p.x,"y":p.y,"health":health}));
|
||||
self.event(p.weapon.clone(),"on_hit",serde_json::json!({"entity":entity,"projectile":p.entity,"damage":damage,"x":p.x,"y":p.y}));
|
||||
} else if matches!(lib.target, 0 | 2 | 4) {
|
||||
let mission = ®istry.missions[&self.mission];
|
||||
let col = (p.x - 16).div_euclid(32);
|
||||
let row = (p.y
|
||||
+ mission.layout.len() as i32 / mission.columns.max(1) as i32 * 32
|
||||
- 200
|
||||
- self.scroll)
|
||||
.div_euclid(32);
|
||||
if col >= 0 && col < mission.columns as i32 && row >= 0 {
|
||||
let pos = row as usize * mission.columns as usize + col as usize;
|
||||
if pos < self.terrain_health.len()
|
||||
&& mission.tiles[mission.layout[pos]].destroyed.is_some()
|
||||
&& !self.terrain_dead[pos]
|
||||
{
|
||||
self.terrain_health[pos] -= weapon.damage;
|
||||
if lib.target != 4 {
|
||||
self.rng.next_value();
|
||||
}
|
||||
state.movement.done = true;
|
||||
}
|
||||
} else if matches!(lib.target, 0 | 2) {
|
||||
if let Some(position) = self.legacy_tile_hit(p.x, p.y, registry) {
|
||||
self.terrain_health[position] -= weapon.damage;
|
||||
self.rng.next_value();
|
||||
state.movement.done = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1646,9 +2036,18 @@ impl World {
|
||||
for enemy in &mut self.enemies {
|
||||
enemy.health -= weapon.damage;
|
||||
}
|
||||
for (health, dead) in self.terrain_health.iter_mut().zip(&self.terrain_dead) {
|
||||
if !dead {
|
||||
*health -= weapon.damage;
|
||||
let mission = ®istry.missions[&self.mission];
|
||||
let columns = mission.columns.max(1) as usize;
|
||||
let (row, _) = self.terrain_window(registry);
|
||||
let start = row * columns;
|
||||
let end = (start + 8 * columns).min(mission.layout.len());
|
||||
// TILE_DamageAll uses a fixed 20 terrain damage and
|
||||
// includes the final visible tile, unlike TILE_IsHit.
|
||||
for position in start..end {
|
||||
if mission.tiles[mission.layout[position]].destroyed.is_some()
|
||||
&& !self.terrain_dead[position]
|
||||
{
|
||||
self.terrain_health[position] -= 20;
|
||||
}
|
||||
}
|
||||
retired.extend(
|
||||
@@ -1681,6 +2080,26 @@ impl World {
|
||||
self.projectiles.retain(|p| !retired.contains(&p.entity));
|
||||
Ok(())
|
||||
}
|
||||
fn legacy_tile_hit(&self, x: i32, y: i32, registry: &Registry) -> Option<usize> {
|
||||
let mission = ®istry.missions[&self.mission];
|
||||
let columns = mission.columns.max(1) as usize;
|
||||
let (row, y_offset) = self.terrain_window(registry);
|
||||
let column = (x - 16).div_euclid(32);
|
||||
let local_row = (y - y_offset).div_euclid(32);
|
||||
if column < 0 || column >= columns as i32 || !(0..8).contains(&local_row) {
|
||||
return None;
|
||||
}
|
||||
let slot = local_row as usize * columns + column as usize;
|
||||
// The original TILE_IsHit/TILE_Bomb loop excludes lastspot itself.
|
||||
if slot >= 8 * columns - 1 {
|
||||
return None;
|
||||
}
|
||||
let position = row * columns + slot;
|
||||
(position < mission.layout.len()
|
||||
&& mission.tiles[mission.layout[position]].destroyed.is_some()
|
||||
&& !self.terrain_dead[position])
|
||||
.then_some(position)
|
||||
}
|
||||
fn fire_legacy(
|
||||
&mut self,
|
||||
id: &ContentId,
|
||||
@@ -2005,6 +2424,7 @@ impl World {
|
||||
&mut self.rng,
|
||||
&mut self.sounds,
|
||||
&mut self.sound_events,
|
||||
self.legacy_transition && self.end_timer.is_some(),
|
||||
);
|
||||
} else {
|
||||
let e = self
|
||||
@@ -2086,11 +2506,7 @@ impl World {
|
||||
}
|
||||
Command::Effect { x, y } => {
|
||||
ensure!(self.effects.len() < 4096, "too many effects");
|
||||
self.effects.push(Effect {
|
||||
x: *x,
|
||||
y: *y,
|
||||
age: 0,
|
||||
});
|
||||
self.effects.push(Effect::new(EffectKind::AirBlast, *x, *y));
|
||||
}
|
||||
Command::Sound { asset } => {
|
||||
self.sounds.push(*asset);
|
||||
@@ -2161,6 +2577,55 @@ impl World {
|
||||
serde_json::json!({"tick":self.tick,"rng":self.rng.seed,"player":[self.player.x,self.player.y,self.player.bank,self.player.shield,self.player.super_shield,self.player.credits],"enemies":self.enemies.iter().map(|e|serde_json::json!([registry.enemies[&e.definition].art.as_ref().and_then(|a|a.asset).unwrap_or(0),e.x,e.y,e.health,e.frame,e.movement.x,e.movement.y])).collect::<Vec<_>>(),"projectiles":self.projectiles.iter().filter(|p|p.hostile).chain(self.projectiles.iter().filter(|p|!p.hostile)).map(|p|serde_json::json!([Self::projectile_asset(p,registry).map(i64::from).unwrap_or(-1),p.x,p.y,p.hostile as u32])).collect::<Vec<_>>()})
|
||||
}
|
||||
}
|
||||
fn initial_shield_latch() -> i32 {
|
||||
-1
|
||||
}
|
||||
fn visible_player() -> bool {
|
||||
true
|
||||
}
|
||||
fn bank_player(player: &mut Player, old_x: i32) {
|
||||
let delta = ((player.x - old_x).abs() >> 2).min(3);
|
||||
if player.x < old_x {
|
||||
if player.bank < 3 + delta {
|
||||
player.bank += 1;
|
||||
}
|
||||
} else if player.x > old_x {
|
||||
if player.bank > 3 - delta {
|
||||
player.bank -= 1;
|
||||
}
|
||||
} else {
|
||||
player.bank += (3 - player.bank).signum();
|
||||
}
|
||||
}
|
||||
fn next_special(player: &Player, registry: &Registry) -> Option<ContentId> {
|
||||
let mut specials: Vec<_> = player
|
||||
.inventory
|
||||
.iter()
|
||||
.filter_map(|(id, count)| {
|
||||
registry
|
||||
.items
|
||||
.get(id)
|
||||
.filter(|item| *count > 0 && item.special)
|
||||
.map(|item| ((item.legacy.unwrap_or(u32::MAX), id.clone()), id.clone()))
|
||||
})
|
||||
.collect();
|
||||
specials.sort_by(|a, b| a.0.cmp(&b.0));
|
||||
let after = player.selected.as_ref().map(|id| {
|
||||
(
|
||||
registry
|
||||
.items
|
||||
.get(id)
|
||||
.and_then(|item| item.legacy)
|
||||
.unwrap_or(u32::MAX),
|
||||
id.clone(),
|
||||
)
|
||||
});
|
||||
specials
|
||||
.iter()
|
||||
.find(|(key, _)| after.as_ref().is_none_or(|after| key > after))
|
||||
.or_else(|| specials.first())
|
||||
.map(|(_, id)| id.clone())
|
||||
}
|
||||
fn update_legacy_animation(enemy: &mut Enemy, lib: &crate::assets::LegacyEnemy) {
|
||||
let f = &lib.fields;
|
||||
enemy.art_frame = enemy.frame;
|
||||
@@ -2269,7 +2734,11 @@ fn damage_player(
|
||||
rng: &mut LegacyRng,
|
||||
sounds: &mut Vec<u32>,
|
||||
events: &mut Vec<SoundEvent>,
|
||||
immune: bool,
|
||||
) {
|
||||
if immune {
|
||||
return;
|
||||
}
|
||||
let damage = if difficulty == 0 && damage > 1 {
|
||||
damage >> 1
|
||||
} else {
|
||||
|
||||
Reference in new issue
Block a user