Refactor controls (#14)

* Move controls

* Use system sets

* Use control states for controller

* Unite controls

* move collisions part

* Right deadzone as well

* Remove comment
This commit is contained in:
GitGhillie
2025-03-22 20:32:09 +01:00
committed by GitHub
parent e21efb9bdb
commit f6b640d06c
8 changed files with 555 additions and 582 deletions

View File

@@ -1,7 +1,7 @@
use avian3d::prelude::*;
use bevy::prelude::*;
use crate::{controller::PlayerMovement, controls::Controls, physics_layers::GameLayer};
use crate::{control::Controls, control::controller::PlayerMovement, physics_layers::GameLayer};
#[derive(Component, Reflect, Debug)]
pub struct CameraTarget;

169
src/control/collisions.rs Normal file
View File

@@ -0,0 +1,169 @@
use avian3d::{math::*, prelude::*};
use bevy::prelude::*;
use super::controller::{CharacterController, MaxSlopeAngle};
/// Kinematic bodies do not get pushed by collisions by default,
/// so it needs to be done manually.
///
/// This system handles collision response for kinematic character controllers
/// by pushing them along their contact normals by the current penetration depth,
/// and applying velocity corrections in order to snap to slopes, slide along walls,
/// and predict collisions using speculative contacts.
#[allow(clippy::type_complexity)]
pub fn kinematic_controller_collisions(
collisions: Res<Collisions>,
bodies: Query<&RigidBody>,
collider_parents: Query<&ColliderParent, Without<Sensor>>,
mut character_controllers: Query<
(
&mut Position,
&Rotation,
&mut LinearVelocity,
Option<&MaxSlopeAngle>,
),
(With<RigidBody>, With<CharacterController>),
>,
time: Res<Time>,
) {
// Iterate through collisions and move the kinematic body to resolve penetration
for contacts in collisions.iter() {
// Get the rigid body entities of the colliders (colliders could be children)
let Ok([collider_parent1, collider_parent2]) =
collider_parents.get_many([contacts.entity1, contacts.entity2])
else {
continue;
};
// Get the body of the character controller and whether it is the first
// or second entity in the collision.
let is_first: bool;
let character_rb: RigidBody;
let is_other_dynamic: bool;
let (mut position, rotation, mut linear_velocity, max_slope_angle) =
if let Ok(character) = character_controllers.get_mut(collider_parent1.get()) {
is_first = true;
character_rb = *bodies.get(collider_parent1.get()).unwrap();
is_other_dynamic = bodies
.get(collider_parent2.get())
.is_ok_and(|rb| rb.is_dynamic());
character
} else if let Ok(character) = character_controllers.get_mut(collider_parent2.get()) {
is_first = false;
character_rb = *bodies.get(collider_parent2.get()).unwrap();
is_other_dynamic = bodies
.get(collider_parent1.get())
.is_ok_and(|rb| rb.is_dynamic());
character
} else {
continue;
};
// This system only handles collision response for kinematic character controllers.
if !character_rb.is_kinematic() {
continue;
}
// Iterate through contact manifolds and their contacts.
// Each contact in a single manifold shares the same contact normal.
for manifold in contacts.manifolds.iter() {
let normal = if is_first {
-manifold.global_normal1(rotation)
} else {
-manifold.global_normal2(rotation)
};
let mut deepest_penetration: Scalar = Scalar::MIN;
// Solve each penetrating contact in the manifold.
for contact in manifold.contacts.iter() {
if contact.penetration > 0.0 {
position.0 += normal * contact.penetration;
}
deepest_penetration = deepest_penetration.max(contact.penetration);
}
// For now, this system only handles velocity corrections for collisions against static geometry.
if is_other_dynamic {
continue;
}
// Determine if the slope is climbable or if it's too steep to walk on.
let slope_angle = normal.angle_between(Vector::Y);
let climbable = max_slope_angle.is_some_and(|angle| slope_angle.abs() <= angle.0);
if deepest_penetration > 0.0 {
// If the slope is climbable, snap the velocity so that the character
// up and down the surface smoothly.
if climbable {
// Points in the normal's direction in the XZ plane.
let normal_direction_xz =
normal.reject_from_normalized(Vector::Y).normalize_or_zero();
// The movement speed along the direction above.
let linear_velocity_xz = linear_velocity.dot(normal_direction_xz);
// Snap the Y speed based on the speed at which the character is moving
// up or down the slope, and how steep the slope is.
//
// A 2D visualization of the slope, the contact normal, and the velocity components:
//
//
// normal
// *
// │ * velocity_x
// │ * - - - - - -
// │ * | velocity_y
// │ * |
// *───────────────────*
let max_y_speed = -linear_velocity_xz * slope_angle.tan();
linear_velocity.y = linear_velocity.y.max(max_y_speed);
} else {
// The character is intersecting an unclimbable object, like a wall.
// We want the character to slide along the surface, similarly to
// a collide-and-slide algorithm.
// Don't apply an impulse if the character is moving away from the surface.
if linear_velocity.dot(normal) > 0.0 {
continue;
}
// Slide along the surface, rejecting the velocity along the contact normal.
let impulse = linear_velocity.reject_from_normalized(normal);
linear_velocity.0 = impulse;
}
} else {
// The character is not yet intersecting the other object,
// but the narrow phase detected a speculative collision.
//
// We need to push back the part of the velocity
// that would cause penetration within the next frame.
let normal_speed = linear_velocity.dot(normal);
// Don't apply an impulse if the character is moving away from the surface.
if normal_speed > 0.0 {
continue;
}
// Compute the impulse to apply.
let impulse_magnitude =
normal_speed - (deepest_penetration / time.delta_secs_f64().adjust_precision());
let mut impulse = impulse_magnitude * normal;
// Apply the impulse differently depending on the slope angle.
if climbable {
// Avoid sliding down slopes.
linear_velocity.y -= impulse.y.min(0.0);
} else {
// Avoid climbing up walls.
impulse.y = impulse.y.max(0.0);
linear_velocity.0 -= impulse;
}
}
}
}
}

315
src/control/controller.rs Normal file
View File

@@ -0,0 +1,315 @@
use avian3d::{math::*, prelude::*};
use bevy::{ecs::query::Has, prelude::*};
use crate::player::PlayerRig;
use super::{ControllerSet, Controls, collisions::kinematic_controller_collisions};
pub struct CharacterControllerPlugin;
impl Plugin for CharacterControllerPlugin {
fn build(&self, app: &mut App) {
app.init_resource::<PlayerMovement>();
app.init_resource::<MovementSettings>();
app.register_type::<MovementSettings>();
app.register_type::<MovementDampingFactor>();
app.register_type::<JumpImpulse>();
app.register_type::<ControllerGravity>();
app.register_type::<MovementAcceleration>();
app.add_systems(
Update,
(
set_movement_flag,
brake_on_release,
update_grounded,
apply_gravity,
movement,
apply_movement_damping,
)
.chain()
.in_set(ControllerSet::ApplyControls),
);
app.add_systems(
// Run collision handling after collision detection.
//
// NOTE: The collision implementation here is very basic and a bit buggy.
// A collide-and-slide algorithm would likely work better.
PostProcessCollisions,
kinematic_controller_collisions,
);
}
}
/// A marker component indicating that an entity is using a character controller.
#[derive(Component)]
pub struct CharacterController;
/// A marker component indicating that an entity is on the ground.
#[derive(Component)]
#[component(storage = "SparseSet")]
pub struct Grounded;
#[derive(Resource, Default)]
pub struct PlayerMovement {
pub any_direction: bool,
}
#[derive(Resource, Reflect)]
#[reflect(Resource)]
struct MovementSettings {
damping_normal: f32,
damping_brake: f32,
damping_brake_air: f32,
}
// todo some duplicate with player.rs settings
impl Default for MovementSettings {
fn default() -> Self {
Self {
damping_normal: 1.0,
damping_brake: 30.0,
damping_brake_air: 1.0,
}
}
}
/// The acceleration used for character movement.
#[derive(Component, Reflect)]
#[reflect(Component)]
pub struct MovementAcceleration(Scalar);
/// The damping factor used for slowing down movement.
#[derive(Component, Reflect)]
#[reflect(Component)]
pub struct MovementDampingFactor(Scalar);
/// The strength of a jump.
#[derive(Component, Reflect)]
#[reflect(Component)]
pub struct JumpImpulse(Scalar);
/// The gravitational acceleration used for a character controller.
#[derive(Component, Reflect)]
#[reflect(Component)]
pub struct ControllerGravity(Vector);
/// The maximum angle a slope can have for a character controller
/// to be able to climb and jump. If the slope is steeper than this angle,
/// the character will slide down.
#[derive(Component, Reflect)]
#[reflect(Component)]
pub struct MaxSlopeAngle(pub Scalar);
/// A bundle that contains the components needed for a basic
/// kinematic character controller.
#[derive(Bundle)]
pub struct CharacterControllerBundle {
character_controller: CharacterController,
rigid_body: RigidBody,
collider: Collider,
ground_caster: ShapeCaster,
gravity: ControllerGravity,
movement: MovementBundle,
}
/// A bundle that contains components for character movement.
#[derive(Bundle)]
pub struct MovementBundle {
acceleration: MovementAcceleration,
damping: MovementDampingFactor,
jump_impulse: JumpImpulse,
max_slope_angle: MaxSlopeAngle,
}
impl MovementBundle {
pub const fn new(
acceleration: Scalar,
damping: Scalar,
jump_impulse: Scalar,
max_slope_angle: Scalar,
) -> Self {
Self {
acceleration: MovementAcceleration(acceleration),
damping: MovementDampingFactor(damping),
jump_impulse: JumpImpulse(jump_impulse),
max_slope_angle: MaxSlopeAngle(max_slope_angle),
}
}
}
impl Default for MovementBundle {
fn default() -> Self {
Self::new(30.0, 0.9, 7.0, PI * 0.45)
}
}
impl CharacterControllerBundle {
pub fn new(collider: Collider, gravity: Vector) -> Self {
// Create shape caster as a slightly smaller version of collider
let mut caster_shape = collider.clone();
caster_shape.set_scale(Vector::ONE * 0.98, 10);
Self {
character_controller: CharacterController,
rigid_body: RigidBody::Kinematic,
collider,
ground_caster: ShapeCaster::new(
caster_shape,
Vector::ZERO,
Quaternion::default(),
Dir3::NEG_Y,
)
.with_max_distance(0.2),
gravity: ControllerGravity(gravity),
movement: MovementBundle::default(),
}
}
pub fn with_movement(
mut self,
acceleration: Scalar,
damping: Scalar,
jump_impulse: Scalar,
max_slope_angle: Scalar,
) -> Self {
self.movement = MovementBundle::new(acceleration, damping, jump_impulse, max_slope_angle);
self
}
}
/// Apply extra friction when no movement input is given
/// In the original you stop instantly in this case
fn brake_on_release(
player_movement: Res<PlayerMovement>,
movement_settings: Res<MovementSettings>,
mut damping_q: Query<(Entity, &mut MovementDampingFactor)>,
grounded_q: Query<&Grounded>,
) {
for (entity, mut damping) in &mut damping_q {
let is_grounded = grounded_q.get(entity).is_ok();
if !player_movement.any_direction && is_grounded {
damping.0 = movement_settings.damping_brake;
} else if !player_movement.any_direction && !is_grounded {
damping.0 = movement_settings.damping_brake_air;
} else {
damping.0 = movement_settings.damping_normal;
}
}
}
/// Updates the [`Grounded`] status for character controllers.
fn update_grounded(
mut commands: Commands,
mut query: Query<
(Entity, &ShapeHits, &Rotation, Option<&MaxSlopeAngle>),
With<CharacterController>,
>,
) {
for (entity, hits, rotation, max_slope_angle) in &mut query {
// The character is grounded if the shape caster has a hit with a normal
// that isn't too steep.
let is_grounded = hits.iter().any(|hit| {
if let Some(angle) = max_slope_angle {
(rotation * -hit.normal2).angle_between(Vector::Y).abs() <= angle.0
} else {
true
}
});
if is_grounded {
commands.entity(entity).insert(Grounded);
} else {
commands.entity(entity).remove::<Grounded>();
}
}
}
/// Sets the movement flag, which is an indicator for the rig animation and the braking system.
fn set_movement_flag(mut player_movement: ResMut<PlayerMovement>, controls: Res<Controls>) {
let mut direction = controls.keyboard_state.move_dir;
let deadzone = 0.2;
if let Some(gamepad) = controls.gamepad_state {
direction += gamepad.move_dir;
}
if player_movement.any_direction {
if direction.length_squared() < deadzone {
player_movement.any_direction = false;
}
} else {
if direction.length_squared() > deadzone {
player_movement.any_direction = true;
}
}
}
/// Responds to [`MovementAction`] events and moves character controllers accordingly.
fn movement(
time: Res<Time>,
controls: Res<Controls>,
mut controllers: Query<(
&MovementAcceleration,
&JumpImpulse,
&mut LinearVelocity,
Has<Grounded>,
)>,
rig_transform_q: Option<Single<&GlobalTransform, With<PlayerRig>>>,
) {
let delta_time = time.delta_secs();
let mut direction = controls.keyboard_state.move_dir;
let mut jump_requested = controls.keyboard_state.jump;
if let Some(gamepad) = controls.gamepad_state {
direction += gamepad.move_dir;
direction = direction.normalize_or_zero();
jump_requested |= gamepad.jump;
}
for (movement_acceleration, jump_impulse, mut linear_velocity, is_grounded) in &mut controllers
{
let mut direction = direction.extend(0.0).xzy();
if let Some(ref rig_transform) = rig_transform_q {
direction =
(rig_transform.forward() * direction.z) + (rig_transform.right() * direction.x);
}
linear_velocity.x -= direction.x * movement_acceleration.0 * delta_time;
linear_velocity.z -= direction.z * movement_acceleration.0 * delta_time;
if is_grounded && jump_requested {
linear_velocity.y = jump_impulse.0;
}
}
}
/// Applies [`ControllerGravity`] to character controllers.
fn apply_gravity(
time: Res<Time>,
mut controllers: Query<(&ControllerGravity, &mut LinearVelocity)>,
) {
let delta_time = time.delta_secs();
for (gravity, mut linear_velocity) in &mut controllers {
linear_velocity.0 += gravity.0 * delta_time;
}
}
/// Slows down movement in the XZ plane.
fn apply_movement_damping(
time: Res<Time>,
mut query: Query<(&MovementDampingFactor, &mut LinearVelocity)>,
) {
let delta_time = time.delta_secs();
for (damping_factor, mut linear_velocity) in &mut query {
// We could use `LinearDamping`, but we don't want to dampen movement along the Y axis
linear_velocity.x *= 1.0 - damping_factor.0 * delta_time;
linear_velocity.z *= 1.0 - damping_factor.0 * delta_time;
}
}

View File

@@ -8,29 +8,26 @@ use bevy::{
prelude::*,
};
#[derive(Resource, Debug, Clone, Copy, Default, PartialEq)]
pub struct ControlState {
pub move_dir: Vec2,
pub look_dir: Vec2,
pub jump: bool,
pub view_mode: bool,
}
#[derive(Resource, Debug, Default)]
pub struct Controls {
pub keyboard_state: ControlState,
pub gamepad_state: Option<ControlState>,
}
use super::{ControlState, ControllerSet, Controls};
pub fn plugin(app: &mut App) {
app.init_resource::<Controls>();
app.add_systems(Update, (gamepad_controls, keyboard_controls, mouse_rotate));
app.add_systems(Update, mouse_click.run_if(on_event::<MouseButtonInput>));
app.add_systems(Update, gamepad_connections.run_if(on_event::<GamepadEvent>));
app.add_systems(
Update,
(
gamepad_controls,
keyboard_controls,
mouse_rotate,
mouse_click.run_if(on_event::<MouseButtonInput>),
gamepad_connections.run_if(on_event::<GamepadEvent>),
)
.in_set(ControllerSet::CollectInputs),
);
}
fn clamp_vec2(v: Vec2, min: f32) -> Vec2 {
/// 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 },
@@ -49,6 +46,9 @@ fn gamepad_controls(
return;
};
let deadzone_left_stick = 0.15;
let deadzone_right_stick = 0.15;
// info!("gamepad: {:?}", gamepad);
let rotate = gamepad
@@ -69,11 +69,11 @@ fn gamepad_controls(
0.,
)
} else {
clamp_vec2(gamepad.right_stick(), 0.01) * 40.
deadzone_square(gamepad.right_stick(), deadzone_right_stick) * 40.
};
let state = ControlState {
move_dir: gamepad.left_stick(),
move_dir: deadzone_square(gamepad.left_stick(), deadzone_left_stick),
look_dir,
jump: gamepad.pressed(GamepadButton::South),
view_mode: gamepad.pressed(GamepadButton::LeftTrigger2),
@@ -126,20 +126,19 @@ fn keyboard_controls(
keyboard: Res<ButtonInput<KeyCode>>,
mut controls: ResMut<Controls>,
) {
let mut direction = Vec2::ZERO;
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];
if keyboard.pressed(KeyCode::KeyW) {
direction = Vec2::Y;
}
if keyboard.pressed(KeyCode::KeyS) {
direction = -Vec2::Y;
}
if keyboard.pressed(KeyCode::KeyA) {
direction += -Vec2::X;
}
if keyboard.pressed(KeyCode::KeyD) {
direction += Vec2::X;
}
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);
if keyboard.just_pressed(KeyCode::KeyB) {
commands.trigger(BackpackAction::OpenClose);
@@ -206,9 +205,6 @@ fn gamepad_connections(mut evr_gamepad: EventReader<GamepadEvent>) {
info!("Lost connection with gamepad: {:?}", connection.gamepad);
}
},
GamepadEvent::Button(gamepad_button_changed_event) => {
info!("Gamepad Button: {:?}", gamepad_button_changed_event);
}
_ => {}
}
}

