Crate unification (#88)

* move client/server/config into shared

* move platforms into shared

* move head drops into shared

* move tb_entities to shared

* reduce server to just a call into shared

* get solo play working

* fix server opening window

* fix fmt

* extracted a few more modules from client

* near completely migrated client

* fixed duplicate CharacterInputEnabled definition

* simplify a few things related to builds

* more simplifications

* fix warnings/check

* ci update

* address comments

* try fixing macos steam build

* address comments

* address comments

* CI tweaks with default client feature

---------

Co-authored-by: PROMETHIA-27 <electriccobras@gmail.com>
This commit is contained in:
extrawurst
2025-12-18 18:31:22 +01:00
committed by GitHub
parent c80129dac1
commit 7cfae285ed
100 changed files with 1099 additions and 1791 deletions

View File

@@ -0,0 +1,52 @@
[package]
name = "hedz_reloaded"
version = "0.1.0"
edition = "2024"
build = "build.rs"
[[bin]]
name = "hedz_reloaded_server"
[features]
default = ["client"]
client = [
"bevy/bevy_audio",
"bevy/bevy_window",
# depend on `winit`
"bevy/bevy_winit",
"bevy/x11",
"bevy/custom_cursor",
"bevy_replicon/client",
"bevy_replicon_renet/client",
"bevy_trenchbroom/client",
]
dbg = ["avian3d/debug-plugin", "dep:bevy-inspector-egui"]
[dependencies]
avian3d = { workspace = true }
bevy = { workspace = true }
bevy-inspector-egui = { workspace = true, optional = true }
bevy-steamworks = { workspace = true }
bevy_asset_loader = { workspace = true }
bevy_ballistic = { workspace = true }
bevy_common_assets = { workspace = true }
bevy_debug_log = { workspace = true }
bevy_replicon = { workspace = true }
bevy_replicon_renet = { workspace = true }
bevy_sprite3d = { workspace = true }
bevy_trenchbroom = { workspace = true }
bevy_trenchbroom_avian = { workspace = true }
clap = { workspace = true }
happy_feet = { workspace = true }
nil = { workspace = true }
rand = { workspace = true }
ron = { workspace = true }
serde = { workspace = true }
steamworks = { workspace = true }
[build-dependencies]
vergen-gitcl = "1.0"
[lints.clippy]
too_many_arguments = "allow"
type_complexity = "allow"

View File

@@ -0,0 +1,17 @@
use vergen_gitcl::{Emitter, GitclBuilder};
fn main() {
let gitcl = GitclBuilder::default()
.branch(true)
.sha(true)
.build()
.unwrap();
Emitter::default()
.add_instructions(&gitcl)
.unwrap()
.emit()
.unwrap();
#[cfg(target_os = "linux")]
println!("cargo:rustc-link-arg=-Wl,-rpath,$ORIGIN");
}

View File

@@ -0,0 +1,122 @@
use super::TriggerArrow;
use crate::{
GameState, billboards::Billboard, global_observer, heads_database::HeadsDatabase,
hitpoints::Hit, loading_assets::GameAssets, physics_layers::GameLayer,
utils::sprite_3d_animation::AnimationTimer,
};
use avian3d::prelude::*;
use bevy::{light::NotShadowCaster, prelude::*};
use bevy_sprite3d::Sprite3d;
#[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>, asset_server: Res<AssetServer>) {
let layout = TextureAtlasLayout::from_grid(UVec2::splat(256), 7, 6, None, None);
let layout = asset_server.add(layout);
commands.insert_resource(ShotAssets {
image: assets.impact_atlas.clone(),
layout,
});
}
fn on_trigger_arrow(
trigger: On<TriggerArrow>,
mut commands: Commands,
query_transform: Query<&Transform>,
heads_db: Res<HeadsDatabase>,
) {
let state = trigger.0;
#[cfg(feature = "client")]
commands.trigger(crate::protocol::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()
};
let mut transform = Transform::from_translation(state.pos).with_rotation(rotation);
transform.translation += transform.forward().as_vec3() * 2.;
let damage = heads_db.head_stats(state.head).damage;
commands.spawn((
Name::new("projectile-arrow"),
ArrowProjectile { damage },
transform,
));
}
fn update(
mut cmds: Commands,
query: Query<(Entity, &Transform, &ArrowProjectile)>,
spatial_query: SpatialQuery,
assets: Res<ShotAssets>,
) {
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.trigger(Hit {
damage: arrow.damage,
entity: first_hit.entity,
});
cmds.spawn((
Sprite3d {
pixels_per_metre: 128.,
alpha_mode: AlphaMode::Blend,
unlit: true,
..default()
},
Sprite {
image: assets.image.clone(),
texture_atlas: Some(TextureAtlas {
layout: assets.layout.clone(),
index: 0,
}),
..default()
},
))
.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,206 @@
use crate::{
GameState,
abilities::{BuildExplosionSprite, ProjectileId, TriggerCurver},
heads_database::HeadsDatabase,
hitpoints::Hit,
physics_layers::GameLayer,
tb_entities::EnemySpawn,
utils::global_observer,
};
use avian3d::prelude::*;
use bevy::prelude::*;
use bevy_replicon::prelude::{Replicated, SendMode, ServerTriggerExt, ToClients};
use serde::{Deserialize, Serialize};
const MAX_SHOT_AGES: f32 = 15.;
#[derive(Component, Reflect, Deserialize, Serialize)]
#[reflect(Component)]
pub struct CurverProjectile {
time: f32,
damage: u32,
projectile: String,
}
pub fn plugin(app: &mut App) {
app.register_type::<CurverProjectile>();
app.add_systems(
Update,
(shot_collision, enemy_hit).run_if(in_state(GameState::Playing)),
);
#[cfg(feature = "client")]
app.add_systems(Update, shot_visuals.run_if(in_state(GameState::Playing)));
app.add_systems(
FixedUpdate,
(update, timeout).run_if(in_state(GameState::Playing)),
);
global_observer!(app, on_trigger_curver);
}
#[cfg(feature = "client")]
fn shot_visuals(
mut commands: Commands,
query: Query<(Entity, &CurverProjectile), Added<CurverProjectile>>,
) {
for (entity, projectile) in query.iter() {
if commands.get_entity(entity).is_ok() {
let child = commands
.spawn((
crate::utils::auto_rotate::AutoRotation(
Quat::from_rotation_x(0.4) * Quat::from_rotation_z(0.3),
),
crate::protocol::GltfSceneRoot::Projectile(projectile.projectile.clone()),
))
.id();
commands.entity(entity).add_child(child);
}
}
}
fn on_trigger_curver(
trigger: On<TriggerCurver>,
mut commands: Commands,
query_transform: Query<&Transform>,
time: Res<Time>,
heads_db: Res<HeadsDatabase>,
) {
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()
};
let head = heads_db.head_stats(state.head);
let mut transform = Transform::from_translation(state.pos).with_rotation(rotation);
transform.translation += transform.forward().as_vec3() * 2.0;
let id = commands
.spawn((
Name::new("projectile-curver"),
CurverProjectile {
time: time.elapsed_secs(),
damage: head.damage,
projectile: head.projectile.clone(),
},
Collider::capsule_endpoints(0.5, Vec3::new(0., 0., 1.), Vec3::new(0., 0., -1.)),
CollisionLayers::new(
LayerMask(GameLayer::Projectile.to_bits()),
LayerMask(state.target_layer.to_bits() | GameLayer::Level.to_bits()),
),
Sensor,
RigidBody::Kinematic,
CollisionEventsEnabled,
Visibility::default(),
transform,
Replicated,
ProjectileId(state.trigger_id),
))
.id();
debug!(id=?id, trigger_id = state.trigger_id, "Curver");
}
fn enemy_hit(
mut commands: Commands,
mut collision_message_reader: MessageReader<CollisionStart>,
query_shot: Query<&CurverProjectile>,
query_npc: Query<&EnemySpawn>,
) {
for CollisionStart {
collider1: e1,
collider2: e2,
..
} in collision_message_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(|entity| Hit { entity, 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_message_reader: MessageReader<CollisionStart>,
query_shot: Query<&Transform, With<CurverProjectile>>,
sensors: Query<(), With<Sensor>>,
) {
for CollisionStart {
collider1: e1,
collider2: e2,
..
} in collision_message_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;
}
commands.server_trigger(ToClients {
message: BuildExplosionSprite {
pos: shot_pos,
pixels_per_meter: 128.,
time: 0.01,
},
mode: SendMode::Broadcast,
});
}
}

View File

@@ -0,0 +1,218 @@
use super::TriggerGun;
use crate::{
GameState, abilities::ProjectileId, billboards::Billboard, global_observer,
heads_database::HeadsDatabase, hitpoints::Hit, loading_assets::GameAssets,
physics_layers::GameLayer, tb_entities::EnemySpawn, utils::sprite_3d_animation::AnimationTimer,
};
use avian3d::prelude::*;
use bevy::{light::NotShadowCaster, prelude::*};
use bevy_replicon::prelude::Replicated;
use bevy_sprite3d::Sprite3d;
#[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>, asset_server: Res<AssetServer>) {
let layout = TextureAtlasLayout::from_grid(UVec2::splat(256), 7, 6, None, None);
let texture_atlas_layout = asset_server.add(layout);
commands.insert_resource(ShotAssets {
image: assets.impact_atlas.clone(),
layout: texture_atlas_layout,
});
}
fn enemy_hit(
mut commands: Commands,
mut collision_message_reader: MessageReader<CollisionStart>,
query_shot: Query<&GunProjectile>,
query_npc: Query<&EnemySpawn>,
heads_db: Res<HeadsDatabase>,
) {
for CollisionStart {
collider1: e1,
collider2: e2,
..
} in collision_message_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.trigger(Hit {
damage,
entity: enemy_entity,
});
}
}
}
fn on_trigger_gun(
trigger: On<TriggerGun>,
mut commands: Commands,
query_transform: Query<&Transform>,
time: Res<Time>,
mut gizmo_assets: ResMut<Assets<GizmoAsset>>,
) {
let state = trigger.0;
#[cfg(feature = "client")]
commands.trigger(crate::protocol::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()
};
let mut transform = Transform::from_translation(state.pos).with_rotation(rotation);
transform.translation += transform.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(),
transform,
Replicated,
ProjectileId(state.trigger_id),
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_message_reader: MessageReader<CollisionStart>,
query_shot: Query<(&GunProjectile, &Transform)>,
sensors: Query<(), With<Sensor>>,
assets: Res<ShotAssets>,
) {
for CollisionStart {
collider1: e1,
collider2: e2,
..
} in collision_message_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((
Sprite3d {
pixels_per_metre: 128.,
alpha_mode: AlphaMode::Blend,
unlit: true,
..default()
},
Sprite {
image: assets.image.clone(),
texture_atlas: Some(texture_atlas),
..default()
},
))
.insert((
Billboard::All,
Transform::from_translation(shot_pos),
NotShadowCaster,
AnimationTimer::new(Timer::from_seconds(0.01, TimerMode::Repeating)),
));
}
}

View File

@@ -0,0 +1,76 @@
use crate::{
GameState, global_observer, heads::ActiveHeads, heads_database::HeadsDatabase,
hitpoints::Hitpoints,
};
use bevy::prelude::*;
use serde::{Deserialize, Serialize};
#[derive(Component, Serialize, Deserialize, PartialEq)]
pub struct Healing;
#[derive(Clone, EntityEvent, Debug, Serialize, Deserialize)]
pub struct HealingStateChanged {
pub entity: Entity,
pub state: HealingState,
}
#[derive(Clone, Debug, Serialize, Deserialize)]
pub enum HealingState {
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: On<HealingStateChanged>,
mut cmds: Commands,
query: Query<&Healing>,
) {
if matches!(trigger.event().state, HealingState::Started) {
if query.contains(trigger.event().entity) {
// already healing, just ignore
return;
}
cmds.entity(trigger.event().entity).insert(Healing);
} else {
if !query.contains(trigger.event().entity) {
// Not healing, just ignore
return;
}
cmds.entity(trigger.event().entity).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,156 @@
use super::TriggerMissile;
use crate::{
GameState,
abilities::{ExplodingProjectile, ExplodingProjectileSet, ProjectileId},
heads_database::HeadsDatabase,
physics_layers::GameLayer,
protocol::{GltfSceneRoot, PlaySound},
utils::{global_observer, trail::SpawnTrail},
};
use avian3d::prelude::*;
use bevy::prelude::*;
use bevy_replicon::prelude::Replicated;
use std::f32::consts::PI;
const MAX_SHOT_AGES: f32 = 15.;
const MISSLE_SPEED: f32 = 3.;
#[derive(Component, Reflect)]
#[reflect(Component)]
struct MissileProjectile {
time: f32,
damage: u32,
}
pub fn plugin(app: &mut App) {
app.add_systems(
FixedUpdate,
shot_collision.in_set(ExplodingProjectileSet::Mark),
);
app.add_systems(
FixedUpdate,
(update, timeout).run_if(in_state(GameState::Playing)),
);
global_observer!(app, on_trigger_missile);
}
fn on_trigger_missile(
trigger: On<TriggerMissile>,
mut commands: Commands,
query_transform: Query<&Transform>,
time: Res<Time>,
heads_db: Res<HeadsDatabase>,
) {
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()
};
let head = heads_db.head_stats(state.head);
let mut transform = Transform::from_translation(state.pos).with_rotation(rotation);
transform.translation += transform.forward().as_vec3() * 2.0;
let id = commands
.spawn((
Name::new("projectile-missile"),
MissileProjectile {
time: time.elapsed_secs(),
damage: head.damage,
},
SpawnTrail::new(
12,
LinearRgba::rgb(1., 0.0, 0.),
LinearRgba::rgb(0.9, 0.9, 0.),
10.,
)
.init_with_pos(),
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,
RigidBody::Kinematic,
CollisionEventsEnabled,
Visibility::default(),
transform,
Replicated,
ProjectileId(state.trigger_id),
children![(
Transform::from_rotation(Quat::from_rotation_x(PI / 2.).inverse()),
GltfSceneRoot::Projectile("missile".to_string()),
Replicated
)],
))
.id();
debug!(id=?id, trigger_id = state.trigger_id, "Missile");
}
fn update(mut query: Query<&mut Transform, With<MissileProjectile>>) {
for mut transform in query.iter_mut() {
let forward = transform.forward();
transform.translation += forward * MISSLE_SPEED;
}
}
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_message_reader: MessageReader<CollisionStart>,
query_shot: Query<(&MissileProjectile, &Transform)>,
sensors: Query<(), With<Sensor>>,
) {
for CollisionStart {
collider1: e1,
collider2: e2,
..
} in collision_message_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.entity(shot_entity).insert(ExplodingProjectile {
sound: PlaySound::MissileExplosion,
damage,
position: shot_pos,
radius: 6.0,
animation: true,
anim_pixels_per_meter: 16.0,
anim_time: 0.01,
});
}
}

View File

