diff --git a/Cargo.lock b/Cargo.lock index 79c8e81..2b4624d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2549,6 +2549,12 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "dfa686283ad6dd069f105e5ab091b04c62850d3e4cf5d67debad1933f55023df" +[[package]] +name = "hound" +version = "3.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "62adaabb884c94955b19907d60019f4e145d091c75345379e70d1ee696f7854f" + [[package]] name = "image" version = "0.25.8" @@ -3787,6 +3793,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e7ceb6607dd738c99bc8cb28eff249b7cd5c8ec88b9db96c0608c1480d140fb1" dependencies = [ "cpal", + "hound", "lewton", ] diff --git a/Cargo.toml b/Cargo.toml index 6a6f6da..685b410 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -24,6 +24,7 @@ bevy = { version = "0.17.2", default-features = false, features = [ "png", "std", "vorbis", + "wav", "wayland", "webgl2", "x11", diff --git a/README.md b/README.md index af7ea36..66a7a08 100644 --- a/README.md +++ b/README.md @@ -11,7 +11,7 @@ If you are playing it before the jam entry is submitted, the password is `ldgjam ## Design Notes ### Plot -you are a trainee pilot thrust into the action when you stumble across the roguelike boss during a routine (tutorial) patrol +You are a space explorer searching for a new habitable planet for humanity. ### Areas classic shmup level @@ -35,12 +35,12 @@ cities for shopping and upgrading ### Required Art Assets -- [ ] Title -- [ ] Splash +- [x] Title +- [x] Splash - [x] Player Ship Sprite -- [ ] Player Projectile Sprite +- [x] Player Projectile Sprite - [x] Asteroid Sprite - [ ] Enemy Ship Sprite - [ ] Enemy Boss Ship Sprite -- [ ] Backgrounds -- [ ] Explosion Sprite/Animation +- [x] Backgrounds +- [x] Explosion Sprite/Animation diff --git a/assets/sfx/explosion.wav b/assets/sfx/explosion.wav new file mode 100644 index 0000000..e9135aa Binary files /dev/null and b/assets/sfx/explosion.wav differ diff --git a/assets/sfx/shooting.wav b/assets/sfx/shooting.wav new file mode 100644 index 0000000..b7cfbf4 Binary files /dev/null and b/assets/sfx/shooting.wav differ diff --git a/assets/soundtrack/the-last-parsec.ogg b/assets/soundtrack/the-last-parsec.ogg new file mode 100644 index 0000000..24eb898 Binary files /dev/null and b/assets/soundtrack/the-last-parsec.ogg differ diff --git a/assets/soundtrack/through-space.ogg b/assets/soundtrack/through-space.ogg new file mode 100644 index 0000000..8780a45 Binary files /dev/null and b/assets/soundtrack/through-space.ogg differ diff --git a/assets/sprites/explosion.aseprite b/assets/sprites/explosion.aseprite new file mode 100644 index 0000000..b7219ab Binary files /dev/null and b/assets/sprites/explosion.aseprite differ diff --git a/assets/sprites/explosion.png b/assets/sprites/explosion.png new file mode 100644 index 0000000..677c1ad Binary files /dev/null and b/assets/sprites/explosion.png differ diff --git a/assets/sprites/planet-smol.aseprite b/assets/sprites/planet-smol.aseprite new file mode 100644 index 0000000..4c06d69 Binary files /dev/null and b/assets/sprites/planet-smol.aseprite differ diff --git a/assets/sprites/planet-smol.png b/assets/sprites/planet-smol.png new file mode 100644 index 0000000..b22e846 Binary files /dev/null and b/assets/sprites/planet-smol.png differ diff --git a/assets/sprites/planet.aseprite b/assets/sprites/planet.aseprite new file mode 100644 index 0000000..6098972 Binary files /dev/null and b/assets/sprites/planet.aseprite differ diff --git a/assets/sprites/planet.png b/assets/sprites/planet.png new file mode 100644 index 0000000..48982ed Binary files /dev/null and b/assets/sprites/planet.png differ diff --git a/assets/sprites/ships/ship3-default.png b/assets/sprites/ships/ship3-default.png new file mode 100644 index 0000000..a97171a Binary files /dev/null and b/assets/sprites/ships/ship3-default.png differ diff --git a/assets/sprites/ships/ship3-fast.png b/assets/sprites/ships/ship3-fast.png new file mode 100644 index 0000000..95d7fec Binary files /dev/null and b/assets/sprites/ships/ship3-fast.png differ diff --git a/assets/sprites/ships/ship3-slow.png b/assets/sprites/ships/ship3-slow.png new file mode 100644 index 0000000..7ba5762 Binary files /dev/null and b/assets/sprites/ships/ship3-slow.png differ diff --git a/assets/sprites/ships/ship3.aseprite b/assets/sprites/ships/ship3.aseprite index 6faefb5..fb2920b 100644 Binary files a/assets/sprites/ships/ship3.aseprite and b/assets/sprites/ships/ship3.aseprite differ diff --git a/assets/sprites/ships/ship3.png b/assets/sprites/ships/ship3.png index 2607868..644774d 100644 Binary files a/assets/sprites/ships/ship3.png and b/assets/sprites/ships/ship3.png differ diff --git a/src/audio/mod.rs b/src/audio/mod.rs index fc04dd8..37a200e 100644 --- a/src/audio/mod.rs +++ b/src/audio/mod.rs @@ -4,68 +4,131 @@ use bevy::{ }; pub(super) fn plugin(app: &mut App) { - app.add_systems(Startup, setup_audio); - // app.add_observer(on_play_soundtrack_event); + app.init_resource::(); + app.init_resource::(); + app.add_observer(on_play_soundtrack_event); + app.add_observer(on_play_sfx_event); app.add_systems(Update, fade_in); } -// #[derive(Resource)] -// pub struct Soundtracks { -// pub main_theme: Handle, -// pub battle_theme: Handle, -// } +#[derive(Resource)] +pub struct SfxLibrary { + pub shoot: Handle, + pub explosion: Handle, +} + +impl FromWorld for SfxLibrary { + fn from_world(world: &mut World) -> Self { + let asset_server = world.resource::(); + let sfx = SfxLibrary { + shoot: asset_server.load("sfx/shooting.wav"), + explosion: asset_server.load("sfx/explosion.wav"), + }; + Self { + shoot: sfx.shoot, + explosion: sfx.explosion, + } + } +} + +pub enum SoundEffect { + Shoot, + Explosion, +} + +#[derive(Event)] +pub struct PlaySfxEvent { + pub sfx: SoundEffect, +} + +#[derive(Component)] +struct SoundtrackPlayer; + +#[derive(Resource)] +pub struct Soundtracks { + pub main_theme: Handle, + pub battle_theme: Handle, +} -// pub enum Soundtrack { -// MainTheme, -// BattleTheme, -// } +impl FromWorld for Soundtracks { + fn from_world(world: &mut World) -> Self { + let asset_server = world.resource::(); + let soundtracks = Soundtracks { + main_theme: asset_server.load("soundtrack/spacetheme.ogg"), + battle_theme: asset_server.load("soundtrack/through-space.ogg"), + }; + Self { + main_theme: soundtracks.main_theme, + battle_theme: soundtracks.battle_theme, + } + } +} -// #[derive(Event)] -// pub struct PlaySoundtrackEvent { -// soundtrack: Soundtrack, -// } +pub enum Soundtrack { + MainTheme, + BattleTheme, +} -fn setup_audio(mut commands: Commands, asset_server: Res) { - let main_handle: Handle = asset_server.load("soundtrack/spacetheme.ogg"); - // let soundtracks = Soundtracks { - // main_theme: main_handle.clone(), - // battle_theme: asset_server.load("audio/soundtrack/battle_theme.ogg"), - // }; +#[derive(Event)] +pub struct PlaySoundtrackEvent { + pub soundtrack: Soundtrack, +} - // commands.insert_resource(soundtracks); +fn on_play_soundtrack_event( + soundtrack_event: On, + mut commands: Commands, + soundtracks: Res, + mut audio_player: Query< + (Entity, &mut AudioPlayer, &mut PlaybackSettings), + With, + >, +) { + let track_handle = match soundtrack_event.soundtrack { + Soundtrack::MainTheme => soundtracks.main_theme.clone(), + Soundtrack::BattleTheme => soundtracks.battle_theme.clone(), + }; + + let Ok((entity, mut audio, mut playback)) = audio_player.single_mut() else { + commands.spawn(( + SoundtrackPlayer, + AudioPlayer(track_handle.clone()), + PlaybackSettings { + mode: PlaybackMode::Loop, + volume: Volume::Linear(0.0), + ..default() + }, + FadeIn { duration: 4.0 }, + Transform::default(), + GlobalTransform::default(), + )); + return; + }; + + if audio.0 != track_handle { + audio.0 = track_handle.clone(); + playback.volume = Volume::Linear(0.0); + commands.entity(entity).insert(FadeIn { duration: 4.0 }); + } +} + +fn on_play_sfx_event(sfx_event: On, mut commands: Commands, sfx: Res) { + let sfx_handle = match sfx_event.sfx { + SoundEffect::Shoot => sfx.shoot.clone(), + SoundEffect::Explosion => sfx.explosion.clone(), + }; commands.spawn(( - AudioPlayer(main_handle.clone()), + AudioPlayer(sfx_handle), PlaybackSettings { - mode: PlaybackMode::Loop, - volume: Volume::Linear(0.0), + mode: PlaybackMode::Remove, + volume: Volume::Linear(0.1), ..default() }, - FadeIn { duration: 4.0 }, Transform::default(), GlobalTransform::default(), )); } -// fn on_play_soundtrack_event( -// soundtrack_event: On, -// soundtracks: Res, -// ) { -// let track_handle = match soundtrack_event.soundtrack { -// Soundtrack::MainTheme => soundtracks.main_theme.clone(), -// Soundtrack::BattleTheme => soundtracks.battle_theme.clone(), -// }; - -// commands.spawn(( -// AudioPlayer(track_handle), -// PlaybackSettings { -// mode: PlaybackMode::Loop, -// volume: Volume::Linear(1.0), -// ..default() -// }, -// )); -// } - #[derive(Component)] struct FadeIn { duration: f32, diff --git a/src/main.rs b/src/main.rs index a746f17..5db336f 100644 --- a/src/main.rs +++ b/src/main.rs @@ -8,11 +8,6 @@ use bevy::{ window::{WindowMode, WindowResized, WindowResolution}, }; -// #[cfg(feature = "dev")] -// use bevy::remote::RemotePlugin; -// #[cfg(feature = "dev")] -// use bevy::remote::http::RemoteHttpPlugin; - use bevy_embedded_assets::{EmbeddedAssetPlugin, PluginMode}; use crate::input::InputPlugin; @@ -20,6 +15,7 @@ use crate::input::InputPlugin; mod audio; mod input; mod screens; +mod stars; mod sundry; const RES_WIDTH: u32 = 853; @@ -59,7 +55,10 @@ impl Plugin for AppPlugin { primary_window: Some(Window { title: "Star Journey".to_string(), fit_canvas_to_parent: true, + #[cfg(not(debug_assertions))] mode: WindowMode::BorderlessFullscreen(MonitorSelection::Current), + #[cfg(debug_assertions)] + mode: WindowMode::Windowed, resolution: WindowResolution::new(1920, 1080), resizable: false, position: WindowPosition::Centered(MonitorSelection::Current), @@ -76,8 +75,7 @@ impl Plugin for AppPlugin { app.add_plugins(screens::plugin); - // #[cfg(feature = "dev")] - // app.add_plugins((RemotePlugin::default(), RemoteHttpPlugin::default())); + app.add_plugins(stars::plugin); app.init_state::(); app.configure_sets(Update, PausableSystems.run_if(in_state(Pause(false)))); diff --git a/src/screens/game.rs b/src/screens/game.rs index 91ca916..fa6d19c 100644 --- a/src/screens/game.rs +++ b/src/screens/game.rs @@ -2,7 +2,21 @@ use bevy::prelude::*; use leafwing_input_manager::prelude::*; use rand::random_range; -use crate::{PIXEL_PERFECT_LAYERS, input::Action, screens::Screen}; +use crate::{ + PIXEL_PERFECT_LAYERS, + audio::{PlaySfxEvent, PlaySoundtrackEvent, SoundEffect, Soundtrack}, + input::Action, + screens::Screen, +}; + +// #[derive(Resource)] +// pub struct GameStats { +// pub asteroids_destroyed: u32, +// pub distance_traveled: u32, +// pub shots_fired: u32, +// pub shots_hit: u32, +// pub time_played: f32, +// } #[derive(States, Copy, Clone, Eq, PartialEq, Hash, Debug, Default)] pub enum GameState { @@ -11,11 +25,31 @@ pub enum GameState { GameOver, } +#[derive(Component)] +struct Thruster; + +#[derive(Resource, PartialEq)] +struct HasEntered(bool); + pub(super) fn plugin(app: &mut App) { app.init_state::(); + app.insert_resource::(HasEntered(false)); + app.add_systems( - OnEnter(GameState::Play), - spawn_game_screen.run_if(in_state(Screen::Game)), + OnEnter(Screen::Game), + |mut has_entered: ResMut| { + has_entered.0 = false; + }, + ); + + app.add_systems(OnEnter(Screen::Game), trigger_music); + app.add_systems(OnEnter(GameState::Play), spawn_game_screen); + app.add_systems( + FixedUpdate, + enter_player.run_if( + in_state(Screen::Game) + .and(in_state(GameState::Play).and(resource_equals(HasEntered(false)))), + ), ); app.add_systems(OnEnter(GameState::GameOver), spawn_game_over); app.add_systems(OnExit(GameState::GameOver), despawn_game_screen); @@ -29,6 +63,7 @@ pub(super) fn plugin(app: &mut App) { player_input, update_projectiles, projectile_asteroid_collision, + update_explosions, ) .in_set(GameSystems::Play) .run_if(in_state(Screen::Game)), @@ -54,7 +89,6 @@ pub(super) fn plugin(app: &mut App) { spawn_asteroids, update_asteroids, collide_with_asteroid_check, - update_stars, ) .in_set(GameSystems::Environment) .run_if(in_state(Screen::Game)), @@ -71,6 +105,7 @@ fn spawn_game_screen( asset_server: Res, mut time: ResMut>, ) { + let default_speed = asset_server.load("sprites/ships/ship3-default.png"); commands.spawn(( Name::new("Player Ship"), Player { @@ -83,52 +118,51 @@ fn spawn_game_screen( custom_size: Some(Vec2::splat(32.0)), ..default() }, + ShipSpeedSprites { + slow: asset_server.load("sprites/ships/ship3-slow.png"), + default: default_speed.clone(), + fast: asset_server.load("sprites/ships/ship3-fast.png"), + }, Transform { - translation: Vec3::new(0.0, -200.0, 0.0), + translation: Vec3::new(0.0, -280.0, 0.0), ..default() }, PIXEL_PERFECT_LAYERS, + children![( + Name::new("Player Ship Thruster"), + Thruster, + Sprite { + image: default_speed.clone(), + custom_size: Some(Vec2::splat(32.0)), + ..default() + } + )], )); - spawn_default_stars(&mut commands, &asset_server); - time.unpause(); } -fn spawn_default_stars(commands: &mut Commands, asset_server: &Res) { - let star_count = 100; - for _ in 0..star_count { - let x = random_range(-400.0..400.0); - let y = random_range(-220.0..220.0); - let variant = random_range(1..=2); - - let brightness: f32 = random_range(0.6..1.0); - let brightness = brightness * brightness; - - let color = if random_range(0.0..1.0) < 0.5 { - Color::linear_rgb(brightness, brightness * 0.9, brightness * 0.7) // warmer star - } else { - Color::linear_rgb(brightness * 0.8, brightness * 0.9, brightness) // cooler star - }; - - commands.spawn(( - Name::new("Star"), - Star { - speed: random_range(0.5..=1.0), - }, - Sprite { - image: asset_server.load(format!("sprites/stars/star{variant}.png")), - color, - ..default() - }, - Transform { - translation: Vec3::new(x, y, -10.0), - ..default() - }, - DespawnOnExit(Screen::Game), - PIXEL_PERFECT_LAYERS, - )); +fn enter_player( + mut player: Query<(&mut Transform, &ShipSpeedSprites), With>, + mut thruster: Query<&mut Sprite, With>, + mut has_entered: ResMut, +) { + let player = player.single_mut(); + if player.is_err() { + return; + } + let (mut player_transform, ship_speed_sprites) = player.unwrap(); + player_transform.translation.y += 3.0; + if player_transform.translation.y > -200.0 { + player_transform.translation.y = -200.0; + has_entered.0 = true; } + let thruster = thruster.single_mut(); + if thruster.is_err() { + return; + } + let mut thruster_sprite = thruster.unwrap(); + thruster_sprite.image = ship_speed_sprites.fast.clone(); } fn despawn_game_screen( @@ -163,17 +197,20 @@ struct Player { } #[derive(Component)] -struct Projectile; +struct ShipSpeedSprites { + slow: Handle, + default: Handle, + fast: Handle, +} #[derive(Component)] -pub struct Star { - pub speed: f32, -} +struct Projectile; #[derive(Component)] struct Asteroid { pub speed: f32, pub rotation_speed: f32, + pub health: u8, } #[derive(Resource)] @@ -182,40 +219,64 @@ struct AsteroidSpawnTimer(Timer); fn player_input( mut commands: Commands, input_query: Query<&ActionState>, - mut player_query: Query<(&mut Transform, &mut Player)>, + mut player_query: Query<(&mut Transform, &mut Player, &ShipSpeedSprites)>, + mut thruster: Query<&mut Sprite, With>, time: Res>, asset_server: Res, ) { let action_state = input_query.single().unwrap(); - let (mut player_transform, mut player) = player_query.single_mut().unwrap(); + let player = player_query.single_mut(); + if player.is_err() { + return; + } + let (mut player_transform, mut player, ship_speed_sprites) = player.unwrap(); player.timer.tick(time.delta()); if player.timer.is_finished() { player.can_shoot = true; } - let player_speed = 6.0; + let player_speed = 3.0; + + let mut intent = Vec2::ZERO; if action_state.pressed(&Action::Up) { - player_transform.translation += Vec3::Y * player_speed; + intent += Vec2::Y; } if action_state.pressed(&Action::Down) { - player_transform.translation -= Vec3::Y * player_speed; + intent -= Vec2::Y; } if action_state.pressed(&Action::Left) { - player_transform.translation -= Vec3::X * player_speed; + intent -= Vec2::X; } if action_state.pressed(&Action::Right) { - player_transform.translation += Vec3::X * player_speed; + intent += Vec2::X; + } + + if intent != Vec2::ZERO { + player_transform.translation += intent.normalize().extend(0.0) * player_speed; + player_transform.translation.x = player_transform.translation.x.clamp(-400.0, 400.0); + player_transform.translation.y = player_transform.translation.y.clamp(-220.0, 220.0); } - player_transform.translation.x = player_transform.translation.x.clamp(-400.0, 400.0); - player_transform.translation.y = player_transform.translation.y.clamp(-220.0, 220.0); + let thruster = thruster.single_mut(); + if let Ok(mut thruster_sprite) = thruster { + if action_state.pressed(&Action::Up) { + thruster_sprite.image = ship_speed_sprites.fast.clone(); + } else if action_state.pressed(&Action::Down) { + thruster_sprite.image = ship_speed_sprites.slow.clone(); + } else { + thruster_sprite.image = ship_speed_sprites.default.clone(); + } + } if action_state.pressed(&Action::Shoot) && player.can_shoot { + commands.trigger(PlaySfxEvent { + sfx: SoundEffect::Shoot, + }); commands.spawn(( Name::new("Player Projectile"), PIXEL_PERFECT_LAYERS, @@ -319,17 +380,6 @@ fn update_asteroids( } } -fn update_stars(mut stars: Query<(&mut Transform, &mut Star)>) { - for (mut transform, mut star) in stars.iter_mut() { - transform.translation -= Vec3::Y * star.speed; - if transform.translation.y < -240.0 { - transform.translation.y = 240.0 + random_range(0.0..20.0); - transform.translation.x = random_range(-400.0..400.0); - star.speed = random_range(0.5..=1.0); - } - } -} - fn spawn_asteroid( commands: &mut Commands, asset_server: &Res, @@ -338,6 +388,7 @@ fn spawn_asteroid( ) { let image_index = random_range(3..=4); let image = format!("sprites/asteroids/asteroid{image_index}.png"); + let health = if size.x > 32.0 { 5 } else { 2 }; commands.spawn(( Name::new("Asteroid"), Sprite { @@ -353,6 +404,7 @@ fn spawn_asteroid( ..default() }, Asteroid { + health, speed: random_range(1.0..2.0), rotation_speed: random_range(-0.02..0.02), }, @@ -379,31 +431,75 @@ fn collide_with_asteroid_check( .translation .distance(asteroid_transform.translation); if distance < (hitbox_size / 2.0) + (30.0 / 2.0) { - println!("Player hit by an asteroid!"); time.pause(); state.set(GameState::GameOver); } } } +#[derive(Component)] +struct Explosion; + fn projectile_asteroid_collision( mut commands: Commands, + asset_server: Res, projectiles: Query<(Entity, &Transform), With>, - asteroids: Query<(Entity, &Transform), With>, + mut asteroids: Query<(Entity, &Transform, &mut Asteroid)>, + mut texture_atlases: ResMut>, ) { for (projectile_entity, projectile_transform) in projectiles.iter() { - for (asteroid_entity, asteroid_transform) in asteroids.iter() { + for (asteroid_entity, asteroid_transform, mut asteroid) in asteroids.iter_mut() { let distance = projectile_transform .translation .distance(asteroid_transform.translation); if distance < (16.0 / 2.0) + (30.0 / 2.0) { + asteroid.health -= 1; + if asteroid.health == 0 { + commands.entity(asteroid_entity).despawn(); + } commands.entity(projectile_entity).despawn(); - commands.entity(asteroid_entity).despawn(); + let texture_atlas_layout = + TextureAtlasLayout::from_grid(UVec2::splat(32), 8, 1, None, None); + let texture_atlas_handle = texture_atlases.add(texture_atlas_layout); + commands.trigger(PlaySfxEvent { + sfx: SoundEffect::Explosion, + }); + commands.spawn(( + Name::new("Explosion"), + Sprite::from_atlas_image( + asset_server.load("sprites/explosion.png"), + TextureAtlas::from(texture_atlas_handle), + ), + Transform { + translation: projectile_transform.translation, + ..default() + }, + DespawnOnExit(Screen::Game), + Explosion, + PIXEL_PERFECT_LAYERS, + )); } } } } +fn update_explosions( + mut commands: Commands, + mut explosions: Query<(Entity, &mut Transform, &mut Sprite), With>, +) { + for (entity, mut transform, mut sprite) in explosions.iter_mut() { + transform.translation -= Vec3::Y * 2.0; + let atlas = sprite.texture_atlas.as_mut().unwrap(); + atlas.index += 1; + if atlas.index >= 8 { + commands.entity(entity).despawn(); + } + sprite.texture_atlas = Some(atlas.clone()); + + // Update sprite animation here + } +} + fn spawn_game_over(mut commands: Commands) { commands.spawn(( Name::new("Game Over Screen"), @@ -438,17 +534,28 @@ fn spawn_game_over(mut commands: Commands) { fn game_over_input( input_query: Query<&ActionState>, + current_game_state: Res>, mut state: ResMut>, interaction_query: Query<&Interaction, (Changed, With