33
src/control/mod.rs Normal file
View File

@@ -0,0 +1,33 @@
use bevy::prelude::*;
mod collisions;
pub mod controller;
pub mod controls;
#[derive(SystemSet, Debug, Hash, PartialEq, Eq, Clone)]
enum ControllerSet {
CollectInputs,
ApplyControls,
}
#[derive(Resource, Debug, Clone, Copy, Default, PartialEq)]
pub struct ControlState {
pub move_dir: Vec2,
pub look_dir: Vec2,
pub jump: bool,
pub view_mode: bool,
}
#[derive(Resource, Debug, Default)]
pub struct Controls {
pub keyboard_state: ControlState,
pub gamepad_state: Option<ControlState>,
}
pub fn plugin(app: &mut App) {
app.add_plugins(controls::plugin);
app.configure_sets(
Update,
(ControllerSet::CollectInputs, ControllerSet::ApplyControls).chain(),
);
}

View File

@@ -1,539 +0,0 @@
use avian3d::{math::*, prelude::*};
use bevy::{ecs::query::Has, prelude::*};
use crate::player::PlayerRig;
pub struct CharacterControllerPlugin;
impl Plugin for CharacterControllerPlugin {
fn build(&self, app: &mut App) {
app.init_resource::<PlayerMovement>();
app.init_resource::<MovementSettings>();
app.register_type::<MovementSettings>();
app.register_type::<MovementDampingFactor>();
app.register_type::<JumpImpulse>();
app.register_type::<ControllerGravity>();
app.register_type::<MovementAcceleration>();
app.add_event::<MovementAction>();
app.add_systems(
Update,
(
keyboard_input,
gamepad_input,
clear_movement_flag,
brake_on_release,
update_grounded,
apply_gravity,
movement,
apply_movement_damping,
)
.chain(),
);
app.add_systems(
// Run collision handling after collision detection.
//
// NOTE: The collision implementation here is very basic and a bit buggy.
// A collide-and-slide algorithm would likely work better.
PostProcessCollisions,
kinematic_controller_collisions,
);
}
}
/// An event sent for a movement input action.
#[derive(Event)]
pub enum MovementAction {
Move(Vector2),
Jump,
}
/// A marker component indicating that an entity is using a character controller.
#[derive(Component)]
pub struct CharacterController;
/// A marker component indicating that an entity is on the ground.
#[derive(Component)]
#[component(storage = "SparseSet")]
pub struct Grounded;
#[derive(Resource, Default)]
pub struct PlayerMovement {
pub any_direction: bool,
}
#[derive(Resource, Reflect)]
#[reflect(Resource)]
struct MovementSettings {
damping_normal: f32,
damping_brake: f32,
damping_brake_air: f32,
}
// todo some duplicate with player.rs settings
impl Default for MovementSettings {
fn default() -> Self {
Self {
damping_normal: 1.0,
damping_brake: 30.0,
damping_brake_air: 1.0,
}
}
}
/// The acceleration used for character movement.
#[derive(Component, Reflect)]
#[reflect(Component)]
pub struct MovementAcceleration(Scalar);
/// The damping factor used for slowing down movement.
#[derive(Component, Reflect)]
#[reflect(Component)]
pub struct MovementDampingFactor(Scalar);
/// The strength of a jump.
#[derive(Component, Reflect)]
#[reflect(Component)]
pub struct JumpImpulse(Scalar);
/// The gravitational acceleration used for a character controller.
#[derive(Component, Reflect)]
#[reflect(Component)]
pub struct ControllerGravity(Vector);
/// The maximum angle a slope can have for a character controller
/// to be able to climb and jump. If the slope is steeper than this angle,
/// the character will slide down.
#[derive(Component, Reflect)]
#[reflect(Component)]
pub struct MaxSlopeAngle(Scalar);
/// A bundle that contains the components needed for a basic
/// kinematic character controller.
#[derive(Bundle)]
pub struct CharacterControllerBundle {
character_controller: CharacterController,
rigid_body: RigidBody,
collider: Collider,
ground_caster: ShapeCaster,
gravity: ControllerGravity,
movement: MovementBundle,
}
/// A bundle that contains components for character movement.
#[derive(Bundle)]
pub struct MovementBundle {
acceleration: MovementAcceleration,
damping: MovementDampingFactor,
jump_impulse: JumpImpulse,
max_slope_angle: MaxSlopeAngle,
}
impl MovementBundle {
pub const fn new(
acceleration: Scalar,
damping: Scalar,
jump_impulse: Scalar,
max_slope_angle: Scalar,
) -> Self {
Self {
acceleration: MovementAcceleration(acceleration),
damping: MovementDampingFactor(damping),
jump_impulse: JumpImpulse(jump_impulse),
max_slope_angle: MaxSlopeAngle(max_slope_angle),
}
}
}
impl Default for MovementBundle {
fn default() -> Self {
Self::new(30.0, 0.9, 7.0, PI * 0.45)
}
}
impl CharacterControllerBundle {
pub fn new(collider: Collider, gravity: Vector) -> Self {
// Create shape caster as a slightly smaller version of collider
let mut caster_shape = collider.clone();
caster_shape.set_scale(Vector::ONE * 0.98, 10);
Self {
character_controller: CharacterController,
rigid_body: RigidBody::Kinematic,
collider,
ground_caster: ShapeCaster::new(
caster_shape,
Vector::ZERO,
Quaternion::default(),
Dir3::NEG_Y,
)
.with_max_distance(0.2),
gravity: ControllerGravity(gravity),
movement: MovementBundle::default(),
}
}
pub fn with_movement(
mut self,
acceleration: Scalar,
damping: Scalar,
jump_impulse: Scalar,
max_slope_angle: Scalar,
) -> Self {
self.movement = MovementBundle::new(acceleration, damping, jump_impulse, max_slope_angle);
self
}
}
/// Sends [`MovementAction`] events based on keyboard input.
fn keyboard_input(
mut movement_event_writer: EventWriter<MovementAction>,
keyboard_input: Res<ButtonInput<KeyCode>>,
) {
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_input.any_pressed(up_binds);
let down = keyboard_input.any_pressed(down_binds);
let left = keyboard_input.any_pressed(left_binds);
let right = keyboard_input.any_pressed(right_binds);
let horizontal = right as i8 - left as i8;
let vertical = up as i8 - down as i8;
let direction = Vector2::new(horizontal as Scalar, vertical as Scalar).clamp_length_max(1.0);
if direction != Vector2::ZERO {
movement_event_writer.send(MovementAction::Move(direction));
}
if keyboard_input.just_pressed(KeyCode::Space) {
movement_event_writer.send(MovementAction::Jump);
}
}
/// Sends [`MovementAction`] events based on gamepad input.
fn gamepad_input(
mut movement_event_writer: EventWriter<MovementAction>,
gamepads: Query<&Gamepad>,
) {
for gamepad in gamepads.iter() {
if let (Some(x), Some(y)) = (
gamepad.get(GamepadAxis::LeftStickX),
gamepad.get(GamepadAxis::LeftStickY),
) {
let deadzone = 0.01;
let dir = Vector2::new(x as Scalar, y as Scalar).clamp_length_max(1.0);
if dir.length_squared() > deadzone {
movement_event_writer.send(MovementAction::Move(dir));
}
}
if gamepad.just_pressed(GamepadButton::South) {
movement_event_writer.send(MovementAction::Jump);
}
}
}
/// Apply extra friction when no movement input is given
/// In the original you stop instantly in this case
fn brake_on_release(
player_movement: Res<PlayerMovement>,
movement_settings: Res<MovementSettings>,
mut damping_q: Query<(Entity, &mut MovementDampingFactor)>,
grounded_q: Query<&Grounded>,
) {
for (entity, mut damping) in &mut damping_q {
let is_grounded = grounded_q.get(entity).is_ok();
if !player_movement.any_direction && is_grounded {
damping.0 = movement_settings.damping_brake;
} else if !player_movement.any_direction && !is_grounded {
damping.0 = movement_settings.damping_brake_air;
} else {
damping.0 = movement_settings.damping_normal;
}
}
}
/// Updates the [`Grounded`] status for character controllers.
fn update_grounded(
mut commands: Commands,
mut query: Query<
(Entity, &ShapeHits, &Rotation, Option<&MaxSlopeAngle>),
With<CharacterController>,
>,
) {
for (entity, hits, rotation, max_slope_angle) in &mut query {
// The character is grounded if the shape caster has a hit with a normal
// that isn't too steep.
let is_grounded = hits.iter().any(|hit| {
if let Some(angle) = max_slope_angle {
(rotation * -hit.normal2).angle_between(Vector::Y).abs() <= angle.0
} else {
true
}
});
if is_grounded {
commands.entity(entity).insert(Grounded);
} else {
commands.entity(entity).remove::<Grounded>();
}
}
}
fn clear_movement_flag(
mut player_movement: ResMut<PlayerMovement>,
movement_event_reader: EventReader<MovementAction>,
) {
if movement_event_reader.is_empty() {
player_movement.any_direction = false;
}
}
/// Responds to [`MovementAction`] events and moves character controllers accordingly.
fn movement(
time: Res<Time>,
mut movement_event_reader: EventReader<MovementAction>,
mut controllers: Query<(
&MovementAcceleration,
&JumpImpulse,
&mut LinearVelocity,
Has<Grounded>,
)>,
rig_transform_q: Option<Single<&GlobalTransform, With<PlayerRig>>>,
mut player_movement: ResMut<PlayerMovement>,
) {
let delta_time = time.delta_secs();
for event in movement_event_reader.read() {
for (movement_acceleration, jump_impulse, mut linear_velocity, is_grounded) in
&mut controllers
{
match event {
MovementAction::Move(direction) => {
let mut direction = direction.extend(0.0).xzy();
if let Some(ref rig_transform) = rig_transform_q {
direction = (rig_transform.forward() * direction.z)
+ (rig_transform.right() * direction.x);
}
linear_velocity.x -= direction.x * movement_acceleration.0 * delta_time;
linear_velocity.z -= direction.z * movement_acceleration.0 * delta_time;
// Update movement flag
let deadzone = 0.2;
// todo this is probably not necessary
if player_movement.any_direction && direction.length_squared() < deadzone {
player_movement.any_direction = false;
}
if !player_movement.any_direction && direction.length_squared() > deadzone {
player_movement.any_direction = true;
}
}
MovementAction::Jump => {
if is_grounded {
linear_velocity.y = jump_impulse.0;
}
}
}
}
}
}
/// Applies [`ControllerGravity`] to character controllers.
fn apply_gravity(
time: Res<Time>,
mut controllers: Query<(&ControllerGravity, &mut LinearVelocity)>,
) {
let delta_time = time.delta_secs();
for (gravity, mut linear_velocity) in &mut controllers {
linear_velocity.0 += gravity.0 * delta_time;
}
}
/// Slows down movement in the XZ plane.
fn apply_movement_damping(
time: Res<Time>,
mut query: Query<(&MovementDampingFactor, &mut LinearVelocity)>,
) {
let delta_time = time.delta_secs();
for (damping_factor, mut linear_velocity) in &mut query {
// We could use `LinearDamping`, but we don't want to dampen movement along the Y axis
linear_velocity.x *= 1.0 - damping_factor.0 * delta_time;
linear_velocity.z *= 1.0 - damping_factor.0 * delta_time;
}
}
/// Kinematic bodies do not get pushed by collisions by default,
/// so it needs to be done manually.
///
/// This system handles collision response for kinematic character controllers
/// by pushing them along their contact normals by the current penetration depth,
/// and applying velocity corrections in order to snap to slopes, slide along walls,
/// and predict collisions using speculative contacts.
#[allow(clippy::type_complexity)]
fn kinematic_controller_collisions(
collisions: Res<Collisions>,
bodies: Query<&RigidBody>,
collider_parents: Query<&ColliderParent, Without<Sensor>>,
mut character_controllers: Query<
(
&mut Position,
&Rotation,
&mut LinearVelocity,
Option<&MaxSlopeAngle>,
),
(With<RigidBody>, With<CharacterController>),
>,
time: Res<Time>,
) {
// Iterate through collisions and move the kinematic body to resolve penetration
for contacts in collisions.iter() {
// Get the rigid body entities of the colliders (colliders could be children)
let Ok([collider_parent1, collider_parent2]) =
collider_parents.get_many([contacts.entity1, contacts.entity2])
else {
continue;
};
// Get the body of the character controller and whether it is the first
// or second entity in the collision.
let is_first: bool;
let character_rb: RigidBody;
let is_other_dynamic: bool;
let (mut position, rotation, mut linear_velocity, max_slope_angle) =
if let Ok(character) = character_controllers.get_mut(collider_parent1.get()) {
is_first = true;
character_rb = *bodies.get(collider_parent1.get()).unwrap();
is_other_dynamic = bodies
.get(collider_parent2.get())
.is_ok_and(|rb| rb.is_dynamic());
character
} else if let Ok(character) = character_controllers.get_mut(collider_parent2.get()) {
is_first = false;
character_rb = *bodies.get(collider_parent2.get()).unwrap();
is_other_dynamic = bodies
.get(collider_parent1.get())
.is_ok_and(|rb| rb.is_dynamic());
character
} else {
continue;
};
// This system only handles collision response for kinematic character controllers.
if !character_rb.is_kinematic() {
continue;
}
// Iterate through contact manifolds and their contacts.
// Each contact in a single manifold shares the same contact normal.
for manifold in contacts.manifolds.iter() {
let normal = if is_first {
-manifold.global_normal1(rotation)
} else {
-manifold.global_normal2(rotation)
};
let mut deepest_penetration: Scalar = Scalar::MIN;
// Solve each penetrating contact in the manifold.
for contact in manifold.contacts.iter() {
if contact.penetration > 0.0 {
position.0 += normal * contact.penetration;
}
deepest_penetration = deepest_penetration.max(contact.penetration);
}
// For now, this system only handles velocity corrections for collisions against static geometry.
if is_other_dynamic {
continue;
}
// Determine if the slope is climbable or if it's too steep to walk on.
let slope_angle = normal.angle_between(Vector::Y);
let climbable = max_slope_angle.is_some_and(|angle| slope_angle.abs() <= angle.0);
if deepest_penetration > 0.0 {
// If the slope is climbable, snap the velocity so that the character
// up and down the surface smoothly.
if climbable {
// Points in the normal's direction in the XZ plane.
let normal_direction_xz =
normal.reject_from_normalized(Vector::Y).normalize_or_zero();
// The movement speed along the direction above.
let linear_velocity_xz = linear_velocity.dot(normal_direction_xz);
// Snap the Y speed based on the speed at which the character is moving
// up or down the slope, and how steep the slope is.
//
// A 2D visualization of the slope, the contact normal, and the velocity components:
//
//
// normal
// *
// │ * velocity_x
// │ * - - - - - -
// │ * | velocity_y
// │ * |
// *───────────────────*
let max_y_speed = -linear_velocity_xz * slope_angle.tan();
linear_velocity.y = linear_velocity.y.max(max_y_speed);
} else {
// The character is intersecting an unclimbable object, like a wall.
// We want the character to slide along the surface, similarly to
// a collide-and-slide algorithm.
// Don't apply an impulse if the character is moving away from the surface.
if linear_velocity.dot(normal) > 0.0 {
continue;
}
// Slide along the surface, rejecting the velocity along the contact normal.
let impulse = linear_velocity.reject_from_normalized(normal);
linear_velocity.0 = impulse;
}
} else {
// The character is not yet intersecting the other object,
// but the narrow phase detected a speculative collision.
//
// We need to push back the part of the velocity
// that would cause penetration within the next frame.
let normal_speed = linear_velocity.dot(normal);
// Don't apply an impulse if the character is moving away from the surface.
if normal_speed > 0.0 {
continue;
}
// Compute the impulse to apply.
let impulse_magnitude =
normal_speed - (deepest_penetration / time.delta_secs_f64().adjust_precision());
let mut impulse = impulse_magnitude * normal;
// Apply the impulse differently depending on the slope angle.
if climbable {
// Avoid sliding down slopes.
linear_velocity.y -= impulse.y.min(0.0);
} else {
// Avoid climbing up walls.
impulse.y = impulse.y.max(0.0);
linear_velocity.0 -= impulse;
}
}
}
}
}

