Files
tdkpin/tdkpin-rs/src/app.rs
T
ddidderr aae4671586 fix(input): restore original nudge and tilt behavior
Replace the float nudge direction, cooldown, and decaying meter with the
scan-code-specific Win16 behavior. Left Shift and keypad 3 now use the original
random directional impulse, while Space uses the reconstructed random horizontal
and vertical formulas with scalar 15 and wrapping millipixel velocities.

Track the original 16-bit tilt counter: each nudge adds 25, compares against
30 + Random(10), and the selected detail timer decrements a nonzero counter once
per callback. Allow the same input while a ball waits in the launcher, retain
bonus state when tilt latches, clear flippers immediately, and keep Right Shift
only as a documented modern alias for keypad 3.

Test Plan:
- `cargo test --all-targets` -- passed, 57 tests
- `cargo clippy --all-targets -- -D warnings` -- passed
- `rumdl check tdkpin-rs/CHANGELOG.md tdkpin-rs/README.md tdkpin-rs/RECONSTRUCTION.md` -- passed
- `git diff --cached --check` -- passed
2026-08-23 16:55:02 +02:00

789 lines
26 KiB
Rust

use crate::{
assets::Assets,
game::{Controls, Event, Game, Nudge},
persistence::{HighScore, Language, Persistence, SavedData, insert_high_score},
table::BUMPERS,
};
use macroquad::prelude::*;
use std::path::Path;
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>,
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,
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();
}
pub fn set_simulation_game(&mut self, game: Game) {
self.game = Some(game);
self.screen = Screen::Playing;
}
pub fn render_simulation(&self) {
self.draw_logical();
}
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) {
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.game = Some(Game::new(1));
self.screen = Screen::Playing;
self.assets.play(2001, self.saved.settings.sounds);
}
}
fn update_game(&mut self) {
if (is_key_pressed(KeyCode::KpAdd) || is_key_pressed(KeyCode::Equal))
&& self
.game
.as_mut()
.is_some_and(Game::add_player_before_launch)
{
self.assets.play(2001, self.saved.settings.sounds);
}
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) {
Nudge::Left
} else if is_key_pressed(KeyCode::RightShift) || is_key_pressed(KeyCode::Kp3) {
Nudge::Right
} else if is_key_pressed(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)
});
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) {
if let Some(resource) = event.sound_resource() {
self.assets.play(resource, 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 + TO START",
475.5,
139.0,
22,
Color::from_rgba(0, 72, 102, 255),
);
draw_centered_at("PRESS + AGAIN TO ADD PLAYERS", 475.5, 165.0, 15, BLACK);
draw_centered_at("THEN HOLD DOWN TO LAUNCH", 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()
},
);
}
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);
}
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.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)]
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(), 605.0, 233.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(),
605.0,
277.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) * 16.0;
self.draw_digits(digits, vec2(right - width, y), vec2(16.0, 28.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,
);
}