fix(game): launch immediately and enforce tilt

Starting from the attract screen previously created an idle ball and required a
second Down press to launch it. Launch the first ball as part of the start action
while retaining the normal launch action for later balls.

Replace the decaying visual-only tilt value with a latched game state. A tilt now
forfeits the current bonus, suppresses scoring and further nudges, returns both
flippers to rest, and remains active until the ball drains. Keep the warning on
the playfield and use the recovered tilt sound instead of treating the state as
a temporary decorative overlay.

Test Plan:
- `cargo fmt -- --check` -- passed
- `cargo test --all-targets` -- passed, including tilt latch, scoring, flipper,
  drain-reset, and launch coverage
- `cargo clippy --all-targets -- -D warnings` -- passed
- `git diff --check` -- passed
This commit is contained in:
2026-08-22 16:54:12 +02:00
parent e19162d54d
commit 7da0f4d171
2 changed files with 112 additions and 23 deletions
+9 -3
View File
@@ -135,9 +135,14 @@ impl App {
self.assets.play(2012, self.saved.settings.sounds);
}
if is_key_pressed(KeyCode::Down) || is_key_pressed(KeyCode::Enter) {
self.game = Some(Game::new(self.player_count));
let mut game = Game::new(self.player_count);
let launched = game.launch();
self.game = Some(game);
self.screen = Screen::Playing;
self.assets.play(2008, self.saved.settings.sounds);
if launched {
self.assets.play(2016, self.saved.settings.sounds);
}
}
}
@@ -196,6 +201,7 @@ impl App {
Event::Media => 2022,
Event::ExtraBall => 2019,
Event::Nudge | Event::BallSearch => 2021,
Event::Tilt => 2015,
Event::Drain => 2013,
};
self.assets.play(id, self.saved.settings.sounds);
@@ -481,8 +487,8 @@ impl App {
);
self.draw_displays(game);
if game.tilt > 1.15 {
draw_centered("TILT", 263.0, 34, RED);
if game.tilted {
draw_centered_at("TILT", 160.0, 263.0, 30, RED);
}
draw_text("F1 HELP F2 SETUP", 345.0, 448.0, 11.0, BLACK);
}
+103 -20
View File
@@ -7,6 +7,7 @@ const GRAVITY: f32 = 135.0;
const MAX_SPEED: f32 = 430.0;
const BALL_SEARCH_DELAY: f32 = 3.0;
const BALL_SEARCH_SPEED: f32 = 24.0;
const TILT_THRESHOLD: f32 = 1.15;
#[derive(Clone, Copy, Debug, Default)]
pub struct Controls {
@@ -28,6 +29,7 @@ pub enum Event {
Media,
ExtraBall,
Nudge,
Tilt,
BallSearch,
Drain,
}
@@ -83,7 +85,7 @@ pub struct Game {
pub top_targets: [bool; 3],
pub lock_lights: u8,
pub magnets: f32,
pub tilt: f32,
pub tilted: bool,
pub left_flipper_angle: f32,
pub right_flipper_angle: f32,
pub finished: bool,
@@ -91,6 +93,7 @@ pub struct Game {
target_cooldown: f32,
bumper_cooldown: f32,
nudge_cooldown: f32,
nudge_meter: f32,
stalled_for: f32,
}
@@ -106,7 +109,7 @@ impl Game {
top_targets: [false; 3],
lock_lights: 0,
magnets: 0.0,
tilt: 0.0,
tilted: false,
left_flipper_angle: -2.72,
right_flipper_angle: -0.42,
finished: false,
@@ -114,6 +117,7 @@ impl Game {
target_cooldown: 0.0,
bumper_cooldown: 0.0,
nudge_cooldown: 0.0,
nudge_meter: 0.0,
stalled_for: 0.0,
}
}
@@ -122,13 +126,31 @@ impl Game {
&self.players[self.current_player]
}
pub fn launch(&mut self) -> bool {
if !self.ball.in_launcher || self.tilted {
return false;
}
self.ball.in_launcher = false;
self.ball.velocity = vec2(12.0, -330.0);
self.stalled_for = 0.0;
true
}
pub fn update(&mut self, frame_time: f32, detail: u8, controls: Controls) -> Vec<Event> {
if self.finished {
return Vec::new();
}
let mut events = Vec::new();
let desired_left = if controls.left_flipper { -2.18 } else { -2.72 };
let desired_right = if controls.right_flipper { -0.96 } else { -0.42 };
let desired_left = if controls.left_flipper && !self.tilted {
-2.18
} else {
-2.72
};
let desired_right = if controls.right_flipper && !self.tilted {
-0.96
} else {
-0.42
};
let flipper_rate = 14.0 * frame_time;
let old_left = self.left_flipper_angle;
let old_right = self.right_flipper_angle;
@@ -136,23 +158,27 @@ impl Game {
(desired_left - self.left_flipper_angle).clamp(-flipper_rate, flipper_rate);
self.right_flipper_angle +=
(desired_right - self.right_flipper_angle).clamp(-flipper_rate, flipper_rate);
if (controls.left_flipper && old_left < -2.68)
|| (controls.right_flipper && old_right > -0.46)
if !self.tilted
&& ((controls.left_flipper && old_left < -2.68)
|| (controls.right_flipper && old_right > -0.46))
{
events.push(Event::Flipper);
}
if controls.launch_pressed && self.ball.in_launcher {
self.ball.in_launcher = false;
self.ball.velocity = vec2(12.0, -330.0);
self.stalled_for = 0.0;
if controls.launch_pressed && self.launch() {
events.push(Event::Launch);
}
if controls.nudge.abs() > 0.1 && self.nudge_cooldown <= 0.0 {
if !self.tilted && controls.nudge.abs() > 0.1 && self.nudge_cooldown <= 0.0 {
self.ball.velocity.x += controls.nudge * 55.0;
self.tilt += controls.nudge.abs() * 0.34;
self.nudge_meter += controls.nudge.abs() * 0.34;
self.nudge_cooldown = 0.22;
events.push(Event::Nudge);
if self.nudge_meter >= TILT_THRESHOLD {
self.tilted = true;
self.bonus = 0;
events.push(Event::Tilt);
} else {
events.push(Event::Nudge);
}
}
self.accumulator = (self.accumulator + frame_time.min(0.05)).min(0.1);
@@ -171,7 +197,9 @@ impl Game {
self.bumper_cooldown = (self.bumper_cooldown - dt).max(0.0);
self.nudge_cooldown = (self.nudge_cooldown - dt).max(0.0);
self.magnets = (self.magnets - dt).max(0.0);
self.tilt = (self.tilt - dt * 0.08).max(0.0);
if !self.tilted {
self.nudge_meter = (self.nudge_meter - dt * 0.34).max(0.0);
}
if self.ball.in_launcher {
self.ball.position = vec2(157.0, 426.0);
@@ -216,15 +244,15 @@ impl Game {
}
for center in [vec2(207.0, 109.0), vec2(167.0, 148.0), vec2(219.0, 167.0)] {
if circle_collision(
let hit = circle_collision(
&mut self.ball.position,
&mut self.ball.velocity,
BALL_RADIUS,
center,
14.0,
105.0,
) && self.bumper_cooldown <= 0.0
{
);
if hit && !self.tilted && self.bumper_cooldown <= 0.0 {
self.add_score(self.player().bumper_value);
self.bonus = self.bonus.saturating_add(100);
self.bumper_cooldown = 0.08;
@@ -232,8 +260,10 @@ impl Game {
}
}
self.check_targets(events);
self.check_media(events);
if !self.tilted {
self.check_targets(events);
self.check_media(events);
}
if self.ball.position.y > 454.0 {
if self.magnets > 0.0 && (self.ball.position.x < 145.0 || self.ball.position.x > 175.0)
@@ -408,7 +438,8 @@ impl Game {
self.wheel_holes.fill(false);
self.lock_lights = 0;
self.magnets = 0.0;
self.tilt = 0.0;
self.tilted = false;
self.nudge_meter = 0.0;
self.stalled_for = 0.0;
let mut next = (self.current_player + 1) % self.players.len();
@@ -613,4 +644,56 @@ mod tests {
assert!(events.contains(&Event::BallSearch));
assert!(game.ball.velocity.y < 0.0);
}
#[test]
fn tilt_latches_and_disables_flippers_and_scoring() {
let mut game = Game::new(1);
game.bonus = 4_000;
for _ in 0..4 {
game.nudge_cooldown = 0.0;
game.update(
1.0 / 60.0,
3,
Controls {
nudge: 1.0,
..Controls::default()
},
);
}
assert!(game.tilted);
assert_eq!(game.bonus, 0);
game.left_flipper_angle = -2.18;
game.right_flipper_angle = -0.96;
let score = game.player().score;
game.ball.in_launcher = false;
game.ball.position = vec2(201.0, 285.0);
game.ball.velocity = Vec2::ZERO;
game.update(
1.0 / 30.0,
5,
Controls {
left_flipper: true,
right_flipper: true,
..Controls::default()
},
);
assert!(game.left_flipper_angle < -2.18);
assert!(game.right_flipper_angle > -0.96);
assert_eq!(game.player().score, score);
}
#[test]
fn drain_clears_latched_tilt() {
let mut game = Game::new(1);
game.tilted = true;
game.ball.in_launcher = false;
game.ball.position = vec2(157.0, 455.0);
game.update(1.0 / 60.0, 3, Controls::default());
assert!(!game.tilted);
assert!(game.ball.in_launcher);
}
}