1use ferravex_core::{FixedClock, FrameStats};
3use ferravex_physics::d2::{self, PhysicsWorld, RigidBodyHandle};
4use ferravex_render::{prelude::*, ui};
5
6const MAX_BODIES: usize = 240;
7const TICK_HZ: f64 = 120.0;
8
9#[derive(Clone, Copy)]
10enum Shape {
11 Ball(f32),
12 Box(Vec2),
13}
14
15struct Body {
16 handle: RigidBodyHandle,
17 shape: Shape,
18 color: Color,
19 previous_position: Vec2,
20 previous_angle: f32,
21}
22
23struct Sandbox {
24 physics: PhysicsWorld,
25 bodies: Vec<Body>,
26 platforms: Vec<(Vec2, Vec2)>,
27}
28
29impl Sandbox {
30 fn new() -> Self {
31 let mut world = Self {
32 physics: PhysicsWorld::new([0.0, -9.81]),
33 bodies: Vec::new(),
34 platforms: vec![],
35 };
36 for (position, half) in [
37 (vec2(0.0, -0.5), vec2(12.0, 0.5)),
38 (vec2(-12.25, 6.0), vec2(0.25, 6.5)),
39 (vec2(12.25, 6.0), vec2(0.25, 6.5)),
40 (vec2(-8.0, 0.8), vec2(2.0, 0.8)),
41 (vec2(8.0, 0.8), vec2(2.0, 0.8)),
42 ] {
43 world
44 .physics
45 .add_box(position.to_array(), half.to_array(), false);
46 world.platforms.push((position, half));
47 }
48 for row in 0..6 {
49 for column in 0..(6 - row) {
50 world.spawn(
51 vec2(
52 -4.0 + column as f32 * 1.06 + row as f32 * 0.53,
53 0.51 + row as f32 * 1.03,
54 ),
55 false,
56 );
57 }
58 }
59 for index in 0..9 {
60 world.spawn(
61 vec2(
62 5.8 + (index % 3) as f32 * 1.05,
63 5.0 + (index / 3) as f32 * 1.1,
64 ),
65 true,
66 );
67 }
68 world
69 }
70
71 fn spawn(&mut self, position: Vec2, ball: bool) {
72 if self.bodies.len() >= MAX_BODIES {
73 return;
74 }
75 let (handle, shape, color) = if ball {
76 (
77 self.physics.add_ball(position.to_array(), 0.44, true),
78 Shape::Ball(0.44),
79 ui::CYAN,
80 )
81 } else {
82 (
83 self.physics
84 .add_box(position.to_array(), [0.48, 0.48], true),
85 Shape::Box(vec2(0.48, 0.48)),
86 ui::AMBER,
87 )
88 };
89 let collider = self.physics.bodies[handle].colliders()[0];
90 self.physics.colliders[collider].set_restitution(if ball { 0.42 } else { 0.08 });
91 self.physics.bodies[handle].enable_ccd(true);
92 self.bodies.push(Body {
93 handle,
94 shape,
95 color,
96 previous_position: position,
97 previous_angle: 0.0,
98 });
99 }
100
101 fn step(&mut self, dt: f32) {
102 for body in &mut self.bodies {
103 let rigid_body = &self.physics.bodies[body.handle];
104 body.previous_position = Vec2::from_array(rigid_body.translation().to_array());
105 let axis = *rigid_body.rotation() * d2::Vector::X;
106 body.previous_angle = axis.y.atan2(axis.x);
107 }
108 self.physics.step(dt);
109 }
110}
111
112fn window_conf() -> Conf {
113 ferravex_render::window_conf("Ferravex / 2D physics laboratory")
114}
115
116ferravex_render::game!(window_conf, game);
117async fn game() {
118 let mut demo = ferravex_render::demo::Demo::new();
119 let mut sandbox = Sandbox::new();
120 let mut clock = FixedClock::new(TICK_HZ, 8);
121 let mut stats = FrameStats::<90>::default();
122 let mut paused = false;
123 let mut ball = true;
124 loop {
125 ui::warm_up_font();
126 stats.record(get_frame_time());
127 if is_key_pressed(KeyCode::Space) {
128 paused = !paused;
129 }
130 if is_key_pressed(KeyCode::Key1) {
131 ball = true;
132 }
133 if is_key_pressed(KeyCode::Key2) {
134 ball = false;
135 }
136 if is_key_pressed(KeyCode::R) {
137 sandbox = Sandbox::new();
138 clock.reset();
139 }
140
141 let footer_y = screen_height() - 98.0;
142 let area_height = (footer_y - 143.0).max(100.0);
143 let scale = ((screen_width() - 58.0) / 26.0)
144 .min(area_height / 14.0)
145 .max(1.0);
146 let origin = vec2(screen_width() * 0.5, footer_y - 15.0 - scale);
147 let to_screen = |point: Vec2| origin + vec2(point.x, -point.y) * scale;
148 let pointer = Vec2::from(mouse_position());
149 let pointer_in_scene = pointer.y > 136.0 && pointer.y < footer_y - 8.0;
150 let spawn_position = vec2(
151 (pointer.x - origin.x) / scale,
152 (origin.y - pointer.y) / scale,
153 );
154 if pointer_in_scene
155 && (is_mouse_button_pressed(MouseButton::Left)
156 || is_mouse_button_pressed(MouseButton::Right))
157 {
158 sandbox.spawn(
159 vec2(
160 spawn_position.x.clamp(-11.2, 11.2),
161 spawn_position.y.max(0.6),
162 ),
163 ball && !is_mouse_button_pressed(MouseButton::Right),
164 );
165 }
166 let frame = clock.advance(if paused { 0.0 } else { get_frame_time() as f64 });
167 for _ in 0..frame.steps {
168 sandbox.step(frame.dt);
169 }
170 let alpha = if paused { 1.0 } else { frame.alpha };
171
172 clear_background(ui::BACKGROUND);
173 for x in -12..=12 {
174 let start = to_screen(vec2(x as f32, 0.0));
175 let end = to_screen(vec2(x as f32, 13.0));
176 draw_line(
177 start.x,
178 start.y,
179 end.x,
180 end.y,
181 1.0,
182 Color::from_rgba(25, 38, 53, 255),
183 );
184 }
185 for y in 0..=13 {
186 let start = to_screen(vec2(-12.0, y as f32));
187 let end = to_screen(vec2(12.0, y as f32));
188 draw_line(
189 start.x,
190 start.y,
191 end.x,
192 end.y,
193 1.0,
194 Color::from_rgba(25, 38, 53, 255),
195 );
196 }
197 for (position, half) in &sandbox.platforms {
198 let top_left = to_screen(*position + vec2(-half.x, half.y));
199 draw_rectangle(
200 top_left.x,
201 top_left.y,
202 half.x * scale * 2.0,
203 half.y * scale * 2.0,
204 Color::from_rgba(43, 59, 73, 255),
205 );
206 draw_line(
207 top_left.x,
208 top_left.y,
209 top_left.x + half.x * scale * 2.0,
210 top_left.y,
211 2.0,
212 ui::MUTED,
213 );
214 }
215 for body in &sandbox.bodies {
216 let rigid_body = &sandbox.physics.bodies[body.handle];
217 let position = body
218 .previous_position
219 .lerp(Vec2::from_array(rigid_body.translation().to_array()), alpha);
220 let axis = *rigid_body.rotation() * d2::Vector::X;
221 let angle_now = axis.y.atan2(axis.x);
222 let difference = (angle_now - body.previous_angle + std::f32::consts::PI)
223 .rem_euclid(std::f32::consts::TAU)
224 - std::f32::consts::PI;
225 let angle = body.previous_angle + difference * alpha;
226 let center = to_screen(position);
227 let rotation = Vec2::from_angle(angle);
228 match body.shape {
229 Shape::Ball(radius) => {
230 draw_circle(center.x, center.y, radius * scale, body.color);
231 draw_circle_lines(
232 center.x,
233 center.y,
234 radius * scale,
235 1.5,
236 Color::from_rgba(191, 239, 230, 255),
237 );
238 let end = to_screen(position + rotation * radius * 0.72);
239 draw_line(center.x, center.y, end.x, end.y, 2.0, ui::BACKGROUND);
240 }
241 Shape::Box(half) => {
242 let corners = [
243 vec2(-half.x, -half.y),
244 vec2(half.x, -half.y),
245 vec2(half.x, half.y),
246 vec2(-half.x, half.y),
247 ]
248 .map(|p| to_screen(position + rotation.rotate(p)));
249 draw_triangle(corners[0], corners[1], corners[2], body.color);
250 draw_triangle(corners[0], corners[2], corners[3], body.color);
251 for index in 0..4 {
252 let a = corners[index];
253 let b = corners[(index + 1) % 4];
254 draw_line(
255 a.x,
256 a.y,
257 b.x,
258 b.y,
259 1.5,
260 Color::from_rgba(250, 212, 147, 255),
261 );
262 }
263 draw_line(
264 corners[0].x,
265 corners[0].y,
266 corners[2].x,
267 corners[2].y,
268 1.0,
269 Color::from_rgba(189, 126, 57, 255),
270 );
271 }
272 }
273 }
274 if pointer_in_scene && sandbox.bodies.len() < MAX_BODIES {
275 if ball {
276 draw_circle_lines(pointer.x, pointer.y, 0.44 * scale, 1.0, ui::CYAN);
277 } else {
278 draw_rectangle_lines(
279 pointer.x - 0.48 * scale,
280 pointer.y - 0.48 * scale,
281 0.96 * scale,
282 0.96 * scale,
283 1.0,
284 ui::AMBER,
285 );
286 }
287 }
288 ui::header(
289 "2D RIGID BODIES",
290 "Gravity, in two dimensions.",
291 if screen_width() > 660.0 {
292 "Click or tap to drop a body / Right click: crate / Space: pause / R: reset"
293 } else {
294 "Tap to drop. Choose a shape below."
295 },
296 );
297 ui::panel(Rect::new(20.0, footer_y, screen_width() - 40.0, 78.0));
298 let button_width = ((screen_width() - 92.0) / 4.0).clamp(38.0, 84.0);
299 let button_rect = |index: f32| {
300 Rect::new(
301 34.0 + index * (button_width + 8.0),
302 footer_y + 20.0,
303 button_width,
304 36.0,
305 )
306 };
307 if ui::button(button_rect(0.0), "1 BALL", ball) {
308 ball = true;
309 }
310 if ui::button(button_rect(1.0), "2 CRATE", !ball) {
311 ball = false;
312 }
313 if ui::button(
314 button_rect(2.0),
315 if paused { "RESUME" } else { "PAUSE" },
316 paused,
317 ) {
318 paused = !paused;
319 }
320 if ui::button(button_rect(3.0), "RESET", false) {
321 sandbox = Sandbox::new();
322 clock.reset();
323 }
324 if screen_width() > 730.0 {
325 let awake = sandbox
326 .bodies
327 .iter()
328 .filter(|b| !sandbox.physics.bodies[b.handle].is_sleeping())
329 .count();
330 ui::stat(
331 430.0,
332 footer_y + 25.0,
333 "DYNAMIC BODIES",
334 &format!("{} / {MAX_BODIES}", sandbox.bodies.len()),
335 );
336 ui::stat(602.0, footer_y + 25.0, "AWAKE", &awake.to_string());
337 }
338 if screen_width() > 920.0 {
339 ui::stat(
340 744.0,
341 footer_y + 25.0,
342 "FRAME AVERAGE",
343 &format!("{:.1} ms", stats.mean_ms()),
344 );
345 }
346 if screen_width() > 1090.0 {
347 ui::stat(934.0, footer_y + 25.0, "FIXED SIMULATION", "120 Hz");
348 }
349 if demo.finish_frame() {
350 break;
351 }
352 next_frame().await;
353 }
354}
355
356#[cfg(test)]
357mod tests {
358 use super::*;
359
360 #[test]
361 fn recovered_sandbox_settles_with_finite_rotating_bodies() {
362 let mut sandbox = Sandbox::new();
363 let mut rotation_seen = false;
364 for _ in 0..720 {
365 sandbox.step(1.0 / 120.0);
366 for body in &sandbox.bodies {
367 let rigid = &sandbox.physics.bodies[body.handle];
368 assert!(rigid.translation().is_finite());
369 assert!(rigid.linvel().is_finite());
370 rotation_seen |= rigid.rotation().angle().abs() > 0.05;
371 }
372 }
373 assert!(rotation_seen, "contacts should rotate the falling bodies");
374 assert!(sandbox.bodies.iter().all(|body| {
375 let p = sandbox.physics.bodies[body.handle].translation();
376 p.y > 0.3 && p.x.abs() < 12.0
377 }));
378 }
379
380 #[test]
381 fn spawning_is_bounded_and_rebuild_replaces_the_world() {
382 let mut sandbox = Sandbox::new();
383 for i in 0..MAX_BODIES + 10 {
384 sandbox.spawn(vec2((i % 10) as f32 - 5.0, 12.0 + (i / 10) as f32), true);
385 }
386 assert_eq!(sandbox.bodies.len(), MAX_BODIES);
387 assert_eq!(
388 sandbox.physics.bodies.len(),
389 MAX_BODIES + sandbox.platforms.len()
390 );
391 sandbox = Sandbox::new();
392 assert_eq!(sandbox.bodies.len(), 30);
393 assert_eq!(sandbox.physics.bodies.len(), 35);
394 }
395}