1use 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 .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#[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 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
169fn 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 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 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
211fn 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
233fn 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
241fn 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
250fn 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 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 if transform.translation.y < collision_area.min.y {
275 transform.translation.y = collision_area.min.y;
276
277 let bounce_height = rng.random_range(min_bounce_height..=max_bounce_height);
279
280 velocity.translation.y = (bounce_height * GRAVITY * 2.).sqrt();
282 }
283
284 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 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
305fn 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
325fn 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 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
353fn 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
372fn 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}