Skip to content

Commit 819c862

Browse files
authored
feat: character controller (#17)
* wip: basic climbing slopes * docs: update readme character controller section * feat: very basic climb slopes and slide along walls * fix: snap on slopes * refactor: move player movement into character controller * fix: add back player movement animation again * fix: stuff from self code review
1 parent 1eec133 commit 819c862

File tree

18 files changed

+408
-372
lines changed

18 files changed

+408
-372
lines changed

README.md

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -14,10 +14,12 @@
1414

1515
## Todo and feature list
1616
- [x] User interface
17-
- [ ] Player movement
17+
- [x] Player movement
1818
- [x] Basic movement
19-
- [ ] Write a proper kinematic character controller from scratch because i dont want to just copy some code
20-
- this is harder than i thought... even all the examples out there have some issues
19+
- [x] Write a proper kinematic character controller from scratch because i dont want to just copy some code
20+
- [x] Climb slopes
21+
- [x] slide along walls when going into walls instead of zeroeing velocity
22+
- there are still some improvements and fixes needed, but it works pretty good so far
2123
- [X] Game modes
2224
- [x] Wave mode (the game gets more difficult each round, e.g. more enemies are spawned)
2325
- [ ] Capture the flag
Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,3 @@
11
version https://git-lfs.github.com/spec/v1
2-
oid sha256:13cd38a7fba85aa1fd7c3825d2203ea436e47d24eb3d327b4c9632a43e72b955
2+
oid sha256:794adc3aa328ac508bba89c4a02a95732c83528f67243eb6a8474c67899da195
33
size 109793554
Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,3 @@
11
version https://git-lfs.github.com/spec/v1
2-
oid sha256:9c1eb5795fcfd04457b198ba49b9d066fe9719445eb575cd571e7417163a7c93
2+
oid sha256:13cd38a7fba85aa1fd7c3825d2203ea436e47d24eb3d327b4c9632a43e72b955
33
size 109793554

src/character_controller/mod.rs

Lines changed: 300 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,300 @@
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+
}

src/enemy/spawn/mod.rs

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,12 @@
11
use crate::{
2+
character_controller::{
3+
CHARACTER_CAPSULE_LENGTH, CHARACTER_CAPSULE_RADIUS, Grounded,
4+
},
25
enemy::{
36
animate::ENEMY_MODEL_PATH,
47
shooting::components::EnemyShootPlayerCooldownTimer,
58
},
6-
kinematic_controller::KinematicController,
79
nav_mesh_pathfinding::{ArchipelagoRef, ENEMY_AGENT_RADIUS},
8-
player::spawn::{PLAYER_CAPSULE_LENGTH, PLAYER_CAPSULE_RADIUS},
910
};
1011
use avian3d::math::PI;
1112
use bevy::prelude::*;
@@ -131,7 +132,7 @@ fn handle_spawn_enemies_at_enemy_spawn_locations_message(
131132
health: 100.0,
132133
..default()
133134
},
134-
KinematicController::default(),
135+
Grounded::default(),
135136
EnemyShootPlayerCooldownTimer(Timer::from_seconds(
136137
0.5,
137138
TimerMode::Repeating,
@@ -144,8 +145,8 @@ fn handle_spawn_enemies_at_enemy_spawn_locations_message(
144145
0.0,
145146
// center enemy model -> in blender, feet are at bottom, so in
146147
// bevy model feet are at center of collider, 0.0
147-
-((PLAYER_CAPSULE_LENGTH
148-
+ PLAYER_CAPSULE_RADIUS * 2.0)
148+
-((CHARACTER_CAPSULE_LENGTH
149+
+ CHARACTER_CAPSULE_RADIUS * 2.0)
149150
/ 2.),
150151
0.0,
151152
),

0 commit comments

Comments
 (0)