@@ -0,0 +1,351 @@
pub mod arrow;
pub mod curver;
pub mod gun;
pub mod healing;
pub mod missile;
pub mod thrown;
use crate::{
GameState,
aim::AimTarget,
character::CharacterHierarchy,
control::Inputs,
global_observer,
heads::ActiveHeads,
heads_database::HeadsDatabase,
loading_assets::GameAssets,
physics_layers::GameLayer,
player::Player,
protocol::PlaySound,
utils::{billboards::Billboard, explosions::Explosion, sprite_3d_animation::AnimationTimer},
};
use bevy::{light::NotShadowCaster, prelude::*};
use bevy_replicon::prelude::{SendMode, ServerTriggerExt, Signature, ToClients};
use bevy_sprite3d::Sprite3d;
pub use healing::Healing;
use serde::{Deserialize, Serialize};
#[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,
pos: Vec3,
target_layer: GameLayer,
head: usize,
trigger_id: usize,
}
impl TriggerData {
pub fn new(
target: Option<Entity>,
dir: Dir3,
pos: Vec3,
target_layer: GameLayer,
head: usize,
trigger_id: usize,
) -> Self {
Self {
target,
dir,
pos,
target_layer,
head,
trigger_id,
}
}
pub fn rot(&self) -> Quat {
// as it turns out, `glam` comes with some `looking_to` functions for left and right handed coordinate systems, but it seems like they're wrong?
// at the very least they give some very odd results and the cross multiplications inside all look backwards compared to what bevy transforms do.
Transform::default().looking_to(self.dir, Vec3::Y).rotation
}
}
#[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(Component, Default, Reflect)]
#[reflect(Component)]
pub struct PlayerTriggerState {
next_trigger_timestamp: f32,
projectile_count: usize,
}
#[derive(Component, Reflect, Deserialize, Serialize, Hash)]
#[reflect(Component)]
#[require(Signature::of::<ProjectileId>())]
pub struct ProjectileId(pub usize);
#[derive(Component)]
#[component(storage = "SparseSet")]
#[allow(dead_code)]
pub struct ExplodingProjectile {
sound: PlaySound,
damage: u32,
position: Vec3,
radius: f32,
animation: bool,
anim_pixels_per_meter: f32,
anim_time: f32,
}
// TODO: move explosions into separate modul
#[derive(SystemSet, Clone, Debug, Hash, PartialEq, Eq, PartialOrd, Ord)]
pub enum ExplodingProjectileSet {
Mark,
Explode,
}
pub fn plugin(app: &mut App) {
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.configure_sets(
FixedUpdate,
ExplodingProjectileSet::Explode
.after(ExplodingProjectileSet::Mark)
.run_if(in_state(GameState::Playing)),
);
app.add_systems(OnEnter(GameState::Playing), setup);
app.add_systems(
FixedUpdate,
(update, update_heal_ability)
.chain()
.run_if(in_state(GameState::Playing)),
);
app.add_systems(
FixedUpdate,
explode_projectiles.in_set(ExplodingProjectileSet::Explode),
);
global_observer!(app, build_explosion_sprite);
}
fn explode_projectiles(mut commands: Commands, query: Query<(Entity, &ExplodingProjectile)>) {
for (shot_entity, projectile) in query.iter() {
debug!(id=?shot_entity, "Projectile explosion");
if let Ok(mut entity) = commands.get_entity(shot_entity) {
entity.try_despawn();
} else {
continue;
}
commands.server_trigger(ToClients {
mode: SendMode::Broadcast,
message: projectile.sound.clone(),
});
commands.trigger(Explosion {
damage: projectile.damage,
position: projectile.position,
//TODO: should be around 1 grid in distance
radius: projectile.radius,
});
//TODO: support different impact animations
if projectile.animation {
commands.server_trigger(ToClients {
mode: SendMode::Broadcast,
message: BuildExplosionSprite {
pos: projectile.position,
pixels_per_meter: projectile.anim_pixels_per_meter,
time: projectile.anim_time,
},
});
}
}
}
fn update(
mut commands: Commands,
mut query: Query<
(
Entity,
&mut ActiveHeads,
&mut PlayerTriggerState,
&AimTarget,
&Inputs,
),
With<Player>,
>,
query_transform: Query<&Transform>,
heads_db: Res<HeadsDatabase>,
time: Res<Time>,
character: CharacterHierarchy,
) {
for (player, mut active_heads, mut trigger_state, target, inputs) in query.iter_mut() {
if inputs.trigger && trigger_state.next_trigger_timestamp < time.elapsed_secs() {
let Some(state) = active_heads.current() else {
return;
};
if !state.has_ammo() {
return;
}
let target = if let Some(target) = target.0
&& query_transform.get(target).is_ok()
{
Some(target)
} else {
None
};
let Some(projectile_origin) = character
.projectile_origin(player)
.map(|origin| origin.translation())
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());
trigger_state.next_trigger_timestamp = time.elapsed_secs() + (1. / head.aps);
trigger_state.projectile_count += 1;
let trigger_state = TriggerData {
dir: Dir3::try_from(inputs.look_dir).unwrap_or(Dir3::NEG_Z),
pos: projectile_origin,
target,
target_layer: GameLayer::Npc,
head: state.head,
trigger_id: trigger_state.projectile_count,
};
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(
mut commands: Commands,
players: Query<(Entity, &ActiveHeads, Ref<Inputs>), With<Player>>,
heads_db: Res<HeadsDatabase>,
) {
for (player, active_heads, inputs) in players.iter() {
if inputs.is_changed() {
let Some(state) = active_heads.current() else {
return;
};
let head = heads_db.head_stats(state.head);
if !matches!(head.ability, HeadAbility::Medic) {
return;
}
use crate::abilities::healing::HealingState;
if inputs.trigger {
use crate::abilities::healing::HealingStateChanged;
commands.trigger(HealingStateChanged {
state: HealingState::Started,
entity: player,
});
} else {
use crate::abilities::healing::HealingStateChanged;
commands.trigger(HealingStateChanged {
state: HealingState::Stopped,
entity: player,
});
}
}
}
}
#[derive(Resource)]
struct ShotAssets {
image: Handle<Image>,
layout: Handle<TextureAtlasLayout>,
}
fn setup(mut commands: Commands, assets: Res<GameAssets>, asset_server: Res<AssetServer>) {
let layout = TextureAtlasLayout::from_grid(UVec2::splat(256), 7, 6, None, None);
let texture_atlas_layout = asset_server.add(layout);
commands.insert_resource(ShotAssets {
image: assets.impact_atlas.clone(),
layout: texture_atlas_layout,
});
}
#[derive(Clone, Copy, Event, Serialize, Deserialize, PartialEq)]
pub struct BuildExplosionSprite {
pos: Vec3,
pixels_per_meter: f32,
time: f32,
}
fn build_explosion_sprite(
trigger: On<BuildExplosionSprite>,
mut commands: Commands,
assets: Res<ShotAssets>,
) {
commands.spawn((
Transform::from_translation(trigger.event().pos),
Sprite3d {
pixels_per_metre: trigger.event().pixels_per_meter,
alpha_mode: AlphaMode::Blend,
unlit: true,
..default()
},
Sprite {
image: assets.image.clone(),
texture_atlas: Some(TextureAtlas {
layout: assets.layout.clone(),
index: 0,
}),
..default()
},
Billboard::All,
NotShadowCaster,
AnimationTimer::new(Timer::from_seconds(
trigger.event().time,
TimerMode::Repeating,
)),
));
}

View File

@@ -0,0 +1,154 @@
use super::TriggerThrow;
use crate::{
GameState,
abilities::{ExplodingProjectile, ExplodingProjectileSet, ProjectileId},
heads_database::HeadsDatabase,
physics_layers::GameLayer,
protocol::PlaySound,
utils::global_observer,
};
use avian3d::prelude::*;
use bevy::prelude::*;
use bevy_ballistic::launch_velocity;
use bevy_replicon::prelude::Replicated;
use serde::{Deserialize, Serialize};
#[derive(Component, Serialize, Deserialize, PartialEq)]
pub struct ThrownProjectile {
impact_animation: bool,
damage: u32,
projectile: String,
}
pub fn plugin(app: &mut App) {
app.add_systems(
FixedUpdate,
shot_collision
.in_set(ExplodingProjectileSet::Mark)
.run_if(in_state(GameState::Playing)),
);
#[cfg(feature = "client")]
app.add_systems(Update, shot_visuals.run_if(in_state(GameState::Playing)));
global_observer!(app, on_trigger_thrown);
}
#[cfg(feature = "client")]
fn shot_visuals(
mut commands: Commands,
query: Query<(Entity, &ThrownProjectile), Added<ThrownProjectile>>,
) {
for (entity, thrown) in query.iter() {
commands.entity(entity).try_insert((
crate::utils::auto_rotate::AutoRotation(
Quat::from_rotation_x(0.4) * Quat::from_rotation_z(0.3),
),
crate::protocol::GltfSceneRoot::Projectile(thrown.projectile.clone()),
));
}
}
fn on_trigger_thrown(
trigger: On<TriggerThrow>,
mut commands: Commands,
query_transform: Query<&Transform>,
heads_db: Res<HeadsDatabase>,
) {
let state = trigger.event().0;
#[cfg(feature = "client")]
commands.trigger(PlaySound::Throw);
const SPEED: f32 = 35.;
let pos = state.pos;
let vel = if let Some(target) = state.target
&& let Ok(t) = query_transform.get(target)
{
launch_velocity(pos, t.translation, SPEED, 9.81)
.map(|(low, _)| low)
.unwrap()
} else {
((state.dir.as_vec3() * 2.0) + Vec3::Y).normalize() * SPEED
};
let head = heads_db.head_stats(state.head);
//TODO: projectile db?
let explosion_animation = !matches!(state.head, 8 | 16);
let id = commands
.spawn((
Transform::from_translation(pos),
Name::new("projectile-thrown"),
ThrownProjectile {
impact_animation: explosion_animation,
damage: head.damage,
projectile: head.projectile.clone(),
},
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,
Replicated,
ProjectileId(state.trigger_id),
))
.id();
debug!(id=?id, trigger_id = state.trigger_id, "Thrown");
}
fn shot_collision(
mut commands: Commands,
mut collision_message_reader: MessageReader<CollisionStart>,
query_shot: Query<(&ThrownProjectile, &Transform)>,
sensors: Query<(), With<Sensor>>,
) {
for CollisionStart {
collider1: e1,
collider2: e2,
..
} in collision_message_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;
};
commands.entity(shot_entity).insert(ExplodingProjectile {
sound: PlaySound::ThrowHit,
damage,
position: shot_pos,
radius: 5.0,
animation,
anim_pixels_per_meter: 32.0,
anim_time: 0.02,
});
}
}

View File

@@ -0,0 +1,177 @@
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 = (agent_transform.translation - target_pos).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());
commands.trigger(TriggerThrow(TriggerData::new(
target.0,
t.forward(),
t.translation,
crate::physics_layers::GameLayer::Player,
npc_head.head,
// TODO: we probably need to make sure the ai's projectile does not get deduped, zero should not be used by anyone though
0,
)));
}
}
}

View File

@@ -0,0 +1,61 @@
use crate::{GameState, global_observer, loading_assets::UIAssets, utils::billboards::Billboard};
use bevy::prelude::*;
use bevy_sprite3d::Sprite3d;
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: On<MarkerEvent>,
mut commands: Commands,
assets: Res<UIAssets>,
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(),
Sprite3d {
pixels_per_metre: 30.,
alpha_mode: AlphaMode::Blend,
unlit: true,
..default()
},
Sprite {
image: assets.head_selector.clone(),
..default()
},
))
.id();
commands.entity(*target).add_child(id);
}

View File

@@ -0,0 +1,199 @@
mod marker;
mod target_ui;
use crate::{
GameState, control::Inputs, head::ActiveHead, heads_database::HeadsDatabase,
hitpoints::Hitpoints, physics_layers::GameLayer, player::Player, tb_entities::EnemySpawn,
};
use avian3d::prelude::*;
use bevy::prelude::*;
use marker::MarkerEvent;
use serde::{Deserialize, Serialize};
use std::f32::consts::PI;
#[derive(Component, Reflect, Default, Deref, PartialEq, Serialize, Deserialize)]
#[reflect(Component)]
pub struct AimTarget(pub Option<Entity>);
#[derive(Component, Reflect, PartialEq, Serialize, Deserialize)]
#[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.register_type::<AimState>();
app.register_type::<AimTarget>();
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>>,
mut player_aim: Query<
(Entity, &AimState, &mut AimTarget, &GlobalTransform, &Inputs),
With<Player>,
>,
spatial_query: SpatialQuery,
) {
for (player, state, mut aim_target, global_tf, inputs) in player_aim.iter_mut() {
let (player_pos, player_forward) = (global_tf.translation(), inputs.look_dir);
let mut new_target = None;
let mut target_distance = f32::MAX;
for (e, t) in potential_targets.iter() {
if e == player {
continue;
}
let delta = t.translation - player_pos;
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
&& 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 = t.translation - pos;
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
&& 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)
&& 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,184 @@
use crate::{
GameState, character::CharacterAnimations, head::ActiveHead, heads_database::HeadsDatabase,
};
use bevy::{animation::RepeatAnimation, ecs::query::QueryData, prelude::*};
use serde::{Deserialize, Serialize};
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, PartialEq, Serialize, Deserialize)]
#[reflect(Component)]
#[require(AnimationFlagCache)]
pub struct AnimationFlags {
pub any_direction: bool,
pub jumping: bool,
pub jump_count: u8,
pub shooting: bool,
pub restart_shooting: bool,
pub hit: bool,
}
#[derive(Component, Default, Reflect)]
#[reflect(Component)]
pub struct AnimationFlagCache {
pub jump_count: u8,
}
#[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 character: Query<(&ActiveHead, &mut AnimationFlags, &mut AnimationFlagCache)>,
headdb: Res<HeadsDatabase>,
) {
for (mut controller, anims) in animated.iter_mut() {
let (head, mut flags, mut cache) = character.get_mut(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.jump_count != cache.jump_count {
controller.play(
anims.jump,
DEFAULT_TRANSITION_DURATION,
RepeatAnimation::Never,
);
cache.jump_count = flags.jump_count;
}
} 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,39 @@
use super::UiHeadState;
use bevy::prelude::*;
pub static BACKPACK_HEAD_SLOTS: usize = 5;
#[derive(Component, Default)]
pub struct BackpackMarker;
#[derive(Component, Default)]
pub struct BackpackCountText;
#[derive(Component, Default)]
pub struct HeadSelector(pub usize);
#[derive(Component, Default)]
pub struct HeadImage(pub usize);
#[derive(Component, Default)]
pub struct HeadDamage(pub usize);
#[derive(Component, Default, Debug, Reflect)]
#[reflect(Component, Default)]
pub struct BackpackUiState {
pub heads: [Option<UiHeadState>; 5],
pub scroll: usize,
pub count: usize,
pub current_slot: usize,
pub open: bool,
}
impl BackpackUiState {
pub fn relative_current_slot(&self) -> usize {
self.current_slot.saturating_sub(self.scroll)
}
}
pub fn plugin(app: &mut App) {
app.register_type::<BackpackUiState>();
}

View File

@@ -0,0 +1,175 @@
use crate::{
cash::CashCollectEvent,
global_observer,
head_drop::HeadCollected,
heads::{ActiveHeads, HeadState},
heads_database::HeadsDatabase,
};
use bevy::prelude::*;
#[cfg(feature = "client")]
use bevy_replicon::prelude::ClientTriggerExt;
use serde::{Deserialize, Serialize};
pub use ui_head_state::UiHeadState;
pub mod backpack_ui;
pub mod ui_head_state;
#[derive(Component, Default, Reflect, Serialize, Deserialize, PartialEq)]
#[reflect(Component)]
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, Serialize, Deserialize)]
pub struct BackpackSwapEvent(pub usize);
pub fn plugin(app: &mut App) {
app.register_type::<Backpack>();
app.add_plugins(backpack_ui::plugin);
#[cfg(feature = "client")]
app.add_systems(FixedUpdate, (backpack_inputs, sync_on_change));
global_observer!(app, on_head_collect);
}
#[cfg(feature = "client")]
#[allow(clippy::too_many_arguments)]
fn backpack_inputs(
backpacks: Single<
(&Backpack, &mut backpack_ui::BackpackUiState),
With<crate::player::LocalPlayer>,
>,
mut backpack_inputs: MessageReader<crate::control::BackpackButtonPress>,
mut commands: Commands,
time: Res<Time>,
) {
use crate::{control::BackpackButtonPress, protocol::PlaySound};
let (backpack, mut state) = backpacks.into_inner();
for input in backpack_inputs.read() {
match input {
BackpackButtonPress::Toggle => {
if state.count == 0 {
return;
}
state.open = !state.open;
commands.trigger(PlaySound::Backpack { open: state.open });
}
BackpackButtonPress::Swap => {
if !state.open {
return;
}
commands.client_trigger(BackpackSwapEvent(state.current_slot));
}
BackpackButtonPress::Left => {
if !state.open {
return;
}
if state.current_slot > 0 {
state.current_slot -= 1;
commands.trigger(PlaySound::Selection);
sync_backpack_ui(backpack, &mut state, time.elapsed_secs());
}
}
BackpackButtonPress::Right => {
if !state.open {
return;
}
if state.current_slot < state.count.saturating_sub(1) {
state.current_slot += 1;
commands.trigger(PlaySound::Selection);
sync_backpack_ui(backpack, &mut state, time.elapsed_secs());
}
}
}
}
}
#[cfg(feature = "client")]
fn sync_on_change(
backpack: Query<Ref<Backpack>>,
mut state: Single<&mut backpack_ui::BackpackUiState>,
time: Res<Time>,
) {
for backpack in backpack.iter() {
if backpack.is_changed() || backpack.reloading() {
sync_backpack_ui(&backpack, &mut state, time.elapsed_secs());
}
}
}
#[cfg(feature = "client")]
fn sync_backpack_ui(backpack: &Backpack, state: &mut backpack_ui::BackpackUiState, time: f32) {
use crate::backpack::backpack_ui::BACKPACK_HEAD_SLOTS;
state.count = backpack.heads.len();
state.scroll = state
.scroll
.min(state.count.saturating_sub(BACKPACK_HEAD_SLOTS));
if state.current_slot >= state.scroll + BACKPACK_HEAD_SLOTS {
state.scroll = state.current_slot.saturating_sub(BACKPACK_HEAD_SLOTS - 1);
}
if state.current_slot < state.scroll {
state.scroll = state.current_slot;
}
for i in 0..BACKPACK_HEAD_SLOTS {
if let Some(head) = backpack.heads.get(i + state.scroll) {
use crate::backpack::ui_head_state::UiHeadState;
state.heads[i] = Some(UiHeadState::new(*head, time));
} else {
state.heads[i] = None;
}
}
}
fn on_head_collect(
trigger: On<HeadCollected>,
mut cmds: Commands,
mut query: Query<(&mut Backpack, &ActiveHeads)>,
heads_db: Res<HeadsDatabase>,
) -> Result {
let HeadCollected { head, entity } = *trigger.event();
let (mut backpack, active_heads) = query.get_mut(entity)?;
if backpack.contains(head) || active_heads.contains(head) {
cmds.trigger(CashCollectEvent);
} else {
backpack.insert(head, heads_db.as_ref());
}
Ok(())
}

View File

@@ -0,0 +1,40 @@
use crate::heads::HeadState;
use bevy::prelude::*;
use serde::{Deserialize, Serialize};
#[derive(Clone, Copy, Debug, PartialEq, Reflect, Default, Serialize, Deserialize)]
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 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,
}
}
}

View File

@@ -0,0 +1,3 @@
pub fn main() {
hedz_reloaded::launch();
}

View File

@@ -0,0 +1,193 @@
use crate::GameState;
#[cfg(feature = "client")]
use crate::control::Inputs;
#[cfg(feature = "client")]
use crate::physics_layers::GameLayer;
#[cfg(feature = "client")]
use crate::player::LocalPlayer;
#[cfg(feature = "client")]
use crate::{control::LookDirMovement, loading_assets::UIAssets};
#[cfg(feature = "client")]
use avian3d::prelude::SpatialQuery;
#[cfg(feature = "client")]
use avian3d::prelude::{
Collider, LayerMask, PhysicsLayer as _, ShapeCastConfig, SpatialQueryFilter,
};
use bevy::prelude::*;
use serde::{Deserialize, Serialize};
#[derive(Component, Reflect, Debug, Serialize, Deserialize, PartialEq)]
pub struct CameraTarget;
#[derive(Component, Reflect, Debug, Serialize, Deserialize, PartialEq)]
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 {
pub enabled: bool,
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 {
enabled: true,
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);
#[cfg(feature = "client")]
app.add_systems(
PostUpdate,
(update, update_ui, update_look_around, rotate_view).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(),
));
}
#[cfg(feature = "client")]
fn update_look_around(
inputs: Single<&Inputs, With<LocalPlayer>>,
mut cam_state: ResMut<CameraState>,
) {
let look_around = inputs.view_mode;
if look_around != cam_state.look_around {
cam_state.look_around = look_around;
}
}
#[cfg(feature = "client")]
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();
}
}
}
}
#[cfg(feature = "client")]
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;
};
if !camera.enabled {
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);
}
#[cfg(feature = "client")]
fn rotate_view(
inputs: Single<&Inputs, With<LocalPlayer>>,
look_dir: Res<LookDirMovement>,
mut cam: Single<&mut CameraRotationInput>,
) {
if !inputs.view_mode {
cam.x = 0.0;
return;
}
cam.0 += look_dir.0 * -0.001;
}

View File

@@ -0,0 +1,91 @@
use crate::{
GameState, HEDZ_GREEN, global_observer, loading_assets::UIAssets, protocol::PlaySound,
server_observer,
};
use avian3d::prelude::Rotation;
use bevy::prelude::*;
use serde::{Deserialize, Serialize};
#[derive(Component, Reflect, Default)]
#[reflect(Component)]
#[require(Transform)]
pub struct Cash;
#[derive(Component, Reflect, Default)]
#[reflect(Component)]
struct CashText;
#[derive(Component, Reflect, Default, Serialize, Deserialize, PartialEq)]
pub struct CashInventory {
pub cash: i32,
}
#[derive(Event)]
pub struct CashCollectEvent;
pub fn plugin(app: &mut App) {
app.add_systems(OnEnter(GameState::Playing), setup);
app.add_systems(
Update,
(rotate, update_ui).run_if(in_state(GameState::Playing)),
);
server_observer!(app, on_cash_collect);
}
fn on_cash_collect(
_trigger: On<CashCollectEvent>,
mut commands: Commands,
mut cash: Single<&mut CashInventory>,
) {
use bevy_replicon::prelude::{SendMode, ServerTriggerExt, ToClients};
commands.server_trigger(ToClients {
mode: SendMode::Broadcast,
message: PlaySound::CashCollect,
});
cash.cash += 100;
}
fn rotate(time: Res<Time>, mut query: Query<&mut Rotation, With<Cash>>) {
for mut rotation in query.iter_mut() {
rotation.0 = rotation
.0
.mul_quat(Quat::from_rotation_y(time.delta_secs()));
}
}
fn update_ui(
cash: Single<&CashInventory, Changed<CashInventory>>,
text: Query<Entity, With<CashText>>,
mut writer: TextUiWriter,
) {
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(HEDZ_GREEN.into()),
TextLayout::new_with_justify(Justify::Center),
Node {
position_type: PositionType::Absolute,
bottom: Val::Px(40.0),
left: Val::Px(100.0),
..default()
},
));
}

View File

