contributors/
contributors.rs

1//! This example displays each contributor to the bevy source code as a bouncing bevy-ball.
2
3use bevy::{math::bounding::Aabb2d, prelude::*};
4use rand::{Rng, SeedableRng};
5use rand_chacha::ChaCha8Rng;
6use std::{
7    collections::HashMap,
8    env::VarError,
9    hash::{DefaultHasher, Hash, Hasher},
10    io::{self, BufRead, BufReader},
11    process::Stdio,
12};
13
14fn main() {
15    App::new()
16        .add_plugins(DefaultPlugins)
17        .init_resource::<SelectionTimer>()
18        .init_resource::<SharedRng>()
19        .add_systems(Startup, (setup_contributor_selection, setup))
20        // Systems are chained for determinism only
21        .add_systems(Update, (gravity, movement, collisions, selection).chain())
22        .run();
23}
24
25type Contributors = Vec<(String, usize)>;
26
27#[derive(Resource)]
28struct ContributorSelection {
29    order: Vec<Entity>,
30    idx: usize,
31}
32
33#[derive(Resource)]
34struct SelectionTimer(Timer);
35
36impl Default for SelectionTimer {
37    fn default() -> Self {
38        Self(Timer::from_seconds(
39            SHOWCASE_TIMER_SECS,
40            TimerMode::Repeating,
41        ))
42    }
43}
44
45#[derive(Component)]
46struct ContributorDisplay;
47
48#[derive(Component)]
49struct Contributor {
50    name: String,
51    num_commits: usize,
52    hue: f32,
53}
54
55#[derive(Component)]
56struct Velocity {
57    translation: Vec3,
58    rotation: f32,
59}
60
61// We're using a shared seeded RNG here to make this example deterministic for testing purposes.
62// This isn't strictly required in practical use unless you need your app to be deterministic.
63#[derive(Resource, Deref, DerefMut)]
64struct SharedRng(ChaCha8Rng);
65impl Default for SharedRng {
66    fn default() -> Self {
67        Self(ChaCha8Rng::seed_from_u64(10223163112))
68    }
69}
70
71const GRAVITY: f32 = 9.821 * 100.0;
72const SPRITE_SIZE: f32 = 75.0;
73
74const SELECTED: Hsla = Hsla::hsl(0.0, 0.9, 0.7);
75const DESELECTED: Hsla = Hsla::new(0.0, 0.3, 0.2, 0.92);
76
77const SELECTED_Z_OFFSET: f32 = 100.0;
78
79const SHOWCASE_TIMER_SECS: f32 = 3.0;
80
81const CONTRIBUTORS_LIST: &[&str] = &["Carter Anderson", "And Many More"];
82
83fn setup_contributor_selection(
84    mut commands: Commands,
85    asset_server: Res<AssetServer>,
86    mut rng: ResMut<SharedRng>,
87) {
88    let contribs = contributors_or_fallback();
89
90    let texture_handle = asset_server.load("branding/icon.png");
91
92    let mut contributor_selection = ContributorSelection {
93        order: Vec::with_capacity(contribs.len()),
94        idx: 0,
95    };
96
97    for (name, num_commits) in contribs {
98        let transform = Transform::from_xyz(
99            rng.random_range(-400.0..400.0),
100            rng.random_range(0.0..400.0),
101            rng.random(),
102        );
103        let dir = rng.random_range(-1.0..1.0);
104        let velocity = Vec3::new(dir * 500.0, 0.0, 0.0);
105        let hue = name_to_hue(&name);
106
107        // Some sprites should be flipped for variety
108        let flipped = rng.random();
109
110        let entity = commands
111            .spawn((
112                Contributor {
113                    name,
114                    num_commits,
115                    hue,
116                },
117                Velocity {
118                    translation: velocity,
119                    rotation: -dir * 5.0,
120                },
121                Sprite {
122                    image: texture_handle.clone(),
123                    custom_size: Some(Vec2::splat(SPRITE_SIZE)),
124                    color: DESELECTED.with_hue(hue).into(),
125                    flip_x: flipped,
126                    ..default()
127                },
128                transform,
129            ))
130            .id();
131
132        contributor_selection.order.push(entity);
133    }
134
135    commands.insert_resource(contributor_selection);
136}
137
138fn setup(mut commands: Commands, asset_server: Res<AssetServer>) {
139    commands.spawn(Camera2d);
140
141    let text_style = TextFont {
142        font: asset_server.load("fonts/FiraSans-Bold.ttf"),
143        font_size: 60.0,
144        ..default()
145    };
146
147    commands
148        .spawn((
149            Text::new("Contributor showcase"),
150            text_style.clone(),
151            ContributorDisplay,
152            Node {
153                position_type: PositionType::Absolute,
154                top: px(12),
155                left: px(12),
156                ..default()
157            },
158        ))
159        .with_child((
160            TextSpan::default(),
161            TextFont {
162                font_size: 30.,
163                ..text_style
164            },
165        ));
166}
167
168/// Finds the next contributor to display and selects the entity
169fn selection(
170    mut timer: ResMut<SelectionTimer>,
171    mut contributor_selection: ResMut<ContributorSelection>,
172    contributor_root: Single<Entity, (With<ContributorDisplay>, With<Text>)>,
173    mut query: Query<(&Contributor, &mut Sprite, &mut Transform)>,
174    mut writer: TextUiWriter,
175    time: Res<Time>,
176) {
177    if !timer.0.tick(time.delta()).just_finished() {
178        return;
179    }
180
181    // Deselect the previous contributor
182
183    let entity = contributor_selection.order[contributor_selection.idx];
184    if let Ok((contributor, mut sprite, mut transform)) = query.get_mut(entity) {
185        deselect(&mut sprite, contributor, &mut transform);
186    }
187
188    // Select the next contributor
189
190    if (contributor_selection.idx + 1) < contributor_selection.order.len() {
191        contributor_selection.idx += 1;
192    } else {
193        contributor_selection.idx = 0;
194    }
195
196    let entity = contributor_selection.order[contributor_selection.idx];
197
198    if let Ok((contributor, mut sprite, mut transform)) = query.get_mut(entity) {
199        let entity = *contributor_root;
200        select(
201            &mut sprite,
202            contributor,
203            &mut transform,
204            entity,
205            &mut writer,
206        );
207    }
208}
209
210/// Change the tint color to the "selected" color, bring the object to the front
211/// and display the name.
212fn select(
213    sprite: &mut Sprite,
214    contributor: &Contributor,
215    transform: &mut Transform,
216    entity: Entity,
217    writer: &mut TextUiWriter,
218) {
219    sprite.color = SELECTED.with_hue(contributor.hue).into();
220
221    transform.translation.z += SELECTED_Z_OFFSET;
222
223    writer.text(entity, 0).clone_from(&contributor.name);
224    *writer.text(entity, 1) = format!(
225        "\n{} commit{}",
226        contributor.num_commits,
227        if contributor.num_commits > 1 { "s" } else { "" }
228    );
229    writer.color(entity, 0).0 = sprite.color;
230}
231
232/// Change the tint color to the "deselected" color and push
233/// the object to the back.
234fn deselect(sprite: &mut Sprite, contributor: &Contributor, transform: &mut Transform) {
235    sprite.color = DESELECTED.with_hue(contributor.hue).into();
236
237    transform.translation.z -= SELECTED_Z_OFFSET;
238}
239
240/// Applies gravity to all entities with a velocity.
241fn gravity(time: Res<Time>, mut velocity_query: Query<&mut Velocity>) {
242    let delta = time.delta_secs();
243
244    for mut velocity in &mut velocity_query {
245        velocity.translation.y -= GRAVITY * delta;
246    }
247}
248
249/// Checks for collisions of contributor-birbs.
250///
251/// On collision with left-or-right wall it resets the horizontal
252/// velocity. On collision with the ground it applies an upwards
253/// force.
254fn collisions(
255    window: Query<&Window>,
256    mut query: Query<(&mut Velocity, &mut Transform), With<Contributor>>,
257    mut rng: ResMut<SharedRng>,
258) {
259    let Ok(window) = window.single() else {
260        return;
261    };
262
263    let window_size = window.size();
264
265    let collision_area = Aabb2d::new(Vec2::ZERO, (window_size - SPRITE_SIZE) / 2.);
266
267    // The maximum height the birbs should try to reach is one birb below the top of the window.
268    let max_bounce_height = (window_size.y - SPRITE_SIZE * 2.0).max(0.0);
269    let min_bounce_height = max_bounce_height * 0.4;
270
271    for (mut velocity, mut transform) in &mut query {
272        // Clamp the translation to not go out of the bounds
273        if transform.translation.y < collision_area.min.y {
274            transform.translation.y = collision_area.min.y;
275
276            // How high this birb will bounce.
277            let bounce_height = rng.random_range(min_bounce_height..=max_bounce_height);
278
279            // Apply the velocity that would bounce the birb up to bounce_height.
280            velocity.translation.y = (bounce_height * GRAVITY * 2.).sqrt();
281        }
282
283        // Birbs might hit the ceiling if the window is resized.
284        // If they do, bounce them.
285        if transform.translation.y > collision_area.max.y {
286            transform.translation.y = collision_area.max.y;
287            velocity.translation.y *= -1.0;
288        }
289
290        // On side walls flip the horizontal velocity
291        if transform.translation.x < collision_area.min.x {
292            transform.translation.x = collision_area.min.x;
293            velocity.translation.x *= -1.0;
294            velocity.rotation *= -1.0;
295        }
296        if transform.translation.x > collision_area.max.x {
297            transform.translation.x = collision_area.max.x;
298            velocity.translation.x *= -1.0;
299            velocity.rotation *= -1.0;
300        }
301    }
302}
303
304/// Apply velocity to positions and rotations.
305fn movement(time: Res<Time>, mut query: Query<(&Velocity, &mut Transform)>) {
306    let delta = time.delta_secs();
307
308    for (velocity, mut transform) in &mut query {
309        transform.translation += delta * velocity.translation;
310        transform.rotate_z(velocity.rotation * delta);
311    }
312}
313
314#[derive(Debug, thiserror::Error)]
315enum LoadContributorsError {
316    #[error("An IO error occurred while reading the git log.")]
317    Io(#[from] io::Error),
318    #[error("The CARGO_MANIFEST_DIR environment variable was not set.")]
319    Var(#[from] VarError),
320    #[error("The git process did not return a stdout handle.")]
321    Stdout,
322}
323
324/// Get the names and commit counts of all contributors from the git log.
325///
326/// This function only works if `git` is installed and
327/// the program is run through `cargo`.
328fn contributors() -> Result<Contributors, LoadContributorsError> {
329    let manifest_dir = std::env::var("CARGO_MANIFEST_DIR")?;
330
331    let mut cmd = std::process::Command::new("git")
332        .args(["--no-pager", "log", "--pretty=format:%an"])
333        .current_dir(manifest_dir)
334        .stdout(Stdio::piped())
335        .spawn()?;
336
337    let stdout = cmd.stdout.take().ok_or(LoadContributorsError::Stdout)?;
338
339    // Take the list of commit author names and collect them into a HashMap,
340    // keeping a count of how many commits they authored.
341    let contributors = BufReader::new(stdout).lines().map_while(Result::ok).fold(
342        HashMap::new(),
343        |mut acc, word| {
344            *acc.entry(word).or_insert(0) += 1;
345            acc
346        },
347    );
348
349    Ok(contributors.into_iter().collect())
350}
351
352/// Get the contributors list, or fall back to a default value if
353/// it's unavailable or we're in CI
354fn contributors_or_fallback() -> Contributors {
355    let get_default = || {
356        CONTRIBUTORS_LIST
357            .iter()
358            .cycle()
359            .take(1000)
360            .map(|name| (name.to_string(), 1))
361            .collect()
362    };
363
364    if cfg!(feature = "bevy_ci_testing") {
365        return get_default();
366    }
367
368    contributors().unwrap_or_else(|_| get_default())
369}
370
371/// Give each unique contributor name a particular hue that is stable between runs.
372fn name_to_hue(s: &str) -> f32 {
373    let mut hasher = DefaultHasher::new();
374    s.hash(&mut hasher);
375    hasher.finish() as f32 / u64::MAX as f32 * 360.
376}