Tainted \\ Coders

Bevy Study: Card Combinator

Last updated:

Card Combinator was a game made by the creator of Bevy during a game jam.

A game about stacking cards on top of each other to make new cards.

Dependencies

[dependencies]
bevy = "0.8"
bevy-inspector-egui = "0.12"
bevy_rapier3d = {version = "0.16", features = ["debug-render"]}

Code Organization

  • src/main.rs -> Main game setup
  • src/game/mod.rs -> The game plugin
  • src/game/card.rs -> Large file with ~1000 loc, contains card plugin
  • src/game/*.rs -> Supporting files
impl Plugin for GamePlugin {
    fn build(&self, app: &mut App) {
        app.add_plugin(CardPlugin)
            .add_plugin(PlayerCameraPlugin)
            .add_plugin(ProgressBarPlugin)
            .add_plugin(TilePlugin)
            .add_startup_system(setup);
    }
}

Nice way of spawning cards:

commands.spawn_bundle(CardBundle {
    transform: Transform::from_xyz(0.5, 0.0, 0.0),
    card: Card::from(CardType::Villager),
    ..default()
});

// Implemented with a From trait

impl From<CardType> for Card {
    fn from(card_type: CardType) -> Self {
        Self {
            info: card_type.into(),
            ..default()
        }
    }
}

impl From<CardType> for CardInfo {
    fn from(card_type: CardType) -> Self {
        let stats = card_type.get_initial_stats();
        Self { card_type, stats }
    }
}

Card plugin:

  • Explicit ordering of most systems
impl Plugin for CardPlugin {
    fn build(&self, app: &mut App) {
        app.init_resource::<SelectedCard>()
            .init_resource::<HoverPoint>()
            .init_resource::<StackRoots>()
            .init_resource::<CardData>()
            .add_system_to_stage(CoreStage::PostUpdate, on_spawn_card)
            .add_system(collide_cards)
            .add_system(
                select_card
                    .after(crate::game::camera::move_camera)
                    .after(collide_cards),
            )
            .add_system(move_cards.after(select_card))
            .add_system(evaluate_stacks.after(move_cards))
            .add_system(handle_enemies.after(evaluate_stacks))
            .add_system(combat.after(handle_enemies))
            .add_system(set_hearts.after(combat));
    }
}

Interestingly lots of explicit entity references through Option<Entity> on cards as well as rendering info like z-index and animations:

#[derive(Component, Default)]
pub struct Card {
    pub animations: Animations,
    pub info: CardInfo,
    pub z: usize,
    pub combat_state: Option<CombatState>,
    pub stack_parent: Option<Entity>,
    pub stack_child: Option<Entity>,
    pub slotted_in_tile: Option<Entity>,
}

Selection was implemented as a enum type:

#[derive(Default, PartialEq, Eq, Copy, Clone)]
pub enum SelectedCard {
    Some(Entity),
    #[default]
    None,
}

Rendering Cards

Card rendering data was stored in a resource:

pub struct CardData {
    mesh: Handle<Mesh>,
    portrait_mesh: Handle<Mesh>,
    heart_mesh: Handle<Mesh>,
    villager_base: Handle<StandardMaterial>,
    resource_base: Handle<StandardMaterial>,
    enemy_base: Handle<StandardMaterial>,
    villager_portrait_base: Handle<StandardMaterial>,
    log_portrait_base: Handle<StandardMaterial>,
    goblin_portrait_base: Handle<StandardMaterial>,
    heart_material: Handle<StandardMaterial>,
    removed_heart_material: Handle<StandardMaterial>,
}

Which implements a FromWorld trait:

impl FromWorld for CardData {
    fn from_world(world: &mut World) -> Self {
        let world = world.cell();
        let mut meshes = world.resource_mut::<Assets<Mesh>>();
        let mut materials = world.resource_mut::<Assets<StandardMaterial>>();
        let asset_server = world.resource::<AssetServer>();
        let card_base_material = StandardMaterial {
            unlit: true,
            alpha_mode: AlphaMode::Blend,
            base_color_texture: Some(asset_server.load("card_base.png")),
            ..default()
        };
        let villager_base = StandardMaterial {
            base_color: Color::rgb(0.4, 0.4, 0.4),
            ..card_base_material.clone()
        };
        let resource_base = StandardMaterial {
            base_color: Color::rgb(0.7, 0.7, 0.4),
            ..card_base_material.clone()
        };
        let enemy_base = StandardMaterial {
            base_color: Color::rgb(0.7, 0.4, 0.4),
            ..card_base_material.clone()
        };
        Self {
            mesh: meshes.add(
                Quad {
                    size: Vec2::new(Card::ASPECT_RATIO, 1.0),
                    ..default()
                }
                .into(),
            ),
            portrait_mesh: meshes.add(
                Quad {
                    size: Vec2::new(Card::ART_ASPECT, 1.0) * 0.65,
                    ..default()
                }
                .into(),
            ),
            heart_mesh: meshes.add(
                Quad {
                    size: Vec2::new(HEART_WIDTH, HEART_HEIGHT),
                    ..default()
                }
                .into(),
            ),
            villager_portrait_base: materials.add(StandardMaterial {
                base_color_texture: Some(asset_server.load("villager.png")),
                ..villager_base.clone()
            }),
            log_portrait_base: materials.add(StandardMaterial {
                base_color_texture: Some(asset_server.load("log.png")),
                ..resource_base.clone()
            }),
            goblin_portrait_base: materials.add(StandardMaterial {
                base_color_texture: Some(asset_server.load("goblin.png")),
                ..enemy_base.clone()
            }),
            heart_material: materials.add(StandardMaterial {
                base_color: Color::rgba_u8(200, 90, 90, 255),
                base_color_texture: Some(asset_server.load("heart.png")),
                unlit: true,
                alpha_mode: AlphaMode::Blend,
                depth_bias: 0.1,
                ..default()
            }),
            removed_heart_material: materials.add(StandardMaterial {
                base_color: Color::rgba(0.1, 0.1, 0.1, 0.5),
                base_color_texture: Some(asset_server.load("heart.png")),
                unlit: true,
                alpha_mode: AlphaMode::Blend,
                depth_bias: 0.1,
                ..default()
            }),
            villager_base: materials.add(villager_base),
            resource_base: materials.add(resource_base),
            enemy_base: materials.add(enemy_base),
        }
    }
}

As well as helpers for the materials:

impl CardData {
    pub fn class_material(&self, card_class: CardClass) -> Handle<StandardMaterial> {
        match card_class {
            CardClass::Villager => self.villager_base.clone(),
            CardClass::Resource => self.resource_base.clone(),
            CardClass::Enemy => self.enemy_base.clone(),
        }
    }
    pub fn portrait_material(&self, card_type: CardType) -> Handle<StandardMaterial> {
        match card_type {
            CardType::Villager { .. } => self.villager_portrait_base.clone(),
            CardType::Log => self.log_portrait_base.clone(),
            CardType::Goblin { .. } => self.goblin_portrait_base.clone(),
        }
    }
}

Rendering concerns happen on a system reacting to Added<Card> events which is a nice separation of concerns:

fn on_spawn_card(
    mut commands: Commands,
    card_data: Res<CardData>,
    cards: Query<(Entity, &Card), Added<Card>>,
) {
    for (entity, card) in &cards {
        commands.entity(entity).with_children(|parent| {
            parent.spawn_bundle(PbrBundle {
                material: card_data.class_material(card.class()),
                mesh: card_data.mesh.clone(),
                ..default()
            });
            parent.spawn_bundle(PbrBundle {
                material: card_data.portrait_material(card.card_type()),
                mesh: card_data.portrait_mesh.clone(),
                transform: Transform::from_xyz(0.0, -0.08, 0.001),
                ..default()
            });
            parent
                .spawn_bundle(SpatialBundle::default())
                .with_children(|parent| {
                    let max = card.info.stats.max_health;
                    let offset = HEART_PANEL_WIDTH / max as f32;
                    let width = (max - 1) as f32 * offset;
                    for i in 0..max {
                        parent.spawn_bundle(PbrBundle {
                            material: card_data.heart_material.clone(),
                            mesh: card_data.heart_mesh.clone(),
                            transform: Transform::from_xyz(
                                i as f32 * offset - width / 2.0,
                                0.37,
                                0.01,
                            ),
                            ..default()
                        });
                    }
                });
        });
    }
}

Selecting a card with the cursor

It was interesting how he calculated the intersection of the cursor with the cards in select_card:

let window = windows.primary();
if let Some(mut cursor) = window.cursor_position() {
    let (camera, camera_transform) = cameras.single();

    let view = camera_transform.compute_matrix();

    let (viewport_min, viewport_max) = camera.logical_viewport_rect().unwrap();
    let screen_size = camera.logical_target_size().unwrap();
    let viewport_size = viewport_max - viewport_min;
    let adj_cursor_pos = cursor - Vec2::new(viewport_min.x, screen_size.y - viewport_max.y);
    let projection = camera.projection_matrix();
    let far_ndc = projection.project_point3(Vec3::NEG_Z).z;
    let near_ndc = projection.project_point3(Vec3::Z).z;
    let cursor_ndc = (adj_cursor_pos / viewport_size) * 2.0 - Vec2::ONE;
    let ndc_to_world: Mat4 = view * projection.inverse();
    let near = ndc_to_world.project_point3(cursor_ndc.extend(near_ndc));
    let far = ndc_to_world.project_point3(cursor_ndc.extend(far_ndc));
    let direction = far - near;
    // ...
}

In the code above, ndc stands for “Normalized Device Coordinates”.

Normalized Device Coordinates are a coordinate system commonly used in computer graphics to represent a normalized space that is independent of the specific resolution or size of the rendering window.

The process of rendering 3D scenes involves transforming 3D coordinates from world space to screen space. The NDC space is an intermediate step in this transformation pipeline. It represents a cube-shaped space with coordinates ranging from -1 to 1 along each axis, where the center of the cube corresponds to the center of the screen or viewport.

  • viewport_min and viewport_max represent the minimum and maximum coordinates of the camera’s logical viewport rectangle.
  • screen_size is the size of the camera’s logical target.
  • viewport_size is the difference between viewport_max and viewport_min.
  • adj_cursor_pos adjusts the cursor position by subtracting the viewport_min from the cursor position.
  • projection retrieves the projection matrix from the camera.
  • far_ndc and near_ndc are the NDC values for the far and near planes of the camera’s frustum.
  • cursor_ndc calculates the normalized device coordinates for the cursor position
  • ndc_to_world is the transformation matrix that converts coordinates from NDC space to world space.
  • near and far represent the near and far points along the mouse ray in world space
  • direction is the vector representing the direction of the mouse ray.

Now, the purpose of the ndc calculations is to determine the intersection point of the mouse ray with the plane in the game world. By transforming the mouse cursor position to NDC, it becomes possible to perform ray-plane intersection calculations.

When we click left mouse button we can check the ray intersection:

let result = context.cast_ray(near, direction, 50.0, true, QueryFilter::new());

if let Some((entity, toi)) = result {
    // ...
}

Camera movement

Moving the camera around with WASD and mousewheel from a fixed persepctive:

pub fn move_camera(
    mut view_height: Local<i8>,
    mut scroll_accumulation: Local<f32>,
    time: Res<Time>,
    input: Res<Input<KeyCode>>,
    mut mouse_wheel_events: EventReader<MouseWheel>,
    mut cameras: Query<(&PlayerCamera, &mut Transform)>,
) {
    for event in mouse_wheel_events.iter() {
        match event.unit {
            bevy::input::mouse::MouseScrollUnit::Line => {
                *scroll_accumulation += 20.0 * event.y.signum()
            }
            bevy::input::mouse::MouseScrollUnit::Pixel => *scroll_accumulation += event.y,
        }
        if *scroll_accumulation >= 20.0 {
            *scroll_accumulation = 0.0;
            *view_height += 1;
        } else if *scroll_accumulation <= -20.0 {
            *scroll_accumulation = 0.0;
            *view_height -= 1;
        }

        *view_height = view_height.min(1).max(-1);
    }

    for (camera, mut transform) in &mut cameras {
        let mut direction = Vec3::ZERO;
        if input.any_pressed([KeyCode::A, KeyCode::Left]) {
            direction.x -= 1.0;
        }
        if input.any_pressed([KeyCode::D, KeyCode::Right]) {
            direction.x += 1.0;
        }
        if input.any_pressed([KeyCode::W, KeyCode::Up]) {
            direction.y += 1.0;
        }
        if input.any_pressed([KeyCode::S, KeyCode::Down]) {
            direction.y -= 1.0;
        }

        if direction.length() > 0.01 {
            direction = direction.normalize();
        }
        transform.translation += direction * camera.base_speed * time.delta_seconds();

        let target_z = 8.0 + *view_height as f32 * 3.0;
        let mut animation = AnimateRange::new(
            Duration::from_secs_f32(0.2),
            Ease::Linear,
            transform.translation.z..target_z,
            false,
        );
        transform.translation.z = animation.tick(time.delta());
    }
}

Read more