@@ -0,0 +1,89 @@
use crate::{
cash::CashInventory,
control::CashHealPressed,
hitpoints::Hitpoints,
player::Player,
protocol::{ClientToController, PlaySound},
};
use bevy::prelude::*;
use bevy_replicon::prelude::{FromClient, SendMode, ServerTriggerExt, ToClients};
pub fn plugin(app: &mut App) {
app.add_systems(FixedUpdate, on_heal_trigger);
}
#[derive(Debug, PartialEq, Eq)]
struct HealAction {
cost: i32,
damage_healed: u32,
}
fn on_heal_trigger(
mut commands: Commands,
controllers: ClientToController,
mut query: Query<(&mut Hitpoints, &mut CashInventory), With<Player>>,
mut inputs: MessageReader<FromClient<CashHealPressed>>,
) {
for press in inputs.read() {
let controller = controllers.get_controller(press.client_id);
let (mut hp, mut cash) = query.get_mut(controller).unwrap();
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
commands.server_trigger(ToClients {
mode: SendMode::Broadcast,
message: 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,245 @@
use crate::{
GameState,
animation::{AnimationController, AnimationFlags},
heads_database::HeadsDatabase,
loading_assets::GameAssets,
utils::trail::SpawnTrail,
};
use bevy::{
animation::RepeatAnimation, ecs::system::SystemParam, platform::collections::HashMap,
prelude::*, scene::SceneInstanceReady,
};
use serde::{Deserialize, Serialize};
use std::{f32::consts::PI, time::Duration};
#[derive(Component, Debug)]
pub struct ProjectileOrigin;
#[derive(Component, Debug, Serialize, Deserialize, PartialEq)]
#[require(Visibility, GlobalTransform)]
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)]
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)]
#[require(AnimationFlags)]
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), Changed<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
error!("Character not found, using default [{}]", key);
&assets.characters["angry demonstrator.glb"]
});
let asset = gltf_assets.get(handle).unwrap();
let mut transform =
Transform::from_translation(Vec3::new(0., -1.45, 0.)).with_scale(Vec3::splat(1.2));
transform.rotate_y(PI);
commands.entity(entity).despawn_related::<Children>();
commands
.spawn((SceneRoot(asset.scenes[0].clone()), ChildOf(entity)))
.observe(find_marker_bones);
commands
.entity(entity)
.insert((transform, AnimatedCharacterAsset(handle.clone())));
}
}
fn find_marker_bones(
trigger: On<SceneInstanceReady>,
mut commands: Commands,
descendants: Query<&Children>,
name: Query<&Name>,
) {
let entity = trigger.event().entity;
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((SpawnTrail::new(
20,
LinearRgba::new(1., 1.0, 1., 0.5),
LinearRgba::new(1., 1., 1., 0.5),
24.,
),));
}
}
if !origin_found {
warn!("ProjectileOrigin not found: {}", entity);
}
}
#[derive(Component, Default, Reflect, PartialEq, Serialize, Deserialize)]
#[reflect(Component)]
pub struct HedzCharacter;
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<HedzCharacter>>,
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<crate::utils::trail::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,209 @@
use crate::{
GameState,
config::NetworkingConfig,
protocol::{
ClientEnteredPlaying, TbMapEntityId, TbMapEntityMapping, messages::DespawnTbMapEntity,
},
tb_entities::{Movable, Platform, PlatformTarget},
};
use avian3d::prelude::{
Collider, ColliderAabb, ColliderDensity, ColliderMarker, ColliderOf, ColliderTransform,
CollisionEventsEnabled, CollisionLayers, Sensor,
};
use bevy::{ecs::bundle::BundleFromComponents, prelude::*, scene::SceneInstance};
use bevy_replicon::{
client::{ClientSystems, confirm_history::ConfirmHistory},
prelude::{ClientState, ClientTriggerExt, RepliconChannels},
};
use bevy_replicon_renet::{
RenetChannelsExt,
netcode::{ClientAuthentication, NetcodeClientTransport, NetcodeError},
renet::{ConnectionConfig, RenetClient},
};
use bevy_trenchbroom::geometry::Brushes;
use std::{
net::{Ipv4Addr, UdpSocket},
time::SystemTime,
};
pub mod backpack;
pub mod control;
pub mod debug;
pub mod enemy;
pub mod heal_effect;
pub mod player;
pub mod setup;
pub mod sounds;
pub mod steam;
pub mod ui;
pub fn plugin(app: &mut App) {
app.add_plugins((
backpack::plugin,
control::plugin,
debug::plugin,
enemy::plugin,
heal_effect::plugin,
player::plugin,
setup::plugin,
sounds::plugin,
steam::plugin,
ui::plugin,
));
app.add_systems(
OnEnter(GameState::Connecting),
connect_to_server.run_if(|config: Res<NetworkingConfig>| config.server.is_some()),
);
app.add_systems(Update, despawn_absent_map_entities);
app.add_systems(
PreUpdate,
(migrate_remote_entities, ApplyDeferred)
.chain()
.after(ClientSystems::Receive),
);
app.add_systems(OnEnter(ClientState::Connected), on_connected_state);
app.add_systems(OnExit(ClientState::Connected), on_disconnect);
}
//
// Client logic
//
fn on_connected_state(mut commands: Commands, mut game_state: ResMut<NextState<GameState>>) {
info!("sent entered playing signal");
commands.client_trigger(ClientEnteredPlaying);
game_state.set(GameState::Playing);
}
fn on_disconnect() {
info!("disconnected from the server");
}
//
// Renet
//
fn connect_to_server(
mut commands: Commands,
config: Res<NetworkingConfig>,
channels: Res<RepliconChannels>,
) -> Result {
let server_channels_config = channels.server_configs();
let client_channels_config = channels.client_configs();
let client = RenetClient::new(ConnectionConfig {
server_channels_config,
client_channels_config,
..Default::default()
});
commands.insert_resource(client);
commands.insert_resource(client_transport(&config)?);
Ok(())
}
fn client_transport(config: &NetworkingConfig) -> Result<NetcodeClientTransport, NetcodeError> {
let current_time = SystemTime::now()
.duration_since(SystemTime::UNIX_EPOCH)
.unwrap();
let client_id = current_time.as_millis() as u64;
let socket = UdpSocket::bind((Ipv4Addr::UNSPECIFIED, 0))?;
let server_addr = config
.server
.flatten()
.unwrap_or_else(|| "127.0.0.1:31111".parse().unwrap());
let authentication = ClientAuthentication::Unsecure {
client_id,
protocol_id: 0,
server_addr,
user_data: None,
};
info!("attempting connection to {server_addr}");
NetcodeClientTransport::new(current_time, authentication, socket)
}
#[allow(clippy::type_complexity)]
fn migrate_remote_entities(
query: Query<(Entity, &TbMapEntityId), (Added<TbMapEntityId>, With<ConfirmHistory>)>,
children: Query<&Children>,
mut commands: Commands,
mut mapping: ResMut<TbMapEntityMapping>,
) {
for (serverside, tb_id) in query.iter() {
received_remote_map_entity(serverside, tb_id.id, &children, &mut mapping, &mut commands);
}
}
fn received_remote_map_entity(
serverside: Entity,
tb_id: u64,
children: &Query<&Children>,
mapping: &mut TbMapEntityMapping,
commands: &mut Commands,
) {
let Some(clientside) = mapping.0.remove(&tb_id) else {
warn!("received unknown MapEntity ID `{tb_id:?}`");
return;
};
// cannot just use `take` directly with a bundle because then any missing component would cause
// the entire bundle to fail
move_component::<Brushes>(commands, clientside, serverside);
move_component::<(
Collider,
ColliderAabb,
ColliderDensity,
ColliderMarker,
CollisionLayers,
)>(commands, clientside, serverside);
move_component::<ColliderOf>(commands, clientside, serverside);
move_component::<ColliderTransform>(commands, clientside, serverside);
move_component::<CollisionEventsEnabled>(commands, clientside, serverside);
move_component::<Movable>(commands, clientside, serverside);
move_component::<Platform>(commands, clientside, serverside);
move_component::<PlatformTarget>(commands, clientside, serverside);
move_component::<SceneInstance>(commands, clientside, serverside);
move_component::<SceneRoot>(commands, clientside, serverside);
move_component::<Sensor>(commands, clientside, serverside);
if let Ok(children) = children.get(clientside) {
for child in children.iter() {
commands.entity(child).insert(ChildOf(serverside));
}
}
commands.entity(clientside).despawn();
}
fn move_component<B: Bundle + BundleFromComponents>(
commands: &mut Commands,
from: Entity,
to: Entity,
) {
commands.queue(move |world: &mut World| {
let comp = world.entity_mut(from).take::<B>();
if let Some(comp) = comp {
world.entity_mut(to).insert(comp);
}
});
}
fn despawn_absent_map_entities(
mut commands: Commands,
mut messages: MessageReader<DespawnTbMapEntity>,
mut map: ResMut<TbMapEntityMapping>,
) {
for msg in messages.read() {
// the server may double-send DespawnTbMapEntity for a given ID, so ignore it if the entity
// was already despawned.
let Some(entity) = map.0.remove(&msg.0) else {
continue;
};
commands.entity(entity).despawn();
}
}

View File

@@ -0,0 +1,212 @@
use crate::{
GameState, HEDZ_GREEN,
backpack::backpack_ui::{
BACKPACK_HEAD_SLOTS, BackpackCountText, BackpackMarker, BackpackUiState, HeadDamage,
HeadImage, HeadSelector,
},
heads::HeadsImages,
loading_assets::UIAssets,
};
use bevy::{ecs::spawn::SpawnIter, prelude::*};
pub fn plugin(app: &mut App) {
app.add_systems(OnEnter(GameState::Playing), setup);
app.add_systems(
FixedUpdate,
(update, update_visibility, update_count).run_if(in_state(GameState::Playing)),
);
}
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..BACKPACK_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(HEDZ_GREEN.into()),
TextLayout::new_with_justify(Justify::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: Single<&BackpackUiState, Changed<BackpackUiState>>,
mut backpack: Single<&mut Visibility, (With<BackpackMarker>, Without<BackpackCountText>)>,
mut count: Single<&mut Visibility, (Without<BackpackMarker>, With<BackpackCountText>)>,
) {
**backpack = if state.open {
Visibility::Visible
} else {
Visibility::Hidden
};
**count = if !state.open {
Visibility::Visible
} else {
Visibility::Hidden
};
}
fn update_count(
state: Single<&BackpackUiState, Changed<BackpackUiState>>,
text: Option<Single<Entity, With<BackpackCountText>>>,
mut writer: TextUiWriter,
) {
let Some(text) = text else {
return;
};
*writer.text(*text, 0) = state.count.to_string();
}
fn update(
state: Single<&BackpackUiState, Changed<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>>,
) {
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
};
}
}

View File

@@ -0,0 +1,7 @@
pub mod backpack_ui;
use bevy::prelude::*;
pub fn plugin(app: &mut App) {
app.add_plugins(backpack_ui::plugin);
}

View File

@@ -0,0 +1,58 @@
use crate::{
GameState,
control::{ControllerSet, Inputs, LookDirMovement},
player::{LocalPlayer, PlayerBodyMesh},
};
use bevy::prelude::*;
use std::f32::consts::PI;
pub fn plugin(app: &mut App) {
app.add_systems(
FixedUpdate,
rotate_rig
.before(crate::control::controller_flying::apply_controls)
.in_set(ControllerSet::ApplyControlsFly)
.run_if(in_state(GameState::Playing)),
);
}
fn rotate_rig(
inputs: Single<&Inputs, With<LocalPlayer>>,
look_dir: Res<LookDirMovement>,
local_player: Single<&Children, With<LocalPlayer>>,
mut player_mesh: Query<&mut Transform, With<PlayerBodyMesh>>,
) {
if inputs.view_mode {
return;
}
local_player.iter().find(|&child| {
if let Ok(mut rig_transform) = player_mesh.get_mut(child) {
let look_dir = look_dir.0;
// 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;
true
} else {
false
}
});
}

View File

@@ -0,0 +1,265 @@
use crate::{
GameState,
client::control::CharacterInputEnabled,
control::{
BackpackButtonPress, CashHealPressed, ClientInputs, ControllerSet, Inputs, LocalInputs,
LookDirMovement, SelectLeftPressed, SelectRightPressed,
},
player::{LocalPlayer, PlayerBodyMesh},
};
use bevy::{
input::{
gamepad::{GamepadConnection, GamepadEvent},
mouse::MouseMotion,
},
prelude::*,
};
use bevy_replicon::client::ClientSystems;
pub fn plugin(app: &mut App) {
app.add_systems(
PreUpdate,
(
gamepad_connections.run_if(on_message::<GamepadEvent>),
reset_lookdir,
keyboard_controls,
gamepad_controls,
mouse_rotate,
get_lookdir,
send_inputs,
)
.chain()
.in_set(ControllerSet::CollectInputs)
.before(ClientSystems::Receive)
.run_if(
in_state(GameState::Playing)
.and(resource_exists_and_equals(CharacterInputEnabled::On)),
),
);
// run this deliberately after local input processing ended
// TODO: can and should be ordered using a set to guarantee it gets send out ASAP but after local input processing
app.add_systems(
PreUpdate,
overwrite_local_inputs.after(ClientSystems::Receive).run_if(
in_state(GameState::Playing).and(resource_exists_and_equals(CharacterInputEnabled::On)),
),
);
app.add_systems(
Update,
reset_control_state_on_disable.run_if(in_state(GameState::Playing)),
);
}
/// Overwrite inputs for this client that were replicated from the server with the local inputs
fn overwrite_local_inputs(
mut inputs: Single<&mut Inputs, With<LocalPlayer>>,
local_inputs: Single<&LocalInputs>,
) {
**inputs = local_inputs.0;
}
/// Write inputs from combined keyboard/gamepad state into the networked input buffer
/// for the local player.
fn send_inputs(mut writer: MessageWriter<ClientInputs>, local_inputs: Single<&LocalInputs>) {
writer.write(ClientInputs(local_inputs.0));
}
fn reset_lookdir(mut look_dir: ResMut<LookDirMovement>) {
look_dir.0 = Vec2::ZERO;
}
/// Reset character inputs to default when character input is disabled.
fn reset_control_state_on_disable(
state: Res<CharacterInputEnabled>,
mut inputs: Single<&mut LocalInputs>,
) {
if state.is_changed() && matches!(*state, CharacterInputEnabled::Off) {
inputs.0 = Inputs {
look_dir: inputs.0.look_dir,
..default()
};
}
}
fn get_lookdir(
mut inputs: Single<&mut LocalInputs>,
rig_transform: Option<Single<&GlobalTransform, With<PlayerBodyMesh>>>,
) {
inputs.0.look_dir = if let Some(ref rig_transform) = rig_transform {
rig_transform.forward().as_vec3()
} else {
Vec3::NEG_Z
};
}
/// 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 },
)
}
/// Collect gamepad inputs
#[allow(clippy::too_many_arguments)]
fn gamepad_controls(
gamepads: Query<&Gamepad>,
mut inputs: Single<&mut LocalInputs>,
mut look_dir: ResMut<LookDirMovement>,
mut backpack_inputs: MessageWriter<BackpackButtonPress>,
mut select_left_pressed: MessageWriter<SelectLeftPressed>,
mut select_right_pressed: MessageWriter<SelectRightPressed>,
mut cash_heal_pressed: MessageWriter<CashHealPressed>,
) {
let deadzone_left_stick = 0.15;
let deadzone_right_stick = 0.15;
for gamepad in gamepads.iter() {
let rotate = gamepad
.get(GamepadButton::RightTrigger2)
.unwrap_or_default();
// 8BitDo Ultimate wireless Controller for PC
look_dir.0 = 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 move_dir = deadzone_square(gamepad.left_stick(), deadzone_left_stick);
inputs.0.move_dir += move_dir.clamp_length_max(1.0);
inputs.0.jump |= gamepad.pressed(GamepadButton::South);
inputs.0.view_mode |= gamepad.pressed(GamepadButton::LeftTrigger2);
inputs.0.trigger |= gamepad.pressed(GamepadButton::RightTrigger2);
if gamepad.just_pressed(GamepadButton::DPadUp) {
backpack_inputs.write(BackpackButtonPress::Toggle);
}
if gamepad.just_pressed(GamepadButton::DPadDown) {
backpack_inputs.write(BackpackButtonPress::Swap);
}
if gamepad.just_pressed(GamepadButton::DPadLeft) {
backpack_inputs.write(BackpackButtonPress::Left);
}
if gamepad.just_pressed(GamepadButton::DPadRight) {
backpack_inputs.write(BackpackButtonPress::Right);
}
if gamepad.just_pressed(GamepadButton::LeftTrigger) {
select_left_pressed.write(SelectLeftPressed);
}
if gamepad.just_pressed(GamepadButton::RightTrigger) {
select_right_pressed.write(SelectRightPressed);
}
if gamepad.just_pressed(GamepadButton::East) {
cash_heal_pressed.write(CashHealPressed);
}
}
}
/// Collect mouse movement input
fn mouse_rotate(mut mouse: MessageReader<MouseMotion>, mut look_dir: ResMut<LookDirMovement>) {
for ev in mouse.read() {
look_dir.0 += ev.delta;
}
}
/// Collect keyboard input
#[allow(clippy::too_many_arguments)]
fn keyboard_controls(
keyboard: Res<ButtonInput<KeyCode>>,
mouse: Res<ButtonInput<MouseButton>>,
mut inputs: Single<&mut LocalInputs>,
mut backpack_inputs: MessageWriter<BackpackButtonPress>,
mut select_left_pressed: MessageWriter<SelectLeftPressed>,
mut select_right_pressed: MessageWriter<SelectRightPressed>,
mut cash_heal_pressed: MessageWriter<CashHealPressed>,
) {
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);
inputs.0.move_dir = direction;
inputs.0.jump = keyboard.pressed(KeyCode::Space);
inputs.0.view_mode = keyboard.pressed(KeyCode::Tab);
inputs.0.trigger = mouse.pressed(MouseButton::Left);
if keyboard.just_pressed(KeyCode::KeyB) {
backpack_inputs.write(BackpackButtonPress::Toggle);
}
if keyboard.just_pressed(KeyCode::Enter) {
backpack_inputs.write(BackpackButtonPress::Swap);
}
if keyboard.just_pressed(KeyCode::Comma) {
backpack_inputs.write(BackpackButtonPress::Left);
}
if keyboard.just_pressed(KeyCode::Period) {
backpack_inputs.write(BackpackButtonPress::Right);
}
if keyboard.just_pressed(KeyCode::KeyQ) {
select_left_pressed.write(SelectLeftPressed);
}
if keyboard.just_pressed(KeyCode::KeyE) {
select_right_pressed.write(SelectRightPressed);
}
if keyboard.just_pressed(KeyCode::Enter) {
cash_heal_pressed.write(CashHealPressed);
}
}
/// Receive gamepad connections and disconnections
fn gamepad_connections(mut evr_gamepad: MessageReader<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,25 @@
use crate::{GameState, control::ControllerSet};
use bevy::prelude::*;
use bevy_replicon::client::ClientSystems;
mod controller_flying;
pub mod controls;
#[derive(Resource, Debug, PartialEq, Eq)]
pub enum CharacterInputEnabled {
On,
Off,
}
pub fn plugin(app: &mut App) {
app.insert_resource(CharacterInputEnabled::On);
app.add_plugins((controller_flying::plugin, controls::plugin));
app.configure_sets(
PreUpdate,
ControllerSet::CollectInputs
.before(ClientSystems::Receive)
.run_if(in_state(GameState::Playing)),
);
}

View File

@@ -0,0 +1,40 @@
use bevy::prelude::*;
use bevy_debug_log::LogViewerVisibility;
// Is supplied by a build script via vergen_gitcl
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(Justify::Left),
Node {
position_type: PositionType::Absolute,
top: Val::Px(5.0),
left: Val::Px(5.0),
..default()
},
));
}

View File

@@ -0,0 +1,14 @@
use crate::{GameState, tb_entities::EnemySpawn};
use bevy::prelude::*;
pub fn plugin(app: &mut App) {
app.add_systems(OnEnter(GameState::Connecting), despawn_enemy_spawns);
}
/// Despawn enemy spawners because only the server will ever spawn enemies with them, and they have a
/// collider.
fn despawn_enemy_spawns(mut commands: Commands, enemy_spawns: Query<Entity, With<EnemySpawn>>) {
for spawner in enemy_spawns.iter() {
commands.entity(spawner).despawn();
}
}

