Replicate Sounds (#68)

This commit is contained in:
PROMETHIA-27
2025-09-29 14:46:38 -04:00
committed by GitHub
parent a07dfb3840
commit a16ee231cc
47 changed files with 992 additions and 721 deletions

View File

@@ -17,7 +17,6 @@ bevy = { version = "0.16.0", default-features = false, features = [
"animation", "animation",
"async_executor", "async_executor",
"bevy_asset", "bevy_asset",
"bevy_audio",
"bevy_color", "bevy_color",
"bevy_core_pipeline", "bevy_core_pipeline",
"bevy_gilrs", "bevy_gilrs",

View File

@@ -11,6 +11,7 @@ dbg = ["avian3d/debug-plugin", "dep:bevy-inspector-egui", "shared/dbg"]
[dependencies] [dependencies]
avian3d = { workspace = true } avian3d = { workspace = true }
bevy = { workspace = true, default-features = false, features = [ bevy = { workspace = true, default-features = false, features = [
"bevy_audio",
"bevy_window", "bevy_window",
"bevy_winit", "bevy_winit",
] } ] }

View File

@@ -0,0 +1,208 @@
use crate::{GameState, HEDZ_GREEN, heads::HeadsImages, loading_assets::UIAssets};
use bevy::{ecs::spawn::SpawnIter, prelude::*};
use shared::backpack::backpack_ui::{
BackpackCountText, BackpackMarker, BackpackUiState, HEAD_SLOTS, HeadDamage, HeadImage,
HeadSelector,
};
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..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(JustifyText::Center),
Node {
position_type: PositionType::Absolute,
top: Val::Px(20.0),
right: Val::Px(20.0),
..default()
},
));
}
fn spawn_head_ui(
bg: Handle<Image>,
regular: Handle<Image>,
selector: Handle<Image>,
damage: Handle<Image>,
head_slot: usize,
) -> impl Bundle {
const SIZE: f32 = 90.0;
const DAMAGE_SIZE: f32 = 74.0;
(
Node {
position_type: PositionType::Relative,
justify_content: JustifyContent::Center,
align_items: AlignItems::Center,
width: Val::Px(SIZE),
..default()
},
children![
(
Name::new("selector"),
Node {
position_type: PositionType::Absolute,
bottom: Val::Px(-30.0),
..default()
},
Visibility::Hidden,
ImageNode::new(selector).with_flip_y(),
HeadSelector(head_slot),
),
(
Name::new("bg"),
Node {
position_type: PositionType::Absolute,
..default()
},
ImageNode::new(bg),
),
(
Name::new("head"),
Node {
position_type: PositionType::Absolute,
..default()
},
ImageNode::default(),
Visibility::Hidden,
HeadImage(head_slot),
),
(
Name::new("rings"),
Node {
position_type: PositionType::Absolute,
..default()
},
ImageNode::new(regular),
),
(
Name::new("health"),
Node {
height: Val::Px(DAMAGE_SIZE),
width: Val::Px(DAMAGE_SIZE),
..default()
},
children![(
Name::new("damage_ring"),
HeadDamage(head_slot),
Node {
position_type: PositionType::Absolute,
display: Display::Block,
overflow: Overflow::clip(),
top: Val::Px(0.),
left: Val::Px(0.),
right: Val::Px(0.),
height: Val::Percent(0.),
..default()
},
children![ImageNode::new(damage)]
)]
)
],
)
}
fn update_visibility(
state: Single<&BackpackUiState, Changed<BackpackUiState>>,
mut backpack: Single<&mut Visibility, (With<BackpackMarker>, Without<BackpackCountText>)>,
mut count: Single<&mut Visibility, (Without<BackpackMarker>, With<BackpackCountText>)>,
) {
**backpack = if state.open {
Visibility::Visible
} else {
Visibility::Hidden
};
**count = if !state.open {
Visibility::Visible
} else {
Visibility::Hidden
};
}
fn update_count(
state: Single<&BackpackUiState, Changed<BackpackUiState>>,
text: Option<Single<Entity, With<BackpackCountText>>>,
mut writer: TextUiWriter,
) {
let Some(text) = text else {
return;
};
*writer.text(*text, 0) = state.count.to_string();
}
fn update(
state: Single<&BackpackUiState, Changed<BackpackUiState>>,
heads_images: Res<HeadsImages>,
mut head_image: Query<(&HeadImage, &mut Visibility, &mut ImageNode), Without<HeadSelector>>,
mut head_damage: Query<(&HeadDamage, &mut Node), Without<HeadSelector>>,
mut head_selector: Query<(&HeadSelector, &mut Visibility), Without<HeadImage>>,
) {
for (HeadImage(head), mut vis, mut image) in head_image.iter_mut() {
if let Some(head) = &state.heads[*head] {
*vis = Visibility::Inherited;
image.image = heads_images.heads[head.head].clone();
} else {
*vis = Visibility::Hidden;
}
}
for (HeadDamage(head), mut node) in head_damage.iter_mut() {
if let Some(head) = &state.heads[*head] {
node.height = Val::Percent(head.damage() * 100.0);
}
}
for (HeadSelector(head), mut vis) in head_selector.iter_mut() {
*vis = if *head == state.relative_current_slot() {
Visibility::Inherited
} else {
Visibility::Hidden
};
}
}

View File

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

View File

