diff --git a/Cargo.toml b/Cargo.toml index dd9a56d466772..6b7994eae3aa2 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1705,6 +1705,17 @@ description = "Displays each contributor as a bouncy bevy-ball!" category = "Games" wasm = true +[[example]] +name = "desk_toy" +path = "examples/games/desk_toy.rs" +doc-scrape-examples = true + +[package.metadata.example.desk_toy] +name = "Desk Toy" +description = "Bevy logo as a desk toy using transparent windows! Now with Googly Eyes!" +category = "Games" +wasm = false + [[example]] name = "game_menu" path = "examples/games/game_menu.rs" diff --git a/examples/README.md b/examples/README.md index 435db1da64dd6..fcad85d7cd8ff 100644 --- a/examples/README.md +++ b/examples/README.md @@ -260,6 +260,7 @@ Example | Description [Alien Cake Addict](../examples/games/alien_cake_addict.rs) | Eat the cakes. Eat them all. An example 3D game [Breakout](../examples/games/breakout.rs) | An implementation of the classic game "Breakout" [Contributors](../examples/games/contributors.rs) | Displays each contributor as a bouncy bevy-ball! +[Desk Toy](../examples/games/desk_toy.rs) | Bevy logo as a desk toy using transparent windows! Now with Googly Eyes! [Game Menu](../examples/games/game_menu.rs) | A simple game menu ## Gizmos diff --git a/examples/games/desk_toy.rs b/examples/games/desk_toy.rs new file mode 100644 index 0000000000000..c79eacd7a91bf --- /dev/null +++ b/examples/games/desk_toy.rs @@ -0,0 +1,397 @@ +//! Bevy logo as a desk toy using transparent windows! Now with Googly Eyes! +//! +//! This example demonstrates: +//! - Transparent windows that can be clicked through. +//! - Drag-and-drop operations in 2D. +//! - Using entity hierarchy and [`SpatialBundle`]s to create simple animations. +//! - Creating simple 2D meshes based on shape primitives. + +use bevy::{ + app::AppExit, + input::common_conditions::{input_just_pressed, input_just_released}, + prelude::*, + sprite::{MaterialMesh2dBundle, Mesh2dHandle}, + window::{PrimaryWindow, WindowLevel}, +}; + +#[cfg(target_os = "macos")] +use bevy::window::CompositeAlphaMode; + +fn main() { + App::new() + .add_plugins(DefaultPlugins.set(WindowPlugin { + primary_window: Some(Window { + title: "Bevy Desk Toy".into(), + transparent: true, + #[cfg(target_os = "macos")] + composite_alpha_mode: CompositeAlphaMode::PostMultiplied, + ..default() + }), + ..default() + })) + .insert_resource(ClearColor(WINDOW_CLEAR_COLOR)) + .insert_resource(WindowTransparency(false)) + .insert_resource(CursorWorldPos(None)) + .add_systems(Startup, setup) + .add_systems( + Update, + ( + get_cursor_world_pos, + update_cursor_hit_test, + ( + start_drag.run_if(input_just_pressed(MouseButton::Left)), + end_drag.run_if(input_just_released(MouseButton::Left)), + drag.run_if(resource_exists::), + quit.run_if(input_just_pressed(MouseButton::Right)), + toggle_transparency.run_if(input_just_pressed(KeyCode::Space)), + move_pupils.after(drag), + ), + ) + .chain(), + ) + .run(); +} + +/// Whether the window is transparent +#[derive(Resource)] +struct WindowTransparency(bool); + +/// The projected 2D world coordinates of the cursor (if it's within primary window bounds). +#[derive(Resource)] +struct CursorWorldPos(Option); + +/// The current drag operation including the offset with which we grabbed the Bevy logo. +#[derive(Resource)] +struct DragOperation(Vec2); + +/// Marker component for the instructions text entity. +#[derive(Component)] +struct InstructionsText; + +/// Marker component for the Bevy logo entity. +#[derive(Component)] +struct BevyLogo; + +/// Component for the moving pupil entity (the moving part of the googly eye). +#[derive(Component)] +struct Pupil { + /// Radius of the eye containing the pupil. + eye_radius: f32, + /// Radius of the pupil. + pupil_radius: f32, + /// Current velocity of the pupil. + velocity: Vec2, +} + +// Dimensions are based on: assets/branding/icon.png +// Bevy logo radius +const BEVY_LOGO_RADIUS: f32 = 128.0; +// Birds' eyes x y (offset from the origin) and radius +// These values are manually determined from the logo image +const BIRDS_EYES: [(f32, f32, f32); 3] = [ + (145.0 - 128.0, -(56.0 - 128.0), 12.0), + (198.0 - 128.0, -(87.0 - 128.0), 10.0), + (222.0 - 128.0, -(140.0 - 128.0), 8.0), +]; + +const WINDOW_CLEAR_COLOR: Color = Color::srgb(0.2, 0.2, 0.2); + +/// Spawn the scene +fn setup( + mut commands: Commands, + asset_server: Res, + mut meshes: ResMut>, + mut materials: ResMut>, +) { + // Spawn a 2D camera + commands.spawn(Camera2dBundle::default()); + + // Spawn the text instructions + let font = asset_server.load("fonts/FiraSans-Bold.ttf"); + let text_style = TextStyle { + font: font.clone(), + font_size: 30.0, + color: Color::WHITE, + }; + commands.spawn(( + Text2dBundle { + text: Text::from_section( + "Press Space to play on your desktop! Press it again to return.\nRight click Bevy logo to exit.", + text_style.clone(), + ), + transform: Transform::from_xyz(0.0, -300.0, 100.0), + ..default() + }, + InstructionsText, + )); + + // Create a circle mesh. We will reuse this mesh for all our circles. + let circle = Mesh2dHandle(meshes.add(Circle { radius: 1.0 })); + // Create the different materials we will use for each part of the eyes. For this demo they are basic [`ColorMaterial`]s. + let outline_material = materials.add(Color::BLACK); + let sclera_material = materials.add(Color::WHITE); + let pupil_material = materials.add(Color::srgb(0.2, 0.2, 0.2)); + let pupil_highlight_material = materials.add(Color::srgba(1.0, 1.0, 1.0, 0.2)); + + // Spawn the Bevy logo sprite + commands + .spawn(( + SpriteBundle { + texture: asset_server.load("branding/icon.png"), + ..default() + }, + BevyLogo, + )) + .with_children(|commands| { + // For each bird eye + for (x, y, radius) in BIRDS_EYES { + // eye outline + commands.spawn(MaterialMesh2dBundle { + mesh: circle.clone(), + material: outline_material.clone(), + transform: Transform::from_xyz(x, y - 1.0, 1.0) + .with_scale(Vec2::splat(radius + 2.0).extend(1.0)), + ..default() + }); + + // sclera + commands + .spawn(SpatialBundle::from_transform(Transform::from_xyz( + x, y, 2.0, + ))) + .with_children(|commands| { + // sclera + commands.spawn(MaterialMesh2dBundle { + mesh: circle.clone(), + material: sclera_material.clone(), + transform: Transform::from_scale(Vec3::new(radius, radius, 0.0)), + ..default() + }); + + let pupil_radius = radius * 0.6; + let pupil_highlight_radius = radius * 0.3; + let pupil_highlight_offset = radius * 0.3; + // pupil + commands + .spawn(( + SpatialBundle::from_transform(Transform::from_xyz(0.0, 0.0, 1.0)), + Pupil { + eye_radius: radius, + pupil_radius, + velocity: Vec2::ZERO, + }, + )) + .with_children(|commands| { + // pupil main + commands.spawn(MaterialMesh2dBundle { + mesh: circle.clone(), + material: pupil_material.clone(), + transform: Transform::from_xyz(0.0, 0.0, 0.0) + .with_scale(Vec3::new(pupil_radius, pupil_radius, 1.0)), + ..default() + }); + + // pupil highlight + commands.spawn(MaterialMesh2dBundle { + mesh: circle.clone(), + material: pupil_highlight_material.clone(), + transform: Transform::from_xyz( + -pupil_highlight_offset, + pupil_highlight_offset, + 1.0, + ) + .with_scale(Vec3::new( + pupil_highlight_radius, + pupil_highlight_radius, + 1.0, + )), + ..default() + }); + }); + }); + } + }); +} + +/// Project the cursor into the world coordinates and store it in a resource for easy use +fn get_cursor_world_pos( + mut cursor_world_pos: ResMut, + q_primary_window: Query<&Window, With>, + q_camera: Query<(&Camera, &GlobalTransform)>, +) { + let primary_window = q_primary_window.single(); + let (main_camera, main_camera_transform) = q_camera.single(); + // Get the cursor position in the world + cursor_world_pos.0 = primary_window + .cursor_position() + .and_then(|cursor_pos| main_camera.viewport_to_world_2d(main_camera_transform, cursor_pos)); +} + +/// Update whether the window is clickable or not +fn update_cursor_hit_test( + cursor_world_pos: Res, + mut q_primary_window: Query<&mut Window, With>, + q_bevy_logo: Query<&Transform, With>, +) { + let mut primary_window = q_primary_window.single_mut(); + + // If the window has decorations (e.g. a border) then it should be clickable + if primary_window.decorations { + primary_window.cursor.hit_test = true; + return; + } + + // If the cursor is not within the window we don't need to update whether the window is clickable or not + let Some(cursor_world_pos) = cursor_world_pos.0 else { + return; + }; + + // If the cursor is within the radius of the Bevy logo make the window clickable otherwise the window is not clickable + let bevy_logo_transform = q_bevy_logo.single(); + primary_window.cursor.hit_test = bevy_logo_transform + .translation + .truncate() + .distance(cursor_world_pos) + < BEVY_LOGO_RADIUS; +} + +/// Start the drag operation and record the offset we started dragging from +fn start_drag( + mut commands: Commands, + cursor_world_pos: Res, + q_bevy_logo: Query<&Transform, With>, +) { + // If the cursor is not within the primary window skip this system + let Some(cursor_world_pos) = cursor_world_pos.0 else { + return; + }; + + // Get the offset from the cursor to the Bevy logo sprite + let bevy_logo_transform = q_bevy_logo.single(); + let drag_offset = bevy_logo_transform.translation.truncate() - cursor_world_pos; + + // If the cursor is within the Bevy logo radius start the drag operation and remember the offset of the cursor from the origin + if drag_offset.length() < BEVY_LOGO_RADIUS { + commands.insert_resource(DragOperation(drag_offset)); + } +} + +/// Stop the current drag operation +fn end_drag(mut commands: Commands) { + commands.remove_resource::(); +} + +/// Drag the Bevy logo +fn drag( + drag_offset: Res, + cursor_world_pos: Res, + time: Res