more clear splitting of visual stuff in client mod

This commit is contained in:
2025-12-21 15:27:11 -05:00
parent 181b617620
commit e22fa8d134
20 changed files with 343 additions and 336 deletions

View File

@@ -1,8 +1,11 @@
use super::TriggerArrow;
use crate::{
GameState, billboards::Billboard, global_observer, heads_database::HeadsDatabase,
hitpoints::Hit, loading_assets::GameAssets, physics_layers::GameLayer,
utils::sprite_3d_animation::AnimationTimer,
GameState, global_observer,
heads_database::HeadsDatabase,
hitpoints::Hit,
loading_assets::GameAssets,
physics_layers::GameLayer,
utils::{Billboard, sprite_3d_animation::AnimationTimer},
};
use avian3d::prelude::*;
use bevy::{light::NotShadowCaster, prelude::*};

View File

@@ -1,8 +1,14 @@
use super::TriggerGun;
use crate::{
GameState, abilities::ProjectileId, billboards::Billboard, global_observer,
heads_database::HeadsDatabase, hitpoints::Hit, loading_assets::GameAssets,
physics_layers::GameLayer, tb_entities::EnemySpawn, utils::sprite_3d_animation::AnimationTimer,
GameState,
abilities::ProjectileId,
global_observer,
heads_database::HeadsDatabase,
hitpoints::Hit,
loading_assets::GameAssets,
physics_layers::GameLayer,
tb_entities::EnemySpawn,
utils::{Billboard, sprite_3d_animation::AnimationTimer},
};
use avian3d::prelude::*;
use bevy::{light::NotShadowCaster, prelude::*};

View File

@@ -17,7 +17,7 @@ use crate::{
physics_layers::GameLayer,
player::Player,
protocol::PlaySound,
utils::{billboards::Billboard, explosions::Explosion, sprite_3d_animation::AnimationTimer},
utils::{Billboard, explosions::Explosion, sprite_3d_animation::AnimationTimer},
};
use bevy::{light::NotShadowCaster, prelude::*};
use bevy_replicon::prelude::{SendMode, ServerTriggerExt, Signature, ToClients};

View File

