# Objective Continue improving the user experience of our UI Node API in the direction specified by [Bevy's Next Generation Scene / UI System](https://github.com/bevyengine/bevy/discussions/14437) ## Solution As specified in the document above, merge `Style` fields into `Node`, and move "computed Node fields" into `ComputedNode` (I chose this name over something like `ComputedNodeLayout` because it currently contains more than just layout info. If we want to break this up / rename these concepts, lets do that in a separate PR). `Style` has been removed. This accomplishes a number of goals: ## Ergonomics wins Specifying both `Node` and `Style` is now no longer required for non-default styles Before: ```rust commands.spawn(( Node::default(), Style { width: Val::Px(100.), ..default() }, )); ``` After: ```rust commands.spawn(Node { width: Val::Px(100.), ..default() }); ``` ## Conceptual clarity `Style` was never a comprehensive "style sheet". It only defined "core" style properties that all `Nodes` shared. Any "styled property" that couldn't fit that mold had to be in a separate component. A "real" style system would style properties _across_ components (`Node`, `Button`, etc). We have plans to build a true style system (see the doc linked above). By moving the `Style` fields to `Node`, we fully embrace `Node` as the driving concept and remove the "style system" confusion. ## Next Steps * Consider identifying and splitting out "style properties that aren't core to Node". This should not happen for Bevy 0.15. --- ## Migration Guide Move any fields set on `Style` into `Node` and replace all `Style` component usage with `Node`. Before: ```rust commands.spawn(( Node::default(), Style { width: Val::Px(100.), ..default() }, )); ``` After: ```rust commands.spawn(Node { width: Val::Px(100.), ..default() }); ``` For any usage of the "computed node properties" that used to live on `Node`, use `ComputedNode` instead: Before: ```rust fn system(nodes: Query<&Node>) { for node in &nodes { let computed_size = node.size(); } } ``` After: ```rust fn system(computed_nodes: Query<&ComputedNode>) { for computed_node in &computed_nodes { let computed_size = computed_node.size(); } } ```
		
			
				
	
	
		
			372 lines
		
	
	
		
			11 KiB
		
	
	
	
		
			Rust
		
	
	
	
	
	
			
		
		
	
	
			372 lines
		
	
	
		
			11 KiB
		
	
	
	
		
			Rust
		
	
	
	
	
	
//! This example displays each contributor to the bevy source code as a bouncing bevy-ball.
 | 
						|
 | 
						|
use bevy::{math::bounding::Aabb2d, prelude::*, utils::HashMap};
 | 
						|
use rand::{Rng, SeedableRng};
 | 
						|
use rand_chacha::ChaCha8Rng;
 | 
						|
use std::{
 | 
						|
    env::VarError,
 | 
						|
    hash::{DefaultHasher, Hash, Hasher},
 | 
						|
    io::{self, BufRead, BufReader},
 | 
						|
    process::Stdio,
 | 
						|
};
 | 
						|
 | 
						|
fn main() {
 | 
						|
    App::new()
 | 
						|
        .add_plugins(DefaultPlugins)
 | 
						|
        .init_resource::<SelectionTimer>()
 | 
						|
        .init_resource::<SharedRng>()
 | 
						|
        .add_systems(Startup, (setup_contributor_selection, setup))
 | 
						|
        // Systems are chained for determinism only
 | 
						|
        .add_systems(Update, (gravity, movement, collisions, selection).chain())
 | 
						|
        .run();
 | 
						|
}
 | 
						|
 | 
						|
type Contributors = Vec<(String, usize)>;
 | 
						|
 | 
						|
#[derive(Resource)]
 | 
						|
struct ContributorSelection {
 | 
						|
    order: Vec<Entity>,
 | 
						|
    idx: usize,
 | 
						|
}
 | 
						|
 | 
						|
#[derive(Resource)]
 | 
						|
struct SelectionTimer(Timer);
 | 
						|
 | 
						|
