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
//! Scenes: self-contained screens (splash, menu, game) that own their state
//! and swap at runtime.
//!
//! A scene is set up once when it becomes active and ticked every frame while
//! it stays active. Transitions go through [`SceneCommands`], a cheap handle
//! that can be cloned into UI callbacks, so a button can request the next
//! scene without holding a borrow on [`AppState`].
use std::sync::{Arc, Mutex};

use crate::ecs::{Component, Entity, Resource, With, World};
use crate::state::AppState;

/// One screen of the game.
///
/// Both methods have default implementations, so a scene only overrides what
/// it needs — a static menu just implements [`Scene::setup`].
///
/// ```no_run
/// # use codecraft::{AppState, scene::Scene, ui::{Button, Panel}};
/// struct MainMenu;
///
/// impl Scene for MainMenu {
///     fn setup(&mut self, app: &mut AppState) {
///         app.spawn(Panel::new("MainMenu").add(Button::new("Quit", || std::process::exit(0))));
///     }
/// }
/// ```
pub trait Scene: Send + 'static {
    /// Runs once, when this scene becomes the active one. The world has
    /// already been cleared of the previous scene's entities.
    fn setup(&mut self, app: &mut AppState) {
        let _ = app;
    }

    /// Runs every frame, before the UI update and render.
    fn update(&mut self, app: &mut AppState) {
        let _ = app;
    }
}

/// A queued transition, and how long is left before it happens.
struct Pending {
    countdown: f32,
    scene: Box<dyn Scene>,
}

/// A clonable handle for requesting a scene change from anywhere — scene
/// methods, UI callbacks, ECS systems.
///
/// The request is queued and applied by the event loop at a frame boundary,
/// so it is safe to call from inside a click handler that is itself owned by
/// an entity the transition will despawn.
#[derive(Resource, Clone, Default)]
pub struct SceneCommands {
    pending: Arc<Mutex<Option<Pending>>>,
}

impl SceneCommands {
    pub fn new() -> Self {
        Self::default()
    }

    /// Queues `scene` as the next active scene, from the next frame. A second
    /// call replaces the first, pending or delayed.
    pub fn change(&self, scene: impl Scene) {
        self.change_after(0.0, scene);
    }

    /// Queues `scene` to take over in `seconds`, letting the current scene
    /// stay up meanwhile — a splash holding on its title, a results screen
    /// pausing before the menu.
    pub fn change_after(&self, seconds: f32, scene: impl Scene) {
        self.queue(seconds, Box::new(scene));
    }

    /// [`SceneCommands::change`] for a scene that is already boxed — for
    /// callers holding a `Box<dyn Scene>` rather than a concrete type.
    pub fn change_boxed(&self, scene: Box<dyn Scene>) {
        self.queue(0.0, scene);
    }

    fn queue(&self, countdown: f32, scene: Box<dyn Scene>) {
        let pending = Pending { countdown, scene };
        *self.pending.lock().expect("scene queue poisoned") = Some(pending);
    }

    /// Whether a transition is queued, however far off.
    pub fn is_pending(&self) -> bool {
        self.pending.lock().expect("scene queue poisoned").is_some()
    }

    /// Counts a frame off a delayed transition. Called once per frame by the
    /// event loop.
    pub(crate) fn tick(&self, delta: f32) {
        if let Some(pending) = self.pending.lock().expect("scene queue poisoned").as_mut() {
            pending.countdown -= delta;
        }
    }

    /// Takes the queued scene once its countdown has run out.
    pub(crate) fn take_ready(&self) -> Option<Box<dyn Scene>> {
        let mut pending = self.pending.lock().expect("scene queue poisoned");
        if pending.as_ref().is_some_and(|p| p.countdown <= 0.0) {
            return pending.take().map(|p| p.scene);
        }
        None
    }
}

/// Marks an entity as belonging to the active scene, so [`clear_scene`] can
/// despawn it on a transition.
///
/// Every built-in widget adds this, as does [`crate::AppState::spawn_entity`].
/// Spawning straight into the world without it leaks the entity across scenes.
#[derive(Component)]
pub struct SceneEntity;

/// Despawns everything belonging to the outgoing scene.
///
/// Only entities carrying [`SceneEntity`] are touched: `bevy_ecs` backs
/// component and resource registration with entities of its own, and
/// despawning those corrupts the world.
pub fn clear_scene(world: &mut World) {
    let mut scene_entities = world.query_filtered::<Entity, With<SceneEntity>>();
    let entities = scene_entities.iter(world).collect::<Vec<_>>();
    for entity in entities {
        world.despawn(entity);
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    struct Next;
    impl Scene for Next {}

    #[test]
    fn an_immediate_change_is_ready_at_once() {
        let commands = SceneCommands::new();
        commands.change(Next);

        assert!(commands.is_pending());
        assert!(commands.take_ready().is_some());
        assert!(!commands.is_pending(), "taking it clears the queue");
    }

    #[test]
    fn a_delayed_change_waits_out_its_countdown() {
        let commands = SceneCommands::new();
        commands.change_after(2.0, Next);

        commands.tick(1.5);
        assert!(commands.take_ready().is_none(), "still counting down");
        assert!(commands.is_pending(), "and still queued");

        commands.tick(0.6);
        assert!(commands.take_ready().is_some(), "countdown ran out");
    }

    #[test]
    fn a_later_request_replaces_a_pending_one() {
        let commands = SceneCommands::new();
        commands.change_after(10.0, Next);
        // e.g. the player clicks through a splash before it finishes.
        commands.change(Next);

        assert!(
            commands.take_ready().is_some(),
            "the immediate request wins"
        );
    }

    #[test]
    fn nothing_is_taken_from_an_empty_queue() {
        let commands = SceneCommands::new();
        commands.tick(1.0);

        assert!(!commands.is_pending());
        assert!(commands.take_ready().is_none());
    }
}