|
| 1 | +use avian3d::prelude::*; |
| 2 | +use bevy::prelude::*; |
| 3 | + |
| 4 | +use crate::{ |
| 5 | + GRAVITY, |
| 6 | + player::{Player, camera::components::ViewModelCamera}, |
| 7 | +}; |
| 8 | + |
| 9 | +pub const CHARACTER_CAPSULE_RADIUS: f32 = 0.2; |
| 10 | +pub const CHARACTER_CAPSULE_LENGTH: f32 = 1.3; |
| 11 | + |
| 12 | +pub const WALK_VELOCITY: f32 = 2.0; |
| 13 | +pub const RUN_VELOCITY: f32 = 5.0; |
| 14 | +pub const JUMP_VELOCITY: f32 = 3.0; |
| 15 | + |
| 16 | +pub const MAX_SLOPE_ANGLE: f32 = 45.0_f32.to_radians(); |
| 17 | + |
| 18 | +// NOTE: So far this character controller is only for the player. The code needs a bit of |
| 19 | +// adjustment to be able to support using it for enemies. But I guess we also don't really need a |
| 20 | +// character controller for enemies. |
| 21 | + |
| 22 | +#[derive(Component)] |
| 23 | +pub struct MovementState(pub MovementStateEnum); |
| 24 | + |
| 25 | +#[derive(Debug, Reflect, PartialEq)] |
| 26 | +pub enum MovementStateEnum { |
| 27 | + Idle, |
| 28 | + Walking, |
| 29 | + Running, |
| 30 | +} |
| 31 | + |
| 32 | +#[derive(Message)] |
| 33 | +pub enum MovementAction { |
| 34 | + // TODO: should be possible to just have Vec2 |
| 35 | + Move(Vec3), |
| 36 | + Jump, |
| 37 | +} |
| 38 | + |
| 39 | +/// Contains all needed components for a character that should be controlled by the player |
| 40 | +#[derive(Bundle)] |
| 41 | +pub struct CharacterControllerBundle { |
| 42 | + velocity: LinearVelocity, |
| 43 | + rigid_body: RigidBody, |
| 44 | + collider: Collider, |
| 45 | + grounded: Grounded, |
| 46 | + locked_axes: LockedAxes, |
| 47 | + movement_state: MovementState, |
| 48 | + colliding_entities: CollidingEntities, |
| 49 | +} |
| 50 | + |
| 51 | +impl Default for CharacterControllerBundle { |
| 52 | + fn default() -> Self { |
| 53 | + Self { |
| 54 | + velocity: LinearVelocity::ZERO, |
| 55 | + rigid_body: RigidBody::Kinematic, |
| 56 | + collider: Collider::capsule( |
| 57 | + CHARACTER_CAPSULE_RADIUS, |
| 58 | + CHARACTER_CAPSULE_LENGTH, |
| 59 | + ), |
| 60 | + grounded: Grounded::default(), |
| 61 | + locked_axes: LockedAxes::new() |
| 62 | + .lock_rotation_x() |
| 63 | + .lock_rotation_y() |
| 64 | + .lock_rotation_z(), |
| 65 | + movement_state: MovementState(MovementStateEnum::Idle), |
| 66 | + colliding_entities: CollidingEntities::default(), |
| 67 | + } |
| 68 | + } |
| 69 | +} |
| 70 | + |
| 71 | +#[derive(Component)] |
| 72 | +pub struct Grounded(pub bool); |
| 73 | + |
| 74 | +impl Default for Grounded { |
| 75 | + fn default() -> Self { |
| 76 | + Self(true) |
| 77 | + } |
| 78 | +} |
| 79 | + |
| 80 | +pub struct CharacterControllerPlugin; |
| 81 | + |
| 82 | +impl Plugin for CharacterControllerPlugin { |
| 83 | + fn build(&self, app: &mut App) { |
| 84 | + app.add_message::<MovementAction>().add_systems( |
| 85 | + Update, |
| 86 | + ( |
| 87 | + update_on_ground, |
| 88 | + apply_gravity_over_time, |
| 89 | + handle_keyboard_input_for_player, |
| 90 | + handle_movement_actions_for_player, |
| 91 | + ), |
| 92 | + ); |
| 93 | + } |
| 94 | +} |
| 95 | + |
| 96 | +fn handle_keyboard_input_for_player( |
| 97 | + keyboard_input: Res<ButtonInput<KeyCode>>, |
| 98 | + mut movement_action_writer: MessageWriter<MovementAction>, |
| 99 | + player_query: Single<(&Transform, &mut MovementState), With<Player>>, |
| 100 | +) { |
| 101 | + let (player_transform, mut movement_state) = player_query.into_inner(); |
| 102 | + let speed = if keyboard_input.pressed(KeyCode::ShiftLeft) { |
| 103 | + RUN_VELOCITY |
| 104 | + } else { |
| 105 | + WALK_VELOCITY |
| 106 | + }; |
| 107 | + |
| 108 | + let mut local_velocity = Vec3::ZERO; |
| 109 | + |
| 110 | + if keyboard_input.pressed(KeyCode::KeyW) { |
| 111 | + local_velocity.z -= 1.0 * speed; |
| 112 | + } |
| 113 | + if keyboard_input.pressed(KeyCode::KeyA) { |
| 114 | + local_velocity.x -= 1.0 * speed; |
| 115 | + } |
| 116 | + if keyboard_input.pressed(KeyCode::KeyD) { |
| 117 | + local_velocity.x += 1.0 * speed; |
| 118 | + } |
| 119 | + if keyboard_input.pressed(KeyCode::KeyS) { |
| 120 | + local_velocity.z += 1.0 * speed; |
| 121 | + } |
| 122 | + |
| 123 | + let world_velocity = player_transform.rotation * local_velocity; |
| 124 | + |
| 125 | + movement_action_writer.write(MovementAction::Move(world_velocity)); |
| 126 | + if local_velocity.x == 0.0 && local_velocity.z == 0.0 { |
| 127 | + if movement_state.0 != MovementStateEnum::Idle { |
| 128 | + movement_state.0 = MovementStateEnum::Idle; |
| 129 | + } |
| 130 | + } else if speed == RUN_VELOCITY { |
| 131 | + if movement_state.0 != MovementStateEnum::Running { |
| 132 | + movement_state.0 = MovementStateEnum::Running; |
| 133 | + } |
| 134 | + } else if speed == WALK_VELOCITY { |
| 135 | + if movement_state.0 != MovementStateEnum::Walking { |
| 136 | + movement_state.0 = MovementStateEnum::Walking; |
| 137 | + } |
| 138 | + } |
| 139 | + |
| 140 | + if keyboard_input.just_pressed(KeyCode::Space) { |
| 141 | + movement_action_writer.write(MovementAction::Jump); |
| 142 | + } |
| 143 | +} |
| 144 | + |
| 145 | +fn handle_movement_actions_for_player( |
| 146 | + mut movement_action_reader: MessageReader<MovementAction>, |
| 147 | + player_query: Single< |
| 148 | + (&mut LinearVelocity, &Grounded, &Transform, Entity), |
| 149 | + With<Player>, |
| 150 | + >, |
| 151 | + player_camera_entity: Single<Entity, With<ViewModelCamera>>, |
| 152 | + spatial_query: SpatialQuery, |
| 153 | + time: Res<Time>, |
| 154 | +) { |
| 155 | + let (mut player_velocity, player_grounded, player_transform, player_entity) = |
| 156 | + player_query.into_inner(); |
| 157 | + for movement_action in movement_action_reader.read() { |
| 158 | + match movement_action { |
| 159 | + MovementAction::Jump => { |
| 160 | + if player_grounded.0 { |
| 161 | + player_velocity.y = JUMP_VELOCITY; |
| 162 | + } |
| 163 | + } |
| 164 | + // TODO: should probably move the content of this block elsewhere |
| 165 | + MovementAction::Move(world_velocity) => { |
| 166 | + let Ok(direction_from_world_velocity) = |
| 167 | + Dir3::new(*world_velocity) |
| 168 | + else { |
| 169 | + player_velocity.x = 0.0; |
| 170 | + player_velocity.z = 0.0; |
| 171 | + return; |
| 172 | + }; |
| 173 | + |
| 174 | + let ray_origin = player_transform.translation |
| 175 | + - direction_from_world_velocity.as_vec3() * 0.025; |
| 176 | + let max_distance = 0.3; |
| 177 | + |
| 178 | + if let Some(hit_ahead) = spatial_query.cast_shape( |
| 179 | + &Collider::capsule( |
| 180 | + CHARACTER_CAPSULE_RADIUS, |
| 181 | + CHARACTER_CAPSULE_LENGTH, |
| 182 | + ), |
| 183 | + ray_origin, |
| 184 | + player_transform.rotation, |
| 185 | + direction_from_world_velocity, |
| 186 | + &ShapeCastConfig { |
| 187 | + max_distance, |
| 188 | + ..default() |
| 189 | + }, |
| 190 | + &SpatialQueryFilter::default().with_excluded_entities([ |
| 191 | + player_entity, |
| 192 | + *player_camera_entity, |
| 193 | + ]), |
| 194 | + ) { |
| 195 | + // obstacle in the way, check if we can slimb it |
| 196 | + // a normal is just a direction something is facing |
| 197 | + let normal = hit_ahead.normal1; |
| 198 | + let slope_angle = normal.angle_between(Vec3::Y); |
| 199 | + let slope_climable = slope_angle < MAX_SLOPE_ANGLE; |
| 200 | + |
| 201 | + if slope_climable { |
| 202 | + debug!("MOVEMENT: Climable slope!"); |
| 203 | + // this is the most important part to make the slope climbing possible. |
| 204 | + // instead of trying to go straight, we slide along the ground |
| 205 | + player_velocity.0 = |
| 206 | + world_velocity.reject_from_normalized(normal); |
| 207 | + |
| 208 | + // slope snapping |
| 209 | + let ray_down_origin = |
| 210 | + player_transform.translation + Vec3::Y * 0.5; |
| 211 | + let ray_down_direction = Dir3::NEG_Y; |
| 212 | + let max_down_distance = 1.0; |
| 213 | + |
| 214 | + if let Some(hit_down) = spatial_query.cast_ray( |
| 215 | + ray_down_origin, |
| 216 | + ray_down_direction, |
| 217 | + max_down_distance, |
| 218 | + true, |
| 219 | + &SpatialQueryFilter::default() |
| 220 | + .with_excluded_entities([ |
| 221 | + player_entity, |
| 222 | + *player_camera_entity, |
| 223 | + ]), |
| 224 | + ) { |
| 225 | + let hit_down_point = ray_down_origin |
| 226 | + + ray_down_direction * hit_down.distance; |
| 227 | + let hit_down_y = hit_down_point.y; |
| 228 | + let player_y = player_transform.translation.y; |
| 229 | + let difference_y = hit_down_y - player_y; |
| 230 | + if difference_y.abs() < 0.3 { |
| 231 | + debug!("Snapping player to slope"); |
| 232 | + player_velocity.y = |
| 233 | + difference_y / time.delta_secs(); |
| 234 | + } |
| 235 | + } |
| 236 | + } else { |
| 237 | + debug!( |
| 238 | + "MOVEMENT: Obstacle in the way, sliding along wall" |
| 239 | + ); |
| 240 | + // not climable, e.g. a wall. we want to slide along the wall, similar to the collide |
| 241 | + // and slide algorithm |
| 242 | + // the main difference is that we ignore the Y part, because its too step, so we dont |
| 243 | + // want to climb up |
| 244 | + let impulse = |
| 245 | + world_velocity.reject_from_normalized(normal); |
| 246 | + player_velocity.x = impulse.x; |
| 247 | + player_velocity.z = impulse.z |
| 248 | + } |
| 249 | + } else { |
| 250 | + debug!("MOVEMENT: No obstacle ahead, free movement"); |
| 251 | + // no obstacle ahead, free movement |
| 252 | + player_velocity.x = world_velocity.x; |
| 253 | + player_velocity.z = world_velocity.z; |
| 254 | + } |
| 255 | + } |
| 256 | + } |
| 257 | + } |
| 258 | +} |
| 259 | + |
| 260 | +fn update_on_ground( |
| 261 | + query: Query<(&Transform, Entity, &mut LinearVelocity, &mut Grounded)>, |
| 262 | + spatial_query: SpatialQuery, |
| 263 | +) { |
| 264 | + for (transform, entity, mut velocity, mut grounded) in query { |
| 265 | + let on_ground = spatial_query |
| 266 | + .cast_shape( |
| 267 | + &Collider::capsule( |
| 268 | + CHARACTER_CAPSULE_RADIUS, |
| 269 | + CHARACTER_CAPSULE_LENGTH, |
| 270 | + ), |
| 271 | + transform.translation, |
| 272 | + transform.rotation, |
| 273 | + Dir3::NEG_Y, |
| 274 | + &ShapeCastConfig { |
| 275 | + max_distance: 0.1, |
| 276 | + ..default() |
| 277 | + }, |
| 278 | + &SpatialQueryFilter::default().with_excluded_entities([entity]), |
| 279 | + ) |
| 280 | + .is_some(); |
| 281 | + if grounded.0 != on_ground { |
| 282 | + grounded.0 = on_ground; |
| 283 | + } |
| 284 | + |
| 285 | + if on_ground && velocity.y <= 0.0 { |
| 286 | + velocity.y = 0.0; |
| 287 | + } |
| 288 | + } |
| 289 | +} |
| 290 | + |
| 291 | +fn apply_gravity_over_time( |
| 292 | + query: Query<(&Grounded, &mut LinearVelocity)>, |
| 293 | + time: Res<Time>, |
| 294 | +) { |
| 295 | + for (grounded, mut velocity) in query { |
| 296 | + if !grounded.0 { |
| 297 | + velocity.y -= GRAVITY * time.delta_secs(); |
| 298 | + } |
| 299 | + } |
| 300 | +} |
0 commit comments