#[cfg(not(target_arch = "wasm32"))] use std::path::Path; use macroquad::prelude::*; use crate::{ assets::Assets, game::{Controls, Event, Game, Nudge}, persistence::{ HighScore, Language, Persistence, SavedData, insert_high_score, qualifies_high_score, }, }; const WIDTH: f32 = 640.0; const HEIGHT: f32 = 460.0; const DETAIL_TIMER_SECONDS: [f32; 5] = [0.050, 0.040, 0.030, 0.020, 0.010]; const ADD_PLAYER_KEYS: [KeyCode; 3] = [KeyCode::KpAdd, KeyCode::RightBracket, KeyCode::Equal]; const TARGET_POSITIONS: [[(f32, f32); 5]; 6] = [ [ (9.0, 41.0), (23.0, 11.0), (56.0, 14.0), (63.0, 47.0), (35.0, 63.0), ], [ (11.0, 45.0), (18.0, 13.0), (52.0, 11.0), (63.0, 42.0), (39.0, 64.0), ], [ (13.0, 50.0), (15.0, 17.0), (46.0, 9.0), (64.0, 37.0), (44.0, 62.0), ], [ (17.0, 55.0), (11.0, 23.0), (39.0, 7.0), (64.0, 30.0), (50.0, 59.0), ], [ (21.0, 60.0), (9.0, 28.0), (35.0, 7.0), (62.0, 25.0), (54.0, 56.0), ], [ (8.0, 35.0), (26.0, 8.0), (59.0, 18.0), (59.0, 50.0), (27.0, 61.0), ], ]; const BUMPER_VALUE_REGIONS: [(i32, i32, i32, i32); 5] = [ (103, 304, 121, 323), (119, 280, 137, 299), (148, 272, 166, 291), (177, 280, 195, 299), (193, 303, 211, 322), ]; const BANK_TARGET_REGIONS: [(u8, i32, i32, i32, i32); 9] = [ (90, 282, 231, 306, 246), (93, 282, 217, 306, 231), (96, 282, 202, 306, 216), (99, 282, 187, 306, 201), (102, 282, 174, 306, 186), (109, 192, 214, 206, 236), (112, 178, 209, 191, 231), (115, 163, 204, 177, 226), (118, 149, 198, 163, 221), ]; fn bumper_value_indicator_count(value: u32) -> usize { usize::try_from(value / 1_000) .unwrap_or(0) .saturating_sub(1) .min(BUMPER_VALUE_REGIONS.len()) } fn add_player_released() -> bool { ADD_PLAYER_KEYS.into_iter().any(is_key_released) } fn initial_game_random_seed() -> u32 { // The original seeds RandSeed from the startup clock. Macroquad's random // generator starts from a fixed zero state unless the application seeds it. macroquad::rand::srand(macroquad::miniquad::date::now().to_bits()); macroquad::rand::rand() } fn visible_score_digits(mut value: u32) -> Vec<(u8, u8)> { let mut digits = Vec::with_capacity(8); for position in 0..8 { let digit = u8::try_from(value % 10).unwrap_or(0); value /= 10; if digit != 0 || value > 0 || position == 0 { digits.push((7 - position, digit)); } } digits } #[allow(clippy::cast_possible_wrap)] fn status_level_for_score(score: u32) -> u8 { let score = score as i32; if score > 1_300_000 { 4 } else if score > 650_000 { 3 } else if score > 140_000 { 2 } else { u8::from(score > 2_000) } } fn displayed_player_markers(balls: u8, extra_balls: u8, is_current: bool) -> u8 { balls .saturating_add(extra_balls) .saturating_sub(u8::from(is_current)) .min(6) } #[derive(Clone, Copy, Debug, PartialEq, Eq)] enum Screen { Loading, Attract, Playing, Help, Settings, HighScores, NameEntry, } #[derive(Clone, Copy, Debug, Default, PartialEq)] struct AttractAnimation { tick: u32, score_counter: u32, accumulator: f32, } impl AttractAnimation { fn update(&mut self, frame_time: f32, detail: u8) { let interval = DETAIL_TIMER_SECONDS[usize::from(detail.clamp(1, 5) - 1)]; self.accumulator = (self.accumulator + frame_time.min(0.05)).min(0.1); while self.accumulator >= interval { self.tick = self.tick.wrapping_add(1); if self.tick.is_multiple_of(6) { self.score_counter = self.score_counter.wrapping_add(1); } self.accumulator -= interval; } } fn target_active(self, index: usize) -> bool { let index = u32::try_from(index).unwrap_or(0); let first_activation = if index == 0 { 32 } else { index * 2 }; self.tick >= first_activation && self.tick.wrapping_sub(index * 2) % 32 < 16 } fn active_word_quad(self) -> Option { (self.tick >= 4).then(|| { let phase = (self.tick % 32) / 4; usize::try_from(if phase <= 4 { phase } else { 8 - phase }).unwrap_or(0) }) } fn magnetic_records_active(self) -> bool { self.tick >= 32 && self.tick % 32 < 16 } fn record_strip_active(self, index: usize) -> bool { let index = u32::try_from(index).unwrap_or(0); let first_activation = if index == 0 { 40 } else { index * 4 }; self.tick >= first_activation && self.tick.wrapping_sub(index * 4) % 40 < 20 } fn chase_index(self) -> Option { (self.tick >= 4).then(|| usize::try_from((self.tick % 16) / 4).unwrap_or(0)) } fn item_index(self) -> Option { let phase = (self.tick % 144) / 8; let item = if phase < 10 { phase } else { 18 - phase }; (item != 0).then(|| usize::try_from(item - 1).unwrap_or(0)) } fn marquee_source_x(self) -> Option { (self.score_counter != 0).then(|| { let phase = self.score_counter % 49; f32::from(u16::try_from(160 + ((phase + 14) % 49) * 16).unwrap_or(160)) }) } } pub struct App { assets: Assets, persistence: Persistence, saved: SavedData, sounds_enabled: bool, render_target: RenderTarget, screen: Screen, return_screen: Screen, game: Option, game_random_seed: u32, setting_row: usize, loading_started: f64, loading_until: f64, last_help_click: f64, name: String, pending_score: u32, attract: AttractAnimation, } impl App { pub async fn load() -> Self { let assets = Assets::load().await; let persistence = Persistence::new(); let saved = persistence.load(); let sounds_enabled = saved.settings.sounds; let render_target = render_target(640, 460); render_target.texture.set_filter(FilterMode::Nearest); let loading_started = get_time(); Self { assets, persistence, saved, sounds_enabled, render_target, screen: Screen::Loading, return_screen: Screen::Attract, game: None, game_random_seed: initial_game_random_seed(), setting_row: 0, loading_started, loading_until: loading_started + 1.1, last_help_click: -1.0, name: String::new(), pending_score: 0, attract: AttractAnimation::default(), } } pub fn frame(&mut self) { #[cfg(target_arch = "wasm32")] self.assets.update(); #[cfg(target_arch = "wasm32")] self.update_remote_high_scores(); self.handle_global_input(); match self.screen { Screen::Loading => { if get_time() >= self.loading_until { self.screen = Screen::Attract; } } Screen::Attract => self.update_attract(), Screen::Playing => self.update_game(), Screen::Help => self.update_help(), Screen::Settings => self.update_settings(), Screen::HighScores => self.update_high_scores(), Screen::NameEntry => self.update_name_entry(), } self.draw_logical(); self.present(); } #[cfg(not(target_arch = "wasm32"))] pub fn set_simulation_game(&mut self, game: Game) { self.game = Some(game); self.screen = Screen::Playing; } #[cfg(not(target_arch = "wasm32"))] pub fn set_simulation_attract(&mut self, steps: u64) { self.game = None; self.screen = Screen::Attract; self.attract = AttractAnimation::default(); for _ in 0..steps { self.attract.update(1.0 / 120.0, 3); } } #[cfg(not(target_arch = "wasm32"))] pub fn set_simulation_loading(&mut self, steps: u64) { self.game = None; self.screen = Screen::Loading; let step = u32::try_from(steps).unwrap_or(u32::MAX); self.loading_started = get_time() - f64::from(step) / 120.0; self.loading_until = get_time() + 60.0; } #[cfg(not(target_arch = "wasm32"))] pub fn set_simulation_highscores(&mut self) { self.game = None; self.return_screen = Screen::Attract; self.screen = Screen::HighScores; } #[cfg(not(target_arch = "wasm32"))] pub fn set_simulation_name_entry(&mut self) { self.game = Some(Game::new(1)); self.return_screen = Screen::Playing; self.pending_score = 6_537_393; "TDK Pinball Player".clone_into(&mut self.name); self.screen = Screen::NameEntry; } #[cfg(not(target_arch = "wasm32"))] pub fn render_simulation(&self) { self.draw_logical(); } #[cfg(not(target_arch = "wasm32"))] pub fn export_simulation_png(&self, path: &Path) -> Result<(), String> { let path = path .to_str() .ok_or_else(|| format!("screenshot path is not valid UTF-8: {}", path.display()))?; self.render_target .texture .get_texture_data() .export_png(path); Ok(()) } fn handle_global_input(&mut self) { let base_screen = matches!(self.screen, Screen::Attract | Screen::Playing); if base_screen && is_key_released(KeyCode::F12) { self.sounds_enabled = !self.sounds_enabled; } if base_screen && is_key_pressed(KeyCode::F1) { self.return_screen = self.base_screen(); self.screen = Screen::Help; } if is_key_pressed(KeyCode::F10) && self.screen != Screen::NameEntry { if self.screen == Screen::Settings { self.screen = self.return_screen; } else { self.return_screen = self.base_screen(); self.screen = Screen::Settings; } } if is_key_pressed(KeyCode::F9) && self.screen != Screen::NameEntry { if self.screen == Screen::HighScores { self.screen = self.return_screen; } else { self.return_screen = self.base_screen(); self.screen = Screen::HighScores; } } } fn base_screen(&self) -> Screen { if self.game.is_some() { Screen::Playing } else { Screen::Attract } } fn update_attract(&mut self) { self.attract .update(get_frame_time(), self.saved.settings.speed); if add_player_released() { self.game = Some(Game::new_with_seed(1, self.game_random_seed)); self.screen = Screen::Playing; self.assets.play(2001, self.sounds_enabled); } } fn update_game(&mut self) { if add_player_released() && self .game .as_mut() .is_some_and(Game::add_player_before_launch) { self.assets.play(2001, self.sounds_enabled); } let left_flipper = is_key_down(KeyCode::LeftControl) || is_key_down(KeyCode::RightControl) || is_key_down(KeyCode::A) || is_key_down(KeyCode::Left); let right_flipper = is_key_down(KeyCode::KpEnter) || is_key_down(KeyCode::Enter) || is_key_down(KeyCode::D) || is_key_down(KeyCode::Right); let nudge = if is_key_released(KeyCode::LeftShift) { Nudge::Left } else if is_key_released(KeyCode::RightShift) || is_key_released(KeyCode::Kp3) { Nudge::Right } else if is_key_released(KeyCode::Space) { Nudge::Center } else { Nudge::None }; let controls = Controls { left_flipper, right_flipper, launch_down: is_key_down(KeyCode::Down), nudge, }; let events = self.game.as_mut().map_or_else(Vec::new, |game| { game.update(get_frame_time(), self.saved.settings.speed, controls) }); let mut high_score_candidate = None; for event in events { if let Event::HighScoreCandidate(score) = event { high_score_candidate = Some(score); } self.play_event(event); } if let Some(score) = high_score_candidate && qualifies_high_score(&self.saved.high_scores, score) { self.pending_score = score; "TDK Pinball Player".clone_into(&mut self.name); self.screen = Screen::NameEntry; return; } if self.game.as_ref().is_some_and(|game| game.finished) { self.finish_game(); } } 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.sounds_enabled); } } fn finish_game(&mut self) { self.take_game_and_preserve_random_seed(); self.return_screen = Screen::Attract; self.screen = Screen::HighScores; } fn take_game_and_preserve_random_seed(&mut self) { if let Some(game) = self.game.take() { self.game_random_seed = game.random_seed(); } } fn update_help(&mut self) { if is_key_pressed(KeyCode::Escape) || is_key_pressed(KeyCode::Enter) { self.screen = self.return_screen; } if is_mouse_button_pressed(MouseButton::Left) { let point = Self::logical_mouse(); if point.x < 145.0 && point.y < 115.0 { let now = get_time(); if now - self.last_help_click <= 0.40 { self.save(); #[cfg(not(target_arch = "wasm32"))] macroquad::miniquad::window::request_quit(); } self.last_help_click = now; } } } fn update_settings(&mut self) { if is_key_pressed(KeyCode::Escape) || is_key_pressed(KeyCode::Enter) { self.save(); self.screen = self.return_screen; return; } if is_key_pressed(KeyCode::Up) { self.setting_row = (self.setting_row + 2) % 3; } if is_key_pressed(KeyCode::Down) { self.setting_row = (self.setting_row + 1) % 3; } let direction = i8::from(is_key_pressed(KeyCode::Right)) - i8::from(is_key_pressed(KeyCode::Left)); if direction == 0 && !is_key_pressed(KeyCode::Space) { return; } match self.setting_row { 0 => { let speed = i16::from(self.saved.settings.speed) + i16::from(direction); self.saved.settings.speed = u8::try_from(speed.clamp(1, 5)).unwrap_or(3); } 1 => { self.sounds_enabled = !self.sounds_enabled; self.saved.settings.sounds = self.sounds_enabled; } _ => { let current = Language::ALL .iter() .position(|language| *language == self.saved.settings.language) .unwrap_or(0); let next = if direction < 0 { (current + Language::ALL.len() - 1) % Language::ALL.len() } else { (current + 1) % Language::ALL.len() }; self.saved.settings.language = Language::ALL[next]; } } self.assets.play(2012, self.sounds_enabled); self.save(); } fn update_high_scores(&mut self) { if is_key_pressed(KeyCode::Escape) || is_key_pressed(KeyCode::Enter) || is_key_pressed(KeyCode::F3) { self.screen = self.return_screen; if self.screen == Screen::Attract { self.attract = AttractAnimation::default(); } } } fn update_name_entry(&mut self) { while let Some(character) = get_char_pressed() { if !character.is_control() && u32::from(character) <= 0xff && self.name.chars().count() < 21 { self.name.push(character); } } if is_key_pressed(KeyCode::Backspace) { self.name.pop(); } let click = is_mouse_button_pressed(MouseButton::Left).then(Self::logical_mouse); let clicked_ok = click.is_some_and(|point| Rect::new(255.0, 256.0, 60.0, 23.0).contains(point)); let clicked_cancel = click.is_some_and(|point| Rect::new(324.0, 256.0, 60.0, 23.0).contains(point)); if is_key_pressed(KeyCode::Escape) || clicked_cancel { "TDK Pinball Player".clone_into(&mut self.name); self.accept_high_score_name(); } else if is_key_pressed(KeyCode::Enter) || clicked_ok { self.accept_high_score_name(); } } fn accept_high_score_name(&mut self) { let entry = HighScore { name: self.name.clone(), score: self.pending_score, }; insert_high_score(&mut self.saved.high_scores, entry.clone()); #[cfg(target_arch = "wasm32")] self.persistence.submit_high_score(&entry); self.save(); if self.game.as_ref().is_some_and(|game| game.finished) { self.take_game_and_preserve_random_seed(); self.return_screen = Screen::Attract; } else { self.return_screen = Screen::Playing; } self.screen = Screen::HighScores; } fn draw_logical(&self) { let mut camera = Camera2D::from_display_rect(Rect::new(0.0, 0.0, WIDTH, HEIGHT)); camera.render_target = Some(self.render_target.clone()); set_camera(&camera); clear_background(BLACK); match self.screen { Screen::Loading => self.draw_loading(), Screen::Help => { let index = Language::ALL .iter() .position(|language| *language == self.saved.settings.language) .unwrap_or(0); draw_texture(&self.assets.help[index], 0.0, 0.0, WHITE); } Screen::Attract => self.draw_attract(), Screen::Playing => self.draw_game(), Screen::Settings => self.draw_settings(), Screen::HighScores => self.draw_high_scores(), Screen::NameEntry => self.draw_name_entry(), } } fn draw_attract(&self) { draw_texture(&self.assets.inactive_table, 0.0, 0.0, WHITE); for (index, (left, top, right, bottom)) in [ (17, 174, 31, 188), (11, 155, 25, 169), (10, 136, 24, 150), (10, 117, 24, 131), (10, 98, 24, 112), (10, 79, 24, 93), (10, 60, 24, 79), (10, 41, 24, 55), ] .into_iter() .enumerate() { if self.attract.target_active(index) { self.draw_active_table_region(left, top, right - left + 1, bottom - top + 1); } } if let Some(active_quad) = self.attract.active_word_quad() { let (left, top, right, bottom) = BUMPER_VALUE_REGIONS[active_quad]; self.draw_active_table_region(left, top, right - left + 1, bottom - top + 1); } if self.attract.magnetic_records_active() { for (x, y, width, height) in [(151, 389, 13, 49), (11, 344, 13, 49), (291, 346, 13, 49)] { self.draw_active_table_region(x, y, width, height); } } for (index, (left, top, right, bottom)) in [ (282, 231, 306, 246), (282, 217, 306, 231), (282, 202, 306, 216), (282, 187, 306, 201), (282, 174, 306, 186), ] .into_iter() .enumerate() { if self.attract.record_strip_active(index) { self.draw_active_table_region(left, top, right - left - 10, bottom - top + 1); } } if let Some(phase) = self.attract.chase_index() { let y = [209, 214, 219, 224][phase]; let x = [150, 164, 178, 192][phase]; self.draw_active_table_region(x, y, 9, 9); } if let Some(item) = self.attract.item_index() { draw_texture(&self.assets.diamond[item], 123.0, 300.0, WHITE); } self.draw_intro_marquee(); } #[allow(clippy::cast_precision_loss)] fn draw_loading(&self) { draw_texture(&self.assets.loading, 0.0, 0.0, WHITE); let stage = Self::loading_stage_at(get_time() - self.loading_started); let width = i32::from(stage.saturating_sub(1)) * 7; draw_texture_region( &self.assets.loading_progress, 202.0, 328.0, width.min(237) as f32, 13.0, 0.0, 0.0, ); } #[allow(clippy::cast_possible_truncation)] fn loading_stage_at(elapsed: f64) -> u8 { let progress = (elapsed / 1.1).clamp(0.0, 0.999_999); u8::try_from(2 + (progress * 34.0).floor() as i32).unwrap_or(35) } fn draw_intro_marquee(&self) { let Some(source_x) = self.attract.marquee_source_x() else { return; }; draw_texture_region( &self.assets.active_table, 372.0, 233.0, 100.0, 30.0, 473.0, 277.0, ); draw_texture_region( &self.assets.active_table, 472.0, 233.0, 137.0, 30.0, 472.0, 233.0, ); let available = 944.0 - source_x; let first_width = available.min(237.0); self.draw_marquee_slice(372.0, source_x, first_width); let wrapped_width = 237.0 - first_width; if wrapped_width > 0.0 { self.draw_marquee_slice(372.0 + first_width, 160.0, wrapped_width); } } fn draw_marquee_slice(&self, destination_x: f32, source_x: f32, width: f32) { draw_texture_ex( &self.assets.digits, destination_x, 233.0, BLACK, DrawTextureParams { dest_size: Some(vec2(width, 28.0)), source: Some(Rect::new(source_x, 0.0, width, 28.0)), ..Default::default() }, ); } #[allow(clippy::cast_precision_loss)] fn draw_active_table_region(&self, x: i32, y: i32, width: i32, height: i32) { draw_texture_region( &self.assets.active_table, x as f32, y as f32, width as f32, height as f32, x as f32, y as f32, ); } #[allow(clippy::cast_precision_loss)] fn draw_inactive_table_region(&self, x: i32, y: i32, width: i32, height: i32) { draw_texture_region( &self.assets.inactive_table, x as f32, y as f32, width as f32, height as f32, x as f32, y as f32, ); } #[allow(clippy::cast_precision_loss, clippy::too_many_lines)] fn draw_game(&self) { // Resource 998 is the original game's resting playfield. Resource 997 // contains exact lit-state patches used below for the active displays. draw_texture(&self.assets.inactive_table, 0.0, 0.0, WHITE); let Some(game) = &self.game else { return; }; draw_texture( &self.assets.player_effects[usize::from(game.player_effect())], 55.0, 79.0, WHITE, ); self.draw_inactive_table_region(84, 77, 14, 14); if let Some(frame) = game.panel_frame { self.draw_panel_animation(frame); } else if let Some(state) = game .target_rotation_state .or_else(|| game.wheel_holes.iter().any(|filled| *filled).then_some(6)) { self.draw_target_rotation(game, state); } for (record_id, left, top, right, bottom) in [ (51, 150, 133, 180, 163), (52, 191, 95, 221, 125), (53, 204, 150, 234, 180), (140, 17, 174, 31, 188), (141, 11, 155, 25, 169), (142, 10, 136, 24, 150), (143, 10, 117, 24, 131), (144, 10, 98, 24, 112), (145, 10, 79, 24, 93), (146, 10, 60, 24, 79), (147, 10, 41, 24, 55), (150, 199, 40, 211, 50), (151, 228, 41, 240, 51), (152, 257, 42, 269, 52), ] { if game.record_countdown(record_id) != 0 { let width = (right - left + 1) as f32; let height = (bottom - top + 1) as f32; draw_texture_region( &self.assets.active_table, left as f32, top as f32, width, height, left as f32, top as f32, ); } } for (record_id, left, top, right, bottom) in BANK_TARGET_REGIONS { if game.bank_target_lit(record_id) { self.draw_active_table_region(left, top, right - left + 1, bottom - top + 1); } } 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, 300.0, WHITE); } for &(left, top, right, bottom) in BUMPER_VALUE_REGIONS .iter() .take(bumper_value_indicator_count(game.player().bumper_value)) { self.draw_active_table_region(left, top, right - left + 1, bottom - top + 1); } for (object_id, region) in [ (6, Rect::new(151.0, 389.0, 13.0, 49.0)), (153, Rect::new(11.0, 344.0, 13.0, 49.0)), (154, Rect::new(291.0, 346.0, 13.0, 49.0)), ] { if game.magnetic_field_active(object_id) { draw_texture_ex( &self.assets.active_table, region.x, region.y, WHITE, DrawTextureParams { dest_size: Some(vec2(region.w, region.h)), source: Some(region), ..Default::default() }, ); } } if game.effect_target_active() { let region = Rect::new(60.0, 57.0, 18.0, 17.0); draw_texture_ex( &self.assets.active_table, region.x, region.y, WHITE, DrawTextureParams { dest_size: Some(vec2(region.w, region.h)), source: Some(region), ..Default::default() }, ); } if game.special_hole_active() { self.draw_active_table_region(9, 14, 17, 17); } if game.ball.in_launcher { draw_texture_ex( &self.assets.plunger, 314.0, 426.0, WHITE, DrawTextureParams { dest_size: Some(vec2(21.0, 18.0)), source: Some(Rect::new( game.launcher_frame() as f32 * 21.0, 0.0, 20.0, 17.0, )), ..Default::default() }, ); } for (raised, region) in [ ( game.flippers.left_raised, Rect::new(93.0, 373.0, 47.0, 52.0), ), ( game.flippers.right_raised, Rect::new(175.0, 372.0, 45.0, 53.0), ), ] { if raised { draw_texture_ex( &self.assets.active_table, region.x, region.y, WHITE, DrawTextureParams { dest_size: Some(vec2(region.w, region.h)), source: Some(region), ..Default::default() }, ); } } if !game.claw.ball_suspended { draw_texture( &self.assets.ball, game.ball.position.x - 8.0, game.ball.position.y - 8.0, WHITE, ); } if let Some(ball) = game.secondary_ball { draw_texture( &self.assets.ball, ball.position.x - 8.0, ball.position.y - 8.0, WHITE, ); } if let Some(source) = game.claw.sprite_source() { draw_texture_ex( &self.assets.robot, 238.0, 47.0, WHITE, DrawTextureParams { dest_size: Some(vec2(100.0, 74.0)), source: Some(source), ..Default::default() }, ); } self.draw_displays(game); if game.tilted { 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() }, ); } } #[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_target_rotation(&self, game: &Game, state: u8) { if state == 0 || state > 6 { return; } if state < 6 { draw_texture_region( &self.assets.wheel, 79.0, 33.0, 91.0, 90.0, f32::from(state - 1) * 91.0, 0.0, ); } else { draw_texture_region( &self.assets.inactive_table, 79.0, 33.0, 91.0, 90.0, 79.0, 33.0, ); } for (active, (x, y)) in game .wheel_holes .iter() .zip(TARGET_POSITIONS[usize::from(state - 1)]) { if *active { draw_texture(&self.assets.panel_target, 79.0 + x, 33.0 + y, WHITE); } } } #[allow(clippy::cast_precision_loss)] fn draw_displays(&self, game: &Game) { self.draw_active_table_region(372, 233, 238, 30); self.draw_score_value(game.secondary_score_display(), 233.0); let status_level = status_level_for_score(game.player().score); if status_level == 0 { self.draw_active_table_region(380, 69, 146, 142); } else { draw_texture( &self.assets.media[usize::from(status_level - 1)], 380.0, 69.0, WHITE, ); } self.draw_active_table_region(544, i32::from(4 - status_level) * 23 + 77, 11, 11); for (index, player) in game.players.iter().enumerate() { let y = 276 + i32::try_from(index).unwrap_or(0) * 44; self.draw_active_table_region(472, y, 139, 31); self.draw_score_value(player.score, (y + 1) as f32); let displayed_balls = displayed_player_markers( player.balls, player.extra_balls, index == game.current_player, ); for marker in 0..6 { let x = 391 + i32::from(marker) * 8; if marker < displayed_balls { self.draw_active_table_region(x, y + 22, 5, 5); } else { self.draw_inactive_table_region(x, y + 22, 5, 5); } } } self.draw_active_table_region( 372, 280 + i32::try_from(game.current_player).unwrap_or(0) * 44, 11, 11, ); } fn draw_score_value(&self, value: u32, y: f32) { for (slot, digit) in visible_score_digits(value) { let x = 478.0 + f32::from(slot) * 16.0; draw_texture_ex( &self.assets.digits, x, y, BLACK, DrawTextureParams { dest_size: Some(vec2(16.0, 28.0)), source: Some(Rect::new(f32::from(digit) * 16.0, 0.0, 16.0, 28.0)), ..Default::default() }, ); } } #[allow(clippy::cast_precision_loss)] fn draw_settings(&self) { draw_texture(&self.assets.inactive_table, 0.0, 0.0, WHITE); draw_panel(145.0, 82.0, 350.0, 285.0); draw_centered("SETTINGS", 122.0, 30, BLACK); let rows = [ format!("GRAPHICS DETAIL / SPEED: {}", self.saved.settings.speed), format!("SOUND: {}", if self.sounds_enabled { "ON" } else { "OFF" }), format!("LANGUAGE: {}", self.saved.settings.language.label()), ]; for (index, row) in rows.iter().enumerate() { let y = 181.0 + index as f32 * 52.0; if index == self.setting_row { draw_rectangle( 174.0, y - 27.0, 292.0, 38.0, Color::from_rgba(0, 191, 209, 255), ); } draw_centered(row, y, 19, BLACK); } draw_centered("UP/DOWN SELECT LEFT/RIGHT CHANGE", 328.0, 14, BLACK); draw_centered("ENTER OR ESC TO RETURN", 349.0, 14, BLACK); } #[allow(clippy::cast_precision_loss)] fn draw_high_scores(&self) { self.draw_modal_background(); draw_rectangle(140.0, 58.0, 360.0, 22.0, Color::from_rgba(0, 0, 128, 255)); draw_rectangle_lines(140.0, 58.0, 360.0, 22.0, 2.0, WHITE); draw_text("HighScore", 147.0, 74.0, 14.0, WHITE); draw_texture(&self.assets.highscore_background, 140.0, 80.0, WHITE); draw_centered_at("HIGH SCORES", 320.0, 122.0, 18, BLACK); for (index, entry) in self.saved.high_scores.iter().take(10).enumerate() { let rank = index + 1; let y = 155.0 + index as f32 * 20.0; let prefix = if rank < 10 { " " } else { "" }; draw_text( format!("{prefix}{rank}. {}", entry.name), 170.0, y, 16.0, BLACK, ); let score = entry.score.to_string(); let width = measure_text(&score, None, 16, 1.0).width; draw_text(&score, 460.0 - width, y, 16.0, BLACK); } } fn draw_name_entry(&self) { self.draw_modal_background(); let x = 185.0; let y = 168.0; draw_rectangle(x, y, 270.0, 124.0, Color::from_rgba(192, 192, 192, 255)); draw_rectangle_lines(x, y, 270.0, 124.0, 2.0, WHITE); draw_rectangle_lines(x + 2.0, y + 2.0, 266.0, 120.0, 2.0, DARKGRAY); draw_rectangle( x + 4.0, y + 4.0, 262.0, 20.0, Color::from_rgba(0, 0, 128, 255), ); draw_text( "Congratulations! This is a Top Ten Score!", x + 9.0, y + 19.0, 12.0, WHITE, ); draw_text("Please input your name:", x + 15.0, y + 44.0, 14.0, BLACK); draw_rectangle(x + 15.0, y + 52.0, 240.0, 21.0, WHITE); draw_rectangle_lines(x + 14.0, y + 51.0, 242.0, 23.0, 2.0, DARKGRAY); draw_text( format!( "{}{}", self.name, if get_time().rem_euclid(1.0) < 0.5 { "_" } else { "" } ), x + 18.0, y + 68.0, 14.0, BLACK, ); Self::draw_dialog_button(x + 70.0, y + 88.0, "OK", true); Self::draw_dialog_button(x + 139.0, y + 88.0, "Cancel", false); } fn draw_modal_background(&self) { if self.game.is_some() { self.draw_game(); } else { self.draw_attract(); } } fn draw_dialog_button(x: f32, y: f32, label: &str, default: bool) { if default { draw_rectangle_lines(x - 2.0, y - 2.0, 64.0, 27.0, 1.0, BLACK); } draw_rectangle(x, y, 60.0, 23.0, Color::from_rgba(192, 192, 192, 255)); draw_rectangle_lines(x, y, 60.0, 23.0, 2.0, WHITE); draw_centered_at(label, x + 30.0, y + 17.0, 13, BLACK); } fn present(&self) { set_default_camera(); clear_background(BLACK); let scale = (screen_width() / WIDTH).min(screen_height() / HEIGHT); let width = WIDTH * scale; let height = HEIGHT * scale; let x = (screen_width() - width) * 0.5; let y = (screen_height() - height) * 0.5; draw_texture_ex( &self.render_target.texture, x, y, WHITE, DrawTextureParams { dest_size: Some(vec2(width, height)), flip_y: true, ..Default::default() }, ); } fn logical_mouse() -> Vec2 { let scale = (screen_width() / WIDTH).min(screen_height() / HEIGHT); let width = WIDTH * scale; let height = HEIGHT * scale; let offset = vec2( (screen_width() - width) * 0.5, (screen_height() - height) * 0.5, ); (vec2(mouse_position().0, mouse_position().1) - offset) / scale } fn save(&self) { if let Err(error) = self.persistence.save(&self.saved) { eprintln!("could not save settings and highscores: {error}"); } } #[cfg(target_arch = "wasm32")] fn update_remote_high_scores(&mut self) { if let Some(scores) = self.persistence.take_high_scores() { self.saved.high_scores = scores; self.save(); } } } fn draw_panel(x: f32, y: f32, width: f32, height: f32) { draw_rectangle(x, y, width, height, Color::from_rgba(214, 214, 214, 248)); draw_rectangle_lines(x, y, width, height, 3.0, WHITE); 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() }, ); } fn draw_centered(text: &str, baseline: f32, font_size: u16, color: Color) { draw_centered_at(text, WIDTH * 0.5, baseline, font_size, color); } fn draw_centered_at(text: &str, center_x: f32, baseline: f32, font_size: u16, color: Color) { let dimensions = measure_text(text, None, font_size, 1.0); draw_text( text, center_x - dimensions.width * 0.5, baseline, f32::from(font_size), color, ); } #[cfg(test)] mod tests { use super::*; #[test] fn attract_timer_uses_the_selected_detail_callback() { for (detail, interval) in [(1, 0.050), (2, 0.040), (3, 0.030), (4, 0.020), (5, 0.010)] { let mut animation = AttractAnimation::default(); animation.update(interval - 0.001, detail); assert_eq!(animation.tick, 0); animation.update(0.001_1, detail); assert_eq!(animation.tick, 1); } } #[test] fn loading_progress_covers_original_stages_two_through_thirty_five() { assert_eq!(App::loading_stage_at(-1.0), 2); assert_eq!(App::loading_stage_at(0.0), 2); assert_eq!(App::loading_stage_at(1.1 / 34.0), 3); assert_eq!(App::loading_stage_at(1.09), 35); assert_eq!(App::loading_stage_at(99.0), 35); assert_eq!(i32::from(App::loading_stage_at(99.0) - 1) * 7, 238); } #[test] fn score_renderer_uses_eight_places_and_original_right_edge() { assert_eq!(visible_score_digits(0), [(7, 0)]); assert_eq!(visible_score_digits(123), [(7, 3), (6, 2), (5, 1)]); let digits = visible_score_digits(123_456_789); assert_eq!(digits.len(), 8); assert_eq!(digits[0], (7, 9)); assert_eq!(digits[7], (0, 2)); assert_eq!(478 + i32::from(digits[0].0) * 16 + 16, 606); } #[test] fn status_and_ball_markers_follow_original_display_thresholds() { assert_eq!(status_level_for_score(2_000), 0); assert_eq!(status_level_for_score(2_001), 1); assert_eq!(status_level_for_score(140_000), 1); assert_eq!(status_level_for_score(140_001), 2); assert_eq!(status_level_for_score(650_001), 3); assert_eq!(status_level_for_score(1_300_001), 4); assert_eq!(status_level_for_score(u32::MAX), 0); assert_eq!(displayed_player_markers(3, 0, true), 2); assert_eq!(displayed_player_markers(3, 0, false), 3); assert_eq!(displayed_player_markers(3, 4, true), 6); assert_eq!(bumper_value_indicator_count(1_000), 0); assert_eq!(bumper_value_indicator_count(2_000), 1); assert_eq!(bumper_value_indicator_count(6_000), 5); assert_eq!(bumper_value_indicator_count(u32::MAX), 5); assert_eq!( ADD_PLAYER_KEYS, [KeyCode::KpAdd, KeyCode::RightBracket, KeyCode::Equal] ); } #[test] fn attract_phases_follow_the_reconstructed_tick_quotients() { let at = |tick| AttractAnimation { tick, ..AttractAnimation::default() }; assert!(!at(31).target_active(0)); assert!(at(32).target_active(0)); assert!(at(2).target_active(1)); assert!(!at(18).target_active(1)); assert!(at(34).target_active(1)); assert_eq!(at(3).active_word_quad(), None); assert_eq!(at(4).active_word_quad(), Some(1)); assert_eq!(at(16).active_word_quad(), Some(4)); assert_eq!(at(20).active_word_quad(), Some(3)); assert_eq!(at(32).active_word_quad(), Some(0)); assert!(!at(31).magnetic_records_active()); assert!(at(32).magnetic_records_active()); assert!(!at(48).magnetic_records_active()); assert!(!at(39).record_strip_active(0)); assert!(at(40).record_strip_active(0)); assert!(at(4).record_strip_active(1)); assert!(!at(24).record_strip_active(1)); assert_eq!(at(4).chase_index(), Some(1)); assert_eq!(at(8).chase_index(), Some(2)); assert_eq!(at(12).chase_index(), Some(3)); assert_eq!(at(16).chase_index(), Some(0)); assert_eq!(at(7).item_index(), None); assert_eq!(at(8).item_index(), Some(0)); assert_eq!(at(72).item_index(), Some(8)); assert_eq!(at(80).item_index(), Some(7)); assert_eq!(at(136).item_index(), Some(0)); assert_eq!(at(144).item_index(), None); } #[test] fn attract_marquee_counter_advances_every_six_callbacks() { let mut animation = AttractAnimation::default(); for _ in 0..5 { animation.update(0.030, 3); } assert_eq!(animation.score_counter, 0); animation.update(0.030, 3); assert_eq!(animation.score_counter, 1); assert_eq!(animation.marquee_source_x(), Some(400.0)); animation.score_counter = 35; assert_eq!(animation.marquee_source_x(), Some(160.0)); animation.score_counter = 49; assert_eq!(animation.marquee_source_x(), Some(384.0)); } }