Bevy Study: Card Combinator
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 setupsrc/game/mod.rs
-> The game pluginsrc/game/card.rs
-> Large file with ~1000 loc, contains card pluginsrc/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
andviewport_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 betweenviewport_max
andviewport_min
.adj_cursor_pos
adjusts the cursor position by subtracting theviewport_min
from the cursor position.projection
retrieves the projection matrix from the camera.far_ndc
andnear_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 positionndc_to_world
is the transformation matrix that converts coordinates from NDC space to world space.near
andfar
represent the near and far points along the mouse ray in world spacedirection
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());
}
}