View File

@@ -0,0 +1,153 @@
use crate::{
GameState,
abilities::Healing,
loading_assets::{AudioAssets, GameAssets},
utils::{billboards::Billboard, observers::global_observer},
};
use bevy::prelude::*;
use rand::{Rng, thread_rng};
// Should not be a relationship because lightyear will silently track state for all relationships
// and break if one end of the relationship isn't replicated and is despawned
#[derive(Component)]
struct HasHealingEffects {
effects: Entity,
}
#[derive(Component)]
struct HealingEffectsOf {
of: Entity,
}
#[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)),
);
global_observer!(app, on_removed);
}
fn on_added(
mut commands: Commands,
query: Query<Entity, Added<Healing>>,
assets: Res<AudioAssets>,
) {
for entity in query.iter() {
let effects = commands
.spawn((
Name::new("heal-particle-effect"),
HealParticleEffect::default(),
AudioPlayer::new(assets.healing.clone()),
PlaybackSettings {
mode: bevy::audio::PlaybackMode::Loop,
..Default::default()
},
HealingEffectsOf { of: entity },
))
.id();
commands
.entity(entity)
.insert(HasHealingEffects { effects });
}
}
fn on_removed(
trigger: On<Remove, Healing>,
mut commands: Commands,
effects: Query<&HasHealingEffects>,
) {
let Ok(has_effects) = effects.get(trigger.event().entity) else {
return;
};
commands.entity(has_effects.effects).try_despawn();
commands
.entity(trigger.event().entity)
.remove::<HasHealingEffects>();
}
fn update_effects(
mut commands: Commands,
mut query: Query<(&mut HealParticleEffect, &HealingEffectsOf, Entity)>,
mut transforms: Query<&mut Transform>,
time: Res<Time>,
assets: Res<GameAssets>,
) {
const DISTANCE: f32 = 4.;
let mut rng = thread_rng();
let now = time.elapsed_secs();
for (mut effect, effects_of, e) in query.iter_mut() {
// We have to manually track the healer's position because lightyear will try to synchronize
// children and there's no reason to synchronize the particle effect entity when we're already
// synchronizing `Healing`
// (trying to ignore/avoid it by excluding the child from replication just causes crashes)
let healer_pos = transforms.get(effects_of.of).unwrap().translation;
transforms.get_mut(e).unwrap().translation = healer_pos;
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);
commands.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,93 @@
use crate::{
global_observer,
heads_database::{HeadControls, HeadsDatabase},
loading_assets::AudioAssets,
player::{LocalPlayer, PlayerBodyMesh},
protocol::{ClientHeadChanged, PlaySound, PlayerId, messages::AssignClientPlayer},
};
use bevy::prelude::*;
pub fn plugin(app: &mut App) {
app.init_state::<PlayerAssignmentState>();
app.add_systems(
Update,
receive_player_id.run_if(not(in_state(PlayerAssignmentState::Confirmed))),
);
global_observer!(app, on_client_update_head_mesh);
}
pub fn receive_player_id(
mut commands: Commands,
mut client_assignments: MessageReader<AssignClientPlayer>,
mut next: ResMut<NextState<PlayerAssignmentState>>,
mut local_id: Local<Option<PlayerId>>,
players: Query<(Entity, &PlayerId), Changed<PlayerId>>,
) {
for &AssignClientPlayer(id) in client_assignments.read() {
info!("player id `{}` received", id.id);
*local_id = Some(id);
}
if let Some(local_id) = *local_id {
for (entity, player_id) in players.iter() {
if *player_id == local_id {
commands.entity(entity).insert(LocalPlayer);
next.set(PlayerAssignmentState::Confirmed);
info!(
"player entity {entity:?} confirmed with id `{}`",
player_id.id
);
break;
}
}
}
}
/// Various states while trying to assign and match an ID to the player character.
/// Every client is given an ID (its player index in the match) and every character controller
/// is given an ID matching the client controlling it. This way the client can easily see which
/// controller it owns.
#[derive(Clone, Copy, Debug, Default, Hash, PartialEq, Eq, PartialOrd, States)]
pub enum PlayerAssignmentState {
/// Waiting for the server to send an [`AssignClientPlayer`] message and replicate a [`PlayerId`]
#[default]
Waiting,
/// Matching controller confirmed; a [`LocalPlayer`] exists
Confirmed,
}
fn on_client_update_head_mesh(
trigger: On<ClientHeadChanged>,
mut commands: Commands,
body_mesh: Single<(Entity, &Children), With<PlayerBodyMesh>>,
head_db: Res<HeadsDatabase>,
audio_assets: Res<AudioAssets>,
sfx: Query<&AudioPlayer>,
) -> Result {
let head = trigger.0 as usize;
let (body_mesh, mesh_children) = *body_mesh;
let head_str = head_db.head_key(head);
commands.trigger(PlaySound::Head(head_str.to_string()));
//TODO: make part of full character mesh later
for child in mesh_children.iter().filter(|child| sfx.contains(*child)) {
commands.entity(child).despawn();
}
if head_db.head_stats(head).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()
},
));
}
Ok(())
}

View File

@@ -0,0 +1,90 @@
use crate::{DebugVisuals, GameState, camera::MainCamera, loading_assets::AudioAssets};
use bevy::{
audio::{PlaybackMode, Volume},
core_pipeline::tonemapping::Tonemapping,
prelude::*,
render::view::ColorGrading,
};
use bevy_trenchbroom::TrenchBroomServer;
pub fn plugin(app: &mut App) {
#[cfg(feature = "dbg")]
{
app.add_plugins(bevy_inspector_egui::bevy_egui::EguiPlugin::default());
app.add_plugins(bevy_inspector_egui::quick::WorldInspectorPlugin::new());
app.add_plugins(avian3d::prelude::PhysicsDebugPlugin::default());
}
app.insert_resource(AmbientLight {
color: Color::WHITE,
brightness: 400.,
..Default::default()
});
app.insert_resource(ClearColor(Color::BLACK));
//TODO: let user control this
app.insert_resource(GlobalVolume::new(Volume::Linear(0.4)));
app.add_systems(Startup, write_trenchbroom_config);
app.add_systems(OnEnter(GameState::Playing), music);
app.add_systems(Update, (set_materials_unlit, set_tonemapping, set_shadows));
}
fn music(assets: Res<AudioAssets>, mut commands: Commands) {
commands.spawn((
Name::new("sfx-music"),
AudioPlayer::new(assets.music.clone()),
PlaybackSettings {
mode: PlaybackMode::Loop,
volume: Volume::Linear(0.6),
..default()
},
));
commands.spawn((
Name::new("sfx-ambient"),
AudioPlayer::new(assets.ambient.clone()),
PlaybackSettings {
mode: PlaybackMode::Loop,
volume: Volume::Linear(0.8),
..default()
},
));
}
fn write_trenchbroom_config(server: Res<TrenchBroomServer>, type_registry: Res<AppTypeRegistry>) {
if let Err(e) = server
.config
.write_game_config("trenchbroom/hedz", &type_registry.read())
{
warn!("Failed to write trenchbroom config: {}", e);
}
}
fn set_tonemapping(
mut cams: Query<(&mut Tonemapping, &mut ColorGrading), With<MainCamera>>,
visuals: Res<DebugVisuals>,
) {
for (mut tm, mut color) in cams.iter_mut() {
*tm = visuals.tonemapping;
color.global.exposure = visuals.exposure;
}
}
fn set_materials_unlit(
mut materials: ResMut<Assets<StandardMaterial>>,
visuals: Res<DebugVisuals>,
) {
if !materials.is_changed() {
return;
}
for (_, material) in materials.iter_mut() {
material.unlit = visuals.unlit;
}
}
fn set_shadows(mut lights: Query<&mut DirectionalLight>, visuals: Res<DebugVisuals>) {
for mut l in lights.iter_mut() {
l.shadows_enabled = visuals.shadows;
}
}

View File

@@ -0,0 +1,66 @@
use crate::{global_observer, loading_assets::AudioAssets, protocol::PlaySound};
use bevy::prelude::*;
pub fn plugin(app: &mut App) {
global_observer!(app, on_spawn_sounds);
}
fn on_spawn_sounds(
trigger: On<PlaySound>,
mut commands: Commands,
// settings: SettingsRead,
assets: Res<AudioAssets>,
) {
let event = trigger.event();
// if !settings.is_sound_on() {
// continue;
// }
let source = match event {
PlaySound::Hit => {
let version = rand::random::<u8>() % 3;
assets.hit[version as usize].clone()
}
PlaySound::KeyCollect => assets.key_collect.clone(),
PlaySound::Gun => assets.gun.clone(),
PlaySound::Crossbow => assets.crossbow.clone(),
PlaySound::Gate => assets.gate.clone(),
PlaySound::CashCollect => assets.cash_collect.clone(),
PlaySound::Selection => assets.selection.clone(),
PlaySound::Throw => assets.throw.clone(),
PlaySound::ThrowHit => assets.throw_explosion.clone(),
PlaySound::Reloaded => assets.reloaded.clone(),
PlaySound::Invalid => assets.invalid.clone(),
PlaySound::CashHeal => assets.cash_heal.clone(),
PlaySound::HeadDrop => assets.head_drop.clone(),
PlaySound::HeadCollect => assets.head_collect.clone(),
PlaySound::SecretHeadCollect => assets.secret_head_collect.clone(),
PlaySound::MissileExplosion => assets.missile_explosion.clone(),
PlaySound::Beaming => assets.beaming.clone(),
PlaySound::Backpack { open } => {
if *open {
assets.backpack_open.clone()
} else {
assets.backpack_close.clone()
}
}
PlaySound::Head(name) => {
let filename = format!("{name}.ogg");
assets
.head
.get(filename.as_str())
.unwrap_or_else(|| panic!("invalid head '{filename}'"))
.clone()
}
};
commands.spawn((
Name::new("sfx"),
AudioPlayer::new(source),
PlaybackSettings {
mode: bevy::audio::PlaybackMode::Despawn,
..Default::default()
},
));
}

View File

@@ -0,0 +1,93 @@
use bevy::prelude::*;
use bevy_steamworks::{Client, FriendFlags, SteamworksEvent, SteamworksPlugin};
use std::io::{Read, Write};
pub fn plugin(app: &mut App) {
let app_id = 1603000;
// should only be done in production builds
#[cfg(not(debug_assertions))]
if steamworks::restart_app_if_necessary(app_id.into()) {
info!("Restarting app via steam");
return;
}
info!("steam app init: {app_id}");
match SteamworksPlugin::init_app(app_id) {
Ok(plugin) => {
info!("steam app init done");
app.add_plugins(plugin);
}
Err(e) => {
warn!("steam init error: {e:?}");
}
};
app.add_systems(
Startup,
(test_steam_system, log_steam_events)
.chain()
.run_if(resource_exists::<Client>),
);
}
fn log_steam_events(mut events: MessageReader<SteamworksEvent>) {
for e in events.read() {
info!("steam ev: {:?}", e);
}
}
fn test_steam_system(steam_client: Res<Client>) {
steam_client.matchmaking().request_lobby_list(|list| {
let Ok(list) = list else { return };
info!("lobby list: [{}]", list.len());
for (i, l) in list.iter().enumerate() {
info!("lobby [{i}]: {:?}", l);
}
});
steam_client
.matchmaking()
.create_lobby(
steamworks::LobbyType::FriendsOnly,
4,
|result| match result {
Ok(lobby_id) => {
info!("Created lobby with ID: {:?}", lobby_id);
}
Err(e) => error!("Failed to create lobby: {}", e),
},
);
for friend in steam_client.friends().get_friends(FriendFlags::IMMEDIATE) {
info!(
"Steam Friend: {:?} - {}({:?})",
friend.id(),
friend.name(),
friend.state()
);
}
steam_client
.remote_storage()
.set_cloud_enabled_for_app(true);
let f = steam_client.remote_storage().file("hedz_data.dat");
if f.exists() {
let mut buf = String::new();
if let Err(e) = f.read().read_to_string(&mut buf) {
error!("File read error: {}", e);
} else {
info!("File content: {}", buf);
}
} else {
info!("File does not exist");
if let Err(e) = f.write().write_all(String::from("hello world").as_bytes()) {
error!("steam cloud error: {}", e);
} else {
info!("steam cloud saved");
}
}
}

View File

@@ -0,0 +1,7 @@
mod pause;
use bevy::prelude::*;
pub fn plugin(app: &mut App) {
app.add_plugins(pause::plugin);
}

View File

@@ -0,0 +1,188 @@
use crate::{
GameState, HEDZ_GREEN, HEDZ_PURPLE, client::control::CharacterInputEnabled,
loading_assets::UIAssets,
};
use bevy::{color::palettes::css::BLACK, prelude::*};
#[derive(States, Default, Clone, Eq, PartialEq, Debug, Hash)]
#[states(scoped_entities)]
enum PauseMenuState {
#[default]
Closed,
Open,
}
#[derive(Component, PartialEq, Eq, Clone, Copy)]
enum ProgressBar {
Music,
Sound,
}
#[derive(Resource)]
struct PauseMenuSelection(ProgressBar);
pub fn plugin(app: &mut App) {
app.init_state::<PauseMenuState>();
app.add_systems(Update, open_pause_menu.run_if(in_state(GameState::Playing)));
app.add_systems(
Update,
(selection_input, selection_changed).run_if(in_state(PauseMenuState::Open)),
);
app.add_systems(OnEnter(PauseMenuState::Open), setup);
}
fn open_pause_menu(
state: Res<State<PauseMenuState>>,
mut next_state: ResMut<NextState<PauseMenuState>>,
mut char_controls: ResMut<CharacterInputEnabled>,
keyboard: Res<ButtonInput<KeyCode>>,
) {
if keyboard.just_pressed(KeyCode::Escape) {
let menu_open = match state.get() {
PauseMenuState::Closed => {
next_state.set(PauseMenuState::Open);
true
}
PauseMenuState::Open => {
next_state.set(PauseMenuState::Closed);
false
}
};
if menu_open {
*char_controls = CharacterInputEnabled::Off;
} else {
*char_controls = CharacterInputEnabled::On;
}
}
}
fn setup(mut commands: Commands, assets: Res<UIAssets>) {
commands.spawn((
Name::new("pause-menu"),
Node {
width: Val::Percent(100.0),
height: Val::Percent(100.0),
flex_direction: FlexDirection::Column,
align_items: AlignItems::Center,
justify_content: JustifyContent::Center,
row_gap: Val::Px(10.),
..default()
},
BackgroundColor(Color::linear_rgba(0., 0., 0., 0.6)),
DespawnOnExit(PauseMenuState::Open),
children![
spawn_progress(ProgressBar::Music, 100, assets.font.clone()),
spawn_progress(ProgressBar::Sound, 80, assets.font.clone())
],
));
commands.insert_resource(PauseMenuSelection(ProgressBar::Music));
}
fn spawn_progress(bar: ProgressBar, value: u8, font: Handle<Font>) -> impl Bundle {
(
Node {
width: Val::Px(500.0),
height: Val::Px(60.0),
border: UiRect::all(Val::Px(8.)),
align_items: AlignItems::Center,
row_gap: Val::Px(10.),
..default()
},
BackgroundColor(BLACK.into()),
BorderRadius::all(Val::Px(100.)),
BorderColor::all(HEDZ_PURPLE),
BoxShadow::new(
BLACK.into(),
Val::Px(2.),
Val::Px(2.),
Val::Px(4.),
Val::Px(4.),
),
bar,
children![
(
Node {
width: Val::Percent(100.0),
margin: UiRect::left(Val::Px(10.)),
..default()
},
Text::new(match bar {
ProgressBar::Music => "MUSIC".to_string(),
ProgressBar::Sound => "SOUND".to_string(),
}),
TextFont {
font: font.clone(),
font_size: 16.0,
..Default::default()
},
TextColor(HEDZ_GREEN.into()),
TextLayout::new_with_justify(Justify::Left),
),
(
Node {
margin: UiRect::horizontal(Val::Px(5.)),
..default()
},
Text::new("<".to_string()),
TextFont {
font: font.clone(),
font_size: 16.0,
..Default::default()
},
TextColor(HEDZ_GREEN.into()),
TextLayout::new_with_justify(Justify::Left).with_no_wrap(),
),
(
Text::new(format!("{value}",)),
TextFont {
font: font.clone(),
font_size: 16.0,
..Default::default()
},
TextColor(HEDZ_GREEN.into()),
TextLayout::new_with_justify(Justify::Left).with_no_wrap(),
),
(
Node {
margin: UiRect::horizontal(Val::Px(5.)),
..default()
},
Text::new(">".to_string()),
TextFont {
font,
font_size: 16.0,
..Default::default()
},
TextColor(HEDZ_GREEN.into()),
TextLayout::new_with_justify(Justify::Left).with_no_wrap(),
)
],
)
}
fn selection_input(mut state: ResMut<PauseMenuSelection>, keyboard: Res<ButtonInput<KeyCode>>) {
if keyboard.just_pressed(KeyCode::ArrowUp) || keyboard.just_pressed(KeyCode::ArrowDown) {
state.0 = match state.0 {
ProgressBar::Music => ProgressBar::Sound,
ProgressBar::Sound => ProgressBar::Music,
}
}
}
fn selection_changed(
state: Res<PauseMenuSelection>,
mut query: Query<(&mut BorderColor, &ProgressBar)>,
) {
if state.is_changed() {
for (mut border, bar) in query.iter_mut() {
*border = BorderColor::all(if *bar == state.0 {
HEDZ_GREEN
} else {
HEDZ_PURPLE
})
}
}
}

View File

@@ -0,0 +1,25 @@
use bevy::prelude::*;
use clap::Parser;
use std::net::SocketAddr;
pub fn plugin(app: &mut App) {
let config = NetworkingConfig::parse();
app.insert_resource(config);
}
#[derive(Resource, Parser, Debug)]
#[command(version, about, long_about = None)]
pub struct NetworkingConfig {
/// The IP/port to connect to.
/// If `None`, host a local server.
/// If Some(None), connect to the default server (`127.0.0.1:31111`)
/// Otherwise, connect to the given server.
/// Does nothing on the server.
#[arg(long)]
pub server: Option<Option<SocketAddr>>,
/// Whether or not to open a port when opening the client, for other clients
/// to connect. Does nothing if `server` is set.
#[arg(long)]
pub host: bool,
}

View File