impl Default for SelectionTimer {
 | 
						|
    fn default() -> Self {
 | 
						|
        Self(Timer::from_seconds(
 | 
						|
            SHOWCASE_TIMER_SECS,
 | 
						|
            TimerMode::Repeating,
 | 
						|
        ))
 | 
						|
    }
 | 
						|
}
 | 
						|
 | 
						|
#[derive(Component)]
 | 
						|
struct ContributorDisplay;
 | 
						|
 | 
						|
#[derive(Component)]
 | 
						|
struct Contributor {
 | 
						|
    name: String,
 | 
						|
    num_commits: usize,
 | 
						|
    hue: f32,
 | 
						|
}
 | 
						|
 | 
						|
#[derive(Component)]
 | 
						|
struct Velocity {
 | 
						|
    translation: Vec3,
 | 
						|
    rotation: f32,
 | 
						|
}
 | 
						|
 | 
						|
// We're using a shared seeded RNG here to make this example deterministic for testing purposes.
 | 
						|
// This isn't strictly required in practical use unless you need your app to be deterministic.
 | 
						|
#[derive(Resource, Deref, DerefMut)]
 | 
						|
struct SharedRng(ChaCha8Rng);
 | 
						|
impl Default for SharedRng {
 | 
						|
    fn default() -> Self {
 | 
						|
        Self(ChaCha8Rng::seed_from_u64(10223163112))
 | 
						|
    }
 | 
						|
}
 | 
						|
 | 
						|
const GRAVITY: f32 = 9.821 * 100.0;
 | 
						|
const SPRITE_SIZE: f32 = 75.0;
 | 
						|
 | 
						|
const SELECTED: Hsla = Hsla::hsl(0.0, 0.9, 0.7);
 | 
						|
const DESELECTED: Hsla = Hsla::new(0.0, 0.3, 0.2, 0.92);
 | 
						|
 | 
						|
const SELECTED_Z_OFFSET: f32 = 100.0;
 | 
						|
 | 
						|
const SHOWCASE_TIMER_SECS: f32 = 3.0;
 | 
						|
 | 
						|
const CONTRIBUTORS_LIST: &[&str] = &["Carter Anderson", "And Many More"];
 | 
						|
 | 
						|
fn setup_contributor_selection(
 | 
						|
    mut commands: Commands,
 | 
						|
    asset_server: Res<AssetServer>,
 | 
						|
    mut rng: ResMut<SharedRng>,
 | 
						|
) {
 | 
						|
    let contribs = contributors_or_fallback();
 | 
						|
 | 
						|
    let texture_handle = asset_server.load("branding/icon.png");
 | 
						|
 | 
						|
    let mut contributor_selection = ContributorSelection {
 | 
						|
        order: Vec::with_capacity(contribs.len()),
 | 
						|
        idx: 0,
 | 
						|
    };
 | 
						|
 | 
						|
    for (name, num_commits) in contribs {
 | 
						|
        let transform = Transform::from_xyz(
 | 
						|
            rng.gen_range(-400.0..400.0),
 | 
						|
            rng.gen_range(0.0..400.0),
 | 
						|
            rng.gen(),
 | 
						|
        );
 | 
						|
        let dir = rng.gen_range(-1.0..1.0);
 | 
						|
        let velocity = Vec3::new(dir * 500.0, 0.0, 0.0);
 | 
						|
        let hue = name_to_hue(&name);
 | 
						|
 | 
						|
        // Some sprites should be flipped for variety
 | 
						|
        let flipped = rng.gen();
 | 
						|
 | 
						|
        let entity = commands
 | 
						|
            .spawn((
 | 
						|
                Contributor {
 | 
						|
                    name,
 | 
						|
                    num_commits,
 | 
						|
                    hue,
 | 
						|
                },
 | 
						|
                Velocity {
 | 
						|
                    translation: velocity,
 | 
						|
                    rotation: -dir * 5.0,
 | 
						|
                },
 | 
						|
                Sprite {
 | 
						|
                    image: texture_handle.clone(),
 | 
						|
                    custom_size: Some(Vec2::splat(SPRITE_SIZE)),
 | 
						|
                    color: DESELECTED.with_hue(hue).into(),
 | 
						|
                    flip_x: flipped,
 | 
						|
                    ..default()
 | 
						|
                },
 | 
						|
                transform,
 | 
						|
            ))
 | 
						|
            .id();
 | 
						|
 | 
						|
        contributor_selection.order.push(entity);
 | 
						|
    }
 | 
						|
 | 
						|
    commands.insert_resource(contributor_selection);
 | 
						|
}
 | 
						|
 | 
						|
