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:
52
crates/hedz_reloaded/Cargo.toml
Normal file
52
crates/hedz_reloaded/Cargo.toml
Normal 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"
|
||||
17
crates/hedz_reloaded/build.rs
Normal file
17
crates/hedz_reloaded/build.rs
Normal 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");
|
||||
}
|
||||
122
crates/hedz_reloaded/src/abilities/arrow.rs
Normal file
122
crates/hedz_reloaded/src/abilities/arrow.rs
Normal 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();
|
||||
}
|
||||
}
|
||||
206
crates/hedz_reloaded/src/abilities/curver.rs
Normal file
206
crates/hedz_reloaded/src/abilities/curver.rs
Normal 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,
|
||||
});
|
||||
}
|
||||
}
|
||||
218
crates/hedz_reloaded/src/abilities/gun.rs
Normal file
218
crates/hedz_reloaded/src/abilities/gun.rs
Normal 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)),
|
||||
));
|
||||
}
|
||||
}
|
||||
76
crates/hedz_reloaded/src/abilities/healing.rs
Normal file
76
crates/hedz_reloaded/src/abilities/healing.rs
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
156
crates/hedz_reloaded/src/abilities/missile.rs
Normal file
156
crates/hedz_reloaded/src/abilities/missile.rs
Normal 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,
|
||||
});
|
||||
}
|
||||
}
|
||||
351
crates/hedz_reloaded/src/abilities/mod.rs
Normal file
351
crates/hedz_reloaded/src/abilities/mod.rs
Normal 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,
|
||||
)),
|
||||
));
|
||||
}
|
||||
154
crates/hedz_reloaded/src/abilities/thrown.rs
Normal file
154
crates/hedz_reloaded/src/abilities/thrown.rs
Normal 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,
|
||||
});
|
||||
}
|
||||
}
|
||||
177
crates/hedz_reloaded/src/ai/mod.rs
Normal file
177
crates/hedz_reloaded/src/ai/mod.rs
Normal 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,
|
||||
)));
|
||||
}
|
||||
}
|
||||
}
|
||||
61
crates/hedz_reloaded/src/aim/marker.rs
Normal file
61
crates/hedz_reloaded/src/aim/marker.rs
Normal 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);
|
||||
}
|
||||
199
crates/hedz_reloaded/src/aim/mod.rs
Normal file
199
crates/hedz_reloaded/src/aim/mod.rs
Normal 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
|
||||
}
|
||||
158
crates/hedz_reloaded/src/aim/target_ui.rs
Normal file
158
crates/hedz_reloaded/src/aim/target_ui.rs
Normal file
@@ -0,0 +1,158 @@
|
||||
use super::AimTarget;
|
||||
use crate::{
|
||||
GameState,
|
||||
backpack::UiHeadState,
|
||||
heads::{ActiveHeads, HeadsImages},
|
||||
hitpoints::Hitpoints,
|
||||
loading_assets::UIAssets,
|
||||
npc::Npc,
|
||||
player::Player,
|
||||
};
|
||||
use bevy::prelude::*;
|
||||
|
||||
#[derive(Component, Reflect, Default)]
|
||||
#[reflect(Component)]
|
||||
struct HeadImage;
|
||||
|
||||
#[derive(Component, Reflect, Default)]
|
||||
#[reflect(Component)]
|
||||
struct HeadDamage;
|
||||
|
||||
#[derive(Resource, Default, PartialEq)]
|
||||
struct TargetUi {
|
||||
head: Option<UiHeadState>,
|
||||
}
|
||||
|
||||
pub fn plugin(app: &mut App) {
|
||||
app.add_systems(OnEnter(GameState::Playing), setup);
|
||||
app.add_systems(Update, (sync, update).run_if(in_state(GameState::Playing)));
|
||||
}
|
||||
|
||||
fn setup(mut commands: Commands, assets: Res<UIAssets>) {
|
||||
commands.spawn((
|
||||
Name::new("target-ui"),
|
||||
Node {
|
||||
position_type: PositionType::Absolute,
|
||||
top: Val::Px(150.0),
|
||||
left: Val::Px(20.0),
|
||||
height: Val::Px(74.0),
|
||||
..default()
|
||||
},
|
||||
children![spawn_head_ui(
|
||||
assets.head_bg.clone(),
|
||||
assets.head_regular.clone(),
|
||||
assets.head_damage.clone(),
|
||||
)],
|
||||
));
|
||||
|
||||
commands.insert_resource(TargetUi::default());
|
||||
}
|
||||
|
||||
fn spawn_head_ui(bg: Handle<Image>, regular: Handle<Image>, damage: Handle<Image>) -> impl Bundle {
|
||||
const SIZE: f32 = 90.0;
|
||||
const DAMAGE_SIZE: f32 = 74.0;
|
||||
|
||||
(
|
||||
Node {
|
||||
position_type: PositionType::Relative,
|
||||
justify_content: JustifyContent::Center,
|
||||
align_items: AlignItems::Center,
|
||||
width: Val::Px(SIZE),
|
||||
..default()
|
||||
},
|
||||
children![
|
||||
(
|
||||
Node {
|
||||
position_type: PositionType::Absolute,
|
||||
..default()
|
||||
},
|
||||
ImageNode::new(bg),
|
||||
),
|
||||
(
|
||||
Node {
|
||||
position_type: PositionType::Absolute,
|
||||
..default()
|
||||
},
|
||||
ImageNode::default(),
|
||||
Visibility::Hidden,
|
||||
HeadImage,
|
||||
),
|
||||
(
|
||||
Node {
|
||||
position_type: PositionType::Absolute,
|
||||
..default()
|
||||
},
|
||||
ImageNode::new(regular),
|
||||
),
|
||||
(
|
||||
Node {
|
||||
height: Val::Px(DAMAGE_SIZE),
|
||||
width: Val::Px(DAMAGE_SIZE),
|
||||
..default()
|
||||
},
|
||||
children![(
|
||||
HeadDamage,
|
||||
Node {
|
||||
position_type: PositionType::Absolute,
|
||||
display: Display::Block,
|
||||
overflow: Overflow::clip(),
|
||||
top: Val::Px(0.),
|
||||
left: Val::Px(0.),
|
||||
right: Val::Px(0.),
|
||||
height: Val::Percent(25.),
|
||||
..default()
|
||||
},
|
||||
children![ImageNode::new(damage)]
|
||||
)]
|
||||
)
|
||||
],
|
||||
)
|
||||
}
|
||||
|
||||
fn update(
|
||||
target: Res<TargetUi>,
|
||||
heads_images: Res<HeadsImages>,
|
||||
mut head_image: Query<
|
||||
(&mut Visibility, &mut ImageNode),
|
||||
(Without<HeadDamage>, With<HeadImage>),
|
||||
>,
|
||||
mut head_damage: Query<&mut Node, (With<HeadDamage>, Without<HeadImage>)>,
|
||||
) {
|
||||
if target.is_changed() {
|
||||
if let Ok((mut vis, mut image)) = head_image.single_mut() {
|
||||
if let Some(head) = target.head {
|
||||
*vis = Visibility::Visible;
|
||||
image.image = heads_images.heads[head.head].clone();
|
||||
} else {
|
||||
*vis = Visibility::Hidden;
|
||||
}
|
||||
}
|
||||
|
||||
if let Ok(mut node) = head_damage.single_mut() {
|
||||
node.height = Val::Percent(target.head.map(|head| head.damage()).unwrap_or(0.) * 100.);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn sync(
|
||||
mut target: ResMut<TargetUi>,
|
||||
player_target: Query<&AimTarget, With<Player>>,
|
||||
target_data: Query<(&Hitpoints, &ActiveHeads), With<Npc>>,
|
||||
) {
|
||||
let mut new_state = None;
|
||||
if let Some(e) = player_target.iter().next().and_then(|target| target.0)
|
||||
&& 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;
|
||||
}
|
||||
}
|
||||
184
crates/hedz_reloaded/src/animation.rs
Normal file
184
crates/hedz_reloaded/src/animation.rs
Normal 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,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
39
crates/hedz_reloaded/src/backpack/backpack_ui.rs
Normal file
39
crates/hedz_reloaded/src/backpack/backpack_ui.rs
Normal 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>();
|
||||
}
|
||||
175
crates/hedz_reloaded/src/backpack/mod.rs
Normal file
175
crates/hedz_reloaded/src/backpack/mod.rs
Normal 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(())
|
||||
}
|
||||
40
crates/hedz_reloaded/src/backpack/ui_head_state.rs
Normal file
40
crates/hedz_reloaded/src/backpack/ui_head_state.rs
Normal 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,
|
||||
}
|
||||
}
|
||||
}
|
||||
3
crates/hedz_reloaded/src/bin/hedz_reloaded_server.rs
Normal file
3
crates/hedz_reloaded/src/bin/hedz_reloaded_server.rs
Normal file
@@ -0,0 +1,3 @@
|
||||
pub fn main() {
|
||||
hedz_reloaded::launch();
|
||||
}
|
||||
193
crates/hedz_reloaded/src/camera.rs
Normal file
193
crates/hedz_reloaded/src/camera.rs
Normal 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;
|
||||
}
|
||||
91
crates/hedz_reloaded/src/cash.rs
Normal file
91
crates/hedz_reloaded/src/cash.rs
Normal 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()
|
||||
},
|
||||
));
|
||||
}
|
||||
89
crates/hedz_reloaded/src/cash_heal.rs
Normal file
89
crates/hedz_reloaded/src/cash_heal.rs
Normal 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
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
245
crates/hedz_reloaded/src/character.rs
Normal file
245
crates/hedz_reloaded/src/character.rs
Normal 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.),
|
||||
);
|
||||
}
|
||||
}
|
||||
209
crates/hedz_reloaded/src/client.rs
Normal file
209
crates/hedz_reloaded/src/client.rs
Normal 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();
|
||||
}
|
||||
}
|
||||
212
crates/hedz_reloaded/src/client/backpack/backpack_ui.rs
Normal file
212
crates/hedz_reloaded/src/client/backpack/backpack_ui.rs
Normal 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
|
||||
};
|
||||
}
|
||||
}
|
||||
7
crates/hedz_reloaded/src/client/backpack/mod.rs
Normal file
7
crates/hedz_reloaded/src/client/backpack/mod.rs
Normal file
@@ -0,0 +1,7 @@
|
||||
pub mod backpack_ui;
|
||||
|
||||
use bevy::prelude::*;
|
||||
|
||||
pub fn plugin(app: &mut App) {
|
||||
app.add_plugins(backpack_ui::plugin);
|
||||
}
|
||||
58
crates/hedz_reloaded/src/client/control/controller_flying.rs
Normal file
58
crates/hedz_reloaded/src/client/control/controller_flying.rs
Normal 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
|
||||
}
|
||||
});
|
||||
}
|
||||
265
crates/hedz_reloaded/src/client/control/controls.rs
Normal file
265
crates/hedz_reloaded/src/client/control/controls.rs
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
25
crates/hedz_reloaded/src/client/control/mod.rs
Normal file
25
crates/hedz_reloaded/src/client/control/mod.rs
Normal 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)),
|
||||
);
|
||||
}
|
||||
40
crates/hedz_reloaded/src/client/debug.rs
Normal file
40
crates/hedz_reloaded/src/client/debug.rs
Normal 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()
|
||||
},
|
||||
));
|
||||
}
|
||||
14
crates/hedz_reloaded/src/client/enemy.rs
Normal file
14
crates/hedz_reloaded/src/client/enemy.rs
Normal 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();
|
||||
}
|
||||
}
|
||||
153
crates/hedz_reloaded/src/client/heal_effect.rs
Normal file
153
crates/hedz_reloaded/src/client/heal_effect.rs
Normal 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));
|
||||
}
|
||||
}
|
||||
93
crates/hedz_reloaded/src/client/player.rs
Normal file
93
crates/hedz_reloaded/src/client/player.rs
Normal 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(())
|
||||
}
|
||||
90
crates/hedz_reloaded/src/client/setup.rs
Normal file
90
crates/hedz_reloaded/src/client/setup.rs
Normal 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;
|
||||
}
|
||||
}
|
||||
66
crates/hedz_reloaded/src/client/sounds.rs
Normal file
66
crates/hedz_reloaded/src/client/sounds.rs
Normal 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()
|
||||
},
|
||||
));
|
||||
}
|
||||
93
crates/hedz_reloaded/src/client/steam.rs
Normal file
93
crates/hedz_reloaded/src/client/steam.rs
Normal 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");
|
||||
}
|
||||
}
|
||||
}
|
||||
7
crates/hedz_reloaded/src/client/ui/mod.rs
Normal file
7
crates/hedz_reloaded/src/client/ui/mod.rs
Normal file
@@ -0,0 +1,7 @@
|
||||
mod pause;
|
||||
|
||||
use bevy::prelude::*;
|
||||
|
||||
pub fn plugin(app: &mut App) {
|
||||
app.add_plugins(pause::plugin);
|
||||
}
|
||||
188
crates/hedz_reloaded/src/client/ui/pause.rs
Normal file
188
crates/hedz_reloaded/src/client/ui/pause.rs
Normal 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
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
25
crates/hedz_reloaded/src/config.rs
Normal file
25
crates/hedz_reloaded/src/config.rs
Normal 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,
|
||||
}
|
||||
245
crates/hedz_reloaded/src/control/controller_common.rs
Normal file
245
crates/hedz_reloaded/src/control/controller_common.rs
Normal 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,
|
||||
},
|
||||
};
|
||||
26
crates/hedz_reloaded/src/control/controller_flying.rs
Normal file
26
crates/hedz_reloaded/src/control/controller_flying.rs
Normal 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);
|
||||
}
|
||||
88
crates/hedz_reloaded/src/control/controller_running.rs
Normal file
88
crates/hedz_reloaded/src/control/controller_running.rs
Normal 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)
|
||||
}
|
||||
}
|
||||
176
crates/hedz_reloaded/src/control/mod.rs
Normal file
176
crates/hedz_reloaded/src/control/mod.rs
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
107
crates/hedz_reloaded/src/cutscene.rs
Normal file
107
crates/hedz_reloaded/src/cutscene.rs
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
49
crates/hedz_reloaded/src/gates.rs
Normal file
49
crates/hedz_reloaded/src/gates.rs
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
5
crates/hedz_reloaded/src/head.rs
Normal file
5
crates/hedz_reloaded/src/head.rs
Normal file
@@ -0,0 +1,5 @@
|
||||
use bevy::prelude::*;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Component, Debug, Serialize, Deserialize, PartialEq)]
|
||||
pub struct ActiveHead(pub usize);
|
||||
208
crates/hedz_reloaded/src/head_drop.rs
Normal file
208
crates/hedz_reloaded/src/head_drop.rs
Normal 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();
|
||||
}
|
||||
}
|
||||
259
crates/hedz_reloaded/src/heads/heads_ui.rs
Normal file
259
crates/hedz_reloaded/src/heads/heads_ui.rs
Normal 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()));
|
||||
}
|
||||
}
|
||||
}
|
||||
332
crates/hedz_reloaded/src/heads/mod.rs
Normal file
332
crates/hedz_reloaded/src/heads/mod.rs
Normal 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,
|
||||
));
|
||||
}
|
||||
88
crates/hedz_reloaded/src/heads_database.rs
Normal file
88
crates/hedz_reloaded/src/heads_database.rs
Normal file
@@ -0,0 +1,88 @@
|
||||
use crate::abilities::HeadAbility;
|
||||
use bevy::prelude::*;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Clone, Copy, Debug, Default, Reflect, Serialize, Deserialize, PartialEq, Eq)]
|
||||
pub enum HeadControls {
|
||||
#[default]
|
||||
Walk,
|
||||
Plane,
|
||||
}
|
||||
|
||||
#[derive(Debug, Default, Reflect, Serialize, Deserialize)]
|
||||
pub struct HeadStats {
|
||||
pub key: String,
|
||||
#[serde(default)]
|
||||
pub ability: HeadAbility,
|
||||
#[serde(default)]
|
||||
pub range: f32,
|
||||
#[serde(default)]
|
||||
pub controls: HeadControls,
|
||||
#[serde(default)]
|
||||
pub projectile: String,
|
||||
#[serde(default = "default_ammo")]
|
||||
pub ammo: u32,
|
||||
#[serde(default)]
|
||||
pub damage: u32,
|
||||
/// ability per second
|
||||
#[serde(default = "default_aps")]
|
||||
pub aps: f32,
|
||||
#[serde(default = "default_interrupt_shoot")]
|
||||
pub interrupt_shoot: bool,
|
||||
#[serde(default)]
|
||||
pub shoot_offset: f32,
|
||||
}
|
||||
|
||||
fn default_aps() -> f32 {
|
||||
1.
|
||||
}
|
||||
|
||||
fn default_ammo() -> u32 {
|
||||
10
|
||||
}
|
||||
|
||||
fn default_interrupt_shoot() -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
#[derive(Debug, Asset, Reflect, Serialize, Deserialize)]
|
||||
pub struct HeadDatabaseAsset(pub Vec<HeadStats>);
|
||||
|
||||
#[derive(Debug, Resource, Reflect)]
|
||||
#[reflect(Resource)]
|
||||
pub struct HeadsDatabase {
|
||||
pub heads: Vec<HeadStats>,
|
||||
}
|
||||
|
||||
impl HeadsDatabase {
|
||||
pub fn head_key(&self, id: usize) -> &str {
|
||||
&self.heads[id].key
|
||||
}
|
||||
|
||||
pub fn head_stats(&self, id: usize) -> &HeadStats {
|
||||
&self.heads[id]
|
||||
}
|
||||
}
|
||||
|
||||
// #[cfg(test)]
|
||||
// mod test {
|
||||
// use super::*;
|
||||
|
||||
// #[test]
|
||||
// fn test_serialize() {
|
||||
// let asset = HeadDatabaseAsset(vec![
|
||||
// HeadStats {
|
||||
// key: String::from("foo"),
|
||||
// range: 90.,
|
||||
// ..Default::default()
|
||||
// },
|
||||
// HeadStats {
|
||||
// key: String::from("bar"),
|
||||
// ability: HeadAbility::Gun,
|
||||
// range: 0.,
|
||||
// },
|
||||
// ]);
|
||||
|
||||
// std::fs::write("assets/test.headsb.ron", ron::to_string(&asset).unwrap()).unwrap();
|
||||
// }
|
||||
// }
|
||||
127
crates/hedz_reloaded/src/hitpoints.rs
Normal file
127
crates/hedz_reloaded/src/hitpoints.rs
Normal 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();
|
||||
}
|
||||
}
|
||||
89
crates/hedz_reloaded/src/keys.rs
Normal file
89
crates/hedz_reloaded/src/keys.rs
Normal 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();
|
||||
}
|
||||
}
|
||||
229
crates/hedz_reloaded/src/lib.rs
Normal file
229
crates/hedz_reloaded/src/lib.rs
Normal 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);
|
||||
}
|
||||
152
crates/hedz_reloaded/src/loading_assets.rs
Normal file
152
crates/hedz_reloaded/src/loading_assets.rs
Normal 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");
|
||||
}
|
||||
52
crates/hedz_reloaded/src/loading_map.rs
Normal file
52
crates/hedz_reloaded/src/loading_map.rs
Normal 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()
|
||||
},
|
||||
));
|
||||
}
|
||||
3
crates/hedz_reloaded/src/main.rs
Normal file
3
crates/hedz_reloaded/src/main.rs
Normal file
@@ -0,0 +1,3 @@
|
||||
pub fn main() {
|
||||
hedz_reloaded::launch();
|
||||
}
|
||||
87
crates/hedz_reloaded/src/movables.rs
Normal file
87
crates/hedz_reloaded/src/movables.rs
Normal 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)>();
|
||||
}
|
||||
}
|
||||
}
|
||||
175
crates/hedz_reloaded/src/npc.rs
Normal file
175
crates/hedz_reloaded/src/npc.rs
Normal 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();
|
||||
}
|
||||
}
|
||||
}
|
||||
13
crates/hedz_reloaded/src/physics_layers.rs
Normal file
13
crates/hedz_reloaded/src/physics_layers.rs
Normal file
@@ -0,0 +1,13 @@
|
||||
use avian3d::prelude::PhysicsLayer;
|
||||
use bevy::reflect::Reflect;
|
||||
|
||||
#[derive(PhysicsLayer, Clone, Copy, Debug, Default, Reflect)]
|
||||
pub enum GameLayer {
|
||||
#[default]
|
||||
Level,
|
||||
Player,
|
||||
Npc,
|
||||
Projectile,
|
||||
CollectiblePhysics,
|
||||
CollectibleSensors,
|
||||
}
|
||||
70
crates/hedz_reloaded/src/platforms.rs
Normal file
70
crates/hedz_reloaded/src/platforms.rs
Normal 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);
|
||||
}
|
||||
}
|
||||
242
crates/hedz_reloaded/src/player.rs
Normal file
242
crates/hedz_reloaded/src/player.rs
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
212
crates/hedz_reloaded/src/protocol/components.rs
Normal file
212
crates/hedz_reloaded/src/protocol/components.rs
Normal 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(())
|
||||
}
|
||||
34
crates/hedz_reloaded/src/protocol/events.rs
Normal file
34
crates/hedz_reloaded/src/protocol/events.rs
Normal 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);
|
||||
11
crates/hedz_reloaded/src/protocol/messages.rs
Normal file
11
crates/hedz_reloaded/src/protocol/messages.rs
Normal 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);
|
||||
231
crates/hedz_reloaded/src/protocol/mod.rs
Normal file
231
crates/hedz_reloaded/src/protocol/mod.rs
Normal 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);
|
||||
123
crates/hedz_reloaded/src/server.rs
Normal file
123
crates/hedz_reloaded/src/server.rs
Normal 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);
|
||||
}
|
||||
276
crates/hedz_reloaded/src/tb_entities.rs
Normal file
276
crates/hedz_reloaded/src/tb_entities.rs
Normal 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),
|
||||
});
|
||||
}
|
||||
}
|
||||
17
crates/hedz_reloaded/src/tick.rs
Normal file
17
crates/hedz_reloaded/src/tick.rs
Normal 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);
|
||||
29
crates/hedz_reloaded/src/utils/auto_rotate.rs
Normal file
29
crates/hedz_reloaded/src/utils/auto_rotate.rs
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
75
crates/hedz_reloaded/src/utils/billboards.rs
Normal file
75
crates/hedz_reloaded/src/utils/billboards.rs
Normal 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);
|
||||
}
|
||||
}
|
||||
37
crates/hedz_reloaded/src/utils/explosions.rs
Normal file
37
crates/hedz_reloaded/src/utils/explosions.rs
Normal 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,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
16
crates/hedz_reloaded/src/utils/mod.rs
Normal file
16
crates/hedz_reloaded/src/utils/mod.rs
Normal 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);
|
||||
}
|
||||
56
crates/hedz_reloaded/src/utils/observers.rs
Normal file
56
crates/hedz_reloaded/src/utils/observers.rs
Normal 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;
|
||||
19
crates/hedz_reloaded/src/utils/one_shot_force.rs
Normal file
19
crates/hedz_reloaded/src/utils/one_shot_force.rs
Normal 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>();
|
||||
}
|
||||
}
|
||||
5
crates/hedz_reloaded/src/utils/run_conditions.rs
Normal file
5
crates/hedz_reloaded/src/utils/run_conditions.rs
Normal 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()
|
||||
}
|
||||
39
crates/hedz_reloaded/src/utils/sprite_3d_animation.rs
Normal file
39
crates/hedz_reloaded/src/utils/sprite_3d_animation.rs
Normal 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();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
21
crates/hedz_reloaded/src/utils/squish_animation.rs
Normal file
21
crates/hedz_reloaded/src/utils/squish_animation.rs
Normal 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,
|
||||
);
|
||||
}
|
||||
}
|
||||
131
crates/hedz_reloaded/src/utils/trail.rs
Normal file
131
crates/hedz_reloaded/src/utils/trail.rs
Normal 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(())
|
||||
}
|
||||
104
crates/hedz_reloaded/src/water.rs
Normal file
104
crates/hedz_reloaded/src/water.rs
Normal 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. };
|
||||
}
|
||||
Reference in New Issue
Block a user