diff --git a/assets/map/elements/item/crate/crate.atlas.yaml b/assets/map/elements/item/crate/crate.atlas.yaml new file mode 100644 index 0000000000..fbb7e6cf45 --- /dev/null +++ b/assets/map/elements/item/crate/crate.atlas.yaml @@ -0,0 +1,4 @@ +image: ./crate.png +tile_size: [36, 31] +rows: 1 +columns: 1 diff --git a/assets/map/elements/item/crate/crate.element.yaml b/assets/map/elements/item/crate/crate.element.yaml index 1f875fb200..ad1891a365 100644 --- a/assets/map/elements/item/crate/crate.element.yaml +++ b/assets/map/elements/item/crate/crate.element.yaml @@ -1,2 +1,18 @@ name: Crate category: Weapons +builtin: !Crate + throw_velocity: [7, 5] + + atlas: ./crate.atlas.yaml + + breaking_atlas: ./crate_breaking.atlas.yaml + breaking_anim_length: 25 + breaking_anim_fps: 30 + + # TODO: Better break sound + break_sound: ./fuse.ogg + + body_size: [36, 30] + body_offset: [0, 0] + grab_offset: [14, -2] + break_timeout: 4 diff --git a/assets/map/elements/item/crate/crate.png b/assets/map/elements/item/crate/crate.png new file mode 100644 index 0000000000..00304e03e7 Binary files /dev/null and b/assets/map/elements/item/crate/crate.png differ diff --git a/assets/map/elements/item/crate/crate_breaking.atlas.yaml b/assets/map/elements/item/crate/crate_breaking.atlas.yaml new file mode 100644 index 0000000000..f2228373c0 --- /dev/null +++ b/assets/map/elements/item/crate/crate_breaking.atlas.yaml @@ -0,0 +1,4 @@ +image: ./crate_breaking.png +tile_size: [128, 128] +rows: 1 +columns: 25 diff --git a/assets/map/elements/item/crate/crate_breaking.png b/assets/map/elements/item/crate/crate_breaking.png new file mode 100644 index 0000000000..7e2b43f768 Binary files /dev/null and b/assets/map/elements/item/crate/crate_breaking.png differ diff --git a/assets/map/elements/item/crate/fuse.ogg b/assets/map/elements/item/crate/fuse.ogg new file mode 100644 index 0000000000..1d3007e825 Binary files /dev/null and b/assets/map/elements/item/crate/fuse.ogg differ diff --git a/assets/map/elements/item/grenade/grenade.element.yaml b/assets/map/elements/item/grenade/grenade.element.yaml index fc36419068..88e2db68e2 100644 --- a/assets/map/elements/item/grenade/grenade.element.yaml +++ b/assets/map/elements/item/grenade/grenade.element.yaml @@ -1,18 +1,23 @@ name: Grenades category: Weapons -builtin: !Grenades - atlas: ./grenade.atlas.yaml - explosion_atlas: ./explosion.atlas.yaml +builtin: !Grenade fuse_time: 4.0 throw_velocity: [7, 5] damage_region_size: [60, 60] damage_region_lifetime: 0.6 + + atlas: ./grenade.atlas.yaml + + explosion_atlas: ./explosion.atlas.yaml explosion_lifetime: 1.0 explosion_frames: 12 explosion_fps: 8 explosion_sound: ./explosion.ogg + fuse_sound: ./fuse.ogg + body_size: [18, 18] grab_offset: [-7, -6] body_offset: [0, 2] + # TODO: Enable and fix rotation problems can_rotate: false diff --git a/assets/map/levels/level1.map.yaml b/assets/map/levels/level1.map.yaml index 069173fb71..1ce89af3d1 100644 --- a/assets/map/levels/level1.map.yaml +++ b/assets/map/levels/level1.map.yaml @@ -1222,6 +1222,10 @@ layers: - 536.0 - 309.5 element: ../elements/item/grenade/grenade.element.yaml + - pos: + - 719.0 + - 400.5 + element: ../elements/item/crate/crate.element.yaml - pos: - 292.2272 - 609.5 diff --git a/src/assets.rs b/src/assets.rs index 3fb511d934..e4690f53da 100644 --- a/src/assets.rs +++ b/src/assets.rs @@ -416,7 +416,7 @@ impl AssetLoader for MapElementMetaLoader { dependencies.push(path); *sound_handle = handle.typed(); } - BuiltinElementKind::Grenades { + BuiltinElementKind::Grenade { atlas, atlas_handle, explosion_atlas, @@ -445,6 +445,30 @@ impl AssetLoader for MapElementMetaLoader { *handle = sound_handle.typed(); } } + BuiltinElementKind::Crate { + atlas, + atlas_handle, + breaking_atlas, + breaking_atlas_handle, + break_sound, + break_sound_handle, + .. + } => { + for (atlas, atlas_handle) in [ + (atlas, atlas_handle), + (breaking_atlas, breaking_atlas_handle), + ] { + let (path, handle) = get_relative_asset(load_context, self_path, atlas); + *atlas_handle = AssetHandle::new(path.clone(), handle.typed()); + dependencies.push(path); + } + + let (sound, handle) = (break_sound, break_sound_handle); + let (sound_path, sound_handle) = + get_relative_asset(load_context, self_path, sound); + dependencies.push(sound_path); + *handle = sound_handle.typed(); + } } // Load preloaded assets diff --git a/src/map.rs b/src/map.rs index 0f6dc2222d..06e0977848 100644 --- a/src/map.rs +++ b/src/map.rs @@ -39,6 +39,7 @@ impl Plugin for MapPlugin { /// script. #[derive(Reflect, Component, Default)] #[reflect(Component, Default)] +#[component(storage = "SparseSet")] pub struct MapElementHydrated; /// If this component and a [`Transform`] component is added to any entity, it will be moved back to diff --git a/src/map/elements.rs b/src/map/elements.rs index eac51f17b2..39d64048dc 100644 --- a/src/map/elements.rs +++ b/src/map/elements.rs @@ -18,6 +18,7 @@ pub mod player_spawner; pub mod sproinger; // Items +pub mod crate_item; pub mod grenade; pub mod sword; @@ -27,6 +28,7 @@ impl Plugin for MapElementsPlugin { fn build(&self, app: &mut App) { app.add_plugin(decoration::DecorationPlugin) .add_plugin(grenade::GrenadePlugin) + .add_plugin(crate_item::CrateItemPlugin) .add_plugin(player_spawner::PlayerSpawnerPlugin) .add_plugin(sproinger::SproingerPlugin) .add_plugin(sword::SwordPlugin); diff --git a/src/map/elements/crate_item.rs b/src/map/elements/crate_item.rs new file mode 100644 index 0000000000..2a30f5c4ed --- /dev/null +++ b/src/map/elements/crate_item.rs @@ -0,0 +1,336 @@ +//! The crate item. +//! +//! This module is inconsistently named with the rest of the modules ( i.e. has an `_item` suffix ) +//! because `crate` is a Rust keyword. + +use crate::{physics::collisions::TileCollision, player::PlayerKillCommand}; + +use super::*; + +pub struct CrateItemPlugin; + +#[derive(Reflect, Component, Clone, Debug)] +#[reflect(Component)] +pub struct IdleCrateItem { + /// The entity ID of the map element that spawned the crate + spawner: Entity, +} + +impl Default for IdleCrateItem { + fn default() -> Self { + Self { + spawner: crate::utils::invalid_entity(), + } + } +} + +#[derive(Reflect, Component, Clone, Debug)] +#[reflect(Component, Default)] +pub struct ThrownCrateItem { + /// The entity ID of the map element that spawned the crate + spawner: Entity, + /// The entity ID of the player that threw the box + owner: Entity, + age: f32, +} + +impl Default for ThrownCrateItem { + fn default() -> Self { + Self { + spawner: crate::utils::invalid_entity(), + owner: crate::utils::invalid_entity(), + age: 0.0, + } + } +} + +impl Plugin for CrateItemPlugin { + fn build(&self, app: &mut App) { + app.add_rollback_system(RollbackStage::PreUpdate, pre_update_in_game) + .add_rollback_system(RollbackStage::Update, update_thrown_crates) + .add_rollback_system(RollbackStage::Update, update_idle_crates) + .extend_rollback_plugin(|plugin| { + plugin + .register_rollback_type::() + .register_rollback_type::() + }); + } +} + +fn pre_update_in_game( + mut commands: Commands, + non_hydrated_map_elements: Query< + (Entity, &Sort, &Handle, &Transform), + Without, + >, + mut ridp: ResMut, + element_assets: ResMut>, +) { + // Hydrate any newly-spawned crates + let mut elements = non_hydrated_map_elements.iter().collect::>(); + elements.sort_by_key(|x| x.1); + for (entity, _sort, map_element_handle, transform) in elements { + let map_element = element_assets.get(map_element_handle).unwrap(); + if let BuiltinElementKind::Crate { + body_size, + body_offset, + atlas_handle, + .. + } = &map_element.builtin + { + commands.entity(entity).insert(MapElementHydrated); + + commands + .spawn() + .insert(Rollback::new(ridp.next_id())) + .insert(Item { + script: "core:crate".into(), + }) + .insert(IdleCrateItem { spawner: entity }) + .insert(EntityName("Item: Crate".into())) + .insert(AnimatedSprite { + start: 0, + end: 0, + atlas: atlas_handle.inner.clone(), + repeat: false, + ..default() + }) + .insert(map_element_handle.clone_weak()) + .insert_bundle(VisibilityBundle::default()) + .insert(MapRespawnPoint(transform.translation)) + .insert_bundle(TransformBundle { + local: *transform, + ..default() + }) + .insert(KinematicBody { + size: *body_size, + offset: *body_offset, + gravity: 1.0, + has_mass: true, + has_friction: true, + ..default() + }); + } + } +} + +fn update_idle_crates( + mut commands: Commands, + players: Query<(&AnimatedSprite, &Transform, &KinematicBody), With>, + mut grenades: Query< + ( + &Rollback, + Entity, + &IdleCrateItem, + &mut Transform, + &mut AnimatedSprite, + &mut KinematicBody, + &Handle, + Option<&Parent>, + Option<&ItemUsed>, + Option<&ItemDropped>, + ), + Without, + >, + mut ridp: ResMut, + element_assets: ResMut>, +) { + let mut items = grenades.iter_mut().collect::>(); + items.sort_by_key(|x| x.0.id()); + for ( + _, + item_ent, + crate_item, + mut transform, + mut sprite, + mut body, + meta_handle, + parent, + used, + dropped, + ) in items + { + let meta = element_assets.get(meta_handle).unwrap(); + let BuiltinElementKind::Crate { + grab_offset, + atlas_handle, + throw_velocity, + .. + } = &meta.builtin else { + unreachable!(); + }; + + // If the item is being held + if let Some(parent) = parent { + let (player_sprite, player_transform, player_body) = + players.get(parent.get()).expect("Parent is not player"); + + // Deactivate items while held + body.is_deactivated = true; + + // Flip the sprite to match the player orientation + let flip = player_sprite.flip_x; + sprite.flip_x = flip; + let flip_factor = if flip { -1.0 } else { 1.0 }; + let horizontal_flip_factor = Vec2::new(flip_factor, 1.0); + transform.translation.x = grab_offset.x * flip_factor; + transform.translation.y = grab_offset.y; + transform.translation.z = 0.0; + + // If the item is being used + if used.is_some() { + // Despawn the item from the player's hand + commands.entity(item_ent).despawn(); + + // Spawn a new, lit grenade + commands + .spawn() + .insert(Rollback::new(ridp.next_id())) + .insert(Name::new("Crate ( Thrown )")) + .insert(Transform::from_translation( + player_transform.translation + + (*grab_offset * horizontal_flip_factor).extend(0.0), + )) + .insert(GlobalTransform::default()) + .insert(Visibility::default()) + .insert(ComputedVisibility::default()) + .insert(AnimatedSprite { + atlas: atlas_handle.inner.clone(), + ..default() + }) + .insert(meta_handle.clone_weak()) + .insert(body.clone()) + .insert(ThrownCrateItem { + spawner: crate_item.spawner, + owner: parent.get(), + ..default() + }) + .insert(KinematicBody { + velocity: *throw_velocity * horizontal_flip_factor + player_body.velocity, + is_deactivated: false, + fall_through: true, + ..body.clone() + }); + } + } + + // If the item is dropped + if let Some(dropped) = dropped { + commands.entity(item_ent).remove::(); + let (player_sprite, player_transform, player_body) = + players.get(dropped.player).expect("Parent is not a player"); + + // Re-activate physics + body.is_deactivated = false; + + // Put sword in rest position + sprite.start = 0; + sprite.end = 0; + body.velocity = player_body.velocity; + body.is_spawning = true; + + let horizontal_flip_factor = if player_sprite.flip_x { + Vec2::new(-1.0, 1.0) + } else { + Vec2::ONE + }; + + // Drop item at player position + transform.translation = + player_transform.translation + (*grab_offset * horizontal_flip_factor).extend(0.0); + } + } +} + +fn update_thrown_crates( + mut commands: Commands, + players: Query>, + mut grenades: Query< + ( + &Rollback, + Entity, + &mut ThrownCrateItem, + &Transform, + &Handle, + ), + Without, + >, + mut ridp: ResMut, + element_assets: ResMut>, + player_inputs: Res, + effects: Res>, + collision_world: CollisionWorld, +) { + let mut items = grenades.iter_mut().collect::>(); + items.sort_by_key(|x| x.0.id()); + for (_, item_ent, mut crate_item, transform, meta_handle) in items { + let meta = element_assets.get(meta_handle).unwrap(); + let BuiltinElementKind::Crate { + breaking_atlas_handle, + breaking_anim_fps, + breaking_anim_length, + break_sound_handle, + break_timeout, + .. + } = &meta.builtin else { + unreachable!(); + }; + let frame_time = 1.0 / crate::FPS as f32; + + crate_item.age += frame_time; + + let colliding_with_wall = { + let collider = collision_world.get_collider(item_ent); + let width = collider.width + 2.0; + let height = collider.width + 2.0; + let pos = transform.translation.truncate(); + collision_world.collide_solids(pos, width, height) == TileCollision::Solid + }; + + let colliding_with_players = collision_world + .actor_collisions(item_ent) + .into_iter() + .filter(|&x| x != crate_item.owner && players.contains(x)) + .collect::>(); + + for &player in &colliding_with_players { + commands.add(PlayerKillCommand::new(player)); + } + + if !colliding_with_players.is_empty() + || colliding_with_wall + || crate_item.age > *break_timeout + { + if player_inputs.is_confirmed { + effects.play(break_sound_handle.clone_weak()); + } + + // Despawn the grenade + commands.entity(item_ent).despawn(); + // Cause the item to re-spawn by re-triggering spawner hydration + commands + .entity(crate_item.spawner) + .remove::(); + + // Spawn the explosion sprite entity + commands + .spawn() + .insert(Rollback::new(ridp.next_id())) + .insert(*transform) + .insert(GlobalTransform::default()) + .insert(Visibility::default()) + .insert(ComputedVisibility::default()) + .insert(AnimatedSprite { + start: 0, + end: *breaking_anim_length, + atlas: breaking_atlas_handle.inner.clone(), + repeat: false, + fps: *breaking_anim_fps, + ..default() + }) + .insert(Lifetime::new( + *breaking_anim_fps * *breaking_anim_length as f32, + )); + } + } +} diff --git a/src/map/elements/grenade.rs b/src/map/elements/grenade.rs index 4ec646f53a..aab9828d0b 100644 --- a/src/map/elements/grenade.rs +++ b/src/map/elements/grenade.rs @@ -65,7 +65,7 @@ fn pre_update_in_game( elements.sort_by_key(|x| x.1); for (entity, _sort, map_element_handle, transform) in elements { let map_element = element_assets.get(map_element_handle).unwrap(); - if let BuiltinElementKind::Grenades { + if let BuiltinElementKind::Grenade { body_size, body_offset, atlas_handle, @@ -148,7 +148,7 @@ fn update_idle_grenades( ) in items { let meta = element_assets.get(meta_handle).unwrap(); - let BuiltinElementKind::Grenades { + let BuiltinElementKind::Grenade { grab_offset, atlas_handle, throw_velocity, @@ -265,7 +265,7 @@ fn update_lit_grenades( items.sort_by_key(|x| x.0.id()); for (_, item_ent, mut grenade, transform, meta_handle) in items { let meta = element_assets.get(meta_handle).unwrap(); - let BuiltinElementKind::Grenades { + let BuiltinElementKind::Grenade { fuse_time, damage_region_size, damage_region_lifetime, diff --git a/src/metadata/map.rs b/src/metadata/map.rs index 7b0aaf7a16..a256dff6ee 100644 --- a/src/metadata/map.rs +++ b/src/metadata/map.rs @@ -208,7 +208,7 @@ pub enum BuiltinElementKind { /// Player spawner PlayerSpawner, /// Grenades item - Grenades { + Grenade { body_size: Vec2, body_offset: Vec2, grab_offset: Vec2, @@ -263,4 +263,28 @@ pub enum BuiltinElementKind { #[serde(skip)] sound_handle: Handle, }, + /// + Crate { + atlas: String, + #[serde(skip)] + atlas_handle: AssetHandle, + + breaking_atlas: String, + #[serde(skip)] + breaking_atlas_handle: AssetHandle, + breaking_anim_length: usize, + breaking_anim_fps: f32, + + break_sound: String, + #[serde(skip)] + break_sound_handle: Handle, + + throw_velocity: Vec2, + + body_size: Vec2, + body_offset: Vec2, + grab_offset: Vec2, + // How long to wait before despawning a thrown crate, if it hans't it anything yet. + break_timeout: f32, + }, } diff --git a/src/physics/collisions.rs b/src/physics/collisions.rs index 9de7bde63c..775e2c3be8 100644 --- a/src/physics/collisions.rs +++ b/src/physics/collisions.rs @@ -538,4 +538,8 @@ impl<'w, 's> CollisionWorld<'w, 's> { || tile == TileCollision::JumpThrough } } + + pub fn get_collider(&self, collider: Entity) -> &Collider { + self.actors.get(collider).unwrap().1 + } }