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)