// Bevy basically forces "complex types" with Querys #![allow(clippy::type_complexity)] use games::physics2d::*; use games::*; use std::hash::BuildHasher; fn main() { App::new() .add_plugins(( BaseGamePlugin { title: "flappy bird (with rewind)".into(), name: "flappy".into(), game_type: GameType::Two, }, Physics2dPlugin, )) .insert_resource(Gravity(Vec2::NEG_Y * 9.8 * 100.0)) .init_resource::() .init_resource::() .init_resource::() .init_resource::() .init_resource::() .init_resource::() .init_state::() .add_systems( Startup, ( init_bird, // init_obstacles, init_obstacle_assets, init_first_batches.after(init_obstacle_assets), init_ui, tweak_camera.after(create_camera_2d), ), ) .add_systems(OnEnter(PlayerState::Alive), alive_bird) .add_systems(OnEnter(PlayerState::Rewind), alive_bird) .add_systems(OnEnter(PlayerState::Pause), pause_bird) .add_systems(OnEnter(PlayerState::Stasis), pause_bird) .add_systems( Update, ( // Systems to run when player state changes ( // Print out when this state changes for debugging purposes debug_state_changes::, // Toggle (UI) elements when the player dies/alives toggle_state_visibility::, ) .run_if(state_changed::), // Detect if the bird is "dead" by checking if it is visible // from the point of view of the camera detect_dead.run_if(in_state(PlayerState::Alive)), // Toggle rewinding state when "R" is pressed/released toggle_rewind.run_if( input_just_pressed(KeyCode::KeyR).or(input_just_released(KeyCode::KeyR)), ), // Systems to run in the "play" state ( // Only flap when we press the space key flap_kb.run_if(input_just_pressed(KeyCode::Space)), // Rewinding systems record.run_if(any_component_changed::), ) .run_if(in_state(PlayerState::Alive)), // Rewinding systems rewind.run_if(in_state(PlayerState::Rewind)), // Camera follows when bird moves regardless of player state camera_follow_bird.run_if(any_component_changed::), // Pause when the player presses Escape pause_game.run_if(input_just_pressed(KeyCode::Escape)), // Transition out of the pause screen if the player presses space un_pause_game .run_if(input_just_pressed(KeyCode::Space)) .run_if(in_state(PlayerState::Pause)), // Stats that get synced to the UI ( sync_resource_to_ui::.run_if(resource_changed::), sync_resource_to_ui::.run_if(resource_changed::), sync_resource_to_ui::.run_if(resource_changed::), sync_resource_to_ui::.run_if(resource_changed::), ), update_batch_position.run_if(any_component_changed::), update_tooltip.run_if(in_state(DebuggingState::On)), // TODO: Add run_if to this system hitbox_collision_handler, manage_score.run_if(on_event::.or(on_event::)), ), ) .add_observer(flap) .add_observer(populate_batch) .add_observer(populate_pipe) .add_observer(populate_ground) .add_observer(populate_hitbox) .run(); } fn tweak_camera(camera: Single<&mut Camera>) { debug!("Tweaking camera"); let mut c = camera.into_inner(); c.clear_color = ClearColorConfig::Custom(WHITE.into()); } #[derive(Component)] struct Bird; #[derive(States, Clone, Eq, PartialEq, Debug, Hash, Default, Component)] enum PlayerState { Alive, Rewind, Stasis, // Kind of a hack, but we need a state for this Credits, #[default] Pause, } // A tape tracking the bird's state every frame #[derive(Component, Default)] struct Tape { accumulated_translations: Vec, linear_velocities: Vec, angular_velocities: Vec, external_angular_impulses: Vec, external_forces: Vec, external_impulses: Vec, external_torques: Vec, positions: Vec, rotations: Vec, } fn init_bird( mut commands: Commands, server: Res, mut meshes: ResMut>, mut materials: ResMut>, ) { let material = MeshMaterial2d(materials.add(ColorMaterial { texture: Some(server.load("flappy/bird.png")), color: ORANGE.into(), alpha_mode: AlphaMode2d::Blend, ..default() })); let mesh = Mesh2d(meshes.add(Rectangle::new(1.0, 1.2))); let name = Name::new("bird"); let t = Transform::from_xyz(0.0, 0.0, 0.0).with_scale(Vec3::splat(50.0)); let physics = ( RigidBody::Static, Collider::rectangle(1.0, 1.2), Mass(10.0), ExternalImpulse::default().with_persistence(false), MaxLinearSpeed(500.0), ); let tape = Tape::default(); commands.spawn(( name, mesh, material, physics, t, Bird, tape, CollisionEventsEnabled, )); } #[derive(Component, Clone)] struct Ground(isize); #[derive(Component, Clone)] enum Pipe { Top, Bottom, } #[derive(Component, Clone)] struct Hitbox; #[derive(Component, Clone)] struct Batch(usize); fn init_first_batches(mut commands: Commands) { commands.spawn(Batch(0)); commands.spawn(Batch(1)); commands.spawn(Batch(2)); commands.spawn(Batch(3)); commands.spawn(Batch(4)); } /// We have the concept of an environment "batch" which contains: /// * The ground obstacle /// * The pipe obstacle /// * The pipe scoring sensor /// /// These are all populated via `OnAdd` observers for the respected components. /// This makes it much more concise to spawn each part of the environment. fn populate_batch( trigger: Trigger, batches: Query<&Batch>, children: Query<&ChildOf>, mut commands: Commands, ) { // Only run this for top level batch entities, // not children containing a reference to their batch, like hitboxes if !children.contains(trigger.target()) { let Batch(batch_id) = batches.get(trigger.target()).unwrap(); commands .entity(trigger.target()) .insert(( Transform::from_xyz(500.0 * (*batch_id) as f32, 0.0, 0.0), Visibility::Inherited, )) .with_children(|parent| { parent.spawn(Ground(-2)); parent.spawn(Ground(-1)); parent.spawn(Ground(0)); parent.spawn(Ground(1)); parent.spawn(Ground(2)); if *batch_id > 0 { parent.spawn((Pipe::Top, Batch(*batch_id))); parent.spawn((Pipe::Bottom, Batch(*batch_id))); parent.spawn((Hitbox, Batch(*batch_id))); } }); } } fn update_batch_position( mut root_changes: Query<(&mut Transform, &Batch), (Changed, Without)>, ) { root_changes.iter_mut().for_each(|(mut t, Batch(idx))| { info!("Updating batch {:?}", idx); t.translation.x = 500.0 * (*idx) as f32; }); // todo!("Adjust pipe positions"); } /// The ground population spawns a center peace and two pieces of ground /// to the left and right of the center. fn populate_ground( trigger: Trigger, grounds: Query<&Ground>, ground_assets: Res, mut commands: Commands, ) { let Ground(idx) = grounds.get(trigger.target()).unwrap(); debug!("populating ground {:?}", idx); commands.entity(trigger.target()).insert(( ground_assets.material.clone(), ground_assets.mesh.clone(), Name::new("ground"), RigidBody::Static, Collider::rectangle(1.0, 1.0), Transform::from_xyz(100.0 * (*idx) as f32, -300.0, -1.0).with_scale(Vec3::splat(100.0)), )); } /// Based on if this is a Top or Bottom pipe the placement changes /// Otherwise this just spawns in the center of the batch. fn populate_pipe( trigger: Trigger, pipes: Query<(&Pipe, &Batch)>, pipe_assets: Res, mut commands: Commands, rand: Res, ) { let pipe_t = { let (pipe, Batch(id)) = pipes.get(trigger.target()).unwrap(); let offset = { let val = rand.0.hash_one(id); let option = val % 3; match option { 0 => 100.0, 1 => 0.0, 2 => -100.0, _ => panic!("Can only pick 1 of 3 pipe offsets"), } }; match pipe { Pipe::Top => { Transform::from_xyz(0.0, 200.0 + offset, -1.0).with_scale(Vec3::splat(100.0)) } Pipe::Bottom => { Transform::from_xyz(0.0, -100.0 + offset, -1.0).with_scale(Vec3::splat(100.0)) } } }; commands.entity(trigger.target()).insert(( pipe_t, pipe_assets.material.clone(), pipe_assets.mesh.clone(), RigidBody::Static, Collider::rectangle(1.0, 1.0), Name::new("pipe"), )); } /// The hitbox should cover the entire height of the screen so if the player /// passes between the pipes it registers a point fn populate_hitbox(trigger: Trigger, mut commands: Commands) { commands.entity(trigger.target()).insert(( RigidBody::Static, Collider::rectangle(1.0, 10.0), Sensor, CollisionEventsEnabled, Name::new("hitbox"), Transform::from_xyz(0.0, 0.0, 0.0).with_scale(Vec3::splat(100.0)), )); } #[derive(Resource, Default)] struct GroundAssets { material: MeshMaterial2d, mesh: Mesh2d, } #[derive(Resource, Default)] struct PipeAssets { material: MeshMaterial2d, mesh: Mesh2d, } /// Initialize some materials and meshes used by the environment obstacles fn init_obstacle_assets( mut meshes: ResMut>, mut materials: ResMut>, mut pipe_assets: ResMut, mut ground_assets: ResMut, server: Res, ) { pipe_assets.material = MeshMaterial2d(materials.add(ColorMaterial { texture: Some(server.load("flappy/pipe.png")), color: GREEN.into(), ..default() })); pipe_assets.mesh = Mesh2d(meshes.add(Rectangle::new(1.0, 1.0))); ground_assets.material = MeshMaterial2d(materials.add(ColorMaterial { texture: Some(server.load("flappy/ground.png")), color: BLACK.into(), ..default() })); ground_assets.mesh = Mesh2d(meshes.add(Rectangle::new(1.0, 1.0))); } fn init_ui(mut commands: Commands) { commands .spawn(( Node { align_self: AlignSelf::Center, justify_self: JustifySelf::Center, flex_direction: FlexDirection::Column, ..default() }, PlayerState::Stasis, )) .with_children(|parent| { parent.spawn(( Text::new("Game Over...?"), TextLayout::new_with_justify(JustifyText::Center), )); parent.spawn(( SyncResource::::default(), Text::default(), TextLayout::new_with_justify(JustifyText::Center), )); parent.spawn(( SyncResource::::default(), Text::default(), TextLayout::new_with_justify(JustifyText::Center), )); parent.spawn(( SyncResource::::default(), Text::default(), TextLayout::new_with_justify(JustifyText::Center), )); parent.spawn(( SyncResource::::default(), Text::default(), TextLayout::new_with_justify(JustifyText::Center), )); parent.spawn(( Text::new("Press R to Rewind"), TextLayout::new_with_justify(JustifyText::Center), )); parent .spawn((Node { flex_direction: FlexDirection::Row, justify_content: JustifyContent::SpaceEvenly, ..default() },)) .with_children(|parent| { fn quit_game( _trigger: Trigger>, mut exit: EventWriter, ) { warn!("Quitting game"); exit.write(AppExit::Success); } fn show_credits( _trigger: Trigger>, mut state: ResMut>, ) { state.set(PlayerState::Credits); } parent .spawn(( Button, Node { ..default() }, children![Text::new("Credits")], )) .observe(show_credits); parent .spawn((Button, Node { ..default() }, children![Text::new("Quit"),])) .observe(quit_game); }); }); fn hide_credits(_trigger: Trigger>, mut state: ResMut>) { state.set(PlayerState::Stasis) } let credits_str = include_str!("../../../assets/flappy/CREDITS"); commands .spawn(( Node { align_self: AlignSelf::Center, justify_self: JustifySelf::Center, flex_direction: FlexDirection::Column, ..default() }, PlayerState::Credits, children![( Text::new(credits_str), TextLayout::new_with_justify(JustifyText::Center) )], )) .with_children(|parent| { parent.spawn(( Node { align_self: AlignSelf::Center, ..default() }, Button, children![Text::new("Close")], )); }) .observe(hide_credits); fn start_game(_trigger: Trigger>, mut next: ResMut>) { next.set(PlayerState::Alive); } commands .spawn(( Node { align_self: AlignSelf::Center, justify_self: JustifySelf::Center, flex_direction: FlexDirection::Column, ..default() }, Button, PlayerState::Pause, children![Text::new("Go!"),], )) .observe(start_game); commands .spawn(Node { align_self: AlignSelf::End, justify_self: JustifySelf::Center, flex_direction: FlexDirection::Row, ..default() }) .with_children(|parent| { parent .spawn(( Node { ..default() }, Button, children![Text::new("Rewind!"),], )) .observe(start_rewind) .observe(end_rewind); parent .spawn((Node { ..default() }, Button, children![Text::new("Flap!"),])) .observe(flap_button); }); commands.spawn(( Node { align_self: AlignSelf::Start, justify_self: JustifySelf::Center, ..default() }, BackgroundColor(WHITE.into()), SyncResource::::default(), Text::default(), TextLayout::new_with_justify(JustifyText::Center), )); } fn start_rewind(_trigger: Trigger>, mut next: ResMut>) { next.set(PlayerState::Rewind); } fn end_rewind(_trigger: Trigger>, mut next: ResMut>) { next.set(PlayerState::Alive); } fn flap_button( _trigger: Trigger>, mut commands: Commands, bird: Single>, ) { let e = *bird; debug!("Flapping {:?}", e); commands.trigger_targets(Flap, e); } /// Pause the game when the player presses "Escape" fn pause_game(mut next: ResMut>) { next.set(PlayerState::Pause); } fn un_pause_game(mut next: ResMut>) { next.set(PlayerState::Alive); } #[derive(Component, Clone, Event)] struct Flap; // Observer for flapping fn flap( trigger: Trigger, mut bird: Query<&mut ExternalImpulse, With>, mut flaps: ResMut, ) { debug!("real flap for {:?}", trigger.target()); // Increment flap stat flaps.0 += 1; // Flap birds wings if let Ok(mut f) = bird.get_mut(trigger.target()) { f.apply_impulse(Vec2::Y * 5000.0 + Vec2::X * 1000.0); } } fn flap_kb( #[cfg(debug_assertions)] state: Res>, #[cfg(debug_assertions)] keycode: Res>, birds: Query>, mut commands: Commands, ) { #[cfg(debug_assertions)] { debug_assert!( matches!(state.get(), PlayerState::Alive), "Only flap when playing" ); debug_assert!( keycode.just_pressed(KeyCode::Space), "Only flap when space is just pressed" ); } birds.iter().for_each(|e| { debug!("Flapping {:?}", e); commands.trigger_targets(Flap, e); }); } fn toggle_rewind(keycode: Res>, mut next: ResMut>) { debug_assert!( keycode.just_pressed(KeyCode::KeyR) || keycode.just_released(KeyCode::KeyR), "Only toggle rewind when R is pressed" ); if keycode.just_pressed(KeyCode::KeyR) { debug!("Toggling rewind ON"); next.set(PlayerState::Rewind) } else { debug!("Toggling rewind OFF"); next.set(PlayerState::Alive) } } fn record( #[cfg(debug_assertions)] state: Res>, mut birds: Query< ( &AccumulatedTranslation, &LinearVelocity, &AngularVelocity, &ExternalAngularImpulse, &ExternalForce, &ExternalImpulse, &ExternalTorque, &Position, &Rotation, &mut Tape, ), With, >, ) { #[cfg(debug_assertions)] debug_assert!( matches!(state.get(), PlayerState::Alive), "Only record in the alive state" ); birds .iter_mut() .for_each(|(at, lv, av, eai, ef, ei, et, p, r, mut tape)| { tape.accumulated_translations.push(*at); tape.linear_velocities.push(*lv); tape.angular_velocities.push(*av); tape.external_angular_impulses.push(*eai); tape.external_forces.push(*ef); tape.external_impulses.push(*ei); tape.external_torques.push(*et); tape.positions.push(*p); tape.rotations.push(*r); }); } fn rewind( #[cfg(debug_assertions)] state: Res>, mut next: ResMut>, mut birds: Query< ( &mut AccumulatedTranslation, &mut LinearVelocity, &mut AngularVelocity, &mut ExternalAngularImpulse, &mut ExternalForce, &mut ExternalImpulse, &mut ExternalTorque, &mut Position, &mut Rotation, &mut Tape, ), With, >, mut frames: ResMut, ) { #[cfg(debug_assertions)] debug_assert!( matches!(state.get(), PlayerState::Rewind), "Only rewind in the rewinding state" ); birds.iter_mut().for_each( |(mut at, mut lv, mut av, mut eai, mut ef, mut ei, mut et, mut p, mut r, mut tape)| { if tape.positions.is_empty() { next.set(PlayerState::Pause); } else { // TODO: Only record/restore variables that we manage! at.0 = tape.accumulated_translations.pop().unwrap().0; lv.0 = tape.linear_velocities.pop().unwrap().0; av.0 = tape.angular_velocities.pop().unwrap().0; eai.set_impulse(tape.external_angular_impulses.pop().unwrap().impulse()); ef.set_force(tape.external_forces.pop().unwrap().force()); ei.set_impulse(tape.external_impulses.pop().unwrap().impulse()); et.set_torque(tape.external_torques.pop().unwrap().torque()); p.0 = tape.positions.pop().unwrap().0; *r = { let curr = tape.rotations.pop().unwrap(); Rotation { cos: curr.cos, sin: curr.sin, } }; frames.0 += 1; } }, ); } // PERF: May run more than necessary, should be event-driven on aabb intersection fn detect_dead( #[cfg(debug_assertions)] state: Res>, bird: Single<&ColliderAabb, With>, obstacles: Query<&ColliderAabb, Or<(With, With)>>, mut next: ResMut>, ) { #[cfg(debug_assertions)] debug_assert!( matches!(state.get(), PlayerState::Alive), "Only check if dead while alive" ); if obstacles.iter().any(|obstacle| bird.intersects(obstacle)) { next.set(PlayerState::Stasis); } } fn alive_bird( mut bird: Single<&mut RigidBody, With>, ) { debug!("Setting bird to Dynamic"); **bird = RigidBody::Dynamic; } fn pause_bird( state: Res>, mut bird: Single<&mut RigidBody, With>, mut deaths: ResMut, ) { // Increment death count if state.get() == &PlayerState::Stasis { deaths.0 += 1 } debug!("Setting bird to Static"); **bird = RigidBody::Static; } fn camera_follow_bird( bird: Single<&Transform, (With, Changed)>, mut camera: Single<&mut Transform, (With, Without)>, ) { camera.translation.x = bird.translation.x; } /// How many pipes the player has passed #[derive(Resource, Default)] struct Score(usize); impl Display for Score { fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result { writeln!(f, "Score: {}", self.0) } } /// Number of frames that were rewound #[derive(Resource, Default)] struct RewindFrames(usize); impl Display for RewindFrames { fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result { writeln!(f, "Frames Rewound: {}", self.0) } } // Track number of times player flapped their wings #[derive(Resource, Default)] struct Flaps(usize); impl Display for Flaps { fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result { writeln!(f, "Flaps: {}", self.0) } } // Track number of times player died #[derive(Resource, Default)] struct Deaths(usize); impl Display for Deaths { fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result { writeln!(f, "Deaths: {}", self.0) } } fn manage_score( mut start: EventReader, mut end: EventReader, state: Res>, hitboxes: Query<&Batch, With>, mut score: ResMut, ) { match state.get() { PlayerState::Rewind => { start.read().for_each(|CollisionStarted(a, b)| { // Set score to collided hitbox if let Ok(Batch(this)) = hitboxes.get(*a) { info!("[Rewind] Setting score to {this}"); score.0 = this - 1; } else if let Ok(Batch(this)) = hitboxes.get(*b) { info!("[Rewind] Setting score to {this}"); score.0 = this - 1; } }) } _ => { end.read().for_each(|CollisionEnded(a, b)| { // Set score to collided hitbox if let Ok(Batch(this)) = hitboxes.get(*b) { info!("[Alive] Setting score to {this}"); score.0 = *this; } else if let Ok(Batch(this)) = hitboxes.get(*a) { info!("[Alive] Setting score to {this}"); score.0 = *this; } }) } } } /// When the player moves forward while alive /// spawn more batches and despawn old batches fn hitbox_collision_handler( mut start: EventReader, bird: Query>, hitboxes: Query<(Entity, &Batch), With>, state: Res>, mut commands: Commands, ) { start.read().for_each(|CollisionStarted(a, b)| { // Get the current batch let (_, Batch(curr)) = if hitboxes.contains(*b) { hitboxes.get(*b).unwrap() } else { hitboxes.get(*a).unwrap() }; let (old_batch, new_batch) = match state.get() { PlayerState::Alive => (curr.saturating_sub(2), curr.saturating_add(2)), PlayerState::Rewind => (curr.saturating_add(2), curr.saturating_sub(2)), _ => (*curr, *curr), }; let old_entity = hitboxes.iter().find_map(|(e, Batch(id))| { (*id == old_batch).then_some(e) }).unwrap(); commands.entity(old_entity).insert(Batch(new_batch)); }); } fn update_tooltip( mut query: Query<( &mut ToolTip, Option<&LinearVelocity>, Option<&Batch>, Option<&RigidBody>, )>, ) { query.iter_mut().for_each(|(mut tt, lv, b, rb)| { // Add Linear Velocity if present on entity lv.iter().for_each(|it| { tt.insert("Velocity", format!("{}", it.0)); }); // Add Batch ID to if present b.iter().for_each(|it| { tt.insert("Batch", format!("{}", it.0)); }); rb.iter().for_each(|it| { tt.insert("RigidBody", format!("{it:?}")); }) }); }