diff --git a/tdkpin-rs/RECONSTRUCTION.md b/tdkpin-rs/RECONSTRUCTION.md index e3e3f78..ed76a3b 100644 --- a/tdkpin-rs/RECONSTRUCTION.md +++ b/tdkpin-rs/RECONSTRUCTION.md @@ -19,7 +19,7 @@ implementation. | Subsystem | Rust status | Evidence and boundary | | --- | --- | --- | -| Artwork | Exact | All 34 custom DIB images, three standard bitmaps, icon, and palette derivatives are preserved in `assets/original/`. The game uses the original 640x460 table, loading, help, media, and diamond frames. | +| Artwork | Exact | All 34 custom DIB images, three standard bitmaps, icon, and palette derivatives are preserved in `assets/original/`. The game uses the original 640x460 table, loading, help, ball, wheel, robot, magnet, media, and diamond frames. | | Audio | Exact samples | All 16 mono PCM WAV resources are embedded unchanged. Their trigger roles were recovered from resource use and gameplay context. | | Help and languages | Exact | Original resource images 1001-1005 are displayed directly. | | Playfield collision layout | Recovered | The active static line segments are transcribed from the original 175-object registration table. The original sideways coordinates are transformed with `screen = (y, 478 - x)`. The lower apron is split around the visible center drain; moving flippers and round bumpers use equivalent native Rust bodies. | diff --git a/tdkpin-rs/src/app.rs b/tdkpin-rs/src/app.rs index e161057..b3a6c24 100644 --- a/tdkpin-rs/src/app.rs +++ b/tdkpin-rs/src/app.rs @@ -384,6 +384,35 @@ impl App { return; }; + if game.wheel_animation > 0.0 { + let frame = animation_frame(f64::from(0.65 - game.wheel_animation), 18.0, 5); + draw_texture_ex( + &self.assets.wheel, + 80.0, + 49.0, + WHITE, + DrawTextureParams { + dest_size: Some(vec2(68.0, 60.0)), + source: Some(Rect::new(frame as f32 * 68.0, 0.0, 68.0, 60.0)), + ..Default::default() + }, + ); + } + if game.robot_animation > 0.0 { + let frame = animation_frame(f64::from(1.0 - game.robot_animation), 14.0, 10); + draw_texture_ex( + &self.assets.robot, + 238.0, + 31.0, + WHITE, + DrawTextureParams { + dest_size: Some(vec2(90.0, 74.0)), + source: Some(Rect::new(frame as f32 * 90.0, 0.0, 90.0, 74.0)), + ..Default::default() + }, + ); + } + for (index, center) in [vec2(205.0, 47.0), vec2(234.0, 50.0), vec2(262.0, 53.0)] .into_iter() .enumerate() @@ -431,6 +460,15 @@ impl App { }, ); } + for (index, center) in [vec2(207.0, 109.0), vec2(167.0, 148.0), vec2(219.0, 167.0)] + .into_iter() + .enumerate() + { + if game.bumper_flash[index] > 0.0 { + draw_circle_lines(center.x, center.y, 17.0, 3.0, WHITE); + draw_circle_lines(center.x, center.y, 13.0, 2.0, YELLOW); + } + } if game.player().diamond_segments > 0 { let index = usize::from(game.player().diamond_segments.min(9) - 1); draw_texture(&self.assets.diamond[index], 123.0, 298.0, WHITE); @@ -450,8 +488,27 @@ impl App { YELLOW, ); } + let frame = animation_frame(get_time(), 18.0, 10); + draw_texture_ex( + &self.assets.magnet, + 306.0, + 411.0, + WHITE, + DrawTextureParams { + dest_size: Some(vec2(23.0, 17.0)), + source: Some(Rect::new(frame as f32 * 23.0, 0.0, 23.0, 17.0)), + ..Default::default() + }, + ); } + for (start, end) in [ + (vec2(103.0, 394.0), vec2(58.0, 374.0)), + (vec2(211.0, 394.0), vec2(256.0, 374.0)), + ] { + draw_line(start.x, start.y, end.x, end.y, 22.0, BLACK); + draw_circle(start.x, start.y, 11.0, BLACK); + } let (left, right) = game.flippers(); for flipper in [left, right] { draw_line( @@ -459,36 +516,48 @@ impl App { flipper.start.y, flipper.end.x, flipper.end.y, - 10.0, - DARKGRAY, + 18.0, + Color::from_rgba(32, 32, 32, 255), ); draw_line( flipper.start.x, flipper.start.y - 1.0, flipper.end.x, flipper.end.y - 1.0, - 6.0, + 12.0, LIGHTGRAY, ); - draw_circle(flipper.start.x, flipper.start.y, 6.0, GRAY); + draw_line( + flipper.start.x, + flipper.start.y - 3.0, + flipper.end.x, + flipper.end.y - 3.0, + 3.0, + WHITE, + ); + draw_circle(flipper.start.x, flipper.start.y, 9.0, DARKGRAY); + draw_circle(flipper.start.x, flipper.start.y - 1.0, 6.0, GRAY); } - draw_circle(game.ball.position.x, game.ball.position.y, 6.0, DARKGRAY); - draw_circle( - game.ball.position.x - 1.0, - game.ball.position.y - 1.0, - 4.5, - LIGHTGRAY, - ); - draw_circle( - game.ball.position.x - 2.0, - game.ball.position.y - 2.0, - 1.5, + draw_texture( + &self.assets.ball, + game.ball.position.x - 8.0, + game.ball.position.y - 8.0, WHITE, ); self.draw_displays(game); if game.tilted { - draw_centered_at("TILT", 160.0, 263.0, 30, RED); + draw_texture_ex( + &self.assets.active_table, + 82.0, + 238.0, + WHITE, + DrawTextureParams { + dest_size: Some(vec2(151.0, 33.0)), + source: Some(Rect::new(82.0, 238.0, 151.0, 33.0)), + ..Default::default() + }, + ); } draw_text("F1 HELP F2 SETUP", 345.0, 448.0, 11.0, BLACK); } @@ -636,10 +705,21 @@ impl App { let height = HEIGHT * scale; let x = (screen_width() - width) * 0.5; let y = (screen_height() - height) * 0.5; + let shake = self.game.as_ref().map_or(Vec2::ZERO, |game| { + if game.nudge_shake <= 0.0 { + return Vec2::ZERO; + } + let strength = (game.nudge_shake / 0.18).min(1.0) * 2.5 * scale; + let phase = game.nudge_shake * 950.0; + vec2( + phase.sin() * strength, + (phase * 0.73).cos() * strength * 0.45, + ) + }); draw_texture_ex( &self.render_target.texture, - x, - y, + x + shake.x, + y + shake.y, WHITE, DrawTextureParams { dest_size: Some(vec2(width, height)), @@ -673,6 +753,11 @@ 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); } +#[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 +} + fn draw_centered(text: &str, baseline: f32, font_size: u16, color: Color) { draw_centered_at(text, WIDTH * 0.5, baseline, font_size, color); } diff --git a/tdkpin-rs/src/assets.rs b/tdkpin-rs/src/assets.rs index 8c1a11b..41616e8 100644 --- a/tdkpin-rs/src/assets.rs +++ b/tdkpin-rs/src/assets.rs @@ -1,6 +1,6 @@ use macroquad::{ audio::{PlaySoundParams, Sound, load_sound_from_bytes, play_sound}, - prelude::{FilterMode, Texture2D}, + prelude::{FilterMode, Image, Texture2D}, }; pub struct Assets { @@ -10,6 +10,10 @@ pub struct Assets { pub help: [Texture2D; 5], pub media: [Texture2D; 4], pub diamond: [Texture2D; 9], + pub wheel: Texture2D, + pub robot: Texture2D, + pub magnet: Texture2D, + pub ball: Texture2D, sounds: Vec<(u16, Sound)>, } @@ -43,6 +47,13 @@ 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 robot = texture(include_bytes!("../assets/original/images/dat_00900.png")); + let magnet = texture(include_bytes!("../assets/original/images/dat_00901.png")); + let ball = masked_texture( + include_bytes!("../assets/original/images/bitmap_00101.png"), + include_bytes!("../assets/original/images/bitmap_00102.png"), + ); let sound_bytes: [(u16, &[u8]); 16] = [ ( @@ -124,6 +135,10 @@ impl Assets { help, media, diamond, + wheel, + robot, + magnet, + ball, sounds, } } @@ -149,3 +164,21 @@ fn texture(bytes: &[u8]) -> Texture2D { texture.set_filter(FilterMode::Nearest); texture } + +fn masked_texture(color_bytes: &[u8], mask_bytes: &[u8]) -> Texture2D { + let mut color = + Image::from_file_with_format(color_bytes, None).expect("embedded color bitmap must decode"); + let mask = + Image::from_file_with_format(mask_bytes, None).expect("embedded mask bitmap must decode"); + assert_eq!((color.width, color.height), (mask.width, mask.height)); + + let (color_pixels, color_remainder) = color.bytes.as_chunks_mut::<4>(); + let (mask_pixels, mask_remainder) = mask.bytes.as_chunks::<4>(); + assert!(color_remainder.is_empty() && mask_remainder.is_empty()); + for (pixel, mask_pixel) in color_pixels.iter_mut().zip(mask_pixels) { + pixel[3] = u8::MAX - mask_pixel[0]; + } + let texture = Texture2D::from_image(&color); + texture.set_filter(FilterMode::Nearest); + texture +} diff --git a/tdkpin-rs/src/game.rs b/tdkpin-rs/src/game.rs index e662553..465481a 100644 --- a/tdkpin-rs/src/game.rs +++ b/tdkpin-rs/src/game.rs @@ -2,7 +2,7 @@ use crate::geometry::{Segment, circle_collision, segment_collision}; use macroquad::prelude::{Vec2, vec2}; use std::sync::LazyLock; -const BALL_RADIUS: f32 = 5.0; +const BALL_RADIUS: f32 = 7.0; const GRAVITY: f32 = 135.0; const MAX_SPEED: f32 = 430.0; const BALL_SEARCH_DELAY: f32 = 3.0; @@ -88,6 +88,10 @@ pub struct Game { pub tilted: bool, pub left_flipper_angle: f32, pub right_flipper_angle: f32, + pub bumper_flash: [f32; 3], + pub wheel_animation: f32, + pub robot_animation: f32, + pub nudge_shake: f32, pub finished: bool, accumulator: f32, target_cooldown: f32, @@ -112,6 +116,10 @@ impl Game { tilted: false, left_flipper_angle: -2.72, right_flipper_angle: -0.42, + bumper_flash: [0.0; 3], + wheel_animation: 0.0, + robot_animation: 0.0, + nudge_shake: 0.0, finished: false, accumulator: 0.0, target_cooldown: 0.0, @@ -172,6 +180,7 @@ impl Game { self.ball.velocity.x += controls.nudge * 55.0; self.nudge_meter += controls.nudge.abs() * 0.34; self.nudge_cooldown = 0.22; + self.nudge_shake = 0.18; if self.nudge_meter >= TILT_THRESHOLD { self.tilted = true; self.bonus = 0; @@ -192,11 +201,18 @@ impl Game { events } + #[allow(clippy::too_many_lines)] fn fixed_update(&mut self, dt: f32, events: &mut Vec) { self.target_cooldown = (self.target_cooldown - dt).max(0.0); self.bumper_cooldown = (self.bumper_cooldown - dt).max(0.0); self.nudge_cooldown = (self.nudge_cooldown - dt).max(0.0); self.magnets = (self.magnets - dt).max(0.0); + self.wheel_animation = (self.wheel_animation - dt).max(0.0); + self.robot_animation = (self.robot_animation - dt).max(0.0); + self.nudge_shake = (self.nudge_shake - dt).max(0.0); + for flash in &mut self.bumper_flash { + *flash = (*flash - dt).max(0.0); + } if !self.tilted { self.nudge_meter = (self.nudge_meter - dt * 0.34).max(0.0); } @@ -243,7 +259,10 @@ impl Game { self.ball.velocity += vec2(20.0, -115.0); } - for center in [vec2(207.0, 109.0), vec2(167.0, 148.0), vec2(219.0, 167.0)] { + for (index, center) in [vec2(207.0, 109.0), vec2(167.0, 148.0), vec2(219.0, 167.0)] + .into_iter() + .enumerate() + { let hit = circle_collision( &mut self.ball.position, &mut self.ball.velocity, @@ -256,6 +275,7 @@ impl Game { self.add_score(self.player().bumper_value); self.bonus = self.bonus.saturating_add(100); self.bumper_cooldown = 0.08; + self.bumper_flash[index] = 0.16; events.push(Event::Bumper); } } @@ -356,6 +376,7 @@ impl Game { self.wheel_holes[hole] = true; self.add_score(2_500); self.target_cooldown = 0.15; + self.wheel_animation = 0.65; events.push(Event::Wheel); } if self.wheel_holes.iter().all(|hole| *hole) { @@ -368,6 +389,7 @@ impl Game { self.add_score(7_500); self.ball.velocity = vec2(-125.0, 60.0); self.target_cooldown = 0.3; + self.robot_animation = 1.0; events.push(Event::Robot); } @@ -377,6 +399,7 @@ impl Game { self.ball.position = vec2(185.0, 181.0); self.ball.velocity = vec2(-40.0 + f32::from(self.lock_lights) * 18.0, -120.0); self.target_cooldown = 0.4; + self.robot_animation = 0.8; events.push(Event::Lock); } @@ -441,6 +464,10 @@ impl Game { self.tilted = false; self.nudge_meter = 0.0; self.stalled_for = 0.0; + self.bumper_flash.fill(0.0); + self.wheel_animation = 0.0; + self.robot_animation = 0.0; + self.nudge_shake = 0.0; let mut next = (self.current_player + 1) % self.players.len(); for _ in 0..self.players.len() {