Files
tdkpin/tdkpin-rs/src/app.rs
T
ddidderr 5dd7f38450 feat(web): use the shared high-score service
Keep browser settings and the existing local save fallback, but route the
browser high-score table through the same-origin service when it is available.
The WASM storage bridge now accepts fetched score JSON and queues one validated
submission at a time. The JavaScript plugin fetches the canonical table,
submits accepted name-entry scores, ignores stale responses, retries failures,
and feeds successful responses back into the running game.

Update the web and project documentation to describe the optional shared
leaderboard and regenerate the tracked browser artifact. A missing service
continues to leave the local table usable; the service documentation records
that anonymous client scores are intentionally not tamper-resistant.

Test Plan:
- `just test` -- passed (3 service tests and 137 game tests)
- `just clippy` -- passed
- `just web-build` -- passed
- `cargo +nightly fmt -- --check` -- passed
- `node --check web/storage.js` and `prettier --check web/storage.js` -- passed
- `rumdl check --flavor commonmark CHANGELOG.md README.md web/README.md highscore-server/README.md` -- passed
- Browser WASM load through same-origin API proxy -- passed
- `git diff --cached --check` -- passed
2026-08-29 15:55:15 +02:00

1485 lines
48 KiB
Rust

#[cfg(not(target_arch = "wasm32"))]
use std::path::Path;
use macroquad::prelude::*;
use crate::{
assets::Assets,
game::{Controls, Event, Game, Nudge},
persistence::{
HighScore,
Language,
Persistence,
SavedData,
insert_high_score,
qualifies_high_score,
},
};
const WIDTH: f32 = 640.0;
const HEIGHT: f32 = 460.0;
const DETAIL_TIMER_SECONDS: [f32; 5] = [0.050, 0.040, 0.030, 0.020, 0.010];
const ADD_PLAYER_KEYS: [KeyCode; 3] = [KeyCode::KpAdd, KeyCode::RightBracket, KeyCode::Equal];
const TARGET_POSITIONS: [[(f32, f32); 5]; 6] = [
[
(9.0, 41.0),
(23.0, 11.0),
(56.0, 14.0),
(63.0, 47.0),
(35.0, 63.0),
],
[
(11.0, 45.0),
(18.0, 13.0),
(52.0, 11.0),
(63.0, 42.0),
(39.0, 64.0),
],
[
(13.0, 50.0),
(15.0, 17.0),
(46.0, 9.0),
(64.0, 37.0),
(44.0, 62.0),
],
[
(17.0, 55.0),
(11.0, 23.0),
(39.0, 7.0),
(64.0, 30.0),
(50.0, 59.0),
],
[
(21.0, 60.0),
(9.0, 28.0),
(35.0, 7.0),
(62.0, 25.0),
(54.0, 56.0),
],
[
(8.0, 35.0),
(26.0, 8.0),
(59.0, 18.0),
(59.0, 50.0),
(27.0, 61.0),
],
];
const BUMPER_VALUE_REGIONS: [(i32, i32, i32, i32); 5] = [
(103, 304, 121, 323),
(119, 280, 137, 299),
(148, 272, 166, 291),
(177, 280, 195, 299),
(193, 303, 211, 322),
];
const BANK_TARGET_REGIONS: [(u8, i32, i32, i32, i32); 9] = [
(90, 282, 231, 306, 246),
(93, 282, 217, 306, 231),
(96, 282, 202, 306, 216),
(99, 282, 187, 306, 201),
(102, 282, 174, 306, 186),
(109, 192, 214, 206, 236),
(112, 178, 209, 191, 231),
(115, 163, 204, 177, 226),
(118, 149, 198, 163, 221),
];
fn bumper_value_indicator_count(value: u32) -> usize {
usize::try_from(value / 1_000)
.unwrap_or(0)
.saturating_sub(1)
.min(BUMPER_VALUE_REGIONS.len())
}
fn add_player_released() -> bool {
ADD_PLAYER_KEYS.into_iter().any(is_key_released)
}
fn initial_game_random_seed() -> u32 {
// The original seeds RandSeed from the startup clock. Macroquad's random
// generator starts from a fixed zero state unless the application seeds it.
macroquad::rand::srand(macroquad::miniquad::date::now().to_bits());
macroquad::rand::rand()
}
fn visible_score_digits(mut value: u32) -> Vec<(u8, u8)> {
let mut digits = Vec::with_capacity(8);
for position in 0..8 {
let digit = u8::try_from(value % 10).unwrap_or(0);
value /= 10;
if digit != 0 || value > 0 || position == 0 {
digits.push((7 - position, digit));
}
}
digits
}
#[allow(clippy::cast_possible_wrap)]
fn status_level_for_score(score: u32) -> u8 {
let score = score as i32;
if score > 1_300_000 {
4
} else if score > 650_000 {
3
} else if score > 140_000 {
2
} else {
u8::from(score > 2_000)
}
}
fn displayed_player_markers(balls: u8, extra_balls: u8, is_current: bool) -> u8 {
balls
.saturating_add(extra_balls)
.saturating_sub(u8::from(is_current))
.min(6)
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
enum Screen {
Loading,
Attract,
Playing,
Help,
Settings,
HighScores,
NameEntry,
}
#[derive(Clone, Copy, Debug, Default, PartialEq)]
struct AttractAnimation {
tick: u32,
score_counter: u32,
accumulator: f32,
}
impl AttractAnimation {
fn update(&mut self, frame_time: f32, detail: u8) {
let interval = DETAIL_TIMER_SECONDS[usize::from(detail.clamp(1, 5) - 1)];
self.accumulator = (self.accumulator + frame_time.min(0.05)).min(0.1);
while self.accumulator >= interval {
self.tick = self.tick.wrapping_add(1);
if self.tick.is_multiple_of(6) {
self.score_counter = self.score_counter.wrapping_add(1);
}
self.accumulator -= interval;
}
}
fn target_active(self, index: usize) -> bool {
let index = u32::try_from(index).unwrap_or(0);
let first_activation = if index == 0 { 32 } else { index * 2 };
self.tick >= first_activation && self.tick.wrapping_sub(index * 2) % 32 < 16
}
fn active_word_quad(self) -> Option<usize> {
(self.tick >= 4).then(|| {
let phase = (self.tick % 32) / 4;
usize::try_from(if phase <= 4 { phase } else { 8 - phase }).unwrap_or(0)
})
}
fn magnetic_records_active(self) -> bool {
self.tick >= 32 && self.tick % 32 < 16
}
fn record_strip_active(self, index: usize) -> bool {
let index = u32::try_from(index).unwrap_or(0);
let first_activation = if index == 0 { 40 } else { index * 4 };
self.tick >= first_activation && self.tick.wrapping_sub(index * 4) % 40 < 20
}
fn chase_index(self) -> Option<usize> {
(self.tick >= 4).then(|| usize::try_from((self.tick % 16) / 4).unwrap_or(0))
}
fn item_index(self) -> Option<usize> {
let phase = (self.tick % 144) / 8;
let item = if phase < 10 { phase } else { 18 - phase };
(item != 0).then(|| usize::try_from(item - 1).unwrap_or(0))
}
fn marquee_source_x(self) -> Option<f32> {
(self.score_counter != 0).then(|| {
let phase = self.score_counter % 49;
f32::from(u16::try_from(160 + ((phase + 14) % 49) * 16).unwrap_or(160))
})
}
}
pub struct App {
assets: Assets,
persistence: Persistence,
saved: SavedData,
sounds_enabled: bool,
render_target: RenderTarget,
screen: Screen,
return_screen: Screen,
game: Option<Game>,
game_random_seed: u32,
setting_row: usize,
loading_started: f64,
loading_until: f64,
last_help_click: f64,
name: String,
pending_score: u32,
attract: AttractAnimation,
}
impl App {
pub async fn load() -> Self {
let assets = Assets::load().await;
let persistence = Persistence::new();
let saved = persistence.load();
let sounds_enabled = saved.settings.sounds;
let render_target = render_target(640, 460);
render_target.texture.set_filter(FilterMode::Nearest);
let loading_started = get_time();
Self {
assets,
persistence,
saved,
sounds_enabled,
render_target,
screen: Screen::Loading,
return_screen: Screen::Attract,
game: None,
game_random_seed: initial_game_random_seed(),
setting_row: 0,
loading_started,
loading_until: loading_started + 1.1,
last_help_click: -1.0,
name: String::new(),
pending_score: 0,
attract: AttractAnimation::default(),
}
}
pub fn frame(&mut self) {
#[cfg(target_arch = "wasm32")]
self.assets.update();
#[cfg(target_arch = "wasm32")]
self.update_remote_high_scores();
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();
}
#[cfg(not(target_arch = "wasm32"))]
pub fn set_simulation_game(&mut self, game: Game) {
self.game = Some(game);
self.screen = Screen::Playing;
}
#[cfg(not(target_arch = "wasm32"))]
pub fn set_simulation_attract(&mut self, steps: u64) {
self.game = None;
self.screen = Screen::Attract;
self.attract = AttractAnimation::default();
for _ in 0..steps {
self.attract.update(1.0 / 120.0, 3);
}
}
#[cfg(not(target_arch = "wasm32"))]
pub fn set_simulation_loading(&mut self, steps: u64) {
self.game = None;
self.screen = Screen::Loading;
let step = u32::try_from(steps).unwrap_or(u32::MAX);
self.loading_started = get_time() - f64::from(step) / 120.0;
self.loading_until = get_time() + 60.0;
}
#[cfg(not(target_arch = "wasm32"))]
pub fn set_simulation_highscores(&mut self) {
self.game = None;
self.return_screen = Screen::Attract;
self.screen = Screen::HighScores;
}
#[cfg(not(target_arch = "wasm32"))]
pub fn set_simulation_name_entry(&mut self) {
self.game = Some(Game::new(1));
self.return_screen = Screen::Playing;
self.pending_score = 6_537_393;
"TDK Pinball Player".clone_into(&mut self.name);
self.screen = Screen::NameEntry;
}
#[cfg(not(target_arch = "wasm32"))]
pub fn render_simulation(&self) {
self.draw_logical();
}
#[cfg(not(target_arch = "wasm32"))]
pub fn export_simulation_png(&self, path: &Path) -> Result<(), String> {
let path = path
.to_str()
.ok_or_else(|| format!("screenshot path is not valid UTF-8: {}", path.display()))?;
self.render_target
.texture
.get_texture_data()
.export_png(path);
Ok(())
}
fn handle_global_input(&mut self) {
let base_screen = matches!(self.screen, Screen::Attract | Screen::Playing);
if base_screen && is_key_released(KeyCode::F12) {
self.sounds_enabled = !self.sounds_enabled;
}
if base_screen && is_key_pressed(KeyCode::F1) {
self.return_screen = self.base_screen();
self.screen = Screen::Help;
}
if is_key_pressed(KeyCode::F10) && 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::F9) && 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) {
self.attract
.update(get_frame_time(), self.saved.settings.speed);
if add_player_released() {
self.game = Some(Game::new_with_seed(1, self.game_random_seed));
self.screen = Screen::Playing;
self.assets.play(2001, self.sounds_enabled);
}
}
fn update_game(&mut self) {
if add_player_released()
&& self
.game
.as_mut()
.is_some_and(Game::add_player_before_launch)
{
self.assets.play(2001, self.sounds_enabled);
}
let left_flipper = is_key_down(KeyCode::LeftControl)
|| is_key_down(KeyCode::RightControl)
|| is_key_down(KeyCode::A)
|| is_key_down(KeyCode::Left);
let right_flipper = is_key_down(KeyCode::KpEnter)
|| is_key_down(KeyCode::Enter)
|| is_key_down(KeyCode::D)
|| is_key_down(KeyCode::Right);
let nudge = if is_key_released(KeyCode::LeftShift) {
Nudge::Left
} else if is_key_released(KeyCode::RightShift) || is_key_released(KeyCode::Kp3) {
Nudge::Right
} else if is_key_released(KeyCode::Space) {
Nudge::Center
} else {
Nudge::None
};
let controls = Controls {
left_flipper,
right_flipper,
launch_down: is_key_down(KeyCode::Down),
nudge,
};
let events = self.game.as_mut().map_or_else(Vec::new, |game| {
game.update(get_frame_time(), self.saved.settings.speed, controls)
});
let mut high_score_candidate = None;
for event in events {
if let Event::HighScoreCandidate(score) = event {
high_score_candidate = Some(score);
}
self.play_event(event);
}
if let Some(score) = high_score_candidate
&& qualifies_high_score(&self.saved.high_scores, score)
{
self.pending_score = score;
"TDK Pinball Player".clone_into(&mut self.name);
self.screen = Screen::NameEntry;
return;
}
if self.game.as_ref().is_some_and(|game| game.finished) {
self.finish_game();
}
}
fn play_event(&self, event: Event) {
if event == Event::StopSound {
self.assets.stop_all_sounds();
return;
}
if let Some(resource) = event.sound_resource() {
self.assets.play(resource, self.sounds_enabled);
}
}
fn finish_game(&mut self) {
self.take_game_and_preserve_random_seed();
self.return_screen = Screen::Attract;
self.screen = Screen::HighScores;
}
fn take_game_and_preserve_random_seed(&mut self) {
if let Some(game) = self.game.take() {
self.game_random_seed = game.random_seed();
}
}
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();
#[cfg(not(target_arch = "wasm32"))]
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.sounds_enabled = !self.sounds_enabled;
self.saved.settings.sounds = self.sounds_enabled;
}
_ => {
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.sounds_enabled);
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;
if self.screen == Screen::Attract {
self.attract = AttractAnimation::default();
}
}
}
fn update_name_entry(&mut self) {
while let Some(character) = get_char_pressed() {
if !character.is_control()
&& u32::from(character) <= 0xff
&& self.name.chars().count() < 21
{
self.name.push(character);
}
}
if is_key_pressed(KeyCode::Backspace) {
self.name.pop();
}
let click = is_mouse_button_pressed(MouseButton::Left).then(Self::logical_mouse);
let clicked_ok =
click.is_some_and(|point| Rect::new(255.0, 256.0, 60.0, 23.0).contains(point));
let clicked_cancel =
click.is_some_and(|point| Rect::new(324.0, 256.0, 60.0, 23.0).contains(point));
if is_key_pressed(KeyCode::Escape) || clicked_cancel {
"TDK Pinball Player".clone_into(&mut self.name);
self.accept_high_score_name();
} else if is_key_pressed(KeyCode::Enter) || clicked_ok {
self.accept_high_score_name();
}
}
fn accept_high_score_name(&mut self) {
let entry = HighScore {
name: self.name.clone(),
score: self.pending_score,
};
insert_high_score(&mut self.saved.high_scores, entry.clone());
#[cfg(target_arch = "wasm32")]
self.persistence.submit_high_score(&entry);
self.save();
if self.game.as_ref().is_some_and(|game| game.finished) {
self.take_game_and_preserve_random_seed();
self.return_screen = Screen::Attract;
} else {
self.return_screen = Screen::Playing;
}
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 => self.draw_loading(),
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(),
}
}
fn draw_attract(&self) {
draw_texture(&self.assets.inactive_table, 0.0, 0.0, WHITE);
for (index, (left, top, right, bottom)) in [
(17, 174, 31, 188),
(11, 155, 25, 169),
(10, 136, 24, 150),
(10, 117, 24, 131),
(10, 98, 24, 112),
(10, 79, 24, 93),
(10, 60, 24, 79),
(10, 41, 24, 55),
]
.into_iter()
.enumerate()
{
if self.attract.target_active(index) {
self.draw_active_table_region(left, top, right - left + 1, bottom - top + 1);
}
}
if let Some(active_quad) = self.attract.active_word_quad() {
let (left, top, right, bottom) = BUMPER_VALUE_REGIONS[active_quad];
self.draw_active_table_region(left, top, right - left + 1, bottom - top + 1);
}
if self.attract.magnetic_records_active() {
for (x, y, width, height) in [(151, 389, 13, 49), (11, 344, 13, 49), (291, 346, 13, 49)]
{
self.draw_active_table_region(x, y, width, height);
}
}
for (index, (left, top, right, bottom)) in [
(282, 231, 306, 246),
(282, 217, 306, 231),
(282, 202, 306, 216),
(282, 187, 306, 201),
(282, 174, 306, 186),
]
.into_iter()
.enumerate()
{
if self.attract.record_strip_active(index) {
self.draw_active_table_region(left, top, right - left - 10, bottom - top + 1);
}
}
if let Some(phase) = self.attract.chase_index() {
let y = [209, 214, 219, 224][phase];
let x = [150, 164, 178, 192][phase];
self.draw_active_table_region(x, y, 9, 9);
}
if let Some(item) = self.attract.item_index() {
draw_texture(&self.assets.diamond[item], 123.0, 300.0, WHITE);
}
self.draw_intro_marquee();
}
#[allow(clippy::cast_precision_loss)]
fn draw_loading(&self) {
draw_texture(&self.assets.loading, 0.0, 0.0, WHITE);
let stage = Self::loading_stage_at(get_time() - self.loading_started);
let width = i32::from(stage.saturating_sub(1)) * 7;
draw_texture_region(
&self.assets.loading_progress,
202.0,
328.0,
width.min(237) as f32,
13.0,
0.0,
0.0,
);
}
#[allow(clippy::cast_possible_truncation)]
fn loading_stage_at(elapsed: f64) -> u8 {
let progress = (elapsed / 1.1).clamp(0.0, 0.999_999);
u8::try_from(2 + (progress * 34.0).floor() as i32).unwrap_or(35)
}
fn draw_intro_marquee(&self) {
let Some(source_x) = self.attract.marquee_source_x() else {
return;
};
draw_texture_region(
&self.assets.active_table,
372.0,
233.0,
100.0,
30.0,
473.0,
277.0,
);
draw_texture_region(
&self.assets.active_table,
472.0,
233.0,
137.0,
30.0,
472.0,
233.0,
);
let available = 944.0 - source_x;
let first_width = available.min(237.0);
self.draw_marquee_slice(372.0, source_x, first_width);
let wrapped_width = 237.0 - first_width;
if wrapped_width > 0.0 {
self.draw_marquee_slice(372.0 + first_width, 160.0, wrapped_width);
}
}
fn draw_marquee_slice(&self, destination_x: f32, source_x: f32, width: f32) {
draw_texture_ex(
&self.assets.digits,
destination_x,
233.0,
BLACK,
DrawTextureParams {
dest_size: Some(vec2(width, 28.0)),
source: Some(Rect::new(source_x, 0.0, width, 28.0)),
..Default::default()
},
);
}
#[allow(clippy::cast_precision_loss)]
fn draw_active_table_region(&self, x: i32, y: i32, width: i32, height: i32) {
draw_texture_region(
&self.assets.active_table,
x as f32,
y as f32,
width as f32,
height as f32,
x as f32,
y as f32,
);
}
#[allow(clippy::cast_precision_loss)]
fn draw_inactive_table_region(&self, x: i32, y: i32, width: i32, height: i32) {
draw_texture_region(
&self.assets.inactive_table,
x as f32,
y as f32,
width as f32,
height as f32,
x as f32,
y as f32,
);
}
#[allow(clippy::cast_precision_loss, clippy::too_many_lines)]
fn draw_game(&self) {
// Resource 998 is the original game's resting playfield. Resource 997
// contains exact lit-state patches used below for the active displays.
draw_texture(&self.assets.inactive_table, 0.0, 0.0, WHITE);
let Some(game) = &self.game else {
return;
};
draw_texture(
&self.assets.player_effects[usize::from(game.player_effect())],
55.0,
79.0,
WHITE,
);
self.draw_inactive_table_region(84, 77, 14, 14);
if let Some(frame) = game.panel_frame {
self.draw_panel_animation(frame);
} else if let Some(state) = game
.target_rotation_state
.or_else(|| game.wheel_holes.iter().any(|filled| *filled).then_some(6))
{
self.draw_target_rotation(game, state);
}
for (record_id, left, top, right, bottom) in [
(51, 150, 133, 180, 163),
(52, 191, 95, 221, 125),
(53, 204, 150, 234, 180),
(140, 17, 174, 31, 188),
(141, 11, 155, 25, 169),
(142, 10, 136, 24, 150),
(143, 10, 117, 24, 131),
(144, 10, 98, 24, 112),
(145, 10, 79, 24, 93),
(146, 10, 60, 24, 79),
(147, 10, 41, 24, 55),
(150, 199, 40, 211, 50),
(151, 228, 41, 240, 51),
(152, 257, 42, 269, 52),
] {
if game.record_countdown(record_id) != 0 {
let width = (right - left + 1) as f32;
let height = (bottom - top + 1) as f32;
draw_texture_region(
&self.assets.active_table,
left as f32,
top as f32,
width,
height,
left as f32,
top as f32,
);
}
}
for (record_id, left, top, right, bottom) in BANK_TARGET_REGIONS {
if game.bank_target_lit(record_id) {
self.draw_active_table_region(left, top, right - left + 1, bottom - top + 1);
}
}
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, 300.0, WHITE);
}
for &(left, top, right, bottom) in BUMPER_VALUE_REGIONS
.iter()
.take(bumper_value_indicator_count(game.player().bumper_value))
{
self.draw_active_table_region(left, top, right - left + 1, bottom - top + 1);
}
for (object_id, region) in [
(6, Rect::new(151.0, 389.0, 13.0, 49.0)),
(153, Rect::new(11.0, 344.0, 13.0, 49.0)),
(154, Rect::new(291.0, 346.0, 13.0, 49.0)),
] {
if game.magnetic_field_active(object_id) {
draw_texture_ex(
&self.assets.active_table,
region.x,
region.y,
WHITE,
DrawTextureParams {
dest_size: Some(vec2(region.w, region.h)),
source: Some(region),
..Default::default()
},
);
}
}
if game.effect_target_active() {
let region = Rect::new(60.0, 57.0, 18.0, 17.0);
draw_texture_ex(
&self.assets.active_table,
region.x,
region.y,
WHITE,
DrawTextureParams {
dest_size: Some(vec2(region.w, region.h)),
source: Some(region),
..Default::default()
},
);
}
if game.special_hole_active() {
self.draw_active_table_region(9, 14, 17, 17);
}
if game.ball.in_launcher {
draw_texture_ex(
&self.assets.plunger,
314.0,
426.0,
WHITE,
DrawTextureParams {
dest_size: Some(vec2(21.0, 18.0)),
source: Some(Rect::new(
game.launcher_frame() as f32 * 21.0,
0.0,
20.0,
17.0,
)),
..Default::default()
},
);
}
for (raised, region) in [
(
game.flippers.left_raised,
Rect::new(93.0, 373.0, 47.0, 52.0),
),
(
game.flippers.right_raised,
Rect::new(175.0, 372.0, 45.0, 53.0),
),
] {
if raised {
draw_texture_ex(
&self.assets.active_table,
region.x,
region.y,
WHITE,
DrawTextureParams {
dest_size: Some(vec2(region.w, region.h)),
source: Some(region),
..Default::default()
},
);
}
}
if !game.claw.ball_suspended {
draw_texture(
&self.assets.ball,
game.ball.position.x - 8.0,
game.ball.position.y - 8.0,
WHITE,
);
}
if let Some(ball) = game.secondary_ball {
draw_texture(
&self.assets.ball,
ball.position.x - 8.0,
ball.position.y - 8.0,
WHITE,
);
}
if let Some(source) = game.claw.sprite_source() {
draw_texture_ex(
&self.assets.robot,
238.0,
47.0,
WHITE,
DrawTextureParams {
dest_size: Some(vec2(100.0, 74.0)),
source: Some(source),
..Default::default()
},
);
}
self.draw_displays(game);
if game.tilted {
draw_texture_ex(
&self.assets.active_table,
82.0,
238.0,
WHITE,
DrawTextureParams {
dest_size: Some(vec2(151.0, 33.0)),
source: Some(Rect::new(82.0, 238.0, 151.0, 33.0)),
..Default::default()
},
);
}
}
#[allow(clippy::cast_precision_loss, clippy::too_many_lines)]
fn draw_panel_animation(&self, frame: u16) {
draw_texture_region(&self.assets.active_table, 79.0, 0.0, 91.0, 123.0, 79.0, 0.0);
let frame = i32::from(frame);
let narrow_a = &self.assets.panel_pair_narrow_a;
let narrow_b = &self.assets.panel_pair_narrow_b;
let wide_a = &self.assets.panel_pair_wide_a;
let wide_b = &self.assets.panel_pair_wide_b;
if (1..=44).contains(&frame) {
draw_texture_region(
narrow_a,
99.0,
0.0,
45.0,
(frame * 2) as f32,
0.0,
((45 - frame) * 2 - 1) as f32,
);
} else if (45..=57).contains(&frame) {
draw_texture_region(narrow_a, 99.0, 0.0, 45.0, 88.0, 0.0, 0.0);
} else if (58..=65).contains(&frame) {
draw_texture_region(narrow_b, 99.0, 0.0, 45.0, 88.0, 0.0, 0.0);
} else if (66..=128).contains(&frame) {
draw_texture_region(&self.assets.wheel, 79.0, 33.0, 91.0, 90.0, 180.0, 90.0);
if frame < 83 {
draw_texture_region(
wide_a,
79.0,
((82 - frame) * 2) as f32,
91.0,
90.0,
0.0,
0.0,
);
} else {
let height = (128 - frame) * 2;
draw_texture_region(
wide_a,
79.0,
0.0,
91.0,
height as f32,
0.0,
((frame - 82) * 2) as f32,
);
}
if frame < 111 {
let height = (110 - frame) * 2;
draw_texture_region(
narrow_b,
99.0,
0.0,
45.0,
height as f32,
0.0,
((frame - 66) * 2) as f32,
);
}
} else if (129..=158).contains(&frame) {
draw_texture_region(&self.assets.wheel, 79.0, 33.0, 91.0, 90.0, 180.0, 90.0);
} else if (159..=221).contains(&frame) {
draw_texture_region(&self.assets.wheel, 79.0, 33.0, 91.0, 90.0, 180.0, 90.0);
if frame < 204 {
let height = (frame - 159) * 2;
draw_texture_region(
wide_b,
79.0,
0.0,
91.0,
height as f32,
0.0,
((204 - frame) * 2) as f32,
);
} else {
draw_texture_region(
wide_b,
79.0,
((frame - 204) * 2) as f32,
91.0,
90.0,
0.0,
0.0,
);
}
if frame > 176 {
if frame == 221 {
draw_texture_region(narrow_b, 99.0, 0.0, 45.0, 88.0, 0.0, 0.0);
} else {
let height = (frame - 177) * 2;
draw_texture_region(
narrow_b,
99.0,
0.0,
45.0,
height as f32,
0.0,
((222 - frame) * 2 - 1) as f32,
);
}
}
} else if (222..=229).contains(&frame) {
draw_texture_region(wide_b, 79.0, 33.0, 91.0, 90.0, 0.0, 0.0);
draw_texture_region(narrow_b, 99.0, 0.0, 45.0, 88.0, 0.0, 0.0);
} else if (230..=237).contains(&frame) {
draw_texture_region(wide_b, 79.0, 33.0, 91.0, 90.0, 0.0, 0.0);
draw_texture_region(narrow_a, 99.0, 0.0, 45.0, 88.0, 0.0, 0.0);
} else if (238..=281).contains(&frame) {
draw_texture_region(wide_b, 79.0, 33.0, 91.0, 90.0, 0.0, 0.0);
let height = (281 - frame) * 2;
draw_texture_region(
narrow_a,
99.0,
0.0,
45.0,
height as f32,
0.0,
((frame - 238) * 2) as f32,
);
}
}
#[allow(clippy::cast_precision_loss)]
fn draw_target_rotation(&self, game: &Game, state: u8) {
if state == 0 || state > 6 {
return;
}
if state < 6 {
draw_texture_region(
&self.assets.wheel,
79.0,
33.0,
91.0,
90.0,
f32::from(state - 1) * 91.0,
0.0,
);
} else {
draw_texture_region(
&self.assets.inactive_table,
79.0,
33.0,
91.0,
90.0,
79.0,
33.0,
);
}
for (active, (x, y)) in game
.wheel_holes
.iter()
.zip(TARGET_POSITIONS[usize::from(state - 1)])
{
if *active {
draw_texture(&self.assets.panel_target, 79.0 + x, 33.0 + y, WHITE);
}
}
}
#[allow(clippy::cast_precision_loss)]
fn draw_displays(&self, game: &Game) {
self.draw_active_table_region(372, 233, 238, 30);
self.draw_score_value(game.secondary_score_display(), 233.0);
let status_level = status_level_for_score(game.player().score);
if status_level == 0 {
self.draw_active_table_region(380, 69, 146, 142);
} else {
draw_texture(
&self.assets.media[usize::from(status_level - 1)],
380.0,
69.0,
WHITE,
);
}
self.draw_active_table_region(544, i32::from(4 - status_level) * 23 + 77, 11, 11);
for (index, player) in game.players.iter().enumerate() {
let y = 276 + i32::try_from(index).unwrap_or(0) * 44;
self.draw_active_table_region(472, y, 139, 31);
self.draw_score_value(player.score, (y + 1) as f32);
let displayed_balls = displayed_player_markers(
player.balls,
player.extra_balls,
index == game.current_player,
);
for marker in 0..6 {
let x = 391 + i32::from(marker) * 8;
if marker < displayed_balls {
self.draw_active_table_region(x, y + 22, 5, 5);
} else {
self.draw_inactive_table_region(x, y + 22, 5, 5);
}
}
}
self.draw_active_table_region(
372,
280 + i32::try_from(game.current_player).unwrap_or(0) * 44,
11,
11,
);
}
fn draw_score_value(&self, value: u32, y: f32) {
for (slot, digit) in visible_score_digits(value) {
let x = 478.0 + f32::from(slot) * 16.0;
draw_texture_ex(
&self.assets.digits,
x,
y,
BLACK,
DrawTextureParams {
dest_size: Some(vec2(16.0, 28.0)),
source: Some(Rect::new(f32::from(digit) * 16.0, 0.0, 16.0, 28.0)),
..Default::default()
},
);
}
}
#[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.sounds_enabled { "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) {
self.draw_modal_background();
draw_rectangle(140.0, 58.0, 360.0, 22.0, Color::from_rgba(0, 0, 128, 255));
draw_rectangle_lines(140.0, 58.0, 360.0, 22.0, 2.0, WHITE);
draw_text("HighScore", 147.0, 74.0, 14.0, WHITE);
draw_texture(&self.assets.highscore_background, 140.0, 80.0, WHITE);
draw_centered_at("HIGH SCORES", 320.0, 122.0, 18, BLACK);
for (index, entry) in self.saved.high_scores.iter().take(10).enumerate() {
let rank = index + 1;
let y = 155.0 + index as f32 * 20.0;
let prefix = if rank < 10 { " " } else { "" };
draw_text(
format!("{prefix}{rank}. {}", entry.name),
170.0,
y,
16.0,
BLACK,
);
let score = entry.score.to_string();
let width = measure_text(&score, None, 16, 1.0).width;
draw_text(&score, 460.0 - width, y, 16.0, BLACK);
}
}
fn draw_name_entry(&self) {
self.draw_modal_background();
let x = 185.0;
let y = 168.0;
draw_rectangle(x, y, 270.0, 124.0, Color::from_rgba(192, 192, 192, 255));
draw_rectangle_lines(x, y, 270.0, 124.0, 2.0, WHITE);
draw_rectangle_lines(x + 2.0, y + 2.0, 266.0, 120.0, 2.0, DARKGRAY);
draw_rectangle(
x + 4.0,
y + 4.0,
262.0,
20.0,
Color::from_rgba(0, 0, 128, 255),
);
draw_text(
"Congratulations! This is a Top Ten Score!",
x + 9.0,
y + 19.0,
12.0,
WHITE,
);
draw_text("Please input your name:", x + 15.0, y + 44.0, 14.0, BLACK);
draw_rectangle(x + 15.0, y + 52.0, 240.0, 21.0, WHITE);
draw_rectangle_lines(x + 14.0, y + 51.0, 242.0, 23.0, 2.0, DARKGRAY);
draw_text(
format!(
"{}{}",
self.name,
if get_time().rem_euclid(1.0) < 0.5 {
"_"
} else {
""
}
),
x + 18.0,
y + 68.0,
14.0,
BLACK,
);
Self::draw_dialog_button(x + 70.0, y + 88.0, "OK", true);
Self::draw_dialog_button(x + 139.0, y + 88.0, "Cancel", false);
}
fn draw_modal_background(&self) {
if self.game.is_some() {
self.draw_game();
} else {
self.draw_attract();
}
}
fn draw_dialog_button(x: f32, y: f32, label: &str, default: bool) {
if default {
draw_rectangle_lines(x - 2.0, y - 2.0, 64.0, 27.0, 1.0, BLACK);
}
draw_rectangle(x, y, 60.0, 23.0, Color::from_rgba(192, 192, 192, 255));
draw_rectangle_lines(x, y, 60.0, 23.0, 2.0, WHITE);
draw_centered_at(label, x + 30.0, y + 17.0, 13, 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}");
}
}
#[cfg(target_arch = "wasm32")]
fn update_remote_high_scores(&mut self) {
if let Some(scores) = self.persistence.take_high_scores() {
self.saved.high_scores = scores;
self.save();
}
}
}
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_texture_region(
texture: &Texture2D,
destination_x: f32,
destination_y: f32,
width: f32,
height: f32,
source_x: f32,
source_y: f32,
) {
if width <= 0.0 || height <= 0.0 {
return;
}
draw_texture_ex(
texture,
destination_x,
destination_y,
WHITE,
DrawTextureParams {
dest_size: Some(vec2(width, height)),
source: Some(Rect::new(source_x, source_y, width, height)),
..Default::default()
},
);
}
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,
);
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn attract_timer_uses_the_selected_detail_callback() {
for (detail, interval) in [(1, 0.050), (2, 0.040), (3, 0.030), (4, 0.020), (5, 0.010)] {
let mut animation = AttractAnimation::default();
animation.update(interval - 0.001, detail);
assert_eq!(animation.tick, 0);
animation.update(0.001_1, detail);
assert_eq!(animation.tick, 1);
}
}
#[test]
fn loading_progress_covers_original_stages_two_through_thirty_five() {
assert_eq!(App::loading_stage_at(-1.0), 2);
assert_eq!(App::loading_stage_at(0.0), 2);
assert_eq!(App::loading_stage_at(1.1 / 34.0), 3);
assert_eq!(App::loading_stage_at(1.09), 35);
assert_eq!(App::loading_stage_at(99.0), 35);
assert_eq!(i32::from(App::loading_stage_at(99.0) - 1) * 7, 238);
}
#[test]
fn score_renderer_uses_eight_places_and_original_right_edge() {
assert_eq!(visible_score_digits(0), [(7, 0)]);
assert_eq!(visible_score_digits(123), [(7, 3), (6, 2), (5, 1)]);
let digits = visible_score_digits(123_456_789);
assert_eq!(digits.len(), 8);
assert_eq!(digits[0], (7, 9));
assert_eq!(digits[7], (0, 2));
assert_eq!(478 + i32::from(digits[0].0) * 16 + 16, 606);
}
#[test]
fn status_and_ball_markers_follow_original_display_thresholds() {
assert_eq!(status_level_for_score(2_000), 0);
assert_eq!(status_level_for_score(2_001), 1);
assert_eq!(status_level_for_score(140_000), 1);
assert_eq!(status_level_for_score(140_001), 2);
assert_eq!(status_level_for_score(650_001), 3);
assert_eq!(status_level_for_score(1_300_001), 4);
assert_eq!(status_level_for_score(u32::MAX), 0);
assert_eq!(displayed_player_markers(3, 0, true), 2);
assert_eq!(displayed_player_markers(3, 0, false), 3);
assert_eq!(displayed_player_markers(3, 4, true), 6);
assert_eq!(bumper_value_indicator_count(1_000), 0);
assert_eq!(bumper_value_indicator_count(2_000), 1);
assert_eq!(bumper_value_indicator_count(6_000), 5);
assert_eq!(bumper_value_indicator_count(u32::MAX), 5);
assert_eq!(
ADD_PLAYER_KEYS,
[KeyCode::KpAdd, KeyCode::RightBracket, KeyCode::Equal]
);
}
#[test]
fn attract_phases_follow_the_reconstructed_tick_quotients() {
let at = |tick| AttractAnimation {
tick,
..AttractAnimation::default()
};
assert!(!at(31).target_active(0));
assert!(at(32).target_active(0));
assert!(at(2).target_active(1));
assert!(!at(18).target_active(1));
assert!(at(34).target_active(1));
assert_eq!(at(3).active_word_quad(), None);
assert_eq!(at(4).active_word_quad(), Some(1));
assert_eq!(at(16).active_word_quad(), Some(4));
assert_eq!(at(20).active_word_quad(), Some(3));
assert_eq!(at(32).active_word_quad(), Some(0));
assert!(!at(31).magnetic_records_active());
assert!(at(32).magnetic_records_active());
assert!(!at(48).magnetic_records_active());
assert!(!at(39).record_strip_active(0));
assert!(at(40).record_strip_active(0));
assert!(at(4).record_strip_active(1));
assert!(!at(24).record_strip_active(1));
assert_eq!(at(4).chase_index(), Some(1));
assert_eq!(at(8).chase_index(), Some(2));
assert_eq!(at(12).chase_index(), Some(3));
assert_eq!(at(16).chase_index(), Some(0));
assert_eq!(at(7).item_index(), None);
assert_eq!(at(8).item_index(), Some(0));
assert_eq!(at(72).item_index(), Some(8));
assert_eq!(at(80).item_index(), Some(7));
assert_eq!(at(136).item_index(), Some(0));
assert_eq!(at(144).item_index(), None);
}
#[test]
fn attract_marquee_counter_advances_every_six_callbacks() {
let mut animation = AttractAnimation::default();
for _ in 0..5 {
animation.update(0.030, 3);
}
assert_eq!(animation.score_counter, 0);
animation.update(0.030, 3);
assert_eq!(animation.score_counter, 1);
assert_eq!(animation.marquee_source_x(), Some(400.0));
animation.score_counter = 35;
assert_eq!(animation.marquee_source_x(), Some(160.0));
animation.score_counter = 49;
assert_eq!(animation.marquee_source_x(), Some(384.0));
}
}