Files
tdkpin/tdkpin-rs/src/app.rs
T
ddidderr 25147ad58e fix(divergence): preserve multiball Greifarm captures
The original port consumed a ball as soon as record 89 completed during
multiball, which skipped the Greifarm animation and lost the ball permanently.
Keep the captured slot through the full animation instead, latch whether the
capture began with two live balls, and exclude the launcher release (frame 18)
for that capture. The surviving slot continues physics, scoring, nudging, and
flipper responses while the held slot is suspended; drain and promotion paths
also preserve the held ball. A later Greifarm entry makes a fresh ball-count
decision. Update slot-aware rendering and refresh the browser WASM artifact.

Test Plan:
- `just --justfile tdkpin-rs/justfile test` -- passed (149 game/service tests)
- `just --justfile tdkpin-rs/justfile clippy` -- passed
- `just --justfile tdkpin-rs/justfile build-production` -- passed
- `just --justfile tdkpin-rs/justfile web-build` -- passed
- `cargo +nightly fmt --manifest-path tdkpin-rs/Cargo.toml --all -- --check` -- passed
- `rumdl check --flavor commonmark tdkpin-rs/CHANGELOG.md tdkpin-rs/AGENTS.md` -- passed
- `git diff --cached --check` -- passed
2026-08-31 22:15:39 +02:00

1487 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.primary_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
&& !game.secondary_ball_suspended()
{
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));
}
}