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

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

View File

@@ -0,0 +1,151 @@
use crate::{
GameState, abilities::Healing, loading_assets::GameAssets, utils::billboards::Billboard,
};
use bevy::prelude::*;
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)]
#[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: 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(
mut commands: Commands,
mut query: Query<(&mut HealParticleEffect, &HealingEffectsOf, Entity)>,
mut transforms: Query<&mut Transform>,
time: Res<Time>,
assets: Res<GameAssets>,
) {
const DISTANCE: f32 = 4.;
let mut rng = thread_rng();
let now = time.elapsed_secs();
for (mut effect, effects_of, e) in query.iter_mut() {
// We have to manually track the healer's position because lightyear will try to synchronize
// children and there's no reason to synchronize the particle effect entity when we're already
// synchronizing `Healing`
// (trying to ignore/avoid it by excluding the child from replication just causes crashes)
let healer_pos = transforms.get(effects_of.of).unwrap().translation;
transforms.get_mut(e).unwrap().translation = healer_pos;
if effect.next_spawn < now {
let start_pos = Vec3::new(
rng.gen_range(-DISTANCE..DISTANCE),
2.,
rng.gen_range(-DISTANCE..DISTANCE),
);
let max_distance = start_pos.length().max(0.8);
let end_pos =
start_pos + (start_pos.normalize() * -1.) * rng.gen_range(0.5..max_distance);
let start_scale = rng.gen_range(0.7..1.0);
let end_scale = rng.gen_range(0.1..start_scale);
commands.entity(e).with_child((
Name::new("heal-particle"),
SceneRoot(assets.mesh_heal_particle.clone()),
Billboard::All,
Transform::from_translation(start_pos),
HealParticle {
start_scale,
end_scale,
start_pos,
end_pos,
start_time: now,
life_time: rng.gen_range(0.3..1.0),
},
));
effect.next_spawn = now + rng.gen_range(0.1..0.5);
}
}
}
fn update_particles(
mut cmds: Commands,
mut query: Query<(&mut Transform, &HealParticle, Entity)>,
time: Res<Time>,
) {
for (mut transform, particle, e) in query.iter_mut() {
if particle.start_time + particle.life_time < time.elapsed_secs() {
cmds.entity(e).despawn();
continue;
}
let t = (time.elapsed_secs() - particle.start_time) / particle.life_time;
// info!("particle[{e:?}] t: {t}");
transform.translation = particle.start_pos.lerp(particle.end_pos, t);
transform.scale = Vec3::splat(particle.start_scale.lerp(particle.end_scale, t));
}
}

View File

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

@@ -0,0 +1,67 @@
use crate::{global_observer, loading_assets::AudioAssets};
use bevy::prelude::*;
use shared::protocol::PlaySound;
pub fn plugin(app: &mut App) {
global_observer!(app, on_spawn_sounds);
}
fn on_spawn_sounds(
trigger: Trigger<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()
},
));
}