1use 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 .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#[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 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
168fn 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 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 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
210fn 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
232fn 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
240fn 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
249fn 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 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 if transform.translation.y < collision_area.min.y {
274 transform.translation.y = collision_area.min.y;
275
276 let bounce_height = rng.random_range(min_bounce_height..=max_bounce_height);
278
279 velocity.translation.y = (bounce_height * GRAVITY * 2.).sqrt();
281 }
282
283 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 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
304fn 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
324fn 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 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
352fn 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
371fn 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}