first committ, still fighting with... a lot! 😅
This commit is contained in:
commit
495556500f
74 changed files with 18135 additions and 0 deletions
71
src/asset_tracking.rs
Normal file
71
src/asset_tracking.rs
Normal 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
47
src/audio.rs
Normal 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
203
src/demo/animation.rs
Normal 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
379
src/demo/level.rs
Normal 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
20
src/demo/mod.rs
Normal 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
133
src/demo/movement.rs
Normal 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
156
src/demo/player.rs
Normal 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
52
src/dev_tools.rs
Normal 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
126
src/main.rs
Normal 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
113
src/menus/credits.rs
Normal 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
55
src/menus/main.rs
Normal 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
30
src/menus/mod.rs
Normal 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
43
src/menus/pause.rs
Normal 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
125
src/menus/settings.rs
Normal 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
61
src/screens/gameplay.rs
Normal 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
31
src/screens/loading.rs
Normal 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
30
src/screens/mod.rs
Normal 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
146
src/screens/splash.rs
Normal 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.
|
||||
"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
18
src/screens/title.rs
Normal 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
89
src/theme/interaction.rs
Normal 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
19
src/theme/mod.rs
Normal 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
16
src/theme/palette.rs
Normal 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
135
src/theme/widget.rs
Normal 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);
|
||||
})),
|
||||
)
|
||||
}
|
Loading…
Add table
Add a link
Reference in a new issue