euv-engine 0.28.21

A high-performance 2D game engine built on the euv framework, featuring ECS, fixed-timestep game loop, canvas rendering, physics, collision detection, sprite animation, and audio.
Documentation
use super::*;

const TOLERANCE: f64 = 1e-6;

fn camera() -> Camera3D {
    Camera3D::create(Vector3D::new(0.0, 0.0, 5.0), Vector3D::zero(), 800.0, 600.0)
}

fn view_space(camera: &Camera3D, world: Vector3D) -> Vector3D {
    camera.view_matrix().transform_point(world)
}

fn orbit_radius(camera: &Camera3D) -> f64 {
    let target_in_view: Vector3D = view_space(camera, Vector3D::zero());
    target_in_view.get_z().abs()
}

fn assert_near(observed: Vector3D, expected: Vector3D, what: &str) {
    assert!(
        (observed.get_x() - expected.get_x()).abs() < TOLERANCE
            && (observed.get_y() - expected.get_y()).abs() < TOLERANCE
            && (observed.get_z() - expected.get_z()).abs() < TOLERANCE,
        "{what}: expected {expected:?}, got {observed:?}"
    );
}

#[test]
fn the_view_matrix_puts_the_eye_at_the_view_origin() {
    let camera: Camera3D = camera();
    let eye: Vector3D = Vector3D::new(0.0, 0.0, 5.0);
    assert_near(
        view_space(&camera, eye),
        Vector3D::zero(),
        "the eye of a look-at camera is always the view-space origin",
    );
}

#[test]
fn the_view_matrix_puts_the_target_straight_down_the_negative_z_axis() {
    let camera: Camera3D = camera();
    assert_near(
        view_space(&camera, Vector3D::zero()),
        Vector3D::new(0.0, 0.0, -5.0),
        "a camera 5 units back from the origin must see it 5 units ahead",
    );
}

#[test]
fn the_view_matrix_leaves_the_up_axis_vertical_for_a_level_camera() {
    let camera: Camera3D = camera();
    let up_in_view: Vector3D = view_space(&camera, Vector3D::up());
    assert!(
        (up_in_view.get_x()).abs() < TOLERANCE,
        "for a camera with no roll the world up axis must stay vertical, got {up_in_view:?}"
    );
    assert!(
        up_in_view.get_y() > 0.0,
        "a level camera keeps looking up as up, got {up_in_view:?}"
    );
}

#[test]
fn the_combined_matrix_puts_the_view_target_inside_normalised_device_coordinates() {
    let camera: Camera3D = camera();
    let clip: Vector3D = camera.view_proj_matrix().transform_point(Vector3D::zero());
    assert!(
        clip.get_x().abs() <= 1.0 && clip.get_y().abs() <= 1.0,
        "a point on the view axis must project to the centre of the frustum, got {clip:?}"
    );
    assert!(
        clip.get_z() >= -1.0 && clip.get_z() <= 1.0,
        "a point in front of the camera must project inside the depth range, got {clip:?}"
    );
}

#[test]
fn a_point_the_camera_looks_at_is_inside_its_frustum() {
    let camera: Camera3D = camera();
    assert!(
        camera.in_frustum(Vector3D::zero()),
        "the point a camera was pointed at is by definition visible"
    );
}

#[test]
fn a_point_behind_the_camera_is_outside_its_frustum() {
    let camera: Camera3D = camera();
    assert!(
        !camera.in_frustum(Vector3D::new(0.0, 0.0, 50.0)),
        "a point 45 units behind a camera at z=5 must not be reported as visible"
    );
}

#[test]
fn a_point_far_off_to_the_side_is_outside_the_frustum() {
    let camera: Camera3D = camera();
    assert!(
        !camera.in_frustum(Vector3D::new(1000.0, 0.0, 0.0)),
        "the horizontal field of view is narrow, so a distant lateral point must be culled"
    );
}

#[test]
fn orbiting_keeps_the_camera_the_same_distance_from_its_target() {
    let mut camera: Camera3D = camera();
    let before: f64 = orbit_radius(&camera);
    camera.orbit(PI / 3.0, 0.2);
    let after: f64 = orbit_radius(&camera);
    assert!(
        (before - after).abs() < TOLERANCE,
        "an orbit rotates around the target, it must not change the orbit radius: {before} -> {after}"
    );
}

