Files
tdkpin/tdkpin-rs/src/app.rs
T
ddidderr c84df73857 fix(ui): restore effect and player display assets
Load and render DAT400-407 effect panels with the preserved wheel overlap, and add deterministic effect-7 coverage. Restore exact 1008:0996/0b9e score fields, configured-player rows, current/status indicators, signed status thresholds, and six 5x5 per-player ball-marker slots.

Test Plan:
- cargo test --all-targets
- cargo clippy --all-targets --all-features -- -D warnings
- rumdl check CHANGELOG.md RECONSTRUCTION.md README.md
- cargo run --quiet -- --simulate effect-7 --step 0 --screenshot /tmp/tdkpin-effect7.png
- cargo run --quiet -- --simulate launcher --step 0 --screenshot /tmp/tdkpin-display.png
- git diff --check
2026-08-23 19:21:28 +02:00

1385 lines
45 KiB
Rust

use crate::{
assets::Assets,
game::{Controls, Event, Game, Nudge},
persistence::{
HighScore, Language, Persistence, SavedData, insert_high_score, qualifies_high_score,
},
};
use macroquad::prelude::*;
use std::path::Path;
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 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)],
];
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 start = if index == 0 {
2
} else {
u32::try_from(index).unwrap_or(0) * 64
};
let end = u32::try_from(index + 8).unwrap_or(8) * 64;
self.tick >= start && self.tick < end
}
fn active_word_quad(self) -> Option<usize> {
(self.tick >= 4).then(|| {
let phase = (self.tick / 128).min(8);
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 start = if index == 0 {
4
} else {
u32::try_from(index).unwrap_or(0) * 160
};
let end = u32::try_from(index + 5).unwrap_or(5) * 160;
self.tick >= start && self.tick < end
}
fn chase_index(self) -> Option<usize> {
(self.tick >= 4).then(|| usize::try_from((self.tick / 64).min(3)).unwrap_or(0))
}
fn item_index(self) -> Option<usize> {
let phase = self.tick / 1_152;
let item = if phase < 10 {
phase
} else {
18_u32.saturating_sub(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>,
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,
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) {
self.handle_global_input();
match self.screen {
Screen::Loading => {
if get_time() >= self.loading_until {
self.screen = Screen::Attract;
}
}
Screen::Attract => self.update_attract(),
Screen::Playing => self.update_game(),
Screen::Help => self.update_help(),
Screen::Settings => self.update_settings(),
Screen::HighScores => self.update_high_scores(),
Screen::NameEntry => self.update_name_entry(),
}
self.draw_logical();
self.present();
}
pub fn set_simulation_game(&mut self, game: Game) {
self.game = Some(game);
self.screen = Screen::Playing;
}
pub fn 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);
}
}
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;
}
pub fn set_simulation_highscores(&mut self) {
self.game = None;
self.return_screen = Screen::Attract;
self.screen = Screen::HighScores;
}
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;
}
pub fn render_simulation(&self) {
self.draw_logical();
}
pub fn export_simulation_png(&self, path: &Path) -> Result<(), String> {
let path = path
.to_str()
.ok_or_else(|| format!("screenshot path is not valid UTF-8: {}", path.display()))?;
self.render_target
.texture
.get_texture_data()
.export_png(path);
Ok(())
}
fn handle_global_input(&mut self) {
if is_key_pressed(KeyCode::F12) {
self.sounds_enabled = !self.sounds_enabled;
}
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::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 is_key_pressed(KeyCode::KpAdd) || is_key_pressed(KeyCode::Equal) {
self.game = Some(Game::new(1));
self.screen = Screen::Playing;
self.assets.play(2001, self.sounds_enabled);
}
}
fn update_game(&mut self) {
if (is_key_pressed(KeyCode::KpAdd) || is_key_pressed(KeyCode::Equal))
&& self
.game
.as_mut()
.is_some_and(Game::add_player_before_launch)
{
self.assets.play(2001, self.sounds_enabled);
}
let left_flipper = is_key_down(KeyCode::LeftControl)
|| is_key_down(KeyCode::A)
|| is_key_down(KeyCode::Left);
let right_flipper = is_key_down(KeyCode::KpEnter)
|| is_key_down(KeyCode::RightControl)
|| is_key_down(KeyCode::D)
|| is_key_down(KeyCode::Right);
let nudge = if is_key_pressed(KeyCode::LeftShift) {
Nudge::Left
} else if is_key_pressed(KeyCode::RightShift) || is_key_pressed(KeyCode::Kp3) {
Nudge::Right
} else if is_key_pressed(KeyCode::Space) {
Nudge::Center
} else {
Nudge::None
};
let controls = Controls {
left_flipper,
right_flipper,
launch_down: is_key_down(KeyCode::Down),
nudge,
};
let events = self.game.as_mut().map_or_else(Vec::new, |game| {
game.update(get_frame_time(), self.saved.settings.speed, controls)
});
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.game = None;
self.return_screen = Screen::Attract;
self.screen = Screen::HighScores;
}
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.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) {
insert_high_score(
&mut self.saved.high_scores,
HighScore {
name: self.name.clone(),
score: self.pending_score,
},
);
self.save();
if self.game.as_ref().is_some_and(|game| game.finished) {
self.game = None;
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) = [
(103, 304, 121, 323),
(119, 280, 137, 299),
(148, 272, 166, 291),
(177, 280, 195, 299),
(193, 303, 211, 322),
][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,
);
}
}
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 (object_id, region) in [
(6, Rect::new(151.0, 389.0, 13.0, 49.0)),
(153, Rect::new(11.0, 344.0, 13.0, 49.0)),
(154, Rect::new(291.0, 346.0, 13.0, 49.0)),
] {
if game.magnetic_field_active(object_id) {
draw_texture_ex(
&self.assets.active_table,
region.x,
region.y,
WHITE,
DrawTextureParams {
dest_size: Some(vec2(region.w, region.h)),
source: Some(region),
..Default::default()
},
);
}
}
if game.effect_target_active() {
let region = Rect::new(60.0, 57.0, 18.0, 17.0);
draw_texture_ex(
&self.assets.active_table,
region.x,
region.y,
WHITE,
DrawTextureParams {
dest_size: Some(vec2(region.w, region.h)),
source: Some(region),
..Default::default()
},
);
}
if game.ball.in_launcher {
draw_texture_ex(
&self.assets.plunger,
314.0,
426.0,
WHITE,
DrawTextureParams {
dest_size: Some(vec2(21.0, 18.0)),
source: Some(Rect::new(
game.launcher_frame() as f32 * 21.0,
0.0,
20.0,
17.0,
)),
..Default::default()
},
);
}
for (raised, region) in [
(
game.flippers.left_raised,
Rect::new(93.0, 373.0, 47.0, 52.0),
),
(
game.flippers.right_raised,
Rect::new(175.0, 372.0, 45.0, 53.0),
),
] {
if raised {
draw_texture_ex(
&self.assets.active_table,
region.x,
region.y,
WHITE,
DrawTextureParams {
dest_size: Some(vec2(region.w, region.h)),
source: Some(region),
..Default::default()
},
);
}
}
if !game.claw.ball_suspended {
draw_texture(
&self.assets.ball,
game.ball.position.x - 8.0,
game.ball.position.y - 8.0,
WHITE,
);
}
if let Some(ball) = game.secondary_ball {
draw_texture(
&self.assets.ball,
ball.position.x - 8.0,
ball.position.y - 8.0,
WHITE,
);
}
if let Some(source) = game.claw.sprite_source() {
draw_texture_ex(
&self.assets.robot,
238.0,
47.0,
WHITE,
DrawTextureParams {
dest_size: Some(vec2(100.0, 74.0)),
source: Some(source),
..Default::default()
},
);
}
self.draw_displays(game);
if game.tilted {
draw_texture_ex(
&self.assets.active_table,
82.0,
238.0,
WHITE,
DrawTextureParams {
dest_size: Some(vec2(151.0, 33.0)),
source: Some(Rect::new(82.0, 238.0, 151.0, 33.0)),
..Default::default()
},
);
}
}
#[allow(clippy::cast_precision_loss, 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;
let shake = self.game.as_ref().map_or(Vec2::ZERO, |game| {
if game.nudge_shake <= 0.0 {
return Vec2::ZERO;
}
let strength = (game.nudge_shake / 0.18).min(1.0) * 2.5 * scale;
let phase = game.nudge_shake * 950.0;
vec2(
phase.sin() * strength,
(phase * 0.73).cos() * strength * 0.45,
)
});
draw_texture_ex(
&self.render_target.texture,
x + shake.x,
y + shake.y,
WHITE,
DrawTextureParams {
dest_size: Some(vec2(width, height)),
flip_y: true,
..Default::default()
},
);
}
fn logical_mouse() -> Vec2 {
let scale = (screen_width() / WIDTH).min(screen_height() / HEIGHT);
let width = WIDTH * scale;
let height = HEIGHT * scale;
let offset = vec2(
(screen_width() - width) * 0.5,
(screen_height() - height) * 0.5,
);
(vec2(mouse_position().0, mouse_position().1) - offset) / scale
}
fn save(&self) {
if let Err(error) = self.persistence.save(&self.saved) {
eprintln!("could not save settings and highscores: {error}");
}
}
}
fn draw_panel(x: f32, y: f32, width: f32, height: f32) {
draw_rectangle(x, y, width, height, Color::from_rgba(214, 214, 214, 248));
draw_rectangle_lines(x, y, width, height, 3.0, WHITE);
draw_rectangle_lines(x + 4.0, y + 4.0, width - 8.0, height - 8.0, 2.0, DARKGRAY);
}
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);
}
#[test]
fn attract_phases_follow_the_reconstructed_tick_quotients() {
let at = |tick| AttractAnimation {
tick,
..AttractAnimation::default()
};
assert!(!at(1).target_active(0));
assert!(at(2).target_active(0));
assert!(at(64).target_active(1));
assert!(!at(512).target_active(0));
assert_eq!(at(3).active_word_quad(), None);
assert_eq!(at(4).active_word_quad(), Some(0));
assert_eq!(at(512).active_word_quad(), Some(4));
assert_eq!(at(640).active_word_quad(), Some(3));
assert!(!at(31).magnetic_records_active());
assert!(at(32).magnetic_records_active());
assert!(!at(48).magnetic_records_active());
assert!(at(4).record_strip_active(0));
assert!(at(160).record_strip_active(1));
assert!(!at(800).record_strip_active(0));
assert_eq!(at(4).chase_index(), Some(0));
assert_eq!(at(192).chase_index(), Some(3));
assert_eq!(at(1_151).item_index(), None);
assert_eq!(at(1_152).item_index(), Some(0));
assert_eq!(at(10_368).item_index(), Some(8));
assert_eq!(at(11_520).item_index(), Some(7));
assert_eq!(at(20_736).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));
}
}