-
-
Notifications
You must be signed in to change notification settings - Fork 4k
New Flappy Bird example #19282
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
New Flappy Bird example #19282
Changes from 1 commit
6e4a60f
216205a
c1f3668
da63a76
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change | ||||||
---|---|---|---|---|---|---|---|---|
@@ -0,0 +1,360 @@ | ||||||||
//! An implementation of the game "Flappy Bird". | ||||||||
|
||||||||
use std::time::Duration; | ||||||||
|
||||||||
use bevy::math::{ | ||||||||
bounding::{Aabb2d, BoundingCircle, IntersectsVolume}, | ||||||||
ops::exp, | ||||||||
}; | ||||||||
use bevy::prelude::*; | ||||||||
use rand::Rng; | ||||||||
|
||||||||
const BACKGROUND_COLOR: Color = Color::srgb(0.9, 0.9, 0.9); | ||||||||
|
||||||||
/// Timer spawning a pipe each time it finishes | ||||||||
const PIPE_TIMER_DURATION: Duration = Duration::from_millis(2000); | ||||||||
|
||||||||
/// Movement speed of the pipes | ||||||||
const PIPE_SPEED: f32 = 200.; | ||||||||
|
||||||||
/// The size of each pipe rectangle | ||||||||
const PIPE_SIZE: Vec2 = Vec2::new(100., 500.); | ||||||||
|
||||||||
/// How large the gap is between the pipes | ||||||||
const GAP_HEIGHT: f32 = 300.; | ||||||||
|
||||||||
/// Gravity applied to the bird | ||||||||
const GRAVITY: f32 = 700.; | ||||||||
|
||||||||
/// Size of the bird sprite | ||||||||
const BIRD_SIZE: f32 = 100.; | ||||||||
|
||||||||
/// Acceleration the bird is set to on a flap | ||||||||
const FLAP_POWER: f32 = 400.; | ||||||||
|
||||||||
/// Horizontal position of the bird | ||||||||
const BIRD_POSITION: f32 = -500.; | ||||||||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Controversial Nit: This is consistent with our other examples, but I'm not a fan of these hard-coded constants. They're all-caps and I don't believe particularly good practice (these kinds of settings would normally be controlled in a configuration file read by the build system for example). I think it would be nicer if all of this was wrapped in a There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. That would be so good with this RFC: https://github.com/rust-lang/rfcs/blob/master/text/3681-default-field-values.md. I'll give your suggestion a go. |
||||||||
|
||||||||
#[derive(Component)] | ||||||||
struct Bird; | ||||||||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Super tiny nit: I believe we refer to the Bevy bird as |
||||||||
|
||||||||
#[derive(Component)] | ||||||||
struct Pipe; | ||||||||
|
||||||||
#[derive(Component)] | ||||||||
struct PipeMarker; | ||||||||
Comment on lines
+68
to
+72
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Nit: Might be nice to use required-components to ensure these are added together. Likewise for There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I don't think required components are correct here. At least as far as I understand required components. The pipes are represented with 1 marker and 2 pipes, so there isn't really a 1-1 requirement. For the scoring part, the I might be totally off here, please correct me if I'm wrong. |
||||||||
|
||||||||
/// Marker component for the text displaying the score | ||||||||
#[derive(Component)] | ||||||||
struct ScoreText; | ||||||||
|
||||||||
/// This resource tracks the game's score | ||||||||
#[derive(Resource, Deref, DerefMut)] | ||||||||
struct Score(usize); | ||||||||
|
||||||||
/// 2-dimensional velocity | ||||||||
#[derive(Component, Deref, DerefMut)] | ||||||||
struct Velocity(Vec2); | ||||||||
|
||||||||
/// Timer that determines when new pipes are spawned | ||||||||
#[derive(Resource, Deref, DerefMut)] | ||||||||
struct PipeTimer(Timer); | ||||||||
|
||||||||
/// The size of the window at the start of the game | ||||||||
/// | ||||||||
/// Handling resizing while the game is playing is quite hard, so we ignore that | ||||||||
#[derive(Resource, Deref, DerefMut)] | ||||||||
struct WindowSize(Vec2); | ||||||||
|
||||||||
/// Event emitted when the bird touches the edges or a pipe | ||||||||
#[derive(Event, Default)] | ||||||||
struct CollisionEvent; | ||||||||
|
||||||||
/// Event emitted when a new pipe should be spawned | ||||||||
#[derive(Event, Default)] | ||||||||
struct SpawnPipeEvent; | ||||||||
|
||||||||
/// Sound that should be played when a pipe is passed | ||||||||
#[derive(Resource, Deref)] | ||||||||
struct ScoreSound(Handle<AudioSource>); | ||||||||
|
||||||||
fn main() { | ||||||||
App::new() | ||||||||
.add_plugins(DefaultPlugins) | ||||||||
.add_systems(Startup, (set_window_size, setup)) | ||||||||
.add_systems( | ||||||||
FixedUpdate, | ||||||||
( | ||||||||
reset, | ||||||||
add_pipes, | ||||||||
spawn_pipe, | ||||||||
flap, | ||||||||
apply_gravity, | ||||||||
apply_velocity, | ||||||||
check_collisions, | ||||||||
increase_score, | ||||||||
remove_pipes, | ||||||||
), | ||||||||
) | ||||||||
.insert_resource(Score(0)) | ||||||||
.insert_resource(ClearColor(BACKGROUND_COLOR)) | ||||||||
.insert_resource(PipeTimer(Timer::new( | ||||||||
PIPE_TIMER_DURATION, | ||||||||
TimerMode::Repeating, | ||||||||
))) | ||||||||
.insert_resource(WindowSize(Vec2::ZERO)) | ||||||||
.add_event::<CollisionEvent>() | ||||||||
.add_event::<SpawnPipeEvent>() | ||||||||
.run(); | ||||||||
} | ||||||||
|
||||||||
/// Set up the camera and score UI | ||||||||
fn setup( | ||||||||
mut commands: Commands, | ||||||||
mut collision_events: EventWriter<CollisionEvent>, | ||||||||
asset_server: Res<AssetServer>, | ||||||||
) { | ||||||||
commands.spawn(Camera2d); | ||||||||
|
||||||||
let score_sound = asset_server.load("sounds/breakout_collision.ogg"); | ||||||||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Suggested change
|
||||||||
commands.insert_resource(ScoreSound(score_sound)); | ||||||||
|
||||||||
// Spawn the score UI. | ||||||||
commands.spawn(( | ||||||||
Node { | ||||||||
width: Val::Percent(100.0), | ||||||||
height: Val::Percent(100.0), | ||||||||
align_items: AlignItems::Start, | ||||||||
justify_content: JustifyContent::Center, | ||||||||
padding: UiRect::all(Val::Px(10.0)), | ||||||||
..default() | ||||||||
}, | ||||||||
children![( | ||||||||
ScoreText, | ||||||||
Text::new("0"), | ||||||||
TextFont { | ||||||||
font_size: 66.0, | ||||||||
..default() | ||||||||
}, | ||||||||
TextColor(Color::srgb(0.3, 0.3, 0.9)), | ||||||||
)], | ||||||||
)); | ||||||||
|
||||||||
// Create a collision event to trigger a reset | ||||||||
collision_events.write_default(); | ||||||||
} | ||||||||
|
||||||||
/// Clear everything and put everything to its start state | ||||||||
fn reset( | ||||||||
mut commands: Commands, | ||||||||
mut timer: ResMut<PipeTimer>, | ||||||||
mut score: ResMut<Score>, | ||||||||
mut collision_events: EventReader<CollisionEvent>, | ||||||||
mut spawn_pipe_events: EventWriter<SpawnPipeEvent>, | ||||||||
score_text: Query<&mut Text, With<ScoreText>>, | ||||||||
pipes: Query<Entity, With<Pipe>>, | ||||||||
pipe_markers: Query<Entity, With<PipeMarker>>, | ||||||||
bird: Query<Entity, With<Bird>>, | ||||||||
asset_server: Res<AssetServer>, | ||||||||
) { | ||||||||
if collision_events.is_empty() { | ||||||||
return; | ||||||||
} | ||||||||
|
||||||||
collision_events.clear(); | ||||||||
|
||||||||
// Remove any entities left over from the previous game (if any) | ||||||||
for ent in bird { | ||||||||
commands.entity(ent).despawn(); | ||||||||
} | ||||||||
|
||||||||
for ent in pipes { | ||||||||
commands.entity(ent).despawn(); | ||||||||
} | ||||||||
|
||||||||
for ent in pipe_markers { | ||||||||
commands.entity(ent).despawn(); | ||||||||
} | ||||||||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Future PR: Surprised we don't have a commands.batch([e1, e2, e3]).despawn(); There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. cart once said something about a It seems to be valid today in principle. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I'll make it slightly cleaner by combining all the entities in a single query. Then at least it just becomes one for loop. |
||||||||
|
||||||||
// Set the score to 0 | ||||||||
score.0 = 0; | ||||||||
for mut text in score_text { | ||||||||
text.0 = 0.to_string(); | ||||||||
} | ||||||||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Nit: We expect a single entity to exist with the |
||||||||
|
||||||||
// Spawn a new bird | ||||||||
commands.spawn(( | ||||||||
Bird, | ||||||||
Sprite { | ||||||||
image: asset_server.load("branding/icon.png"), | ||||||||
custom_size: Some(Vec2::splat(BIRD_SIZE)), | ||||||||
..default() | ||||||||
}, | ||||||||
Transform::from_xyz(BIRD_POSITION, 0., 0.), | ||||||||
Velocity(Vec2::new(0., FLAP_POWER)), | ||||||||
)); | ||||||||
|
||||||||
timer.reset(); | ||||||||
spawn_pipe_events.write_default(); | ||||||||
} | ||||||||
|
||||||||
fn set_window_size(window: Single<&mut Window>, mut window_size: ResMut<WindowSize>) { | ||||||||
window_size.0 = Vec2::new(window.resolution.width(), window.resolution.height()); | ||||||||
} | ||||||||
|
||||||||
/// Flap on a spacebar or left mouse button press | ||||||||
fn flap( | ||||||||
keyboard_input: Res<ButtonInput<KeyCode>>, | ||||||||
mouse_input: Res<ButtonInput<MouseButton>>, | ||||||||
mut bird_velocity: Single<&mut Velocity, With<Bird>>, | ||||||||
) { | ||||||||
if keyboard_input.pressed(KeyCode::Space) || mouse_input.pressed(MouseButton::Left) { | ||||||||
bird_velocity.y = FLAP_POWER; | ||||||||
} | ||||||||
} | ||||||||
|
||||||||
/// Apply gravity to the bird and set its rotation | ||||||||
fn apply_gravity(mut bird: Single<(&mut Transform, &mut Velocity), With<Bird>>, time: Res<Time>) { | ||||||||
/// The logistic function, which is an example of a sigmoid function | ||||||||
fn logistic(x: f32) -> f32 { | ||||||||
1. / (1. + exp(-x)) | ||||||||
} | ||||||||
|
||||||||
bird.1.y -= GRAVITY * time.delta_secs(); | ||||||||
|
||||||||
// We determine the rotation based on the y-component of the velocity. | ||||||||
// This is tweaked such that a velocity of 100 is pretty much a 90 degree | ||||||||
// rotation. We take the output of the sigmoid function, which goes from | ||||||||
// 0 to 1 and stretch it to -1 to 1. Then we multiply with PI/2 to get | ||||||||
// a rotation in radians. | ||||||||
let rotation = std::f32::consts::PI / 2. * 2. * (logistic(bird.1.y / 600.) - 0.5); | ||||||||
bird.0.rotation = Quat::from_rotation_z(rotation); | ||||||||
} | ||||||||
|
||||||||
/// Apply velocity to everything with a `Velocity` component | ||||||||
fn apply_velocity(mut query: Query<(&mut Transform, &Velocity)>, time: Res<Time>) { | ||||||||
for (mut transform, velocity) in &mut query { | ||||||||
transform.translation.x += velocity.x * time.delta_secs(); | ||||||||
transform.translation.y += velocity.y * time.delta_secs(); | ||||||||
} | ||||||||
} | ||||||||
|
||||||||
/// Check for collision with the borders of the window and the pipes | ||||||||
fn check_collisions( | ||||||||
bird: Single<&Transform, With<Bird>>, | ||||||||
pipes: Query<&Transform, With<Pipe>>, | ||||||||
window_size: Res<WindowSize>, | ||||||||
mut collision_events: EventWriter<CollisionEvent>, | ||||||||
) { | ||||||||
if bird.translation.y.abs() > window_size.y / 2. { | ||||||||
collision_events.write_default(); | ||||||||
return; | ||||||||
} | ||||||||
|
||||||||
let bird_collider = BoundingCircle::new(bird.translation.truncate(), BIRD_SIZE / 2.); | ||||||||
for pipe in pipes { | ||||||||
let pipe_collider = Aabb2d::new(pipe.translation.truncate(), PIPE_SIZE / 2.); | ||||||||
if bird_collider.intersects(&pipe_collider) { | ||||||||
collision_events.write_default(); | ||||||||
return; | ||||||||
} | ||||||||
} | ||||||||
} | ||||||||
|
||||||||
/// Add a pipe each time the timer finishes | ||||||||
fn add_pipes( | ||||||||
mut timer: ResMut<PipeTimer>, | ||||||||
time: Res<Time>, | ||||||||
mut events: EventWriter<SpawnPipeEvent>, | ||||||||
) { | ||||||||
timer.tick(time.delta()); | ||||||||
|
||||||||
if timer.finished() { | ||||||||
events.write_default(); | ||||||||
} | ||||||||
} | ||||||||
|
||||||||
fn spawn_pipe( | ||||||||
mut events: EventReader<SpawnPipeEvent>, | ||||||||
window_size: Res<WindowSize>, | ||||||||
mut commands: Commands, | ||||||||
mut meshes: ResMut<Assets<Mesh>>, | ||||||||
mut materials: ResMut<Assets<ColorMaterial>>, | ||||||||
) { | ||||||||
if events.is_empty() { | ||||||||
return; | ||||||||
} | ||||||||
events.clear(); | ||||||||
|
||||||||
let color = Color::BLACK; | ||||||||
let shape = meshes.add(Rectangle::new(PIPE_SIZE.x, PIPE_SIZE.y)); | ||||||||
|
||||||||
let mut rng = rand::thread_rng(); | ||||||||
let gap_offset: i64 = rng.gen_range(-200..=200); | ||||||||
let gap_offset: f32 = gap_offset as f32; | ||||||||
|
||||||||
let pipe_offset = PIPE_SIZE.y / 2. + GAP_HEIGHT / 2.; | ||||||||
|
||||||||
let pipe_location = window_size.x / 2. + PIPE_SIZE.x / 2.; | ||||||||
|
||||||||
// We first spawn in invisible marker that will increase the score once | ||||||||
// it passes the bird position and then despawns. This assures that each | ||||||||
// pipe is counted once. | ||||||||
commands.spawn(( | ||||||||
PipeMarker, | ||||||||
Transform::from_xyz(pipe_location, 0.0, 0.0), | ||||||||
Velocity(Vec2::new(-PIPE_SPEED, 0.)), | ||||||||
)); | ||||||||
|
||||||||
// bottom pipe | ||||||||
commands.spawn(( | ||||||||
Pipe, | ||||||||
Mesh2d(shape.clone()), | ||||||||
MeshMaterial2d(materials.add(color)), | ||||||||
Transform::from_xyz(pipe_location, pipe_offset + gap_offset, 0.0), | ||||||||
Velocity(Vec2::new(-PIPE_SPEED, 0.)), | ||||||||
)); | ||||||||
|
||||||||
// top pipe | ||||||||
commands.spawn(( | ||||||||
Pipe, | ||||||||
Mesh2d(shape), | ||||||||
MeshMaterial2d(materials.add(color)), | ||||||||
Transform::from_xyz(pipe_location, -pipe_offset + gap_offset, 0.0), | ||||||||
Velocity(Vec2::new(-PIPE_SPEED, 0.)), | ||||||||
)); | ||||||||
} | ||||||||
|
||||||||
/// Increase the score every time a pipe marker passes the bird | ||||||||
fn increase_score( | ||||||||
mut commands: Commands, | ||||||||
mut marker_query: Query<(Entity, &mut Transform), With<PipeMarker>>, | ||||||||
mut text_query: Query<&mut Text, With<ScoreText>>, | ||||||||
mut score: ResMut<Score>, | ||||||||
sound: Res<ScoreSound>, | ||||||||
) { | ||||||||
for (entity, transform) in &mut marker_query { | ||||||||
if transform.translation.x < BIRD_POSITION { | ||||||||
commands.entity(entity).despawn(); | ||||||||
score.0 += 1; | ||||||||
text_query.single_mut().unwrap().0 = score.0.to_string(); | ||||||||
commands.spawn((AudioPlayer(sound.clone()), PlaybackSettings::DESPAWN)); | ||||||||
} | ||||||||
} | ||||||||
} | ||||||||
|
||||||||
/// Remove pipes that have left the screen | ||||||||
fn remove_pipes( | ||||||||
mut commands: Commands, | ||||||||
mut query: Query<(Entity, &mut Transform), With<Pipe>>, | ||||||||
window_size: Res<WindowSize>, | ||||||||
) { | ||||||||
for (entity, transform) in &mut query { | ||||||||
// The entire pipe needs to have left the screen, not just its origin, | ||||||||
// so we check that the right side of the pipe is off screen. | ||||||||
let right_side_of_pipe = transform.translation.x + PIPE_SIZE.x / 2.; | ||||||||
if right_side_of_pipe < -window_size.x / 2. { | ||||||||
commands.entity(entity).despawn(); | ||||||||
} | ||||||||
} | ||||||||
} |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Nit: Should we choose a name other than "Flappy Bird"? I would assume the name is trademarked, and unlike "Breakout", it's reasonably recent.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
You are probably right. I'll think about a nice bevy-inspired name!
Fun fact: apparently there is a whole thing around the copyright of Flappy Bird. Some crypto bros did a Flappy Bird thing and then the original creator got mad. There's also some people claiming the original Flappy Bird infringes on Nintendo copyright.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Quick search for
Floppy Bevy
led me to https://github.com/rust-adventure/floppy-corgi haha...