use crate::{ assets::Assets, game::{Controls, Event, Game}, persistence::{HighScore, Language, Persistence, SavedData, insert_high_score}, table::BUMPERS, }; use macroquad::prelude::*; const WIDTH: f32 = 640.0; const HEIGHT: f32 = 460.0; #[derive(Clone, Copy, Debug, PartialEq, Eq)] enum Screen { Loading, Attract, Playing, Help, Settings, HighScores, NameEntry, } pub struct App { assets: Assets, persistence: Persistence, saved: SavedData, render_target: RenderTarget, screen: Screen, return_screen: Screen, game: Option, player_count: usize, setting_row: usize, loading_until: f64, last_help_click: f64, message: String, message_until: f64, name: String, pending_score: u32, } impl App { pub async fn load() -> Self { let assets = Assets::load().await; let persistence = Persistence::new(); let saved = persistence.load(); let render_target = render_target(640, 460); render_target.texture.set_filter(FilterMode::Nearest); Self { assets, persistence, saved, render_target, screen: Screen::Loading, return_screen: Screen::Attract, game: None, player_count: 1, setting_row: 0, loading_until: get_time() + 1.1, last_help_click: -1.0, message: String::new(), message_until: 0.0, name: String::new(), pending_score: 0, } } pub fn frame(&mut self) { 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(); } fn handle_global_input(&mut self) { if is_key_pressed(KeyCode::F12) { self.saved.settings.sounds = !self.saved.settings.sounds; if self.saved.settings.sounds { "SOUND ON" } else { "SOUND OFF" } .clone_into(&mut self.message); self.message_until = get_time() + 1.2; self.save(); } if is_key_pressed(KeyCode::F1) && self.screen != Screen::NameEntry { if self.screen == Screen::Help { self.screen = self.return_screen; } else { self.return_screen = self.base_screen(); self.screen = Screen::Help; } } if is_key_pressed(KeyCode::F2) && 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::F3) && 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) { if is_key_pressed(KeyCode::KpAdd) || is_key_pressed(KeyCode::Equal) { self.player_count = self.player_count % 4 + 1; self.assets.play(2012, self.saved.settings.sounds); } if is_key_pressed(KeyCode::Down) || is_key_pressed(KeyCode::Enter) { self.game = Some(Game::new(self.player_count)); self.screen = Screen::Playing; self.assets.play(2008, self.saved.settings.sounds); } } fn update_game(&mut self) { let left_flipper = is_key_down(KeyCode::LeftControl) || is_key_down(KeyCode::A) || is_key_down(KeyCode::Left); let right_flipper = is_key_down(KeyCode::KpEnter) || is_key_down(KeyCode::RightControl) || is_key_down(KeyCode::D) || is_key_down(KeyCode::Right); let nudge = if is_key_pressed(KeyCode::LeftShift) { -1.0 } else if is_key_pressed(KeyCode::RightShift) || is_key_pressed(KeyCode::Kp3) { 1.0 } else if is_key_pressed(KeyCode::Space) { if self .game .as_ref() .is_some_and(|game| game.ball.position.x < WIDTH / 4.0) { 1.0 } else { -1.0 } } else { 0.0 }; 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) }); for event in events { self.play_event(event); } if self.game.as_ref().is_some_and(|game| game.finished) { self.finish_game(); } } fn play_event(&self, event: Event) { let id = match event { Event::Flipper => 2001, Event::Launch => 2016, Event::Bumper => 2002, Event::Target => 2006, Event::Wheel => 2004, Event::Robot => 2007, Event::Lock => 2017, Event::Media => 2022, Event::ExtraBall => 2019, Event::Nudge | Event::BallSearch => 2021, Event::Tilt => 2015, Event::Drain => 2013, }; self.assets.play(id, self.saved.settings.sounds); } fn finish_game(&mut self) { self.pending_score = self .game .as_ref() .and_then(|game| game.players.iter().map(|player| player.score).max()) .unwrap_or(0); let qualifies = self.saved.high_scores.len() < 10 || self .saved .high_scores .last() .is_some_and(|entry| self.pending_score > entry.score); if qualifies { self.name.clear(); self.screen = Screen::NameEntry; } else { self.game = None; self.return_screen = Screen::Attract; self.screen = Screen::HighScores; } self.assets.play(2020, self.saved.settings.sounds); } 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(); 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.saved.settings.sounds = !self.saved.settings.sounds; } _ => { 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.saved.settings.sounds); 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; } } fn update_name_entry(&mut self) { while let Some(character) = get_char_pressed() { if !character.is_control() && self.name.chars().count() < 21 { self.name.push(character); } } if is_key_pressed(KeyCode::Backspace) { self.name.pop(); } if is_key_pressed(KeyCode::Enter) && !self.name.trim().is_empty() { insert_high_score( &mut self.saved.high_scores, HighScore { name: self.name.trim().to_owned(), score: self.pending_score, }, ); self.save(); self.game = None; self.return_screen = Screen::Attract; 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 => draw_texture(&self.assets.loading, 0.0, 0.0, WHITE), 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(), } if get_time() < self.message_until { draw_panel(232.0, 205.0, 176.0, 48.0); draw_centered(&self.message, 229.0, 24, BLACK); } } fn draw_attract(&self) { draw_texture(&self.assets.inactive_table, 0.0, 0.0, WHITE); draw_panel(342.0, 72.0, 267.0, 144.0); draw_centered_at("TDK PINBALL MACHINE", 475.5, 107.0, 22, BLACK); draw_centered_at( "PRESS DOWN ARROW", 475.5, 139.0, 22, Color::from_rgba(0, 72, 102, 255), ); draw_centered_at("TO START BALL", 475.5, 165.0, 18, BLACK); draw_centered_at( &format!("PLAYERS: {} [+] CHANGE", self.player_count), 475.5, 196.0, 16, BLACK, ); draw_text( "F1 HELP F2 SETTINGS F3 HIGHSCORES F12 SOUND", 322.0, 448.0, 10.0, BLACK, ); } #[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; }; 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, bumper) in BUMPERS.into_iter().enumerate() { if game.bumper_flash[index] > 0.0 { draw_circle_lines(bumper.center.x, bumper.center.y, 17.0, 3.0, WHITE); draw_circle_lines(bumper.center.x, bumper.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); } if game.magnets > 0.0 { for x in [18.0, 296.0] { draw_triangle( vec2(x, 345.0), vec2(x - 7.0, 359.0), vec2(x + 7.0, 359.0), YELLOW, ); draw_triangle( vec2(x, 367.0), vec2(x - 7.0, 381.0), vec2(x + 7.0, 381.0), YELLOW, ); } } if game.ball.in_launcher { let frame = [0.05, 0.15, 0.25, 0.35, 0.45, 0.55, 0.65, 0.75, 0.85, 0.95] .partition_point(|threshold| game.launcher_charge >= *threshold); draw_texture_ex( &self.assets.plunger, 314.0, 426.0, WHITE, DrawTextureParams { dest_size: Some(vec2(21.0, 18.0)), source: Some(Rect::new(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() }, ); } } 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_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)] fn draw_displays(&self, game: &Game) { for region in [ Rect::new(383.0, 72.0, 140.0, 136.0), Rect::new(545.0, 170.0, 9.0, 9.0), Rect::new(372.0, 233.0, 238.0, 29.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() }, ); } self.draw_right_aligned_digits(&game.bonus.to_string(), 610.0, 240.0); let media_level = game.player().media_level; if media_level > 0 { let index = usize::from(media_level.saturating_sub(1).min(3)); draw_texture(&self.assets.media[index], 380.0, 72.0, WHITE); } let row_y = 277.0 + game.current_player as f32 * 44.0; for region in [ Rect::new(373.0, row_y + 4.0, 9.0, 9.0), Rect::new(473.0, row_y, 137.0, 29.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() }, ); } let displayed_balls = game .player() .balls .saturating_sub(1) .saturating_add(game.player().extra_balls) .min(6); for ball in 0..displayed_balls { let x = 392.0 + f32::from(ball) * 8.0; draw_texture_ex( &self.assets.active_table, x, row_y + 22.0, WHITE, DrawTextureParams { dest_size: Some(vec2(3.0, 3.0)), source: Some(Rect::new(x, row_y + 22.0, 3.0, 3.0)), ..Default::default() }, ); } for (index, player) in game.players.iter().enumerate() { self.draw_right_aligned_digits( &player.score.to_string(), 610.0, 280.0 + index as f32 * 44.0, ); } } fn draw_right_aligned_digits(&self, digits: &str, right: f32, y: f32) { let digit_count = u16::try_from(digits.bytes().filter(u8::is_ascii_digit).count()) .expect("a display value has at most ten digits"); let width = f32::from(digit_count) * 10.0; self.draw_digits(digits, vec2(right - width, y), vec2(10.0, 18.0)); } fn draw_digits(&self, digits: &str, position: Vec2, digit_size: Vec2) { let mut x = position.x; for digit in digits.bytes() { if !digit.is_ascii_digit() { continue; } let source_x = f32::from(digit - b'0') * 16.0; draw_texture_ex( &self.assets.digits, x, position.y, BLACK, DrawTextureParams { dest_size: Some(digit_size), source: Some(Rect::new(source_x, 0.0, 16.0, 28.0)), ..Default::default() }, ); x += digit_size.x; } } #[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.saved.settings.sounds { "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) { draw_texture(&self.assets.inactive_table, 0.0, 0.0, WHITE); draw_panel(124.0, 32.0, 392.0, 398.0); draw_centered("TDK HIGHSCORES", 72.0, 28, BLACK); for (index, entry) in self.saved.high_scores.iter().take(10).enumerate() { let y = 110.0 + index as f32 * 29.0; draw_text(format!("{:>2}.", index + 1), 153.0, y, 17.0, BLACK); draw_text(&entry.name, 190.0, y, 17.0, BLACK); let score = format!("{:09}", entry.score); draw_text(&score, 389.0, y, 17.0, Color::from_rgba(0, 72, 102, 255)); } draw_centered("ENTER / ESC TO RETURN", 409.0, 14, BLACK); } fn draw_name_entry(&self) { draw_texture(&self.assets.active_table, 0.0, 0.0, WHITE); draw_panel(112.0, 145.0, 416.0, 170.0); draw_centered("NEW TDK HIGHSCORE", 183.0, 28, BLACK); draw_centered( &format!("SCORE {:09}", self.pending_score), 218.0, 21, BLACK, ); draw_rectangle( 153.0, 235.0, 334.0, 38.0, Color::from_rgba(0, 191, 209, 255), ); draw_text( format!( "{}{}", self.name, if get_time().rem_euclid(1.0) < 0.5 { "_" } else { "" } ), 164.0, 262.0, 23.0, BLACK, ); draw_centered("TYPE YOUR NAME, THEN PRESS ENTER", 296.0, 14, 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; 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 + shake.x, y + shake.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}"); } } } 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); } #[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); } 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, ); }