pub mod bevy_cli;
pub mod commands;
pub mod game_runner;
pub mod preferences;
pub mod project;
pub mod render;
pub mod tools;
pub mod ui;
pub use bevy_map_autotile;
pub use bevy_map_core;
pub use bevy_map_schema;
#[cfg(feature = "runtime")]
pub use bevy_map_runtime;
use bevy::prelude::*;
use bevy_egui::EguiPlugin;
use std::path::PathBuf;
use commands::clipboard::TileSelection;
use commands::{handle_keyboard_shortcuts, CommandHistory, TileClipboard};
use game_runner::{AsyncBuildHandle, GameBuildState};
use project::Project;
use render::MapRenderPlugin;
use tools::EditorToolsPlugin;
use ui::{
AnimationEditorState, DialogueEditorState, EditorTool, EditorUiPlugin, EntityPaintState,
GameSettingsDialogState, PendingAction, SchemaEditorState, Selection, SpriteSheetEditorState,
TerrainPaintState, TilesetEditorState, ToolMode,
};
#[derive(Debug, Clone, PartialEq)]
pub enum PathError {
FileNotFound(String),
OutsideAssetsDirectory(PathBuf),
CopyFailed(String),
}
impl std::fmt::Display for PathError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
PathError::FileNotFound(path) => write!(f, "File not found: {}", path),
PathError::OutsideAssetsDirectory(path) => {
write!(f, "File is outside assets directory: {}", path.display())
}
PathError::CopyFailed(msg) => write!(f, "Failed to copy file: {}", msg),
}
}
}
#[derive(Resource, Default)]
pub struct AssetsBasePath(pub PathBuf);
impl AssetsBasePath {
pub fn new(path: impl Into<PathBuf>) -> Self {
Self(path.into())
}
pub fn path(&self) -> &std::path::Path {
&self.0
}
pub fn to_relative(&self, absolute_path: &std::path::Path) -> PathBuf {
match self.to_relative_checked(absolute_path) {
Ok(path) => path,
Err(_) => {
absolute_path.to_path_buf()
}
}
}
pub fn to_relative_checked(
&self,
absolute_path: &std::path::Path,
) -> Result<PathBuf, PathError> {
let assets_path = self.0.canonicalize().unwrap_or_else(|_| self.0.clone());
let file_path = absolute_path
.canonicalize()
.map_err(|_| PathError::FileNotFound(absolute_path.to_string_lossy().to_string()))?;
if let Ok(relative) = file_path.strip_prefix(&assets_path) {
let relative_str = relative.to_string_lossy().replace('\\', "/");
Ok(PathBuf::from(relative_str))
} else {
Err(PathError::OutsideAssetsDirectory(
absolute_path.to_path_buf(),
))
}
}
pub fn is_inside_assets(&self, absolute_path: &std::path::Path) -> bool {
self.to_relative_checked(absolute_path).is_ok()
}
pub fn copy_to_assets(&self, source_path: &std::path::Path) -> Result<PathBuf, PathError> {
let filename = source_path
.file_name()
.ok_or_else(|| PathError::CopyFailed("Invalid filename".to_string()))?;
let tiles_dir = self.0.join("tiles");
let dest_path = tiles_dir.join(filename);
std::fs::create_dir_all(&tiles_dir).map_err(|e| {
PathError::CopyFailed(format!("Failed to create tiles directory: {}", e))
})?;
if dest_path.exists() {
let source_canon = source_path.canonicalize().ok();
let dest_canon = dest_path.canonicalize().ok();
if source_canon == dest_canon {
return Ok(PathBuf::from(format!(
"tiles/{}",
filename.to_string_lossy()
)));
}
let stem = source_path
.file_stem()
.unwrap_or_default()
.to_string_lossy();
let ext = source_path
.extension()
.map(|e| e.to_string_lossy().to_string())
.unwrap_or_default();
let unique_name = format!("{}_{}.{}", stem, uuid::Uuid::new_v4().simple(), ext);
let dest_path = tiles_dir.join(&unique_name);
std::fs::copy(source_path, &dest_path)
.map_err(|e| PathError::CopyFailed(format!("Failed to copy file: {}", e)))?;
return Ok(PathBuf::from(format!("tiles/{}", unique_name)));
}
std::fs::copy(source_path, &dest_path)
.map_err(|e| PathError::CopyFailed(format!("Failed to copy file: {}", e)))?;
Ok(PathBuf::from(format!(
"tiles/{}",
filename.to_string_lossy()
)))
}
}
pub fn to_asset_path(path: &str) -> String {
let normalized = path.replace('\\', "/");
if normalized.len() >= 2 && normalized.chars().nth(1) == Some(':') {
normalized
} else {
normalized
}
}
pub fn is_absolute_path(path: &str) -> bool {
let path = std::path::Path::new(path);
path.is_absolute()
}
#[derive(Debug, Clone, Default, PartialEq)]
pub enum CopyFileCallback {
#[default]
None,
NewTileset,
AddTilesetImage,
}
#[derive(Default)]
pub struct TerrainPreviewState {
pub preview_tiles: Vec<((i32, i32), u32)>,
pub active: bool,
pub tileset_id: Option<uuid::Uuid>,
}
#[derive(Default)]
pub struct BrushPreviewState {
pub position: Option<(i32, i32)>,
pub active: bool,
}
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub enum EditorViewMode {
#[default]
Level,
World,
}
#[derive(Clone, Debug, PartialEq)]
pub enum RenamingItem {
DataInstance(uuid::Uuid),
Entity(uuid::Uuid, uuid::Uuid),
Level(uuid::Uuid),
Layer(uuid::Uuid, usize),
Tileset(uuid::Uuid),
SpriteSheet(uuid::Uuid),
Dialogue(String),
}
#[derive(Clone, Debug)]
pub struct EditorStateConfig {
pub show_grid: bool,
pub show_collisions: bool,
pub snap_to_grid: bool,
pub initial_zoom: f32,
pub initial_tool: EditorTool,
}
impl Default for EditorStateConfig {
fn default() -> Self {
Self {
show_grid: true,
show_collisions: false,
snap_to_grid: true,
initial_zoom: 1.0,
initial_tool: EditorTool::Select,
}
}
}
pub struct EditorPlugin {
pub assets_path: Option<PathBuf>,
pub initial_state: EditorStateConfig,
}
impl Default for EditorPlugin {
fn default() -> Self {
Self {
assets_path: None,
initial_state: EditorStateConfig::default(),
}
}
}
impl EditorPlugin {
pub fn new() -> Self {
Self::default()
}
pub fn with_assets_path(mut self, path: impl Into<PathBuf>) -> Self {
self.assets_path = Some(path.into());
self
}
pub fn with_initial_grid(mut self, show: bool) -> Self {
self.initial_state.show_grid = show;
self
}
pub fn with_show_collisions(mut self, show: bool) -> Self {
self.initial_state.show_collisions = show;
self
}
pub fn with_snap_to_grid(mut self, snap: bool) -> Self {
self.initial_state.snap_to_grid = snap;
self
}
pub fn with_initial_zoom(mut self, zoom: f32) -> Self {
self.initial_state.initial_zoom = zoom.clamp(0.25, 4.0);
self
}
pub fn with_initial_tool(mut self, tool: EditorTool) -> Self {
self.initial_state.initial_tool = tool;
self
}
fn detect_assets_path(&self) -> PathBuf {
if let Some(path) = &self.assets_path {
return path.clone();
}
std::env::current_dir()
.map(|p| p.join("assets"))
.unwrap_or_else(|_| PathBuf::from("assets"))
}
}
impl Plugin for EditorPlugin {
fn build(&self, app: &mut App) {
let assets_path = self.detect_assets_path();
bevy::log::info!("EditorPlugin: Using assets path: {:?}", assets_path);
let preferences = preferences::EditorPreferences::load();
bevy::log::info!("Loaded editor preferences");
let mut editor_state = EditorState::default();
editor_state.show_grid = self.initial_state.show_grid;
editor_state.show_collisions = self.initial_state.show_collisions;
editor_state.snap_to_grid = self.initial_state.snap_to_grid;
editor_state.zoom = self.initial_state.initial_zoom;
editor_state.current_tool = self.initial_state.initial_tool;
app.add_plugins(EguiPlugin::default())
.add_plugins(EditorUiPlugin)
.add_plugins(MapRenderPlugin)
.add_plugins(EditorToolsPlugin)
.insert_resource(editor_state)
.insert_resource(preferences)
.init_resource::<CommandHistory>()
.init_resource::<TileClipboard>()
.insert_resource(Project::default())
.insert_resource(AssetsBasePath::new(assets_path))
.add_systems(Startup, setup_editor_camera)
.add_systems(Update, handle_keyboard_shortcuts)
.add_systems(Update, handle_recent_projects);
}
}
fn handle_recent_projects(
mut editor_state: ResMut<EditorState>,
mut preferences: ResMut<preferences::EditorPreferences>,
mut project: ResMut<Project>,
) {
if let Some(path) = editor_state.pending_add_recent_project.take() {
let name = project.name().to_string();
preferences.add_recent_project(path, name);
if let Err(e) = preferences.save() {
bevy::log::error!("Failed to save preferences: {}", e);
}
}
if let Some(path) = editor_state.pending_open_recent_project.take() {
match Project::load(&path) {
Ok(loaded) => {
*project = loaded;
let name = project.name().to_string();
preferences.add_recent_project(path, name);
if let Err(e) = preferences.save() {
bevy::log::error!("Failed to save preferences: {}", e);
}
}
Err(e) => {
editor_state.error_message = Some(format!("Failed to load project: {}", e));
preferences.remove_recent_project(&path.to_string_lossy());
if let Err(e) = preferences.save() {
bevy::log::error!("Failed to save preferences: {}", e);
}
}
}
}
if editor_state.pending_clear_recent_projects {
editor_state.pending_clear_recent_projects = false;
preferences.clear_recent_projects();
if let Err(e) = preferences.save() {
bevy::log::error!("Failed to save preferences: {}", e);
}
}
}
fn setup_editor_camera(mut commands: Commands, camera_query: Query<&Camera2d>) {
if camera_query.is_empty() {
commands.spawn(Camera2d);
}
}
#[derive(Resource)]
pub struct EditorState {
pub selection: Selection,
pub selected_layer: Option<usize>,
pub selected_tileset: Option<uuid::Uuid>,
pub selected_tile: Option<u32>,
pub selected_level: Option<uuid::Uuid>,
pub current_tool: EditorTool,
pub tool_mode: ToolMode,
pub show_grid: bool,
pub show_collisions: bool,
pub snap_to_grid: bool,
pub zoom: f32,
pub camera_offset: bevy::math::Vec2,
pub show_new_project_dialog: bool,
pub show_new_level_dialog: bool,
pub show_new_tileset_dialog: bool,
pub show_about_dialog: bool,
pub show_schema_editor: bool,
pub schema_editor_state: SchemaEditorState,
pub error_message: Option<String>,
pub new_project_name: String,
pub new_project_schema_path: Option<PathBuf>,
pub new_project_save_path: Option<PathBuf>,
pub show_settings_dialog: bool,
pub pending_add_recent_project: Option<PathBuf>,
pub pending_open_recent_project: Option<PathBuf>,
pub pending_clear_recent_projects: bool,
pub new_level_name: String,
pub new_level_width: u32,
pub new_level_height: u32,
pub new_tileset_name: String,
pub new_tileset_path: String,
pub new_tileset_tile_size: u32,
pub show_add_tileset_image_dialog: bool,
pub add_image_name: String,
pub add_image_path: String,
pub pending_action: Option<PendingAction>,
pub create_new_instance: Option<String>,
pub is_painting: bool,
pub last_painted_tile: Option<(u32, u32)>,
pub random_paint: bool,
pub random_paint_tiles: Vec<u32>,
pub paint_flip_x: bool,
pub paint_flip_y: bool,
pub selected_stamp: Option<uuid::Uuid>,
pub show_stamp_library: bool,
pub new_stamp_name: String,
pub selected_terrain: Option<uuid::Uuid>,
pub show_new_terrain_dialog: bool,
pub new_terrain_name: String,
pub new_terrain_first_tile: u32,
pub selected_terrain_set: Option<uuid::Uuid>,
pub selected_terrain_in_set: Option<usize>,
pub show_new_terrain_set_dialog: bool,
pub new_terrain_set_type: bevy_map_autotile::TerrainSetType,
pub show_add_terrain_to_set_dialog: bool,
pub new_terrain_color: [f32; 3],
pub show_tileset_editor: bool,
pub tileset_editor_state: TilesetEditorState,
pub show_spritesheet_editor: bool,
pub spritesheet_editor_state: SpriteSheetEditorState,
pub show_animation_editor: bool,
pub animation_editor_state: AnimationEditorState,
pub show_dialogue_editor: bool,
pub dialogue_editor_state: DialogueEditorState,
pub dialogue_editor_asset_id: Option<String>,
pub terrain_paint_state: TerrainPaintState,
pub entity_paint_state: EntityPaintState,
pub selected_entity_type: Option<String>,
pub tile_selection: TileSelection,
pub is_pasting: bool,
pub pending_delete_selection: bool,
pub show_copy_file_dialog: bool,
pub pending_copy_source: Option<PathBuf>,
pub pending_copy_callback: CopyFileCallback,
pub terrain_preview: TerrainPreviewState,
pub brush_preview: BrushPreviewState,
pub renaming_item: Option<RenamingItem>,
pub rename_buffer: String,
pub is_moving: bool,
pub move_drag_start: Option<bevy::math::Vec2>,
pub entity_original_position: Option<[f32; 2]>,
pub tile_move_original: Option<std::collections::HashMap<(u32, u32), (usize, Option<u32>)>>,
pub tile_move_offset: Option<(i32, i32)>,
pub pending_cancel_move: bool,
pub view_mode: EditorViewMode,
pub world_view_zoom: f32,
pub world_view_offset: bevy::math::Vec2,
pub world_dragging_level: Option<uuid::Uuid>,
pub world_drag_start: Option<bevy::math::Vec2>,
pub world_drag_level_start_pos: Option<(i32, i32)>,
pub show_connections: bool,
pub world_hovered_level: Option<uuid::Uuid>,
pub world_connection_from: Option<(uuid::Uuid, bevy_map_core::ConnectionDirection)>,
pub world_new_level_dialog_open: bool,
pub world_new_level_pos: (i32, i32),
pub world_new_level_name: String,
pub world_new_level_width: u32,
pub world_new_level_height: u32,
pub game_settings_dialog: GameSettingsDialogState,
pub running_game: Option<std::process::Child>,
pub game_build_state: GameBuildState,
pub build_handle: Option<AsyncBuildHandle>,
}
impl Default for EditorState {
fn default() -> Self {
Self {
selection: Selection::None,
selected_layer: None,
selected_tileset: None,
selected_tile: None,
selected_level: None,
current_tool: EditorTool::Select,
tool_mode: ToolMode::Point,
show_grid: true,
show_collisions: false,
snap_to_grid: true,
zoom: 1.0,
camera_offset: bevy::math::Vec2::ZERO,
show_new_project_dialog: false,
show_new_level_dialog: false,
show_new_tileset_dialog: false,
show_about_dialog: false,
show_schema_editor: false,
schema_editor_state: SchemaEditorState::default(),
error_message: None,
new_project_name: String::new(),
new_project_schema_path: None,
new_project_save_path: None,
show_settings_dialog: false,
pending_add_recent_project: None,
pending_open_recent_project: None,
pending_clear_recent_projects: false,
new_level_name: "New Level".to_string(),
new_level_width: 50,
new_level_height: 50,
new_tileset_name: "New Tileset".to_string(),
new_tileset_path: String::new(),
new_tileset_tile_size: 32,
show_add_tileset_image_dialog: false,
add_image_name: String::new(),
add_image_path: String::new(),
pending_action: None,
create_new_instance: None,
is_painting: false,
last_painted_tile: None,
random_paint: false,
random_paint_tiles: Vec::new(),
paint_flip_x: false,
paint_flip_y: false,
selected_stamp: None,
show_stamp_library: false,
new_stamp_name: String::new(),
selected_terrain: None,
show_new_terrain_dialog: false,
new_terrain_name: String::new(),
new_terrain_first_tile: 0,
selected_terrain_set: None,
selected_terrain_in_set: None,
show_new_terrain_set_dialog: false,
new_terrain_set_type: bevy_map_autotile::TerrainSetType::Corner,
show_add_terrain_to_set_dialog: false,
new_terrain_color: [0.0, 1.0, 0.0],
show_tileset_editor: false,
tileset_editor_state: TilesetEditorState::default(),
show_spritesheet_editor: false,
spritesheet_editor_state: SpriteSheetEditorState::new(),
show_animation_editor: false,
animation_editor_state: AnimationEditorState::new(),
show_dialogue_editor: false,
dialogue_editor_state: DialogueEditorState::new(),
dialogue_editor_asset_id: None,
terrain_paint_state: TerrainPaintState::new(),
entity_paint_state: EntityPaintState::new(),
selected_entity_type: None,
tile_selection: TileSelection::default(),
is_pasting: false,
pending_delete_selection: false,
show_copy_file_dialog: false,
pending_copy_source: None,
pending_copy_callback: CopyFileCallback::None,
terrain_preview: TerrainPreviewState::default(),
brush_preview: BrushPreviewState::default(),
renaming_item: None,
rename_buffer: String::new(),
is_moving: false,
move_drag_start: None,
entity_original_position: None,
tile_move_original: None,
tile_move_offset: None,
pending_cancel_move: false,
view_mode: EditorViewMode::Level,
world_view_zoom: 0.25,
world_view_offset: bevy::math::Vec2::ZERO,
world_dragging_level: None,
world_drag_start: None,
world_drag_level_start_pos: None,
show_connections: true,
world_hovered_level: None,
world_connection_from: None,
world_new_level_dialog_open: false,
world_new_level_pos: (0, 0),
world_new_level_name: String::new(),
world_new_level_width: 50,
world_new_level_height: 50,
game_settings_dialog: GameSettingsDialogState::default(),
running_game: None,
game_build_state: GameBuildState::default(),
build_handle: None,
}
}
}