fn setup(mut commands: Commands, asset_server: Res<AssetServer>) {
 | 
						|
    commands.spawn(Camera2d);
 | 
						|
 | 
						|
    let text_style = TextFont {
 | 
						|
        font: asset_server.load("fonts/FiraSans-Bold.ttf"),
 | 
						|
        font_size: 60.0,
 | 
						|
        ..default()
 | 
						|
    };
 | 
						|
 | 
						|
    commands
 | 
						|
        .spawn((
 | 
						|
            Text::new("Contributor showcase"),
 | 
						|
            text_style.clone(),
 | 
						|
            ContributorDisplay,
 | 
						|
            Node {
 | 
						|
                position_type: PositionType::Absolute,
 | 
						|
                top: Val::Px(12.),
 | 
						|
                left: Val::Px(12.),
 | 
						|
                ..default()
 | 
						|
            },
 | 
						|
        ))
 | 
						|
        .with_child((
 | 
						|
            TextSpan::default(),
 | 
						|
            TextFont {
 | 
						|
                font_size: 30.,
 | 
						|
                ..text_style
 | 
						|
            },
 | 
						|
        ));
 | 
						|
}
 | 
						|
 | 
						|
/// Finds the next contributor to display and selects the entity
 | 
						|
fn selection(
 | 
						|
    mut timer: ResMut<SelectionTimer>,
 | 
						|
    mut contributor_selection: ResMut<ContributorSelection>,
 | 
						|
    contributor_root: Single<Entity, (With<ContributorDisplay>, With<Text>)>,
 | 
						|
    mut query: Query<(&Contributor, &mut Sprite, &mut Transform)>,
 | 
						|
    mut writer: TextUiWriter,
 | 
						|
    time: Res<Time>,
 | 
						|
) {
 | 
						|
    if !timer.0.tick(time.delta()).just_finished() {
 | 
						|
        return;
 | 
						|
    }
 | 
						|
 | 
						|
    // Deselect the previous contributor
 | 
						|
 | 
						|
    let entity = contributor_selection.order[contributor_selection.idx];
 | 
						|
    if let Ok((contributor, mut sprite, mut transform)) = query.get_mut(entity) {
 | 
						|
        deselect(&mut sprite, contributor, &mut transform);
 | 
						|
    }
 | 
						|
 | 
						|
    // Select the next contributor
 | 
						|
 | 
						|
    if (contributor_selection.idx + 1) < contributor_selection.order.len() {
 | 
						|
        contributor_selection.idx += 1;
 | 
						|
    } else {
 | 
						|
        contributor_selection.idx = 0;
 | 
						|
    }
 | 
						|
 | 
						|
    let entity = contributor_selection.order[contributor_selection.idx];
 | 
						|
 | 
						|
    if let Ok((contributor, mut sprite, mut transform)) = query.get_mut(entity) {
 | 
						|
        let entity = *contributor_root;
 | 
						|
        select(
 | 
						|
            &mut sprite,
 | 
						|
            contributor,
 | 
						|
            &mut transform,
 | 
						|
            entity,
 | 
						|
            &mut writer,
 | 
						|
        );
 | 
						|
    }
 | 
						|
}
 | 
						|
 | 
						|
/// Change the tint color to the "selected" color, bring the object to the front
 | 
						|
/// and display the name.
 | 
						|
