marble_run/
lib.rs

1//! An asset-free rolling-ball time trial over a suspended, low-poly aqueduct.
2mod sim;
3use ferravex_core::FixedClock;
4use ferravex_render::{
5    demo::Demo,
6    mesh::Mesh3d,
7    prelude::*,
8    shaders::{LitMaterial, LitSettings},
9    visibility::{LodChain, LodState},
10};
11use sim::{CHECKPOINTS, Controls, FINISH, GEMS, OBSTACLES, PLATFORMS, RADIUS, Run};
12
13const SKY: Color = Color::from_rgba(172, 194, 189, 255);
14const CREAM: Color = Color::from_rgba(255, 241, 205, 255);
15const INK: Color = Color::from_rgba(32, 57, 60, 255);
16const TEAL: Color = Color::from_rgba(62, 143, 133, 255);
17const GOLD: Color = Color::from_rgba(236, 175, 65, 255);
18fn conf() -> Conf {
19    ferravex_render::window_conf("Marble Run / Ferravex")
20}
21fn rgb(r: u8, g: u8, b: u8) -> Color {
22    Color::from_rgba(r, g, b, 255)
23}
24struct Prop {
25    mesh: Mesh3d,
26    position: Vec3,
27    rotation: Quat,
28    lod: Option<LodChain>,
29    lod_state: std::cell::Cell<LodState>,
30}
31impl Prop {
32    fn new(mesh: Mesh3d, position: Vec3) -> Self {
33        Self {
34            mesh,
35            position,
36            rotation: Quat::IDENTITY,
37            lod: None,
38            lod_state: Default::default(),
39        }
40    }
41    fn with_lod(mut self, low: Mesh3d) -> Self {
42        self.lod = Some(
43            LodChain::new(
44                vec![self.mesh.resident().clone(), low.resident().clone()],
45                vec![50.],
46                0.12,
47            )
48            .unwrap(),
49        );
50        self
51    }
52    fn draw(&self) {
53        if let Some(chain) = &self.lod {
54            let mut state = self.lod_state.get();
55            ferravex_render::draw_lod(
56                chain,
57                Mat4::from_rotation_translation(self.rotation, self.position),
58                &mut state,
59            );
60            self.lod_state.set(state);
61        } else {
62            self.mesh.draw(self.position, self.rotation);
63        }
64    }
65}
66struct Scene {
67    platforms: Vec<Prop>,
68    decoration: Vec<Prop>,
69    marble: Mesh3d,
70    gem: Mesh3d,
71    gem_base: Mesh3d,
72    pillar: Mesh3d,
73    cap: Mesh3d,
74    checkpoint: Mesh3d,
75    checkpoint_lit: Mesh3d,
76    shadow: Mesh3d,
77}
78impl Scene {
79    fn new() -> Self {
80        let platforms = PLATFORMS
81            .iter()
82            .enumerate()
83            .map(|(i, p)| {
84                Prop::new(
85                    Mesh3d::cuboid(
86                        p.size,
87                        if i % 2 == 0 {
88                            rgb(190, 149, 113)
89                        } else {
90                            rgb(210, 172, 126)
91                        },
92                    ),
93                    p.center,
94                )
95            })
96            .collect();
97        let mut decoration = Vec::new();
98        // Hanging stone buttresses and terracotta trim make the course feel built.
99        for (i, p) in PLATFORMS.iter().enumerate() {
100            decoration.push(Prop::new(
101                Mesh3d::cuboid(
102                    vec3(p.size.x + 0.08, 0.12, p.size.z + 0.08),
103                    rgb(129, 102, 85),
104                ),
105                p.center - vec3(0., 0.26, 0.),
106            ));
107            if i % 2 == 0 {
108                decoration.push(Prop::new(
109                    Mesh3d::frustum(0.65, 2.0, 6.0, rgb(101, 121, 112), 5),
110                    p.center - vec3(0., 3.3, 0.),
111                ));
112                decoration.push(Prop::new(
113                    Mesh3d::cuboid(vec3(1.15, 1.6, 1.15), rgb(161, 132, 104)),
114                    p.center - vec3(0., 1.1, 0.),
115                ));
116            }
117            // Inset colored edges are visual guides, not invisible collision rails.
118            for side in [-1., 1.] {
119                let (size, offset) = if p.size.z > p.size.x {
120                    (
121                        vec3(0.11, 0.018, p.size.z),
122                        vec3(side * (p.size.x * 0.5 - 0.15), 0.311, 0.),
123                    )
124                } else {
125                    (
126                        vec3(p.size.x, 0.018, 0.11),
127                        vec3(0., 0.311, side * (p.size.z * 0.5 - 0.15)),
128                    )
129                };
130                decoration.push(Prop::new(Mesh3d::cuboid(size, TEAL), p.center + offset));
131            }
132        }
133        // Thin expansion joints across each lane, using geometry rather than textures.
134        for p in PLATFORMS.iter().filter(|p| p.size.x.max(p.size.z) > 7.) {
135            let length = p.size.x.max(p.size.z);
136            for i in 1..length as usize {
137                let d = i as f32 - length * 0.5;
138                let (size, offset) = if p.size.z > p.size.x {
139                    (vec3(p.size.x - 0.4, 0.013, 0.025), vec3(0., 0.308, d))
140                } else {
141                    (vec3(0.025, 0.013, p.size.z - 0.4), vec3(d, 0.308, 0.))
142                };
143                decoration.push(Prop::new(
144                    Mesh3d::cuboid(size, rgb(160, 128, 99)),
145                    p.center + offset,
146                ));
147            }
148        }
149        for p in CHECKPOINTS {
150            decoration.push(Prop::new(
151                Mesh3d::cylinder(0.07, 2.8, INK, 6),
152                p + vec3(-2., 0.6, -1.8),
153            ));
154            decoration.push(Prop::new(
155                Mesh3d::cuboid(vec3(0.95, 0.5, 0.04), TEAL),
156                p + vec3(-1.6, 1.65, -1.8),
157            ));
158        }
159        for x in [-2.1, 2.1] {
160            decoration.push(Prop::new(
161                Mesh3d::cuboid(vec3(0.3, 3.2, 0.3), TEAL),
162                FINISH + vec3(x, 1., -0.8),
163            ));
164        }
165        decoration.push(Prop::new(
166            Mesh3d::cuboid(vec3(4.6, 0.5, 0.4), TEAL),
167            FINISH + vec3(0., 2.75, -0.8),
168        ));
169        for x in -4..=4 {
170            decoration.push(Prop::new(
171                Mesh3d::cuboid(vec3(0.45, 0.02, 1.4), if x % 2 == 0 { CREAM } else { INK }),
172                FINISH + vec3(x as f32 * 0.45, -0.58, 0.),
173            ));
174        }
175        // A distant archipelago is intentionally coarse and bounded (25 meshes).
176        for i in 0..25 {
177            let angle = i as f32 * 2.39996;
178            let distance = 40. + (i % 5) as f32 * 9.;
179            let p = vec3(angle.cos() * distance, -15., -20. + angle.sin() * distance);
180            let radius = 8. + (i % 4) as f32 * 2.;
181            let height = 15. + (i % 3) as f32 * 7.;
182            let color = if i % 2 == 0 {
183                rgb(110, 144, 130)
184            } else {
185                rgb(131, 155, 139)
186            };
187            decoration.push(
188                Prop::new(Mesh3d::cone(radius, height, color, 5), p)
189                    .with_lod(Mesh3d::cone(radius, height, color, 3)),
190            );
191        }
192        let mut marble = Mesh3d::sphere(RADIUS, CREAM, 12, 8);
193        for v in &mut marble.mesh.vertices {
194            if v.position.y.abs() < 0.18 {
195                v.color = GOLD.to_rgba();
196            } else if v.position.x < -0.2 {
197                v.color = TEAL.to_rgba();
198            }
199        }
200        Self {
201            platforms,
202            decoration,
203            marble,
204            gem: Mesh3d::sphere(0.36, GOLD, 4, 2),
205            gem_base: Mesh3d::cylinder(0.48, 0.022, rgb(185, 134, 61), 12),
206            pillar: Mesh3d::cylinder(0.38, 1.3, rgb(146, 80, 61), 8),
207            cap: Mesh3d::cylinder(0.39, 0.13, CREAM, 8),
208            checkpoint: Mesh3d::cylinder(1.25, 0.02, rgb(105, 142, 128), 16),
209            checkpoint_lit: Mesh3d::cylinder(1.25, 0.025, TEAL, 16),
210            shadow: Mesh3d::cylinder(0.62, 0.015, rgb(131, 113, 90), 12),
211        }
212    }
213    fn draw(&self, game: &Run, position: Vec3, rotation: Quat, time: f32) {
214        for prop in &self.platforms {
215            prop.draw();
216        }
217        for prop in &self.decoration {
218            prop.draw();
219        }
220        for p in OBSTACLES {
221            self.pillar.draw(p, Quat::IDENTITY);
222            self.cap.draw(p + vec3(0., 0.55, 0.), Quat::IDENTITY);
223        }
224        for (i, p) in CHECKPOINTS.iter().enumerate() {
225            (if game.checkpoint > i {
226                &self.checkpoint_lit
227            } else {
228                &self.checkpoint
229            })
230            .draw(vec3(p.x, 0.323, p.z), Quat::IDENTITY);
231        }
232        for (i, p) in GEMS.iter().enumerate() {
233            if !game.collected[i] {
234                self.gem_base.draw(vec3(p.x, 0.325, p.z), Quat::IDENTITY);
235                self.gem.draw(
236                    *p + vec3(0., 0.15 + (time * 2. + i as f32).sin() * 0.1, 0.),
237                    Quat::from_rotation_y(time * 1.3),
238                );
239            }
240        }
241        if position.y > 0.3
242            && PLATFORMS.iter().any(|p| {
243                (position.x - p.center.x).abs() < p.size.x * 0.5
244                    && (position.z - p.center.z).abs() < p.size.z * 0.5
245            })
246        {
247            self.shadow.draw(
248                vec3(position.x + 0.12, 0.322, position.z + 0.18),
249                Quat::IDENTITY,
250            );
251        }
252        self.marble.draw(position, rotation);
253    }
254}
255fn text(s: &str, x: f32, y: f32, size: f32, color: Color) {
256    draw_text(s, x + 1., y + 1., size, Color::new(0.1, 0.2, 0.2, 0.25));
257    draw_text(s, x, y, size, color);
258}
259fn centered(s: &str, y: f32, size: u16, color: Color) {
260    let m = measure_text(s, None, size, 1.);
261    text(s, (screen_width() - m.width) * 0.5, y, size as f32, color);
262}
263fn touch_input() -> (Vec2, bool, bool) {
264    let mut direction = Vec2::ZERO;
265    let mut brake = false;
266    let mut active = false;
267    for touch in ferravex_render::input::logical_touches()
268        .iter()
269        .filter(|t| !matches!(t.phase, TouchPhase::Ended | TouchPhase::Cancelled))
270    {
271        active = true;
272        let p = touch.position;
273        let base = vec2(100., screen_height() - 105.);
274        if p.distance(base) < 100. {
275            direction = (p - base).clamp_length_max(48.) / 48.;
276        }
277        if p.distance(vec2(screen_width() - 75., screen_height() - 90.)) < 65. {
278            brake = true;
279        }
280    }
281    (direction, brake, active)
282}
283ferravex_render::game!(conf, game);
284async fn game() {
285    let mut game = Run::new();
286    let scene = Scene::new();
287    let material = LitMaterial::new().expect("Ferravex's portable lit material must compile");
288    let mut clock = FixedClock::new(120., 12);
289    let mut demo = Demo::new();
290    let mut touch_mode = false;
291    let mut camera_target = game.position();
292    let forward = vec3(-8., 0., -19.).normalize();
293    let right = vec3(-forward.z, 0., forward.x);
294    loop {
295        ferravex_render::ui::warm_up_font_sizes(&[12, 17, 18, 20, 23, 26, 30]);
296        let (touch, touch_brake, touched) = touch_input();
297        touch_mode |= touched;
298        let restart = is_key_pressed(KeyCode::R)
299            || game.finished && is_mouse_button_pressed(MouseButton::Left);
300        if restart {
301            game = Run::new();
302            clock.reset();
303            camera_target = game.position();
304        }
305        let held = |a, b| f32::from(is_key_down(a) || is_key_down(b));
306        let horizontal =
307            held(KeyCode::D, KeyCode::Right) - held(KeyCode::A, KeyCode::Left) + touch.x;
308        let vertical = held(KeyCode::W, KeyCode::Up) - held(KeyCode::S, KeyCode::Down) - touch.y;
309        let direction = right * horizontal + forward * vertical;
310        let input = Controls {
311            roll: vec2(direction.x, direction.z).clamp_length_max(1.),
312            brake: is_key_down(KeyCode::Space) || touch_brake,
313        };
314        let frame = clock.advance(demo.dt() as f64);
315        for _ in 0..frame.steps {
316            let controls = if demo.autoplay {
317                game.autopilot()
318            } else {
319                input
320            };
321            game.step(controls, frame.dt);
322        }
323        let position = game.previous_position.lerp(game.position(), frame.alpha);
324        let rotation = game.previous_rotation.slerp(game.rotation(), frame.alpha);
325        camera_target = camera_target.lerp(position, 1. - (-demo.dt() * 6.).exp());
326        clear_background(SKY);
327        let overview = is_key_down(KeyCode::Tab);
328        let camera_position = if overview {
329            vec3(31., 40., 20.)
330        } else {
331            camera_target + vec3(8., 12., 14.)
332        };
333        let target = if overview {
334            vec3(3., 0., -18.)
335        } else {
336            camera_target + vec3(0., -0.2, -5.)
337        };
338        set_camera(&Camera3D {
339            position: camera_position,
340            target,
341            up: Vec3::Y,
342            fovy: 48.0_f32.to_radians(),
343            z_near: 0.1,
344            z_far: 220.,
345            ..Default::default()
346        });
347        material.begin(LitSettings {
348            camera_position,
349            fog_color: SKY,
350            fog_start: 38.,
351            fog_end: 115.,
352            light_direction: vec3(-0.55, 0.85, 0.35),
353            ambient: 0.48,
354        });
355        scene.draw(&game, position, rotation, demo.time());
356        material.end();
357        set_default_camera();
358        text("MARBLE RUN", 27., 39., 26., INK);
359        if screen_width() > 620. {
360            text("THE HANGING AQUEDUCT", 28., 60., 12., INK);
361        }
362        let status = format!(
363            "{} / 6     {:02}:{:04.1}",
364            game.gems(),
365            (game.elapsed / 60.) as u32,
366            game.elapsed % 60.
367        );
368        let status_width = measure_text(&status, None, 23, 1.).width;
369        text(&status, screen_width() - status_width - 27., 39., 23., INK);
370        draw_poly(screen_width() - status_width - 43., 31., 4, 8., 0., GOLD);
371        if game.notice > 0. && !game.finished {
372            centered(
373                if game.recovered {
374                    "BACK ON TRACK"
375                } else if game.checkpoint > 0 {
376                    "CHECKPOINT SAVED"
377                } else {
378                    "BACK ON TRACK"
379                },
380                89.,
381                17,
382                INK,
383            );
384        }
385        if touch_mode && !game.finished {
386            let center = vec2(100., screen_height() - 105.);
387            draw_circle(center.x, center.y, 66., Color::new(0.12, 0.25, 0.24, 0.15));
388            draw_circle_lines(center.x, center.y, 66., 2., CREAM);
389            draw_circle(
390                center.x + touch.x * 35.,
391                center.y + touch.y * 35.,
392                24.,
393                Color::new(0.98, 0.94, 0.81, 0.7),
394            );
395            draw_circle(
396                screen_width() - 75.,
397                screen_height() - 90.,
398                45.,
399                Color::new(0.12, 0.25, 0.24, 0.35),
400            );
401            text(
402                "BRAKE",
403                screen_width() - 102.,
404                screen_height() - 85.,
405                18.,
406                CREAM,
407            );
408        } else if game.elapsed < 10. {
409            centered(
410                if screen_width() > 700. {
411                    "WASD / ARROWS  roll     SPACE  brake     TAB  course     R  restart"
412                } else {
413                    "Arrows roll / Space brake / R restart"
414                },
415                screen_height() - 27.,
416                17,
417                INK,
418            );
419        }
420        if game.finished {
421            let w = 330_f32.min(screen_width() - 30.);
422            let y = screen_height() * 0.43;
423            draw_rectangle(
424                (screen_width() - w) * 0.5,
425                y - 62.,
426                w,
427                182.,
428                Color::new(0.12, 0.23, 0.23, 0.94),
429            );
430            centered("COURSE CLEAR", y - 20., 30, CREAM);
431            centered(
432                &format!("6 crystals   /   {:.1} seconds", game.elapsed),
433                y + 17.,
434                20,
435                CREAM,
436            );
437            centered(&format!("{} falls", game.falls), y + 48., 18, CREAM);
438            centered("R or tap to roll again", y + 90., 17, CREAM);
439        }
440        if demo.finish_frame() {
441            break;
442        }
443        next_frame().await;
444    }
445}