brkrs 0.0.1

Breakout/Arkanoid-style game built in Rust using the Bevy engine, with physics powered by bevy_rapier3d
Documentation
use bevy::app::App;
use bevy::ecs::message::Messages;
use bevy::prelude::*;
use bevy::MinimalPlugins;
use bevy_rapier3d::prelude::{CollisionEvent, Velocity};
use bevy_rapier3d::rapier::prelude::CollisionEventFlags;

use brkrs::systems::respawn::{
    InputLocked, RespawnCompleted, RespawnEntityKind, RespawnFadeOverlay, RespawnHandle,
    RespawnPlugin, RespawnSchedule, RespawnVisualState, SpawnPoints, SpawnTransform,
};
use brkrs::{Ball, BallFrozen, LowerGoal, Paddle, PaddleGrowing};

use std::time::Duration;

fn test_app() -> App {
    let mut app = App::new();
    app.add_plugins(MinimalPlugins)
        .insert_resource(Assets::<Mesh>::default())
        .insert_resource(Assets::<StandardMaterial>::default())
        .add_message::<CollisionEvent>()
        .add_plugins(RespawnPlugin);
    {
        let mut spawn_points = app.world_mut().resource_mut::<SpawnPoints>();
        spawn_points.ball = Some(Vec3::new(0.0, 2.0, 0.0));
        spawn_points.paddle = Some(Vec3::new(0.0, 2.0, 0.0));
    }
    app
}

fn ball_handle_at(position: Vec3) -> RespawnHandle {
    RespawnHandle {
        spawn: SpawnTransform::new(position, Quat::IDENTITY),
        kind: RespawnEntityKind::Ball,
    }
}

fn paddle_handle_at(position: Vec3) -> RespawnHandle {
    RespawnHandle {
        spawn: SpawnTransform::new(position, Quat::from_rotation_x(-std::f32::consts::PI / 2.0)),
        kind: RespawnEntityKind::Paddle,
    }
}

fn advance_time(app: &mut App, delta_secs: f32) {
    let mut time = app.world_mut().resource_mut::<Time>();
    time.advance_by(Duration::from_secs_f32(delta_secs));
}

fn spawn_respawn_fixture(app: &mut App) -> (Entity, Entity, Entity) {
    let lower_goal = app.world_mut().spawn(LowerGoal).id();
    let ball = app
        .world_mut()
        .spawn((Ball, ball_handle_at(Vec3::new(0.0, 2.0, 0.0))))
        .id();
    let paddle = app
        .world_mut()
        .spawn((
            Paddle,
            Transform::default(),
            Velocity::zero(),
            paddle_handle_at(Vec3::new(0.0, 2.0, 0.0)),
        ))
        .id();
    (lower_goal, ball, paddle)
}

fn trigger_life_loss(app: &mut App, ball: Entity, lower_goal: Entity) {
    app.world_mut()
        .resource_mut::<Messages<CollisionEvent>>()
        .write(CollisionEvent::Started(
            ball,
            lower_goal,
            CollisionEventFlags::SENSOR,
        ));
}

#[test]
fn respawn_timer_finishes_within_frame_tolerance() {
    let mut app = test_app();
    let (lower_goal, ball, _) = spawn_respawn_fixture(&mut app);

    trigger_life_loss(&mut app, ball, lower_goal);

    advance_time(&mut app, 0.016);
    app.update();

    let frame_tolerance = Duration::from_secs_f32(0.016);
    let total_duration = app.world().resource::<RespawnSchedule>().timer.duration();

    {
        let mut schedule = app.world_mut().resource_mut::<RespawnSchedule>();
        schedule.timer.tick(total_duration - frame_tolerance);
        assert!(
            !schedule.timer.is_finished(),
            "timer should remain pending right before frame tolerance boundary",
        );
    }

    advance_time(&mut app, 0.0);
    app.update();
    {
        let schedule = app.world().resource::<RespawnSchedule>();
        assert!(
            schedule.pending.is_some(),
            "respawn should still be pending after partial tick",
        );
    }

    {
        let mut schedule = app.world_mut().resource_mut::<RespawnSchedule>();
        schedule
            .timer
            .tick(frame_tolerance + Duration::from_millis(1));
    }

    advance_time(&mut app, 0.0);
    app.update();
    {
        let schedule = app.world().resource::<RespawnSchedule>();
        assert!(
            schedule.pending.is_none(),
            "respawn should complete within tolerance once timer elapses",
        );
    }

    let completions = app.world().resource::<Messages<RespawnCompleted>>();
    assert!(
        !completions.is_empty(),
        "respawn completion event should fire when timer elapses",
    );
}

#[test]
fn ball_remains_frozen_until_launch_unlock() {
    let mut app = test_app();
    let (lower_goal, ball, paddle) = spawn_respawn_fixture(&mut app);

    trigger_life_loss(&mut app, ball, lower_goal);

    advance_time(&mut app, 0.016);
    app.update();

    {
        let total_duration = app.world().resource::<RespawnSchedule>().timer.duration();
        let mut schedule = app.world_mut().resource_mut::<RespawnSchedule>();
        schedule
            .timer
            .tick(total_duration + Duration::from_millis(10));
    }
    advance_time(&mut app, 0.0);
    app.update();

    let respawned_ball = {
        let mut query = app
            .world_mut()
            .query_filtered::<Entity, (With<Ball>, With<BallFrozen>)>();
        query
            .iter(app.world())
            .next()
            .expect("respawned ball should exist")
    };

    {
        let world = app.world();
        let velocity = world.entity(respawned_ball).get::<Velocity>().unwrap();
        assert_eq!(velocity.linvel, Vec3::ZERO);
        assert!(world.entity(paddle).contains::<InputLocked>());
    }

    advance_time(&mut app, 0.5);
    app.update();
    {
        let world = app.world();
        let velocity = world.entity(respawned_ball).get::<Velocity>().unwrap();
        assert_eq!(velocity.linvel, Vec3::ZERO);
    }

    {
        let world = app.world_mut();
        world.entity_mut(paddle).remove::<PaddleGrowing>();
    }
    app.update();

    {
        let world = app.world();
        assert!(
            world.resource::<RespawnVisualState>().is_active(),
            "visual overlay should be active before fade completes",
        );
        assert!(world.entity(paddle).contains::<InputLocked>());
    }

    {
        let overlay_entity = {
            let mut query = app
                .world_mut()
                .query_filtered::<Entity, With<RespawnFadeOverlay>>();
            query
                .iter(app.world())
                .next()
                .expect("respawn overlay should exist while fade is active")
        };
        let world = app.world_mut();
        {
            let mut entity = world.entity_mut(overlay_entity);
            let mut overlay = entity
                .get_mut::<RespawnFadeOverlay>()
                .expect("overlay component missing");
            let remaining = overlay.timer().remaining_secs();
            overlay
                .timer_mut()
                .tick(Duration::from_secs_f32(remaining + 0.1));
        }
    }
    advance_time(&mut app, 0.0);
    app.update();

    {
        let world = app.world();
        assert!(
            !world.resource::<RespawnVisualState>().is_active(),
            "overlay should clear after fade completes",
        );
        assert!(
            !world.entity(respawned_ball).contains::<BallFrozen>(),
            "ball should unfreeze once controls unlock",
        );
        assert!(
            !world.entity(paddle).contains::<InputLocked>(),
            "paddle controls should unlock after fade",
        );
    }
}