fn select(
 | 
						|
    sprite: &mut Sprite,
 | 
						|
    contributor: &Contributor,
 | 
						|
    transform: &mut Transform,
 | 
						|
    entity: Entity,
 | 
						|
    writer: &mut TextUiWriter,
 | 
						|
) {
 | 
						|
    sprite.color = SELECTED.with_hue(contributor.hue).into();
 | 
						|
 | 
						|
    transform.translation.z += SELECTED_Z_OFFSET;
 | 
						|
 | 
						|
    writer.text(entity, 0).clone_from(&contributor.name);
 | 
						|
    *writer.text(entity, 1) = format!(
 | 
						|
        "\n{} commit{}",
 | 
						|
        contributor.num_commits,
 | 
						|
        if contributor.num_commits > 1 { "s" } else { "" }
 | 
						|
    );
 | 
						|
    writer.color(entity, 0).0 = sprite.color;
 | 
						|
}
 | 
						|
 | 
						|
/// Change the tint color to the "deselected" color and push
 | 
						|
/// the object to the back.
 | 
						|
fn deselect(sprite: &mut Sprite, contributor: &Contributor, transform: &mut Transform) {
 | 
						|
    sprite.color = DESELECTED.with_hue(contributor.hue).into();
 | 
						|
 | 
						|
    transform.translation.z -= SELECTED_Z_OFFSET;
 | 
						|
}
 | 
						|
 | 
						|
/// Applies gravity to all entities with a velocity.
 | 
						|
fn gravity(time: Res<Time>, mut velocity_query: Query<&mut Velocity>) {
 | 
						|
    let delta = time.delta_secs();
 | 
						|
 | 
						|
    for mut velocity in &mut velocity_query {
 | 
						|
        velocity.translation.y -= GRAVITY * delta;
 | 
						|
    }
 | 
						|
}
 | 
						|
 | 
						|
/// Checks for collisions of contributor-birbs.
 | 
						|
///
 | 
						|
/// On collision with left-or-right wall it resets the horizontal
 | 
						|
/// velocity. On collision with the ground it applies an upwards
 | 
						|
/// force.
 | 
						|
fn collisions(
 | 
						|
    window: Single<&Window>,
 | 
						|
    mut query: Query<(&mut Velocity, &mut Transform), With<Contributor>>,
 | 
						|
    mut rng: ResMut<SharedRng>,
 | 
						|
) {
 | 
						|
    let window_size = window.size();
 | 
						|
 | 
						|
    let collision_area = Aabb2d::new(Vec2::ZERO, (window_size - SPRITE_SIZE) / 2.);
 | 
						|
 | 
						|
    // The maximum height the birbs should try to reach is one birb below the top of the window.
 | 
						|
    let max_bounce_height = (window_size.y - SPRITE_SIZE * 2.0).max(0.0);
 | 
						|
    let min_bounce_height = max_bounce_height * 0.4;
 | 
						|
 | 
						|
    for (mut velocity, mut transform) in &mut query {
 | 
						|
        // Clamp the translation to not go out of the bounds
 | 
						|
        if transform.translation.y < collision_area.min.y {
 | 
						|
            transform.translation.y = collision_area.min.y;
 | 
						|
 | 
						|
            // How high this birb will bounce.
 | 
						|
            let bounce_height = rng.gen_range(min_bounce_height..=max_bounce_height);
 | 
						|
 | 
						|
            // Apply the velocity that would bounce the birb up to bounce_height.
 | 
						|
            velocity.translation.y = (bounce_height * GRAVITY * 2.).sqrt();
 | 
						|
        }
 | 
						|
 | 
						|
        // Birbs might hit the ceiling if the window is resized.
 | 
						|
        // If they do, bounce them.
 | 
						|
        if transform.translation.y > collision_area.max.y {
 | 
						|
            transform.translation.y = collision_area.max.y;
 | 
						|
            velocity.translation.y *= -1.0;
 | 
						|
        }
 | 
						|
 | 
						|
        // On side walls flip the horizontal velocity
 | 
						|
        if transform.translation.x < collision_area.min.x {
 | 
						|
            transform.translation.x = collision_area.min.x;
 | 
						|
            velocity.translation.x *= -1.0;
 | 
						|
            velocity.rotation *= -1.0;
 | 
						|
        }
 | 
						|
        if transform.translation.x > collision_area.max.x {
 | 
						|
            transform.translation.x = collision_area.max.x;
 | 
						|
            velocity.translation.x *= -1.0;
 | 
						|
            velocity.rotation *= -1.0;
 | 
						|
        }
 | 
						|
    }
 | 
						|
}
 | 
						|
 | 
						|
