docs/bevy/examples/showcase/contributors.rs 11 K · 377 lines · raw · history

1 //! This example displays each contributor to the bevy source code as a bouncing bevy-ball.
2
3 use bevy::prelude::*;
4
5 use chacha20::ChaCha8Rng;
6 use rand::{RngExt, SeedableRng};
7 use std::{
8 collections::HashMap,
9 env::VarError,
10 hash::{DefaultHasher, Hash, Hasher},
11 io::{self, BufRead, BufReader},
12 process::Stdio,
13 };
14
15 fn 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
26 type Contributors = Vec<(String, usize)>;
27
28 #[derive(Resource)]
29 struct ContributorSelection {
30 order: Vec<Entity>,
31 idx: usize,
32 }
33
34 #[derive(Resource)]
35 struct SelectionTimer(Timer);
36
37 impl 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)]
47 struct ContributorDisplay;
48
49 #[derive(Component)]
50 struct Contributor {
51 name: String,
52 num_commits: usize,
53 hue: f32,
54 }
55
56 #[derive(Component)]
57 struct 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)]
65 struct SharedRng(ChaCha8Rng);
66 impl Default for SharedRng {
67 fn default() -> Self {
68 Self(ChaCha8Rng::seed_from_u64(10223163112))
69 }
70 }
71
72 const GRAVITY: f32 = 9.821 * 100.0;
73 const SPRITE_SIZE: f32 = 75.0;
74
75 const SELECTED: Hsla = Hsla::hsl(0.0, 0.9, 0.7);
76 const DESELECTED: Hsla = Hsla::new(0.0, 0.3, 0.2, 0.92);
77
78 const SELECTED_Z_OFFSET: f32 = 100.0;
79
80 const SHOWCASE_TIMER_SECS: f32 = 3.0;
81
82 const CONTRIBUTORS_LIST: &[&str] = &["Carter Anderson", "And Many More"];
83
84 fn 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
139 fn 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
170 fn 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.
213 fn 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.
235 fn 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.
242 fn 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.
255 fn 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.
306 fn 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)]
316 enum 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`.
329 fn 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
355 fn 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.
373 fn 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 }