Crate unification (#88)

* move client/server/config into shared

* move platforms into shared

* move head drops into shared

* move tb_entities to shared

* reduce server to just a call into shared

* get solo play working

* fix server opening window

* fix fmt

* extracted a few more modules from client

* near completely migrated client

* fixed duplicate CharacterInputEnabled definition

* simplify a few things related to builds

* more simplifications

* fix warnings/check

* ci update

* address comments

* try fixing macos steam build

* address comments

* address comments

* CI tweaks with default client feature

---------

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

View File

@@ -0,0 +1,212 @@
use crate::{
GameState, HEDZ_GREEN,
backpack::backpack_ui::{
BACKPACK_HEAD_SLOTS, BackpackCountText, BackpackMarker, BackpackUiState, HeadDamage,
HeadImage, HeadSelector,
},
heads::HeadsImages,
loading_assets::UIAssets,
};
use bevy::{ecs::spawn::SpawnIter, prelude::*};
pub fn plugin(app: &mut App) {
app.add_systems(OnEnter(GameState::Playing), setup);
app.add_systems(
FixedUpdate,
(update, update_visibility, update_count).run_if(in_state(GameState::Playing)),
);
}
fn setup(mut commands: Commands, assets: Res<UIAssets>) {
commands.spawn((
Name::new("backpack-ui"),
BackpackMarker,
Visibility::Hidden,
Node {
position_type: PositionType::Absolute,
top: Val::Px(20.0),
right: Val::Px(20.0),
height: Val::Px(74.0),
..default()
},
Children::spawn(SpawnIter((0..BACKPACK_HEAD_SLOTS).map({
let bg = assets.head_bg.clone();
let regular = assets.head_regular.clone();
let selector = assets.head_selector.clone();
let damage = assets.head_damage.clone();
move |i| {
spawn_head_ui(
bg.clone(),
regular.clone(),
selector.clone(),
damage.clone(),
i,
)
}
}))),
));
commands.spawn((
Name::new("backpack-head-count-ui"),
Text::new("0"),
TextShadow::default(),
BackpackCountText,
TextFont {
font: assets.font.clone(),
font_size: 34.0,
..default()
},
TextColor(HEDZ_GREEN.into()),
TextLayout::new_with_justify(Justify::Center),
Node {
position_type: PositionType::Absolute,
top: Val::Px(20.0),
right: Val::Px(20.0),
..default()
},
));
}
fn spawn_head_ui(
bg: Handle<Image>,
regular: Handle<Image>,
selector: Handle<Image>,
damage: Handle<Image>,
head_slot: usize,
) -> impl Bundle {
const SIZE: f32 = 90.0;
const DAMAGE_SIZE: f32 = 74.0;
(
Node {
position_type: PositionType::Relative,
justify_content: JustifyContent::Center,
align_items: AlignItems::Center,
width: Val::Px(SIZE),
..default()
},
children![
(
Name::new("selector"),
Node {
position_type: PositionType::Absolute,
bottom: Val::Px(-30.0),
..default()
},
Visibility::Hidden,
ImageNode::new(selector).with_flip_y(),
HeadSelector(head_slot),
),
(
Name::new("bg"),
Node {
position_type: PositionType::Absolute,
..default()
},
ImageNode::new(bg),
),
(
Name::new("head"),
Node {
position_type: PositionType::Absolute,
..default()
},
ImageNode::default(),
Visibility::Hidden,
HeadImage(head_slot),
),
(
Name::new("rings"),
Node {
position_type: PositionType::Absolute,
..default()
},
ImageNode::new(regular),
),
(
Name::new("health"),
Node {
height: Val::Px(DAMAGE_SIZE),
width: Val::Px(DAMAGE_SIZE),
..default()
},
children![(
Name::new("damage_ring"),
HeadDamage(head_slot),
Node {
position_type: PositionType::Absolute,
display: Display::Block,
overflow: Overflow::clip(),
top: Val::Px(0.),
left: Val::Px(0.),
right: Val::Px(0.),
height: Val::Percent(0.),
..default()
},
children![ImageNode::new(damage)]
)]
)
],
)
}
fn update_visibility(
state: Single<&BackpackUiState, Changed<BackpackUiState>>,
mut backpack: Single<&mut Visibility, (With<BackpackMarker>, Without<BackpackCountText>)>,
mut count: Single<&mut Visibility, (Without<BackpackMarker>, With<BackpackCountText>)>,
) {
**backpack = if state.open {
Visibility::Visible
} else {
Visibility::Hidden
};
**count = if !state.open {
Visibility::Visible
} else {
Visibility::Hidden
};
}
fn update_count(
state: Single<&BackpackUiState, Changed<BackpackUiState>>,
text: Option<Single<Entity, With<BackpackCountText>>>,
mut writer: TextUiWriter,
) {
let Some(text) = text else {
return;
};
*writer.text(*text, 0) = state.count.to_string();
}
fn update(
state: Single<&BackpackUiState, Changed<BackpackUiState>>,
heads_images: Res<HeadsImages>,
mut head_image: Query<(&HeadImage, &mut Visibility, &mut ImageNode), Without<HeadSelector>>,
mut head_damage: Query<(&HeadDamage, &mut Node), Without<HeadSelector>>,
mut head_selector: Query<(&HeadSelector, &mut Visibility), Without<HeadImage>>,
) {
for (HeadImage(head), mut vis, mut image) in head_image.iter_mut() {
if let Some(head) = &state.heads[*head] {
*vis = Visibility::Inherited;
image.image = heads_images.heads[head.head].clone();
} else {
*vis = Visibility::Hidden;
}
}
for (HeadDamage(head), mut node) in head_damage.iter_mut() {
if let Some(head) = &state.heads[*head] {
node.height = Val::Percent(head.damage() * 100.0);
}
}
for (HeadSelector(head), mut vis) in head_selector.iter_mut() {
*vis = if *head == state.relative_current_slot() {
Visibility::Inherited
} else {
Visibility::Hidden
};
}
}

View File

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

View File

@@ -0,0 +1,58 @@
use crate::{
GameState,
control::{ControllerSet, Inputs, LookDirMovement},
player::{LocalPlayer, PlayerBodyMesh},
};
use bevy::prelude::*;
use std::f32::consts::PI;
pub fn plugin(app: &mut App) {
app.add_systems(
FixedUpdate,
rotate_rig
.before(crate::control::controller_flying::apply_controls)
.in_set(ControllerSet::ApplyControlsFly)
.run_if(in_state(GameState::Playing)),
);
}
fn rotate_rig(
inputs: Single<&Inputs, With<LocalPlayer>>,
look_dir: Res<LookDirMovement>,
local_player: Single<&Children, With<LocalPlayer>>,
mut player_mesh: Query<&mut Transform, With<PlayerBodyMesh>>,
) {
if inputs.view_mode {
return;
}
local_player.iter().find(|&child| {
if let Ok(mut rig_transform) = player_mesh.get_mut(child) {
let look_dir = look_dir.0;
// todo: Make consistent with the running controller
let sensitivity = 0.001;
let max_pitch = 35.0 * PI / 180.0;
let min_pitch = -25.0 * PI / 180.0;
rig_transform.rotate_y(look_dir.x * -sensitivity);
let euler_rot = rig_transform.rotation.to_euler(EulerRot::YXZ);
let yaw = euler_rot.0;
let pitch = euler_rot.1 + look_dir.y * -sensitivity;
let pitch_clamped = pitch.clamp(min_pitch, max_pitch);
rig_transform.rotation = Quat::from_euler(EulerRot::YXZ, yaw, pitch_clamped, 0.0);
// The following can be used to limit the amount of rotation per frame
// let target_rotation = rig_transform.rotation
// * Quat::from_rotation_x(controls.keyboard_state.look_dir.y * sensitivity);
// let clamped_rotation = rig_transform.rotation.rotate_towards(target_rotation, 0.01);
// rig_transform.rotation = clamped_rotation;
true
} else {
false
}
});
}

View File

@@ -0,0 +1,265 @@
use crate::{
GameState,
client::control::CharacterInputEnabled,
control::{
BackpackButtonPress, CashHealPressed, ClientInputs, ControllerSet, Inputs, LocalInputs,
LookDirMovement, SelectLeftPressed, SelectRightPressed,
},
player::{LocalPlayer, PlayerBodyMesh},
};
use bevy::{
input::{
gamepad::{GamepadConnection, GamepadEvent},
mouse::MouseMotion,
},
prelude::*,
};
use bevy_replicon::client::ClientSystems;
pub fn plugin(app: &mut App) {
app.add_systems(
PreUpdate,
(
gamepad_connections.run_if(on_message::<GamepadEvent>),
reset_lookdir,
keyboard_controls,
gamepad_controls,
mouse_rotate,
get_lookdir,
send_inputs,
)
.chain()
.in_set(ControllerSet::CollectInputs)
.before(ClientSystems::Receive)
.run_if(
in_state(GameState::Playing)
.and(resource_exists_and_equals(CharacterInputEnabled::On)),
),
);
// run this deliberately after local input processing ended
// TODO: can and should be ordered using a set to guarantee it gets send out ASAP but after local input processing
app.add_systems(
PreUpdate,
overwrite_local_inputs.after(ClientSystems::Receive).run_if(
in_state(GameState::Playing).and(resource_exists_and_equals(CharacterInputEnabled::On)),
),
);
app.add_systems(
Update,
reset_control_state_on_disable.run_if(in_state(GameState::Playing)),
);
}
/// Overwrite inputs for this client that were replicated from the server with the local inputs
fn overwrite_local_inputs(
mut inputs: Single<&mut Inputs, With<LocalPlayer>>,
local_inputs: Single<&LocalInputs>,
) {
**inputs = local_inputs.0;
}
/// Write inputs from combined keyboard/gamepad state into the networked input buffer
/// for the local player.
fn send_inputs(mut writer: MessageWriter<ClientInputs>, local_inputs: Single<&LocalInputs>) {
writer.write(ClientInputs(local_inputs.0));
}
fn reset_lookdir(mut look_dir: ResMut<LookDirMovement>) {
look_dir.0 = Vec2::ZERO;
}
/// Reset character inputs to default when character input is disabled.
fn reset_control_state_on_disable(
state: Res<CharacterInputEnabled>,
mut inputs: Single<&mut LocalInputs>,
) {
if state.is_changed() && matches!(*state, CharacterInputEnabled::Off) {
inputs.0 = Inputs {
look_dir: inputs.0.look_dir,
..default()
};
}
}
fn get_lookdir(
mut inputs: Single<&mut LocalInputs>,
rig_transform: Option<Single<&GlobalTransform, With<PlayerBodyMesh>>>,
) {
inputs.0.look_dir = if let Some(ref rig_transform) = rig_transform {
rig_transform.forward().as_vec3()
} else {
Vec3::NEG_Z
};
}
/// Applies a square deadzone to a Vec2
fn deadzone_square(v: Vec2, min: f32) -> Vec2 {
Vec2::new(
if v.x.abs() < min { 0. } else { v.x },
if v.y.abs() < min { 0. } else { v.y },
)
}
/// Collect gamepad inputs
#[allow(clippy::too_many_arguments)]
fn gamepad_controls(
gamepads: Query<&Gamepad>,
mut inputs: Single<&mut LocalInputs>,
mut look_dir: ResMut<LookDirMovement>,
mut backpack_inputs: MessageWriter<BackpackButtonPress>,
mut select_left_pressed: MessageWriter<SelectLeftPressed>,
mut select_right_pressed: MessageWriter<SelectRightPressed>,
mut cash_heal_pressed: MessageWriter<CashHealPressed>,
) {
let deadzone_left_stick = 0.15;
let deadzone_right_stick = 0.15;
for gamepad in gamepads.iter() {
let rotate = gamepad
.get(GamepadButton::RightTrigger2)
.unwrap_or_default();
// 8BitDo Ultimate wireless Controller for PC
look_dir.0 = if gamepad.vendor_id() == Some(11720) && gamepad.product_id() == Some(12306) {
const EPSILON: f32 = 0.015;
Vec2::new(
if rotate < 0.5 - EPSILON {
40. * (rotate - 0.5)
} else if rotate > 0.5 + EPSILON {
-40. * (rotate - 0.5)
} else {
0.
},
0.,
)
} else {
deadzone_square(gamepad.right_stick(), deadzone_right_stick) * 40.
};
let move_dir = deadzone_square(gamepad.left_stick(), deadzone_left_stick);
inputs.0.move_dir += move_dir.clamp_length_max(1.0);
inputs.0.jump |= gamepad.pressed(GamepadButton::South);
inputs.0.view_mode |= gamepad.pressed(GamepadButton::LeftTrigger2);
inputs.0.trigger |= gamepad.pressed(GamepadButton::RightTrigger2);
if gamepad.just_pressed(GamepadButton::DPadUp) {
backpack_inputs.write(BackpackButtonPress::Toggle);
}
if gamepad.just_pressed(GamepadButton::DPadDown) {
backpack_inputs.write(BackpackButtonPress::Swap);
}
if gamepad.just_pressed(GamepadButton::DPadLeft) {
backpack_inputs.write(BackpackButtonPress::Left);
}
if gamepad.just_pressed(GamepadButton::DPadRight) {
backpack_inputs.write(BackpackButtonPress::Right);
}
if gamepad.just_pressed(GamepadButton::LeftTrigger) {
select_left_pressed.write(SelectLeftPressed);
}
if gamepad.just_pressed(GamepadButton::RightTrigger) {
select_right_pressed.write(SelectRightPressed);
}
if gamepad.just_pressed(GamepadButton::East) {
cash_heal_pressed.write(CashHealPressed);
}
}
}
/// Collect mouse movement input
fn mouse_rotate(mut mouse: MessageReader<MouseMotion>, mut look_dir: ResMut<LookDirMovement>) {
for ev in mouse.read() {
look_dir.0 += ev.delta;
}
}
/// Collect keyboard input
#[allow(clippy::too_many_arguments)]
fn keyboard_controls(
keyboard: Res<ButtonInput<KeyCode>>,
mouse: Res<ButtonInput<MouseButton>>,
mut inputs: Single<&mut LocalInputs>,
mut backpack_inputs: MessageWriter<BackpackButtonPress>,
mut select_left_pressed: MessageWriter<SelectLeftPressed>,
mut select_right_pressed: MessageWriter<SelectRightPressed>,
mut cash_heal_pressed: MessageWriter<CashHealPressed>,
) {
let up_binds = [KeyCode::KeyW, KeyCode::ArrowUp];
let down_binds = [KeyCode::KeyS, KeyCode::ArrowDown];
let left_binds = [KeyCode::KeyA, KeyCode::ArrowLeft];
let right_binds = [KeyCode::KeyD, KeyCode::ArrowRight];
let up = keyboard.any_pressed(up_binds);
let down = keyboard.any_pressed(down_binds);
let left = keyboard.any_pressed(left_binds);
let right = keyboard.any_pressed(right_binds);
let horizontal = right as i8 - left as i8;
let vertical = up as i8 - down as i8;
let direction = Vec2::new(horizontal as f32, vertical as f32).clamp_length_max(1.0);
inputs.0.move_dir = direction;
inputs.0.jump = keyboard.pressed(KeyCode::Space);
inputs.0.view_mode = keyboard.pressed(KeyCode::Tab);
inputs.0.trigger = mouse.pressed(MouseButton::Left);
if keyboard.just_pressed(KeyCode::KeyB) {
backpack_inputs.write(BackpackButtonPress::Toggle);
}
if keyboard.just_pressed(KeyCode::Enter) {
backpack_inputs.write(BackpackButtonPress::Swap);
}
if keyboard.just_pressed(KeyCode::Comma) {
backpack_inputs.write(BackpackButtonPress::Left);
}
if keyboard.just_pressed(KeyCode::Period) {
backpack_inputs.write(BackpackButtonPress::Right);
}
if keyboard.just_pressed(KeyCode::KeyQ) {
select_left_pressed.write(SelectLeftPressed);
}
if keyboard.just_pressed(KeyCode::KeyE) {
select_right_pressed.write(SelectRightPressed);
}
if keyboard.just_pressed(KeyCode::Enter) {
cash_heal_pressed.write(CashHealPressed);
}
}
/// Receive gamepad connections and disconnections
fn gamepad_connections(mut evr_gamepad: MessageReader<GamepadEvent>) {
for ev in evr_gamepad.read() {
if let GamepadEvent::Connection(connection) = ev {
match &connection.connection {
GamepadConnection::Connected {
name,
vendor_id,
product_id,
} => {
info!(
"New gamepad connected: {:?}, name: {name}, vendor: {vendor_id:?}, product: {product_id:?}",
connection.gamepad,
);
}
GamepadConnection::Disconnected => {
info!("Lost connection with gamepad: {:?}", connection.gamepad);
}
}
}
}
}

View File

@@ -0,0 +1,25 @@
use crate::{GameState, control::ControllerSet};
use bevy::prelude::*;
use bevy_replicon::client::ClientSystems;
mod controller_flying;
pub mod controls;
#[derive(Resource, Debug, PartialEq, Eq)]
pub enum CharacterInputEnabled {
On,
Off,
}
pub fn plugin(app: &mut App) {
app.insert_resource(CharacterInputEnabled::On);
app.add_plugins((controller_flying::plugin, controls::plugin));
app.configure_sets(
PreUpdate,
ControllerSet::CollectInputs
.before(ClientSystems::Receive)
.run_if(in_state(GameState::Playing)),
);
}

View File

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

View File

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

View File

@@ -0,0 +1,153 @@
use crate::{
GameState,
abilities::Healing,
loading_assets::{AudioAssets, GameAssets},
utils::{billboards::Billboard, observers::global_observer},
};
use bevy::prelude::*;
use rand::{Rng, thread_rng};
// Should not be a relationship because lightyear will silently track state for all relationships
// and break if one end of the relationship isn't replicated and is despawned
#[derive(Component)]
struct HasHealingEffects {
effects: Entity,
}
#[derive(Component)]
struct HealingEffectsOf {
of: Entity,
}
#[derive(Component, Default)]
#[require(Transform, InheritedVisibility)]
struct HealParticleEffect {
next_spawn: f32,
}
#[derive(Component)]
struct HealParticle {
start_scale: f32,
end_scale: f32,
start_pos: Vec3,
end_pos: Vec3,
start_time: f32,
life_time: f32,
}
pub fn plugin(app: &mut App) {
app.add_systems(
Update,
(on_added, update_effects, update_particles).run_if(in_state(GameState::Playing)),
);
global_observer!(app, on_removed);
}
fn on_added(
mut commands: Commands,
query: Query<Entity, Added<Healing>>,
assets: Res<AudioAssets>,
) {
for entity in query.iter() {
let effects = commands
.spawn((
Name::new("heal-particle-effect"),
HealParticleEffect::default(),
AudioPlayer::new(assets.healing.clone()),
PlaybackSettings {
mode: bevy::audio::PlaybackMode::Loop,
..Default::default()
},
HealingEffectsOf { of: entity },
))
.id();
commands
.entity(entity)
.insert(HasHealingEffects { effects });
}
}
fn on_removed(
trigger: On<Remove, Healing>,
mut commands: Commands,
effects: Query<&HasHealingEffects>,
) {
let Ok(has_effects) = effects.get(trigger.event().entity) else {
return;
};
commands.entity(has_effects.effects).try_despawn();
commands
.entity(trigger.event().entity)
.remove::<HasHealingEffects>();
}
fn update_effects(
mut commands: Commands,
mut query: Query<(&mut HealParticleEffect, &HealingEffectsOf, Entity)>,
mut transforms: Query<&mut Transform>,
time: Res<Time>,
assets: Res<GameAssets>,
) {
const DISTANCE: f32 = 4.;
let mut rng = thread_rng();
let now = time.elapsed_secs();
for (mut effect, effects_of, e) in query.iter_mut() {
// We have to manually track the healer's position because lightyear will try to synchronize
// children and there's no reason to synchronize the particle effect entity when we're already
// synchronizing `Healing`
// (trying to ignore/avoid it by excluding the child from replication just causes crashes)
let healer_pos = transforms.get(effects_of.of).unwrap().translation;
transforms.get_mut(e).unwrap().translation = healer_pos;
if effect.next_spawn < now {
let start_pos = Vec3::new(
rng.gen_range(-DISTANCE..DISTANCE),
2.,
rng.gen_range(-DISTANCE..DISTANCE),
);
let max_distance = start_pos.length().max(0.8);
let end_pos =
start_pos + (start_pos.normalize() * -1.) * rng.gen_range(0.5..max_distance);
let start_scale = rng.gen_range(0.7..1.0);
let end_scale = rng.gen_range(0.1..start_scale);
commands.entity(e).with_child((
Name::new("heal-particle"),
SceneRoot(assets.mesh_heal_particle.clone()),
Billboard::All,
Transform::from_translation(start_pos),
HealParticle {
start_scale,
end_scale,
start_pos,
end_pos,
start_time: now,
life_time: rng.gen_range(0.3..1.0),
},
));
effect.next_spawn = now + rng.gen_range(0.1..0.5);
}
}
}
fn update_particles(
mut cmds: Commands,
mut query: Query<(&mut Transform, &HealParticle, Entity)>,
time: Res<Time>,
) {
for (mut transform, particle, e) in query.iter_mut() {
if particle.start_time + particle.life_time < time.elapsed_secs() {
cmds.entity(e).despawn();
continue;
}
let t = (time.elapsed_secs() - particle.start_time) / particle.life_time;
// info!("particle[{e:?}] t: {t}");
transform.translation = particle.start_pos.lerp(particle.end_pos, t);
transform.scale = Vec3::splat(particle.start_scale.lerp(particle.end_scale, t));
}
}

View File

@@ -0,0 +1,93 @@
use crate::{
global_observer,
heads_database::{HeadControls, HeadsDatabase},
loading_assets::AudioAssets,
player::{LocalPlayer, PlayerBodyMesh},
protocol::{ClientHeadChanged, PlaySound, PlayerId, messages::AssignClientPlayer},
};
use bevy::prelude::*;
pub fn plugin(app: &mut App) {
app.init_state::<PlayerAssignmentState>();
app.add_systems(
Update,
receive_player_id.run_if(not(in_state(PlayerAssignmentState::Confirmed))),
);
global_observer!(app, on_client_update_head_mesh);
}
pub fn receive_player_id(
mut commands: Commands,
mut client_assignments: MessageReader<AssignClientPlayer>,
mut next: ResMut<NextState<PlayerAssignmentState>>,
mut local_id: Local<Option<PlayerId>>,
players: Query<(Entity, &PlayerId), Changed<PlayerId>>,
) {
for &AssignClientPlayer(id) in client_assignments.read() {
info!("player id `{}` received", id.id);
*local_id = Some(id);
}
if let Some(local_id) = *local_id {
for (entity, player_id) in players.iter() {
if *player_id == local_id {
commands.entity(entity).insert(LocalPlayer);
next.set(PlayerAssignmentState::Confirmed);
info!(
"player entity {entity:?} confirmed with id `{}`",
player_id.id
);
break;
}
}
}
}
/// Various states while trying to assign and match an ID to the player character.
/// Every client is given an ID (its player index in the match) and every character controller
/// is given an ID matching the client controlling it. This way the client can easily see which
/// controller it owns.
#[derive(Clone, Copy, Debug, Default, Hash, PartialEq, Eq, PartialOrd, States)]
pub enum PlayerAssignmentState {
/// Waiting for the server to send an [`AssignClientPlayer`] message and replicate a [`PlayerId`]
#[default]
Waiting,
/// Matching controller confirmed; a [`LocalPlayer`] exists
Confirmed,
}
fn on_client_update_head_mesh(
trigger: On<ClientHeadChanged>,
mut commands: Commands,
body_mesh: Single<(Entity, &Children), With<PlayerBodyMesh>>,
head_db: Res<HeadsDatabase>,
audio_assets: Res<AudioAssets>,
sfx: Query<&AudioPlayer>,
) -> Result {
let head = trigger.0 as usize;
let (body_mesh, mesh_children) = *body_mesh;
let head_str = head_db.head_key(head);
commands.trigger(PlaySound::Head(head_str.to_string()));
//TODO: make part of full character mesh later
for child in mesh_children.iter().filter(|child| sfx.contains(*child)) {
commands.entity(child).despawn();
}
if head_db.head_stats(head).controls == HeadControls::Plane {
commands.entity(body_mesh).with_child((
Name::new("sfx"),
AudioPlayer::new(audio_assets.jet.clone()),
PlaybackSettings {
mode: bevy::audio::PlaybackMode::Loop,
..Default::default()
},
));
}
Ok(())
}

View File

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

View File

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

View File

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

View File

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

View File

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