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
1385 lines
45 KiB
Rust
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));
|
|
}
|
|
}
|