@@ -1,19 +1,3 @@
use crate::GameState;
#[cfg(feature = "client")]
use crate::control::Inputs;
use crate::control::ViewMode;
#[cfg(feature = "client")]
use crate::physics_layers::GameLayer;
#[cfg(feature = "client")]
use crate::player::LocalPlayer;
#[cfg(feature = "client")]
use crate::{control::LookDirMovement, loading_assets::UIAssets};
#[cfg(feature = "client")]
use avian3d::prelude::SpatialQuery;
#[cfg(feature = "client")]
use avian3d::prelude::{
Collider, LayerMask, PhysicsLayer as _, ShapeCastConfig, SpatialQueryFilter,
};
use bevy::prelude::*;
use serde::{Deserialize, Serialize};
@@ -22,183 +6,3 @@ pub struct CameraTarget;
#[derive(Component, Reflect, Debug, Serialize, Deserialize, PartialEq)]
pub struct CameraArmRotation;
/// Requested camera rotation based on various input sources (keyboard, gamepad)
#[derive(Component, Reflect, Debug, Default, Deref, DerefMut)]
#[reflect(Component)]
pub struct CameraRotationInput(pub Vec2);
#[derive(Resource, Reflect, Debug, Default)]
#[reflect(Resource)]
pub struct CameraState {
pub cutscene: bool,
pub view_mode: ViewMode,
}
#[derive(Component, Reflect, Debug, Default)]
struct CameraUi;
#[derive(Component, Reflect, Debug)]
#[reflect(Component)]
pub struct MainCamera {
pub enabled: bool,
dir: Dir3,
distance: f32,
target_offset: Vec3,
}
impl MainCamera {
fn new(arm: Vec3) -> Self {
let (dir, distance) = Dir3::new_and_length(arm).expect("invalid arm length");
Self {
enabled: true,
dir,
distance,
target_offset: Vec3::new(0., 2., 0.),
}
}
}
pub fn plugin(app: &mut App) {
app.register_type::<CameraRotationInput>();
app.register_type::<CameraState>();
app.register_type::<MainCamera>();
app.init_resource::<CameraState>();
app.add_systems(OnEnter(GameState::Playing), startup);
#[cfg(feature = "client")]
app.add_systems(
PostUpdate,
(update, update_ui, update_look_around, rotate_view).run_if(in_state(GameState::Playing)),
);
}
fn startup(mut commands: Commands) {
commands.spawn((
Camera3d::default(),
MainCamera::new(Vec3::new(0., 1.8, 15.)),
CameraRotationInput::default(),
));
}
#[cfg(feature = "client")]
fn update_look_around(
inputs: Single<&Inputs, With<LocalPlayer>>,
mut cam_state: ResMut<CameraState>,
) {
let view_mode = inputs.view_mode;
if view_mode != cam_state.view_mode {
cam_state.view_mode = view_mode;
}
}
#[cfg(feature = "client")]
fn update_ui(
mut commands: Commands,
cam_state: Res<CameraState>,
assets: Res<UIAssets>,
query: Query<Entity, With<CameraUi>>,
) {
if cam_state.is_changed() {
let show_free_cam_ui = cam_state.view_mode.is_free() || cam_state.cutscene;
if show_free_cam_ui {
commands.spawn((
CameraUi,
Node {
margin: UiRect::top(Val::Px(20.))
.with_left(Val::Auto)
.with_right(Val::Auto),
justify_content: JustifyContent::Center,
..default()
},
children![(
Node {
display: Display::Block,
position_type: PositionType::Absolute,
..default()
},
ImageNode::new(assets.camera.clone()),
)],
));
} else {
for entity in query.iter() {
commands.entity(entity).despawn();
}
}
}
}
#[cfg(feature = "client")]
fn update(
cam: Single<
(&MainCamera, &mut Transform, &CameraRotationInput),
(Without<CameraTarget>, Without<CameraArmRotation>),
>,
target_q: Single<
(&Transform, &Children),
(
With<CameraTarget>,
With<LocalPlayer>,
Without<CameraArmRotation>,
),
>,
arm_rotation: Query<&Transform, With<CameraArmRotation>>,
spatial_query: SpatialQuery,
cam_state: Res<CameraState>,
) {
if cam_state.cutscene {
return;
}
let (camera, mut cam_transform, cam_rotation_input) = cam.into_inner();
let (target_q, children) = target_q.into_inner();
let arm_tf = children
.iter()
.find_map(|child| arm_rotation.get(child).ok())
.unwrap();
if !camera.enabled {
return;
}
let target = target_q.translation + camera.target_offset;
let direction = arm_tf.rotation * Quat::from_rotation_y(cam_rotation_input.x) * camera.dir;
let max_distance = camera.distance;
let filter = SpatialQueryFilter::from_mask(LayerMask(GameLayer::Level.to_bits()));
let cam_pos = if let Some(first_hit) = spatial_query.cast_shape(
&Collider::sphere(0.5),
target,
Quat::IDENTITY,
direction,
&ShapeCastConfig::from_max_distance(max_distance),
&filter,
) {
let distance = first_hit.distance;
target + (direction * distance)
} else {
target + (direction * camera.distance)
};
*cam_transform = Transform::from_translation(cam_pos).looking_at(target, Vec3::Y);
}
#[cfg(feature = "client")]
fn rotate_view(
inputs: Single<&Inputs, With<LocalPlayer>>,
look_dir: Res<LookDirMovement>,
mut cam: Single<&mut CameraRotationInput>,
) {
if !inputs.view_mode.is_free() {
cam.x = 0.0;
return;
}
cam.0 += look_dir.0 * -0.001;
}

View File

@@ -1,6 +1,5 @@
use crate::{
GameState, aim::MarkerEvent, global_observer, loading_assets::UIAssets,
utils::billboards::Billboard,
GameState, aim::MarkerEvent, global_observer, loading_assets::UIAssets, utils::Billboard,
};
use bevy::prelude::*;
use bevy_sprite3d::Sprite3d;

View File

