Skip to content

Commit

Permalink
feat!: redesign LdtkProject with better level data accessors and corr…
Browse files Browse the repository at this point in the history
…ect modeling of internal/external levels (#244)

Closes #205

This is the final PR for redesigning the asset types. The main
improvements of this redesign are that the type provides better APIs for
accessing raw or loaded level data, and internal/external levels are
modeled more correctly.

The `LdtkProject` type is still the asset type and stores most metadata
applicable to either level locale, along with `LdtkProjectData`.

`LdtkProjectData` is a enum whose variants store the actual ldtk json
data, with a variant for each level locality. The internal types of the
variants are `LdtkJsonWithMetadata<L: LevelLocale>`, with either
`InternalLevels` or `ExternalLevels` as `L`.

`LdtkJsonWithMetadata<L>` is a generic type storing the actual ldtk json
data + level metadata, with either `InternalLevels` or `ExternalLevels`
as `L`.

Splitting these up like this allows us to define some methods that are
exclusive to each level locality. This is important because accessing
loaded level data is a very different operation memory-wise for each
case (indexing the `LdtkJson` for internal-levels, or accessing the
`Assets<LdtkLevel>` asset store for external levels). But other methods
can be provided for either case, either with a generic implementation at
the lowest level, or exposing transparent implementations in the
higher-level types.

An important point about this new design is that `LdtkLevel` assets are
no longer used in the internal-levels case. Level entities will only
ever have a `LevelIid` component, no longer a `Handle<LdtkLevel>`. The
handle for the asset in the external-levels case is only stored inside
the `LdtkProject`. To help make this change clear, `LdtkLevel` has been
renamed `LdtkExternalLevel`. Doing things this way actually fixes an
egregious clone of all level data that is in all previous versions of
this plugin.

See the documentation of `LdtkProject` to learn about the best ways to
access loaded level data now.

feat!: LdtkLevel renamed to LdtkExternalLevel and is no longer used as a component (#244)
  • Loading branch information
Trouv authored Oct 18, 2023
1 parent c530bc9 commit 670cd4e
Show file tree
Hide file tree
Showing 14 changed files with 1,178 additions and 773 deletions.
18 changes: 10 additions & 8 deletions examples/field_instances/level_title.rs
Original file line number Diff line number Diff line change
Expand Up @@ -11,22 +11,24 @@ pub struct LevelTitle(String);

pub fn set_level_title_to_current_level(
mut level_events: EventReader<LevelEvent>,
level_handles: Query<&Handle<LdtkLevel>>,
level_assets: Res<Assets<LdtkLevel>>,
levels: Query<&LevelIid>,
projects: Query<&Handle<LdtkProject>>,
project_assets: Res<Assets<LdtkProject>>,
mut current_level_title: ResMut<LevelTitle>,
) {
for level_event in level_events.iter() {
if matches!(level_event, LevelEvent::Transformed(_)) {
let level_handle = level_handles
let level_iid = levels
.get_single()
.expect("only one level should be spawned at a time in this example");

let level_asset = level_assets
.get(level_handle)
.expect("level asset should be loaded before LevelEvent::Transformed");
let level_data = project_assets
.get(projects.single())
.expect("project asset should be loaded if levels are spawned")
.get_raw_level_by_iid(&level_iid.to_string())
.expect("spawned level should exist in the loaded project");

let title = level_asset
.data()
let title = level_data
.get_string_field("title")
.expect("level should have non-nullable title string field");

Expand Down
148 changes: 80 additions & 68 deletions examples/platformer/systems.rs
Original file line number Diff line number Diff line change
Expand Up @@ -79,8 +79,9 @@ pub fn spawn_wall_collision(
mut commands: Commands,
wall_query: Query<(&GridCoords, &Parent), Added<Wall>>,
parent_query: Query<&Parent, Without<Wall>>,
level_query: Query<(Entity, &Handle<LdtkLevel>)>,
levels: Res<Assets<LdtkLevel>>,
level_query: Query<(Entity, &LevelIid)>,
ldtk_projects: Query<&Handle<LdtkProject>>,
ldtk_project_assets: Res<Assets<LdtkProject>>,
) {
/// Represents a wide wall that is 1 tile tall
/// Used to spawn wall collisions
Expand Down Expand Up @@ -120,22 +121,23 @@ pub fn spawn_wall_collision(
});

if !wall_query.is_empty() {
level_query.for_each(|(level_entity, level_handle)| {
level_query.for_each(|(level_entity, level_iid)| {
if let Some(level_walls) = level_to_wall_locations.get(&level_entity) {
let level = levels
.get(level_handle)
.expect("Level should be loaded by this point");
let ldtk_project = ldtk_project_assets
.get(ldtk_projects.single())
.expect("Project should be loaded if level has spawned");

let level = ldtk_project
.as_standalone()
.get_loaded_level_by_iid(&level_iid.to_string())
.expect("Spawned level should exist in LDtk project");

let LayerInstance {
c_wid: width,
c_hei: height,
grid_size,
..
} = level
.data()
.layer_instances
.clone()
.expect("Level asset should have layers")[0];
} = level.layer_instances()[0];

// combine wall tiles into flat "plates" in each individual row
let mut plate_stack: Vec<Vec<Plate>> = Vec::new();
Expand Down Expand Up @@ -319,12 +321,10 @@ pub fn camera_fit_inside_current_level(
Without<Player>,
>,
player_query: Query<&Transform, With<Player>>,
level_query: Query<
(&Transform, &Handle<LdtkLevel>),
(Without<OrthographicProjection>, Without<Player>),
>,
level_query: Query<(&Transform, &LevelIid), (Without<OrthographicProjection>, Without<Player>)>,
ldtk_projects: Query<&Handle<LdtkProject>>,
level_selection: Res<LevelSelection>,
ldtk_levels: Res<Assets<LdtkLevel>>,
ldtk_project_assets: Res<Assets<LdtkProject>>,
) {
if let Ok(Transform {
translation: player_translation,
Expand All @@ -335,67 +335,79 @@ pub fn camera_fit_inside_current_level(

let (mut orthographic_projection, mut camera_transform) = camera_query.single_mut();

for (level_transform, level_handle) in &level_query {
if let Some(ldtk_level) = ldtk_levels.get(level_handle) {
let level = &ldtk_level.data();
if level_selection.is_match(&LevelIndices::default(), level) {
let level_ratio = level.px_wid as f32 / ldtk_level.data().px_hei as f32;
orthographic_projection.viewport_origin = Vec2::ZERO;
if level_ratio > ASPECT_RATIO {
// level is wider than the screen
let height = (level.px_hei as f32 / 9.).round() * 9.;
let width = height * ASPECT_RATIO;
orthographic_projection.scaling_mode =
bevy::render::camera::ScalingMode::Fixed { width, height };
camera_transform.translation.x =
(player_translation.x - level_transform.translation.x - width / 2.)
.clamp(0., level.px_wid as f32 - width);
camera_transform.translation.y = 0.;
} else {
// level is taller than the screen
let width = (level.px_wid as f32 / 16.).round() * 16.;
let height = width / ASPECT_RATIO;
orthographic_projection.scaling_mode =
bevy::render::camera::ScalingMode::Fixed { width, height };
camera_transform.translation.y =
(player_translation.y - level_transform.translation.y - height / 2.)
.clamp(0., level.px_hei as f32 - height);
camera_transform.translation.x = 0.;
}

camera_transform.translation.x += level_transform.translation.x;
camera_transform.translation.y += level_transform.translation.y;
for (level_transform, level_iid) in &level_query {
let ldtk_project = ldtk_project_assets
.get(ldtk_projects.single())
.expect("Project should be loaded if level has spawned");

let level = ldtk_project
.get_raw_level_by_iid(&level_iid.to_string())
.expect("Spawned level should exist in LDtk project");

if level_selection.is_match(&LevelIndices::default(), level) {
let level_ratio = level.px_wid as f32 / level.px_hei as f32;
orthographic_projection.viewport_origin = Vec2::ZERO;
if level_ratio > ASPECT_RATIO {
// level is wider than the screen
let height = (level.px_hei as f32 / 9.).round() * 9.;
let width = height * ASPECT_RATIO;
orthographic_projection.scaling_mode =
bevy::render::camera::ScalingMode::Fixed { width, height };
camera_transform.translation.x =
(player_translation.x - level_transform.translation.x - width / 2.)
.clamp(0., level.px_wid as f32 - width);
camera_transform.translation.y = 0.;
} else {
// level is taller than the screen
let width = (level.px_wid as f32 / 16.).round() * 16.;
let height = width / ASPECT_RATIO;
orthographic_projection.scaling_mode =
bevy::render::camera::ScalingMode::Fixed { width, height };
camera_transform.translation.y =
(player_translation.y - level_transform.translation.y - height / 2.)
.clamp(0., level.px_hei as f32 - height);
camera_transform.translation.x = 0.;
}

camera_transform.translation.x += level_transform.translation.x;
camera_transform.translation.y += level_transform.translation.y;
}
}
}
}

pub fn update_level_selection(
level_query: Query<(&Handle<LdtkLevel>, &Transform), Without<Player>>,
level_query: Query<(&LevelIid, &Transform), Without<Player>>,
player_query: Query<&Transform, With<Player>>,
mut level_selection: ResMut<LevelSelection>,
ldtk_levels: Res<Assets<LdtkLevel>>,
ldtk_projects: Query<&Handle<LdtkProject>>,
ldtk_project_assets: Res<Assets<LdtkProject>>,
) {
for (level_handle, level_transform) in &level_query {
if let Some(ldtk_level) = ldtk_levels.get(level_handle) {
let level_bounds = Rect {
min: Vec2::new(level_transform.translation.x, level_transform.translation.y),
max: Vec2::new(
level_transform.translation.x + ldtk_level.data().px_wid as f32,
level_transform.translation.y + ldtk_level.data().px_hei as f32,
),
};

for player_transform in &player_query {
if player_transform.translation.x < level_bounds.max.x
&& player_transform.translation.x > level_bounds.min.x
&& player_transform.translation.y < level_bounds.max.y
&& player_transform.translation.y > level_bounds.min.y
&& !level_selection.is_match(&LevelIndices::default(), ldtk_level.data())
{
*level_selection = LevelSelection::iid(ldtk_level.data().iid.clone());
}
for (level_iid, level_transform) in &level_query {
let ldtk_project = ldtk_project_assets
.get(ldtk_projects.single())
.expect("Project should be loaded if level has spawned");

let level = ldtk_project
.get_raw_level_by_iid(&level_iid.to_string())
.expect("Spawned level should exist in LDtk project");

let level_bounds = Rect {
min: Vec2::new(level_transform.translation.x, level_transform.translation.y),
max: Vec2::new(
level_transform.translation.x + level.px_wid as f32,
level_transform.translation.y + level.px_hei as f32,
),
};

for player_transform in &player_query {
if player_transform.translation.x < level_bounds.max.x
&& player_transform.translation.x > level_bounds.min.x
&& player_transform.translation.y < level_bounds.max.y
&& player_transform.translation.y > level_bounds.min.y
&& !level_selection.is_match(&LevelIndices::default(), level)
{
*level_selection = LevelSelection::iid(level.iid.clone());
}
}
}
Expand Down Expand Up @@ -479,7 +491,7 @@ pub fn update_on_ground(

pub fn restart_level(
mut commands: Commands,
level_query: Query<Entity, With<Handle<LdtkLevel>>>,
level_query: Query<Entity, With<LevelIid>>,
input: Res<Input<KeyCode>>,
) {
if input.just_pressed(KeyCode::R) {
Expand Down
18 changes: 11 additions & 7 deletions src/assets/ldtk_asset_plugin.rs
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
use crate::assets::{
ldtk_level::LdtkLevelLoader, ldtk_project::LdtkProjectLoader, LdtkLevel, LdtkProject,
};
#[cfg(feature = "external_levels")]
use crate::assets::{ldtk_external_level::LdtkExternalLevelLoader, LdtkExternalLevel};
use crate::assets::{ldtk_project::LdtkProjectLoader, LdtkProject};
use bevy::prelude::*;

/// Plugin that registers LDtk-related assets.
Expand All @@ -10,9 +10,13 @@ pub struct LdtkAssetPlugin;
impl Plugin for LdtkAssetPlugin {
fn build(&self, app: &mut App) {
app.add_asset::<LdtkProject>()
.init_asset_loader::<LdtkProjectLoader>()
.add_asset::<LdtkLevel>()
.init_asset_loader::<LdtkLevelLoader>()
.register_asset_reflect::<LdtkLevel>();
.init_asset_loader::<LdtkProjectLoader>();

#[cfg(feature = "external_levels")]
{
app.add_asset::<LdtkExternalLevel>()
.init_asset_loader::<LdtkExternalLevelLoader>()
.register_asset_reflect::<LdtkExternalLevel>();
}
}
}
107 changes: 107 additions & 0 deletions src/assets/ldtk_external_level.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
use crate::ldtk::{loaded_level::LoadedLevel, Level};
use bevy::{
asset::{AssetLoader, LoadContext, LoadedAsset},
prelude::*,
reflect::TypeUuid,
utils::BoxedFuture,
};
use thiserror::Error;

/// Secondary asset for loading external-levels ldtk files, specific to level data.
///
/// Loaded as a dependency of the [`LdtkProject`] asset.
///
/// Requires the `external_levels` feature to be enabled.
///
/// [`LdtkProject`]: crate::assets::LdtkProject
#[derive(Clone, Debug, PartialEq, TypeUuid, Reflect)]
#[uuid = "5448469b-2134-44f5-a86c-a7b829f70a0c"]
pub struct LdtkExternalLevel {
/// Raw LDtk level data.
data: Level,
}

impl LdtkExternalLevel {
/// Construct a new [`LdtkExternalLevel`].
///
/// Only available for testing.
/// This type should only be constructed via the bevy asset system under normal use.
#[cfg(test)]
pub fn new(data: Level) -> LdtkExternalLevel {
LdtkExternalLevel { data }
}

/// Internal LDtk level data as a [`LoadedLevel`].
pub fn data(&self) -> LoadedLevel {
LoadedLevel::try_from(&self.data)
.expect("construction of LdtkExternalLevel should guarantee that the level is loaded.")
}
}

/// Errors that can occur when loading an [`LdtkExternalLevel`] asset.
#[derive(Debug, Error)]
pub enum LdtkExternalLevelLoaderError {
/// External LDtk level should contain all level data, but some level has null layers.
#[error("external LDtk level should contain all level data, but some level has null layers")]
NullLayers,
}

/// AssetLoader for [`LdtkExternalLevel`]
#[derive(Default)]
pub struct LdtkExternalLevelLoader;

impl AssetLoader for LdtkExternalLevelLoader {
fn load<'a>(
&'a self,
bytes: &'a [u8],
load_context: &'a mut LoadContext,
) -> BoxedFuture<'a, anyhow::Result<()>> {
Box::pin(async move {
let data: Level = serde_json::from_slice(bytes)?;

if data.layer_instances.is_none() {
Err(LdtkExternalLevelLoaderError::NullLayers)?;
}

let ldtk_level = LdtkExternalLevel { data };

let loaded_asset = LoadedAsset::new(ldtk_level);

load_context.set_default_asset(loaded_asset);
Ok(())
})
}

fn extensions(&self) -> &[&str] {
&["ldtkl"]
}
}

#[cfg(test)]
mod tests {
use fake::{Fake, Faker};

use crate::ldtk::fake::UnloadedLevelFaker;

use super::*;

#[test]
fn data_accessor_for_loaded_level_succeeds() {
// default level faker creates a loaded level
let level: Level = Faker.fake();

let ldtk_external_level = LdtkExternalLevel::new(level.clone());

assert_eq!(ldtk_external_level.data().raw(), &level);
}

#[test]
#[should_panic]
fn data_accessor_for_unloaded_level_panics() {
let level: Level = UnloadedLevelFaker.fake();

let ldtk_external_level = LdtkExternalLevel::new(level.clone());

let _should_panic = ldtk_external_level.data();
}
}
Loading

0 comments on commit 670cd4e

Please sign in to comment.