@@ -0,0 +1,245 @@
use super::{ControllerSet, ControllerSwitchEvent};
use crate::{
GameState,
animation::AnimationFlags,
control::{ControllerSettings, Inputs, SelectedController},
physics_layers::GameLayer,
player::{Player, PlayerBodyMesh},
};
use avian3d::{math::*, prelude::*};
use bevy::prelude::*;
use bevy_replicon::client::confirm_history::ConfirmHistory;
use happy_feet::prelude::{
Character, CharacterDrag, CharacterGravity, CharacterMovement, CharacterPlugins,
GroundFriction, Grounding, GroundingConfig, KinematicVelocity, MoveInput, SteppingBehaviour,
SteppingConfig,
};
use serde::{Deserialize, Serialize};
pub fn plugin(app: &mut App) {
app.add_plugins(CharacterPlugins::default());
app.register_type::<MovementSpeedFactor>();
app.add_systems(
FixedPreUpdate,
reset_upon_switch
.after(super::head_change)
.run_if(in_state(GameState::Playing)),
)
.add_systems(
PreUpdate,
set_animation_flags.run_if(in_state(GameState::Playing)),
)
.add_systems(
FixedUpdate,
decelerate
.after(ControllerSet::ApplyControlsRun)
.after(ControllerSet::ApplyControlsFly)
.run_if(in_state(GameState::Playing)),
);
}
fn set_animation_flags(
mut player: Query<
(&Grounding, &mut AnimationFlags, &Inputs),
(With<Player>, Without<ConfirmHistory>),
>,
) {
for (grounding, mut flags, inputs) in player.iter_mut() {
let direction = inputs.move_dir;
let deadzone = 0.2;
if flags.any_direction {
if direction.length_squared() < deadzone {
flags.any_direction = false;
}
} else if direction.length_squared() > deadzone {
flags.any_direction = true;
}
flags.shooting = inputs.trigger;
// `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;
}
}
}
/// 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: MessageReader<ControllerSwitchEvent>,
selected_controller: Res<SelectedController>,
mut rig_transforms: Query<&mut Transform, With<PlayerBodyMesh>>,
mut controllers: Query<(&mut KinematicVelocity, &Children, &Inputs), With<Player>>,
) {
for &ControllerSwitchEvent { controller } in event_controller_switch.read() {
let (mut velocity, children, inputs) = controllers.get_mut(controller).unwrap();
velocity.0 = Vec3::ZERO;
let rig_transform = children
.iter()
.find(|child| rig_transforms.contains(*child))
.unwrap();
let mut rig_transform = rig_transforms.get_mut(rig_transform).unwrap();
// Reset pitch but keep yaw the same
let flat_look_dir = inputs.look_dir.with_y(0.0).normalize();
rig_transform.look_to(flat_look_dir, Dir3::Y);
match *selected_controller {
SelectedController::Flying => {
c.entity(controller).insert(FLYING_MOVEMENT_CONFIG);
}
SelectedController::Running => {
c.entity(controller).insert(RUNNING_MOVEMENT_CONFIG);
}
}
}
}
/// 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, PartialEq, Serialize, Deserialize)]
#[reflect(Component)]
pub struct MovementSpeedFactor(pub f32);
impl Default for MovementSpeedFactor {
fn default() -> Self {
Self(1.0)
}
}
#[derive(Component, Reflect, Default, Debug, Serialize, Deserialize, PartialEq)]
#[reflect(Component)]
#[require(
Character,
RigidBody::Kinematic,
Collider::capsule(0.9, 1.2),
CollisionLayers::new(
LayerMask(GameLayer::Player.to_bits()),
LayerMask::ALL & !GameLayer::CollectiblePhysics.to_bits(),
),
CollisionEventsEnabled,
MoveInput,
MovementSpeedFactor,
TransformInterpolation,
CharacterMovement = RUNNING_MOVEMENT_CONFIG.movement,
ControllerSettings = RUNNING_MOVEMENT_CONFIG.settings,
CharacterGravity = RUNNING_MOVEMENT_CONFIG.gravity,
CharacterDrag = RUNNING_MOVEMENT_CONFIG.drag,
SteppingConfig = RUNNING_MOVEMENT_CONFIG.step,
GroundFriction = RUNNING_MOVEMENT_CONFIG.friction,
GroundingConfig = RUNNING_MOVEMENT_CONFIG.ground,
)]
pub struct PlayerCharacterController;
#[derive(Bundle)]
struct MovementConfig {
movement: CharacterMovement,
step: SteppingConfig,
ground: GroundingConfig,
gravity: CharacterGravity,
friction: GroundFriction,
drag: CharacterDrag,
settings: ControllerSettings,
}
const RUNNING_MOVEMENT_CONFIG: MovementConfig = MovementConfig {
movement: CharacterMovement {
target_speed: 15.0,
acceleration: 40.0,
},
step: SteppingConfig {
max_vertical: 0.25,
max_horizontal: 0.4,
max_angle: Some(PI / 4.0),
behaviour: SteppingBehaviour::Grounded,
max_substeps: 8,
},
ground: GroundingConfig {
max_angle: PI / 4.0,
max_distance: 0.2,
snap_to_surface: true,
up_direction: Dir3::Y,
max_iterations: 2,
override_velocity_projection: true,
},
gravity: CharacterGravity(Some(vec3(0.0, -60.0, 0.0))),
friction: GroundFriction(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: 50.0,
acceleration: 300.0,
},
step: SteppingConfig {
max_vertical: 0.25,
max_horizontal: 0.4,
max_angle: Some(0.0),
behaviour: SteppingBehaviour::Never,
max_substeps: 8,
},
ground: GroundingConfig {
max_angle: 0.0,
max_distance: -1.0,
snap_to_surface: false,
up_direction: Dir3::Y,
max_iterations: 2,
override_velocity_projection: true,
},
gravity: CharacterGravity(Some(Vec3::ZERO)),
friction: GroundFriction(0.0),
drag: CharacterDrag(10.0),
settings: ControllerSettings {
jump_force: 0.0,
deceleration_factor: 0.0,
},
};

View File

@@ -0,0 +1,26 @@
use super::ControllerSet;
use crate::{
GameState,
control::{Inputs, controller_common::MovementSpeedFactor},
};
use bevy::prelude::*;
use happy_feet::prelude::MoveInput;
pub struct CharacterControllerPlugin;
impl Plugin for CharacterControllerPlugin {
fn build(&self, app: &mut App) {
app.add_systems(
FixedUpdate,
apply_controls
.in_set(ControllerSet::ApplyControlsFly)
.run_if(in_state(GameState::Playing)),
);
}
}
pub fn apply_controls(character: Single<(&mut MoveInput, &MovementSpeedFactor, &Inputs)>) {
let (mut char_input, factor, inputs) = character.into_inner();
char_input.set(inputs.look_dir * factor.0);
}

View File

@@ -0,0 +1,88 @@
use crate::{
GameState,
animation::AnimationFlags,
control::{ControllerSet, ControllerSettings, Inputs, controller_common::MovementSpeedFactor},
protocol::is_server,
};
#[cfg(feature = "client")]
use crate::{
control::LookDirMovement,
player::{LocalPlayer, PlayerBodyMesh},
};
use bevy::prelude::*;
use happy_feet::prelude::{Grounding, KinematicVelocity, MoveInput};
pub struct CharacterControllerPlugin;
impl Plugin for CharacterControllerPlugin {
fn build(&self, app: &mut App) {
#[cfg(feature = "client")]
app.add_systems(
Update,
rotate_view
.in_set(ControllerSet::ApplyControlsRun)
.run_if(in_state(GameState::Playing)),
);
app.add_systems(
FixedUpdate,
apply_controls
.in_set(ControllerSet::ApplyControlsRun)
.run_if(in_state(GameState::Playing).and(is_server)),
);
}
}
#[cfg(feature = "client")]
fn rotate_view(
controller: Single<(&Inputs, &Children), With<LocalPlayer>>,
mut player_mesh: Query<&mut Transform, With<PlayerBodyMesh>>,
look_dir: Res<LookDirMovement>,
) {
let (inputs, children) = controller.into_inner();
if inputs.view_mode {
return;
}
children.iter().find(|&child| {
if let Ok(mut body_transform) = player_mesh.get_mut(child) {
body_transform.rotate_y(look_dir.0.x * -0.001);
true
} else {
false
}
});
}
fn apply_controls(
character: Single<(
&mut MoveInput,
&mut Grounding,
&mut KinematicVelocity,
&mut AnimationFlags,
&ControllerSettings,
&MovementSpeedFactor,
&Inputs,
)>,
) {
let (mut move_input, mut grounding, mut velocity, mut flags, settings, move_factor, inputs) =
character.into_inner();
let ground_normal = *grounding.normal().unwrap_or(Dir3::Y);
let mut direction = inputs.move_dir.extend(0.0).xzy();
let look_dir_right = inputs.look_dir.cross(Vec3::Y);
direction = (inputs.look_dir * direction.z) + (look_dir_right * direction.x);
let y_projection = direction.project_onto(ground_normal);
direction -= y_projection;
direction = direction.normalize_or_zero();
move_input.set(direction * move_factor.0);
if inputs.jump && grounding.is_grounded() {
flags.jumping = true;
flags.jump_count += 1;
happy_feet::movement::jump(settings.jump_force, &mut velocity, &mut grounding, Dir3::Y)
}
}

View File

@@ -0,0 +1,176 @@
use crate::{
GameState,
head::ActiveHead,
heads_database::{HeadControls, HeadsDatabase},
player::Player,
protocol::{ClientToController, is_server},
};
use bevy::{ecs::entity::MapEntities, prelude::*};
use bevy_replicon::{client::ClientSystems, prelude::FromClient};
use serde::{Deserialize, Serialize};
pub mod controller_common;
pub mod controller_flying;
pub mod controller_running;
#[derive(SystemSet, Debug, Hash, PartialEq, Eq, Clone)]
pub enum ControllerSet {
CollectInputs,
ApplyControlsFly,
ApplyControlsRun,
}
#[derive(Resource, Debug, Clone, Copy, PartialEq, Default)]
pub enum SelectedController {
Flying,
#[default]
Running,
}
pub fn plugin(app: &mut App) {
app.register_type::<ControllerSettings>()
.register_type::<LookDirMovement>()
.register_type::<Inputs>();
#[cfg(feature = "client")]
app.register_type::<LocalInputs>();
app.init_resource::<LookDirMovement>();
app.init_resource::<SelectedController>();
app.add_message::<ControllerSwitchEvent>()
.add_message::<BackpackButtonPress>();
app.add_plugins(controller_common::plugin);
app.add_plugins(controller_flying::CharacterControllerPlugin);
app.add_plugins(controller_running::CharacterControllerPlugin);
app.configure_sets(
FixedUpdate,
(
ControllerSet::ApplyControlsFly.run_if(resource_equals(SelectedController::Flying)),
ControllerSet::ApplyControlsRun.run_if(resource_equals(SelectedController::Running)),
)
.chain()
.run_if(in_state(GameState::Playing)),
);
app.add_systems(
PreUpdate,
collect_player_inputs
.run_if(is_server.and(in_state(GameState::Playing)))
.after(ClientSystems::Receive),
);
app.add_systems(Update, head_change.run_if(in_state(GameState::Playing)));
}
/// The continuous inputs of a client for a tick. The instant inputs are sent via messages like `BackpackTogglePressed`.
#[derive(Component, Clone, Copy, Debug, Serialize, Deserialize, Reflect)]
#[reflect(Component, Default)]
pub struct Inputs {
/// Movement direction with a maximum length of 1.0
pub move_dir: Vec2,
/// The current direction that the character is facing
/// (i.e. the direction that holding the forward movement key moves)
pub look_dir: Vec3,
pub jump: bool,
/// Determines if the camera can rotate freely around the player
pub view_mode: bool,
pub trigger: bool,
}
impl Default for Inputs {
fn default() -> Self {
Self {
move_dir: Default::default(),
look_dir: Vec3::NEG_Z,
jump: Default::default(),
view_mode: Default::default(),
trigger: Default::default(),
}
}
}
impl MapEntities for Inputs {
fn map_entities<E: EntityMapper>(&mut self, _entity_mapper: &mut E) {}
}
/// A message to tell the server what inputs the client pressed this tick
#[derive(Debug, Clone, Copy, Message, Serialize, Deserialize, Reflect)]
pub struct ClientInputs(pub Inputs);
/// A cache to collect inputs into clientside, so that they don't get overwritten by replication from the server
#[derive(Component, Default, Reflect)]
#[reflect(Component)]
pub struct LocalInputs(pub Inputs);
#[derive(Message, Serialize, Deserialize)]
pub struct SelectLeftPressed;
#[derive(Message, Serialize, Deserialize)]
pub struct SelectRightPressed;
#[derive(Message)]
pub enum BackpackButtonPress {
Toggle,
Swap,
Left,
Right,
}
#[derive(Message, Serialize, Deserialize)]
pub struct CashHealPressed;
#[derive(Resource, Default, Reflect)]
#[reflect(Resource)]
pub struct LookDirMovement(pub Vec2);
#[derive(Component, Clone, PartialEq, Reflect, Serialize, Deserialize)]
#[reflect(Component)]
pub struct ControllerSettings {
pub deceleration_factor: f32,
pub jump_force: f32,
}
#[derive(Message)]
pub struct ControllerSwitchEvent {
controller: Entity,
}
/// Take incoming client input messages and cache them on the corresponding player controller
fn collect_player_inputs(
mut players: Query<&mut Inputs>,
clients: ClientToController,
mut input_messages: MessageReader<FromClient<ClientInputs>>,
) {
for msg in input_messages.read() {
let player = clients.get_controller(msg.client_id);
let Ok(mut inputs) = players.get_mut(player) else {
continue;
};
*inputs = msg.message.0;
}
}
fn head_change(
//TODO: needs a 'LocalPlayer' at some point for multiplayer
query: Query<(Entity, &ActiveHead), (Changed<ActiveHead>, With<Player>)>,
heads_db: Res<HeadsDatabase>,
mut selected_controller: ResMut<SelectedController>,
mut event_controller_switch: MessageWriter<ControllerSwitchEvent>,
) {
for (entity, head) in query.iter() {
let stats = heads_db.head_stats(head.0);
let controller = match stats.controls {
HeadControls::Plane => SelectedController::Flying,
HeadControls::Walk => SelectedController::Running,
};
if *selected_controller != controller {
event_controller_switch.write(ControllerSwitchEvent { controller: entity });
*selected_controller = controller;
}
}
}

View File

@@ -0,0 +1,107 @@
use crate::{
GameState,
camera::{CameraState, MainCamera},
global_observer,
tb_entities::{CameraTarget, CutsceneCamera, CutsceneCameraMovementEnd},
};
use bevy::prelude::*;
use bevy_trenchbroom::prelude::*;
use serde::{Deserialize, Serialize};
#[derive(Clone, Debug, Event, Serialize, Deserialize)]
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: On<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.is_finished() {
cam_state.cutscene = false;
*cutscene_state = CutsceneState::None;
}
}
}

View File

@@ -0,0 +1,49 @@
use crate::{
cutscene::StartCutscene, global_observer, keys::KeyCollected, movables::TriggerMovableEvent,
protocol::PlaySound,
};
use bevy::{platform::collections::HashSet, prelude::*};
use bevy_replicon::prelude::{SendMode, ServerTriggerExt, ToClients};
pub fn plugin(app: &mut App) {
global_observer!(app, on_key_collected);
}
fn on_key_collected(trigger: On<KeyCollected>, mut commands: Commands) {
match trigger.event().0.as_str() {
"fence_gate" => {
commands.server_trigger(ToClients {
mode: SendMode::Broadcast,
message: StartCutscene("fence_01".to_string()),
});
let entities: HashSet<_> = vec!["fence_01", "fence_02"]
.into_iter()
.map(String::from)
.collect();
commands.server_trigger(ToClients {
mode: SendMode::Broadcast,
message: PlaySound::Gate,
});
commands.trigger(TriggerMovableEvent(entities));
}
"fence_shaft" => {
commands.server_trigger(ToClients {
mode: SendMode::Broadcast,
message: StartCutscene("cutscene_02".to_string()),
});
let entities: HashSet<_> = vec!["fence_shaft"].into_iter().map(String::from).collect();
commands.server_trigger(ToClients {
mode: SendMode::Broadcast,
message: PlaySound::Gate,
});
commands.trigger(TriggerMovableEvent(entities));
}
_ => {
error!("unknown key logic: {}", trigger.event().0);
}
}
}

View File

@@ -0,0 +1,5 @@
use bevy::prelude::*;
use serde::{Deserialize, Serialize};
#[derive(Component, Debug, Serialize, Deserialize, PartialEq)]
pub struct ActiveHead(pub usize);

View File

@@ -0,0 +1,208 @@
use crate::{
GameState, global_observer,
heads_database::HeadsDatabase,
physics_layers::GameLayer,
player::Player,
protocol::{GltfSceneRoot, NetworkEnv, PlaySound},
server_observer,
tb_entities::SecretHead,
utils::{
billboards::Billboard, one_shot_force::OneShotImpulse, squish_animation::SquishAnimation,
},
};
use avian3d::prelude::*;
use bevy::{ecs::relationship::RelatedSpawner, prelude::*};
use bevy_replicon::prelude::{Replicated, SendMode, ServerTriggerExt, ToClients};
use std::f32::consts::PI;
#[derive(Event, Reflect)]
pub struct HeadDrops {
pub pos: Vec3,
pub head_id: usize,
pub 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)]
pub struct HeadDrop {
pub head_id: usize,
}
#[derive(Component, Reflect)]
#[reflect(Component)]
pub struct HeadDropEnableTime(pub f32);
#[derive(Component, Reflect)]
#[reflect(Component)]
pub struct SecretHeadMarker;
#[derive(EntityEvent, Reflect)]
pub struct HeadCollected {
pub entity: Entity,
pub head: usize,
}
pub fn plugin(app: &mut App) {
app.register_type::<HeadDrop>();
app.register_type::<HeadDropEnableTime>();
app.register_type::<SecretHeadMarker>();
app.add_systems(
Update,
enable_collectible.run_if(in_state(GameState::Playing)),
);
app.add_systems(OnEnter(GameState::Playing), spawn);
server_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 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_head_drop(
trigger: On<HeadDrops>,
mut commands: Commands,
heads_db: Res<HeadsDatabase>,
time: Res<Time>,
) -> Result<(), BevyError> {
let drop = trigger.event();
let angle = rand::random::<f32>() * PI * 2.;
let spawn_impulse = Quat::from_rotation_y(angle) * Vec3::new(0.5, 0.6, 0.).normalize() * 180.;
let impulse = drop.impulse;
if impulse {
commands.server_trigger(ToClients {
mode: SendMode::Broadcast,
message: PlaySound::HeadDrop,
});
}
let mesh_addr = format!("{:?}", heads_db.head_stats(drop.head_id).ability).to_lowercase();
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, GameLayer::Level),
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),
Replicated,
))
.observe(on_collect_head);
}
})),
Replicated,
))
.insert_if(OneShotImpulse(spawn_impulse), move || impulse)
.with_child((
Billboard::All,
SquishAnimation(2.6),
GltfSceneRoot::HeadDrop(mesh_addr),
Replicated,
));
Ok(())
}
fn on_collect_head(
trigger: On<CollisionStart>,
mut commands: Commands,
query_player: Query<&Player>,
query_collectable: Query<(&HeadDrop, &ChildOf)>,
query_secret: Query<&SecretHeadMarker>,
env: NetworkEnv,
) {
if !env.is_server() {
return;
}
let collectable = trigger.event().collider1;
let collider = trigger.event().collider2;
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.server_trigger(ToClients {
mode: SendMode::Broadcast,
message: PlaySound::SecretHeadCollect,
});
} else {
commands.server_trigger(ToClients {
mode: SendMode::Broadcast,
message: PlaySound::HeadCollect,
});
}
commands.entity(collider).trigger(|entity| HeadCollected {
head: drop.head_id,
entity,
});
commands.entity(child_of.parent()).despawn();
}
}

View File

