emberline/
lib.rs

1//! A small precision platformer: no assets, a fixed simulation, and forgiving jumps.
2use 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        // A side platform moves before the motor, so standing passengers inherit its pose.
112        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    // Deep hot fissures provide an unmistakable landing boundary.
287    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    // Checkpoint lantern and the final lift share the same visual language.
348    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}