Crate bevy_yoleck

source ·
Expand description

§Your Own Level Editor Creation Kit

Yoleck is a crate for having a game built with the Bevy game engine act as its own level editor.

Yoleck uses Plain Old Rust Structs to store the data, and uses Serde to store them in files. The user code defines populate systems for creating Bevy entities (populating their components) from these structs and edit systems to edit these structs with egui.

The synchronization between the structs and the files is bidirectional, and so is the synchronization between the structs and the egui widgets, but the synchronization from the structs to the entities is unidirectional - changes in the entities are not reflected in the structs:

┌────────┐  Populate   ┏━━━━━━━━━┓   Edit      ┌───────┐
│Bevy    │  Systems    ┃Yoleck   ┃   Systems   │egui   │
│Entities│◄────────────┃Component┃◄═══════════►│Widgets│
└────────┘             ┃Structs  ┃             └───────┘
                       ┗━━━━━━━━━┛
                           ▲
                           ║
                           ║ Serde
                           ║
                           ▼
                         ┌─────┐
                         │.yol │
                         │Files│
                         └─────┘

To support integrate Yoleck, a game needs to:

To support picking and moving entities in the viewport with the mouse, check out the vpeol_2d and vpeol_3d modules. After adding the appropriate feature flag (vpeol_2d/vpeol_3d), import their types from bevy_yoleck::vpeol::prelude::*.

§Example

use bevy::prelude::*;
use bevy_yoleck::bevy_egui::EguiPlugin;
use bevy_yoleck::prelude::*;
use serde::{Deserialize, Serialize};

fn main() {
    let is_editor = std::env::args().any(|arg| arg == "--editor");

    let mut app = App::new();
    app.add_plugins(DefaultPlugins);
    if is_editor {
        // Doesn't matter in this example, but a proper game would have systems that can work
        // on the entity in `GameState::Game`, so while the level is edited we want to be in
        // `GameState::Editor` - which can be treated as a pause state. When the editor wants
        // to playtest the level we want to move to `GameState::Game` so that they can play it.
        app.add_plugins((
            YoleckSyncWithEditorState {
                when_editor: GameState::Editor,
                when_game: GameState::Game,
            },
            EguiPlugin,
            YoleckPluginForEditor
        ));
    } else {
        app.add_plugins(YoleckPluginForGame);
        app.init_state::<GameState>();
        // In editor mode Yoleck takes care of level loading. In game mode the game needs to
        // tell yoleck which levels to load and when.
        app.add_systems(Update, load_first_level.run_if(in_state(GameState::Loading)));
    }
    app.add_systems(Startup, setup_camera);

    app.add_yoleck_entity_type({
        YoleckEntityType::new("Rectangle")
            .with::<Rectangle>()
    });
    app.add_yoleck_edit_system(edit_rectangle);
    app.add_systems(YoleckSchedule::Populate, populate_rectangle);

    app.run();
}

#[derive(States, Default, Debug, Clone, PartialEq, Eq, Hash)]
enum GameState {
    #[default]
    Loading,
    Game,
    Editor,
}

fn setup_camera(mut commands: Commands) {
    commands.spawn(Camera2dBundle::default());
}

#[derive(Clone, PartialEq, Serialize, Deserialize, Component, YoleckComponent)]
struct Rectangle {
    width: f32,
    height: f32,
}

impl Default for Rectangle {
    fn default() -> Self {
        Self {
            width: 50.0,
            height: 50.0,
        }
    }
}

fn populate_rectangle(mut populate: YoleckPopulate<&Rectangle>) {
    populate.populate(|_ctx, mut cmd, rectangle| {
        cmd.insert(SpriteBundle {
            sprite: Sprite {
                color: Color::RED,
                custom_size: Some(Vec2::new(rectangle.width, rectangle.height)),
                ..Default::default()
            },
            ..Default::default()
        });
    });
}

fn edit_rectangle(mut ui: ResMut<YoleckUi>, mut edit: YoleckEdit<&mut Rectangle>) {
    let Ok(mut rectangle) = edit.get_single_mut() else { return };
    ui.add(egui::Slider::new(&mut rectangle.width, 50.0..=500.0).prefix("Width: "));
    ui.add(egui::Slider::new(&mut rectangle.height, 50.0..=500.0).prefix("Height: "));
}

fn load_first_level(
    mut level_index_handle: Local<Option<Handle<YoleckLevelIndex>>>,
    asset_server: Res<AssetServer>,
    level_index_assets: Res<Assets<YoleckLevelIndex>>,
    mut commands: Commands,
    mut game_state: ResMut<NextState<GameState>>,
) {
    // Keep the handle in local resource, so that Bevy will not unload the level index asset
    // between frames.
    let level_index_handle = level_index_handle
        .get_or_insert_with(|| asset_server.load("levels/index.yoli"))
        .clone();
    let Some(level_index) = level_index_assets.get(level_index_handle) else {
        // During the first invocation of this system, the level index asset is not going to be
        // loaded just yet. Since this system is going to run on every frame during the Loading
        // state, it just has to keep trying until it starts in a frame where it is loaded.
        return;
    };
    // A proper game would have a proper level progression system, but here we are just
    // taking the first level and loading it.
    let level_handle: Handle<YoleckRawLevel> =
        asset_server.load(&format!("levels/{}", level_index[0].filename));
    commands.spawn(YoleckLoadLevel(level_handle));
    game_state.set(GameState::Game);
}

Re-exports§

Modules§

Structs§

Enums§

Traits§

Functions§