codecraft 0.1.1

A minimalist 3D game engine built on parts of Bevy (ECS, color) with wgpu and winit: OpenPBR materials, clustered lighting, an immediate-mode UI, audio and gamepad haptics
Documentation
//! The progress bar centers itself from ScreenSize at draw time, so its
//! geometry is worth pinning: it never reaches the GPU, only the quad list.
use codecraft::ecs::Application as EcsApp;
use std::sync::Arc;
use std::sync::atomic::{AtomicUsize, Ordering};

use codecraft::Time;
use codecraft::scene::{Scene, SceneCommands};
use codecraft::ui::{Progress, ProgressBar, ScreenSize, UiDrawList, UiPlugin, Widget};

const WIDTH: f32 = 800.0;
const HEIGHT: f32 = 600.0;

fn ui_app() -> EcsApp {
    let mut app = EcsApp::new();
    app.add_plugin(UiPlugin);
    app.insert_resource(ScreenSize {
        width: WIDTH,
        height: HEIGHT,
    });
    app
}

/// The quads drawn for a bar at `value`, in the order the system emits them:
/// track first, then fill (if any).
fn quads_for(value: f32) -> Vec<([f32; 2], [f32; 2])> {
    let mut app = ui_app();
    ProgressBar::new()
        .value(value)
        .spawn(&mut app.world, WIDTH, HEIGHT);
    app.update();
    app.world
        .resource::<UiDrawList>()
        .0
        .iter()
        .map(|quad| (quad.pos, quad.size))
        .collect()
}

#[test]
fn an_empty_bar_draws_only_its_track() {
    let quads = quads_for(0.0);
    assert_eq!(quads.len(), 1, "track only, no fill");

    let (pos, size) = quads[0];
    assert_eq!(size[0], WIDTH * 0.34, "default width ratio of the screen");
    assert_eq!(pos[0], WIDTH * 0.5 - size[0] * 0.5, "horizontally centered");
    assert_eq!(pos[1], HEIGHT * 0.5 - size[1] * 0.5, "vertically centered");
}

#[test]
fn a_y_offset_shifts_the_bar_down_by_pixels() {
    let mut app = ui_app();
    ProgressBar::new()
        .y_offset(70.0)
        .spawn(&mut app.world, WIDTH, HEIGHT);
    app.update();

    let quad = app.world.resource::<UiDrawList>().0[0];
    assert_eq!(quad.pos[1], HEIGHT * 0.5 + 70.0 - quad.size[1] * 0.5);
}

#[test]
fn a_half_filled_bar_fills_half_its_inner_width() {
    let quads = quads_for(0.5);
    assert_eq!(quads.len(), 2, "track and fill");

    let (track_pos, track_size) = quads[0];
    let (fill_pos, fill_size) = quads[1];
    let padding = 3.0;
    let inner = track_size[0] - padding * 2.0;

    assert_eq!(fill_size[0], inner * 0.5);
    assert_eq!(fill_size[1], track_size[1] - padding * 2.0);
    assert_eq!(fill_pos[0], track_pos[0] + padding, "fill is inset");
    assert_eq!(fill_pos[1], track_pos[1] + padding);
}

#[test]
fn progress_is_clamped_to_the_track() {
    let full = Progress {
        value: 1.0,
        ..progress_defaults()
    };
    let overfull = Progress {
        value: 4.2,
        ..progress_defaults()
    };
    let negative = Progress {
        value: -1.0,
        ..progress_defaults()
    };

    let track = 200.0;
    assert_eq!(full.filled_width(track), overfull.filled_width(track));
    assert_eq!(negative.filled_width(track), 0.0);
}

/// A bar's defaults, read back off a spawned entity.
fn progress_defaults() -> Progress {
    let mut app = ui_app();
    let entity = ProgressBar::new().spawn(&mut app.world, WIDTH, HEIGHT);
    app.world.get::<Progress>(entity).unwrap().clone()
}