@@ -0,0 +1,259 @@
use super::{ActiveHeads, HEAD_SLOTS};
#[cfg(feature = "client")]
use crate::heads::HeadsImages;
use crate::{
GameState, backpack::UiHeadState, loading_assets::UIAssets, player::Player, protocol::is_server,
};
use bevy::{ecs::spawn::SpawnIter, prelude::*};
use serde::{Deserialize, Serialize};
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(Component, Default, Reflect, Serialize, Deserialize, PartialEq)]
#[reflect(Component)]
pub 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(
FixedUpdate,
sync.run_if(in_state(GameState::Playing).and(is_server)),
);
#[cfg(feature = "client")]
app.add_systems(
FixedUpdate,
(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,
)
}
}))),
));
}
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.)),
ImageNode::default(),
Visibility::Hidden,
HeadImage(head_slot),
children![(
Node {
width: Val::Percent(100.0),
height: Val::Percent(100.0),
..default()
},
BorderRadius::all(Val::Px(9999.)),
HeadImage(0),
ImageNode {
color: Color::linear_rgba(0.0, 0.0, 0.0, 0.0),
..default()
},
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: UiPosition::CENTER,
color_space: InterpolationColorSpace::Srgba,
}),
)]
),
(
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)]
)]
)
],
)
}
#[cfg(feature = "client")]
fn update(
res: Single<&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>>,
) {
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
};
}
}
#[cfg(feature = "client")]
fn update_ammo(
res: Single<&UiActiveHeads, Changed<UiActiveHeads>>,
heads: Query<&HeadImage>,
mut gradients: Query<(&mut BackgroundGradient, &ChildOf)>,
) {
for (mut gradient, child_of) in gradients.iter_mut() {
let Ok(HeadImage(head)) = heads.get(child_of.parent()) else {
continue;
};
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.0;
gradient.stops[1].angle = Some(angle);
gradient.stops[2].angle = Some(angle);
}
}
}
#[cfg(feature = "client")]
fn update_health(
res: Single<&UiActiveHeads, Changed<UiActiveHeads>>,
mut query: Query<(&mut Node, &HeadDamage)>,
) {
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: Single<&mut 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,332 @@
use crate::{
GameState,
animation::AnimationFlags,
backpack::{Backpack, BackpackSwapEvent},
control::{SelectLeftPressed, SelectRightPressed},
global_observer,
heads_database::HeadsDatabase,
hitpoints::Hitpoints,
player::Player,
protocol::{ClientToController, PlaySound, is_server},
};
use bevy::prelude::*;
use bevy_replicon::prelude::FromClient;
use serde::{Deserialize, Serialize};
pub mod heads_ui;
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, Serialize, Deserialize)]
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, Serialize, Deserialize, PartialEq)]
#[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
&& 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
}
pub fn contains(&self, head: usize) -> bool {
self.heads.iter().any(|h| h.is_some_and(|h| h.head == head))
}
}
#[derive(Event)]
pub struct HeadChanged(pub usize);
pub fn plugin(app: &mut App) {
app.add_plugins(heads_ui::plugin);
app.register_type::<ActiveHeads>();
app.add_systems(OnEnter(GameState::Playing), setup);
app.add_systems(
FixedUpdate,
(
(reload, sync_hp).run_if(in_state(GameState::Playing)),
on_select_active_head,
)
.run_if(is_server),
);
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>,
mut flags: Single<&mut AnimationFlags, With<Player>>,
) {
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?
use bevy_replicon::prelude::{SendMode, ServerTriggerExt, ToClients};
commands.server_trigger(ToClients {
mode: SendMode::Broadcast,
message: PlaySound::Reloaded,
});
flags.restart_shooting = true;
head.ammo = head.ammo_max;
}
}
}
}
fn on_select_active_head(
mut commands: Commands,
mut query: Query<(&mut ActiveHeads, &mut Hitpoints), With<Player>>,
mut select_lefts: MessageReader<FromClient<SelectLeftPressed>>,
mut select_rights: MessageReader<FromClient<SelectRightPressed>>,
controllers: ClientToController,
) {
for press in select_lefts.read() {
use bevy_replicon::prelude::{SendMode, ServerTriggerExt, ToClients};
let player = controllers.get_controller(press.client_id);
let (mut active_heads, mut hp) = query.get_mut(player).unwrap();
active_heads.selected_slot = (active_heads.selected_slot + (HEAD_SLOTS - 1)) % HEAD_SLOTS;
commands.server_trigger(ToClients {
mode: SendMode::Broadcast,
message: 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,
));
}
}
for press in select_rights.read() {
use bevy_replicon::prelude::{SendMode, ServerTriggerExt, ToClients};
let player = controllers.get_controller(press.client_id);
let (mut active_heads, mut hp) = query.get_mut(player).unwrap();
active_heads.selected_slot = (active_heads.selected_slot + 1) % HEAD_SLOTS;
commands.server_trigger(ToClients {
mode: SendMode::Broadcast,
message: 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: On<FromClient<BackpackSwapEvent>>,
mut commands: Commands,
mut query: Query<(&mut ActiveHeads, &mut Hitpoints, &mut Backpack), With<Player>>,
) {
let backpack_slot = trigger.event().0;
let Ok((mut active_heads, mut hp, mut backpack)) = query.single_mut() else {
return;
};
let head = backpack.heads.get(backpack_slot).unwrap();
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,127 @@
use crate::{
GameState,
animation::AnimationFlags,
character::{CharacterAnimations, HasCharacterAnimations},
protocol::{PlaySound, is_server},
};
use bevy::prelude::*;
use bevy_replicon::prelude::{SendMode, ServerTriggerExt, ToClients};
use serde::{Deserialize, Serialize};
#[derive(EntityEvent, Reflect)]
pub struct Kill {
pub entity: Entity,
}
#[derive(EntityEvent, Reflect)]
pub struct Hit {
pub entity: Entity,
pub damage: u32,
}
#[derive(Component, Reflect, Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
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.run_if(is_server))
.add_systems(
PreUpdate,
reset_hit_animation_flag.run_if(is_server.and(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: On<Hit>,
mut commands: Commands,
mut query: Query<(&mut Hitpoints, Option<&mut AnimationFlags>)>,
time: Res<Time>,
) {
let &Hit { damage, entity } = trigger.event();
let Ok((mut hp, flags)) = query.get_mut(trigger.event().entity) else {
return;
};
commands.server_trigger(ToClients {
mode: SendMode::Broadcast,
message: PlaySound::Hit,
});
if let Some(mut flags) = flags {
flags.hit = true;
}
hp.current = hp.current.saturating_sub(damage);
hp.last_hit_timestamp = time.elapsed_secs();
if hp.current == 0 {
commands.trigger(Kill { entity });
}
}
fn reset_hit_animation_flag(
mut query: Query<(&Hitpoints, &HasCharacterAnimations, &mut AnimationFlags)>,
animations: Query<(&AnimationGraphHandle, &CharacterAnimations)>,
graphs: Res<Assets<AnimationGraph>>,
clips: Res<Assets<AnimationClip>>,
time: Res<Time>,
) {
for (hp, anims, mut flags) in query.iter_mut() {
let (graph_handle, anims) = animations.get(*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();
}
}

View File

@@ -0,0 +1,89 @@
use crate::{
billboards::Billboard,
global_observer,
physics_layers::GameLayer,
player::Player,
protocol::{GltfSceneRoot, PlaySound},
squish_animation::SquishAnimation,
utils::one_shot_force::OneShotImpulse,
};
use avian3d::prelude::*;
use bevy::prelude::*;
use bevy_replicon::prelude::{Replicated, SendMode, ServerTriggerExt, ToClients};
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: On<KeySpawn>, mut commands: Commands) {
let KeySpawn(position, id) = trigger.event();
let id = id.clone();
let angle = rand::random::<f32>() * PI * 2.;
let spawn_force = Quat::from_rotation_y(angle) * Vec3::new(0.5, 0.6, 0.).normalize() * 180.;
commands
.spawn((
Name::new("key"),
Transform::from_translation(*position),
Position::new(*position),
Visibility::default(),
Collider::sphere(1.5),
LockedAxes::ROTATION_LOCKED,
RigidBody::Dynamic,
OneShotImpulse(spawn_force),
CollisionLayers::new(GameLayer::CollectiblePhysics, GameLayer::Level),
Restitution::new(0.6),
Replicated,
))
.with_children(|c| {
c.spawn((
Billboard::All,
SquishAnimation(2.6),
GltfSceneRoot::Key,
Replicated,
));
c.spawn((
Collider::sphere(1.5),
CollisionLayers::new(GameLayer::CollectibleSensors, GameLayer::Player),
Sensor,
CollisionEventsEnabled,
Key(id),
Replicated,
))
.observe(on_collect_key);
});
}
fn on_collect_key(
trigger: On<CollisionStart>,
mut commands: Commands,
query_player: Query<&Player>,
query_collectable: Query<(&Key, &ChildOf)>,
) {
let key = trigger.event().collider1;
let collider = trigger.event().collider2;
if query_player.contains(collider) {
let (key, child_of) = query_collectable.get(key).unwrap();
commands.server_trigger(ToClients {
mode: SendMode::Broadcast,
message: PlaySound::KeyCollect,
});
commands.trigger(KeyCollected(key.0.clone()));
commands.entity(child_of.parent()).despawn();
}
}

View File

@@ -0,0 +1,229 @@
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;
#[cfg(feature = "client")]
pub mod client;
pub mod config;
pub mod control;
pub mod cutscene;
pub mod gates;
pub mod head;
pub mod head_drop;
pub mod heads;
pub mod heads_database;
pub mod 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 protocol;
pub mod server;
pub mod tb_entities;
pub mod tick;
pub mod utils;
pub mod water;
use crate::{
config::NetworkingConfig,
heads_database::{HeadDatabaseAsset, HeadsDatabase},
protocol::{PlayerId, messages::AssignClientPlayer},
tb_entities::SpawnPoint,
};
use avian3d::{PhysicsPlugins, prelude::TransformInterpolation};
#[cfg(not(feature = "client"))]
use bevy::app::ScheduleRunnerPlugin;
use bevy::{core_pipeline::tonemapping::Tonemapping, prelude::*};
use bevy_common_assets::ron::RonAssetPlugin;
use bevy_replicon::{RepliconPlugins, prelude::ClientId};
use bevy_replicon_renet::RepliconRenetPlugins;
use bevy_sprite3d::Sprite3dPlugin;
use bevy_trenchbroom::{
TrenchBroomPlugins, config::TrenchBroomConfig, prelude::TrenchBroomPhysicsPlugin,
};
use bevy_trenchbroom_avian::AvianPhysicsBackend;
use utils::{billboards, squish_animation};
pub const HEDZ_GREEN: Srgba = Srgba::rgb(0.0, 1.0, 0.0);
pub const HEDZ_PURPLE: Srgba = Srgba::rgb(91. / 256., 4. / 256., 138. / 256.);
pub fn launch() {
let mut app = App::new();
app.register_type::<DebugVisuals>()
.register_type::<TransformInterpolation>();
app.insert_resource(DebugVisuals {
unlit: false,
tonemapping: Tonemapping::None,
exposure: 1.,
shadows: true,
cam_follow: true,
});
let default_plugins = DefaultPlugins;
#[cfg(feature = "client")]
let default_plugins = default_plugins.set(WindowPlugin {
primary_window: Some(Window {
title: "HEDZ Reloaded".into(),
..default()
}),
..default()
});
app.add_plugins(default_plugins);
#[cfg(not(feature = "client"))]
app.add_plugins(ScheduleRunnerPlugin::default());
#[cfg(feature = "client")]
app.add_plugins(
bevy_debug_log::LogViewerPlugin::default()
.auto_open_threshold(bevy::log::tracing::level_filters::LevelFilter::OFF),
);
app.add_plugins(PhysicsPlugins::default());
app.add_plugins((RepliconPlugins, RepliconRenetPlugins));
app.add_plugins(Sprite3dPlugin);
app.add_plugins(TrenchBroomPlugins(
TrenchBroomConfig::new("hedz").icon(None),
));
app.add_plugins(TrenchBroomPhysicsPlugin::new(AvianPhysicsBackend));
app.add_plugins(RonAssetPlugin::<HeadDatabaseAsset>::new(&["headsdb.ron"]));
app.add_plugins(plugin);
app.init_state::<GameState>();
app.run();
}
pub fn plugin(app: &mut App) {
app.add_plugins(abilities::plugin);
app.add_plugins(ai::plugin);
app.add_plugins(animation::plugin);
app.add_plugins(character::plugin);
app.add_plugins(cash::plugin);
app.add_plugins(cash_heal::plugin);
app.add_plugins(config::plugin);
app.add_plugins(player::plugin);
app.add_plugins(gates::plugin);
app.add_plugins(platforms::plugin);
app.add_plugins(movables::plugin);
app.add_plugins(utils::billboards::plugin);
app.add_plugins(aim::plugin);
app.add_plugins(npc::plugin);
app.add_plugins(keys::plugin);
app.add_plugins(utils::squish_animation::plugin);
app.add_plugins(camera::plugin);
#[cfg(feature = "client")]
app.add_plugins(client::plugin);
app.add_plugins(control::plugin);
app.add_plugins(cutscene::plugin);
app.add_plugins(backpack::plugin);
app.add_plugins(loading_assets::LoadingPlugin);
app.add_plugins(loading_map::plugin);
app.add_plugins(heads::plugin);
app.add_plugins(hitpoints::plugin);
app.add_plugins(head_drop::plugin);
app.add_plugins(protocol::plugin);
app.add_plugins(server::plugin);
app.add_plugins(tb_entities::plugin);
app.add_plugins(tick::plugin);
app.add_plugins(utils::plugin);
app.add_plugins(utils::auto_rotate::plugin);
app.add_plugins(utils::explosions::plugin);
app.add_plugins(utils::sprite_3d_animation::plugin);
app.add_plugins(utils::trail::plugin);
app.add_plugins(water::plugin);
if cfg!(feature = "client") {
app.add_systems(
OnEnter(GameState::Waiting),
start_solo_client
.run_if(|config: Res<NetworkingConfig>| config.server.is_none() && !config.host),
);
app.add_systems(
OnEnter(GameState::Waiting),
start_listen_server
.run_if(|config: Res<NetworkingConfig>| config.server.is_none() && config.host),
);
app.add_systems(
OnEnter(GameState::Waiting),
start_client.run_if(|config: Res<NetworkingConfig>| config.server.is_some()),
);
} else {
app.add_systems(OnEnter(GameState::Waiting), start_dedicated_server);
}
}
#[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, Copy, Eq, PartialEq, Debug, Hash)]
pub enum GameState {
/// Loading assets from disk
#[default]
AssetLoading,
/// Loading + constructing map
MapLoading,
/// Waiting to host/connect/play
Waiting,
/// Connecting to server
Connecting,
/// Opening server
Hosting,
/// Running the game
Playing,
}
fn start_solo_client(
commands: Commands,
mut next: ResMut<NextState<GameState>>,
query: Query<&Transform, With<SpawnPoint>>,
heads_db: Res<HeadsDatabase>,
mut assign_player_id: MessageWriter<AssignClientPlayer>,
) {
next.set(GameState::Playing);
player::spawn(commands, ClientId::Server, query, heads_db);
assign_player_id.write(AssignClientPlayer(PlayerId { id: 0 }));
}
fn start_listen_server(
commands: Commands,
mut next: ResMut<NextState<GameState>>,
query: Query<&Transform, With<SpawnPoint>>,
heads_db: Res<HeadsDatabase>,
mut assign_player_id: MessageWriter<AssignClientPlayer>,
) {
next.set(GameState::Hosting);
player::spawn(commands, ClientId::Server, query, heads_db);
assign_player_id.write(AssignClientPlayer(PlayerId { id: 0 }));
}
fn start_client(mut next: ResMut<NextState<GameState>>) {
next.set(GameState::Connecting);
}
fn start_dedicated_server(mut next: ResMut<NextState<GameState>>) {
next.set(GameState::Hosting);
}

View File

@@ -0,0 +1,152 @@
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);
let loading_state = LoadingState::new(GameState::AssetLoading);
let loading_state = loading_state
.continue_to_state(GameState::MapLoading)
.load_collection::<GameAssets>()
.load_collection::<HeadsAssets>()
.load_collection::<HeadDropAssets>()
.load_collection::<UIAssets>();
#[cfg(feature = "client")]
let loading_state = loading_state.load_collection::<AudioAssets>();
app.add_loading_state(loading_state);
}
}
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 });
info!("loaded assets");
}

View File

@@ -0,0 +1,52 @@
use crate::{GameState, physics_layers::GameLayer, protocol::TbMapEntityId};
use avian3d::prelude::*;
use bevy::prelude::*;
use bevy_replicon::prelude::Replicated;
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((
Name::new("LevelRoot"),
CollisionLayers::new(LayerMask(GameLayer::Level.to_bits()), LayerMask::ALL),
SceneRoot(asset_server.load("maps/map1.map#Scene")),
))
.observe(
|t: On<SceneCollidersReady>,
children: Query<&Children>,
map_entities: Query<&TbMapEntityId>,
mut commands: Commands,
mut next_game_state: ResMut<NextState<GameState>>| {
info!("map loaded");
for child in children.get(t.event().scene_root_entity).unwrap() {
commands.entity(*child).remove::<ChildOf>();
if map_entities.contains(*child) {
commands.entity(*child).insert(Replicated);
}
}
commands.entity(t.scene_root_entity).insert(Replicated);
next_game_state.set(GameState::Waiting);
},
);
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,3 @@
pub fn main() {
hedz_reloaded::launch();
}

View File

@@ -0,0 +1,87 @@
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: On<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.translation = active.target.translation;
transform.rotation = active.target.rotation;
commands.entity(e).remove::<(ActiveMovable, Movable)>();
}
}
}

View File

@@ -0,0 +1,175 @@
use crate::{
GameState,
ai::Ai,
character::{AnimatedCharacter, HedzCharacter},
global_observer,
head::ActiveHead,
head_drop::HeadDrops,
heads::{ActiveHeads, HEAD_COUNT, HeadState},
heads_database::HeadsDatabase,
hitpoints::{Hitpoints, Kill},
keys::KeySpawn,
loading_assets::GameAssets,
protocol::{PlaySound, is_server},
tb_entities::EnemySpawn,
utils::billboards::Billboard,
};
use bevy::{light::NotShadowCaster, prelude::*};
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
#[derive(Component, Reflect, PartialEq, Serialize, Deserialize)]
#[reflect(Component)]
#[require(HedzCharacter)]
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(
FixedUpdate,
setup.run_if(in_state(GameState::Playing).and(is_server)),
);
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, mut spawned: Local<bool>) {
if *spawned {
return;
}
commands.init_resource::<NpcSpawning>();
commands.trigger(OnCheckSpawns);
*spawned = true;
}
fn on_spawn_check(
_trigger: On<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() {
use bevy_replicon::prelude::{Replicated, SendMode, ServerTriggerExt, ToClients};
if let Some(order) = spawn.spawn_order
&& order > spawning.spawn_index
{
continue;
}
let id = names[&spawn.head];
let mut ecommands = commands.entity(e);
ecommands
.insert((
Hitpoints::new(100),
Npc,
ActiveHead(id),
ActiveHeads::new([
Some(HeadState::new(id, heads_db.as_ref())),
None,
None,
None,
None,
]),
Replicated,
))
.insert_if(Ai, || !spawn.disable_ai)
.with_child((
Name::from("body-rig"),
AnimatedCharacter::new(id),
Replicated,
))
.observe(on_kill);
commands.trigger(SpawnCharacter(transform.translation));
commands.server_trigger(ToClients {
mode: SendMode::Broadcast,
message: PlaySound::Beaming,
});
}
}
fn on_kill(
trigger: On<Kill>,
mut commands: Commands,
query: Query<(&Transform, &EnemySpawn, &ActiveHead)>,
) {
let Ok((transform, enemy, head)) = query.get(trigger.event().entity) 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.event().entity).despawn();
if !enemy.key.is_empty() {
commands.trigger(KeySpawn(transform.translation, enemy.key.clone()));
}
}
fn on_spawn(
trigger: On<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,70 @@
use crate::{
GameState,
protocol::is_server,
tb_entities::{Platform, PlatformTarget},
tick::GameTick,
};
use avian3d::prelude::{LinearVelocity, Position};
use bevy::{math::ops::sin, prelude::*};
use bevy_trenchbroom::prelude::Target;
use serde::{Deserialize, Serialize};
#[derive(Component, Reflect, Default, Debug, Serialize, Deserialize, PartialEq)]
#[reflect(Component)]
pub struct ActivePlatform {
pub start: Vec3,
pub target: Vec3,
}
pub fn plugin(app: &mut App) {
app.register_type::<ActivePlatform>();
app.add_systems(
FixedUpdate,
move_active.run_if(in_state(GameState::Playing)),
);
app.add_systems(OnEnter(GameState::Playing), init.run_if(is_server));
}
fn move_active(
tick: Res<GameTick>,
fixed_time: Res<Time<Fixed>>,
mut platforms: Query<(&Position, &ActivePlatform, &mut LinearVelocity)>,
) {
for (position, active, mut velocity) in platforms.iter_mut() {
let now = tick.0 as f32 * fixed_time.timestep().as_secs_f32();
let t = (sin(now * 0.4) + 1.) / 2.;
let target = active.start.lerp(active.target, t);
let prev = position.0;
velocity.0 = (target - prev) / fixed_time.timestep().as_secs_f32();
}
}
#[allow(clippy::type_complexity)]
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);
}
}

