Tainted\\Coders

Bevy Study: Card Combinator

Bevy version: 0.13Last 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

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:

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.

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());
    }
}