first committ, still fighting with... a lot! 😅
Some checks are pending
CI / Format (push) Waiting to run
CI / Docs (push) Waiting to run
CI / Clippy lints (push) Waiting to run
CI / Bevy lints (push) Waiting to run
CI / Tests (push) Waiting to run
CI / Check web (push) Waiting to run

This commit is contained in:
Roberto Maurizzi 2025-07-06 22:14:17 +08:00
commit 495556500f
Signed by: robm
GPG key ID: F26E59AFAAADEA55
74 changed files with 18135 additions and 0 deletions

71
src/asset_tracking.rs Normal file
View file

@ -0,0 +1,71 @@
//! A high-level way to load collections of asset handles as resources.
use std::collections::VecDeque;
use bevy::prelude::*;
pub(super) fn plugin(app: &mut App) {
app.init_resource::<ResourceHandles>();
app.add_systems(PreUpdate, load_resource_assets);
}
pub trait LoadResource {
/// This will load the [`Resource`] as an [`Asset`]. When all of its asset dependencies
/// have been loaded, it will be inserted as a resource. This ensures that the resource only
/// exists when the assets are ready.
fn load_resource<T: Resource + Asset + Clone + FromWorld>(&mut self) -> &mut Self;
}
impl LoadResource for App {
fn load_resource<T: Resource + Asset + Clone + FromWorld>(&mut self) -> &mut Self {
self.init_asset::<T>();
let world = self.world_mut();
let value = T::from_world(world);
let assets = world.resource::<AssetServer>();
let handle = assets.add(value);
let mut handles = world.resource_mut::<ResourceHandles>();
handles
.waiting
.push_back((handle.untyped(), |world, handle| {
let assets = world.resource::<Assets<T>>();
if let Some(value) = assets.get(handle.id().typed::<T>()) {
world.insert_resource(value.clone());
}
}));
self
}
}
/// A function that inserts a loaded resource.
type InsertLoadedResource = fn(&mut World, &UntypedHandle);
#[derive(Resource, Default)]
pub struct ResourceHandles {
// Use a queue for waiting assets so they can be cycled through and moved to
// `finished` one at a time.
waiting: VecDeque<(UntypedHandle, InsertLoadedResource)>,
finished: Vec<UntypedHandle>,
}
impl ResourceHandles {
/// Returns true if all requested [`Asset`]s have finished loading and are available as [`Resource`]s.
pub fn is_all_done(&self) -> bool {
self.waiting.is_empty()
}
}
fn load_resource_assets(world: &mut World) {
world.resource_scope(|world, mut resource_handles: Mut<ResourceHandles>| {
world.resource_scope(|world, assets: Mut<AssetServer>| {
for _ in 0..resource_handles.waiting.len() {
let (handle, insert_fn) = resource_handles.waiting.pop_front().unwrap();
if assets.is_loaded_with_dependencies(&handle) {
insert_fn(world, &handle);
resource_handles.finished.push(handle);
} else {
resource_handles.waiting.push_back((handle, insert_fn));
}
}
});
});
}

47
src/audio.rs Normal file
View file

@ -0,0 +1,47 @@
use bevy::prelude::*;
pub(super) fn plugin(app: &mut App) {
app.register_type::<Music>();
app.register_type::<SoundEffect>();
app.add_systems(
Update,
apply_global_volume.run_if(resource_changed::<GlobalVolume>),
);
}
/// An organizational marker component that should be added to a spawned [`AudioPlayer`] if it's in the
/// general "music" category (e.g. global background music, soundtrack).
///
/// This can then be used to query for and operate on sounds in that category.
#[derive(Component, Reflect, Default)]
#[reflect(Component)]
pub struct Music;
/// A music audio instance.
pub fn music(handle: Handle<AudioSource>) -> impl Bundle {
(AudioPlayer(handle), PlaybackSettings::LOOP, Music)
}
/// An organizational marker component that should be added to a spawned [`AudioPlayer`] if it's in the
/// general "sound effect" category (e.g. footsteps, the sound of a magic spell, a door opening).
///
/// This can then be used to query for and operate on sounds in that category.
#[derive(Component, Reflect, Default)]
#[reflect(Component)]
pub struct SoundEffect;
/// A sound effect audio instance.
pub fn sound_effect(handle: Handle<AudioSource>) -> impl Bundle {
(AudioPlayer(handle), PlaybackSettings::DESPAWN, SoundEffect)
}
/// [`GlobalVolume`] doesn't apply to already-running audio entities, so this system will update them.
fn apply_global_volume(
global_volume: Res<GlobalVolume>,
mut audio_query: Query<(&PlaybackSettings, &mut AudioSink)>,
) {
for (playback, mut sink) in &mut audio_query {
sink.set_volume(global_volume.volume * playback.volume);
}
}

203
src/demo/animation.rs Normal file
View file

@ -0,0 +1,203 @@
//! Player sprite animation.
//! This is based on multiple examples and may be very different for your game.
//! - [Sprite flipping](https://github.com/bevyengine/bevy/blob/latest/examples/2d/sprite_flipping.rs)
//! - [Sprite animation](https://github.com/bevyengine/bevy/blob/latest/examples/2d/sprite_animation.rs)
//! - [Timers](https://github.com/bevyengine/bevy/blob/latest/examples/time/timers.rs)
use bevy::prelude::*;
use rand::prelude::*;
use std::time::Duration;
use crate::{
AppSystems, PausableSystems,
audio::sound_effect,
demo::{movement::MovementController, player::PlayerAssets},
};
pub(super) fn plugin(app: &mut App) {
// Animate and play sound effects based on controls.
app.register_type::<PlayerAnimation>();
app.add_systems(
Update,
(
update_animation_timer.in_set(AppSystems::TickTimers),
(
update_animation_movement,
update_animation_atlas,
trigger_step_sound_effect,
)
.chain()
.run_if(resource_exists::<PlayerAssets>)
.in_set(AppSystems::Update),
)
.in_set(PausableSystems),
);
}
/// Update the sprite direction and animation state (idling/walking).
fn update_animation_movement(
mut player_query: Query<(&MovementController, &mut Sprite, &mut PlayerAnimation)>,
) {
for (controller, mut sprite, mut animation) in &mut player_query {
let dx = controller.intent.x;
if dx != 0.0 {
sprite.flip_x = dx < 0.0;
}
let animation_state = if controller.intent == Vec2::ZERO {
PlayerAnimationState::Idling
} else {
PlayerAnimationState::Walking
};
animation.update_state(animation_state);
}
}
/// Update the animation timer.
fn update_animation_timer(time: Res<Time>, mut query: Query<&mut PlayerAnimation>) {
for mut animation in &mut query {
animation.update_timer(time.delta());
}
}
/// Update the texture atlas to reflect changes in the animation.
fn update_animation_atlas(mut query: Query<(&PlayerAnimation, &mut Sprite)>) {
for (animation, mut sprite) in &mut query {
let Some(atlas) = sprite.texture_atlas.as_mut() else {
continue;
};
if animation.changed() {
atlas.index = animation.get_atlas_index();
}
}
}
/// If the player is moving, play a step sound effect synchronized with the
/// animation.
fn trigger_step_sound_effect(
mut commands: Commands,
player_assets: Res<PlayerAssets>,
mut step_query: Query<&PlayerAnimation>,
) {
for animation in &mut step_query {
if animation.state == PlayerAnimationState::Walking
&& animation.changed()
&& (animation.frame == 2 || animation.frame == 5)
{
let rng = &mut rand::thread_rng();
let random_step = player_assets.steps.choose(rng).unwrap().clone();
commands.spawn(sound_effect(random_step));
}
}
}
/// Component that tracks player's animation state.
/// It is tightly bound to the texture atlas we use.
#[derive(Component, Reflect)]
#[reflect(Component)]
pub struct PlayerAnimation {
timer: Timer,
frame: usize,
state: PlayerAnimationState,
}
#[derive(Reflect, PartialEq)]
pub enum PlayerAnimationState {
Idling,
Walking,
Thrusting,
Hurting,
}
impl PlayerAnimation {
/// The number of idle frames.
const IDLE_FRAMES: usize = 1;
/// The duration of each idle frame.
const IDLE_INTERVAL: Duration = Duration::from_millis(500);
/// The number of walking frames.
const WALKING_FRAMES: usize = 8;
/// The duration of each walking frame.
const WALKING_INTERVAL: Duration = Duration::from_millis(50);
const THRUST_FRAMES: usize = 8;
const THRUST_INTERVAL: Duration = Duration::from_millis(50);
const HURT_FRAMES: usize = 4;
const HURT_INTERVAL: Duration = Duration::from_millis(100);
fn idling() -> Self {
Self {
timer: Timer::new(Self::IDLE_INTERVAL, TimerMode::Repeating),
frame: 0,
state: PlayerAnimationState::Idling,
}
}
fn walking() -> Self {
Self {
timer: Timer::new(Self::WALKING_INTERVAL, TimerMode::Repeating),
frame: 0,
state: PlayerAnimationState::Walking,
}
}
fn thrusting() -> Self {
Self {
timer: Timer::new(Self::THRUST_INTERVAL, TimerMode::Once),
frame: 0,
state: PlayerAnimationState::Thrusting,
}
}
fn hurting() -> Self {
Self {
timer: Timer::new(Self::HURT_INTERVAL, TimerMode::Once),
frame: 0,
state: PlayerAnimationState::Hurting,
}
}
pub fn new() -> Self {
Self::idling()
}
/// Update animation timers.
pub fn update_timer(&mut self, delta: Duration) {
self.timer.tick(delta);
if !self.timer.finished() {
return;
}
self.frame = (self.frame + 1)
% match self.state {
PlayerAnimationState::Idling => Self::IDLE_FRAMES,
PlayerAnimationState::Walking => Self::WALKING_FRAMES,
PlayerAnimationState::Thrusting => Self::THRUST_FRAMES,
PlayerAnimationState::Hurting => Self::HURT_FRAMES,
};
}
/// Update animation state if it changes.
pub fn update_state(&mut self, state: PlayerAnimationState) {
if self.state != state {
match state {
PlayerAnimationState::Idling => *self = Self::idling(),
PlayerAnimationState::Walking => *self = Self::walking(),
PlayerAnimationState::Thrusting => *self = Self::thrusting(),
PlayerAnimationState::Hurting => *self = Self::hurting(),
}
}
}
/// Whether animation changed this tick.
pub fn changed(&self) -> bool {
self.timer.finished()
}
/// Return sprite index in the atlas.
pub fn get_atlas_index(&self) -> usize {
match self.state {
PlayerAnimationState::Idling => self.frame,
PlayerAnimationState::Walking => 4 + self.frame,
PlayerAnimationState::Thrusting => 12 + self.frame,
PlayerAnimationState::Hurting => 20 + self.frame,
}
}
}

