// Bevy basically forces "complex types" with Querys #![allow(clippy::type_complexity)] use bevy::image::{ImageLoaderSettings, ImageSampler}; use bevy::render::view::ColorGrading; use games::physics2d::*; use games::*; use std::hash::BuildHasher; use std::time::Instant; 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_resource::() .init_resource::() .init_resource::() .init_state::() .add_systems( Startup, ( // init_obstacles, init_assets, init_bird.after(init_assets), init_first_batches.after(init_assets), init_ui, init_background, tweak_camera.after(create_camera_2d), ), ) .add_systems( OnEnter(PlayerState::Alive), (alive_bird, start_run, reset_button::), ) .add_systems(OnExit(PlayerState::Alive), end_run) .add_systems(OnEnter(PlayerState::Rewind), alive_bird) .add_systems(OnEnter(PlayerState::Pause), pause_bird) .add_systems(OnEnter(PlayerState::Stasis), pause_bird) .add_systems(OnExit(PlayerState::Stasis), reset_button::) .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::), sync_resource_to_ui::.run_if(resource_changed::), ), (update_tooltip, debug_trail).run_if(in_state(DebuggingState::On)), // TODO: Add run_if to this system update_batch_position.run_if(any_component_changed::), move_batches.run_if(on_event::.or(on_event::)), manage_score.run_if(on_event::.or(on_event::)), shimmer_button::.run_if(in_state(PlayerState::Stasis)), shimmer_button::.run_if(in_state(PlayerState::Pause)), ), ) .add_observer(flap) .add_observer(populate_batch) .add_observer(populate_pipe) .add_observer(move_pipe) .add_observer(populate_ground) .add_observer(populate_ceiling) .add_observer(populate_hitbox) .run(); } fn tweak_camera(camera: Single<(Entity, &mut Camera)>, mut commands: Commands) { debug!("Tweaking camera"); let (e, mut c) = camera.into_inner(); c.clear_color = ClearColorConfig::Custom(SKY_BLUE.into()); commands.entity(e).insert(ColorGrading { ..default() }); } #[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)] struct Tape { capacity: usize, linear_velocities: VecDeque, angular_velocities: VecDeque, external_impulses: VecDeque, positions: VecDeque, rotations: VecDeque, } impl Tape { fn new_with_capacity(capacity: usize) -> Self { Tape { capacity, linear_velocities: VecDeque::with_capacity(capacity), angular_velocities: VecDeque::with_capacity(capacity), external_impulses: VecDeque::with_capacity(capacity), positions: VecDeque::with_capacity(capacity), rotations: VecDeque::with_capacity(capacity), } } fn push( &mut self, lv: LinearVelocity, av: AngularVelocity, ei: ExternalImpulse, p: Position, r: Rotation, ) { // If we are at capacity, make room if self.linear_velocities.len() == self.capacity { self.linear_velocities.pop_front().unwrap(); self.angular_velocities.pop_front().unwrap(); self.external_impulses.pop_front().unwrap(); self.positions.pop_front().unwrap(); self.rotations.pop_front().unwrap(); } self.linear_velocities.push_back(lv); self.angular_velocities.push_back(av); self.external_impulses.push_back(ei); self.positions.push_back(p); self.rotations.push_back(r); } fn pop( &mut self, ) -> Option<( LinearVelocity, AngularVelocity, ExternalImpulse, Position, Rotation, )> { if self.linear_velocities.is_empty() { None } else { let lv = self.linear_velocities.pop_back().unwrap(); let av = self.angular_velocities.pop_back().unwrap(); let ei = self.external_impulses.pop_back().unwrap(); let p = self.positions.pop_back().unwrap(); let r = self.rotations.pop_back().unwrap(); Some((lv, av, ei, p, r)) } } } fn init_bird(mut commands: Commands, bird_assets: Res) { let name = Name::new("bird"); let t = Transform::from_xyz(0.0, 0.0, 0.0).with_scale(Vec3::new(62.5, 50.0, 50.0)); let physics = ( RigidBody::Static, Collider::rectangle(1.0, 1.0), Mass(10.0), ExternalImpulse::default().with_persistence(false), MaxLinearSpeed(500.0), ); // 60fps * 5 seconds const REWIND_SECONDS: usize = 5; let tape = Tape::new_with_capacity(60 * REWIND_SECONDS); commands.spawn(( name, bird_assets.mesh.clone(), bird_assets.material.clone(), physics, t, Bird, tape, CollisionEventsEnabled, )); } #[derive(Component, Clone)] struct Ground(isize); #[derive(Component, Clone)] struct Ceiling(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)); parent.spawn(Ceiling(-2)); parent.spawn(Ceiling(-1)); parent.spawn(Ceiling(0)); parent.spawn(Ceiling(1)); parent.spawn(Ceiling(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))| { debug!("Updating batch {:?}", idx); t.translation.x = 500.0 * (*idx) as f32; }); } 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, 0.75), Transform::from_xyz(100.0 * (*idx) as f32, -275.0, -1.0).with_scale(Vec3::splat(100.0)), )); } fn populate_ceiling( trigger: Trigger, ceiling: Query<&Ceiling>, ceiling_assets: Res, mut commands: Commands, ) { let Ceiling(idx) = ceiling.get(trigger.target()).unwrap(); debug!("populating ceiling{:?}", idx); commands.entity(trigger.target()).insert(( ceiling_assets.material.clone(), ceiling_assets.mesh.clone(), Name::new("ceiling"), RigidBody::Static, Collider::rectangle(1.0, 0.75), Transform::from_xyz(100.0 * (*idx) as f32, 300.0, -3.0).with_scale(Vec3::splat(100.0)), )); } fn move_pipe( trigger: Trigger, mut pipes: Query<(&Batch, &Pipe, &mut Transform)>, rand: Res, ) { if let Ok((Batch(id), pipe, mut pipe_t)) = pipes.get_mut(trigger.target()) { *pipe_t = { 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, 300.0 + offset, -2.0) .with_scale(Vec3::splat(100.0)), Pipe::Bottom => Transform::from_xyz(0.0, -300.0 + offset, -2.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<(&Batch, &Pipe)>, pipe_assets: Res, mut commands: Commands, rand: Res, ) { let pipe_t = { let (Batch(id), pipe) = 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, 300.0 + offset, -2.0).with_scale(Vec3::splat(100.0)) } Pipe::Bottom => { Transform::from_xyz(0.0, -300.0 + offset, -2.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, 4.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 BirdAssets { material: MeshMaterial2d, mesh: Mesh2d, } #[derive(Resource, Default)] struct GroundAssets { material: MeshMaterial2d, mesh: Mesh2d, } #[derive(Resource, Default)] struct CeilingAssets { 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_assets( mut meshes: ResMut>, mut materials: ResMut>, mut bird_assets: ResMut, mut pipe_assets: ResMut, mut ground_assets: ResMut, mut ceiling_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(0.875, 4.0))); ground_assets.material = MeshMaterial2d(materials.add(ColorMaterial { texture: Some(server.load("flappy/ground.png")), color: GREEN.into(), ..default() })); ground_assets.mesh = Mesh2d(meshes.add(Rectangle::new(1.0, 1.0))); ceiling_assets.material = MeshMaterial2d(materials.add(ColorMaterial { texture: Some(server.load("flappy/ceiling.png")), color: FIRE_BRICK.into(), ..default() })); ceiling_assets.mesh = Mesh2d(meshes.add(Rectangle::new(1.0, 0.777))); bird_assets.material = MeshMaterial2d(materials.add(ColorMaterial { texture: Some(server.load("flappy/bird.png")), color: ORANGE.into(), alpha_mode: AlphaMode2d::Blend, ..default() })); bird_assets.mesh = Mesh2d(meshes.add(Rectangle::new(1.0, 1.2))); } #[derive(Component)] struct FlapButton; #[derive(Component)] struct RewindButton; fn init_ui(mut commands: Commands, server: Res) { commands .spawn(( Node { align_self: AlignSelf::Center, justify_self: JustifySelf::Center, flex_direction: FlexDirection::Column, ..default() }, BorderRadius::all(Val::Px(5.0)), BackgroundColor(Color::WHITE), BorderColor(BLACK.into()), PlayerState::Stasis, )) .with_children(|parent| { parent.spawn(( Text::new("Game Over...?"), TextColor(BLACK.into()), TextLayout::new_with_justify(JustifyText::Center), )); parent.spawn(( SyncResource::::default(), Text::default(), TextColor(BLACK.into()), TextLayout::new_with_justify(JustifyText::Center), )); parent.spawn(( SyncResource::::default(), Text::default(), TextColor(BLACK.into()), TextLayout::new_with_justify(JustifyText::Center), )); parent.spawn(( SyncResource::::default(), Text::default(), TextColor(BLACK.into()), TextLayout::new_with_justify(JustifyText::Center), )); parent.spawn(( SyncResource::::default(), Text::default(), TextColor(BLACK.into()), TextLayout::new_with_justify(JustifyText::Center), )); parent.spawn(( SyncResource::::default(), Text::default(), TextColor(BLACK.into()), TextLayout::new_with_justify(JustifyText::Center), )); parent.spawn(( Text::new("Press R to Rewind"), TextColor(BLACK.into()), TextLayout::new_with_justify(JustifyText::Center), )); parent .spawn((Node { flex_direction: FlexDirection::Row, justify_content: JustifyContent::SpaceEvenly, ..default() },)) .with_children(|parent| { fn show_credits( _trigger: Trigger>, mut state: ResMut>, ) { state.set(PlayerState::Credits); } parent .spawn(( Button, BorderRadius::all(Val::Px(5.0)), BorderColor(BLACK.into()), Node { ..default() }, children![(TextColor(BLACK.into()), Text::new("Credits")),], )) .observe(show_credits); #[cfg(not(target_arch = "wasm32"))] { fn quit_game( _trigger: Trigger>, mut exit: EventWriter, ) { warn!("Quitting game"); exit.write(AppExit::Success); } parent .spawn(( BorderRadius::all(Val::Px(5.0)), BorderColor(BLACK.into()), Button, Node { ..default() }, children![(Text::new("Quit"), TextColor(BLACK.into()))], )) .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() }, BorderRadius::all(Val::Px(5.0)), BackgroundColor(WHITE.into()), BorderColor(BLACK.into()), PlayerState::Credits, children![( Text::new(credits_str), TextColor(BLACK.into()), TextLayout::new_with_justify(JustifyText::Center) )], )) .with_children(|parent| { parent.spawn(( Node { align_self: AlignSelf::Center, ..default() }, BorderRadius::all(Val::Px(5.0)), BorderColor(BLACK.into()), Button, children![(Text::new("Close"), TextColor(BLACK.into()))], )); }) .observe(hide_credits); let logo = server.load_with_settings("flappy/logo.png", |settings: &mut ImageLoaderSettings| { // Need to use nearest filtering to avoid bleeding between the slices with tiling settings.sampler = ImageSampler::nearest(); }); commands.spawn(( Node { align_self: AlignSelf::Center, justify_self: JustifySelf::Center, flex_direction: FlexDirection::Column, align_items: AlignItems::Center, justify_items: JustifyItems::Center, ..default() }, BackgroundColor(Color::NONE), PlayerState::Pause, children![ ( ImageNode { image: logo, ..default() }, Node { width: Val::Px(256.0), ..default() } ), ( Node { margin: UiRect::all(Val::Px(5.0)), ..default() }, BorderRadius::all(Val::Px(5.0)), BorderColor(BLACK.into()), Text::new("(paused)"), TextColor(BLACK.into()), ) ], )); commands .spawn(( Node { align_self: AlignSelf::End, justify_self: JustifySelf::Center, flex_direction: FlexDirection::Row, justify_content: JustifyContent::SpaceEvenly, width: Val::Percent(100.0), min_height: Val::Percent(10.0), ..default() }, )) .with_children(|parent| { let rewind_image = server.load_with_settings( "flappy/rewind.png", |settings: &mut ImageLoaderSettings| { // Need to use nearest filtering to avoid bleeding between the slices with tiling settings.sampler = ImageSampler::nearest(); }, ); parent .spawn(( Node { width: Val::Percent(50.0), align_items: AlignItems::Center, justify_content: JustifyContent::Center, ..default() }, BorderRadius::all(Val::Px(5.0)), BorderColor(BLACK.into()), BackgroundColor(Color::WHITE.with_alpha(0.9)), Button, RewindButton, children![ ( ImageNode { color: BLACK.into(), image: rewind_image, ..default() }, Node { height: Val::Px(50.0), ..default() }, ), ( Text::new("Rewind!\n(Hold R)"), TextColor(BLACK.into()), TextFont::from_font_size(30.0), TextLayout::new_with_justify(JustifyText::Center) ), ], )) .observe(start_rewind) .observe(end_rewind); let play_image = server.load_with_settings( "flappy/play.png", |settings: &mut ImageLoaderSettings| { // Need to use nearest filtering to avoid bleeding between the slices with tiling settings.sampler = ImageSampler::nearest(); }, ); parent .spawn(( Node { width: Val::Percent(50.0), align_items: AlignItems::Center, justify_content: JustifyContent::Center, ..default() }, BorderRadius::all(Val::Px(5.0)), BorderColor(BLACK.into()), BackgroundColor(Color::WHITE.with_alpha(0.9)), Button, FlapButton, children![ ( Text::new("Flap!\n(Spacebar)"), TextColor(BLACK.into()), TextFont::from_font_size(30.0), TextLayout::new_with_justify(JustifyText::Center) ), ( ImageNode { color: BLACK.into(), image: play_image, ..default() }, Node { height: Val::Px(50.0), ..default() }, ), ], )) .observe(flap_button); }); commands.spawn(( Node { align_self: AlignSelf::Start, justify_self: JustifySelf::Center, ..default() }, BorderRadius::all(Val::Px(5.0)), BackgroundColor(Color::WHITE), BorderColor(BLACK.into()), children![( SyncResource::::default(), Text::default(), TextColor(BLACK.into()), TextLayout::new_with_justify(JustifyText::Center), )], )); } fn init_background( mut commands: Commands, server: Res, mut meshes: ResMut>, mut materials: ResMut>, ) { { let material = MeshMaterial2d(materials.add(ColorMaterial { texture: Some(server.load("flappy/background-city.png")), color: LIGHT_GREY.with_alpha(0.8).into(), alpha_mode: AlphaMode2d::Blend, ..default() })); let mesh = Mesh2d(meshes.add(Rectangle::new(1.0, 1.0))); { let t = Transform::from_xyz(-325.0, 0.0, -32.0).with_scale(Vec3::splat(650.0)); commands.spawn((ParallaxDepth(24.0), mesh.clone(), material.clone(), t)); } { let t = Transform::from_xyz(325.0, 0.0, -32.0).with_scale(Vec3::splat(650.0)); commands.spawn((ParallaxDepth(24.0), mesh.clone(), material.clone(), t)); } } { let material = MeshMaterial2d(materials.add(ColorMaterial { texture: Some(server.load("flappy/background-clouds.png")), color: WHITE_SMOKE.with_alpha(0.8).into(), alpha_mode: AlphaMode2d::Blend, ..default() })); let mesh = Mesh2d(meshes.add(Rectangle::new(1.0, 1.0))); { let t = Transform::from_xyz(-325.0, 0.0, -64.0).with_scale(Vec3::splat(650.0)); commands.spawn((ParallaxDepth(32.0), mesh.clone(), material.clone(), t)); } { let t = Transform::from_xyz(325.0, 0.0, -64.0).with_scale(Vec3::splat(650.0)); commands.spawn((ParallaxDepth(32.0), mesh.clone(), material.clone(), t)); } } } 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>, curr: Res>, mut next: ResMut>, ) { if !matches!(curr.get(), PlayerState::Alive) { next.set(PlayerState::Alive); } 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< ( &LinearVelocity, &AngularVelocity, &ExternalImpulse, &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(|(lv, av, ei, p, r, mut tape)| { tape.push(*lv, *av, *ei, *p, *r); }); } fn rewind( #[cfg(debug_assertions)] state: Res>, mut next: ResMut>, mut birds: Query< ( &mut LinearVelocity, &mut AngularVelocity, &mut ExternalImpulse, &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 lv, mut av, mut ei, mut p, mut r, mut tape)| { if let Some((new_lv, new_av, new_ei, new_p, new_r)) = tape.pop() { lv.0 = new_lv.0; av.0 = new_av.0; ei.set_impulse(new_ei.impulse()); p.0 = new_p.0; *r = new_r; frames.0 += 1; } else { next.set(PlayerState::Pause); } }); } // 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, 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 the longest run a player has done /// Store the latest run and upsert latest run to the longest run if it is longer #[derive(Resource, Default)] struct LongestRun { /// Longest run start start: Option, /// Longest run end end: Option, /// Latest run start latest_start: Option, /// Latest run start latest_end: Option, } impl Display for LongestRun { fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result { match (self.start, self.end) { (Some(a), Some(b)) => { if b > a { writeln!(f, "Longest Run: {:.2?}", b - a) } else { writeln!(f, "Longest Run: ???") } } _ => writeln!(f, "Longest Run: ???"), } } } fn start_run(mut longest_run: ResMut) { longest_run.latest_start = Some(Instant::now()); longest_run.latest_end = None; } fn end_run(mut longest_run: ResMut) { longest_run.latest_end = Some(Instant::now()); match (longest_run.start, longest_run.end) { // Longest run hasn't been set yet, so set it to the latest run (None, None) => { longest_run.start = longest_run.latest_start; longest_run.end = longest_run.latest_end; } // Longest run was previously set (Some(start), Some(end)) => { // Check if latest run is longer than current longest run if longest_run.latest_end.unwrap() - longest_run.latest_start.unwrap() > end - start { longest_run.start = longest_run.latest_start; longest_run.end = longest_run.latest_end; } } _ => panic!("What?"), } } // 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) { debug!("[Rewind] Setting score to {this}"); score.0 = this.saturating_sub(1); } else if let Ok(Batch(this)) = hitboxes.get(*b) { debug!("[Rewind] Setting score to {this}"); score.0 = this.saturating_sub(1); } }) } _ => { end.read().for_each(|CollisionEnded(a, b)| { // Set score to collided hitbox if let Ok(Batch(this)) = hitboxes.get(*b) { debug!("[Alive] Setting score to {this}"); score.0 = *this; } else if let Ok(Batch(this)) = hitboxes.get(*a) { debug!("[Alive] Setting score to {this}"); score.0 = *this; } }) } } } /// WARNING: This method is somewhat janky /// /// We first figure out which collided entity was a hitbox /// and bial out early if neither is a hitbox /// Then we find the batch ID for the hitbox that was hit /// Next we figure out the old -> new batch IDs based on offsets from the current batch /// skipping the 0th batch intentionally as that is a special case /// Finally we iterate over all entities with the old batch ID and upsert the new batch ID /// This includes root batch entities as well as pipes and hitboxes fn move_batches( mut start: EventReader, mut end: EventReader, hitboxes: Query>, batches: Query<(Entity, &Batch)>, state: Res>, mut commands: Commands, ) { let s = start.read().map(|CollisionStarted(a, b)| (a, b)); let e = end.read().map(|CollisionEnded(a, b)| (a, b)); let c = s.chain(e); c.for_each(|(a, b)| { debug!("[batches] Collision {a} -> {b}"); let target = { if hitboxes.contains(*b) { *b } else if hitboxes.contains(*a) { *a } else { return; } }; let (_, Batch(curr)) = batches.get(target).unwrap(); debug!("[batches] Current: {curr}"); 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), }; if old_batch > 0 && new_batch != 0 { batches .iter() // Filter to just entities with this batch ID .filter_map(|(e, Batch(id))| (*id == old_batch).then_some(e)) .for_each(|old| { debug!("Moving batch {old_batch}({old}) -> {new_batch}"); commands.entity(old).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:?}")); }) }); } fn debug_trail( mut physics_objects: Query<(Entity, &Transform), With>, mut gizmos: Gizmos, mut positions: Local>>, ) { physics_objects.iter_mut().for_each(|(e, obj)| { if let Some(list) = positions.get_mut(&e) { list.push_front(obj.translation); if list.len() > 60 { list.pop_back(); } } else { positions.insert(e, vec![obj.translation].into()); } positions.iter().for_each(|(_e, list)| { list.iter().for_each(|v| { gizmos.cross_2d(Vec2::new(v.x, v.y), 12., FUCHSIA); }); }); }) } fn shimmer_button(mut bg: Single<&mut BackgroundColor, With>, time: Res