use bevy::input::mouse::MouseWheel;
use bevy::prelude::*;
use bevy_egui::EguiContexts;
use bevy_map_autotile;
use bevy_map_core::{EntityInstance, LayerData, OCCUPIED_CELL};
use std::collections::HashMap;
use crate::commands::{
collect_tiles_in_region, BatchTileCommand, CommandHistory, MoveEntityCommand,
};
use crate::project::Project;
use crate::render::RenderState;
use crate::ui::{EditorTool, Selection, ToolMode};
use crate::EditorState;
use std::collections::HashSet;
pub struct EditorToolsPlugin;
impl Plugin for EditorToolsPlugin {
fn build(&self, app: &mut App) {
app.init_resource::<ViewportInputState>()
.init_resource::<PaintStrokeTracker>()
.add_systems(
Update,
(
handle_viewport_input,
handle_zoom_input,
finalize_paint_stroke,
),
);
}
}
#[derive(Resource, Default)]
pub struct ViewportInputState {
pub last_world_pos: Option<Vec2>,
pub is_panning: bool,
pub pan_start_pos: Option<Vec2>,
pub rect_start_tile: Option<(i32, i32)>,
pub is_drawing_rect: bool,
pub painted_targets_this_stroke: HashSet<bevy_map_autotile::PaintTarget>,
pub last_preview_target: Option<bevy_map_autotile::PaintTarget>,
pub last_paint_world_pos: Option<Vec2>,
pub last_preview_full_tile_mode: bool,
}
#[derive(Resource, Default)]
pub struct PaintStrokeTracker {
pub active: bool,
pub level_id: Option<uuid::Uuid>,
pub layer_idx: Option<usize>,
pub changes: HashMap<(u32, u32), (Option<u32>, Option<u32>)>,
pub description: String,
}
fn capture_tile_region(
tiles: &[Option<u32>],
width: u32,
height: u32,
center_x: i32,
center_y: i32,
radius: i32,
) -> HashMap<(u32, u32), Option<u32>> {
let mut snapshot = HashMap::new();
for dy in -radius..=radius {
for dx in -radius..=radius {
let x = center_x + dx;
let y = center_y + dy;
if x >= 0 && y >= 0 && x < width as i32 && y < height as i32 {
let x = x as u32;
let y = y as u32;
let idx = (y * width + x) as usize;
let tile = tiles.get(idx).copied().flatten();
snapshot.insert((x, y), tile);
}
}
}
snapshot
}
fn capture_tile_region_bounds(
tiles: &[Option<u32>],
width: u32,
height: u32,
min_x: i32,
min_y: i32,
max_x: i32,
max_y: i32,
) -> HashMap<(u32, u32), Option<u32>> {
let mut snapshot = HashMap::new();
for y in min_y..=max_y {
for x in min_x..=max_x {
if x >= 0 && y >= 0 && x < width as i32 && y < height as i32 {
let x = x as u32;
let y = y as u32;
let idx = (y * width + x) as usize;
let tile = tiles.get(idx).copied().flatten();
snapshot.insert((x, y), tile);
}
}
}
snapshot
}
fn calculate_targets_bounds(
targets: &[bevy_map_autotile::PaintTarget],
buffer: i32,
) -> (i32, i32, i32, i32) {
if targets.is_empty() {
return (0, 0, 0, 0);
}
let mut min_x = i32::MAX;
let mut min_y = i32::MAX;
let mut max_x = i32::MIN;
let mut max_y = i32::MIN;
for target in targets {
let (cx, cy) = match target {
bevy_map_autotile::PaintTarget::Corner { corner_x, corner_y } => {
(*corner_x as i32, *corner_y as i32)
}
bevy_map_autotile::PaintTarget::HorizontalEdge { tile_x, edge_y } => {
(*tile_x as i32, *edge_y as i32)
}
bevy_map_autotile::PaintTarget::VerticalEdge { edge_x, tile_y } => {
(*edge_x as i32, *tile_y as i32)
}
};
min_x = min_x.min(cx - buffer);
min_y = min_y.min(cy - buffer);
max_x = max_x.max(cx + buffer);
max_y = max_y.max(cy + buffer);
}
(min_x, min_y, max_x, max_y)
}
fn get_selected_tile_grid_size(editor_state: &EditorState, project: &Project) -> (u32, u32) {
if let (Some(tile_id), Some(tileset_id)) =
(editor_state.selected_tile, editor_state.selected_tileset)
{
if let Some(tileset) = project.tilesets.iter().find(|t| t.id == tileset_id) {
return tileset.get_tile_grid_size(tile_id);
}
}
(1, 1) }
fn handle_viewport_input(
mut commands: Commands,
mut contexts: EguiContexts,
mut editor_state: ResMut<EditorState>,
mut project: ResMut<Project>,
mut render_state: ResMut<RenderState>,
mut input_state: ResMut<ViewportInputState>,
mut stroke_tracker: ResMut<PaintStrokeTracker>,
mut history: ResMut<CommandHistory>,
tileset_cache: Res<crate::ui::TilesetTextureCache>,
windows: Query<&Window>,
camera_q: Query<(&Camera, &GlobalTransform), With<Camera2d>>,
mouse_buttons: Res<ButtonInput<MouseButton>>,
keyboard: Res<ButtonInput<KeyCode>>,
) {
let Ok(ctx) = contexts.ctx_mut() else { return };
let Some(window) = windows.iter().next() else {
return;
};
let Some((camera, camera_transform)) = camera_q.iter().next() else {
return;
};
let Some(cursor_position) = window.cursor_position() else {
input_state.is_panning = false;
editor_state.is_painting = false;
editor_state.brush_preview.active = false;
return;
};
let Ok(world_pos) = camera.viewport_to_world_2d(camera_transform, cursor_position) else {
return;
};
input_state.last_world_pos = Some(world_pos);
if editor_state.pending_cancel_move {
cancel_move_operation(&mut editor_state, &mut project);
editor_state.pending_cancel_move = false;
}
let egui_wants_pointer = ctx.wants_pointer_input() || ctx.is_using_pointer();
if egui_wants_pointer && !input_state.is_drawing_rect {
input_state.is_panning = false;
editor_state.is_painting = false;
editor_state.brush_preview.active = false;
return;
}
if mouse_buttons.pressed(MouseButton::Middle) || mouse_buttons.pressed(MouseButton::Right) {
if !input_state.is_panning {
input_state.is_panning = true;
input_state.pan_start_pos = Some(cursor_position);
} else if let Some(start_pos) = input_state.pan_start_pos {
let delta = cursor_position - start_pos;
editor_state.camera_offset.x -= delta.x / editor_state.zoom;
editor_state.camera_offset.y += delta.y / editor_state.zoom;
input_state.pan_start_pos = Some(cursor_position);
}
} else {
input_state.is_panning = false;
input_state.pan_start_pos = None;
}
let tile_size = get_tile_size(&editor_state, &project);
let pointer_over_right_panel = {
if let Some(cursor_pos) = window.cursor_position() {
let window_width = window.resolution.width();
cursor_pos.x > (window_width - 250.0)
} else {
false
}
};
let modal_editor_open = editor_state.show_tileset_editor
|| editor_state.show_spritesheet_editor
|| editor_state.show_animation_editor
|| editor_state.show_dialogue_editor;
let is_rectangle_mode =
editor_state.tool_mode == ToolMode::Rectangle && editor_state.current_tool.supports_modes();
if mouse_buttons.just_pressed(MouseButton::Left)
&& !input_state.is_panning
&& !pointer_over_right_panel
&& !modal_editor_open
{
match editor_state.current_tool {
EditorTool::Entity => {
place_entity(&mut editor_state, &mut project, world_pos);
}
EditorTool::Fill => {
fill_area(
&mut editor_state,
&mut project,
&mut render_state,
world_pos,
);
}
EditorTool::Select => {
if is_click_on_selected_entity(world_pos, &editor_state, &project) {
if let Selection::Entity(level_id, entity_id) = &editor_state.selection {
if let Some(level) = project.levels.iter().find(|l| l.id == *level_id) {
if let Some(entity) = level.entities.iter().find(|e| e.id == *entity_id)
{
editor_state.is_moving = true;
editor_state.move_drag_start = Some(world_pos);
editor_state.entity_original_position = Some(entity.position);
}
}
}
return;
}
if is_click_on_tile_selection(world_pos, &editor_state, tile_size) {
editor_state.is_moving = true;
editor_state.move_drag_start = Some(world_pos);
editor_state.tile_move_offset = Some((0, 0));
capture_tile_selection_for_move(&mut editor_state, &project);
return;
}
if let Some(level_id) = editor_state.selected_level {
if let Some(entity_id) = find_entity_at_position(
world_pos,
&project,
level_id,
editor_state.selected_layer,
) {
editor_state.selection = Selection::Entity(level_id, entity_id);
editor_state.tile_selection.clear();
return;
}
}
editor_state.selection = Selection::None;
let tile_x = (world_pos.x / tile_size).floor() as i32;
let tile_y = (world_pos.y / tile_size).floor() as i32;
input_state.rect_start_tile = Some((tile_x, tile_y));
input_state.is_drawing_rect = true;
editor_state.tile_selection.is_selecting = true;
editor_state.tile_selection.drag_start = Some((tile_x, tile_y));
}
EditorTool::Paint | EditorTool::Erase | EditorTool::Terrain if is_rectangle_mode => {
let tile_x = (world_pos.x / tile_size).floor() as i32;
let tile_y = (world_pos.y / tile_size).floor() as i32;
input_state.rect_start_tile = Some((tile_x, tile_y));
input_state.is_drawing_rect = true;
}
_ => {}
}
}
if mouse_buttons.just_released(MouseButton::Left) && input_state.is_drawing_rect {
if let Some((start_x, start_y)) = input_state.rect_start_tile {
let end_x = (world_pos.x / tile_size).floor() as i32;
let end_y = (world_pos.y / tile_size).floor() as i32;
match editor_state.current_tool {
EditorTool::Terrain => {
fill_terrain_rectangle(
&mut editor_state,
&mut project,
&mut render_state,
&mut history,
start_x,
start_y,
end_x,
end_y,
);
}
EditorTool::Paint | EditorTool::Erase => {
fill_rectangle(
&mut editor_state,
&mut project,
&mut render_state,
start_x,
start_y,
end_x,
end_y,
);
}
EditorTool::Select => {
if let (Some(level_id), Some(layer_idx)) =
(editor_state.selected_level, editor_state.selected_layer)
{
let additive = false;
let min_x = start_x.min(end_x).max(0) as u32;
let max_x = start_x.max(end_x).max(0) as u32;
let min_y = start_y.min(end_y).max(0) as u32;
let max_y = start_y.max(end_y).max(0) as u32;
editor_state.tile_selection.select_rectangle(
level_id, layer_idx, min_x, min_y, max_x, max_y, additive,
);
}
editor_state.tile_selection.is_selecting = false;
editor_state.tile_selection.drag_start = None;
}
_ => {}
}
}
input_state.rect_start_tile = None;
input_state.is_drawing_rect = false;
}
if mouse_buttons.just_released(MouseButton::Left) && editor_state.is_moving {
if editor_state.entity_original_position.is_some() {
finalize_entity_move(&mut editor_state, &mut project, &mut history);
}
else if editor_state.tile_move_original.is_some() {
finalize_tile_move(
&mut editor_state,
&mut project,
&mut render_state,
&mut history,
);
}
editor_state.is_moving = false;
editor_state.move_drag_start = None;
editor_state.entity_original_position = None;
editor_state.tile_move_original = None;
editor_state.tile_move_offset = None;
}
if mouse_buttons.pressed(MouseButton::Left) && editor_state.is_moving && !input_state.is_panning
{
if let Some(start_pos) = editor_state.move_drag_start {
let delta = world_pos - start_pos;
if editor_state.entity_original_position.is_some() {
if let Selection::Entity(level_id, entity_id) = &editor_state.selection {
let level_id = *level_id;
let entity_id = *entity_id;
if let Some(original_pos) = editor_state.entity_original_position {
let mut new_pos = [original_pos[0] + delta.x, original_pos[1] + delta.y];
if editor_state.snap_to_grid {
let snap_unit = tile_size / 2.0;
new_pos[0] = (new_pos[0] / snap_unit).round() * snap_unit;
new_pos[1] = (new_pos[1] / snap_unit).round() * snap_unit;
}
if let Some(level) = project.get_level_mut(level_id) {
let level_width_px = level.width as f32 * tile_size;
let level_height_px = level.height as f32 * tile_size;
new_pos[0] = new_pos[0].clamp(0.0, level_width_px);
new_pos[1] = new_pos[1].clamp(0.0, level_height_px);
if let Some(entity) =
level.entities.iter_mut().find(|e| e.id == entity_id)
{
entity.position = new_pos;
}
}
}
}
}
else if editor_state.tile_move_original.is_some() {
let offset_x = (delta.x / tile_size).round() as i32;
let offset_y = (delta.y / tile_size).round() as i32;
editor_state.tile_move_offset = Some((offset_x, offset_y));
}
}
}
if !input_state.is_drawing_rect
&& editor_state.terrain_paint_state.is_terrain_mode
&& editor_state.current_tool == EditorTool::Terrain
{
let full_tile_mode =
keyboard.pressed(KeyCode::ControlLeft) || keyboard.pressed(KeyCode::ControlRight);
if let (Some(terrain_set_id), Some(_)) = (
editor_state.selected_terrain_set,
editor_state.selected_terrain_in_set,
) {
if let Some(terrain_set) = project.autotile_config.get_terrain_set(terrain_set_id) {
let tile_size = project
.tilesets
.iter()
.find(|t| t.id == terrain_set.tileset_id)
.map(|t| t.tile_size as f32)
.unwrap_or(32.0);
let paint_target = bevy_map_autotile::get_paint_target(
world_pos.x,
world_pos.y,
tile_size,
terrain_set.set_type,
);
let mode_changed = input_state.last_preview_full_tile_mode != full_tile_mode;
if input_state.last_preview_target != Some(paint_target) || mode_changed {
input_state.last_preview_target = Some(paint_target);
input_state.last_preview_full_tile_mode = full_tile_mode;
calculate_terrain_preview(
&mut editor_state,
&project,
world_pos,
tile_size,
full_tile_mode,
);
}
}
}
} else if !input_state.is_drawing_rect {
editor_state.terrain_preview.active = false;
editor_state.terrain_preview.preview_tiles.clear();
input_state.last_preview_target = None;
} else {
editor_state.terrain_preview.active = false;
input_state.last_preview_target = None;
}
if editor_state.current_tool == EditorTool::Paint
&& !editor_state.terrain_paint_state.is_terrain_mode
&& editor_state.selected_tile.is_some()
&& !input_state.is_drawing_rect
&& !pointer_over_right_panel
&& !modal_editor_open
{
let (grid_width, grid_height) = get_selected_tile_grid_size(&editor_state, &project);
let offset_x = (grid_width as f32 * tile_size) / 2.0;
let offset_y = (grid_height as f32 * tile_size) / 2.0;
let tile_x = ((world_pos.x - offset_x) / tile_size).floor() as i32;
let tile_y = ((world_pos.y - offset_y) / tile_size).floor() as i32;
editor_state.brush_preview.position = Some((tile_x, tile_y));
editor_state.brush_preview.active = true;
} else {
editor_state.brush_preview.active = false;
editor_state.brush_preview.position = None;
}
if mouse_buttons.pressed(MouseButton::Left) && !input_state.is_panning && !is_rectangle_mode {
match editor_state.current_tool {
EditorTool::Paint => {
paint_tile(
&mut commands,
&mut editor_state,
&mut project,
&mut render_state,
&mut stroke_tracker,
&tileset_cache,
world_pos,
);
}
EditorTool::Terrain => {
let full_tile_mode = keyboard.pressed(KeyCode::ControlLeft)
|| keyboard.pressed(KeyCode::ControlRight);
paint_terrain_tile(
&mut commands,
&mut editor_state,
&mut project,
&mut render_state,
&mut input_state,
&mut stroke_tracker,
&tileset_cache,
world_pos,
full_tile_mode,
);
}
EditorTool::Erase => {
erase_tile(
&mut commands,
&mut editor_state,
&mut project,
&mut render_state,
&mut stroke_tracker,
&tileset_cache,
world_pos,
);
}
_ => {}
}
} else if !input_state.is_drawing_rect {
editor_state.is_painting = false;
editor_state.last_painted_tile = None;
input_state.painted_targets_this_stroke.clear();
input_state.last_paint_world_pos = None;
}
}
fn get_tile_size(editor_state: &EditorState, project: &Project) -> f32 {
let level_id = editor_state.selected_level;
let layer_idx = editor_state.selected_layer;
let level = level_id.and_then(|id| project.levels.iter().find(|l| l.id == id));
let layer_tileset_id = level.and_then(|l| {
layer_idx
.and_then(|idx| l.layers.get(idx))
.and_then(|layer| {
if let LayerData::Tiles { tileset_id, .. } = &layer.data {
Some(*tileset_id)
} else {
None
}
})
});
layer_tileset_id
.or(editor_state.selected_tileset)
.and_then(|id| project.tilesets.iter().find(|t| t.id == id))
.map(|t| t.tile_size as f32)
.unwrap_or(32.0)
}
#[allow(deprecated)] fn handle_zoom_input(
mut contexts: EguiContexts,
mut editor_state: ResMut<EditorState>,
mut scroll_events: bevy::ecs::event::EventReader<MouseWheel>,
windows: Query<&Window>,
) {
let Ok(ctx) = contexts.ctx_mut() else { return };
let Ok(window) = windows.single() else { return };
let egui_using_pointer = ctx.is_using_pointer();
let over_side_panel = if let Some(cursor_pos) = window.cursor_position() {
let window_width = window.resolution.width();
cursor_pos.x < 250.0 || cursor_pos.x > (window_width - 250.0)
} else {
false
};
for event in scroll_events.read() {
if egui_using_pointer || over_side_panel {
continue;
}
let zoom_delta = event.y * 0.1;
editor_state.zoom = (editor_state.zoom * (1.0 + zoom_delta)).clamp(0.25, 4.0);
}
}
fn layer_has_tiles(layer: &bevy_map_core::Layer) -> bool {
if let LayerData::Tiles { tiles, .. } = &layer.data {
tiles.iter().any(|t| t.is_some())
} else {
false
}
}
fn get_layer_tileset_id(layer: &bevy_map_core::Layer) -> Option<uuid::Uuid> {
if let LayerData::Tiles { tileset_id, .. } = &layer.data {
Some(*tileset_id)
} else {
None
}
}
fn is_tile_layer(project: &Project, level_id: uuid::Uuid, layer_idx: usize) -> bool {
project
.get_level(level_id)
.and_then(|level| level.layers.get(layer_idx))
.map(|layer| matches!(&layer.data, LayerData::Tiles { .. }))
.unwrap_or(false)
}
fn find_entity_at_position(
world_pos: Vec2,
project: &Project,
level_id: uuid::Uuid,
layer_idx: Option<usize>,
) -> Option<uuid::Uuid> {
let level = project.levels.iter().find(|l| l.id == level_id)?;
let layer_entity_ids: std::collections::HashSet<uuid::Uuid> = layer_idx
.and_then(|idx| level.layers.get(idx))
.and_then(|layer| match &layer.data {
LayerData::Objects { entities } => Some(entities.iter().copied().collect()),
_ => None,
})
.unwrap_or_default();
if layer_entity_ids.is_empty() {
return None;
}
for entity in level.entities.iter().rev() {
if !layer_entity_ids.contains(&entity.id) {
continue;
}
let marker_size = project
.schema
.get_type(&entity.type_name)
.and_then(|td| td.marker_size)
.unwrap_or(16) as f32;
let half_size = marker_size / 2.0;
let entity_pos = Vec2::new(entity.position[0], entity.position[1]);
let min = entity_pos - Vec2::splat(half_size);
let max = entity_pos + Vec2::splat(half_size);
if world_pos.x >= min.x
&& world_pos.x <= max.x
&& world_pos.y >= min.y
&& world_pos.y <= max.y
{
return Some(entity.id);
}
}
None
}
fn is_click_on_selected_entity(
world_pos: Vec2,
editor_state: &EditorState,
project: &Project,
) -> bool {
if let Selection::Entity(level_id, entity_id) = &editor_state.selection {
if let Some(level) = project.levels.iter().find(|l| l.id == *level_id) {
if let Some(entity) = level.entities.iter().find(|e| e.id == *entity_id) {
let marker_size = project
.schema
.get_type(&entity.type_name)
.and_then(|td| td.marker_size)
.unwrap_or(16) as f32;
let half_size = marker_size / 2.0;
let entity_pos = Vec2::new(entity.position[0], entity.position[1]);
let min = entity_pos - Vec2::splat(half_size);
let max = entity_pos + Vec2::splat(half_size);
return world_pos.x >= min.x
&& world_pos.x <= max.x
&& world_pos.y >= min.y
&& world_pos.y <= max.y;
}
}
}
false
}
fn is_click_on_tile_selection(world_pos: Vec2, editor_state: &EditorState, tile_size: f32) -> bool {
if editor_state.tile_selection.tiles.is_empty() {
return false;
}
let tile_x = (world_pos.x / tile_size).floor() as u32;
let tile_y = (world_pos.y / tile_size).floor() as u32;
let level_id = match editor_state.tile_selection.level_id {
Some(id) => id,
None => return false,
};
let layer_idx = match editor_state.tile_selection.layer_idx {
Some(idx) => idx,
None => return false,
};
editor_state
.tile_selection
.tiles
.contains(&(level_id, layer_idx, tile_x, tile_y))
}
fn capture_tile_selection_for_move(editor_state: &mut EditorState, project: &Project) {
let Some(level_id) = editor_state.tile_selection.level_id else {
return;
};
let Some(layer_idx) = editor_state.tile_selection.layer_idx else {
return;
};
let Some(level) = project.levels.iter().find(|l| l.id == level_id) else {
return;
};
let Some(layer) = level.layers.get(layer_idx) else {
return;
};
let tiles = if let LayerData::Tiles { tiles, .. } = &layer.data {
tiles
} else {
return;
};
let mut original_tiles = HashMap::new();
for &(_sel_level_id, _sel_layer_idx, x, y) in &editor_state.tile_selection.tiles {
let idx = (y * level.width + x) as usize;
let tile = tiles.get(idx).copied().flatten();
original_tiles.insert((x, y), (layer_idx, tile));
}
editor_state.tile_move_original = Some(original_tiles);
}
fn finalize_entity_move(
editor_state: &mut EditorState,
project: &mut Project,
history: &mut CommandHistory,
) {
let Some(original_pos) = editor_state.entity_original_position else {
return;
};
if let Selection::Entity(level_id, entity_id) = &editor_state.selection {
let level_id = *level_id;
let entity_id = *entity_id;
let Some(level) = project.get_level(level_id) else {
return;
};
let Some(entity) = level.entities.iter().find(|e| e.id == entity_id) else {
return;
};
let new_pos = entity.position;
if original_pos == new_pos {
return;
}
let command = MoveEntityCommand::new(level_id, entity_id, original_pos, new_pos);
history.push_undo(Box::new(command));
project.mark_dirty();
}
}
fn finalize_tile_move(
editor_state: &mut EditorState,
project: &mut Project,
render_state: &mut RenderState,
history: &mut CommandHistory,
) {
let Some(original_tiles) = editor_state.tile_move_original.take() else {
return;
};
let Some((offset_x, offset_y)) = editor_state.tile_move_offset else {
return;
};
if offset_x == 0 && offset_y == 0 {
return;
}
let Some(level_id) = editor_state.tile_selection.level_id else {
return;
};
let Some(layer_idx) = editor_state.tile_selection.layer_idx else {
return;
};
let Some(level) = project.get_level_mut(level_id) else {
return;
};
let level_width = level.width;
let level_height = level.height;
let mut changes: HashMap<(u32, u32), (Option<u32>, Option<u32>)> = HashMap::new();
for ((x, y), (_, _tile)) in &original_tiles {
let old_tile = level.get_tile(layer_idx, *x, *y);
level.set_tile(layer_idx, *x, *y, None);
changes.insert((*x, *y), (old_tile, None));
}
let mut new_selection = HashSet::new();
for ((x, y), (_, tile)) in &original_tiles {
let dest_x = *x as i32 + offset_x;
let dest_y = *y as i32 + offset_y;
if dest_x >= 0 && dest_y >= 0 && dest_x < level_width as i32 && dest_y < level_height as i32
{
let dest_x = dest_x as u32;
let dest_y = dest_y as u32;
if !changes.contains_key(&(dest_x, dest_y)) {
let old_tile = level.get_tile(layer_idx, dest_x, dest_y);
changes.insert((dest_x, dest_y), (old_tile, *tile));
} else {
if let Some(change) = changes.get_mut(&(dest_x, dest_y)) {
change.1 = *tile;
}
}
level.set_tile(layer_idx, dest_x, dest_y, *tile);
new_selection.insert((level_id, layer_idx, dest_x, dest_y));
}
}
editor_state.tile_selection.tiles = new_selection;
if !changes.is_empty() {
let mut inverse_changes = HashMap::new();
for ((x, y), (old_tile, new_tile)) in &changes {
inverse_changes.insert((*x, *y), (*new_tile, *old_tile));
}
let command = BatchTileCommand::new(level_id, layer_idx, inverse_changes, "Move Tiles");
history.push_undo(Box::new(command));
}
render_state.needs_rebuild = true;
project.mark_dirty();
}
fn cancel_move_operation(editor_state: &mut EditorState, project: &mut Project) {
if let Some(original_pos) = editor_state.entity_original_position {
if let Selection::Entity(level_id, entity_id) = &editor_state.selection {
if let Some(level) = project.get_level_mut(*level_id) {
if let Some(entity) = level.entities.iter_mut().find(|e| e.id == *entity_id) {
entity.position = original_pos;
}
}
}
}
editor_state.is_moving = false;
editor_state.move_drag_start = None;
editor_state.entity_original_position = None;
editor_state.tile_move_original = None;
editor_state.tile_move_offset = None;
}
fn paint_tile(
commands: &mut Commands,
editor_state: &mut EditorState,
project: &mut Project,
render_state: &mut RenderState,
stroke_tracker: &mut PaintStrokeTracker,
tileset_cache: &crate::ui::TilesetTextureCache,
world_pos: Vec2,
) {
let Some(level_id) = editor_state.selected_level else {
return;
};
let Some(layer_idx) = editor_state.selected_layer else {
return;
};
let Some(tile_index) = editor_state.selected_tile else {
return;
};
let Some(selected_tileset) = editor_state.selected_tileset else {
return;
};
if !is_tile_layer(project, level_id, layer_idx) {
return;
}
let tileset_info = project
.tilesets
.iter()
.find(|t| t.id == selected_tileset)
.map(|t| (t.tile_size as f32, t.get_tile_grid_size(tile_index)));
let tile_size = tileset_info.map(|(ts, _)| ts).unwrap_or(32.0);
let (grid_width, grid_height) = tileset_info.map(|(_, gs)| gs).unwrap_or((1, 1));
let is_multi_cell = grid_width > 1 || grid_height > 1;
let valid_tileset_ids: HashSet<_> = project.tilesets.iter().map(|t| t.id).collect();
let offset_x = (grid_width as f32 * tile_size) / 2.0;
let offset_y = (grid_height as f32 * tile_size) / 2.0;
let tile_x = ((world_pos.x - offset_x) / tile_size).floor() as i32;
let tile_y = ((world_pos.y - offset_y) / tile_size).floor() as i32;
if editor_state.last_painted_tile == Some((tile_x as u32, tile_y as u32)) {
return;
}
let Some(level) = project.get_level_mut(level_id) else {
return;
};
if tile_x < 0 || tile_y < 0 || tile_x >= level.width as i32 || tile_y >= level.height as i32 {
return;
}
let tile_x = tile_x as u32;
let tile_y = tile_y as u32;
let (has_tiles, layer_tileset) = level
.layers
.get(layer_idx)
.map(|layer| (layer_has_tiles(layer), get_layer_tileset_id(layer)))
.unwrap_or((false, None));
let tileset_exists = layer_tileset
.map(|id| valid_tileset_ids.contains(&id))
.unwrap_or(false);
if has_tiles {
if !tileset_exists {
warn!(
"Layer has tiles from a deleted tileset. Clearing orphaned data and assigning new tileset."
);
if let Some(layer) = level.layers.get_mut(layer_idx) {
if let LayerData::Tiles {
tileset_id,
tiles,
occupied_cells,
} = &mut layer.data
{
tiles.iter_mut().for_each(|t| *t = None);
occupied_cells.clear();
*tileset_id = selected_tileset;
}
}
} else if layer_tileset != Some(selected_tileset) {
return;
}
} else {
if let Some(layer) = level.layers.get_mut(layer_idx) {
if let LayerData::Tiles { tileset_id, .. } = &mut layer.data {
*tileset_id = selected_tileset;
}
}
}
if is_multi_cell {
for dy in 0..grid_height {
for dx in 0..grid_width {
let cx = tile_x + dx;
let cy = tile_y + dy;
if cx >= level.width || cy >= level.height {
return;
}
}
}
}
if !stroke_tracker.active {
stroke_tracker.active = true;
stroke_tracker.level_id = Some(level_id);
stroke_tracker.layer_idx = Some(layer_idx);
stroke_tracker.changes.clear();
stroke_tracker.description = "Paint Tiles".to_string();
}
let mut tiles_to_update: Vec<(u32, u32, Option<u32>)> = Vec::new();
if is_multi_cell {
let base_idx = (tile_y * level.width + tile_x) as usize;
let level_width = level.width;
for dy in 0..grid_height {
for dx in 0..grid_width {
let cx = tile_x + dx;
let cy = tile_y + dy;
let cell_idx = (cy * level_width + cx) as usize;
if let Some(layer) = level.layers.get_mut(layer_idx) {
if let LayerData::Tiles { occupied_cells, .. } = &mut layer.data {
occupied_cells.remove(&cell_idx);
}
}
}
}
for dy in 0..grid_height {
for dx in 0..grid_width {
let cx = tile_x + dx;
let cy = tile_y + dy;
let cell_idx = (cy * level_width + cx) as usize;
let old_tile = level.get_tile(layer_idx, cx, cy);
let new_tile = if dx == 0 && dy == 0 {
level.set_tile(layer_idx, cx, cy, Some(tile_index));
Some(tile_index)
} else {
level.set_tile(layer_idx, cx, cy, Some(OCCUPIED_CELL));
if let Some(layer) = level.layers.get_mut(layer_idx) {
if let LayerData::Tiles { occupied_cells, .. } = &mut layer.data {
occupied_cells.insert(cell_idx, base_idx);
}
}
Some(OCCUPIED_CELL)
};
if !stroke_tracker.changes.contains_key(&(cx, cy)) {
stroke_tracker
.changes
.insert((cx, cy), (old_tile, new_tile));
} else if let Some(change) = stroke_tracker.changes.get_mut(&(cx, cy)) {
change.1 = new_tile;
}
tiles_to_update.push((cx, cy, new_tile));
}
}
} else {
let old_tile = level.get_tile(layer_idx, tile_x, tile_y);
level.set_tile(layer_idx, tile_x, tile_y, Some(tile_index));
if !stroke_tracker.changes.contains_key(&(tile_x, tile_y)) {
stroke_tracker
.changes
.insert((tile_x, tile_y), (old_tile, Some(tile_index)));
} else if let Some(change) = stroke_tracker.changes.get_mut(&(tile_x, tile_y)) {
change.1 = Some(tile_index);
}
tiles_to_update.push((tile_x, tile_y, Some(tile_index)));
}
for (cx, cy, new_tile) in tiles_to_update {
crate::render::update_tile(
commands,
render_state,
project,
tileset_cache,
level_id,
layer_idx,
cx,
cy,
new_tile,
);
}
project.mark_dirty();
editor_state.is_painting = true;
editor_state.last_painted_tile = Some((tile_x, tile_y));
}
fn erase_tile(
commands: &mut Commands,
editor_state: &mut EditorState,
project: &mut Project,
render_state: &mut RenderState,
stroke_tracker: &mut PaintStrokeTracker,
tileset_cache: &crate::ui::TilesetTextureCache,
world_pos: Vec2,
) {
let Some(level_id) = editor_state.selected_level else {
return;
};
let Some(layer_idx) = editor_state.selected_layer else {
return;
};
if !is_tile_layer(project, level_id, layer_idx) {
return;
}
let tile_size = get_tile_size(editor_state, project);
let tile_x = (world_pos.x / tile_size).floor() as i32;
let tile_y = (world_pos.y / tile_size).floor() as i32;
if editor_state.last_painted_tile == Some((tile_x as u32, tile_y as u32)) {
return;
}
let erase_info: Option<(u32, u32, u32, u32, u32, u32, bool)> = {
let Some(level) = project.get_level(level_id) else {
return;
};
if tile_x < 0 || tile_y < 0 || tile_x >= level.width as i32 || tile_y >= level.height as i32
{
return;
}
let tile_x = tile_x as u32;
let tile_y = tile_y as u32;
let cell_idx = (tile_y * level.width + tile_x) as usize;
let level_width = level.width;
let base_cell_idx = if let Some(layer) = level.layers.get(layer_idx) {
if let LayerData::Tiles { occupied_cells, .. } = &layer.data {
occupied_cells.get(&cell_idx).copied()
} else {
None
}
} else {
None
};
let actual_base_idx = base_cell_idx.unwrap_or(cell_idx);
let base_x = (actual_base_idx % level_width as usize) as u32;
let base_y = (actual_base_idx / level_width as usize) as u32;
let base_tile = level.get_tile(layer_idx, base_x, base_y);
let tileset_id = level.layers.get(layer_idx).and_then(|l| {
if let LayerData::Tiles { tileset_id, .. } = &l.data {
Some(*tileset_id)
} else {
None
}
});
let (grid_width, grid_height) = if let Some(tile_index) = base_tile {
if tile_index != OCCUPIED_CELL {
if let Some(ts_id) = tileset_id {
project
.tilesets
.iter()
.find(|t| t.id == ts_id)
.map(|t| t.get_tile_grid_size(tile_index))
.unwrap_or((1, 1))
} else {
(1, 1)
}
} else {
(1, 1)
}
} else {
(1, 1)
};
let is_multi_cell = grid_width > 1 || grid_height > 1;
Some((
tile_x,
tile_y,
base_x,
base_y,
grid_width,
grid_height,
is_multi_cell,
))
};
let Some((tile_x, tile_y, base_x, base_y, grid_width, grid_height, is_multi_cell)) = erase_info
else {
return;
};
if !stroke_tracker.active {
stroke_tracker.active = true;
stroke_tracker.level_id = Some(level_id);
stroke_tracker.layer_idx = Some(layer_idx);
stroke_tracker.changes.clear();
stroke_tracker.description = "Erase Tiles".to_string();
}
let mut tiles_to_update: Vec<(u32, u32)> = Vec::new();
{
let Some(level) = project.get_level_mut(level_id) else {
return;
};
let level_width = level.width;
if is_multi_cell {
for dy in 0..grid_height {
for dx in 0..grid_width {
let cx = base_x + dx;
let cy = base_y + dy;
let cidx = (cy * level_width + cx) as usize;
let old_tile = level.get_tile(layer_idx, cx, cy);
level.set_tile(layer_idx, cx, cy, None);
if dx != 0 || dy != 0 {
if let Some(layer) = level.layers.get_mut(layer_idx) {
if let LayerData::Tiles { occupied_cells, .. } = &mut layer.data {
occupied_cells.remove(&cidx);
}
}
}
if !stroke_tracker.changes.contains_key(&(cx, cy)) {
stroke_tracker.changes.insert((cx, cy), (old_tile, None));
} else if let Some(change) = stroke_tracker.changes.get_mut(&(cx, cy)) {
change.1 = None;
}
tiles_to_update.push((cx, cy));
}
}
} else {
let old_tile = level.get_tile(layer_idx, tile_x, tile_y);
level.set_tile(layer_idx, tile_x, tile_y, None);
if !stroke_tracker.changes.contains_key(&(tile_x, tile_y)) {
stroke_tracker
.changes
.insert((tile_x, tile_y), (old_tile, None));
} else if let Some(change) = stroke_tracker.changes.get_mut(&(tile_x, tile_y)) {
change.1 = None;
}
tiles_to_update.push((tile_x, tile_y));
}
}
for (cx, cy) in tiles_to_update {
crate::render::update_tile(
commands,
render_state,
project,
tileset_cache,
level_id,
layer_idx,
cx,
cy,
None,
);
}
project.mark_dirty();
editor_state.is_painting = true;
editor_state.last_painted_tile = Some((tile_x, tile_y));
}
fn place_entity(editor_state: &mut EditorState, project: &mut Project, world_pos: Vec2) {
let Some(level_id) = editor_state.selected_level else {
return;
};
let Some(layer_idx) = editor_state.selected_layer else {
return;
};
let Some(type_name) = editor_state.selected_entity_type.clone() else {
return;
};
{
let Some(level) = project.get_level(level_id) else {
return;
};
let Some(layer) = level.layers.get(layer_idx) else {
return;
};
if !matches!(&layer.data, LayerData::Objects { .. }) {
return; }
}
let tile_size = get_tile_size(editor_state, project);
let final_pos = if editor_state.snap_to_grid {
let snap_unit = tile_size / 2.0;
let snapped_x = (world_pos.x / snap_unit).round() * snap_unit;
let snapped_y = (world_pos.y / snap_unit).round() * snap_unit;
Vec2::new(snapped_x, snapped_y)
} else {
world_pos
};
{
let Some(level) = project.get_level(level_id) else {
return;
};
let level_width_px = level.width as f32 * tile_size;
let level_height_px = level.height as f32 * tile_size;
if final_pos.x < 0.0
|| final_pos.y < 0.0
|| final_pos.x >= level_width_px
|| final_pos.y >= level_height_px
{
return; }
}
let position = [final_pos.x, final_pos.y];
let mut entity = EntityInstance::new(type_name.clone(), position);
if let Some(type_def) = project.schema.get_type(&type_name) {
for prop in &type_def.properties {
if let Some(default_val) = &prop.default {
entity.properties.insert(
prop.name.clone(),
bevy_map_core::Value::from_json(default_val.clone()),
);
}
}
}
let entity_id = entity.id;
let Some(level) = project.get_level_mut(level_id) else {
return;
};
level.add_entity(entity);
if let Some(layer) = level.layers.get_mut(layer_idx) {
if let LayerData::Objects { entities } = &mut layer.data {
entities.push(entity_id);
}
}
project.mark_dirty();
editor_state.selection = Selection::Entity(level_id, entity_id);
}
fn fill_rectangle(
editor_state: &mut EditorState,
project: &mut Project,
render_state: &mut RenderState,
start_x: i32,
start_y: i32,
end_x: i32,
end_y: i32,
) {
let Some(level_id) = editor_state.selected_level else {
return;
};
let Some(layer_idx) = editor_state.selected_layer else {
return;
};
if !is_tile_layer(project, level_id, layer_idx) {
return;
}
let tile_index = editor_state.selected_tile;
let selected_tileset = editor_state.selected_tileset;
let Some(level) = project.get_level_mut(level_id) else {
return;
};
let level_width = level.width as i32;
let level_height = level.height as i32;
let min_x = start_x.min(end_x).max(0);
let max_x = start_x.max(end_x).min(level_width - 1);
let min_y = start_y.min(end_y).max(0);
let max_y = start_y.max(end_y).min(level_height - 1);
if let (Some(tile_idx), Some(sel_tileset)) = (tile_index, selected_tileset) {
let (has_tiles, layer_tileset) = level
.layers
.get(layer_idx)
.map(|layer| (layer_has_tiles(layer), get_layer_tileset_id(layer)))
.unwrap_or((false, None));
if has_tiles {
if layer_tileset != Some(sel_tileset) {
return;
}
} else {
if let Some(layer) = level.layers.get_mut(layer_idx) {
if let LayerData::Tiles { tileset_id, .. } = &mut layer.data {
*tileset_id = sel_tileset;
}
}
}
for y in min_y..=max_y {
for x in min_x..=max_x {
level.set_tile(layer_idx, x as u32, y as u32, Some(tile_idx));
}
}
} else {
for y in min_y..=max_y {
for x in min_x..=max_x {
level.set_tile(layer_idx, x as u32, y as u32, None);
}
}
}
project.mark_dirty();
render_state.needs_rebuild = true;
}
fn fill_area(
editor_state: &mut EditorState,
project: &mut Project,
render_state: &mut RenderState,
world_pos: Vec2,
) {
let Some(level_id) = editor_state.selected_level else {
return;
};
let Some(layer_idx) = editor_state.selected_layer else {
return;
};
let Some(tile_index) = editor_state.selected_tile else {
return;
};
let Some(selected_tileset) = editor_state.selected_tileset else {
return;
};
if !is_tile_layer(project, level_id, layer_idx) {
return;
}
let tile_size = get_tile_size(editor_state, project);
let start_x = (world_pos.x / tile_size).floor() as i32;
let start_y = (world_pos.y / tile_size).floor() as i32;
let Some(level) = project.get_level_mut(level_id) else {
return;
};
if start_x < 0 || start_y < 0 || start_x >= level.width as i32 || start_y >= level.height as i32
{
return;
}
let target_tile = level.get_tile(layer_idx, start_x as u32, start_y as u32);
if target_tile == Some(tile_index) {
return;
}
let (has_tiles, layer_tileset) = level
.layers
.get(layer_idx)
.map(|layer| (layer_has_tiles(layer), get_layer_tileset_id(layer)))
.unwrap_or((false, None));
if has_tiles {
if layer_tileset != Some(selected_tileset) {
return;
}
} else {
if let Some(layer) = level.layers.get_mut(layer_idx) {
if let LayerData::Tiles { tileset_id, .. } = &mut layer.data {
*tileset_id = selected_tileset;
}
}
}
let level_width = level.width;
let level_height = level.height;
let mut stack = vec![(start_x as u32, start_y as u32)];
let mut visited = std::collections::HashSet::new();
while let Some((x, y)) = stack.pop() {
if visited.contains(&(x, y)) {
continue;
}
visited.insert((x, y));
if level.get_tile(layer_idx, x, y) != target_tile {
continue;
}
level.set_tile(layer_idx, x, y, Some(tile_index));
if x > 0 {
stack.push((x - 1, y));
}
if x < level_width - 1 {
stack.push((x + 1, y));
}
if y > 0 {
stack.push((x, y - 1));
}
if y < level_height - 1 {
stack.push((x, y + 1));
}
}
project.mark_dirty();
render_state.needs_rebuild = true;
}
fn paint_terrain_tile(
commands: &mut Commands,
editor_state: &mut EditorState,
project: &mut Project,
render_state: &mut RenderState,
input_state: &mut ViewportInputState,
stroke_tracker: &mut PaintStrokeTracker,
tileset_cache: &crate::ui::TilesetTextureCache,
world_pos: Vec2,
full_tile_mode: bool,
) {
let Some(level_id) = editor_state.selected_level else {
return;
};
let Some(layer_idx) = editor_state.selected_layer else {
return;
};
if !is_tile_layer(project, level_id, layer_idx) {
return;
}
if let Some(terrain_set_id) = editor_state.selected_terrain_set {
paint_terrain_set_tile(
commands,
editor_state,
project,
render_state,
input_state,
stroke_tracker,
tileset_cache,
world_pos,
level_id,
layer_idx,
terrain_set_id,
full_tile_mode,
);
}
else if let Some(terrain_id) = editor_state.selected_terrain {
paint_legacy_terrain_tile(
commands,
editor_state,
project,
render_state,
stroke_tracker,
tileset_cache,
world_pos,
level_id,
layer_idx,
terrain_id,
);
}
}
fn get_paint_targets_along_line(
start: Vec2,
end: Vec2,
tile_size: f32,
set_type: bevy_map_autotile::TerrainSetType,
) -> Vec<bevy_map_autotile::PaintTarget> {
let mut targets = Vec::new();
let dist = start.distance(end);
let steps = (dist / (tile_size * 0.4)).ceil() as i32;
let steps = steps.max(1);
for i in 0..=steps {
let t = i as f32 / steps as f32;
let pos = start.lerp(end, t);
let target = bevy_map_autotile::get_paint_target(pos.x, pos.y, tile_size, set_type);
if targets.last() != Some(&target) {
targets.push(target);
}
}
targets
}
fn paint_terrain_set_tile(
commands: &mut Commands,
editor_state: &mut EditorState,
project: &mut Project,
render_state: &mut RenderState,
input_state: &mut ViewportInputState,
stroke_tracker: &mut PaintStrokeTracker,
tileset_cache: &crate::ui::TilesetTextureCache,
world_pos: Vec2,
level_id: uuid::Uuid,
layer_idx: usize,
terrain_set_id: uuid::Uuid,
full_tile_mode: bool,
) {
let Some(terrain_idx) = editor_state.selected_terrain_in_set else {
return;
};
let (set_type, selected_tileset) = {
let Some(ts) = project.autotile_config.get_terrain_set(terrain_set_id) else {
return;
};
(ts.set_type, ts.tileset_id)
};
let tile_size = project
.tilesets
.iter()
.find(|t| t.id == selected_tileset)
.map(|t| t.tile_size as f32)
.unwrap_or(32.0);
let paint_targets = if full_tile_mode {
let tile_x = (world_pos.x / tile_size).floor() as u32;
let tile_y = (world_pos.y / tile_size).floor() as u32;
vec![
bevy_map_autotile::PaintTarget::Corner {
corner_x: tile_x,
corner_y: tile_y,
},
bevy_map_autotile::PaintTarget::Corner {
corner_x: tile_x + 1,
corner_y: tile_y,
},
bevy_map_autotile::PaintTarget::Corner {
corner_x: tile_x,
corner_y: tile_y + 1,
},
bevy_map_autotile::PaintTarget::Corner {
corner_x: tile_x + 1,
corner_y: tile_y + 1,
},
bevy_map_autotile::PaintTarget::HorizontalEdge {
tile_x,
edge_y: tile_y,
},
bevy_map_autotile::PaintTarget::HorizontalEdge {
tile_x,
edge_y: tile_y + 1,
},
bevy_map_autotile::PaintTarget::VerticalEdge {
edge_x: tile_x,
tile_y,
},
bevy_map_autotile::PaintTarget::VerticalEdge {
edge_x: tile_x + 1,
tile_y,
},
]
} else if let Some(last_pos) = input_state.last_paint_world_pos {
get_paint_targets_along_line(last_pos, world_pos, tile_size, set_type)
} else {
vec![bevy_map_autotile::get_paint_target(
world_pos.x,
world_pos.y,
tile_size,
set_type,
)]
};
let new_targets: Vec<_> = paint_targets
.into_iter()
.filter(|target| !input_state.painted_targets_this_stroke.contains(target))
.collect();
if new_targets.is_empty() {
input_state.last_paint_world_pos = Some(world_pos);
return;
}
let Some(level) = project.levels.iter_mut().find(|l| l.id == level_id) else {
return;
};
let level_width = level.width;
let level_height = level.height;
let (has_tiles, layer_tileset) = level
.layers
.get(layer_idx)
.map(|layer| (layer_has_tiles(layer), get_layer_tileset_id(layer)))
.unwrap_or((false, None));
if has_tiles {
if layer_tileset != Some(selected_tileset) {
input_state.last_paint_world_pos = Some(world_pos);
return;
}
} else {
if let Some(layer) = level.layers.get_mut(layer_idx) {
if let LayerData::Tiles { tileset_id, .. } = &mut layer.data {
*tileset_id = selected_tileset;
}
}
}
let tiles = if let Some(layer) = level.layers.get_mut(layer_idx) {
if let LayerData::Tiles { tiles, .. } = &mut layer.data {
tiles
} else {
input_state.last_paint_world_pos = Some(world_pos);
return;
}
} else {
input_state.last_paint_world_pos = Some(world_pos);
return;
};
if !stroke_tracker.active {
stroke_tracker.active = true;
stroke_tracker.level_id = Some(level_id);
stroke_tracker.layer_idx = Some(layer_idx);
stroke_tracker.changes.clear();
stroke_tracker.description = "Paint Terrain".to_string();
}
let Some(terrain_set) = project.autotile_config.get_terrain_set(terrain_set_id) else {
return;
};
let (min_x, min_y, max_x, max_y) = calculate_targets_bounds(&new_targets, 2);
let unified_snapshot =
capture_tile_region_bounds(tiles, level_width, level_height, min_x, min_y, max_x, max_y);
bevy_map_autotile::paint_terrain_at_targets(
tiles,
level_width,
level_height,
&new_targets,
terrain_set,
terrain_idx,
);
let mut changed_tiles = Vec::new();
for ((x, y), old_tile) in unified_snapshot {
let idx = (y * level_width + x) as usize;
let new_tile = tiles.get(idx).copied().flatten();
if old_tile != new_tile {
changed_tiles.push((x, y, new_tile));
if !stroke_tracker.changes.contains_key(&(x, y)) {
stroke_tracker.changes.insert((x, y), (old_tile, new_tile));
} else if let Some(change) = stroke_tracker.changes.get_mut(&(x, y)) {
change.1 = new_tile;
}
}
}
for paint_target in &new_targets {
input_state
.painted_targets_this_stroke
.insert(*paint_target);
}
for (x, y, new_tile) in changed_tiles {
crate::render::update_tile(
commands,
render_state,
project,
tileset_cache,
level_id,
layer_idx,
x,
y,
new_tile,
);
}
project.mark_dirty();
editor_state.is_painting = true;
input_state.last_paint_world_pos = Some(world_pos);
}
fn paint_legacy_terrain_tile(
commands: &mut Commands,
editor_state: &mut EditorState,
project: &mut Project,
render_state: &mut RenderState,
stroke_tracker: &mut PaintStrokeTracker,
tileset_cache: &crate::ui::TilesetTextureCache,
world_pos: Vec2,
level_id: uuid::Uuid,
layer_idx: usize,
terrain_id: uuid::Uuid,
) {
let terrain = match project.autotile_config.get_terrain(terrain_id) {
Some(t) => t.clone(),
None => return,
};
let selected_tileset = terrain.tileset_id;
let tile_size = project
.tilesets
.iter()
.find(|t| t.id == selected_tileset)
.map(|t| t.tile_size as f32)
.unwrap_or(32.0);
let tile_x = (world_pos.x / tile_size).floor() as i32;
let tile_y = (world_pos.y / tile_size).floor() as i32;
if editor_state.last_painted_tile == Some((tile_x as u32, tile_y as u32)) {
return;
}
let Some(level) = project.get_level_mut(level_id) else {
return;
};
if tile_x < 0 || tile_y < 0 || tile_x >= level.width as i32 || tile_y >= level.height as i32 {
return;
}
let tile_x_u32 = tile_x as u32;
let tile_y_u32 = tile_y as u32;
let (has_tiles, layer_tileset) = level
.layers
.get(layer_idx)
.map(|layer| (layer_has_tiles(layer), get_layer_tileset_id(layer)))
.unwrap_or((false, None));
if has_tiles {
if layer_tileset != Some(selected_tileset) {
return;
}
} else {
if let Some(layer) = level.layers.get_mut(layer_idx) {
if let LayerData::Tiles { tileset_id, .. } = &mut layer.data {
*tileset_id = selected_tileset;
}
}
}
let level_width = level.width;
let level_height = level.height;
if let Some(layer) = level.layers.get_mut(layer_idx) {
if let LayerData::Tiles { tiles, .. } = &mut layer.data {
let snapshot_region =
capture_tile_region(tiles, level_width, level_height, tile_x, tile_y, 1);
let first_tile = terrain.base_tile.saturating_sub(46);
let last_tile = terrain.base_tile;
let is_terrain_tile = |tile: Option<u32>| -> bool {
match tile {
Some(t) => t >= first_tile && t <= last_tile,
None => false,
}
};
bevy_map_autotile::paint_autotile(
tiles,
level_width,
level_height,
tile_x_u32,
tile_y_u32,
&terrain,
is_terrain_tile,
);
if !stroke_tracker.active {
stroke_tracker.active = true;
stroke_tracker.level_id = Some(level_id);
stroke_tracker.layer_idx = Some(layer_idx);
stroke_tracker.changes.clear();
stroke_tracker.description = "Paint Terrain".to_string();
}
let mut changed_tiles = Vec::new();
for ((x, y), old_tile) in snapshot_region {
let idx = (y * level_width + x) as usize;
let new_tile = tiles.get(idx).copied().flatten();
if old_tile != new_tile {
changed_tiles.push((x, y, new_tile));
if !stroke_tracker.changes.contains_key(&(x, y)) {
stroke_tracker.changes.insert((x, y), (old_tile, new_tile));
} else {
if let Some(change) = stroke_tracker.changes.get_mut(&(x, y)) {
change.1 = new_tile;
}
}
}
}
for (x, y, new_tile) in changed_tiles {
crate::render::update_tile(
commands,
render_state,
project,
tileset_cache,
level_id,
layer_idx,
x,
y,
new_tile,
);
}
}
}
project.mark_dirty();
editor_state.is_painting = true;
editor_state.last_painted_tile = Some((tile_x_u32, tile_y_u32));
}
fn fill_terrain_rectangle(
editor_state: &mut EditorState,
project: &mut Project,
render_state: &mut RenderState,
history: &mut CommandHistory,
start_x: i32,
start_y: i32,
end_x: i32,
end_y: i32,
) {
let Some(level_id) = editor_state.selected_level else {
return;
};
let Some(layer_idx) = editor_state.selected_layer else {
return;
};
let Some(terrain_set_id) = editor_state.selected_terrain_set else {
return;
};
let Some(terrain_idx) = editor_state.selected_terrain_in_set else {
return;
};
if !is_tile_layer(project, level_id, layer_idx) {
return;
}
let selected_tileset = {
let Some(ts) = project.autotile_config.get_terrain_set(terrain_set_id) else {
return;
};
ts.tileset_id
};
let Some(level) = project.levels.iter().find(|l| l.id == level_id) else {
return;
};
let level_width = level.width as i32;
let level_height = level.height as i32;
let min_x = start_x.min(end_x).max(0);
let max_x = start_x.max(end_x).min(level_width - 1);
let min_y = start_y.min(end_y).max(0);
let max_y = start_y.max(end_y).min(level_height - 1);
let update_min_x = (min_x - 1).max(0);
let update_max_x = (max_x + 1).min(level_width - 1);
let update_min_y = (min_y - 1).max(0);
let update_max_y = (max_y + 1).min(level_height - 1);
let before_tiles = collect_tiles_in_region(
project,
level_id,
layer_idx,
update_min_x,
update_max_x,
update_min_y,
update_max_y,
);
let Some(level) = project.levels.iter_mut().find(|l| l.id == level_id) else {
return;
};
let (has_tiles, layer_tileset) = level
.layers
.get(layer_idx)
.map(|layer| (layer_has_tiles(layer), get_layer_tileset_id(layer)))
.unwrap_or((false, None));
if has_tiles {
if layer_tileset != Some(selected_tileset) {
return;
}
} else {
if let Some(layer) = level.layers.get_mut(layer_idx) {
if let LayerData::Tiles { tileset_id, .. } = &mut layer.data {
*tileset_id = selected_tileset;
}
}
}
let level_width = level.width;
let level_height = level.height;
let tiles = if let Some(layer) = level.layers.get_mut(layer_idx) {
if let LayerData::Tiles { tiles, .. } = &mut layer.data {
tiles
} else {
return;
}
} else {
return;
};
let Some(terrain_set) = project.autotile_config.get_terrain_set(terrain_set_id) else {
return;
};
let uniform_tiles = terrain_set.find_uniform_tiles(terrain_idx);
let uniform_tile = uniform_tiles.first().copied();
if let Some(tile_index) = uniform_tile {
for y in min_y..=max_y {
for x in min_x..=max_x {
let idx = (y as u32 * level_width + x as u32) as usize;
if idx < tiles.len() {
tiles[idx] = Some(tile_index);
}
}
}
} else {
return;
}
for y in min_y..=max_y {
for x in min_x..=max_x {
let is_at_edge = x == min_x || x == max_x || y == min_y || y == max_y;
if is_at_edge {
bevy_map_autotile::update_tile_with_neighbors(
tiles,
level_width,
level_height,
x,
y,
terrain_set,
terrain_idx,
);
}
}
}
let update_min_x = (min_x - 1).max(0);
let update_max_x = (max_x + 1).min(level_width as i32 - 1);
let update_min_y = (min_y - 1).max(0);
let update_max_y = (max_y + 1).min(level_height as i32 - 1);
for y in update_min_y..=update_max_y {
for x in update_min_x..=update_max_x {
let is_inside = x >= min_x && x <= max_x && y >= min_y && y <= max_y;
if is_inside {
continue;
}
let idx = (y as u32 * level_width + x as u32) as usize;
let current_tile = tiles.get(idx).copied().flatten();
if let Some(tile) = current_tile {
if let Some(tile_data) = terrain_set.get_tile_terrain(tile) {
if let Some(primary_terrain) = tile_data.terrains.iter().find_map(|t| *t) {
bevy_map_autotile::update_tile_with_neighbors(
tiles,
level_width,
level_height,
x,
y,
terrain_set,
primary_terrain,
);
}
}
}
}
}
let after_tiles = collect_tiles_in_region(
project,
level_id,
layer_idx,
update_min_x,
update_max_x,
update_min_y,
update_max_y,
);
let command = BatchTileCommand::from_diff(
level_id,
layer_idx,
before_tiles,
after_tiles,
"Fill Terrain Rectangle",
);
if !command.changes.is_empty() {
let mut inverse_changes = std::collections::HashMap::new();
for ((x, y), (old_tile, new_tile)) in &command.changes {
inverse_changes.insert((*x, *y), (*new_tile, *old_tile));
}
let inverse_command = BatchTileCommand::new(
level_id,
layer_idx,
inverse_changes,
"Undo Fill Terrain Rectangle",
);
history.push_undo(Box::new(inverse_command));
}
project.mark_dirty();
render_state.needs_rebuild = true;
}
fn finalize_paint_stroke(
mut stroke_tracker: ResMut<PaintStrokeTracker>,
mut history: ResMut<CommandHistory>,
editor_state: Res<EditorState>,
mouse_buttons: Res<ButtonInput<MouseButton>>,
) {
if !stroke_tracker.active {
return;
}
if !editor_state.is_painting && !mouse_buttons.pressed(MouseButton::Left) {
if !stroke_tracker.changes.is_empty() {
if let (Some(level_id), Some(layer_idx)) =
(stroke_tracker.level_id, stroke_tracker.layer_idx)
{
let mut inverse_changes = HashMap::new();
for ((x, y), (old_tile, new_tile)) in &stroke_tracker.changes {
inverse_changes.insert((*x, *y), (*new_tile, *old_tile));
}
let inverse_command = BatchTileCommand::new(
level_id,
layer_idx,
inverse_changes,
stroke_tracker.description.clone(),
);
history.push_undo(Box::new(inverse_command));
}
}
stroke_tracker.active = false;
stroke_tracker.level_id = None;
stroke_tracker.layer_idx = None;
stroke_tracker.changes.clear();
stroke_tracker.description.clear();
}
}
fn calculate_terrain_preview(
editor_state: &mut EditorState,
project: &Project,
world_pos: Vec2,
tile_size: f32,
full_tile_mode: bool,
) {
let Some(level_id) = editor_state.selected_level else {
editor_state.terrain_preview.active = false;
return;
};
let Some(layer_idx) = editor_state.selected_layer else {
editor_state.terrain_preview.active = false;
return;
};
let Some(terrain_set_id) = editor_state.selected_terrain_set else {
editor_state.terrain_preview.active = false;
return;
};
let Some(terrain_idx) = editor_state.selected_terrain_in_set else {
editor_state.terrain_preview.active = false;
return;
};
let Some(terrain_set) = project.autotile_config.get_terrain_set(terrain_set_id) else {
editor_state.terrain_preview.active = false;
return;
};
let tileset_id = terrain_set.tileset_id;
let Some(level) = project.levels.iter().find(|l| l.id == level_id) else {
editor_state.terrain_preview.active = false;
return;
};
let Some(layer) = level.layers.get(layer_idx) else {
editor_state.terrain_preview.active = false;
return;
};
let tiles = if let LayerData::Tiles { tiles, .. } = &layer.data {
tiles
} else {
editor_state.terrain_preview.active = false;
return;
};
let paint_targets = if full_tile_mode {
let tile_x = (world_pos.x / tile_size).floor() as u32;
let tile_y = (world_pos.y / tile_size).floor() as u32;
vec![
bevy_map_autotile::PaintTarget::Corner {
corner_x: tile_x,
corner_y: tile_y,
},
bevy_map_autotile::PaintTarget::Corner {
corner_x: tile_x + 1,
corner_y: tile_y,
},
bevy_map_autotile::PaintTarget::Corner {
corner_x: tile_x,
corner_y: tile_y + 1,
},
bevy_map_autotile::PaintTarget::Corner {
corner_x: tile_x + 1,
corner_y: tile_y + 1,
},
bevy_map_autotile::PaintTarget::HorizontalEdge {
tile_x,
edge_y: tile_y,
},
bevy_map_autotile::PaintTarget::HorizontalEdge {
tile_x,
edge_y: tile_y + 1,
},
bevy_map_autotile::PaintTarget::VerticalEdge {
edge_x: tile_x,
tile_y,
},
bevy_map_autotile::PaintTarget::VerticalEdge {
edge_x: tile_x + 1,
tile_y,
},
]
} else {
vec![bevy_map_autotile::get_paint_target(
world_pos.x,
world_pos.y,
tile_size,
terrain_set.set_type,
)]
};
let preview_tiles = bevy_map_autotile::preview_terrain_at_targets(
tiles,
level.width,
level.height,
&paint_targets,
terrain_set,
terrain_idx,
);
editor_state.terrain_preview.preview_tiles = preview_tiles;
editor_state.terrain_preview.tileset_id = Some(tileset_id);
editor_state.terrain_preview.active = true;
}