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 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 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}