#[test]
fn a_self_filling_bar_advances_with_the_frame_clock() {
    let mut app = ui_app();
    let bar = ProgressBar::new()
        .fill_over(2.0)
        .spawn(&mut app.world, WIDTH, HEIGHT);
    let value = |app: &EcsApp| app.world.get::<Progress>(bar).unwrap().value;

    app.insert_resource(Time {
        delta: 0.5,
        elapsed: 0.5,
    });
    app.update();
    assert_eq!(value(&app), 0.25, "half a second of a two second fill");

    app.update();
    assert_eq!(value(&app), 0.5);

    // Well past the duration: it stops at full rather than overshooting.
    app.insert_resource(Time {
        delta: 60.0,
        elapsed: 61.0,
    });
    app.update();
    assert_eq!(value(&app), 1.0);
}

#[test]
fn a_bar_without_fill_over_is_left_alone() {
    let mut app = ui_app();
    let bar = ProgressBar::new()
        .value(0.3)
        .spawn(&mut app.world, WIDTH, HEIGHT);

    app.insert_resource(Time {
        delta: 5.0,
        elapsed: 5.0,
    });
    app.update();

    assert_eq!(
        app.world.get::<Progress>(bar).unwrap().value,
        0.3,
        "a caller-driven bar only moves when the caller moves it",
    );
}

/// Runs `frames` updates with a fixed delta.
fn run(app: &mut EcsApp, frames: usize, delta: f32) {
    for frame in 0..frames {
        app.insert_resource(Time {
            delta,
            elapsed: delta * (frame + 1) as f32,
        });
        app.update();
    }
}

#[test]
fn a_full_bar_runs_its_callback_exactly_once() {
    let mut app = ui_app();
    let fired = Arc::new(AtomicUsize::new(0));
    let counter = fired.clone();
    ProgressBar::new()
        .fill_over(1.0)
        .on_full(move || {
            counter.fetch_add(1, Ordering::Relaxed);
        })
        .spawn(&mut app.world, WIDTH, HEIGHT);

    run(&mut app, 3, 0.25);
    assert_eq!(fired.load(Ordering::Relaxed), 0, "not full yet");

    run(&mut app, 1, 0.25);
    assert_eq!(fired.load(Ordering::Relaxed), 1, "fires on reaching full");

    // It stays full for many more frames without firing again.
    run(&mut app, 5, 0.25);
    assert_eq!(fired.load(Ordering::Relaxed), 1);
}

#[test]
fn a_caller_driven_bar_fires_when_the_caller_fills_it() {
    let mut app = ui_app();
    let fired = Arc::new(AtomicUsize::new(0));
    let counter = fired.clone();
    let bar = ProgressBar::new()
        .on_full(move || {
            counter.fetch_add(1, Ordering::Relaxed);
        })
        .spawn(&mut app.world, WIDTH, HEIGHT);

    run(&mut app, 1, 0.5);
    assert_eq!(
        fired.load(Ordering::Relaxed),
        0,
        "no fill_over, no movement"
    );

    // Real loading would report progress like this.
    app.world.get_mut::<Progress>(bar).unwrap().value = 1.0;
    run(&mut app, 1, 0.5);
    assert_eq!(fired.load(Ordering::Relaxed), 1);
}

struct Next;
impl Scene for Next {}

#[test]
fn then_scene_queues_the_handover_when_the_bar_fills() {
    let mut app = ui_app();
    let scenes = SceneCommands::new();
    app.insert_resource(scenes.clone());
    ProgressBar::new()
        .fill_over(2.0)
        .then_scene(Next)
        .spawn(&mut app.world, WIDTH, HEIGHT);

    run(&mut app, 3, 0.5);
    assert!(!scenes.is_pending(), "still filling, still on this scene");

    run(&mut app, 1, 0.5);
    assert!(scenes.is_pending(), "full bar hands over");
}