stick-the-quick/characters/Runner.gd

641 lines
19 KiB
GDScript

class_name Runner extends RigidBody3D
const COYOTE_TIMEOUT: float = 0.25
const FLOOR_THRESHOLD: float = 0.5
const TURNING_LERP_WEIGHT: float = 0.125
const RIGHTING_LERP_WEIGHT: float = 0.03125
const CENTRIFUGAL_ACCELERATION: float = 50.0
const CENTRIFUGAL_COYOTE_FACTOR: float = 0.75
const TURNING_THRESHOLD: float = 0.25
const ANIMATION_BLEND_TIME: float = 0.25
const KNOCKBACK_SPEED: float = 10.0
const WALL_JUMP_FACTOR: float = 0.75
const DEATH_PLANE: float = -128.0
const RESPAWN_HEIGHT: float = 2.0
const LAND_SOUND_THRESHOLD: float = 0.0
const SWIM_VERTICAL_ACCELERATION: float = 5.0
const KNOCKBACK_FLASHING_DURATION: float = 1.5
const KNOCKBACK_FLASHING_FREQUENCY: float = 2.0
const STAIR_FACTOR_V: float = 0.5
const STAIR_FACTOR_H: float = 2.0
const STAIR_ANTICLIP: float = 0.125
const STAIR_CLIMB_ACCELERATION: float = 320.0
const STAIR_CLIMB_ANTI_FALL_BOOST: float = 4.0
const STAIR_CLIMB_ANTI_FALL_BOOST_THRESHOLD: float = 1.0
const STAIR_COYOTE_THRESHOLD: float = 0.97
const TURN_CUTOFF: float = 0.02
@onready var target_basis := basis
var floor_normal := Vector3.ZERO
var wall_normal := Vector3.ZERO
var stairs_detected := false
@onready var up_normal := basis.y
var floor_coyote_timer: float = 0.0
var wall_coyote_timer: float = 0.0
@onready var last_known_safe_position := global_position
var impetus := Vector3.ZERO
var state := &'fall'
@onready var animation_player := $'AnimationPlayer' as AnimationPlayer
@onready var audio_player := $'AudioStreamPlayer3D' as AudioStreamPlayer3D
@onready var mesh := $'Figure/Skeleton3D/Mesh' as MeshInstance3D
var height: float
var width: float
var knockback_flashing_timer: float = 0.0
@onready var raycast := PhysicsRayQueryParameters3D.new()
var finished_course := false
var floor_unsafe := false
var turn_target := Vector3.ZERO
var turn_target_basis := Basis.IDENTITY
@export var top_run_speed: float = 20.0
@export var run_acceleration: float = 15.0
@export var run_deceleration: float = 15.0
@export var jump_strength: float = 9.0
@export var jump_sound: AudioStream
@export var fall_sound: AudioStream
@export var land_sound: AudioStream
@export var knockback_sound: AudioStream
@export var slide_sound: AudioStream
@export var walk_animation_speed_factor: float = 0.1
@export var run_animation_speed_factor: float = 0.1
@export var sprint_animation_speed_factor: float = 0.1
@export var swim_animation_speed_factor: float = 0.1
@export var ability: RunnerAbility
@export var taunt_animation: StringName
func _find_dimensions() -> void:
var shape := ($'CollisionShape3D' as CollisionShape3D).shape
if shape is BoxShape3D:
width = shape.size.slide(Vector3.UP).length()
height = shape.size.y
elif shape is CapsuleShape3D or shape is CylinderShape3D:
width = 2.0*shape.radius
height = shape.height
elif shape is SphereShape3D:
width = 2.0*shape.radius
height = 2.0*shape.radius
else:
width = 0.0
height = 0.0
func _ready() -> void:
add_to_group(&'runners')
continuous_cd = true
max_contacts_reported = 24
contact_monitor = true
can_sleep = false
lock_rotation = true
physics_material_override = PhysicsMaterial.new()
physics_material_override.friction = 0.0
animation_player.playback_default_blend_time = ANIMATION_BLEND_TIME
audio_player.bus = &'Sound'
raycast.exclude = [self]
raycast.hit_back_faces = true
_find_dimensions()
_do_state_begin()
func _integrate_forces(body_state: PhysicsDirectBodyState3D) -> void:
_detect_floors_and_walls(body_state)
_detect_stairs(body_state)
_adjust_basis(body_state)
_apply_fake_and_exaggerated_centrifugal_force(body_state)
_respond_to_impetus(body_state)
_apply_deceleration(body_state)
_enforce_top_speed(body_state)
_do_state_integrate_forces(body_state)
_update_animation_speed()
_clear_impetus()
func _physics_process(delta: float) -> void:
_check_for_death_plane()
_update_knockback_flashing(delta)
func _update_knockback_flashing(delta: float) -> void:
if knockback_flashing_timer > 0.0:
knockback_flashing_timer -= delta
if knockback_flashing_timer <= 0.0:
mesh.visible = true
else:
mesh.visible = (
2.0*KNOCKBACK_FLASHING_FREQUENCY*fposmod(
knockback_flashing_timer,
1.0/(2.0*KNOCKBACK_FLASHING_FREQUENCY)
) >= 0.5
)
func _detect_floors_and_walls(body_state: PhysicsDirectBodyState3D) -> void:
var old_floor_normal := floor_normal
var old_wall_normal := wall_normal
floor_normal = Vector3.ZERO
wall_normal = Vector3.ZERO
for i in body_state.get_contact_count():
var other := body_state.get_contact_collider_object(i)
if not (
other is RigidBody3D and (not (other is Platform)) and
(other as RigidBody3D).mass <= mass
):
var normal := body_state.get_contact_local_normal(i)
if (
normal.dot(up_normal) >= FLOOR_THRESHOLD or
normal.dot(Vector3.UP) >= FLOOR_THRESHOLD
):
floor_normal += normal
else:
wall_normal += normal
if (
(not floor_normal.is_zero_approx())
and state != &'jump' and state != &'wall_jump'
):
floor_normal = floor_normal.normalized()
floor_coyote_timer = COYOTE_TIMEOUT
if not floor_unsafe:
last_known_safe_position = (
global_position + floor_normal*RESPAWN_HEIGHT
)
elif floor_coyote_timer > 0.0:
floor_normal = old_floor_normal
floor_coyote_timer -= body_state.step
if not wall_normal.is_zero_approx():
wall_normal = wall_normal.normalized()
wall_coyote_timer = COYOTE_TIMEOUT
elif wall_coyote_timer > 0.0:
wall_normal = old_wall_normal
wall_coyote_timer -= body_state.step
func _detect_stairs(
body_state: PhysicsDirectBodyState3D
) -> void:
if (
(not wall_normal.is_zero_approx()) and
(not floor_normal.is_zero_approx()) and
floor_coyote_timer/COYOTE_TIMEOUT > STAIR_COYOTE_THRESHOLD
):
var dss := get_world_3d().direct_space_state
raycast.to = body_state.transform.origin + (
up_normal*STAIR_ANTICLIP -
width*STAIR_FACTOR_H*wall_normal
)
raycast.from = raycast.to + height*STAIR_FACTOR_V*up_normal
var clip := dss.intersect_ray(raycast)
stairs_detected = (
(not clip.is_empty()) and not clip.normal.is_zero_approx()
)
else:
stairs_detected = false
func _adjust_basis(body_state: PhysicsDirectBodyState3D) -> void:
if not floor_normal.is_zero_approx():
up_normal = floor_normal.normalized()
else:
up_normal = (
up_normal.lerp(Vector3.UP, RIGHTING_LERP_WEIGHT).normalized()
)
if up_normal.is_zero_approx():
up_normal = Vector3.UP
var lookdir = _orientation_in_current_state()
if body_state.total_gravity.dot(Vector3.UP) < 0.0:
lookdir = lookdir.slide(up_normal)
if (
lookdir.length() >= TURNING_THRESHOLD and
not up_normal.cross(lookdir.normalized()).is_zero_approx()
):
target_basis = Basis.looking_at(lookdir, up_normal, true)
angular_velocity = (
basis.x.cross(target_basis.x) +
basis.y.cross(target_basis.y) +
basis.z.cross(target_basis.z)
)*TURNING_LERP_WEIGHT/body_state.step
func _apply_fake_and_exaggerated_centrifugal_force(
body_state: PhysicsDirectBodyState3D
) -> void:
if (
(not floor_normal.is_zero_approx()) and
floor_coyote_timer/COYOTE_TIMEOUT >= CENTRIFUGAL_COYOTE_FACTOR and
state != &'jump' and state != &'wall_jump' and state != &'fall'
):
body_state.apply_central_force(
-mass *
CENTRIFUGAL_ACCELERATION *
((linear_velocity.slide(up_normal).length()/top_run_speed)**2.0) *
up_normal
)
func change_state(new_state: StringName) -> void:
if not finished_course:
_do_state_end()
state = new_state
_do_state_begin()
func _orientation_in_current_state() -> Vector3:
match state:
&'ability': return ability.orientation(self)
&'climb_stairs':
return basis.z if wall_normal.is_zero_approx() else -wall_normal
&'fall': return linear_velocity
&'jump': return linear_velocity
&'knockback': return basis.z
&'run': return linear_velocity
&'sprint': return linear_velocity
&'stand': return basis.z
&'swim': return linear_velocity
&'taunt': return basis.z
&'turn_to_look': return turn_target - global_position
&'walk': return linear_velocity
&'wall_jump': return linear_velocity
&'wall_slide': return wall_normal
_: return basis.z
func _play_land_sound_if_landing_hard_enough() -> void:
if linear_velocity.project(up_normal).length() >= LAND_SOUND_THRESHOLD:
audio_player.stream = land_sound
audio_player.play()
func _do_state_begin() -> void:
match state:
&'ability':
animation_player.play(ability.animation)
audio_player.stream = ability.sound
audio_player.play()
ability.begin(self)
&'climb_stairs':
animation_player.play(&'walk')
&'fall':
animation_player.play(&'fall')
audio_player.stream = fall_sound
audio_player.play()
&'jump':
animation_player.play(&'jump')
audio_player.stream = jump_sound
audio_player.play()
linear_velocity += up_normal*jump_strength
floor_normal = Vector3.ZERO
floor_coyote_timer = 0.0
&'knockback':
animation_player.play(&'knockback')
audio_player.stream = knockback_sound
audio_player.play()
floor_normal = Vector3.ZERO
floor_coyote_timer = 0.0
knockback_flashing_timer = KNOCKBACK_FLASHING_DURATION
&'run':
animation_player.play(&'run')
&'sprint':
animation_player.play(&'sprint')
&'stand':
animation_player.play(&'stand')
&'swim':
animation_player.play(&'swim')
&'taunt':
animation_player.play(taunt_animation)
&'turn_to_look':
animation_player.play(&'stand')
&'walk':
animation_player.play(&'walk')
&'wall_jump':
animation_player.play(&'jump')
audio_player.stream = jump_sound
audio_player.play()
linear_velocity = (
linear_velocity.slide(up_normal) +
(2.0*wall_normal + up_normal)*jump_strength*WALL_JUMP_FACTOR
)
wall_normal = Vector3.ZERO
wall_coyote_timer = 0.0
&'wall_slide':
animation_player.play(&'wall_slide')
audio_player.stream = slide_sound
audio_player.play()
_: pass
func _impetus_multiplier_in_current_state() -> float:
match state:
&'ability': return ability.impetus_multiplier
&'climb_stairs': return 0.0
&'fall': return 0.5
&'jump': return 0.5
&'knockback': return 0.0
&'run': return 1.0
&'sprint': return 1.0
&'stand': return 0.0
&'swim': return 1.0
&'taunt': return 0.0
&'turn_to_look': return 0.0
&'walk': return 0.5
&'wall_jump': return 0.5
&'wall_slide': return 0.25
_: return 1.0
func abnormal_gravity(body_state: PhysicsDirectBodyState3D) -> bool:
return body_state.total_gravity.dot(Vector3.UP) > 0.0
func _do_state_integrate_forces(body_state: PhysicsDirectBodyState3D) -> void:
ability.passive(self, body_state.step, state == &'ability')
match state:
&'ability':
ability.integrate_forces(self, body_state)
&'climb_stairs':
if impetus.dot(wall_normal) >= 0.0:
body_state.linear_velocity = (
body_state.linear_velocity.slide(up_normal)
)
change_state(&'walk')
elif wall_normal.is_zero_approx() or (
wall_coyote_timer/COYOTE_TIMEOUT < STAIR_COYOTE_THRESHOLD
):
body_state.linear_velocity = (
body_state.linear_velocity.slide(up_normal)
)
if body_state.linear_velocity.length() < (
STAIR_CLIMB_ANTI_FALL_BOOST_THRESHOLD
):
body_state.linear_velocity += basis.z*(
STAIR_CLIMB_ANTI_FALL_BOOST
)
change_state(&'walk')
else:
body_state.linear_velocity += (
STAIR_CLIMB_ACCELERATION*up_normal*body_state.step
)
&'fall':
if not floor_normal.is_zero_approx():
_play_land_sound_if_landing_hard_enough()
change_state(&'run')
elif abnormal_gravity(body_state):
change_state(&'swim')
elif not wall_normal.is_zero_approx():
change_state(&'wall_slide')
&'jump':
if not wall_normal.is_zero_approx():
change_state(&'wall_slide')
elif (
body_state.linear_velocity.normalized().dot(up_normal) < 0.125
):
change_state(&'fall')
elif abnormal_gravity(body_state) and (
body_state.linear_velocity.normalized().dot(up_normal) < 0.5
):
change_state(&'swim')
&'knockback':
if knockback_flashing_timer <= KNOCKBACK_FLASHING_DURATION/2.0:
if not floor_normal.is_zero_approx():
_play_land_sound_if_landing_hard_enough()
change_state(&'stand')
elif not wall_normal.is_zero_approx():
change_state(&'wall_slide')
&'run':
if floor_normal.is_zero_approx():
change_state(&'fall')
else:
var speed_factor := (
body_state.linear_velocity.slide(up_normal).length() /
top_run_speed
)
if speed_factor < 0.125:
change_state(&'walk')
elif speed_factor > 0.625:
change_state(&'sprint')
&'sprint':
if floor_normal.is_zero_approx():
change_state(&'fall')
else:
var speed_factor := (
body_state.linear_velocity.slide(up_normal).length() /
top_run_speed
)
if speed_factor < 0.625:
change_state(&'run')
&'stand':
if floor_normal.is_zero_approx():
change_state(&'fall')
elif not impetus.is_zero_approx():
change_state(&'walk')
else:
var speed_factor := (
body_state.linear_velocity.slide(up_normal).length() /
top_run_speed
)
if speed_factor > 0.125:
change_state(&'run')
&'swim':
var iz: float = (impetus*basis).z
body_state.linear_velocity -= (
up_normal*iz*SWIM_VERTICAL_ACCELERATION*body_state.step
)
if iz < 0.0:
body_state.linear_velocity -= (
1.5*basis.z*iz*run_acceleration*body_state.step
)
if not floor_normal.is_zero_approx():
change_state(&'run')
elif not abnormal_gravity(body_state):
change_state(&'fall')
&'taunt':
body_state.linear_velocity = Vector3.ZERO
&'turn_to_look':
if (basis.z - turn_target_basis.z).length() < TURN_CUTOFF:
change_state(&'stand')
&'walk':
if stairs_detected:
change_state(&'climb_stairs')
elif floor_normal.is_zero_approx():
change_state(&'fall')
elif impetus.is_zero_approx():
change_state(&'stand')
else:
var speed_factor := (
body_state.linear_velocity.slide(up_normal).length() /
top_run_speed
)
if speed_factor > 0.125:
change_state(&'run')
&'wall_jump':
if body_state.linear_velocity.normalized().dot(up_normal) < 0.125:
change_state(&'fall')
&'wall_slide':
if not floor_normal.is_zero_approx():
_play_land_sound_if_landing_hard_enough()
change_state(&'run')
elif wall_normal.is_zero_approx():
change_state(&'fall')
_: pass
func _respond_to_impetus(body_state: PhysicsDirectBodyState3D) -> void:
body_state.linear_velocity += (
_impetus_multiplier_in_current_state() *
impetus *
run_acceleration *
body_state.step
)
func _apply_deceleration(body_state: PhysicsDirectBodyState3D) -> void:
if impetus.is_zero_approx():
var horizontal_velocity = body_state.linear_velocity.slide(up_normal)
if not horizontal_velocity.is_zero_approx():
var speed = horizontal_velocity.length()
var direction = horizontal_velocity/speed
speed = max(speed - run_deceleration*body_state.step, 0.0)
body_state.linear_velocity -= horizontal_velocity - direction*speed
func _enforce_top_speed(body_state: PhysicsDirectBodyState3D) -> void:
var horizontal_velocity = body_state.linear_velocity.slide(up_normal)
var speed = horizontal_velocity.length()
if speed > top_run_speed:
var direction = horizontal_velocity/speed
body_state.linear_velocity -= horizontal_velocity - direction*clampf(
speed - run_acceleration*body_state.step,
0.0, speed
)
func _clear_impetus() -> void:
impetus = Vector3.ZERO
func _do_state_end() -> void:
match state:
&'ability': ability.end(self)
&'climb_stairs': pass
&'fall': pass
&'jump': pass
&'knockback': pass
&'run': pass
&'sprint': pass
&'stand': pass
&'swim': pass
&'taunt': pass
&'turn_to_look': pass
&'walk': pass
&'wall_jump': pass
&'wall_slide':
if audio_player.stream == slide_sound:
audio_player.stop()
_: pass
func _update_animation_speed() -> void:
match state:
&'ability':
animation_player.speed_scale = ability.animation_speed(self)
&'climb_stairs':
animation_player.speed_scale = 1.0 + (
linear_velocity.length()*walk_animation_speed_factor
)
&'fall':
animation_player.speed_scale = 1.0
&'jump':
animation_player.speed_scale = 1.0
&'knockback':
animation_player.speed_scale = 1.0
&'run':
animation_player.speed_scale = 1.0 + (
linear_velocity.length()*run_animation_speed_factor
)
&'sprint':
animation_player.speed_scale = 1.0 + (
linear_velocity.length()*sprint_animation_speed_factor
)
&'stand':
animation_player.speed_scale = 1.0
&'swim':
animation_player.speed_scale = 0.25 + (
linear_velocity.length()*swim_animation_speed_factor
)
&'taunt':
animation_player.speed_scale = 1.0
&'turn_to_look':
animation_player.speed_scale = 1.0
&'walk':
animation_player.speed_scale = 1.0 + (
linear_velocity.length()*walk_animation_speed_factor
)
&'wall_jump':
animation_player.speed_scale = 1.0
&'wall_slide':
animation_player.speed_scale = 1.0
_: pass
func _check_for_death_plane() -> void:
if global_position.y <= DEATH_PLANE:
respawn()
func respawn() -> void:
global_position = last_known_safe_position
linear_velocity = Vector3.ZERO
if FX.camera_is_following(self):
FX.hard_reorient_camera(basis.z)
audio_player.stream = knockback_sound
audio_player.play()
knockback_flashing_timer = KNOCKBACK_FLASHING_DURATION
func jump() -> void:
if (
state != &'knockback' and
state != &'turn_to_look' and
not finished_course
):
if (
state == &'jump' or state == &'wall_jump' or
state == &'fall' or state == &'ability'
):
do_ability()
elif state == &'swim' or not floor_normal.is_zero_approx():
change_state(&'jump')
elif not wall_normal.is_zero_approx():
change_state(&'wall_jump')
func cancel_jump() -> void:
if (state == &'jump' or state == &'wall_jump') and not finished_course:
linear_velocity = (
linear_velocity.slide(up_normal) +
linear_velocity.project(up_normal)/2.0
)
func do_ability() -> void:
if (
state != &'knockback' and
state != &'turn_to_look' and
not finished_course
):
if state != &'ability':
if ability.available(self):
change_state(&'ability')
else:
ability.on_repeated_input(self)
func knockback(origin: Vector3, strength: float) -> void:
if (
state != &'knockback' and knockback_flashing_timer <= 0.0 and
not finished_course
):
linear_velocity = Vector3.ZERO
apply_central_impulse((
(global_position - origin).slide(up_normal).normalized() +
up_normal/2.0
).normalized()*strength)
change_state(&'knockback')
look_at(
global_position + (origin - global_position).slide(up_normal),
up_normal,
true
)
func finish_course() -> void:
change_state(&'taunt')
finished_course = true
func cancel_finish_course() -> void:
finished_course = false
change_state(&'stand')
func turn_toward(where: Vector3) -> void:
turn_target = where
turn_target_basis = Basis.looking_at(
(where - global_position).slide(up_normal),
up_normal, true
)
change_state(&'turn_to_look')
var done := func() -> bool:
return state != &'turn_to_look'
await Wait.until(done)