siege_foundry/
lib.rs

1//! A compact destruction range: all knockdowns come from rigid-body contacts.
2use 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    /// A physical low arc from the barrel muzzle to a selected point.
174    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    /// Autoplay chooses the next standing tower and uses the same fire operation.
194    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}