use happy_feet as kinematic character controller (#42)

This commit is contained in:
extrawurst
2025-06-17 21:27:20 +02:00
committed by GitHub
parent 580419e823
commit a2ea917c1e
22 changed files with 727 additions and 786 deletions

775
Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@@ -35,6 +35,7 @@ bevy_debug_log = "0.6.0"
bevy_common_assets = { version = "0.13.0", features = ["ron"] }
serde = { version = "1.0.219", features = ["derive"] }
ron = "0.8"
happy_feet = { git = "https://github.com/rustunit/happy_feet.git", rev = "ecfecc6243862bc2bc64dcadfd0efd21c766ab5b" }
[build-dependencies]
vergen-gitcl = "1.0"

View File

@@ -83,7 +83,7 @@ fn on_trigger_missile(
let asset = gltf_assets.get(&mesh).unwrap();
commands.spawn((
Name::new("projectile-missle"),
Name::new("projectile-missile"),
CurverProjectile {
time: time.elapsed_secs(),
damage: head.damage,
@@ -153,6 +153,7 @@ fn shot_collision(
mut commands: Commands,
mut collision_event_reader: EventReader<CollisionStarted>,
query_shot: Query<&Transform, With<CurverProjectile>>,
sensors: Query<(), With<Sensor>>,
assets: Res<ShotAssets>,
mut sprite_params: Sprite3dParams,
) {
@@ -161,6 +162,10 @@ fn shot_collision(
continue;
}
if sensors.contains(*e1) && sensors.contains(*e2) {
continue;
}
let shot_entity = if query_shot.contains(*e1) { *e1 } else { *e2 };
let Ok(shot_pos) = query_shot.get(shot_entity).map(|t| t.translation) else {

View File

@@ -151,6 +151,7 @@ fn shot_collision(
mut commands: Commands,
mut collision_event_reader: EventReader<CollisionStarted>,
query_shot: Query<(&GunProjectile, &Transform)>,
sensors: Query<(), With<Sensor>>,
assets: Res<ShotAssets>,
mut sprite_params: Sprite3dParams,
) {
@@ -159,6 +160,10 @@ fn shot_collision(
continue;
}
if sensors.contains(*e1) && sensors.contains(*e2) {
continue;
}
let shot_entity = if query_shot.contains(*e1) { *e1 } else { *e2 };
if let Ok(mut entity) = commands.get_entity(shot_entity) {

View File

@@ -82,7 +82,7 @@ fn on_trigger_missile(
let asset = gltf_assets.get(&mesh).unwrap();
commands.spawn((
Name::new("projectile-missle"),
Name::new("projectile-missile"),
MissileProjectile {
time: time.elapsed_secs(),
damage: head.damage,
@@ -143,6 +143,7 @@ fn shot_collision(
mut commands: Commands,
mut collision_event_reader: EventReader<CollisionStarted>,
query_shot: Query<(&MissileProjectile, &Transform)>,
sensors: Query<(), With<Sensor>>,
assets: Res<ShotAssets>,
mut sprite_params: Sprite3dParams,
) {
@@ -151,6 +152,10 @@ fn shot_collision(
continue;
}
if sensors.contains(*e1) && sensors.contains(*e2) {
continue;
}
let shot_entity = if query_shot.contains(*e1) { *e1 } else { *e2 };
let Ok((shot_pos, damage)) = query_shot

View File

@@ -100,6 +100,7 @@ fn on_trigger_thrown(
Mass(0.01),
LinearVelocity(vel),
Visibility::default(),
Sensor,
))
.with_child((
AutoRotation(Quat::from_rotation_x(0.4) * Quat::from_rotation_z(0.3)),
@@ -111,6 +112,7 @@ fn shot_collision(
mut commands: Commands,
mut collision_event_reader: EventReader<CollisionStarted>,
query_shot: Query<(&ThrownProjectile, &Transform)>,
sensors: Query<(), With<Sensor>>,
assets: Res<ShotAssets>,
mut sprite_params: Sprite3dParams,
) {
@@ -119,6 +121,10 @@ fn shot_collision(
continue;
}
if sensors.contains(*e1) && sensors.contains(*e2) {
continue;
}
let shot_entity = if query_shot.contains(*e1) { *e1 } else { *e2 };
let Ok((shot_pos, animation, damage)) =

View File

@@ -48,7 +48,7 @@ pub fn plugin(app: &mut App) {
app.init_resource::<CameraState>();
app.add_systems(OnEnter(GameState::Playing), startup);
app.add_systems(
Update,
PreUpdate,
(update, update_ui, update_look_around, rotate_view).run_if(in_state(GameState::Playing)),
);
}
@@ -110,7 +110,7 @@ fn update(
(&MainCamera, &mut Transform, &CameraRotationInput),
(Without<CameraTarget>, Without<CameraArmRotation>),
>,
target: Single<&GlobalTransform, (With<CameraTarget>, Without<CameraArmRotation>)>,
target_q: Single<&Transform, (With<CameraTarget>, Without<CameraArmRotation>)>,
arm_rotation: Single<&Transform, With<CameraArmRotation>>,
spatial_query: SpatialQuery,
cam_state: Res<CameraState>,
@@ -119,7 +119,7 @@ fn update(
return;
}
let target = target.translation();
let target = target_q.translation;
let arm_tf = arm_rotation;

View File

@@ -1,163 +0,0 @@
use avian3d::{math::*, prelude::*};
use bevy::prelude::*;
use super::controller_common::{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: Collisions,
bodies: Query<&RigidBody>,
collider_rbs: Query<&ColliderOf, Without<Sensor>>,
mut character_controllers: Query<
(&mut Position, &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([&ColliderOf { body: rb1 }, &ColliderOf { body: rb2 }]) =
collider_rbs.get_many([contacts.collider1, contacts.collider2])
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, mut linear_velocity, max_slope_angle) =
if let Ok(character) = character_controllers.get_mut(rb1) {
is_first = true;
character_rb = *bodies.get(rb1).unwrap();
is_other_dynamic = bodies.get(rb2).is_ok_and(|rb| rb.is_dynamic());
character
} else if let Ok(character) = character_controllers.get_mut(rb2) {
is_first = false;
character_rb = *bodies.get(rb2).unwrap();
is_other_dynamic = bodies.get(rb1).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.normal
} else {
manifold.normal
};
let mut deepest_penetration: Scalar = Scalar::MIN;
// Solve each penetrating contact in the manifold.
for contact in manifold.points.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();
if linear_velocity.y < 0.0 {
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

@@ -1,45 +1,51 @@
use avian3d::{math::*, prelude::*};
use bevy::prelude::*;
use crate::{
GameState, control::collisions::kinematic_controller_collisions, player::PlayerBodyMesh,
use happy_feet::KinematicVelocity;
use happy_feet::ground::{Grounding, GroundingConfig};
use happy_feet::prelude::{
Character, CharacterDrag, CharacterFriction, CharacterGravity, CharacterMovement,
CharacterPlugin, MoveInput, SteppingBehaviour, SteppingConfig,
};
use super::ControllerSwitchEvent;
use crate::GameState;
use crate::control::SelectedController;
use crate::control::controls::ControllerSettings;
use crate::heads_database::HeadControls;
use crate::player::PlayerBodyMesh;
use super::{ControllerSet, ControllerSwitchEvent};
pub fn plugin(app: &mut App) {
app.init_resource::<PlayerMovement>();
app.init_resource::<MovementSettings>();
app.add_plugins(CharacterPlugin::default());
app.register_type::<MovementSettings>();
app.register_type::<MovementDampingFactor>();
app.register_type::<JumpImpulse>();
app.register_type::<ControllerGravity>();
app.register_type::<MovementSpeed>();
app.register_type::<MovementSpeedFactor>();
app.init_resource::<PlayerMovement>();
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.
PhysicsSchedule,
kinematic_controller_collisions
.in_set(NarrowPhaseSet::Last)
.run_if(in_state(GameState::Playing)),
PreUpdate,
reset_upon_switch
.run_if(in_state(GameState::Playing))
.before(ControllerSet::ApplyControlsRun)
.before(ControllerSet::ApplyControlsFly),
)
.add_systems(
FixedPreUpdate,
decelerate.run_if(in_state(GameState::Playing)),
);
}
/// Reset the pitch and velocity of the character if the controller was switched.
pub fn reset_upon_switch(
mut c: Commands,
mut event_controller_switch: EventReader<ControllerSwitchEvent>,
controller: Res<SelectedController>,
mut rig_transform_q: Option<Single<&mut Transform, With<PlayerBodyMesh>>>,
mut controllers: Query<&mut LinearVelocity>,
mut velocity: Single<&mut KinematicVelocity, With<Character>>,
character: Single<Entity, With<Character>>,
) {
for _ in event_controller_switch.read() {
// Reset velocity
for mut linear_velocity in &mut controllers {
linear_velocity.0 = Vec3::ZERO;
}
velocity.0 = Vec3::ZERO;
// Reset pitch but keep yaw the same
if let Some(ref mut rig_transform) = rig_transform_q {
@@ -47,6 +53,55 @@ pub fn reset_upon_switch(
let yaw = euler_rot.0;
rig_transform.rotation = Quat::from_euler(EulerRot::YXZ, yaw, 0.0, 0.0);
}
match controller.0 {
ControllerSet::ApplyControlsFly => {
c.entity(*character).insert(FLYING_MOVEMENT_CONFIG);
}
ControllerSet::ApplyControlsRun => {
c.entity(*character).insert(RUNNING_MOVEMENT_CONFIG);
}
_ => unreachable!(),
}
}
}
/// Decelerates the player in the directions of "undesired velocity"; velocity that is not aligned
/// with the movement input direction. This makes it quicker to reverse direction, and prevents
/// sliding around, even with low friction, without slowing down the player globally like high
/// friction or drag would.
fn decelerate(
mut character: Query<(
&mut KinematicVelocity,
&MoveInput,
Option<&Grounding>,
&ControllerSettings,
)>,
) {
for (mut velocity, input, grounding, settings) in &mut character {
let direction = input.value.normalize();
let ground_normal = grounding
.and_then(|it| it.normal())
.unwrap_or(Dir3::Y)
.as_vec3();
let velocity_within_90_degrees = direction.dot(velocity.0) > 0.0;
let desired_velocity = if direction != Vec3::ZERO && velocity_within_90_degrees {
// project velocity onto direction to extract the component directly aligned with direction
velocity.0.project_onto(direction)
} else {
// if velocity isn't within 90 degrees of direction then the projection would be in the
// exact opposite direction of `direction`; so just zero it
Vec3::ZERO
};
let undesired_velocity = velocity.0 - desired_velocity;
let vertical_undesired_velocity = undesired_velocity.project_onto(ground_normal);
// only select the velocity along the ground plane; that way the character can't decelerate
// while falling or jumping, but will decelerate along slopes properly
let undesired_velocity = undesired_velocity - vertical_undesired_velocity;
let deceleration =
Vec3::ZERO.move_towards(undesired_velocity, settings.deceleration_factor);
velocity.0 -= deceleration;
}
}
@@ -56,124 +111,97 @@ pub struct PlayerMovement {
pub shooting: bool,
}
/// 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, Reflect)]
#[reflect(Resource)]
pub struct MovementSettings {
pub damping_normal: f32,
pub damping_brake: f32,
pub 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,
}
}
}
#[derive(Component, Reflect)]
#[reflect(Component)]
pub struct MovementSpeedFactor(pub f32);
/// The speed used for character movement.
#[derive(Component, Reflect)]
#[reflect(Component)]
pub struct MovementSpeed(pub Scalar);
/// The damping factor used for slowing down movement.
#[derive(Component, Reflect)]
#[reflect(Component)]
pub struct MovementDampingFactor(pub Scalar);
/// The strength of a jump.
#[derive(Component, Reflect)]
#[reflect(Component)]
pub struct JumpImpulse(pub Scalar);
/// The gravitational acceleration used for a character controller.
#[derive(Component, Reflect)]
#[reflect(Component)]
pub struct ControllerGravity(pub 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,
character_controller: Character,
collider: Collider,
ground_caster: ShapeCaster,
gravity: ControllerGravity,
movement: MovementBundle,
move_input: MoveInput,
movement_factor: MovementSpeedFactor,
collision_events: CollisionEventsEnabled,
movement_config: MovementConfig,
}
/// A bundle that contains components for character movement.
#[derive(Bundle)]
pub struct MovementBundle {
acceleration: MovementSpeed,
jump_impulse: JumpImpulse,
max_slope_angle: MaxSlopeAngle,
factor: MovementSpeedFactor,
struct MovementConfig {
movement: CharacterMovement,
step: SteppingConfig,
ground: GroundingConfig,
gravity: CharacterGravity,
friction: CharacterFriction,
drag: CharacterDrag,
settings: ControllerSettings,
}
impl MovementBundle {
pub const fn new(acceleration: Scalar, jump_impulse: Scalar, max_slope_angle: Scalar) -> Self {
Self {
acceleration: MovementSpeed(acceleration),
jump_impulse: JumpImpulse(jump_impulse),
max_slope_angle: MaxSlopeAngle(max_slope_angle),
factor: MovementSpeedFactor(1.0),
}
}
}
const RUNNING_MOVEMENT_CONFIG: MovementConfig = MovementConfig {
movement: CharacterMovement {
target_speed: 15.0,
acceleration: 40.0,
},
step: SteppingConfig {
max_height: 0.25,
behaviour: SteppingBehaviour::Grounded,
},
ground: GroundingConfig {
max_angle: PI / 4.0,
max_distance: 0.2,
snap_to_surface: true,
},
gravity: CharacterGravity(vec3(0.0, -60.0, 0.0)),
friction: CharacterFriction(10.0),
drag: CharacterDrag(0.0),
settings: ControllerSettings {
jump_force: 25.0,
deceleration_factor: 1.0,
},
};
impl Default for MovementBundle {
fn default() -> Self {
Self::new(30.0, 18.0, (60.0 as Scalar).to_radians())
}
}
const FLYING_MOVEMENT_CONFIG: MovementConfig = MovementConfig {
movement: CharacterMovement {
target_speed: 20.0,
acceleration: 50.0,
},
step: SteppingConfig {
max_height: 0.25,
behaviour: SteppingBehaviour::Never,
},
ground: GroundingConfig {
max_angle: 0.0,
max_distance: -1.0,
snap_to_surface: false,
},
gravity: CharacterGravity(Vec3::ZERO),
friction: CharacterFriction(0.0),
drag: CharacterDrag(1.0),
settings: ControllerSettings {
jump_force: 0.0,
deceleration_factor: 0.0,
},
};
impl CharacterControllerBundle {
pub fn new(collider: Collider, gravity: Vector) -> Self {
pub fn new(collider: Collider, controls: HeadControls) -> 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);
let config = match controls {
HeadControls::Plane => FLYING_MOVEMENT_CONFIG,
HeadControls::Walk => RUNNING_MOVEMENT_CONFIG,
};
Self {
character_controller: CharacterController,
rigid_body: RigidBody::Kinematic,
character_controller: Character { up: Dir3::Y },
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(),
move_input: MoveInput::default(),
movement_factor: MovementSpeedFactor(1.0),
collision_events: CollisionEventsEnabled,
movement_config: config,
}
}
}

View File

@@ -1,24 +1,24 @@
use std::f32::consts::PI;
use avian3d::prelude::*;
use bevy::prelude::*;
use happy_feet::prelude::MoveInput;
use crate::GameState;
use crate::control::controller_common::MovementSpeedFactor;
use crate::player::PlayerBodyMesh;
use super::{
ControlState, ControllerSet,
controller_common::{MovementDampingFactor, MovementSpeed},
};
use super::{ControlState, ControllerSet};
pub struct CharacterControllerPlugin;
impl Plugin for CharacterControllerPlugin {
fn build(&self, app: &mut App) {
app.add_systems(
Update,
(rotate_rig, movement, apply_movement_damping)
PreUpdate,
(rotate_rig, apply_controls)
.chain()
.in_set(ControllerSet::ApplyControlsFly), // todo only in GameState::Playing?
.in_set(ControllerSet::ApplyControlsFly)
.run_if(in_state(GameState::Playing)),
);
}
}
@@ -56,36 +56,13 @@ fn rotate_rig(
}
}
/// Responds to [`MovementAction`] events and moves character controllers accordingly.
fn movement(
mut controllers: Query<(&MovementSpeed, &mut LinearVelocity)>,
fn apply_controls(
mut character: Query<(&mut MoveInput, &MovementSpeedFactor)>,
rig_transform_q: Option<Single<&GlobalTransform, With<PlayerBodyMesh>>>,
time: Res<Time>,
) {
let move_dir = Vec2::new(0.0, 70.) * time.delta_secs();
for (movement_acceleration, mut linear_velocity) in &mut controllers {
let mut direction = move_dir.extend(0.0).xzy();
let (mut char_input, factor) = character.single_mut().unwrap();
if let Some(ref rig_transform) = rig_transform_q {
direction =
(rig_transform.forward() * direction.z) + (rig_transform.right() * direction.x);
}
linear_velocity.0 = -direction * movement_acceleration.0;
}
}
/// 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;
char_input.set(-*rig_transform.forward() * factor.0);
}
}

View File

@@ -1,63 +1,26 @@
use super::{ControlState, ControllerSet, Controls, controller_common::MovementSpeedFactor};
use super::{ControlState, ControllerSet, Controls};
use crate::control::controller_common::MovementSpeedFactor;
use crate::control::controls::ControllerSettings;
use crate::{GameState, abilities::TriggerStateRes, player::PlayerBodyMesh};
use avian3d::{math::*, prelude::*};
use bevy::{ecs::query::Has, prelude::*};
use bevy::prelude::*;
use happy_feet::prelude::{Grounding, KinematicVelocity, MoveInput};
use super::controller_common::{
CharacterController, ControllerGravity, Grounded, JumpImpulse, MaxSlopeAngle, MovementSpeed,
PlayerMovement, reset_upon_switch,
};
use super::controller_common::PlayerMovement;
pub struct CharacterControllerPlugin;
impl Plugin for CharacterControllerPlugin {
fn build(&self, app: &mut App) {
app.add_systems(
Update,
(
reset_upon_switch,
set_movement_flag,
update_grounded,
apply_gravity,
rotate_view,
movement,
)
PreUpdate,
(set_movement_flag, rotate_view, apply_controls)
.chain()
.in_set(ControllerSet::ApplyControlsRun)
.run_if(in_state(GameState::Playing)), // todo check if we can make this less viral
.run_if(in_state(GameState::Playing)),
);
}
}
/// 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 {
debug!("grounded");
commands.entity(entity).insert(Grounded);
} else {
debug!("not grounded");
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>,
@@ -97,70 +60,38 @@ fn rotate_view(
}
}
/// Responds to [`MovementAction`] events and moves character controllers accordingly.
fn movement(
controls: Res<Controls>,
mut controllers: Query<(
&MovementSpeed,
fn apply_controls(
controls: Res<ControlState>,
mut character: Query<(
&mut MoveInput,
&mut Grounding,
&mut KinematicVelocity,
&ControllerSettings,
&MovementSpeedFactor,
&JumpImpulse,
&mut LinearVelocity,
Has<Grounded>,
)>,
rig_transform_q: Option<Single<&GlobalTransform, With<PlayerBodyMesh>>>,
mut jump_used: Local<bool>,
) {
let mut direction = controls.keyboard_state.move_dir;
let Ok((mut move_input, mut grounding, mut velocity, settings, move_factor)) =
character.single_mut()
else {
return;
};
let mut jump_requested = controls.keyboard_state.jump;
if let Some(gamepad) = controls.gamepad_state {
direction += gamepad.move_dir;
jump_requested |= gamepad.jump;
}
direction = direction.normalize_or_zero();
for (movement_speed, factor, jump_impulse, mut linear_velocity, is_grounded) in &mut controllers
{
let mut direction = direction.extend(0.0).xzy();
let mut direction = -controls.move_dir.extend(0.0).xzy();
if let Some(ref rig_transform) = rig_transform_q {
direction =
(rig_transform.forward() * direction.z) + (rig_transform.right() * direction.x);
direction = (rig_transform.forward() * direction.z) + (rig_transform.right() * direction.x);
}
linear_velocity.x = -direction.x * movement_speed.0 * factor.0;
linear_velocity.z = -direction.z * movement_speed.0 * factor.0;
let ground_normal = *grounding.normal().unwrap_or(Dir3::Y);
if is_grounded && jump_requested && !*jump_used {
linear_velocity.y = jump_impulse.0;
debug!("jump");
*jump_used = true;
}
let y_projection = direction.project_onto(ground_normal);
direction -= y_projection;
direction = direction.normalize_or_zero();
if !controls.keyboard_state.jump
&& !controls
.gamepad_state
.map(|state| state.jump)
.unwrap_or_default()
{
*jump_used = false;
}
}
}
/// Applies [`ControllerGravity`] to character controllers.
fn apply_gravity(
time: Res<Time>,
mut controllers: Query<(&ControllerGravity, &mut LinearVelocity, Option<&Grounded>)>,
) {
let delta_time = time.delta_secs();
for (gravity, mut linear_velocity, grounded) in &mut controllers {
if grounded.is_none() {
linear_velocity.0 += gravity.0 * delta_time;
}
move_input.set(direction * move_factor.0);
if controls.jump && grounding.is_grounded() {
happy_feet::movement::jump(settings.jump_force, &mut velocity, &mut grounding, Dir3::Y)
}
}

View File

@@ -1,3 +1,4 @@
use crate::control::ControllerSet;
use crate::{
GameState,
abilities::{TriggerCashHeal, TriggerState},
@@ -13,13 +14,15 @@ use bevy::{
prelude::*,
};
use super::{ControlState, ControllerSet, Controls};
use super::{ControlState, Controls};
pub fn plugin(app: &mut App) {
app.init_resource::<Controls>();
app.register_type::<ControllerSettings>();
app.add_systems(
Update,
PreUpdate,
(
gamepad_controls,
keyboard_controls,
@@ -34,6 +37,13 @@ pub fn plugin(app: &mut App) {
);
}
#[derive(Component, Clone, PartialEq, Reflect)]
#[reflect(Component)]
pub struct ControllerSettings {
pub deceleration_factor: f32,
pub jump_force: f32,
}
fn combine_controls(controls: Res<Controls>, mut combined_controls: ResMut<ControlState>) {
let keyboard = controls.keyboard_state;

View File

@@ -6,7 +6,6 @@ use crate::{
heads_database::{HeadControls, HeadsDatabase},
};
mod collisions;
pub mod controller_common;
pub mod controller_flying;
pub mod controller_running;
@@ -54,7 +53,7 @@ pub fn plugin(app: &mut App) {
app.add_event::<ControllerSwitchEvent>();
app.configure_sets(
Update,
PreUpdate,
(
ControllerSet::CollectInputs,
ControllerSet::ApplyControlsFly.run_if(resource_equals(SelectedController(

View File

@@ -88,15 +88,24 @@ fn on_head_drop(
commands
.spawn((
Name::new("headdrop"),
HeadDrop(drop.head_id),
Transform::from_translation(drop.pos),
Visibility::default(),
Collider::sphere(1.5),
LockedAxes::ROTATION_LOCKED,
RigidBody::Dynamic,
CollisionLayers::new(LayerMask(GameLayer::Collectibles.to_bits()), LayerMask::ALL),
CollisionLayers::new(
GameLayer::CollectiblePhysics,
LayerMask::ALL & !GameLayer::Player.to_bits(),
),
CollisionEventsEnabled,
Restitution::new(0.6),
children![(
Collider::sphere(1.5),
CollisionLayers::new(GameLayer::CollectibleSensors, GameLayer::Player),
Sensor,
CollisionEventsEnabled,
HeadDrop(drop.head_id),
)],
))
.insert_if(
ExternalImpulse::new(spawn_dir * 180.).with_persistence(false),
@@ -115,7 +124,7 @@ fn collect_head(
mut commands: Commands,
mut collision_event_reader: EventReader<CollisionStarted>,
query_player: Query<&Player>,
query_collectable: Query<&HeadDrop>,
query_collectable: Query<(&HeadDrop, &ChildOf)>,
query_secret: Query<&SecretHeadMarker>,
) {
for CollisionStarted(e1, e2) in collision_event_reader.read() {
@@ -127,7 +136,7 @@ fn collect_head(
continue;
};
let key = query_collectable.get(collectable).unwrap();
let (key, child_of) = query_collectable.get(collectable).unwrap();
let is_secret = query_secret.contains(collectable);
@@ -137,6 +146,6 @@ fn collect_head(
commands.trigger(PlaySound::HeadCollect);
}
commands.trigger(HeadCollected(key.0));
commands.entity(collectable).despawn();
commands.entity(child_of.parent()).despawn();
}
}

View File

@@ -2,7 +2,7 @@ use crate::abilities::HeadAbility;
use bevy::prelude::*;
use serde::{Deserialize, Serialize};
#[derive(Debug, Default, Reflect, Serialize, Deserialize, PartialEq, Eq)]
#[derive(Clone, Copy, Debug, Default, Reflect, Serialize, Deserialize, PartialEq, Eq)]
pub enum HeadControls {
#[default]
Walk,

View File

@@ -31,21 +31,29 @@ fn on_spawn_key(trigger: Trigger<KeySpawn>, mut commands: Commands, assets: Res<
commands.spawn((
Name::new("key"),
Key(id.clone()),
Transform::from_translation(*position),
Visibility::default(),
Collider::sphere(1.5),
ExternalImpulse::new(spawn_dir * 180.).with_persistence(false),
LockedAxes::ROTATION_LOCKED,
RigidBody::Dynamic,
CollisionLayers::new(LayerMask(GameLayer::Collectibles.to_bits()), LayerMask::ALL),
CollisionLayers::new(GameLayer::CollectiblePhysics, GameLayer::Level),
CollisionEventsEnabled,
Restitution::new(0.6),
Children::spawn(Spawn((
children![
(
Billboard,
SquishAnimation(2.6),
SceneRoot(assets.mesh_key.clone()),
))),
),
(
Collider::sphere(1.5),
CollisionLayers::new(GameLayer::CollectibleSensors, GameLayer::Player),
Sensor,
CollisionEventsEnabled,
Key(id.clone()),
)
],
));
}
@@ -53,7 +61,7 @@ fn collect_key(
mut commands: Commands,
mut collision_event_reader: EventReader<CollisionStarted>,
query_player: Query<&Player>,
query_collectable: Query<&Key>,
query_collectable: Query<(&Key, &ChildOf)>,
) {
for CollisionStarted(e1, e2) in collision_event_reader.read() {
let collectable = if query_player.contains(*e1) && query_collectable.contains(*e2) {
@@ -64,10 +72,10 @@ fn collect_key(
continue;
};
let key = query_collectable.get(collectable).unwrap();
let (key, child_of) = query_collectable.get(collectable).unwrap();
commands.trigger(PlaySound::KeyCollect);
commands.trigger(KeyCollected(key.0.clone()));
commands.entity(collectable).despawn();
commands.entity(child_of.parent()).despawn();
}
}

View File

@@ -70,7 +70,8 @@ enum GameState {
fn main() {
let mut app = App::new();
app.register_type::<DebugVisuals>();
app.register_type::<DebugVisuals>()
.register_type::<TransformInterpolation>();
app.insert_resource(DebugVisuals {
unlit: false,
tonemapping: Tonemapping::None,

View File

@@ -8,5 +8,6 @@ pub enum GameLayer {
Player,
Npc,
Projectile,
Collectibles,
CollectiblePhysics,
CollectibleSensors,
}

View File

@@ -15,7 +15,10 @@ struct ActivePlatform {
pub fn plugin(app: &mut App) {
app.register_type::<ActivePlatform>();
app.add_systems(OnEnter(GameState::Playing), init);
app.add_systems(Update, move_active.run_if(in_state(GameState::Playing)));
app.add_systems(
FixedUpdate,
move_active.run_if(in_state(GameState::Playing)),
);
}
fn init(

View File

@@ -14,7 +14,7 @@ use crate::{
sounds::PlaySound,
tb_entities::SpawnPoint,
};
use avian3d::{math::Vector, prelude::*};
use avian3d::prelude::*;
use bevy::{
input::common_conditions::input_just_pressed,
prelude::*,
@@ -61,7 +61,6 @@ fn spawn(
let transform = Transform::from_translation(spawn.translation + Vec3::new(0., 3., 0.));
let gravity = Vector::NEG_Y * 40.0;
let collider = Collider::capsule(0.9, 1.2);
commands.spawn((
@@ -80,8 +79,11 @@ fn spawn(
transform,
Visibility::default(),
// LockedAxes::ROTATION_LOCKED, todo
CollisionLayers::new(LayerMask(GameLayer::Player.to_bits()), LayerMask::ALL),
CharacterControllerBundle::new(collider, gravity),
CollisionLayers::new(
GameLayer::Player,
LayerMask::ALL & !GameLayer::CollectiblePhysics.to_bits(),
),
CharacterControllerBundle::new(collider, heads_db.head_stats(0).controls),
children![(
Name::new("player-rig"),
PlayerBodyMesh,

View File

@@ -7,6 +7,7 @@ use bevy::{
prelude::*,
};
use bevy_trenchbroom::prelude::*;
use happy_feet::prelude::PhysicsMover;
use crate::cash::Cash;
use crate::loading_assets::GameAssets;
@@ -65,6 +66,7 @@ pub struct NamedEntity {
#[reflect(QuakeClass, Component)]
#[base(Transform, Target)]
#[spawn_hooks(SpawnHooks::new().convex_collider())]
#[require(PhysicsMover = PhysicsMover, TransformInterpolation)]
pub struct Platform;
#[derive(PointClass, Component, Reflect, Default)]
@@ -172,7 +174,7 @@ impl CashSpawn {
SceneRoot(mesh),
Cash,
Collider::cuboid(2., 3.0, 2.),
CollisionLayers::new(LayerMask(GameLayer::Collectibles.to_bits()), LayerMask::ALL),
CollisionLayers::new(GameLayer::CollectibleSensors, LayerMask::ALL),
CollisionEventsEnabled,
Sensor,
));

View File

@@ -1,7 +1,5 @@
use crate::{
GameState, control::controller_common::MovementSpeedFactor, global_observer, player::Player,
tb_entities::Water,
};
use crate::control::controller_common::MovementSpeedFactor;
use crate::{GameState, global_observer, player::Player, tb_entities::Water};
use avian3d::prelude::*;
use bevy::prelude::*;
@@ -35,7 +33,9 @@ fn setup(mut commands: Commands, query: Query<(Entity, &Children), With<Water>>)
ColliderConstructorHierarchy::new(ColliderConstructor::ConvexHullFromMesh),
));
commands.entity(child).insert(WaterSensor);
// TODO: Figure out why water requires a `Sensor` or else the character will stand *on* it
// rather than *in* it
commands.entity(child).insert((WaterSensor, Sensor));
}
}