strike/
lib.rs

1//! Original low-poly FPS vertical slice on Ferravex; native UDP or offline practice on the web.
2mod 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    // Slow, imperfect opponents; same authoritative collision/fire code as human players.
78    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
102// Both offline authority and replicated network events feed the same effects.
103// Client prediction never manufactures a hit confirmation.
104fn 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// Optional browser chrome follows actual gameplay state, including touch input.
143#[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    // SAFETY: web/index.html registers this import before instantiating the game.
157    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}