#[test]
fn a_full_turn_of_yaw_returns_the_camera_to_its_original_side() {
    let mut camera: Camera3D = camera();
    let start: Vector3D = Vector3D::new(0.0, 0.0, 5.0);
    camera.orbit(TAU, 0.0);
    assert_near(
        view_space(&camera, start),
        Vector3D::zero(),
        "after a full revolution the starting eye point is the eye again",
    );
}

#[test]
fn orbiting_yaw_moves_the_eye_off_its_starting_side() {
    let mut camera: Camera3D = camera();
    let start: Vector3D = Vector3D::new(0.0, 0.0, 5.0);
    camera.orbit(PI, 0.0);
    let moved: Vector3D = view_space(&camera, start);
    assert!(
        moved.get_z() < -1.0,
        "a half turn swings the camera to the far side of the target, so the old eye ends up \
         two radii straight ahead of it, got {moved:?}"
    );
    assert_near(
        moved,
        Vector3D::new(0.0, 0.0, -10.0),
        "a half turn from radius 5 puts the old eye exactly 10 units ahead",
    );
}

#[test]
fn orbiting_saturates_at_the_pitch_clamp_instead_of_tumbling_over_the_pole() {
    let mut camera: Camera3D = camera();
    let radius: f64 = orbit_radius(&camera);
    camera.orbit(0.0, PI);
    let saturated: f64 = orbit_radius(&camera);
    assert!(
        (radius - saturated).abs() < TOLERANCE,
        "clamping the pitch must still keep the camera on the orbit sphere: {radius} -> {saturated}"
    );
    let repeated: f64 = {
        camera.orbit(0.0, PI);
        orbit_radius(&camera)
    };
    assert!(
        (saturated - repeated).abs() < TOLERANCE,
        "the pitch clamp is symmetric, so a second oversized pitch must not drift further"
    );
    assert!(
        camera.in_frustum(Vector3D::new(0.0, -5.0, 0.0)),
        "clamping an upward pitch must leave the camera above the target looking down, \
         so a point below the target is in front of it"
    );
}

#[test]
fn the_target_stays_centred_in_the_viewport_whatever_the_orbit() {
    let mut camera: Camera3D = camera();
    camera.orbit(1.1, -0.4);
    let projected: Vector3D = camera.world_to_screen(Vector3D::zero());
    assert!(
        (projected.get_x() - 400.0).abs() < 1e-3 && (projected.get_y() - 300.0).abs() < 1e-3,
        "orbiting turns the camera around its target, so the target must stay in the centre, got {projected:?}"
    );
}

#[test]
fn zooming_moves_the_camera_toward_the_target_rather_than_along_a_world_axis() {
    let target: Vector3D = Vector3D::new(0.0, 0.0, 0.0);
    let mut camera: Camera3D =
        Camera3D::create(Vector3D::new(0.0, 0.0, 10.0), target, 800.0, 600.0);
    let before: f64 = (camera.get_position() - target).magnitude();

    camera.zoom(2.5);

    let after: f64 = (camera.get_position() - target).magnitude();
    assert!(
        after < before,
        "zoom is a step along the camera's own forward vector, so it has to shorten the \
         distance to the target — a step along a fixed world axis would leave it unchanged \
         for any other viewing angle"
    );
    assert!(
        (before - after - 2.5).abs() < 1e-9,
        "and by exactly the distance asked for, because forward is a normalized vector"
    );
    assert_eq!(
        camera.get_position().get_x(),
        0.0,
        "this target is straight ahead on the z axis, so x cannot drift"
    );
}

#[test]
fn zooming_twice_moves_twice_as_far_as_zooming_once() {
    let mut camera: Camera3D = Camera3D::create(
        Vector3D::new(0.0, 0.0, 10.0),
        Vector3D::new(0.0, 0.0, 0.0),
        800.0,
        600.0,
    );
    let start: Vector3D = camera.get_position();
    let one_step: Vector3D = camera.get_position() + camera.forward().scaled(3.0);

    camera.zoom(3.0);
    let after_one: Vector3D = camera.get_position();
    camera.zoom(3.0);
    let after_two: Vector3D = camera.get_position();

    let first_move: Vector3D = after_one - start;
    let second_move: Vector3D = after_two - after_one;
    assert!(
        (second_move.get_z() - first_move.get_z()).abs() < 1e-9,
        "each zoom is a plain translation along the current forward vector, so the second step \
         is the same length as the first; anything else means the distance is being scaled \
         rather than stepped, which compounds differently on every call"
    );
    assert!(
        (after_one.get_z() - one_step.get_z()).abs() < 1e-9,
        "and one step lands where position + forward * distance says it should"
    );
}