30
crates/shared/Cargo.toml
Normal file
30
crates/shared/Cargo.toml
Normal file
@@ -0,0 +1,30 @@
|
||||
[package]
|
||||
name = "shared"
|
||||
version = "0.1.0"
|
||||
edition = "2024"
|
||||
|
||||
[features]
|
||||
dbg = []
|
||||
|
||||
[dependencies]
|
||||
avian3d = { workspace = true }
|
||||
bevy = { workspace = true }
|
||||
bevy-inspector-egui = { workspace = true, optional = true }
|
||||
bevy-steamworks = { workspace = true }
|
||||
bevy-ui-gradients = { workspace = true }
|
||||
bevy_asset_loader = { workspace = true }
|
||||
bevy_ballistic = { workspace = true }
|
||||
bevy_common_assets = { workspace = true }
|
||||
bevy_debug_log = { workspace = true }
|
||||
bevy_sprite3d = { workspace = true }
|
||||
bevy_trenchbroom = { workspace = true }
|
||||
happy_feet = { workspace = true }
|
||||
nil = { workspace = true }
|
||||
rand = { workspace = true }
|
||||
ron = { workspace = true }
|
||||
serde = { workspace = true }
|
||||
steamworks = { workspace = true }
|
||||
|
||||
[lints.clippy]
|
||||
too_many_arguments = "allow"
|
||||
type_complexity = "allow"
|
||||
118
crates/shared/src/abilities/arrow.rs
Normal file
118
crates/shared/src/abilities/arrow.rs
Normal file
@@ -0,0 +1,118 @@
|
||||
use super::TriggerArrow;
|
||||
use crate::{
|
||||
GameState, billboards::Billboard, global_observer, heads_database::HeadsDatabase,
|
||||
hitpoints::Hit, loading_assets::GameAssets, physics_layers::GameLayer, sounds::PlaySound,
|
||||
utils::sprite_3d_animation::AnimationTimer,
|
||||
};
|
||||
use avian3d::prelude::*;
|
||||
use bevy::{pbr::NotShadowCaster, prelude::*};
|
||||
use bevy_sprite3d::{Sprite3dBuilder, Sprite3dParams};
|
||||
use std::f32::consts::PI;
|
||||
|
||||
#[derive(Component)]
|
||||
struct ArrowProjectile {
|
||||
damage: u32,
|
||||
}
|
||||
|
||||
#[derive(Resource)]
|
||||
struct ShotAssets {
|
||||
image: Handle<Image>,
|
||||
layout: Handle<TextureAtlasLayout>,
|
||||
}
|
||||
|
||||
pub fn plugin(app: &mut App) {
|
||||
app.add_systems(OnEnter(GameState::Playing), setup);
|
||||
app.add_systems(Update, update.run_if(in_state(GameState::Playing)));
|
||||
|
||||
global_observer!(app, on_trigger_arrow);
|
||||
}
|
||||
|
||||
fn setup(mut commands: Commands, assets: Res<GameAssets>, mut sprite_params: Sprite3dParams) {
|
||||
let layout = TextureAtlasLayout::from_grid(UVec2::splat(256), 7, 6, None, None);
|
||||
let texture_atlas_layout = sprite_params.atlas_layouts.add(layout);
|
||||
|
||||
commands.insert_resource(ShotAssets {
|
||||
image: assets.impact_atlas.clone(),
|
||||
layout: texture_atlas_layout,
|
||||
});
|
||||
}
|
||||
|
||||
fn on_trigger_arrow(
|
||||
trigger: Trigger<TriggerArrow>,
|
||||
mut commands: Commands,
|
||||
query_transform: Query<&Transform>,
|
||||
heads_db: Res<HeadsDatabase>,
|
||||
) {
|
||||
let state = trigger.0;
|
||||
|
||||
commands.trigger(PlaySound::Crossbow);
|
||||
|
||||
let rotation = if let Some(target) = state.target {
|
||||
let t = query_transform
|
||||
.get(target)
|
||||
.expect("target must have transform");
|
||||
Transform::from_translation(state.pos)
|
||||
.looking_at(t.translation, Vec3::Y)
|
||||
.rotation
|
||||
} else {
|
||||
state.rot.mul_quat(Quat::from_rotation_y(PI))
|
||||
};
|
||||
|
||||
let mut t = Transform::from_translation(state.pos).with_rotation(rotation);
|
||||
t.translation += t.forward().as_vec3() * 2.;
|
||||
|
||||
let damage = heads_db.head_stats(state.head).damage;
|
||||
commands.spawn((Name::new("projectile-arrow"), ArrowProjectile { damage }, t));
|
||||
}
|
||||
|
||||
fn update(
|
||||
mut cmds: Commands,
|
||||
query: Query<(Entity, &Transform, &ArrowProjectile)>,
|
||||
spatial_query: SpatialQuery,
|
||||
assets: Res<ShotAssets>,
|
||||
mut sprite_params: Sprite3dParams,
|
||||
) {
|
||||
for (e, t, arrow) in query.iter() {
|
||||
let filter = SpatialQueryFilter::from_mask(LayerMask(
|
||||
GameLayer::Level.to_bits() | GameLayer::Npc.to_bits(),
|
||||
));
|
||||
|
||||
if let Some(first_hit) = spatial_query.cast_shape(
|
||||
&Collider::sphere(0.5),
|
||||
t.translation,
|
||||
t.rotation,
|
||||
t.forward(),
|
||||
&ShapeCastConfig::from_max_distance(150.),
|
||||
&filter,
|
||||
) {
|
||||
cmds.entity(first_hit.entity).trigger(Hit {
|
||||
damage: arrow.damage,
|
||||
});
|
||||
|
||||
cmds.spawn(
|
||||
Sprite3dBuilder {
|
||||
image: assets.image.clone(),
|
||||
pixels_per_metre: 128.,
|
||||
alpha_mode: AlphaMode::Blend,
|
||||
unlit: true,
|
||||
..default()
|
||||
}
|
||||
.bundle_with_atlas(
|
||||
&mut sprite_params,
|
||||
TextureAtlas {
|
||||
layout: assets.layout.clone(),
|
||||
index: 0,
|
||||
},
|
||||
),
|
||||
)
|
||||
.insert((
|
||||
Billboard::All,
|
||||
Transform::from_translation(first_hit.point1),
|
||||
NotShadowCaster,
|
||||
AnimationTimer::new(Timer::from_seconds(0.005, TimerMode::Repeating)),
|
||||
));
|
||||
}
|
||||
|
||||
cmds.entity(e).despawn();
|
||||
}
|
||||
}
|
||||
204
crates/shared/src/abilities/curver.rs
Normal file
204
crates/shared/src/abilities/curver.rs
Normal file
@@ -0,0 +1,204 @@
|
||||
use crate::{
|
||||
GameState,
|
||||
abilities::TriggerCurver,
|
||||
billboards::Billboard,
|
||||
heads_database::HeadsDatabase,
|
||||
hitpoints::Hit,
|
||||
loading_assets::GameAssets,
|
||||
physics_layers::GameLayer,
|
||||
tb_entities::EnemySpawn,
|
||||
utils::{auto_rotate::AutoRotation, global_observer, sprite_3d_animation::AnimationTimer},
|
||||
};
|
||||
use avian3d::prelude::*;
|
||||
use bevy::{pbr::NotShadowCaster, prelude::*};
|
||||
use bevy_sprite3d::{Sprite3dBuilder, Sprite3dParams};
|
||||
use std::f32::consts::PI;
|
||||
|
||||
const MAX_SHOT_AGES: f32 = 15.;
|
||||
|
||||
#[derive(Component)]
|
||||
struct CurverProjectile {
|
||||
time: f32,
|
||||
damage: u32,
|
||||
}
|
||||
|
||||
#[derive(Resource)]
|
||||
struct ShotAssets {
|
||||
image: Handle<Image>,
|
||||
layout: Handle<TextureAtlasLayout>,
|
||||
}
|
||||
|
||||
pub fn plugin(app: &mut App) {
|
||||
app.add_systems(OnEnter(GameState::Playing), setup);
|
||||
app.add_systems(
|
||||
Update,
|
||||
(shot_collision, enemy_hit).run_if(in_state(GameState::Playing)),
|
||||
);
|
||||
app.add_systems(
|
||||
FixedUpdate,
|
||||
(update, timeout).run_if(in_state(GameState::Playing)),
|
||||
);
|
||||
|
||||
global_observer!(app, on_trigger_missile);
|
||||
}
|
||||
|
||||
fn setup(mut commands: Commands, assets: Res<GameAssets>, mut sprite_params: Sprite3dParams) {
|
||||
let layout = TextureAtlasLayout::from_grid(UVec2::splat(256), 7, 6, None, None);
|
||||
let texture_atlas_layout = sprite_params.atlas_layouts.add(layout);
|
||||
|
||||
commands.insert_resource(ShotAssets {
|
||||
image: assets.impact_atlas.clone(),
|
||||
layout: texture_atlas_layout,
|
||||
});
|
||||
}
|
||||
|
||||
fn on_trigger_missile(
|
||||
trigger: Trigger<TriggerCurver>,
|
||||
mut commands: Commands,
|
||||
query_transform: Query<&Transform>,
|
||||
time: Res<Time>,
|
||||
heads_db: Res<HeadsDatabase>,
|
||||
assets: Res<GameAssets>,
|
||||
gltf_assets: Res<Assets<Gltf>>,
|
||||
) {
|
||||
let state = trigger.event().0;
|
||||
|
||||
let rotation = if let Some(target) = state.target {
|
||||
let t = query_transform
|
||||
.get(target)
|
||||
.expect("target must have transform");
|
||||
Transform::from_translation(state.pos)
|
||||
.looking_at(t.translation, Vec3::Y)
|
||||
.rotation
|
||||
} else {
|
||||
state.rot.mul_quat(Quat::from_rotation_y(PI))
|
||||
};
|
||||
|
||||
let head = heads_db.head_stats(state.head);
|
||||
|
||||
let mut t = Transform::from_translation(state.pos).with_rotation(rotation);
|
||||
t.translation += t.forward().as_vec3() * 2.0;
|
||||
|
||||
let mesh = assets.projectiles[format!("{}.glb", head.projectile).as_str()].clone();
|
||||
let asset = gltf_assets.get(&mesh).unwrap();
|
||||
|
||||
commands.spawn((
|
||||
Name::new("projectile-missile"),
|
||||
CurverProjectile {
|
||||
time: time.elapsed_secs(),
|
||||
damage: head.damage,
|
||||
},
|
||||
Collider::capsule_endpoints(0.4, Vec3::new(0., 0., 2.), Vec3::new(0., 0., -2.)),
|
||||
CollisionLayers::new(
|
||||
LayerMask(GameLayer::Projectile.to_bits()),
|
||||
LayerMask(state.target_layer.to_bits() | GameLayer::Level.to_bits()),
|
||||
),
|
||||
Sensor,
|
||||
CollisionEventsEnabled,
|
||||
Visibility::default(),
|
||||
t,
|
||||
children![(
|
||||
Transform::from_rotation(Quat::from_rotation_x(PI / 2.).inverse()),
|
||||
AutoRotation(Quat::from_rotation_x(0.4) * Quat::from_rotation_z(0.3)),
|
||||
SceneRoot(asset.scenes[0].clone()),
|
||||
),],
|
||||
));
|
||||
}
|
||||
|
||||
fn enemy_hit(
|
||||
mut commands: Commands,
|
||||
mut collision_event_reader: EventReader<CollisionStarted>,
|
||||
query_shot: Query<&CurverProjectile>,
|
||||
query_npc: Query<&EnemySpawn>,
|
||||
) {
|
||||
for CollisionStarted(e1, e2) in collision_event_reader.read() {
|
||||
if !query_shot.contains(*e1) && !query_shot.contains(*e2) {
|
||||
continue;
|
||||
}
|
||||
if !query_npc.contains(*e1) && !query_npc.contains(*e2) {
|
||||
continue;
|
||||
}
|
||||
|
||||
let (enemy_entity, projectile) = if query_npc.contains(*e1) {
|
||||
(*e1, query_shot.get(*e2))
|
||||
} else {
|
||||
(*e2, query_shot.get(*e1))
|
||||
};
|
||||
|
||||
if let Ok(projectile) = projectile {
|
||||
let damage = projectile.damage;
|
||||
commands.entity(enemy_entity).trigger(Hit { damage });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn update(mut query: Query<&mut Transform, With<CurverProjectile>>) {
|
||||
for mut transform in query.iter_mut() {
|
||||
let forward = transform.forward();
|
||||
transform.translation += forward * 0.5;
|
||||
}
|
||||
}
|
||||
|
||||
fn timeout(mut commands: Commands, query: Query<(Entity, &CurverProjectile)>, time: Res<Time>) {
|
||||
let current_time = time.elapsed_secs();
|
||||
|
||||
for (e, CurverProjectile { time, .. }) in query.iter() {
|
||||
if current_time > time + MAX_SHOT_AGES {
|
||||
commands.entity(e).despawn();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn shot_collision(
|
||||
mut commands: Commands,
|
||||
mut collision_event_reader: EventReader<CollisionStarted>,
|
||||
query_shot: Query<&Transform, With<CurverProjectile>>,
|
||||
sensors: Query<(), With<Sensor>>,
|
||||
assets: Res<ShotAssets>,
|
||||
mut sprite_params: Sprite3dParams,
|
||||
) {
|
||||
for CollisionStarted(e1, e2) in collision_event_reader.read() {
|
||||
if !query_shot.contains(*e1) && !query_shot.contains(*e2) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if sensors.contains(*e1) && sensors.contains(*e2) {
|
||||
continue;
|
||||
}
|
||||
|
||||
let shot_entity = if query_shot.contains(*e1) { *e1 } else { *e2 };
|
||||
|
||||
let Ok(shot_pos) = query_shot.get(shot_entity).map(|t| t.translation) else {
|
||||
continue;
|
||||
};
|
||||
|
||||
if let Ok(mut entity) = commands.get_entity(shot_entity) {
|
||||
entity.try_despawn();
|
||||
} else {
|
||||
continue;
|
||||
}
|
||||
|
||||
let texture_atlas = TextureAtlas {
|
||||
layout: assets.layout.clone(),
|
||||
index: 0,
|
||||
};
|
||||
|
||||
commands
|
||||
.spawn(
|
||||
Sprite3dBuilder {
|
||||
image: assets.image.clone(),
|
||||
pixels_per_metre: 128.,
|
||||
alpha_mode: AlphaMode::Blend,
|
||||
unlit: true,
|
||||
..default()
|
||||
}
|
||||
.bundle_with_atlas(&mut sprite_params, texture_atlas),
|
||||
)
|
||||
.insert((
|
||||
Billboard::All,
|
||||
Transform::from_translation(shot_pos),
|
||||
NotShadowCaster,
|
||||
AnimationTimer::new(Timer::from_seconds(0.01, TimerMode::Repeating)),
|
||||
));
|
||||
}
|
||||
}
|
||||
200
crates/shared/src/abilities/gun.rs
Normal file
200
crates/shared/src/abilities/gun.rs
Normal file
@@ -0,0 +1,200 @@
|
||||
use super::TriggerGun;
|
||||
use crate::{
|
||||
GameState, billboards::Billboard, global_observer, heads_database::HeadsDatabase,
|
||||
hitpoints::Hit, loading_assets::GameAssets, physics_layers::GameLayer, sounds::PlaySound,
|
||||
tb_entities::EnemySpawn, utils::sprite_3d_animation::AnimationTimer,
|
||||
};
|
||||
use avian3d::prelude::*;
|
||||
use bevy::{pbr::NotShadowCaster, prelude::*};
|
||||
use bevy_sprite3d::{Sprite3dBuilder, Sprite3dParams};
|
||||
use std::f32::consts::PI;
|
||||
|
||||
#[derive(Component)]
|
||||
struct GunProjectile {
|
||||
time: f32,
|
||||
owner_head: usize,
|
||||
}
|
||||
|
||||
const MAX_SHOT_AGES: f32 = 1.;
|
||||
|
||||
#[derive(Resource)]
|
||||
struct ShotAssets {
|
||||
image: Handle<Image>,
|
||||
layout: Handle<TextureAtlasLayout>,
|
||||
}
|
||||
|
||||
pub fn plugin(app: &mut App) {
|
||||
app.add_systems(OnEnter(GameState::Playing), setup);
|
||||
app.add_systems(
|
||||
Update,
|
||||
(shot_collision, enemy_hit).run_if(in_state(GameState::Playing)),
|
||||
);
|
||||
app.add_systems(
|
||||
FixedUpdate,
|
||||
(update, timeout).run_if(in_state(GameState::Playing)),
|
||||
);
|
||||
|
||||
global_observer!(app, on_trigger_gun);
|
||||
}
|
||||
|
||||
fn setup(mut commands: Commands, assets: Res<GameAssets>, mut sprite_params: Sprite3dParams) {
|
||||
let layout = TextureAtlasLayout::from_grid(UVec2::splat(256), 7, 6, None, None);
|
||||
let texture_atlas_layout = sprite_params.atlas_layouts.add(layout);
|
||||
|
||||
commands.insert_resource(ShotAssets {
|
||||
image: assets.impact_atlas.clone(),
|
||||
layout: texture_atlas_layout,
|
||||
});
|
||||
}
|
||||
|
||||
fn enemy_hit(
|
||||
mut commands: Commands,
|
||||
mut collision_event_reader: EventReader<CollisionStarted>,
|
||||
query_shot: Query<&GunProjectile>,
|
||||
query_npc: Query<&EnemySpawn>,
|
||||
heads_db: Res<HeadsDatabase>,
|
||||
) {
|
||||
for CollisionStarted(e1, e2) in collision_event_reader.read() {
|
||||
if !query_shot.contains(*e1) && !query_shot.contains(*e2) {
|
||||
continue;
|
||||
}
|
||||
if !query_npc.contains(*e1) && !query_npc.contains(*e2) {
|
||||
continue;
|
||||
}
|
||||
|
||||
let (enemy_entity, projectile) = if query_npc.contains(*e1) {
|
||||
(*e1, query_shot.get(*e2))
|
||||
} else {
|
||||
(*e2, query_shot.get(*e1))
|
||||
};
|
||||
|
||||
if let Ok(head) = projectile.map(|p| p.owner_head) {
|
||||
let damage = heads_db.head_stats(head).damage;
|
||||
commands.entity(enemy_entity).trigger(Hit { damage });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn on_trigger_gun(
|
||||
trigger: Trigger<TriggerGun>,
|
||||
mut commands: Commands,
|
||||
query_transform: Query<&Transform>,
|
||||
time: Res<Time>,
|
||||
mut gizmo_assets: ResMut<Assets<GizmoAsset>>,
|
||||
) {
|
||||
let state = trigger.0;
|
||||
|
||||
commands.trigger(PlaySound::Gun);
|
||||
|
||||
let rotation = if let Some(t) = state
|
||||
.target
|
||||
.and_then(|target| query_transform.get(target).ok())
|
||||
{
|
||||
Transform::from_translation(state.pos)
|
||||
.looking_at(t.translation, Vec3::Y)
|
||||
.rotation
|
||||
} else {
|
||||
state.rot.mul_quat(Quat::from_rotation_y(PI))
|
||||
};
|
||||
|
||||
let mut t = Transform::from_translation(state.pos).with_rotation(rotation);
|
||||
t.translation += t.forward().as_vec3() * 2.0;
|
||||
|
||||
commands.spawn((
|
||||
Name::new("projectile-gun"),
|
||||
GunProjectile {
|
||||
time: time.elapsed_secs(),
|
||||
owner_head: state.head,
|
||||
},
|
||||
Collider::capsule_endpoints(0.5, Vec3::new(0., 0., 0.), Vec3::new(0., 0., -3.)),
|
||||
CollisionLayers::new(
|
||||
LayerMask(GameLayer::Projectile.to_bits()),
|
||||
LayerMask(state.target_layer.to_bits() | GameLayer::Level.to_bits()),
|
||||
),
|
||||
Sensor,
|
||||
CollisionEventsEnabled,
|
||||
Visibility::default(),
|
||||
t,
|
||||
Children::spawn(Spawn(Gizmo {
|
||||
handle: gizmo_assets.add({
|
||||
let mut g = GizmoAsset::default();
|
||||
g.line(Vec3::Z * -2., Vec3::Z * 2., LinearRgba::rgb(0.9, 0.9, 0.));
|
||||
g
|
||||
}),
|
||||
line_config: GizmoLineConfig {
|
||||
width: 8.,
|
||||
..default()
|
||||
},
|
||||
..default()
|
||||
})),
|
||||
));
|
||||
}
|
||||
|
||||
fn update(mut query: Query<&mut Transform, With<GunProjectile>>) {
|
||||
for mut transform in query.iter_mut() {
|
||||
let forward = transform.forward();
|
||||
transform.translation += forward * 2.;
|
||||
}
|
||||
}
|
||||
|
||||
fn timeout(mut commands: Commands, query: Query<(Entity, &GunProjectile)>, time: Res<Time>) {
|
||||
let current_time = time.elapsed_secs();
|
||||
|
||||
for (e, GunProjectile { time, .. }) in query.iter() {
|
||||
if current_time > time + MAX_SHOT_AGES {
|
||||
commands.entity(e).despawn();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn shot_collision(
|
||||
mut commands: Commands,
|
||||
mut collision_event_reader: EventReader<CollisionStarted>,
|
||||
query_shot: Query<(&GunProjectile, &Transform)>,
|
||||
sensors: Query<(), With<Sensor>>,
|
||||
assets: Res<ShotAssets>,
|
||||
mut sprite_params: Sprite3dParams,
|
||||
) {
|
||||
for CollisionStarted(e1, e2) in collision_event_reader.read() {
|
||||
if !query_shot.contains(*e1) && !query_shot.contains(*e2) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if sensors.contains(*e1) && sensors.contains(*e2) {
|
||||
continue;
|
||||
}
|
||||
|
||||
let shot_entity = if query_shot.contains(*e1) { *e1 } else { *e2 };
|
||||
|
||||
if let Ok(mut entity) = commands.get_entity(shot_entity) {
|
||||
entity.try_despawn();
|
||||
} else {
|
||||
continue;
|
||||
}
|
||||
|
||||
let shot_pos = query_shot.get(shot_entity).unwrap().1.translation;
|
||||
|
||||
let texture_atlas = TextureAtlas {
|
||||
layout: assets.layout.clone(),
|
||||
index: 0,
|
||||
};
|
||||
|
||||
commands
|
||||
.spawn(
|
||||
Sprite3dBuilder {
|
||||
image: assets.image.clone(),
|
||||
pixels_per_metre: 128.,
|
||||
alpha_mode: AlphaMode::Blend,
|
||||
unlit: true,
|
||||
..default()
|
||||
}
|
||||
.bundle_with_atlas(&mut sprite_params, texture_atlas),
|
||||
)
|
||||
.insert((
|
||||
Billboard::All,
|
||||
Transform::from_translation(shot_pos),
|
||||
NotShadowCaster,
|
||||
AnimationTimer::new(Timer::from_seconds(0.01, TimerMode::Repeating)),
|
||||
));
|
||||
}
|
||||
}
|
||||
75
crates/shared/src/abilities/healing.rs
Normal file
75
crates/shared/src/abilities/healing.rs
Normal file
@@ -0,0 +1,75 @@
|
||||
use crate::{
|
||||
GameState, global_observer, heads::ActiveHeads, heads_database::HeadsDatabase,
|
||||
hitpoints::Hitpoints, loading_assets::AudioAssets,
|
||||
};
|
||||
use bevy::prelude::*;
|
||||
|
||||
#[derive(Component)]
|
||||
pub struct Healing(pub Entity);
|
||||
|
||||
#[derive(Event, Debug)]
|
||||
pub enum HealingStateChanged {
|
||||
Started,
|
||||
Stopped,
|
||||
}
|
||||
|
||||
pub fn plugin(app: &mut App) {
|
||||
app.add_systems(Update, update.run_if(in_state(GameState::Playing)));
|
||||
|
||||
global_observer!(app, on_heal_start_stop);
|
||||
}
|
||||
|
||||
fn on_heal_start_stop(
|
||||
trigger: Trigger<HealingStateChanged>,
|
||||
mut cmds: Commands,
|
||||
assets: Res<AudioAssets>,
|
||||
query: Query<&Healing>,
|
||||
) {
|
||||
if matches!(trigger.event(), HealingStateChanged::Started) {
|
||||
let e = cmds
|
||||
.spawn((
|
||||
Name::new("sfx-heal"),
|
||||
AudioPlayer::new(assets.healing.clone()),
|
||||
PlaybackSettings {
|
||||
mode: bevy::audio::PlaybackMode::Loop,
|
||||
..Default::default()
|
||||
},
|
||||
))
|
||||
.id();
|
||||
cmds.entity(trigger.target())
|
||||
.add_child(e)
|
||||
.insert(Healing(e));
|
||||
} else {
|
||||
if let Ok(healing) = query.single() {
|
||||
cmds.entity(healing.0).despawn();
|
||||
}
|
||||
cmds.entity(trigger.target()).remove::<Healing>();
|
||||
}
|
||||
}
|
||||
|
||||
fn update(
|
||||
mut query: Query<(&mut ActiveHeads, &mut Hitpoints), With<Healing>>,
|
||||
time: Res<Time>,
|
||||
heads_db: Res<HeadsDatabase>,
|
||||
) {
|
||||
for (mut heads, mut hitpoints) in query.iter_mut() {
|
||||
let Some(current) = heads.current() else {
|
||||
continue;
|
||||
};
|
||||
|
||||
let head = heads_db.head_stats(current.head);
|
||||
|
||||
if current.last_use + (1. / head.aps) > time.elapsed_secs() {
|
||||
continue;
|
||||
}
|
||||
|
||||
let medic_hp = hitpoints.get().0;
|
||||
if medic_hp > 0 {
|
||||
if let Some(health) = heads.medic_heal(2, time.elapsed_secs()) {
|
||||
hitpoints.set_health(health);
|
||||
} else {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
201
crates/shared/src/abilities/missile.rs
Normal file
201
crates/shared/src/abilities/missile.rs
Normal file
@@ -0,0 +1,201 @@
|
||||
use super::TriggerMissile;
|
||||
use crate::{
|
||||
GameState,
|
||||
billboards::Billboard,
|
||||
heads_database::HeadsDatabase,
|
||||
loading_assets::GameAssets,
|
||||
physics_layers::GameLayer,
|
||||
sounds::PlaySound,
|
||||
utils::{
|
||||
explosions::Explosion, global_observer, sprite_3d_animation::AnimationTimer, trail::Trail,
|
||||
},
|
||||
};
|
||||
use avian3d::prelude::*;
|
||||
use bevy::{pbr::NotShadowCaster, prelude::*};
|
||||
use bevy_sprite3d::{Sprite3dBuilder, Sprite3dParams};
|
||||
use std::f32::consts::PI;
|
||||
|
||||
const MAX_SHOT_AGES: f32 = 15.;
|
||||
|
||||
#[derive(Component)]
|
||||
struct MissileProjectile {
|
||||
time: f32,
|
||||
damage: u32,
|
||||
}
|
||||
|
||||
#[derive(Resource)]
|
||||
struct ShotAssets {
|
||||
image: Handle<Image>,
|
||||
layout: Handle<TextureAtlasLayout>,
|
||||
}
|
||||
|
||||
pub fn plugin(app: &mut App) {
|
||||
app.add_systems(OnEnter(GameState::Playing), setup);
|
||||
app.add_systems(Update, shot_collision.run_if(in_state(GameState::Playing)));
|
||||
app.add_systems(
|
||||
FixedUpdate,
|
||||
(update, timeout).run_if(in_state(GameState::Playing)),
|
||||
);
|
||||
|
||||
global_observer!(app, on_trigger_missile);
|
||||
}
|
||||
|
||||
fn setup(mut commands: Commands, assets: Res<GameAssets>, mut sprite_params: Sprite3dParams) {
|
||||
let layout = TextureAtlasLayout::from_grid(UVec2::splat(256), 7, 6, None, None);
|
||||
let texture_atlas_layout = sprite_params.atlas_layouts.add(layout);
|
||||
|
||||
commands.insert_resource(ShotAssets {
|
||||
image: assets.impact_atlas.clone(),
|
||||
layout: texture_atlas_layout,
|
||||
});
|
||||
}
|
||||
|
||||
fn on_trigger_missile(
|
||||
trigger: Trigger<TriggerMissile>,
|
||||
mut commands: Commands,
|
||||
query_transform: Query<&Transform>,
|
||||
time: Res<Time>,
|
||||
heads_db: Res<HeadsDatabase>,
|
||||
assets: Res<GameAssets>,
|
||||
gltf_assets: Res<Assets<Gltf>>,
|
||||
mut gizmo_assets: ResMut<Assets<GizmoAsset>>,
|
||||
) {
|
||||
let state = trigger.event().0;
|
||||
|
||||
let rotation = if let Some(target) = state.target {
|
||||
let t = query_transform
|
||||
.get(target)
|
||||
.expect("target must have transform");
|
||||
Transform::from_translation(state.pos)
|
||||
.looking_at(t.translation, Vec3::Y)
|
||||
.rotation
|
||||
} else {
|
||||
state.rot.mul_quat(Quat::from_rotation_y(PI))
|
||||
};
|
||||
|
||||
let head = heads_db.head_stats(state.head);
|
||||
|
||||
let mut t = Transform::from_translation(state.pos).with_rotation(rotation);
|
||||
t.translation += t.forward().as_vec3() * 2.0;
|
||||
|
||||
let mesh = assets.projectiles["missile.glb"].clone();
|
||||
let asset = gltf_assets.get(&mesh).unwrap();
|
||||
|
||||
commands.spawn((
|
||||
Name::new("projectile-missile"),
|
||||
MissileProjectile {
|
||||
time: time.elapsed_secs(),
|
||||
damage: head.damage,
|
||||
},
|
||||
Collider::capsule_endpoints(0.4, Vec3::new(0., 0., 2.), Vec3::new(0., 0., -2.)),
|
||||
CollisionLayers::new(
|
||||
LayerMask(GameLayer::Projectile.to_bits()),
|
||||
LayerMask(state.target_layer.to_bits() | GameLayer::Level.to_bits()),
|
||||
),
|
||||
Sensor,
|
||||
CollisionEventsEnabled,
|
||||
Visibility::default(),
|
||||
t,
|
||||
children![
|
||||
(
|
||||
Transform::from_rotation(Quat::from_rotation_x(PI / 2.).inverse())
|
||||
.with_scale(Vec3::splat(0.04)),
|
||||
SceneRoot(asset.scenes[0].clone()),
|
||||
),
|
||||
(
|
||||
Trail::new(
|
||||
12,
|
||||
LinearRgba::rgb(1., 0.0, 0.),
|
||||
LinearRgba::rgb(0.9, 0.9, 0.)
|
||||
)
|
||||
.with_pos(t.translation),
|
||||
Gizmo {
|
||||
handle: gizmo_assets.add(GizmoAsset::default()),
|
||||
line_config: GizmoLineConfig {
|
||||
width: 10.,
|
||||
..default()
|
||||
},
|
||||
..default()
|
||||
},
|
||||
)
|
||||
],
|
||||
));
|
||||
}
|
||||
|
||||
fn update(mut query: Query<&mut Transform, With<MissileProjectile>>) {
|
||||
for mut transform in query.iter_mut() {
|
||||
let forward = transform.forward();
|
||||
transform.translation += forward * 3.;
|
||||
}
|
||||
}
|
||||
|
||||
fn timeout(mut commands: Commands, query: Query<(Entity, &MissileProjectile)>, time: Res<Time>) {
|
||||
let current_time = time.elapsed_secs();
|
||||
|
||||
for (e, MissileProjectile { time, .. }) in query.iter() {
|
||||
if current_time > time + MAX_SHOT_AGES {
|
||||
commands.entity(e).despawn();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn shot_collision(
|
||||
mut commands: Commands,
|
||||
mut collision_event_reader: EventReader<CollisionStarted>,
|
||||
query_shot: Query<(&MissileProjectile, &Transform)>,
|
||||
sensors: Query<(), With<Sensor>>,
|
||||
assets: Res<ShotAssets>,
|
||||
mut sprite_params: Sprite3dParams,
|
||||
) {
|
||||
for CollisionStarted(e1, e2) in collision_event_reader.read() {
|
||||
if !query_shot.contains(*e1) && !query_shot.contains(*e2) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if sensors.contains(*e1) && sensors.contains(*e2) {
|
||||
continue;
|
||||
}
|
||||
|
||||
let shot_entity = if query_shot.contains(*e1) { *e1 } else { *e2 };
|
||||
|
||||
let Ok((shot_pos, damage)) = query_shot
|
||||
.get(shot_entity)
|
||||
.map(|(projectile, t)| (t.translation, projectile.damage))
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
|
||||
commands.trigger(PlaySound::MissileExplosion);
|
||||
|
||||
commands.entity(shot_entity).despawn();
|
||||
|
||||
commands.trigger(Explosion {
|
||||
damage,
|
||||
position: shot_pos,
|
||||
radius: 6.,
|
||||
});
|
||||
|
||||
let texture_atlas = TextureAtlas {
|
||||
layout: assets.layout.clone(),
|
||||
index: 0,
|
||||
};
|
||||
|
||||
commands
|
||||
.spawn(
|
||||
Sprite3dBuilder {
|
||||
image: assets.image.clone(),
|
||||
pixels_per_metre: 16.,
|
||||
alpha_mode: AlphaMode::Blend,
|
||||
unlit: true,
|
||||
..default()
|
||||
}
|
||||
.bundle_with_atlas(&mut sprite_params, texture_atlas),
|
||||
)
|
||||
.insert((
|
||||
Billboard::All,
|
||||
Transform::from_translation(shot_pos),
|
||||
NotShadowCaster,
|
||||
AnimationTimer::new(Timer::from_seconds(0.01, TimerMode::Repeating)),
|
||||
));
|
||||
}
|
||||
}
|
||||
224
crates/shared/src/abilities/mod.rs
Normal file
224
crates/shared/src/abilities/mod.rs
Normal file
@@ -0,0 +1,224 @@
|
||||
mod arrow;
|
||||
mod curver;
|
||||
mod gun;
|
||||
mod healing;
|
||||
mod missile;
|
||||
mod thrown;
|
||||
|
||||
use crate::{
|
||||
GameState,
|
||||
aim::AimTarget,
|
||||
character::CharacterHierarchy,
|
||||
global_observer,
|
||||
head::ActiveHead,
|
||||
heads::ActiveHeads,
|
||||
heads_database::HeadsDatabase,
|
||||
physics_layers::GameLayer,
|
||||
player::{Player, PlayerBodyMesh},
|
||||
sounds::PlaySound,
|
||||
};
|
||||
use bevy::prelude::*;
|
||||
pub use healing::Healing;
|
||||
use healing::HealingStateChanged;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Event, Reflect)]
|
||||
pub enum TriggerState {
|
||||
Active,
|
||||
Inactive,
|
||||
}
|
||||
|
||||
#[derive(Event, Reflect)]
|
||||
pub struct TriggerCashHeal;
|
||||
|
||||
#[derive(Debug, Copy, Clone, PartialEq, Reflect, Default, Serialize, Deserialize)]
|
||||
pub enum HeadAbility {
|
||||
#[default]
|
||||
None,
|
||||
Arrow,
|
||||
Thrown,
|
||||
Gun,
|
||||
Missile,
|
||||
Medic,
|
||||
Curver,
|
||||
Boat,
|
||||
Turbo,
|
||||
Spray,
|
||||
}
|
||||
|
||||
#[derive(Debug, Reflect, Clone, Copy)]
|
||||
pub struct TriggerData {
|
||||
target: Option<Entity>,
|
||||
dir: Dir3,
|
||||
rot: Quat,
|
||||
pos: Vec3,
|
||||
target_layer: GameLayer,
|
||||
head: usize,
|
||||
}
|
||||
|
||||
impl TriggerData {
|
||||
pub fn new(
|
||||
target: Option<Entity>,
|
||||
dir: Dir3,
|
||||
rot: Quat,
|
||||
pos: Vec3,
|
||||
target_layer: GameLayer,
|
||||
head: usize,
|
||||
) -> Self {
|
||||
Self {
|
||||
target,
|
||||
dir,
|
||||
rot,
|
||||
pos,
|
||||
target_layer,
|
||||
head,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Event, Reflect)]
|
||||
pub struct TriggerGun(pub TriggerData);
|
||||
#[derive(Event, Reflect)]
|
||||
pub struct TriggerArrow(pub TriggerData);
|
||||
#[derive(Event, Reflect)]
|
||||
pub struct TriggerThrow(pub TriggerData);
|
||||
#[derive(Event, Reflect)]
|
||||
pub struct TriggerMissile(pub TriggerData);
|
||||
#[derive(Event, Reflect)]
|
||||
pub struct TriggerCurver(pub TriggerData);
|
||||
|
||||
#[derive(Resource, Default)]
|
||||
pub struct TriggerStateRes {
|
||||
next_trigger_timestamp: f32,
|
||||
active: bool,
|
||||
}
|
||||
|
||||
impl TriggerStateRes {
|
||||
pub fn is_active(&self) -> bool {
|
||||
self.active
|
||||
}
|
||||
}
|
||||
|
||||
pub fn plugin(app: &mut App) {
|
||||
app.init_resource::<TriggerStateRes>();
|
||||
|
||||
app.add_plugins(gun::plugin);
|
||||
app.add_plugins(thrown::plugin);
|
||||
app.add_plugins(arrow::plugin);
|
||||
app.add_plugins(missile::plugin);
|
||||
app.add_plugins(healing::plugin);
|
||||
app.add_plugins(curver::plugin);
|
||||
|
||||
app.add_systems(
|
||||
Update,
|
||||
(update, update_heal_ability).run_if(in_state(GameState::Playing)),
|
||||
);
|
||||
|
||||
global_observer!(app, on_trigger_state);
|
||||
}
|
||||
|
||||
fn on_trigger_state(
|
||||
trigger: Trigger<TriggerState>,
|
||||
mut res: ResMut<TriggerStateRes>,
|
||||
player_head: Single<&ActiveHead, With<Player>>,
|
||||
headdb: Res<HeadsDatabase>,
|
||||
time: Res<Time>,
|
||||
) {
|
||||
res.active = matches!(trigger.event(), TriggerState::Active);
|
||||
if res.active {
|
||||
let head_stats = headdb.head_stats(player_head.0);
|
||||
res.next_trigger_timestamp = time.elapsed_secs() + head_stats.shoot_offset;
|
||||
}
|
||||
}
|
||||
|
||||
fn update(
|
||||
mut res: ResMut<TriggerStateRes>,
|
||||
mut commands: Commands,
|
||||
player_rot: Query<&Transform, With<PlayerBodyMesh>>,
|
||||
player_query: Query<(Entity, &AimTarget), With<Player>>,
|
||||
mut active_heads: Single<&mut ActiveHeads, With<Player>>,
|
||||
heads_db: Res<HeadsDatabase>,
|
||||
time: Res<Time>,
|
||||
character: CharacterHierarchy,
|
||||
) {
|
||||
if res.active && res.next_trigger_timestamp < time.elapsed_secs() {
|
||||
let Some(state) = active_heads.current() else {
|
||||
return;
|
||||
};
|
||||
|
||||
if !state.has_ammo() {
|
||||
commands.trigger(PlaySound::Invalid);
|
||||
return;
|
||||
}
|
||||
|
||||
let Some((player, target)) = player_query.iter().next() else {
|
||||
return;
|
||||
};
|
||||
|
||||
let Some(projectile_origin) = character
|
||||
.projectile_origin(player)
|
||||
.map(|origin| origin.translation())
|
||||
else {
|
||||
return;
|
||||
};
|
||||
|
||||
let Some((rot, dir)) = player_rot.iter().next().map(|t| (t.rotation, t.forward())) else {
|
||||
return;
|
||||
};
|
||||
|
||||
let head = heads_db.head_stats(state.head);
|
||||
|
||||
if matches!(head.ability, HeadAbility::None | HeadAbility::Medic) {
|
||||
return;
|
||||
}
|
||||
|
||||
active_heads.use_ammo(time.elapsed_secs());
|
||||
|
||||
res.next_trigger_timestamp = time.elapsed_secs() + (1. / head.aps);
|
||||
|
||||
let trigger_state = TriggerData {
|
||||
dir,
|
||||
rot,
|
||||
pos: projectile_origin,
|
||||
target: target.0,
|
||||
target_layer: GameLayer::Npc,
|
||||
head: state.head,
|
||||
};
|
||||
|
||||
match head.ability {
|
||||
HeadAbility::Thrown => commands.trigger(TriggerThrow(trigger_state)),
|
||||
HeadAbility::Gun => commands.trigger(TriggerGun(trigger_state)),
|
||||
HeadAbility::Missile => commands.trigger(TriggerMissile(trigger_state)),
|
||||
HeadAbility::Arrow => commands.trigger(TriggerArrow(trigger_state)),
|
||||
HeadAbility::Curver => commands.trigger(TriggerCurver(trigger_state)),
|
||||
_ => panic!("Unhandled head ability"),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
fn update_heal_ability(
|
||||
res: Res<TriggerStateRes>,
|
||||
mut commands: Commands,
|
||||
active_heads: Single<(Entity, &ActiveHeads), With<Player>>,
|
||||
heads_db: Res<HeadsDatabase>,
|
||||
) {
|
||||
if res.is_changed() {
|
||||
let Some(state) = active_heads.1.current() else {
|
||||
return;
|
||||
};
|
||||
|
||||
let player = active_heads.0;
|
||||
|
||||
let head = heads_db.head_stats(state.head);
|
||||
|
||||
if !matches!(head.ability, HeadAbility::Medic) {
|
||||
return;
|
||||
}
|
||||
|
||||
if res.active {
|
||||
commands.trigger_targets(HealingStateChanged::Started, player);
|
||||
} else {
|
||||
commands.trigger_targets(HealingStateChanged::Stopped, player);
|
||||
}
|
||||
}
|
||||
}
|
||||
184
crates/shared/src/abilities/thrown.rs
Normal file
184
crates/shared/src/abilities/thrown.rs
Normal file
@@ -0,0 +1,184 @@
|
||||
use super::TriggerThrow;
|
||||
use crate::{
|
||||
GameState,
|
||||
billboards::Billboard,
|
||||
heads_database::HeadsDatabase,
|
||||
loading_assets::GameAssets,
|
||||
physics_layers::GameLayer,
|
||||
sounds::PlaySound,
|
||||
utils::{
|
||||
auto_rotate::AutoRotation, explosions::Explosion, global_observer,
|
||||
sprite_3d_animation::AnimationTimer,
|
||||
},
|
||||
};
|
||||
use avian3d::prelude::*;
|
||||
use bevy::{pbr::NotShadowCaster, prelude::*};
|
||||
use bevy_ballistic::launch_velocity;
|
||||
use bevy_sprite3d::{Sprite3dBuilder, Sprite3dParams};
|
||||
use std::f32::consts::PI;
|
||||
|
||||
#[derive(Component)]
|
||||
struct ThrownProjectile {
|
||||
impact_animation: bool,
|
||||
damage: u32,
|
||||
}
|
||||
|
||||
#[derive(Resource)]
|
||||
struct ShotAssets {
|
||||
image: Handle<Image>,
|
||||
layout: Handle<TextureAtlasLayout>,
|
||||
}
|
||||
|
||||
pub fn plugin(app: &mut App) {
|
||||
app.add_systems(OnEnter(GameState::Playing), setup);
|
||||
app.add_systems(Update, shot_collision.run_if(in_state(GameState::Playing)));
|
||||
|
||||
global_observer!(app, on_trigger_thrown);
|
||||
}
|
||||
|
||||
fn setup(mut commands: Commands, assets: Res<GameAssets>, mut sprite_params: Sprite3dParams) {
|
||||
let layout = TextureAtlasLayout::from_grid(UVec2::splat(256), 7, 6, None, None);
|
||||
let texture_atlas_layout = sprite_params.atlas_layouts.add(layout);
|
||||
|
||||
commands.insert_resource(ShotAssets {
|
||||
image: assets.impact_atlas.clone(),
|
||||
layout: texture_atlas_layout,
|
||||
});
|
||||
}
|
||||
|
||||
fn on_trigger_thrown(
|
||||
trigger: Trigger<TriggerThrow>,
|
||||
mut commands: Commands,
|
||||
query_transform: Query<&Transform>,
|
||||
assets: Res<GameAssets>,
|
||||
gltf_assets: Res<Assets<Gltf>>,
|
||||
heads_db: Res<HeadsDatabase>,
|
||||
) {
|
||||
let state = trigger.event().0;
|
||||
|
||||
commands.trigger(PlaySound::Throw);
|
||||
|
||||
const SPEED: f32 = 35.;
|
||||
|
||||
let pos = state.pos;
|
||||
|
||||
let vel = if let Some(target) = state.target {
|
||||
let t = query_transform
|
||||
.get(target)
|
||||
.expect("target must have transform");
|
||||
|
||||
launch_velocity(pos, t.translation, SPEED, 9.81)
|
||||
.map(|(low, _)| low)
|
||||
.unwrap()
|
||||
} else {
|
||||
state.rot.mul_quat(Quat::from_rotation_y(-PI / 2.))
|
||||
* (Vec3::new(2., 1., 0.).normalize() * SPEED)
|
||||
};
|
||||
|
||||
let head = heads_db.head_stats(state.head);
|
||||
let mesh = assets.projectiles[format!("{}.glb", head.projectile).as_str()].clone();
|
||||
let asset = gltf_assets.get(&mesh).unwrap();
|
||||
|
||||
//TODO: projectile db?
|
||||
let explosion_animation = !matches!(state.head, 8 | 16);
|
||||
|
||||
commands
|
||||
.spawn((
|
||||
Transform::from_translation(pos),
|
||||
Name::new("projectile-thrown"),
|
||||
ThrownProjectile {
|
||||
impact_animation: explosion_animation,
|
||||
damage: head.damage,
|
||||
},
|
||||
Collider::sphere(0.4),
|
||||
CollisionLayers::new(
|
||||
LayerMask(GameLayer::Projectile.to_bits()),
|
||||
LayerMask(state.target_layer.to_bits() | GameLayer::Level.to_bits()),
|
||||
),
|
||||
RigidBody::Dynamic,
|
||||
CollisionEventsEnabled,
|
||||
Mass(0.01),
|
||||
LinearVelocity(vel),
|
||||
Visibility::default(),
|
||||
Sensor,
|
||||
))
|
||||
.with_child((
|
||||
AutoRotation(Quat::from_rotation_x(0.4) * Quat::from_rotation_z(0.3)),
|
||||
SceneRoot(asset.scenes[0].clone()),
|
||||
));
|
||||
}
|
||||
|
||||
fn shot_collision(
|
||||
mut commands: Commands,
|
||||
mut collision_event_reader: EventReader<CollisionStarted>,
|
||||
query_shot: Query<(&ThrownProjectile, &Transform)>,
|
||||
sensors: Query<(), With<Sensor>>,
|
||||
assets: Res<ShotAssets>,
|
||||
mut sprite_params: Sprite3dParams,
|
||||
) {
|
||||
for CollisionStarted(e1, e2) in collision_event_reader.read() {
|
||||
if !query_shot.contains(*e1) && !query_shot.contains(*e2) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if sensors.contains(*e1) && sensors.contains(*e2) {
|
||||
continue;
|
||||
}
|
||||
|
||||
let shot_entity = if query_shot.contains(*e1) { *e1 } else { *e2 };
|
||||
|
||||
let Ok((shot_pos, animation, damage)) =
|
||||
query_shot.get(shot_entity).map(|(projectile, t)| {
|
||||
(
|
||||
t.translation,
|
||||
projectile.impact_animation,
|
||||
projectile.damage,
|
||||
)
|
||||
})
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
|
||||
if let Ok(mut entity) = commands.get_entity(shot_entity) {
|
||||
entity.try_despawn();
|
||||
} else {
|
||||
continue;
|
||||
}
|
||||
|
||||
commands.trigger(PlaySound::ThrowHit);
|
||||
|
||||
commands.trigger(Explosion {
|
||||
damage,
|
||||
position: shot_pos,
|
||||
//TODO: should be around 1 grid in distance
|
||||
radius: 5.,
|
||||
});
|
||||
|
||||
//TODO: support different impact animations
|
||||
if animation {
|
||||
commands
|
||||
.spawn(
|
||||
Sprite3dBuilder {
|
||||
image: assets.image.clone(),
|
||||
pixels_per_metre: 32.,
|
||||
alpha_mode: AlphaMode::Blend,
|
||||
unlit: true,
|
||||
..default()
|
||||
}
|
||||
.bundle_with_atlas(
|
||||
&mut sprite_params,
|
||||
TextureAtlas {
|
||||
layout: assets.layout.clone(),
|
||||
index: 0,
|
||||
},
|
||||
),
|
||||
)
|
||||
.insert((
|
||||
Billboard::All,
|
||||
Transform::from_translation(shot_pos),
|
||||
NotShadowCaster,
|
||||
AnimationTimer::new(Timer::from_seconds(0.02, TimerMode::Repeating)),
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
178
crates/shared/src/ai/mod.rs
Normal file
178
crates/shared/src/ai/mod.rs
Normal file
@@ -0,0 +1,178 @@
|
||||
use crate::{
|
||||
GameState,
|
||||
abilities::{HeadAbility, TriggerData, TriggerThrow},
|
||||
aim::AimTarget,
|
||||
heads::ActiveHeads,
|
||||
heads_database::HeadsDatabase,
|
||||
player::Player,
|
||||
};
|
||||
use bevy::prelude::*;
|
||||
|
||||
#[derive(Component, Reflect)]
|
||||
#[reflect(Component)]
|
||||
pub struct Ai;
|
||||
|
||||
#[derive(Component, Reflect, Clone)]
|
||||
#[reflect(Component)]
|
||||
struct WaitForAnyPlayer;
|
||||
|
||||
#[derive(Component, Reflect, Clone)]
|
||||
#[reflect(Component)]
|
||||
struct Engage(Entity);
|
||||
|
||||
#[derive(Component, Reflect, Clone)]
|
||||
#[reflect(Component)]
|
||||
struct Reload;
|
||||
|
||||
pub fn plugin(app: &mut App) {
|
||||
app.add_systems(
|
||||
Update,
|
||||
(
|
||||
engage_and_throw,
|
||||
wait_for_player,
|
||||
out_of_range,
|
||||
detect_reload,
|
||||
detect_reload_done,
|
||||
rotate,
|
||||
)
|
||||
.run_if(in_state(GameState::Playing)),
|
||||
);
|
||||
app.add_systems(Update, on_ai_added);
|
||||
}
|
||||
|
||||
fn on_ai_added(mut commands: Commands, query: Query<Entity, Added<Ai>>) {
|
||||
for entity in query.iter() {
|
||||
commands.entity(entity).insert(WaitForAnyPlayer);
|
||||
}
|
||||
}
|
||||
|
||||
fn wait_for_player(
|
||||
mut commands: Commands,
|
||||
agents: Query<Entity, With<WaitForAnyPlayer>>,
|
||||
transform: Query<&Transform>,
|
||||
players: Query<Entity, With<Player>>,
|
||||
) {
|
||||
for agent in agents.iter() {
|
||||
if let Some(player) = in_range(50., agent, &players, &transform) {
|
||||
info!("[{agent}] Engage: {player}");
|
||||
if let Ok(mut agent) = commands.get_entity(agent) {
|
||||
agent.remove::<WaitForAnyPlayer>().insert(Engage(player));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn out_of_range(
|
||||
mut commands: Commands,
|
||||
agents: Query<Entity, With<Engage>>,
|
||||
transform: Query<&Transform>,
|
||||
players: Query<Entity, With<Player>>,
|
||||
) {
|
||||
for agent in agents.iter() {
|
||||
if in_range(100., agent, &players, &transform).is_none() {
|
||||
info!("[{agent}] Player out of range");
|
||||
commands
|
||||
.entity(agent)
|
||||
.remove::<Engage>()
|
||||
.insert(WaitForAnyPlayer);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn detect_reload(mut commands: Commands, agents: Query<(Entity, &ActiveHeads), With<Engage>>) {
|
||||
for (e, head) in agents.iter() {
|
||||
if head.reloading() {
|
||||
info!("[{e}] Reload started");
|
||||
commands.entity(e).remove::<Engage>().insert(Reload);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn detect_reload_done(mut commands: Commands, agents: Query<(Entity, &ActiveHeads), With<Reload>>) {
|
||||
for (e, head) in agents.iter() {
|
||||
if !head.reloading() {
|
||||
info!("[{e}] Reload done");
|
||||
commands
|
||||
.entity(e)
|
||||
.remove::<Reload>()
|
||||
.insert(WaitForAnyPlayer);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn in_range(
|
||||
range: f32,
|
||||
entity: Entity,
|
||||
players: &Query<'_, '_, Entity, With<Player>>,
|
||||
transform: &Query<'_, '_, &Transform>,
|
||||
) -> Option<Entity> {
|
||||
let Ok(pos) = transform.get(entity).map(|t| t.translation) else {
|
||||
return None;
|
||||
};
|
||||
|
||||
players
|
||||
.iter()
|
||||
.filter_map(|p| transform.get(p).ok().map(|t| (p, *t)))
|
||||
.find(|(_, t)| t.translation.distance(pos) < range)
|
||||
.map(|(e, _)| e)
|
||||
}
|
||||
|
||||
fn rotate(agent: Query<(Entity, &Engage)>, mut transform: Query<&mut Transform>) {
|
||||
for (agent, Engage(target)) in agent.iter() {
|
||||
let Ok(target_pos) = transform.get(*target).map(|t| t.translation) else {
|
||||
continue;
|
||||
};
|
||||
let Ok(mut agent_transform) = transform.get_mut(agent) else {
|
||||
continue;
|
||||
};
|
||||
|
||||
// Get the direction vector from the current position to the target
|
||||
let direction = (target_pos - agent_transform.translation).normalize();
|
||||
|
||||
// Project the direction onto the XZ plane by zeroing out the Y component
|
||||
let xz_direction = Vec3::new(direction.x, 0.0, direction.z).normalize();
|
||||
|
||||
agent_transform.rotation = Quat::from_rotation_arc(Vec3::Z, xz_direction);
|
||||
}
|
||||
}
|
||||
|
||||
fn engage_and_throw(
|
||||
mut commands: Commands,
|
||||
mut query: Query<(&mut ActiveHeads, &AimTarget, &Transform), With<Engage>>,
|
||||
time: Res<Time>,
|
||||
heads_db: Res<HeadsDatabase>,
|
||||
) {
|
||||
for (mut npc, target, t) in query.iter_mut() {
|
||||
if target.0.is_none() {
|
||||
continue;
|
||||
}
|
||||
|
||||
let Some(npc_head) = npc.current() else {
|
||||
continue;
|
||||
};
|
||||
|
||||
let ability = heads_db.head_stats(npc_head.head).ability;
|
||||
|
||||
//TODO: support other abilities
|
||||
if ability != HeadAbility::Thrown {
|
||||
continue;
|
||||
}
|
||||
|
||||
let can_shoot_again = npc_head.last_use + 1. < time.elapsed_secs();
|
||||
|
||||
if can_shoot_again && npc_head.has_ammo() {
|
||||
npc.use_ammo(time.elapsed_secs());
|
||||
|
||||
let dir = t.forward();
|
||||
|
||||
commands.trigger(TriggerThrow(TriggerData::new(
|
||||
target.0,
|
||||
dir,
|
||||
t.rotation,
|
||||
t.translation,
|
||||
crate::physics_layers::GameLayer::Player,
|
||||
npc_head.head,
|
||||
)));
|
||||
}
|
||||
}
|
||||
}
|
||||
60
crates/shared/src/aim/marker.rs
Normal file
60
crates/shared/src/aim/marker.rs
Normal file
@@ -0,0 +1,60 @@
|
||||
use crate::{GameState, global_observer, loading_assets::UIAssets, utils::billboards::Billboard};
|
||||
use bevy::prelude::*;
|
||||
use bevy_sprite3d::{Sprite3dBuilder, Sprite3dParams};
|
||||
use ops::sin;
|
||||
|
||||
#[derive(Component, Reflect)]
|
||||
#[reflect(Component)]
|
||||
struct TargetMarker;
|
||||
|
||||
#[derive(Event)]
|
||||
pub enum MarkerEvent {
|
||||
Spawn(Entity),
|
||||
Despawn,
|
||||
}
|
||||
|
||||
pub fn plugin(app: &mut App) {
|
||||
app.add_systems(Update, move_marker.run_if(in_state(GameState::Playing)));
|
||||
global_observer!(app, marker_event);
|
||||
}
|
||||
|
||||
fn move_marker(mut query: Query<&mut Transform, With<TargetMarker>>, time: Res<Time>) {
|
||||
for mut transform in query.iter_mut() {
|
||||
transform.translation = Vec3::new(0., 3. + (sin(time.elapsed_secs() * 6.) * 0.2), 0.);
|
||||
}
|
||||
}
|
||||
|
||||
fn marker_event(
|
||||
trigger: Trigger<MarkerEvent>,
|
||||
mut commands: Commands,
|
||||
assets: Res<UIAssets>,
|
||||
mut sprite_params: Sprite3dParams,
|
||||
marker: Query<Entity, With<TargetMarker>>,
|
||||
) {
|
||||
for m in marker.iter() {
|
||||
commands.entity(m).despawn();
|
||||
}
|
||||
|
||||
let MarkerEvent::Spawn(target) = trigger.event() else {
|
||||
return;
|
||||
};
|
||||
|
||||
let id = commands
|
||||
.spawn((
|
||||
Name::new("aim-marker"),
|
||||
Billboard::All,
|
||||
TargetMarker,
|
||||
Transform::default(),
|
||||
Sprite3dBuilder {
|
||||
image: assets.head_selector.clone(),
|
||||
pixels_per_metre: 30.,
|
||||
alpha_mode: AlphaMode::Blend,
|
||||
unlit: true,
|
||||
..default()
|
||||
}
|
||||
.bundle(&mut sprite_params),
|
||||
))
|
||||
.id();
|
||||
|
||||
commands.entity(*target).add_child(id);
|
||||
}
|
||||
206
crates/shared/src/aim/mod.rs
Normal file
206
crates/shared/src/aim/mod.rs
Normal file
@@ -0,0 +1,206 @@
|
||||
mod marker;
|
||||
mod target_ui;
|
||||
|
||||
use crate::{
|
||||
GameState,
|
||||
head::ActiveHead,
|
||||
heads_database::HeadsDatabase,
|
||||
hitpoints::Hitpoints,
|
||||
physics_layers::GameLayer,
|
||||
player::{Player, PlayerBodyMesh},
|
||||
tb_entities::EnemySpawn,
|
||||
};
|
||||
use avian3d::prelude::*;
|
||||
use bevy::prelude::*;
|
||||
use marker::MarkerEvent;
|
||||
use std::f32::consts::PI;
|
||||
|
||||
#[derive(Component, Reflect, Default, Deref)]
|
||||
#[reflect(Component)]
|
||||
pub struct AimTarget(pub Option<Entity>);
|
||||
|
||||
#[derive(Component, Reflect)]
|
||||
#[reflect(Component)]
|
||||
#[require(AimTarget)]
|
||||
pub struct AimState {
|
||||
pub range: f32,
|
||||
pub max_angle: f32,
|
||||
pub spawn_marker: bool,
|
||||
}
|
||||
|
||||
impl Default for AimState {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
range: 80.,
|
||||
max_angle: PI / 8.,
|
||||
spawn_marker: true,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn plugin(app: &mut App) {
|
||||
app.add_plugins(target_ui::plugin);
|
||||
app.add_plugins(marker::plugin);
|
||||
|
||||
app.add_systems(
|
||||
Update,
|
||||
(update_player_aim, update_npc_aim, head_change).run_if(in_state(GameState::Playing)),
|
||||
);
|
||||
app.add_systems(Update, add_aim);
|
||||
}
|
||||
|
||||
fn add_aim(mut commands: Commands, query: Query<Entity, Added<ActiveHead>>) {
|
||||
for e in query.iter() {
|
||||
commands.entity(e).insert(AimState::default());
|
||||
}
|
||||
}
|
||||
|
||||
fn head_change(
|
||||
mut query: Query<(&ActiveHead, &mut AimState), Changed<ActiveHead>>,
|
||||
heads_db: Res<HeadsDatabase>,
|
||||
) {
|
||||
for (head, mut state) in query.iter_mut() {
|
||||
// info!("head changed: {}", head.0);
|
||||
// state.max_angle = if head.0 == 0 { PI / 8. } else { PI / 2. }
|
||||
let stats = heads_db.head_stats(head.0);
|
||||
state.range = stats.range;
|
||||
}
|
||||
}
|
||||
|
||||
fn update_player_aim(
|
||||
mut commands: Commands,
|
||||
potential_targets: Query<(Entity, &Transform), With<Hitpoints>>,
|
||||
player_rot: Query<(&Transform, &GlobalTransform), With<PlayerBodyMesh>>,
|
||||
mut player_aim: Query<(Entity, &AimState, &mut AimTarget), With<Player>>,
|
||||
spatial_query: SpatialQuery,
|
||||
) {
|
||||
let Some((player, state, mut aim_target)) = player_aim.iter_mut().next() else {
|
||||
return;
|
||||
};
|
||||
|
||||
let Some((player_pos, player_forward)) = player_rot
|
||||
.iter()
|
||||
.next()
|
||||
.map(|(t, global)| (global.translation(), t.forward()))
|
||||
else {
|
||||
return;
|
||||
};
|
||||
|
||||
let mut new_target = None;
|
||||
let mut target_distance = f32::MAX;
|
||||
|
||||
for (e, t) in potential_targets.iter() {
|
||||
if e == player {
|
||||
continue;
|
||||
}
|
||||
|
||||
let delta = player_pos - t.translation;
|
||||
|
||||
let distance = delta.length();
|
||||
|
||||
if distance > state.range {
|
||||
continue;
|
||||
}
|
||||
|
||||
let angle = player_forward.angle_between(delta.normalize());
|
||||
|
||||
if angle < state.max_angle && distance < target_distance {
|
||||
if !line_of_sight(&spatial_query, player_pos, delta, distance) {
|
||||
continue;
|
||||
}
|
||||
|
||||
new_target = Some(e);
|
||||
target_distance = distance;
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(e) = &aim_target.0 {
|
||||
if commands.get_entity(*e).is_err() {
|
||||
aim_target.0 = None;
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if new_target != aim_target.0 {
|
||||
if state.spawn_marker {
|
||||
if let Some(target) = new_target {
|
||||
commands.trigger(MarkerEvent::Spawn(target));
|
||||
} else {
|
||||
commands.trigger(MarkerEvent::Despawn);
|
||||
}
|
||||
}
|
||||
aim_target.0 = new_target;
|
||||
}
|
||||
}
|
||||
|
||||
fn update_npc_aim(
|
||||
mut commands: Commands,
|
||||
mut subject: Query<(&AimState, &Transform, &mut AimTarget), With<EnemySpawn>>,
|
||||
potential_targets: Query<(Entity, &Transform), With<Player>>,
|
||||
spatial_query: SpatialQuery,
|
||||
) {
|
||||
for (state, t, mut aim_target) in subject.iter_mut() {
|
||||
let (pos, forward) = (t.translation, t.forward());
|
||||
|
||||
let mut new_target = None;
|
||||
let mut target_distance = f32::MAX;
|
||||
|
||||
for (e, t) in potential_targets.iter() {
|
||||
let delta = pos - t.translation;
|
||||
|
||||
let distance = delta.length();
|
||||
|
||||
if distance > state.range {
|
||||
continue;
|
||||
}
|
||||
|
||||
let angle = forward.angle_between(delta.normalize());
|
||||
|
||||
if angle < state.max_angle && distance < target_distance {
|
||||
if !line_of_sight(&spatial_query, pos, delta, distance) {
|
||||
continue;
|
||||
}
|
||||
|
||||
new_target = Some(e);
|
||||
target_distance = distance;
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(e) = &aim_target.0 {
|
||||
if commands.get_entity(*e).is_err() {
|
||||
aim_target.0 = None;
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if new_target != aim_target.0 {
|
||||
aim_target.0 = new_target;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn line_of_sight(
|
||||
spatial_query: &SpatialQuery<'_, '_>,
|
||||
player_pos: Vec3,
|
||||
delta: Vec3,
|
||||
distance: f32,
|
||||
) -> bool {
|
||||
if let Some(_hit) = spatial_query.cast_shape(
|
||||
&Collider::sphere(0.1),
|
||||
player_pos + -delta.normalize() + (Vec3::Y * 2.),
|
||||
Quat::default(),
|
||||
Dir3::new(-delta).unwrap(),
|
||||
&ShapeCastConfig {
|
||||
max_distance: distance * 0.98,
|
||||
compute_contact_on_penetration: false,
|
||||
ignore_origin_penetration: true,
|
||||
..Default::default()
|
||||
},
|
||||
&SpatialQueryFilter::default().with_mask(LayerMask(GameLayer::Level.to_bits())),
|
||||
) {
|
||||
// info!("no line of sight");
|
||||
return false;
|
||||
};
|
||||
|
||||
true
|
||||
}
|
||||
158
crates/shared/src/aim/target_ui.rs
Normal file
158
crates/shared/src/aim/target_ui.rs
Normal file
@@ -0,0 +1,158 @@
|
||||
use super::AimTarget;
|
||||
use crate::{
|
||||
GameState,
|
||||
backpack::UiHeadState,
|
||||
heads::{ActiveHeads, HeadsImages},
|
||||
hitpoints::Hitpoints,
|
||||
loading_assets::UIAssets,
|
||||
npc::Npc,
|
||||
player::Player,
|
||||
};
|
||||
use bevy::prelude::*;
|
||||
|
||||
#[derive(Component, Reflect, Default)]
|
||||
#[reflect(Component)]
|
||||
struct HeadImage;
|
||||
|
||||
#[derive(Component, Reflect, Default)]
|
||||
#[reflect(Component)]
|
||||
struct HeadDamage;
|
||||
|
||||
#[derive(Resource, Default, PartialEq)]
|
||||
struct TargetUi {
|
||||
head: Option<UiHeadState>,
|
||||
}
|
||||
|
||||
pub fn plugin(app: &mut App) {
|
||||
app.add_systems(OnEnter(GameState::Playing), setup);
|
||||
app.add_systems(Update, (sync, update).run_if(in_state(GameState::Playing)));
|
||||
}
|
||||
|
||||
fn setup(mut commands: Commands, assets: Res<UIAssets>) {
|
||||
commands.spawn((
|
||||
Name::new("target-ui"),
|
||||
Node {
|
||||
position_type: PositionType::Absolute,
|
||||
top: Val::Px(150.0),
|
||||
left: Val::Px(20.0),
|
||||
height: Val::Px(74.0),
|
||||
..default()
|
||||
},
|
||||
children![spawn_head_ui(
|
||||
assets.head_bg.clone(),
|
||||
assets.head_regular.clone(),
|
||||
assets.head_damage.clone(),
|
||||
)],
|
||||
));
|
||||
|
||||
commands.insert_resource(TargetUi::default());
|
||||
}
|
||||
|
||||
fn spawn_head_ui(bg: Handle<Image>, regular: Handle<Image>, damage: Handle<Image>) -> impl Bundle {
|
||||
const SIZE: f32 = 90.0;
|
||||
const DAMAGE_SIZE: f32 = 74.0;
|
||||
|
||||
(
|
||||
Node {
|
||||
position_type: PositionType::Relative,
|
||||
justify_content: JustifyContent::Center,
|
||||
align_items: AlignItems::Center,
|
||||
width: Val::Px(SIZE),
|
||||
..default()
|
||||
},
|
||||
children![
|
||||
(
|
||||
Node {
|
||||
position_type: PositionType::Absolute,
|
||||
..default()
|
||||
},
|
||||
ImageNode::new(bg),
|
||||
),
|
||||
(
|
||||
Node {
|
||||
position_type: PositionType::Absolute,
|
||||
..default()
|
||||
},
|
||||
ImageNode::default(),
|
||||
Visibility::Hidden,
|
||||
HeadImage,
|
||||
),
|
||||
(
|
||||
Node {
|
||||
position_type: PositionType::Absolute,
|
||||
..default()
|
||||
},
|
||||
ImageNode::new(regular),
|
||||
),
|
||||
(
|
||||
Node {
|
||||
height: Val::Px(DAMAGE_SIZE),
|
||||
width: Val::Px(DAMAGE_SIZE),
|
||||
..default()
|
||||
},
|
||||
children![(
|
||||
HeadDamage,
|
||||
Node {
|
||||
position_type: PositionType::Absolute,
|
||||
display: Display::Block,
|
||||
overflow: Overflow::clip(),
|
||||
top: Val::Px(0.),
|
||||
left: Val::Px(0.),
|
||||
right: Val::Px(0.),
|
||||
height: Val::Percent(25.),
|
||||
..default()
|
||||
},
|
||||
children![ImageNode::new(damage)]
|
||||
)]
|
||||
)
|
||||
],
|
||||
)
|
||||
}
|
||||
|
||||
fn update(
|
||||
target: Res<TargetUi>,
|
||||
heads_images: Res<HeadsImages>,
|
||||
mut head_image: Query<
|
||||
(&mut Visibility, &mut ImageNode),
|
||||
(Without<HeadDamage>, With<HeadImage>),
|
||||
>,
|
||||
mut head_damage: Query<&mut Node, (With<HeadDamage>, Without<HeadImage>)>,
|
||||
) {
|
||||
if target.is_changed() {
|
||||
if let Ok((mut vis, mut image)) = head_image.single_mut() {
|
||||
if let Some(head) = target.head {
|
||||
*vis = Visibility::Visible;
|
||||
image.image = heads_images.heads[head.head].clone();
|
||||
} else {
|
||||
*vis = Visibility::Hidden;
|
||||
}
|
||||
}
|
||||
|
||||
if let Ok(mut node) = head_damage.single_mut() {
|
||||
node.height = Val::Percent(target.head.map(|head| head.damage()).unwrap_or(0.) * 100.);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn sync(
|
||||
mut target: ResMut<TargetUi>,
|
||||
player_target: Query<&AimTarget, With<Player>>,
|
||||
target_data: Query<(&Hitpoints, &ActiveHeads), With<Npc>>,
|
||||
) {
|
||||
let mut new_state = None;
|
||||
if let Some(e) = player_target.iter().next().and_then(|target| target.0) {
|
||||
if let Ok((hp, heads)) = target_data.get(e) {
|
||||
let head = heads.current().expect("target must have a head on");
|
||||
new_state = Some(UiHeadState {
|
||||
head: head.head,
|
||||
health: hp.health(),
|
||||
ammo: 1.,
|
||||
reloading: None,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if new_state != target.head {
|
||||
target.head = new_state;
|
||||
}
|
||||
}
|
||||
180
crates/shared/src/animation.rs
Normal file
180
crates/shared/src/animation.rs
Normal file
@@ -0,0 +1,180 @@
|
||||
use crate::{
|
||||
GameState, character::CharacterAnimations, head::ActiveHead, heads_database::HeadsDatabase,
|
||||
};
|
||||
use bevy::{animation::RepeatAnimation, ecs::query::QueryData, prelude::*};
|
||||
use std::time::Duration;
|
||||
|
||||
pub fn plugin(app: &mut App) {
|
||||
app.register_type::<AnimationFlags>();
|
||||
|
||||
app.add_systems(
|
||||
Update,
|
||||
update_animation.run_if(in_state(GameState::Playing)),
|
||||
);
|
||||
}
|
||||
|
||||
#[derive(Component, Default, Reflect)]
|
||||
#[reflect(Component)]
|
||||
pub struct AnimationFlags {
|
||||
pub any_direction: bool,
|
||||
pub jumping: bool,
|
||||
pub just_jumped: bool,
|
||||
pub shooting: bool,
|
||||
pub restart_shooting: bool,
|
||||
pub hit: bool,
|
||||
}
|
||||
|
||||
#[derive(QueryData)]
|
||||
#[query_data(mutable)]
|
||||
pub struct AnimationController {
|
||||
pub transitions: &'static mut AnimationTransitions,
|
||||
pub player: &'static mut AnimationPlayer,
|
||||
}
|
||||
|
||||
impl AnimationController {
|
||||
pub fn play_inner(
|
||||
player: &mut AnimationPlayer,
|
||||
transitions: &mut AnimationTransitions,
|
||||
animation: AnimationNodeIndex,
|
||||
transition: Duration,
|
||||
repeat: RepeatAnimation,
|
||||
) {
|
||||
transitions
|
||||
.play(player, animation, transition)
|
||||
.set_repeat(repeat);
|
||||
}
|
||||
}
|
||||
|
||||
impl AnimationControllerItem<'_> {
|
||||
pub fn play(
|
||||
&mut self,
|
||||
animation: AnimationNodeIndex,
|
||||
transition: Duration,
|
||||
repeat: RepeatAnimation,
|
||||
) {
|
||||
AnimationController::play_inner(
|
||||
&mut self.player,
|
||||
&mut self.transitions,
|
||||
animation,
|
||||
transition,
|
||||
repeat,
|
||||
);
|
||||
}
|
||||
|
||||
pub fn is_playing(&self, index: AnimationNodeIndex) -> bool {
|
||||
self.player.is_playing_animation(index)
|
||||
}
|
||||
}
|
||||
|
||||
const DEFAULT_TRANSITION_DURATION: Duration = Duration::ZERO;
|
||||
|
||||
fn update_animation(
|
||||
mut animated: Query<(
|
||||
AnimationController,
|
||||
&CharacterAnimations,
|
||||
&mut AnimationFlags,
|
||||
)>,
|
||||
character: Query<&ActiveHead>,
|
||||
headdb: Res<HeadsDatabase>,
|
||||
) {
|
||||
for (mut controller, anims, mut flags) in animated.iter_mut() {
|
||||
let head = character.get(anims.of_character).unwrap();
|
||||
let head = headdb.head_stats(head.0);
|
||||
|
||||
let is_playing_shoot = anims.shoot.is_some()
|
||||
&& controller.is_playing(anims.shoot.unwrap())
|
||||
&& !controller
|
||||
.player
|
||||
.animation(anims.shoot.unwrap())
|
||||
.unwrap()
|
||||
.is_finished();
|
||||
let is_playing_run_shoot = anims.run_shoot.is_some()
|
||||
&& controller.is_playing(anims.run_shoot.unwrap())
|
||||
&& !controller
|
||||
.player
|
||||
.animation(anims.run_shoot.unwrap())
|
||||
.unwrap()
|
||||
.is_finished();
|
||||
let wait_for_shoot = !head.interrupt_shoot && (is_playing_shoot || is_playing_run_shoot);
|
||||
if wait_for_shoot {
|
||||
return;
|
||||
} else if flags.shooting && flags.any_direction && anims.run_shoot.is_some() {
|
||||
if !controller.is_playing(anims.run_shoot.unwrap()) {
|
||||
controller.play(
|
||||
anims.run_shoot.unwrap(),
|
||||
DEFAULT_TRANSITION_DURATION,
|
||||
RepeatAnimation::Never,
|
||||
);
|
||||
}
|
||||
if controller
|
||||
.player
|
||||
.animation(anims.run_shoot.unwrap())
|
||||
.unwrap()
|
||||
.is_finished()
|
||||
|| flags.restart_shooting
|
||||
{
|
||||
controller
|
||||
.player
|
||||
.animation_mut(anims.run_shoot.unwrap())
|
||||
.unwrap()
|
||||
.replay();
|
||||
|
||||
flags.restart_shooting = false;
|
||||
}
|
||||
} else if flags.shooting && anims.shoot.is_some() {
|
||||
if !controller.is_playing(anims.shoot.unwrap()) {
|
||||
controller.play(
|
||||
anims.shoot.unwrap(),
|
||||
DEFAULT_TRANSITION_DURATION,
|
||||
RepeatAnimation::Never,
|
||||
);
|
||||
}
|
||||
if controller
|
||||
.player
|
||||
.animation(anims.shoot.unwrap())
|
||||
.unwrap()
|
||||
.is_finished()
|
||||
|| flags.restart_shooting
|
||||
{
|
||||
controller
|
||||
.player
|
||||
.animation_mut(anims.shoot.unwrap())
|
||||
.unwrap()
|
||||
.replay();
|
||||
|
||||
flags.restart_shooting = false;
|
||||
}
|
||||
} else if flags.hit {
|
||||
if !controller.is_playing(anims.hit) {
|
||||
controller.play(
|
||||
anims.hit,
|
||||
DEFAULT_TRANSITION_DURATION,
|
||||
RepeatAnimation::Never,
|
||||
);
|
||||
}
|
||||
} else if flags.jumping {
|
||||
if !controller.is_playing(anims.jump) || flags.just_jumped {
|
||||
controller.play(
|
||||
anims.jump,
|
||||
DEFAULT_TRANSITION_DURATION,
|
||||
RepeatAnimation::Never,
|
||||
);
|
||||
flags.just_jumped = false;
|
||||
}
|
||||
} else if flags.any_direction {
|
||||
if !controller.player.is_playing_animation(anims.run) {
|
||||
controller.play(
|
||||
anims.run,
|
||||
DEFAULT_TRANSITION_DURATION,
|
||||
RepeatAnimation::Forever,
|
||||
);
|
||||
}
|
||||
} else if !controller.is_playing(anims.idle) {
|
||||
controller.play(
|
||||
anims.idle,
|
||||
DEFAULT_TRANSITION_DURATION,
|
||||
RepeatAnimation::Forever,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
328
crates/shared/src/backpack/backpack_ui.rs
Normal file
328
crates/shared/src/backpack/backpack_ui.rs
Normal file
@@ -0,0 +1,328 @@
|
||||
use super::{BackbackSwapEvent, Backpack, UiHeadState};
|
||||
use crate::{
|
||||
GameState, global_observer, heads::HeadsImages, loading_assets::UIAssets, sounds::PlaySound,
|
||||
};
|
||||
use bevy::{ecs::spawn::SpawnIter, prelude::*};
|
||||
|
||||
static HEAD_SLOTS: usize = 5;
|
||||
|
||||
#[derive(Event, Clone, Copy, Reflect, PartialEq)]
|
||||
pub enum BackpackAction {
|
||||
Left,
|
||||
Right,
|
||||
Swap,
|
||||
OpenClose,
|
||||
}
|
||||
|
||||
#[derive(Component, Default)]
|
||||
struct BackpackMarker;
|
||||
|
||||
#[derive(Component, Default)]
|
||||
struct BackpackCountText;
|
||||
|
||||
#[derive(Component, Default)]
|
||||
struct HeadSelector(pub usize);
|
||||
|
||||
#[derive(Component, Default)]
|
||||
struct HeadImage(pub usize);
|
||||
|
||||
#[derive(Component, Default)]
|
||||
struct HeadDamage(pub usize);
|
||||
|
||||
#[derive(Resource, Default, Debug)]
|
||||
struct BackpackUiState {
|
||||
heads: [Option<UiHeadState>; 5],
|
||||
scroll: usize,
|
||||
count: usize,
|
||||
current_slot: usize,
|
||||
open: bool,
|
||||
}
|
||||
|
||||
impl BackpackUiState {
|
||||
fn relative_current_slot(&self) -> usize {
|
||||
self.current_slot.saturating_sub(self.scroll)
|
||||
}
|
||||
}
|
||||
|
||||
pub fn plugin(app: &mut App) {
|
||||
app.init_resource::<BackpackUiState>();
|
||||
app.add_systems(OnEnter(GameState::Playing), setup);
|
||||
app.add_systems(
|
||||
Update,
|
||||
(update, sync_on_change, update_visibility, update_count)
|
||||
.run_if(in_state(GameState::Playing)),
|
||||
);
|
||||
|
||||
global_observer!(app, swap_head_inputs);
|
||||
}
|
||||
|
||||
fn setup(mut commands: Commands, assets: Res<UIAssets>) {
|
||||
commands.spawn((
|
||||
Name::new("backpack-ui"),
|
||||
BackpackMarker,
|
||||
Visibility::Hidden,
|
||||
Node {
|
||||
position_type: PositionType::Absolute,
|
||||
top: Val::Px(20.0),
|
||||
right: Val::Px(20.0),
|
||||
height: Val::Px(74.0),
|
||||
..default()
|
||||
},
|
||||
Children::spawn(SpawnIter((0..HEAD_SLOTS).map({
|
||||
let bg = assets.head_bg.clone();
|
||||
let regular = assets.head_regular.clone();
|
||||
let selector = assets.head_selector.clone();
|
||||
let damage = assets.head_damage.clone();
|
||||
|
||||
move |i| {
|
||||
spawn_head_ui(
|
||||
bg.clone(),
|
||||
regular.clone(),
|
||||
selector.clone(),
|
||||
damage.clone(),
|
||||
i,
|
||||
)
|
||||
}
|
||||
}))),
|
||||
));
|
||||
|
||||
commands.spawn((
|
||||
Name::new("backpack-head-count-ui"),
|
||||
Text::new("0"),
|
||||
TextShadow::default(),
|
||||
BackpackCountText,
|
||||
TextFont {
|
||||
font: assets.font.clone(),
|
||||
font_size: 34.0,
|
||||
..default()
|
||||
},
|
||||
TextColor(Color::Srgba(Srgba::rgb(0., 1., 0.))),
|
||||
TextLayout::new_with_justify(JustifyText::Center),
|
||||
Node {
|
||||
position_type: PositionType::Absolute,
|
||||
top: Val::Px(20.0),
|
||||
right: Val::Px(20.0),
|
||||
..default()
|
||||
},
|
||||
));
|
||||
}
|
||||
|
||||
fn spawn_head_ui(
|
||||
bg: Handle<Image>,
|
||||
regular: Handle<Image>,
|
||||
selector: Handle<Image>,
|
||||
damage: Handle<Image>,
|
||||
head_slot: usize,
|
||||
) -> impl Bundle {
|
||||
const SIZE: f32 = 90.0;
|
||||
const DAMAGE_SIZE: f32 = 74.0;
|
||||
|
||||
(
|
||||
Node {
|
||||
position_type: PositionType::Relative,
|
||||
justify_content: JustifyContent::Center,
|
||||
align_items: AlignItems::Center,
|
||||
width: Val::Px(SIZE),
|
||||
..default()
|
||||
},
|
||||
children![
|
||||
(
|
||||
Name::new("selector"),
|
||||
Node {
|
||||
position_type: PositionType::Absolute,
|
||||
bottom: Val::Px(-30.0),
|
||||
..default()
|
||||
},
|
||||
Visibility::Hidden,
|
||||
ImageNode::new(selector).with_flip_y(),
|
||||
HeadSelector(head_slot),
|
||||
),
|
||||
(
|
||||
Name::new("bg"),
|
||||
Node {
|
||||
position_type: PositionType::Absolute,
|
||||
..default()
|
||||
},
|
||||
ImageNode::new(bg),
|
||||
),
|
||||
(
|
||||
Name::new("head"),
|
||||
Node {
|
||||
position_type: PositionType::Absolute,
|
||||
..default()
|
||||
},
|
||||
ImageNode::default(),
|
||||
Visibility::Hidden,
|
||||
HeadImage(head_slot),
|
||||
),
|
||||
(
|
||||
Name::new("rings"),
|
||||
Node {
|
||||
position_type: PositionType::Absolute,
|
||||
..default()
|
||||
},
|
||||
ImageNode::new(regular),
|
||||
),
|
||||
(
|
||||
Name::new("health"),
|
||||
Node {
|
||||
height: Val::Px(DAMAGE_SIZE),
|
||||
width: Val::Px(DAMAGE_SIZE),
|
||||
..default()
|
||||
},
|
||||
children![(
|
||||
Name::new("damage_ring"),
|
||||
HeadDamage(head_slot),
|
||||
Node {
|
||||
position_type: PositionType::Absolute,
|
||||
display: Display::Block,
|
||||
overflow: Overflow::clip(),
|
||||
top: Val::Px(0.),
|
||||
left: Val::Px(0.),
|
||||
right: Val::Px(0.),
|
||||
height: Val::Percent(0.),
|
||||
..default()
|
||||
},
|
||||
children![ImageNode::new(damage)]
|
||||
)]
|
||||
)
|
||||
],
|
||||
)
|
||||
}
|
||||
|
||||
fn update_visibility(
|
||||
state: Res<BackpackUiState>,
|
||||
mut backpack: Query<&mut Visibility, (With<BackpackMarker>, Without<BackpackCountText>)>,
|
||||
mut count: Query<&mut Visibility, (Without<BackpackMarker>, With<BackpackCountText>)>,
|
||||
) {
|
||||
if state.is_changed() {
|
||||
for mut vis in backpack.iter_mut() {
|
||||
*vis = if state.open {
|
||||
Visibility::Visible
|
||||
} else {
|
||||
Visibility::Hidden
|
||||
};
|
||||
}
|
||||
|
||||
for mut vis in count.iter_mut() {
|
||||
*vis = if !state.open {
|
||||
Visibility::Visible
|
||||
} else {
|
||||
Visibility::Hidden
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn update_count(
|
||||
state: Res<BackpackUiState>,
|
||||
text: Query<Entity, With<BackpackCountText>>,
|
||||
mut writer: TextUiWriter,
|
||||
) {
|
||||
if state.is_changed() {
|
||||
let Some(text) = text.iter().next() else {
|
||||
return;
|
||||
};
|
||||
|
||||
*writer.text(text, 0) = state.count.to_string();
|
||||
}
|
||||
}
|
||||
|
||||
fn update(
|
||||
state: Res<BackpackUiState>,
|
||||
heads_images: Res<HeadsImages>,
|
||||
mut head_image: Query<(&HeadImage, &mut Visibility, &mut ImageNode), Without<HeadSelector>>,
|
||||
mut head_damage: Query<(&HeadDamage, &mut Node), Without<HeadSelector>>,
|
||||
mut head_selector: Query<(&HeadSelector, &mut Visibility), Without<HeadImage>>,
|
||||
) {
|
||||
if state.is_changed() {
|
||||
for (HeadImage(head), mut vis, mut image) in head_image.iter_mut() {
|
||||
if let Some(head) = &state.heads[*head] {
|
||||
*vis = Visibility::Inherited;
|
||||
image.image = heads_images.heads[head.head].clone();
|
||||
} else {
|
||||
*vis = Visibility::Hidden;
|
||||
}
|
||||
}
|
||||
for (HeadDamage(head), mut node) in head_damage.iter_mut() {
|
||||
if let Some(head) = &state.heads[*head] {
|
||||
node.height = Val::Percent(head.damage() * 100.0);
|
||||
}
|
||||
}
|
||||
|
||||
for (HeadSelector(head), mut vis) in head_selector.iter_mut() {
|
||||
*vis = if *head == state.relative_current_slot() {
|
||||
Visibility::Inherited
|
||||
} else {
|
||||
Visibility::Hidden
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn swap_head_inputs(
|
||||
trigger: Trigger<BackpackAction>,
|
||||
backpack: Res<Backpack>,
|
||||
mut commands: Commands,
|
||||
mut state: ResMut<BackpackUiState>,
|
||||
time: Res<Time>,
|
||||
) {
|
||||
if state.count == 0 {
|
||||
return;
|
||||
}
|
||||
|
||||
let action = *trigger.event();
|
||||
if action == BackpackAction::OpenClose {
|
||||
state.open = !state.open;
|
||||
commands.trigger(PlaySound::Backpack { open: state.open });
|
||||
}
|
||||
|
||||
if !state.open {
|
||||
return;
|
||||
}
|
||||
|
||||
let mut changed = false;
|
||||
if action == BackpackAction::Left && state.current_slot > 0 {
|
||||
state.current_slot -= 1;
|
||||
changed = true;
|
||||
}
|
||||
if action == BackpackAction::Right && state.current_slot < state.count.saturating_sub(1) {
|
||||
state.current_slot += 1;
|
||||
changed = true;
|
||||
}
|
||||
if action == BackpackAction::Swap {
|
||||
commands.trigger(BackbackSwapEvent(state.current_slot));
|
||||
}
|
||||
|
||||
if changed {
|
||||
commands.trigger(PlaySound::Selection);
|
||||
sync(&backpack, &mut state, time.elapsed_secs());
|
||||
}
|
||||
}
|
||||
|
||||
fn sync_on_change(backpack: Res<Backpack>, mut state: ResMut<BackpackUiState>, time: Res<Time>) {
|
||||
if backpack.is_changed() || backpack.reloading() {
|
||||
sync(&backpack, &mut state, time.elapsed_secs());
|
||||
}
|
||||
}
|
||||
|
||||
fn sync(backpack: &Res<Backpack>, state: &mut ResMut<BackpackUiState>, time: f32) {
|
||||
state.count = backpack.heads.len();
|
||||
|
||||
state.scroll = state.scroll.min(state.count.saturating_sub(HEAD_SLOTS));
|
||||
|
||||
if state.current_slot >= state.scroll + HEAD_SLOTS {
|
||||
state.scroll = state.current_slot.saturating_sub(HEAD_SLOTS - 1);
|
||||
}
|
||||
if state.current_slot < state.scroll {
|
||||
state.scroll = state.current_slot;
|
||||
}
|
||||
|
||||
for i in 0..HEAD_SLOTS {
|
||||
if let Some(head) = backpack.heads.get(i + state.scroll) {
|
||||
state.heads[i] = Some(UiHeadState::new(*head, time));
|
||||
} else {
|
||||
state.heads[i] = None;
|
||||
}
|
||||
}
|
||||
}
|
||||
61
crates/shared/src/backpack/mod.rs
Normal file
61
crates/shared/src/backpack/mod.rs
Normal file
@@ -0,0 +1,61 @@
|
||||
mod backpack_ui;
|
||||
mod ui_head_state;
|
||||
|
||||
use crate::{
|
||||
cash::CashCollectEvent, global_observer, head_drop::HeadCollected, heads::HeadState,
|
||||
heads_database::HeadsDatabase,
|
||||
};
|
||||
pub use backpack_ui::BackpackAction;
|
||||
use bevy::prelude::*;
|
||||
pub use ui_head_state::UiHeadState;
|
||||
|
||||
#[derive(Resource, Default)]
|
||||
pub struct Backpack {
|
||||
pub heads: Vec<HeadState>,
|
||||
}
|
||||
|
||||
impl Backpack {
|
||||
pub fn reloading(&self) -> bool {
|
||||
for head in &self.heads {
|
||||
if !head.has_ammo() {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
false
|
||||
}
|
||||
|
||||
pub fn contains(&self, head_id: usize) -> bool {
|
||||
self.heads.iter().any(|head| head.head == head_id)
|
||||
}
|
||||
|
||||
pub fn insert(&mut self, head_id: usize, heads_db: &HeadsDatabase) {
|
||||
self.heads.push(HeadState::new(head_id, heads_db));
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Event)]
|
||||
pub struct BackbackSwapEvent(pub usize);
|
||||
|
||||
pub fn plugin(app: &mut App) {
|
||||
app.init_resource::<Backpack>();
|
||||
|
||||
app.add_plugins(backpack_ui::plugin);
|
||||
|
||||
global_observer!(app, on_head_collect);
|
||||
}
|
||||
|
||||
fn on_head_collect(
|
||||
trigger: Trigger<HeadCollected>,
|
||||
mut cmds: Commands,
|
||||
mut backpack: ResMut<Backpack>,
|
||||
heads_db: Res<HeadsDatabase>,
|
||||
) {
|
||||
let HeadCollected(head) = *trigger.event();
|
||||
|
||||
if backpack.contains(head) {
|
||||
cmds.trigger(CashCollectEvent);
|
||||
} else {
|
||||
backpack.insert(head, heads_db.as_ref());
|
||||
}
|
||||
}
|
||||
39
crates/shared/src/backpack/ui_head_state.rs
Normal file
39
crates/shared/src/backpack/ui_head_state.rs
Normal file
@@ -0,0 +1,39 @@
|
||||
use crate::heads::HeadState;
|
||||
use bevy::prelude::*;
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Reflect, Default)]
|
||||
pub struct UiHeadState {
|
||||
pub head: usize,
|
||||
pub health: f32,
|
||||
pub ammo: f32,
|
||||
pub reloading: Option<f32>,
|
||||
}
|
||||
|
||||
impl UiHeadState {
|
||||
pub fn damage(&self) -> f32 {
|
||||
1. - self.health
|
||||
}
|
||||
|
||||
pub fn ammo_used(&self) -> f32 {
|
||||
1. - self.ammo
|
||||
}
|
||||
|
||||
pub fn reloading(&self) -> Option<f32> {
|
||||
self.reloading
|
||||
}
|
||||
|
||||
pub(crate) fn new(value: HeadState, time: f32) -> Self {
|
||||
let reloading = if value.has_ammo() {
|
||||
None
|
||||
} else {
|
||||
Some((time - value.last_use) / value.reload_duration)
|
||||
};
|
||||
|
||||
Self {
|
||||
head: value.head,
|
||||
ammo: value.ammo as f32 / value.ammo_max as f32,
|
||||
health: value.health as f32 / value.health_max as f32,
|
||||
reloading,
|
||||
}
|
||||
}
|
||||
}
|
||||
165
crates/shared/src/camera.rs
Normal file
165
crates/shared/src/camera.rs
Normal file
@@ -0,0 +1,165 @@
|
||||
use crate::{
|
||||
GameState, control::ControlState, loading_assets::UIAssets, physics_layers::GameLayer,
|
||||
};
|
||||
use avian3d::prelude::*;
|
||||
use bevy::prelude::*;
|
||||
|
||||
#[derive(Component, Reflect, Debug)]
|
||||
pub struct CameraTarget;
|
||||
|
||||
#[derive(Component, Reflect, Debug)]
|
||||
pub struct CameraArmRotation;
|
||||
|
||||
/// Requested camera rotation based on various input sources (keyboard, gamepad)
|
||||
#[derive(Component, Reflect, Debug, Default, Deref, DerefMut)]
|
||||
#[reflect(Component)]
|
||||
pub struct CameraRotationInput(pub Vec2);
|
||||
|
||||
#[derive(Resource, Reflect, Debug, Default)]
|
||||
#[reflect(Resource)]
|
||||
pub struct CameraState {
|
||||
pub cutscene: bool,
|
||||
pub look_around: bool,
|
||||
}
|
||||
|
||||
#[derive(Component, Reflect, Debug, Default)]
|
||||
struct CameraUi;
|
||||
|
||||
#[derive(Component, Reflect, Debug)]
|
||||
#[reflect(Component)]
|
||||
pub struct MainCamera {
|
||||
dir: Dir3,
|
||||
distance: f32,
|
||||
target_offset: Vec3,
|
||||
}
|
||||
|
||||
impl MainCamera {
|
||||
fn new(arm: Vec3) -> Self {
|
||||
let (dir, distance) = Dir3::new_and_length(arm).expect("invalid arm length");
|
||||
Self {
|
||||
dir,
|
||||
distance,
|
||||
target_offset: Vec3::new(0., 2., 0.),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn plugin(app: &mut App) {
|
||||
app.register_type::<CameraRotationInput>();
|
||||
app.register_type::<CameraState>();
|
||||
app.register_type::<MainCamera>();
|
||||
|
||||
app.init_resource::<CameraState>();
|
||||
app.add_systems(OnEnter(GameState::Playing), startup);
|
||||
app.add_systems(
|
||||
RunFixedMainLoop,
|
||||
(update, update_ui, update_look_around, rotate_view)
|
||||
.after(RunFixedMainLoopSystem::AfterFixedMainLoop)
|
||||
.run_if(in_state(GameState::Playing)),
|
||||
);
|
||||
}
|
||||
|
||||
fn startup(mut commands: Commands) {
|
||||
commands.spawn((
|
||||
Camera3d::default(),
|
||||
MainCamera::new(Vec3::new(0., 1.8, -15.)),
|
||||
CameraRotationInput::default(),
|
||||
));
|
||||
}
|
||||
|
||||
fn update_look_around(controls: Res<ControlState>, mut cam_state: ResMut<CameraState>) {
|
||||
let look_around = controls.view_mode;
|
||||
|
||||
if look_around != cam_state.look_around {
|
||||
cam_state.look_around = look_around;
|
||||
}
|
||||
}
|
||||
|
||||
fn update_ui(
|
||||
mut commands: Commands,
|
||||
cam_state: Res<CameraState>,
|
||||
assets: Res<UIAssets>,
|
||||
query: Query<Entity, With<CameraUi>>,
|
||||
) {
|
||||
if cam_state.is_changed() {
|
||||
let show_ui = cam_state.look_around || cam_state.cutscene;
|
||||
|
||||
if show_ui {
|
||||
commands.spawn((
|
||||
CameraUi,
|
||||
Node {
|
||||
margin: UiRect::top(Val::Px(20.))
|
||||
.with_left(Val::Auto)
|
||||
.with_right(Val::Auto),
|
||||
justify_content: JustifyContent::Center,
|
||||
..default()
|
||||
},
|
||||
children![(
|
||||
Node {
|
||||
display: Display::Block,
|
||||
position_type: PositionType::Absolute,
|
||||
..default()
|
||||
},
|
||||
ImageNode::new(assets.camera.clone()),
|
||||
)],
|
||||
));
|
||||
} else {
|
||||
for entity in query.iter() {
|
||||
commands.entity(entity).despawn();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn update(
|
||||
mut cam: Query<
|
||||
(&MainCamera, &mut Transform, &CameraRotationInput),
|
||||
(Without<CameraTarget>, Without<CameraArmRotation>),
|
||||
>,
|
||||
target_q: Single<&Transform, (With<CameraTarget>, Without<CameraArmRotation>)>,
|
||||
arm_rotation: Single<&Transform, With<CameraArmRotation>>,
|
||||
spatial_query: SpatialQuery,
|
||||
cam_state: Res<CameraState>,
|
||||
) {
|
||||
if cam_state.cutscene {
|
||||
return;
|
||||
}
|
||||
|
||||
let arm_tf = arm_rotation;
|
||||
|
||||
let Ok((camera, mut cam_transform, cam_rotation_input)) = cam.single_mut() else {
|
||||
return;
|
||||
};
|
||||
|
||||
let target = target_q.translation + camera.target_offset;
|
||||
|
||||
let direction = arm_tf.rotation * Quat::from_rotation_y(cam_rotation_input.x) * camera.dir;
|
||||
|
||||
let max_distance = camera.distance;
|
||||
|
||||
let filter = SpatialQueryFilter::from_mask(LayerMask(GameLayer::Level.to_bits()));
|
||||
let cam_pos = if let Some(first_hit) = spatial_query.cast_shape(
|
||||
&Collider::sphere(0.5),
|
||||
target,
|
||||
Quat::IDENTITY,
|
||||
direction,
|
||||
&ShapeCastConfig::from_max_distance(max_distance),
|
||||
&filter,
|
||||
) {
|
||||
let distance = first_hit.distance;
|
||||
target + (direction * distance)
|
||||
} else {
|
||||
target + (direction * camera.distance)
|
||||
};
|
||||
|
||||
*cam_transform = Transform::from_translation(cam_pos).looking_at(target, Vec3::Y);
|
||||
}
|
||||
|
||||
fn rotate_view(controls: Res<ControlState>, mut cam: Single<&mut CameraRotationInput>) {
|
||||
if !controls.view_mode {
|
||||
cam.x = 0.;
|
||||
return;
|
||||
}
|
||||
|
||||
cam.0 += controls.look_dir * -0.001;
|
||||
}
|
||||
82
crates/shared/src/cash.rs
Normal file
82
crates/shared/src/cash.rs
Normal file
@@ -0,0 +1,82 @@
|
||||
use crate::{GameState, global_observer, loading_assets::UIAssets, sounds::PlaySound};
|
||||
use bevy::prelude::*;
|
||||
|
||||
#[derive(Component, Reflect, Default)]
|
||||
#[reflect(Component)]
|
||||
#[require(Transform)]
|
||||
pub struct Cash;
|
||||
|
||||
#[derive(Component, Reflect, Default)]
|
||||
#[reflect(Component)]
|
||||
struct CashText;
|
||||
|
||||
#[derive(Resource, Reflect, Default)]
|
||||
pub struct CashResource {
|
||||
pub cash: i32,
|
||||
}
|
||||
|
||||
#[derive(Event)]
|
||||
pub struct CashCollectEvent;
|
||||
|
||||
pub fn plugin(app: &mut App) {
|
||||
app.init_resource::<CashResource>();
|
||||
app.add_systems(OnEnter(GameState::Playing), setup);
|
||||
app.add_systems(
|
||||
Update,
|
||||
(rotate, update_ui).run_if(in_state(GameState::Playing)),
|
||||
);
|
||||
|
||||
global_observer!(app, on_cash_collect);
|
||||
}
|
||||
|
||||
fn on_cash_collect(
|
||||
_trigger: Trigger<CashCollectEvent>,
|
||||
mut commands: Commands,
|
||||
mut cash: ResMut<CashResource>,
|
||||
) {
|
||||
commands.trigger(PlaySound::CashCollect);
|
||||
|
||||
cash.cash += 100;
|
||||
}
|
||||
|
||||
fn rotate(time: Res<Time>, mut query: Query<&mut Transform, With<Cash>>) {
|
||||
for mut transform in query.iter_mut() {
|
||||
transform.rotate(Quat::from_rotation_y(time.delta_secs()));
|
||||
}
|
||||
}
|
||||
|
||||
fn update_ui(
|
||||
cash: Res<CashResource>,
|
||||
text: Query<Entity, With<CashText>>,
|
||||
mut writer: TextUiWriter,
|
||||
) {
|
||||
if cash.is_changed() {
|
||||
let Some(text) = text.iter().next() else {
|
||||
return;
|
||||
};
|
||||
|
||||
*writer.text(text, 0) = cash.cash.to_string();
|
||||
}
|
||||
}
|
||||
|
||||
fn setup(mut commands: Commands, assets: Res<UIAssets>) {
|
||||
commands.spawn((
|
||||
Name::new("cash-ui"),
|
||||
Text::new("0"),
|
||||
TextShadow::default(),
|
||||
CashText,
|
||||
TextFont {
|
||||
font: assets.font.clone(),
|
||||
font_size: 34.0,
|
||||
..default()
|
||||
},
|
||||
TextColor(Color::Srgba(Srgba::rgb(0., 1., 0.))),
|
||||
TextLayout::new_with_justify(JustifyText::Center),
|
||||
Node {
|
||||
position_type: PositionType::Absolute,
|
||||
bottom: Val::Px(40.0),
|
||||
left: Val::Px(100.0),
|
||||
..default()
|
||||
},
|
||||
));
|
||||
}
|
||||
81
crates/shared/src/cash_heal.rs
Normal file
81
crates/shared/src/cash_heal.rs
Normal file
@@ -0,0 +1,81 @@
|
||||
use crate::{
|
||||
abilities::TriggerCashHeal, cash::CashResource, global_observer, hitpoints::Hitpoints,
|
||||
player::Player, sounds::PlaySound,
|
||||
};
|
||||
use bevy::prelude::*;
|
||||
|
||||
pub fn plugin(app: &mut App) {
|
||||
global_observer!(app, on_heal_trigger);
|
||||
}
|
||||
|
||||
#[derive(Debug, PartialEq, Eq)]
|
||||
struct HealAction {
|
||||
cost: i32,
|
||||
damage_healed: u32,
|
||||
}
|
||||
|
||||
fn on_heal_trigger(
|
||||
_trigger: Trigger<TriggerCashHeal>,
|
||||
mut cmds: Commands,
|
||||
mut cash: ResMut<CashResource>,
|
||||
mut query: Query<&mut Hitpoints, With<Player>>,
|
||||
) {
|
||||
let Ok(mut hp) = query.single_mut() else {
|
||||
return;
|
||||
};
|
||||
|
||||
if hp.max() || cash.cash == 0 {
|
||||
return;
|
||||
}
|
||||
|
||||
let action = heal(cash.cash, hp.get().1 - hp.get().0);
|
||||
|
||||
hp.heal(action.damage_healed);
|
||||
|
||||
cash.cash = cash.cash.saturating_sub(action.cost);
|
||||
|
||||
//TODO: trigger ui cost animation
|
||||
cmds.trigger(PlaySound::CashHeal);
|
||||
}
|
||||
|
||||
fn heal(cash: i32, damage: u32) -> HealAction {
|
||||
let cost = (damage as f32 / 10. * 25.) as i32;
|
||||
|
||||
if cash >= cost {
|
||||
HealAction {
|
||||
cost,
|
||||
damage_healed: damage,
|
||||
}
|
||||
} else {
|
||||
let damage_healed = (cash as f32 * 10. / 25.) as u32;
|
||||
|
||||
HealAction {
|
||||
cost: cash,
|
||||
damage_healed,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod test {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_heal() {
|
||||
assert_eq!(
|
||||
heal(100, 10),
|
||||
HealAction {
|
||||
cost: 25,
|
||||
damage_healed: 10
|
||||
}
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
heal(100, 90),
|
||||
HealAction {
|
||||
cost: 100,
|
||||
damage_healed: 40
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
249
crates/shared/src/character.rs
Normal file
249
crates/shared/src/character.rs
Normal file
@@ -0,0 +1,249 @@
|
||||
use crate::{
|
||||
GameState,
|
||||
animation::{AnimationController, AnimationFlags},
|
||||
heads_database::HeadsDatabase,
|
||||
hitpoints::Hitpoints,
|
||||
loading_assets::GameAssets,
|
||||
utils::trail::Trail,
|
||||
};
|
||||
use bevy::{
|
||||
animation::RepeatAnimation, ecs::system::SystemParam, platform::collections::HashMap,
|
||||
prelude::*, scene::SceneInstanceReady,
|
||||
};
|
||||
use std::{f32::consts::PI, time::Duration};
|
||||
|
||||
#[derive(Component, Debug)]
|
||||
pub struct ProjectileOrigin;
|
||||
|
||||
#[derive(Component, Debug)]
|
||||
pub struct AnimatedCharacter {
|
||||
head: usize,
|
||||
}
|
||||
|
||||
impl AnimatedCharacter {
|
||||
pub fn new(head: usize) -> Self {
|
||||
Self { head }
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Component, Debug)]
|
||||
struct AnimatedCharacterAsset(pub Handle<Gltf>);
|
||||
|
||||
#[derive(SystemParam)]
|
||||
pub struct CharacterHierarchy<'w, 's> {
|
||||
descendants: Query<'w, 's, &'static Children>,
|
||||
projectile_origin: Query<'w, 's, &'static GlobalTransform, With<ProjectileOrigin>>,
|
||||
}
|
||||
|
||||
impl CharacterHierarchy<'_, '_> {
|
||||
pub fn projectile_origin(&self, entity: Entity) -> Option<&GlobalTransform> {
|
||||
self.descendants
|
||||
.iter_descendants(entity)
|
||||
.find_map(|child| self.projectile_origin.get(child).ok())
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Component, Debug, Reflect)]
|
||||
#[reflect(Component)]
|
||||
#[relationship(relationship_target = HasCharacterAnimations)]
|
||||
#[require(AnimationFlags)]
|
||||
pub struct CharacterAnimations {
|
||||
#[relationship]
|
||||
pub of_character: Entity,
|
||||
pub idle: AnimationNodeIndex,
|
||||
pub run: AnimationNodeIndex,
|
||||
pub jump: AnimationNodeIndex,
|
||||
pub shoot: Option<AnimationNodeIndex>,
|
||||
pub run_shoot: Option<AnimationNodeIndex>,
|
||||
pub hit: AnimationNodeIndex,
|
||||
pub graph: Handle<AnimationGraph>,
|
||||
}
|
||||
|
||||
const ANIM_IDLE: &str = "idle";
|
||||
const ANIM_RUN: &str = "run";
|
||||
const ANIM_JUMP: &str = "jump";
|
||||
const ANIM_SHOOT: &str = "shoot";
|
||||
const ANIM_RUN_SHOOT: &str = "run_shoot";
|
||||
const ANIM_HIT: &str = "hit";
|
||||
|
||||
#[derive(Component)]
|
||||
#[relationship_target(relationship = CharacterAnimations)]
|
||||
pub struct HasCharacterAnimations(Entity);
|
||||
|
||||
pub fn plugin(app: &mut App) {
|
||||
app.add_systems(
|
||||
Update,
|
||||
(spawn, setup_once_loaded).run_if(in_state(GameState::Playing)),
|
||||
);
|
||||
#[cfg(feature = "dbg")]
|
||||
app.add_systems(
|
||||
Update,
|
||||
debug_show_projectile_origin_and_trial.run_if(in_state(GameState::Playing)),
|
||||
);
|
||||
}
|
||||
|
||||
fn spawn(
|
||||
mut commands: Commands,
|
||||
query: Query<(Entity, &AnimatedCharacter), Added<AnimatedCharacter>>,
|
||||
gltf_assets: Res<Assets<Gltf>>,
|
||||
assets: Res<GameAssets>,
|
||||
heads_db: Res<HeadsDatabase>,
|
||||
) {
|
||||
for (entity, character) in query.iter() {
|
||||
let key = heads_db.head_key(character.head);
|
||||
|
||||
let handle = assets
|
||||
.characters
|
||||
.get(format!("{key}.glb").as_str())
|
||||
.unwrap_or_else(|| {
|
||||
//TODO: remove once we use the new format for all
|
||||
info!("Character not found, using default [{}]", key);
|
||||
&assets.characters["angry demonstrator.glb"]
|
||||
});
|
||||
let asset = gltf_assets.get(handle).unwrap();
|
||||
|
||||
let mut t =
|
||||
Transform::from_translation(Vec3::new(0., -1.45, 0.)).with_scale(Vec3::splat(1.2));
|
||||
|
||||
t.rotate_y(PI);
|
||||
|
||||
commands
|
||||
.entity(entity)
|
||||
.insert((
|
||||
t,
|
||||
SceneRoot(asset.scenes[0].clone()),
|
||||
AnimatedCharacterAsset(handle.clone()),
|
||||
))
|
||||
.observe(find_marker_bones);
|
||||
}
|
||||
}
|
||||
|
||||
fn find_marker_bones(
|
||||
trigger: Trigger<SceneInstanceReady>,
|
||||
mut commands: Commands,
|
||||
descendants: Query<&Children>,
|
||||
name: Query<&Name>,
|
||||
mut gizmo_assets: ResMut<Assets<GizmoAsset>>,
|
||||
) {
|
||||
let entity = trigger.target();
|
||||
|
||||
let mut origin_found = false;
|
||||
for child in descendants.iter_descendants(entity) {
|
||||
let Ok(name) = name.get(child) else {
|
||||
continue;
|
||||
};
|
||||
|
||||
if name.as_str() == "ProjectileOrigin" {
|
||||
commands.entity(child).insert(ProjectileOrigin);
|
||||
origin_found = true;
|
||||
} else if name.as_str().starts_with("Trail") {
|
||||
commands.entity(child).insert((
|
||||
Trail::new(
|
||||
20,
|
||||
LinearRgba::new(1., 1.0, 1., 0.5),
|
||||
LinearRgba::new(1., 1., 1., 0.5),
|
||||
),
|
||||
Gizmo {
|
||||
handle: gizmo_assets.add(GizmoAsset::default()),
|
||||
line_config: GizmoLineConfig {
|
||||
width: 24.,
|
||||
..default()
|
||||
},
|
||||
..default()
|
||||
},
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
if !origin_found {
|
||||
error!("ProjectileOrigin not found");
|
||||
}
|
||||
}
|
||||
|
||||
fn setup_once_loaded(
|
||||
mut commands: Commands,
|
||||
mut query: Query<(Entity, &mut AnimationPlayer), Added<AnimationPlayer>>,
|
||||
parent: Query<&ChildOf>,
|
||||
animated_character: Query<(&AnimatedCharacter, &AnimatedCharacterAsset)>,
|
||||
characters: Query<Entity, With<Hitpoints>>,
|
||||
gltf_assets: Res<Assets<Gltf>>,
|
||||
mut graphs: ResMut<Assets<AnimationGraph>>,
|
||||
) {
|
||||
for (entity, mut player) in query.iter_mut() {
|
||||
let Some((_, asset)) = parent
|
||||
.iter_ancestors(entity)
|
||||
.find_map(|ancestor| animated_character.get(ancestor).ok())
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
|
||||
let Some(character) = parent
|
||||
.iter_ancestors(entity)
|
||||
.find_map(|ancestor| characters.get(ancestor).ok())
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
|
||||
let asset = gltf_assets.get(asset.0.id()).unwrap();
|
||||
|
||||
let animations = asset
|
||||
.named_animations
|
||||
.iter()
|
||||
.map(|(name, animation)| (name.to_string(), animation.clone()))
|
||||
.collect::<HashMap<_, _>>();
|
||||
|
||||
let mut graph = AnimationGraph::new();
|
||||
let root = graph.root;
|
||||
let idle = graph.add_clip(animations[ANIM_IDLE].clone(), 1.0, root);
|
||||
let run = graph.add_clip(animations[ANIM_RUN].clone(), 1.0, root);
|
||||
let jump = graph.add_clip(animations[ANIM_JUMP].clone(), 1.0, root);
|
||||
let shoot = animations
|
||||
.get(ANIM_SHOOT)
|
||||
.map(|clip| graph.add_clip(clip.clone(), 1.0, root));
|
||||
let run_shoot = animations
|
||||
.get(ANIM_RUN_SHOOT)
|
||||
.map(|clip| graph.add_clip(clip.clone(), 1.0, root));
|
||||
let hit = graph.add_clip(animations[ANIM_HIT].clone(), 1.0, root);
|
||||
|
||||
// Insert a resource with the current scene information
|
||||
let graph_handle = graphs.add(graph);
|
||||
let animations = CharacterAnimations {
|
||||
of_character: character,
|
||||
idle,
|
||||
run,
|
||||
jump,
|
||||
shoot,
|
||||
run_shoot,
|
||||
hit,
|
||||
graph: graph_handle.clone(),
|
||||
};
|
||||
|
||||
let mut transitions = AnimationTransitions::new();
|
||||
AnimationController::play_inner(
|
||||
&mut player,
|
||||
&mut transitions,
|
||||
animations.idle,
|
||||
Duration::ZERO,
|
||||
RepeatAnimation::Forever,
|
||||
);
|
||||
commands.entity(entity).insert((
|
||||
AnimationGraphHandle(animations.graph.clone()),
|
||||
transitions,
|
||||
animations,
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(feature = "dbg")]
|
||||
fn debug_show_projectile_origin_and_trial(
|
||||
mut gizmos: Gizmos,
|
||||
query: Query<&GlobalTransform, Or<(With<ProjectileOrigin>, With<Trail>)>>,
|
||||
) {
|
||||
for projectile_origin in query.iter() {
|
||||
gizmos.sphere(
|
||||
Isometry3d::from_translation(projectile_origin.translation()),
|
||||
0.1,
|
||||
Color::linear_rgb(0., 1., 0.),
|
||||
);
|
||||
}
|
||||
}
|
||||
202
crates/shared/src/control/controller_common.rs
Normal file
202
crates/shared/src/control/controller_common.rs
Normal file
@@ -0,0 +1,202 @@
|
||||
use super::{ControllerSet, ControllerSwitchEvent};
|
||||
use crate::{
|
||||
GameState,
|
||||
control::{SelectedController, controls::ControllerSettings},
|
||||
heads_database::HeadControls,
|
||||
player::PlayerBodyMesh,
|
||||
};
|
||||
use avian3d::{math::*, prelude::*};
|
||||
use bevy::prelude::*;
|
||||
use happy_feet::{
|
||||
KinematicVelocity,
|
||||
ground::{Grounding, GroundingConfig},
|
||||
prelude::{
|
||||
Character, CharacterDrag, CharacterFriction, CharacterGravity, CharacterMovement,
|
||||
CharacterPlugin, MoveInput, SteppingBehaviour, SteppingConfig,
|
||||
},
|
||||
};
|
||||
|
||||
pub fn plugin(app: &mut App) {
|
||||
app.add_plugins(CharacterPlugin::default());
|
||||
|
||||
app.register_type::<MovementSpeedFactor>();
|
||||
|
||||
app.add_systems(
|
||||
PreUpdate,
|
||||
reset_upon_switch
|
||||
.run_if(in_state(GameState::Playing))
|
||||
.before(ControllerSet::ApplyControlsRun)
|
||||
.before(ControllerSet::ApplyControlsFly),
|
||||
)
|
||||
.add_systems(
|
||||
FixedPreUpdate,
|
||||
decelerate.run_if(in_state(GameState::Playing)),
|
||||
);
|
||||
}
|
||||
|
||||
/// Reset the pitch and velocity of the character if the controller was switched.
|
||||
pub fn reset_upon_switch(
|
||||
mut c: Commands,
|
||||
mut event_controller_switch: EventReader<ControllerSwitchEvent>,
|
||||
controller: Res<SelectedController>,
|
||||
mut rig_transform_q: Option<Single<&mut Transform, With<PlayerBodyMesh>>>,
|
||||
mut velocity: Single<&mut KinematicVelocity, With<Character>>,
|
||||
character: Single<Entity, With<Character>>,
|
||||
) {
|
||||
for _ in event_controller_switch.read() {
|
||||
velocity.0 = Vec3::ZERO;
|
||||
|
||||
// Reset pitch but keep yaw the same
|
||||
if let Some(ref mut rig_transform) = rig_transform_q {
|
||||
let euler_rot = rig_transform.rotation.to_euler(EulerRot::YXZ);
|
||||
let yaw = euler_rot.0;
|
||||
rig_transform.rotation = Quat::from_euler(EulerRot::YXZ, yaw, 0.0, 0.0);
|
||||
}
|
||||
|
||||
match controller.0 {
|
||||
ControllerSet::ApplyControlsFly => {
|
||||
c.entity(*character).insert(FLYING_MOVEMENT_CONFIG);
|
||||
}
|
||||
ControllerSet::ApplyControlsRun => {
|
||||
c.entity(*character).insert(RUNNING_MOVEMENT_CONFIG);
|
||||
}
|
||||
_ => unreachable!(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Decelerates the player in the directions of "undesired velocity"; velocity that is not aligned
|
||||
/// with the movement input direction. This makes it quicker to reverse direction, and prevents
|
||||
/// sliding around, even with low friction, without slowing down the player globally like high
|
||||
/// friction or drag would.
|
||||
fn decelerate(
|
||||
mut character: Query<(
|
||||
&mut KinematicVelocity,
|
||||
&MoveInput,
|
||||
Option<&Grounding>,
|
||||
&ControllerSettings,
|
||||
)>,
|
||||
) {
|
||||
for (mut velocity, input, grounding, settings) in character.iter_mut() {
|
||||
let direction = input.value.normalize();
|
||||
let ground_normal = grounding
|
||||
.and_then(|it| it.normal())
|
||||
.unwrap_or(Dir3::Y)
|
||||
.as_vec3();
|
||||
|
||||
let velocity_within_90_degrees = direction.dot(velocity.0) > 0.0;
|
||||
let desired_velocity = if direction != Vec3::ZERO && velocity_within_90_degrees {
|
||||
// project velocity onto direction to extract the component directly aligned with direction
|
||||
velocity.0.project_onto(direction)
|
||||
} else {
|
||||
// if velocity isn't within 90 degrees of direction then the projection would be in the
|
||||
// exact opposite direction of `direction`; so just zero it
|
||||
Vec3::ZERO
|
||||
};
|
||||
let undesired_velocity = velocity.0 - desired_velocity;
|
||||
let vertical_undesired_velocity = undesired_velocity.project_onto(ground_normal);
|
||||
// only select the velocity along the ground plane; that way the character can't decelerate
|
||||
// while falling or jumping, but will decelerate along slopes properly
|
||||
let undesired_velocity = undesired_velocity - vertical_undesired_velocity;
|
||||
let deceleration =
|
||||
Vec3::ZERO.move_towards(undesired_velocity, settings.deceleration_factor);
|
||||
velocity.0 -= deceleration;
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Component, Reflect)]
|
||||
#[reflect(Component)]
|
||||
pub struct MovementSpeedFactor(pub f32);
|
||||
|
||||
/// A bundle that contains the components needed for a basic
|
||||
/// kinematic character controller.
|
||||
#[derive(Bundle)]
|
||||
pub struct CharacterControllerBundle {
|
||||
character_controller: Character,
|
||||
collider: Collider,
|
||||
move_input: MoveInput,
|
||||
movement_factor: MovementSpeedFactor,
|
||||
collision_events: CollisionEventsEnabled,
|
||||
movement_config: MovementConfig,
|
||||
interpolation: TransformInterpolation,
|
||||
}
|
||||
|
||||
impl CharacterControllerBundle {
|
||||
pub fn new(collider: Collider, controls: HeadControls) -> Self {
|
||||
// Create shape caster as a slightly smaller version of collider
|
||||
let mut caster_shape = collider.clone();
|
||||
caster_shape.set_scale(Vector::ONE * 0.98, 10);
|
||||
|
||||
let config = match controls {
|
||||
HeadControls::Plane => FLYING_MOVEMENT_CONFIG,
|
||||
HeadControls::Walk => RUNNING_MOVEMENT_CONFIG,
|
||||
};
|
||||
|
||||
Self {
|
||||
character_controller: Character { up: Dir3::Y },
|
||||
collider,
|
||||
move_input: MoveInput::default(),
|
||||
movement_factor: MovementSpeedFactor(1.0),
|
||||
collision_events: CollisionEventsEnabled,
|
||||
movement_config: config,
|
||||
interpolation: TransformInterpolation,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Bundle)]
|
||||
struct MovementConfig {
|
||||
movement: CharacterMovement,
|
||||
step: SteppingConfig,
|
||||
ground: GroundingConfig,
|
||||
gravity: CharacterGravity,
|
||||
friction: CharacterFriction,
|
||||
drag: CharacterDrag,
|
||||
settings: ControllerSettings,
|
||||
}
|
||||
|
||||
const RUNNING_MOVEMENT_CONFIG: MovementConfig = MovementConfig {
|
||||
movement: CharacterMovement {
|
||||
target_speed: 15.0,
|
||||
acceleration: 40.0,
|
||||
},
|
||||
step: SteppingConfig {
|
||||
max_height: 0.25,
|
||||
behaviour: SteppingBehaviour::Grounded,
|
||||
},
|
||||
ground: GroundingConfig {
|
||||
max_angle: PI / 4.0,
|
||||
max_distance: 0.2,
|
||||
snap_to_surface: true,
|
||||
},
|
||||
gravity: CharacterGravity(vec3(0.0, -60.0, 0.0)),
|
||||
friction: CharacterFriction(10.0),
|
||||
drag: CharacterDrag(0.0),
|
||||
settings: ControllerSettings {
|
||||
jump_force: 25.0,
|
||||
deceleration_factor: 1.0,
|
||||
},
|
||||
};
|
||||
|
||||
const FLYING_MOVEMENT_CONFIG: MovementConfig = MovementConfig {
|
||||
movement: CharacterMovement {
|
||||
target_speed: 20.0,
|
||||
acceleration: 300.0,
|
||||
},
|
||||
step: SteppingConfig {
|
||||
max_height: 0.25,
|
||||
behaviour: SteppingBehaviour::Never,
|
||||
},
|
||||
ground: GroundingConfig {
|
||||
max_angle: 0.0,
|
||||
max_distance: -1.0,
|
||||
snap_to_surface: false,
|
||||
},
|
||||
gravity: CharacterGravity(Vec3::ZERO),
|
||||
friction: CharacterFriction(0.0),
|
||||
drag: CharacterDrag(10.0),
|
||||
settings: ControllerSettings {
|
||||
jump_force: 0.0,
|
||||
deceleration_factor: 0.0,
|
||||
},
|
||||
};
|
||||
63
crates/shared/src/control/controller_flying.rs
Normal file
63
crates/shared/src/control/controller_flying.rs
Normal file
@@ -0,0 +1,63 @@
|
||||
use super::{ControlState, ControllerSet};
|
||||
use crate::{GameState, control::controller_common::MovementSpeedFactor, player::PlayerBodyMesh};
|
||||
use bevy::prelude::*;
|
||||
use happy_feet::prelude::MoveInput;
|
||||
use std::f32::consts::PI;
|
||||
|
||||
pub struct CharacterControllerPlugin;
|
||||
|
||||
impl Plugin for CharacterControllerPlugin {
|
||||
fn build(&self, app: &mut App) {
|
||||
app.add_systems(
|
||||
PreUpdate,
|
||||
(rotate_rig, apply_controls)
|
||||
.chain()
|
||||
.in_set(ControllerSet::ApplyControlsFly)
|
||||
.run_if(in_state(GameState::Playing)),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
fn rotate_rig(
|
||||
mut rig_transform_q: Option<Single<&mut Transform, With<PlayerBodyMesh>>>,
|
||||
controls: Res<ControlState>,
|
||||
) {
|
||||
if controls.view_mode {
|
||||
return;
|
||||
}
|
||||
|
||||
let look_dir = controls.look_dir;
|
||||
|
||||
if let Some(ref mut rig_transform) = rig_transform_q {
|
||||
// todo: Make consistent with the running controller
|
||||
let sensitivity = 0.001;
|
||||
let max_pitch = 35.0 * PI / 180.0;
|
||||
let min_pitch = -25.0 * PI / 180.0;
|
||||
|
||||
rig_transform.rotate_y(look_dir.x * -sensitivity);
|
||||
|
||||
let euler_rot = rig_transform.rotation.to_euler(EulerRot::YXZ);
|
||||
let yaw = euler_rot.0;
|
||||
let pitch = euler_rot.1 + look_dir.y * sensitivity;
|
||||
|
||||
let pitch_clamped = pitch.clamp(min_pitch, max_pitch);
|
||||
rig_transform.rotation = Quat::from_euler(EulerRot::YXZ, yaw, pitch_clamped, 0.0);
|
||||
|
||||
// The following can be used to limit the amount of rotation per frame
|
||||
// let target_rotation = rig_transform.rotation
|
||||
// * Quat::from_rotation_x(controls.keyboard_state.look_dir.y * sensitivity);
|
||||
// let clamped_rotation = rig_transform.rotation.rotate_towards(target_rotation, 0.01);
|
||||
// rig_transform.rotation = clamped_rotation;
|
||||
}
|
||||
}
|
||||
|
||||
fn apply_controls(
|
||||
mut character: Query<(&mut MoveInput, &MovementSpeedFactor)>,
|
||||
rig_transform_q: Option<Single<&GlobalTransform, With<PlayerBodyMesh>>>,
|
||||
) {
|
||||
let (mut char_input, factor) = character.single_mut().unwrap();
|
||||
|
||||
if let Some(ref rig_transform) = rig_transform_q {
|
||||
char_input.set(-*rig_transform.forward() * factor.0);
|
||||
}
|
||||
}
|
||||
116
crates/shared/src/control/controller_running.rs
Normal file
116
crates/shared/src/control/controller_running.rs
Normal file
@@ -0,0 +1,116 @@
|
||||
use super::{ControlState, ControllerSet, Controls};
|
||||
use crate::{
|
||||
GameState,
|
||||
abilities::TriggerStateRes,
|
||||
animation::AnimationFlags,
|
||||
character::HasCharacterAnimations,
|
||||
control::{controller_common::MovementSpeedFactor, controls::ControllerSettings},
|
||||
player::{Player, PlayerBodyMesh},
|
||||
};
|
||||
use bevy::prelude::*;
|
||||
use happy_feet::prelude::{Grounding, KinematicVelocity, MoveInput};
|
||||
|
||||
pub struct CharacterControllerPlugin;
|
||||
|
||||
impl Plugin for CharacterControllerPlugin {
|
||||
fn build(&self, app: &mut App) {
|
||||
app.add_systems(
|
||||
PreUpdate,
|
||||
(set_animation_flags, rotate_view, apply_controls)
|
||||
.chain()
|
||||
.in_set(ControllerSet::ApplyControlsRun)
|
||||
.run_if(in_state(GameState::Playing)),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Sets the movement flag, which is an indicator for the rig animation and the braking system.
|
||||
fn set_animation_flags(
|
||||
mut flags: Query<&mut AnimationFlags>,
|
||||
controls: Res<Controls>,
|
||||
trigger: Res<TriggerStateRes>,
|
||||
player: Single<(&Grounding, &HasCharacterAnimations), With<Player>>,
|
||||
) {
|
||||
let mut direction = controls.keyboard_state.move_dir;
|
||||
let deadzone = 0.2;
|
||||
|
||||
let (grounding, has_anims) = *player;
|
||||
let mut flags = flags.get_mut(*has_anims.collection()).unwrap();
|
||||
|
||||
if let Some(gamepad) = controls.gamepad_state {
|
||||
direction += gamepad.move_dir;
|
||||
}
|
||||
|
||||
if flags.any_direction {
|
||||
if direction.length_squared() < deadzone {
|
||||
flags.any_direction = false;
|
||||
}
|
||||
} else if direction.length_squared() > deadzone {
|
||||
flags.any_direction = true;
|
||||
}
|
||||
|
||||
if flags.shooting != trigger.is_active() {
|
||||
flags.shooting = trigger.is_active();
|
||||
}
|
||||
|
||||
// `apply_controls` sets the jump flag when the player actually jumps.
|
||||
// Unset the flag on hitting the ground
|
||||
if grounding.is_grounded() {
|
||||
flags.jumping = false;
|
||||
}
|
||||
}
|
||||
|
||||
fn rotate_view(
|
||||
controls: Res<ControlState>,
|
||||
mut player: Query<&mut Transform, With<PlayerBodyMesh>>,
|
||||
) {
|
||||
if controls.view_mode {
|
||||
return;
|
||||
}
|
||||
|
||||
for mut tr in player.iter_mut() {
|
||||
tr.rotate_y(controls.look_dir.x * -0.001);
|
||||
}
|
||||
}
|
||||
|
||||
fn apply_controls(
|
||||
controls: Res<ControlState>,
|
||||
mut character: Query<(
|
||||
&mut MoveInput,
|
||||
&mut Grounding,
|
||||
&mut KinematicVelocity,
|
||||
&ControllerSettings,
|
||||
&MovementSpeedFactor,
|
||||
&HasCharacterAnimations,
|
||||
)>,
|
||||
rig_transform_q: Option<Single<&GlobalTransform, With<PlayerBodyMesh>>>,
|
||||
mut anim_flags: Query<&mut AnimationFlags>,
|
||||
) {
|
||||
let Ok((mut move_input, mut grounding, mut velocity, settings, move_factor, has_anims)) =
|
||||
character.single_mut()
|
||||
else {
|
||||
return;
|
||||
};
|
||||
|
||||
let mut flags = anim_flags.get_mut(*has_anims.collection()).unwrap();
|
||||
|
||||
let mut direction = -controls.move_dir.extend(0.0).xzy();
|
||||
|
||||
if let Some(ref rig_transform) = rig_transform_q {
|
||||
direction = (rig_transform.forward() * direction.z) + (rig_transform.right() * direction.x);
|
||||
}
|
||||
|
||||
let ground_normal = *grounding.normal().unwrap_or(Dir3::Y);
|
||||
|
||||
let y_projection = direction.project_onto(ground_normal);
|
||||
direction -= y_projection;
|
||||
direction = direction.normalize_or_zero();
|
||||
|
||||
move_input.set(direction * move_factor.0);
|
||||
|
||||
if controls.jump && grounding.is_grounded() {
|
||||
flags.jumping = true;
|
||||
flags.just_jumped = true;
|
||||
happy_feet::movement::jump(settings.jump_force, &mut velocity, &mut grounding, Dir3::Y)
|
||||
}
|
||||
}
|
||||
251
crates/shared/src/control/controls.rs
Normal file
251
crates/shared/src/control/controls.rs
Normal file
@@ -0,0 +1,251 @@
|
||||
use super::{ControlState, Controls};
|
||||
use crate::{
|
||||
GameState,
|
||||
abilities::{TriggerCashHeal, TriggerState},
|
||||
backpack::BackpackAction,
|
||||
control::ControllerSet,
|
||||
heads::SelectActiveHead,
|
||||
};
|
||||
use bevy::{
|
||||
input::{
|
||||
ButtonState,
|
||||
gamepad::{GamepadConnection, GamepadEvent},
|
||||
mouse::{MouseButtonInput, MouseMotion},
|
||||
},
|
||||
prelude::*,
|
||||
};
|
||||
|
||||
pub fn plugin(app: &mut App) {
|
||||
app.init_resource::<Controls>();
|
||||
|
||||
app.register_type::<ControllerSettings>();
|
||||
|
||||
app.add_systems(
|
||||
PreUpdate,
|
||||
(
|
||||
gamepad_controls,
|
||||
keyboard_controls,
|
||||
mouse_rotate,
|
||||
mouse_click.run_if(on_event::<MouseButtonInput>),
|
||||
gamepad_connections.run_if(on_event::<GamepadEvent>),
|
||||
combine_controls,
|
||||
)
|
||||
.chain()
|
||||
.in_set(ControllerSet::CollectInputs)
|
||||
.run_if(in_state(GameState::Playing)),
|
||||
);
|
||||
}
|
||||
|
||||
#[derive(Component, Clone, PartialEq, Reflect)]
|
||||
#[reflect(Component)]
|
||||
pub struct ControllerSettings {
|
||||
pub deceleration_factor: f32,
|
||||
pub jump_force: f32,
|
||||
}
|
||||
|
||||
fn combine_controls(controls: Res<Controls>, mut combined_controls: ResMut<ControlState>) {
|
||||
let keyboard = controls.keyboard_state;
|
||||
|
||||
if let Some(gamepad) = controls.gamepad_state {
|
||||
combined_controls.look_dir = gamepad.look_dir + keyboard.look_dir;
|
||||
combined_controls.move_dir = gamepad.move_dir + keyboard.move_dir;
|
||||
combined_controls.jump = gamepad.jump | keyboard.jump;
|
||||
combined_controls.view_mode = gamepad.view_mode | keyboard.view_mode;
|
||||
} else {
|
||||
combined_controls.look_dir = keyboard.look_dir;
|
||||
combined_controls.move_dir = keyboard.move_dir;
|
||||
combined_controls.jump = keyboard.jump;
|
||||
combined_controls.view_mode = keyboard.view_mode;
|
||||
};
|
||||
}
|
||||
|
||||
/// Applies a square deadzone to a Vec2
|
||||
fn deadzone_square(v: Vec2, min: f32) -> Vec2 {
|
||||
Vec2::new(
|
||||
if v.x.abs() < min { 0. } else { v.x },
|
||||
if v.y.abs() < min { 0. } else { v.y },
|
||||
)
|
||||
}
|
||||
|
||||
fn gamepad_controls(
|
||||
mut commands: Commands,
|
||||
gamepads: Query<(Entity, &Gamepad)>,
|
||||
mut controls: ResMut<Controls>,
|
||||
) {
|
||||
let Some((_e, gamepad)) = gamepads.iter().next() else {
|
||||
if controls.gamepad_state.is_some() {
|
||||
controls.gamepad_state = None;
|
||||
}
|
||||
return;
|
||||
};
|
||||
|
||||
let deadzone_left_stick = 0.15;
|
||||
let deadzone_right_stick = 0.15;
|
||||
|
||||
// info!("gamepad: {:?}", gamepad);
|
||||
|
||||
let rotate = gamepad
|
||||
.get(GamepadButton::RightTrigger2)
|
||||
.unwrap_or_default();
|
||||
|
||||
// 8BitDo Ultimate wireless Controller for PC
|
||||
let look_dir = if gamepad.vendor_id() == Some(11720) && gamepad.product_id() == Some(12306) {
|
||||
const EPSILON: f32 = 0.015;
|
||||
Vec2::new(
|
||||
if rotate < 0.5 - EPSILON {
|
||||
40. * (rotate - 0.5)
|
||||
} else if rotate > 0.5 + EPSILON {
|
||||
-40. * (rotate - 0.5)
|
||||
} else {
|
||||
0.
|
||||
},
|
||||
0.,
|
||||
)
|
||||
} else {
|
||||
deadzone_square(gamepad.right_stick(), deadzone_right_stick) * 40.
|
||||
};
|
||||
|
||||
let state = ControlState {
|
||||
move_dir: deadzone_square(gamepad.left_stick(), deadzone_left_stick),
|
||||
look_dir,
|
||||
jump: gamepad.pressed(GamepadButton::South),
|
||||
view_mode: gamepad.pressed(GamepadButton::LeftTrigger2),
|
||||
};
|
||||
|
||||
if gamepad.just_pressed(GamepadButton::RightTrigger2) {
|
||||
commands.trigger(TriggerState::Active);
|
||||
}
|
||||
if gamepad.just_released(GamepadButton::RightTrigger2) {
|
||||
commands.trigger(TriggerState::Inactive);
|
||||
}
|
||||
if gamepad.just_pressed(GamepadButton::LeftTrigger) {
|
||||
commands.trigger(SelectActiveHead::Left);
|
||||
}
|
||||
if gamepad.just_pressed(GamepadButton::RightTrigger) {
|
||||
commands.trigger(SelectActiveHead::Right);
|
||||
}
|
||||
if gamepad.just_pressed(GamepadButton::DPadLeft) {
|
||||
commands.trigger(BackpackAction::Left);
|
||||
}
|
||||
if gamepad.just_pressed(GamepadButton::DPadRight) {
|
||||
commands.trigger(BackpackAction::Right);
|
||||
}
|
||||
if gamepad.just_pressed(GamepadButton::DPadDown) {
|
||||
commands.trigger(BackpackAction::Swap);
|
||||
}
|
||||
if gamepad.just_pressed(GamepadButton::DPadUp) {
|
||||
commands.trigger(BackpackAction::OpenClose);
|
||||
}
|
||||
if gamepad.just_pressed(GamepadButton::East) {
|
||||
commands.trigger(TriggerCashHeal);
|
||||
}
|
||||
|
||||
if controls
|
||||
.gamepad_state
|
||||
.as_ref()
|
||||
.map(|last_state| *last_state != state)
|
||||
.unwrap_or(true)
|
||||
{
|
||||
// info!("gamepad state changed: {:?}", state);
|
||||
controls.gamepad_state = Some(state);
|
||||
}
|
||||
}
|
||||
|
||||
fn mouse_rotate(mut mouse: EventReader<MouseMotion>, mut controls: ResMut<Controls>) {
|
||||
controls.keyboard_state.look_dir = Vec2::ZERO;
|
||||
|
||||
for ev in mouse.read() {
|
||||
controls.keyboard_state.look_dir += ev.delta;
|
||||
}
|
||||
}
|
||||
|
||||
fn keyboard_controls(
|
||||
mut commands: Commands,
|
||||
keyboard: Res<ButtonInput<KeyCode>>,
|
||||
mut controls: ResMut<Controls>,
|
||||
) {
|
||||
let up_binds = [KeyCode::KeyW, KeyCode::ArrowUp];
|
||||
let down_binds = [KeyCode::KeyS, KeyCode::ArrowDown];
|
||||
let left_binds = [KeyCode::KeyA, KeyCode::ArrowLeft];
|
||||
let right_binds = [KeyCode::KeyD, KeyCode::ArrowRight];
|
||||
|
||||
let up = keyboard.any_pressed(up_binds);
|
||||
let down = keyboard.any_pressed(down_binds);
|
||||
let left = keyboard.any_pressed(left_binds);
|
||||
let right = keyboard.any_pressed(right_binds);
|
||||
|
||||
let horizontal = right as i8 - left as i8;
|
||||
let vertical = up as i8 - down as i8;
|
||||
let direction = Vec2::new(horizontal as f32, vertical as f32).clamp_length_max(1.0);
|
||||
|
||||
if keyboard.just_pressed(KeyCode::KeyB) {
|
||||
commands.trigger(BackpackAction::OpenClose);
|
||||
}
|
||||
if keyboard.just_pressed(KeyCode::Enter) {
|
||||
commands.trigger(BackpackAction::Swap);
|
||||
}
|
||||
if keyboard.just_pressed(KeyCode::Comma) {
|
||||
commands.trigger(BackpackAction::Left);
|
||||
}
|
||||
if keyboard.just_pressed(KeyCode::Period) {
|
||||
commands.trigger(BackpackAction::Right);
|
||||
}
|
||||
|
||||
if keyboard.just_pressed(KeyCode::KeyQ) {
|
||||
commands.trigger(SelectActiveHead::Left);
|
||||
}
|
||||
if keyboard.just_pressed(KeyCode::KeyE) {
|
||||
commands.trigger(SelectActiveHead::Right);
|
||||
}
|
||||
if keyboard.just_pressed(KeyCode::Enter) {
|
||||
commands.trigger(TriggerCashHeal);
|
||||
}
|
||||
|
||||
controls.keyboard_state.move_dir = direction;
|
||||
controls.keyboard_state.jump = keyboard.pressed(KeyCode::Space);
|
||||
controls.keyboard_state.view_mode = keyboard.pressed(KeyCode::Tab);
|
||||
}
|
||||
|
||||
fn mouse_click(mut events: EventReader<MouseButtonInput>, mut commands: Commands) {
|
||||
for ev in events.read() {
|
||||
match ev {
|
||||
MouseButtonInput {
|
||||
button: MouseButton::Left,
|
||||
state: ButtonState::Pressed,
|
||||
..
|
||||
} => {
|
||||
commands.trigger(TriggerState::Active);
|
||||
}
|
||||
MouseButtonInput {
|
||||
button: MouseButton::Left,
|
||||
state: ButtonState::Released,
|
||||
..
|
||||
} => {
|
||||
commands.trigger(TriggerState::Inactive);
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn gamepad_connections(mut evr_gamepad: EventReader<GamepadEvent>) {
|
||||
for ev in evr_gamepad.read() {
|
||||
if let GamepadEvent::Connection(connection) = ev {
|
||||
match &connection.connection {
|
||||
GamepadConnection::Connected {
|
||||
name,
|
||||
vendor_id,
|
||||
product_id,
|
||||
} => {
|
||||
info!(
|
||||
"New gamepad connected: {:?}, name: {name}, vendor: {vendor_id:?}, product: {product_id:?}",
|
||||
connection.gamepad,
|
||||
);
|
||||
}
|
||||
GamepadConnection::Disconnected => {
|
||||
info!("Lost connection with gamepad: {:?}", connection.gamepad);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
93
crates/shared/src/control/mod.rs
Normal file
93
crates/shared/src/control/mod.rs
Normal file
@@ -0,0 +1,93 @@
|
||||
use crate::{
|
||||
GameState,
|
||||
head::ActiveHead,
|
||||
heads_database::{HeadControls, HeadsDatabase},
|
||||
player::Player,
|
||||
};
|
||||
use bevy::prelude::*;
|
||||
|
||||
pub mod controller_common;
|
||||
pub mod controller_flying;
|
||||
pub mod controller_running;
|
||||
pub mod controls;
|
||||
|
||||
#[derive(SystemSet, Debug, Hash, PartialEq, Eq, Clone, Default)]
|
||||
enum ControllerSet {
|
||||
CollectInputs,
|
||||
ApplyControlsFly,
|
||||
#[default]
|
||||
ApplyControlsRun,
|
||||
}
|
||||
|
||||
#[derive(Resource, Debug, Clone, Copy, Default, PartialEq)]
|
||||
pub struct ControlState {
|
||||
/// Movement direction with a maximum length of 1.0
|
||||
pub move_dir: Vec2,
|
||||
pub look_dir: Vec2,
|
||||
pub jump: bool,
|
||||
/// Determines if the camera can rotate freely around the player
|
||||
pub view_mode: bool,
|
||||
}
|
||||
|
||||
#[derive(Resource, Debug, Default)]
|
||||
struct Controls {
|
||||
keyboard_state: ControlState,
|
||||
gamepad_state: Option<ControlState>,
|
||||
}
|
||||
|
||||
#[derive(Event)]
|
||||
pub struct ControllerSwitchEvent;
|
||||
|
||||
#[derive(Resource, Debug, Default, PartialEq)]
|
||||
pub struct SelectedController(ControllerSet);
|
||||
|
||||
pub fn plugin(app: &mut App) {
|
||||
app.init_resource::<SelectedController>();
|
||||
app.init_resource::<ControlState>();
|
||||
|
||||
app.add_plugins(controls::plugin);
|
||||
app.add_plugins(controller_common::plugin);
|
||||
app.add_plugins(controller_flying::CharacterControllerPlugin);
|
||||
app.add_plugins(controller_running::CharacterControllerPlugin);
|
||||
|
||||
app.add_event::<ControllerSwitchEvent>();
|
||||
|
||||
app.configure_sets(
|
||||
PreUpdate,
|
||||
(
|
||||
ControllerSet::CollectInputs,
|
||||
ControllerSet::ApplyControlsFly.run_if(resource_equals(SelectedController(
|
||||
ControllerSet::ApplyControlsFly,
|
||||
))),
|
||||
ControllerSet::ApplyControlsRun.run_if(resource_equals(SelectedController(
|
||||
ControllerSet::ApplyControlsRun,
|
||||
))),
|
||||
)
|
||||
.chain()
|
||||
.run_if(in_state(GameState::Playing)),
|
||||
);
|
||||
|
||||
app.add_systems(Update, head_change.run_if(in_state(GameState::Playing)));
|
||||
}
|
||||
|
||||
fn head_change(
|
||||
//TODO: needs a 'LocalPlayer' at some point for multiplayer
|
||||
query: Query<&ActiveHead, (Changed<ActiveHead>, With<Player>)>,
|
||||
heads_db: Res<HeadsDatabase>,
|
||||
mut selected_controller: ResMut<SelectedController>,
|
||||
mut event_controller_switch: EventWriter<ControllerSwitchEvent>,
|
||||
) {
|
||||
for head in query.iter() {
|
||||
let stats = heads_db.head_stats(head.0);
|
||||
let controller = match stats.controls {
|
||||
HeadControls::Plane => ControllerSet::ApplyControlsFly,
|
||||
HeadControls::Walk => ControllerSet::ApplyControlsRun,
|
||||
};
|
||||
|
||||
if selected_controller.0 != controller {
|
||||
event_controller_switch.write(ControllerSwitchEvent);
|
||||
|
||||
selected_controller.0 = controller;
|
||||
}
|
||||
}
|
||||
}
|
||||
106
crates/shared/src/cutscene.rs
Normal file
106
crates/shared/src/cutscene.rs
Normal file
@@ -0,0 +1,106 @@
|
||||
use crate::{
|
||||
GameState,
|
||||
camera::{CameraState, MainCamera},
|
||||
global_observer,
|
||||
tb_entities::{CameraTarget, CutsceneCamera, CutsceneCameraMovementEnd},
|
||||
};
|
||||
use bevy::prelude::*;
|
||||
use bevy_trenchbroom::prelude::*;
|
||||
|
||||
#[derive(Event, Debug)]
|
||||
pub struct StartCutscene(pub String);
|
||||
|
||||
#[derive(Resource, Debug, Default)]
|
||||
enum CutsceneState {
|
||||
#[default]
|
||||
None,
|
||||
Playing {
|
||||
timer: Timer,
|
||||
camera_start: Transform,
|
||||
camera_end: Transform,
|
||||
},
|
||||
}
|
||||
|
||||
pub fn plugin(app: &mut App) {
|
||||
app.init_resource::<CutsceneState>();
|
||||
app.add_systems(Update, update.run_if(in_state(GameState::Playing)));
|
||||
|
||||
global_observer!(app, on_start_cutscene);
|
||||
}
|
||||
|
||||
fn on_start_cutscene(
|
||||
trigger: Trigger<StartCutscene>,
|
||||
mut cam_state: ResMut<CameraState>,
|
||||
mut cutscene_state: ResMut<CutsceneState>,
|
||||
cutscenes: Query<(&Transform, &CutsceneCamera, &Target), Without<MainCamera>>,
|
||||
cutscene_movement: Query<
|
||||
(&Transform, &CutsceneCameraMovementEnd, &Target),
|
||||
Without<MainCamera>,
|
||||
>,
|
||||
cam_target: Query<(&Transform, &CameraTarget), Without<MainCamera>>,
|
||||
) {
|
||||
let cutscene = trigger.event().0.clone();
|
||||
|
||||
cam_state.cutscene = true;
|
||||
|
||||
// asumes `name` and `targetname` are equal
|
||||
let Some((t, _, target)) = cutscenes
|
||||
.iter()
|
||||
.find(|(_, cutscene_camera, _)| cutscene == cutscene_camera.name)
|
||||
else {
|
||||
return;
|
||||
};
|
||||
|
||||
let move_end = cutscene_movement
|
||||
.iter()
|
||||
.find(|(_, _, target)| cutscene == target.target.clone().unwrap_or_default())
|
||||
.map(|(t, _, _)| *t)
|
||||
.unwrap_or_else(|| *t);
|
||||
|
||||
let Some((target, _)) = cam_target.iter().find(|(_, camera_target)| {
|
||||
camera_target.targetname == target.target.clone().unwrap_or_default()
|
||||
}) else {
|
||||
return;
|
||||
};
|
||||
|
||||
*cutscene_state = CutsceneState::Playing {
|
||||
timer: Timer::from_seconds(2.0, TimerMode::Once),
|
||||
camera_start: t.looking_at(target.translation, Vec3::Y),
|
||||
camera_end: move_end.looking_at(target.translation, Vec3::Y),
|
||||
};
|
||||
}
|
||||
|
||||
fn update(
|
||||
mut cam_state: ResMut<CameraState>,
|
||||
mut cutscene_state: ResMut<CutsceneState>,
|
||||
mut cam: Query<&mut Transform, With<MainCamera>>,
|
||||
time: Res<Time>,
|
||||
) {
|
||||
if let CutsceneState::Playing {
|
||||
timer,
|
||||
camera_start,
|
||||
camera_end,
|
||||
} = &mut *cutscene_state
|
||||
{
|
||||
cam_state.cutscene = true;
|
||||
timer.tick(time.delta());
|
||||
|
||||
let t = Transform::from_translation(
|
||||
camera_start
|
||||
.translation
|
||||
.lerp(camera_end.translation, timer.fraction()),
|
||||
)
|
||||
.with_rotation(
|
||||
camera_start
|
||||
.rotation
|
||||
.lerp(camera_end.rotation, timer.fraction()),
|
||||
);
|
||||
|
||||
let _ = cam.single_mut().map(|mut cam| *cam = t);
|
||||
|
||||
if timer.finished() {
|
||||
cam_state.cutscene = false;
|
||||
*cutscene_state = CutsceneState::None;
|
||||
}
|
||||
}
|
||||
}
|
||||
36
crates/shared/src/gates.rs
Normal file
36
crates/shared/src/gates.rs
Normal file
@@ -0,0 +1,36 @@
|
||||
use crate::{
|
||||
cutscene::StartCutscene, global_observer, keys::KeyCollected, movables::TriggerMovableEvent,
|
||||
sounds::PlaySound,
|
||||
};
|
||||
use bevy::{platform::collections::HashSet, prelude::*};
|
||||
|
||||
pub fn plugin(app: &mut App) {
|
||||
global_observer!(app, on_key_collected);
|
||||
}
|
||||
|
||||
fn on_key_collected(trigger: Trigger<KeyCollected>, mut commands: Commands) {
|
||||
match trigger.event().0.as_str() {
|
||||
"fence_gate" => {
|
||||
commands.trigger(StartCutscene("fence_01".to_string()));
|
||||
|
||||
let entities: HashSet<_> = vec!["fence_01", "fence_02"]
|
||||
.into_iter()
|
||||
.map(String::from)
|
||||
.collect();
|
||||
|
||||
commands.trigger(PlaySound::Gate);
|
||||
commands.trigger(TriggerMovableEvent(entities));
|
||||
}
|
||||
"fence_shaft" => {
|
||||
commands.trigger(StartCutscene("cutscene_02".to_string()));
|
||||
|
||||
let entities: HashSet<_> = vec!["fence_shaft"].into_iter().map(String::from).collect();
|
||||
|
||||
commands.trigger(PlaySound::Gate);
|
||||
commands.trigger(TriggerMovableEvent(entities));
|
||||
}
|
||||
_ => {
|
||||
error!("unknown key logic: {}", trigger.event().0);
|
||||
}
|
||||
}
|
||||
}
|
||||
4
crates/shared/src/head.rs
Normal file
4
crates/shared/src/head.rs
Normal file
@@ -0,0 +1,4 @@
|
||||
use bevy::prelude::*;
|
||||
|
||||
#[derive(Component, Debug)]
|
||||
pub struct ActiveHead(pub usize);
|
||||
189
crates/shared/src/head_drop.rs
Normal file
189
crates/shared/src/head_drop.rs
Normal file
@@ -0,0 +1,189 @@
|
||||
use crate::{
|
||||
GameState, billboards::Billboard, global_observer, heads_database::HeadsDatabase,
|
||||
loading_assets::HeadDropAssets, physics_layers::GameLayer, player::Player, sounds::PlaySound,
|
||||
squish_animation::SquishAnimation, tb_entities::SecretHead,
|
||||
};
|
||||
use avian3d::prelude::*;
|
||||
use bevy::{
|
||||
ecs::{relationship::RelatedSpawner, spawn::SpawnWith},
|
||||
prelude::*,
|
||||
};
|
||||
use std::f32::consts::PI;
|
||||
|
||||
#[derive(Event, Reflect)]
|
||||
pub struct HeadDrops {
|
||||
pos: Vec3,
|
||||
head_id: usize,
|
||||
impulse: bool,
|
||||
}
|
||||
|
||||
impl HeadDrops {
|
||||
pub fn new(pos: Vec3, head_id: usize) -> Self {
|
||||
Self {
|
||||
pos,
|
||||
head_id,
|
||||
impulse: true,
|
||||
}
|
||||
}
|
||||
|
||||
fn new_static(pos: Vec3, head_id: usize) -> Self {
|
||||
Self {
|
||||
pos,
|
||||
head_id,
|
||||
impulse: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Component, Reflect)]
|
||||
#[reflect(Component)]
|
||||
struct HeadDrop {
|
||||
pub head_id: usize,
|
||||
}
|
||||
|
||||
#[derive(Component, Reflect)]
|
||||
#[reflect(Component)]
|
||||
struct HeadDropEnableTime(f32);
|
||||
|
||||
#[derive(Component, Reflect)]
|
||||
#[reflect(Component)]
|
||||
struct SecretHeadMarker;
|
||||
|
||||
#[derive(Event, Reflect)]
|
||||
pub struct HeadCollected(pub usize);
|
||||
|
||||
pub fn plugin(app: &mut App) {
|
||||
app.add_systems(
|
||||
Update,
|
||||
enable_collectible.run_if(in_state(GameState::Playing)),
|
||||
);
|
||||
|
||||
app.add_systems(OnEnter(GameState::Playing), spawn);
|
||||
|
||||
global_observer!(app, on_head_drop);
|
||||
}
|
||||
|
||||
fn spawn(mut commands: Commands, query: Query<(Entity, &GlobalTransform, &SecretHead)>) {
|
||||
for (e, t, head) in query {
|
||||
commands.trigger(HeadDrops::new_static(
|
||||
t.translation() + Vec3::new(0., 2., 0.),
|
||||
head.head_id,
|
||||
));
|
||||
|
||||
commands.entity(e).despawn();
|
||||
}
|
||||
}
|
||||
|
||||
fn on_head_drop(
|
||||
trigger: Trigger<HeadDrops>,
|
||||
mut commands: Commands,
|
||||
assets: Res<HeadDropAssets>,
|
||||
heads_db: Res<HeadsDatabase>,
|
||||
gltf_assets: Res<Assets<Gltf>>,
|
||||
time: Res<Time>,
|
||||
) -> Result<(), BevyError> {
|
||||
let drop = trigger.event();
|
||||
|
||||
let angle = rand::random::<f32>() * PI * 2.;
|
||||
let spawn_dir = Quat::from_rotation_y(angle) * Vec3::new(0.5, 0.6, 0.).normalize();
|
||||
|
||||
if drop.impulse {
|
||||
commands.trigger(PlaySound::HeadDrop);
|
||||
}
|
||||
|
||||
let ability = format!("{:?}.glb", heads_db.head_stats(drop.head_id).ability).to_lowercase();
|
||||
let mesh = if let Some(handle) = assets.meshes.get(ability.as_str()) {
|
||||
gltf_assets.get(handle)
|
||||
} else {
|
||||
gltf_assets.get(&assets.meshes["none.glb"])
|
||||
}
|
||||
.ok_or("asset not found")?;
|
||||
|
||||
commands
|
||||
.spawn((
|
||||
Name::new("headdrop"),
|
||||
Transform::from_translation(drop.pos),
|
||||
Visibility::default(),
|
||||
Collider::sphere(1.5),
|
||||
LockedAxes::ROTATION_LOCKED,
|
||||
RigidBody::Dynamic,
|
||||
CollisionLayers::new(
|
||||
GameLayer::CollectiblePhysics,
|
||||
LayerMask::ALL & !GameLayer::Player.to_bits(),
|
||||
),
|
||||
Restitution::new(0.6),
|
||||
Children::spawn(SpawnWith({
|
||||
let head_id = drop.head_id;
|
||||
let now = time.elapsed_secs();
|
||||
move |parent: &mut RelatedSpawner<ChildOf>| {
|
||||
parent
|
||||
.spawn((
|
||||
Collider::sphere(1.5),
|
||||
CollisionLayers::new(GameLayer::CollectibleSensors, LayerMask::NONE),
|
||||
Sensor,
|
||||
CollisionEventsEnabled,
|
||||
HeadDrop { head_id },
|
||||
HeadDropEnableTime(now + 1.2),
|
||||
))
|
||||
.observe(on_collect_head);
|
||||
}
|
||||
})),
|
||||
))
|
||||
.insert_if(
|
||||
ExternalImpulse::new(spawn_dir * 180.).with_persistence(false),
|
||||
|| drop.impulse,
|
||||
)
|
||||
.with_child((
|
||||
Billboard::All,
|
||||
SquishAnimation(2.6),
|
||||
SceneRoot(mesh.scenes[0].clone()),
|
||||
));
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn enable_collectible(
|
||||
mut commands: Commands,
|
||||
query: Query<(Entity, &HeadDropEnableTime)>,
|
||||
time: Res<Time>,
|
||||
) {
|
||||
let now = time.elapsed_secs();
|
||||
for (e, enable_time) in query.iter() {
|
||||
if now > enable_time.0 {
|
||||
commands
|
||||
.entity(e)
|
||||
.insert(CollisionLayers::new(
|
||||
LayerMask(GameLayer::CollectibleSensors.to_bits()),
|
||||
LayerMask::ALL,
|
||||
))
|
||||
.remove::<HeadDropEnableTime>();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn on_collect_head(
|
||||
trigger: Trigger<OnCollisionStart>,
|
||||
|
||||
mut commands: Commands,
|
||||
query_player: Query<&Player>,
|
||||
query_collectable: Query<(&HeadDrop, &ChildOf)>,
|
||||
query_secret: Query<&SecretHeadMarker>,
|
||||
) {
|
||||
let collectable = trigger.target();
|
||||
let collider = trigger.collider;
|
||||
|
||||
if query_player.contains(collider) {
|
||||
let (drop, child_of) = query_collectable.get(collectable).unwrap();
|
||||
|
||||
let is_secret = query_secret.contains(collectable);
|
||||
|
||||
if is_secret {
|
||||
commands.trigger(PlaySound::SecretHeadCollect);
|
||||
} else {
|
||||
commands.trigger(PlaySound::HeadCollect);
|
||||
}
|
||||
|
||||
commands.trigger(HeadCollected(drop.head_id));
|
||||
commands.entity(child_of.parent()).despawn();
|
||||
}
|
||||
}
|
||||
234
crates/shared/src/heads/heads_ui.rs
Normal file
234
crates/shared/src/heads/heads_ui.rs
Normal file
@@ -0,0 +1,234 @@
|
||||
use super::{ActiveHeads, HEAD_SLOTS, HeadsImages};
|
||||
use crate::{GameState, backpack::UiHeadState, loading_assets::UIAssets, player::Player};
|
||||
use bevy::{ecs::spawn::SpawnIter, prelude::*};
|
||||
use bevy_ui_gradients::{AngularColorStop, BackgroundGradient, ConicGradient, Gradient, Position};
|
||||
use std::f32::consts::PI;
|
||||
|
||||
#[derive(Component, Reflect, Default)]
|
||||
#[reflect(Component)]
|
||||
struct HeadSelector(pub usize);
|
||||
|
||||
#[derive(Component, Reflect, Default)]
|
||||
#[reflect(Component)]
|
||||
struct HeadImage(pub usize);
|
||||
|
||||
#[derive(Component, Reflect, Default)]
|
||||
#[reflect(Component)]
|
||||
struct HeadDamage(pub usize);
|
||||
|
||||
#[derive(Resource, Default, Reflect)]
|
||||
#[reflect(Resource)]
|
||||
struct UiActiveHeads {
|
||||
heads: [Option<UiHeadState>; 5],
|
||||
selected_slot: usize,
|
||||
}
|
||||
|
||||
pub fn plugin(app: &mut App) {
|
||||
app.register_type::<HeadDamage>();
|
||||
app.register_type::<UiActiveHeads>();
|
||||
|
||||
app.add_systems(OnEnter(GameState::Playing), setup);
|
||||
app.add_systems(
|
||||
Update,
|
||||
(sync, update, update_ammo, update_health).run_if(in_state(GameState::Playing)),
|
||||
);
|
||||
}
|
||||
|
||||
fn setup(mut commands: Commands, assets: Res<UIAssets>) {
|
||||
commands.spawn((
|
||||
Name::new("heads-ui"),
|
||||
Node {
|
||||
position_type: PositionType::Absolute,
|
||||
bottom: Val::Px(20.0),
|
||||
right: Val::Px(20.0),
|
||||
height: Val::Px(74.0),
|
||||
..default()
|
||||
},
|
||||
Children::spawn(SpawnIter((0..HEAD_SLOTS).map({
|
||||
let bg = assets.head_bg.clone();
|
||||
let regular = assets.head_regular.clone();
|
||||
let selector = assets.head_selector.clone();
|
||||
let damage = assets.head_damage.clone();
|
||||
|
||||
move |i| {
|
||||
spawn_head_ui(
|
||||
bg.clone(),
|
||||
regular.clone(),
|
||||
selector.clone(),
|
||||
damage.clone(),
|
||||
i,
|
||||
)
|
||||
}
|
||||
}))),
|
||||
));
|
||||
|
||||
commands.init_resource::<UiActiveHeads>();
|
||||
}
|
||||
|
||||
fn spawn_head_ui(
|
||||
bg: Handle<Image>,
|
||||
regular: Handle<Image>,
|
||||
selector: Handle<Image>,
|
||||
damage: Handle<Image>,
|
||||
head_slot: usize,
|
||||
) -> impl Bundle {
|
||||
const SIZE: f32 = 90.0;
|
||||
const DAMAGE_SIZE: f32 = 74.0;
|
||||
|
||||
(
|
||||
Node {
|
||||
position_type: PositionType::Relative,
|
||||
justify_content: JustifyContent::Center,
|
||||
align_items: AlignItems::Center,
|
||||
width: Val::Px(SIZE),
|
||||
..default()
|
||||
},
|
||||
children![
|
||||
(
|
||||
Node {
|
||||
position_type: PositionType::Absolute,
|
||||
top: Val::Px(-30.0),
|
||||
..default()
|
||||
},
|
||||
Visibility::Hidden,
|
||||
ImageNode::new(selector),
|
||||
HeadSelector(head_slot),
|
||||
),
|
||||
(
|
||||
Node {
|
||||
position_type: PositionType::Absolute,
|
||||
..default()
|
||||
},
|
||||
ImageNode::new(bg),
|
||||
),
|
||||
(
|
||||
Name::new("head-icon"),
|
||||
Node {
|
||||
position_type: PositionType::Absolute,
|
||||
..default()
|
||||
},
|
||||
BorderRadius::all(Val::Px(9999.)),
|
||||
BackgroundGradient::from(ConicGradient {
|
||||
start: 0.,
|
||||
stops: vec![
|
||||
AngularColorStop::new(Color::linear_rgba(0., 0., 0., 0.9), 0.),
|
||||
AngularColorStop::new(Color::linear_rgba(0., 0., 0., 0.9), PI * 1.5),
|
||||
AngularColorStop::new(Color::linear_rgba(0., 0., 0., 0.0), PI * 1.5),
|
||||
],
|
||||
position: Position::CENTER,
|
||||
}),
|
||||
ImageNode::default(),
|
||||
Visibility::Hidden,
|
||||
HeadImage(head_slot),
|
||||
),
|
||||
(
|
||||
Node {
|
||||
position_type: PositionType::Absolute,
|
||||
..default()
|
||||
},
|
||||
ImageNode::new(regular),
|
||||
),
|
||||
(
|
||||
Node {
|
||||
height: Val::Px(DAMAGE_SIZE),
|
||||
width: Val::Px(DAMAGE_SIZE),
|
||||
..default()
|
||||
},
|
||||
children![(
|
||||
HeadDamage(head_slot),
|
||||
Node {
|
||||
position_type: PositionType::Absolute,
|
||||
display: Display::Block,
|
||||
overflow: Overflow::clip(),
|
||||
top: Val::Px(0.),
|
||||
left: Val::Px(0.),
|
||||
right: Val::Px(0.),
|
||||
height: Val::Percent(25.),
|
||||
..default()
|
||||
},
|
||||
children![ImageNode::new(damage)]
|
||||
)]
|
||||
)
|
||||
],
|
||||
)
|
||||
}
|
||||
|
||||
fn update(
|
||||
res: Res<UiActiveHeads>,
|
||||
heads_images: Res<HeadsImages>,
|
||||
mut head_image: Query<(&HeadImage, &mut Visibility, &mut ImageNode), Without<HeadSelector>>,
|
||||
mut head_selector: Query<(&HeadSelector, &mut Visibility), Without<HeadImage>>,
|
||||
) {
|
||||
if res.is_changed() {
|
||||
for (HeadImage(head), mut vis, mut image) in head_image.iter_mut() {
|
||||
if let Some(head) = res.heads[*head] {
|
||||
*vis = Visibility::Visible;
|
||||
image.image = heads_images.heads[head.head].clone();
|
||||
} else {
|
||||
*vis = Visibility::Hidden;
|
||||
}
|
||||
}
|
||||
for (HeadSelector(head), mut vis) in head_selector.iter_mut() {
|
||||
*vis = if *head == res.selected_slot {
|
||||
Visibility::Visible
|
||||
} else {
|
||||
Visibility::Hidden
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn update_ammo(
|
||||
res: Res<UiActiveHeads>,
|
||||
mut gradients: Query<(&mut BackgroundGradient, &HeadImage)>,
|
||||
) {
|
||||
if res.is_changed() {
|
||||
for (mut gradient, HeadImage(head)) in gradients.iter_mut() {
|
||||
if let Some(head) = res.heads[*head] {
|
||||
let Gradient::Conic(gradient) = &mut gradient.0[0] else {
|
||||
continue;
|
||||
};
|
||||
|
||||
let progress = if let Some(reloading) = head.reloading() {
|
||||
1. - reloading
|
||||
} else {
|
||||
head.ammo_used()
|
||||
};
|
||||
|
||||
let angle = progress * PI * 2.;
|
||||
|
||||
gradient.stops[1].angle = Some(angle);
|
||||
gradient.stops[2].angle = Some(angle);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn update_health(res: Res<UiActiveHeads>, mut query: Query<(&mut Node, &HeadDamage)>) {
|
||||
if res.is_changed() {
|
||||
for (mut node, HeadDamage(head)) in query.iter_mut() {
|
||||
node.height =
|
||||
Val::Percent(res.heads[*head].map(|head| head.damage()).unwrap_or(0.) * 100.);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn sync(
|
||||
active_heads: Query<Ref<ActiveHeads>, With<Player>>,
|
||||
mut state: ResMut<UiActiveHeads>,
|
||||
time: Res<Time>,
|
||||
) {
|
||||
let Ok(active_heads) = active_heads.single() else {
|
||||
return;
|
||||
};
|
||||
|
||||
if active_heads.is_changed() || active_heads.reloading() {
|
||||
state.selected_slot = active_heads.selected_slot;
|
||||
|
||||
for i in 0..HEAD_SLOTS {
|
||||
state.heads[i] = active_heads
|
||||
.head(i)
|
||||
.map(|state| UiHeadState::new(state, time.elapsed_secs()));
|
||||
}
|
||||
}
|
||||
}
|
||||
302
crates/shared/src/heads/mod.rs
Normal file
302
crates/shared/src/heads/mod.rs
Normal file
@@ -0,0 +1,302 @@
|
||||
mod heads_ui;
|
||||
|
||||
use crate::{
|
||||
GameState,
|
||||
animation::AnimationFlags,
|
||||
backpack::{BackbackSwapEvent, Backpack},
|
||||
character::HasCharacterAnimations,
|
||||
global_observer,
|
||||
heads_database::HeadsDatabase,
|
||||
hitpoints::Hitpoints,
|
||||
player::Player,
|
||||
sounds::PlaySound,
|
||||
};
|
||||
use bevy::prelude::*;
|
||||
|
||||
pub static HEAD_COUNT: usize = 18;
|
||||
pub static HEAD_SLOTS: usize = 5;
|
||||
|
||||
#[derive(Resource, Default)]
|
||||
pub struct HeadsImages {
|
||||
pub heads: Vec<Handle<Image>>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Reflect)]
|
||||
pub struct HeadState {
|
||||
pub head: usize,
|
||||
pub health: u32,
|
||||
pub health_max: u32,
|
||||
pub ammo: u32,
|
||||
pub ammo_max: u32,
|
||||
pub reload_duration: f32,
|
||||
pub last_use: f32,
|
||||
}
|
||||
|
||||
impl HeadState {
|
||||
pub fn new(head: usize, heads_db: &HeadsDatabase) -> Self {
|
||||
let ammo = heads_db.head_stats(head).ammo;
|
||||
Self {
|
||||
head,
|
||||
health: 100,
|
||||
health_max: 100,
|
||||
ammo,
|
||||
ammo_max: ammo,
|
||||
reload_duration: 5.,
|
||||
last_use: 0.,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn has_ammo(&self) -> bool {
|
||||
self.ammo > 0
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Component, Default, Reflect, Debug)]
|
||||
#[reflect(Component)]
|
||||
pub struct ActiveHeads {
|
||||
heads: [Option<HeadState>; 5],
|
||||
current_slot: usize,
|
||||
selected_slot: usize,
|
||||
}
|
||||
|
||||
impl ActiveHeads {
|
||||
pub fn new(heads: [Option<HeadState>; 5]) -> Self {
|
||||
Self {
|
||||
heads,
|
||||
current_slot: 0,
|
||||
selected_slot: 0,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn current(&self) -> Option<HeadState> {
|
||||
self.heads[self.current_slot]
|
||||
}
|
||||
|
||||
pub fn use_ammo(&mut self, time: f32) {
|
||||
let Some(head) = &mut self.heads[self.current_slot] else {
|
||||
error!("cannot use ammo of empty head");
|
||||
return;
|
||||
};
|
||||
|
||||
head.last_use = time;
|
||||
head.ammo = head.ammo.saturating_sub(1);
|
||||
}
|
||||
|
||||
pub fn medic_heal(&mut self, heal_amount: u32, time: f32) -> Option<u32> {
|
||||
let mut healed = false;
|
||||
for (index, head) in self.heads.iter_mut().enumerate() {
|
||||
if index == self.current_slot {
|
||||
continue;
|
||||
}
|
||||
|
||||
if let Some(head) = head {
|
||||
if head.health < head.health_max {
|
||||
head.health = head
|
||||
.health
|
||||
.saturating_add(heal_amount)
|
||||
.clamp(0, head.health_max);
|
||||
healed = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if healed {
|
||||
let Some(head) = &mut self.heads[self.current_slot] else {
|
||||
error!("cannot heal with empty head");
|
||||
return None;
|
||||
};
|
||||
|
||||
head.last_use = time;
|
||||
head.health = head.health.saturating_sub(1);
|
||||
|
||||
Some(head.health)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
pub fn head(&self, slot: usize) -> Option<HeadState> {
|
||||
self.heads[slot]
|
||||
}
|
||||
|
||||
pub fn reloading(&self) -> bool {
|
||||
for head in self.heads {
|
||||
let Some(head) = head else {
|
||||
continue;
|
||||
};
|
||||
|
||||
if head.ammo == 0 {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
false
|
||||
}
|
||||
|
||||
pub fn hp(&self) -> Hitpoints {
|
||||
if let Some(head) = &self.heads[self.current_slot] {
|
||||
Hitpoints::new(head.health_max).with_health(head.health)
|
||||
} else {
|
||||
Hitpoints::new(0)
|
||||
}
|
||||
}
|
||||
|
||||
pub fn set_hitpoint(&mut self, hp: &Hitpoints) {
|
||||
let Some(head) = &mut self.heads[self.current_slot] else {
|
||||
error!("cannot use ammo of empty head");
|
||||
return;
|
||||
};
|
||||
|
||||
(head.health, head.health_max) = hp.get()
|
||||
}
|
||||
|
||||
// returns new current head id
|
||||
pub fn loose_current(&mut self) -> Option<usize> {
|
||||
self.heads[self.current_slot] = None;
|
||||
self.next_head()
|
||||
}
|
||||
|
||||
fn next_head(&mut self) -> Option<usize> {
|
||||
let start_idx = self.current_slot;
|
||||
|
||||
for offset in 1..5 {
|
||||
let new_idx = (start_idx + offset) % 5;
|
||||
if let Some(head) = self.heads[new_idx] {
|
||||
self.current_slot = new_idx;
|
||||
return Some(head.head);
|
||||
}
|
||||
}
|
||||
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Event, Reflect)]
|
||||
pub enum SelectActiveHead {
|
||||
Left,
|
||||
Right,
|
||||
}
|
||||
|
||||
#[derive(Event)]
|
||||
pub struct HeadChanged(pub usize);
|
||||
|
||||
pub fn plugin(app: &mut App) {
|
||||
app.add_plugins(heads_ui::plugin);
|
||||
|
||||
app.add_systems(OnEnter(GameState::Playing), setup);
|
||||
app.add_systems(
|
||||
Update,
|
||||
(reload, sync_hp).run_if(in_state(GameState::Playing)),
|
||||
);
|
||||
|
||||
global_observer!(app, on_select_active_head);
|
||||
global_observer!(app, on_swap_backpack);
|
||||
}
|
||||
|
||||
fn setup(mut commands: Commands, asset_server: Res<AssetServer>, heads: Res<HeadsDatabase>) {
|
||||
// TODO: load via asset loader
|
||||
let heads = (0usize..HEAD_COUNT)
|
||||
.map(|i| asset_server.load(format!("ui/heads/{}.png", heads.head_key(i))))
|
||||
.collect();
|
||||
|
||||
commands.insert_resource(HeadsImages { heads });
|
||||
}
|
||||
|
||||
fn sync_hp(mut query: Query<(&mut ActiveHeads, &Hitpoints)>) {
|
||||
for (mut active_heads, hp) in query.iter_mut() {
|
||||
if active_heads.hp().get() != hp.get() {
|
||||
active_heads.set_hitpoint(hp);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn reload(
|
||||
mut commands: Commands,
|
||||
mut active: Query<&mut ActiveHeads>,
|
||||
time: Res<Time>,
|
||||
player: Single<&HasCharacterAnimations, With<Player>>,
|
||||
mut anim_flags: Query<&mut AnimationFlags>,
|
||||
) {
|
||||
for mut active in active.iter_mut() {
|
||||
if !active.reloading() {
|
||||
continue;
|
||||
}
|
||||
|
||||
for head in active.heads.iter_mut() {
|
||||
let Some(head) = head else {
|
||||
continue;
|
||||
};
|
||||
|
||||
if !head.has_ammo() && (head.last_use + head.reload_duration <= time.elapsed_secs()) {
|
||||
// only for player?
|
||||
commands.trigger(PlaySound::Reloaded);
|
||||
let mut flags = anim_flags.get_mut(*player.collection()).unwrap();
|
||||
flags.restart_shooting = true;
|
||||
head.ammo = head.ammo_max;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn on_select_active_head(
|
||||
trigger: Trigger<SelectActiveHead>,
|
||||
mut commands: Commands,
|
||||
mut query: Query<(&mut ActiveHeads, &mut Hitpoints), With<Player>>,
|
||||
) {
|
||||
let Ok((mut active_heads, mut hp)) = query.single_mut() else {
|
||||
return;
|
||||
};
|
||||
|
||||
match trigger.event() {
|
||||
SelectActiveHead::Right => {
|
||||
active_heads.selected_slot = (active_heads.selected_slot + 1) % HEAD_SLOTS;
|
||||
}
|
||||
SelectActiveHead::Left => {
|
||||
active_heads.selected_slot =
|
||||
(active_heads.selected_slot + (HEAD_SLOTS - 1)) % HEAD_SLOTS;
|
||||
}
|
||||
}
|
||||
|
||||
commands.trigger(PlaySound::Selection);
|
||||
|
||||
if active_heads.head(active_heads.selected_slot).is_some() {
|
||||
active_heads.current_slot = active_heads.selected_slot;
|
||||
hp.set_health(active_heads.current().unwrap().health);
|
||||
|
||||
commands.trigger(HeadChanged(
|
||||
active_heads.heads[active_heads.current_slot].unwrap().head,
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
fn on_swap_backpack(
|
||||
trigger: Trigger<BackbackSwapEvent>,
|
||||
mut commands: Commands,
|
||||
mut query: Query<(&mut ActiveHeads, &mut Hitpoints), With<Player>>,
|
||||
mut backpack: ResMut<Backpack>,
|
||||
) {
|
||||
let backpack_slot = trigger.event().0;
|
||||
|
||||
let head = backpack.heads.get(backpack_slot).unwrap();
|
||||
|
||||
let Ok((mut active_heads, mut hp)) = query.single_mut() else {
|
||||
return;
|
||||
};
|
||||
|
||||
let selected_slot = active_heads.selected_slot;
|
||||
|
||||
let selected_head = active_heads.heads[selected_slot];
|
||||
active_heads.heads[selected_slot] = Some(*head);
|
||||
|
||||
if let Some(old_active) = selected_head {
|
||||
backpack.heads[backpack_slot] = old_active;
|
||||
} else {
|
||||
backpack.heads.remove(backpack_slot);
|
||||
}
|
||||
|
||||
hp.set_health(active_heads.current().unwrap().health);
|
||||
|
||||
commands.trigger(HeadChanged(
|
||||
active_heads.heads[active_heads.selected_slot].unwrap().head,
|
||||
));
|
||||
}
|
||||
88
crates/shared/src/heads_database.rs
Normal file
88
crates/shared/src/heads_database.rs
Normal file
@@ -0,0 +1,88 @@
|
||||
use crate::abilities::HeadAbility;
|
||||
use bevy::prelude::*;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Clone, Copy, Debug, Default, Reflect, Serialize, Deserialize, PartialEq, Eq)]
|
||||
pub enum HeadControls {
|
||||
#[default]
|
||||
Walk,
|
||||
Plane,
|
||||
}
|
||||
|
||||
#[derive(Debug, Default, Reflect, Serialize, Deserialize)]
|
||||
pub struct HeadStats {
|
||||
pub key: String,
|
||||
#[serde(default)]
|
||||
pub ability: HeadAbility,
|
||||
#[serde(default)]
|
||||
pub range: f32,
|
||||
#[serde(default)]
|
||||
pub controls: HeadControls,
|
||||
#[serde(default)]
|
||||
pub projectile: String,
|
||||
#[serde(default = "default_ammo")]
|
||||
pub ammo: u32,
|
||||
#[serde(default)]
|
||||
pub damage: u32,
|
||||
/// ability per second
|
||||
#[serde(default = "default_aps")]
|
||||
pub aps: f32,
|
||||
#[serde(default = "default_interrupt_shoot")]
|
||||
pub interrupt_shoot: bool,
|
||||
#[serde(default)]
|
||||
pub shoot_offset: f32,
|
||||
}
|
||||
|
||||
fn default_aps() -> f32 {
|
||||
1.
|
||||
}
|
||||
|
||||
fn default_ammo() -> u32 {
|
||||
10
|
||||
}
|
||||
|
||||
fn default_interrupt_shoot() -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
#[derive(Debug, Asset, Reflect, Serialize, Deserialize)]
|
||||
pub struct HeadDatabaseAsset(pub Vec<HeadStats>);
|
||||
|
||||
#[derive(Debug, Resource, Reflect)]
|
||||
#[reflect(Resource)]
|
||||
pub struct HeadsDatabase {
|
||||
pub heads: Vec<HeadStats>,
|
||||
}
|
||||
|
||||
impl HeadsDatabase {
|
||||
pub fn head_key(&self, id: usize) -> &str {
|
||||
&self.heads[id].key
|
||||
}
|
||||
|
||||
pub fn head_stats(&self, id: usize) -> &HeadStats {
|
||||
&self.heads[id]
|
||||
}
|
||||
}
|
||||
|
||||
// #[cfg(test)]
|
||||
// mod test {
|
||||
// use super::*;
|
||||
|
||||
// #[test]
|
||||
// fn test_serialize() {
|
||||
// let asset = HeadDatabaseAsset(vec![
|
||||
// HeadStats {
|
||||
// key: String::from("foo"),
|
||||
// range: 90.,
|
||||
// ..Default::default()
|
||||
// },
|
||||
// HeadStats {
|
||||
// key: String::from("bar"),
|
||||
// ability: HeadAbility::Gun,
|
||||
// range: 0.,
|
||||
// },
|
||||
// ]);
|
||||
|
||||
// std::fs::write("assets/test.headsb.ron", ron::to_string(&asset).unwrap()).unwrap();
|
||||
// }
|
||||
// }
|
||||
99
crates/shared/src/heal_effect.rs
Normal file
99
crates/shared/src/heal_effect.rs
Normal file
@@ -0,0 +1,99 @@
|
||||
use crate::{
|
||||
GameState, abilities::Healing, loading_assets::GameAssets, utils::billboards::Billboard,
|
||||
};
|
||||
use bevy::prelude::*;
|
||||
use rand::{Rng, thread_rng};
|
||||
|
||||
#[derive(Component, Default)]
|
||||
#[require(Transform, InheritedVisibility)]
|
||||
struct HealParticleEffect {
|
||||
next_spawn: f32,
|
||||
}
|
||||
|
||||
#[derive(Component)]
|
||||
struct HealParticle {
|
||||
start_scale: f32,
|
||||
end_scale: f32,
|
||||
start_pos: Vec3,
|
||||
end_pos: Vec3,
|
||||
start_time: f32,
|
||||
life_time: f32,
|
||||
}
|
||||
|
||||
pub fn plugin(app: &mut App) {
|
||||
app.add_systems(
|
||||
Update,
|
||||
(on_added, update_effects, update_particles).run_if(in_state(GameState::Playing)),
|
||||
);
|
||||
}
|
||||
|
||||
fn on_added(mut cmds: Commands, query: Query<&Healing, Added<Healing>>) {
|
||||
for healing in query.iter() {
|
||||
cmds.entity(healing.0).insert((
|
||||
Name::new("heal-particle-effect"),
|
||||
HealParticleEffect::default(),
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
fn update_effects(
|
||||
mut cmds: Commands,
|
||||
mut query: Query<(&mut HealParticleEffect, Entity)>,
|
||||
time: Res<Time>,
|
||||
assets: Res<GameAssets>,
|
||||
) {
|
||||
const DISTANCE: f32 = 4.;
|
||||
let mut rng = thread_rng();
|
||||
|
||||
let now = time.elapsed_secs();
|
||||
for (mut effect, e) in query.iter_mut() {
|
||||
if effect.next_spawn < now {
|
||||
let start_pos = Vec3::new(
|
||||
rng.gen_range(-DISTANCE..DISTANCE),
|
||||
2.,
|
||||
rng.gen_range(-DISTANCE..DISTANCE),
|
||||
);
|
||||
let max_distance = start_pos.length().max(0.8);
|
||||
let end_pos =
|
||||
start_pos + (start_pos.normalize() * -1.) * rng.gen_range(0.5..max_distance);
|
||||
let start_scale = rng.gen_range(0.7..1.0);
|
||||
let end_scale = rng.gen_range(0.1..start_scale);
|
||||
|
||||
cmds.entity(e).with_child((
|
||||
Name::new("heal-particle"),
|
||||
SceneRoot(assets.mesh_heal_particle.clone()),
|
||||
Billboard::All,
|
||||
Transform::from_translation(start_pos),
|
||||
HealParticle {
|
||||
start_scale,
|
||||
end_scale,
|
||||
start_pos,
|
||||
end_pos,
|
||||
start_time: now,
|
||||
life_time: rng.gen_range(0.3..1.0),
|
||||
},
|
||||
));
|
||||
|
||||
effect.next_spawn = now + rng.gen_range(0.1..0.5);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn update_particles(
|
||||
mut cmds: Commands,
|
||||
mut query: Query<(&mut Transform, &HealParticle, Entity)>,
|
||||
time: Res<Time>,
|
||||
) {
|
||||
for (mut transform, particle, e) in query.iter_mut() {
|
||||
if particle.start_time + particle.life_time < time.elapsed_secs() {
|
||||
cmds.entity(e).despawn();
|
||||
continue;
|
||||
}
|
||||
|
||||
let t = (time.elapsed_secs() - particle.start_time) / particle.life_time;
|
||||
|
||||
// info!("particle[{e:?}] t: {t}");
|
||||
transform.translation = particle.start_pos.lerp(particle.end_pos, t);
|
||||
transform.scale = Vec3::splat(particle.start_scale.lerp(particle.end_scale, t));
|
||||
}
|
||||
}
|
||||
124
crates/shared/src/hitpoints.rs
Normal file
124
crates/shared/src/hitpoints.rs
Normal file
@@ -0,0 +1,124 @@
|
||||
use crate::{
|
||||
GameState,
|
||||
animation::AnimationFlags,
|
||||
character::{CharacterAnimations, HasCharacterAnimations},
|
||||
sounds::PlaySound,
|
||||
};
|
||||
use bevy::prelude::*;
|
||||
|
||||
#[derive(Event, Reflect)]
|
||||
pub struct Kill;
|
||||
|
||||
#[derive(Event, Reflect)]
|
||||
pub struct Hit {
|
||||
pub damage: u32,
|
||||
}
|
||||
|
||||
#[derive(Component, Reflect, Debug, Clone, Copy)]
|
||||
pub struct Hitpoints {
|
||||
max: u32,
|
||||
current: u32,
|
||||
last_hit_timestamp: f32,
|
||||
}
|
||||
|
||||
impl Hitpoints {
|
||||
pub fn new(v: u32) -> Self {
|
||||
Self {
|
||||
max: v,
|
||||
current: v,
|
||||
last_hit_timestamp: f32::NEG_INFINITY,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn with_health(mut self, v: u32) -> Self {
|
||||
self.current = v;
|
||||
self
|
||||
}
|
||||
|
||||
pub fn health(&self) -> f32 {
|
||||
self.current as f32 / self.max as f32
|
||||
}
|
||||
|
||||
pub fn set_health(&mut self, v: u32) {
|
||||
self.current = v;
|
||||
}
|
||||
|
||||
pub fn heal(&mut self, v: u32) {
|
||||
self.current += v;
|
||||
}
|
||||
|
||||
pub fn get(&self) -> (u32, u32) {
|
||||
(self.current, self.max)
|
||||
}
|
||||
|
||||
pub fn max(&self) -> bool {
|
||||
self.current == self.max
|
||||
}
|
||||
|
||||
pub fn time_since_hit(&self, time: &Time) -> f32 {
|
||||
time.elapsed_secs() - self.last_hit_timestamp
|
||||
}
|
||||
}
|
||||
|
||||
pub fn plugin(app: &mut App) {
|
||||
app.add_systems(Update, on_hp_added).add_systems(
|
||||
PreUpdate,
|
||||
reset_hit_animation_flag.run_if(in_state(GameState::Playing)),
|
||||
);
|
||||
}
|
||||
|
||||
fn on_hp_added(mut commands: Commands, query: Query<Entity, Added<Hitpoints>>) {
|
||||
for e in query.iter() {
|
||||
commands.entity(e).observe(on_hit);
|
||||
}
|
||||
}
|
||||
|
||||
fn on_hit(
|
||||
trigger: Trigger<Hit>,
|
||||
mut commands: Commands,
|
||||
mut query: Query<(&mut Hitpoints, Option<&HasCharacterAnimations>)>,
|
||||
mut anim_flags: Query<&mut AnimationFlags>,
|
||||
time: Res<Time>,
|
||||
) {
|
||||
let Hit { damage } = trigger.event();
|
||||
|
||||
let Ok((mut hp, has_anims)) = query.get_mut(trigger.target()) else {
|
||||
return;
|
||||
};
|
||||
|
||||
commands.trigger(PlaySound::Hit);
|
||||
|
||||
if let Some(has_anims) = has_anims {
|
||||
let mut flags = anim_flags.get_mut(*has_anims.collection()).unwrap();
|
||||
flags.hit = true;
|
||||
}
|
||||
|
||||
hp.current = hp.current.saturating_sub(*damage);
|
||||
hp.last_hit_timestamp = time.elapsed_secs();
|
||||
|
||||
if hp.current == 0 {
|
||||
commands.trigger_targets(Kill, trigger.target());
|
||||
}
|
||||
}
|
||||
|
||||
fn reset_hit_animation_flag(
|
||||
query: Query<(&Hitpoints, &HasCharacterAnimations)>,
|
||||
mut animations: Query<(
|
||||
&AnimationGraphHandle,
|
||||
&CharacterAnimations,
|
||||
&mut AnimationFlags,
|
||||
)>,
|
||||
graphs: Res<Assets<AnimationGraph>>,
|
||||
clips: Res<Assets<AnimationClip>>,
|
||||
time: Res<Time>,
|
||||
) {
|
||||
for (hp, anims) in query.iter() {
|
||||
let (graph_handle, anims, mut flags) = animations.get_mut(*anims.collection()).unwrap();
|
||||
let graph = graphs.get(graph_handle.id()).unwrap();
|
||||
let hit_anim = match graph.get(anims.hit).unwrap().node_type {
|
||||
AnimationNodeType::Clip(ref handle) => clips.get(handle.id()).unwrap(),
|
||||
_ => unreachable!(),
|
||||
};
|
||||
flags.hit = hp.time_since_hit(&time) < hit_anim.duration();
|
||||
}
|
||||
}
|
||||
81
crates/shared/src/keys.rs
Normal file
81
crates/shared/src/keys.rs
Normal file
@@ -0,0 +1,81 @@
|
||||
use crate::{
|
||||
billboards::Billboard, global_observer, loading_assets::GameAssets, physics_layers::GameLayer,
|
||||
player::Player, sounds::PlaySound, squish_animation::SquishAnimation,
|
||||
};
|
||||
use avian3d::prelude::*;
|
||||
use bevy::{
|
||||
ecs::{relationship::RelatedSpawner, spawn::SpawnWith},
|
||||
prelude::*,
|
||||
};
|
||||
use std::f32::consts::PI;
|
||||
|
||||
#[derive(Event, Reflect)]
|
||||
pub struct KeySpawn(pub Vec3, pub String);
|
||||
|
||||
#[derive(Component, Reflect)]
|
||||
#[reflect(Component)]
|
||||
struct Key(pub String);
|
||||
|
||||
#[derive(Event, Reflect)]
|
||||
pub struct KeyCollected(pub String);
|
||||
|
||||
pub fn plugin(app: &mut App) {
|
||||
global_observer!(app, on_spawn_key);
|
||||
}
|
||||
|
||||
fn on_spawn_key(trigger: Trigger<KeySpawn>, mut commands: Commands, assets: Res<GameAssets>) {
|
||||
let KeySpawn(position, id) = trigger.event();
|
||||
|
||||
let id = id.clone();
|
||||
|
||||
let angle = rand::random::<f32>() * PI * 2.;
|
||||
let spawn_dir = Quat::from_rotation_y(angle) * Vec3::new(0.5, 0.6, 0.).normalize();
|
||||
|
||||
commands.spawn((
|
||||
Name::new("key"),
|
||||
Transform::from_translation(*position),
|
||||
Visibility::default(),
|
||||
Collider::sphere(1.5),
|
||||
ExternalImpulse::new(spawn_dir * 180.).with_persistence(false),
|
||||
LockedAxes::ROTATION_LOCKED,
|
||||
RigidBody::Dynamic,
|
||||
CollisionLayers::new(GameLayer::CollectiblePhysics, GameLayer::Level),
|
||||
Restitution::new(0.6),
|
||||
Children::spawn((
|
||||
Spawn((
|
||||
Billboard::All,
|
||||
SquishAnimation(2.6),
|
||||
SceneRoot(assets.mesh_key.clone()),
|
||||
)),
|
||||
SpawnWith(|parent: &mut RelatedSpawner<ChildOf>| {
|
||||
parent
|
||||
.spawn((
|
||||
Collider::sphere(1.5),
|
||||
CollisionLayers::new(GameLayer::CollectibleSensors, GameLayer::Player),
|
||||
Sensor,
|
||||
CollisionEventsEnabled,
|
||||
Key(id),
|
||||
))
|
||||
.observe(on_collect_key);
|
||||
}),
|
||||
)),
|
||||
));
|
||||
}
|
||||
|
||||
fn on_collect_key(
|
||||
trigger: Trigger<OnCollisionStart>,
|
||||
mut commands: Commands,
|
||||
query_player: Query<&Player>,
|
||||
query_collectable: Query<(&Key, &ChildOf)>,
|
||||
) {
|
||||
let key = trigger.target();
|
||||
let collider = trigger.collider;
|
||||
|
||||
if query_player.contains(collider) {
|
||||
let (key, child_of) = query_collectable.get(key).unwrap();
|
||||
|
||||
commands.trigger(PlaySound::KeyCollect);
|
||||
commands.trigger(KeyCollected(key.0.clone()));
|
||||
commands.entity(child_of.parent()).despawn();
|
||||
}
|
||||
}
|
||||
51
crates/shared/src/lib.rs
Normal file
51
crates/shared/src/lib.rs
Normal file
@@ -0,0 +1,51 @@
|
||||
pub mod abilities;
|
||||
pub mod ai;
|
||||
pub mod aim;
|
||||
pub mod animation;
|
||||
pub mod backpack;
|
||||
pub mod camera;
|
||||
pub mod cash;
|
||||
pub mod cash_heal;
|
||||
pub mod character;
|
||||
pub mod control;
|
||||
pub mod cutscene;
|
||||
pub mod gates;
|
||||
pub mod head;
|
||||
pub mod head_drop;
|
||||
pub mod heads;
|
||||
pub mod heads_database;
|
||||
pub mod heal_effect;
|
||||
pub mod hitpoints;
|
||||
pub mod keys;
|
||||
pub mod loading_assets;
|
||||
pub mod loading_map;
|
||||
pub mod movables;
|
||||
pub mod npc;
|
||||
pub mod physics_layers;
|
||||
pub mod platforms;
|
||||
pub mod player;
|
||||
pub mod sounds;
|
||||
pub mod tb_entities;
|
||||
pub mod utils;
|
||||
pub mod water;
|
||||
|
||||
use bevy::{core_pipeline::tonemapping::Tonemapping, prelude::*};
|
||||
use utils::{billboards, squish_animation};
|
||||
|
||||
#[derive(Resource, Reflect, Debug)]
|
||||
#[reflect(Resource)]
|
||||
pub struct DebugVisuals {
|
||||
pub unlit: bool,
|
||||
pub tonemapping: Tonemapping,
|
||||
pub exposure: f32,
|
||||
pub shadows: bool,
|
||||
pub cam_follow: bool,
|
||||
}
|
||||
|
||||
#[derive(States, Default, Clone, Eq, PartialEq, Debug, Hash)]
|
||||
pub enum GameState {
|
||||
#[default]
|
||||
AssetLoading,
|
||||
MapLoading,
|
||||
Playing,
|
||||
}
|
||||
149
crates/shared/src/loading_assets.rs
Normal file
149
crates/shared/src/loading_assets.rs
Normal file
@@ -0,0 +1,149 @@
|
||||
use crate::{
|
||||
GameState,
|
||||
heads_database::{HeadDatabaseAsset, HeadsDatabase},
|
||||
};
|
||||
use bevy::{platform::collections::HashMap, prelude::*};
|
||||
use bevy_asset_loader::prelude::*;
|
||||
|
||||
#[derive(AssetCollection, Resource)]
|
||||
pub struct AudioAssets {
|
||||
#[asset(path = "sfx/music/02.ogg")]
|
||||
pub music: Handle<AudioSource>,
|
||||
#[asset(path = "sfx/ambient/downtown_loop.ogg")]
|
||||
pub ambient: Handle<AudioSource>,
|
||||
#[asset(path = "sfx/effects/key_collect.ogg")]
|
||||
pub key_collect: Handle<AudioSource>,
|
||||
#[asset(path = "sfx/effects/gate.ogg")]
|
||||
pub gate: Handle<AudioSource>,
|
||||
#[asset(path = "sfx/effects/cash_collect.ogg")]
|
||||
pub cash_collect: Handle<AudioSource>,
|
||||
#[asset(path = "sfx/ui/selection.ogg")]
|
||||
pub selection: Handle<AudioSource>,
|
||||
|
||||
#[asset(path = "sfx/ui/invalid.ogg")]
|
||||
pub invalid: Handle<AudioSource>,
|
||||
#[asset(path = "sfx/ui/reloaded.ogg")]
|
||||
pub reloaded: Handle<AudioSource>,
|
||||
|
||||
#[asset(path = "sfx/ui/cash_heal.ogg")]
|
||||
pub cash_heal: Handle<AudioSource>,
|
||||
|
||||
#[asset(path = "sfx/abilities/throw.ogg")]
|
||||
pub throw: Handle<AudioSource>,
|
||||
#[asset(path = "sfx/abilities/throw-explosion.ogg")]
|
||||
pub throw_explosion: Handle<AudioSource>,
|
||||
#[asset(path = "sfx/abilities/jet.ogg")]
|
||||
pub jet: Handle<AudioSource>,
|
||||
#[asset(path = "sfx/abilities/gun.ogg")]
|
||||
pub gun: Handle<AudioSource>,
|
||||
#[asset(path = "sfx/abilities/crossbow.ogg")]
|
||||
pub crossbow: Handle<AudioSource>,
|
||||
#[asset(path = "sfx/abilities/heal.ogg")]
|
||||
pub healing: Handle<AudioSource>,
|
||||
#[asset(path = "sfx/abilities/missile-explosion.ogg")]
|
||||
pub missile_explosion: Handle<AudioSource>,
|
||||
|
||||
#[asset(path = "sfx/ui/backpack_open.ogg")]
|
||||
pub backpack_open: Handle<AudioSource>,
|
||||
#[asset(path = "sfx/ui/backpack_close.ogg")]
|
||||
pub backpack_close: Handle<AudioSource>,
|
||||
|
||||
#[asset(path = "sfx/effects/head_collect.ogg")]
|
||||
pub head_collect: Handle<AudioSource>,
|
||||
#[asset(path = "sfx/effects/secret_collected.ogg")]
|
||||
pub secret_head_collect: Handle<AudioSource>,
|
||||
#[asset(path = "sfx/effects/head_drop.ogg")]
|
||||
pub head_drop: Handle<AudioSource>,
|
||||
#[asset(path = "sfx/effects/beam_in_out.ogg")]
|
||||
pub beaming: Handle<AudioSource>,
|
||||
|
||||
#[asset(path = "sfx/hit", collection(typed))]
|
||||
pub hit: Vec<Handle<AudioSource>>,
|
||||
#[asset(path = "sfx/heads", collection(mapped, typed))]
|
||||
pub head: HashMap<AssetFileName, Handle<AudioSource>>,
|
||||
}
|
||||
|
||||
#[derive(AssetCollection, Resource)]
|
||||
struct HeadsAssets {
|
||||
#[asset(path = "all.headsdb.ron")]
|
||||
heads: Handle<HeadDatabaseAsset>,
|
||||
}
|
||||
|
||||
#[derive(AssetCollection, Resource)]
|
||||
pub struct HeadDropAssets {
|
||||
#[asset(path = "models/head_drops", collection(mapped, typed))]
|
||||
pub meshes: HashMap<AssetFileName, Handle<Gltf>>,
|
||||
}
|
||||
|
||||
#[derive(AssetCollection, Resource)]
|
||||
pub struct UIAssets {
|
||||
#[asset(path = "font.ttf")]
|
||||
pub font: Handle<Font>,
|
||||
|
||||
#[asset(path = "ui/head_bg.png")]
|
||||
pub head_bg: Handle<Image>,
|
||||
#[asset(path = "ui/head_regular.png")]
|
||||
pub head_regular: Handle<Image>,
|
||||
#[asset(path = "ui/head_damage.png")]
|
||||
pub head_damage: Handle<Image>,
|
||||
#[asset(path = "ui/selector.png")]
|
||||
pub head_selector: Handle<Image>,
|
||||
|
||||
#[asset(path = "ui/camera.png")]
|
||||
pub camera: Handle<Image>,
|
||||
}
|
||||
|
||||
#[derive(AssetCollection, Resource)]
|
||||
pub struct GameAssets {
|
||||
#[asset(path = "textures/fx/impact.png")]
|
||||
pub impact_atlas: Handle<Image>,
|
||||
|
||||
#[asset(path = "models/key.glb#Scene0")]
|
||||
pub mesh_key: Handle<Scene>,
|
||||
|
||||
#[asset(path = "models/spawn.glb#Scene0")]
|
||||
pub mesh_spawn: Handle<Scene>,
|
||||
|
||||
#[asset(path = "models/cash.glb#Scene0")]
|
||||
pub mesh_cash: Handle<Scene>,
|
||||
|
||||
#[asset(path = "models/medic_particle.glb#Scene0")]
|
||||
pub mesh_heal_particle: Handle<Scene>,
|
||||
|
||||
#[asset(path = "models/beaming.glb#Scene0")]
|
||||
pub beaming: Handle<Scene>,
|
||||
|
||||
#[asset(path = "models/projectiles", collection(mapped, typed))]
|
||||
pub projectiles: HashMap<AssetFileName, Handle<Gltf>>,
|
||||
|
||||
#[asset(path = "models/characters", collection(mapped, typed))]
|
||||
pub characters: HashMap<AssetFileName, Handle<Gltf>>,
|
||||
}
|
||||
|
||||
pub struct LoadingPlugin;
|
||||
impl Plugin for LoadingPlugin {
|
||||
fn build(&self, app: &mut App) {
|
||||
app.add_systems(OnExit(GameState::AssetLoading), on_exit);
|
||||
app.add_loading_state(
|
||||
LoadingState::new(GameState::AssetLoading)
|
||||
.continue_to_state(GameState::MapLoading)
|
||||
.load_collection::<AudioAssets>()
|
||||
.load_collection::<GameAssets>()
|
||||
.load_collection::<HeadsAssets>()
|
||||
.load_collection::<HeadDropAssets>()
|
||||
.load_collection::<UIAssets>(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
fn on_exit(
|
||||
mut cmds: Commands,
|
||||
res: Res<HeadsAssets>,
|
||||
mut assets: ResMut<Assets<HeadDatabaseAsset>>,
|
||||
) {
|
||||
let asset = assets
|
||||
.remove(res.heads.id())
|
||||
.expect("headsdb failed to load");
|
||||
|
||||
cmds.insert_resource(HeadsDatabase { heads: asset.0 });
|
||||
}
|
||||
37
crates/shared/src/loading_map.rs
Normal file
37
crates/shared/src/loading_map.rs
Normal file
@@ -0,0 +1,37 @@
|
||||
use crate::{GameState, physics_layers::GameLayer};
|
||||
use avian3d::prelude::*;
|
||||
use bevy::prelude::*;
|
||||
use bevy_trenchbroom::physics::SceneCollidersReady;
|
||||
|
||||
pub fn plugin(app: &mut App) {
|
||||
app.add_systems(OnEnter(GameState::MapLoading), setup_scene);
|
||||
}
|
||||
|
||||
fn setup_scene(mut commands: Commands, asset_server: Res<AssetServer>) {
|
||||
commands
|
||||
.spawn((
|
||||
CollisionLayers::new(LayerMask(GameLayer::Level.to_bits()), LayerMask::ALL),
|
||||
SceneRoot(asset_server.load("maps/map1.map#Scene")),
|
||||
))
|
||||
.observe(
|
||||
|_t: Trigger<SceneCollidersReady>,
|
||||
mut next_game_state: ResMut<NextState<GameState>>| {
|
||||
info!("map loaded");
|
||||
|
||||
next_game_state.set(GameState::Playing);
|
||||
},
|
||||
);
|
||||
|
||||
commands.spawn((
|
||||
DirectionalLight {
|
||||
illuminance: light_consts::lux::OVERCAST_DAY,
|
||||
shadows_enabled: true,
|
||||
..default()
|
||||
},
|
||||
Transform {
|
||||
translation: Vec3::new(0.0, 2.0, 0.0),
|
||||
rotation: Quat::from_rotation_x(-1.7),
|
||||
..default()
|
||||
},
|
||||
));
|
||||
}
|
||||
86
crates/shared/src/movables.rs
Normal file
86
crates/shared/src/movables.rs
Normal file
@@ -0,0 +1,86 @@
|
||||
use crate::{
|
||||
GameState, global_observer,
|
||||
tb_entities::{Movable, MoveTarget},
|
||||
};
|
||||
use bevy::{platform::collections::HashSet, prelude::*};
|
||||
use bevy_trenchbroom::prelude::*;
|
||||
|
||||
#[derive(Component, Reflect, Default, Debug)]
|
||||
#[reflect(Component)]
|
||||
struct ActiveMovable {
|
||||
pub start: Transform,
|
||||
pub target: Transform,
|
||||
pub start_time: f32,
|
||||
pub duration: f32,
|
||||
}
|
||||
|
||||
#[derive(Event)]
|
||||
pub struct TriggerMovableEvent(pub HashSet<String>);
|
||||
|
||||
pub fn plugin(app: &mut App) {
|
||||
app.register_type::<ActiveMovable>();
|
||||
app.add_systems(Update, move_active.run_if(in_state(GameState::Playing)));
|
||||
|
||||
global_observer!(app, on_movable_event);
|
||||
}
|
||||
|
||||
fn on_movable_event(
|
||||
trigger: Trigger<TriggerMovableEvent>,
|
||||
mut commands: Commands,
|
||||
uninit_movables: Query<
|
||||
(Entity, &Target, &Transform, &Movable),
|
||||
(Without<ActiveMovable>, With<Movable>),
|
||||
>,
|
||||
targets: Query<(&MoveTarget, &Transform)>,
|
||||
time: Res<Time>,
|
||||
) {
|
||||
info!("trigger: {:?}", trigger.0);
|
||||
|
||||
for (e, target, transform, movable) in uninit_movables.iter() {
|
||||
if !trigger.0.contains(&movable.name) {
|
||||
continue;
|
||||
}
|
||||
|
||||
let target_name = target.target.clone().unwrap_or_default();
|
||||
|
||||
let Some(target) = targets
|
||||
.iter()
|
||||
.find(|(t, _)| t.targetname == target_name)
|
||||
.map(|(_, t)| *t)
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
|
||||
info!("found target: {:?}", target_name);
|
||||
|
||||
let target: Transform =
|
||||
Transform::from_translation(transform.translation).with_rotation(target.rotation);
|
||||
|
||||
let platform = ActiveMovable {
|
||||
start: *transform,
|
||||
target,
|
||||
start_time: time.elapsed_secs(),
|
||||
//TODO: make this configurable
|
||||
duration: 2.,
|
||||
};
|
||||
|
||||
commands.entity(e).insert(platform);
|
||||
}
|
||||
}
|
||||
|
||||
fn move_active(
|
||||
mut commands: Commands,
|
||||
mut platforms: Query<(Entity, &mut Transform, &mut ActiveMovable)>,
|
||||
time: Res<Time>,
|
||||
) {
|
||||
let elapsed = time.elapsed_secs();
|
||||
for (e, mut transform, active) in platforms.iter_mut() {
|
||||
if elapsed < active.start_time + active.duration {
|
||||
let t = (elapsed - active.start_time) / active.duration;
|
||||
transform.rotation = active.start.rotation.lerp(active.target.rotation, t);
|
||||
} else {
|
||||
*transform = active.target;
|
||||
commands.entity(e).remove::<(ActiveMovable, Movable)>();
|
||||
}
|
||||
}
|
||||
}
|
||||
153
crates/shared/src/npc.rs
Normal file
153
crates/shared/src/npc.rs
Normal file
@@ -0,0 +1,153 @@
|
||||
use crate::{
|
||||
GameState,
|
||||
ai::Ai,
|
||||
character::AnimatedCharacter,
|
||||
global_observer,
|
||||
head::ActiveHead,
|
||||
head_drop::HeadDrops,
|
||||
heads::{ActiveHeads, HEAD_COUNT, HeadState},
|
||||
heads_database::HeadsDatabase,
|
||||
hitpoints::{Hitpoints, Kill},
|
||||
keys::KeySpawn,
|
||||
loading_assets::GameAssets,
|
||||
sounds::PlaySound,
|
||||
tb_entities::EnemySpawn,
|
||||
utils::billboards::Billboard,
|
||||
};
|
||||
use bevy::{pbr::NotShadowCaster, prelude::*};
|
||||
use std::collections::HashMap;
|
||||
|
||||
#[derive(Component, Reflect)]
|
||||
#[reflect(Component)]
|
||||
pub struct Npc;
|
||||
|
||||
#[derive(Resource, Reflect, Default)]
|
||||
#[reflect(Resource)]
|
||||
struct NpcSpawning {
|
||||
spawn_index: u32,
|
||||
}
|
||||
|
||||
#[derive(Component, Reflect)]
|
||||
#[reflect(Component)]
|
||||
pub struct SpawningBeam(pub f32);
|
||||
|
||||
#[derive(Event)]
|
||||
struct OnCheckSpawns;
|
||||
|
||||
#[derive(Event)]
|
||||
pub struct SpawnCharacter(pub Vec3);
|
||||
|
||||
pub fn plugin(app: &mut App) {
|
||||
app.init_resource::<NpcSpawning>();
|
||||
app.add_systems(OnEnter(GameState::Playing), setup);
|
||||
app.add_systems(Update, update_beams.run_if(in_state(GameState::Playing)));
|
||||
|
||||
global_observer!(app, on_spawn_check);
|
||||
global_observer!(app, on_spawn);
|
||||
}
|
||||
|
||||
fn setup(mut commands: Commands) {
|
||||
commands.init_resource::<NpcSpawning>();
|
||||
commands.trigger(OnCheckSpawns);
|
||||
}
|
||||
|
||||
fn on_spawn_check(
|
||||
_trigger: Trigger<OnCheckSpawns>,
|
||||
mut commands: Commands,
|
||||
query: Query<(Entity, &EnemySpawn, &Transform), Without<Npc>>,
|
||||
heads_db: Res<HeadsDatabase>,
|
||||
spawning: Res<NpcSpawning>,
|
||||
) {
|
||||
//TODO: move into HeadsDatabase
|
||||
let mut names: HashMap<String, usize> = HashMap::default();
|
||||
for i in 0..HEAD_COUNT {
|
||||
names.insert(heads_db.head_key(i).to_string(), i);
|
||||
}
|
||||
|
||||
for (e, spawn, transform) in query.iter() {
|
||||
if let Some(order) = spawn.spawn_order {
|
||||
if order > spawning.spawn_index {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
let id = names[&spawn.head];
|
||||
commands
|
||||
.entity(e)
|
||||
.insert((
|
||||
Hitpoints::new(100),
|
||||
Npc,
|
||||
ActiveHead(id),
|
||||
ActiveHeads::new([
|
||||
Some(HeadState::new(id, heads_db.as_ref())),
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
]),
|
||||
))
|
||||
.insert_if(Ai, || !spawn.disable_ai)
|
||||
.with_child((Name::from("body-rig"), AnimatedCharacter::new(id)))
|
||||
.observe(on_kill);
|
||||
|
||||
commands.trigger(SpawnCharacter(transform.translation));
|
||||
commands.trigger(PlaySound::Beaming);
|
||||
}
|
||||
}
|
||||
|
||||
fn on_kill(
|
||||
trigger: Trigger<Kill>,
|
||||
mut commands: Commands,
|
||||
query: Query<(&Transform, &EnemySpawn, &ActiveHead)>,
|
||||
) {
|
||||
let Ok((transform, enemy, head)) = query.get(trigger.target()) else {
|
||||
return;
|
||||
};
|
||||
|
||||
if let Some(order) = enemy.spawn_order {
|
||||
commands.insert_resource(NpcSpawning {
|
||||
spawn_index: order + 1,
|
||||
});
|
||||
}
|
||||
|
||||
commands.trigger(HeadDrops::new(transform.translation, head.0));
|
||||
commands.trigger(OnCheckSpawns);
|
||||
|
||||
commands.entity(trigger.target()).despawn();
|
||||
|
||||
if !enemy.key.is_empty() {
|
||||
commands.trigger(KeySpawn(transform.translation, enemy.key.clone()));
|
||||
}
|
||||
}
|
||||
|
||||
fn on_spawn(
|
||||
trigger: Trigger<SpawnCharacter>,
|
||||
mut commands: Commands,
|
||||
assets: Res<GameAssets>,
|
||||
time: Res<Time>,
|
||||
) {
|
||||
commands.spawn((
|
||||
Transform::from_translation(trigger.event().0 + Vec3::new(0., -2., 0.))
|
||||
.with_scale(Vec3::new(1., 40., 1.)),
|
||||
Billboard::XZ,
|
||||
NotShadowCaster,
|
||||
SpawningBeam(time.elapsed_secs()),
|
||||
SceneRoot(assets.beaming.clone()),
|
||||
));
|
||||
}
|
||||
|
||||
fn update_beams(
|
||||
mut commands: Commands,
|
||||
mut query: Query<(Entity, &SpawningBeam, &mut Transform)>,
|
||||
time: Res<Time>,
|
||||
) {
|
||||
for (entity, beam, mut transform) in query.iter_mut() {
|
||||
let age = time.elapsed_secs() - beam.0;
|
||||
|
||||
transform.scale.x = age.sin() * 2.;
|
||||
|
||||
if age > 3. {
|
||||
commands.entity(entity).despawn();
|
||||
}
|
||||
}
|
||||
}
|
||||
13
crates/shared/src/physics_layers.rs
Normal file
13
crates/shared/src/physics_layers.rs
Normal file
@@ -0,0 +1,13 @@
|
||||
use avian3d::prelude::PhysicsLayer;
|
||||
use bevy::reflect::Reflect;
|
||||
|
||||
#[derive(PhysicsLayer, Clone, Copy, Debug, Default, Reflect)]
|
||||
pub enum GameLayer {
|
||||
#[default]
|
||||
Level,
|
||||
Player,
|
||||
Npc,
|
||||
Projectile,
|
||||
CollectiblePhysics,
|
||||
CollectibleSensors,
|
||||
}
|
||||
56
crates/shared/src/platforms.rs
Normal file
56
crates/shared/src/platforms.rs
Normal file
@@ -0,0 +1,56 @@
|
||||
use crate::{
|
||||
GameState,
|
||||
tb_entities::{Platform, PlatformTarget},
|
||||
};
|
||||
use bevy::{math::ops::sin, prelude::*};
|
||||
use bevy_trenchbroom::prelude::*;
|
||||
|
||||
#[derive(Component, Reflect, Default, Debug)]
|
||||
#[reflect(Component)]
|
||||
struct ActivePlatform {
|
||||
pub start: Vec3,
|
||||
pub target: Vec3,
|
||||
}
|
||||
|
||||
pub fn plugin(app: &mut App) {
|
||||
app.register_type::<ActivePlatform>();
|
||||
app.add_systems(OnEnter(GameState::Playing), init);
|
||||
app.add_systems(
|
||||
FixedUpdate,
|
||||
move_active.run_if(in_state(GameState::Playing)),
|
||||
);
|
||||
}
|
||||
|
||||
fn init(
|
||||
mut commands: Commands,
|
||||
uninit_platforms: Query<
|
||||
(Entity, &Target, &Transform),
|
||||
(Without<ActivePlatform>, With<Platform>),
|
||||
>,
|
||||
targets: Query<(&PlatformTarget, &Transform)>,
|
||||
) {
|
||||
for (e, target, transform) in uninit_platforms.iter() {
|
||||
let Some(target) = targets
|
||||
.iter()
|
||||
.find(|(t, _)| t.targetname == target.target.clone().unwrap_or_default())
|
||||
.map(|(_, t)| t.translation)
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
|
||||
let platform = ActivePlatform {
|
||||
start: transform.translation,
|
||||
target,
|
||||
};
|
||||
|
||||
commands.entity(e).insert(platform);
|
||||
}
|
||||
}
|
||||
|
||||
fn move_active(time: Res<Time>, mut platforms: Query<(&mut Transform, &mut ActivePlatform)>) {
|
||||
for (mut transform, active) in platforms.iter_mut() {
|
||||
let t = (sin(time.elapsed_secs() * 0.4) + 1.) / 2.;
|
||||
|
||||
transform.translation = active.start.lerp(active.target, t);
|
||||
}
|
||||
}
|
||||
216
crates/shared/src/player.rs
Normal file
216
crates/shared/src/player.rs
Normal file
@@ -0,0 +1,216 @@
|
||||
use crate::{
|
||||
GameState,
|
||||
camera::{CameraArmRotation, CameraTarget},
|
||||
cash::{Cash, CashCollectEvent},
|
||||
character::AnimatedCharacter,
|
||||
control::controller_common::CharacterControllerBundle,
|
||||
global_observer,
|
||||
head::ActiveHead,
|
||||
head_drop::HeadDrops,
|
||||
heads::{ActiveHeads, HeadChanged, HeadState},
|
||||
heads_database::{HeadControls, HeadsDatabase},
|
||||
hitpoints::{Hitpoints, Kill},
|
||||
loading_assets::AudioAssets,
|
||||
npc::SpawnCharacter,
|
||||
physics_layers::GameLayer,
|
||||
sounds::PlaySound,
|
||||
tb_entities::SpawnPoint,
|
||||
};
|
||||
use avian3d::prelude::*;
|
||||
use bevy::{
|
||||
input::common_conditions::input_just_pressed,
|
||||
prelude::*,
|
||||
window::{CursorGrabMode, PrimaryWindow},
|
||||
};
|
||||
|
||||
#[derive(Component, Default)]
|
||||
pub struct Player;
|
||||
|
||||
#[derive(Component, Default)]
|
||||
#[require(Transform, Visibility)]
|
||||
pub struct PlayerBodyMesh;
|
||||
|
||||
pub fn plugin(app: &mut App) {
|
||||
app.add_systems(Startup, (toggle_cursor_system, cursor_recenter));
|
||||
app.add_systems(OnEnter(GameState::Playing), spawn);
|
||||
app.add_systems(
|
||||
Update,
|
||||
(
|
||||
collect_cash,
|
||||
setup_animations_marker_for_player,
|
||||
toggle_cursor_system.run_if(input_just_pressed(KeyCode::Escape)),
|
||||
)
|
||||
.run_if(in_state(GameState::Playing)),
|
||||
);
|
||||
|
||||
global_observer!(app, on_update_head_mesh);
|
||||
}
|
||||
|
||||
fn spawn(
|
||||
mut commands: Commands,
|
||||
asset_server: Res<AssetServer>,
|
||||
query: Query<&Transform, With<SpawnPoint>>,
|
||||
heads_db: Res<HeadsDatabase>,
|
||||
) {
|
||||
let Some(spawn) = query.iter().next() else {
|
||||
return;
|
||||
};
|
||||
|
||||
let transform = Transform::from_translation(spawn.translation + Vec3::new(0., 3., 0.));
|
||||
|
||||
let collider = Collider::capsule(0.9, 1.2);
|
||||
|
||||
commands
|
||||
.spawn((
|
||||
Name::from("player"),
|
||||
Player,
|
||||
ActiveHead(0),
|
||||
ActiveHeads::new([
|
||||
Some(HeadState::new(0, heads_db.as_ref())),
|
||||
Some(HeadState::new(3, heads_db.as_ref())),
|
||||
Some(HeadState::new(6, heads_db.as_ref())),
|
||||
Some(HeadState::new(10, heads_db.as_ref())),
|
||||
Some(HeadState::new(9, heads_db.as_ref())),
|
||||
]),
|
||||
Hitpoints::new(100),
|
||||
CameraTarget,
|
||||
transform,
|
||||
Visibility::default(),
|
||||
CollisionLayers::new(
|
||||
LayerMask(GameLayer::Player.to_bits()),
|
||||
LayerMask::ALL & !GameLayer::CollectiblePhysics.to_bits(),
|
||||
),
|
||||
CharacterControllerBundle::new(collider, heads_db.head_stats(0).controls),
|
||||
children![(
|
||||
Name::new("player-rig"),
|
||||
PlayerBodyMesh,
|
||||
CameraArmRotation,
|
||||
children![AnimatedCharacter::new(0)]
|
||||
)],
|
||||
))
|
||||
.observe(on_kill);
|
||||
|
||||
commands.spawn((
|
||||
AudioPlayer::new(asset_server.load("sfx/heads/angry demonstrator.ogg")),
|
||||
PlaybackSettings::DESPAWN,
|
||||
));
|
||||
|
||||
commands.trigger(SpawnCharacter(transform.translation));
|
||||
}
|
||||
|
||||
fn on_kill(
|
||||
trigger: Trigger<Kill>,
|
||||
mut commands: Commands,
|
||||
mut query: Query<(&Transform, &ActiveHead, &mut ActiveHeads, &mut Hitpoints)>,
|
||||
) {
|
||||
let Ok((transform, active, mut heads, mut hp)) = query.get_mut(trigger.target()) else {
|
||||
return;
|
||||
};
|
||||
|
||||
commands.trigger(HeadDrops::new(transform.translation, active.0));
|
||||
|
||||
if let Some(new_head) = heads.loose_current() {
|
||||
hp.set_health(heads.current().unwrap().health);
|
||||
|
||||
commands.trigger(HeadChanged(new_head));
|
||||
}
|
||||
}
|
||||
|
||||
fn cursor_recenter(q_windows: Single<&mut Window, With<PrimaryWindow>>) {
|
||||
let mut primary_window = q_windows;
|
||||
let center = Vec2::new(
|
||||
primary_window.resolution.width() / 2.,
|
||||
primary_window.resolution.height() / 2.,
|
||||
);
|
||||
primary_window.set_cursor_position(Some(center));
|
||||
}
|
||||
|
||||
fn toggle_grab_cursor(window: &mut Window) {
|
||||
match window.cursor_options.grab_mode {
|
||||
CursorGrabMode::None => {
|
||||
window.cursor_options.grab_mode = CursorGrabMode::Confined;
|
||||
window.cursor_options.visible = false;
|
||||
}
|
||||
_ => {
|
||||
window.cursor_options.grab_mode = CursorGrabMode::None;
|
||||
window.cursor_options.visible = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn toggle_cursor_system(mut window: Single<&mut Window, With<PrimaryWindow>>) {
|
||||
toggle_grab_cursor(&mut window);
|
||||
}
|
||||
|
||||
fn collect_cash(
|
||||
mut commands: Commands,
|
||||
mut collision_event_reader: EventReader<CollisionStarted>,
|
||||
query_player: Query<&Player>,
|
||||
query_cash: Query<&Cash>,
|
||||
) {
|
||||
for CollisionStarted(e1, e2) in collision_event_reader.read() {
|
||||
let collect = if query_player.contains(*e1) && query_cash.contains(*e2) {
|
||||
Some(*e2)
|
||||
} else if query_player.contains(*e2) && query_cash.contains(*e1) {
|
||||
Some(*e1)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
if let Some(cash) = collect {
|
||||
commands.trigger(CashCollectEvent);
|
||||
commands.entity(cash).despawn();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn setup_animations_marker_for_player(
|
||||
mut commands: Commands,
|
||||
animation_handles: Query<Entity, Added<AnimationGraphHandle>>,
|
||||
child_of: Query<&ChildOf>,
|
||||
player_rig: Query<&ChildOf, With<PlayerBodyMesh>>,
|
||||
) {
|
||||
for animation_rig in animation_handles.iter() {
|
||||
for ancestor in child_of.iter_ancestors(animation_rig) {
|
||||
if let Ok(rig_child_of) = player_rig.get(ancestor) {
|
||||
commands.entity(rig_child_of.parent());
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn on_update_head_mesh(
|
||||
trigger: Trigger<HeadChanged>,
|
||||
mut commands: Commands,
|
||||
body_mesh: Single<Entity, With<PlayerBodyMesh>>,
|
||||
mut player: Single<&mut ActiveHead, With<Player>>,
|
||||
head_db: Res<HeadsDatabase>,
|
||||
audio_assets: Res<AudioAssets>,
|
||||
) {
|
||||
let body_mesh = *body_mesh;
|
||||
|
||||
player.0 = trigger.0;
|
||||
|
||||
let head_str = head_db.head_key(trigger.0);
|
||||
|
||||
commands.trigger(PlaySound::Head(head_str.to_string()));
|
||||
|
||||
commands.entity(body_mesh).despawn_related::<Children>();
|
||||
|
||||
commands
|
||||
.entity(body_mesh)
|
||||
.with_child(AnimatedCharacter::new(trigger.0));
|
||||
|
||||
//TODO: make part of full character mesh later
|
||||
if head_db.head_stats(trigger.0).controls == HeadControls::Plane {
|
||||
commands.entity(body_mesh).with_child((
|
||||
Name::new("sfx"),
|
||||
AudioPlayer::new(audio_assets.jet.clone()),
|
||||
PlaybackSettings {
|
||||
mode: bevy::audio::PlaybackMode::Loop,
|
||||
..Default::default()
|
||||
},
|
||||
));
|
||||
}
|
||||
}
|
||||
90
crates/shared/src/sounds.rs
Normal file
90
crates/shared/src/sounds.rs
Normal file
@@ -0,0 +1,90 @@
|
||||
use crate::{global_observer, loading_assets::AudioAssets};
|
||||
use bevy::prelude::*;
|
||||
|
||||
#[derive(Event, Clone, Debug)]
|
||||
pub enum PlaySound {
|
||||
Hit,
|
||||
KeyCollect,
|
||||
Gun,
|
||||
Throw,
|
||||
ThrowHit,
|
||||
Gate,
|
||||
CashCollect,
|
||||
HeadCollect,
|
||||
SecretHeadCollect,
|
||||
HeadDrop,
|
||||
Selection,
|
||||
Invalid,
|
||||
MissileExplosion,
|
||||
Reloaded,
|
||||
CashHeal,
|
||||
Crossbow,
|
||||
Beaming,
|
||||
Backpack { open: bool },
|
||||
Head(String),
|
||||
}
|
||||
|
||||
pub fn plugin(app: &mut App) {
|
||||
global_observer!(app, on_spawn_sounds);
|
||||
}
|
||||
|
||||
fn on_spawn_sounds(
|
||||
trigger: Trigger<PlaySound>,
|
||||
mut commands: Commands,
|
||||
// sound_res: Res<AudioAssets>,
|
||||
// settings: SettingsRead,
|
||||
assets: Res<AudioAssets>,
|
||||
) {
|
||||
let event = trigger.event();
|
||||
|
||||
// if !settings.is_sound_on() {
|
||||
// continue;
|
||||
// }
|
||||
|
||||
let source = match event {
|
||||
PlaySound::Hit => {
|
||||
let version = rand::random::<u8>() % 3;
|
||||
assets.hit[version as usize].clone()
|
||||
}
|
||||
PlaySound::KeyCollect => assets.key_collect.clone(),
|
||||
PlaySound::Gun => assets.gun.clone(),
|
||||
PlaySound::Crossbow => assets.crossbow.clone(),
|
||||
PlaySound::Gate => assets.gate.clone(),
|
||||
PlaySound::CashCollect => assets.cash_collect.clone(),
|
||||
PlaySound::Selection => assets.selection.clone(),
|
||||
PlaySound::Throw => assets.throw.clone(),
|
||||
PlaySound::ThrowHit => assets.throw_explosion.clone(),
|
||||
PlaySound::Reloaded => assets.reloaded.clone(),
|
||||
PlaySound::Invalid => assets.invalid.clone(),
|
||||
PlaySound::CashHeal => assets.cash_heal.clone(),
|
||||
PlaySound::HeadDrop => assets.head_drop.clone(),
|
||||
PlaySound::HeadCollect => assets.head_collect.clone(),
|
||||
PlaySound::SecretHeadCollect => assets.secret_head_collect.clone(),
|
||||
PlaySound::MissileExplosion => assets.missile_explosion.clone(),
|
||||
PlaySound::Beaming => assets.beaming.clone(),
|
||||
PlaySound::Backpack { open } => {
|
||||
if *open {
|
||||
assets.backpack_open.clone()
|
||||
} else {
|
||||
assets.backpack_close.clone()
|
||||
}
|
||||
}
|
||||
PlaySound::Head(name) => {
|
||||
let filename = format!("{name}.ogg");
|
||||
assets
|
||||
.head
|
||||
.get(filename.as_str())
|
||||
.unwrap_or_else(|| panic!("invalid head '{filename}'"))
|
||||
.clone()
|
||||
}
|
||||
};
|
||||
|
||||
commands.spawn((
|
||||
Name::new("sfx"),
|
||||
AudioPlayer::new(source),
|
||||
PlaybackSettings {
|
||||
mode: bevy::audio::PlaybackMode::Despawn,
|
||||
..Default::default()
|
||||
},
|
||||
));
|
||||
}
|
||||
205
crates/shared/src/tb_entities.rs
Normal file
205
crates/shared/src/tb_entities.rs
Normal file
@@ -0,0 +1,205 @@
|
||||
use crate::{cash::Cash, loading_assets::GameAssets, physics_layers::GameLayer};
|
||||
use avian3d::prelude::*;
|
||||
use bevy::{
|
||||
ecs::{component::HookContext, world::DeferredWorld},
|
||||
math::*,
|
||||
prelude::*,
|
||||
};
|
||||
use bevy_trenchbroom::prelude::*;
|
||||
use happy_feet::prelude::PhysicsMover;
|
||||
use std::f32::consts::PI;
|
||||
|
||||
#[derive(PointClass, Component, Reflect, Default)]
|
||||
#[reflect(QuakeClass, Component)]
|
||||
#[base(Transform)]
|
||||
#[component(on_add = Self::on_add)]
|
||||
#[model({ "path": "models/spawn.glb" })]
|
||||
pub struct SpawnPoint {}
|
||||
|
||||
impl SpawnPoint {
|
||||
fn on_add(mut world: DeferredWorld, HookContext { entity, .. }: HookContext) {
|
||||
let Some(assets) = world.get_resource::<GameAssets>() else {
|
||||
return;
|
||||
};
|
||||
|
||||
let mesh = assets.mesh_spawn.clone();
|
||||
|
||||
world.commands().entity(entity).insert((
|
||||
Name::new("spawn"),
|
||||
SceneRoot(mesh),
|
||||
RigidBody::Static,
|
||||
ColliderConstructorHierarchy::new(ColliderConstructor::ConvexHullFromMesh),
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(SolidClass, Component, Reflect, Default)]
|
||||
#[reflect(QuakeClass, Component)]
|
||||
#[spawn_hooks(SpawnHooks::new().convex_collider())]
|
||||
pub struct Worldspawn;
|
||||
|
||||
#[derive(SolidClass, Component, Reflect, Default)]
|
||||
#[reflect(QuakeClass, Component)]
|
||||
#[spawn_hooks(SpawnHooks::new())]
|
||||
#[base(Transform)]
|
||||
pub struct Water;
|
||||
|
||||
#[derive(SolidClass, Component, Reflect, Default)]
|
||||
#[reflect(QuakeClass, Component)]
|
||||
#[base(Transform)]
|
||||
#[spawn_hooks(SpawnHooks::new().convex_collider())]
|
||||
pub struct Crates;
|
||||
|
||||
#[derive(SolidClass, Component, Reflect, Default)]
|
||||
#[reflect(QuakeClass, Component)]
|
||||
#[base(Transform)]
|
||||
#[spawn_hooks(SpawnHooks::new().convex_collider())]
|
||||
pub struct NamedEntity {
|
||||
pub name: String,
|
||||
}
|
||||
|
||||
#[derive(SolidClass, Component, Reflect, Default)]
|
||||
#[reflect(QuakeClass, Component)]
|
||||
#[base(Transform, Target)]
|
||||
#[spawn_hooks(SpawnHooks::new().convex_collider())]
|
||||
#[require(PhysicsMover = PhysicsMover, TransformInterpolation)]
|
||||
pub struct Platform;
|
||||
|
||||
#[derive(PointClass, Component, Reflect, Default)]
|
||||
#[reflect(QuakeClass, Component)]
|
||||
#[base(Transform)]
|
||||
pub struct PlatformTarget {
|
||||
pub targetname: String,
|
||||
}
|
||||
|
||||
#[derive(SolidClass, Component, Reflect, Default)]
|
||||
#[reflect(QuakeClass, Component)]
|
||||
#[base(Transform, Target)]
|
||||
#[spawn_hooks(SpawnHooks::new().convex_collider())]
|
||||
pub struct Movable {
|
||||
pub name: String,
|
||||
}
|
||||
|
||||
#[derive(PointClass, Component, Reflect, Default)]
|
||||
#[reflect(QuakeClass, Component)]
|
||||
#[base(Transform)]
|
||||
pub struct MoveTarget {
|
||||
pub targetname: String,
|
||||
}
|
||||
|
||||
#[derive(PointClass, Component, Reflect, Default)]
|
||||
#[reflect(QuakeClass, Component)]
|
||||
#[base(Transform)]
|
||||
pub struct CameraTarget {
|
||||
pub targetname: String,
|
||||
}
|
||||
|
||||
#[derive(PointClass, Component, Reflect, Default)]
|
||||
#[reflect(QuakeClass, Component)]
|
||||
#[base(Transform, Target)]
|
||||
pub struct CutsceneCamera {
|
||||
pub name: String,
|
||||
pub targetname: String,
|
||||
}
|
||||
|
||||
#[derive(PointClass, Component, Reflect, Default)]
|
||||
#[reflect(QuakeClass, Component)]
|
||||
#[base(Transform, Target)]
|
||||
pub struct CutsceneCameraMovementEnd;
|
||||
|
||||
#[derive(PointClass, Component, Reflect, Default)]
|
||||
#[reflect(QuakeClass, Component)]
|
||||
#[base(Transform)]
|
||||
#[component(on_add = Self::on_add)]
|
||||
#[model({ "path": "models/alien_naked.glb" })]
|
||||
pub struct EnemySpawn {
|
||||
pub head: String,
|
||||
pub key: String,
|
||||
pub disable_ai: bool,
|
||||
pub spawn_order: Option<u32>,
|
||||
}
|
||||
|
||||
impl EnemySpawn {
|
||||
fn on_add(mut world: DeferredWorld, HookContext { entity, .. }: HookContext) {
|
||||
//TODO: figure out why this crashes if removed
|
||||
let Some(_assets) = world.get_resource::<GameAssets>() else {
|
||||
return;
|
||||
};
|
||||
|
||||
let this = world.get_entity(entity).unwrap().get::<Self>().unwrap();
|
||||
let this_transform = world
|
||||
.get_entity(entity)
|
||||
.unwrap()
|
||||
.get::<Transform>()
|
||||
.unwrap();
|
||||
|
||||
let mut this_transform = *this_transform;
|
||||
this_transform.translation += Vec3::new(0., 1.5, 0.);
|
||||
this_transform.rotate_y(PI);
|
||||
|
||||
let head = this.head.clone();
|
||||
|
||||
world.commands().entity(entity).insert((
|
||||
this_transform,
|
||||
Name::from(format!("enemy [{head}]")),
|
||||
Visibility::default(),
|
||||
RigidBody::Dynamic,
|
||||
Collider::capsule(0.6, 2.),
|
||||
CollisionLayers::new(LayerMask(GameLayer::Npc.to_bits()), LayerMask::ALL),
|
||||
LockedAxes::new().lock_rotation_z().lock_rotation_x(),
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(PointClass, Component, Reflect, Default)]
|
||||
#[reflect(QuakeClass, Component)]
|
||||
#[base(Transform)]
|
||||
#[component(on_add = Self::on_add)]
|
||||
#[model({ "path": "models/cash.glb" })]
|
||||
pub struct CashSpawn {}
|
||||
|
||||
impl CashSpawn {
|
||||
fn on_add(mut world: DeferredWorld, HookContext { entity, .. }: HookContext) {
|
||||
let Some(assets) = world.get_resource::<GameAssets>() else {
|
||||
return;
|
||||
};
|
||||
|
||||
let mesh = assets.mesh_cash.clone();
|
||||
|
||||
world.commands().entity(entity).insert((
|
||||
Name::new("cash"),
|
||||
SceneRoot(mesh),
|
||||
Cash,
|
||||
Collider::cuboid(2., 3.0, 2.),
|
||||
CollisionLayers::new(GameLayer::CollectibleSensors, LayerMask::ALL),
|
||||
CollisionEventsEnabled,
|
||||
Sensor,
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(PointClass, Component, Reflect, Default)]
|
||||
#[reflect(QuakeClass, Component)]
|
||||
#[base(Transform)]
|
||||
#[model({ "path": "models/head_drop.glb" })]
|
||||
pub struct SecretHead {
|
||||
pub head_id: usize,
|
||||
}
|
||||
|
||||
pub fn plugin(app: &mut App) {
|
||||
app.register_type::<SpawnPoint>();
|
||||
app.register_type::<Worldspawn>();
|
||||
app.register_type::<Water>();
|
||||
app.register_type::<Crates>();
|
||||
app.register_type::<NamedEntity>();
|
||||
app.register_type::<Platform>();
|
||||
app.register_type::<PlatformTarget>();
|
||||
app.register_type::<Movable>();
|
||||
app.register_type::<MoveTarget>();
|
||||
app.register_type::<CameraTarget>();
|
||||
app.register_type::<CutsceneCamera>();
|
||||
app.register_type::<CutsceneCameraMovementEnd>();
|
||||
app.register_type::<EnemySpawn>();
|
||||
app.register_type::<CashSpawn>();
|
||||
app.register_type::<SecretHead>();
|
||||
}
|
||||
17
crates/shared/src/utils/auto_rotate.rs
Normal file
17
crates/shared/src/utils/auto_rotate.rs
Normal file
@@ -0,0 +1,17 @@
|
||||
use bevy::prelude::*;
|
||||
|
||||
#[derive(Component, Reflect)]
|
||||
#[reflect(Component)]
|
||||
pub struct AutoRotation(pub Quat);
|
||||
|
||||
pub fn plugin(app: &mut App) {
|
||||
app.register_type::<AutoRotation>();
|
||||
|
||||
app.add_systems(FixedUpdate, update_auto_rotation);
|
||||
}
|
||||
|
||||
fn update_auto_rotation(mut query: Query<(&AutoRotation, &mut Transform)>) {
|
||||
for (auto_rotation, mut transform) in query.iter_mut() {
|
||||
transform.rotate_local(auto_rotation.0);
|
||||
}
|
||||
}
|
||||
74
crates/shared/src/utils/billboards.rs
Normal file
74
crates/shared/src/utils/billboards.rs
Normal file
@@ -0,0 +1,74 @@
|
||||
use crate::camera::MainCamera;
|
||||
use bevy::prelude::*;
|
||||
use bevy_sprite3d::Sprite3dPlugin;
|
||||
|
||||
#[derive(Component, Reflect, Default, PartialEq, Eq)]
|
||||
#[reflect(Component)]
|
||||
pub enum Billboard {
|
||||
#[default]
|
||||
All,
|
||||
XZ,
|
||||
}
|
||||
|
||||
pub fn plugin(app: &mut App) {
|
||||
if !app.is_plugin_added::<Sprite3dPlugin>() {
|
||||
app.add_plugins(Sprite3dPlugin);
|
||||
}
|
||||
|
||||
app.register_type::<Billboard>();
|
||||
app.add_systems(Update, (face_camera, face_camera_no_parent));
|
||||
}
|
||||
|
||||
fn face_camera(
|
||||
cam_query: Query<&GlobalTransform, With<MainCamera>>,
|
||||
mut query: Query<
|
||||
(&mut Transform, &ChildOf, &InheritedVisibility, &Billboard),
|
||||
Without<MainCamera>,
|
||||
>,
|
||||
parent_transform: Query<&GlobalTransform>,
|
||||
) {
|
||||
let Ok(cam_transform) = cam_query.single() else {
|
||||
return;
|
||||
};
|
||||
|
||||
for (mut transform, parent, visible, billboard) in query.iter_mut() {
|
||||
if !matches!(*visible, InheritedVisibility::VISIBLE) {
|
||||
continue;
|
||||
}
|
||||
|
||||
let Ok(parent_global) = parent_transform.get(parent.parent()) else {
|
||||
continue;
|
||||
};
|
||||
|
||||
let target = cam_transform.reparented_to(parent_global);
|
||||
|
||||
let target = match *billboard {
|
||||
Billboard::All => target.translation,
|
||||
Billboard::XZ => Vec3::new(
|
||||
target.translation.x,
|
||||
transform.translation.y,
|
||||
target.translation.z,
|
||||
),
|
||||
};
|
||||
|
||||
transform.look_at(target, Vec3::Y);
|
||||
}
|
||||
}
|
||||
|
||||
fn face_camera_no_parent(
|
||||
cam_query: Query<&GlobalTransform, With<MainCamera>>,
|
||||
mut query: Query<(&mut Transform, &Billboard), (Without<MainCamera>, Without<ChildOf>)>,
|
||||
) {
|
||||
let Ok(cam_transform) = cam_query.single() else {
|
||||
return;
|
||||
};
|
||||
for (mut transform, billboard) in query.iter_mut() {
|
||||
let target = cam_transform.translation();
|
||||
let target = match *billboard {
|
||||
Billboard::All => cam_transform.translation(),
|
||||
Billboard::XZ => Vec3::new(target.x, transform.translation.y, target.z),
|
||||
};
|
||||
|
||||
transform.look_at(target, Vec3::Y);
|
||||
}
|
||||
}
|
||||
40
crates/shared/src/utils/explosions.rs
Normal file
40
crates/shared/src/utils/explosions.rs
Normal file
@@ -0,0 +1,40 @@
|
||||
use crate::{global_observer, hitpoints::Hit, physics_layers::GameLayer};
|
||||
use avian3d::prelude::*;
|
||||
use bevy::prelude::*;
|
||||
|
||||
#[derive(Event, Debug)]
|
||||
pub struct Explosion {
|
||||
pub position: Vec3,
|
||||
pub radius: f32,
|
||||
pub damage: u32,
|
||||
}
|
||||
|
||||
pub fn plugin(app: &mut App) {
|
||||
global_observer!(app, on_explosion);
|
||||
}
|
||||
|
||||
fn on_explosion(
|
||||
explosion: Trigger<Explosion>,
|
||||
mut commands: Commands,
|
||||
spatial_query: SpatialQuery,
|
||||
) {
|
||||
let explosion = explosion.event();
|
||||
let intersections = {
|
||||
spatial_query.shape_intersections(
|
||||
&Collider::sphere(explosion.radius),
|
||||
explosion.position,
|
||||
Quat::default(),
|
||||
&SpatialQueryFilter::default().with_mask(LayerMask(
|
||||
GameLayer::Npc.to_bits() | GameLayer::Player.to_bits(),
|
||||
)),
|
||||
)
|
||||
};
|
||||
|
||||
for entity in intersections.iter() {
|
||||
if let Ok(mut e) = commands.get_entity(*entity) {
|
||||
e.trigger(Hit {
|
||||
damage: explosion.damage,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
9
crates/shared/src/utils/mod.rs
Normal file
9
crates/shared/src/utils/mod.rs
Normal file
@@ -0,0 +1,9 @@
|
||||
pub mod auto_rotate;
|
||||
pub mod billboards;
|
||||
pub mod explosions;
|
||||
pub mod observers;
|
||||
pub mod sprite_3d_animation;
|
||||
pub mod squish_animation;
|
||||
pub mod trail;
|
||||
|
||||
pub(crate) use observers::global_observer;
|
||||
35
crates/shared/src/utils/observers.rs
Normal file
35
crates/shared/src/utils/observers.rs
Normal file
@@ -0,0 +1,35 @@
|
||||
use bevy::prelude::*;
|
||||
|
||||
#[macro_export]
|
||||
macro_rules! global_observer {
|
||||
($app:expr,$system:expr) => {{
|
||||
$app.world_mut()
|
||||
.add_observer($system)
|
||||
.insert(Name::new(stringify!($system)))
|
||||
}};
|
||||
}
|
||||
|
||||
pub use global_observer;
|
||||
|
||||
pub fn plugin(app: &mut App) {
|
||||
app.add_systems(Update, global_observers);
|
||||
}
|
||||
|
||||
fn global_observers(
|
||||
mut cmds: Commands,
|
||||
query: Query<Entity, (With<Observer>, Without<Children>, Added<Observer>)>,
|
||||
mut root: Local<Option<Entity>>,
|
||||
) {
|
||||
if root.is_none() {
|
||||
let new_root = cmds.spawn(Name::new("Observers")).id();
|
||||
*root = Some(new_root);
|
||||
}
|
||||
|
||||
let Some(root) = *root else {
|
||||
return;
|
||||
};
|
||||
|
||||
for o in query.iter() {
|
||||
cmds.entity(root).add_child(o);
|
||||
}
|
||||
}
|
||||
36
crates/shared/src/utils/sprite_3d_animation.rs
Normal file
36
crates/shared/src/utils/sprite_3d_animation.rs
Normal file
@@ -0,0 +1,36 @@
|
||||
use bevy::prelude::*;
|
||||
use bevy_sprite3d::Sprite3d;
|
||||
|
||||
#[derive(Component, Reflect, Deref, DerefMut)]
|
||||
#[reflect(Component)]
|
||||
pub struct AnimationTimer(Timer);
|
||||
|
||||
impl AnimationTimer {
|
||||
pub fn new(t: Timer) -> Self {
|
||||
Self(t)
|
||||
}
|
||||
}
|
||||
|
||||
pub fn plugin(app: &mut App) {
|
||||
app.add_systems(Update, animate_sprite);
|
||||
}
|
||||
|
||||
fn animate_sprite(
|
||||
mut commands: Commands,
|
||||
time: Res<Time>,
|
||||
mut query: Query<(Entity, &mut AnimationTimer, &mut Sprite3d)>,
|
||||
) {
|
||||
for (e, mut timer, mut sprite_3d) in query.iter_mut() {
|
||||
timer.tick(time.delta());
|
||||
if timer.just_finished() {
|
||||
let length = sprite_3d.texture_atlas_keys.as_ref().unwrap().len();
|
||||
let atlas = sprite_3d.texture_atlas.as_mut().unwrap();
|
||||
|
||||
if atlas.index < length - 1 {
|
||||
atlas.index = atlas.index.saturating_add(1) % length;
|
||||
} else {
|
||||
commands.entity(e).despawn();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
20
crates/shared/src/utils/squish_animation.rs
Normal file
20
crates/shared/src/utils/squish_animation.rs
Normal file
@@ -0,0 +1,20 @@
|
||||
use bevy::prelude::*;
|
||||
use ops::sin;
|
||||
|
||||
#[derive(Component, Reflect)]
|
||||
#[reflect(Component)]
|
||||
pub struct SquishAnimation(pub f32);
|
||||
|
||||
pub fn plugin(app: &mut App) {
|
||||
app.add_systems(Update, update);
|
||||
}
|
||||
|
||||
fn update(mut query: Query<(&mut Transform, &SquishAnimation)>, time: Res<Time>) {
|
||||
for (mut transform, keymesh) in query.iter_mut() {
|
||||
transform.scale = Vec3::new(
|
||||
keymesh.0,
|
||||
keymesh.0 + (sin(time.elapsed_secs() * 6.) * 0.2),
|
||||
keymesh.0,
|
||||
);
|
||||
}
|
||||
}
|
||||
70
crates/shared/src/utils/trail.rs
Normal file
70
crates/shared/src/utils/trail.rs
Normal file
@@ -0,0 +1,70 @@
|
||||
use crate::GameState;
|
||||
use bevy::prelude::*;
|
||||
|
||||
#[derive(Component)]
|
||||
pub struct Trail {
|
||||
points: Vec<Vec3>,
|
||||
col_start: LinearRgba,
|
||||
col_end: LinearRgba,
|
||||
}
|
||||
|
||||
impl Trail {
|
||||
pub fn new(points: usize, col_start: LinearRgba, col_end: LinearRgba) -> Self {
|
||||
Self {
|
||||
points: Vec::with_capacity(points),
|
||||
col_start,
|
||||
col_end,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn with_pos(self, pos: Vec3) -> Self {
|
||||
let mut trail = self;
|
||||
trail.add(pos);
|
||||
trail
|
||||
}
|
||||
|
||||
pub fn add(&mut self, pos: Vec3) {
|
||||
if self.points.len() >= self.points.capacity() {
|
||||
self.points.pop();
|
||||
}
|
||||
self.points.insert(0, pos);
|
||||
}
|
||||
}
|
||||
|
||||
pub fn plugin(app: &mut App) {
|
||||
app.add_systems(
|
||||
FixedUpdate,
|
||||
update_trail.run_if(in_state(GameState::Playing)),
|
||||
);
|
||||
}
|
||||
|
||||
fn update_trail(
|
||||
mut query: Query<(Entity, &mut Trail, &Gizmo, &GlobalTransform)>,
|
||||
global_transform: Query<&GlobalTransform>,
|
||||
mut gizmo_assets: ResMut<Assets<GizmoAsset>>,
|
||||
) -> Result {
|
||||
for (e, mut trail, gizmo, pos) in query.iter_mut() {
|
||||
trail.add(pos.translation());
|
||||
|
||||
let parent_transform = global_transform.get(e)?;
|
||||
|
||||
let Some(gizmo) = gizmo_assets.get_mut(gizmo.handle.id()) else {
|
||||
continue;
|
||||
};
|
||||
|
||||
gizmo.clear();
|
||||
|
||||
let lerp_denom = trail.points.len() as f32;
|
||||
|
||||
gizmo.linestrip_gradient(trail.points.iter().enumerate().map(|(i, pos)| {
|
||||
(
|
||||
GlobalTransform::from_translation(*pos)
|
||||
.reparented_to(parent_transform)
|
||||
.translation,
|
||||
trail.col_start.mix(&trail.col_end, i as f32 / lerp_denom),
|
||||
)
|
||||
}));
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
96
crates/shared/src/water.rs
Normal file
96
crates/shared/src/water.rs
Normal file
@@ -0,0 +1,96 @@
|
||||
use crate::{
|
||||
GameState, control::controller_common::MovementSpeedFactor, global_observer, player::Player,
|
||||
tb_entities::Water,
|
||||
};
|
||||
use avian3d::prelude::*;
|
||||
use bevy::prelude::*;
|
||||
|
||||
#[derive(Component, Reflect)]
|
||||
#[reflect(Component)]
|
||||
struct WaterSensor;
|
||||
|
||||
#[derive(Event)]
|
||||
struct PlayerInWater {
|
||||
player: Entity,
|
||||
entered: bool,
|
||||
}
|
||||
|
||||
pub fn plugin(app: &mut App) {
|
||||
app.add_systems(OnEnter(GameState::Playing), setup);
|
||||
app.add_systems(
|
||||
Update,
|
||||
check_water_collision.run_if(in_state(GameState::Playing)),
|
||||
);
|
||||
global_observer!(app, on_player_water);
|
||||
}
|
||||
|
||||
fn setup(mut commands: Commands, query: Query<(Entity, &Children), With<Water>>) {
|
||||
for (e, c) in query.iter() {
|
||||
assert!(c.len() == 1);
|
||||
let child = c.iter().next().unwrap();
|
||||
|
||||
commands.entity(e).insert((
|
||||
Sensor,
|
||||
CollisionEventsEnabled,
|
||||
ColliderConstructorHierarchy::new(ColliderConstructor::ConvexHullFromMesh),
|
||||
));
|
||||
|
||||
// TODO: Figure out why water requires a `Sensor` or else the character will stand *on* it
|
||||
// rather than *in* it
|
||||
commands.entity(child).insert((WaterSensor, Sensor));
|
||||
}
|
||||
}
|
||||
|
||||
fn check_water_collision(
|
||||
mut cmds: Commands,
|
||||
mut collisionstart_events: EventReader<CollisionStarted>,
|
||||
mut collisionend_events: EventReader<CollisionEnded>,
|
||||
query_player: Query<&Player>,
|
||||
query_water: Query<(Entity, &WaterSensor)>,
|
||||
) {
|
||||
let start_events = collisionstart_events
|
||||
.read()
|
||||
.map(|CollisionStarted(e1, e2)| (true, *e1, *e2));
|
||||
let end_events = collisionend_events
|
||||
.read()
|
||||
.map(|CollisionEnded(e1, e2)| (false, *e1, *e2));
|
||||
|
||||
for (started, e1, e2) in start_events.chain(end_events) {
|
||||
let entities = [e1, e2];
|
||||
|
||||
let player = entities
|
||||
.iter()
|
||||
.find(|e| query_player.contains(**e))
|
||||
.copied();
|
||||
let water = entities.iter().find(|e| query_water.contains(**e)).copied();
|
||||
|
||||
if !(player.is_some() && water.is_some()) {
|
||||
continue;
|
||||
}
|
||||
|
||||
let Some(player) = player else {
|
||||
continue;
|
||||
};
|
||||
|
||||
cmds.trigger(PlayerInWater {
|
||||
player,
|
||||
entered: started,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
fn on_player_water(
|
||||
trigger: Trigger<PlayerInWater>,
|
||||
//TODO: use a sparse set component `InWater` that we attach to the player
|
||||
// then we can have a movement factor system that reacts on these components to update the factor
|
||||
// PLUS we can then always adhoc check if a player is `InWater` to play an according sound and such
|
||||
mut query: Query<&mut MovementSpeedFactor, With<Player>>,
|
||||
) {
|
||||
let player = trigger.player;
|
||||
|
||||
let Ok(mut factor) = query.get_mut(player) else {
|
||||
return;
|
||||
};
|
||||
|
||||
factor.0 = if trigger.entered { 0.5 } else { 1. };
|
||||
}
|
||||
Reference in New Issue
Block a user