Split crate into shared logic library and binary crate (#52)

This commit is contained in:
PROMETHIA-27
2025-06-28 16:53:40 -04:00
committed by GitHub
parent 5d00cede94
commit b93c0e4d96
64 changed files with 514 additions and 104 deletions

View 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();
}
}

View 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)),
));
}
}

View 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)),
));
}
}

View 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;
}
}
}
}

View 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)),
));
}
}

View 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);
}
}
}

View 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
View 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,
)));
}
}
}

View 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);
}

View 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
}

View 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;
}
}

View 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,
);
}
}
}

View 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;
}
}
}

View 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());
}
}

View 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
View 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
View 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()
},
));
}

View 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
}
);
}
}

View 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.),
);
}
}

View 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,
},
};

View 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);
}
}

View 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)
}
}

View 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);
}
}
}
}
}

View 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;
}
}
}

View 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;
}
}
}

View File

@@ -0,0 +1,39 @@
use bevy::prelude::*;
use bevy_debug_log::LogViewerVisibility;
pub const GIT_HASH: &str = env!("VERGEN_GIT_SHA");
pub fn plugin(app: &mut App) {
app.add_systems(Update, update);
app.add_systems(Startup, setup);
}
fn update(mut commands: Commands, keyboard: Res<ButtonInput<KeyCode>>, gamepads: Query<&Gamepad>) {
if keyboard.just_pressed(KeyCode::Backquote) {
commands.trigger(LogViewerVisibility::Toggle);
}
for g in gamepads.iter() {
if g.just_pressed(GamepadButton::North) {
commands.trigger(LogViewerVisibility::Toggle);
}
}
}
fn setup(mut commands: Commands) {
commands.spawn((
Name::new("githash-ui"),
Text::new(GIT_HASH),
TextFont {
font_size: 12.0,
..default()
},
TextLayout::new_with_justify(JustifyText::Left),
Node {
position_type: PositionType::Absolute,
top: Val::Px(5.0),
left: Val::Px(5.0),
..default()
},
));
}

View 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);
}
}
}

View File

@@ -0,0 +1,4 @@
use bevy::prelude::*;
#[derive(Component, Debug)]
pub struct ActiveHead(pub usize);

View 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();
}
}

View 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()));
}
}
}

View 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,
));
}

View 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();
// }
// }

View 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));
}
}

View 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
View 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();
}
}

52
crates/shared/src/lib.rs Normal file
View File

@@ -0,0 +1,52 @@
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 debug;
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,
}

View 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 });
}

View 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()
},
));
}

View 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
View 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();
}
}
}

View 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,
}

View 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
View 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()
},
));
}
}

View 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!("{}.ogg", name);
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()
},
));
}

View 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>();
}

View 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);
}
}

View 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);
}
}

View 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,
});
}
}
}

View 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;

View 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);
}
}

View 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();
}
}
}
}

View 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,
);
}
}

View 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(())
}

View 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. };
}