/// Apply velocity to positions and rotations.
 | 
						|
fn movement(time: Res<Time>, mut query: Query<(&Velocity, &mut Transform)>) {
 | 
						|
    let delta = time.delta_secs();
 | 
						|
 | 
						|
    for (velocity, mut transform) in &mut query {
 | 
						|
        transform.translation += delta * velocity.translation;
 | 
						|
        transform.rotate_z(velocity.rotation * delta);
 | 
						|
    }
 | 
						|
}
 | 
						|
 | 
						|
#[derive(Debug, thiserror::Error)]
 | 
						|
enum LoadContributorsError {
 | 
						|
    #[error("An IO error occurred while reading the git log.")]
 | 
						|
    Io(#[from] io::Error),
 | 
						|
    #[error("The CARGO_MANIFEST_DIR environment variable was not set.")]
 | 
						|
    Var(#[from] VarError),
 | 
						|
    #[error("The git process did not return a stdout handle.")]
 | 
						|
    Stdout,
 | 
						|
}
 | 
						|
 | 
						|
/// Get the names and commit counts of all contributors from the git log.
 | 
						|
///
 | 
						|
/// This function only works if `git` is installed and
 | 
						|
/// the program is run through `cargo`.
 | 
						|
fn contributors() -> Result<Contributors, LoadContributorsError> {
 | 
						|
    let manifest_dir = std::env::var("CARGO_MANIFEST_DIR")?;
 | 
						|
 | 
						|
    let mut cmd = std::process::Command::new("git")
 | 
						|
        .args(["--no-pager", "log", "--pretty=format:%an"])
 | 
						|
        .current_dir(manifest_dir)
 | 
						|
        .stdout(Stdio::piped())
 | 
						|
        .spawn()?;
 | 
						|
 | 
						|
    let stdout = cmd.stdout.take().ok_or(LoadContributorsError::Stdout)?;
 | 
						|
 | 
						|
    // Take the list of commit author names and collect them into a HashMap,
 | 
						|
    // keeping a count of how many commits they authored.
 | 
						|
    let contributors = BufReader::new(stdout).lines().map_while(Result::ok).fold(
 | 
						|
        HashMap::new(),
 | 
						|
        |mut acc, word| {
 | 
						|
            *acc.entry(word).or_insert(0) += 1;
 | 
						|
            acc
 | 
						|
        },
 | 
						|
    );
 | 
						|
 | 
						|
    Ok(contributors.into_iter().collect())
 | 
						|
}
 | 
						|
 | 
						|
/// Get the contributors list, or fall back to a default value if
 | 
						|
/// it's unavailable or we're in CI
 | 
						|
fn contributors_or_fallback() -> Contributors {
 | 
						|
    let get_default = || {
 | 
						|
        CONTRIBUTORS_LIST
 | 
						|
            .iter()
 | 
						|
            .cycle()
 | 
						|
            .take(1000)
 | 
						|
            .map(|name| (name.to_string(), 1))
 | 
						|
            .collect()
 | 
						|
    };
 | 
						|
 | 
						|
    if cfg!(feature = "bevy_ci_testing") {
 | 
						|
        return get_default();
 | 
						|
    }
 | 
						|
 | 
						|
    contributors().unwrap_or_else(|_| get_default())
 | 
						|
}
 | 
						|
 | 
						|
/// Give each unique contributor name a particular hue that is stable between runs.
 | 
						|
fn name_to_hue(s: &str) -> f32 {
 | 
						|
    let mut hasher = DefaultHasher::new();
 | 
						|
    s.hash(&mut hasher);
 | 
						|
    hasher.finish() as f32 / u64::MAX as f32 * 360.
 | 
						|
}
 |