brkrs 0.0.1

Breakout/Arkanoid-style game built in Rust using the Bevy engine, with physics powered by bevy_rapier3d
Documentation
use super::tests::{advance_time, ball_handle_at, paddle_handle_at, test_app};
use super::*;
use bevy::ecs::message::Messages;
use bevy_rapier3d::prelude::{CollisionEvent, Velocity};
use bevy_rapier3d::rapier::prelude::CollisionEventFlags;
use std::time::Duration;

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 ±16ms tolerance after 1s"
        );
    }

    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_eq!(velocity.angvel, Vec3::ZERO);
        assert!(world.entity(respawned_ball).contains::<BallFrozen>());
        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,
            "ball must stay stationary while frozen"
        );
        assert!(world.entity(respawned_ball).contains::<BallFrozen>());
    }

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

    {
        let world = app.world();
        assert!(
            world.entity(respawned_ball).contains::<BallFrozen>(),
            "ball should remain frozen while the overlay fade runs",
        );
        assert!(
            world.resource::<RespawnVisualState>().active,
            "visual overlay stays active until fade completes",
        );
        assert!(
            world.entity(paddle).contains::<InputLocked>(),
            "paddle control must stay locked until animation finishes",
        );
    }

    {
        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 visual state 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 timer");
        let remaining = overlay.timer.remaining_secs();
        overlay.timer.tick(Duration::from_secs_f32(remaining + 0.1));
    }
    advance_time(&mut app, 0.0);
    app.update();

    {
        let world = app.world();
        assert!(
            !world.resource::<RespawnVisualState>().active,
            "overlay state should clear once fade completes",
        );
        assert!(
            !world.entity(respawned_ball).contains::<BallFrozen>(),
            "ball should unfreeze once paddle is ready and overlay finishes",
        );
        assert!(
            !world.entity(paddle).contains::<InputLocked>(),
            "paddle should regain control after growth and fade",
        );
    }
}