379
src/demo/level.rs Normal file
View file

@ -0,0 +1,379 @@
//! Spawn the main level.
use bevy::{
input::mouse::AccumulatedMouseMotion,
platform::collections::{HashMap, HashSet},
prelude::*,
window::PrimaryWindow,
};
use bevy_ecs_ldtk::{ldtk::NeighbourLevel, prelude::*};
use crate::{
asset_tracking::LoadResource, audio::music, demo::player::PlayerAssets, screens::Screen,
};
use super::player::Player;
pub(super) fn plugin(app: &mut App) {
app.add_plugins(LdtkPlugin)
.insert_resource(LevelSelection::iid("d53f9950-c640-11ed-8430-4942c04951ff"))
.insert_resource(LdtkSettings {
level_spawn_behavior: LevelSpawnBehavior::UseWorldTranslation {
load_level_neighbors: true,
},
..default()
})
.init_resource::<LevelWalls>()
.init_resource::<MultiLevelWalls>();
app.register_type::<PanLevel>();
app.insert_resource(PanLevel::default());
app.register_type::<LevelAssets>();
app.load_resource::<LevelAssets>();
app.register_type::<LevelWalls>();
app.register_type::<MultiLevelWalls>();
app.register_ldtk_int_cell::<WallBundle>(1);
app.add_systems(
Update,
(
translate_grid_coords_entities,
level_selection_follow_player,
cache_wall_locations,
pan_camera,
),
);
}
#[derive(Resource, Default, Asset, Clone, Reflect)]
#[reflect(Resource)]
struct PanLevel {
offset: Vec2,
}
#[derive(Resource, Asset, Clone, Reflect)]
#[reflect(Resource)]
pub struct LevelAssets {
#[dependency]
music: Handle<AudioSource>,
world: LdtkProjectHandle,
}
impl FromWorld for LevelAssets {
fn from_world(world: &mut World) -> Self {
let assets = world.resource::<AssetServer>();
Self {
music: assets.load("audio/music/Fluffing A Duck.ogg"),
world: assets.load("levels/world.ldtk").into(),
// world: assets.load("levels/collectathon.ldtk").into(),
}
}
}
const GRID_SIZE: i32 = 16;
fn pan_camera(
mut pan: ResMut<PanLevel>,
mouse_buttons: Res<ButtonInput<MouseButton>>,
mouse_motion: Res<AccumulatedMouseMotion>,
) {
let delta = mouse_motion.delta;
if mouse_buttons.pressed(MouseButton::Middle) {
pan.offset += delta;
info!("pan offset: {}", pan.offset);
}
}
fn translate_grid_coords_entities(
mut grid_coords_entities: Query<(&mut Transform, &GridCoords), Changed<GridCoords>>,
pan: Res<PanLevel>,
) {
for (mut transform, grid_coords) in grid_coords_entities.iter_mut() {
transform.translation = (
//pan.offset +
bevy_ecs_ldtk::utils::grid_coords_to_translation(*grid_coords, IVec2::splat(GRID_SIZE))
)
.extend(transform.translation.z);
}
}
#[derive(Default, Component)]
struct Wall;
#[derive(Default, Bundle, LdtkIntCell)]
struct WallBundle {
wall: Wall,
}
#[derive(Debug, Default, Eq, Hash, PartialEq, Reflect)]
pub enum Direction {
#[default]
N,
E,
S,
W,
}
impl TryFrom<&str> for Direction {
type Error = &'static str;
fn try_from(value: &str) -> Result<Self, Self::Error> {
match value {
"n" => Ok(Direction::N),
"e" => Ok(Direction::E),
"s" => Ok(Direction::S),
"w" => Ok(Direction::W),
_ => Err("Invalid direction string"),
}
}
}
pub fn convert_neighbors(
neighbors: &[NeighbourLevel],
) -> Result<HashMap<Direction, LevelIid>, &'static str> {
info!("got neighbors: {:?}", neighbors);
let gino = neighbors
.iter()
// .map(|neighbor| {
// let direction = Direction::try_from(neighbor.dir.as_str())?;
// Ok((direction, LevelIid::from(neighbor.level_iid.clone())))
// })
.filter_map(|neighbor| {
Direction::try_from(neighbor.dir.as_str())
.ok()
.map(|dir| (dir, LevelIid::from(neighbor.level_iid.clone())))
})
.collect();
info!("converted to: {:?}", gino);
Ok(gino)
}
#[derive(Debug, Default, Resource, Reflect)]
#[reflect(Resource)]
pub struct LevelWalls {
wall_locations: HashSet<GridCoords>,
level_width: i32,
level_height: i32,
level_neighbours: HashMap<Direction, LevelIid>,
}
impl LevelWalls {
pub fn in_wall(&self, grid_coords: &GridCoords) -> bool {
grid_coords.x < 0
|| grid_coords.y < 0
|| grid_coords.x >= self.level_width
|| grid_coords.y >= self.level_height
|| self.wall_locations.contains(grid_coords)
}
pub fn debug_collisions(&self, player_pos: &GridCoords) {
info!(
"map for a level that is x: {} by y: {}",
self.level_width, self.level_height
);
for y in (0..self.level_height) {
for x in 0..self.level_width {
let coords = GridCoords::new(x, y);
if coords == *player_pos {
print!("@");
} else if self.in_wall(&coords) {
print!("X");
} else {
print!("_");
}
}
println!();
}
}
}
#[derive(Debug, Default, Resource, Reflect)]
#[reflect(Resource)]
pub struct MultiLevelWalls {
cache: HashMap<LevelIid, LevelWalls>,
}
impl MultiLevelWalls {
pub fn in_wall(&self, level: &LevelIid, grid_coords: &GridCoords) -> bool {
self.cache[level].in_wall(grid_coords)
}
pub fn debug_collisions(&self, level: &LevelIid, player_pos: &GridCoords) {
if let Some(level) = self.cache.get(level) {
level.debug_collisions(player_pos);
} else {
warn!("No walls cached for level: {:?}", level);
}
}
}
/// A system that spawns the main level.
pub fn spawn_level(
mut commands: Commands,
window: Single<&Window, With<PrimaryWindow>>,
level_assets: Res<LevelAssets>,
) {
let half_size = window.size() / 2.0;
commands.spawn((
Name::new("Ldtk level"),
LdtkWorldBundle {
ldtk_handle: level_assets.world.clone(),
transform: Transform::from_xyz(-half_size.x, half_size.y, 0.0),
..Default::default()
},
));
}
fn _old_cache_wall_locations(
level_selection: Res<LevelSelection>,
mut level_walls: ResMut<LevelWalls>,
mut level_events: EventReader<LevelEvent>,
walls: Query<&GridCoords, With<Wall>>,
ldtk_project_entities: Query<&LdtkProjectHandle>,
ldtk_project_assets: Res<Assets<LdtkProject>>,
) -> Result {
for level_event in level_events.read() {
if let LevelEvent::Spawned(level_iid) = level_event {
let ldtk_project = ldtk_project_assets
.get(ldtk_project_entities.single()?)
.expect("LdtkProject should be loaded when level is spawned");
let level = ldtk_project
.get_raw_level_by_iid(level_iid.get())
.expect("spawned level should exist in project");
let wall_locations = walls.iter().copied().collect();
info!(
"loading level of dimension x: {} by y: {}",
level.px_wid, level.px_hei
);
let new_level_walls = LevelWalls {
wall_locations,
level_width: level.px_wid / GRID_SIZE,
level_height: level.px_hei / GRID_SIZE,
..Default::default()
};
*level_walls = new_level_walls;
info!(
"new level tile dimensions are x: {} y {}",
level_walls.level_width, level_walls.level_height
);
level_walls.debug_collisions(&GridCoords::default());
}
}
Ok(())
}
fn cache_wall_locations(
mut levels_wall_cache: ResMut<MultiLevelWalls>,
mut level_events: EventReader<LevelEvent>,
walls: Query<(&ChildOf, &GridCoords), With<Wall>>,
ldtk_project_entities: Query<&LdtkProjectHandle>,
ldtk_project_assets: Res<Assets<LdtkProject>>,
) -> Result {
let multi_level_walls = levels_wall_cache.into_inner();
for level_event in level_events.read() {
if let LevelEvent::Spawned(level_iid) = level_event {
let ldtk_project = ldtk_project_assets
.get(ldtk_project_entities.single()?)
.expect("LdtkProject should be loaded when level is spawned");
let level = ldtk_project
.get_raw_level_by_iid(level_iid.get())
.expect("spawned level should exist in project");
let mut wall_locations = HashSet::<GridCoords>::default();
info!("current level neighbours: {:?}", level.neighbours);
if let Some(layers) = level.layer_instances.clone() {
// info!("layers: {:?}", layers);
layers.iter().for_each(|field| {
info!("Layer field: {:?}", field.identifier);
if field.identifier == "Walls" {
info!("Found walls layer: {:?}", field.int_grid_csv);
info!("Trying to format it");
// FIXME: a .rev() here? It doesn't look necessary from what gets printed
// remember to fix the supposed "map dragging" too
for y in (0..(level.px_hei / GRID_SIZE)).rev() {
for x in (0..(level.px_wid / GRID_SIZE)) {
let index = (y * level.px_wid / GRID_SIZE + x) as usize;
if let Some(value) = field.int_grid_csv.get(index) {
if *value == 1 {
print!("X");
wall_locations.insert(GridCoords::new(x, y));
} else {
print!("_");
}
}
}
println!();
}
}
});
}
// level.iter_fields().for_each(|field| {
// info!("Field: {:?}", field);
// });
// let wall_locations = walls.iter().map(|e| e.1).copied().collect();
info!(
"loading level of dimension x: {} by y: {}",
level.px_wid, level.px_hei
);
multi_level_walls.cache.insert(
level_iid.clone(), // You'll need to clone the key since HashMap takes ownership
LevelWalls {
wall_locations,
level_width: level.px_wid / GRID_SIZE,
level_height: level.px_hei / GRID_SIZE,
level_neighbours: convert_neighbors(&level.neighbours).unwrap_or_default(), // Convert neighbours to a HashMap
},
);
info!(
"new level tile dimensions are x: {} y {}",
multi_level_walls.cache[level_iid].level_width,
multi_level_walls.cache[level_iid].level_height
);
multi_level_walls.cache[level_iid].debug_collisions(&GridCoords::default());
}
}
Ok(())
}
fn level_selection_follow_player(
players: Query<&GlobalTransform, With<Player>>,
levels: Query<(&LevelIid, &GlobalTransform)>,
ldtk_projects: Query<&LdtkProjectHandle>,
ldtk_project_assets: Res<Assets<LdtkProject>>,
mut level_selection: ResMut<LevelSelection>,
) -> Result {
if let Ok(player_transform) = players.single() {
let ldtk_project = ldtk_project_assets
.get(ldtk_projects.single()?)
.expect("ldtk project should be loaded before player is spawned");
for (level_iid, level_transform) in levels.iter() {
let level = ldtk_project
.get_raw_level_by_iid(level_iid.get())
.expect("level should exist in only project");
let level_bounds = Rect {
min: Vec2::new(
level_transform.translation().x,
level_transform.translation().y,
),
max: Vec2::new(
level_transform.translation().x + level.px_wid as f32,
level_transform.translation().y + level.px_hei as f32,
),
};
if level_bounds.contains(player_transform.translation().truncate()) {
*level_selection = LevelSelection::Iid(level_iid.clone());
}
}
}
Ok(())
}

