codecraft 0.1.2

A minimalist 3D game engine built on parts of Bevy (ECS, color) with wgpu and winit: OpenPBR materials, clustered lighting, a yakui-drawn UI, audio and gamepad haptics; its binary maps any folder, and the symbols of its Rust files, as a 3D wall of boxes
Documentation
//! Scenes: self-contained screens (splash, menu, game) that own their state
//! and swap at runtime through [`SceneCommands`].
use std::sync::{Arc, Mutex};

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

/// One screen of the game.
///
/// ```no_run
/// # use codecraft::{AppState, scene::Scene, ui};
/// struct MainMenu;
///
/// impl Scene for MainMenu {
///     fn update(&mut self, app: &mut AppState) {
///         if ui::menu(&["QUIT"]) == Some(0) {
///             std::process::exit(0);
///         }
///     }
/// }
/// ```
pub trait Scene: Send + 'static {
    /// Runs once when this scene becomes active, after the previous scene's entities are cleared.
    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;
    }
}

struct Pending {
    countdown: f32,
    scene: Box<dyn Scene>,
}

/// A clonable handle for requesting a scene change; applied at a frame boundary.
#[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; a second call replaces the first.
    pub fn change(&self, scene: impl Scene) {
        self.change_after(0.0, scene);
    }

    /// Queues `scene` to take over in `seconds`.
    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.
    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()
    }

    pub(crate) fn tick(&self, delta: f32) {
        if let Some(pending) = self.pending.lock().expect("scene queue poisoned").as_mut() {
            pending.countdown -= delta;
        }
    }

    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
    }
}

/// Something that can spawn itself into the world via [`crate::AppState::spawn`].
pub trait Spawn {
    type Output;

    fn spawn(self, world: &mut World) -> Self::Output;
}

/// Marks an entity as belonging to the active scene, so [`clear_scene`] despawns it.
#[derive(Component)]
pub struct SceneEntity;

/// Despawns everything belonging to the outgoing scene.
// Only [`SceneEntity`] entities: bevy_ecs backs 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);
        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());
    }
}