concinnity 0.19.16

Asset-driven world engine
Documentation
//! The application: a world plus the loop that runs it.

use alloc::boxed::Box;

#[cfg(feature = "std")]
use std::path::Path;

use concinnity_core::Driver;

use crate::{Error, World, driver};

/// A runnable application.
///
/// Built either from a [`World`] assembled in process, or from a world already
/// compiled into a blob file.
///
/// ```no_run
/// # use concinnity::{App, World};
/// App::from_world(World::new()).run().expect("the app runs");
/// ```
pub struct App {
    inner: Box<dyn Driver>,
}

impl core::fmt::Debug for App {
    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
        f.debug_struct("App").finish_non_exhaustive()
    }
}

impl App {
    /// An app that runs `world`.
    pub fn from_world(world: World) -> Self {
        Self {
            inner: driver::select(world.into_inner()),
        }
    }

    /// An app that runs the world compiled into the blob file at `path`, as
    /// written by the `cook` module. Overflow payload blobs are that
    /// file's siblings named by index, so a world written to `data/0` reads
    /// `data/1`, `data/2`, ... beside it.
    ///
    /// What the app writes at runtime -- its settings, save files, crash
    /// reports, and shader caches -- lands beside the world it read, stepping
    /// out of a directory named `data`, so `from_blob("mygame/data/0")` keeps
    /// them under `mygame/`. A world that declares an `AppConfig` with a `home`
    /// chooses the location itself.
    ///
    /// Loading and running report the same [`Error`], so one `?` carries both.
    ///
    /// ```no_run
    /// # use concinnity::{App, Error};
    /// # fn main() -> Result<(), Error> {
    /// App::from_blob("data/0")?.run()
    /// # }
    /// ```
    #[cfg(feature = "std")]
    pub fn from_blob(path: impl AsRef<Path>) -> Result<Self, Error> {
        concinnity_engine::App::from_blob(path.as_ref())
            .map(|app| Self {
                inner: driver::adopt(app),
            })
            .map_err(crate::error::from_startup)
    }

    /// The same app on the headless loop: the simulation systems stepped on a
    /// fixed virtual timestep, with no window and no renderer. A world that
    /// declares a `GraphicsConfig` keeps it and draws nothing, which is what
    /// lets a test or a simulation-only tool run a world authored to be seen.
    ///
    /// The `no_std` build has no other loop to run, so there it changes
    /// nothing.
    ///
    /// ```no_run
    /// # use concinnity::{App, World};
    /// App::from_world(World::new())
    ///     .into_headless()
    ///     .run()
    ///     .expect("the app runs");
    /// ```
    pub fn into_headless(self) -> Self {
        Self {
            inner: driver::headless(self.inner.into_world()),
        }
    }

    /// Run the app until its window closes, a system stops the world, its last
    /// system finishes, or the process is interrupted.
    ///
    /// A headless run has no window to close and no clock to follow: the world
    /// steps on a fixed virtual timestep, as fast as the host can step it.
    pub fn run(self) -> Result<(), Error> {
        self.inner.run().map_err(Error::from)
    }

    #[cfg(test)]
    pub(crate) fn inner_mut(&mut self) -> &mut dyn Driver {
        &mut *self.inner
    }
}

#[cfg(all(test, feature = "cook"))]
mod tests {
    use super::App;
    use crate::components::DirectionalLight;
    use crate::cook;

    // The ahead-of-time pair, end to end: what `write_blob` puts on disk is
    // what `from_blob` reads back, with no state-tree anchor in between.
    #[test]
    fn a_written_blob_loads_back_into_a_runnable_app() {
        let tree = concinnity_testing::TempTree::new();
        let primary = tree.join("data/0");

        cook::world()
            .add(
                "sun",
                DirectionalLight {
                    intensity: 3.5,
                    ..Default::default()
                },
            )
            .write_blob(&primary)
            .expect("the world is written");

        let app = App::from_blob(&primary).expect("the written blob loads");
        crate::test_support::assert_starts_headless(app);
    }

    // A path with no blob behind it is an error naming the file, not a panic
    // and not an empty world that fails later.
    #[test]
    fn a_missing_blob_reports_the_path_it_could_not_read() {
        let tree = concinnity_testing::TempTree::new();
        let missing = tree.join("concinnity-no-such-blob/0");
        let err = App::from_blob(&missing).expect_err("nothing to load");
        assert!(matches!(err, crate::Error::MissingData { .. }), "{err:?}");
        assert!(err.to_string().contains("concinnity-no-such-blob"), "{err}");
    }
}