20
src/demo/mod.rs Normal file
View file

@ -0,0 +1,20 @@
//! Demo gameplay. All of these modules are only intended for demonstration
//! purposes and should be replaced with your own game logic.
//! Feel free to change the logic found here if you feel like tinkering around
//! to get a feeling for the template.
use bevy::prelude::*;
mod animation;
pub mod level;
mod movement;
pub mod player;
pub(super) fn plugin(app: &mut App) {
app.add_plugins((
animation::plugin,
level::plugin,
movement::plugin,
player::plugin,
));
}

133
src/demo/movement.rs Normal file
View file

@ -0,0 +1,133 @@
//! Handle player input and translate it into movement through a character
//! controller. A character controller is the collection of systems that govern
//! the movement of characters.
//!
//! In our case, the character controller has the following logic:
//! - Set [`MovementController`] intent based on directional keyboard input.
//! This is done in the `player` module, as it is specific to the player
//! character.
//! - Apply movement based on [`MovementController`] intent and maximum speed.
//! - Wrap the character within the window.
//!
//! Note that the implementation used here is limited for demonstration
//! purposes. If you want to move the player in a smoother way,
//! consider using a [fixed timestep](https://github.com/bevyengine/bevy/blob/main/examples/movement/physics_in_fixed_timestep.rs).
use bevy::{prelude::*, window::PrimaryWindow};
use bevy_ecs_ldtk::{GridCoords, LevelSelection};
use crate::{AppSystems, PausableSystems};
use super::{
level::{LevelWalls, MultiLevelWalls},
player::Player,
};
pub(super) fn plugin(app: &mut App) {
app.register_type::<MovementController>();
app.register_type::<ScreenWrap>();
app.add_systems(
Update,
(apply_movement, apply_screen_wrap)
.chain()
.in_set(AppSystems::Update)
.in_set(PausableSystems),
);
}
/// These are the movement parameters for our character controller.
/// For now, this is only used for a single player, but it could power NPCs or
/// other players as well.
#[derive(Component, Debug, Reflect)]
#[reflect(Component)]
pub struct MovementController {
/// The direction the character wants to move in.
pub intent: Vec2,
/// Maximum speed in world units per second.
/// 1 world unit = 1 pixel when using the default 2D camera and no physics engine.
pub max_speed: f32,
}
impl Default for MovementController {
fn default() -> Self {
Self {
intent: Vec2::ZERO,
// 400 pixels per second is a nice default, but we can still vary this per character.
max_speed: 40.0,
}
}
}
fn apply_movement(
_time: Res<Time>,
movement_query: Query<&MovementController, With<Player>>,
player_transform_query: Query<&Transform, With<Player>>,
mut players: Query<&mut GridCoords, With<Player>>,
level_selection: Res<LevelSelection>,
// level_walls: Res<LevelWalls>,
level_walls: Res<MultiLevelWalls>,
) {
let level_selection_iid = match level_selection.as_ref() {
LevelSelection::Iid(iid) => iid,
_ => panic!("level should be selected by iid"),
};
for controller in &movement_query {
// no velocity... for now? It'd be nice to have different speed depending on the tile type
// let velocity = controller.max_speed * controller.intent;
for mut player_grid_coords in players.iter_mut() {
let movement_direction = if controller.intent.x == 1.0 {
GridCoords::new(1, 0)
} else if controller.intent.x == -1.0 {
GridCoords::new(-1, 0)
} else if controller.intent.y == 1.0 {
GridCoords::new(0, 1)
} else if controller.intent.y == -1.0 {
GridCoords::new(0, -1)
} else if controller.intent == Vec2::ZERO {
// no movement
continue;
} else {
// unrecognized intent, log a warning
warn!("Unrecognized intent: {:?}", controller.intent);
return;
};
info!("player old coords: {:?}", player_grid_coords);
let destination = *player_grid_coords + movement_direction;
info!("commanded movement player coords: {:?}", destination);
info!("Level selection: {:?}", level_selection_iid);
if !level_walls.in_wall(level_selection_iid, &destination) {
*player_grid_coords = destination;
info!("new player grid_coords: {:?}", player_grid_coords);
info!(
"new player screen_coords: {:?}",
player_transform_query.single()
);
// transform.translation += velocity.extend(0.0) * time.delta_secs();
} else {
info!("SDENG!");
}
level_walls.debug_collisions(level_selection_iid, &player_grid_coords);
}
}
}
#[derive(Component, Reflect)]
#[reflect(Component)]
pub struct ScreenWrap;
fn apply_screen_wrap(
window: Single<&Window, With<PrimaryWindow>>,
mut wrap_query: Query<&mut Transform, With<ScreenWrap>>,
) {
let size = window.size() + 256.0;
let half_size = size / 2.0;
for mut transform in &mut wrap_query {
let position = transform.translation.xy();
let wrapped = (position + half_size).rem_euclid(size) - half_size;
transform.translation = wrapped.extend(transform.translation.z);
}
}

