neon_swarm/
lib.rs

1mod game;
2
3use ferravex_core::FixedClock;
4use ferravex_render::{canvas::Canvas2D, demo::Demo, prelude::*};
5use game::{ARENA, Bullet, COVER, Enemy, Game, Input, Phase, Pickup, Position};
6
7const INK: Color = Color::from_rgba(9, 16, 22, 255);
8const FLOOR: Color = Color::from_rgba(17, 29, 35, 255);
9const LINE: Color = Color::from_rgba(40, 62, 68, 255);
10const MUTED: Color = Color::from_rgba(125, 150, 154, 255);
11const PAPER: Color = Color::from_rgba(221, 237, 227, 255);
12const CYAN: Color = Color::from_rgba(98, 236, 207, 255);
13const CORAL: Color = Color::from_rgba(255, 132, 93, 255);
14
15fn conf() -> Conf {
16    ferravex_render::window_conf("Neon Swarm ยท Ferravex")
17}
18
19ferravex_render::game!(conf, game);
20async fn game() {
21    let mut demo = Demo::new();
22    let mut game = Game::new();
23    let mut clock = FixedClock::new(120.0, 12);
24    let mut started = demo.autoplay;
25    let mut paused = false;
26    let mut touch_mode = false;
27    loop {
28        ferravex_render::ui::warm_up_font_sizes(&[14, 15, 18, 22, 30, 32, 56]);
29        let canvas = Canvas2D::fit(
30            vec2(1280.0, 800.0),
31            vec2(screen_width().max(1.0), screen_height().max(1.0)),
32        );
33        let mouse = canvas.unproject(Vec2::from(mouse_position()));
34        let touch_points = ferravex_render::input::logical_touches();
35        if !touch_points.is_empty() {
36            touch_mode = true;
37        }
38        if !started
39            && (is_key_pressed(KeyCode::Enter)
40                || is_mouse_button_pressed(MouseButton::Left)
41                || !touch_points.is_empty())
42        {
43            started = true;
44        }
45        if is_key_pressed(KeyCode::Escape) && started && game.phase == Phase::Playing {
46            paused = !paused;
47        }
48        if is_key_pressed(KeyCode::R)
49            || (game.phase != Phase::Playing
50                && (is_key_pressed(KeyCode::Enter)
51                    || is_mouse_button_pressed(MouseButton::Left)
52                    || !touch_points.is_empty()))
53        {
54            game = Game::new();
55            clock.reset();
56            started = true;
57            paused = false;
58        }
59        let mut input = Input {
60            movement: vec2(
61                axis(KeyCode::D, KeyCode::Right) - axis(KeyCode::A, KeyCode::Left),
62                axis(KeyCode::S, KeyCode::Down) - axis(KeyCode::W, KeyCode::Up),
63            ),
64            aim: mouse,
65            fire: is_mouse_button_down(MouseButton::Left),
66            dash: is_key_down(KeyCode::Space) || is_key_down(KeyCode::LeftShift),
67        };
68        if touch_mode {
69            for touch in &touch_points {
70                let p = canvas.unproject(touch.position);
71                if p.x < 640.0 {
72                    input.movement = ((p - vec2(202.0, 600.0)) / 66.0).clamp_length_max(1.0);
73                } else {
74                    input.dash = true;
75                }
76            }
77            input.aim = game.nearest_enemy().unwrap_or(game.player + game.facing);
78            input.fire = game.nearest_enemy().is_some();
79        }
80        if started && !paused {
81            let step = clock.advance(f64::from(demo.dt()));
82            for _ in 0..step.steps {
83                game.update(
84                    if demo.autoplay {
85                        game.autoplay()
86                    } else {
87                        input
88                    },
89                    step.dt,
90                );
91            }
92        }
93        clear_background(INK);
94        set_camera(&canvas.camera());
95        draw_arena();
96        draw_entities(&game);
97        draw_hud(&game, started, touch_mode);
98        if started && !paused && game.phase == Phase::Playing && !touch_mode && !demo.autoplay {
99            draw_circle_lines(mouse.x, mouse.y, 7.0, 1.2, CYAN);
100            draw_line(mouse.x - 11.0, mouse.y, mouse.x - 5.0, mouse.y, 1.0, CYAN);
101            draw_line(mouse.x + 5.0, mouse.y, mouse.x + 11.0, mouse.y, 1.0, CYAN);
102        }
103        if touch_mode && started && game.phase == Phase::Playing {
104            draw_circle_lines(202.0, 600.0, 66.0, 1.4, Color::new(0.4, 0.8, 0.75, 0.22));
105            draw_circle(
106                202.0 + input.movement.x * 34.0,
107                600.0 + input.movement.y * 34.0,
108                18.0,
109                Color::new(0.4, 0.8, 0.75, 0.25),
110            );
111            draw_circle_lines(1100.0, 610.0, 40.0, 1.4, Color::new(0.4, 0.8, 0.75, 0.4));
112            label("DASH", 1078.0, 616.0, 18, CYAN);
113        }
114        if !started {
115            overlay(
116                "NEON SWARM",
117                "Contain three waves. Keep moving.",
118                if touch_mode {
119                    "TAP TO START"
120                } else {
121                    "ENTER TO START"
122                },
123            );
124        } else if paused {
125            overlay(
126                "PAUSED",
127                "Take a breath. The swarm can wait.",
128                "ESC TO RESUME",
129            );
130        } else if game.phase != Phase::Playing {
131            let won = game.phase == Phase::Won;
132            overlay(
133                if won { "SECTOR SECURED" } else { "SIGNAL LOST" },
134                &format!(
135                    "{} hostiles cleared  /  {:.0} seconds",
136                    game.kills, game.time
137                ),
138                if touch_mode {
139                    "TAP TO RESTART"
140                } else {
141                    "R TO RESTART"
142                },
143            );
144        } else if game.wave_banner > 0.0 {
145            let alpha = game.wave_banner.min(1.0);
146            let text = format!("WAVE 0{}", game.wave);
147            let width = measure_text(&text, None, 22, 1.0).width;
148            label(
149                &text,
150                640.0 - width * 0.5,
151                154.0,
152                22,
153                Color::new(CYAN.r, CYAN.g, CYAN.b, alpha),
154            );
155        }
156        set_default_camera();
157        if demo.finish_frame() {
158            break;
159        }
160        next_frame().await;
161    }
162}
163
164fn axis(primary: KeyCode, alternate: KeyCode) -> f32 {
165    if is_key_down(primary) || is_key_down(alternate) {
166        1.0
167    } else {
168        0.0
169    }
170}
171
172fn label(text: &str, x: f32, y: f32, size: u16, color: Color) {
173    draw_text_ex(
174        text,
175        x,
176        y,
177        TextParams {
178            font_size: size,
179            color,
180            ..Default::default()
181        },
182    );
183}
184
185fn draw_arena() {
186    draw_rectangle(
187        ARENA.x - 9.0,
188        ARENA.y - 9.0,
189        ARENA.w + 18.0,
190        ARENA.h + 18.0,
191        Color::from_rgba(24, 40, 46, 255),
192    );
193    draw_rectangle(ARENA.x, ARENA.y, ARENA.w, ARENA.h, FLOOR);
194    // Sparse floor joints establish scale while leaving combat silhouettes quiet.
195    for x in (96..1208).step_by(64) {
196        draw_line(
197            x as f32,
198            ARENA.y,
199            x as f32,
200            ARENA.bottom(),
201            1.0,
202            Color::from_rgba(24, 39, 45, 255),
203        );
204    }
205    for y in (136..716).step_by(64) {
206        draw_line(
207            ARENA.x,
208            y as f32,
209            ARENA.right(),
210            y as f32,
211            1.0,
212            Color::from_rgba(24, 39, 45, 255),
213        );
214    }
215    draw_circle_lines(640.0, 414.0, 92.0, 1.0, LINE);
216    draw_circle_lines(640.0, 414.0, 100.0, 1.0, LINE);
217    draw_line(620.0, 414.0, 660.0, 414.0, 1.0, LINE);
218    draw_line(640.0, 394.0, 640.0, 434.0, 1.0, LINE);
219    label("07", 596.0, 435.0, 56, Color::from_rgba(32, 51, 56, 255));
220    for (i, cover) in COVER.iter().enumerate() {
221        draw_rectangle(cover.x + 5.0, cover.y + 8.0, cover.w, cover.h, INK);
222        draw_rectangle(
223            cover.x,
224            cover.y,
225            cover.w,
226            cover.h,
227            Color::from_rgba(35, 51, 57, 255),
228        );
229        draw_rectangle_lines(
230            cover.x,
231            cover.y,
232            cover.w,
233            cover.h,
234            1.4,
235            Color::from_rgba(69, 91, 94, 255),
236        );
237        draw_line(
238            cover.x + 9.0,
239            cover.y + 12.0,
240            cover.right() - 9.0,
241            cover.y + 12.0,
242            2.0,
243            LINE,
244        );
245        for x in 0..4 {
246            draw_line(
247                cover.x + 12.0 + x as f32 * 10.0,
248                cover.bottom() - 8.0,
249                cover.x + 17.0 + x as f32 * 10.0,
250                cover.bottom() - 13.0,
251                3.0,
252                Color::from_rgba(139, 116, 68, 255),
253            );
254        }
255        label(
256            &format!("C{}", i + 1),
257            cover.right() - 27.0,
258            cover.bottom() - 10.0,
259            14,
260            MUTED,
261        );
262    }
263    for p in [
264        vec2(72.0, 414.0),
265        vec2(1208.0, 414.0),
266        vec2(640.0, 112.0),
267        vec2(640.0, 716.0),
268    ] {
269        draw_circle(p.x, p.y, 4.0, CORAL);
270        draw_circle_lines(
271            p.x,
272            p.y,
273            9.0,
274            1.0,
275            Color::new(CORAL.r, CORAL.g, CORAL.b, 0.35),
276        );
277    }
278    for (x, y, sx, sy) in [
279        (72.0, 112.0, 1.0, 1.0),
280        (1208.0, 112.0, -1.0, 1.0),
281        (72.0, 716.0, 1.0, -1.0),
282        (1208.0, 716.0, -1.0, -1.0),
283    ] {
284        draw_line(x, y, x + sx * 25.0, y, 2.0, CYAN);
285        draw_line(x, y, x, y + sy * 25.0, 2.0, CYAN);
286    }
287}
288
289fn draw_entities(game: &Game) {
290    for (p, pickup) in &mut game.world.query::<(&Position, &Pickup)>() {
291        let pulse = 1.0 + (game.time * 5.0).sin() * 0.1;
292        draw_poly(
293            p.0.x,
294            p.0.y,
295            4,
296            15.0 * pulse,
297            45.0,
298            Color::from_rgba(26, 69, 63, 255),
299        );
300        let color = if pickup.remaining < 3.0 && (game.time * 8.0).sin() < 0.0 {
301            MUTED
302        } else {
303            CYAN
304        };
305        draw_line(p.0.x - 5.0, p.0.y, p.0.x + 5.0, p.0.y, 2.0, color);
306        draw_line(p.0.x, p.0.y - 5.0, p.0.x, p.0.y + 5.0, 2.0, color);
307    }
308    for (p, enemy) in &mut game.world.query::<(&Position, &Enemy)>() {
309        let color = if enemy.flash > 0.0 {
310            PAPER
311        } else if enemy.heavy {
312            Color::from_rgba(208, 109, 153, 255)
313        } else {
314            CORAL
315        };
316        let heading = (game.player - p.0).to_angle().to_degrees();
317        let sides = if enemy.heavy { 6 } else { 4 };
318        draw_poly(
319            p.0.x + 2.0,
320            p.0.y + 5.0,
321            sides,
322            enemy.radius + 2.0,
323            heading,
324            INK,
325        );
326        draw_poly(p.0.x, p.0.y, sides, enemy.radius, heading, color);
327        draw_poly(p.0.x, p.0.y, sides, enemy.radius - 4.0, heading, FLOOR);
328        let nose = Vec2::from_angle(heading.to_radians()) * (enemy.radius - 2.0);
329        draw_circle(p.0.x + nose.x, p.0.y + nose.y, 3.0, color);
330        if enemy.heavy {
331            draw_circle(p.0.x, p.0.y, 5.0, color);
332            for pip in 0..(enemy.health / 24.0).ceil() as u32 {
333                draw_rectangle(
334                    p.0.x - 10.0 + pip as f32 * 8.0,
335                    p.0.y - 32.0,
336                    5.0,
337                    2.0,
338                    color,
339                );
340            }
341        }
342    }
343    for (p, bullet) in &mut game.world.query::<(&Position, &Bullet)>() {
344        let tail = p.0 - bullet.velocity.normalize() * 16.0;
345        draw_line(tail.x, tail.y, p.0.x, p.0.y, 2.5, CYAN);
346        draw_circle(p.0.x, p.0.y, 2.0, PAPER);
347    }
348    for particle in &game.particles {
349        let color = Color::new(
350            particle.color.r,
351            particle.color.g,
352            particle.color.b,
353            (particle.life * 2.0).min(1.0),
354        );
355        draw_rectangle(particle.pos.x, particle.pos.y, 3.0, 3.0, color);
356    }
357    let p = game.player;
358    let angle = game.facing.to_angle().to_degrees();
359    if game.dash_time > 0.0 {
360        for index in 1..5 {
361            let ghost = p - game.facing * index as f32 * 12.0;
362            draw_poly(
363                ghost.x,
364                ghost.y,
365                6,
366                14.0,
367                angle,
368                Color::new(CYAN.r, CYAN.g, CYAN.b, 0.15 / index as f32),
369            );
370        }
371    }
372    draw_circle(p.x + 2.0, p.y + 5.0, 17.0, INK);
373    let player_color = if game.health == 0 {
374        MUTED
375    } else if game.hurt_time > 0.6 {
376        CORAL
377    } else {
378        CYAN
379    };
380    draw_poly(p.x, p.y, 6, 15.0, angle, player_color);
381    draw_poly(p.x, p.y, 6, 10.0, angle, FLOOR);
382    let nose = p + game.facing * 24.0;
383    draw_line(p.x, p.y, nose.x, nose.y, 6.0, player_color);
384    if game.muzzle_time > 0.0 {
385        draw_circle(nose.x, nose.y, 7.0, PAPER);
386    }
387    if game.hurt_time > 0.0 {
388        draw_circle_lines(
389            p.x,
390            p.y,
391            23.0,
392            1.0,
393            Color::new(CORAL.r, CORAL.g, CORAL.b, game.hurt_time),
394        );
395    }
396}
397
398fn draw_hud(game: &Game, started: bool, touch: bool) {
399    label("NEON SWARM", 72.0, 57.0, 30, PAPER);
400    label("SECTOR 07 / CONTAINMENT", 73.0, 80.0, 14, MUTED);
401    for wave in 1..=3 {
402        let x = 600.0 + (wave - 1) as f32 * 40.0;
403        draw_circle(x, 54.0, 5.0, if wave <= game.wave { CYAN } else { LINE });
404    }
405    if started {
406        label(
407            &format!("{} REMAIN", game.remaining_enemies()),
408            596.0,
409            80.0,
410            14,
411            MUTED,
412        );
413    }
414    let hp_color = if game.health <= 30 { CORAL } else { CYAN };
415    draw_line(1088.0, 51.0, 1104.0, 51.0, 3.0, hp_color);
416    draw_line(1096.0, 43.0, 1096.0, 59.0, 3.0, hp_color);
417    label(&format!("{:03}", game.health), 1120.0, 61.0, 32, PAPER);
418    for pip in 0..10 {
419        draw_rectangle(
420            1088.0 + pip as f32 * 12.0,
421            76.0,
422            8.0,
423            3.0,
424            if game.health > pip * 10 {
425                hp_color
426            } else {
427                LINE
428            },
429        );
430    }
431    let ready = game.dash_cooldown <= 0.0;
432    label(
433        if ready { "DASH READY" } else { "DASH" },
434        73.0,
435        756.0,
436        15,
437        if ready { CYAN } else { MUTED },
438    );
439    draw_rectangle(73.0, 768.0, 108.0, 2.0, LINE);
440    draw_rectangle(
441        73.0,
442        768.0,
443        108.0 * (1.0 - game.dash_cooldown / 1.7),
444        2.0,
445        CYAN,
446    );
447    let controls = if touch {
448        "LEFT: MOVE     RIGHT: DASH     AUTO FIRE"
449    } else {
450        "WASD  MOVE       MOUSE  FIRE       SPACE  DASH       ESC  PAUSE"
451    };
452    let width = measure_text(controls, None, 14, 1.0).width;
453    label(controls, 640.0 - width / 2.0, 759.0, 14, MUTED);
454    label(
455        &format!("{:02} CLEARED", game.kills),
456        1097.0,
457        757.0,
458        15,
459        MUTED,
460    );
461}
462
463fn overlay(title: &str, detail: &str, action: &str) {
464    draw_rectangle(
465        0.0,
466        100.0,
467        1280.0,
468        626.0,
469        Color::new(0.025, 0.055, 0.07, 0.82),
470    );
471    draw_line(602.0, 323.0, 678.0, 323.0, 3.0, CYAN);
472    for (text, y, size, color) in [
473        (title, 386.0, 52, PAPER),
474        (detail, 430.0, 21, MUTED),
475        (action, 495.0, 18, CYAN),
476    ] {
477        let width = measure_text(text, None, size, 1.0).width;
478        label(text, 640.0 - width * 0.5, y, size, color);
479    }
480}