fix(game): restore the 281-frame panel sequence
Replace the immediate five-hole cleanup with the original 281-callback panel state machine. Suspend physics, clear contact/item state at the recovered boundaries, and emit WAVE 2013, WAVE 2012, and stop events at frames 1/45/58/66/129/159/222/230/238 before resuming after cleanup. Decode DAT600's four AND/OR mask-image pairs into transparent native textures and render all nine phase formulas with the original source rectangles, destination offsets, variable heights, and opaque wheel copies. Add a `panel` simulation scenario for deterministic trace and framebuffer validation. Test Plan: - `cargo test --all-targets` -- passed, 72 tests - `cargo clippy --all-targets -- -D warnings` -- passed - `rumdl check CHANGELOG.md README.md RECONSTRUCTION.md` -- passed - rendered panel frames 1, 66, 159, 238, and completion -- visually verified - `git diff --cached --check` -- passed
This commit is contained in:
@@ -216,6 +216,10 @@ impl App {
|
||||
}
|
||||
|
||||
fn play_event(&self, event: Event) {
|
||||
if event == Event::StopSound {
|
||||
self.assets.stop_all_sounds();
|
||||
return;
|
||||
}
|
||||
if let Some(resource) = event.sound_resource() {
|
||||
self.assets.play(resource, self.saved.settings.sounds);
|
||||
}
|
||||
@@ -363,6 +367,10 @@ impl App {
|
||||
return;
|
||||
};
|
||||
|
||||
if let Some(frame) = game.panel_frame {
|
||||
self.draw_panel_animation(frame);
|
||||
}
|
||||
|
||||
if game.wheel_animation > 0.0 {
|
||||
let frame = animation_frame(f64::from(0.65 - game.wheel_animation), 18.0, 5);
|
||||
draw_texture_ex(
|
||||
@@ -508,6 +516,120 @@ impl App {
|
||||
}
|
||||
}
|
||||
|
||||
#[allow(clippy::cast_precision_loss, clippy::too_many_lines)]
|
||||
fn draw_panel_animation(&self, frame: u16) {
|
||||
draw_texture_region(&self.assets.active_table, 79.0, 0.0, 91.0, 123.0, 79.0, 0.0);
|
||||
let frame = i32::from(frame);
|
||||
let narrow_a = &self.assets.panel_pair_narrow_a;
|
||||
let narrow_b = &self.assets.panel_pair_narrow_b;
|
||||
let wide_a = &self.assets.panel_pair_wide_a;
|
||||
let wide_b = &self.assets.panel_pair_wide_b;
|
||||
|
||||
if (1..=44).contains(&frame) {
|
||||
draw_texture_region(
|
||||
narrow_a,
|
||||
99.0,
|
||||
0.0,
|
||||
45.0,
|
||||
(frame * 2) as f32,
|
||||
0.0,
|
||||
((45 - frame) * 2 - 1) as f32,
|
||||
);
|
||||
} else if (45..=57).contains(&frame) {
|
||||
draw_texture_region(narrow_a, 99.0, 0.0, 45.0, 88.0, 0.0, 0.0);
|
||||
} else if (58..=65).contains(&frame) {
|
||||
draw_texture_region(narrow_b, 99.0, 0.0, 45.0, 88.0, 0.0, 0.0);
|
||||
} else if (66..=128).contains(&frame) {
|
||||
draw_texture_region(&self.assets.wheel, 79.0, 33.0, 91.0, 90.0, 180.0, 90.0);
|
||||
if frame < 83 {
|
||||
draw_texture_region(wide_a, 79.0, ((82 - frame) * 2) as f32, 91.0, 90.0, 0.0, 0.0);
|
||||
} else {
|
||||
let height = (128 - frame) * 2;
|
||||
draw_texture_region(
|
||||
wide_a,
|
||||
79.0,
|
||||
0.0,
|
||||
91.0,
|
||||
height as f32,
|
||||
0.0,
|
||||
((frame - 82) * 2) as f32,
|
||||
);
|
||||
}
|
||||
if frame < 111 {
|
||||
let height = (110 - frame) * 2;
|
||||
draw_texture_region(
|
||||
narrow_b,
|
||||
99.0,
|
||||
0.0,
|
||||
45.0,
|
||||
height as f32,
|
||||
0.0,
|
||||
((frame - 66) * 2) as f32,
|
||||
);
|
||||
}
|
||||
} else if (129..=158).contains(&frame) {
|
||||
draw_texture_region(&self.assets.wheel, 79.0, 33.0, 91.0, 90.0, 180.0, 90.0);
|
||||
} else if (159..=221).contains(&frame) {
|
||||
draw_texture_region(&self.assets.wheel, 79.0, 33.0, 91.0, 90.0, 180.0, 90.0);
|
||||
if frame < 204 {
|
||||
let height = (frame - 159) * 2;
|
||||
draw_texture_region(
|
||||
wide_b,
|
||||
79.0,
|
||||
0.0,
|
||||
91.0,
|
||||
height as f32,
|
||||
0.0,
|
||||
((204 - frame) * 2) as f32,
|
||||
);
|
||||
} else {
|
||||
draw_texture_region(
|
||||
wide_b,
|
||||
79.0,
|
||||
((frame - 204) * 2) as f32,
|
||||
91.0,
|
||||
90.0,
|
||||
0.0,
|
||||
0.0,
|
||||
);
|
||||
}
|
||||
if frame > 176 {
|
||||
if frame == 221 {
|
||||
draw_texture_region(narrow_b, 99.0, 0.0, 45.0, 88.0, 0.0, 0.0);
|
||||
} else {
|
||||
let height = (frame - 177) * 2;
|
||||
draw_texture_region(
|
||||
narrow_b,
|
||||
99.0,
|
||||
0.0,
|
||||
45.0,
|
||||
height as f32,
|
||||
0.0,
|
||||
((222 - frame) * 2 - 1) as f32,
|
||||
);
|
||||
}
|
||||
}
|
||||
} else if (222..=229).contains(&frame) {
|
||||
draw_texture_region(wide_b, 79.0, 33.0, 91.0, 90.0, 0.0, 0.0);
|
||||
draw_texture_region(narrow_b, 99.0, 0.0, 45.0, 88.0, 0.0, 0.0);
|
||||
} else if (230..=237).contains(&frame) {
|
||||
draw_texture_region(wide_b, 79.0, 33.0, 91.0, 90.0, 0.0, 0.0);
|
||||
draw_texture_region(narrow_a, 99.0, 0.0, 45.0, 88.0, 0.0, 0.0);
|
||||
} else if (238..=281).contains(&frame) {
|
||||
draw_texture_region(wide_b, 79.0, 33.0, 91.0, 90.0, 0.0, 0.0);
|
||||
let height = (281 - frame) * 2;
|
||||
draw_texture_region(
|
||||
narrow_a,
|
||||
99.0,
|
||||
0.0,
|
||||
45.0,
|
||||
height as f32,
|
||||
0.0,
|
||||
((frame - 238) * 2) as f32,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[allow(clippy::cast_precision_loss)]
|
||||
fn draw_displays(&self, game: &Game) {
|
||||
for region in [
|
||||
@@ -751,6 +873,31 @@ fn draw_panel(x: f32, y: f32, width: f32, height: f32) {
|
||||
draw_rectangle_lines(x + 4.0, y + 4.0, width - 8.0, height - 8.0, 2.0, DARKGRAY);
|
||||
}
|
||||
|
||||
fn draw_texture_region(
|
||||
texture: &Texture2D,
|
||||
destination_x: f32,
|
||||
destination_y: f32,
|
||||
width: f32,
|
||||
height: f32,
|
||||
source_x: f32,
|
||||
source_y: f32,
|
||||
) {
|
||||
if width <= 0.0 || height <= 0.0 {
|
||||
return;
|
||||
}
|
||||
draw_texture_ex(
|
||||
texture,
|
||||
destination_x,
|
||||
destination_y,
|
||||
WHITE,
|
||||
DrawTextureParams {
|
||||
dest_size: Some(vec2(width, height)),
|
||||
source: Some(Rect::new(source_x, source_y, width, height)),
|
||||
..Default::default()
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
#[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
|
||||
fn animation_frame(elapsed: f64, frames_per_second: f64, frame_count: usize) -> usize {
|
||||
((elapsed.max(0.0) * frames_per_second) as usize) % frame_count
|
||||
|
||||
+50
-2
@@ -1,5 +1,5 @@
|
||||
use macroquad::{
|
||||
audio::{PlaySoundParams, Sound, load_sound_from_bytes, play_sound},
|
||||
audio::{PlaySoundParams, Sound, load_sound_from_bytes, play_sound, stop_sound},
|
||||
prelude::{FilterMode, Image, Texture2D},
|
||||
};
|
||||
|
||||
@@ -11,6 +11,10 @@ pub struct Assets {
|
||||
pub media: [Texture2D; 4],
|
||||
pub diamond: [Texture2D; 9],
|
||||
pub wheel: Texture2D,
|
||||
pub panel_pair_narrow_a: Texture2D,
|
||||
pub panel_pair_narrow_b: Texture2D,
|
||||
pub panel_pair_wide_a: Texture2D,
|
||||
pub panel_pair_wide_b: Texture2D,
|
||||
pub robot: Texture2D,
|
||||
pub plunger: Texture2D,
|
||||
pub ball: Texture2D,
|
||||
@@ -48,7 +52,12 @@ impl Assets {
|
||||
texture(include_bytes!("../assets/original/images/dat_00808.png")),
|
||||
texture(include_bytes!("../assets/original/images/dat_00809.png")),
|
||||
];
|
||||
let wheel = texture(include_bytes!("../assets/original/images/dat_00600.png"));
|
||||
let wheel_bytes = include_bytes!("../assets/original/images/dat_00600.png");
|
||||
let wheel = texture(wheel_bytes);
|
||||
let panel_pair_narrow_a = masked_atlas_pair(wheel_bytes, 0, 45, 45, 90);
|
||||
let panel_pair_narrow_b = masked_atlas_pair(wheel_bytes, 90, 135, 45, 90);
|
||||
let panel_pair_wide_a = masked_atlas_pair(wheel_bytes, 271, 362, 91, 90);
|
||||
let panel_pair_wide_b = masked_atlas_pair(wheel_bytes, 453, 362, 91, 90);
|
||||
let robot = texture(include_bytes!("../assets/original/images/dat_00900.png"));
|
||||
let plunger = texture(include_bytes!("../assets/original/images/dat_00901.png"));
|
||||
let ball = masked_texture(
|
||||
@@ -139,6 +148,10 @@ impl Assets {
|
||||
media,
|
||||
diamond,
|
||||
wheel,
|
||||
panel_pair_narrow_a,
|
||||
panel_pair_narrow_b,
|
||||
panel_pair_wide_a,
|
||||
panel_pair_wide_b,
|
||||
robot,
|
||||
plunger,
|
||||
ball,
|
||||
@@ -161,6 +174,12 @@ impl Assets {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
pub fn stop_all_sounds(&self) {
|
||||
for (_, sound) in &self.sounds {
|
||||
stop_sound(sound);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn texture(bytes: &[u8]) -> Texture2D {
|
||||
@@ -200,3 +219,32 @@ fn monochrome_texture(bytes: &[u8]) -> Texture2D {
|
||||
texture.set_filter(FilterMode::Nearest);
|
||||
texture
|
||||
}
|
||||
|
||||
fn masked_atlas_pair(
|
||||
bytes: &[u8],
|
||||
image_x: usize,
|
||||
mask_x: usize,
|
||||
width: usize,
|
||||
height: usize,
|
||||
) -> Texture2D {
|
||||
let atlas = Image::from_file_with_format(bytes, None).expect("embedded DAT600 must decode");
|
||||
let atlas_width = usize::from(atlas.width);
|
||||
let mut output = Image {
|
||||
bytes: vec![0; width * height * 4],
|
||||
width: u16::try_from(width).unwrap_or(0),
|
||||
height: u16::try_from(height).unwrap_or(0),
|
||||
};
|
||||
for y in 0..height {
|
||||
for x in 0..width {
|
||||
let output_index = (y * width + x) * 4;
|
||||
let image_index = ((y + 90) * atlas_width + image_x + x) * 4;
|
||||
let mask_index = ((y + 90) * atlas_width + mask_x + x) * 4;
|
||||
output.bytes[output_index..output_index + 3]
|
||||
.copy_from_slice(&atlas.bytes[image_index..image_index + 3]);
|
||||
output.bytes[output_index + 3] = u8::MAX - atlas.bytes[mask_index];
|
||||
}
|
||||
}
|
||||
let texture = Texture2D::from_image(&output);
|
||||
texture.set_filter(FilterMode::Nearest);
|
||||
texture
|
||||
}
|
||||
|
||||
+67
-2
@@ -66,6 +66,7 @@ pub enum Event {
|
||||
Tilt,
|
||||
Drain,
|
||||
HighScoreCandidate(u32),
|
||||
StopSound,
|
||||
Sound(u16),
|
||||
}
|
||||
|
||||
@@ -87,7 +88,8 @@ impl Event {
|
||||
| Self::Nudge
|
||||
| Self::Tilt
|
||||
| Self::Drain
|
||||
| Self::HighScoreCandidate(_) => None,
|
||||
| Self::HighScoreCandidate(_)
|
||||
| Self::StopSound => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -267,6 +269,7 @@ pub struct Game {
|
||||
pub claw: Claw,
|
||||
pub bumper_flash: [f32; 3],
|
||||
pub wheel_animation: f32,
|
||||
pub panel_frame: Option<u16>,
|
||||
pub nudge_shake: f32,
|
||||
pub launcher_charge: f32,
|
||||
pub finished: bool,
|
||||
@@ -310,6 +313,7 @@ impl Game {
|
||||
claw: Claw::default(),
|
||||
bumper_flash: [0.0; 3],
|
||||
wheel_animation: 0.0,
|
||||
panel_frame: None,
|
||||
nudge_shake: 0.0,
|
||||
launcher_charge: 0.0,
|
||||
finished: false,
|
||||
@@ -452,7 +456,8 @@ impl Game {
|
||||
|
||||
fn timer_tick(&mut self, elapsed: f32, substeps: u8, events: &mut Vec<Event>) {
|
||||
self.update_claw(elapsed, events);
|
||||
if !self.claw.ball_suspended {
|
||||
let panel_active = self.update_panel_completion(events);
|
||||
if !self.claw.ball_suspended && !panel_active {
|
||||
for _ in 0..substeps {
|
||||
self.fixed_update(STEP_SECONDS, events);
|
||||
self.advance_secondary_ball(events);
|
||||
@@ -488,6 +493,31 @@ impl Game {
|
||||
self.apply_flipper_kicks();
|
||||
}
|
||||
|
||||
fn update_panel_completion(&mut self, events: &mut Vec<Event>) -> bool {
|
||||
let Some(frame) = self.panel_frame else {
|
||||
return false;
|
||||
};
|
||||
if frame >= 281 {
|
||||
self.panel_frame = None;
|
||||
self.wheel_holes.fill(false);
|
||||
return true;
|
||||
}
|
||||
if frame == 1 {
|
||||
for record_id in 129..=133 {
|
||||
self.record_contacts[record_id] = 0;
|
||||
}
|
||||
}
|
||||
let next = frame + 1;
|
||||
self.panel_frame = Some(next);
|
||||
match next {
|
||||
1 | 66 | 159 | 238 => events.push(Event::Sound(2013)),
|
||||
45 | 129 | 222 => events.push(Event::StopSound),
|
||||
58 | 230 => events.push(Event::Sound(2012)),
|
||||
_ => {}
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
fn advance_tilt_counter(&mut self, elapsed: f32) {
|
||||
self.tilt_counter_accumulator += elapsed;
|
||||
while self.tilt_counter_accumulator >= self.claw_frame_seconds {
|
||||
@@ -1025,6 +1055,7 @@ impl Game {
|
||||
player.secondary_score = 0;
|
||||
player.score_multiplier = player.score_multiplier.wrapping_add(1);
|
||||
player.extra_balls = player.extra_balls.wrapping_add(1);
|
||||
self.panel_frame = Some(0);
|
||||
}
|
||||
self.reset_ball_to_launcher();
|
||||
events.push(Event::Lock);
|
||||
@@ -1354,6 +1385,7 @@ impl Game {
|
||||
self.tilt_counter_accumulator = 0.0;
|
||||
self.bumper_flash.fill(0.0);
|
||||
self.wheel_animation = 0.0;
|
||||
self.panel_frame = None;
|
||||
self.claw = Claw::default();
|
||||
self.flipper_inputs = Flippers::default();
|
||||
self.flippers = Flippers::default();
|
||||
@@ -2410,6 +2442,39 @@ mod tests {
|
||||
assert_eq!(game.capture_age, 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn panel_completion_uses_all_281_frames_and_original_sound_boundaries() {
|
||||
let mut game = Game::new(1);
|
||||
game.panel_frame = Some(0);
|
||||
game.wheel_holes.fill(true);
|
||||
for record_id in 129..=133 {
|
||||
game.record_contacts[record_id] = 99;
|
||||
}
|
||||
let mut events = Vec::new();
|
||||
|
||||
for _ in 0..282 {
|
||||
game.timer_tick(0.030, 0, &mut events);
|
||||
}
|
||||
|
||||
assert_eq!(game.panel_frame, None);
|
||||
assert_eq!(game.wheel_holes, [false; 5]);
|
||||
assert!(game.record_contacts[129..=133].iter().all(|contact| *contact == 0));
|
||||
assert_eq!(
|
||||
events,
|
||||
[
|
||||
Event::Sound(2013),
|
||||
Event::StopSound,
|
||||
Event::Sound(2012),
|
||||
Event::Sound(2013),
|
||||
Event::StopSound,
|
||||
Event::Sound(2013),
|
||||
Event::StopSound,
|
||||
Event::Sound(2012),
|
||||
Event::Sound(2013),
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn claw_release_table_decodes_the_original_thousandth_pixel_coordinates() {
|
||||
for (frame, expected_position, expected_velocity) in [
|
||||
|
||||
@@ -16,6 +16,7 @@ pub enum Scenario {
|
||||
Autoplay,
|
||||
Launcher,
|
||||
Flippers,
|
||||
Panel,
|
||||
Claw1,
|
||||
Claw6,
|
||||
Claw7,
|
||||
@@ -28,6 +29,7 @@ impl Scenario {
|
||||
Self::Autoplay => "autoplay",
|
||||
Self::Launcher => "launcher",
|
||||
Self::Flippers => "flippers",
|
||||
Self::Panel => "panel",
|
||||
Self::Claw1 => "claw-1",
|
||||
Self::Claw6 => "claw-6",
|
||||
Self::Claw7 => "claw-7",
|
||||
@@ -41,7 +43,7 @@ impl Scenario {
|
||||
Self::Claw6 => Some(6),
|
||||
Self::Claw7 => Some(7),
|
||||
Self::Claw18 => Some(18),
|
||||
Self::Autoplay | Self::Launcher | Self::Flippers => None,
|
||||
Self::Autoplay | Self::Launcher | Self::Flippers | Self::Panel => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -54,12 +56,13 @@ impl FromStr for Scenario {
|
||||
"autoplay" => Ok(Self::Autoplay),
|
||||
"launcher" => Ok(Self::Launcher),
|
||||
"flippers" => Ok(Self::Flippers),
|
||||
"panel" => Ok(Self::Panel),
|
||||
"claw-1" => Ok(Self::Claw1),
|
||||
"claw-6" => Ok(Self::Claw6),
|
||||
"claw-7" => Ok(Self::Claw7),
|
||||
"claw-18" => Ok(Self::Claw18),
|
||||
_ => Err(format!(
|
||||
"unknown scenario {value:?}; expected autoplay, launcher, flippers, claw-1, claw-6, claw-7, or claw-18"
|
||||
"unknown scenario {value:?}; expected autoplay, launcher, flippers, panel, claw-1, claw-6, claw-7, or claw-18"
|
||||
)),
|
||||
}
|
||||
}
|
||||
@@ -143,7 +146,7 @@ fn seconds_to_step(value: &str) -> Result<u64, String> {
|
||||
}
|
||||
|
||||
pub const fn usage() -> &'static str {
|
||||
"Usage:\n tdkpin-rs\n tdkpin-rs --simulate SCENARIO [--at SECONDS | --step N] [--screenshot FILE.png] [--trace FILE.json] [--seed N]\n\nScenarios: autoplay, launcher, flippers, claw-1, claw-6, claw-7, claw-18"
|
||||
"Usage:\n tdkpin-rs\n tdkpin-rs --simulate SCENARIO [--at SECONDS | --step N] [--screenshot FILE.png] [--trace FILE.json] [--seed N]\n\nScenarios: autoplay, launcher, flippers, panel, claw-1, claw-6, claw-7, claw-18"
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, PartialEq)]
|
||||
@@ -157,6 +160,7 @@ pub struct Snapshot {
|
||||
pub flippers: FlipperSnapshot,
|
||||
pub launcher_charge: f32,
|
||||
pub collision_id: Option<u8>,
|
||||
pub panel_frame: Option<u16>,
|
||||
pub events: Vec<String>,
|
||||
}
|
||||
|
||||
@@ -194,6 +198,10 @@ pub struct Simulation {
|
||||
impl Simulation {
|
||||
pub fn new(scenario: Scenario, seed: u32) -> Self {
|
||||
let mut game = Game::new_with_seed(1, seed);
|
||||
if scenario == Scenario::Panel {
|
||||
game.panel_frame = Some(0);
|
||||
game.wheel_holes.fill(true);
|
||||
}
|
||||
let initial_events = scenario
|
||||
.claw_terminal()
|
||||
.map_or_else(Vec::new, |terminal| game.begin_claw_scenario(terminal));
|
||||
@@ -274,6 +282,7 @@ impl Simulation {
|
||||
},
|
||||
launcher_charge: self.game.launcher_charge,
|
||||
collision_id: self.game.last_collision_id,
|
||||
panel_frame: self.game.panel_frame,
|
||||
events: events
|
||||
.into_iter()
|
||||
.map(|event| format!("{event:?}"))
|
||||
@@ -317,7 +326,7 @@ fn controls_for(scenario: Scenario, step: u64) -> Controls {
|
||||
..Controls::default()
|
||||
}
|
||||
}
|
||||
Scenario::Claw1 | Scenario::Claw6 | Scenario::Claw7 | Scenario::Claw18 => {
|
||||
Scenario::Panel | Scenario::Claw1 | Scenario::Claw6 | Scenario::Claw7 | Scenario::Claw18 => {
|
||||
Controls::default()
|
||||
}
|
||||
}
|
||||
@@ -432,6 +441,26 @@ mod tests {
|
||||
assert!(!simulation.game.flippers.left_raised);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn panel_scenario_runs_all_frames_and_cleans_up() {
|
||||
let mut simulation = Simulation::new(Scenario::Panel, 1);
|
||||
simulation.advance_to(4);
|
||||
assert_eq!(simulation.game.panel_frame, Some(1));
|
||||
assert_eq!(simulation.trace[4].events, ["Sound(2013)"]);
|
||||
|
||||
simulation.advance_to(1_020);
|
||||
assert_eq!(simulation.game.panel_frame, None);
|
||||
assert_eq!(simulation.game.wheel_holes, [false; 5]);
|
||||
let events = simulation
|
||||
.trace
|
||||
.iter()
|
||||
.flat_map(|snapshot| snapshot.events.iter().map(String::as_str))
|
||||
.collect::<Vec<_>>();
|
||||
assert_eq!(events.iter().filter(|event| **event == "StopSound").count(), 3);
|
||||
assert_eq!(events.iter().filter(|event| **event == "Sound(2013)").count(), 4);
|
||||
assert_eq!(events.iter().filter(|event| **event == "Sound(2012)").count(), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_claw_scenario_completes_its_seeded_capture() {
|
||||
for scenario in [
|
||||
|
||||
Reference in New Issue
Block a user