You cannot select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
1288 lines
42 KiB
Rust
1288 lines
42 KiB
Rust
// 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::<BirdAssets>()
|
|
.init_resource::<PipeAssets>()
|
|
.init_resource::<GroundAssets>()
|
|
.init_resource::<CeilingAssets>()
|
|
.init_resource::<Score>()
|
|
.init_resource::<RewindFrames>()
|
|
.init_resource::<Flaps>()
|
|
.init_resource::<Deaths>()
|
|
.init_resource::<LongestRun>()
|
|
.init_state::<PlayerState>()
|
|
.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::<FlapButton>),
|
|
)
|
|
.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::<RewindButton>)
|
|
.add_systems(
|
|
Update,
|
|
(
|
|
// Systems to run when player state changes
|
|
(
|
|
// Print out when this state changes for debugging purposes
|
|
debug_state_changes::<PlayerState>,
|
|
// Toggle (UI) elements when the player dies/alives
|
|
toggle_state_visibility::<PlayerState>,
|
|
)
|
|
.run_if(state_changed::<PlayerState>),
|
|
// 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::<Transform>),
|
|
)
|
|
.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::<Transform>),
|
|
// 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::<Score>.run_if(resource_changed::<Score>),
|
|
sync_resource_to_ui::<LongestRun>.run_if(resource_changed::<LongestRun>),
|
|
sync_resource_to_ui::<Deaths>.run_if(resource_changed::<Deaths>),
|
|
sync_resource_to_ui::<Flaps>.run_if(resource_changed::<Flaps>),
|
|
sync_resource_to_ui::<RewindFrames>.run_if(resource_changed::<RewindFrames>),
|
|
),
|
|
(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::<Batch>),
|
|
move_batches.run_if(on_event::<CollisionStarted>.or(on_event::<CollisionEnded>)),
|
|
manage_score.run_if(on_event::<CollisionStarted>.or(on_event::<CollisionEnded>)),
|
|
shimmer_button::<RewindButton>.run_if(in_state(PlayerState::Stasis)),
|
|
shimmer_button::<FlapButton>.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<LinearVelocity>,
|
|
angular_velocities: VecDeque<AngularVelocity>,
|
|
external_impulses: VecDeque<ExternalImpulse>,
|
|
positions: VecDeque<Position>,
|
|
rotations: VecDeque<Rotation>,
|
|
}
|
|
|
|
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<BirdAssets>) {
|
|
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<OnAdd, Batch>,
|
|
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<Batch>, Without<ChildOf>)>,
|
|
) {
|
|
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<OnAdd, Ground>,
|
|
grounds: Query<&Ground>,
|
|
ground_assets: Res<GroundAssets>,
|
|
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<OnAdd, Ceiling>,
|
|
ceiling: Query<&Ceiling>,
|
|
ceiling_assets: Res<CeilingAssets>,
|
|
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<OnInsert, Batch>,
|
|
mut pipes: Query<(&Batch, &Pipe, &mut Transform)>,
|
|
rand: Res<Rand>,
|
|
) {
|
|
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<OnAdd, Pipe>,
|
|
pipes: Query<(&Batch, &Pipe)>,
|
|
pipe_assets: Res<PipeAssets>,
|
|
mut commands: Commands,
|
|
rand: Res<Rand>,
|
|
) {
|
|
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<OnAdd, Hitbox>, 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<ColorMaterial>,
|
|
mesh: Mesh2d,
|
|
}
|
|
|
|
#[derive(Resource, Default)]
|
|
struct GroundAssets {
|
|
material: MeshMaterial2d<ColorMaterial>,
|
|
mesh: Mesh2d,
|
|
}
|
|
|
|
#[derive(Resource, Default)]
|
|
struct CeilingAssets {
|
|
material: MeshMaterial2d<ColorMaterial>,
|
|
mesh: Mesh2d,
|
|
}
|
|
|
|
#[derive(Resource, Default)]
|
|
struct PipeAssets {
|
|
material: MeshMaterial2d<ColorMaterial>,
|
|
mesh: Mesh2d,
|
|
}
|
|
|
|
/// Initialize some materials and meshes used by the environment obstacles
|
|
fn init_assets(
|
|
mut meshes: ResMut<Assets<Mesh>>,
|
|
mut materials: ResMut<Assets<ColorMaterial>>,
|
|
mut bird_assets: ResMut<BirdAssets>,
|
|
mut pipe_assets: ResMut<PipeAssets>,
|
|
mut ground_assets: ResMut<GroundAssets>,
|
|
mut ceiling_assets: ResMut<CeilingAssets>,
|
|
server: Res<AssetServer>,
|
|
) {
|
|
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<AssetServer>) {
|
|
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::<Score>::default(),
|
|
Text::default(),
|
|
TextColor(BLACK.into()),
|
|
TextLayout::new_with_justify(JustifyText::Center),
|
|
));
|
|
parent.spawn((
|
|
SyncResource::<LongestRun>::default(),
|
|
Text::default(),
|
|
TextColor(BLACK.into()),
|
|
TextLayout::new_with_justify(JustifyText::Center),
|
|
));
|
|
parent.spawn((
|
|
SyncResource::<Deaths>::default(),
|
|
Text::default(),
|
|
TextColor(BLACK.into()),
|
|
TextLayout::new_with_justify(JustifyText::Center),
|
|
));
|
|
parent.spawn((
|
|
SyncResource::<Flaps>::default(),
|
|
Text::default(),
|
|
TextColor(BLACK.into()),
|
|
TextLayout::new_with_justify(JustifyText::Center),
|
|
));
|
|
parent.spawn((
|
|
SyncResource::<RewindFrames>::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<Pointer<Click>>,
|
|
mut state: ResMut<NextState<PlayerState>>,
|
|
) {
|
|
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<Pointer<Click>>,
|
|
mut exit: EventWriter<AppExit>,
|
|
) {
|
|
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<Pointer<Click>>, mut state: ResMut<NextState<PlayerState>>) {
|
|
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::<Score>::default(),
|
|
Text::default(),
|
|
TextColor(BLACK.into()),
|
|
TextLayout::new_with_justify(JustifyText::Center),
|
|
)],
|
|
));
|
|
}
|
|
|
|
fn init_background(
|
|
mut commands: Commands,
|
|
server: Res<AssetServer>,
|
|
mut meshes: ResMut<Assets<Mesh>>,
|
|
mut materials: ResMut<Assets<ColorMaterial>>,
|
|
) {
|
|
{
|
|
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<Pointer<Pressed>>, mut next: ResMut<NextState<PlayerState>>) {
|
|
next.set(PlayerState::Rewind);
|
|
}
|
|
|
|
fn end_rewind(_trigger: Trigger<Pointer<Released>>, mut next: ResMut<NextState<PlayerState>>) {
|
|
next.set(PlayerState::Alive);
|
|
}
|
|
|
|
fn flap_button(
|
|
_trigger: Trigger<Pointer<Pressed>>,
|
|
mut commands: Commands,
|
|
bird: Single<Entity, With<Bird>>,
|
|
curr: Res<State<PlayerState>>,
|
|
mut next: ResMut<NextState<PlayerState>>,
|
|
) {
|
|
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<NextState<PlayerState>>) {
|
|
next.set(PlayerState::Pause);
|
|
}
|
|
|
|
fn un_pause_game(mut next: ResMut<NextState<PlayerState>>) {
|
|
next.set(PlayerState::Alive);
|
|
}
|
|
|
|
#[derive(Component, Clone, Event)]
|
|
struct Flap;
|
|
|
|
// Observer for flapping
|
|
fn flap(
|
|
trigger: Trigger<Flap>,
|
|
mut bird: Query<&mut ExternalImpulse, With<Bird>>,
|
|
mut flaps: ResMut<Flaps>,
|
|
) {
|
|
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<State<PlayerState>>,
|
|
#[cfg(debug_assertions)] keycode: Res<ButtonInput<KeyCode>>,
|
|
birds: Query<Entity, With<Bird>>,
|
|
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<ButtonInput<KeyCode>>, mut next: ResMut<NextState<PlayerState>>) {
|
|
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<State<PlayerState>>,
|
|
mut birds: Query<
|
|
(
|
|
&LinearVelocity,
|
|
&AngularVelocity,
|
|
&ExternalImpulse,
|
|
&Position,
|
|
&Rotation,
|
|
&mut Tape,
|
|
),
|
|
With<Bird>,
|
|
>,
|
|
) {
|
|
#[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<State<PlayerState>>,
|
|
mut next: ResMut<NextState<PlayerState>>,
|
|
mut birds: Query<
|
|
(
|
|
&mut LinearVelocity,
|
|
&mut AngularVelocity,
|
|
&mut ExternalImpulse,
|
|
&mut Position,
|
|
&mut Rotation,
|
|
&mut Tape,
|
|
),
|
|
With<Bird>,
|
|
>,
|
|
mut frames: ResMut<RewindFrames>,
|
|
) {
|
|
#[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<State<PlayerState>>,
|
|
bird: Single<&ColliderAabb, With<Bird>>,
|
|
obstacles: Query<&ColliderAabb, Or<(With<Ground>, With<Pipe>, With<Ceiling>)>>,
|
|
mut next: ResMut<NextState<PlayerState>>,
|
|
) {
|
|
#[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<Bird>>) {
|
|
debug!("Setting bird to Dynamic");
|
|
**bird = RigidBody::Dynamic;
|
|
}
|
|
|
|
fn pause_bird(
|
|
state: Res<State<PlayerState>>,
|
|
mut bird: Single<&mut RigidBody, With<Bird>>,
|
|
mut deaths: ResMut<Deaths>,
|
|
) {
|
|
// 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<Bird>, Changed<Transform>)>,
|
|
mut camera: Single<&mut Transform, (With<Camera>, Without<Bird>)>,
|
|
) {
|
|
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<Instant>,
|
|
/// Longest run end
|
|
end: Option<Instant>,
|
|
/// Latest run start
|
|
latest_start: Option<Instant>,
|
|
/// Latest run start
|
|
latest_end: Option<Instant>,
|
|
}
|
|
|
|
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<LongestRun>) {
|
|
longest_run.latest_start = Some(Instant::now());
|
|
longest_run.latest_end = None;
|
|
}
|
|
|
|
fn end_run(mut longest_run: ResMut<LongestRun>) {
|
|
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<CollisionStarted>,
|
|
mut end: EventReader<CollisionEnded>,
|
|
state: Res<State<PlayerState>>,
|
|
hitboxes: Query<&Batch, With<Hitbox>>,
|
|
mut score: ResMut<Score>,
|
|
) {
|
|
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<CollisionStarted>,
|
|
mut end: EventReader<CollisionEnded>,
|
|
hitboxes: Query<Entity, With<Hitbox>>,
|
|
batches: Query<(Entity, &Batch)>,
|
|
state: Res<State<PlayerState>>,
|
|
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<Mass>>,
|
|
mut gizmos: Gizmos,
|
|
mut positions: Local<HashMap<Entity, VecDeque<Vec3>>>,
|
|
) {
|
|
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<T: Component>(mut bg: Single<&mut BackgroundColor, With<T>>, time: Res<Time>) {
|
|
let t = time.elapsed_secs();
|
|
let period = 3.0;
|
|
let red = (((t / period) % 1.0) * std::f32::consts::PI).cos();
|
|
let green = ((((t / period) + 0.3) % 1.0) * std::f32::consts::PI).cos();
|
|
let blue = ((((t / period) + 0.6) % 1.0) * std::f32::consts::PI).cos();
|
|
bg.0 = Srgba { red, green, blue, alpha: bg.0.alpha() }.into();
|
|
}
|
|
|
|
fn reset_button<T: Component>(mut bg: Single<&mut BackgroundColor, With<T>>) {
|
|
bg.0 = WHITE.into();
|
|
}
|