1use crate::orbit::render::{Color, RenderCommand};
7
8#[derive(Debug, Clone)]
10pub struct Ball {
11 pub x: f64,
12 pub y: f64,
13 pub vx: f64,
14 pub vy: f64,
15 pub radius: f64,
16 pub mass: f64,
17 pub trail: Vec<(f64, f64)>,
19}
20
21#[derive(Debug, Clone)]
23pub struct BouncingBallsConfig {
24 pub width: f64,
26 pub height: f64,
28 pub gravity: f64,
30 pub restitution: f64,
32 pub trail_length: usize,
34 pub ball_count: usize,
36}
37
38impl Default for BouncingBallsConfig {
39 fn default() -> Self {
40 Self {
41 width: 1920.0,
42 height: 1080.0,
43 gravity: 400.0,
44 restitution: 0.92,
45 trail_length: 30,
46 ball_count: 8,
47 }
48 }
49}
50
51#[derive(Debug, Clone)]
53pub struct BouncingBallsState {
54 pub balls: Vec<Ball>,
55 pub config: BouncingBallsConfig,
56}
57
58impl BouncingBallsState {
59 #[must_use]
61 pub fn new(config: BouncingBallsConfig, seed: u64) -> Self {
62 let mut balls = Vec::with_capacity(config.ball_count);
63 let mut rng = seed.wrapping_add(1);
65
66 for _ in 0..config.ball_count {
67 rng = xorshift64(rng);
68 let radius = 20.0 + (rng % 30) as f64;
69 rng = xorshift64(rng);
70 let x = radius + (rng % (config.width as u64 - 2 * radius as u64)) as f64;
71 rng = xorshift64(rng);
72 let y = radius + (rng % ((config.height as u64) / 2)) as f64;
73 rng = xorshift64(rng);
74 let vx = (rng % 400) as f64 - 200.0;
75 rng = xorshift64(rng);
76 let vy = (rng % 300) as f64 - 150.0;
77
78 balls.push(Ball {
79 x,
80 y,
81 vx,
82 vy,
83 radius,
84 mass: radius * radius, trail: Vec::with_capacity(config.trail_length),
86 });
87 }
88
89 Self { balls, config }
90 }
91
92 pub fn step(&mut self, dt: f64) {
94 let gravity = self.config.gravity;
95 let restitution = self.config.restitution;
96 let w = self.config.width;
97 let h = self.config.height;
98 let trail_len = self.config.trail_length;
99
100 for ball in &mut self.balls {
102 ball.vy += gravity * dt;
103 ball.x += ball.vx * dt;
104 ball.y += ball.vy * dt;
105
106 if ball.x - ball.radius < 0.0 {
108 ball.x = ball.radius;
109 ball.vx = -ball.vx * restitution;
110 }
111 if ball.x + ball.radius > w {
112 ball.x = w - ball.radius;
113 ball.vx = -ball.vx * restitution;
114 }
115 if ball.y - ball.radius < 0.0 {
116 ball.y = ball.radius;
117 ball.vy = -ball.vy * restitution;
118 }
119 if ball.y + ball.radius > h {
120 ball.y = h - ball.radius;
121 ball.vy = -ball.vy * restitution;
122 }
123
124 ball.trail.push((ball.x, ball.y));
126 if ball.trail.len() > trail_len {
127 ball.trail.remove(0);
128 }
129 }
130
131 let n = self.balls.len();
133 for i in 0..n {
134 for j in (i + 1)..n {
135 resolve_collision(&mut self.balls, i, j, restitution);
136 }
137 }
138 }
139
140 #[must_use]
142 pub fn render(&self) -> Vec<RenderCommand> {
143 let mut commands = Vec::with_capacity(self.balls.len() * 3 + 2);
144
145 commands.push(RenderCommand::SetCamera {
147 center_x: self.config.width / 2.0,
148 center_y: self.config.height / 2.0,
149 zoom: 1.0,
150 });
151
152 commands.push(RenderCommand::Clear {
154 color: Color::rgb(18, 18, 24),
155 });
156
157 commands.push(RenderCommand::DrawLine {
159 x1: 0.0,
160 y1: self.config.height - 2.0,
161 x2: self.config.width,
162 y2: self.config.height - 2.0,
163 color: Color::rgb(60, 60, 80),
164 });
165
166 for ball in &self.balls {
168 if ball.trail.len() >= 2 {
169 commands.push(RenderCommand::DrawOrbitPath {
170 points: ball.trail.clone(),
171 color: Color::new(255, 255, 255, 40),
172 });
173 }
174 }
175
176 for ball in &self.balls {
178 let speed = (ball.vx * ball.vx + ball.vy * ball.vy).sqrt();
179 let color = velocity_to_color(speed);
180
181 commands.push(RenderCommand::DrawCircle {
182 x: ball.x,
183 y: ball.y,
184 radius: ball.radius,
185 color,
186 filled: true,
187 });
188 }
189
190 for ball in &self.balls {
192 let speed = (ball.vx * ball.vx + ball.vy * ball.vy).sqrt();
193 commands.push(RenderCommand::DrawText {
194 x: ball.x,
195 y: ball.y - ball.radius - 8.0,
196 text: format!("{speed:.0}"),
197 color: Color::rgb(180, 180, 200),
198 });
199 }
200
201 commands
202 }
203}
204
205fn xorshift64(mut state: u64) -> u64 {
207 state ^= state << 13;
208 state ^= state >> 7;
209 state ^= state << 17;
210 state
211}
212
213fn velocity_to_color(speed: f64) -> Color {
215 let t = (speed / 600.0).clamp(0.0, 1.0);
216 if t < 0.25 {
217 let s = t / 0.25;
219 Color::rgb(30, (100.0 + 155.0 * s) as u8, 255)
220 } else if t < 0.5 {
221 let s = (t - 0.25) / 0.25;
223 Color::rgb(30, 255, (255.0 - 155.0 * s) as u8)
224 } else if t < 0.75 {
225 let s = (t - 0.5) / 0.25;
227 Color::rgb((30.0 + 225.0 * s) as u8, 255, (100.0 - 70.0 * s) as u8)
228 } else {
229 let s = (t - 0.75) / 0.25;
231 Color::rgb(255, (255.0 - 155.0 * s) as u8, 30)
232 }
233}
234
235fn resolve_collision(balls: &mut [Ball], i: usize, j: usize, restitution: f64) {
237 let dx = balls[j].x - balls[i].x;
238 let dy = balls[j].y - balls[i].y;
239 let dist_sq = dx * dx + dy * dy;
240 let min_dist = balls[i].radius + balls[j].radius;
241
242 if dist_sq >= min_dist * min_dist || dist_sq < 1e-10 {
243 return;
244 }
245
246 let dist = dist_sq.sqrt();
247 let nx = dx / dist;
248 let ny = dy / dist;
249
250 let dvx = balls[i].vx - balls[j].vx;
252 let dvy = balls[i].vy - balls[j].vy;
253 let dvn = dvx * nx + dvy * ny;
254
255 if dvn < 0.0 {
257 return;
258 }
259
260 let m1 = balls[i].mass;
261 let m2 = balls[j].mass;
262 let impulse = (1.0 + restitution) * dvn / (m1 + m2);
263
264 balls[i].vx -= impulse * m2 * nx;
265 balls[i].vy -= impulse * m2 * ny;
266 balls[j].vx += impulse * m1 * nx;
267 balls[j].vy += impulse * m1 * ny;
268
269 let overlap = min_dist - dist;
271 let sep = overlap / 2.0 + 0.5;
272 balls[i].x -= sep * nx;
273 balls[i].y -= sep * ny;
274 balls[j].x += sep * nx;
275 balls[j].y += sep * ny;
276}
277
278#[cfg(test)]
279mod tests {
280 use super::*;
281
282 #[test]
283 fn test_default_config() {
284 let cfg = BouncingBallsConfig::default();
285 assert_eq!(cfg.width, 1920.0);
286 assert_eq!(cfg.height, 1080.0);
287 assert_eq!(cfg.ball_count, 8);
288 }
289
290 #[test]
291 fn test_new_state_creates_correct_ball_count() {
292 let cfg = BouncingBallsConfig {
293 ball_count: 5,
294 ..Default::default()
295 };
296 let state = BouncingBallsState::new(cfg, 42);
297 assert_eq!(state.balls.len(), 5);
298 }
299
300 #[test]
301 fn test_balls_within_bounds() {
302 let cfg = BouncingBallsConfig::default();
303 let state = BouncingBallsState::new(cfg, 42);
304 for ball in &state.balls {
305 assert!(ball.x >= ball.radius);
306 assert!(ball.x <= 1920.0 - ball.radius);
307 assert!(ball.y >= ball.radius);
308 }
309 }
310
311 #[test]
312 fn test_deterministic_seed() {
313 let cfg = BouncingBallsConfig::default();
314 let s1 = BouncingBallsState::new(cfg.clone(), 42);
315 let s2 = BouncingBallsState::new(cfg, 42);
316 for (a, b) in s1.balls.iter().zip(s2.balls.iter()) {
317 assert_eq!(a.x, b.x);
318 assert_eq!(a.y, b.y);
319 assert_eq!(a.vx, b.vx);
320 assert_eq!(a.vy, b.vy);
321 }
322 }
323
324 #[test]
325 fn test_different_seeds_differ() {
326 let cfg = BouncingBallsConfig::default();
327 let s1 = BouncingBallsState::new(cfg.clone(), 42);
328 let s2 = BouncingBallsState::new(cfg, 99);
329 let differs = s1
330 .balls
331 .iter()
332 .zip(s2.balls.iter())
333 .any(|(a, b)| a.x != b.x);
334 assert!(differs);
335 }
336
337 #[test]
338 fn test_step_applies_gravity() {
339 let cfg = BouncingBallsConfig {
340 ball_count: 1,
341 gravity: 100.0,
342 ..Default::default()
343 };
344 let mut state = BouncingBallsState::new(cfg, 42);
345 let vy_before = state.balls[0].vy;
346 state.step(0.1);
347 assert!(state.balls[0].vy > vy_before);
349 }
350
351 #[test]
352 fn test_wall_collision_keeps_in_bounds() {
353 let cfg = BouncingBallsConfig {
354 ball_count: 1,
355 ..Default::default()
356 };
357 let mut state = BouncingBallsState::new(cfg, 42);
358 for _ in 0..1000 {
360 state.step(1.0 / 60.0);
361 }
362 let ball = &state.balls[0];
363 assert!(ball.x >= ball.radius);
364 assert!(ball.x <= 1920.0);
365 assert!(ball.y >= ball.radius);
366 assert!(ball.y <= 1080.0);
367 }
368
369 #[test]
370 fn test_trail_grows() {
371 let cfg = BouncingBallsConfig {
372 ball_count: 1,
373 trail_length: 10,
374 ..Default::default()
375 };
376 let mut state = BouncingBallsState::new(cfg, 42);
377 assert!(state.balls[0].trail.is_empty());
378 for _ in 0..5 {
379 state.step(1.0 / 60.0);
380 }
381 assert_eq!(state.balls[0].trail.len(), 5);
382 }
383
384 #[test]
385 fn test_trail_caps_at_max_length() {
386 let cfg = BouncingBallsConfig {
387 ball_count: 1,
388 trail_length: 5,
389 ..Default::default()
390 };
391 let mut state = BouncingBallsState::new(cfg, 42);
392 for _ in 0..20 {
393 state.step(1.0 / 60.0);
394 }
395 assert_eq!(state.balls[0].trail.len(), 5);
396 }
397
398 #[test]
399 fn test_render_produces_commands() {
400 let cfg = BouncingBallsConfig {
401 ball_count: 3,
402 ..Default::default()
403 };
404 let state = BouncingBallsState::new(cfg, 42);
405 let commands = state.render();
406 assert!(commands.len() >= 8);
408 }
409
410 #[test]
411 fn test_render_starts_with_camera_then_clear() {
412 let cfg = BouncingBallsConfig {
413 ball_count: 1,
414 ..Default::default()
415 };
416 let state = BouncingBallsState::new(cfg, 42);
417 let commands = state.render();
418 assert!(matches!(commands[0], RenderCommand::SetCamera { .. }));
419 assert!(matches!(commands[1], RenderCommand::Clear { .. }));
420 }
421
422 #[test]
423 fn test_velocity_color_gradient() {
424 let slow = velocity_to_color(0.0);
425 let fast = velocity_to_color(600.0);
426 assert!(slow.b > slow.r);
428 assert!(fast.r > fast.b);
429 }
430
431 #[test]
432 fn test_velocity_color_clamps() {
433 let over = velocity_to_color(9999.0);
434 let under = velocity_to_color(-10.0);
435 assert_eq!(over.r, 255);
437 assert!(under.b == 255);
438 }
439
440 #[test]
441 fn test_elastic_collision_conserves_momentum() {
442 let cfg = BouncingBallsConfig {
443 ball_count: 2,
444 gravity: 0.0,
445 restitution: 1.0,
446 ..Default::default()
447 };
448 let mut state = BouncingBallsState::new(cfg, 42);
449 state.balls[0].x = 500.0;
451 state.balls[0].y = 540.0;
452 state.balls[0].vx = 100.0;
453 state.balls[0].vy = 0.0;
454 state.balls[1].x = 560.0;
455 state.balls[1].y = 540.0;
456 state.balls[1].vx = -100.0;
457 state.balls[1].vy = 0.0;
458
459 let m1 = state.balls[0].mass;
460 let m2 = state.balls[1].mass;
461 let px_before = m1 * state.balls[0].vx + m2 * state.balls[1].vx;
462 let py_before = m1 * state.balls[0].vy + m2 * state.balls[1].vy;
463
464 state.step(1.0 / 60.0);
465
466 let px_after = m1 * state.balls[0].vx + m2 * state.balls[1].vx;
467 let py_after = m1 * state.balls[0].vy + m2 * state.balls[1].vy;
468
469 assert!((px_before - px_after).abs() < 1.0);
470 assert!((py_before - py_after).abs() < 1.0);
471 }
472
473 #[test]
474 fn test_xorshift_deterministic() {
475 assert_eq!(xorshift64(42), xorshift64(42));
476 assert_ne!(xorshift64(42), xorshift64(43));
477 }
478
479 #[test]
480 fn test_render_after_many_steps() {
481 let cfg = BouncingBallsConfig::default();
482 let mut state = BouncingBallsState::new(cfg, 42);
483 for _ in 0..600 {
484 state.step(1.0 / 60.0);
485 }
486 let commands = state.render();
487 assert!(!commands.is_empty());
489 for ball in &state.balls {
491 assert!(ball.x >= 0.0 && ball.x <= 1920.0);
492 assert!(ball.y >= 0.0 && ball.y <= 1080.0);
493 }
494 }
495}