salvage_pinball/
lib.rs

1//! A small pinball table driven by Ferravex's original fixed-step physics world.
2use ferravex_physics::d2::{
3    ColliderBuilder, ColliderHandle, PhysicsWorld, RigidBodyBuilder, RigidBodyHandle, Rotation,
4    Vector,
5};
6
7pub const BALL_RADIUS: f32 = 0.19;
8pub const FLIPPER_LENGTH: f32 = 1.65;
9pub const PIVOTS: [[f32; 2]; 2] = [[3.0, 13.2], [6.9, 13.2]];
10pub const BUMPERS: [[f32; 2]; 3] = [[2.6, 4.1], [6.2, 4.1], [4.4, 6.6]];
11/// Capsule rails, also used by the renderer so collisions match the visible table.
12pub const RAILS: [([f32; 2], [f32; 2]); 13] = [
13    ([0.6, 2.0], [0.6, 11.6]),
14    ([0.6, 2.0], [1.8, 0.7]),
15    ([1.8, 0.7], [8.1, 0.7]),
16    ([8.1, 0.7], [9.7, 2.3]),
17    ([9.7, 2.3], [9.7, 15.1]),
18    ([8.8, 3.7], [8.8, 15.1]),
19    ([8.8, 15.1], [9.7, 15.1]),
20    ([0.6, 11.6], [3.0, 13.2]),
21    ([8.8, 11.6], [6.9, 13.2]),
22    ([1.2, 8.2], [2.7, 10.7]),
23    ([8.1, 8.2], [6.6, 10.7]),
24    ([2.7, 10.7], [2.9, 11.3]),
25    ([6.6, 10.7], [6.4, 11.3]),
26];
27
28#[derive(Clone, Copy, Default)]
29pub struct Controls {
30    pub left: bool,
31    pub right: bool,
32    pub launch: bool,
33}
34
35pub struct Table {
36    pub world: PhysicsWorld,
37    pub ball: RigidBodyHandle,
38    ball_collider: ColliderHandle,
39    flippers: [RigidBodyHandle; 2],
40    bumpers: [ColliderHandle; 3],
41    contacts: [bool; 3],
42    pub bumper_flash: [f32; 3],
43    pub angles: [f32; 2],
44    pub score: u32,
45    pub lives: u8,
46    pub waiting: bool,
47    pub launches: u32,
48    pub hits: u32,
49    pub drains: u32,
50    pub time: f32,
51    pub trail: [[f32; 2]; 12],
52    trail_tick: u32,
53}
54
55impl Default for Table {
56    fn default() -> Self {
57        Self::new()
58    }
59}
60
61impl Table {
62    pub fn new() -> Self {
63        let mut world = PhysicsWorld::new([0.0, 10.5]);
64        for (a, b) in RAILS {
65            world.raw.insert(
66                RigidBodyBuilder::fixed(),
67                ColliderBuilder::capsule_from_endpoints(
68                    Vector::from_array(a),
69                    Vector::from_array(b),
70                    0.1,
71                )
72                .restitution(0.65)
73                .friction(0.08),
74            );
75        }
76        let bumpers = BUMPERS.map(|p| {
77            world
78                .raw
79                .insert(
80                    RigidBodyBuilder::fixed().translation(Vector::from_array(p)),
81                    ColliderBuilder::ball(0.61).restitution(0.9).friction(0.02),
82                )
83                .1
84        });
85        let angles = [0.4, std::f32::consts::PI - 0.4];
86        let flippers = std::array::from_fn(|i| {
87            world
88                .raw
89                .insert(
90                    RigidBodyBuilder::kinematic_position_based()
91                        .translation(Vector::from_array(PIVOTS[i]))
92                        .rotation(angles[i]),
93                    ColliderBuilder::capsule_from_endpoints(
94                        Vector::ZERO,
95                        Vector::new(FLIPPER_LENGTH, 0.0),
96                        0.16,
97                    )
98                    .friction(0.15)
99                    .restitution(0.45),
100                )
101                .0
102        });
103        let (ball, ball_collider) = Self::spawn_ball(&mut world);
104        Self {
105            world,
106            ball,
107            ball_collider,
108            flippers,
109            bumpers,
110            angles,
111            contacts: [false; 3],
112            bumper_flash: [0.0; 3],
113            score: 0,
114            lives: 3,
115            waiting: true,
116            launches: 0,
117            hits: 0,
118            drains: 0,
119            time: 0.0,
120            trail: [[9.25, 14.4]; 12],
121            trail_tick: 0,
122        }
123    }
124
125    fn spawn_ball(world: &mut PhysicsWorld) -> (RigidBodyHandle, ColliderHandle) {
126        world.raw.insert(
127            RigidBodyBuilder::dynamic()
128                .translation(Vector::new(9.25, 14.4))
129                .gravity_scale(0.0)
130                .ccd_enabled(true)
131                .linear_damping(0.03),
132            ColliderBuilder::ball(BALL_RADIUS)
133                .density(2.0)
134                .friction(0.08)
135                .restitution(0.5),
136        )
137    }
138
139    pub fn position(&self) -> [f32; 2] {
140        self.world.bodies[self.ball].translation().to_array()
141    }
142
143    pub fn over(&self) -> bool {
144        self.lives == 0
145    }
146
147    /// An automatic player presses the same controls as a human, with no ball steering.
148    pub fn autoplay(&self) -> Controls {
149        let p = self.position();
150        let descending = self.world.bodies[self.ball].linvel().y > 0.0;
151        let ready = p[1] > 12.95 && descending;
152        Controls {
153            left: ready && p[0] > 3.6 && p[0] < 5.6,
154            right: ready && p[0] > 4.2 && p[0] < 6.3,
155            launch: self.waiting,
156        }
157    }
158
159    pub fn step(&mut self, dt: f32, controls: Controls) {
160        if self.over() {
161            return;
162        }
163        self.time += dt;
164        for (i, pressed) in [controls.left, controls.right].into_iter().enumerate() {
165            let target = if i == 0 {
166                if pressed { -0.5 } else { 0.4 }
167            } else {
168                std::f32::consts::PI - if pressed { -0.5 } else { 0.4 }
169            };
170            self.angles[i] += (target - self.angles[i]).clamp(-18.0 * dt, 18.0 * dt);
171            self.world.bodies[self.flippers[i]]
172                .set_next_kinematic_rotation(Rotation::from_angle(self.angles[i]));
173        }
174        if controls.launch && self.waiting {
175            self.waiting = false;
176            self.launches += 1;
177            let ball = &mut self.world.bodies[self.ball];
178            ball.set_gravity_scale(1.0, true);
179            ball.apply_impulse(Vector::new(0.0, -25.0) * ball.mass(), true);
180        }
181        self.world.step(dt);
182        for (i, bumper) in self.bumpers.iter().enumerate() {
183            self.bumper_flash[i] = (self.bumper_flash[i] - dt).max(0.0);
184            let touching = self
185                .world
186                .raw
187                .contact_pair(self.ball_collider, *bumper)
188                .is_some_and(|pair| pair.has_any_active_contact());
189            if touching && !self.contacts[i] && self.bumper_flash[i] <= 0.0 {
190                self.score += 100;
191                self.hits += 1;
192                self.bumper_flash[i] = 0.18;
193                let ball = &mut self.world.bodies[self.ball];
194                let outward = (ball.translation() - Vector::from_array(BUMPERS[i]))
195                    .try_normalize()
196                    .unwrap_or(Vector::new(0.0, -1.0));
197                // The powered pop bumper adds energy at actual physical contact.
198                ball.apply_impulse(outward * ball.mass() * 7.5, true);
199            }
200            self.contacts[i] = touching;
201        }
202        self.trail_tick += 1;
203        if self.trail_tick.is_multiple_of(3) {
204            self.trail.rotate_right(1);
205            self.trail[0] = self.position();
206        }
207        let p = self.position();
208        if !self.waiting
209            && p[0] > 8.95
210            && p[1] > 14.2
211            && self.world.bodies[self.ball].linvel().length() < 1.5
212        {
213            // A ball that falls back into the real launch lane can be served again.
214            self.waiting = true;
215        }
216        if p[1] > 16.0 || p[0] < -1.0 || p[0] > 11.0 {
217            self.lives -= 1;
218            self.drains += 1;
219            if self.lives > 0 {
220                self.world.raw.remove_body(self.ball);
221                (self.ball, self.ball_collider) = Self::spawn_ball(&mut self.world);
222                self.contacts = [false; 3];
223                self.waiting = true;
224                self.trail = [self.position(); 12];
225            }
226        }
227    }
228}
229
230#[cfg(test)]
231mod tests {
232    use super::*;
233    const DT: f32 = 1.0 / 120.0;
234
235    #[test]
236    fn launch_is_gated_and_uses_impulse() {
237        let mut table = Table::new();
238        for _ in 0..30 {
239            table.step(DT, Controls::default());
240        }
241        assert_eq!(table.launches, 0);
242        assert!((table.position()[1] - 14.4).abs() < 0.01);
243        let launch = Controls {
244            launch: true,
245            ..Controls::default()
246        };
247        table.step(DT, launch);
248        assert!(table.world.bodies[table.ball].linvel().y < -20.0);
249        table.step(DT, launch);
250        assert_eq!(table.launches, 1);
251    }
252
253    #[test]
254    fn bumper_scores_on_real_contact_not_proximity() {
255        let mut table = Table::new();
256        table.waiting = false;
257        table.world.bodies[table.ball].set_translation(Vector::new(2.6, 3.0), true);
258        table.step(DT, Controls::default());
259        assert_eq!(table.score, 0);
260        table.world.bodies[table.ball].set_translation(Vector::new(2.6, 3.32), true);
261        table.world.bodies[table.ball].set_linvel(Vector::new(0.0, 2.0), true);
262        table.step(DT, Controls::default());
263        assert_eq!(table.score, 100);
264        assert!(table.world.bodies[table.ball].linvel().y < 0.0);
265        for _ in 0..10 {
266            table.step(DT, Controls::default());
267        }
268        assert_eq!(table.score, 100);
269    }
270
271    #[test]
272    fn three_drains_end_game_without_duplicate_life_loss() {
273        let mut table = Table::new();
274        for lives in [2, 1, 0] {
275            table.world.bodies[table.ball].set_translation(Vector::new(4.9, 16.2), true);
276            table.step(DT, Controls::default());
277            assert_eq!(table.lives, lives);
278        }
279        for _ in 0..120 {
280            table.step(DT, Controls::default());
281        }
282        assert_eq!(table.drains, 3);
283        assert!(table.over());
284    }
285
286    #[test]
287    fn moving_flipper_transfers_energy_to_ball() {
288        let mut table = Table::new();
289        table.waiting = false;
290        table.world.bodies[table.ball].set_translation(Vector::new(4.1, 13.25), true);
291        for _ in 0..8 {
292            table.step(
293                DT,
294                Controls {
295                    left: true,
296                    ..Controls::default()
297                },
298            );
299        }
300        assert!(table.world.bodies[table.ball].linvel().y < -1.0);
301        assert!(table.angles[0] < 0.0);
302    }
303
304    #[test]
305    fn autoplay_plays_real_table_and_scores() {
306        let mut table = Table::new();
307        for _ in 0..7200 {
308            let input = table.autoplay();
309            table.step(DT, input);
310            assert!(table.position().into_iter().all(f32::is_finite));
311        }
312        eprintln!(
313            "pinball: score={} hits={} launches={} drains={}",
314            table.score, table.hits, table.launches, table.drains
315        );
316        assert!(table.launches > 0);
317        assert!(
318            table.hits >= 2,
319            "autoplay should reach and hit the bumper field"
320        );
321    }
322}
323
324mod app;
325#[cfg(not(target_arch = "wasm32"))]
326pub use app::run;