Files
tdkpin/tdkpin-rs/src/app.rs
T
ddidderr 4ef563361b fix(physics): restore recovered flipper states
Replace the reversed oversized flipper vectors with the original binary rest
and raised geometry. Render the exact resource 997 flipper rectangles while
pressed and keep the live collision capsules aligned with those sprites.

Test Plan:
- cargo fmt --check
- cargo test
- cargo clippy --all-targets --all-features -- -D warnings
- compare forced raised-state rendering with the extracted resource rectangles
2026-08-22 18:15:38 +02:00

764 lines
25 KiB
Rust

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<Game>,
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,
);
}