156
src/demo/player.rs Normal file
View file

@ -0,0 +1,156 @@
//! Player-specific behavior.
use bevy::{
image::{ImageLoaderSettings, ImageSampler},
prelude::*,
};
use bevy_ecs_ldtk::prelude::*;
use crate::{
AppSystems, PausableSystems,
asset_tracking::LoadResource,
demo::{
animation::PlayerAnimation,
movement::{MovementController, ScreenWrap},
},
};
pub(super) fn plugin(app: &mut App) {
app.register_type::<Player>();
app.register_type::<PlayerAssets>();
app.load_resource::<PlayerAssets>();
app.register_ldtk_entity::<PlayerBundle>("Player");
// Record directional input as movement controls.
app.add_systems(
Update,
record_player_directional_input
.in_set(AppSystems::RecordInput)
.in_set(PausableSystems),
);
app.add_systems(Update, process_player);
}
/// The player character.
// pub fn player(
// max_speed: f32,
// player_assets: &PlayerAssets,
// texture_atlas_layouts: &mut Assets<TextureAtlasLayout>,
// ) -> impl Bundle {
// // A texture atlas is a way to split a single image into a grid of related images.
// // You can learn more in this example: https://github.com/bevyengine/bevy/blob/latest/examples/2d/texture_atlas.rs
// let layout =
// TextureAtlasLayout::from_grid(UVec2::new(64, 64), 9, 54, Some(UVec2::splat(1)), None);
// let texture_atlas_layout = texture_atlas_layouts.add(layout);
// let player_animation = PlayerAnimation::new();
//
// (
// Name::new("Player"),
// Player,
// Sprite {
// image: player_assets.ducky.clone(),
// texture_atlas: Some(TextureAtlas {
// layout: texture_atlas_layout,
// index: player_animation.get_atlas_index(),
// }),
// ..default()
// },
// // Transform::from_scale(Vec2::splat(1.0).extend(1.0)),
// MovementController {
// max_speed,
// ..default()
// },
// ScreenWrap,
// player_animation,
// )
// }
#[derive(Component, Debug, Clone, Copy, PartialEq, Eq, Default, Reflect)]
#[reflect(Component)]
pub struct Player;
#[derive(Default, Bundle, LdtkEntity)]
struct PlayerBundle {
#[sprite_sheet]
sprite_sheet: Sprite,
#[worldly]
worldly: Worldly,
#[grid_coords]
grid_coords: GridCoords,
// non-ecsldtk-related components
player_comp: Player,
movement: MovementController,
}
fn record_player_directional_input(
input: Res<ButtonInput<KeyCode>>,
mut controller_query: Query<&mut MovementController, With<Player>>,
) {
// Collect directional input.
let mut intent = Vec2::ZERO;
// TODO: check axes for gridcoords!
if input.just_pressed(KeyCode::KeyW) || input.pressed(KeyCode::ArrowUp) {
intent.y -= 1.0;
}
if input.just_pressed(KeyCode::KeyS) || input.pressed(KeyCode::ArrowDown) {
intent.y += 1.0;
}
if input.just_pressed(KeyCode::KeyA) || input.pressed(KeyCode::ArrowLeft) {
intent.x -= 1.0;
}
if input.just_pressed(KeyCode::KeyD) || input.pressed(KeyCode::ArrowRight) {
intent.x += 1.0;
}
// Normalize intent so that diagonal movement is the same speed as horizontal / vertical.
// This should be omitted if the input comes from an analog stick instead.
let intent = intent.normalize_or_zero();
// Apply movement intent to controllers.
for mut controller in &mut controller_query {
controller.intent = intent;
}
}
#[derive(Resource, Asset, Clone, Reflect)]
#[reflect(Resource)]
pub struct PlayerAssets {
#[dependency]
ducky: Handle<Image>,
#[dependency]
pub steps: Vec<Handle<AudioSource>>,
}
impl FromWorld for PlayerAssets {
fn from_world(world: &mut World) -> Self {
let assets = world.resource::<AssetServer>();
Self {
ducky: assets.load_with_settings(
"spritesheets/researcher.png",
|settings: &mut ImageLoaderSettings| {
// Use `nearest` image sampling to preserve pixel art style.
settings.sampler = ImageSampler::nearest();
},
),
steps: vec![
assets.load("audio/sound_effects/step1.ogg"),
assets.load("audio/sound_effects/step2.ogg"),
assets.load("audio/sound_effects/step3.ogg"),
assets.load("audio/sound_effects/step4.ogg"),
],
}
}
}
fn process_player(
mut _commands: Commands,
new_players: Query<(Entity, &GridCoords), Added<Player>>,
) {
for (player_entity, player_coords) in new_players.iter() {
info!(
"Spawned new player: {:?} at {:?}",
player_entity, player_coords
);
}
}

52
src/dev_tools.rs Normal file
View file

