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
use bevy_ecs::prelude::*;

use super::button::ClickHandler;
use super::color::Color;
use super::widget::Widget;
use crate::scene::{Scene, SceneCommands, SceneEntity};

/// A horizontal progress bar: a track with a fill inset inside it.
///
/// Like [`super::HeadingText`], it is centered from [`super::ScreenSize`] at
/// draw time, so it needs no layout bookkeeping across resizes. Drive it by
/// writing [`Progress::value`] (0.0 ..= 1.0); out-of-range values are clamped
/// when drawn.
#[derive(Component, Clone, Debug)]
pub struct Progress {
    pub value: f32,
    /// Seconds to go from empty to full on its own. `None` (the default)
    /// leaves the bar entirely under the caller's control.
    pub fill_over: Option<f32>,
    /// Track width as a fraction of screen width.
    pub width_ratio: f32,
    /// Track height in pixels.
    pub height: f32,
    /// Vertical center as a fraction of screen height (0.5 = middle).
    pub y_ratio: f32,
    /// Pixels to shift down from `y_ratio`, so a bar sits a fixed distance
    /// under a heading rather than one that stretches with the window.
    pub y_offset: f32,
    /// Gap between the track edge and the fill, in pixels.
    pub padding: f32,
    pub track: Color,
    pub fill: Color,
}

impl Progress {
    /// Advances a self-filling bar by one frame. Bars without
    /// [`Progress::fill_over`] are left alone.
    pub fn advance(&mut self, delta: f32) {
        let Some(duration) = self.fill_over else {
            return;
        };
        if duration <= 0.0 {
            self.value = 1.0;
        } else {
            self.value = (self.value + delta / duration).min(1.0);
        }
    }

    /// The fill's width for a track of `track_width` pixels.
    pub fn filled_width(&self, track_width: f32) -> f32 {
        let inner = (track_width - self.padding * 2.0).max(0.0);
        inner * self.value.clamp(0.0, 1.0)
    }
}

/// A callback to run the first time a bar reaches full, set with
/// [`ProgressBar::on_full`] or [`ProgressBar::then_scene`].
#[derive(Component)]
pub struct OnFull {
    handler: ClickHandler,
    armed: bool,
}

impl OnFull {
    pub fn new(handler: ClickHandler) -> Self {
        Self {
            handler,
            armed: true,
        }
    }

    /// Whether the callback is still waiting to run. It fires once, not on
    /// every frame the bar spends full.
    pub fn is_armed(&self) -> bool {
        self.armed
    }

    pub fn fire(&mut self) {
        self.armed = false;
        (self.handler)();
    }
}

/// Builder for a [`Progress`] bar.
///
/// ```no_run
/// # use codecraft::{AppState, ui::ProgressBar};
/// # fn demo(app: &mut AppState) {
/// let bar = app.spawn(ProgressBar::new().y_ratio(0.6));
/// # }
/// ```
pub struct ProgressBar {
    progress: Progress,
    on_full: Option<ClickHandler>,
    next_scene: Option<Box<dyn Scene>>,
}

impl ProgressBar {
    pub fn new() -> Self {
        Self {
            on_full: None,
            next_scene: None,
            progress: Progress {
                value: 0.0,
                fill_over: None,
                width_ratio: 0.34,
                height: 18.0,
                y_ratio: 0.5,
                y_offset: 0.0,
                padding: 3.0,
                track: Color::srgba(0.16, 0.17, 0.22, 0.92),
                fill: Color::srgb(0.42, 0.62, 0.86),
            },
        }
    }

    pub fn value(mut self, value: f32) -> Self {
        self.progress.value = value;
        self
    }

    /// Fills the bar over `seconds`, driven by the frame clock — for a hold
    /// with nothing real to report, like a splash screen. Leave it off and
    /// write [`Progress::value`] to show actual progress.
    pub fn fill_over(mut self, seconds: f32) -> Self {
        self.progress.fill_over = Some(seconds);
        self
    }

    /// Track width as a fraction of screen width.
    pub fn width_ratio(mut self, width_ratio: f32) -> Self {
        self.progress.width_ratio = width_ratio;
        self
    }

    /// Track height in pixels.
    pub fn height(mut self, height: f32) -> Self {
        self.progress.height = height;
        self
    }

    /// Vertical placement as a fraction of screen height (0.5 = middle).
    pub fn y_ratio(mut self, y_ratio: f32) -> Self {
        self.progress.y_ratio = y_ratio;
        self
    }

    /// Pixels to shift down from [`ProgressBar::y_ratio`].
    pub fn y_offset(mut self, y_offset: f32) -> Self {
        self.progress.y_offset = y_offset;
        self
    }

    /// Runs `on_full` the first time the bar reaches full — however it got
    /// there, whether [`ProgressBar::fill_over`] or the caller drove it.
    pub fn on_full(mut self, on_full: impl FnMut() + Send + Sync + 'static) -> Self {
        self.on_full = Some(Box::new(on_full));
        self
    }

    /// Hands over to `scene` once the bar reaches full, so the bar's duration
    /// is the only place the timing is written down.
    ///
    /// ```no_run
    /// # use codecraft::{AppState, scene::Scene, ui::ProgressBar};
    /// # struct MainMenu;
    /// # impl Scene for MainMenu {}
    /// # fn demo(app: &mut AppState) {
    /// app.spawn(ProgressBar::new().fill_over(2.0).then_scene(MainMenu));
    /// # }
    /// ```
    pub fn then_scene(mut self, scene: impl Scene) -> Self {
        self.next_scene = Some(Box::new(scene));
        self
    }

    pub fn colors(mut self, track: Color, fill: Color) -> Self {
        self.progress.track = track;
        self.progress.fill = fill;
        self
    }
}

impl Default for ProgressBar {
    fn default() -> Self {
        Self::new()
    }
}

impl Widget for ProgressBar {
    type Output = Entity;

    fn spawn(self, world: &mut World, _screen_width: f32, _screen_height: f32) -> Entity {
        let handler = full_handler(world, self.on_full, self.next_scene);
        let mut entity = world.spawn((self.progress, SceneEntity));
        if let Some(handler) = handler {
            entity.insert(OnFull::new(handler));
        }
        entity.id()
    }
}

/// Folds an `on_full` callback and a queued scene change into the single
/// callback the entity carries.
fn full_handler(
    world: &mut World,
    on_full: Option<ClickHandler>,
    next_scene: Option<Box<dyn Scene>>,
) -> Option<ClickHandler> {
    let Some(scene) = next_scene else {
        return on_full;
    };

    // The transition queue lives in the world, so a bar can ask for a scene
    // change without the caller threading a handle through.
    let scenes = world
        .get_resource_or_insert_with(SceneCommands::default)
        .clone();
    // A component has to be `Sync`, but a scene only has to be `Send` — it is
    // moved once, never shared. The mutex is what bridges the two, so scenes
    // stay free to hold things like channel receivers.
    let scene = std::sync::Mutex::new(Some(scene));
    let mut on_full = on_full;
    Some(Box::new(move || {
        if let Some(on_full) = on_full.as_mut() {
            on_full();
        }
        if let Some(scene) = scene.lock().expect("scene handoff poisoned").take() {
            scenes.change_boxed(scene);
        }
    }))
}