use crate::entity_registry::{EntityProperties, MapEntityMarker};
use crate::MapRoot;
use bevy::prelude::*;
use bevy_map_animation::{AnimatedSprite, SpriteData};
use bevy_map_core::{MapProject, SpriteConfig};
pub struct MapEntitySpritePlugin;
impl Plugin for MapEntitySpritePlugin {
fn build(&self, app: &mut App) {
app.add_systems(
Update,
(spawn_entity_sprites, complete_entity_sprite_loads).chain(),
);
}
}
#[derive(Component)]
pub struct EntitySpriteSetup {
pub sprite_sheet_id: uuid::Uuid,
pub default_animation: Option<String>,
pub scale: Option<f32>,
pub flip_with_direction: bool,
pub offset: [f32; 2],
}
#[derive(Component)]
pub struct EntitySpriteSpawned;
fn spawn_entity_sprites(
mut commands: Commands,
asset_server: Res<AssetServer>,
entity_query: Query<
(Entity, &MapEntityMarker, Option<&EntityProperties>),
(
Added<MapEntityMarker>,
Without<EntitySpriteSetup>,
Without<EntitySpriteSpawned>,
),
>,
map_root_query: Query<&MapRoot>,
map_assets: Res<Assets<MapProject>>,
) {
let project = map_root_query
.iter()
.find_map(|root| map_assets.get(&root.handle));
let Some(project) = project else {
return;
};
for (entity, marker, entity_props) in entity_query.iter() {
let Some(type_config) = project.get_entity_type_config(&marker.type_name) else {
continue;
};
let Some(base_sprite_config) = &type_config.sprite else {
continue;
};
let sprite_config: SpriteConfig = if let Some(props) = entity_props {
if let Some(ref sprite_overrides) = props.component_overrides.sprite {
base_sprite_config.with_overrides(sprite_overrides)
} else {
base_sprite_config.clone()
}
} else {
base_sprite_config.clone()
};
let sprite_config = &sprite_config;
let Some(sprite_sheet_id) = sprite_config.sprite_sheet_id else {
continue;
};
let Some(sprite_data) = project.get_sprite_sheet(sprite_sheet_id) else {
warn!(
"Sprite sheet {} not found for entity type '{}'",
sprite_sheet_id, marker.type_name
);
continue;
};
let texture_path = &sprite_data.sheet_path;
if texture_path.is_empty() {
warn!(
"Sprite sheet {} has no path for entity type '{}'",
sprite_sheet_id, marker.type_name
);
continue;
}
let asset_path = normalize_asset_path(texture_path);
let texture_handle: Handle<Image> = asset_server.load(asset_path);
let initial_rect = calculate_initial_rect(sprite_data, sprite_config);
let custom_size = sprite_config.scale.map(|s| {
Vec2::new(
sprite_data.frame_width as f32 * s,
sprite_data.frame_height as f32 * s,
)
});
commands.entity(entity).insert((
EntitySpriteSetup {
sprite_sheet_id,
default_animation: sprite_config.default_animation.clone(),
scale: sprite_config.scale,
flip_with_direction: sprite_config.flip_with_direction,
offset: sprite_config.offset,
},
Sprite {
image: texture_handle,
rect: initial_rect,
custom_size,
..default()
},
));
info!(
"Starting sprite load for entity '{}' (type: {}, sheet: {})",
marker.instance_id, marker.type_name, sprite_data.name
);
}
}
fn complete_entity_sprite_loads(
mut commands: Commands,
asset_server: Res<AssetServer>,
mut sprite_data_assets: ResMut<Assets<SpriteData>>,
map_root_query: Query<&MapRoot>,
map_assets: Res<Assets<MapProject>>,
query: Query<(Entity, &EntitySpriteSetup, &Sprite), Without<EntitySpriteSpawned>>,
) {
use bevy::asset::LoadState;
let project = map_root_query
.iter()
.find_map(|root| map_assets.get(&root.handle));
let Some(project) = project else {
return;
};
for (entity, setup, sprite) in query.iter() {
let load_state = asset_server.get_load_state(&sprite.image);
if !matches!(load_state, Some(LoadState::Loaded)) {
continue;
}
let Some(sprite_data) = project.get_sprite_sheet(setup.sprite_sheet_id) else {
commands.entity(entity).insert(EntitySpriteSpawned);
continue;
};
if !sprite_data.animations.is_empty() {
let sprite_data_handle = sprite_data_assets.add(sprite_data.clone());
let mut animated = AnimatedSprite::new(sprite_data_handle);
let initial_anim = setup
.default_animation
.as_ref()
.filter(|name| sprite_data.animations.contains_key(*name))
.cloned()
.or_else(|| {
if sprite_data.animations.contains_key("idle") {
Some("idle".to_string())
} else {
sprite_data.animations.keys().next().cloned()
}
});
if let Some(ref anim_name) = initial_anim {
animated.play(anim_name);
}
commands.entity(entity).insert(animated);
}
commands.entity(entity).insert(EntitySpriteSpawned);
info!(
"Completed sprite setup for entity with sheet {}",
sprite_data.name
);
}
}
fn calculate_initial_rect(sprite_data: &SpriteData, config: &SpriteConfig) -> Option<Rect> {
let first_frame = config
.default_animation
.as_ref()
.and_then(|name| sprite_data.animations.get(name))
.and_then(|anim| anim.frames.first().copied())
.or_else(|| {
sprite_data
.animations
.get("idle")
.and_then(|anim| anim.frames.first().copied())
})
.or_else(|| {
sprite_data
.animations
.values()
.next()
.and_then(|anim| anim.frames.first().copied())
})
.unwrap_or(0);
let columns = sprite_data.columns;
if columns == 0 {
return None;
}
let row = first_frame as u32 / columns;
let col = first_frame as u32 % columns;
Some(Rect::new(
col as f32 * sprite_data.frame_width as f32,
row as f32 * sprite_data.frame_height as f32,
(col + 1) as f32 * sprite_data.frame_width as f32,
(row + 1) as f32 * sprite_data.frame_height as f32,
))
}
fn normalize_asset_path(path: &str) -> String {
let normalized = path.replace('\\', "/");
let lower = normalized.to_lowercase();
if let Some(idx) = lower.find("/assets/") {
return normalized[idx + 8..].to_string();
}
if lower.starts_with("assets/") {
return normalized[7..].to_string();
}
normalized
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_plugin_compiles() {
let _plugin = MapEntitySpritePlugin;
}
#[test]
fn test_normalize_asset_path() {
assert_eq!(
normalize_asset_path("C:/project/assets/sprites/player.png"),
"sprites/player.png"
);
assert_eq!(
normalize_asset_path("assets/sprites/player.png"),
"sprites/player.png"
);
assert_eq!(
normalize_asset_path("sprites/player.png"),
"sprites/player.png"
);
}
}