View File

@@ -0,0 +1,242 @@
use crate::{
GameState,
abilities::PlayerTriggerState,
backpack::{Backpack, backpack_ui::BackpackUiState},
camera::{CameraArmRotation, CameraTarget},
cash::{Cash, CashCollectEvent, CashInventory},
character::{AnimatedCharacter, HedzCharacter},
control::{Inputs, LocalInputs, controller_common::PlayerCharacterController},
global_observer,
head::ActiveHead,
head_drop::HeadDrops,
heads::{ActiveHeads, HeadChanged, HeadState, heads_ui::UiActiveHeads},
heads_database::HeadsDatabase,
hitpoints::{Hitpoints, Kill},
npc::SpawnCharacter,
protocol::{ClientHeadChanged, OwnedByClient, PlaySound, PlayerId},
tb_entities::SpawnPoint,
};
use avian3d::prelude::*;
use bevy::{
input::common_conditions::input_just_pressed,
prelude::*,
window::{CursorGrabMode, CursorOptions, PrimaryWindow},
};
use bevy_replicon::prelude::{ClientId, Replicated, SendMode, ServerTriggerExt, ToClients};
use happy_feet::debug::DebugInput;
use serde::{Deserialize, Serialize};
#[derive(Component, Default, Serialize, Deserialize, PartialEq)]
#[require(HedzCharacter, DebugInput = DebugInput, PlayerTriggerState)]
pub struct Player;
#[derive(Component, Debug, Reflect)]
#[reflect(Component)]
#[require(LocalInputs, BackpackUiState)]
pub struct LocalPlayer;
#[derive(Component, Default, Serialize, Deserialize, PartialEq)]
#[require(Transform, Visibility)]
pub struct PlayerBodyMesh;
/// Server-side only; inserted on each `client` (not the controller) to track player ids.
#[derive(Component, Clone, Copy)]
pub struct ClientPlayerId(pub PlayerId);
pub fn plugin(app: &mut App) {
app.add_systems(
OnEnter(GameState::Playing),
(toggle_cursor_system, cursor_recenter),
);
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);
}
pub fn spawn(
mut commands: Commands,
owner: ClientId,
query: Query<&Transform, With<SpawnPoint>>,
heads_db: Res<HeadsDatabase>,
) -> Option<Entity> {
let spawn = query.iter().next()?;
let transform = Transform::from_translation(spawn.translation + Vec3::new(0., 3., 0.));
let id = 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),
CashInventory::default(),
CameraTarget,
transform,
Visibility::default(),
PlayerCharacterController,
PlayerId { id: 0 },
),
Backpack::default(),
BackpackUiState::default(),
UiActiveHeads::default(),
Inputs::default(),
Replicated,
))
.with_children(|c| {
c.spawn((
Name::new("player-rig"),
PlayerBodyMesh,
CameraArmRotation,
Replicated,
))
.with_child((
Name::new("player-animated-character"),
AnimatedCharacter::new(0),
Replicated,
));
})
.observe(on_kill)
.id();
if let Some(owner) = owner.entity() {
commands.entity(id).insert(OwnedByClient(owner));
}
commands.server_trigger(ToClients {
mode: SendMode::Broadcast,
message: PlaySound::Head("angry demonstrator".to_string()),
});
commands.trigger(SpawnCharacter(transform.translation));
Some(id)
}
fn on_kill(
trigger: On<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.event().entity) 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 on_update_head_mesh(
trigger: On<HeadChanged>,
mut commands: Commands,
mesh_children: Single<&Children, With<PlayerBodyMesh>>,
animated_characters: Query<&AnimatedCharacter>,
mut player: Single<&mut ActiveHead, With<Player>>,
) -> Result {
let animated_char = mesh_children
.iter()
.find(|child| animated_characters.contains(*child))
.ok_or("tried to update head mesh before AnimatedCharacter was readded")?;
player.0 = trigger.0;
commands
.entity(animated_char)
.insert(AnimatedCharacter::new(trigger.0));
commands.server_trigger(ToClients {
mode: SendMode::Broadcast,
message: ClientHeadChanged(trigger.0 as u64),
});
Ok(())
}
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(options: &mut CursorOptions) {
match options.grab_mode {
CursorGrabMode::None => {
options.grab_mode = CursorGrabMode::Confined;
options.visible = false;
}
_ => {
options.grab_mode = CursorGrabMode::None;
options.visible = true;
}
}
}
fn toggle_cursor_system(mut window: Single<&mut CursorOptions, With<PrimaryWindow>>) {
toggle_grab_cursor(&mut window);
}
fn collect_cash(
mut commands: Commands,
mut collision_message_reader: MessageReader<CollisionStart>,
query_player: Query<&Player>,
query_cash: Query<&Cash>,
) {
for CollisionStart {
collider1: e1,
collider2: e2,
..
} in collision_message_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;
}
}
}
}

View File

@@ -0,0 +1,212 @@
#[cfg(feature = "client")]
use crate::player::LocalPlayer;
use crate::{
loading_assets::{GameAssets, HeadDropAssets},
player::ClientPlayerId,
protocol::TbMapEntityMapping,
};
use avian3d::prelude::Collider;
use bevy::{
ecs::{lifecycle::HookContext, system::SystemParam, world::DeferredWorld},
platform::collections::{HashMap, hash_map},
prelude::*,
};
use bevy_replicon::{client::confirm_history::ConfirmHistory, prelude::ClientId};
use serde::{Deserialize, Serialize};
#[derive(Component)]
pub struct SkipReplicateColliders;
#[derive(Serialize, Deserialize)]
pub enum NetworkedCollider {
Sphere {
radius: f32,
},
Cuboid {
half_extents: Vec3,
},
Capsule {
a: Vec3,
b: Vec3,
radius: f32,
},
/// If a collider value wasn't set up to be replicated, it is replicated as unknown
/// and a warning is logged, and unwraps to `sphere(0.1)` on the other side. Likely
/// very incorrect, but good enough to mitigate some bugs before it's fixed.
Unknown,
}
impl From<Collider> for NetworkedCollider {
fn from(value: Collider) -> Self {
if let Some(value) = value.shape().as_ball() {
NetworkedCollider::Sphere {
radius: value.radius,
}
} else if let Some(value) = value.shape().as_cuboid() {
NetworkedCollider::Cuboid {
half_extents: value.half_extents.into(),
}
} else if let Some(value) = value.shape().as_capsule() {
NetworkedCollider::Capsule {
a: value.segment.a.into(),
b: value.segment.b.into(),
radius: value.radius,
}
} else {
warn!(
"unable to serialize collider type {value:?}; must be accounted for in `NetworkedCollider`"
);
NetworkedCollider::Unknown
}
}
}
impl From<NetworkedCollider> for Collider {
fn from(value: NetworkedCollider) -> Self {
match value {
NetworkedCollider::Sphere { radius } => Collider::sphere(radius),
NetworkedCollider::Cuboid { half_extents } => {
Collider::cuboid(half_extents.x, half_extents.y, half_extents.z)
}
NetworkedCollider::Capsule { a, b, radius } => {
Collider::capsule_endpoints(radius, a, b)
}
NetworkedCollider::Unknown => Collider::sphere(0.1),
}
}
}
/// An ID, unique per player, inserted on the character controller. The `PlayerIdMap` maintains a mapping of ID -> controller entity
/// on the server
#[derive(Clone, Copy, Component, Hash, Reflect, Serialize, Deserialize, PartialEq, Eq)]
#[reflect(Component)]
#[component(on_insert = PlayerId::on_insert, on_remove = PlayerId::on_remove)]
pub struct PlayerId {
pub id: u8,
}
impl PlayerId {
fn on_insert(mut world: DeferredWorld, ctx: HookContext) {
let id = *world.get::<PlayerId>(ctx.entity).unwrap();
world.resource_mut::<PlayerIdMap>().insert(id, ctx.entity);
}
fn on_remove(mut world: DeferredWorld, ctx: HookContext) {
let id = *world.get::<PlayerId>(ctx.entity).unwrap();
world.resource_mut::<PlayerIdMap>().insert(id, ctx.entity);
}
}
/// A (serverside only) mapping of ID -> controller entity
#[derive(Resource, Default, Deref, DerefMut)]
pub struct PlayerIdMap {
pub map: HashMap<PlayerId, Entity>,
}
#[derive(SystemParam)]
pub struct ClientToController<'w, 's> {
clients: Query<'w, 's, &'static ClientPlayerId>,
players: Res<'w, PlayerIdMap>,
#[cfg(feature = "client")]
local_id: Option<Single<'w, 's, &'static PlayerId, With<LocalPlayer>>>,
}
impl ClientToController<'_, '_> {
/// Looks up the character controller owned by the given client
pub fn get_controller(&self, client: ClientId) -> Entity {
let player_id = match client.entity() {
Some(client) => self.clients.get(client).unwrap().0,
None => {
#[cfg(not(feature = "client"))]
{
error!("attempted to look up the local controller on a dedicated server");
PlayerId { id: 0 }
}
#[cfg(feature = "client")]
***self.local_id.as_ref().unwrap()
}
};
*self.players.get(&player_id).unwrap()
}
}
/// A unique ID assigned to every entity spawned by the trenchbroom map loader. This allows synchronizing
/// them across the network even when they are spawned initially by both sides.
#[derive(Clone, Copy, Component, Debug, Reflect, Serialize, Deserialize, PartialEq)]
#[reflect(Component)]
#[component(on_insert = TbMapEntityId::insert_id, on_remove = TbMapEntityId::remove_id)]
pub struct TbMapEntityId {
pub id: u64,
}
impl TbMapEntityId {
fn insert_id(mut world: DeferredWorld, ctx: HookContext) {
let id = world.get::<TbMapEntityId>(ctx.entity).unwrap().id;
// Under lightyear, this was querying `Replicated`. But in replicon `Replicated` is on both sides, and
// `ConfirmHistory` is only client-side.
let entity_is_replicated = world.entity(ctx.entity).contains::<ConfirmHistory>();
let mut mapping = world.resource_mut::<TbMapEntityMapping>();
if let hash_map::Entry::Vacant(e) = mapping.entry(id) {
if entity_is_replicated {
warn!(
"attempted to add a replicated entity to the TbMapEntityMapping; all TbMapEntityIds should be accounted for on the client before the server replicates"
);
return;
}
e.insert(ctx.entity);
}
}
fn remove_id(mut world: DeferredWorld, ctx: HookContext) {
let id = world.get::<TbMapEntityId>(ctx.entity).unwrap().id;
world.resource_mut::<TbMapEntityMapping>().remove(&id);
}
}
#[derive(Component, Reflect, Serialize, Deserialize, PartialEq)]
#[reflect(Component)]
pub enum GltfSceneRoot {
Projectile(String),
HeadDrop(String),
Key,
}
pub fn spawn_gltf_scene_roots(
trigger: On<Add, GltfSceneRoot>,
mut commands: Commands,
gltf_roots: Query<&GltfSceneRoot>,
head_drop_assets: Res<HeadDropAssets>,
assets: Res<GameAssets>,
gltfs: Res<Assets<Gltf>>,
) -> Result {
let root = gltf_roots.get(trigger.event().entity)?;
let get_scene = |gltf: Handle<Gltf>, index: usize| {
let gltf = gltfs.get(&gltf).unwrap();
gltf.scenes[index].clone()
};
let scene = match root {
GltfSceneRoot::Projectile(addr) => get_scene(
assets.projectiles[format!("{addr}.glb").as_str()].clone(),
0,
),
GltfSceneRoot::HeadDrop(addr) => {
let gltf = head_drop_assets
.meshes
.get(format!("{addr}.glb").as_str())
.cloned();
let gltf = gltf.unwrap_or_else(|| head_drop_assets.meshes["none.glb"].clone());
get_scene(gltf, 0)
}
GltfSceneRoot::Key => assets.mesh_key.clone(),
};
commands
.entity(trigger.event().entity)
.insert(SceneRoot(scene));
Ok(())
}

View File

@@ -0,0 +1,34 @@
use bevy::prelude::*;
use serde::{Deserialize, Serialize};
#[derive(Clone, Event, Serialize, Deserialize, PartialEq)]
pub struct ClientHeadChanged(pub u64);
#[derive(Event, Clone, Debug, Serialize, Deserialize)]
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),
}
#[derive(Clone, Default, Event, Serialize, Deserialize)]
pub struct ClientEnteredPlaying;
#[derive(Clone, Event, Serialize, Deserialize)]
pub struct SetGameTick(pub u64);

View File

@@ -0,0 +1,11 @@
use crate::protocol::PlayerId;
use bevy::ecs::message::Message;
use serde::{Deserialize, Serialize};
/// Trenchbroom map entities (spawned during map loading) must be despawned manually if the server
/// has already despawned it but the client has just loaded the map and connected
#[derive(Clone, Copy, Message, Serialize, Deserialize)]
pub struct DespawnTbMapEntity(pub u64);
#[derive(Clone, Copy, Message, Serialize, Deserialize)]
pub struct AssignClientPlayer(pub PlayerId);

View File

@@ -0,0 +1,231 @@
use crate::{
GameState,
abilities::{
BuildExplosionSprite, curver::CurverProjectile, healing::Healing, thrown::ThrownProjectile,
},
animation::AnimationFlags,
backpack::{Backpack, BackpackSwapEvent},
camera::{CameraArmRotation, CameraTarget},
cash::CashInventory,
character::{AnimatedCharacter, HedzCharacter},
control::{
CashHealPressed, ClientInputs, ControllerSettings, Inputs, SelectLeftPressed,
SelectRightPressed,
controller_common::{MovementSpeedFactor, PlayerCharacterController},
},
cutscene::StartCutscene,
global_observer,
head::ActiveHead,
heads::{ActiveHeads, heads_ui::UiActiveHeads},
hitpoints::Hitpoints,
npc::Npc,
platforms::ActivePlatform,
player::{Player, PlayerBodyMesh},
tick::GameTick,
utils::{
auto_rotate::AutoRotation, billboards::Billboard, squish_animation::SquishAnimation,
trail::SpawnTrail,
},
};
use avian3d::prelude::{
AngularInertia, AngularVelocity, CenterOfMass, Collider, ColliderDensity, CollisionLayers,
LinearVelocity, LockedAxes, Mass, Position, RigidBody, Rotation,
};
use bevy::{ecs::system::SystemParam, platform::collections::HashMap, prelude::*};
use bevy_replicon::prelude::{
AppRuleExt, Channel, ClientEventAppExt, ClientMessageAppExt, ClientState, ServerEventAppExt,
ServerMessageAppExt, SyncRelatedAppExt,
};
pub use components::*;
pub use events::*;
use happy_feet::{
grounding::GroundingState,
prelude::{
CharacterDrag, CharacterGravity, CharacterMovement, GroundFriction, Grounding,
GroundingConfig, KinematicVelocity, MoveInput, SteppingConfig,
},
};
use serde::{Deserialize, Serialize};
pub mod components;
pub mod events;
pub mod messages;
pub fn plugin(app: &mut App) {
app.add_client_message::<ClientInputs>(Channel::Unreliable)
.add_client_message::<SelectLeftPressed>(Channel::Ordered)
.add_client_message::<SelectRightPressed>(Channel::Ordered)
.add_client_message::<CashHealPressed>(Channel::Ordered);
app.add_client_event::<ClientEnteredPlaying>(Channel::Ordered)
.add_client_event::<BackpackSwapEvent>(Channel::Ordered);
app.add_server_message::<messages::DespawnTbMapEntity>(Channel::Unordered)
.add_server_message::<messages::AssignClientPlayer>(Channel::Unordered);
app.add_server_event::<ClientHeadChanged>(Channel::Unordered)
.add_server_event::<BuildExplosionSprite>(Channel::Unreliable)
.add_server_event::<StartCutscene>(Channel::Ordered)
.add_server_event::<events::PlaySound>(Channel::Unreliable)
.add_server_event::<events::SetGameTick>(Channel::Ordered);
app.register_type::<PlayerId>();
app.register_type::<TbMapEntityId>();
app.register_type::<TbMapIdCounter>();
app.register_type::<TbMapEntityMapping>();
app.init_resource::<PlayerIdMap>();
app.init_resource::<TbMapIdCounter>();
app.init_resource::<TbMapEntityMapping>();
app.replicate::<ChildOf>();
app.sync_related_entities::<ChildOf>();
app.replicate_once::<components::GltfSceneRoot>()
.replicate_once::<components::PlayerId>()
.replicate::<components::TbMapEntityId>()
.replicate::<ActiveHead>()
.replicate::<ActiveHeads>()
.replicate::<ActivePlatform>()
.replicate::<AnimatedCharacter>()
.replicate::<AnimationFlags>()
.replicate_once::<AutoRotation>()
.replicate::<Backpack>()
.replicate::<Billboard>()
.replicate_once::<CameraArmRotation>()
.replicate_once::<CameraTarget>()
.replicate::<CashInventory>()
.replicate_once::<HedzCharacter>()
.replicate_once::<Healing>()
.replicate::<Hitpoints>()
.replicate::<Inputs>()
.replicate::<Name>()
.replicate_once::<Player>()
.replicate_once::<PlayerBodyMesh>()
.replicate_once::<Npc>()
.replicate::<SquishAnimation>()
.replicate_once::<Transform>()
.replicate_once::<SpawnTrail>()
.replicate::<UiActiveHeads>()
.replicate_as::<Visibility, SerVisibility>();
app.replicate_once::<ThrownProjectile>()
.replicate_once::<CurverProjectile>();
// Physics components
app.replicate::<AngularInertia>()
.replicate::<AngularVelocity>()
.replicate::<CenterOfMass>()
.replicate_filtered_as::<Collider, NetworkedCollider, Without<SkipReplicateColliders>>()
.replicate::<ColliderDensity>()
.replicate::<CollisionLayers>()
.replicate::<LinearVelocity>()
.replicate::<LockedAxes>()
.replicate::<Mass>()
.replicate::<Position>()
.replicate::<RigidBody>()
.replicate::<Rotation>();
// Character controller components
app.replicate::<CharacterDrag>()
.replicate::<CharacterGravity>()
.replicate::<CharacterMovement>()
.replicate::<ControllerSettings>()
.replicate::<GroundFriction>()
.replicate::<Grounding>()
.replicate::<GroundingConfig>()
.replicate::<GroundingState>()
.replicate::<KinematicVelocity>()
.replicate::<MoveInput>()
.replicate::<MovementSpeedFactor>()
.replicate_once::<PlayerCharacterController>()
.replicate::<SteppingConfig>();
app.add_systems(
OnEnter(GameState::MapLoading),
|mut counter: ResMut<TbMapIdCounter>| counter.reset(),
);
global_observer!(app, set_game_tick);
global_observer!(app, components::spawn_gltf_scene_roots);
}
#[derive(SystemParam)]
pub struct NetworkEnv<'w> {
client_state: Res<'w, State<ClientState>>,
}
impl NetworkEnv<'_> {
/// Returns true if this process is currently responsible for being the server/host/"source of truth".
/// May change over time.
pub fn is_server(&self) -> bool {
matches!(**self.client_state, ClientState::Disconnected)
}
}
pub fn is_server(state: Res<State<ClientState>>) -> bool {
matches!(**state, ClientState::Disconnected)
}
fn set_game_tick(on: On<SetGameTick>, mut tick: ResMut<GameTick>) {
tick.0 = on.event().0;
}
#[derive(Serialize, Deserialize)]
enum SerVisibility {
Inherited,
Hidden,
Visible,
}
impl From<Visibility> for SerVisibility {
fn from(value: Visibility) -> Self {
match value {
Visibility::Inherited => Self::Inherited,
Visibility::Hidden => Self::Hidden,
Visibility::Visible => Self::Visible,
}
}
}
impl From<SerVisibility> for Visibility {
fn from(value: SerVisibility) -> Self {
match value {
SerVisibility::Inherited => Self::Inherited,
SerVisibility::Hidden => Self::Hidden,
SerVisibility::Visible => Self::Visible,
}
}
}
/// A global allocator for `TbMapEntityId` values. Should be reset when a map begins loading.
#[derive(Resource, Reflect, Default)]
#[reflect(Resource)]
pub struct TbMapIdCounter(u64);
impl TbMapIdCounter {
pub fn reset(&mut self) {
self.0 = 0;
}
pub fn alloc(&mut self) -> TbMapEntityId {
let id = self.0;
self.0 += 1;
TbMapEntityId { id }
}
}
/// A mapping from TbMapEntityId to clientside map entity. When the serverside is spawned and the client's
/// components migrated to it, or the clientside is despawned because the serverside is already despawned,
/// the Id entry is removed from this mapping.
#[derive(Resource, Reflect, Default, Deref, DerefMut)]
#[reflect(Resource)]
pub struct TbMapEntityMapping(pub HashMap<u64, Entity>);
#[derive(Component)]
#[relationship(relationship_target = ClientOwns)]
pub struct OwnedByClient(pub Entity);
#[derive(Component)]
#[relationship_target(relationship = OwnedByClient, linked_spawn)]
pub struct ClientOwns(Entity);

