desk_toy/
desk_toy.rs

1//! Bevy logo as a desk toy using transparent windows! Now with Googly Eyes!
2//!
3//! This example demonstrates:
4//! - Transparent windows that can be clicked through.
5//! - Drag-and-drop operations in 2D.
6//! - Using entity hierarchy, Transform, and Visibility to create simple animations.
7//! - Creating simple 2D meshes based on shape primitives.
8
9use bevy::{
10    app::AppExit,
11    input::common_conditions::{input_just_pressed, input_just_released},
12    prelude::*,
13    window::{CursorOptions, PrimaryWindow, WindowLevel},
14};
15
16#[cfg(target_os = "macos")]
17use bevy::window::CompositeAlphaMode;
18
19fn main() {
20    App::new()
21        .add_plugins(DefaultPlugins.set(WindowPlugin {
22            primary_window: Some(Window {
23                title: "Bevy Desk Toy".into(),
24                transparent: true,
25                #[cfg(target_os = "macos")]
26                composite_alpha_mode: CompositeAlphaMode::PostMultiplied,
27                ..default()
28            }),
29            ..default()
30        }))
31        .insert_resource(ClearColor(WINDOW_CLEAR_COLOR))
32        .insert_resource(WindowTransparency(false))
33        .insert_resource(CursorWorldPos(None))
34        .add_systems(Startup, setup)
35        .add_systems(
36            Update,
37            (
38                get_cursor_world_pos,
39                update_cursor_hit_test,
40                (
41                    start_drag.run_if(input_just_pressed(MouseButton::Left)),
42                    end_drag.run_if(input_just_released(MouseButton::Left)),
43                    drag.run_if(resource_exists::<DragOperation>),
44                    quit.run_if(input_just_pressed(MouseButton::Right)),
45                    toggle_transparency.run_if(input_just_pressed(KeyCode::Space)),
46                    move_pupils.after(drag),
47                ),
48            )
49                .chain(),
50        )
51        .run();
52}
53
54/// Whether the window is transparent
55#[derive(Resource)]
56struct WindowTransparency(bool);
57
58/// The projected 2D world coordinates of the cursor (if it's within primary window bounds).
59#[derive(Resource)]
60struct CursorWorldPos(Option<Vec2>);
61
62/// The current drag operation including the offset with which we grabbed the Bevy logo.
63#[derive(Resource)]
64struct DragOperation(Vec2);
65
66/// Marker component for the instructions text entity.
67#[derive(Component)]
68struct InstructionsText;
69
70/// Marker component for the Bevy logo entity.
71#[derive(Component)]
72struct BevyLogo;
73
74/// Component for the moving pupil entity (the moving part of the googly eye).
75#[derive(Component)]
76struct Pupil {
77    /// Radius of the eye containing the pupil.
78    eye_radius: f32,
79    /// Radius of the pupil.
80    pupil_radius: f32,
81    /// Current velocity of the pupil.
82    velocity: Vec2,
83}
84
85// Dimensions are based on: assets/branding/icon.png
86// Bevy logo radius
87const BEVY_LOGO_RADIUS: f32 = 128.0;
88// Birds' eyes x y (offset from the origin) and radius
89// These values are manually determined from the logo image
90const BIRDS_EYES: [(f32, f32, f32); 3] = [
91    (145.0 - 128.0, -(56.0 - 128.0), 12.0),
92    (198.0 - 128.0, -(87.0 - 128.0), 10.0),
93    (222.0 - 128.0, -(140.0 - 128.0), 8.0),
94];
95
96const WINDOW_CLEAR_COLOR: Color = Color::srgb(0.2, 0.2, 0.2);
97
98/// Spawn the scene
99fn setup(
100    mut commands: Commands,
101    asset_server: Res<AssetServer>,
102    mut meshes: ResMut<Assets<Mesh>>,
103    mut materials: ResMut<Assets<ColorMaterial>>,
104) {
105    // Spawn a 2D camera
106    commands.spawn(Camera2d);
107
108    // Spawn the text instructions
109    let font = asset_server.load("fonts/FiraSans-Bold.ttf");
110    let text_style = TextFont {
111        font: font.clone(),
112        font_size: 25.0,
113        ..default()
114    };
115    commands.spawn((
116        Text2d::new("Press Space to play on your desktop! Press it again to return.\nRight click Bevy logo to exit."),
117            text_style.clone(),
118            Transform::from_xyz(0.0, -300.0, 100.0),
119        InstructionsText,
120    ));
121
122    // Create a circle mesh. We will reuse this mesh for all our circles.
123    let circle = meshes.add(Circle { radius: 1.0 });
124    // Create the different materials we will use for each part of the eyes. For this demo they are basic [`ColorMaterial`]s.
125    let outline_material = materials.add(Color::BLACK);
126    let sclera_material = materials.add(Color::WHITE);
127    let pupil_material = materials.add(Color::srgb(0.2, 0.2, 0.2));
128    let pupil_highlight_material = materials.add(Color::srgba(1.0, 1.0, 1.0, 0.2));
129
130    // Spawn the Bevy logo sprite
131    commands
132        .spawn((
133            Sprite::from_image(asset_server.load("branding/icon.png")),
134            BevyLogo,
135        ))
136        .with_children(|commands| {
137            // For each bird eye
138            for (x, y, radius) in BIRDS_EYES {
139                let pupil_radius = radius * 0.6;
140                let pupil_highlight_radius = radius * 0.3;
141                let pupil_highlight_offset = radius * 0.3;
142                // eye outline
143                commands.spawn((
144                    Mesh2d(circle.clone()),
145                    MeshMaterial2d(outline_material.clone()),
146                    Transform::from_xyz(x, y - 1.0, 1.0)
147                        .with_scale(Vec2::splat(radius + 2.0).extend(1.0)),
148                ));
149
150                // sclera
151                commands.spawn((
152                    Transform::from_xyz(x, y, 2.0),
153                    Visibility::default(),
154                    children![
155                        // sclera
156                        (
157                            Mesh2d(circle.clone()),
158                            MeshMaterial2d(sclera_material.clone()),
159                            Transform::from_scale(Vec3::new(radius, radius, 0.0)),
160                        ),
161                        // pupil
162                        (
163                            Transform::from_xyz(0.0, 0.0, 1.0),
164                            Visibility::default(),
165                            Pupil {
166                                eye_radius: radius,
167                                pupil_radius,
168                                velocity: Vec2::ZERO,
169                            },
170                            children![
171                                // pupil main
172                                (
173                                    Mesh2d(circle.clone()),
174                                    MeshMaterial2d(pupil_material.clone()),
175                                    Transform::from_xyz(0.0, 0.0, 0.0).with_scale(Vec3::new(
176                                        pupil_radius,
177                                        pupil_radius,
178                                        1.0,
179                                    )),
180                                ),
181                                // pupil highlight
182                                (
183                                    Mesh2d(circle.clone()),
184                                    MeshMaterial2d(pupil_highlight_material.clone()),
185                                    Transform::from_xyz(
186                                        -pupil_highlight_offset,
187                                        pupil_highlight_offset,
188                                        1.0,
189                                    )
190                                    .with_scale(Vec3::new(
191                                        pupil_highlight_radius,
192                                        pupil_highlight_radius,
193                                        1.0,
194                                    )),
195                                )
196                            ],
197                        )
198                    ],
199                ));
200            }
201        });
202}
203
204/// Project the cursor into the world coordinates and store it in a resource for easy use
205fn get_cursor_world_pos(
206    mut cursor_world_pos: ResMut<CursorWorldPos>,
207    primary_window: Single<&Window, With<PrimaryWindow>>,
208    q_camera: Single<(&Camera, &GlobalTransform)>,
209) {
210    let (main_camera, main_camera_transform) = *q_camera;
211    // Get the cursor position in the world
212    cursor_world_pos.0 = primary_window.cursor_position().and_then(|cursor_pos| {
213        main_camera
214            .viewport_to_world_2d(main_camera_transform, cursor_pos)
215            .ok()
216    });
217}
218
219/// Update whether the window is clickable or not
220fn update_cursor_hit_test(
221    cursor_world_pos: Res<CursorWorldPos>,
222    primary_window: Single<(&Window, &mut CursorOptions), With<PrimaryWindow>>,
223    bevy_logo_transform: Single<&Transform, With<BevyLogo>>,
224) {
225    let (window, mut cursor_options) = primary_window.into_inner();
226    // If the window has decorations (e.g. a border) then it should be clickable
227    if window.decorations {
228        cursor_options.hit_test = true;
229        return;
230    }
231
232    // If the cursor is not within the window we don't need to update whether the window is clickable or not
233    let Some(cursor_world_pos) = cursor_world_pos.0 else {
234        return;
235    };
236
237    // If the cursor is within the radius of the Bevy logo make the window clickable otherwise the window is not clickable
238    cursor_options.hit_test = bevy_logo_transform
239        .translation
240        .truncate()
241        .distance(cursor_world_pos)
242        < BEVY_LOGO_RADIUS;
243}
244
245/// Start the drag operation and record the offset we started dragging from
246fn start_drag(
247    mut commands: Commands,
248    cursor_world_pos: Res<CursorWorldPos>,
249    bevy_logo_transform: Single<&Transform, With<BevyLogo>>,
250) {
251    // If the cursor is not within the primary window skip this system
252    let Some(cursor_world_pos) = cursor_world_pos.0 else {
253        return;
254    };
255
256    // Get the offset from the cursor to the Bevy logo sprite
257    let drag_offset = bevy_logo_transform.translation.truncate() - cursor_world_pos;
258
259    // If the cursor is within the Bevy logo radius start the drag operation and remember the offset of the cursor from the origin
260    if drag_offset.length() < BEVY_LOGO_RADIUS {
261        commands.insert_resource(DragOperation(drag_offset));
262    }
263}
264
265/// Stop the current drag operation
266fn end_drag(mut commands: Commands) {
267    commands.remove_resource::<DragOperation>();
268}
269
270/// Drag the Bevy logo
271fn drag(
272    drag_offset: Res<DragOperation>,
273    cursor_world_pos: Res<CursorWorldPos>,
274    time: Res<Time>,
275    mut bevy_transform: Single<&mut Transform, With<BevyLogo>>,
276    mut q_pupils: Query<&mut Pupil>,
277) {
278    // If the cursor is not within the primary window skip this system
279    let Some(cursor_world_pos) = cursor_world_pos.0 else {
280        return;
281    };
282
283    // Calculate the new translation of the Bevy logo based on cursor and drag offset
284    let new_translation = cursor_world_pos + drag_offset.0;
285
286    // Calculate how fast we are dragging the Bevy logo (unit/second)
287    let drag_velocity =
288        (new_translation - bevy_transform.translation.truncate()) / time.delta_secs();
289
290    // Update the translation of Bevy logo transform to new translation
291    bevy_transform.translation = new_translation.extend(bevy_transform.translation.z);
292
293    // Add the cursor drag velocity in the opposite direction to each pupil.
294    // Remember pupils are using local coordinates to move. So when the Bevy logo moves right they need to move left to
295    // simulate inertia, otherwise they will move fixed to the parent.
296    for mut pupil in &mut q_pupils {
297        pupil.velocity -= drag_velocity;
298    }
299}
300
301/// Quit when the user right clicks the Bevy logo
302fn quit(
303    cursor_world_pos: Res<CursorWorldPos>,
304    mut app_exit: MessageWriter<AppExit>,
305    bevy_logo_transform: Single<&Transform, With<BevyLogo>>,
306) {
307    // If the cursor is not within the primary window skip this system
308    let Some(cursor_world_pos) = cursor_world_pos.0 else {
309        return;
310    };
311
312    // If the cursor is within the Bevy logo radius send the [`AppExit`] event to quit the app
313    if bevy_logo_transform
314        .translation
315        .truncate()
316        .distance(cursor_world_pos)
317        < BEVY_LOGO_RADIUS
318    {
319        app_exit.write(AppExit::Success);
320    }
321}
322
323/// Enable transparency for the window and make it on top
324fn toggle_transparency(
325    mut commands: Commands,
326    mut window_transparency: ResMut<WindowTransparency>,
327    mut q_instructions_text: Query<&mut Visibility, With<InstructionsText>>,
328    mut primary_window: Single<&mut Window, With<PrimaryWindow>>,
329) {
330    // Toggle the window transparency resource
331    window_transparency.0 = !window_transparency.0;
332
333    // Show or hide the instructions text
334    for mut visibility in &mut q_instructions_text {
335        *visibility = if window_transparency.0 {
336            Visibility::Hidden
337        } else {
338            Visibility::Visible
339        };
340    }
341
342    // Remove the primary window's decorations (e.g. borders), make it always on top of other desktop windows, and set the clear color to transparent
343    // only if window transparency is enabled
344    let clear_color;
345    (
346        primary_window.decorations,
347        primary_window.window_level,
348        clear_color,
349    ) = if window_transparency.0 {
350        (false, WindowLevel::AlwaysOnTop, Color::NONE)
351    } else {
352        (true, WindowLevel::Normal, WINDOW_CLEAR_COLOR)
353    };
354
355    // Set the clear color
356    commands.insert_resource(ClearColor(clear_color));
357}
358
359/// Move the pupils and bounce them around
360fn move_pupils(time: Res<Time>, mut q_pupils: Query<(&mut Pupil, &mut Transform)>) {
361    for (mut pupil, mut transform) in &mut q_pupils {
362        // The wiggle radius is how much the pupil can move within the eye
363        let wiggle_radius = pupil.eye_radius - pupil.pupil_radius;
364        // Store the Z component
365        let z = transform.translation.z;
366        // Truncate the Z component to make the calculations be on [`Vec2`]
367        let mut translation = transform.translation.truncate();
368        // Decay the pupil velocity
369        pupil.velocity *= ops::powf(0.04f32, time.delta_secs());
370        // Move the pupil
371        translation += pupil.velocity * time.delta_secs();
372        // If the pupil hit the outside border of the eye, limit the translation to be within the wiggle radius and invert the velocity.
373        // This is not physically accurate but it's good enough for the googly eyes effect.
374        if translation.length() > wiggle_radius {
375            translation = translation.normalize() * wiggle_radius;
376            // Invert and decrease the velocity of the pupil when it bounces
377            pupil.velocity *= -0.75;
378        }
379        // Update the entity transform with the new translation after reading the Z component
380        transform.translation = translation.extend(z);
381    }
382}