docs/bevy/examples/showcase/desk_toy.rs 14.2 K · 382 lines · raw · history

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
9 use 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")]
17 use bevy::window::CompositeAlphaMode;
18
19 fn 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)]
56 struct WindowTransparency(bool);
57
58 /// The projected 2D world coordinates of the cursor (if it's within primary window bounds).
59 #[derive(Resource)]
60 struct CursorWorldPos(Option<Vec2>);
61
62 /// The current drag operation including the offset with which we grabbed the Bevy logo.
63 #[derive(Resource)]
64 struct DragOperation(Vec2);
65
66 /// Marker component for the instructions text entity.
67 #[derive(Component)]
68 struct InstructionsText;
69
70 /// Marker component for the Bevy logo entity.
71 #[derive(Component)]
72 struct BevyLogo;
73
74 /// Component for the moving pupil entity (the moving part of the googly eye).
75 #[derive(Component)]
76 struct 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
87 const 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
90 const 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
96 const WINDOW_CLEAR_COLOR: Color = Color::srgb(0.2, 0.2, 0.2);
97
98 /// Spawn the scene
99 fn 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().into(),
112 font_size: FontSize::Px(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
205 fn 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
220 fn 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
246 fn 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
266 fn end_drag(mut commands: Commands) {
267 commands.remove_resource::<DragOperation>();
268 }
269
270 /// Drag the Bevy logo
271 fn 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
302 fn 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
324 fn 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
360 fn 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 }