@ -0,0 +1,52 @@
//! Development tools for the game. This plugin is only enabled in dev builds.
use bevy::{
color::palettes::css::*,
dev_tools::states::log_transitions,
input::common_conditions::{input_just_pressed, input_toggle_active},
prelude::*,
ui::UiDebugOptions,
};
#[cfg(all(feature = "dev_native", feature = "inspector"))]
use bevy_inspector_egui::{
DefaultInspectorConfigPlugin, bevy_egui::EguiPlugin, quick::WorldInspectorPlugin,
};
use crate::screens::Screen;
const TOGGLE_KEY: KeyCode = KeyCode::Backquote;
const TOGGLE_ORIG: KeyCode = KeyCode::NumpadAdd;
pub(super) fn plugin(app: &mut App) {
// activate inspector if feature is active
#[cfg(all(feature = "dev_native", feature = "inspector"))]
app.add_plugins((
DefaultInspectorConfigPlugin,
EguiPlugin {
enable_multipass_for_primary_context: true,
},
WorldInspectorPlugin::default().run_if(input_toggle_active(false, KeyCode::Backquote)),
));
// Log `Screen` state transitions.
app.add_systems(Update, log_transitions::<Screen>);
// Toggle the debug overlay for UI.
app.add_systems(
Update,
toggle_debug_ui.run_if(input_just_pressed(TOGGLE_KEY)),
);
app.add_systems(
Update,
toggle_origin.run_if(input_just_pressed(TOGGLE_ORIG)),
);
}
fn toggle_debug_ui(mut options: ResMut<UiDebugOptions>) {
options.toggle();
}
fn toggle_origin(mut gizmos: Gizmos) {
info!("cucu?");
gizmos.cross_2d(Vec2::new(-0., 0.), 12., FUCHSIA);
}

126
src/main.rs Normal file
View file

@ -0,0 +1,126 @@
// Support configuring Bevy lints within code.
#![cfg_attr(bevy_lint, feature(register_tool), register_tool(bevy))]
// Disable console on Windows for non-dev builds.
#![cfg_attr(not(feature = "dev"), windows_subsystem = "windows")]
mod asset_tracking;
mod audio;
mod demo;
#[cfg(feature = "dev")]
mod dev_tools;
mod menus;
mod screens;
mod theme;
use bevy::{asset::AssetMetaCheck, prelude::*, window::PrimaryWindow};
fn main() -> AppExit {
App::new().add_plugins(AppPlugin).run()
}
pub struct AppPlugin;
impl Plugin for AppPlugin {
fn build(&self, app: &mut App) {
// Add Bevy plugins.
app.add_plugins(
DefaultPlugins
.set(AssetPlugin {
// Wasm builds will check for meta files (that don't exist) if this isn't set.
// This causes errors and even panics on web build on itch.
// See https://github.com/bevyengine/bevy_github_ci_template/issues/48.
meta_check: AssetMetaCheck::Never,
..default()
})
.set(WindowPlugin {
primary_window: Window {
title: "Chain Reaction Collapse".to_string(),
fit_canvas_to_parent: true,
..default()
}
.into(),
..default()
}),
);
// Add other plugins.
app.add_plugins((
asset_tracking::plugin,
audio::plugin,
demo::plugin,
#[cfg(feature = "dev")]
dev_tools::plugin,
menus::plugin,
screens::plugin,
theme::plugin,
));
// Order new `AppSystems` variants by adding them here:
app.configure_sets(
Update,
(
AppSystems::TickTimers,
AppSystems::RecordInput,
AppSystems::Update,
)
.chain(),
);
// Set up the `Pause` state.
app.init_state::<Pause>();
app.configure_sets(Update, PausableSystems.run_if(in_state(Pause(false))));
// Spawn the main camera.
app.add_systems(Startup, spawn_camera);
}
}
/// High-level groupings of systems for the app in the `Update` schedule.
/// When adding a new variant, make sure to order it in the `configure_sets`
/// call above.
#[derive(SystemSet, Debug, Clone, Copy, Eq, PartialEq, Hash, PartialOrd, Ord)]
enum AppSystems {
/// Tick timers.
TickTimers,
/// Record player input.
RecordInput,
/// Do everything else (consider splitting this into further variants).
Update,
}
/// Whether or not the game is paused.
#[derive(States, Copy, Clone, Eq, PartialEq, Hash, Debug, Default)]
#[states(scoped_entities)]
struct Pause(pub bool);
/// A system set for systems that shouldn't run while the game is paused.
#[derive(SystemSet, Copy, Clone, Eq, PartialEq, Hash, Debug)]
struct PausableSystems;
// fn apply_screen_wrap(
// window: Single<&Window, With<PrimaryWindow>>,
// mut wrap_query: Query<&mut Transform, With<ScreenWrap>>,
// ) {
// let size = window.size() + 256.0;
// let half_size = size / 2.0;
// for mut transform in &mut wrap_query {
// let position = transform.translation.xy();
// let wrapped = (position + half_size).rem_euclid(size) - half_size;
// transform.translation = wrapped.extend(transform.translation.z);
// }
// }
fn spawn_camera(mut commands: Commands, window: Single<&Window, With<PrimaryWindow>>) {
let half_size = window.size() / 2.0;
commands.spawn((
Name::new("Camera"),
Camera2d,
Projection::Orthographic(OrthographicProjection {
scale: 0.5,
..OrthographicProjection::default_2d()
}),
// FIXME: what do I need to transform the camera for?
// Transform::from_xyz(-half_size.x, half_size.y, 0.0),
));
}

113
src/menus/credits.rs Normal file
View file

