feat: rebuild TDK Pinball Machine in Rust

Replace the empty Rust scaffold with a playable native reconstruction of the
1995 Win16 game. Embed the complete decoded asset set, retain the original
640x460 presentation and localized help, and implement a resizable letterboxed
Macroquad frontend for Linux, macOS, and Windows.

Recreate the multiplayer ball flow, flippers, nudging, target banks, wheel,
robot, lock, magnets, bumper progression, TDK diamond multiplier, media extra
balls, sound dispatch, settings, and high-score entry. The physics engine uses
a fixed time step and 101 static collision segments transcribed from the
original 175-object table. Portable JSON persistence imports and decodes the
original XOR-obfuscated high-score file on first run.

Document the evidence boundary explicitly: artwork and PCM samples are exact,
static geometry is recovered, and several numeric scoring/impulse values remain
best-evidence behavioral tuning rather than bit-identical Win16 arithmetic.
Add a native three-OS CI matrix and retain every decoded resource for future
fidelity work.

Test Plan:
- `diff -qr original/assets/decoded tdkpin-rs/assets/original` with the two
  intentionally added legacy root files excluded -- passed
- `cargo fmt --all -- --check` -- passed
- `cargo check --all-targets` -- passed
- `cargo test --all-targets` -- passed, 8 tests
- `cargo clippy --all-targets -- -D warnings` -- passed
- `cargo build --profile production` -- passed
- `cargo check --all-targets --target x86_64-pc-windows-gnu` -- passed
- `cargo check --all-targets --target x86_64-apple-darwin` -- passed
- `cargo run` graphical start, launch, collision, and score smoke test -- passed;
  audible output was unavailable because the host has no ALSA device