@@ -21,11 +21,15 @@ use shared::{
control::ControlState, control::ControlState,
global_observer, global_observer,
player::Player, player::Player,
protocol::{DespawnTbMapEntity, TbMapEntityId, TbMapEntityMapping}, protocol::{
ClientEnteredPlaying, TbMapEntityId, TbMapEntityMapping,
channels::UnorderedReliableChannel, messages::DespawnTbMapEntity,
},
tb_entities::{Platform, PlatformTarget}, tb_entities::{Platform, PlatformTarget},
}; };
use std::{ use std::{
env::current_exe, env::current_exe,
fs::File,
io::{BufRead, BufReader}, io::{BufRead, BufReader},
net::{IpAddr, Ipv4Addr, SocketAddr}, net::{IpAddr, Ipv4Addr, SocketAddr},
process::Stdio, process::Stdio,
@@ -43,7 +47,7 @@ pub fn plugin(app: &mut App) {
parse_local_server_stdout.run_if(resource_exists::<LocalServerStdout>), parse_local_server_stdout.run_if(resource_exists::<LocalServerStdout>),
); );
app.add_systems(Last, close_server_processes); app.add_systems(Last, close_server_processes);
app.add_systems(FixedUpdate, despawn_absent_map_entities); app.add_systems(Update, despawn_absent_map_entities);
global_observer!(app, on_connecting); global_observer!(app, on_connecting);
global_observer!(app, on_connection_failed); global_observer!(app, on_connection_failed);
@@ -124,9 +128,11 @@ fn on_connection_succeeded(
_trigger: Trigger<OnAdd, Connected>, _trigger: Trigger<OnAdd, Connected>,
state: Res<State<GameState>>, state: Res<State<GameState>>,
mut change_state: ResMut<NextState<GameState>>, mut change_state: ResMut<NextState<GameState>>,
mut sender: Single<&mut TriggerSender<ClientEnteredPlaying>>,
) { ) {
if *state == GameState::Connecting { if *state == GameState::Connecting {
change_state.set(GameState::Playing); change_state.set(GameState::Playing);
sender.trigger::<UnorderedReliableChannel>(ClientEnteredPlaying);
} }
} }
@@ -148,7 +154,7 @@ fn on_connection_failed(
mut commands: Commands, mut commands: Commands,
client_active: Query<&ClientActive>, client_active: Query<&ClientActive>,
mut opened_server: Local<bool>, mut opened_server: Local<bool>,
) { ) -> Result {
let disconnected = disconnected.get(trigger.target()).unwrap(); let disconnected = disconnected.get(trigger.target()).unwrap();
if *opened_server { if *opened_server {
panic!( panic!(
@@ -164,11 +170,13 @@ fn on_connection_failed(
// the server executable is assumed to be adjacent to the client executable // the server executable is assumed to be adjacent to the client executable
let mut exe_path = current_exe().expect("failed to get path of client executable"); let mut exe_path = current_exe().expect("failed to get path of client executable");
exe_path.set_file_name("server"); exe_path.set_file_name("server");
let server_log_file = File::create("server.log")?;
let mut server_process = std::process::Command::new(exe_path) let mut server_process = std::process::Command::new(exe_path)
.args(["--timeout", "60", "--close-on-client-disconnect"]) .args(["--timeout", "60", "--close-on-client-disconnect"])
.env("NO_COLOR", "1")
.stdin(Stdio::null()) .stdin(Stdio::null())
.stdout(Stdio::piped()) .stdout(Stdio::piped())
.stderr(Stdio::null()) .stderr(server_log_file)
.spawn() .spawn()
.expect("failed to start server"); .expect("failed to start server");
let server_stdout = server_process.stdout.take().unwrap(); let server_stdout = server_process.stdout.take().unwrap();
@@ -195,6 +203,8 @@ fn on_connection_failed(
*opened_server = true; *opened_server = true;
} }
Ok(())
} }
#[derive(Event)] #[derive(Event)]
@@ -206,6 +216,8 @@ fn parse_local_server_stdout(mut commands: Commands, mut stdout: ResMut<LocalSer
while let Ok(line) = stdout.0.get().try_recv() { while let Ok(line) = stdout.0.get().try_recv() {
if let "hedz.server_started" = &line[..] { if let "hedz.server_started" = &line[..] {
commands.trigger(LocalServerStarted); commands.trigger(LocalServerStarted);
} else {
info!("SERVER: {line}");
} }
} }
} }

View File

@@ -3,6 +3,19 @@ use crate::{
}; };
use bevy::prelude::*; use bevy::prelude::*;
use rand::{Rng, thread_rng}; use rand::{Rng, thread_rng};
use shared::{loading_assets::AudioAssets, utils::observers::global_observer};
// 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)] #[derive(Component, Default)]
#[require(Transform, InheritedVisibility)] #[require(Transform, InheritedVisibility)]
@@ -25,20 +38,52 @@ pub fn plugin(app: &mut App) {
Update, Update,
(on_added, update_effects, update_particles).run_if(in_state(GameState::Playing)), (on_added, update_effects, update_particles).run_if(in_state(GameState::Playing)),
); );
global_observer!(app, on_removed);
} }
fn on_added(mut cmds: Commands, query: Query<&Healing, Added<Healing>>) { fn on_added(
for healing in query.iter() { mut commands: Commands,
cmds.entity(healing.0).insert(( query: Query<Entity, Added<Healing>>,
Name::new("heal-particle-effect"), assets: Res<AudioAssets>,
HealParticleEffect::default(), ) {
)); 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: Trigger<OnRemove, Healing>,
mut commands: Commands,
effects: Query<&HasHealingEffects>,
) {
let Ok(has_effects) = effects.get(trigger.target()) else {
return;
};
commands.entity(has_effects.effects).try_despawn();
commands
.entity(trigger.target())
.remove::<HasHealingEffects>();
}
fn update_effects( fn update_effects(
mut cmds: Commands, mut commands: Commands,
mut query: Query<(&mut HealParticleEffect, Entity)>, mut query: Query<(&mut HealParticleEffect, &HealingEffectsOf, Entity)>,
mut transforms: Query<&mut Transform>,
time: Res<Time>, time: Res<Time>,
assets: Res<GameAssets>, assets: Res<GameAssets>,
) { ) {
@@ -46,7 +91,14 @@ fn update_effects(
let mut rng = thread_rng(); let mut rng = thread_rng();
let now = time.elapsed_secs(); let now = time.elapsed_secs();
for (mut effect, e) in query.iter_mut() { 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 { if effect.next_spawn < now {
let start_pos = Vec3::new( let start_pos = Vec3::new(
rng.gen_range(-DISTANCE..DISTANCE), rng.gen_range(-DISTANCE..DISTANCE),
@@ -59,7 +111,7 @@ fn update_effects(
let start_scale = rng.gen_range(0.7..1.0); let start_scale = rng.gen_range(0.7..1.0);
let end_scale = rng.gen_range(0.1..start_scale); let end_scale = rng.gen_range(0.1..start_scale);
cmds.entity(e).with_child(( commands.entity(e).with_child((
Name::new("heal-particle"), Name::new("heal-particle"),
SceneRoot(assets.mesh_heal_particle.clone()), SceneRoot(assets.mesh_heal_particle.clone()),
Billboard::All, Billboard::All,

View File

@@ -1,10 +1,13 @@
mod backpack;
mod client; mod client;
mod debug; mod debug;
mod enemy; mod enemy;
mod heal_effect;
mod player;
mod sounds;
mod steam; mod steam;
mod ui; mod ui;
use crate::utils::{auto_rotate, explosions};
use avian3d::prelude::*; use avian3d::prelude::*;
use bevy::{ use bevy::{
audio::{PlaybackMode, Volume}, audio::{PlaybackMode, Volume},
@@ -22,7 +25,6 @@ use lightyear::prelude::client::ClientPlugins;
use loading_assets::AudioAssets; use loading_assets::AudioAssets;
use shared::*; use shared::*;
use std::time::Duration; use std::time::Duration;
use utils::{billboards, sprite_3d_animation, squish_animation, trail};
fn main() { fn main() {
let mut app = App::new(); let mut app = App::new();
@@ -87,43 +89,46 @@ fn main() {
// }); // });
} }
app.add_plugins(ai::plugin); app.add_plugins(shared::ai::plugin);
app.add_plugins(animation::plugin); app.add_plugins(shared::animation::plugin);
app.add_plugins(character::plugin); app.add_plugins(shared::character::plugin);
app.add_plugins(cash::plugin); app.add_plugins(shared::cash::plugin);
app.add_plugins(enemy::plugin); app.add_plugins(shared::player::plugin);
app.add_plugins(player::plugin); app.add_plugins(shared::gates::plugin);
app.add_plugins(gates::plugin); app.add_plugins(shared::platforms::plugin);
app.add_plugins(platforms::plugin); app.add_plugins(shared::protocol::plugin);
app.add_plugins(protocol::plugin); app.add_plugins(shared::movables::plugin);
app.add_plugins(movables::plugin); app.add_plugins(shared::utils::billboards::plugin);
app.add_plugins(billboards::plugin); app.add_plugins(shared::aim::plugin);
app.add_plugins(aim::plugin); app.add_plugins(shared::npc::plugin);
app.add_plugins(client::plugin); app.add_plugins(shared::keys::plugin);
app.add_plugins(npc::plugin); app.add_plugins(shared::utils::squish_animation::plugin);
app.add_plugins(keys::plugin); app.add_plugins(shared::cutscene::plugin);
app.add_plugins(squish_animation::plugin); app.add_plugins(shared::control::plugin);
app.add_plugins(cutscene::plugin); app.add_plugins(shared::camera::plugin);
app.add_plugins(control::plugin); app.add_plugins(shared::backpack::plugin);
app.add_plugins(sounds::plugin); app.add_plugins(shared::loading_assets::LoadingPlugin);
app.add_plugins(camera::plugin); app.add_plugins(shared::loading_map::plugin);
app.add_plugins(shared::utils::sprite_3d_animation::plugin);
app.add_plugins(shared::abilities::plugin);
app.add_plugins(shared::heads::plugin);
app.add_plugins(shared::hitpoints::plugin);
app.add_plugins(shared::cash_heal::plugin);
app.add_plugins(shared::utils::plugin);
app.add_plugins(shared::water::plugin);
app.add_plugins(shared::head_drop::plugin);
app.add_plugins(shared::utils::trail::plugin);
app.add_plugins(shared::utils::auto_rotate::plugin);
app.add_plugins(shared::tb_entities::plugin);
app.add_plugins(shared::utils::explosions::plugin);
app.add_plugins(backpack::plugin); app.add_plugins(backpack::plugin);
app.add_plugins(loading_assets::LoadingPlugin); app.add_plugins(client::plugin);
app.add_plugins(loading_map::plugin);
app.add_plugins(sprite_3d_animation::plugin);
app.add_plugins(abilities::plugin);
app.add_plugins(heads::plugin);
app.add_plugins(hitpoints::plugin);
app.add_plugins(cash_heal::plugin);
app.add_plugins(debug::plugin); app.add_plugins(debug::plugin);
app.add_plugins(utils::plugin); app.add_plugins(enemy::plugin);
app.add_plugins(water::plugin);
app.add_plugins(head_drop::plugin);
app.add_plugins(trail::plugin);
app.add_plugins(auto_rotate::plugin);
app.add_plugins(heal_effect::plugin); app.add_plugins(heal_effect::plugin);
app.add_plugins(tb_entities::plugin); app.add_plugins(player::plugin);
app.add_plugins(explosions::plugin); app.add_plugins(sounds::plugin);
app.add_plugins(ui::plugin); app.add_plugins(ui::plugin);
app.init_state::<GameState>(); app.init_state::<GameState>();

115
crates/client/src/player.rs Normal file
View File

@@ -0,0 +1,115 @@
use crate::{
global_observer,
heads_database::{HeadControls, HeadsDatabase},
loading_assets::AudioAssets,
};
use bevy::prelude::*;
use lightyear::prelude::MessageReceiver;
use shared::{
player::PlayerBodyMesh,
protocol::{PlaySound, PlayerId, events::ClientHeadChanged, messages::AssignClientPlayer},
};
pub fn plugin(app: &mut App) {
app.register_type::<ClientPlayerId>();
app.register_type::<LocalPlayer>();
app.init_state::<PlayerAssignmentState>();
app.add_systems(
Update,
receive_player_id.run_if(in_state(PlayerAssignmentState::Waiting)),
);
app.add_systems(
Update,
match_player_id.run_if(in_state(PlayerAssignmentState::IdReceived)),
);
global_observer!(app, on_update_head_mesh);
}
#[derive(Resource, Reflect)]
#[reflect(Resource)]
pub struct ClientPlayerId {
id: u8,
}
fn receive_player_id(
mut commands: Commands,
mut recv: Single<&mut MessageReceiver<AssignClientPlayer>>,
mut next: ResMut<NextState<PlayerAssignmentState>>,
) {
for AssignClientPlayer(id) in recv.receive() {
commands.insert_resource(ClientPlayerId { id });
next.set(PlayerAssignmentState::IdReceived);
info!("player id `{id}` received");
}
}
fn match_player_id(
mut commands: Commands,
players: Query<(Entity, &PlayerId), Changed<PlayerId>>,
client: Res<ClientPlayerId>,
mut next: ResMut<NextState<PlayerAssignmentState>>,
) {
for (entity, player) in players.iter() {
if player.id == client.id {
commands.entity(entity).insert(LocalPlayer);
next.set(PlayerAssignmentState::Confirmed);
info!("player entity {entity:?} confirmed with id `{}`", player.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
#[default]
Waiting,
/// Received an [`AssignClientPlayer`], querying for a matching controller
IdReceived,
/// Matching controller confirmed; a [`LocalPlayer`] exists
Confirmed,
}
#[derive(Component, Debug, Reflect)]
#[reflect(Component)]
pub struct LocalPlayer;
fn on_update_head_mesh(
trigger: Trigger<ClientHeadChanged>,
mut commands: Commands,
body_mesh: Single<(Entity, &Children), With<PlayerBodyMesh>>,
head_db: Res<HeadsDatabase>,
audio_assets: Res<AudioAssets>,
sfx: Query<&AudioPlayer>,
) -> Result {
let head = trigger.0 as usize;
let (body_mesh, mesh_children) = *body_mesh;
let head_str = head_db.head_key(head);
commands.trigger(PlaySound::Head(head_str.to_string()));
//TODO: make part of full character mesh later
for child in mesh_children.iter().filter(|child| sfx.contains(*child)) {
commands.entity(child).despawn();
}
if head_db.head_stats(head).controls == HeadControls::Plane {
commands.entity(body_mesh).with_child((
Name::new("sfx"),
AudioPlayer::new(audio_assets.jet.clone()),
PlaybackSettings {
mode: bevy::audio::PlaybackMode::Loop,
..Default::default()
},
));
}
Ok(())
}

View File

@@ -1,28 +1,6 @@
use crate::{global_observer, loading_assets::AudioAssets}; use crate::{global_observer, loading_assets::AudioAssets};
use bevy::prelude::*; use bevy::prelude::*;
use shared::protocol::PlaySound;
#[derive(Event, Clone, Debug)]
pub enum PlaySound {
Hit,
KeyCollect,
Gun,
Throw,
ThrowHit,
Gate,
CashCollect,
HeadCollect,
SecretHeadCollect,
HeadDrop,
Selection,
Invalid,
MissileExplosion,
Reloaded,
CashHeal,
Crossbow,
Beaming,
Backpack { open: bool },
Head(String),
}
pub fn plugin(app: &mut App) { pub fn plugin(app: &mut App) {
global_observer!(app, on_spawn_sounds); global_observer!(app, on_spawn_sounds);
@@ -31,7 +9,6 @@ pub fn plugin(app: &mut App) {
fn on_spawn_sounds( fn on_spawn_sounds(
trigger: Trigger<PlaySound>, trigger: Trigger<PlaySound>,
mut commands: Commands, mut commands: Commands,
// sound_res: Res<AudioAssets>,
// settings: SettingsRead, // settings: SettingsRead,
assets: Res<AudioAssets>, assets: Res<AudioAssets>,
) { ) {

View File

@@ -0,0 +1,92 @@
use bevy::prelude::*;
use lightyear::prelude::input::native::ActionState;
use shared::{
GameState,
backpack::{
BackbackSwapEvent, Backpack, UiHeadState,
backpack_ui::{BackpackUiState, HEAD_SLOTS},
},
control::ControlState,
protocol::PlaySound,
};
pub fn plugin(app: &mut App) {
app.add_systems(
FixedUpdate,
sync_on_change.run_if(in_state(GameState::Playing)),
);
app.add_systems(FixedUpdate, swap_head_inputs);
}
fn swap_head_inputs(
player: Query<(&ActionState<ControlState>, Ref<Backpack>)>,
mut commands: Commands,
mut state: Single<&mut BackpackUiState>,
time: Res<Time>,
) {
for (controls, backpack) in player.iter() {
if state.count == 0 {
return;
}
if controls.backpack_toggle {
state.open = !state.open;
commands.trigger(PlaySound::Backpack { open: state.open });
}
if !state.open {
return;
}
let mut changed = false;
if controls.backpack_left && state.current_slot > 0 {
state.current_slot -= 1;
changed = true;
}
if controls.backpack_right && state.current_slot < state.count.saturating_sub(1) {
state.current_slot += 1;
changed = true;
}
if controls.backpack_swap {
commands.trigger(BackbackSwapEvent(state.current_slot));
}
if changed {
commands.trigger(PlaySound::Selection);
sync(&backpack, &mut state, time.elapsed_secs());
}
}
}
fn sync_on_change(
backpack: Query<Ref<Backpack>>,
mut state: Single<&mut BackpackUiState>,
time: Res<Time>,
) {
for backpack in backpack.iter() {
if backpack.is_changed() || backpack.reloading() {
sync(&backpack, &mut state, time.elapsed_secs());
}
}
}
fn sync(backpack: &Backpack, state: &mut Single<&mut BackpackUiState>, time: f32) {
state.count = backpack.heads.len();
state.scroll = state.scroll.min(state.count.saturating_sub(HEAD_SLOTS));
if state.current_slot >= state.scroll + HEAD_SLOTS {
state.scroll = state.current_slot.saturating_sub(HEAD_SLOTS - 1);
}
if state.current_slot < state.scroll {
state.scroll = state.current_slot;
}
for i in 0..HEAD_SLOTS {
if let Some(head) = backpack.heads.get(i + state.scroll) {
state.heads[i] = Some(UiHeadState::new(*head, time));
} else {
state.heads[i] = None;
}
}
}

View File

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

View File

@@ -1,11 +1,5 @@
use avian3d::prelude::*; use avian3d::prelude::*;
use bevy::{ use bevy::{app::plugin_group, core_pipeline::tonemapping::Tonemapping, prelude::*};
app::plugin_group,
audio::Volume,
core_pipeline::tonemapping::Tonemapping,
log::{BoxedLayer, tracing_subscriber::Layer},
prelude::*,
};
use bevy_common_assets::ron::RonAssetPlugin; use bevy_common_assets::ron::RonAssetPlugin;
use bevy_sprite3d::Sprite3dPlugin; use bevy_sprite3d::Sprite3dPlugin;
use bevy_trenchbroom::prelude::*; use bevy_trenchbroom::prelude::*;
@@ -14,10 +8,12 @@ use lightyear::prelude::server::ServerPlugins;
use shared::{DebugVisuals, GameState, heads_database::HeadDatabaseAsset}; use shared::{DebugVisuals, GameState, heads_database::HeadDatabaseAsset};
use std::time::Duration; use std::time::Duration;
mod backpack;
mod config; mod config;
mod player; mod player;
mod server; mod server;
mod tb_entities; mod tb_entities;
mod utils;
plugin_group! { plugin_group! {
pub struct DefaultPlugins { pub struct DefaultPlugins {
@@ -44,7 +40,6 @@ plugin_group! {
bevy::ui:::UiPlugin, bevy::ui:::UiPlugin,
bevy::pbr:::PbrPlugin, bevy::pbr:::PbrPlugin,
bevy::gltf:::GltfPlugin, bevy::gltf:::GltfPlugin,
bevy::audio:::AudioPlugin,
bevy::gilrs:::GilrsPlugin, bevy::gilrs:::GilrsPlugin,
bevy::animation:::AnimationPlugin, bevy::animation:::AnimationPlugin,
bevy::gizmos:::GizmoPlugin, bevy::gizmos:::GizmoPlugin,
@@ -54,23 +49,6 @@ plugin_group! {
} }
} }
pub fn log_to_file_layer(_app: &mut App) -> Option<BoxedLayer> {
let file = std::fs::OpenOptions::new()
.write(true)
.create(true)
.truncate(true)
.open("server.log")
.ok()?;
Some(
bevy::log::tracing_subscriber::fmt::layer()
.with_writer(std::sync::Mutex::new(file))
.with_ansi(false)
.with_file(true)
.with_line_number(true)
.boxed(),
)
}
fn main() { fn main() {
let mut app = App::new(); let mut app = App::new();
@@ -88,7 +66,7 @@ fn main() {
filter: "info,lightyear_replication=off".into(), filter: "info,lightyear_replication=off".into(),
level: bevy::log::Level::INFO, level: bevy::log::Level::INFO,
// provide custom log layer to receive logging events // provide custom log layer to receive logging events
custom_layer: log_to_file_layer, ..default()
})); }));
app.add_plugins(ServerPlugins { app.add_plugins(ServerPlugins {
@@ -102,17 +80,6 @@ fn main() {
app.add_plugins(UiGradientsPlugin); app.add_plugins(UiGradientsPlugin);
app.add_plugins(RonAssetPlugin::<HeadDatabaseAsset>::new(&["headsdb.ron"])); app.add_plugins(RonAssetPlugin::<HeadDatabaseAsset>::new(&["headsdb.ron"]));
#[cfg(feature = "dbg")]
{
app.add_plugins(PhysicsDebugPlugin::default());
// app.add_plugins(bevy::pbr::wireframe::WireframePlugin)
// .insert_resource(bevy::pbr::wireframe::WireframeConfig {
// global: true,
// default_color: bevy::color::palettes::css::WHITE.into(),
// });
}
app.add_plugins(shared::abilities::plugin); app.add_plugins(shared::abilities::plugin);
app.add_plugins(shared::ai::plugin); app.add_plugins(shared::ai::plugin);
app.add_plugins(shared::aim::plugin); app.add_plugins(shared::aim::plugin);
@@ -127,7 +94,6 @@ fn main() {
app.add_plugins(shared::gates::plugin); app.add_plugins(shared::gates::plugin);
app.add_plugins(shared::head_drop::plugin); app.add_plugins(shared::head_drop::plugin);
app.add_plugins(shared::heads::plugin); app.add_plugins(shared::heads::plugin);
app.add_plugins(shared::heal_effect::plugin);
app.add_plugins(shared::hitpoints::plugin); app.add_plugins(shared::hitpoints::plugin);
app.add_plugins(shared::keys::plugin); app.add_plugins(shared::keys::plugin);
app.add_plugins(shared::loading_assets::LoadingPlugin); app.add_plugins(shared::loading_assets::LoadingPlugin);
@@ -137,7 +103,6 @@ fn main() {
app.add_plugins(shared::platforms::plugin); app.add_plugins(shared::platforms::plugin);
app.add_plugins(shared::player::plugin); app.add_plugins(shared::player::plugin);
app.add_plugins(shared::protocol::plugin); app.add_plugins(shared::protocol::plugin);
app.add_plugins(shared::sounds::plugin);
app.add_plugins(shared::steam::plugin); app.add_plugins(shared::steam::plugin);
app.add_plugins(shared::tb_entities::plugin); app.add_plugins(shared::tb_entities::plugin);
app.add_plugins(shared::utils::auto_rotate::plugin); app.add_plugins(shared::utils::auto_rotate::plugin);
@@ -149,20 +114,20 @@ fn main() {
app.add_plugins(shared::utils::plugin); app.add_plugins(shared::utils::plugin);
app.add_plugins(shared::water::plugin); app.add_plugins(shared::water::plugin);
app.add_plugins(backpack::plugin);
app.add_plugins(config::plugin); app.add_plugins(config::plugin);
app.add_plugins(player::plugin);
app.add_plugins(server::plugin); app.add_plugins(server::plugin);
app.add_plugins(tb_entities::plugin); app.add_plugins(tb_entities::plugin);
app.add_plugins(utils::plugin);
app.init_state::<GameState>(); app.init_state::<GameState>();
app.insert_resource(AmbientLight { app.add_systems(PostStartup, setup_panic_handler);
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.run(); app.run();
} }
fn setup_panic_handler() {
_ = std::panic::take_hook();
}

View File

@@ -6,6 +6,7 @@ use shared::{
cash::CashResource, cash::CashResource,
character::AnimatedCharacter, character::AnimatedCharacter,
control::{ControlState, controller_common::PlayerCharacterController}, control::{ControlState, controller_common::PlayerCharacterController},
global_observer,
head::ActiveHead, head::ActiveHead,
head_drop::HeadDrops, head_drop::HeadDrops,
heads::{ActiveHeads, HeadChanged, HeadState, heads_ui::UiActiveHeads}, heads::{ActiveHeads, HeadChanged, HeadState, heads_ui::UiActiveHeads},
@@ -13,19 +14,23 @@ use shared::{
hitpoints::{Hitpoints, Kill}, hitpoints::{Hitpoints, Kill},
npc::SpawnCharacter, npc::SpawnCharacter,
player::{Player, PlayerBodyMesh}, player::{Player, PlayerBodyMesh},
protocol::{
PlaySound, PlayerId, channels::UnorderedReliableChannel, events::ClientHeadChanged,
},
tb_entities::SpawnPoint, tb_entities::SpawnPoint,
}; };
pub fn plugin(app: &mut App) {
global_observer!(app, on_update_head_mesh);
}
pub fn spawn( pub fn spawn(
mut commands: Commands, mut commands: Commands,
owner: Entity, owner: Entity,
query: Query<&Transform, With<SpawnPoint>>, query: Query<&Transform, With<SpawnPoint>>,
asset_server: Res<AssetServer>,
heads_db: Res<HeadsDatabase>, heads_db: Res<HeadsDatabase>,
) { ) -> Option<Entity> {
let Some(spawn) = query.iter().next() else { let spawn = query.iter().next()?;
return;
};
let transform = Transform::from_translation(spawn.translation + Vec3::new(0., 3., 0.)); let transform = Transform::from_translation(spawn.translation + Vec3::new(0., 3., 0.));
@@ -47,6 +52,7 @@ pub fn spawn(
transform, transform,
Visibility::default(), Visibility::default(),
PlayerCharacterController, PlayerCharacterController,
PlayerId { id: 0 },
), ),
ActionState::<ControlState>::default(), ActionState::<ControlState>::default(),
Backpack::default(), Backpack::default(),
@@ -67,12 +73,12 @@ pub fn spawn(
)); ));
player.observe(on_kill); player.observe(on_kill);
commands.spawn(( let id = player.id();
AudioPlayer::new(asset_server.load("sfx/heads/angry demonstrator.ogg")),
PlaybackSettings::DESPAWN,
));
commands.trigger(PlaySound::Head("angry demonstrator".to_string()));
commands.trigger(SpawnCharacter(transform.translation)); commands.trigger(SpawnCharacter(transform.translation));
Some(id)
} }
fn on_kill( fn on_kill(
@@ -92,3 +98,27 @@ fn on_kill(
commands.trigger(HeadChanged(new_head)); commands.trigger(HeadChanged(new_head));
} }
} }
fn on_update_head_mesh(
trigger: Trigger<HeadChanged>,
mut commands: Commands,
mesh_children: Single<&Children, With<PlayerBodyMesh>>,
mut sender: Single<&mut TriggerSender<ClientHeadChanged>>,
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));
sender.trigger::<UnorderedReliableChannel>(ClientHeadChanged(trigger.0 as u64));
Ok(())
}

View File

@@ -1,42 +1,54 @@
use crate::config::ServerConfig; use crate::config::ServerConfig;
use bevy::prelude::*; use bevy::prelude::*;
use lightyear::{ use lightyear::{
connection::client::PeerMetadata,
link::LinkConditioner, link::LinkConditioner,
prelude::{ prelude::{
server::{NetcodeConfig, NetcodeServer, ServerUdpIo, Started}, server::{ClientOf, NetcodeConfig, NetcodeServer, ServerUdpIo, Started},
*, *,
}, },
}; };
use shared::{GameState, global_observer, heads_database::HeadsDatabase, tb_entities::SpawnPoint}; use shared::{
GameState, global_observer,
heads_database::HeadsDatabase,
protocol::{
ClientEnteredPlaying, channels::UnorderedReliableChannel, messages::AssignClientPlayer,
},
tb_entities::SpawnPoint,
};
use std::{ use std::{
net::{IpAddr, Ipv4Addr, SocketAddr}, net::{IpAddr, Ipv4Addr, SocketAddr},
time::Duration, time::Duration,
}; };
pub fn plugin(app: &mut App) { pub fn plugin(app: &mut App) {
app.add_systems(Startup, (start_server, setup_timeout_timer)); app.add_systems(
OnEnter(GameState::Playing),
(start_server, setup_timeout_timer),
);
app.add_systems( app.add_systems(
Update, Update,
( (notify_started, run_timeout).run_if(in_state(GameState::Playing)),
notify_started.run_if(in_state(GameState::Playing)),
run_timeout,
),
); );
global_observer!(app, handle_new_client); global_observer!(app, handle_new_client);
global_observer!(app, on_client_connected);
global_observer!(app, on_client_playing);
global_observer!(app, close_on_disconnect); global_observer!(app, close_on_disconnect);
global_observer!(app, cancel_timeout); global_observer!(app, cancel_timeout);
} }
#[derive(Component)]
struct ClientPlayerId(u8);
fn handle_new_client( fn handle_new_client(
trigger: Trigger<OnAdd, Connected>, trigger: Trigger<OnAdd, Linked>,
mut commands: Commands, mut commands: Commands,
id: Query<&PeerAddr>, id: Query<&PeerAddr>,
asset_server: Res<AssetServer>,
query: Query<&Transform, With<SpawnPoint>>,
heads_db: Res<HeadsDatabase>,
) -> Result { ) -> Result {
let id = id.get(trigger.target())?; let Ok(id) = id.get(trigger.target()) else {
return Ok(());
};
info!("Client connected on IP: {}", id.ip()); info!("Client connected on IP: {}", id.ip());
@@ -46,11 +58,38 @@ fn handle_new_client(
incoming_loss: 0.0, incoming_loss: 0.0,
}); });
commands commands.entity(trigger.target()).insert((
.entity(trigger.target()) ReplicationSender::default(),
.insert((ReplicationSender::default(), Link::new(Some(conditioner)))); Link::new(Some(conditioner)),
ClientPlayerId(0),
));
crate::player::spawn(commands, trigger.target(), query, asset_server, heads_db); Ok(())
}
fn on_client_connected(
trigger: Trigger<OnAdd, ClientOf>,
mut assign_player: Query<(&ClientPlayerId, &mut MessageSender<AssignClientPlayer>)>,
) -> Result {
// `Linked` happens before the `ClientOf` and `MessageSender` components are added, so the server can't
// send the client player id until now.
let (id, mut sender) = assign_player.get_mut(trigger.target())?;
sender.send::<UnorderedReliableChannel>(AssignClientPlayer(id.0));
Ok(())
}
fn on_client_playing(
trigger: Trigger<RemoteTrigger<ClientEnteredPlaying>>,
commands: Commands,
query: Query<&Transform, With<SpawnPoint>>,
heads_db: Res<HeadsDatabase>,
peers: Res<PeerMetadata>,
) -> Result {
let Some(&client) = peers.mapping.get(&trigger.from) else {
return Ok(());
};
crate::player::spawn(commands, client, query, heads_db).ok_or("failed to spawn player")?;
Ok(()) Ok(())
} }
@@ -61,6 +100,7 @@ fn close_on_disconnect(
mut writer: EventWriter<AppExit>, mut writer: EventWriter<AppExit>,
) { ) {
if config.close_on_client_disconnect { if config.close_on_client_disconnect {
info!("client disconnected, exiting");
writer.write(AppExit::Success); writer.write(AppExit::Success);
} }
} }
@@ -103,10 +143,12 @@ fn run_timeout(mut timer: ResMut<TimeoutTimer>, mut writer: EventWriter<AppExit>
timer.0 -= time.delta_secs(); timer.0 -= time.delta_secs();
if timer.0 <= 0.0 { if timer.0 <= 0.0 {
info!("client timed out, exiting");
writer.write(AppExit::Success); writer.write(AppExit::Success);
} }
} }
fn cancel_timeout(_trigger: Trigger<OnAdd, Connected>, mut timer: ResMut<TimeoutTimer>) { fn cancel_timeout(_trigger: Trigger<OnAdd, Connected>, mut timer: ResMut<TimeoutTimer>) {
info!("client connected, cancelling timeout");
timer.0 = f32::INFINITY; timer.0 = f32::INFINITY;
} }

View File

@@ -1,8 +1,8 @@
use bevy::prelude::*; use bevy::prelude::*;
use lightyear::prelude::{ActionsChannel, Connected, MessageSender}; use lightyear::prelude::{Connected, MessageSender};
use shared::{ use shared::{
GameState, global_observer, GameState, global_observer,
protocol::{DespawnTbMapEntity, TbMapEntityId}, protocol::{TbMapEntityId, channels::UnorderedReliableChannel, messages::DespawnTbMapEntity},
}; };
pub fn plugin(app: &mut App) { pub fn plugin(app: &mut App) {
@@ -38,6 +38,6 @@ fn send_new_client_despawned_cache(
) { ) {
let mut send = send.get_mut(trigger.target()).unwrap(); let mut send = send.get_mut(trigger.target()).unwrap();
for &id in cache.0.iter() { for &id in cache.0.iter() {
send.send::<ActionsChannel>(DespawnTbMapEntity(id)); send.send::<UnorderedReliableChannel>(DespawnTbMapEntity(id));
} }
} }

View File

@@ -0,0 +1,37 @@
use bevy::{
ecs::{archetype::Archetypes, component::Components, entity::Entities},
prelude::*,
};
use shared::global_observer;
pub fn plugin(app: &mut App) {
global_observer!(app, report_entity_components);
}
#[derive(Event)]
pub struct ReportEntityComponents(pub Entity);
fn report_entity_components(
trigger: Trigger<ReportEntityComponents>,
entities: &Entities,
components: &Components,
archetypes: &Archetypes,
) {
let Some(location) = entities.get(trigger.event().0) else {
warn!("failed to report entity components; had no location");
return;
};
let Some(archetype) = archetypes.get(location.archetype_id) else {
warn!("failed to report entity components; had no archetype");
return;
};
let mut output = format!("Entity {:?} Components: ", trigger.event().0);
for component in archetype.components() {
if let Some(name) = components.get_name(component) {
output.push_str(&format!("{name}, "));
}
}
info!("{}; Caller: {}", output, trigger.caller());
}

View File

@@ -1,7 +1,7 @@
use super::TriggerArrow; use super::TriggerArrow;
use crate::{ use crate::{
GameState, billboards::Billboard, global_observer, heads_database::HeadsDatabase, GameState, billboards::Billboard, global_observer, heads_database::HeadsDatabase,
hitpoints::Hit, loading_assets::GameAssets, physics_layers::GameLayer, sounds::PlaySound, hitpoints::Hit, loading_assets::GameAssets, physics_layers::GameLayer, protocol::PlaySound,
utils::sprite_3d_animation::AnimationTimer, utils::sprite_3d_animation::AnimationTimer,
}; };
use avian3d::prelude::*; use avian3d::prelude::*;

View File

@@ -1,7 +1,7 @@
use super::TriggerGun; use super::TriggerGun;
use crate::{ use crate::{
GameState, billboards::Billboard, global_observer, heads_database::HeadsDatabase, GameState, billboards::Billboard, global_observer, heads_database::HeadsDatabase,
hitpoints::Hit, loading_assets::GameAssets, physics_layers::GameLayer, sounds::PlaySound, hitpoints::Hit, loading_assets::GameAssets, physics_layers::GameLayer, protocol::PlaySound,
tb_entities::EnemySpawn, utils::sprite_3d_animation::AnimationTimer, tb_entities::EnemySpawn, utils::sprite_3d_animation::AnimationTimer,
}; };
use avian3d::prelude::*; use avian3d::prelude::*;

View File

@@ -1,13 +1,14 @@
use crate::{ use crate::{
GameState, global_observer, heads::ActiveHeads, heads_database::HeadsDatabase, GameState, global_observer, heads::ActiveHeads, heads_database::HeadsDatabase,
hitpoints::Hitpoints, loading_assets::AudioAssets, hitpoints::Hitpoints,
}; };
use bevy::prelude::*; use bevy::prelude::*;
use serde::{Deserialize, Serialize};
#[derive(Component)] #[derive(Component, Serialize, Deserialize, PartialEq)]
pub struct Healing(pub Entity); pub struct Healing;
#[derive(Event, Debug)] #[derive(Clone, Event, Debug, Serialize, Deserialize)]
pub enum HealingStateChanged { pub enum HealingStateChanged {
Started, Started,
Stopped, Stopped,
@@ -22,27 +23,21 @@ pub fn plugin(app: &mut App) {
fn on_heal_start_stop( fn on_heal_start_stop(
trigger: Trigger<HealingStateChanged>, trigger: Trigger<HealingStateChanged>,
mut cmds: Commands, mut cmds: Commands,
assets: Res<AudioAssets>,
query: Query<&Healing>, query: Query<&Healing>,
) { ) {
if matches!(trigger.event(), HealingStateChanged::Started) { if matches!(trigger.event(), HealingStateChanged::Started) {
let e = cmds if query.contains(trigger.target()) {
.spawn(( // already healing, just ignore
Name::new("sfx-heal"), return;
AudioPlayer::new(assets.healing.clone()),
PlaybackSettings {
mode: bevy::audio::PlaybackMode::Loop,
..Default::default()
},
))
.id();
cmds.entity(trigger.target())
.add_child(e)
.insert(Healing(e));
} else {
if let Ok(healing) = query.single() {
cmds.entity(healing.0).despawn();
} }
cmds.entity(trigger.target()).insert(Healing);
} else {
if !query.contains(trigger.target()) {
// Not healing, just ignore
return;
}
cmds.entity(trigger.target()).remove::<Healing>(); cmds.entity(trigger.target()).remove::<Healing>();
} }
} }

View File

@@ -4,8 +4,7 @@ use crate::{
abilities::BuildExplosionSprite, abilities::BuildExplosionSprite,
heads_database::HeadsDatabase, heads_database::HeadsDatabase,
physics_layers::GameLayer, physics_layers::GameLayer,
protocol::GltfSceneRoot, protocol::{GltfSceneRoot, PlaySound},
sounds::PlaySound,
utils::{commands::CommandExt, explosions::Explosion, global_observer, trail::Trail}, utils::{commands::CommandExt, explosions::Explosion, global_observer, trail::Trail},
}; };
use avian3d::prelude::*; use avian3d::prelude::*;

View File

@@ -9,25 +9,23 @@ use crate::{
GameState, GameState,
aim::AimTarget, aim::AimTarget,
character::CharacterHierarchy, character::CharacterHierarchy,
control::ControlState,
global_observer, global_observer,
head::ActiveHead,
heads::ActiveHeads, heads::ActiveHeads,
heads_database::HeadsDatabase, heads_database::HeadsDatabase,
loading_assets::GameAssets, loading_assets::GameAssets,
physics_layers::GameLayer, physics_layers::GameLayer,
player::{Player, PlayerBodyMesh}, player::{Player, PlayerBodyMesh},
sounds::PlaySound, protocol::PlaySound,
utils::{billboards::Billboard, sprite_3d_animation::AnimationTimer}, utils::{billboards::Billboard, sprite_3d_animation::AnimationTimer},
}; };
#[cfg(feature = "server")]
use crate::{control::ControlState, head::ActiveHead};
use bevy::{pbr::NotShadowCaster, prelude::*}; use bevy::{pbr::NotShadowCaster, prelude::*};
use bevy_sprite3d::{Sprite3dBuilder, Sprite3dParams}; use bevy_sprite3d::{Sprite3dBuilder, Sprite3dParams};
pub use healing::Healing; pub use healing::Healing;
use healing::HealingStateChanged; use healing::HealingStateChanged;
use lightyear::{ #[cfg(feature = "server")]
connection::client::ClientState, use lightyear::prelude::input::native::ActionState;
prelude::{Client, input::native::ActionState},
};
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
#[derive(Debug, Copy, Clone, PartialEq, Reflect, Default, Serialize, Deserialize)] #[derive(Debug, Copy, Clone, PartialEq, Reflect, Default, Serialize, Deserialize)]
@@ -113,6 +111,7 @@ pub fn plugin(app: &mut App) {
Update, Update,
(update, update_heal_ability).run_if(in_state(GameState::Playing)), (update, update_heal_ability).run_if(in_state(GameState::Playing)),
); );
#[cfg(feature = "server")]
app.add_systems( app.add_systems(
FixedUpdate, FixedUpdate,
on_trigger_state.run_if(in_state(GameState::Playing)), on_trigger_state.run_if(in_state(GameState::Playing)),
@@ -121,19 +120,13 @@ pub fn plugin(app: &mut App) {
global_observer!(app, build_explosion_sprite); global_observer!(app, build_explosion_sprite);
} }
#[cfg(feature = "server")]
fn on_trigger_state( fn on_trigger_state(
mut res: ResMut<TriggerStateRes>, mut res: ResMut<TriggerStateRes>,
player: Query<(&ActiveHead, &ActionState<ControlState>), With<Player>>, player: Query<(&ActiveHead, &ActionState<ControlState>), With<Player>>,
headdb: Res<HeadsDatabase>, headdb: Res<HeadsDatabase>,
time: Res<Time>, time: Res<Time>,
client: Query<&Client>,
) { ) {
if let Ok(client) = client.single()
&& (client.state == ClientState::Connected && cfg!(not(feature = "server")))
{
return;
}
for (player_head, controls) in player.iter() { for (player_head, controls) in player.iter() {
res.active = controls.trigger; res.active = controls.trigger;
if controls.just_triggered { if controls.just_triggered {

View File

@@ -4,8 +4,7 @@ use crate::{
abilities::BuildExplosionSprite, abilities::BuildExplosionSprite,
heads_database::HeadsDatabase, heads_database::HeadsDatabase,
physics_layers::GameLayer, physics_layers::GameLayer,
protocol::GltfSceneRoot, protocol::{GltfSceneRoot, PlaySound},
sounds::PlaySound,
utils::{ utils::{
auto_rotate::AutoRotation, commands::CommandExt, explosions::Explosion, global_observer, auto_rotate::AutoRotation, commands::CommandExt, explosions::Explosion, global_observer,
}, },

View File

@@ -1,352 +1,40 @@
#[cfg(feature = "server")]
use super::Backpack;
use super::UiHeadState; use super::UiHeadState;
use crate::{ use bevy::prelude::*;
GameState, HEDZ_GREEN, loading_assets::UIAssets, protocol::PlayBackpackSound, sounds::PlaySound,
};
#[cfg(feature = "server")]
use crate::{backpack::BackbackSwapEvent, control::ControlState};
#[cfg(feature = "client")]
use crate::{global_observer, heads::HeadsImages};
use bevy::{ecs::spawn::SpawnIter, prelude::*};
#[cfg(feature = "server")]
use lightyear::prelude::{ActionsChannel, TriggerSender, input::native::ActionState};
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
static HEAD_SLOTS: usize = 5; pub static HEAD_SLOTS: usize = 5;
#[derive(Component, Default)] #[derive(Component, Default)]
struct BackpackMarker; pub struct BackpackMarker;
#[derive(Component, Default)] #[derive(Component, Default)]
struct BackpackCountText; pub struct BackpackCountText;
#[allow(unused)]
#[derive(Component, Default)] #[derive(Component, Default)]
struct HeadSelector(pub usize); pub struct HeadSelector(pub usize);
#[allow(unused)]
#[derive(Component, Default)] #[derive(Component, Default)]
struct HeadImage(pub usize); pub struct HeadImage(pub usize);
#[allow(unused)]
#[derive(Component, Default)] #[derive(Component, Default)]
struct HeadDamage(pub usize); pub struct HeadDamage(pub usize);
#[derive(Component, Default, Debug, Reflect, Serialize, Deserialize, PartialEq)] #[derive(Component, Default, Debug, Reflect, Serialize, Deserialize, PartialEq)]
#[reflect(Component, Default)] #[reflect(Component, Default)]
pub struct BackpackUiState { pub struct BackpackUiState {
heads: [Option<UiHeadState>; 5], pub heads: [Option<UiHeadState>; 5],
scroll: usize, pub scroll: usize,
count: usize, pub count: usize,
current_slot: usize, pub current_slot: usize,
open: bool, pub open: bool,
} }
#[cfg(feature = "client")]
impl BackpackUiState { impl BackpackUiState {
fn relative_current_slot(&self) -> usize { pub fn relative_current_slot(&self) -> usize {
self.current_slot.saturating_sub(self.scroll) self.current_slot.saturating_sub(self.scroll)
} }
} }
pub fn plugin(app: &mut App) { pub fn plugin(app: &mut App) {
app.register_type::<BackpackUiState>(); app.register_type::<BackpackUiState>();
app.add_systems(OnEnter(GameState::Playing), setup);
#[cfg(feature = "server")]
app.add_systems(
FixedUpdate,
sync_on_change.run_if(in_state(GameState::Playing)),
);
#[cfg(feature = "client")]
app.add_systems(
FixedUpdate,
(update, update_visibility, update_count).run_if(in_state(GameState::Playing)),
);
#[cfg(feature = "server")]
app.add_systems(FixedUpdate, swap_head_inputs);
#[cfg(feature = "client")]
global_observer!(app, play_backpack_sound);
}
fn setup(mut commands: Commands, assets: Res<UIAssets>) {
commands.spawn((
Name::new("backpack-ui"),
BackpackMarker,
Visibility::Hidden,
Node {
position_type: PositionType::Absolute,
top: Val::Px(20.0),
right: Val::Px(20.0),
height: Val::Px(74.0),
..default()
},
Children::spawn(SpawnIter((0..HEAD_SLOTS).map({
let bg = assets.head_bg.clone();
let regular = assets.head_regular.clone();
let selector = assets.head_selector.clone();
let damage = assets.head_damage.clone();
move |i| {
spawn_head_ui(
bg.clone(),
regular.clone(),
selector.clone(),
damage.clone(),
i,
)
}
}))),
));
commands.spawn((
Name::new("backpack-head-count-ui"),
Text::new("0"),
TextShadow::default(),
BackpackCountText,
TextFont {
font: assets.font.clone(),
font_size: 34.0,
..default()
},
TextColor(HEDZ_GREEN.into()),
TextLayout::new_with_justify(JustifyText::Center),
Node {
position_type: PositionType::Absolute,
top: Val::Px(20.0),
right: Val::Px(20.0),
..default()
},
));
}
fn spawn_head_ui(
bg: Handle<Image>,
regular: Handle<Image>,
selector: Handle<Image>,
damage: Handle<Image>,
head_slot: usize,
) -> impl Bundle {
const SIZE: f32 = 90.0;
const DAMAGE_SIZE: f32 = 74.0;
(
Node {
position_type: PositionType::Relative,
justify_content: JustifyContent::Center,
align_items: AlignItems::Center,
width: Val::Px(SIZE),
..default()
},
children![
(
Name::new("selector"),
Node {
position_type: PositionType::Absolute,
bottom: Val::Px(-30.0),
..default()
},
Visibility::Hidden,
ImageNode::new(selector).with_flip_y(),
HeadSelector(head_slot),
),
(
Name::new("bg"),
Node {
position_type: PositionType::Absolute,
..default()
},
ImageNode::new(bg),
),
(
Name::new("head"),
Node {
position_type: PositionType::Absolute,
..default()
},
ImageNode::default(),
Visibility::Hidden,
HeadImage(head_slot),
),
(
Name::new("rings"),
Node {
position_type: PositionType::Absolute,
..default()
},
ImageNode::new(regular),
),
(
Name::new("health"),
Node {
height: Val::Px(DAMAGE_SIZE),
width: Val::Px(DAMAGE_SIZE),
..default()
},
children![(
Name::new("damage_ring"),
HeadDamage(head_slot),
Node {
position_type: PositionType::Absolute,
display: Display::Block,
overflow: Overflow::clip(),
top: Val::Px(0.),
left: Val::Px(0.),
right: Val::Px(0.),
height: Val::Percent(0.),
..default()
},
children![ImageNode::new(damage)]
)]
)
],
)
}
#[cfg(feature = "client")]
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
};
}
#[cfg(feature = "client")]
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();
}
#[cfg(feature = "client")]
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
};
}
}
#[cfg(feature = "server")]
fn swap_head_inputs(
player: Query<(&ActionState<ControlState>, Ref<Backpack>)>,
mut trigger: Single<&mut TriggerSender<PlayBackpackSound>>,
mut commands: Commands,
mut state: Single<&mut BackpackUiState>,
time: Res<Time>,
) {
for (controls, backpack) in player.iter() {
if state.count == 0 {
return;
}
if controls.backpack_toggle {
state.open = !state.open;
trigger.trigger::<ActionsChannel>(PlayBackpackSound { open: state.open });
}
if !state.open {
return;
}
let mut changed = false;
if controls.backpack_left && state.current_slot > 0 {
state.current_slot -= 1;
changed = true;
}
if controls.backpack_right && state.current_slot < state.count.saturating_sub(1) {
state.current_slot += 1;
changed = true;
}
if controls.backpack_swap {
commands.trigger(BackbackSwapEvent(state.current_slot));
}
if changed {
commands.trigger(PlaySound::Selection);
sync(&backpack, &mut state, time.elapsed_secs());
}
}
}
#[cfg(feature = "client")]
fn play_backpack_sound(trigger: Trigger<PlayBackpackSound>, mut commands: Commands) {
commands.trigger(PlaySound::Backpack {
open: trigger.event().open,
});
}
#[cfg(feature = "server")]
fn sync_on_change(
backpack: Query<Ref<Backpack>>,
mut state: Single<&mut BackpackUiState>,
time: Res<Time>,
) {
for backpack in backpack.iter() {
if backpack.is_changed() || backpack.reloading() {
sync(&backpack, &mut state, time.elapsed_secs());
}
}
}
#[cfg(feature = "server")]
fn sync(backpack: &Backpack, state: &mut Single<&mut BackpackUiState>, time: f32) {
state.count = backpack.heads.len();
state.scroll = state.scroll.min(state.count.saturating_sub(HEAD_SLOTS));
if state.current_slot >= state.scroll + HEAD_SLOTS {
state.scroll = state.current_slot.saturating_sub(HEAD_SLOTS - 1);
}
if state.current_slot < state.scroll {
state.scroll = state.current_slot;
}
for i in 0..HEAD_SLOTS {
if let Some(head) = backpack.heads.get(i + state.scroll) {
state.heads[i] = Some(UiHeadState::new(*head, time));
} else {
state.heads[i] = None;
}
}
} }

View File

@@ -1,4 +1,3 @@
#[cfg(feature = "server")]
use crate::heads::HeadState; use crate::heads::HeadState;
use bevy::prelude::*; use bevy::prelude::*;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
@@ -24,8 +23,7 @@ impl UiHeadState {
self.reloading self.reloading
} }
#[cfg(feature = "server")] pub fn new(value: HeadState, time: f32) -> Self {
pub(crate) fn new(value: HeadState, time: f32) -> Self {
let reloading = if value.has_ammo() { let reloading = if value.has_ammo() {
None None
} else { } else {

View File

@@ -1,6 +1,6 @@
use crate::{GameState, HEDZ_GREEN, loading_assets::UIAssets}; use crate::{GameState, HEDZ_GREEN, loading_assets::UIAssets};
#[cfg(feature = "server")] #[cfg(feature = "server")]
use crate::{global_observer, sounds::PlaySound}; use crate::{global_observer, protocol::PlaySound};
use bevy::prelude::*; use bevy::prelude::*;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};

View File

@@ -1,6 +1,6 @@
use crate::{ use crate::{
cash::CashResource, control::ControlState, hitpoints::Hitpoints, player::Player, cash::CashResource, control::ControlState, hitpoints::Hitpoints, player::Player,
sounds::PlaySound, protocol::PlaySound,
}; };
use bevy::prelude::*; use bevy::prelude::*;
use lightyear::prelude::input::native::ActionState; use lightyear::prelude::input::native::ActionState;

View File

@@ -9,6 +9,7 @@ use bevy::{
animation::RepeatAnimation, ecs::system::SystemParam, platform::collections::HashMap, animation::RepeatAnimation, ecs::system::SystemParam, platform::collections::HashMap,
prelude::*, scene::SceneInstanceReady, prelude::*, scene::SceneInstanceReady,
}; };
use lightyear::prelude::DisableReplicateHierarchy;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use std::{f32::consts::PI, time::Duration}; use std::{f32::consts::PI, time::Duration};
@@ -16,6 +17,7 @@ use std::{f32::consts::PI, time::Duration};
pub struct ProjectileOrigin; pub struct ProjectileOrigin;
#[derive(Component, Debug, Serialize, Deserialize, PartialEq)] #[derive(Component, Debug, Serialize, Deserialize, PartialEq)]
#[require(Visibility, GlobalTransform)]
pub struct AnimatedCharacter { pub struct AnimatedCharacter {
head: usize, head: usize,
} }
@@ -107,14 +109,19 @@ fn spawn(
transform.rotate_y(PI); transform.rotate_y(PI);
commands.entity(entity).despawn_related::<Children>();
commands commands
.entity(entity) .spawn((
.insert((
transform,
SceneRoot(asset.scenes[0].clone()), SceneRoot(asset.scenes[0].clone()),
AnimatedCharacterAsset(handle.clone()), DisableReplicateHierarchy,
ChildOf(entity),
)) ))
.observe(find_marker_bones); .observe(find_marker_bones);
commands
.entity(entity)
.insert((transform, AnimatedCharacterAsset(handle.clone())));
} }
} }
@@ -162,14 +169,14 @@ fn find_marker_bones(
#[derive(Component, Default, Reflect, PartialEq, Serialize, Deserialize)] #[derive(Component, Default, Reflect, PartialEq, Serialize, Deserialize)]
#[reflect(Component)] #[reflect(Component)]
pub struct Character; pub struct HedzCharacter;
fn setup_once_loaded( fn setup_once_loaded(
mut commands: Commands, mut commands: Commands,
mut query: Query<(Entity, &mut AnimationPlayer), Added<AnimationPlayer>>, mut query: Query<(Entity, &mut AnimationPlayer), Added<AnimationPlayer>>,
parent: Query<&ChildOf>, parent: Query<&ChildOf>,
animated_character: Query<(&AnimatedCharacter, &AnimatedCharacterAsset)>, animated_character: Query<(&AnimatedCharacter, &AnimatedCharacterAsset)>,
characters: Query<Entity, With<Character>>, characters: Query<Entity, With<HedzCharacter>>,
gltf_assets: Res<Assets<Gltf>>, gltf_assets: Res<Assets<Gltf>>,
mut graphs: ResMut<Assets<AnimationGraph>>, mut graphs: ResMut<Assets<AnimationGraph>>,
) { ) {

View File

@@ -55,14 +55,12 @@ fn rotate_rig(
} }
fn apply_controls( fn apply_controls(
mut character: Query<(&mut MoveInput, &MovementSpeedFactor)>, character: Single<(&mut MoveInput, &MovementSpeedFactor)>,
rig_transform_q: Option<Single<&GlobalTransform, With<PlayerBodyMesh>>>, rig_transform_q: Option<Single<&GlobalTransform, With<PlayerBodyMesh>>>,
) -> Result { ) {
let (mut char_input, factor) = character.single_mut()?; let (mut char_input, factor) = character.into_inner();
if let Some(ref rig_transform) = rig_transform_q { if let Some(ref rig_transform) = rig_transform_q {
char_input.set(-*rig_transform.forward() * factor.0); char_input.set(-*rig_transform.forward() * factor.0);
} }
Ok(())
} }

View File

@@ -1,6 +1,4 @@
use super::{ControlState, Controls}; use super::{ControlState, Controls};
#[cfg(feature = "client")]
use crate::player::Player;
use crate::{ use crate::{
GameState, GameState,
control::{CharacterInputEnabled, ControllerSet}, control::{CharacterInputEnabled, ControllerSet},
@@ -14,7 +12,7 @@ use bevy::{
prelude::*, prelude::*,
}; };
#[cfg(feature = "client")] #[cfg(feature = "client")]
use lightyear::prelude::input::native::ActionState; use lightyear::prelude::input::native::{ActionState, InputMarker};
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
pub fn plugin(app: &mut App) { pub fn plugin(app: &mut App) {
@@ -66,7 +64,7 @@ pub struct ControllerSettings {
/// Write inputs from combined keyboard/gamepad state into the networked input buffer /// Write inputs from combined keyboard/gamepad state into the networked input buffer
/// for the local player. /// for the local player.
fn buffer_inputs( fn buffer_inputs(
mut player: Single<&mut ActionState<ControlState>, With<Player>>, mut player: Single<&mut ActionState<ControlState>, With<InputMarker<ControlState>>>,
controls: Res<ControlState>, controls: Res<ControlState>,
) { ) {
player.0 = *controls; player.0 = *controls;

View File

@@ -1,6 +1,6 @@
use crate::{ use crate::{
cutscene::StartCutscene, global_observer, keys::KeyCollected, movables::TriggerMovableEvent, cutscene::StartCutscene, global_observer, keys::KeyCollected, movables::TriggerMovableEvent,
sounds::PlaySound, protocol::PlaySound,
}; };
use bevy::{platform::collections::HashSet, prelude::*}; use bevy::{platform::collections::HashSet, prelude::*};

View File

@@ -1,7 +1,13 @@
use crate::{ use crate::{
GameState, billboards::Billboard, global_observer, heads_database::HeadsDatabase, GameState,
physics_layers::GameLayer, player::Player, protocol::GltfSceneRoot, sounds::PlaySound, billboards::Billboard,
squish_animation::SquishAnimation, tb_entities::SecretHead, global_observer,
heads_database::HeadsDatabase,
physics_layers::GameLayer,
player::Player,
protocol::{GltfSceneRoot, PlaySound},
squish_animation::SquishAnimation,
tb_entities::SecretHead,
}; };
use avian3d::prelude::*; use avian3d::prelude::*;
use bevy::{ use bevy::{

View File

@@ -5,14 +5,15 @@ use crate::animation::AnimationFlags;
use crate::{ use crate::{
GameState, GameState,
backpack::{BackbackSwapEvent, Backpack}, backpack::{BackbackSwapEvent, Backpack},
control::ControlState,
global_observer, global_observer,
heads_database::HeadsDatabase, heads_database::HeadsDatabase,
hitpoints::Hitpoints, hitpoints::Hitpoints,
player::Player, player::Player,
sounds::PlaySound,
}; };
#[cfg(feature = "server")]
use crate::{control::ControlState, protocol::PlaySound};
use bevy::prelude::*; use bevy::prelude::*;
#[cfg(feature = "server")]
use lightyear::prelude::input::native::ActionState; use lightyear::prelude::input::native::ActionState;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
@@ -188,6 +189,7 @@ pub fn plugin(app: &mut App) {
FixedUpdate, FixedUpdate,
(reload, sync_hp).run_if(in_state(GameState::Playing)), (reload, sync_hp).run_if(in_state(GameState::Playing)),
); );
#[cfg(feature = "server")]
app.add_systems(FixedUpdate, on_select_active_head); app.add_systems(FixedUpdate, on_select_active_head);
global_observer!(app, on_swap_backpack); global_observer!(app, on_swap_backpack);
@@ -238,6 +240,7 @@ fn reload(
} }
} }
#[cfg(feature = "server")]
fn on_select_active_head( fn on_select_active_head(
mut commands: Commands, mut commands: Commands,
mut query: Query<(&mut ActiveHeads, &mut Hitpoints, &ActionState<ControlState>), With<Player>>, mut query: Query<(&mut ActiveHeads, &mut Hitpoints, &ActionState<ControlState>), With<Player>>,

View File

@@ -2,7 +2,7 @@ use crate::{
GameState, GameState,
animation::AnimationFlags, animation::AnimationFlags,
character::{CharacterAnimations, HasCharacterAnimations}, character::{CharacterAnimations, HasCharacterAnimations},
sounds::PlaySound, protocol::PlaySound,
}; };
use bevy::prelude::*; use bevy::prelude::*;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};

View File

@@ -1,6 +1,10 @@
use crate::{ use crate::{
billboards::Billboard, global_observer, physics_layers::GameLayer, player::Player, billboards::Billboard,
protocol::GltfSceneRoot, sounds::PlaySound, squish_animation::SquishAnimation, global_observer,
physics_layers::GameLayer,
player::Player,
protocol::{GltfSceneRoot, PlaySound},
squish_animation::SquishAnimation,
}; };
use avian3d::prelude::*; use avian3d::prelude::*;
use bevy::{ use bevy::{

View File

@@ -14,7 +14,6 @@ pub mod head;
pub mod head_drop; pub mod head_drop;
pub mod heads; pub mod heads;
pub mod heads_database; pub mod heads_database;
pub mod heal_effect;
pub mod hitpoints; pub mod hitpoints;
pub mod keys; pub mod keys;
pub mod loading_assets; pub mod loading_assets;
@@ -25,7 +24,6 @@ pub mod physics_layers;
pub mod platforms; pub mod platforms;
pub mod player; pub mod player;
pub mod protocol; pub mod protocol;
pub mod sounds;
pub mod steam; pub mod steam;
pub mod tb_entities; pub mod tb_entities;
pub mod utils; pub mod utils;

View File

@@ -124,15 +124,16 @@ pub struct LoadingPlugin;
impl Plugin for LoadingPlugin { impl Plugin for LoadingPlugin {
fn build(&self, app: &mut App) { fn build(&self, app: &mut App) {
app.add_systems(OnExit(GameState::AssetLoading), on_exit); app.add_systems(OnExit(GameState::AssetLoading), on_exit);
app.add_loading_state( let loading_state = LoadingState::new(GameState::AssetLoading);
LoadingState::new(GameState::AssetLoading) let loading_state = loading_state
.continue_to_state(GameState::MapLoading) .continue_to_state(GameState::MapLoading)
.load_collection::<AudioAssets>() .load_collection::<GameAssets>()
.load_collection::<GameAssets>() .load_collection::<HeadsAssets>()
.load_collection::<HeadsAssets>() .load_collection::<HeadDropAssets>()
.load_collection::<HeadDropAssets>() .load_collection::<UIAssets>();
.load_collection::<UIAssets>(), #[cfg(feature = "client")]
); let loading_state = loading_state.load_collection::<AudioAssets>();
app.add_loading_state(loading_state);
} }
} }

View File

@@ -1,5 +1,5 @@
use crate::{ use crate::{
GameState, character::Character, global_observer, loading_assets::GameAssets, GameState, character::HedzCharacter, global_observer, loading_assets::GameAssets,
utils::billboards::Billboard, utils::billboards::Billboard,
}; };
#[cfg(feature = "server")] #[cfg(feature = "server")]
@@ -12,7 +12,7 @@ use crate::{
heads_database::HeadsDatabase, heads_database::HeadsDatabase,
hitpoints::{Hitpoints, Kill}, hitpoints::{Hitpoints, Kill},
keys::KeySpawn, keys::KeySpawn,
sounds::PlaySound, protocol::PlaySound,
tb_entities::EnemySpawn, tb_entities::EnemySpawn,
}; };
use bevy::{pbr::NotShadowCaster, prelude::*}; use bevy::{pbr::NotShadowCaster, prelude::*};
@@ -24,7 +24,7 @@ use std::collections::HashMap;
#[derive(Component, Reflect, PartialEq, Serialize, Deserialize)] #[derive(Component, Reflect, PartialEq, Serialize, Deserialize)]
#[reflect(Component)] #[reflect(Component)]
#[require(Character)] #[require(HedzCharacter)]
pub struct Npc; pub struct Npc;
#[derive(Resource, Reflect, Default)] #[derive(Resource, Reflect, Default)]
@@ -102,7 +102,6 @@ fn on_spawn_check(
None, None,
None, None,
]), ]),
#[cfg(feature = "server")]
Replicate::to_clients(NetworkTarget::All), Replicate::to_clients(NetworkTarget::All),
)) ))
.insert_if(Ai, || !spawn.disable_ai) .insert_if(Ai, || !spawn.disable_ai)

View File

@@ -1,13 +1,7 @@
use crate::{ use crate::{
GameState, GameState,
cash::{Cash, CashCollectEvent}, cash::{Cash, CashCollectEvent},
character::{AnimatedCharacter, Character}, character::HedzCharacter,
global_observer,
head::ActiveHead,
heads::HeadChanged,
heads_database::{HeadControls, HeadsDatabase},
loading_assets::AudioAssets,
sounds::PlaySound,
}; };
use avian3d::prelude::*; use avian3d::prelude::*;
use bevy::{ use bevy::{
@@ -18,7 +12,7 @@ use bevy::{
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
#[derive(Component, Default, Serialize, Deserialize, PartialEq)] #[derive(Component, Default, Serialize, Deserialize, PartialEq)]
#[require(Character)] #[require(HedzCharacter)]
pub struct Player; pub struct Player;
#[derive(Component, Default, Serialize, Deserialize, PartialEq)] #[derive(Component, Default, Serialize, Deserialize, PartialEq)]
@@ -36,8 +30,6 @@ pub fn plugin(app: &mut App) {
) )
.run_if(in_state(GameState::Playing)), .run_if(in_state(GameState::Playing)),
); );
global_observer!(app, on_update_head_mesh);
} }
fn cursor_recenter(q_windows: Single<&mut Window, With<PrimaryWindow>>) { fn cursor_recenter(q_windows: Single<&mut Window, With<PrimaryWindow>>) {
@@ -103,48 +95,3 @@ fn setup_animations_marker_for_player(
} }
} }
} }
fn on_update_head_mesh(
trigger: Trigger<HeadChanged>,
mut commands: Commands,
body_mesh: Single<(Entity, &Children), With<PlayerBodyMesh>>,
animated_characters: Query<&AnimatedCharacter>,
mut player: Single<&mut ActiveHead, With<Player>>,
head_db: Res<HeadsDatabase>,
audio_assets: Res<AudioAssets>,
sfx: Query<&AudioPlayer>,
) -> Result {
let (body_mesh, mesh_children) = *body_mesh;
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;
let head_str = head_db.head_key(trigger.0);
commands.trigger(PlaySound::Head(head_str.to_string()));
commands
.entity(animated_char)
.insert(AnimatedCharacter::new(trigger.0));
//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(trigger.0).controls == HeadControls::Plane {
commands.entity(body_mesh).with_child((
Name::new("sfx"),
AudioPlayer::new(audio_assets.jet.clone()),
PlaybackSettings {
mode: bevy::audio::PlaybackMode::Loop,
..Default::default()
},
));
}
Ok(())
}

View File

@@ -0,0 +1 @@
pub struct UnorderedReliableChannel;

View File

@@ -0,0 +1,82 @@
use crate::{
loading_assets::{GameAssets, HeadDropAssets},
protocol::TbMapEntityMapping,
};
use bevy::{
ecs::{component::HookContext, world::DeferredWorld},
prelude::*,
};
use serde::{Deserialize, Serialize};
#[derive(Clone, Copy, Component, Reflect, Serialize, Deserialize, PartialEq)]
#[reflect(Component)]
pub struct PlayerId {
pub id: u8,
}
/// 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;
world
.resource_mut::<TbMapEntityMapping>()
.insert(id, 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: Trigger<OnAdd, 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.target())?;
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.target()).insert(SceneRoot(scene));
Ok(())
}

View File

@@ -0,0 +1,31 @@
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, Event, Serialize, Deserialize)]
pub struct ClientEnteredPlaying;

View File

@@ -0,0 +1,9 @@
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, Serialize, Deserialize)]
pub struct DespawnTbMapEntity(pub u64);
#[derive(Clone, Copy, Serialize, Deserialize)]
pub struct AssignClientPlayer(pub u8);

View File

@@ -1,6 +1,11 @@
pub mod channels;
pub mod components;
pub mod events;
pub mod messages;
use crate::{ use crate::{
GameState, GameState,
abilities::BuildExplosionSprite, abilities::{BuildExplosionSprite, healing::Healing},
animation::AnimationFlags, animation::AnimationFlags,
backpack::{Backpack, backpack_ui::BackpackUiState}, backpack::{Backpack, backpack_ui::BackpackUiState},
camera::{CameraArmRotation, CameraTarget}, camera::{CameraArmRotation, CameraTarget},
@@ -16,16 +21,15 @@ use crate::{
head::ActiveHead, head::ActiveHead,
heads::{ActiveHeads, heads_ui::UiActiveHeads}, heads::{ActiveHeads, heads_ui::UiActiveHeads},
hitpoints::Hitpoints, hitpoints::Hitpoints,
loading_assets::{GameAssets, HeadDropAssets},
platforms::ActivePlatform, platforms::ActivePlatform,
player::{Player, PlayerBodyMesh}, player::{Player, PlayerBodyMesh},
protocol::channels::UnorderedReliableChannel,
utils::triggers::TriggerAppExt, utils::triggers::TriggerAppExt,
}; };
use avian3d::prelude::{AngularVelocity, CollisionLayers, LinearVelocity}; use avian3d::prelude::{AngularVelocity, CollisionLayers, LinearVelocity};
use bevy::{ use bevy::prelude::*;
ecs::{component::HookContext, world::DeferredWorld}, pub use components::*;
prelude::*, pub use events::*;
};
use happy_feet::{ use happy_feet::{
grounding::GroundingState, grounding::GroundingState,
prelude::{ prelude::{
@@ -34,18 +38,19 @@ use happy_feet::{
}, },
}; };
use lightyear::prelude::{ use lightyear::prelude::{
ActionsChannel, AppComponentExt, AppMessageExt, NetworkDirection, PredictionMode, AppChannelExt, AppComponentExt, AppMessageExt, AppTriggerExt, ChannelMode, ChannelSettings,
PredictionRegistrationExt, input::native::InputPlugin, NetworkDirection, PredictionMode, PredictionRegistrationExt, ReliableSettings,
input::native::InputPlugin,
}; };
use lightyear_serde::{ use lightyear_serde::{
SerializationError, reader::ReadInteger, registry::SerializeFns, writer::WriteInteger, SerializationError, reader::ReadInteger, registry::SerializeFns, writer::WriteInteger,
}; };
use serde::{Deserialize, Serialize}; use std::{collections::HashMap, time::Duration};
use std::collections::HashMap;
pub fn plugin(app: &mut App) { pub fn plugin(app: &mut App) {
app.add_plugins(InputPlugin::<ControlState>::default()); app.add_plugins(InputPlugin::<ControlState>::default());
app.register_type::<PlayerId>();
app.register_type::<TbMapEntityId>(); app.register_type::<TbMapEntityId>();
app.register_type::<TbMapIdCounter>(); app.register_type::<TbMapIdCounter>();
app.register_type::<TbMapEntityMapping>(); app.register_type::<TbMapEntityMapping>();
@@ -53,9 +58,21 @@ pub fn plugin(app: &mut App) {
app.init_resource::<TbMapIdCounter>(); app.init_resource::<TbMapIdCounter>();
app.init_resource::<TbMapEntityMapping>(); app.init_resource::<TbMapEntityMapping>();
app.add_message::<DespawnTbMapEntity>() app.add_channel::<UnorderedReliableChannel>(ChannelSettings {
mode: ChannelMode::UnorderedReliable(ReliableSettings::default()),
send_frequency: Duration::from_millis(100),
priority: 1.0,
})
.add_direction(NetworkDirection::Bidirectional);
app.add_message::<messages::DespawnTbMapEntity>()
.add_direction(NetworkDirection::ServerToClient);
app.add_message::<messages::AssignClientPlayer>()
.add_direction(NetworkDirection::ServerToClient); .add_direction(NetworkDirection::ServerToClient);
app.register_component::<components::GltfSceneRoot>();
app.register_component::<components::PlayerId>();
app.register_component::<components::TbMapEntityId>();
app.register_component::<ActiveHead>(); app.register_component::<ActiveHead>();
app.register_component::<ActiveHeads>(); app.register_component::<ActiveHeads>();
app.register_component::<ActivePlatform>(); app.register_component::<ActivePlatform>();
@@ -68,17 +85,17 @@ pub fn plugin(app: &mut App) {
app.register_component::<CameraTarget>(); app.register_component::<CameraTarget>();
app.register_component::<CashResource>(); app.register_component::<CashResource>();
app.register_component::<happy_feet::prelude::Character>(); app.register_component::<happy_feet::prelude::Character>();
app.register_component::<character::Character>(); app.register_component::<character::HedzCharacter>();
app.register_component::<CharacterDrag>(); app.register_component::<CharacterDrag>();
app.register_component::<CharacterGravity>(); app.register_component::<CharacterGravity>();
app.register_component::<CharacterMovement>(); app.register_component::<CharacterMovement>();
app.register_component::<CollisionLayers>(); app.register_component::<CollisionLayers>();
app.register_component::<ControllerSettings>(); app.register_component::<ControllerSettings>();
app.register_component::<GltfSceneRoot>();
app.register_component::<GroundFriction>(); app.register_component::<GroundFriction>();
app.register_component::<Grounding>(); app.register_component::<Grounding>();
app.register_component::<GroundingConfig>(); app.register_component::<GroundingConfig>();
app.register_component::<GroundingState>(); app.register_component::<GroundingState>();
app.register_component::<Healing>();
app.register_component::<Hitpoints>(); app.register_component::<Hitpoints>();
app.register_component::<KinematicVelocity>(); app.register_component::<KinematicVelocity>();
app.register_component::<LinearVelocity>(); app.register_component::<LinearVelocity>();
@@ -89,7 +106,6 @@ pub fn plugin(app: &mut App) {
app.register_component::<PlayerBodyMesh>(); app.register_component::<PlayerBodyMesh>();
app.register_component::<PlayerCharacterController>(); app.register_component::<PlayerCharacterController>();
app.register_component::<SteppingConfig>(); app.register_component::<SteppingConfig>();
app.register_component::<TbMapEntityId>();
app.register_component::<Transform>() app.register_component::<Transform>()
.add_prediction(PredictionMode::Full) .add_prediction(PredictionMode::Full)
.add_should_rollback(transform_should_rollback); .add_should_rollback(transform_should_rollback);
@@ -108,52 +124,28 @@ pub fn plugin(app: &mut App) {
}, },
}); });
app.replicate_trigger::<BuildExplosionSprite, ActionsChannel>(); app.replicate_trigger::<BuildExplosionSprite, UnorderedReliableChannel>();
app.replicate_trigger::<StartCutscene, ActionsChannel>(); app.replicate_trigger::<StartCutscene, UnorderedReliableChannel>();
app.replicate_trigger::<PlayBackpackSound, ActionsChannel>(); app.replicate_trigger::<events::ClientHeadChanged, UnorderedReliableChannel>();
app.replicate_trigger::<events::PlaySound, UnorderedReliableChannel>();
app.add_trigger::<events::ClientEnteredPlaying>()
.add_direction(NetworkDirection::ClientToServer);
app.add_systems( app.add_systems(
OnEnter(GameState::MapLoading), OnEnter(GameState::MapLoading),
|mut counter: ResMut<TbMapIdCounter>| counter.reset(), |mut counter: ResMut<TbMapIdCounter>| counter.reset(),
); );
global_observer!(app, spawn_gltf_scene_roots); global_observer!(app, components::spawn_gltf_scene_roots);
} }
fn transform_should_rollback(this: &Transform, that: &Transform) -> bool { fn transform_should_rollback(this: &Transform, that: &Transform) -> bool {
this.translation.distance_squared(that.translation) >= 0.01f32.powf(2.) this.translation.distance_squared(that.translation) >= 0.01f32.powf(2.)
} }
/// Trenchbroom map entities (spawned during map loading) must be despawned manually if the server /// A global allocator for `TbMapEntityId` values. Should be reset when a map begins loading.
/// has already despawned it but the client has just loaded the map and connected
#[derive(Clone, Copy, Serialize, Deserialize)]
pub struct DespawnTbMapEntity(pub u64);
/// 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;
world
.resource_mut::<TbMapEntityMapping>()
.insert(id, 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);
}
}
/// A global allocator for `TBMapEntityId` values. Should be reset when a map begins loading.
#[derive(Resource, Reflect, Default)] #[derive(Resource, Reflect, Default)]
#[reflect(Resource)] #[reflect(Resource)]
pub struct TbMapIdCounter(u64); pub struct TbMapIdCounter(u64);
@@ -176,52 +168,3 @@ impl TbMapIdCounter {
#[derive(Resource, Reflect, Default, Deref, DerefMut)] #[derive(Resource, Reflect, Default, Deref, DerefMut)]
#[reflect(Resource)] #[reflect(Resource)]
pub struct TbMapEntityMapping(pub HashMap<u64, Entity>); pub struct TbMapEntityMapping(pub HashMap<u64, Entity>);
#[derive(Clone, Event, Serialize, Deserialize)]
pub struct PlayBackpackSound {
pub open: bool,
}
#[derive(Component, Reflect, Serialize, Deserialize, PartialEq)]
#[reflect(Component)]
pub enum GltfSceneRoot {
Projectile(String),
HeadDrop(String),
Key,
}
fn spawn_gltf_scene_roots(
trigger: Trigger<OnAdd, 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.target())?;
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.target()).insert(SceneRoot(scene));
Ok(())
}

View File

@@ -1,5 +1,6 @@
use crate::{ use crate::{
cash::Cash, loading_assets::GameAssets, physics_layers::GameLayer, protocol::TbMapIdCounter, cash::Cash, loading_assets::GameAssets, physics_layers::GameLayer, protocol::TbMapIdCounter,
utils::global_observer,
}; };
use avian3d::prelude::*; use avian3d::prelude::*;
use bevy::{ use bevy::{
@@ -206,10 +207,10 @@ pub fn plugin(app: &mut App) {
app.register_type::<CashSpawn>(); app.register_type::<CashSpawn>();
app.register_type::<SecretHead>(); app.register_type::<SecretHead>();
app.add_observer(tb_component_setup::<CashSpawn>); global_observer!(app, tb_component_setup::<CashSpawn>);
app.add_observer(tb_component_setup::<Platform>); global_observer!(app, tb_component_setup::<Platform>);
app.add_observer(tb_component_setup::<PlatformTarget>); global_observer!(app, tb_component_setup::<PlatformTarget>);
app.add_observer(tb_component_setup::<Movable>); global_observer!(app, tb_component_setup::<Movable>);
} }
fn tb_component_setup<C: Component>(trigger: Trigger<OnAdd, C>, world: &mut World) { fn tb_component_setup<C: Component>(trigger: Trigger<OnAdd, C>, world: &mut World) {

View File

@@ -2,11 +2,26 @@ use bevy::prelude::*;
#[macro_export] #[macro_export]
macro_rules! global_observer { macro_rules! global_observer {
($app:expr,$system:expr) => {{ ($app:expr, $($system:tt)*) => {{
$app.world_mut() $app.world_mut()
.add_observer($system) .add_observer($($system)*)
.insert(Name::new(stringify!($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; pub use global_observer;
@@ -30,6 +45,6 @@ fn global_observers(
}; };
for o in query.iter() { for o in query.iter() {
cmds.entity(root).add_child(o); cmds.entity(o).try_insert(ChildOf(root));
} }
} }

View File

@@ -1,3 +1,4 @@
use crate::utils::global_observer;
use bevy::{ecs::system::SystemParam, prelude::*}; use bevy::{ecs::system::SystemParam, prelude::*};
use lightyear::prelude::{AppTriggerExt, Channel, NetworkDirection, RemoteTrigger, TriggerSender}; use lightyear::prelude::{AppTriggerExt, Channel, NetworkDirection, RemoteTrigger, TriggerSender};
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
@@ -31,8 +32,8 @@ impl TriggerAppExt for App {
) { ) {
self.add_trigger::<M>() self.add_trigger::<M>()
.add_direction(NetworkDirection::ServerToClient); .add_direction(NetworkDirection::ServerToClient);
self.add_observer(replicate_trigger_to_clients::<M, C>); global_observer!(self, replicate_trigger_to_clients::<M, C>);
self.add_observer(remote_to_local_trigger::<M>); global_observer!(self, remote_to_local_trigger::<M>);
} }
} }

View File

@@ -12,6 +12,9 @@ run *args:
cargo b {{server_args}} cargo b {{server_args}}
RUST_BACKTRACE=1 cargo r {{client_args}} -- {{args}} RUST_BACKTRACE=1 cargo r {{client_args}} -- {{args}}
client *args:
RUST_BACKTRACE=1 cargo r {{client_args}} -- {{args}}
server: server:
RUST_BACKTRACE=1 cargo r {{server_args}} RUST_BACKTRACE=1 cargo r {{server_args}}
@@ -19,6 +22,9 @@ dbg *args:
cargo b {{server_args}},dbg cargo b {{server_args}},dbg
RUST_BACKTRACE=1 cargo r {{client_args}},dbg -- {{args}} RUST_BACKTRACE=1 cargo r {{client_args}},dbg -- {{args}}
dbg-client *args:
RUST_BACKTRACE=1 cargo r {{client_args}},dbg -- {{args}}
dbg-server: dbg-server:
RUST_BACKTRACE=1 cargo r {{server_args}},dbg RUST_BACKTRACE=1 cargo r {{server_args}},dbg