View File

@@ -4,8 +4,7 @@ mod backpack;
mod billboards;
mod camera;
mod cash;
mod controller;
mod controls;
mod control;
mod cutscene;
mod gates;
mod heads_ui;
@@ -28,7 +27,7 @@ use bevy::prelude::*;
use bevy::render::view::ColorGrading;
use bevy::scene::SceneInstanceReady;
use bevy_trenchbroom::prelude::*;
use controller::CharacterControllerPlugin;
use control::controller::CharacterControllerPlugin;
use physics_layers::GameLayer;
#[derive(Resource, Reflect, Debug)]
@@ -92,7 +91,7 @@ fn main() {
app.add_plugins(keys::plugin);
app.add_plugins(squish_animation::plugin);
app.add_plugins(cutscene::plugin);
app.add_plugins(controls::plugin);
app.add_plugins(control::plugin);
app.add_plugins(sounds::plugin);
app.add_plugins(camera::plugin);
app.add_plugins(backpack::plugin);

View File

@@ -2,8 +2,8 @@ use crate::{
alien::{ALIEN_ASSET_PATH, Animations},
camera::{CameraArmRotation, CameraTarget},
cash::{Cash, CashCollectEvent},
controller::{CharacterControllerBundle, PlayerMovement},
controls::Controls,
control::Controls,
control::controller::{CharacterControllerBundle, PlayerMovement},
heads_ui::HeadChanged,
physics_layers::GameLayer,
tb_entities::SpawnPoint,