1use ferravex_core::FixedClock;
3use ferravex_physics::character::{
4 Character, CharacterInput, CharacterSettings, CharacterShape, Obstacle, QueryBounds, Terrain,
5};
6use ferravex_render::{demo::Demo, prelude::*};
7
8const WIDTH: f32 = 1280.0;
9const HEIGHT: f32 = 720.0;
10const WORLD_END: f32 = 2700.0;
11const PLAYER: Vec2 = Vec2::new(24.0, 40.0);
12const SPEED: f32 = 280.0;
13const GRAVITY: f32 = 1600.0;
14const JUMP: f32 = 660.0;
15const COYOTE: f32 = 0.10;
16const BUFFER: f32 = 0.12;
17const ORANGE: Color = Color::new(1.0, 0.54, 0.25, 1.0);
18const CREAM: Color = Color::new(0.96, 0.88, 0.72, 1.0);
19const GREEN: Color = Color::new(0.48, 0.68, 0.48, 1.0);
20
21#[derive(Default, Clone, Copy)]
22struct Input {
23 direction: f32,
24 jump: bool,
25}
26
27struct Game {
28 motor: Character,
29 platforms: Vec<Rect>,
30 hazards: Vec<Rect>,
31 embers: Vec<(Vec2, bool)>,
32 checkpoint: Vec2,
33 checkpoint_lit: bool,
34 won: bool,
35 deaths: u32,
36 jumps: u32,
37 elapsed: f32,
38 respawn_flash: f32,
39}
40
41impl Game {
42 fn new() -> Self {
43 Self {
44 motor: Character::new(feet(vec2(90.0, 520.0))),
45 platforms: vec![
46 Rect::new(-100.0, 560.0, 680.0, 250.0),
47 Rect::new(700.0, 560.0, 500.0, 250.0),
48 Rect::new(1320.0, 560.0, 500.0, 250.0),
49 Rect::new(1940.0, 560.0, 850.0, 250.0),
50 Rect::new(140.0, 445.0, 80.0, 18.0),
51 Rect::new(770.0, 435.0, 130.0, 18.0),
52 Rect::new(1350.0, 430.0, 120.0, 18.0),
53 Rect::new(2010.0, 430.0, 120.0, 18.0),
54 ],
55 hazards: vec![
56 Rect::new(350.0, 542.0, 44.0, 18.0),
57 Rect::new(1040.0, 542.0, 56.0, 18.0),
58 Rect::new(1600.0, 542.0, 52.0, 18.0),
59 Rect::new(2230.0, 542.0, 56.0, 18.0),
60 ],
61 embers: [
62 (180.0, 406.0),
63 (375.0, 455.0),
64 (640.0, 470.0),
65 (835.0, 395.0),
66 (1070.0, 455.0),
67 (1260.0, 470.0),
68 (1410.0, 390.0),
69 (1880.0, 470.0),
70 (2070.0, 390.0),
71 (2430.0, 520.0),
72 ]
73 .into_iter()
74 .map(|(x, y)| (vec2(x, y), false))
75 .collect(),
76 checkpoint: vec2(90.0, 520.0),
77 checkpoint_lit: false,
78 won: false,
79 deaths: 0,
80 jumps: 0,
81 elapsed: 0.0,
82 respawn_flash: 0.0,
83 }
84 }
85
86 fn position(&self) -> Vec2 {
87 vec2(
88 self.motor.position.x - PLAYER.x * 0.5,
89 -self.motor.position.y - PLAYER.y,
90 )
91 }
92 fn velocity(&self) -> Vec2 {
93 vec2(self.motor.velocity.x, -self.motor.velocity.y)
94 }
95 fn body(&self) -> Rect {
96 Rect::new(self.position().x, self.position().y, PLAYER.x, PLAYER.y)
97 }
98
99 fn respawn(&mut self) {
100 self.motor = Character::new(feet(self.checkpoint));
101 self.deaths += 1;
102 self.respawn_flash = 0.6;
103 }
104
105 fn update(&mut self, input: Input, dt: f32) {
106 if self.won {
107 return;
108 }
109 self.elapsed += dt;
110 self.respawn_flash = (self.respawn_flash - dt).max(0.0);
111 self.platforms[5].x = 770.0 + (self.elapsed * 1.2).sin() * 28.0;
113 let result = self
114 .motor
115 .step(
116 &EmberTerrain(&self.platforms),
117 CharacterShape::Box2 { half: PLAYER * 0.5 },
118 CharacterSettings {
119 speed: SPEED,
120 gravity: GRAVITY,
121 jump_speed: JUMP,
122 terminal_speed: 1000.,
123 ground_acceleration: 2400.,
124 air_acceleration: 1600.,
125 coyote_time: COYOTE,
126 jump_buffer: BUFFER,
127 ground_snap: 0.1,
128 step_height: 0.,
129 max_recovery: 24.,
130 ..Default::default()
131 },
132 CharacterInput {
133 direction: vec3(input.direction, 0., 0.),
134 jump: input.jump,
135 },
136 dt,
137 )
138 .expect("authored Emberline character terrain");
139 self.jumps += u32::from(result.jumped);
140 self.motor.position.x = self
141 .motor
142 .position
143 .x
144 .clamp(PLAYER.x * 0.5, WORLD_END - PLAYER.x * 0.5);
145 if self.position().y > 780.0 || self.hazards.iter().any(|h| self.body().overlaps(h)) {
146 self.respawn();
147 return;
148 }
149 let body = self.body();
150 for (position, collected) in &mut self.embers {
151 if body.overlaps(&Rect::new(position.x - 12.0, position.y - 12.0, 24.0, 24.0)) {
152 *collected = true;
153 }
154 }
155 if self.position().x > 1380.0 && self.position().x < 1490.0 && self.motor.grounded {
156 self.checkpoint_lit = true;
157 self.checkpoint = vec2(1410.0, 520.0);
158 }
159 if self.position().x > 2560.0 && self.motor.grounded {
160 self.won = true;
161 }
162 }
163
164 fn autoplay(&self) -> Input {
165 let front = self.position().x + PLAYER.x;
166 let hazard = self
167 .hazards
168 .iter()
169 .any(|h| h.x >= front - 2.0 && h.x - front < 74.0);
170 let gap = self
171 .platforms
172 .iter()
173 .filter(|p| p.y == 560.0)
174 .any(|p| front > p.x && front <= p.right() && p.right() - front < 58.0);
175 Input {
176 direction: 1.0,
177 jump: self.motor.grounded && (hazard || gap),
178 }
179 }
180}
181
182fn feet(position: Vec2) -> Vec3 {
183 vec3(position.x + PLAYER.x * 0.5, -position.y - PLAYER.y, 0.)
184}
185struct EmberTerrain<'a>(&'a [Rect]);
186impl EmberTerrain<'_> {
187 fn obstacle_at(&self, i: usize) -> Obstacle {
188 let p = self.0[i];
189 Obstacle::cuboid(
190 i as u64,
191 vec3(p.x + p.w * 0.5, -p.y - p.h * 0.5, 0.),
192 vec3(p.w * 0.5, p.h * 0.5, 1.),
193 )
194 }
195}
196impl Terrain for EmberTerrain<'_> {
197 fn visit(&self, bounds: QueryBounds, visitor: &mut dyn FnMut(Obstacle)) {
198 for i in 0..self.0.len() {
199 let o = self.obstacle_at(i);
200 if bounds.intersects(o.bounds()) {
201 visitor(o);
202 }
203 }
204 }
205 fn obstacle(&self, id: u64) -> Option<Obstacle> {
206 ((id as usize) < self.0.len()).then(|| self.obstacle_at(id as usize))
207 }
208}
209
210fn diamond(position: Vec2, radius: f32, color: Color) {
211 draw_triangle(
212 position + vec2(0.0, -radius),
213 position + vec2(radius * 0.65, 0.0),
214 position + vec2(0.0, radius),
215 color,
216 );
217 draw_triangle(
218 position + vec2(0.0, -radius),
219 position + vec2(0.0, radius),
220 position - vec2(radius * 0.65, 0.0),
221 Color::new(color.r * 0.7, color.g * 0.7, color.b * 0.7, color.a),
222 );
223}
224
225fn tree(x: f32, y: f32, size: f32, color: Color) {
226 draw_rectangle(
227 x - size * 0.04,
228 y - size * 0.45,
229 size * 0.08,
230 size * 0.45,
231 color,
232 );
233 draw_triangle(
234 vec2(x, y - size),
235 vec2(x - size * 0.35, y - size * 0.2),
236 vec2(x + size * 0.3, y - size * 0.2),
237 color,
238 );
239 draw_triangle(
240 vec2(x, y - size * 1.2),
241 vec2(x - size * 0.25, y - size * 0.5),
242 vec2(x + size * 0.25, y - size * 0.5),
243 color,
244 );
245}
246
247fn world(game: &Game, camera: f32, time: f32) {
248 clear_background(Color::from_hex(0x242c2b));
249 draw_circle(
250 1040.0 - camera * 0.12,
251 160.0,
252 86.0,
253 Color::from_hex(0xa97043),
254 );
255 for i in 0..12 {
256 let x = i as f32 * 330.0 - camera * 0.22 - 140.0;
257 draw_triangle(
258 vec2(x, 510.0),
259 vec2(x + 210.0, 175.0 + (i % 3) as f32 * 50.0),
260 vec2(x + 450.0, 510.0),
261 Color::from_hex(0x34413b),
262 );
263 }
264 for i in 0..32 {
265 tree(
266 i as f32 * 130.0 - camera * 0.52,
267 570.0,
268 140.0 + (i % 4) as f32 * 40.0,
269 Color::from_hex(0x28352f),
270 );
271 }
272 for i in 0..8 {
273 let x = 800.0 + i as f32 * 290.0 - camera * 0.8;
274 draw_rectangle(x, 260.0, 12.0, 300.0, Color::from_hex(0x1e2926));
275 draw_line(x, 268.0, x + 280.0, 290.0, 4.0, Color::from_hex(0x1e2926));
276 draw_line(
277 x + 100.0,
278 275.0,
279 x + 100.0,
280 325.0,
281 2.0,
282 Color::from_hex(0x1e2926),
283 );
284 draw_circle(x + 100.0, 328.0, 4.0, ORANGE);
285 }
286 draw_rectangle(0.0, 645.0, WIDTH, 75.0, Color::from_hex(0x8c452c));
288 for i in 0..40 {
289 let x = (i as f32 * 119.0 + time * 12.0).rem_euclid(WIDTH);
290 let y = 675.0 - ((time * 25.0 + i as f32 * 39.0) % 180.0);
291 draw_circle(x, y, 1.5, Color::new(1.0, 0.52, 0.22, 0.45));
292 }
293 for p in &game.platforms {
294 let x = p.x - camera;
295 draw_rectangle(x, p.y, p.w, p.h, Color::from_hex(0x383c32));
296 draw_rectangle(x, p.y, p.w, 7.0, Color::from_hex(0x8d8962));
297 draw_rectangle(x, p.y + 7.0, p.w, 8.0, Color::from_hex(0x545942));
298 if p.h > 100.0 {
299 for i in 0..(p.w / 64.0) as usize {
300 let rx = x + i as f32 * 64.0;
301 draw_triangle(
302 vec2(rx, 598.0),
303 vec2(rx + 50.0, 585.0),
304 vec2(rx + 33.0, 684.0),
305 Color::from_hex(0x30372f),
306 );
307 }
308 } else {
309 draw_line(
310 x + 8.0,
311 p.y + p.h,
312 x + 8.0,
313 560.0,
314 3.0,
315 Color::from_hex(0x62634d),
316 );
317 draw_line(
318 x + p.w - 8.0,
319 p.y + p.h,
320 x + p.w - 8.0,
321 560.0,
322 3.0,
323 Color::from_hex(0x62634d),
324 );
325 }
326 }
327 for h in &game.hazards {
328 for i in 0..4 {
329 let x = h.x - camera + i as f32 * h.w / 4.0;
330 draw_triangle(
331 vec2(x, h.bottom()),
332 vec2(x + h.w / 8.0, h.y),
333 vec2(x + h.w / 4.0, h.bottom()),
334 ORANGE,
335 );
336 }
337 }
338 for (position, collected) in &game.embers {
339 if !collected {
340 diamond(
341 *position - vec2(camera, 0.0) + vec2(0.0, (time * 3.0 + position.x).sin() * 3.0),
342 10.0,
343 ORANGE,
344 );
345 }
346 }
347 let checkpoint_color = if game.checkpoint_lit { GREEN } else { CREAM };
349 let cx = 1435.0 - camera;
350 draw_rectangle(cx - 3.0, 471.0, 6.0, 89.0, Color::from_hex(0x70715a));
351 draw_rectangle(cx - 14.0, 459.0, 28.0, 30.0, checkpoint_color);
352 draw_rectangle(cx - 8.0, 466.0, 16.0, 16.0, Color::from_hex(0x324237));
353 let exit = 2590.0 - camera;
354 draw_rectangle(exit - 35.0, 450.0, 70.0, 110.0, Color::from_hex(0x1a2925));
355 draw_rectangle_lines(exit - 35.0, 450.0, 70.0, 110.0, 5.0, GREEN);
356 draw_triangle(
357 vec2(exit - 8.0, 495.0),
358 vec2(exit + 9.0, 505.0),
359 vec2(exit - 8.0, 515.0),
360 GREEN,
361 );
362 let p = game.position() - vec2(camera, 0.0);
363 let bob = if game.motor.grounded {
364 (time * 17.0).sin() * game.velocity().x.abs() / SPEED * 2.0
365 } else {
366 0.0
367 };
368 draw_ellipse(
369 p.x + 12.0,
370 p.y + 42.0,
371 15.0,
372 3.0,
373 0.0,
374 Color::new(0.0, 0.0, 0.0, 0.20),
375 );
376 draw_rectangle(p.x + 3.0, p.y + 15.0, 18.0, 19.0, ORANGE);
377 draw_rectangle(
378 p.x + 4.0,
379 p.y + 32.0,
380 6.0,
381 8.0 + bob,
382 Color::from_hex(0x1b2524),
383 );
384 draw_rectangle(
385 p.x + 15.0,
386 p.y + 32.0,
387 6.0,
388 8.0 - bob,
389 Color::from_hex(0x1b2524),
390 );
391 draw_rectangle(p.x + 4.0, p.y + 1.0, 17.0, 15.0, CREAM);
392 draw_rectangle(p.x + 1.0, p.y, 24.0, 6.0, Color::from_hex(0xc79d57));
393 let face = if game.velocity().x < -1.0 { 5.0 } else { 14.0 };
394 draw_rectangle(p.x + face, p.y + 7.0, 5.0, 3.0, Color::from_hex(0x26312b));
395 draw_triangle(
396 vec2(p.x + 5.0, p.y + 18.0),
397 vec2(p.x - 13.0, p.y + 22.0 + bob),
398 vec2(p.x + 5.0, p.y + 26.0),
399 Color::from_hex(0x9e5033),
400 );
401}
402
403fn label(text: &str, x: f32, y: f32, size: f32, color: Color) {
404 draw_text(
405 text,
406 x + 1.0,
407 y + 2.0,
408 size,
409 Color::new(0.08, 0.12, 0.10, 0.8),
410 );
411 draw_text(text, x, y, size, color);
412}
413
414fn config() -> Conf {
415 ferravex_render::window_conf("EMBERLINE | Ferravex")
416}
417
418ferravex_render::game!(config, game);
419async fn game() {
420 let mut demo = Demo::new();
421 let mut game = Game::new();
422 let mut clock = FixedClock::new(120.0, 12);
423 let mut jump_pending = false;
424 let mut touch_jump_was_down = false;
425 let mut touch_mode = false;
426 let mut camera_x = 0.0;
427 let mut font_dpi = 0.0;
428 loop {
429 let dpi = screen_dpi_scale();
430 if font_dpi != dpi {
431 let characters: Vec<char> = (' '..='~').collect();
432 for size in [15_u16, 19, 23, 24, 28, 32, 33] {
433 get_default_font()
434 .populate_font_cache(&characters, (f32::from(size) * dpi).ceil() as u16);
435 }
436 font_dpi = dpi;
437 }
438 if is_key_pressed(KeyCode::R) {
439 game = Game::new();
440 clock.reset();
441 camera_x = 0.0;
442 }
443 let canvas = ferravex_render::canvas::Canvas2D::fit(
444 vec2(WIDTH, HEIGHT),
445 vec2(screen_width(), screen_height()),
446 );
447 let mut touch_left = false;
448 let mut touch_right = false;
449 let mut touch_jump = false;
450 for touch in ferravex_render::input::logical_touches() {
451 if matches!(touch.phase, TouchPhase::Ended | TouchPhase::Cancelled) {
452 continue;
453 }
454 touch_mode = true;
455 let point = canvas.unproject(touch.position);
456 if point.y > 590.0 {
457 touch_left |= point.x < 120.0;
458 touch_right |= (120.0..250.0).contains(&point.x);
459 touch_jump |= point.x > 1080.0;
460 }
461 if game.won && point.y > 350.0 && point.y < 470.0 {
462 game = Game::new();
463 }
464 }
465 jump_pending |= is_key_pressed(KeyCode::Space)
466 || is_key_pressed(KeyCode::W)
467 || is_key_pressed(KeyCode::Up)
468 || (touch_jump && !touch_jump_was_down);
469 touch_jump_was_down = touch_jump;
470 let direction =
471 f32::from(is_key_down(KeyCode::D) || is_key_down(KeyCode::Right) || touch_right)
472 - f32::from(is_key_down(KeyCode::A) || is_key_down(KeyCode::Left) || touch_left);
473 let frame = clock.advance(f64::from(demo.dt()));
474 for _ in 0..frame.steps {
475 let input = if demo.autoplay {
476 game.autoplay()
477 } else {
478 Input {
479 direction,
480 jump: jump_pending,
481 }
482 };
483 game.update(input, frame.dt);
484 jump_pending = false;
485 }
486 let target = (game.position().x - WIDTH * 0.35).clamp(0.0, WORLD_END - WIDTH);
487 camera_x += (target - camera_x) * (1.0 - (-9.0 * demo.dt()).exp());
488 set_camera(&canvas.camera());
489 world(&game, camera_x, demo.time());
490 let collected = game.embers.iter().filter(|(_, found)| *found).count();
491 label("EMBERLINE", 32.0, 48.0, 28.0, CREAM);
492 let zone = if game.position().x < 900.0 {
493 "01 / THE TREELINE"
494 } else if game.position().x < 1800.0 {
495 "02 / THE OLD MILL"
496 } else {
497 "03 / THE FURNACE"
498 };
499 label(zone, 34.0, 72.0, 15.0, Color::from_hex(0xa3ab91));
500 diamond(vec2(WIDTH - 121.0, 38.0), 8.0, ORANGE);
501 label(
502 &format!("{collected} / {}", game.embers.len()),
503 WIDTH - 105.0,
504 45.0,
505 23.0,
506 CREAM,
507 );
508 if game.elapsed < 7.0 && !touch_mode {
509 label(
510 "A / D move SPACE jump R restart",
511 34.0,
512 680.0,
513 19.0,
514 CREAM,
515 );
516 }
517 if touch_mode {
518 for (x, text) in [(68.0, "<"), (182.0, ">"), (1184.0, "^")] {
519 draw_circle(x, 651.0, 38.0, Color::new(0.09, 0.14, 0.12, 0.65));
520 label(text, x - 11.0, 663.0, 32.0, CREAM);
521 }
522 }
523 if game.respawn_flash > 0.0 {
524 label(
525 "Back to the lantern",
526 WIDTH * 0.5 - 110.0,
527 112.0,
528 24.0,
529 CREAM,
530 );
531 }
532 if game.won {
533 draw_rectangle(
534 380.0,
535 220.0,
536 520.0,
537 250.0,
538 Color::new(0.08, 0.13, 0.11, 0.96),
539 );
540 label("THE LIGHT CARRIES ON", 422.0, 280.0, 33.0, CREAM);
541 label(
542 &format!(
543 "{collected} embers / {:.1}s / {} falls",
544 game.elapsed, game.deaths
545 ),
546 432.0,
547 330.0,
548 24.0,
549 ORANGE,
550 );
551 label(
552 if touch_mode {
553 "Tap here to run again"
554 } else {
555 "Press R to run again"
556 },
557 504.0,
558 417.0,
559 23.0,
560 CREAM,
561 );
562 }
563 set_default_camera();
564 if demo.finish_frame() {
565 break;
566 }
567 next_frame().await;
568 }
569}
570
571#[cfg(test)]
572mod tests {
573 use super::*;
574 const DT: f32 = 1.0 / 120.0;
575
576 #[test]
577 fn floor_stops_player_without_tunneling() {
578 let mut game = Game::new();
579 game.motor.position = feet(vec2(90., 80.));
580 game.motor.grounded = false;
581 for _ in 0..240 {
582 game.update(Input::default(), DT);
583 }
584 assert!(game.motor.grounded);
585 assert!((game.position().y - 520.).abs() < 0.01);
586 assert_eq!(game.deaths, 0);
587 }
588
589 #[test]
590 fn coyote_jump_and_buffered_landing_work() {
591 let mut game = Game::new();
592 game.motor.position = feet(vec2(590., 520.));
593 game.motor.grounded = false;
594 game.motor.jump.coyote = 0.07;
595 game.update(
596 Input {
597 jump: true,
598 ..Input::default()
599 },
600 DT,
601 );
602 assert!(game.velocity().y < -600.0);
603 game.motor.position = feet(vec2(90., 517.));
604 game.motor.jump.held = false;
605 game.motor.velocity.y = -180.;
606 game.motor.jump.coyote = 0.0;
607 game.update(
608 Input {
609 jump: true,
610 ..Input::default()
611 },
612 DT,
613 );
614 for _ in 0..4 {
615 game.update(Input::default(), DT);
616 }
617 assert!(game.velocity().y < -500.0);
618 assert_eq!(game.jumps, 2);
619 }
620
621 #[test]
622 fn hazards_respawn_at_activated_checkpoint() {
623 let mut game = Game::new();
624 game.motor.position = feet(vec2(1410., 520.));
625 game.update(Input::default(), DT);
626 assert!(game.checkpoint_lit);
627 game.motor.position = feet(vec2(1605., 520.));
628 game.update(Input::default(), DT);
629 assert_eq!(game.position(), game.checkpoint);
630 assert_eq!(game.deaths, 1);
631 }
632
633 #[test]
634 fn moving_side_platform_carries_a_standing_player() {
635 let mut game = Game::new();
636 game.motor = Character::new(feet(vec2(815., 395.)));
637 game.update(Input::default(), DT);
638 let before = game.position();
639 game.update(Input::default(), DT);
640 assert!(game.motor.grounded);
641 assert!(game.position().x - before.x > 0.27);
642 assert!((game.position().y - before.y).abs() < 0.01);
643 }
644 #[test]
645 fn autoplay_completes_all_sections_with_real_physics() {
646 let mut game = Game::new();
647 for _ in 0..2400 {
648 game.update(game.autoplay(), DT);
649 if game.won {
650 break;
651 }
652 }
653 assert!(
654 game.won,
655 "position {:?}, deaths {}",
656 game.position(),
657 game.deaths
658 );
659 assert!(game.checkpoint_lit);
660 assert!(game.jumps >= 7);
661 assert!(game.embers.iter().any(|(_, found)| *found));
662 assert_eq!(game.deaths, 0);
663 }
664}