410 lines
13 KiB
Rust
410 lines
13 KiB
Rust
use bevy::ecs::system::SystemParam;
|
|
use bevy::picking::mesh_picking::ray_cast::{MeshRayCast, MeshRayCastSettings, RayCastVisibility};
|
|
use bevy::picking::pointer::{PointerAction, PointerInput, PointerInteraction};
|
|
use bevy::prelude::*;
|
|
use shared::LevelObject;
|
|
use transform_gizmo_bevy::prelude::GizmoTarget;
|
|
|
|
use crate::camera::EditorCamera;
|
|
use crate::infra::EditorOnly;
|
|
use crate::state::scene_tools_active;
|
|
use crate::state::EditorMode;
|
|
use crate::ui::helpers::ensure_player_spawn_for_edit;
|
|
use crate::ui::hierarchy_ops::is_entity_locked;
|
|
use crate::ui::hierarchy_state::HierarchyPanelState;
|
|
use crate::ui::UiState;
|
|
use crate::viewport::actor_icons::ActorIconProxy;
|
|
use crate::viewport::brush_tool::BrushToolState;
|
|
use crate::viewport::ViewportDisplayMode;
|
|
use crate::visualizers::EditorVisualizerProxy;
|
|
use bevy_egui::egui;
|
|
|
|
use crate::viewport::scene_view_ray;
|
|
|
|
#[derive(Resource, Default, Debug, Clone, Copy)]
|
|
pub struct SelectedEntity(pub Option<Entity>);
|
|
|
|
/// Primary click on the viewport panel (egui), consumed by [`handle_pick_events`].
|
|
#[derive(Resource, Default, Debug, Clone, Copy)]
|
|
pub struct ViewportClick(pub Option<egui::Pos2>);
|
|
|
|
/// Overlapping pick targets at the last viewport click; **Tab** cycles forward.
|
|
#[derive(Resource, Default, Debug)]
|
|
pub struct ViewportPickStack {
|
|
pub entities: Vec<Entity>,
|
|
pub index: usize,
|
|
pub anchor: Option<egui::Pos2>,
|
|
}
|
|
|
|
pub struct EditorSelectionPlugin;
|
|
|
|
#[derive(SystemParam)]
|
|
struct PickTargetQueries<'w, 's> {
|
|
level_objects: Query<'w, 's, Entity, (With<LevelObject>, Without<EditorOnly>)>,
|
|
proxies: Query<'w, 's, &'static EditorVisualizerProxy>,
|
|
icon_proxies: Query<'w, 's, &'static ActorIconProxy>,
|
|
parents: Query<'w, 's, &'static ChildOf>,
|
|
editor_only: Query<'w, 's, (), With<EditorOnly>>,
|
|
}
|
|
|
|
impl Plugin for EditorSelectionPlugin {
|
|
fn build(&self, app: &mut App) {
|
|
app.init_resource::<SelectedEntity>()
|
|
.init_resource::<ViewportClick>()
|
|
.init_resource::<ViewportPickStack>()
|
|
.add_systems(
|
|
Update,
|
|
(
|
|
redirect_runtime_player_selection_to_spawn,
|
|
sync_gizmo_targets,
|
|
)
|
|
.chain()
|
|
.run_if(scene_tools_active),
|
|
)
|
|
// After transform-gizmo `update_gizmos` (Last) so clicks hit gizmos first.
|
|
.add_systems(
|
|
Last,
|
|
(handle_pick_events, cycle_overlapping_viewport_pick)
|
|
.chain()
|
|
.run_if(scene_tools_active),
|
|
);
|
|
}
|
|
}
|
|
|
|
fn redirect_runtime_player_selection_to_spawn(world: &mut World) {
|
|
if *world.resource::<State<EditorMode>>().get() != EditorMode::Editing {
|
|
return;
|
|
}
|
|
let Some(selected_entity) = world.resource::<SelectedEntity>().0 else {
|
|
return;
|
|
};
|
|
if world.get::<sim::Player>(selected_entity).is_none() {
|
|
return;
|
|
}
|
|
|
|
let spawn = ensure_player_spawn_for_edit(world);
|
|
world.resource_mut::<SelectedEntity>().0 = Some(spawn);
|
|
if let Some(mut ui_state) = world.get_resource_mut::<UiState>() {
|
|
ui_state.selected_entities.select_replace(spawn);
|
|
}
|
|
}
|
|
|
|
#[allow(clippy::too_many_arguments)]
|
|
fn handle_pick_events(
|
|
mut ui_state: ResMut<UiState>,
|
|
mut selected: ResMut<SelectedEntity>,
|
|
mut viewport_click: ResMut<ViewportClick>,
|
|
mut pick_stack: ResMut<ViewportPickStack>,
|
|
mut click_events: MessageReader<PointerInput>,
|
|
pointers: Query<&PointerInteraction>,
|
|
editor_cameras: Query<(&Camera, &GlobalTransform), With<EditorCamera>>,
|
|
mut mesh_ray_cast: MeshRayCast,
|
|
pick_targets: PickTargetQueries,
|
|
keys: Res<ButtonInput<KeyCode>>,
|
|
buttons: Res<ButtonInput<MouseButton>>,
|
|
display: Res<ViewportDisplayMode>,
|
|
brush_tool: Res<BrushToolState>,
|
|
gizmo_targets: Query<&GizmoTarget>,
|
|
hierarchy: Option<Res<HierarchyPanelState>>,
|
|
) -> Result {
|
|
if ui_state.pointer_in_viewport
|
|
&& !buttons.any_pressed([MouseButton::Right, MouseButton::Middle])
|
|
&& keys.just_pressed(KeyCode::Escape)
|
|
{
|
|
ui_state.selected_entities.clear();
|
|
selected.0 = None;
|
|
pick_stack.entities.clear();
|
|
pick_stack.index = 0;
|
|
pick_stack.anchor = None;
|
|
return Ok(());
|
|
}
|
|
|
|
if display.clean_game_view {
|
|
viewport_click.0 = None;
|
|
return Ok(());
|
|
}
|
|
|
|
if brush_tool.active {
|
|
return Ok(());
|
|
}
|
|
|
|
if gizmo_targets
|
|
.iter()
|
|
.any(|g| GizmoTarget::is_focused(g) || GizmoTarget::is_active(g))
|
|
{
|
|
viewport_click.0 = None;
|
|
return Ok(());
|
|
}
|
|
|
|
let additive = keys.any_pressed([KeyCode::ControlLeft, KeyCode::ShiftLeft]);
|
|
|
|
if let Some(pointer_pos) = viewport_click.0.take() {
|
|
apply_viewport_pick(
|
|
hierarchy.as_deref(),
|
|
&mut ui_state,
|
|
&mut selected,
|
|
&mut pick_stack,
|
|
&editor_cameras,
|
|
&mut mesh_ray_cast,
|
|
&pick_targets,
|
|
pointer_pos,
|
|
additive,
|
|
);
|
|
return Ok(());
|
|
}
|
|
|
|
if !ui_state.pointer_in_viewport {
|
|
return Ok(());
|
|
}
|
|
|
|
for event in click_events.read() {
|
|
if !matches!(event.action, PointerAction::Press(PointerButton::Primary)) {
|
|
continue;
|
|
}
|
|
|
|
let mut picked = None;
|
|
for interaction in &pointers {
|
|
if let Some(entity) = interaction
|
|
.as_slice()
|
|
.iter()
|
|
.find_map(|(entity, _hit)| pick_target_for_entity(*entity, &pick_targets))
|
|
{
|
|
picked = Some(entity);
|
|
break;
|
|
}
|
|
}
|
|
|
|
if picked.is_none() {
|
|
let Some(pointer_pos) = ui_state.viewport_pointer_pos else {
|
|
continue;
|
|
};
|
|
let hits = collect_viewport_pick_hits(
|
|
&editor_cameras,
|
|
&mut mesh_ray_cast,
|
|
&pick_targets,
|
|
ui_state.viewport_rect,
|
|
pointer_pos,
|
|
);
|
|
pick_stack.entities = hits;
|
|
pick_stack.index = 0;
|
|
pick_stack.anchor = Some(pointer_pos);
|
|
picked = pick_stack.entities.first().copied();
|
|
} else {
|
|
pick_stack.entities.clear();
|
|
pick_stack.index = 0;
|
|
pick_stack.anchor = ui_state.viewport_pointer_pos;
|
|
}
|
|
|
|
apply_pick_result(
|
|
hierarchy.as_deref(),
|
|
&mut ui_state,
|
|
&mut selected,
|
|
picked,
|
|
additive,
|
|
);
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
fn cycle_overlapping_viewport_pick(
|
|
mut ui_state: ResMut<UiState>,
|
|
mut selected: ResMut<SelectedEntity>,
|
|
mut pick_stack: ResMut<ViewportPickStack>,
|
|
keys: Res<ButtonInput<KeyCode>>,
|
|
buttons: Res<ButtonInput<MouseButton>>,
|
|
display: Res<ViewportDisplayMode>,
|
|
) -> Result {
|
|
if display.clean_game_view {
|
|
return Ok(());
|
|
}
|
|
if !ui_state.pointer_in_viewport
|
|
|| buttons.any_pressed([MouseButton::Right, MouseButton::Middle])
|
|
{
|
|
return Ok(());
|
|
}
|
|
if !keys.just_pressed(KeyCode::Tab) || pick_stack.entities.len() < 2 {
|
|
return Ok(());
|
|
}
|
|
if keys.any_pressed([KeyCode::ControlLeft, KeyCode::ShiftLeft]) {
|
|
return Ok(());
|
|
}
|
|
|
|
pick_stack.index = (pick_stack.index + 1) % pick_stack.entities.len();
|
|
let entity = pick_stack.entities[pick_stack.index];
|
|
ui_state.selected_entities.select_replace(entity);
|
|
selected.0 = Some(entity);
|
|
Ok(())
|
|
}
|
|
|
|
fn apply_viewport_pick(
|
|
hierarchy: Option<&HierarchyPanelState>,
|
|
ui_state: &mut UiState,
|
|
selected: &mut SelectedEntity,
|
|
pick_stack: &mut ViewportPickStack,
|
|
editor_cameras: &Query<(&Camera, &GlobalTransform), With<EditorCamera>>,
|
|
mesh_ray_cast: &mut MeshRayCast,
|
|
pick_targets: &PickTargetQueries,
|
|
pointer_pos: egui::Pos2,
|
|
additive: bool,
|
|
) {
|
|
let hits = collect_viewport_pick_hits(
|
|
editor_cameras,
|
|
mesh_ray_cast,
|
|
pick_targets,
|
|
ui_state.viewport_rect,
|
|
pointer_pos,
|
|
);
|
|
pick_stack.entities = hits;
|
|
pick_stack.index = 0;
|
|
pick_stack.anchor = Some(pointer_pos);
|
|
let picked = pick_stack.entities.first().copied();
|
|
apply_pick_result(hierarchy, ui_state, selected, picked, additive);
|
|
}
|
|
|
|
fn apply_pick_result(
|
|
hierarchy: Option<&HierarchyPanelState>,
|
|
ui_state: &mut UiState,
|
|
selected: &mut SelectedEntity,
|
|
picked: Option<Entity>,
|
|
additive: bool,
|
|
) {
|
|
if let Some(entity) = picked {
|
|
if hierarchy.is_some_and(|state| is_entity_locked(&state.locked, entity)) {
|
|
return;
|
|
}
|
|
ui_state
|
|
.selected_entities
|
|
.select_maybe_add(entity, additive);
|
|
selected.0 = Some(entity);
|
|
} else if !additive {
|
|
ui_state.selected_entities.clear();
|
|
selected.0 = None;
|
|
}
|
|
}
|
|
|
|
fn collect_viewport_pick_hits(
|
|
editor_cameras: &Query<(&Camera, &GlobalTransform), With<EditorCamera>>,
|
|
mesh_ray_cast: &mut MeshRayCast,
|
|
pick_targets: &PickTargetQueries,
|
|
scene_rect: egui::Rect,
|
|
pointer_pos: egui::Pos2,
|
|
) -> Vec<Entity> {
|
|
if !scene_rect.contains(pointer_pos) {
|
|
return Vec::new();
|
|
}
|
|
|
|
let (camera, transform) = match editor_cameras
|
|
.iter()
|
|
.find(|(camera, _)| camera.is_active)
|
|
.or_else(|| editor_cameras.iter().next())
|
|
{
|
|
Some(pair) => pair,
|
|
None => return Vec::new(),
|
|
};
|
|
let Some(ray) = scene_view_ray(camera, transform, pointer_pos, scene_rect) else {
|
|
return Vec::new();
|
|
};
|
|
let filter = |entity: Entity| pick_target_for_entity(entity, pick_targets).is_some();
|
|
let settings = MeshRayCastSettings::default()
|
|
.with_visibility(RayCastVisibility::Any)
|
|
.with_filter(&filter)
|
|
.never_early_exit();
|
|
let hits = mesh_ray_cast.cast_ray(ray, &settings);
|
|
|
|
let mut seen = std::collections::HashSet::new();
|
|
let mut ordered = Vec::new();
|
|
for icon_priority in [true, false] {
|
|
for (entity, _) in hits {
|
|
let is_icon = pick_targets.icon_proxies.get(*entity).is_ok();
|
|
if is_icon != icon_priority {
|
|
continue;
|
|
}
|
|
let Some(target) = pick_target_for_entity(*entity, pick_targets) else {
|
|
continue;
|
|
};
|
|
if seen.insert(target) {
|
|
ordered.push(target);
|
|
}
|
|
}
|
|
}
|
|
ordered
|
|
}
|
|
|
|
fn pick_target_for_entity(entity: Entity, pick_targets: &PickTargetQueries) -> Option<Entity> {
|
|
if let Ok(proxy) = pick_targets.icon_proxies.get(entity) {
|
|
return pick_targets
|
|
.level_objects
|
|
.contains(proxy.target)
|
|
.then_some(proxy.target);
|
|
}
|
|
if let Ok(proxy) = pick_targets.proxies.get(entity) {
|
|
return Some(proxy.target);
|
|
}
|
|
if pick_targets.editor_only.contains(entity) {
|
|
return None;
|
|
}
|
|
if pick_targets.level_objects.contains(entity) {
|
|
return Some(entity);
|
|
}
|
|
let mut current = pick_targets
|
|
.parents
|
|
.get(entity)
|
|
.ok()
|
|
.map(|parent| parent.parent());
|
|
while let Some(parent) = current {
|
|
if pick_targets.editor_only.contains(parent) {
|
|
return None;
|
|
}
|
|
if pick_targets.level_objects.contains(parent) {
|
|
return Some(parent);
|
|
}
|
|
current = pick_targets
|
|
.parents
|
|
.get(parent)
|
|
.ok()
|
|
.map(|parent| parent.parent());
|
|
}
|
|
None
|
|
}
|
|
|
|
fn sync_gizmo_targets(
|
|
mut ui_state: ResMut<UiState>,
|
|
mut selected: ResMut<SelectedEntity>,
|
|
mut commands: Commands,
|
|
targets: Query<Entity, With<GizmoTarget>>,
|
|
level_objects: Query<(), (With<LevelObject>, Without<EditorOnly>)>,
|
|
editor_only: Query<(), With<EditorOnly>>,
|
|
transforms: Query<(), With<Transform>>,
|
|
hierarchy: Option<Res<HierarchyPanelState>>,
|
|
display: Res<ViewportDisplayMode>,
|
|
) {
|
|
ui_state
|
|
.selected_entities
|
|
.retain(|entity| transforms.contains(entity) && !editor_only.contains(entity));
|
|
selected.0 = ui_state.selected_entities.as_slice().first().copied();
|
|
|
|
if display.clean_game_view {
|
|
for entity in &targets {
|
|
commands.entity(entity).remove::<GizmoTarget>();
|
|
}
|
|
return;
|
|
}
|
|
|
|
for entity in &targets {
|
|
if !ui_state.selected_entities.contains(entity) {
|
|
commands.entity(entity).remove::<GizmoTarget>();
|
|
}
|
|
}
|
|
if let Some(entity) = ui_state.selected_entities.as_slice().first().copied() {
|
|
if level_objects.contains(entity)
|
|
&& transforms.contains(entity)
|
|
&& !targets.contains(entity)
|
|
&& !hierarchy
|
|
.as_ref()
|
|
.is_some_and(|state| is_entity_locked(&state.locked, entity))
|
|
{
|
|
commands.entity(entity).insert(GizmoTarget::default());
|
|
}
|
|
}
|
|
}
|