View File

@@ -0,0 +1,123 @@
use crate::{
GameState, global_observer,
heads_database::HeadsDatabase,
player::ClientPlayerId,
protocol::{ClientEnteredPlaying, PlayerId, SetGameTick, messages::AssignClientPlayer},
tb_entities::SpawnPoint,
tick::GameTick,
};
use bevy::prelude::*;
use bevy_replicon::{
prelude::{
ClientId, ConnectedClient, FromClient, RepliconChannels, SendMode, ServerTriggerExt,
ToClients,
},
server::AuthorizedClient,
};
use bevy_replicon_renet::{
RenetChannelsExt,
netcode::{NetcodeServerTransport, ServerAuthentication},
renet::{ConnectionConfig, RenetServer},
};
use std::{
net::{Ipv4Addr, UdpSocket},
time::SystemTime,
};
pub fn plugin(app: &mut App) {
app.add_systems(OnEnter(GameState::Hosting), open_renet_server);
// Replicon
global_observer!(app, on_connected);
global_observer!(app, on_disconnected);
// Server logic
global_observer!(app, on_client_playing);
}
fn on_client_playing(
trigger: On<FromClient<ClientEnteredPlaying>>,
commands: Commands,
query: Query<&Transform, With<SpawnPoint>>,
heads_db: Res<HeadsDatabase>,
) -> Result {
info!("client has entered playing gamestate");
crate::player::spawn(commands, trigger.client_id, query, heads_db)
.ok_or("failed to spawn player")?;
Ok(())
}
//
// Renet
//
fn open_renet_server(
mut commands: Commands,
channels: Res<RepliconChannels>,
mut next: ResMut<NextState<GameState>>,
) -> Result<(), BevyError> {
info!("opening server");
let server_channels_config = channels.server_configs();
let client_channels_config = channels.client_configs();
let server = RenetServer::new(ConnectionConfig {
server_channels_config,
client_channels_config,
..Default::default()
});
let current_time = SystemTime::now().duration_since(SystemTime::UNIX_EPOCH)?;
let port = 31111;
let socket = UdpSocket::bind((Ipv4Addr::UNSPECIFIED, port))?;
let server_config = bevy_replicon_renet::netcode::ServerConfig {
current_time,
max_clients: 1,
protocol_id: 0,
authentication: ServerAuthentication::Unsecure,
public_addresses: Default::default(),
};
let transport = NetcodeServerTransport::new(server_config, socket)?;
commands.insert_resource(server);
commands.insert_resource(transport);
info!("hosting a server on port {port}");
next.set(GameState::Playing);
Ok(())
}
//
// server logic
//
fn on_connected(
trigger: On<Add, AuthorizedClient>,
game_tick: Res<GameTick>,
mut commands: Commands,
mut assign_id: MessageWriter<ToClients<AssignClientPlayer>>,
) {
let client = trigger.event_target();
info!("{client} connected to server!");
let id = ClientPlayerId(PlayerId { id: 0 });
commands.entity(client).insert(id);
assign_id.write(ToClients {
mode: SendMode::Direct(ClientId::Client(trigger.entity)),
message: AssignClientPlayer(id.0),
});
commands.server_trigger(ToClients {
mode: SendMode::Direct(ClientId::Client(trigger.entity)),
message: SetGameTick(game_tick.0),
});
}
fn on_disconnected(on: On<Remove, ConnectedClient>) {
info!("client {} disconnected", on.entity);
}

View File

@@ -0,0 +1,276 @@
use crate::{
GameState,
cash::Cash,
loading_assets::GameAssets,
physics_layers::GameLayer,
protocol::{
SkipReplicateColliders, TbMapEntityId, TbMapIdCounter, messages::DespawnTbMapEntity,
},
utils::global_observer,
};
use avian3d::{
parry::{na::SVector, shape::SharedShape},
prelude::*,
};
use bevy::{
ecs::{lifecycle::HookContext, world::DeferredWorld},
math::*,
prelude::*,
};
use bevy_replicon::prelude::{ClientId, ConnectedClient, SendMode, ToClients};
use bevy_trenchbroom::prelude::*;
use serde::{Deserialize, Serialize};
#[point_class(base(Transform), model({ "path": "models/spawn.glb" }))]
#[derive(Default)]
#[component(on_add = Self::on_add)]
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),
));
}
}
#[solid_class(
hooks(SpawnHooks::new().convex_collider())
)]
#[derive(Default)]
pub struct Worldspawn;
#[solid_class(base(Transform), hooks(SpawnHooks::new()))]
#[derive(Default)]
pub struct Water;
#[solid_class(base(Transform), hooks(SpawnHooks::new().convex_collider()))]
#[derive(Default)]
pub struct Crates;
#[solid_class(base(Transform), hooks(SpawnHooks::new().convex_collider()))]
#[derive(Default)]
pub struct NamedEntity {
pub name: String,
}
#[solid_class(base(Transform, Target), hooks(SpawnHooks::new().convex_collider()))]
#[derive(Default)]
#[require(RigidBody = RigidBody::Kinematic)]
pub struct Platform;
#[point_class(base(Transform))]
#[derive(Default)]
pub struct PlatformTarget {
pub targetname: String,
}
#[solid_class(base(Transform, Target), hooks(SpawnHooks::new().convex_collider()))]
#[derive(Default, Serialize, Deserialize, PartialEq)]
#[require(RigidBody = RigidBody::Kinematic)]
pub struct Movable {
pub name: String,
}
#[point_class(base(Transform))]
#[derive(Default)]
pub struct MoveTarget {
pub targetname: String,
}
#[point_class(base(Transform))]
#[derive(Default)]
pub struct CameraTarget {
pub targetname: String,
}
#[point_class(base(Transform, Target))]
#[derive(Default)]
pub struct CutsceneCamera {
pub name: String,
pub targetname: String,
}
#[point_class(base(Transform, Target))]
#[derive(Default)]
pub struct CutsceneCameraMovementEnd;
#[point_class(base(Transform), model({ "path": "models/alien_naked.glb" }))]
#[derive(Default)]
#[component(on_add = Self::on_add)]
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.);
let position = Position::new(this_transform.translation);
let rotation = Rotation(this_transform.rotation);
let head = this.head.clone();
world.commands().entity(entity).insert((
this_transform,
position,
rotation,
Name::from(format!("enemy [{head}]")),
Visibility::default(),
RigidBody::Kinematic,
Collider::capsule(0.6, 2.),
CollisionLayers::new(LayerMask(GameLayer::Npc.to_bits()), LayerMask::ALL),
LockedAxes::new().lock_rotation_z().lock_rotation_x(),
));
}
}
#[point_class(base(Transform), model({ "path": "models/cash.glb" }))]
#[derive(Default)]
#[component(on_add = Self::on_add)]
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),
RigidBody::Static,
CollisionEventsEnabled,
Sensor,
));
}
}
#[point_class(base(Transform), model({ "path": "models/head_drop.glb" }))]
#[derive(Default)]
pub struct SecretHead {
pub head_id: usize,
}
fn fix_target_tb_entities(
mut commands: Commands,
mut entities: Query<(Entity, &Transform, &Collider), With<Target>>,
) {
for (entity, tf, coll) in entities.iter_mut() {
if let Some(shape) = coll.shape().as_compound() {
let mut shapes: Vec<_> = shape.shapes().to_vec();
for shape in shapes.iter_mut() {
shape.0.translation.vector -= SVector::<f32, 3>::from(tf.translation.to_array());
}
commands
.entity(entity)
.insert(Collider::from(SharedShape::compound(shapes)));
}
}
}
pub fn plugin(app: &mut App) {
app.register_type::<DespawnedTbEntityCache>();
app.register_type::<SpawnPoint>();
app.override_class::<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>();
app.init_resource::<DespawnedTbEntityCache>();
app.add_systems(OnExit(GameState::MapLoading), fix_target_tb_entities);
app.add_systems(
OnEnter(GameState::MapLoading),
|mut cache: ResMut<DespawnedTbEntityCache>| cache.0.clear(),
);
global_observer!(app, add_despawned_entities_to_cache);
global_observer!(app, send_new_client_despawned_cache);
global_observer!(app, tb_component_setup::<CashSpawn>);
global_observer!(app, tb_component_setup::<Movable>);
global_observer!(app, tb_component_setup::<Platform>);
global_observer!(app, tb_component_setup::<PlatformTarget>);
}
fn tb_component_setup<C: Component>(
trigger: On<Add, C>,
mut commands: Commands,
mut world: DeferredWorld,
) {
let id = world.resource_mut::<TbMapIdCounter>().alloc();
commands
.entity(trigger.event().entity)
.insert_if_new(id)
.insert(SkipReplicateColliders);
}
fn add_despawned_entities_to_cache(
trigger: On<Remove, TbMapEntityId>,
id: Query<&TbMapEntityId>,
mut cache: ResMut<DespawnedTbEntityCache>,
) {
cache.0.push(id.get(trigger.event().entity).unwrap().id);
}
#[derive(Default, Resource, Reflect)]
#[reflect(Resource)]
pub struct DespawnedTbEntityCache(pub Vec<u64>);
fn send_new_client_despawned_cache(
on: On<Add, ConnectedClient>,
cache: Res<DespawnedTbEntityCache>,
mut send: MessageWriter<ToClients<DespawnTbMapEntity>>,
) {
for &id in cache.0.iter() {
send.write(ToClients {
mode: SendMode::Direct(ClientId::Client(on.entity)),
message: DespawnTbMapEntity(id),
});
}
}

View File

@@ -0,0 +1,17 @@
use crate::GameState;
use bevy::prelude::*;
pub fn plugin(app: &mut App) {
app.init_resource::<GameTick>();
app.add_systems(
FixedLast,
(|mut tick: ResMut<GameTick>| {
tick.0 += 1;
})
.run_if(in_state(GameState::Playing)),
);
}
#[derive(Default, Resource)]
pub struct GameTick(pub u64);

View File

@@ -0,0 +1,29 @@
use bevy::prelude::*;
use serde::{Deserialize, Serialize};
#[derive(Component, Reflect, PartialEq, Serialize, Deserialize)]
#[reflect(Component)]
pub struct AutoRotation(pub Quat);
pub fn plugin(app: &mut App) {
app.register_type::<AutoRotation>();
#[cfg(feature = "client")]
app.add_systems(Update, update_auto_rotation);
}
#[cfg(feature = "client")]
fn update_auto_rotation(
query: Query<(&AutoRotation, &Children)>,
mut meshes: Query<&mut Transform>,
) {
for (auto_rotation, children) in query.iter() {
for &child in children {
let Ok(mut transform) = meshes.get_mut(child) else {
continue;
};
transform.rotate_local(auto_rotation.0);
}
}
}

View File

@@ -0,0 +1,75 @@
use crate::camera::MainCamera;
use bevy::prelude::*;
use bevy_sprite3d::Sprite3dPlugin;
use serde::{Deserialize, Serialize};
#[derive(Component, Reflect, Default, PartialEq, Eq, Serialize, Deserialize)]
#[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,37 @@
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: On<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(|entity| Hit {
entity,
damage: explosion.damage,
});
}
}
}

View File

@@ -0,0 +1,16 @@
pub mod auto_rotate;
pub mod billboards;
pub mod explosions;
pub mod observers;
pub mod one_shot_force;
pub mod run_conditions;
pub mod sprite_3d_animation;
pub mod squish_animation;
pub mod trail;
use bevy::prelude::*;
pub(crate) use observers::global_observer;
pub fn plugin(app: &mut App) {
app.add_plugins(one_shot_force::plugin);
}

View File

@@ -0,0 +1,56 @@
#[macro_export]
macro_rules! global_observer {
($app:expr, $($system:tt)*) => {{
$app.world_mut()
.add_observer($($system)*)
.insert(global_observer!(@name $($system)*))
}};
(@name $system:ident ::< $($param:ident),+ $(,)? >) => {{
let mut name = String::new();
name.push_str(stringify!($system));
name.push_str("::<");
$(
name.push_str(std::any::type_name::<$param>());
)+
name.push_str(">");
Name::new(name)
}};
(@name $system:expr) => {
Name::new(stringify!($system))
};
}
pub use global_observer;
#[macro_export]
macro_rules! server_observer {
($app:expr, $($system:tt)*) => {{
$app.add_systems(OnEnter(::bevy_replicon::prelude::ClientState::Disconnected), |mut commands: Commands| {
commands
.add_observer($($system)*)
.insert((
global_observer!(@name $($system)*),
DespawnOnExit(::bevy_replicon::prelude::ClientState::Disconnected),
));
})
}};
(@name $system:ident ::< $($param:ident),+ $(,)? >) => {{
let mut name = String::new();
name.push_str(stringify!($system));
name.push_str("::<");
$(
name.push_str(std::any::type_name::<$param>());
)+
name.push_str(">");
Name::new(name)
}};
(@name $system:expr) => {
Name::new(stringify!($system))
};
}
pub use server_observer;

View File

@@ -0,0 +1,19 @@
use avian3d::prelude::{Forces, RigidBodyForces};
use bevy::prelude::*;
pub fn plugin(app: &mut App) {
app.add_systems(FixedUpdate, apply_one_shot_forces);
}
#[derive(Component)]
pub struct OneShotImpulse(pub Vec3);
pub fn apply_one_shot_forces(
mut commands: Commands,
mut query: Query<(Entity, &OneShotImpulse, Forces)>,
) {
for (entity, force, mut forces) in query.iter_mut() {
forces.apply_linear_impulse(force.0);
commands.entity(entity).remove::<OneShotImpulse>();
}
}

View File

@@ -0,0 +1,5 @@
use bevy::ecs::{resource::Resource, system::Res};
pub fn resource_absent<R: Resource>(res: Option<Res<R>>) -> bool {
res.is_none()
}

View File

@@ -0,0 +1,39 @@
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, &Sprite3d, &mut Sprite)>,
) {
for (e, mut timer, sprite_3d, mut sprite) in query.iter_mut() {
let length = sprite_3d.texture_atlas_keys.len();
let atlas = sprite.texture_atlas.as_mut().unwrap();
if length > 0 {
timer.tick(time.delta());
}
if timer.just_finished() {
if atlas.index + 1 < length {
atlas.index = (atlas.index + 1) % length;
} else {
commands.entity(e).despawn();
}
}
}
}

View File

@@ -0,0 +1,21 @@
use bevy::prelude::*;
use ops::sin;
use serde::{Deserialize, Serialize};
#[derive(Component, Reflect, PartialEq, Serialize, Deserialize)]
#[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,131 @@
use crate::GameState;
use bevy::prelude::*;
use serde::{Deserialize, Serialize};
#[derive(Copy, Clone, Component, Reflect, Deserialize, Serialize)]
#[reflect(Component)]
pub struct SpawnTrail {
pub points: usize,
pub col_start: LinearRgba,
pub col_end: LinearRgba,
pub width: f32,
pub init_pos: bool,
}
impl SpawnTrail {
pub fn new(points: usize, col_start: LinearRgba, col_end: LinearRgba, width: f32) -> Self {
Self {
points,
col_start,
col_end,
width,
init_pos: false,
}
}
pub fn init_with_pos(mut self) -> Self {
self.init_pos = true;
self
}
}
#[derive(Component, Reflect)]
#[reflect(Component)]
pub struct Trail {
points: Vec<Vec3>,
col_start: LinearRgba,
col_end: LinearRgba,
}
impl Trail {
pub fn new(trail: SpawnTrail) -> Self {
Self {
points: Vec::with_capacity(trail.points),
col_start: trail.col_start,
col_end: trail.col_end,
}
}
pub fn with_pos(self, pos: Option<Vec3>) -> Self {
let mut trail = self;
if let Some(pos) = pos {
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)),
);
#[cfg(feature = "client")]
app.add_systems(Update, attach_trail.run_if(in_state(GameState::Playing)));
}
#[cfg(feature = "client")]
fn attach_trail(
mut commands: Commands,
query: Query<(Entity, &Transform, &SpawnTrail), Added<SpawnTrail>>,
mut gizmo_assets: ResMut<Assets<GizmoAsset>>,
) {
for (entity, transform, trail) in query.iter() {
let width = trail.width;
let init_pos = trail.init_pos.then_some(transform.translation);
let id = commands
.spawn((
Trail::new(*trail).with_pos(init_pos),
Gizmo {
handle: gizmo_assets.add(GizmoAsset::default()),
line_config: GizmoLineConfig { width, ..default() },
..default()
},
))
.id();
commands
.entity(entity)
.queue_silenced(move |mut world: EntityWorldMut| {
world.add_child(id);
});
}
}
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,104 @@
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: MessageReader<CollisionStart>,
mut collisionend_events: MessageReader<CollisionEnd>,
query_player: Query<&Player>,
query_water: Query<(Entity, &WaterSensor)>,
) {
let start_events = collisionstart_events.read().map(
|CollisionStart {
collider1: e1,
collider2: e2,
..
}| (true, *e1, *e2),
);
let end_events = collisionend_events.read().map(
|CollisionEnd {
collider1: e1,
collider2: 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: On<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. };
}