1mod race;
3
4use ferravex_core::FixedClock;
5use ferravex_render::{
6 demo::Demo,
7 material::{MaterialOptions, ShaderMaterial, ShaderSource},
8 mesh::Mesh3d,
9 prelude::*,
10 shaders::{LitMaterial, LitSettings},
11 ui,
12 visibility::{LodChain, LodState},
13};
14use race::{
15 Course, GATES, HALF_WIDTH, HOVER_HEIGHT, Input, LAPS, Race, heading, path, right, tangent,
16 world,
17};
18use std::f32::consts::{PI, TAU};
19
20const SKY: Color = Color::from_rgba(181, 201, 195, 255);
21const SAND: Color = Color::from_rgba(194, 154, 106, 255);
22const STONE: Color = Color::from_rgba(170, 116, 74, 255);
23const PALE: Color = Color::from_rgba(238, 219, 179, 255);
24const TEAL: Color = Color::from_rgba(56, 159, 151, 255);
25const DARK: Color = Color::from_rgba(35, 55, 55, 255);
26const ORANGE: Color = Color::from_rgba(239, 125, 58, 255);
27
28fn conf() -> Conf {
29 let mut conf = ferravex_render::window_conf("Dune Skimmer / Ferravex");
30 conf.window_width = 1280;
31 conf.window_height = 800;
32 conf
33}
34
35struct Prop {
36 mesh: usize,
37 position: Vec3,
38 rotation: Quat,
39}
40struct Scene {
41 exhaust: ShaderMaterial,
42 ground: Mesh3d,
43 road: Mesh3d,
44 curbs: Mesh3d,
45 props: Vec<Prop>,
46 prop_meshes: Vec<Mesh3d>,
47 rock_lods: Vec<LodChain>,
48 rock_lod_states: std::cell::RefCell<Vec<LodState>>,
49 pillar: Mesh3d,
50 lintel: Mesh3d,
51 cap: Mesh3d,
52 marker: Mesh3d,
53 active_marker: Mesh3d,
54 arrow: Mesh3d,
55 hull: Mesh3d,
56 nose: Mesh3d,
57 cockpit: Mesh3d,
58 rail: Mesh3d,
59 engine: Mesh3d,
60 glow: Mesh3d,
61 stripe: Mesh3d,
62 shadow: Mesh3d,
63 dust: Mesh3d,
64 sun: Mesh3d,
65}
66
67fn ribbon(inner: f32, outer: f32, height: f32, colors: [Color; 2]) -> Mesh3d {
69 let mut vertices = Vec::with_capacity(192 * 4);
70 let mut indices = Vec::with_capacity(192 * 6);
71 for i in 0..192 {
72 let a = i as f32 / 192.0 * TAU;
73 let b = (i + 1) as f32 / 192.0 * TAU;
74 let color = colors[(i / 3) % 2];
75 let base = vertices.len() as u16;
76 for (t, side) in [(a, inner), (a, outer), (b, outer), (b, inner)] {
77 let mut vertex = Vertex::new2(
78 world(path(t) + right(tangent(t)) * side, height),
79 Vec2::ZERO,
80 color,
81 );
82 vertex.normal = Vec3::Y.extend(0.0);
83 vertices.push(vertex);
84 }
85 indices.extend([base, base + 1, base + 2, base, base + 2, base + 3]);
86 }
87 Mesh3d::from_mesh(Mesh {
88 vertices,
89 indices,
90 texture: None,
91 })
92}
93
94fn merge_meshes(first: Mesh3d, second: Mesh3d) -> Mesh3d {
95 let mut mesh = first.mesh;
96 let offset = mesh.vertices.len() as u16;
97 mesh.vertices.extend(second.mesh.vertices);
98 mesh.indices
99 .extend(second.mesh.indices.into_iter().map(|i| i + offset));
100 Mesh3d::from_mesh(mesh)
101}
102
103impl Scene {
104 fn new() -> Self {
105 let mut props = Vec::new();
106 let prop_meshes = vec![
109 Mesh3d::frustum(16.0, 7.0, 15.0, STONE, 6),
110 Mesh3d::frustum(11.0, 6.0, 8.0, Color::from_rgba(193, 139, 88, 255), 5),
111 Mesh3d::cone(21.0, 7.0, SAND, 7),
112 Mesh3d::cuboid(vec3(2.8, 7.0, 2.8), STONE),
113 Mesh3d::cuboid(vec3(9.0, 1.8, 2.8), PALE),
114 Mesh3d::frustum(3.0, 1.8, 3.0, Color::from_rgba(158, 107, 69, 255), 5),
115 ];
116 for i in 0..33 {
117 let angle = i as f32 / 33.0 * TAU;
118 let radius = 100.0 + (i % 4) as f32 * 10.0;
119 let mesh = i % 3;
120 let height = [7.1, 3.6, 3.1][mesh];
121 props.push(Prop {
122 mesh,
123 position: vec3(angle.cos() * radius, height, angle.sin() * radius),
124 rotation: Quat::from_rotation_y(angle),
125 });
126 }
127 for i in 0..12 {
128 let angle = (i as f32 + 0.3) / 12.0 * TAU;
129 let p = path(angle) - right(tangent(angle)) * (18.0 + (i % 3) as f32 * 2.0);
130 props.push(Prop {
131 mesh: 3,
132 position: world(p, 3.3),
133 rotation: Quat::from_rotation_y(angle),
134 });
135 if i % 3 == 0 {
136 props.push(Prop {
137 mesh: 3,
138 position: world(p + tangent(angle) * 6.0, 3.3),
139 rotation: Quat::from_rotation_y(angle),
140 });
141 props.push(Prop {
142 mesh: 4,
143 position: world(p + tangent(angle) * 3.0, 7.0),
144 rotation: Quat::from_rotation_y(heading(tangent(angle)) + PI * 0.5),
145 });
146 }
147 }
148 for i in 0..25 {
149 let angle = i as f32 * 2.399_963;
150 let radius = 11.0 + (i % 6) as f32 * 2.5;
151 props.push(Prop {
152 mesh: if i % 4 == 0 { 1 } else { 5 },
153 position: vec3(
154 angle.cos() * radius,
155 if i % 4 == 0 { 3.6 } else { 1.3 },
156 angle.sin() * radius * 0.7,
157 ),
158 rotation: Quat::from_rotation_y(angle),
159 });
160 }
161 Self {
162 exhaust: ShaderMaterial::new(
163 ShaderSource {
164 vertex: include_str!("../shaders/exhaust.vert").into(),
165 fragment: include_str!("../shaders/exhaust.frag").into(),
166 },
167 MaterialOptions {
168 blend: ferravex_render::material::BlendMode::Opaque,
169 bounds_padding: 0.1,
170 casts_shadows: false,
171 receives_shadows: false,
172 },
173 )
174 .expect("exhaust material"),
175 ground: Mesh3d::plane(vec2(440.0, 440.0), SAND, 22),
176 road: ribbon(
177 -HALF_WIDTH,
178 HALF_WIDTH,
179 0.012,
180 [
181 Color::from_rgba(170, 140, 101, 255),
182 Color::from_rgba(174, 143, 104, 255),
183 ],
184 ),
185 curbs: merge_meshes(
186 ribbon(-HALF_WIDTH - 0.30, -HALF_WIDTH, 0.055, [PALE, STONE]),
187 ribbon(HALF_WIDTH, HALF_WIDTH + 0.30, 0.055, [PALE, STONE]),
188 ),
189 props,
190 prop_meshes,
191 rock_lods: (0..3)
192 .map(|i| {
193 let radius = 1.1 + i as f32 * 0.24;
194 LodChain::new(
195 vec![
196 Mesh3d::sphere(radius, STONE, 6, 4).resident().clone(),
197 Mesh3d::sphere(radius, STONE, 4, 3).resident().clone(),
198 Mesh3d::sphere(radius, STONE, 3, 2).resident().clone(),
199 ],
200 vec![35., 75.],
201 0.12,
202 )
203 .unwrap()
204 })
205 .collect(),
206 rock_lod_states: Default::default(),
207 pillar: Mesh3d::cuboid(vec3(0.75, 7.6, 1.15), STONE),
208 lintel: Mesh3d::cuboid(vec3(HALF_WIDTH * 2.0 + 1.5, 0.7, 1.15), PALE),
209 cap: Mesh3d::cuboid(vec3(1.1, 0.5, 1.5), PALE),
210 marker: Mesh3d::cuboid(vec3(0.8, 1.0, 1.2), ORANGE),
211 active_marker: Mesh3d::cuboid(vec3(0.82, 1.0, 1.22), TEAL),
212 arrow: Mesh3d::cone(0.65, 1.1, TEAL, 3),
213 hull: Mesh3d::cuboid(vec3(1.8, 0.42, 3.2), TEAL),
214 nose: Mesh3d::cone(1.05, 2.3, TEAL, 4),
215 cockpit: Mesh3d::cuboid(vec3(1.05, 0.46, 1.3), DARK),
216 rail: Mesh3d::cuboid(vec3(0.3, 0.35, 3.8), PALE),
217 engine: Mesh3d::cylinder(0.42, 1.5, DARK, 8),
218 glow: Mesh3d::cone(0.29, 1.25, ORANGE, 6),
219 stripe: Mesh3d::cuboid(vec3(0.18, 0.015, 2.6), ORANGE),
220 shadow: Mesh3d::cylinder(1.0, 0.006, Color::from_rgba(114, 100, 77, 255), 8),
221 dust: Mesh3d::cuboid(vec3(0.22, 0.22, 0.22), PALE),
222 sun: Mesh3d::sphere(6.0, Color::from_rgba(255, 226, 166, 255), 12, 8),
223 }
224 }
225 fn draw(&self, course: &Course, race: &Race, position: Vec2, yaw: f32, time: f32) {
226 use ferravex_render::lighting::{PointLight, SunShadow};
227 ferravex_render::set_sun_shadow(Some(SunShadow {
228 center: world(position, 0.),
229 direction: vec3(-0.45, 0.9, -0.4),
230 extent: 38.,
231 depth: 100.,
232 ..Default::default()
233 }))
234 .unwrap();
235 let engine_light = if race.speed() > 1. { 1.4 } else { 0. };
236 ferravex_render::set_point_lights(&[PointLight {
237 position: world(position - race.forward() * 1.8, HOVER_HEIGHT),
238 color: ORANGE,
239 radius: 5.,
240 intensity: engine_light,
241 }])
242 .unwrap();
243
244 self.ground.draw(vec3(0.0, -0.03, 0.0), Quat::IDENTITY);
245 self.road.draw(Vec3::ZERO, Quat::IDENTITY);
246 self.curbs.draw(Vec3::ZERO, Quat::IDENTITY);
247 self.sun.draw(vec3(-95.0, 75.0, -130.0), Quat::IDENTITY);
248 for prop in &self.props {
249 self.prop_meshes[prop.mesh].draw(prop.position, prop.rotation);
250 }
251 {
252 let mut states = self.rock_lod_states.borrow_mut();
253 states.resize(course.rocks.len(), LodState::default());
254 for (index, &(p, _)) in course.rocks.iter().enumerate() {
255 ferravex_render::draw_lod(
256 &self.rock_lods[index % 3],
257 Mat4::from_rotation_translation(
258 Quat::from_rotation_y(index as f32 * 1.7),
259 world(p, 0.65),
260 ),
261 &mut states[index],
262 );
263 }
264 }
265 for (index, gate) in course.gates.iter().enumerate() {
266 let rotation = Quat::from_rotation_y(heading(gate.direction));
267 for side in [-1.0, 1.0] {
268 let p = gate.position + right(gate.direction) * (HALF_WIDTH + 0.4) * side;
269 self.pillar.draw(world(p, 3.7), rotation);
270 self.cap.draw(world(p, 0.4), rotation);
271 if index == race.next_gate {
272 self.active_marker.draw(world(p, 4.7), rotation);
273 } else {
274 self.marker.draw(world(p, 4.7), rotation);
275 }
276 }
277 self.lintel.draw(world(gate.position, 7.55), rotation);
278 if index == race.next_gate && !race.finished {
279 self.arrow.draw(
280 world(gate.position, 4.7 + time.sin() * 0.18),
281 Quat::from_rotation_z(PI),
282 );
283 }
284 }
285 let forward = vec2(yaw.sin(), yaw.cos());
286 let hover = HOVER_HEIGHT + (time * 5.0).sin() * 0.07;
287 let center = world(position, hover);
288 let rotation = Quat::from_rotation_y(yaw) * Quat::from_rotation_z(race.bank);
289 let draw = |mesh: &Mesh3d, offset: Vec3, local: Quat| {
290 mesh.draw(center + rotation * offset, rotation * local);
291 };
292 if ferravex_render::quality() == ferravex_render::lighting::Quality::Low {
293 for z in [-0.8, 0.8] {
294 self.shadow
295 .draw(world(position + forward * z, 0.022), Quat::IDENTITY);
296 }
297 }
298 draw(&self.hull, Vec3::ZERO, Quat::IDENTITY);
299 draw(
300 &self.nose,
301 vec3(0.0, -0.03, 1.9),
302 Quat::from_rotation_x(PI * 0.5),
303 );
304 draw(
305 &self.cockpit,
306 vec3(0.0, 0.40, -0.3),
307 Quat::from_rotation_x(-0.08),
308 );
309 draw(&self.stripe, vec3(0.0, 0.22, 0.1), Quat::IDENTITY);
310 for x in [-1.12, 1.12] {
311 draw(&self.rail, vec3(x, -0.14, -0.1), Quat::IDENTITY);
312 draw(
313 &self.engine,
314 vec3(x, -0.05, -1.2),
315 Quat::from_rotation_x(PI * 0.5),
316 );
317 if race.speed() > 1.0 {
318 self.exhaust
319 .begin([[time, 0., 0., 0.], [0.; 4], [0.; 4], [0.; 4]])
320 .unwrap();
321 draw(
322 &self.glow,
323 vec3(x, -0.05, -2.0 - if race.boosting { 0.45 } else { 0.0 }),
324 Quat::from_rotation_x(-PI * 0.5),
325 );
326 self.exhaust.end();
327 }
328 }
329 if race.speed() > 3.0 && !race.finished {
331 for i in 0..22 {
332 let age = (time * 1.7 + i as f32 / 22.0).fract();
333 let side = (i as f32 * 2.39).sin() * (0.5 + age * 1.6);
334 let p =
335 position - forward * (2.5 + age * race.speed() * 0.18) + right(forward) * side;
336 self.dust.draw(
337 world(p, 0.25 + age * 0.8),
338 Quat::from_rotation_y(i as f32 + time),
339 );
340 }
341 }
342 }
343}
344
345fn label(text: &str, x: f32, y: f32, size: f32, color: Color) {
346 draw_text(
347 text,
348 x + 1.0,
349 y + 2.0,
350 size,
351 Color::new(0.1, 0.14, 0.13, 0.5),
352 );
353 draw_text(text, x, y, size, color);
354}
355fn centered(text: &str, y: f32, size: u16, color: Color) {
356 let width = measure_text(text, None, size, 1.0).width;
357 label(text, (screen_width() - width) * 0.5, y, size as f32, color);
358}
359fn time_label(time: f32) -> String {
360 format!("{:02}:{:05.2}", (time / 60.0) as u32, time % 60.0)
361}
362fn draw_hud(race: &Race, touch_mode: bool) {
363 let w = screen_width();
364 let h = screen_height();
365 let margin = (w * 0.035).clamp(18.0, 44.0);
366 label("DUNE SKIMMER", margin, 39.0, 22.0, PALE);
367 label(
368 &format!("LAP {:02} / 03", (race.laps + 1).min(LAPS)),
369 margin,
370 66.0,
371 18.0,
372 WHITE,
373 );
374 let timer = time_label(race.elapsed);
375 let width = measure_text(&timer, None, 28, 1.0).width;
376 label(
377 &timer,
378 (w - width) * 0.5,
379 if w < 600.0 { 97.0 } else { 42.0 },
380 28.0,
381 WHITE,
382 );
383 let bottom = if touch_mode { h - 140.0 } else { h - 50.0 };
384 label(
385 &format!("{:03}", (race.speed() * 3.6) as u32),
386 margin,
387 bottom,
388 47.0,
389 WHITE,
390 );
391 label("KM/H", margin + 91.0, bottom, 14.0, PALE);
392 let bar_w = 136.0;
393 draw_rectangle(
394 w - margin - bar_w,
395 bottom - 16.0,
396 bar_w,
397 4.0,
398 Color::new(0.1, 0.2, 0.19, 0.45),
399 );
400 draw_rectangle(
401 w - margin - bar_w,
402 bottom - 16.0,
403 bar_w * race.boost,
404 4.0,
405 if race.boosting { ORANGE } else { TEAL },
406 );
407 label("BOOST", w - margin - bar_w, bottom + 8.0, 13.0, PALE);
408 if race.countdown > 0.0 {
409 centered("THREE LAPS THROUGH THE RUINS", h * 0.34, 20, PALE);
410 centered(
411 &format!("{}", race.countdown.ceil() as u32),
412 h * 0.48,
413 74,
414 WHITE,
415 );
416 } else if race.elapsed < 0.8 {
417 centered("GO", h * 0.42, 54, TEAL);
418 }
419 if race.off_track && !race.finished {
420 centered("LOOSE SAND", h - 27.0, 16, PALE);
421 } else if race.elapsed < 10.0 && !touch_mode {
422 centered(
423 "WASD / ARROWS steer & accelerate SPACE boost R restart",
424 h - 19.0,
425 16,
426 PALE,
427 );
428 }
429 if race.last_gate_timer > 0.0 && race.countdown == 0.0 && !race.finished {
430 let text = if race.next_gate == 1 {
431 "LAP COMPLETE".to_owned()
432 } else {
433 format!(
434 "GATE {:02} / {GATES}",
435 if race.next_gate == 0 {
436 GATES
437 } else {
438 race.next_gate
439 }
440 )
441 };
442 centered(&text, 67.0, 17, PALE);
443 }
444 if race.finished {
445 let panel = Rect::new(w * 0.5 - 180.0, h * 0.5 - 113.0, 360.0, 220.0);
446 draw_rectangle(
447 panel.x,
448 panel.y,
449 panel.w,
450 panel.h,
451 Color::new(0.10, 0.19, 0.18, 0.94),
452 );
453 centered("CIRCUIT COMPLETE", h * 0.5 - 65.0, 22, PALE);
454 centered(&time_label(race.elapsed), h * 0.5 - 6.0, 49, WHITE);
455 centered(
456 &format!("3 laps / {} impacts", race.impacts),
457 h * 0.5 + 28.0,
458 18,
459 PALE,
460 );
461 centered("R / TAP TO RACE AGAIN", h * 0.5 + 76.0, 16, TEAL);
462 }
463}
464fn touch_controls() -> [Rect; 4] {
465 let h = screen_height();
466 let w = screen_width();
467 let size = (w * 0.14).clamp(48.0, 72.0);
468 [
469 Rect::new(18.0, h - size - 20.0, size, size),
470 Rect::new(size + 32.0, h - size - 20.0, size, size),
471 Rect::new(w - size * 2.0 - 32.0, h - size - 20.0, size, size),
472 Rect::new(w - size - 18.0, h - size - 20.0, size, size),
473 ]
474}
475fn read_input(touch_mode: bool) -> Input {
476 let mut input = Input {
477 throttle: f32::from(is_key_down(KeyCode::W) || is_key_down(KeyCode::Up))
478 - f32::from(is_key_down(KeyCode::S) || is_key_down(KeyCode::Down)),
479 steer: f32::from(is_key_down(KeyCode::D) || is_key_down(KeyCode::Right))
480 - f32::from(is_key_down(KeyCode::A) || is_key_down(KeyCode::Left)),
481 boost: is_key_down(KeyCode::Space),
482 };
483 if touch_mode {
484 let rects = touch_controls();
485 for touch in ferravex_render::input::logical_touches()
486 .iter()
487 .filter(|touch| {
488 touch.phase != TouchPhase::Ended && touch.phase != TouchPhase::Cancelled
489 })
490 {
491 if rects[0].contains(touch.position) {
492 input.steer = -1.0;
493 }
494 if rects[1].contains(touch.position) {
495 input.steer = 1.0;
496 }
497 if rects[2].contains(touch.position) {
498 input.throttle = 1.0;
499 }
500 if rects[3].contains(touch.position) {
501 input.boost = true;
502 input.throttle = 1.0;
503 }
504 }
505 }
506 input
507}
508
509ferravex_render::game!(conf, game);
510async fn game() {
511 let course = Course::new();
512 let scene = Scene::new();
513 let material = LitMaterial::new().expect("Ferravex's portable lit material should compile");
514 let mut race = Race::new();
515 let mut clock = FixedClock::new(120.0, 16);
516 let mut demo = Demo::new();
517 ferravex_render::set_quality(ferravex_render::lighting::Quality::Medium);
518 let mut touch_mode = false;
519 let mut fovy = 51.0_f32;
520 let mut camera_position = world(race.position - race.forward() * 11.0, 5.1);
521 let mut camera_target = world(race.position + race.forward() * 5.0, 1.8);
522 loop {
523 ferravex_render::ui::warm_up_font_sizes(&[13, 14, 16, 17, 18, 20, 22, 28, 47, 49, 54, 74]);
524 ui::warm_up_font();
525 touch_mode |= !ferravex_render::input::logical_touches().is_empty();
526 if is_key_pressed(KeyCode::R)
527 || (race.finished
528 && (is_mouse_button_pressed(MouseButton::Left)
529 || ferravex_render::input::logical_touches()
530 .iter()
531 .any(|t| t.phase == TouchPhase::Started)))
532 {
533 race = Race::new();
534 clock.reset();
535 camera_position = world(race.position - race.forward() * 11.0, 5.1);
536 camera_target = world(race.position + race.forward() * 5.0, 1.8);
537 }
538 let input = read_input(touch_mode);
539 let dt = demo.dt();
540 let frame = clock.advance(dt as f64);
541 for _ in 0..frame.steps {
542 race.step(
543 &course,
544 if demo.autoplay {
545 race.autoplay(&course)
546 } else {
547 input
548 },
549 frame.dt,
550 );
551 }
552 let position = race.previous_position.lerp(race.position, frame.alpha);
553 let yaw =
554 race.previous_yaw + race::angle_difference(race.yaw, race.previous_yaw) * frame.alpha;
555 let forward = vec2(yaw.sin(), yaw.cos());
556 let eye = world(
557 position - forward * (10.7 + race.speed() * 0.035),
558 4.8 + race.speed() * 0.015,
559 );
560 let target = world(position + forward * 6.0, 1.6);
561 camera_position = camera_position.lerp(eye, 1.0 - (-dt * 5.5).exp());
562 camera_target = camera_target.lerp(target, 1.0 - (-dt * 8.0).exp());
563 fovy += ((if race.boosting { 54.0 } else { 51.0 }) - fovy) * (dt * 5.0).min(1.0);
564 clear_background(SKY);
565 set_camera(&Camera3D {
566 position: camera_position,
567 target: camera_target,
568 up: Vec3::Y,
569 fovy: fovy.to_radians(),
570 z_near: 0.12,
571 z_far: 370.0,
572 ..Default::default()
573 });
574 material.begin(LitSettings {
575 camera_position,
576 light_direction: vec3(-0.45, 0.9, -0.4),
577 fog_color: SKY,
578 fog_start: 75.0,
579 fog_end: 245.0,
580 ambient: 0.62,
581 });
582 scene.draw(&course, &race, position, yaw, demo.time());
583 material.end();
584 set_default_camera();
585 draw_hud(&race, touch_mode);
586 if touch_mode && !race.finished {
587 for (rect, text) in touch_controls().into_iter().zip(["<", ">", "GO", "BOOST"]) {
588 draw_rectangle(
589 rect.x,
590 rect.y,
591 rect.w,
592 rect.h,
593 Color::new(0.1, 0.19, 0.18, 0.50),
594 );
595 let text_width = measure_text(text, None, 18, 1.0).width;
596 label(
597 text,
598 rect.x + (rect.w - text_width) * 0.5,
599 rect.y + rect.h * 0.5 + 6.0,
600 18.0,
601 PALE,
602 );
603 }
604 }
605 if demo.finish_frame() {
606 println!(
607 "DUNE SKIMMER laps={} next_gate={} time={:.2} finished={} impacts={} recoveries={} finite={}",
608 race.laps,
609 race.next_gate,
610 race.elapsed,
611 race.finished,
612 race.impacts,
613 race.recovery_count,
614 race.position.is_finite()
615 );
616 break;
617 }
618 next_frame().await;
619 }
620}