@ -0,0 +1,113 @@
//! The credits menu.
use bevy::{
ecs::spawn::SpawnIter, input::common_conditions::input_just_pressed, prelude::*, ui::Val::*,
};
use crate::{asset_tracking::LoadResource, audio::music, menus::Menu, theme::prelude::*};
pub(super) fn plugin(app: &mut App) {
app.add_systems(OnEnter(Menu::Credits), spawn_credits_menu);
app.add_systems(
Update,
go_back.run_if(in_state(Menu::Credits).and(input_just_pressed(KeyCode::Escape))),
);
app.register_type::<CreditsAssets>();
app.load_resource::<CreditsAssets>();
app.add_systems(OnEnter(Menu::Credits), start_credits_music);
}
fn spawn_credits_menu(mut commands: Commands) {
commands.spawn((
widget::ui_root("Credits Menu"),
GlobalZIndex(2),
StateScoped(Menu::Credits),
children![
widget::header("Created by"),
created_by(),
widget::header("Assets"),
assets(),
widget::button("Back", go_back_on_click),
],
));
}
fn created_by() -> impl Bundle {
grid(vec![
["Joe Shmoe", "Implemented alligator wrestling AI"],
["Jane Doe", "Made the music for the alien invasion"],
])
}
fn assets() -> impl Bundle {
grid(vec![
["Ducky sprite", "CC0 by Caz Creates Games"],
["Button SFX", "CC0 by Jaszunio15"],
["Music", "CC BY 3.0 by Kevin MacLeod"],
[
"Bevy logo",
"All rights reserved by the Bevy Foundation, permission granted for splash screen use when unmodified",
],
])
}
fn grid(content: Vec<[&'static str; 2]>) -> impl Bundle {
(
Name::new("Grid"),
Node {
display: Display::Grid,
row_gap: Px(10.0),
column_gap: Px(30.0),
grid_template_columns: RepeatedGridTrack::px(2, 400.0),
..default()
},
Children::spawn(SpawnIter(content.into_iter().flatten().enumerate().map(
|(i, text)| {
(
widget::label(text),
Node {
justify_self: if i % 2 == 0 {
JustifySelf::End
} else {
JustifySelf::Start
},
..default()
},
)
},
))),
)
}
fn go_back_on_click(_: Trigger<Pointer<Click>>, mut next_menu: ResMut<NextState<Menu>>) {
next_menu.set(Menu::Main);
}
fn go_back(mut next_menu: ResMut<NextState<Menu>>) {
next_menu.set(Menu::Main);
}
#[derive(Resource, Asset, Clone, Reflect)]
#[reflect(Resource)]
struct CreditsAssets {
#[dependency]
music: Handle<AudioSource>,
}
impl FromWorld for CreditsAssets {
fn from_world(world: &mut World) -> Self {
let assets = world.resource::<AssetServer>();
Self {
music: assets.load("audio/music/Monkeys Spinning Monkeys.ogg"),
}
}
}
fn start_credits_music(mut commands: Commands, credits_music: Res<CreditsAssets>) {
commands.spawn((
Name::new("Credits Music"),
StateScoped(Menu::Credits),
music(credits_music.music.clone()),
));
}

55
src/menus/main.rs Normal file
View file

@ -0,0 +1,55 @@
//! The main menu (seen on the title screen).
use bevy::prelude::*;
use crate::{asset_tracking::ResourceHandles, menus::Menu, screens::Screen, theme::widget};
pub(super) fn plugin(app: &mut App) {
app.add_systems(OnEnter(Menu::Main), spawn_main_menu);
}
fn spawn_main_menu(mut commands: Commands) {
commands.spawn((
widget::ui_root("Main Menu"),
GlobalZIndex(2),
StateScoped(Menu::Main),
#[cfg(not(target_family = "wasm"))]
children![
widget::button("Play", enter_loading_or_gameplay_screen),
widget::button("Settings", open_settings_menu),
widget::button("Credits", open_credits_menu),
widget::button("Exit", exit_app),
],
#[cfg(target_family = "wasm")]
children![
widget::button("Play", enter_loading_or_gameplay_screen),
widget::button("Settings", open_settings_menu),
widget::button("Credits", open_credits_menu),
],
));
}
fn enter_loading_or_gameplay_screen(
_: Trigger<Pointer<Click>>,
resource_handles: Res<ResourceHandles>,
mut next_screen: ResMut<NextState<Screen>>,
) {
if resource_handles.is_all_done() {
next_screen.set(Screen::Gameplay);
} else {
next_screen.set(Screen::Loading);
}
}
fn open_settings_menu(_: Trigger<Pointer<Click>>, mut next_menu: ResMut<NextState<Menu>>) {
next_menu.set(Menu::Settings);
}
fn open_credits_menu(_: Trigger<Pointer<Click>>, mut next_menu: ResMut<NextState<Menu>>) {
next_menu.set(Menu::Credits);
}
#[cfg(not(target_family = "wasm"))]
fn exit_app(_: Trigger<Pointer<Click>>, mut app_exit: EventWriter<AppExit>) {
app_exit.write(AppExit::Success);
}

30
src/menus/mod.rs Normal file
View file

@ -0,0 +1,30 @@
//! The game's menus and transitions between them.
mod credits;
mod main;
mod pause;
mod settings;
use bevy::prelude::*;
pub(super) fn plugin(app: &mut App) {
app.init_state::<Menu>();
app.add_plugins((
credits::plugin,
main::plugin,
settings::plugin,
pause::plugin,
));
}
#[derive(States, Copy, Clone, Eq, PartialEq, Hash, Debug, Default)]
#[states(scoped_entities)]
pub enum Menu {
#[default]
None,
Main,
Credits,
Settings,
Pause,
}

43
src/menus/pause.rs Normal file
View file

@ -0,0 +1,43 @@
//! The pause menu.
use bevy::{input::common_conditions::input_just_pressed, prelude::*};
use crate::{menus::Menu, screens::Screen, theme::widget};
pub(super) fn plugin(app: &mut App) {
app.add_systems(OnEnter(Menu::Pause), spawn_pause_menu);
app.add_systems(
Update,
go_back.run_if(in_state(Menu::Pause).and(input_just_pressed(KeyCode::Escape))),
);
}
fn spawn_pause_menu(mut commands: Commands) {
commands.spawn((
widget::ui_root("Pause Menu"),
GlobalZIndex(2),
StateScoped(Menu::Pause),
children![
widget::header("Game paused"),
widget::button("Continue", close_menu),
widget::button("Settings", open_settings_menu),
widget::button("Quit to title", quit_to_title),
],
));
}
fn open_settings_menu(_: Trigger<Pointer<Click>>, mut next_menu: ResMut<NextState<Menu>>) {
next_menu.set(Menu::Settings);
}
fn close_menu(_: Trigger<Pointer<Click>>, mut next_menu: ResMut<NextState<Menu>>) {
next_menu.set(Menu::None);
}
fn quit_to_title(_: Trigger<Pointer<Click>>, mut next_screen: ResMut<NextState<Screen>>) {
next_screen.set(Screen::Title);
}
fn go_back(mut next_menu: ResMut<NextState<Menu>>) {
next_menu.set(Menu::None);
}

125
src/menus/settings.rs Normal file
View file

@ -0,0 +1,125 @@
//! The settings menu.
//!
//! Additional settings and accessibility options should go here.
use bevy::{audio::Volume, input::common_conditions::input_just_pressed, prelude::*, ui::Val::*};
use crate::{menus::Menu, screens::Screen, theme::prelude::*};
pub(super) fn plugin(app: &mut App) {
app.add_systems(OnEnter(Menu::Settings), spawn_settings_menu);
app.add_systems(
Update,
go_back.run_if(in_state(Menu::Settings).and(input_just_pressed(KeyCode::Escape))),
);
app.register_type::<GlobalVolumeLabel>();
app.add_systems(
Update,
update_global_volume_label.run_if(in_state(Menu::Settings)),
);
}
fn spawn_settings_menu(mut commands: Commands) {
commands.spawn((
widget::ui_root("Settings Menu"),
GlobalZIndex(2),
StateScoped(Menu::Settings),
children![
widget::header("Settings"),
settings_grid(),
widget::button("Back", go_back_on_click),
],
));
}
fn settings_grid() -> impl Bundle {
(
Name::new("Settings Grid"),
Node {
display: Display::Grid,
row_gap: Px(10.0),
column_gap: Px(30.0),
grid_template_columns: RepeatedGridTrack::px(2, 400.0),
..default()
},
children![
(
widget::label("Master Volume"),
Node {
justify_self: JustifySelf::End,
..default()
}
),
global_volume_widget(),
],
)
}
fn global_volume_widget() -> impl Bundle {
(
Name::new("Global Volume Widget"),
Node {
justify_self: JustifySelf::Start,
..default()
},
children![
widget::button_small("-", lower_global_volume),
(
Name::new("Current Volume"),
Node {
padding: UiRect::horizontal(Px(10.0)),
justify_content: JustifyContent::Center,
..default()
},
children![(widget::label(""), GlobalVolumeLabel)],
),
widget::button_small("+", raise_global_volume),
],
)
}
const MIN_VOLUME: f32 = 0.0;
const MAX_VOLUME: f32 = 3.0;
fn lower_global_volume(_: Trigger<Pointer<Click>>, mut global_volume: ResMut<GlobalVolume>) {
let linear = (global_volume.volume.to_linear() - 0.1).max(MIN_VOLUME);
global_volume.volume = Volume::Linear(linear);
}
fn raise_global_volume(_: Trigger<Pointer<Click>>, mut global_volume: ResMut<GlobalVolume>) {
let linear = (global_volume.volume.to_linear() + 0.1).min(MAX_VOLUME);
global_volume.volume = Volume::Linear(linear);
}
#[derive(Component, Reflect)]
#[reflect(Component)]
struct GlobalVolumeLabel;
fn update_global_volume_label(
global_volume: Res<GlobalVolume>,
mut label: Single<&mut Text, With<GlobalVolumeLabel>>,
) {
let percent = 100.0 * global_volume.volume.to_linear();
label.0 = format!("{percent:3.0}%");
}
fn go_back_on_click(
_: Trigger<Pointer<Click>>,
screen: Res<State<Screen>>,
mut next_menu: ResMut<NextState<Menu>>,
) {
next_menu.set(if screen.get() == &Screen::Title {
Menu::Main
} else {
Menu::Pause
});
}
fn go_back(screen: Res<State<Screen>>, mut next_menu: ResMut<NextState<Menu>>) {
next_menu.set(if screen.get() == &Screen::Title {
Menu::Main
} else {
Menu::Pause
});
}

61
src/screens/gameplay.rs Normal file
View file

@ -0,0 +1,61 @@
//! The screen state for the main gameplay.
use bevy::{input::common_conditions::input_just_pressed, prelude::*, ui::Val::*};
use crate::{Pause, demo::level::spawn_level, menus::Menu, screens::Screen};
pub(super) fn plugin(app: &mut App) {
app.add_systems(OnEnter(Screen::Gameplay), spawn_level);
// Toggle pause on key press.
app.add_systems(
Update,
(
(pause, spawn_pause_overlay, open_pause_menu).run_if(
in_state(Screen::Gameplay)
.and(in_state(Menu::None))
.and(input_just_pressed(KeyCode::KeyP).or(input_just_pressed(KeyCode::Escape))),
),
close_menu.run_if(
in_state(Screen::Gameplay)
.and(not(in_state(Menu::None)))
.and(input_just_pressed(KeyCode::KeyP)),
),
),
);
app.add_systems(OnExit(Screen::Gameplay), (close_menu, unpause));
app.add_systems(
OnEnter(Menu::None),
unpause.run_if(in_state(Screen::Gameplay)),
);
}
fn unpause(mut next_pause: ResMut<NextState<Pause>>) {
next_pause.set(Pause(false));
}
fn pause(mut next_pause: ResMut<NextState<Pause>>) {
next_pause.set(Pause(true));
}
fn spawn_pause_overlay(mut commands: Commands) {
commands.spawn((
Name::new("Pause Overlay"),
Node {
width: Percent(100.0),
height: Percent(100.0),
..default()
},
GlobalZIndex(1),
BackgroundColor(Color::srgba(0.0, 0.0, 0.0, 0.8)),
StateScoped(Pause(true)),
));
}
fn open_pause_menu(mut next_menu: ResMut<NextState<Menu>>) {
next_menu.set(Menu::Pause);
}
fn close_menu(mut next_menu: ResMut<NextState<Menu>>) {
next_menu.set(Menu::None);
}

31
src/screens/loading.rs Normal file
View file

@ -0,0 +1,31 @@
//! A loading screen during which game assets are loaded if necessary.
//! This reduces stuttering, especially for audio on Wasm.
use bevy::prelude::*;
use crate::{asset_tracking::ResourceHandles, screens::Screen, theme::prelude::*};
pub(super) fn plugin(app: &mut App) {
app.add_systems(OnEnter(Screen::Loading), spawn_loading_screen);
app.add_systems(
Update,
enter_gameplay_screen.run_if(in_state(Screen::Loading).and(all_assets_loaded)),
);
}
fn spawn_loading_screen(mut commands: Commands) {
commands.spawn((
widget::ui_root("Loading Screen"),
StateScoped(Screen::Loading),
children![widget::label("Loading...")],
));
}
fn enter_gameplay_screen(mut next_screen: ResMut<NextState<Screen>>) {
next_screen.set(Screen::Gameplay);
}
fn all_assets_loaded(resource_handles: Res<ResourceHandles>) -> bool {
resource_handles.is_all_done()
}

30
src/screens/mod.rs Normal file
View file

@ -0,0 +1,30 @@
//! The game's main screen states and transitions between them.
mod gameplay;
mod loading;
mod splash;
mod title;
use bevy::prelude::*;
pub(super) fn plugin(app: &mut App) {
app.init_state::<Screen>();
app.add_plugins((
gameplay::plugin,
loading::plugin,
splash::plugin,
title::plugin,
));
}
/// The game's main screen states.
#[derive(States, Copy, Clone, Eq, PartialEq, Hash, Debug, Default)]
#[states(scoped_entities)]
pub enum Screen {
#[default]
Splash,
Title,
Loading,
Gameplay,
}

146
src/screens/splash.rs Normal file
View file

@ -0,0 +1,146 @@
//! A splash screen that plays briefly at startup.
use bevy::{
image::{ImageLoaderSettings, ImageSampler},
input::common_conditions::input_just_pressed,
prelude::*,
};
use crate::{AppSystems, screens::Screen, theme::prelude::*};
pub(super) fn plugin(app: &mut App) {
// Spawn splash screen.
app.insert_resource(ClearColor(SPLASH_BACKGROUND_COLOR));
app.add_systems(OnEnter(Screen::Splash), spawn_splash_screen);
// Animate splash screen.
app.add_systems(
Update,
(
tick_fade_in_out.in_set(AppSystems::TickTimers),
apply_fade_in_out.in_set(AppSystems::Update),
)
.run_if(in_state(Screen::Splash)),
);
// Add splash timer.
app.register_type::<SplashTimer>();
app.add_systems(OnEnter(Screen::Splash), insert_splash_timer);
app.add_systems(OnExit(Screen::Splash), remove_splash_timer);
app.add_systems(
Update,
(
tick_splash_timer.in_set(AppSystems::TickTimers),
check_splash_timer.in_set(AppSystems::Update),
)
.run_if(in_state(Screen::Splash)),
);
// Exit the splash screen early if the player hits escape.
app.add_systems(
Update,
enter_title_screen
.run_if(input_just_pressed(KeyCode::Escape).and(in_state(Screen::Splash))),
);
}
const SPLASH_BACKGROUND_COLOR: Color = Color::srgb(0.157, 0.157, 0.157);
const SPLASH_DURATION_SECS: f32 = 1.8;
const SPLASH_FADE_DURATION_SECS: f32 = 0.6;
fn spawn_splash_screen(mut commands: Commands, asset_server: Res<AssetServer>) {
commands.spawn((
widget::ui_root("Splash Screen"),
BackgroundColor(SPLASH_BACKGROUND_COLOR),
StateScoped(Screen::Splash),
children![(
Name::new("Splash image"),
Node {
margin: UiRect::all(Val::Auto),
width: Val::Percent(70.0),
..default()
},
ImageNode::new(asset_server.load_with_settings(
// This should be an embedded asset for instant loading, but that is
// currently [broken on Windows Wasm builds](https://github.com/bevyengine/bevy/issues/14246).
"images/splash.png",
|settings: &mut ImageLoaderSettings| {
// Make an exception for the splash image in case
// `ImagePlugin::default_nearest()` is used for pixel art.
settings.sampler = ImageSampler::linear();
},
)),
ImageNodeFadeInOut {
total_duration: SPLASH_DURATION_SECS,
fade_duration: SPLASH_FADE_DURATION_SECS,
t: 0.0,
},
)],
));
}
#[derive(Component, Reflect)]
#[reflect(Component)]
struct ImageNodeFadeInOut {
/// Total duration in seconds.
total_duration: f32,
/// Fade duration in seconds.
fade_duration: f32,
/// Current progress in seconds, between 0 and [`Self::total_duration`].
t: f32,
}
impl ImageNodeFadeInOut {
fn alpha(&self) -> f32 {
// Normalize by duration.
let t = (self.t / self.total_duration).clamp(0.0, 1.0);
let fade = self.fade_duration / self.total_duration;
// Regular trapezoid-shaped graph, flat at the top with alpha = 1.0.
((1.0 - (2.0 * t - 1.0).abs()) / fade).min(1.0)
}
}
fn tick_fade_in_out(time: Res<Time>, mut animation_query: Query<&mut ImageNodeFadeInOut>) {
for mut anim in &mut animation_query {
anim.t += time.delta_secs();
}
}
fn apply_fade_in_out(mut animation_query: Query<(&ImageNodeFadeInOut, &mut ImageNode)>) {
for (anim, mut image) in &mut animation_query {
image.color.set_alpha(anim.alpha())
}
}
#[derive(Resource, Debug, Clone, PartialEq, Reflect)]
#[reflect(Resource)]
struct SplashTimer(Timer);
impl Default for SplashTimer {
fn default() -> Self {
Self(Timer::from_seconds(SPLASH_DURATION_SECS, TimerMode::Once))
}
}
fn insert_splash_timer(mut commands: Commands) {
commands.init_resource::<SplashTimer>();
}
fn remove_splash_timer(mut commands: Commands) {
commands.remove_resource::<SplashTimer>();
}
fn tick_splash_timer(time: Res<Time>, mut timer: ResMut<SplashTimer>) {
timer.0.tick(time.delta());
}
fn check_splash_timer(timer: ResMut<SplashTimer>, mut next_screen: ResMut<NextState<Screen>>) {
if timer.0.just_finished() {
next_screen.set(Screen::Title);
}
}
fn enter_title_screen(mut next_screen: ResMut<NextState<Screen>>) {
next_screen.set(Screen::Title);
}

18
src/screens/title.rs Normal file
View file

@ -0,0 +1,18 @@
//! The title screen that appears after the splash screen.
use bevy::prelude::*;
use crate::{menus::Menu, screens::Screen};
pub(super) fn plugin(app: &mut App) {
app.add_systems(OnEnter(Screen::Title), open_main_menu);
app.add_systems(OnExit(Screen::Title), close_menu);
}
fn open_main_menu(mut next_menu: ResMut<NextState<Menu>>) {
next_menu.set(Menu::Main);
}
fn close_menu(mut next_menu: ResMut<NextState<Menu>>) {
next_menu.set(Menu::None);
}

89
src/theme/interaction.rs Normal file
View file

@ -0,0 +1,89 @@
use bevy::prelude::*;
use crate::{asset_tracking::LoadResource, audio::sound_effect};
pub(super) fn plugin(app: &mut App) {
app.register_type::<InteractionPalette>();
app.add_systems(Update, apply_interaction_palette);
app.register_type::<InteractionAssets>();
app.load_resource::<InteractionAssets>();
app.add_observer(play_on_hover_sound_effect);
app.add_observer(play_on_click_sound_effect);
}
/// Palette for widget interactions. Add this to an entity that supports
/// [`Interaction`]s, such as a button, to change its [`BackgroundColor`] based
/// on the current interaction state.
#[derive(Component, Debug, Reflect)]
#[reflect(Component)]
pub struct InteractionPalette {
pub none: Color,
pub hovered: Color,
pub pressed: Color,
}
fn apply_interaction_palette(
mut palette_query: Query<
(&Interaction, &InteractionPalette, &mut BackgroundColor),
Changed<Interaction>,
>,
) {
for (interaction, palette, mut background) in &mut palette_query {
*background = match interaction {
Interaction::None => palette.none,
Interaction::Hovered => palette.hovered,
Interaction::Pressed => palette.pressed,
}
.into();
}
}
#[derive(Resource, Asset, Clone, Reflect)]
#[reflect(Resource)]
struct InteractionAssets {
#[dependency]
hover: Handle<AudioSource>,
#[dependency]
click: Handle<AudioSource>,
}
impl FromWorld for InteractionAssets {
fn from_world(world: &mut World) -> Self {
let assets = world.resource::<AssetServer>();
Self {
hover: assets.load("audio/sound_effects/button_hover.ogg"),
click: assets.load("audio/sound_effects/button_click.ogg"),
}
}
}
fn play_on_hover_sound_effect(
trigger: Trigger<Pointer<Over>>,
mut commands: Commands,
interaction_assets: Option<Res<InteractionAssets>>,
interaction_query: Query<(), With<Interaction>>,
) {
let Some(interaction_assets) = interaction_assets else {
return;
};
if interaction_query.contains(trigger.target()) {
commands.spawn(sound_effect(interaction_assets.hover.clone()));
}
}
fn play_on_click_sound_effect(
trigger: Trigger<Pointer<Click>>,
mut commands: Commands,
interaction_assets: Option<Res<InteractionAssets>>,
interaction_query: Query<(), With<Interaction>>,
) {
let Some(interaction_assets) = interaction_assets else {
return;
};
if interaction_query.contains(trigger.target()) {
commands.spawn(sound_effect(interaction_assets.click.clone()));
}
}

19
src/theme/mod.rs Normal file
View file

@ -0,0 +1,19 @@
//! Reusable UI widgets & theming.
// Unused utilities may trigger this lints undesirably.
#![allow(dead_code)]
pub mod interaction;
pub mod palette;
pub mod widget;
#[allow(unused_imports)]
pub mod prelude {
pub use super::{interaction::InteractionPalette, palette as ui_palette, widget};
}
use bevy::prelude::*;
pub(super) fn plugin(app: &mut App) {
app.add_plugins(interaction::plugin);
}

16
src/theme/palette.rs Normal file
View file

@ -0,0 +1,16 @@
use bevy::prelude::*;
/// #ddd369
pub const LABEL_TEXT: Color = Color::srgb(0.867, 0.827, 0.412);
/// #fcfbcc
pub const HEADER_TEXT: Color = Color::srgb(0.988, 0.984, 0.800);
/// #ececec
pub const BUTTON_TEXT: Color = Color::srgb(0.925, 0.925, 0.925);
/// #4666bf
pub const BUTTON_BACKGROUND: Color = Color::srgb(0.275, 0.400, 0.750);
/// #6299d1
pub const BUTTON_HOVERED_BACKGROUND: Color = Color::srgb(0.384, 0.600, 0.820);
/// #3d4999
pub const BUTTON_PRESSED_BACKGROUND: Color = Color::srgb(0.239, 0.286, 0.600);

135
src/theme/widget.rs Normal file
View file

@ -0,0 +1,135 @@
//! Helper functions for creating common widgets.
use std::borrow::Cow;
use bevy::{
ecs::{spawn::SpawnWith, system::IntoObserverSystem},
prelude::*,
ui::Val::*,
};
use crate::theme::{interaction::InteractionPalette, palette::*};
/// A root UI node that fills the window and centers its content.
pub fn ui_root(name: impl Into<Cow<'static, str>>) -> impl Bundle {
(
Name::new(name),
Node {
position_type: PositionType::Absolute,
width: Percent(100.0),
height: Percent(100.0),
align_items: AlignItems::Center,
justify_content: JustifyContent::Center,
flex_direction: FlexDirection::Column,
row_gap: Px(20.0),
..default()
},
// Don't block picking events for other UI roots.
Pickable::IGNORE,
)
}
/// A simple header label. Bigger than [`label`].
pub fn header(text: impl Into<String>) -> impl Bundle {
(
Name::new("Header"),
Text(text.into()),
TextFont::from_font_size(40.0),
TextColor(HEADER_TEXT),
)
}
/// A simple text label.
pub fn label(text: impl Into<String>) -> impl Bundle {
(
Name::new("Label"),
Text(text.into()),
TextFont::from_font_size(24.0),
TextColor(LABEL_TEXT),
)
}
/// A large rounded button with text and an action defined as an [`Observer`].
pub fn button<E, B, M, I>(text: impl Into<String>, action: I) -> impl Bundle
where
E: Event,
B: Bundle,
I: IntoObserverSystem<E, B, M>,
{
button_base(
text,
action,
(
Node {
width: Px(380.0),
height: Px(80.0),
align_items: AlignItems::Center,
justify_content: JustifyContent::Center,
..default()
},
BorderRadius::MAX,
),
)
}
/// A small square button with text and an action defined as an [`Observer`].
pub fn button_small<E, B, M, I>(text: impl Into<String>, action: I) -> impl Bundle
where
E: Event,
B: Bundle,
I: IntoObserverSystem<E, B, M>,
{
button_base(
text,
action,
Node {
width: Px(30.0),
height: Px(30.0),
align_items: AlignItems::Center,
justify_content: JustifyContent::Center,
..default()
},
)
}
/// A simple button with text and an action defined as an [`Observer`]. The button's layout is provided by `button_bundle`.
fn button_base<E, B, M, I>(
text: impl Into<String>,
action: I,
button_bundle: impl Bundle,
) -> impl Bundle
where
E: Event,
B: Bundle,
I: IntoObserverSystem<E, B, M>,
{
let text = text.into();
let action = IntoObserverSystem::into_system(action);
(
Name::new("Button"),
Node::default(),
Children::spawn(SpawnWith(|parent: &mut ChildSpawner| {
parent
.spawn((
Name::new("Button Inner"),
Button,
BackgroundColor(BUTTON_BACKGROUND),
InteractionPalette {
none: BUTTON_BACKGROUND,
hovered: BUTTON_HOVERED_BACKGROUND,
pressed: BUTTON_PRESSED_BACKGROUND,
},
children![(
Name::new("Button Text"),
Text(text),
TextFont::from_font_size(40.0),
TextColor(BUTTON_TEXT),
// Don't bubble picking events from the text up to the button.
Pickable::IGNORE,
)],
))
.insert(button_bundle)
.observe(action);
})),
)
}