@@ -0,0 +1,193 @@
use crate::{
GameState,
camera::{CameraArmRotation, CameraTarget},
control::{Inputs, LookDirMovement, ViewMode},
loading_assets::UIAssets,
physics_layers::GameLayer,
player::LocalPlayer,
};
use avian3d::prelude::SpatialQuery;
use avian3d::prelude::{
Collider, LayerMask, PhysicsLayer as _, ShapeCastConfig, SpatialQueryFilter,
};
use bevy::prelude::*;
/// Requested camera rotation based on various input sources (keyboard, gamepad)
#[derive(Component, Reflect, Debug, Default, Deref, DerefMut)]
#[reflect(Component)]
pub struct CameraRotationInput(pub Vec2);
#[derive(Resource, Reflect, Debug, Default)]
#[reflect(Resource)]
pub struct CameraState {
pub cutscene: bool,
pub view_mode: ViewMode,
}
#[derive(Component, Reflect, Debug, Default)]
struct CameraUi;
#[derive(Component, Reflect, Debug)]
#[reflect(Component)]
pub struct MainCamera {
pub enabled: bool,
dir: Dir3,
distance: f32,
target_offset: Vec3,
}
impl MainCamera {
fn new(arm: Vec3) -> Self {
let (dir, distance) = Dir3::new_and_length(arm).expect("invalid arm length");
Self {
enabled: true,
dir,
distance,
target_offset: Vec3::new(0., 2., 0.),
}
}
}
pub fn plugin(app: &mut App) {
app.register_type::<CameraRotationInput>();
app.register_type::<CameraState>();
app.register_type::<MainCamera>();
app.init_resource::<CameraState>();
app.add_systems(OnEnter(GameState::Playing), startup);
app.add_systems(
PostUpdate,
(update, update_ui, update_look_around, rotate_view).run_if(in_state(GameState::Playing)),
);
}
fn startup(mut commands: Commands) {
commands.spawn((
Camera3d::default(),
MainCamera::new(Vec3::new(0., 1.8, 15.)),
CameraRotationInput::default(),
));
}
#[cfg(feature = "client")]
fn update_look_around(
inputs: Single<&Inputs, With<LocalPlayer>>,
mut cam_state: ResMut<CameraState>,
) {
let view_mode = inputs.view_mode;
if view_mode != cam_state.view_mode {
cam_state.view_mode = view_mode;
}
}
#[cfg(feature = "client")]
fn update_ui(
mut commands: Commands,
cam_state: Res<CameraState>,
assets: Res<UIAssets>,
query: Query<Entity, With<CameraUi>>,
) {
if cam_state.is_changed() {
let show_free_cam_ui = cam_state.view_mode.is_free() || cam_state.cutscene;
if show_free_cam_ui {
commands.spawn((
CameraUi,
Node {
margin: UiRect::top(Val::Px(20.))
.with_left(Val::Auto)
.with_right(Val::Auto),
justify_content: JustifyContent::Center,
..default()
},
children![(
Node {
display: Display::Block,
position_type: PositionType::Absolute,
..default()
},
ImageNode::new(assets.camera.clone()),
)],
));
} else {
for entity in query.iter() {
commands.entity(entity).despawn();
}
}
}
}
fn update(
cam: Single<
(&MainCamera, &mut Transform, &CameraRotationInput),
(Without<CameraTarget>, Without<CameraArmRotation>),
>,
target_q: Single<
(&Transform, &Children),
(
With<CameraTarget>,
With<LocalPlayer>,
Without<CameraArmRotation>,
),
>,
arm_rotation: Query<&Transform, With<CameraArmRotation>>,
spatial_query: SpatialQuery,
cam_state: Res<CameraState>,
) {
if cam_state.cutscene {
return;
}
let (camera, mut cam_transform, cam_rotation_input) = cam.into_inner();
let (target_q, children) = target_q.into_inner();
let arm_tf = children
.iter()
.find_map(|child| arm_rotation.get(child).ok())
.unwrap();
if !camera.enabled {
return;
}
let target = target_q.translation + camera.target_offset;
let direction = arm_tf.rotation * Quat::from_rotation_y(cam_rotation_input.x) * camera.dir;
let max_distance = camera.distance;
let filter = SpatialQueryFilter::from_mask(LayerMask(GameLayer::Level.to_bits()));
let cam_pos = if let Some(first_hit) = spatial_query.cast_shape(
&Collider::sphere(0.5),
target,
Quat::IDENTITY,
direction,
&ShapeCastConfig::from_max_distance(max_distance),
&filter,
) {
let distance = first_hit.distance;
target + (direction * distance)
} else {
target + (direction * camera.distance)
};
*cam_transform = Transform::from_translation(cam_pos).looking_at(target, Vec3::Y);
}
#[cfg(feature = "client")]
fn rotate_view(
inputs: Single<&Inputs, With<LocalPlayer>>,
look_dir: Res<LookDirMovement>,
mut cam: Single<&mut CameraRotationInput>,
) {
if !inputs.view_mode.is_free() {
cam.x = 0.0;
return;
}
cam.0 += look_dir.0 * -0.001;
}

