1use ferravex_core::FixedClock;
3use ferravex_physics::d3::{
4 ColliderBuilder, PhysicsWorld, RigidBodyBuilder, RigidBodyHandle, Vector,
5};
6use ferravex_render::{
7 demo::Demo,
8 mesh::Mesh3d,
9 prelude::*,
10 shaders::{LitMaterial, LitSettings},
11};
12
13#[cfg(test)]
14const DT: f32 = 1.0 / 120.0;
15const AMMO: u8 = 8;
16const GOAL: usize = 6;
17const SPEED: f32 = 28.0;
18const PIVOT: Vec3 = Vec3::new(0.0, 1.3, 15.0);
19const SKY: Color = Color::from_rgba(173, 177, 161, 255);
20const INK: Color = Color::from_rgba(34, 40, 39, 255);
21const PAPER: Color = Color::from_rgba(246, 235, 211, 255);
22const RUST: Color = Color::from_rgba(176, 86, 48, 255);
23const SAND: Color = Color::from_rgba(194, 169, 124, 255);
24const STEEL: Color = Color::from_rgba(65, 81, 78, 255);
25
26#[derive(Clone, Copy, Debug, PartialEq, Eq)]
27enum Phase {
28 Aiming,
29 Won,
30 Lost,
31}
32
33struct Block {
34 handle: RigidBodyHandle,
35 home: Vec3,
36 target: bool,
37 down: bool,
38 previous_position: Vec3,
39 previous_rotation: Quat,
40}
41
42struct Shot {
43 handle: RigidBodyHandle,
44 previous_position: Vec3,
45}
46
47struct Range {
48 physics: PhysicsWorld,
49 blocks: Vec<Block>,
50 shots: Vec<Shot>,
51 yaw: f32,
52 elevation: f32,
53 ammo: u8,
54 cooldown: f32,
55 since_shot: f32,
56 elapsed: f32,
57 phase: Phase,
58}
59
60impl Range {
61 fn new() -> Self {
62 let mut physics = PhysicsWorld::new([0.0, -9.81, 0.0]);
63 physics.add_box([0.0, -0.4, 4.0], [14.0, 0.4, 20.0], false);
64 let mut blocks = Vec::new();
65 for x in [-4.0, 0.0, 4.0] {
66 for layer in 0..4 {
67 let home = vec3(x, 0.6 + layer as f32 * 1.202, -4.0);
68 let (handle, _) = physics.insert(
69 RigidBodyBuilder::dynamic()
70 .translation(Vector::from_array(home.to_array()))
71 .linear_damping(0.12)
72 .angular_damping(0.18)
73 .ccd_enabled(true),
74 ColliderBuilder::cuboid(0.6, 0.6, 0.6)
75 .density(0.85)
76 .friction(0.55)
77 .restitution(0.025),
78 );
79 blocks.push(Block {
80 handle,
81 home,
82 target: layer >= 2,
83 down: false,
84 previous_position: home,
85 previous_rotation: Quat::IDENTITY,
86 });
87 }
88 }
89 Self {
90 physics,
91 blocks,
92 shots: Vec::with_capacity(AMMO as usize),
93 yaw: 0.0,
94 elevation: 0.13,
95 ammo: AMMO,
96 cooldown: 0.0,
97 since_shot: 0.0,
98 elapsed: 0.0,
99 phase: Phase::Aiming,
100 }
101 }
102
103 fn direction(&self) -> Vec3 {
104 vec3(
105 self.yaw.sin() * self.elevation.cos(),
106 self.elevation.sin(),
107 -self.yaw.cos() * self.elevation.cos(),
108 )
109 }
110
111 fn score(&self) -> usize {
112 self.blocks.iter().filter(|b| b.target && b.down).count()
113 }
114
115 fn fire(&mut self) -> bool {
116 if self.phase != Phase::Aiming || self.cooldown > 0.0 || self.ammo == 0 {
117 return false;
118 }
119 let direction = self.direction();
120 let muzzle = PIVOT + direction * 1.6;
121 let (handle, _) = self.physics.insert(
122 RigidBodyBuilder::dynamic()
123 .translation(Vector::from_array(muzzle.to_array()))
124 .linvel(Vector::from_array((direction * SPEED).to_array()))
125 .ccd_enabled(true),
126 ColliderBuilder::ball(0.36)
127 .density(18.0)
128 .friction(0.6)
129 .restitution(0.08),
130 );
131 self.shots.push(Shot {
132 handle,
133 previous_position: muzzle,
134 });
135 self.ammo -= 1;
136 self.cooldown = 0.8;
137 self.since_shot = 0.0;
138 true
139 }
140
141 fn step(&mut self, dt: f32) {
142 self.elapsed += dt;
143 self.since_shot += dt;
144 self.cooldown = (self.cooldown - dt).max(0.0);
145 for block in &mut self.blocks {
146 let body = &self.physics.bodies[block.handle];
147 block.previous_position = Vec3::from_array(body.translation().to_array());
148 block.previous_rotation = Quat::from_array(body.rotation().to_array());
149 }
150 for shot in &mut self.shots {
151 shot.previous_position =
152 Vec3::from_array(self.physics.bodies[shot.handle].translation().to_array());
153 }
154 self.physics.step(dt);
155 for block in &mut self.blocks {
156 if !block.target || block.down {
157 continue;
158 }
159 let body = &self.physics.bodies[block.handle];
160 let p = Vec3::from_array(body.translation().to_array());
161 let rotation = Quat::from_array(body.rotation().to_array());
162 block.down = p.distance(block.home) > 1.3
163 || p.y < block.home.y - 0.75
164 || (rotation * Vec3::Y).y < 0.45;
165 }
166 if self.score() == GOAL {
167 self.phase = Phase::Won;
168 } else if self.ammo == 0 && self.since_shot > 6.0 {
169 self.phase = Phase::Lost;
170 }
171 }
172
173 fn aim_at(&mut self, point: Vec3) {
175 let delta = point - PIVOT;
176 self.yaw = delta.x.atan2(-delta.z);
177 let distance = vec2(delta.x, delta.z).length();
178 let mut low: f32 = 0.025;
179 let mut high: f32 = 0.7;
180 for _ in 0..24 {
181 let angle = (low + high) * 0.5;
182 let time = (distance - 1.6 * angle.cos()) / (SPEED * angle.cos());
183 let height = PIVOT.y + distance * angle.tan() - 4.905 * time * time;
184 if height < point.y {
185 low = angle;
186 } else {
187 high = angle;
188 }
189 }
190 self.elevation = (low + high) * 0.5;
191 }
192
193 fn autoplay(&mut self) -> bool {
195 if self.phase != Phase::Aiming || self.elapsed < 0.7 || self.since_shot < 2.0 {
196 return false;
197 }
198 if let Some(target) = self.blocks.iter().find(|b| b.target && !b.down) {
199 self.aim_at(vec3(target.home.x, 1.8, target.home.z));
200 true
201 } else {
202 false
203 }
204 }
205}
206
207struct Art {
208 floor: Mesh3d,
209 stone: Mesh3d,
210 target: Mesh3d,
211 fallen: Mesh3d,
212 ball: Mesh3d,
213 barrel: Mesh3d,
214 muzzle: Mesh3d,
215 wheel: Mesh3d,
216 chassis: Mesh3d,
217 line: Mesh3d,
218 column: Mesh3d,
219 girder: Mesh3d,
220 chimney: Mesh3d,
221 roof: Mesh3d,
222 building: Mesh3d,
223 rivet: Mesh3d,
224}
225
226impl Art {
227 fn new() -> Self {
228 Self {
229 floor: Mesh3d::cuboid(vec3(28.0, 0.8, 40.0), Color::from_rgba(132, 130, 111, 255)),
230 stone: Mesh3d::cuboid(Vec3::splat(1.17), SAND),
231 target: Mesh3d::cuboid(Vec3::splat(1.17), RUST),
232 fallen: Mesh3d::cuboid(Vec3::splat(1.2), Color::from_rgba(103, 111, 91, 255)),
233 ball: Mesh3d::sphere(0.36, INK, 10, 6),
234 barrel: Mesh3d::frustum(0.34, 0.27, 2.4, STEEL, 12),
235 muzzle: Mesh3d::cylinder(0.2, 0.035, INK, 12),
236 wheel: Mesh3d::cylinder(0.6, 0.3, INK, 10),
237 chassis: Mesh3d::cuboid(vec3(1.5, 0.45, 1.6), RUST),
238 line: Mesh3d::cuboid(
239 vec3(0.075, 0.008, 26.0),
240 Color::from_rgba(190, 181, 139, 255),
241 ),
242 column: Mesh3d::cuboid(vec3(0.32, 7.0, 0.32), STEEL),
243 girder: Mesh3d::cuboid(vec3(18.0, 0.3, 0.34), RUST),
244 chimney: Mesh3d::frustum(1.0, 0.7, 12.0, Color::from_rgba(139, 118, 91, 255), 8),
245 roof: Mesh3d::cuboid(vec3(8.5, 0.3, 6.5), STEEL),
246 building: Mesh3d::cuboid(vec3(8.0, 4.0, 6.0), Color::from_rgba(158, 145, 114, 255)),
247 rivet: Mesh3d::cylinder(0.16, 0.015, PAPER, 8),
248 }
249 }
250
251 fn draw(&self, game: &Range, alpha: f32, material: &LitMaterial) {
252 let eye = vec3(23.0, 18.0, 28.0);
253 set_camera(&Camera3D {
254 position: eye,
255 target: vec3(0.0, 1.5, 3.5),
256 up: Vec3::Y,
257 fovy: 43.0_f32.to_radians(),
258 z_near: 0.1,
259 z_far: 130.0,
260 ..Default::default()
261 });
262 material.begin(LitSettings {
263 camera_position: eye,
264 fog_color: SKY,
265 fog_start: 40.0,
266 fog_end: 100.0,
267 ambient: 0.55,
268 ..Default::default()
269 });
270 self.floor.draw(vec3(0.0, -0.4, 4.0), Quat::IDENTITY);
271 for x in [-8.0, -6.0, 6.0, 8.0] {
272 self.line.draw(vec3(x, 0.012, 4.0), Quat::IDENTITY);
273 }
274 for x in [-9.0, 9.0] {
275 self.column.draw(vec3(x, 3.5, -9.0), Quat::IDENTITY);
276 }
277 self.girder.draw(vec3(0.0, 6.9, -9.0), Quat::IDENTITY);
278 self.building.draw(vec3(-15.0, 2.0, -15.0), Quat::IDENTITY);
279 self.roof
280 .draw(vec3(-15.0, 4.15, -15.0), Quat::from_rotation_z(-0.1));
281 self.chimney.draw(vec3(-12.0, 6.0, -17.0), Quat::IDENTITY);
282 self.chimney.draw(vec3(-16.0, 5.0, -18.0), Quat::IDENTITY);
283 for block in &game.blocks {
284 let body = &game.physics.bodies[block.handle];
285 let p = block
286 .previous_position
287 .lerp(Vec3::from_array(body.translation().to_array()), alpha);
288 let q = block
289 .previous_rotation
290 .slerp(Quat::from_array(body.rotation().to_array()), alpha);
291 let mesh = if block.down {
292 &self.fallen
293 } else if block.target {
294 &self.target
295 } else {
296 &self.stone
297 };
298 mesh.draw(p, q);
299 if block.target && !block.down {
300 self.rivet.draw(
301 p + q * vec3(0.0, 0.0, 0.594),
302 q * Quat::from_rotation_x(std::f32::consts::FRAC_PI_2),
303 );
304 }
305 }
306 for shot in &game.shots {
307 let p = shot.previous_position.lerp(
308 Vec3::from_array(game.physics.bodies[shot.handle].translation().to_array()),
309 alpha,
310 );
311 self.ball.draw(p, Quat::IDENTITY);
312 }
313 let direction = game.direction();
314 let q = Quat::from_rotation_arc(Vec3::Y, direction);
315 self.chassis
316 .draw(vec3(0.0, 0.7, 15.0), Quat::from_rotation_y(-game.yaw));
317 for x in [-0.95, 0.95] {
318 self.wheel.draw(
319 vec3(x, 0.6, 15.0),
320 Quat::from_rotation_z(std::f32::consts::FRAC_PI_2),
321 );
322 }
323 let recoil = (game.cooldown - 0.55).max(0.0) * 1.2;
324 self.barrel.draw(PIVOT + direction * (0.3 - recoil), q);
325 self.muzzle.draw(PIVOT + direction * (1.515 - recoil), q);
326 material.end();
327 if game.phase == Phase::Aiming && game.ammo > 0 {
328 let start = PIVOT + direction * 1.6;
329 let mut previous = start;
330 for i in 1..=44 {
331 let t = i as f32 * 0.025;
332 let p = start + direction * SPEED * t - Vec3::Y * (4.905 * t * t);
333 if p.y <= 0.05 {
334 break;
335 }
336 if i % 2 == 0 {
337 draw_line_3d(previous, p, Color::from_rgba(249, 225, 157, 180));
338 }
339 previous = p;
340 }
341 }
342 set_default_camera();
343 }
344}
345
346fn label(text: &str, x: f32, y: f32, size: f32, color: Color) {
347 draw_text(
348 text,
349 x + 1.0,
350 y + 1.0,
351 size,
352 Color::new(0.0, 0.0, 0.0, 0.22),
353 );
354 draw_text(text, x, y, size, color);
355}
356
357fn button(rect: Rect, title: &str, enabled: bool) -> bool {
358 let pointer = Vec2::from(mouse_position());
359 let hover = rect.contains(pointer);
360 draw_rectangle(
361 rect.x,
362 rect.y,
363 rect.w,
364 rect.h,
365 if hover && enabled { RUST } else { STEEL },
366 );
367 let size = 19;
368 let width = measure_text(title, None, size, 1.0).width;
369 label(
370 title,
371 rect.x + (rect.w - width) * 0.5,
372 rect.y + rect.h * 0.5 + 7.0,
373 size as f32,
374 if enabled { PAPER } else { GRAY },
375 );
376 enabled && hover && is_mouse_button_pressed(MouseButton::Left)
377}
378
379fn window_conf() -> Conf {
380 ferravex_render::window_conf("Siege Foundry / Ferravex")
381}
382
383ferravex_render::game!(window_conf, game);
384async fn game() {
385 let mut game = Range::new();
386 let art = Art::new();
387 let material = LitMaterial::new().expect("Ferravex lit material");
388 let mut demo = Demo::new();
389 let mut clock = FixedClock::new(120.0, 12);
390 let mut pending_fire = false;
391 let mut previous_mouse = Vec2::from(mouse_position());
392 loop {
393 ferravex_render::ui::warm_up_font_sizes(&[16, 17, 18, 19, 23, 26, 28, 36]);
394 let dt = demo.dt();
395 let pointer = Vec2::from(mouse_position());
396 let w = screen_width();
397 let h = screen_height();
398 let compact = w < 640.0;
399 if is_key_pressed(KeyCode::R) {
400 game = Range::new();
401 clock.reset();
402 pending_fire = false;
403 }
404 if game.phase == Phase::Aiming {
405 game.yaw += (is_key_down(KeyCode::Right) as i32 - is_key_down(KeyCode::Left) as i32)
406 as f32
407 * dt
408 * 0.33;
409 game.elevation += (is_key_down(KeyCode::Up) as i32 - is_key_down(KeyCode::Down) as i32)
410 as f32
411 * dt
412 * 0.2;
413 if is_mouse_button_down(MouseButton::Left)
414 && !is_mouse_button_pressed(MouseButton::Left)
415 && pointer.y > 90.0
416 && pointer.y < h - 100.0
417 {
418 let delta = pointer - previous_mouse;
419 game.yaw += delta.x * 0.002;
420 game.elevation -= delta.y * 0.0012;
421 }
422 game.yaw = game.yaw.clamp(-0.55, 0.55);
423 game.elevation = game.elevation.clamp(0.025, 0.65);
424 pending_fire |= is_key_pressed(KeyCode::Space);
425 if demo.autoplay {
426 pending_fire |= game.autoplay();
427 }
428 }
429 previous_mouse = pointer;
430 let frame = clock.advance(dt as f64);
431 for _ in 0..frame.steps {
432 if pending_fire {
433 game.fire();
434 pending_fire = false;
435 }
436 game.step(frame.dt);
437 }
438 clear_background(SKY);
439 art.draw(&game, frame.alpha, &material);
440 label("SIEGE FOUNDRY", 28.0, 37.0, 26.0, INK);
441 label("Topple the six rust-red blocks.", 28.0, 61.0, 18.0, INK);
442 label(
443 &format!("{} / {} DOWN", game.score(), GOAL),
444 28.0,
445 if compact { 97.0 } else { h - 57.0 },
446 23.0,
447 PAPER,
448 );
449 label(&format!("{} SHOTS", game.ammo), 28.0, h - 30.0, 19.0, PAPER);
450 let fire_rect = Rect::new(w - 146.0, h - 74.0, 118.0, 44.0);
451 let reset_rect = Rect::new(w - 274.0, h - 74.0, 118.0, 44.0);
452 pending_fire |= button(
453 fire_rect,
454 if game.cooldown > 0.0 {
455 "LOADING"
456 } else {
457 "FIRE"
458 },
459 game.ammo > 0 && game.cooldown <= 0.0 && game.phase == Phase::Aiming,
460 );
461 if button(reset_rect, "REBUILD", true) {
462 game = Range::new();
463 clock.reset();
464 pending_fire = false;
465 }
466 if compact && game.phase == Phase::Aiming {
467 label("Drag to aim; tap Fire.", 28.0, 149.0, 16.0, INK);
468 }
469 if w > 720.0 {
470 label(
471 "Arrows / drag: aim Space: fire R: rebuild",
472 28.0,
473 h - 102.0,
474 17.0,
475 PAPER,
476 );
477 label(
478 &format!("ELEV {:.1}°", game.elevation.to_degrees()),
479 w - 146.0,
480 h - 90.0,
481 17.0,
482 INK,
483 );
484 }
485 if game.phase != Phase::Aiming {
486 let text = if game.phase == Phase::Won {
487 "FOUNDRY CLEARED"
488 } else {
489 "OUT OF SHOTS"
490 };
491 let result_size = if compact { 28 } else { 36 };
492 let bounds = measure_text(text, None, result_size, 1.0);
493 let panel_width = (w - 32.0).min(380.0);
494 draw_rectangle(
495 (w - panel_width) * 0.5,
496 96.0,
497 panel_width,
498 86.0,
499 Color::new(0.12, 0.16, 0.15, 0.92),
500 );
501 label(
502 text,
503 (w - bounds.width) * 0.5,
504 133.0,
505 result_size as f32,
506 PAPER,
507 );
508 let detail = if compact {
509 "Rebuild to play again."
510 } else if game.phase == Phase::Won {
511 "Every target brought down. Rebuild to play again."
512 } else {
513 "Adjust the low arc, then rebuild to try again."
514 };
515 let width = measure_text(detail, None, 16, 1.0).width;
516 label(detail, (w - width) * 0.5, 161.0, 16.0, PAPER);
517 }
518 if demo.finish_frame() {
519 break;
520 }
521 next_frame().await;
522 }
523}
524
525#[cfg(test)]
526mod tests {
527 use super::*;
528 fn tick(game: &mut Range, seconds: f32) {
529 for _ in 0..(seconds / DT).round() as usize {
530 game.step(DT);
531 }
532 }
533
534 #[test]
535 fn stack_stays_upright_without_projectiles() {
536 let mut game = Range::new();
537 tick(&mut game, 8.0);
538 assert_eq!(game.score(), 0);
539 assert_eq!(game.phase, Phase::Aiming);
540 }
541
542 #[test]
543 fn ammunition_cooldown_ccd_and_loss_are_real() {
544 let mut game = Range::new();
545 game.yaw = 0.5;
546 assert!(game.fire());
547 assert!(!game.fire());
548 assert!(game.physics.bodies[game.shots[0].handle].is_ccd_enabled());
549 while game.ammo > 0 {
550 tick(&mut game, 0.9);
551 assert!(game.fire());
552 }
553 tick(&mut game, 6.2);
554 assert_eq!(game.phase, Phase::Lost);
555 assert_eq!(game.shots.len(), AMMO as usize);
556 assert!(!game.fire());
557 }
558
559 #[test]
560 fn autoplay_wins_through_ballistic_contacts() {
561 let mut game = Range::new();
562 for _ in 0..(30.0 / DT) as usize {
563 if game.autoplay() {
564 game.fire();
565 }
566 game.step(DT);
567 if game.phase == Phase::Won {
568 break;
569 }
570 }
571 assert_eq!(
572 game.score(),
573 GOAL,
574 "score {}, ammo {}",
575 game.score(),
576 game.ammo
577 );
578 assert_eq!(game.phase, Phase::Won);
579 assert!(game.ammo > 0);
580 assert!(!game.shots.is_empty());
581 }
582
583 #[test]
584 fn shot_input_survives_zero_step_render_frame() {
585 let mut clock = FixedClock::new(120.0, 12);
586 let mut game = Range::new();
587 let mut pending = true;
588 assert_eq!(clock.advance(0.001).steps, 0);
589 let frame = clock.advance(0.008);
590 for _ in 0..frame.steps {
591 if pending {
592 game.fire();
593 pending = false;
594 }
595 game.step(frame.dt);
596 }
597 assert_eq!(game.ammo, AMMO - 1);
598 assert!(!pending);
599 }
600}