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:
@@ -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,
|
||||
);
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user