// Bevy basically forces "complex types" with Querys #![allow(clippy::type_complexity)] use games::physics2d::*; use games::*; fn main() { App::new() .add_plugins(( BaseGamePlugin { name: "flappy bird (with rewind)".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::Pause), pause_bird) .add_systems(OnEnter(PlayerState::Stasis), pause_bird) .add_systems(OnEnter(PlayerState::Rewind), 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::), ), scoring.run_if(on_event::), manage_batches.run_if(on_event::).run_if(in_state(PlayerState::Alive).or(in_state(PlayerState::Rewind))), update_tooltip.run_if(in_state(DebuggingState::On)), ), ) .add_observer(flap) .add_observer(populate_batch) .add_observer(update_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, #[default] Pause, } // A tape tracking the bird's state every frame #[derive(Component, Default)] struct Tape { translations: Vec, rotations: Vec, linear_velocities: Vec, angular_velocities: 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("kenny.nl/1-bit-platformer-pack/tile_0380.png")), color: ORANGE.into(), alpha_mode: AlphaMode2d::Blend, ..default() })); let mesh = Mesh2d(meshes.add(Rectangle::new(1.0, 1.0))); let name = Name::new("bird"); let t = Transform::from_xyz(0.0, 0.0, 0.0).with_scale(Vec3::splat(100.0)); let physics = ( RigidBody::Static, Collider::circle(0.5), 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); parent.spawn(Pipe::Bottom); parent.spawn((Hitbox, Batch(*batch_id))); } }); } } fn update_batch( trigger: Trigger, mut root: Query<(&mut Transform, &Batch), With>, ) { if let Ok((mut t, Batch(idx))) = root.get_mut(trigger.target()) { debug!("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>, pipe_assets: Res, mut commands: Commands, ) { let pipe_t = match pipes.get(trigger.target()).unwrap() { Pipe::Top => Transform::from_xyz(0.0, 200.0, -1.0).with_scale(Vec3::splat(100.0)), Pipe::Bottom => Transform::from_xyz(0.0, -200.0, -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 GroundComponents { material: MeshMaterial2d, mesh: Mesh2d, } #[derive(Resource, Default)] struct PipeComponents { 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("kenny.nl/1-bit-platformer-pack/tile_0247.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("kenny.nl/1-bit-platformer-pack/tile_0088.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, children![ ( Text::new("You Died"), TextLayout::new_with_justify(JustifyText::Center) ), ( SyncResource::::default(), Text::default(), TextLayout::new_with_justify(JustifyText::Center) ), ( SyncResource::::default(), Text::default(), TextLayout::new_with_justify(JustifyText::Center) ), ( SyncResource::::default(), Text::default(), TextLayout::new_with_justify(JustifyText::Center) ), ( SyncResource::::default(), Text::default(), TextLayout::new_with_justify(JustifyText::Center) ), ( Text::new("Press R to Rewind"), TextLayout::new_with_justify(JustifyText::Center) ), ], )); 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, // TODO: Add Pause (basically Stasis) state 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, ) { 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<(&Transform, &LinearVelocity, &AngularVelocity, &mut Tape), With>, ) { debug_assert!( matches!(state.get(), PlayerState::Alive), "Only record in the alive state" ); birds .iter_mut() .for_each(|(transform, linear_velocity, angular_velocity, mut tape)| { tape.translations.push(transform.translation); tape.rotations.push(transform.rotation); tape.linear_velocities.push(*linear_velocity); tape.angular_velocities.push(*angular_velocity); }); } fn rewind( #[cfg(debug_assertions)] state: Res>, mut birds: Query< ( &mut Transform, &mut AngularVelocity, &mut LinearVelocity, &mut Tape, ), With, >, mut frames: ResMut, ) { debug_assert!( matches!(state.get(), PlayerState::Rewind), "Only rewind in the rewinding state" ); birds.iter_mut().for_each( |(mut transform, mut angular_velocity, mut linear_velocity, mut tape)| { match (tape.translations.pop(), tape.rotations.pop()) { (Some(t), Some(r)) => { transform.translation = t; transform.rotation = r; frames.0 += 1; } (None, None) => (), _ => panic!("Translations and rotations are out of sync!"), } // TODO: Only need to set {angular|linear}_velocity at end of Rewind if let Some(av) = tape.angular_velocities.pop() { *angular_velocity = av; } if let Some(lv) = tape.linear_velocities.pop() { *linear_velocity = lv; } }, ); } // 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>, ) { 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( #[cfg(debug_assertions)] state: Res>, mut bird: Single<&mut RigidBody, With>, ) { debug_assert!(matches!(state.get(), PlayerState::Alive)); debug!("Setting bird to Dynamic"); **bird = RigidBody::Dynamic; } fn pause_bird( state: Res>, mut bird: Single<&mut RigidBody, With>, mut deaths: ResMut, ) { debug_assert!(!matches!(state.get(), PlayerState::Alive)); // 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) } } // TODO: Scoring is bugged, need to make it correct. fn scoring( mut events: EventReader, state: Res>, bird: Query>, hitboxes: Query>, mut score: ResMut, ) { events .read() .filter(|CollisionEnded(a, b)| bird.contains(*a) && hitboxes.contains(*b)) .for_each(|_| { debug!("Hit event while {:?}", state.get()); match state.get() { PlayerState::Alive => score.0 = score.0.saturating_add(1), PlayerState::Rewind => score.0 = score.0.saturating_sub(1), PlayerState::Pause | PlayerState::Stasis => (), } }) } /// When the player moves forward while alive /// spawn more batches and despawn old batches fn manage_batches( mut events: EventReader, state: Res>, bird: Query>, batches: Query<(Entity, &Batch)>, mut commands: Commands, ) { debug_assert!(matches!(state.get(), PlayerState::Alive) || matches!(state.get(), PlayerState::Rewind)); events .read() // This is written in a wonky way to avoid borrow checker rules // Instaed of updating the Batch in place we use Commands to get it and upsert a new batch .filter_map(|CollisionStarted(a, b)| { if bird.contains(*a) && let Ok((e, Batch(idx))) = batches.get(*b) && *idx > 2 { Some((e, idx)) } else { None } }).for_each(|(_, idx)| { let (old_batch_idx, new_batch_idx) = match state.get() { PlayerState::Alive => (idx - 2, idx + 2), PlayerState::Rewind => (idx + 2, idx - 2), _ => panic!("Should not happen!"), }; // Find all entities with the old batch and adjust them batches.iter().filter_map(|(e, batch)| { (batch.0 == old_batch_idx).then_some(e) }).for_each(|e| { commands.entity(e).insert(Batch(new_batch_idx)); }) }); } fn update_tooltip( mut query: Query<(&mut ToolTip, &LinearVelocity, Entity), With>, ) { query.iter_mut().for_each(|(mut tt, lv, _)| { tt.insert("Velocity", format!("{}", lv.0)); }); }