bevy_yoleck/
vpeol_2d.rs

1//! # Viewport Editing Overlay for 2D games.
2//!
3//! Use this module to implement simple 2D editing for 2D games.
4//!
5//! To use add the egui and Yoleck plugins to the Bevy app, as well as the plugin of this module:
6//!
7//! ```no_run
8//! # use bevy::prelude::*;
9//! # use bevy_yoleck::bevy_egui::EguiPlugin;
10//! # use bevy_yoleck::prelude::*;
11//! # use bevy_yoleck::vpeol::prelude::*;
12//! # let mut app = App::new();
13//! app.add_plugins(EguiPlugin {
14//!     enable_multipass_for_primary_context: true,
15//! });
16//! app.add_plugins(YoleckPluginForEditor);
17//! // Use `Vpeol2dPluginForGame` instead when setting up for game.
18//! app.add_plugins(Vpeol2dPluginForEditor);
19//! ```
20//!
21//! Add the following components to the camera entity:
22//! * [`VpeolCameraState`] in order to select and drag entities.
23//! * [`Vpeol2dCameraControl`] in order to pan and zoom the camera with the mouse. This one can be
24//!   skipped if there are other means to control the camera inside the editor, or if no camera
25//!   control inside the editor is desired.
26//!
27//! ```no_run
28//! # use bevy::prelude::*;
29//! # use bevy_yoleck::vpeol::VpeolCameraState;
30//! # use bevy_yoleck::vpeol::prelude::*;
31//! # let commands: Commands = panic!();
32//! commands
33//!     .spawn(Camera2d::default())
34//!     .insert(VpeolCameraState::default())
35//!     .insert(Vpeol2dCameraControl::default());
36//! ```
37//!
38//! Entity selection by clicking on it is supported by just adding the plugin. To implement
39//! dragging, there are two options:
40//!
41//! 1. Add  the [`Vpeol2dPosition`] Yoleck component and use it as the source of position (there
42//!    are also [`Vpeol2dRotatation`] and [`Vpeol2dScale`], but they don't currently get editing
43//!    support from vpeol_2d)
44//!     ```no_run
45//!     # use bevy::prelude::*;
46//!     # use bevy_yoleck::prelude::*;
47//!     # use bevy_yoleck::vpeol::prelude::*;
48//!     # use serde::{Deserialize, Serialize};
49//!     # #[derive(Clone, PartialEq, Serialize, Deserialize, Component, Default, YoleckComponent)]
50//!     # struct Example;
51//!     # let mut app = App::new();
52//!     app.add_yoleck_entity_type({
53//!         YoleckEntityType::new("Example")
54//!             .with::<Vpeol2dPosition>() // vpeol_2d dragging
55//!             .with::<Example>() // entity's specific data and systems
56//!     });
57//!     ```
58//! 2. Use data passing. vpeol_2d will pass a `Vec3` to the entity being dragged:
59//!     ```no_run
60//!     # use bevy::prelude::*;
61//!     # use bevy_yoleck::prelude::*;
62//!     # use serde::{Deserialize, Serialize};
63//!     # #[derive(Clone, PartialEq, Serialize, Deserialize, Component, Default, YoleckComponent)]
64//!     # struct Example {
65//!     #     position: Vec2,
66//!     # }
67//!     # let mut app = App::new();
68//!     fn edit_example(mut edit: YoleckEdit<(Entity, &mut Example)>, passed_data: Res<YoleckPassedData>) {
69//!         let Ok((entity, mut example)) = edit.single_mut() else { return };
70//!         if let Some(pos) = passed_data.get::<Vec3>(entity) {
71//!             example.position = pos.truncate();
72//!         }
73//!     }
74//!
75//!     fn populate_example(mut populate: YoleckPopulate<&Example>) {
76//!         populate.populate(|_ctx, mut cmd, example| {
77//!             cmd.insert(Transform::from_translation(example.position.extend(0.0)));
78//!             cmd.insert(Sprite {
79//!                 // Actual sprite data
80//!                 ..Default::default()
81//!             });
82//!         });
83//!     }
84//!     ```
85
86use std::any::TypeId;
87
88use crate::bevy_egui::{egui, EguiContexts};
89use crate::exclusive_systems::{
90    YoleckEntityCreationExclusiveSystems, YoleckExclusiveSystemDirective,
91};
92use crate::vpeol::{
93    handle_clickable_children_system, ray_intersection_with_mesh, VpeolBasePlugin,
94    VpeolCameraState, VpeolDragPlane, VpeolRepositionLevel, VpeolRootResolver, VpeolSystemSet,
95    WindowGetter,
96};
97use bevy::input::mouse::MouseWheel;
98use bevy::math::DVec2;
99use bevy::platform::collections::HashMap;
100use bevy::prelude::*;
101use bevy::render::camera::RenderTarget;
102use bevy::render::view::VisibleEntities;
103use bevy::sprite::Anchor;
104use bevy::text::TextLayoutInfo;
105use serde::{Deserialize, Serialize};
106
107use crate::{prelude::*, YoleckSchedule};
108
109/// Add the systems required for loading levels that use vpeol_2d components
110pub struct Vpeol2dPluginForGame;
111
112impl Plugin for Vpeol2dPluginForGame {
113    fn build(&self, app: &mut App) {
114        app.add_systems(
115            YoleckSchedule::OverrideCommonComponents,
116            vpeol_2d_populate_transform,
117        );
118        #[cfg(feature = "bevy_reflect")]
119        register_reflect_types(app);
120    }
121}
122
123#[cfg(feature = "bevy_reflect")]
124fn register_reflect_types(app: &mut App) {
125    app.register_type::<Vpeol2dPosition>();
126    app.register_type::<Vpeol2dRotatation>();
127    app.register_type::<Vpeol2dScale>();
128    app.register_type::<Vpeol2dCameraControl>();
129}
130
131/// Add the systems required for 2D editing.
132///
133/// * 2D camera control (for cameras with [`Vpeol2dCameraControl`])
134/// * Entity selection.
135/// * Entity dragging.
136/// * Connecting nested entities.
137pub struct Vpeol2dPluginForEditor;
138
139impl Plugin for Vpeol2dPluginForEditor {
140    fn build(&self, app: &mut App) {
141        app.add_plugins(VpeolBasePlugin);
142        app.add_plugins(Vpeol2dPluginForGame);
143        app.insert_resource(VpeolDragPlane::XY);
144
145        app.add_systems(
146            Update,
147            (
148                update_camera_status_for_sprites,
149                update_camera_status_for_2d_meshes,
150                update_camera_status_for_text_2d,
151            )
152                .in_set(VpeolSystemSet::UpdateCameraState),
153        );
154        app.add_systems(
155            PostUpdate, // to prevent camera shaking (only seen it in 3D, but still)
156            (camera_2d_pan, camera_2d_zoom).run_if(in_state(YoleckEditorState::EditorActive)),
157        );
158        app.add_systems(
159            Update,
160            (
161                ApplyDeferred,
162                handle_clickable_children_system::<
163                    Or<(With<Sprite>, (With<TextLayoutInfo>, With<Anchor>))>,
164                    (),
165                >,
166                ApplyDeferred,
167            )
168                .chain()
169                .run_if(in_state(YoleckEditorState::EditorActive)),
170        );
171        app.add_yoleck_edit_system(vpeol_2d_edit_position);
172        app.world_mut()
173            .resource_mut::<YoleckEntityCreationExclusiveSystems>()
174            .on_entity_creation(|queue| queue.push_back(vpeol_2d_init_position));
175    }
176}
177
178struct CursorInWorldPos {
179    cursor_in_world_pos: Vec2,
180}
181
182impl CursorInWorldPos {
183    fn from_camera_state(camera_state: &VpeolCameraState) -> Option<Self> {
184        Some(Self {
185            cursor_in_world_pos: camera_state.cursor_ray?.origin.truncate(),
186        })
187    }
188
189    fn cursor_in_entity_space(&self, transform: &GlobalTransform) -> Vec2 {
190        transform
191            .compute_matrix()
192            .inverse()
193            .project_point3(self.cursor_in_world_pos.extend(0.0))
194            .truncate()
195    }
196
197    fn check_square(
198        &self,
199        entity_transform: &GlobalTransform,
200        anchor: &Anchor,
201        size: Vec2,
202    ) -> bool {
203        let cursor = self.cursor_in_entity_space(entity_transform);
204        let anchor = anchor.as_vec();
205        let mut min_corner = Vec2::new(-0.5, -0.5) - anchor;
206        let mut max_corner = Vec2::new(0.5, 0.5) - anchor;
207        for corner in [&mut min_corner, &mut max_corner] {
208            corner.x *= size.x;
209            corner.y *= size.y;
210        }
211        min_corner.x <= cursor.x
212            && cursor.x <= max_corner.x
213            && min_corner.y <= cursor.y
214            && cursor.y <= max_corner.y
215    }
216}
217
218#[allow(clippy::type_complexity)]
219fn update_camera_status_for_sprites(
220    mut cameras_query: Query<(&mut VpeolCameraState, &VisibleEntities)>,
221    entities_query: Query<(Entity, &GlobalTransform, &Sprite)>,
222    image_assets: Res<Assets<Image>>,
223    texture_atlas_layout_assets: Res<Assets<TextureAtlasLayout>>,
224    root_resolver: VpeolRootResolver,
225) {
226    for (mut camera_state, visible_entities) in cameras_query.iter_mut() {
227        let Some(cursor) = CursorInWorldPos::from_camera_state(&camera_state) else {
228            continue;
229        };
230
231        for (entity, entity_transform, sprite) in
232            entities_query.iter_many(visible_entities.iter(TypeId::of::<Sprite>()))
233        // entities_query.iter()
234        {
235            let size = if let Some(custom_size) = sprite.custom_size {
236                custom_size
237            } else if let Some(texture_atlas) = sprite.texture_atlas.as_ref() {
238                let Some(texture_atlas_layout) =
239                    texture_atlas_layout_assets.get(&texture_atlas.layout)
240                else {
241                    continue;
242                };
243                texture_atlas_layout.textures[texture_atlas.index]
244                    .size()
245                    .as_vec2()
246            } else if let Some(texture) = image_assets.get(&sprite.image) {
247                texture.size().as_vec2()
248            } else {
249                continue;
250            };
251            if cursor.check_square(entity_transform, &sprite.anchor, size) {
252                let z_depth = entity_transform.translation().z;
253                let Some(root_entity) = root_resolver.resolve_root(entity) else {
254                    continue;
255                };
256                camera_state.consider(root_entity, z_depth, || {
257                    cursor.cursor_in_world_pos.extend(z_depth)
258                });
259            }
260        }
261    }
262}
263
264fn update_camera_status_for_2d_meshes(
265    mut cameras_query: Query<(&mut VpeolCameraState, &VisibleEntities)>,
266    entities_query: Query<(Entity, &GlobalTransform, &Mesh2d)>,
267    mesh_assets: Res<Assets<Mesh>>,
268    root_resolver: VpeolRootResolver,
269) {
270    for (mut camera_state, visible_entities) in cameras_query.iter_mut() {
271        let Some(cursor_ray) = camera_state.cursor_ray else {
272            continue;
273        };
274        for (entity, global_transform, mesh) in
275            entities_query.iter_many(visible_entities.iter(TypeId::of::<Mesh2d>()))
276        {
277            let Some(mesh) = mesh_assets.get(&mesh.0) else {
278                continue;
279            };
280
281            let inverse_transform = global_transform.compute_matrix().inverse();
282
283            let ray_in_object_coords = Ray3d {
284                origin: inverse_transform.transform_point3(cursor_ray.origin),
285                direction: inverse_transform
286                    .transform_vector3(*cursor_ray.direction)
287                    .try_into()
288                    .unwrap(),
289            };
290
291            let Some(distance) = ray_intersection_with_mesh(ray_in_object_coords, mesh) else {
292                continue;
293            };
294
295            let Some(root_entity) = root_resolver.resolve_root(entity) else {
296                continue;
297            };
298            camera_state.consider(root_entity, -distance, || cursor_ray.get_point(distance));
299        }
300    }
301}
302
303fn update_camera_status_for_text_2d(
304    mut cameras_query: Query<(&mut VpeolCameraState, &VisibleEntities)>,
305    entities_query: Query<(Entity, &GlobalTransform, &TextLayoutInfo, &Anchor)>,
306    root_resolver: VpeolRootResolver,
307) {
308    for (mut camera_state, visible_entities) in cameras_query.iter_mut() {
309        let Some(cursor) = CursorInWorldPos::from_camera_state(&camera_state) else {
310            continue;
311        };
312
313        for (entity, entity_transform, text_layout_info, anchor) in
314            // Weird that it is not `WithText`...
315            entities_query.iter_many(visible_entities.iter(TypeId::of::<Sprite>()))
316        {
317            if cursor.check_square(entity_transform, anchor, text_layout_info.size) {
318                let z_depth = entity_transform.translation().z;
319                let Some(root_entity) = root_resolver.resolve_root(entity) else {
320                    continue;
321                };
322                camera_state.consider(root_entity, z_depth, || {
323                    cursor.cursor_in_world_pos.extend(z_depth)
324                });
325            }
326        }
327    }
328}
329
330/// Pan and zoom a camera entity with the mouse while inisde the editor.
331#[derive(Component)]
332#[cfg_attr(feature = "bevy_reflect", derive(bevy::reflect::Reflect))]
333pub struct Vpeol2dCameraControl {
334    /// How much to zoom when receiving scroll event in `MouseScrollUnit::Line` units.
335    pub zoom_per_scroll_line: f32,
336    /// How much to zoom when receiving scroll event in `MouseScrollUnit::Pixel` units.
337    pub zoom_per_scroll_pixel: f32,
338}
339
340impl Default for Vpeol2dCameraControl {
341    fn default() -> Self {
342        Self {
343            zoom_per_scroll_line: 0.2,
344            zoom_per_scroll_pixel: 0.001,
345        }
346    }
347}
348
349fn camera_2d_pan(
350    mut egui_context: EguiContexts,
351    mouse_buttons: Res<ButtonInput<MouseButton>>,
352    mut cameras_query: Query<
353        (Entity, &mut Transform, &VpeolCameraState),
354        With<Vpeol2dCameraControl>,
355    >,
356    mut last_cursor_world_pos_by_camera: Local<HashMap<Entity, Vec2>>,
357) {
358    enum MouseButtonOp {
359        JustPressed,
360        BeingPressed,
361    }
362
363    let mouse_button_op = if mouse_buttons.just_pressed(MouseButton::Right) {
364        if egui_context.ctx_mut().is_pointer_over_area() {
365            return;
366        }
367        MouseButtonOp::JustPressed
368    } else if mouse_buttons.pressed(MouseButton::Right) {
369        MouseButtonOp::BeingPressed
370    } else {
371        last_cursor_world_pos_by_camera.clear();
372        return;
373    };
374
375    for (camera_entity, mut camera_transform, camera_state) in cameras_query.iter_mut() {
376        let Some(cursor_ray) = camera_state.cursor_ray else {
377            continue;
378        };
379        let world_pos = cursor_ray.origin.truncate();
380
381        match mouse_button_op {
382            MouseButtonOp::JustPressed => {
383                last_cursor_world_pos_by_camera.insert(camera_entity, world_pos);
384            }
385            MouseButtonOp::BeingPressed => {
386                if let Some(prev_pos) = last_cursor_world_pos_by_camera.get_mut(&camera_entity) {
387                    let movement = *prev_pos - world_pos;
388                    camera_transform.translation += movement.extend(0.0);
389                }
390            }
391        }
392    }
393}
394
395fn camera_2d_zoom(
396    mut egui_context: EguiContexts,
397    window_getter: WindowGetter,
398    mut cameras_query: Query<(
399        &mut Transform,
400        &VpeolCameraState,
401        &Camera,
402        &Vpeol2dCameraControl,
403    )>,
404    mut wheel_events_reader: EventReader<MouseWheel>,
405) {
406    if egui_context.ctx_mut().is_pointer_over_area() {
407        return;
408    }
409
410    for (mut camera_transform, camera_state, camera, camera_control) in cameras_query.iter_mut() {
411        let Some(cursor_ray) = camera_state.cursor_ray else {
412            continue;
413        };
414        let world_pos = cursor_ray.origin.truncate();
415
416        let zoom_amount: f32 = wheel_events_reader
417            .read()
418            .map(|wheel_event| match wheel_event.unit {
419                bevy::input::mouse::MouseScrollUnit::Line => {
420                    wheel_event.y * camera_control.zoom_per_scroll_line
421                }
422                bevy::input::mouse::MouseScrollUnit::Pixel => {
423                    wheel_event.y * camera_control.zoom_per_scroll_pixel
424                }
425            })
426            .sum();
427
428        if zoom_amount == 0.0 {
429            continue;
430        }
431
432        let scale_by = (-zoom_amount).exp();
433
434        let window = if let RenderTarget::Window(window_ref) = camera.target {
435            window_getter.get_window(window_ref).unwrap()
436        } else {
437            continue;
438        };
439        camera_transform.scale.x *= scale_by;
440        camera_transform.scale.y *= scale_by;
441        let Some(cursor_in_screen_pos) = window.cursor_position() else {
442            continue;
443        };
444        let Ok(new_cursor_ray) =
445            camera.viewport_to_world(&((*camera_transform.as_ref()).into()), cursor_in_screen_pos)
446        else {
447            continue;
448        };
449        let new_world_pos = new_cursor_ray.origin.truncate();
450        camera_transform.translation += (world_pos - new_world_pos).extend(0.0);
451    }
452}
453
454/// A position component that's edited and populated by vpeol_2d.
455#[derive(Clone, PartialEq, Serialize, Deserialize, Component, Default, YoleckComponent)]
456#[serde(transparent)]
457#[cfg_attr(feature = "bevy_reflect", derive(bevy::reflect::Reflect))]
458pub struct Vpeol2dPosition(pub Vec2);
459
460/// A rotation component that's populated (but not edited) by vpeol_2d.
461///
462/// The rotation is in radians around the Z axis.
463#[derive(Default, Clone, PartialEq, Serialize, Deserialize, Component, YoleckComponent)]
464#[serde(transparent)]
465#[cfg_attr(feature = "bevy_reflect", derive(bevy::reflect::Reflect))]
466pub struct Vpeol2dRotatation(pub f32);
467
468/// A scale component that's populated (but not edited) by vpeol_2d.
469#[derive(Clone, PartialEq, Serialize, Deserialize, Component, YoleckComponent)]
470#[serde(transparent)]
471#[cfg_attr(feature = "bevy_reflect", derive(bevy::reflect::Reflect))]
472pub struct Vpeol2dScale(pub Vec2);
473
474impl Default for Vpeol2dScale {
475    fn default() -> Self {
476        Self(Vec2::ONE)
477    }
478}
479
480fn vpeol_2d_edit_position(
481    mut ui: ResMut<YoleckUi>,
482    mut edit: YoleckEdit<(Entity, &mut Vpeol2dPosition)>,
483    passed_data: Res<YoleckPassedData>,
484) {
485    if edit.is_empty() || edit.has_nonmatching() {
486        return;
487    }
488    // Use double precision to prevent rounding errors when there are many entities.
489    let mut average = DVec2::ZERO;
490    let mut num_entities = 0;
491    let mut transition = Vec2::ZERO;
492    for (entity, position) in edit.iter_matching() {
493        if let Some(pos) = passed_data.get::<Vec3>(entity) {
494            transition = pos.truncate() - position.0;
495        }
496        average += position.0.as_dvec2();
497        num_entities += 1;
498    }
499    average /= num_entities as f64;
500
501    ui.horizontal(|ui| {
502        let mut new_average = average;
503        ui.add(egui::DragValue::new(&mut new_average.x).prefix("X:"));
504        ui.add(egui::DragValue::new(&mut new_average.y).prefix("Y:"));
505        transition += (new_average - average).as_vec2();
506    });
507
508    if transition.is_finite() && transition != Vec2::ZERO {
509        for (_, mut position) in edit.iter_matching_mut() {
510            position.0 += transition;
511        }
512    }
513}
514
515fn vpeol_2d_init_position(
516    mut egui_context: EguiContexts,
517    ui: Res<YoleckUi>,
518    mut edit: YoleckEdit<&mut Vpeol2dPosition>,
519    cameras_query: Query<&VpeolCameraState>,
520    mouse_buttons: Res<ButtonInput<MouseButton>>,
521) -> YoleckExclusiveSystemDirective {
522    let Ok(mut position) = edit.single_mut() else {
523        return YoleckExclusiveSystemDirective::Finished;
524    };
525
526    let Some(cursor_ray) = cameras_query
527        .iter()
528        .find_map(|camera_state| camera_state.cursor_ray)
529    else {
530        return YoleckExclusiveSystemDirective::Listening;
531    };
532
533    position.0 = cursor_ray.origin.truncate();
534
535    if egui_context.ctx_mut().is_pointer_over_area() || ui.ctx().is_pointer_over_area() {
536        return YoleckExclusiveSystemDirective::Listening;
537    }
538
539    if mouse_buttons.just_released(MouseButton::Left) {
540        return YoleckExclusiveSystemDirective::Finished;
541    }
542
543    YoleckExclusiveSystemDirective::Listening
544}
545
546fn vpeol_2d_populate_transform(
547    mut populate: YoleckPopulate<(
548        &Vpeol2dPosition,
549        Option<&Vpeol2dRotatation>,
550        Option<&Vpeol2dScale>,
551        &YoleckBelongsToLevel,
552    )>,
553    levels_query: Query<&VpeolRepositionLevel>,
554) {
555    populate.populate(
556        |_ctx, mut cmd, (position, rotation, scale, belongs_to_level)| {
557            let mut transform = Transform::from_translation(position.0.extend(0.0));
558            if let Some(Vpeol2dRotatation(rotation)) = rotation {
559                transform = transform.with_rotation(Quat::from_rotation_z(*rotation));
560            }
561            if let Some(Vpeol2dScale(scale)) = scale {
562                transform = transform.with_scale(scale.extend(1.0));
563            }
564
565            if let Ok(VpeolRepositionLevel(level_transform)) =
566                levels_query.get(belongs_to_level.level)
567            {
568                transform = *level_transform * transform;
569            }
570
571            cmd.insert((transform, GlobalTransform::from(transform)));
572        },
573    )
574}