sky_courier/
lib.rs

1use ferravex_core::FixedClock;
2use ferravex_render::{
3    demo::Demo,
4    mesh::Mesh3d,
5    prelude::*,
6    shaders::{LitMaterial, LitSettings},
7};
8
9const SKY: Color = Color::new(0.64, 0.76, 0.77, 1.0);
10const INK: Color = Color::new(0.10, 0.20, 0.22, 1.0);
11const CREAM: Color = Color::new(0.95, 0.91, 0.78, 1.0);
12const CORAL: Color = Color::new(0.82, 0.36, 0.23, 1.0);
13const ROUTE: [Vec3; 6] = [
14    vec3(0., 24., 100.),
15    vec3(44., 40., 205.),
16    vec3(-20., 30., 320.),
17    vec3(-62., 47., 430.),
18    vec3(10., 34., 550.),
19    vec3(0., 16., 680.),
20];
21const ISLANDS: [(Vec3, f32); 9] = [
22    (vec3(-42., 1., 73.), 30.),
23    (vec3(67., 13., 207.), 30.),
24    (vec3(-48., 7., 315.), 31.),
25    (vec3(-86., 22., 430.), 30.),
26    (vec3(38., 10., 553.), 30.),
27    (vec3(0., 8., 680.), 37.),
28    (vec3(112., 4., 380.), 40.),
29    (vec3(-130., 14., 170.), 36.),
30    (vec3(105., 7., 680.), 29.),
31];
32
33#[derive(Default, Clone, Copy)]
34struct Input {
35    turn: f32,
36    climb: f32,
37    boost: bool,
38    slow: bool,
39}
40#[derive(Clone, Copy, Debug, PartialEq)]
41enum Outcome {
42    Flying,
43    Delivered,
44    Lost,
45}
46struct Flight {
47    pos: Vec3,
48    yaw: f32,
49    pitch: f32,
50    bank: f32,
51    speed: f32,
52    next: usize,
53    hull: u8,
54    remaining: f32,
55    outcome: Outcome,
56    recovery: f32,
57}
58impl Flight {
59    fn new() -> Self {
60        Self {
61            pos: vec3(0., 20., 0.),
62            yaw: 0.,
63            pitch: 0.,
64            bank: 0.,
65            speed: 24.,
66            next: 0,
67            hull: 3,
68            remaining: 90.,
69            outcome: Outcome::Flying,
70            recovery: 0.,
71        }
72    }
73    fn forward(&self) -> Vec3 {
74        vec3(
75            self.yaw.sin() * self.pitch.cos(),
76            self.pitch.sin(),
77            self.yaw.cos() * self.pitch.cos(),
78        )
79    }
80    fn damage(&mut self) {
81        if self.recovery > 0. || self.outcome != Outcome::Flying {
82            return;
83        }
84        self.hull = self.hull.saturating_sub(1);
85        if self.hull == 0 {
86            self.outcome = Outcome::Lost;
87            return;
88        }
89        self.pos = if self.next == 0 {
90            vec3(0., 20., 0.)
91        } else {
92            ROUTE[self.next - 1]
93        };
94        let delta = ROUTE[self.next] - self.pos;
95        self.yaw = delta.x.atan2(delta.z);
96        self.pitch = 0.;
97        self.bank = 0.;
98        self.speed = 24.;
99        self.recovery = 2.5;
100        self.remaining = (self.remaining - 5.).max(0.);
101    }
102    fn tick(&mut self, input: Input, dt: f32) {
103        if self.outcome != Outcome::Flying {
104            return;
105        }
106        self.remaining = (self.remaining - dt).max(0.);
107        if self.remaining <= 0. {
108            self.outcome = Outcome::Lost;
109            return;
110        }
111        self.recovery = (self.recovery - dt).max(0.);
112        let turn = input.turn.clamp(-1., 1.);
113        self.yaw += turn * 1.15 * dt;
114        self.pitch += (input.climb.clamp(-1., 1.) * 0.58 - self.pitch) * (dt * 3.8).min(1.);
115        self.bank += (-turn * 0.7 - self.bank) * (dt * 4.).min(1.);
116        let target_speed = if input.slow {
117            13.
118        } else if input.boost {
119            35.
120        } else {
121            24.
122        };
123        self.speed += (target_speed - self.speed) * (dt * 2.).min(1.);
124        let previous = self.pos;
125        self.pos += self.forward() * self.speed * dt;
126        if self.pos.y < -17.
127            || self.pos.y > 110.
128            || self.pos.x.abs() > 260.
129            || self.pos.z < -90.
130            || self.pos.z > 850.
131            || island_hit(self.pos)
132        {
133            self.damage();
134            return;
135        }
136        // Swept sphere capture prevents missing a beacon between fixed steps.
137        let radius = if self.next == 5 { 8. } else { 8.5 };
138        if segment_hits(previous, self.pos, ROUTE[self.next], radius)
139            && (self.next < 5 || self.speed <= 18.)
140        {
141            self.next += 1;
142            if self.next == ROUTE.len() {
143                self.outcome = Outcome::Delivered;
144            }
145        }
146    }
147    fn autopilot(&self) -> Input {
148        if self.outcome != Outcome::Flying {
149            return Input::default();
150        }
151        let delta = ROUTE[self.next] - self.pos;
152        let error = (delta.x.atan2(delta.z) - self.yaw + std::f32::consts::PI)
153            .rem_euclid(std::f32::consts::TAU)
154            - std::f32::consts::PI;
155        let pitch = delta.y.atan2(vec2(delta.x, delta.z).length());
156        Input {
157            turn: (error * 2.7).clamp(-1., 1.),
158            climb: (pitch / 0.58).clamp(-1., 1.),
159            slow: self.next == 5 && delta.length() < 90.,
160            boost: false,
161        }
162    }
163}
164fn segment_hits(a: Vec3, b: Vec3, center: Vec3, radius: f32) -> bool {
165    let delta = b - a;
166    let t = if delta.length_squared() > 0. {
167        ((center - a).dot(delta) / delta.length_squared()).clamp(0., 1.)
168    } else {
169        0.
170    };
171    (a + delta * t).distance_squared(center) <= radius * radius
172}
173fn island_hit(pos: Vec3) -> bool {
174    ISLANDS.iter().any(|(center, radius)| {
175        let horizontal = vec2(pos.x - center.x, pos.z - center.z).length();
176        horizontal < radius * 0.85 + 1. && pos.y < center.y + 2. && pos.y > center.y - 27.
177    })
178}
179
180struct Scene {
181    ocean: Mesh3d,
182    islands: Vec<(Mesh3d, Mesh3d)>,
183    house: Mesh3d,
184    roof: Mesh3d,
185    trunk: Mesh3d,
186    leaves: Mesh3d,
187    body: Mesh3d,
188    nose: Mesh3d,
189    wing: Mesh3d,
190    tail: Mesh3d,
191    fin: Mesh3d,
192    glass: Mesh3d,
193    float: Mesh3d,
194    strut: Mesh3d,
195    propeller: Mesh3d,
196    ring: Mesh3d,
197    dim_ring: Mesh3d,
198    pad: Mesh3d,
199    stripe: Mesh3d,
200    cloud: Mesh3d,
201    parcel: Mesh3d,
202}
203impl Scene {
204    fn new() -> Self {
205        Self {
206            ocean: Mesh3d::plane(vec2(1200., 1400.), Color::from_rgba(60, 121, 130, 255), 28),
207            islands: ISLANDS
208                .iter()
209                .map(|(_, r)| {
210                    (
211                        Mesh3d::frustum(r * 0.35, *r, 24., Color::from_rgba(100, 110, 104, 255), 7),
212                        Mesh3d::frustum(*r, r * 0.85, 3., Color::from_rgba(142, 158, 112, 255), 7),
213                    )
214                })
215                .collect(),
216            house: Mesh3d::cuboid(vec3(6., 5., 5.), CREAM),
217            roof: Mesh3d::cone(5., 3.4, CORAL, 4),
218            trunk: Mesh3d::cylinder(0.55, 4., Color::from_rgba(104, 89, 65, 255), 5),
219            leaves: Mesh3d::cone(3.6, 8., Color::from_rgba(57, 95, 75, 255), 6),
220            body: Mesh3d::cuboid(vec3(1.9, 1.6, 5.5), CREAM),
221            nose: Mesh3d::cylinder(1., 1.8, CORAL, 7),
222            wing: Mesh3d::cuboid(vec3(11.5, 0.23, 1.9), CORAL),
223            tail: Mesh3d::cuboid(vec3(4.5, 0.2, 1.3), CREAM),
224            fin: Mesh3d::cuboid(vec3(0.2, 1.8, 1.5), CORAL),
225            glass: Mesh3d::cuboid(vec3(1.6, 0.85, 1.8), Color::from_rgba(61, 89, 94, 255)),
226            float: Mesh3d::cuboid(vec3(0.65, 0.6, 4.2), Color::from_rgba(76, 85, 81, 255)),
227            strut: Mesh3d::cuboid(vec3(0.12, 1.6, 0.12), INK),
228            propeller: Mesh3d::cuboid(vec3(0.15, 3.3, 0.12), INK),
229            ring: Mesh3d::cuboid(vec3(3.6, 0.45, 0.5), Color::from_rgba(236, 178, 86, 255)),
230            dim_ring: Mesh3d::cuboid(vec3(3.6, 0.4, 0.4), Color::from_rgba(121, 163, 157, 255)),
231            pad: Mesh3d::cylinder(11., 0.25, Color::from_rgba(228, 206, 146, 255), 16),
232            stripe: Mesh3d::cuboid(vec3(1.2, 0.12, 9.), CORAL),
233            cloud: Mesh3d::sphere(10., Color::from_rgba(217, 226, 214, 255), 7, 4),
234            parcel: Mesh3d::cuboid(vec3(1.4, 1.2, 1.1), Color::from_rgba(165, 121, 71, 255)),
235        }
236    }
237    fn draw(&self, game: &Flight, time: f32) {
238        self.ocean.draw(vec3(0., -20., 350.), Quat::IDENTITY);
239        for (i, (center, radius)) in ISLANDS.iter().enumerate() {
240            let (rock, grass) = &self.islands[i];
241            rock.draw(*center - Vec3::Y * 13.5, Quat::IDENTITY);
242            grass.draw(*center, Quat::IDENTITY);
243            for j in 0..5 {
244                let a = j as f32 * 1.23 + i as f32;
245                let p = *center + vec3(a.cos() * radius * 0.57, 3.4, a.sin() * radius * 0.57);
246                self.trunk.draw(p, Quat::IDENTITY);
247                self.leaves.draw(p + Vec3::Y * 4.5, Quat::IDENTITY);
248            }
249            let p = *center + vec3(radius * 0.3, 4., -radius * 0.15);
250            self.house.draw(p, Quat::IDENTITY);
251            self.roof.draw(
252                p + Vec3::Y * 4.1,
253                Quat::from_rotation_y(std::f32::consts::FRAC_PI_4),
254            );
255        }
256        for (i, gate) in ROUTE.iter().enumerate().take(5) {
257            if i < game.next {
258                continue;
259            }
260            let mesh = if i == game.next {
261                &self.ring
262            } else {
263                &self.dim_ring
264            };
265            for segment in 0..16 {
266                let angle = segment as f32 * std::f32::consts::TAU / 16.;
267                let position = *gate + vec3(angle.cos() * 9., angle.sin() * 9., 0.);
268                mesh.draw(
269                    position,
270                    Quat::from_rotation_z(angle + std::f32::consts::FRAC_PI_2),
271                );
272            }
273        }
274        self.pad.draw(vec3(0., 9.7, 680.), Quat::IDENTITY);
275        self.stripe.draw(vec3(0., 9.9, 680.), Quat::IDENTITY);
276        self.stripe.draw(
277            vec3(0., 9.9, 680.),
278            Quat::from_rotation_y(std::f32::consts::FRAC_PI_2),
279        );
280        for i in 0..13 {
281            let x = ((i * 79) % 330) as f32 - 165.;
282            let z = i as f32 * 67. - 50.;
283            let y = 79. + (i % 3) as f32 * 10.;
284            self.cloud.draw(vec3(x, y, z), Quat::IDENTITY);
285            self.cloud
286                .draw(vec3(x + 10., y - 2., z + 3.), Quat::IDENTITY);
287        }
288        let rotation = Quat::from_rotation_y(game.yaw)
289            * Quat::from_rotation_x(-game.pitch)
290            * Quat::from_rotation_z(game.bank);
291        let draw = |mesh: &Mesh3d, offset: Vec3| mesh.draw(game.pos + rotation * offset, rotation);
292        draw(&self.body, Vec3::ZERO);
293        self.nose.draw(
294            game.pos + rotation * vec3(0., 0., 3.2),
295            rotation * Quat::from_rotation_x(std::f32::consts::FRAC_PI_2),
296        );
297        draw(&self.wing, vec3(0., 0.2, 0.6));
298        draw(&self.tail, vec3(0., 0.3, -3.));
299        draw(&self.fin, vec3(0., 1., -2.8));
300        draw(&self.glass, vec3(0., 1., 0.5));
301        draw(&self.parcel, vec3(0., -1.2, -0.8));
302        for x in [-1.5, 1.5] {
303            draw(&self.float, vec3(x, -2., 0.));
304            draw(&self.strut, vec3(x, -1.1, 0.7));
305            draw(&self.strut, vec3(x, -1.1, -1.3));
306        }
307        self.propeller.draw(
308            game.pos + rotation * vec3(0., 0., 4.2),
309            rotation * Quat::from_rotation_z(time * 42.),
310        );
311    }
312}
313
314fn human_input() -> Input {
315    let mut input = Input {
316        turn: (is_key_down(KeyCode::D) || is_key_down(KeyCode::Right)) as u8 as f32
317            - (is_key_down(KeyCode::A) || is_key_down(KeyCode::Left)) as u8 as f32,
318        climb: (is_key_down(KeyCode::W) || is_key_down(KeyCode::Up)) as u8 as f32
319            - (is_key_down(KeyCode::S) || is_key_down(KeyCode::Down)) as u8 as f32,
320        boost: is_key_down(KeyCode::Space),
321        slow: is_key_down(KeyCode::LeftShift) || is_key_down(KeyCode::RightShift),
322    };
323    // A left-side virtual stick and a right-side slow-flight pad support touch.
324    for touch in ferravex_render::input::logical_touches() {
325        if touch.phase == TouchPhase::Ended || touch.phase == TouchPhase::Cancelled {
326            continue;
327        }
328        if touch.position.x < screen_width() * 0.55 {
329            let offset = touch.position - vec2(90., screen_height() - 100.);
330            input.turn = (offset.x / 60.).clamp(-1., 1.);
331            input.climb = (-offset.y / 60.).clamp(-1., 1.);
332        } else {
333            input.slow = true;
334        }
335    }
336    input
337}
338fn label(text: &str, x: f32, y: f32, size: f32, color: Color) {
339    draw_text(
340        text,
341        x + 1.,
342        y + 1.,
343        size,
344        Color::new(0.82, 0.88, 0.85, 0.6),
345    );
346    draw_text(text, x, y, size, color);
347}
348fn centered(text: &str, y: f32, size: f32, color: Color) {
349    let width = measure_text(text, None, size as u16, 1.).width;
350    label(text, (screen_width() - width) * 0.5, y, size, color);
351}
352fn hud(game: &Flight, touch: bool) {
353    let width = screen_width();
354    label("SKY COURIER", 28., 42., 26., INK);
355    label("THE NORTH COAST POST", 29., 63., 13., INK);
356    let route = if game.next < 5 {
357        format!("BEACON {} / 5", game.next + 1)
358    } else {
359        "DELIVERY APPROACH".to_owned()
360    };
361    centered(&route, 37., 19., INK);
362    centered(
363        &format!(
364            "{:02}:{:02}",
365            game.remaining as u32 / 60,
366            game.remaining as u32 % 60
367        ),
368        63.,
369        22.,
370        INK,
371    );
372    label("HULL", width - 113., 32., 14., INK);
373    for i in 0..3 {
374        draw_rectangle(
375            width - 111. + i as f32 * 24.,
376            43.,
377            17.,
378            5.,
379            if i < game.hull as usize {
380                INK
381            } else {
382                Color::new(0.3, 0.4, 0.4, 0.2)
383            },
384        );
385    }
386    let bottom = screen_height() - 25.;
387    label(
388        &format!("{} m", game.pos.y.max(0.) as u32),
389        28.,
390        bottom - 24.,
391        24.,
392        CREAM,
393    );
394    label(
395        &format!("{} kt", (game.speed * 1.94) as u32),
396        28.,
397        bottom,
398        16.,
399        CREAM,
400    );
401    if game.next == 5 && game.outcome == Outcome::Flying {
402        centered("Slow pass over the landing cross to deliver", 98., 20., INK);
403        centered("Hold SHIFT / touch right side", 121., 16., INK);
404    } else if game.outcome == Outcome::Flying {
405        centered("Fly through the amber beacons", 98., 18., INK);
406    }
407    if game.recovery > 0. {
408        centered("RECOVERED AT LAST BEACON  /  -5 seconds", 148., 17., CORAL);
409    }
410    if touch {
411        draw_circle_lines(90., screen_height() - 100., 60., 2., CREAM);
412        draw_line(
413            75.,
414            screen_height() - 100.,
415            105.,
416            screen_height() - 100.,
417            1.,
418            CREAM,
419        );
420        draw_line(
421            90.,
422            screen_height() - 115.,
423            90.,
424            screen_height() - 85.,
425            1.,
426            CREAM,
427        );
428        label("SLOW", width - 100., screen_height() - 90., 19., CREAM);
429    } else {
430        centered(
431            "A D  turn    W S  climb / dive    SPACE  boost    SHIFT  slow    R  restart",
432            bottom,
433            16.,
434            CREAM,
435        );
436    }
437    if game.outcome != Outcome::Flying {
438        let y = screen_height() * 0.45;
439        draw_rectangle(0., y - 65., width, 165., Color::new(0.08, 0.17, 0.19, 0.9));
440        centered(
441            if game.outcome == Outcome::Delivered {
442                "MAIL DELIVERED"
443            } else {
444                "FLIGHT ENDED"
445            },
446            y,
447            38.,
448            CREAM,
449        );
450        centered(
451            if game.outcome == Outcome::Delivered {
452                "The north coast is connected again."
453            } else if game.hull == 0 {
454                "Your aircraft needs repairs."
455            } else {
456                "The delivery window closed."
457            },
458            y + 35.,
459            20.,
460            CREAM,
461        );
462        centered("Press R or tap to fly again", y + 71., 18., CREAM);
463    }
464}
465fn conf() -> Conf {
466    ferravex_render::window_conf("Sky Courier — Ferravex")
467}
468ferravex_render::game!(conf, game);
469async fn game() {
470    let mut demo = Demo::new();
471    let mut flight = Flight::new();
472    let mut clock = FixedClock::new(120., 12);
473    let scene = Scene::new();
474    let material = LitMaterial::new().expect("portable flight material");
475    let mut touch_mode = false;
476    let mut reported = false;
477    loop {
478        ferravex_render::ui::warm_up_font_sizes(&[13, 14, 16, 17, 18, 19, 20, 22, 24, 26, 38]);
479        touch_mode |= !ferravex_render::input::logical_touches().is_empty();
480        if is_key_pressed(KeyCode::R)
481            || (flight.outcome != Outcome::Flying
482                && ferravex_render::input::logical_touches()
483                    .iter()
484                    .any(|t| t.phase == TouchPhase::Started))
485        {
486            flight = Flight::new();
487            clock.reset();
488            reported = false;
489        }
490        let steps = clock.advance(demo.dt() as f64);
491        let human = human_input();
492        for _ in 0..steps.steps {
493            let input = if demo.autoplay {
494                flight.autopilot()
495            } else {
496                human
497            };
498            flight.tick(input, steps.dt);
499        }
500        let forward = vec3(flight.yaw.sin(), 0., flight.yaw.cos());
501        let camera_pos = flight.pos - forward * 24. + Vec3::Y * 10.;
502        clear_background(SKY);
503        set_camera(&Camera3D {
504            position: camera_pos,
505            target: flight.pos + forward * 20.,
506            up: Vec3::Y,
507            fovy: 55f32.to_radians(),
508            z_near: 0.2,
509            z_far: 650.,
510            ..Default::default()
511        });
512        material.begin(LitSettings {
513            camera_position: camera_pos,
514            fog_color: SKY,
515            fog_start: 160.,
516            fog_end: 500.,
517            ambient: 0.65,
518            ..Default::default()
519        });
520        scene.draw(&flight, demo.time());
521        material.end();
522        set_default_camera();
523        hud(&flight, touch_mode);
524        if demo.autoplay && flight.outcome != Outcome::Flying && !reported {
525            println!(
526                "Sky Courier autoplay: {:?}; beacons={}; hull={}; remaining={:.1}s",
527                flight.outcome, flight.next, flight.hull, flight.remaining
528            );
529            reported = true;
530        }
531        if demo.finish_frame() {
532            break;
533        }
534        next_frame().await;
535    }
536}
537
538#[cfg(test)]
539mod tests {
540    use super::*;
541    #[test]
542    fn ordinary_flight_inputs_complete_the_route_and_delivery() {
543        let mut game = Flight::new();
544        for _ in 0..120 * 85 {
545            game.tick(game.autopilot(), 1. / 120.);
546        }
547        assert_eq!(game.outcome, Outcome::Delivered);
548        assert_eq!(game.next, 6);
549        assert_eq!(game.hull, 3);
550        assert!(game.remaining > 0.);
551    }
552    #[test]
553    fn beacons_must_be_visited_in_order() {
554        let mut game = Flight::new();
555        game.pos = ROUTE[3];
556        game.tick(Input::default(), 1. / 120.);
557        assert_eq!(game.next, 0);
558        game.pos = ROUTE[0];
559        game.tick(Input::default(), 1. / 120.);
560        assert_eq!(game.next, 1);
561    }
562    #[test]
563    fn damage_recovers_and_three_crashes_end_the_flight() {
564        let mut game = Flight::new();
565        for hull in [2, 1, 0] {
566            game.recovery = 0.;
567            game.pos.y = -21.;
568            game.tick(Input::default(), 1. / 120.);
569            assert_eq!(game.hull, hull);
570            if hull > 0 {
571                assert_eq!(game.pos, vec3(0., 20., 0.));
572            }
573        }
574        assert_eq!(game.outcome, Outcome::Lost);
575        assert!(island_hit(ISLANDS[0].0));
576        assert!(!island_hit(ISLANDS[0].0 + Vec3::Y * 30.));
577    }
578    #[test]
579    fn timeout_freezes_and_restart_restores_game() {
580        let mut game = Flight::new();
581        game.remaining = 0.001;
582        game.tick(Input::default(), 0.02);
583        assert_eq!(game.outcome, Outcome::Lost);
584        let pos = game.pos;
585        game.tick(Input::default(), 1.);
586        assert_eq!(game.pos, pos);
587        game = Flight::new();
588        assert_eq!(game.next, 0);
589        assert_eq!(game.hull, 3);
590        assert_eq!(game.outcome, Outcome::Flying);
591    }
592    #[test]
593    fn cargo_requires_a_slow_pass_and_swept_capture_handles_fast_motion() {
594        let mut game = Flight::new();
595        game.next = 5;
596        game.pos = ROUTE[5];
597        game.speed = 30.;
598        game.tick(Input::default(), 1. / 120.);
599        assert_eq!(game.next, 5);
600        game.speed = 13.;
601        game.tick(
602            Input {
603                slow: true,
604                ..Input::default()
605            },
606            1. / 120.,
607        );
608        assert_eq!(game.outcome, Outcome::Delivered);
609        assert!(segment_hits(Vec3::ZERO, Vec3::Z * 20., Vec3::Z * 10., 1.));
610    }
611}