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