Skip to main content

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