View File

@@ -26,13 +26,13 @@ fn rotate_rig(
return;
}
let (local_player_childer, selected_controller) = *local_player;
let (local_player_children, selected_controller) = *local_player;
if !matches!(selected_controller, SelectedController::Flying) {
return;
}
local_player_childer.iter().find(|&child| {
local_player_children.iter().find(|&child| {
if let Ok(mut rig_transform) = player_mesh.get_mut(child) {
let look_dir = look_dir.0;

View File

@@ -0,0 +1,104 @@
use crate::{
GameState,
client::camera::{CameraState, MainCamera},
cutscene::StartCutscene,
global_observer,
tb_entities::{CameraTarget, CutsceneCamera, CutsceneCameraMovementEnd},
};
use bevy::prelude::*;
use bevy_trenchbroom::prelude::*;
#[derive(Resource, Debug, Default)]
enum CutsceneState {
#[default]
None,
Playing {
timer: Timer,
camera_start: Transform,
camera_end: Transform,
},
}
pub fn plugin(app: &mut App) {
app.init_resource::<CutsceneState>();
app.add_systems(Update, update.run_if(in_state(GameState::Playing)));
global_observer!(app, on_start_cutscene);
}
fn on_start_cutscene(
trigger: On<StartCutscene>,
mut cam_state: ResMut<CameraState>,
mut cutscene_state: ResMut<CutsceneState>,
cutscenes: Query<(&Transform, &CutsceneCamera, &Target), Without<MainCamera>>,
cutscene_movement: Query<
(&Transform, &CutsceneCameraMovementEnd, &Target),
Without<MainCamera>,
>,
cam_target: Query<(&Transform, &CameraTarget), Without<MainCamera>>,
) {
let cutscene = trigger.event().0.clone();
cam_state.cutscene = true;
// asumes `name` and `targetname` are equal
let Some((t, _, target)) = cutscenes
.iter()
.find(|(_, cutscene_camera, _)| cutscene == cutscene_camera.name)
else {
return;
};
let move_end = cutscene_movement
.iter()
.find(|(_, _, target)| cutscene == target.target.clone().unwrap_or_default())
.map(|(t, _, _)| *t)
.unwrap_or_else(|| *t);
let Some((target, _)) = cam_target.iter().find(|(_, camera_target)| {
camera_target.targetname == target.target.clone().unwrap_or_default()
}) else {
return;
};
*cutscene_state = CutsceneState::Playing {
timer: Timer::from_seconds(2.0, TimerMode::Once),
camera_start: t.looking_at(target.translation, Vec3::Y),
camera_end: move_end.looking_at(target.translation, Vec3::Y),
};
}
fn update(
mut cam_state: ResMut<CameraState>,
mut cutscene_state: ResMut<CutsceneState>,
mut cam: Query<&mut Transform, With<MainCamera>>,
time: Res<Time>,
) {
if let CutsceneState::Playing {
timer,
camera_start,
camera_end,
} = &mut *cutscene_state
{
cam_state.cutscene = true;
timer.tick(time.delta());
let t = Transform::from_translation(
camera_start
.translation
.lerp(camera_end.translation, timer.fraction()),
)
.with_rotation(
camera_start
.rotation
.lerp(camera_end.rotation, timer.fraction()),
);
let _ = cam.single_mut().map(|mut cam| *cam = t);
if timer.is_finished() {
cam_state.cutscene = false;
*cutscene_state = CutsceneState::None;
}
}
}

View File

@@ -2,7 +2,7 @@ use crate::{
GameState,
abilities::Healing,
loading_assets::{AudioAssets, GameAssets},
utils::{billboards::Billboard, observers::global_observer},
utils::{Billboard, observers::global_observer},
};
use bevy::prelude::*;
use rand::{Rng, thread_rng};

View File

@@ -25,7 +25,9 @@ use bevy_trenchbroom::geometry::Brushes;
pub mod aim;
pub mod audio;
mod backpack;
pub mod camera;
pub mod control;
pub mod cutscene;
pub mod debug;
pub mod enemy;
pub mod heal_effect;
@@ -34,6 +36,7 @@ mod settings;
pub mod setup;
pub mod steam;
pub mod ui;
mod utils;
pub fn plugin(app: &mut App) {
app.add_plugins((
@@ -49,6 +52,9 @@ pub fn plugin(app: &mut App) {
ui::plugin,
settings::plugin,
backpack::plugin,
camera::plugin,
utils::billboards::plugin,
cutscene::plugin,
));
app.add_systems(

View File

@@ -1,4 +1,4 @@
use crate::{DebugVisuals, camera::MainCamera};
use crate::{DebugVisuals, client::camera::MainCamera};
use bevy::{core_pipeline::tonemapping::Tonemapping, prelude::*, render::view::ColorGrading};
use bevy_trenchbroom::TrenchBroomServer;

View File

@@ -1,22 +1,12 @@
use crate::camera::MainCamera;
use crate::{client::camera::MainCamera, utils::Billboard};
use bevy::prelude::*;
use bevy_sprite3d::Sprite3dPlugin;
use serde::{Deserialize, Serialize};
#[derive(Component, Reflect, Default, PartialEq, Eq, Serialize, Deserialize)]
#[reflect(Component)]
pub enum Billboard {
#[default]
All,
XZ,
}
pub fn plugin(app: &mut App) {
if !app.is_plugin_added::<Sprite3dPlugin>() {
app.add_plugins(Sprite3dPlugin);
}
app.register_type::<Billboard>();
app.add_systems(Update, (face_camera, face_camera_no_parent));
}

View File

@@ -0,0 +1 @@
pub mod billboards;

View File

@@ -1,107 +1,5 @@
use crate::{
GameState,
camera::{CameraState, MainCamera},
global_observer,
tb_entities::{CameraTarget, CutsceneCamera, CutsceneCameraMovementEnd},
};
use bevy::prelude::*;
use bevy_trenchbroom::prelude::*;
use serde::{Deserialize, Serialize};
#[derive(Clone, Debug, Event, Serialize, Deserialize)]
pub struct StartCutscene(pub String);
#[derive(Resource, Debug, Default)]
enum CutsceneState {
#[default]
None,
Playing {
timer: Timer,
camera_start: Transform,
camera_end: Transform,
},
}
pub fn plugin(app: &mut App) {
app.init_resource::<CutsceneState>();
app.add_systems(Update, update.run_if(in_state(GameState::Playing)));
global_observer!(app, on_start_cutscene);
}
fn on_start_cutscene(
trigger: On<StartCutscene>,
mut cam_state: ResMut<CameraState>,
mut cutscene_state: ResMut<CutsceneState>,
cutscenes: Query<(&Transform, &CutsceneCamera, &Target), Without<MainCamera>>,
cutscene_movement: Query<
(&Transform, &CutsceneCameraMovementEnd, &Target),
Without<MainCamera>,
>,
cam_target: Query<(&Transform, &CameraTarget), Without<MainCamera>>,
) {
let cutscene = trigger.event().0.clone();
cam_state.cutscene = true;
// asumes `name` and `targetname` are equal
let Some((t, _, target)) = cutscenes
.iter()
.find(|(_, cutscene_camera, _)| cutscene == cutscene_camera.name)
else {
return;
};
let move_end = cutscene_movement
.iter()
.find(|(_, _, target)| cutscene == target.target.clone().unwrap_or_default())
.map(|(t, _, _)| *t)
.unwrap_or_else(|| *t);
let Some((target, _)) = cam_target.iter().find(|(_, camera_target)| {
camera_target.targetname == target.target.clone().unwrap_or_default()
}) else {
return;
};
*cutscene_state = CutsceneState::Playing {
timer: Timer::from_seconds(2.0, TimerMode::Once),
camera_start: t.looking_at(target.translation, Vec3::Y),
camera_end: move_end.looking_at(target.translation, Vec3::Y),
};
}
fn update(
mut cam_state: ResMut<CameraState>,
mut cutscene_state: ResMut<CutsceneState>,
mut cam: Query<&mut Transform, With<MainCamera>>,
time: Res<Time>,
) {
if let CutsceneState::Playing {
timer,
camera_start,
camera_end,
} = &mut *cutscene_state
{
cam_state.cutscene = true;
timer.tick(time.delta());
let t = Transform::from_translation(
camera_start
.translation
.lerp(camera_end.translation, timer.fraction()),
)
.with_rotation(
camera_start
.rotation
.lerp(camera_end.rotation, timer.fraction()),
);
let _ = cam.single_mut().map(|mut cam| *cam = t);
if timer.is_finished() {
cam_state.cutscene = false;
*cutscene_state = CutsceneState::None;
}
}
}

View File

@@ -6,9 +6,7 @@ use crate::{
protocol::{GltfSceneRoot, NetworkEnv, PlaySound},
server_observer,
tb_entities::SecretHead,
utils::{
billboards::Billboard, one_shot_force::OneShotImpulse, squish_animation::SquishAnimation,
},
utils::{Billboard, one_shot_force::OneShotImpulse, squish_animation::SquishAnimation},
};
use avian3d::prelude::*;
use bevy::{ecs::relationship::RelatedSpawner, prelude::*};

View File

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

View File

@@ -52,7 +52,7 @@ use bevy_trenchbroom::{
TrenchBroomPlugins, config::TrenchBroomConfig, prelude::TrenchBroomPhysicsPlugin,
};
use bevy_trenchbroom_avian::AvianPhysicsBackend;
use utils::{billboards, squish_animation};
use utils::squish_animation;
pub const HEDZ_GREEN: Srgba = Srgba::rgb(0.0, 1.0, 0.0);
pub const HEDZ_PURPLE: Srgba = Srgba::rgb(91. / 256., 4. / 256., 138. / 256.);
@@ -119,16 +119,13 @@ pub fn plugin(app: &mut App) {
app.add_plugins(gates::plugin);
app.add_plugins(platforms::plugin);
app.add_plugins(movables::plugin);
app.add_plugins(utils::billboards::plugin);
app.add_plugins(aim::plugin);
app.add_plugins(npc::plugin);
app.add_plugins(keys::plugin);
app.add_plugins(utils::squish_animation::plugin);
app.add_plugins(camera::plugin);
#[cfg(feature = "client")]
app.add_plugins(client::plugin);
app.add_plugins(control::plugin);
app.add_plugins(cutscene::plugin);
app.add_plugins(backpack::plugin);
app.add_plugins(loading_assets::LoadingPlugin);
app.add_plugins(loading_map::plugin);

View File

@@ -12,7 +12,7 @@ use crate::{
loading_assets::GameAssets,
protocol::{PlaySound, is_server},
tb_entities::EnemySpawn,
utils::billboards::Billboard,
utils::Billboard,
};
use bevy::{light::NotShadowCaster, prelude::*};
use serde::{Deserialize, Serialize};

View File

@@ -23,8 +23,7 @@ use crate::{
player::{Player, PlayerBodyMesh},
tick::GameTick,
utils::{
auto_rotate::AutoRotation, billboards::Billboard, squish_animation::SquishAnimation,
trail::SpawnTrail,
Billboard, auto_rotate::AutoRotation, squish_animation::SquishAnimation, trail::SpawnTrail,
},
};
use avian3d::prelude::{
@@ -105,7 +104,7 @@ pub fn plugin(app: &mut App) {
.replicate_once::<PlayerBodyMesh>()
.replicate_once::<Npc>()
.replicate::<SquishAnimation>()
.replicate_once::<Transform>()
.replicate::<Transform>()
.replicate_once::<SpawnTrail>()
.replicate_as::<Visibility, SerVisibility>();

View File

@@ -1,5 +1,4 @@
pub mod auto_rotate;
pub mod billboards;
pub mod cooldown;
pub mod debounce;
pub mod explosions;
@@ -14,7 +13,18 @@ use bevy::prelude::*;
pub use cooldown::Cooldown;
pub use debounce::Debounce;
pub(crate) use observers::global_observer;
use serde::{Deserialize, Serialize};
#[derive(Component, Reflect, Default, PartialEq, Eq, Serialize, Deserialize)]
#[reflect(Component)]
pub enum Billboard {
#[default]
All,
XZ,
}
pub fn plugin(app: &mut App) {
app.register_type::<Billboard>();
app.add_plugins(one_shot_force::plugin);
}