This commit is contained in:
2026-08-22 16:12:11 +02:00
parent 54061f1eb7
commit f817b63683
81 changed files with 3653 additions and 2 deletions
+683
View File
@@ -0,0 +1,683 @@
use crate::{
assets::Assets,
game::{Controls, Event, Game},
persistence::{HighScore, Language, Persistence, SavedData, insert_high_score},
};
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_pressed: is_key_pressed(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 => 2021,
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) {
draw_texture(&self.assets.inactive_table, 0.0, 0.0, WHITE);
let Some(game) = &self.game else {
return;
};
for (index, center) in [vec2(205.0, 47.0), vec2(234.0, 50.0), vec2(262.0, 53.0)]
.into_iter()
.enumerate()
{
if game.top_targets[index] {
draw_line(
center.x - 7.0,
center.y + 9.0,
center.x + 5.0,
center.y - 10.0,
7.0,
YELLOW,
);
}
}
let side_colors = [RED, ORANGE, YELLOW, GREEN, SKYBLUE];
for (index, y) in [180.0, 195.0, 210.0, 225.0, 240.0].into_iter().enumerate() {
let color = if game.side_targets[index] {
side_colors[index]
} else {
Color::from_rgba(38, 38, 38, 255)
};
draw_circle(286.0, y, 5.0, color);
}
for (index, lit) in game.wheel_holes.iter().enumerate() {
if *lit {
let angle = index as f32 / 8.0 * std::f32::consts::TAU;
draw_circle(
114.0 + angle.cos() * 29.0,
79.0 + angle.sin() * 29.0,
6.0,
YELLOW,
);
}
}
for index in 0..4 {
draw_circle(
155.0 + index as f32 * 14.0,
214.0,
5.0,
if index < usize::from(game.lock_lights) {
WHITE
} else {
DARKGRAY
},
);
}
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,
);
}
}
let (left, right) = game.flippers();
for flipper in [left, right] {
draw_line(
flipper.start.x,
flipper.start.y,
flipper.end.x,
flipper.end.y,
10.0,
DARKGRAY,
);
draw_line(
flipper.start.x,
flipper.start.y - 1.0,
flipper.end.x,
flipper.end.y - 1.0,
6.0,
LIGHTGRAY,
);
draw_circle(flipper.start.x, flipper.start.y, 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,
WHITE,
);
self.draw_displays(game);
if game.tilt > 1.15 {
draw_centered("TILT", 263.0, 34, RED);
}
draw_text("F1 HELP F2 SETUP", 345.0, 448.0, 11.0, BLACK);
}
#[allow(clippy::cast_precision_loss)]
fn draw_displays(&self, game: &Game) {
draw_rectangle(
371.0,
235.0,
222.0,
31.0,
Color::from_rgba(0, 191, 209, 255),
);
draw_text(
format!("BONUS {:07}", game.bonus),
374.0,
258.0,
24.0,
BLACK,
);
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);
}
draw_text(
format!("{:07} KBytes", game.player().score / 1_000),
395.0,
211.0,
16.0,
WHITE,
);
for (index, player) in game.players.iter().enumerate() {
let y = 274.0 + index as f32 * 44.0;
let panel = if index == game.current_player {
Color::from_rgba(0, 191, 209, 255)
} else {
Color::from_rgba(36, 82, 137, 255)
};
draw_rectangle(472.0, y, 121.0, 30.0, panel);
draw_text(format!("{:09}", player.score), 478.0, y + 21.0, 18.0, BLACK);
for ball in 0..6 {
let lit = ball < usize::from(player.balls + player.extra_balls);
draw_circle(
397.0 + ball as f32 * 8.0,
y + 27.0,
2.4,
if lit { GREEN } else { DARKGRAY },
);
}
}
}
#[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;
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}");
}
}
}
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_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,
);
}
+151
View File
@@ -0,0 +1,151 @@
use macroquad::{
audio::{PlaySoundParams, Sound, load_sound_from_bytes, play_sound},
prelude::{FilterMode, Texture2D},
};
pub struct Assets {
pub active_table: Texture2D,
pub inactive_table: Texture2D,
pub loading: Texture2D,
pub help: [Texture2D; 5],
pub media: [Texture2D; 4],
pub diamond: [Texture2D; 9],
sounds: Vec<(u16, Sound)>,
}
impl Assets {
#[allow(clippy::too_many_lines)]
pub async fn load() -> Self {
let active_table = texture(include_bytes!("../assets/original/images/dat_00997.png"));
let inactive_table = texture(include_bytes!("../assets/original/images/dat_00998.png"));
let loading = texture(include_bytes!("../assets/original/images/dat_00995.png"));
let help = [
texture(include_bytes!("../assets/original/images/dat_01001.png")),
texture(include_bytes!("../assets/original/images/dat_01002.png")),
texture(include_bytes!("../assets/original/images/dat_01003.png")),
texture(include_bytes!("../assets/original/images/dat_01004.png")),
texture(include_bytes!("../assets/original/images/dat_01005.png")),
];
let media = [
texture(include_bytes!("../assets/original/images/dat_00701.png")),
texture(include_bytes!("../assets/original/images/dat_00702.png")),
texture(include_bytes!("../assets/original/images/dat_00703.png")),
texture(include_bytes!("../assets/original/images/dat_00704.png")),
];
let diamond = [
texture(include_bytes!("../assets/original/images/dat_00801.png")),
texture(include_bytes!("../assets/original/images/dat_00802.png")),
texture(include_bytes!("../assets/original/images/dat_00803.png")),
texture(include_bytes!("../assets/original/images/dat_00804.png")),
texture(include_bytes!("../assets/original/images/dat_00805.png")),
texture(include_bytes!("../assets/original/images/dat_00806.png")),
texture(include_bytes!("../assets/original/images/dat_00807.png")),
texture(include_bytes!("../assets/original/images/dat_00808.png")),
texture(include_bytes!("../assets/original/images/dat_00809.png")),
];
let sound_bytes: [(u16, &[u8]); 16] = [
(
2001,
include_bytes!("../assets/original/audio/wav_02001.wav"),
),
(
2002,
include_bytes!("../assets/original/audio/wav_02002.wav"),
),
(
2004,
include_bytes!("../assets/original/audio/wav_02004.wav"),
),
(
2006,
include_bytes!("../assets/original/audio/wav_02006.wav"),
),
(
2007,
include_bytes!("../assets/original/audio/wav_02007.wav"),
),
(
2008,
include_bytes!("../assets/original/audio/wav_02008.wav"),
),
(
2011,
include_bytes!("../assets/original/audio/wav_02011.wav"),
),
(
2012,
include_bytes!("../assets/original/audio/wav_02012.wav"),
),
(
2013,
include_bytes!("../assets/original/audio/wav_02013.wav"),
),
(
2015,
include_bytes!("../assets/original/audio/wav_02015.wav"),
),
(
2016,
include_bytes!("../assets/original/audio/wav_02016.wav"),
),
(
2017,
include_bytes!("../assets/original/audio/wav_02017.wav"),
),
(
2019,
include_bytes!("../assets/original/audio/wav_02019.wav"),
),
(
2020,
include_bytes!("../assets/original/audio/wav_02020.wav"),
),
(
2021,
include_bytes!("../assets/original/audio/wav_02021.wav"),
),
(
2022,
include_bytes!("../assets/original/audio/wav_02022.wav"),
),
];
let mut sounds = Vec::with_capacity(sound_bytes.len());
for (id, bytes) in sound_bytes {
if let Ok(sound) = load_sound_from_bytes(bytes).await {
sounds.push((id, sound));
}
}
Self {
active_table,
inactive_table,
loading,
help,
media,
diamond,
sounds,
}
}
pub fn play(&self, id: u16, enabled: bool) {
if !enabled {
return;
}
if let Some((_, sound)) = self.sounds.iter().find(|(sound_id, _)| *sound_id == id) {
play_sound(
sound,
PlaySoundParams {
looped: false,
volume: 0.72,
},
);
}
}
}
fn texture(bytes: &[u8]) -> Texture2D {
let texture = Texture2D::from_file_with_format(bytes, None);
texture.set_filter(FilterMode::Nearest);
texture
}
+556
View File
@@ -0,0 +1,556 @@
use crate::geometry::{Segment, circle_collision, segment_collision};
use macroquad::prelude::{Vec2, vec2};
use std::sync::LazyLock;
const BALL_RADIUS: f32 = 5.0;
const GRAVITY: f32 = 135.0;
const MAX_SPEED: f32 = 430.0;
#[derive(Clone, Copy, Debug, Default)]
pub struct Controls {
pub left_flipper: bool,
pub right_flipper: bool,
pub launch_pressed: bool,
pub nudge: f32,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Event {
Flipper,
Launch,
Bumper,
Target,
Wheel,
Robot,
Lock,
Media,
ExtraBall,
Nudge,
Drain,
}
#[derive(Clone, Debug)]
pub struct Player {
pub score: u32,
pub balls: u8,
pub extra_balls: u8,
pub bumper_value: u32,
pub diamond_segments: u8,
pub media_level: u8,
}
impl Default for Player {
fn default() -> Self {
Self {
score: 0,
balls: 3,
extra_balls: 0,
bumper_value: 1_000,
diamond_segments: 0,
media_level: 0,
}
}
}
#[derive(Clone, Copy, Debug)]
pub struct Ball {
pub position: Vec2,
pub velocity: Vec2,
pub in_launcher: bool,
}
impl Default for Ball {
fn default() -> Self {
Self {
position: vec2(157.0, 426.0),
velocity: Vec2::ZERO,
in_launcher: true,
}
}
}
#[derive(Debug)]
pub struct Game {
pub players: Vec<Player>,
pub current_player: usize,
pub ball: Ball,
pub bonus: u32,
pub wheel_holes: [bool; 8],
pub side_targets: [bool; 5],
pub top_targets: [bool; 3],
pub lock_lights: u8,
pub magnets: f32,
pub tilt: f32,
pub left_flipper_angle: f32,
pub right_flipper_angle: f32,
pub finished: bool,
accumulator: f32,
target_cooldown: f32,
bumper_cooldown: f32,
nudge_cooldown: f32,
}
impl Game {
pub fn new(player_count: usize) -> Self {
Self {
players: vec![Player::default(); player_count.clamp(1, 4)],
current_player: 0,
ball: Ball::default(),
bonus: 0,
wheel_holes: [false; 8],
side_targets: [false; 5],
top_targets: [false; 3],
lock_lights: 0,
magnets: 0.0,
tilt: 0.0,
left_flipper_angle: -2.72,
right_flipper_angle: -0.42,
finished: false,
accumulator: 0.0,
target_cooldown: 0.0,
bumper_cooldown: 0.0,
nudge_cooldown: 0.0,
}
}
pub fn player(&self) -> &Player {
&self.players[self.current_player]
}
pub fn update(&mut self, frame_time: f32, detail: u8, controls: Controls) -> Vec<Event> {
if self.finished {
return Vec::new();
}
let mut events = Vec::new();
let desired_left = if controls.left_flipper { -2.18 } else { -2.72 };
let desired_right = if controls.right_flipper { -0.96 } else { -0.42 };
let flipper_rate = 14.0 * frame_time;
let old_left = self.left_flipper_angle;
let old_right = self.right_flipper_angle;
self.left_flipper_angle +=
(desired_left - self.left_flipper_angle).clamp(-flipper_rate, flipper_rate);
self.right_flipper_angle +=
(desired_right - self.right_flipper_angle).clamp(-flipper_rate, flipper_rate);
if (controls.left_flipper && old_left < -2.68)
|| (controls.right_flipper && old_right > -0.46)
{
events.push(Event::Flipper);
}
if controls.launch_pressed && self.ball.in_launcher {
self.ball.in_launcher = false;
self.ball.velocity = vec2(12.0, -330.0);
events.push(Event::Launch);
}
if controls.nudge.abs() > 0.1 && self.nudge_cooldown <= 0.0 {
self.ball.velocity.x += controls.nudge * 55.0;
self.tilt += controls.nudge.abs() * 0.34;
self.nudge_cooldown = 0.22;
events.push(Event::Nudge);
}
self.accumulator = (self.accumulator + frame_time.min(0.05)).min(0.1);
let steps_per_second =
[60.0, 90.0, 120.0, 180.0, 240.0][usize::from(detail.clamp(1, 5) - 1)];
let step = 1.0 / steps_per_second;
while self.accumulator >= step {
self.fixed_update(step, &mut events);
self.accumulator -= step;
}
events
}
fn fixed_update(&mut self, dt: f32, events: &mut Vec<Event>) {
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.tilt = (self.tilt - dt * 0.08).max(0.0);
if self.ball.in_launcher {
self.ball.position = vec2(157.0, 426.0);
self.ball.velocity = Vec2::ZERO;
return;
}
self.ball.velocity.y += GRAVITY * dt;
self.ball.velocity *= 1.0 - dt * 0.055;
self.ball.velocity = self.ball.velocity.clamp_length_max(MAX_SPEED);
self.ball.position += self.ball.velocity * dt;
for wall in table_walls() {
segment_collision(
&mut self.ball.position,
&mut self.ball.velocity,
BALL_RADIUS,
*wall,
);
}
let left_flipper = flipper_segment(vec2(103.0, 394.0), self.left_flipper_angle);
let right_flipper = flipper_segment(vec2(211.0, 394.0), self.right_flipper_angle);
if segment_collision(
&mut self.ball.position,
&mut self.ball.velocity,
BALL_RADIUS + 2.0,
left_flipper,
) && self.left_flipper_angle > -2.55
{
self.ball.velocity += vec2(-20.0, -115.0);
}
if segment_collision(
&mut self.ball.position,
&mut self.ball.velocity,
BALL_RADIUS + 2.0,
right_flipper,
) && self.right_flipper_angle < -0.58
{
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)] {
if circle_collision(
&mut self.ball.position,
&mut self.ball.velocity,
BALL_RADIUS,
center,
14.0,
105.0,
) && self.bumper_cooldown <= 0.0
{
self.add_score(self.player().bumper_value);
self.bonus = self.bonus.saturating_add(100);
self.bumper_cooldown = 0.08;
events.push(Event::Bumper);
}
}
self.check_targets(events);
self.check_media(events);
if self.ball.position.y > 454.0 {
if self.magnets > 0.0 && (self.ball.position.x < 145.0 || self.ball.position.x > 175.0)
{
self.ball.position.y = 410.0;
self.ball.velocity = vec2((157.0 - self.ball.position.x) * 2.0, -245.0);
self.magnets = 0.0;
} else {
self.drain(events);
}
}
}
fn check_targets(&mut self, events: &mut Vec<Event>) {
if self.target_cooldown > 0.0 {
return;
}
let position = self.ball.position;
let top = [vec2(205.0, 47.0), vec2(234.0, 50.0), vec2(262.0, 53.0)];
for (index, center) in top.into_iter().enumerate() {
if position.distance_squared(center) < 11.0_f32.powi(2) && !self.top_targets[index] {
self.top_targets[index] = true;
self.add_score(1_500);
self.ball.velocity.y = self.ball.velocity.y.abs() + 65.0;
self.target_cooldown = 0.12;
events.push(Event::Target);
}
}
if self.top_targets.iter().all(|target| *target) {
self.top_targets.fill(false);
self.magnets = 12.0;
self.add_score(5_000);
}
let side = [180.0, 195.0, 210.0, 225.0, 240.0];
for (index, y) in side.into_iter().enumerate() {
if position.distance_squared(vec2(286.0, y)) < 9.0_f32.powi(2)
&& !self.side_targets[index]
{
self.side_targets[index] = true;
self.add_score(2_000 + u32::try_from(index).unwrap_or(0) * 1_000);
self.ball.velocity.x = -self.ball.velocity.x.abs() - 55.0;
self.target_cooldown = 0.10;
events.push(Event::Target);
}
}
if self.side_targets.iter().all(|target| *target) {
self.side_targets.fill(false);
let player = &mut self.players[self.current_player];
player.bumper_value = (player.bumper_value + 1_000).min(10_000);
player.diamond_segments = (player.diamond_segments + 1).min(9);
self.add_score(10_000);
}
let wheel_center = vec2(114.0, 79.0);
let from_wheel = position - wheel_center;
if (25.0..=43.0).contains(&from_wheel.length()) {
let angle =
(from_wheel.y.atan2(from_wheel.x) + std::f32::consts::TAU) % std::f32::consts::TAU;
let sector_boundaries = [
std::f32::consts::TAU / 16.0,
std::f32::consts::TAU * 3.0 / 16.0,
std::f32::consts::TAU * 5.0 / 16.0,
std::f32::consts::TAU * 7.0 / 16.0,
std::f32::consts::TAU * 9.0 / 16.0,
std::f32::consts::TAU * 11.0 / 16.0,
std::f32::consts::TAU * 13.0 / 16.0,
std::f32::consts::TAU * 15.0 / 16.0,
];
let hole = sector_boundaries.partition_point(|boundary| angle >= *boundary) % 8;
if !self.wheel_holes[hole] {
self.wheel_holes[hole] = true;
self.add_score(2_500);
self.target_cooldown = 0.15;
events.push(Event::Wheel);
}
if self.wheel_holes.iter().all(|hole| *hole) {
self.wheel_holes.fill(false);
self.add_score(25_000);
}
}
if (270.0..=302.0).contains(&position.x) && (45.0..=105.0).contains(&position.y) {
self.add_score(7_500);
self.ball.velocity = vec2(-125.0, 60.0);
self.target_cooldown = 0.3;
events.push(Event::Robot);
}
if (151.0..=220.0).contains(&position.x) && (187.0..=205.0).contains(&position.y) {
self.lock_lights = (self.lock_lights + 1).min(4);
self.add_score(u32::from(self.lock_lights) * 5_000);
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;
events.push(Event::Lock);
}
for (center, value) in [
(vec2(113.0, 285.0), 2_000),
(vec2(132.0, 270.0), 3_000),
(vec2(155.0, 258.0), 4_000),
(vec2(181.0, 270.0), 5_000),
(vec2(201.0, 285.0), 6_000),
] {
if position.distance_squared(center) < 10.0_f32.powi(2) {
self.add_score(value);
self.ball.velocity.y = -self.ball.velocity.y.abs() - 40.0;
self.target_cooldown = 0.13;
events.push(Event::Target);
break;
}
}
}
fn check_media(&mut self, events: &mut Vec<Event>) {
const THRESHOLDS: [u32; 5] = [360_000, 720_000, 1_440_000, 2_880_000, 5_760_000];
let score = self.player().score;
let level =
u8::try_from(THRESHOLDS.partition_point(|threshold| score >= *threshold)).unwrap_or(5);
if level > self.player().media_level {
let player = &mut self.players[self.current_player];
player.media_level = level;
player.extra_balls = player.extra_balls.saturating_add(1);
events.push(Event::Media);
events.push(Event::ExtraBall);
}
}
fn add_score(&mut self, points: u32) {
let multiplier = if self.player().diamond_segments == 9 {
2
} else {
1
};
let player = &mut self.players[self.current_player];
player.score = player
.score
.saturating_add(points.saturating_mul(multiplier));
}
fn drain(&mut self, events: &mut Vec<Event>) {
let player = &mut self.players[self.current_player];
player.score = player.score.saturating_add(self.bonus);
if player.extra_balls > 0 {
player.extra_balls -= 1;
} else {
player.balls = player.balls.saturating_sub(1);
}
events.push(Event::Drain);
self.bonus = 0;
self.side_targets.fill(false);
self.top_targets.fill(false);
self.wheel_holes.fill(false);
self.lock_lights = 0;
self.magnets = 0.0;
self.tilt = 0.0;
let mut next = (self.current_player + 1) % self.players.len();
for _ in 0..self.players.len() {
if self.players[next].balls > 0 || self.players[next].extra_balls > 0 {
self.current_player = next;
self.ball = Ball::default();
return;
}
next = (next + 1) % self.players.len();
}
self.finished = true;
}
pub fn flippers(&self) -> (Segment, Segment) {
(
flipper_segment(vec2(103.0, 394.0), self.left_flipper_angle),
flipper_segment(vec2(211.0, 394.0), self.right_flipper_angle),
)
}
}
fn flipper_segment(pivot: Vec2, angle: f32) -> Segment {
Segment::new(pivot, pivot + vec2(angle.cos(), angle.sin()) * 49.0, 0.88)
}
#[allow(clippy::too_many_lines)]
fn table_walls() -> &'static [Segment] {
static WALLS: LazyLock<Vec<Segment>> = LazyLock::new(|| {
vec![
Segment::new(Vec2::new(125.0, 21.0), Vec2::new(7.0, 61.0), 0.82),
Segment::new(Vec2::new(300.0, 3.0), Vec2::new(10.0, 3.0), 0.82),
Segment::new(Vec2::new(120.0, 6.0), Vec2::new(120.0, 26.0), 0.82),
Segment::new(Vec2::new(195.0, 26.0), Vec2::new(195.0, 6.0), 0.82),
Segment::new(Vec2::new(134.0, 33.0), Vec2::new(134.0, 16.0), 0.82),
Segment::new(Vec2::new(148.0, 11.0), Vec2::new(148.0, 33.0), 0.82),
Segment::new(Vec2::new(164.0, 12.0), Vec2::new(165.0, 33.0), 0.82),
Segment::new(Vec2::new(180.0, 11.0), Vec2::new(179.0, 33.0), 0.82),
Segment::new(Vec2::new(304.0, 64.0), Vec2::new(183.0, 21.0), 0.82),
Segment::new(Vec2::new(298.0, 417.0), Vec2::new(298.0, 313.0), 0.82),
Segment::new(Vec2::new(298.0, 336.0), Vec2::new(310.0, 336.0), 0.82),
Segment::new(Vec2::new(292.0, 374.0), Vec2::new(291.0, 342.0), 0.82),
Segment::new(Vec2::new(281.0, 378.0), Vec2::new(300.0, 369.0), 0.82),
Segment::new(Vec2::new(275.0, 343.0), Vec2::new(290.0, 382.0), 0.82),
Segment::new(Vec2::new(248.0, 364.0), Vec2::new(264.0, 339.0), 0.82),
Segment::new(Vec2::new(257.0, 387.0), Vec2::new(249.0, 364.0), 0.82),
Segment::new(Vec2::new(277.0, 429.0), Vec2::new(257.0, 387.0), 0.82),
Segment::new(Vec2::new(287.0, 446.0), Vec2::new(277.0, 427.0), 0.82),
Segment::new(Vec2::new(303.0, 430.0), Vec2::new(283.0, 436.0), 0.82),
Segment::new(Vec2::new(320.0, 410.0), Vec2::new(302.0, 430.0), 0.82),
Segment::new(Vec2::new(320.0, 21.0), Vec2::new(320.0, 410.0), 0.82),
Segment::new(Vec2::new(307.0, 437.0), Vec2::new(316.0, 430.0), 0.82),
Segment::new(Vec2::new(296.0, 442.0), Vec2::new(307.0, 437.0), 0.82),
Segment::new(Vec2::new(285.0, 443.0), Vec2::new(296.0, 442.0), 0.82),
Segment::new(Vec2::new(112.0, 443.0), Vec2::new(285.0, 443.0), 0.82),
Segment::new(Vec2::new(99.0, 438.0), Vec2::new(121.0, 444.0), 0.82),
Segment::new(Vec2::new(78.0, 427.0), Vec2::new(100.0, 438.0), 0.82),
Segment::new(Vec2::new(66.0, 409.0), Vec2::new(79.0, 428.0), 0.82),
Segment::new(Vec2::new(57.0, 385.0), Vec2::new(68.0, 413.0), 0.82),
Segment::new(Vec2::new(59.0, 304.0), Vec2::new(59.0, 396.0), 0.82),
Segment::new(Vec2::new(67.0, 288.0), Vec2::new(58.0, 311.0), 0.82),
Segment::new(Vec2::new(41.0, 268.0), Vec2::new(64.0, 279.0), 0.82),
Segment::new(Vec2::new(22.0, 293.0), Vec2::new(31.0, 272.0), 0.82),
Segment::new(Vec2::new(22.0, 447.0), Vec2::new(22.0, 293.0), 0.82),
Segment::new(Vec2::new(11.0, 445.0), Vec2::new(23.0, 445.0), 0.82),
Segment::new(Vec2::new(13.0, 288.0), Vec2::new(13.0, 448.0), 0.82),
Segment::new(Vec2::new(32.0, 228.0), Vec2::new(12.0, 293.0), 0.82),
Segment::new(Vec2::new(10.0, 158.0), Vec2::new(33.0, 227.0), 0.82),
Segment::new(Vec2::new(12.0, 51.0), Vec2::new(12.0, 197.0), 0.82),
Segment::new(Vec2::new(37.0, 111.0), Vec2::new(37.0, 166.0), 0.82),
Segment::new(Vec2::new(110.0, 74.0), Vec2::new(37.0, 111.0), 0.82),
Segment::new(Vec2::new(19.0, 73.0), Vec2::new(92.0, 50.0), 0.82),
Segment::new(Vec2::new(19.0, 167.0), Vec2::new(19.0, 73.0), 0.82),
Segment::new(Vec2::new(141.0, 45.0), Vec2::new(112.0, 72.0), 0.82),
Segment::new(Vec2::new(97.0, 47.0), Vec2::new(130.0, 31.0), 0.82),
Segment::new(Vec2::new(0.0, 433.0), Vec2::new(7.0, 478.0), 0.82),
Segment::new(Vec2::new(292.0, 73.0), Vec2::new(292.0, 167.0), 0.82),
Segment::new(Vec2::new(221.0, 50.0), Vec2::new(292.0, 73.0), 0.82),
Segment::new(Vec2::new(275.0, 111.0), Vec2::new(205.0, 74.0), 0.82),
Segment::new(Vec2::new(275.0, 166.0), Vec2::new(275.0, 111.0), 0.82),
Segment::new(Vec2::new(177.0, 31.0), Vec2::new(216.0, 47.0), 0.82),
Segment::new(Vec2::new(201.0, 72.0), Vec2::new(172.0, 45.0), 0.82),
Segment::new(Vec2::new(40.0, 257.0), Vec2::new(73.0, 273.0), 0.82),
Segment::new(Vec2::new(73.0, 273.0), Vec2::new(67.0, 285.0), 0.82),
Segment::new(Vec2::new(34.0, 269.0), Vec2::new(40.0, 257.0), 0.82),
Segment::new(Vec2::new(292.0, 231.0), Vec2::new(292.0, 205.0), 0.82),
Segment::new(Vec2::new(292.0, 205.0), Vec2::new(302.0, 205.0), 0.82),
Segment::new(Vec2::new(302.0, 231.0), Vec2::new(292.0, 231.0), 0.82),
Segment::new(Vec2::new(292.0, 247.0), Vec2::new(292.0, 222.0), 0.82),
Segment::new(Vec2::new(292.0, 222.0), Vec2::new(302.0, 222.0), 0.82),
Segment::new(Vec2::new(302.0, 247.0), Vec2::new(292.0, 247.0), 0.82),
Segment::new(Vec2::new(292.0, 261.0), Vec2::new(292.0, 237.0), 0.82),
Segment::new(Vec2::new(292.0, 237.0), Vec2::new(302.0, 237.0), 0.82),
Segment::new(Vec2::new(302.0, 261.0), Vec2::new(292.0, 261.0), 0.82),
Segment::new(Vec2::new(292.0, 277.0), Vec2::new(292.0, 252.0), 0.82),
Segment::new(Vec2::new(292.0, 252.0), Vec2::new(302.0, 252.0), 0.82),
Segment::new(Vec2::new(302.0, 276.0), Vec2::new(292.0, 276.0), 0.82),
Segment::new(Vec2::new(292.0, 291.0), Vec2::new(292.0, 268.0), 0.82),
Segment::new(Vec2::new(292.0, 269.0), Vec2::new(302.0, 268.0), 0.82),
Segment::new(Vec2::new(302.0, 295.0), Vec2::new(292.0, 290.0), 0.82),
Segment::new(Vec2::new(148.0, 254.0), Vec2::new(203.0, 236.0), 0.82),
Segment::new(Vec2::new(215.0, 261.0), Vec2::new(158.0, 280.0), 0.82),
Segment::new(Vec2::new(187.0, 232.0), Vec2::new(209.0, 225.0), 0.82),
Segment::new(Vec2::new(209.0, 225.0), Vec2::new(214.0, 240.0), 0.82),
Segment::new(Vec2::new(191.0, 244.0), Vec2::new(187.0, 232.0), 0.82),
Segment::new(Vec2::new(169.0, 239.0), Vec2::new(198.0, 229.0), 0.82),
Segment::new(Vec2::new(198.0, 229.0), Vec2::new(202.0, 241.0), 0.82),
Segment::new(Vec2::new(173.0, 251.0), Vec2::new(169.0, 239.0), 0.82),
Segment::new(Vec2::new(153.0, 245.0), Vec2::new(182.0, 235.0), 0.82),
Segment::new(Vec2::new(182.0, 235.0), Vec2::new(186.0, 247.0), 0.82),
Segment::new(Vec2::new(157.0, 257.0), Vec2::new(153.0, 245.0), 0.82),
Segment::new(Vec2::new(139.0, 252.0), Vec2::new(163.0, 241.0), 0.82),
Segment::new(Vec2::new(163.0, 241.0), Vec2::new(167.0, 253.0), 0.82),
Segment::new(Vec2::new(144.0, 267.0), Vec2::new(139.0, 252.0), 0.82),
Segment::new(Vec2::new(84.0, 318.0), Vec2::new(105.0, 329.0), 0.82),
Segment::new(Vec2::new(105.0, 329.0), Vec2::new(101.0, 339.0), 0.82),
Segment::new(Vec2::new(80.0, 328.0), Vec2::new(84.0, 318.0), 0.82),
Segment::new(Vec2::new(87.0, 327.0), Vec2::new(111.0, 339.0), 0.82),
Segment::new(Vec2::new(77.0, 370.0), Vec2::new(77.0, 331.0), 0.82),
Segment::new(Vec2::new(87.0, 372.0), Vec2::new(77.0, 370.0), 0.82),
Segment::new(Vec2::new(110.0, 337.0), Vec2::new(87.0, 372.0), 0.82),
Segment::new(Vec2::new(184.0, 426.0), Vec2::new(172.0, 403.0), 0.82),
Segment::new(Vec2::new(193.0, 396.0), Vec2::new(205.0, 417.0), 0.82),
Segment::new(Vec2::new(213.0, 426.0), Vec2::new(201.0, 403.0), 0.82),
Segment::new(Vec2::new(222.0, 396.0), Vec2::new(234.0, 417.0), 0.82),
Segment::new(Vec2::new(242.0, 426.0), Vec2::new(230.0, 403.0), 0.82),
Segment::new(Vec2::new(251.0, 396.0), Vec2::new(263.0, 417.0), 0.82),
Segment::new(Vec2::new(291.0, 205.0), Vec2::new(298.0, 187.0), 0.82),
Segment::new(Vec2::new(298.0, 378.0), Vec2::new(298.0, 58.0), 0.82),
Segment::new(Vec2::new(298.0, 288.0), Vec2::new(298.0, 211.0), 0.82),
Segment::new(Vec2::new(298.0, 313.0), Vec2::new(291.0, 290.0), 0.82),
]
});
&WALLS
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn player_count_is_bounded() {
assert_eq!(Game::new(0).players.len(), 1);
assert_eq!(Game::new(99).players.len(), 4);
}
#[test]
fn launch_is_deterministic() {
let mut game = Game::new(1);
let events = game.update(
1.0 / 60.0,
3,
Controls {
launch_pressed: true,
..Controls::default()
},
);
assert!(events.contains(&Event::Launch));
assert!(!game.ball.in_launcher);
assert!(game.ball.velocity.y < 0.0);
}
#[test]
fn completed_diamond_doubles_score() {
let mut game = Game::new(1);
game.players[0].diamond_segments = 9;
game.add_score(1_000);
assert_eq!(game.players[0].score, 2_000);
}
}
+106
View File
@@ -0,0 +1,106 @@
use macroquad::prelude::Vec2;
#[derive(Clone, Copy, Debug)]
pub struct Segment {
pub start: Vec2,
pub end: Vec2,
pub bounce: f32,
}
impl Segment {
pub const fn new(start: Vec2, end: Vec2, bounce: f32) -> Self {
Self { start, end, bounce }
}
}
pub fn closest_point(point: Vec2, segment: Segment) -> Vec2 {
let line = segment.end - segment.start;
let length_squared = line.length_squared();
if length_squared <= f32::EPSILON {
return segment.start;
}
let t = ((point - segment.start).dot(line) / length_squared).clamp(0.0, 1.0);
segment.start + line * t
}
pub fn segment_collision(
position: &mut Vec2,
velocity: &mut Vec2,
radius: f32,
segment: Segment,
) -> bool {
let point = closest_point(*position, segment);
let offset = *position - point;
let distance_squared = offset.length_squared();
if distance_squared >= radius * radius {
return false;
}
let normal = if distance_squared > 0.000_001 {
offset / distance_squared.sqrt()
} else {
let line = segment.end - segment.start;
Vec2::new(-line.y, line.x).normalize_or_zero()
};
*position = point + normal * radius;
let inward_speed = velocity.dot(normal);
if inward_speed < 0.0 {
*velocity -= normal * inward_speed * (1.0 + segment.bounce);
}
true
}
pub fn circle_collision(
position: &mut Vec2,
velocity: &mut Vec2,
radius: f32,
center: Vec2,
obstacle_radius: f32,
kick: f32,
) -> bool {
let offset = *position - center;
let minimum = radius + obstacle_radius;
if offset.length_squared() >= minimum * minimum {
return false;
}
let normal = if offset.length_squared() > 0.000_001 {
offset.normalize()
} else {
Vec2::Y
};
*position = center + normal * minimum;
let inward_speed = velocity.dot(normal);
if inward_speed < 0.0 {
*velocity -= normal * inward_speed * 1.8;
}
*velocity += normal * kick;
true
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn closest_point_is_clamped_to_segment() {
let segment = Segment::new(Vec2::ZERO, Vec2::new(10.0, 0.0), 0.8);
assert_eq!(
closest_point(Vec2::new(12.0, 4.0), segment),
Vec2::new(10.0, 0.0)
);
}
#[test]
fn collision_reflects_velocity() {
let mut position = Vec2::new(5.0, 1.0);
let mut velocity = Vec2::new(0.0, -10.0);
let hit = segment_collision(
&mut position,
&mut velocity,
2.0,
Segment::new(Vec2::ZERO, Vec2::new(10.0, 0.0), 1.0),
);
assert!(hit);
assert!(velocity.y > 9.9);
}
}
+30 -2
View File
@@ -1,3 +1,31 @@
fn main() {
println!("Hello, world!");
mod app;
mod assets;
mod game;
mod geometry;
mod persistence;
use app::App;
use macroquad::prelude::*;
const LOGICAL_WIDTH: i32 = 640;
const LOGICAL_HEIGHT: i32 = 460;
fn window_conf() -> Conf {
Conf {
window_title: "TDK Pinball Machine".to_owned(),
window_width: LOGICAL_WIDTH,
window_height: LOGICAL_HEIGHT,
window_resizable: true,
high_dpi: true,
..Default::default()
}
}
#[macroquad::main(window_conf)]
async fn main() {
let mut app = App::load().await;
loop {
app.frame();
next_frame().await;
}
}
+196
View File
@@ -0,0 +1,196 @@
use directories::ProjectDirs;
use serde::{Deserialize, Serialize};
use std::{cmp::Reverse, fs, io, path::PathBuf};
const ORIGINAL_HIGHSCORES: &[u8] = include_bytes!("../assets/original/HISCORES.DAT");
#[derive(Clone, Copy, Debug, Deserialize, Serialize, PartialEq, Eq)]
pub enum Language {
English,
German,
French,
Italian,
Spanish,
}
impl Language {
pub const ALL: [Self; 5] = [
Self::English,
Self::German,
Self::French,
Self::Italian,
Self::Spanish,
];
pub const fn label(self) -> &'static str {
match self {
Self::English => "English",
Self::German => "Deutsch",
Self::French => "Francais",
Self::Italian => "Italiano",
Self::Spanish => "Espanol",
}
}
}
#[derive(Clone, Debug, Deserialize, Serialize)]
#[serde(default)]
pub struct Settings {
pub speed: u8,
pub sounds: bool,
pub language: Language,
}
impl Default for Settings {
fn default() -> Self {
Self {
speed: 3,
sounds: true,
language: Language::English,
}
}
}
#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)]
pub struct HighScore {
pub name: String,
pub score: u32,
}
#[derive(Clone, Debug, Deserialize, Serialize)]
#[serde(default)]
pub struct SavedData {
pub settings: Settings,
pub high_scores: Vec<HighScore>,
}
impl Default for SavedData {
fn default() -> Self {
Self {
settings: Settings::default(),
high_scores: parse_original_high_scores(ORIGINAL_HIGHSCORES),
}
}
}
pub struct Persistence {
path: PathBuf,
}
impl Persistence {
pub fn new() -> Self {
let path = ProjectDirs::from("com", "kiwi-hamburg", "TDK Pinball Machine").map_or_else(
|| PathBuf::from("tdkpin-save.json"),
|dirs| dirs.data_local_dir().join("save.json"),
);
Self { path }
}
pub fn load(&self) -> SavedData {
fs::read_to_string(&self.path)
.ok()
.and_then(|text| serde_json::from_str(&text).ok())
.unwrap_or_default()
}
pub fn save(&self, data: &SavedData) -> io::Result<()> {
if let Some(parent) = self
.path
.parent()
.filter(|parent| !parent.as_os_str().is_empty())
{
fs::create_dir_all(parent)?;
}
let encoded = serde_json::to_vec_pretty(data).map_err(io::Error::other)?;
let temporary = self.path.with_extension("json.tmp");
fs::write(&temporary, encoded)?;
#[cfg(target_os = "windows")]
if self.path.exists() {
fs::remove_file(&self.path)?;
}
fs::rename(temporary, &self.path)
}
}
pub fn parse_original_high_scores(bytes: &[u8]) -> Vec<HighScore> {
const HEADER: usize = 16;
const COUNT: usize = 10;
const NAME_LENGTH: usize = 22;
const SCORE_KEY: u32 = u32::from_le_bytes(*b"IWIK");
if bytes.len() < HEADER + COUNT * 4 + COUNT * NAME_LENGTH
|| !bytes.starts_with(b"TDK Highscores\0\0")
{
return Vec::new();
}
let scores = &bytes[HEADER..HEADER + COUNT * 4];
let names = &bytes[HEADER + COUNT * 4..];
let mut result = (0..COUNT)
.map(|index| {
let score_start = index * 4;
let score = u32::from_le_bytes([
scores[score_start],
scores[score_start + 1],
scores[score_start + 2],
scores[score_start + 3],
]) ^ SCORE_KEY;
let name_start = index * NAME_LENGTH;
let name_bytes = &names[name_start..name_start + NAME_LENGTH];
let length = name_bytes
.iter()
.position(|byte| *byte == 0)
.unwrap_or(NAME_LENGTH);
HighScore {
name: String::from_utf8_lossy(&name_bytes[..length]).into_owned(),
score,
}
})
.collect::<Vec<_>>();
result.sort_by_key(|entry| Reverse(entry.score));
result
}
pub fn insert_high_score(scores: &mut Vec<HighScore>, entry: HighScore) {
scores.push(entry);
scores.sort_by_key(|entry| Reverse(entry.score));
scores.truncate(10);
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn imports_all_original_entries_in_score_order() {
let scores = parse_original_high_scores(ORIGINAL_HIGHSCORES);
assert_eq!(scores.len(), 10);
assert_eq!(scores[0].score, 6_537_392);
assert!(scores.windows(2).all(|pair| pair[0].score >= pair[1].score));
assert!(
scores
.iter()
.any(|entry| entry.name == "TDK Pinball Player")
);
}
#[test]
fn rejects_a_table_without_the_original_signature() {
let mut corrupt = ORIGINAL_HIGHSCORES.to_vec();
corrupt[0] = b'X';
assert!(parse_original_high_scores(&corrupt).is_empty());
}
#[test]
fn new_score_is_inserted_and_table_stays_bounded() {
let mut scores = parse_original_high_scores(ORIGINAL_HIGHSCORES);
insert_high_score(
&mut scores,
HighScore {
name: "TEST".to_owned(),
score: u32::MAX,
},
);
assert_eq!(scores.len(), 10);
assert_eq!(scores[0].name, "TEST");
}
}