1use 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]];
11pub 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 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 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 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;