1mod controls;
3mod geometry;
4mod hud;
5
6use controls::Controls;
7use ferravex_core::{FixedClock, FrameStats};
8#[cfg(not(target_arch = "wasm32"))]
9use ferravex_fps::GameClient;
10use ferravex_fps::{Arena, Match, Player, TICK_RATE, forward};
11use ferravex_net::{BUTTON_FIRE, InputFrame};
12use ferravex_render::prelude::*;
13use ferravex_render::shaders::{LitMaterial, LitSettings};
14use geometry::{build_operator, build_rifle, build_yard, draw_transformed, rgb};
15
16fn window_conf() -> Conf {
17 let mut conf = ferravex_render::window_conf("Ferravex / Strike — Dockyard");
18 conf.window_width = 1440;
19 conf.window_height = 900;
20 conf
21}
22
23#[derive(Default)]
24struct Options {
25 connect: Option<String>,
26 smoke_frames: Option<u64>,
27 #[cfg(not(target_arch = "wasm32"))]
28 screenshot: Option<String>,
29 #[cfg(not(target_arch = "wasm32"))]
30 capture_dir: Option<String>,
31 play: bool,
32}
33#[cfg(target_arch = "wasm32")]
34fn options() -> Options {
35 Options::default()
36}
37
38#[cfg(not(target_arch = "wasm32"))]
39fn options() -> Options {
40 let mut out = Options::default();
41 #[cfg(not(target_arch = "wasm32"))]
42 {
43 let mut args = std::env::args().skip(1);
44 while let Some(arg) = args.next() {
45 match arg.as_str() {
46 "--connect" => out.connect = args.next(),
47 "--smoke-frames" => out.smoke_frames = args.next().and_then(|s| s.parse().ok()),
48 "--screenshot" => out.screenshot = args.next(),
49 "--capture-dir" => out.capture_dir = args.next(),
50 "--play" => out.play = true,
51 "--help" => {
52 println!(
53 "strike [--connect 127.0.0.1:4000] [--play] [--smoke-frames N] [--screenshot path.png] [--capture-dir directory]"
54 );
55 std::process::exit(0);
56 }
57 _ => eprintln!("Unknown option: {arg}"),
58 }
59 }
60 }
61 out
62}
63
64fn practice() -> Match {
65 let mut game = Match::new(Arena::default());
66 for id in 0..6 {
67 game.spawn_player(id);
68 }
69 game
70}
71
72fn bot_input(bot: &Player, target: &Player, tick: u32) -> InputFrame {
73 let mut to = target.position - bot.eye_position();
74 to.y += 1.0;
75 let yaw = to.x.atan2(-to.z);
76 let pitch = (to.y / to.length().max(0.001)).asin();
77 let phase = tick as f32 * 0.014 + bot.id as f32 * 1.7;
79 InputFrame {
80 sequence: tick,
81 move_x: phase.sin() * 0.42,
82 move_z: if to.length() > 12. { 0.28 } else { -0.12 },
83 yaw: yaw + phase.cos() * 0.09,
84 pitch,
85 buttons: if tick > 320 && tick.wrapping_add(bot.id as u32 * 47).is_multiple_of(320) {
86 BUTTON_FIRE
87 } else if bot.ammo == 0 {
88 ferravex_net::BUTTON_RELOAD
89 } else {
90 0
91 },
92 }
93}
94
95const MAX_TRACERS: usize = 32;
96struct Tracer {
97 origin: Vec3,
98 end: Vec3,
99 life: f32,
100}
101
102fn apply_game_events(
105 events: impl IntoIterator<Item = ferravex_fps::GameEvent>,
106 local_id: u8,
107 tracers: &mut Vec<Tracer>,
108 hit_timer: &mut f32,
109 damage_timer: &mut f32,
110) {
111 for event in events {
112 match event {
113 ferravex_fps::GameEvent::Shot {
114 player,
115 origin,
116 end,
117 } if player != local_id => {
118 if tracers.len() == MAX_TRACERS {
119 tracers.remove(0);
120 }
121 tracers.push(Tracer {
122 origin: Vec3::from_array(origin.to_array()),
123 end: Vec3::from_array(end.to_array()),
124 life: 0.09,
125 });
126 }
127 ferravex_fps::GameEvent::Hit {
128 attacker, victim, ..
129 } => {
130 if attacker == local_id {
131 *hit_timer = 0.16;
132 }
133 if victim == local_id {
134 *damage_timer = 0.25;
135 }
136 }
137 _ => {}
138 }
139 }
140}
141
142#[cfg(target_arch = "wasm32")]
144#[link(wasm_import_module = "env")]
145unsafe extern "C" {
146 fn ferravex_set_playing(playing: i32);
147}
148#[cfg(target_arch = "wasm32")]
149#[unsafe(no_mangle)]
150pub extern "C" fn ferravex_ui_crate_version() -> u32 {
151 1
152}
153
154fn sync_browser_ui(playing: bool) {
155 #[cfg(target_arch = "wasm32")]
156 unsafe {
158 ferravex_set_playing(i32::from(playing));
159 }
160 #[cfg(not(target_arch = "wasm32"))]
161 let _ = playing;
162}
163
164ferravex_render::game!(window_conf, game);
165async fn game() {
166 let opts = options();
167 simulate_mouse_with_touch(false);
168 let mut game = practice();
169 ferravex_render::set_quality(ferravex_render::lighting::Quality::Medium);
170 let mut arena = build_yard(&game.arena);
171 let ao =
172 ferravex_render::ao::AoScene::new([(&arena.mesh, Mat4::IDENTITY)]).expect("yard AO scene");
173 ao.bake(
174 &mut arena.mesh,
175 Mat4::IDENTITY,
176 ferravex_render::ao::AoSettings {
177 samples: 8,
178 radius: 1.5,
179 strength: 0.3,
180 max_tests: 20_000_000,
181 ..Default::default()
182 },
183 )
184 .expect("yard vertex AO");
185 let operator = build_operator();
186 let rifle = build_rifle();
187 let mut tracer_model = geometry::Model::new();
188 tracer_model.cube(Vec3::ZERO, vec3(0.018, 0.018, 1.), rgb(0xe8b56a));
189 let mut tracers = Vec::with_capacity(MAX_TRACERS);
190
191 let shader = LitMaterial::new();
192 #[cfg_attr(target_arch = "wasm32", allow(unused_mut))]
193 let mut error = shader.as_ref().err().cloned().unwrap_or_default();
194 #[cfg(not(target_arch = "wasm32"))]
195 let mut client = opts
196 .connect
197 .as_ref()
198 .and_then(|addr| match GameClient::connect(addr) {
199 Ok(client) => Some(client),
200 Err(e) => {
201 error = format!("Connection error: {e}");
202 None
203 }
204 });
205 let network = opts.connect.is_some();
206 let mut active = opts.play;
207 let mut has_played = active;
208 let mut diagnostics = false;
209 let mut browser_playing = active;
210 sync_browser_ui(active);
211 let mut controls = Controls::default();
212 let mut clock = FixedClock::new(TICK_RATE as f64, 8);
213 let mut stats = FrameStats::<120>::default();
214 let mut frames = 0u64;
215 let mut pending_buttons = 0;
216 let mut shot_timer = 0f32;
217 let mut hit_timer = 0f32;
218 let mut damage_timer = 0f32;
219 let mut local = game.players[0].clone();
220 let mut remote = game.players.clone();
221 let mut server_tick = 0;
222 let mut was_ammo = local.ammo;
223 let mut was_health = local.health;
224 let mut previous_local_position = local.position;
225 if active && opts.smoke_frames.is_none() {
226 set_cursor_grab(true);
227 show_mouse(false);
228 }
229
230 loop {
231 let dt = if opts.smoke_frames.is_some() {
232 1.0 / 60.0
233 } else {
234 get_frame_time().min(0.25)
235 };
236 stats.record(get_frame_time());
237 if is_key_pressed(KeyCode::Escape) {
238 active = !active;
239 has_played |= active;
240 set_cursor_grab(active && !controls.touch_mode);
241 show_mouse(!active || controls.touch_mode);
242 }
243 if is_key_pressed(KeyCode::F3) {
244 diagnostics = !diagnostics;
245 }
246 let mut input = controls.sample(active);
247 #[cfg(not(target_arch = "wasm32"))]
248 if opts.capture_dir.is_some() && active {
249 let t = frames as f32 / 60.0;
250 input.yaw = (t * 0.18).sin() * 1.2;
251 input.pitch = -0.03;
252 input.move_z = if t < 8.0 { 0.8 } else { 0.2 };
253 input.move_x = (t * 0.7).sin() * 0.6;
254 input.buttons = if (frames / 120).is_multiple_of(3) {
255 ferravex_fps::BUTTON_RELOAD
256 } else {
257 ferravex_fps::BUTTON_FIRE
258 };
259 }
260 if active {
261 pending_buttons |= input.buttons;
262 } else {
263 pending_buttons = 0;
264 }
265 input.buttons = pending_buttons;
266 let frame = clock.advance(dt as f64);
267 if frame.steps > 0 {
268 pending_buttons = 0;
269 }
270 #[cfg(not(target_arch = "wasm32"))]
271 if let Some(c) = client.as_mut() {
272 if let Err(e) = c.poll() {
273 error = e.to_string();
274 }
275 let local_id = c.player_id().unwrap_or(local.id);
276 apply_game_events(
277 c.drain_events().into_iter().map(|message| message.event),
278 local_id,
279 &mut tracers,
280 &mut hit_timer,
281 &mut damage_timer,
282 );
283 }
284 for _ in 0..frame.steps {
285 previous_local_position = local.position;
286 #[cfg(not(target_arch = "wasm32"))]
287 if let Some(c) = client.as_mut() {
288 if let Err(e) = c.tick(input) {
289 error = e.to_string();
290 }
291 if let Some(player) = c.local_player() {
292 local = player.clone();
293 }
294 remote.clear();
295 remote.extend_from_slice(c.players());
296 server_tick = c.server_tick();
297 continue;
298 }
299 if !network && active {
300 let mut commands = Vec::with_capacity(6);
301 commands.push((
302 0,
303 InputFrame {
304 sequence: game.tick,
305 ..input
306 },
307 ));
308 let target = game.players[0].clone();
309 for bot in game.players.iter().skip(1) {
310 commands.push((bot.id, bot_input(bot, &target, game.tick)));
311 }
312 let events = game.step(&commands);
313 apply_game_events(events, 0, &mut tracers, &mut hit_timer, &mut damage_timer);
314 local = game.players[0].clone();
315 remote.clone_from(&game.players);
316 server_tick = game.tick;
317 }
318 }
319 #[cfg(not(target_arch = "wasm32"))]
320 if let Some(c) = client.as_ref() {
321 remote = c.interpolated_players(c.interpolation_alpha());
322 if !c.is_connected() {
323 error = "Connecting to server...".into();
324 } else if error == "Connecting to server..." {
325 error.clear();
326 }
327 }
328 if local.ammo < was_ammo {
329 shot_timer = 0.09;
330 }
331
332 if local.health < was_health {
333 damage_timer = 0.25;
334 }
335 was_ammo = local.ammo;
336 was_health = local.health;
337 shot_timer = (shot_timer - dt).max(0.);
338 hit_timer = (hit_timer - dt).max(0.);
339 damage_timer = (damage_timer - dt).max(0.);
340 tracers.retain_mut(|tracer| {
341 tracer.life -= dt;
342 tracer.life > 0.
343 });
344 let sky = rgb(0xb8c4b7);
345 clear_background(sky);
346 let eye = if active {
347 let feet = if previous_local_position.distance_squared(local.position) < 4. {
348 previous_local_position.lerp(local.position, frame.alpha)
349 } else {
350 local.position
351 };
352 Vec3::from_array(feet.to_array()) + Vec3::Y * ferravex_fps::EYE_HEIGHT
353 } else {
354 let t = get_time() as f32 * 0.035;
355 vec3(17. + t.sin() * 2., 11., 24.)
356 };
357 let direction = Vec3::from_array(forward(controls.yaw, controls.pitch).to_array());
358 let target = if active {
359 eye + direction
360 } else {
361 vec3(-3., 1., -3.)
362 };
363 let camera = Camera3D {
364 position: eye,
365 target,
366 up: Vec3::Y,
367 fovy: 68f32.to_radians(),
368 z_near: 0.04,
369 z_far: 150.,
370 ..Default::default()
371 };
372 set_camera(&camera);
373 let light = LitSettings {
374 camera_position: eye,
375 fog_color: sky,
376 fog_start: 28.,
377 fog_end: 100.,
378 ambient: 0.56,
379 light_direction: vec3(-0.5, 0.85, 0.3),
380 };
381 if let Ok(s) = &shader {
382 s.begin(light);
383 }
384 ferravex_render::set_sun_shadow(Some(ferravex_render::lighting::SunShadow {
385 center: vec3(eye.x, 0., eye.z),
386 direction: light.light_direction,
387 extent: 34.,
388 depth: 90.,
389 ..Default::default()
390 }))
391 .unwrap();
392 if shot_timer > 0. {
393 ferravex_render::set_point_lights(&[ferravex_render::lighting::PointLight {
394 position: eye + direction * 0.8,
395 color: rgb(0xffb65c),
396 radius: 5.,
397 intensity: shot_timer * 18.,
398 }])
399 .unwrap();
400 }
401 if active && local.health > 0 {
402 ferravex_render::draw_shadow(
403 operator.resident(),
404 Mat4::from_rotation_translation(
405 Quat::from_rotation_y(-local.yaw),
406 Vec3::from_array(local.position.to_array()),
407 ),
408 );
409 }
410 arena.draw();
411 for p in &remote {
412 if (active && p.id == local.id) || p.health == 0 {
413 continue;
414 }
415 draw_transformed(
416 &operator,
417 Mat4::from_rotation_translation(
418 Quat::from_rotation_y(-p.yaw),
419 Vec3::from_array(p.position.to_array()),
420 ),
421 );
422 }
423 if active {
424 for tracer in &tracers {
425 let delta = tracer.end - tracer.origin;
426 let length = delta.length();
427 if length > 0.001 {
428 draw_transformed(
429 &tracer_model,
430 Mat4::from_scale_rotation_translation(
431 vec3(1., 1., length),
432 Quat::from_rotation_arc(Vec3::Z, delta / length),
433 (tracer.origin + tracer.end) * 0.5,
434 ),
435 );
436 }
437 }
438 }
439 if active && local.health > 0 {
440 let side = direction.cross(Vec3::Y).normalize_or_zero();
441 let up = side.cross(direction).normalize_or_zero();
442 let speed = vec2(local.velocity.x, local.velocity.z).length();
443 let bob = (get_time() as f32 * 10.).sin() * 0.008 * (speed / 7.).min(1.);
444 let offset = eye + side * 0.27 - up * (0.25 + shot_timer * 0.28)
445 + direction * (0.47 - shot_timer * 0.2)
446 + Vec3::Y * bob;
447 let rot = Quat::from_rotation_y(-controls.yaw) * Quat::from_rotation_x(controls.pitch);
448 draw_transformed(&rifle, Mat4::from_rotation_translation(rot, offset));
449 if shot_timer > 0.055 {
450 let muzzle = offset + rot * vec3(0., 0.05, -0.80);
451 draw_cube(muzzle, vec3(0.065, 0.06, 0.12), None, rgb(0xffda87));
452 }
453 }
454 if let Ok(s) = &shader {
455 s.end();
456 }
457 set_default_camera();
458 if damage_timer > 0. {
459 draw_rectangle(
460 0.,
461 0.,
462 screen_width(),
463 screen_height(),
464 Color::new(0.6, 0.12, 0.05, damage_timer * 0.3),
465 );
466 }
467 let triangles = if active && local.health > 0 && shot_timer > 0.055 {
468 12
469 } else {
470 0
471 } + arena.triangles()
472 + if active {
473 tracers.len() * tracer_model.triangles()
474 } else {
475 0
476 }
477 + remote
478 .iter()
479 .filter(|p| p.health > 0 && (!active || p.id != local.id))
480 .count()
481 * operator.triangles()
482 + if active && local.health > 0 {
483 rifle.triangles()
484 } else {
485 0
486 };
487 hud::overlay(
488 &local,
489 hud::HudState {
490 active,
491 touch_mode: controls.touch_mode,
492 diagnostics,
493 frame_ms: stats.mean_ms(),
494 triangles,
495 tick: server_tick,
496 hit_timer,
497 shot_timer,
498 error: &error,
499 },
500 );
501 if active {
502 controls.draw_touch();
503 if is_key_down(KeyCode::Tab) {
504 hud::roster(&remote, local.id);
505 }
506 } else if hud::menu(network, has_played) {
507 active = true;
508 has_played = true;
509 controls.touch_mode |= !ferravex_render::input::logical_touches().is_empty();
510 set_cursor_grab(!controls.touch_mode);
511 show_mouse(controls.touch_mode);
512 clock.reset();
513 }
514 if browser_playing != active {
515 sync_browser_ui(active);
516 browser_playing = active;
517 }
518 #[cfg(not(target_arch = "wasm32"))]
519 if let Some(directory) = &opts.capture_dir {
520 std::fs::create_dir_all(directory).expect("create capture directory");
521 get_screen_data().export_png(&format!("{directory}/{frames:06}.png"));
522 }
523 frames += 1;
524 if opts.smoke_frames.is_some_and(|limit| frames >= limit) {
525 #[cfg(not(target_arch = "wasm32"))]
526 println!(
527 "STRIKE mode={} connected={} server_tick={} frames={} error={}",
528 if network { "network" } else { "offline" },
529 client.as_ref().is_some_and(|c| c.player_id().is_some()),
530 server_tick,
531 frames,
532 error
533 );
534 #[cfg(not(target_arch = "wasm32"))]
535 if let Some(path) = &opts.screenshot {
536 get_screen_data().export_png(path);
537 }
538 break;
539 }
540 next_frame().await;
541 }
542}
543
544#[cfg(test)]
545mod event_effect_tests {
546 use super::*;
547 #[test]
548 fn replicated_hits_confirm_only_authority_and_remote_tracers_are_bounded() {
549 let mut tracers = Vec::new();
550 let mut hit = 0.;
551 let mut damage = 0.;
552 apply_game_events(
553 [ferravex_fps::GameEvent::Hit {
554 attacker: 0,
555 victim: 1,
556 damage: 34,
557 headshot: false,
558 }],
559 0,
560 &mut tracers,
561 &mut hit,
562 &mut damage,
563 );
564 assert_eq!(hit, 0.16);
565 assert_eq!(damage, 0.);
566 for player in std::iter::repeat_n(1, MAX_TRACERS + 10).chain([0]) {
567 apply_game_events(
568 [ferravex_fps::GameEvent::Shot {
569 player,
570 origin: [0., 1., 0.].into(),
571 end: [0., 1., -5.].into(),
572 }],
573 0,
574 &mut tracers,
575 &mut hit,
576 &mut damage,
577 );
578 }
579 assert_eq!(tracers.len(), MAX_TRACERS);
580 apply_game_events(
581 [ferravex_fps::GameEvent::Hit {
582 attacker: 1,
583 victim: 0,
584 damage: 34,
585 headshot: false,
586 }],
587 0,
588 &mut tracers,
589 &mut hit,
590 &mut damage,
591 );
592 assert_eq!(damage, 0.25);
593 }
594}