class_name Character extends RigidBody3D ## "Lifelike" physics body suitable for player characters, NPCs, and enemies. #region Exported Properties @export_group("Helper Nodes") ## Animation player used to play appropriate animation for current state. @export var _anim_player: AnimationPlayer ## Body collider. Should be a capsule. Script may change height and radius. @export var _collider: CollisionShape3D ## Player for character-associated sounds such as footsteps and vocalizations. @export var _audio_player: AudioStreamPlayer3D ## Needed for saving and restoring properties between state transitions. @export var _property_save_restore_stack: PropertySaveRestoreStack ## Needed for keeping track of current state and state transitions. @export var _state_machine: StateMachine @export_group("States") ## How being in each possible state should influence the character. @export var _state_properties: Dictionary[StringName, CharacterStateProperties] ## State the character should be in when the scene starts. @export var _initial_state: StringName = &'idle' @export_group("Physics") @export_subgroup("On Ground") ## Refuse forward input if moving faster than this while grounded. @export var _top_ground_speed: float = 20.0 ## Magnitude of manual forward acceleration while grounded. @export var _ground_acceleration: float = 15.0 ## Magnitude of automatic deceleration while grounded. @export var _ground_auto_deceleration: float = 10.0 ## Magnitude of manual deceleration while grounded. @export var _ground_manual_deceleration: float = 30.0 ## Magnitude of centrifugal acceleration at top ground speed. ## [br][br] ## Character will be subject to a centrifugal force ## proportional to current ground speed. ## It is at its maximum at top ground speed, ## and at zero when the character is at rest. ## While this is by no means physically realistic ## (in real life, there is no such thing as centrifugal force ## in an inertial frame of reference), ## it permits genre-staple parkour techniques ## such as being able to run up walls and on ceilings ## if moving fast enough. ## [br][br] ## The centrifugal acceleration applied may actually exceed this amount, ## but only whenever the character's ground speed exceeds its top ground speed. @export var _max_centrifugal_acceleration: float = 20.0 ## Jump height if the jump button is tapped and immediately released. ## [br][br] ## This assumes the character is jumping from rest position on level ground ## under standard gravity. When this assumption does not hold, ## actual jump height may vary, but will vary in a physically sensible way. ## Put another way, this property is used to compute the magnitudes ## of both the initial jump impact and the vertical deceleration to apply ## to truncate the jump if the button is released, and, once computed, ## it is these magnitudes, not the jump height itself, ## which remain fixed across all situations. @export var _min_jump_height: float = 1.0 ## Jump height if the jump button is held until the peak of the jump. ## [br][br] ## This assumes the character is jumping from rest position on level ground ## under standard gravity. When this assumption does not hold, ## actual jump height may vary, but will vary in a physically sensible way. ## Put another way, this property is used to compute the magnitudes ## of both the initial jump impact and the vertical deceleration to apply ## to truncate the jump if the button is released, and, once computed, ## it is these magnitudes, not the jump height itself, ## which remain fixed across all situations. @export var _max_jump_height: float = 4.0 ## Maximum angular distance in radians to normal of next ground. ## [br][br] ## That is to say, if the angle between the current ground normal ## and the normal of some contact surface exceeds this many radians, ## then that contact surface is considered a wall or ceiling; ## otherwise, it is ground, and can be walked upon. @export var _max_slope_angle: float = 1.0 ## Friction when standing still. ## [br][br] ## Friction at or above top speed is always 0.0. ## Friction at any speed between 0.0 and top speed is decided ## by interpolating between the value given here and 0.0, respectively. @export var _max_friction: float = 0.25 ## Audio stream to play when landing from a fall or jump. @export var _landing_sound: AudioStream = null ## Volume to play landing sound when landing from a fall or jump. @export var _landing_sound_volume_db: float = 0.0 @export_subgroup("In Air") ## Refuse forward input if moving faster than this while not grounded. @export var _top_air_speed: float = 30.0 ## Magnitude of manual forward acceleration while not grounded. @export var _air_acceleration: float = 5.0 ## Magnitude of automatic deceleration while not grounded. @export var _air_auto_deceleration: float = 5.0 ## Magnitude of manual deceleration while not grounded. @export var _air_manual_deceleration: float = 10.0 ## Lerp speed for gradually turning right-side-up while not grounded. @export var _air_righting_lerp_speed: float = 0.5 @export_group("Combat") ## Amount of health character possesses when undamaged. @export var max_health: float = 100.0 ## Damage dealt is multiplied by this. @export var attack_power: float = 1.0 ## Damage taken is divided by this. @export var defense_power: float = 1.0 ## Knockback dealt is multiplied by this. @export var knockback: float = 1.0 ## Knockback taken is divided by this. @export var knockback_resistance: float = 1.0 #endregion #region Private Properties var _state_handler_descriptors: Dictionary[StringName, Variant] var _current_state_properties: CharacterStateProperties var _state_coyote_time: float = 0.0 var _state_time_elapsed: float = 0.0 var _air_time_elapsed: float = 0.0 var _grounded := false var _touching_wall := false var _body_state: PhysicsDirectBodyState3D var _teleport_requested := false var _teleport_target := Vector3.ZERO var _freeze_lerp_requested := false var _freeze_lerp_target := Vector3.ZERO var _carrying: Node3D = null @onready var _shapecast_object := PhysicsShapeQueryParameters3D.new() #endregion #region Public Properties ## Direction facing away from what we currently think is the ground. ## [br][br] ## While the character is actually standing on a surface, ## this is the contact normal of that surface. ## Otherwise, this starts off at its last known value, ## and is gradually interpolated toward the direction that opposes gravity. var ground_normal := Vector3.UP ## Direction facing away from what we currently think is the wall. ## [br][br] ## Unlike ground_normal, this property has no meaning ## if there is not currently an actual wall. var wall_normal := Vector3.ZERO ## Current state of the state machine. ## [br][br] ## Attempting to set this property to the name of an unimplemented state ## will fail with an error message. ## [br][br] ## Attempting to set this property while coyote time is active ## will fail silently. Though it will not work, attempting to do it ## is not to be considered an error; rather, the caller is expected ## to repeat the attempt for as long as the state transition condition ## remains satisfied, and the assignment will succeed once coyote time expires. var state: StringName: get(): return _state_machine.state set(value): change_state(value) ## Local-space direction the character "wants" / is "trying" to move in. var impetus := Vector3.ZERO: set(value): if value.is_zero_approx(): impetus = Vector3.ZERO else: impetus = value.limit_length() ## World-space direction the character "wants" / is "trying" to move in. var global_impetus: Vector3: get(): var gi := global_transform*impetus - global_position if gi.is_zero_approx(): return Vector3.ZERO else: return gi.limit_length() set(value): impetus = (global_position + value)*global_transform ## Whether the character is "trying" to jump. var jump_impetus := false ## Whether the character is "trying" to perform their primary action. var action1_impetus := false ## Whether the character is "trying" to perform their secondary action. var action2_impetus := false ## Whether the character is "trying" to perform a tertiary interaction. var interact_impetus := false ## Collision shape height. var height: float: get(): if _current_state_properties.collider_horizontal: return 2.0*_collider.shape.radius else: return _collider.shape.height set(value): if _current_state_properties.collider_horizontal: _collider.shape.radius = value/2.0 else: _collider.shape.height = value _collider.position.y = value/2.0 _audio_player.position.y = _collider.position.y ## Collision shape diameter. var width: float: get(): if _current_state_properties.collider_horizontal: return _collider.shape.height else: return 2.0*_collider.shape.radius set(value): if _current_state_properties.collider_horizontal: _collider.shape.height = value else: _collider.shape.radius = value/2.0 ## Character's current health. ## [br][br] ## Setting this variable to a positive value lower than its previous value ## force-changes state to hit. ## Setting it at or below 0.0 force-changes state to defeat. ## Trying to set it to a value higher than max_health ## will instead set it to max_health. ## [br][br] ## Normally you want to deal damage using the take_damage function, ## as it also handles knockback, which directly invoking this setter does not. ## However, the setter is suitable for custom knockback handling, ## damage without knockback, and healing. var health: float: set(value): if value >= max_health: value = max_health elif value <= 0.0: force_change_state(&'defeat') elif value < health && !state_uninterruptible(): force_change_state(&'hit') health = value ## Object that the character is carrying if any, else null. var carrying: Node3D: get(): return _carrying set(value): _pick_up_without_animation(value) #endregion #region Overrides func _ready() -> void: _enforce_certain_properties() _connect_to_state_machine() force_change_state(_initial_state) func _exit_tree() -> void: _disconnect_from_state_machine() request_ready() func _integrate_forces(body_state: PhysicsDirectBodyState3D) -> void: _body_state = body_state _update_timers() _update_anim_speed() _check_contacts() _reorient() _handle_teleport_request() func _process(delta: float) -> void: _handle_freeze_lerp_request(delta) #endregion #region State-Agnostic Private Methods func _update_timers() -> void: if _state_coyote_time > 0.0: _state_coyote_time -= _body_state.step _state_time_elapsed += _body_state.step if is_really_grounded(): _air_time_elapsed = 0.0 else: _air_time_elapsed += _body_state.step func _update_anim_speed() -> void: _anim_player.speed_scale = ( _current_state_properties.animation_base_speed + ( _current_state_properties.animation_speedup_with_velocity * linear_velocity.length() ) ) func _handle_teleport_request() -> void: if _teleport_requested: global_position = _teleport_target _teleport_requested = false func _handle_freeze_lerp_request(delta: float) -> void: if freeze && _freeze_lerp_requested: global_position = lerp( global_position, _freeze_lerp_target, 1.0 - 0.01**delta ) if (global_position - _freeze_lerp_target).length() < height*0.03125: global_position = _freeze_lerp_target _freeze_lerp_requested = false func _connect_to_state_machine() -> void: _state_handler_descriptors = _make_state_handler_descriptors() _state_machine.connect_handlers(_state_handler_descriptors) _state_machine.state_changed.connect(_on_state_changed) func _disconnect_from_state_machine() -> void: _state_machine.disconnect_handlers(_state_handler_descriptors) _state_machine.state_changed.disconnect(_on_state_changed) func _enforce_certain_properties() -> void: if _collider.shape is CapsuleShape3D: _collider.shape = _collider.shape.duplicate() else: _collider.shape = CapsuleShape3D.new() _audio_player.bus = &'Sound Effects' _property_save_restore_stack.target = self if physics_material_override: physics_material_override = physics_material_override.duplicate() else: physics_material_override = PhysicsMaterial.new() physics_material_override.friction = 0.0 can_sleep = false lock_rotation = true freeze_mode = FREEZE_MODE_STATIC custom_integrator = false continuous_cd = true contact_monitor = true if max_contacts_reported < 6: max_contacts_reported = 6 collision_priority = 2.0 health = max_health func _on_state_changed(state_name: StringName) -> void: # Empty out the property save/restore stack, # restoring all saved properties along the way. while !_property_save_restore_stack.is_empty(): _property_save_restore_stack.pop() # If there is a defined properties profile for this state: if state_name in _state_properties: _current_state_properties = _state_properties[state_name] # Set coyote timer. if _current_state_properties.use_coyote_time: _state_coyote_time = _current_state_properties.coyote_time else: _state_coyote_time = 0.0 _state_time_elapsed = 0.0 # Play animation. var anim_name := _current_state_properties.animation_name if _current_state_properties.animation_alt_name && randf() < 0.5: anim_name = _current_state_properties.animation_alt_name _anim_player.play( anim_name, _current_state_properties.animation_blend_time ) # Play audio. if _current_state_properties.audio: play_sound( _current_state_properties.audio, _current_state_properties.audio_volume_db ) # Freeze/unfreeze. freeze = ( _current_state_properties.physics_mode == CharacterStateProperties.PhysicsMode.FREEZE ) # If entering non-carrying state, drop carried object. if !_current_state_properties.is_carrying_state: _put_down_without_animation() # Save collider and etc properties to the stack # and overwrite them with those provided by the state profile. _property_save_restore_stack.push({ ^"_collider:rotation": ( Vector3.RIGHT*PI/2.0 if _current_state_properties.collider_horizontal else Vector3.ZERO ), ^"height": ( 2.0*_current_state_properties.collider_radius if _current_state_properties.collider_horizontal else _current_state_properties.collider_length ), ^"width": ( _current_state_properties.collider_length if _current_state_properties.collider_horizontal else 2.0*_current_state_properties.collider_radius ) }) _property_save_restore_stack.push(_current_state_properties.etc) func _handle_collision_with_other_character(other: Character) -> void: if ( other.is_attacking() && !self.is_invulnerable() ): take_damage( other.get_attack_damage(), other.get_attack_knockback(), other.global_position, other.linear_velocity ) func _check_contacts() -> void: # Sort static contacts into ground or wall and sum them up. var new_ground := Vector3.ZERO var new_wall := Vector3.ZERO for i in _body_state.get_contact_count(): var other := _body_state.get_contact_collider_object(i) if other is StaticBody3D: var contact_normal := _body_state.get_contact_local_normal(i) var diff_current_ground := acos(contact_normal.dot( ground_normal )) var diff_true_ground := acos(contact_normal.dot( Vector3.UP if gravity_scale >= 0.0 else Vector3.DOWN )) if ( diff_current_ground <= _max_slope_angle || diff_true_ground <= _max_slope_angle/2.0 ): new_ground += contact_normal elif diff_current_ground < PI - _max_slope_angle/2.0: new_wall += contact_normal # Along the way, handle collisions with other kinds of objects. elif other is Character: _handle_collision_with_other_character(other) # Update stored normals only if any direction "wins." _grounded = !new_ground.is_zero_approx() _touching_wall = !new_wall.is_zero_approx() if _grounded: ground_normal = new_ground.normalized() if _touching_wall: wall_normal = new_wall.normalized() func _reorient() -> void: var gi := global_impetus # Unless grounded, turn right-side-up. if !_current_state_properties.counts_as_grounded: var target_ground_normal: Vector3 = ( Vector3.UP if gravity_scale >= 0.0 else Vector3.DOWN ) ground_normal = lerp( ground_normal, target_ground_normal, 1.0 - (1.0 - _air_righting_lerp_speed)**_body_state.step ) as Vector3 if ground_normal.is_zero_approx(): ground_normal = target_ground_normal ground_normal = ground_normal.normalized() # Determine target yaw. var target_forward := Vector3.ZERO match _current_state_properties.yaw_orientation: CharacterStateProperties.OrientationMode.MANUAL: # If manual, do not modify yaw. target_forward = -global_basis.z CharacterStateProperties.OrientationMode.FOLLOW_VELOCITY: # If follow velocity, face velocity direction. if !linear_velocity.is_zero_approx(): target_forward = linear_velocity.normalized() CharacterStateProperties.OrientationMode.FOLLOW_ACCELERATION: # If follow acceleration, face impetus direction. if !gi.is_zero_approx(): target_forward = gi.normalized() CharacterStateProperties.OrientationMode.FOLLOW_NORMAL: # If follow normal, face away from the wall if there is one. # Else, mimic manual. target_forward = wall_normal if _touching_wall else -global_basis.z # Determine target pitch. var target_upward := Vector3.ZERO var old_target_forward := target_forward match _current_state_properties.pitch_orientation: CharacterStateProperties.OrientationMode.MANUAL: # If manual, do not modify pitch. target_upward = global_basis.y CharacterStateProperties.OrientationMode.FOLLOW_VELOCITY: # If follow velocity, add influence from velocity to pitch. target_upward = ground_normal if !linear_velocity.is_zero_approx(): var vnorm := linear_velocity.normalized() target_forward += vnorm.project(target_upward) CharacterStateProperties.OrientationMode.FOLLOW_ACCELERATION: # If follow acceleration, add influence from impetus to pitch. target_upward = ground_normal if !gi.is_zero_approx(): var inorm := gi.normalized() target_forward += inorm.project(target_upward) CharacterStateProperties.OrientationMode.FOLLOW_NORMAL: # If follow normal, keep pitch level to ground. target_upward = ground_normal # In all cases, remove any pitch that was already implied by target_forward # before other modifications involved in computing pitch. # If pitch follows velocity or acceleration, # then this amounts to *replacing* the pitch with the one implied # by whichever vector the pitch is supposed to follow; # otherwise, it amounts to keeping the pitch level to target_upward plane. target_forward -= old_target_forward.project(target_upward) # If target yaw and pitch are defined and not gimbal-locked, # set angular velocity to try to rotate toward the described basis. if ( !target_forward.is_zero_approx() && !target_forward.cross(target_upward).is_zero_approx() ): target_forward = target_forward.normalized() var target_basis := Basis.looking_at(target_forward, target_upward) angular_velocity = ( global_basis.x.cross(target_basis.x) + global_basis.y.cross(target_basis.y) + global_basis.z.cross(target_basis.z) )*_current_state_properties.orientation_speed*_body_state.step else: # If target yaw or pitch is undefined, # or they exist but are gimbal-locked, # cease all rotation. angular_velocity = Vector3.ZERO func _get_effective_impetus() -> Vector3: var result := Vector3.ZERO match _current_state_properties.physics_mode: CharacterStateProperties.PhysicsMode.NORMAL: # In normal mode, impetus is bound to ground plane. result = global_impetus result -= result.project(ground_normal) CharacterStateProperties.PhysicsMode.FREE: # In free mode, impetus may leave ground plane. result = global_impetus if result.is_zero_approx(): return Vector3.ZERO else: return result.normalized() func _do_standard_motion(delta: float) -> void: # In freeze mode, this function does nothing. if ( _current_state_properties.physics_mode != CharacterStateProperties.PhysicsMode.FREEZE ): # Check where we are trying to go. var effective_impetus := _get_effective_impetus() # Check whether in free mode. # Use air physics iff either in free mode or not grounded (by state). var use_air_physics := air_physics_active() # Calculate "volitional" velocity # (component of velocity projected into the space # that our impetus is allowed to affect). var volitional_velocity := get_volitional_velocity() # Check if we are exceeding top speed. var too_fast := going_too_fast() # Regardless, also check how close we are to top speed, 1.0 max. var top_speed_proximity: float = clamp( get_proportional_speed(), 0.0, 1.0 ) # Use no friction with air physics. # Otherwise, calculate friction according to speed # (more speed = less friction [yes I know that's not realistic]). physics_material_override.friction = ( 0.0 if use_air_physics else lerp(_max_friction, 0.0, top_speed_proximity) ) # Check which manual deceleration value to use. var manual_deceleration: float = ( _air_manual_deceleration if use_air_physics else _ground_manual_deceleration ) # Check which automatic deceleration value to use. var auto_deceleration: float = ( _air_auto_deceleration if use_air_physics else _ground_auto_deceleration ) # If we are using ground physics and not jumping, # apply centrifugal force to allow running upside down. if !use_air_physics && !jump_impetus: apply_central_impulse( -ground_normal*mass*delta*lerp( 0.0, _max_centrifugal_acceleration, top_speed_proximity ) ) # If we are going too fast, adjust impetus # to forbid manual acceleration in the direction we're already going. if too_fast: effective_impetus -= effective_impetus.project(volitional_velocity) if !effective_impetus.is_zero_approx(): effective_impetus = effective_impetus.normalized() # If impetus, once adjusted, is zero, and volitional velocity is not, # and we are not on a steep slope, then engage automatic deceleration. if effective_impetus.is_zero_approx(): if ( !volitional_velocity.is_zero_approx() && acos(ground_normal.dot( Vector3.UP if gravity_scale >= 0.0 else Vector3.DOWN )) <= _max_slope_angle/2.0 ): apply_central_impulse( -volitional_velocity.normalized() * mass*auto_deceleration*delta ) else: # If impetus once adjusted is *not* zero, then proceed as follows: # First, calculate "how decelerative" our impetus is, # on a scale of 0.0 to 1.0. var degree_decelerating: float if volitional_velocity.is_zero_approx(): degree_decelerating = 0.0 else: degree_decelerating = clamp( (1.0 - ( volitional_velocity.normalized().dot(effective_impetus) ))/2.0, 0.0, 1.0 ) # Then, check which forward acceleration value to use. var max_accel: float = ( _air_acceleration if use_air_physics else _ground_acceleration ) # Then, calculate true forward acceleration as follows: # from standing at rest, # it would be the forward acceleration value we just checked, # but the closer we are to top speed, # the closer that value is dragged down toward 0.0. var forward_accel: float = lerp( max_accel, 0.0, top_speed_proximity ) # Then, calculate actual acceleration as follows: # simply interpolate between true forward acceleration # and manual deceleration # depending on "how decelerative" our impetus is. var accel: float = lerp( forward_accel, manual_deceleration, degree_decelerating ) # Finally, apply the resultant propulsion. apply_central_impulse(effective_impetus*mass*accel*delta) func _shapecast(from: Vector3, to: Vector3) -> Dictionary: # prepare from/to sweep var space_state := get_world_3d().direct_space_state var query := _shapecast_object query.collide_with_areas = false query.collide_with_bodies = true query.collision_mask = 0xffffffff query.exclude = [self] query.margin = 0.0 query.motion = to - from query.shape = _collider.shape query.transform = Transform3D(_collider.global_transform) query.transform.origin = from # do sweep var dists := space_state.cast_motion(query) # if no collisions: fail if is_equal_approx(dists[0], 1.0) && is_equal_approx(dists[1], 1.0): return {} else: # if collisions: check if any intersections at safe point # (i.e. if safe is actually unsafe) query.motion = Vector3.ZERO query.transform.origin = lerp(from, to, dists[0]) if space_state.get_rest_info(query).is_empty(): # if no intersections: check if any intersections at unsafe point query.transform.origin = lerp(from, to, dists[1]) var result := space_state.get_rest_info(query) if result.is_empty(): # if no intersections at unsafe point (should not happen): fail return {} else: # if there is an intersection at unsafe point: # add info about where the safe position is and return result.own_position = ( lerp(from, to, dists[0]) - global_basis.y*height/2.0 ) return result else: # if intersections: safe is actually unsafe, fail return {} func _get_ledge() -> Vector3: var shapecast := _shapecast( global_position + 2.0*height*ground_normal, global_position - height*wall_normal/2.0 ) if ( !shapecast.is_empty() && acos(shapecast.normal.dot(ground_normal)) <= _max_slope_angle ): return shapecast.own_position - global_position else: return Vector3.ZERO func _put_down_without_animation() -> void: if _carrying: _carrying.reparent(get_parent()) _carrying.process_mode = PROCESS_MODE_INHERIT _carrying = null func _pick_up_without_animation(what: Node3D) -> void: if _carrying: _put_down_without_animation() what.process_mode = PROCESS_MODE_DISABLED what.reparent(self) _carrying = what func _state_animation_done() -> bool: return !_anim_player.is_playing() || ( _anim_player.current_animation != _current_state_properties.animation_name && ( !_current_state_properties.animation_alt_name || _anim_player.current_animation != _current_state_properties.animation_alt_name ) ) func _get_jump_initial_velocity() -> float: # h = vt - gt^2/2 # dh/dt = v - gt = 0 # v = gt # t = v/g # h = v(v/g) - g(v/g)^2/2 # h = v^2/g - v^2/(2g) # h = v^2/(2g) # 2gh = v^2 # v = sqrt(2gh) var standard_gravity: float = PhysicsServer3D.area_get_param( get_world_3d().space, PhysicsServer3D.AreaParameter.AREA_PARAM_GRAVITY ) return sqrt(2.0*standard_gravity*_max_jump_height) func _get_jump_deceleration() -> float: # v = sqrt(2gh) (from previous algebra) # H = vt - Gt^2/2 # vt - H = Gt^2/2 # G = 2(vt - H)/t^2 = 2(sqrt(2gh)t - H)/t^2 if _state_time_elapsed > 0.0: var standard_gravity: float = PhysicsServer3D.area_get_param( get_world_3d().space, PhysicsServer3D.AreaParameter.AREA_PARAM_GRAVITY ) var chonkbert: float = ( 2.0*( sqrt( 2.0*standard_gravity*_max_jump_height )*_state_time_elapsed - _min_jump_height )/(_state_time_elapsed**2.0) - standard_gravity ) if chonkbert > 0.0: return chonkbert else: return 0.0 else: return 0.0 func _apply_jump_impulse() -> void: apply_central_impulse(mass*( _get_jump_initial_velocity()*ground_normal - linear_velocity.project(ground_normal) )) func _apply_jump_deceleration(delta: float) -> void: apply_central_impulse( mass*sign(gravity_scale)*Vector3.DOWN * _get_jump_deceleration()*delta ) func _pick_up_deferred_callback(what: Node3D) -> void: if _carrying == what: if what is RigidBody3D: what.linear_velocity = Vector3.ZERO what.angular_velocity = Vector3.ZERO what.process_mode = PROCESS_MODE_DISABLED what.reparent(self) func _put_down_deferred_callback(what: Node3D) -> void: what.reparent(get_parent()) what.process_mode = PROCESS_MODE_INHERIT func _throw_deferred_callback(what: Node3D) -> void: _put_down_deferred_callback(what) if what is RigidBody3D: what.linear_velocity = 1.5*linear_velocity.length()*knockback*( (ground_normal/2.0 - global_basis.z).normalized() ) #endregion #region Public Methods ## Whether the current state counts as grounded. func is_grounded() -> bool: return ( _current_state_properties && _current_state_properties.counts_as_grounded ) ## Whether there is a current contact that counts as ground. func is_really_grounded() -> bool: return _grounded ## Whether there is a current contact that counts as a wall. func is_touching_wall() -> bool: return _touching_wall ## Whether coyote time prevents transition out of the current state. func state_coyote_time_active() -> bool: return _current_state_properties.use_coyote_time && ( _state_coyote_time > 0.0 ) ## Whether there has been a ground contact since one coyote timespan ago. ## [br][br] ## Certain states use this function to decide whether to transition to falling, ## independently of whether coyote time is still active for transitions ## out of the state more generally. func air_coyote_time_active() -> bool: return _current_state_properties.use_coyote_time && ( _air_time_elapsed <= _current_state_properties.coyote_time ) ## Whether the current state counts as an attack. func is_attacking() -> bool: return _current_state_properties.is_attack ## Attack damage before defense in current state if it is an attack. func get_attack_damage() -> float: if is_attacking(): return attack_power*_current_state_properties.attack_base_damage else: return 0.0 ## Attack knockback before resistance in current state if it is an attack. func get_attack_knockback() -> float: if is_attacking(): return knockback*_current_state_properties.attack_base_knockback else: return 0.0 ## Whether the character will ignore attacks received. func is_invulnerable() -> bool: return _current_state_properties.invulnerable ## Attempts state transition. May fail under certain conditions. ## [br][br] ## Failure conditions:[br] ## * state does not exist (this is also an error condition);[br] ## * coyote time is active;[br] ## * state is uninterruptible ## (expected to leave it with force_change_state);[br] ## * state is a carrying state and we are not carrying anything. ## [br][br] ## In addition, if we *are* carrying something, ## and the target state is *not* a carrying state, ## but has an equivalent_carrying_state, ## we will cancel this attempt and immediately attempt ## to transition to the equivalent_carrying_state state instead. ## In that case, the success or failure status ## of that alternative transition attempt is returned. func change_state(state_name: StringName) -> bool: var prospective_state_properties := ( _state_properties[state_name] if state_name in _state_properties else null ) if !state_exists(state_name): # Disallow switching to nonexistent state. # In addition, this is an error. push_error("%s does not have state %s" % [self, state_name]) return false elif _state_coyote_time > 0.0: # Disallow switching away from state while coyote time is active. return false elif _current_state_properties.uninterruptible: # Disallow switching away from uninterruptible state. return false elif !prospective_state_properties: # Else, allow switching to state if it has no properties profile. # (Remaining restrictions are based on the properties profile.) _state_machine.state = state_name return true elif prospective_state_properties.is_carrying_state && !_carrying: # Disallow switching to carrying state while not carrying anything. return false elif ( _carrying && !prospective_state_properties.is_carrying_state && prospective_state_properties.equivalent_carrying_state ): # If we are carrying something # and there is an equivalent carrying state, # switch to that one instead. return change_state( prospective_state_properties.equivalent_carrying_state ) else: # If all checks pass, allow state transition. _state_machine.state = state_name return true ## Directly invokes state transition without the sanity checks of change_state. ## [br][br] ## Suggested use is to ensure availability of actions ## that can safely be exempted from other states' restrictions ## and need to be highly responsive, such as jumping. func force_change_state(state_name: StringName) -> void: _state_machine.state = state_name ## Immediately expires coyote time and then tries to change state. func soft_force_change_state(state_name: StringName) -> bool: _state_coyote_time = 0.0 return change_state(state_name) ## Teleports the character during the next physics tick. func teleport(where: Vector3) -> void: _teleport_requested = true _teleport_target = where ## Lerps the character's position to the given target. Character must be frozen. func set_freeze_lerp(where: Vector3) -> void: if freeze: _freeze_lerp_requested = true _freeze_lerp_target = where else: push_error("can't freeze-lerp %s: not frozen" % self) ## Whether any behavior is implemented for some named state. func state_exists(state_name: StringName) -> bool: return ( state_name in _state_handler_descriptors || state_name in _state_properties ) ## Plays a sound on the character's dedicated audio player. func play_sound(sound: AudioStream, volume_db: float = 0.0) -> void: _audio_player.stop() _audio_player.stream = sound _audio_player.volume_db = volume_db _audio_player.play() ## If vulnerable, processes damage and knockback and changes state. func take_damage( damage_in: float, knockback_in: float, source: Vector3 = global_position, source_velocity: Vector3 = linear_velocity ) -> void: if !is_invulnerable(): health -= damage_in/defense_power var displacement := global_position - source var relative_velocity := source_velocity - linear_velocity var base_impulse: Vector3 if displacement.is_zero_approx(): base_impulse = relative_velocity elif relative_velocity.length() < 1.0: base_impulse = displacement.normalized() else: base_impulse = ( relative_velocity + displacement.normalized()*relative_velocity.length() ).normalized()*relative_velocity.length() if base_impulse.is_zero_approx(): base_impulse = Vector3.UP else: base_impulse = ( (base_impulse.normalized() + Vector3.UP).normalized() * base_impulse.length() ) apply_central_impulse( mass*knockback_in*base_impulse/knockback_resistance ) func state_uninterruptible() -> bool: return _current_state_properties.uninterruptible ## Puts down carried object. func put_down(force: bool = false) -> void: if _carrying: if state_uninterruptible(): if force: _put_down_without_animation() else: force_change_state(&'put-down') ## Character picks up object and begins carrying it. func pick_up(what: Node3D, force: bool = false) -> void: if _carrying: if state_uninterruptible(): if force: _put_down_without_animation() else: return else: force_change_state(&'put-down') await _state_machine.state_changed if state_uninterruptible(): if force: _pick_up_without_animation(what) else: _carrying = what force_change_state(&'pick-up') ## Whether our current physics mode is free mode. func is_free_moving() -> bool: return _current_state_properties && ( _current_state_properties.physics_mode == CharacterStateProperties.PhysicsMode.FREE ) ## Whether we currently move according to air physics. ## [br][br] ## If false, then we currently move according to ground physics. func air_physics_active() -> bool: return is_free_moving() || !is_grounded() ## Component of linear velocity that can be affected by impetus. ## [br][br] ## If we are free-moving, then this is simply our linear velocity. ## Otherwise, it equals linear velocity projected onto ground plane. func get_volitional_velocity() -> Vector3: if is_free_moving(): return linear_velocity else: return linear_velocity - linear_velocity.project(ground_normal) ## Top air speed if air physics active, else top ground speed. func get_top_speed() -> float: return _top_air_speed if air_physics_active() else _top_ground_speed ## Whether volitional velocity exceeds top speed. func going_too_fast() -> bool: return get_volitional_velocity().length() > get_top_speed() ## Current volitional speed as compared to (divided by) top speed. func get_proportional_speed() -> float: return get_volitional_velocity().length()/get_top_speed() #endregion #region Protected Methods ## Creates handler descriptors to subscribe to the state machine. ## [br][br] ## Subclasses should override this ## to extend the resulting dictionary with additional descriptors ## for whatever novel states the subclass implements. func _make_state_handler_descriptors() -> Dictionary[StringName, Variant]: return { &'idle': _on_idle_state_tick, &'walk': _on_walk_state_tick, &'run': _on_run_state_tick, &'sprint': _on_sprint_state_tick, &'jump': { &'started': _on_jump_state_start, &'ticked': _on_jump_state_tick }, &'fall': _on_fall_state_tick, &'launch': _on_launch_state_tick, &'skid': _on_skid_state_tick, &'wall-slide': _on_wall_slide_state_tick, &'swim': _on_swim_state_tick, &'hang': _on_hang_state_tick, &'pull-up': _on_pull_up_state_tick, &'hit': _on_hit_state_tick, &'victory1': _on_victory1_state_tick, &'pick-up': { &'started': _on_pick_up_state_start, &'ticked': _on_pick_up_state_tick }, &'put-down': { &'ticked': _on_put_down_state_tick, &'stopped': _on_put_down_state_stop }, &'throw': _on_throw_state_tick, &'idle-while-holding': _on_idle_while_holding_state_tick, &'walk-while-holding': _on_walk_while_holding_state_tick, &'run-while-holding': _on_run_while_holding_state_tick, &'jump-while-holding': { &'started': _on_jump_while_holding_state_start, &'ticked': _on_jump_while_holding_state_tick }, &'fall-while-holding': _on_fall_while_holding_state_tick, &'launch-while-holding': _on_launch_while_holding_state_tick, &'swim-while-holding': _on_swim_while_holding_state_tick } #endregion #region State Handlers func _on_idle_state_tick(delta: float) -> void: _do_standard_motion(delta) if !is_really_grounded(): state = &'fall' elif jump_impetus: soft_force_change_state(&'jump') elif !impetus.is_zero_approx(): state = &'walk' func _on_walk_state_tick(delta: float) -> void: _do_standard_motion(delta) var top_speed_proximity: float = ( linear_velocity - linear_velocity.project(ground_normal) ).length()/_top_ground_speed if !is_really_grounded(): state = &'fall' elif jump_impetus: soft_force_change_state(&'jump') elif top_speed_proximity >= 0.125: state = &'run' elif impetus.is_zero_approx() && top_speed_proximity < 0.03125: state = &'idle' func _on_run_state_tick(delta: float) -> void: _do_standard_motion(delta) var top_speed_proximity: float = ( linear_velocity - linear_velocity.project(ground_normal) ).length()/_top_ground_speed if !is_really_grounded() && !air_coyote_time_active(): state = &'fall' elif jump_impetus: soft_force_change_state(&'jump') elif top_speed_proximity >= 0.375: state = &'sprint' elif top_speed_proximity < 0.125: state = &'walk' func _on_sprint_state_tick(delta: float) -> void: _do_standard_motion(delta) var top_speed_proximity: float = ( linear_velocity - linear_velocity.project(ground_normal) ).length()/_top_ground_speed if !is_really_grounded() && !air_coyote_time_active(): state = &'fall' elif jump_impetus: soft_force_change_state(&'jump') elif top_speed_proximity < 0.375: state = &'run' elif _get_effective_impetus().dot(linear_velocity) <= -0.75: state = &'skid' func _on_jump_state_start() -> void: _apply_jump_impulse() func _on_jump_state_tick(delta: float) -> void: _do_standard_motion(delta) if !jump_impetus: _apply_jump_deceleration(delta) if is_really_grounded() && !state_coyote_time_active(): play_sound(_landing_sound, _landing_sound_volume_db) state = &'run' elif linear_velocity.dot(ground_normal) <= -0.25: state = &'fall' elif _touching_wall: var ledge_distance := _get_ledge() if ledge_distance.is_zero_approx(): state = &'wall-slide' else: state = &'hang' set_freeze_lerp(global_position + ledge_distance) func _on_fall_state_tick(delta: float) -> void: _do_standard_motion(delta) if is_really_grounded() && !state_coyote_time_active(): play_sound(_landing_sound, _landing_sound_volume_db) state = &'run' elif is_touching_wall(): var ledge_distance := _get_ledge() if ledge_distance.is_zero_approx(): state = &'wall-slide' else: state = &'hang' set_freeze_lerp(global_position + ledge_distance) func _on_hit_state_tick(delta: float) -> void: _do_standard_motion(delta) if is_really_grounded(): state = &'idle' func _on_hang_state_tick(delta: float) -> void: global_basis = global_basis.slerp( Basis.looking_at(-wall_normal, ground_normal), 1.0 - 0.01**delta ) if jump_impetus && !_freeze_lerp_requested: force_change_state(&'pull-up') elif action1_impetus || action2_impetus: _freeze_lerp_requested = false force_change_state(&'hit') apply_central_impulse(4.0*mass*global_basis.z) func _on_pull_up_state_tick(_delta: float) -> void: if _state_animation_done(): force_change_state(&'idle') func _on_skid_state_tick(delta: float) -> void: _do_standard_motion(delta) if !is_really_grounded(): state = &'fall' elif _get_effective_impetus().dot(linear_velocity) >= 0.0: state = &'walk' func _on_wall_slide_state_tick(delta: float) -> void: _do_standard_motion(delta) if jump_impetus && _state_coyote_time <= 0.0: soft_force_change_state(&'jump') # in addition, not instead: apply_central_impulse(mass*_get_jump_initial_velocity()*wall_normal) elif is_really_grounded(): state = &'run' elif !is_touching_wall(): state = &'fall' func _on_victory1_state_tick(_delta: float) -> void: if _state_animation_done(): force_change_state(&'victory2') func _on_put_down_state_tick(delta: float) -> void: _do_standard_motion(delta) var target_displacement := ( 0.25*height*Vector3.UP + 1.5*width*Vector3.FORWARD ) if !_carrying: force_change_state(&'idle') elif _state_animation_done(): _carrying.position = target_displacement force_change_state(&'idle') else: _carrying.position = lerp( _carrying.position, target_displacement, _anim_player.current_animation_position / _anim_player.current_animation_length ) func _on_put_down_state_stop() -> void: if _carrying: call_deferred(&'_put_down_deferred_callback', _carrying) _carrying = null func _on_pick_up_state_start() -> void: if _carrying: call_deferred(&'_pick_up_deferred_callback', _carrying) else: force_change_state(&'idle') func _on_pick_up_state_tick(delta: float) -> void: _do_standard_motion(delta) var target_displacement := ( 0.5*height*Vector3.UP + 0.75*width*Vector3.FORWARD ) if !_carrying: force_change_state(&'idle') elif _state_animation_done(): _carrying.position = target_displacement force_change_state(&'idle-while-holding') else: _carrying.position = lerp( _carrying.position, target_displacement, _anim_player.current_animation_position / _anim_player.current_animation_length ) func _on_idle_while_holding_state_tick(delta: float) -> void: _do_standard_motion(delta) if !is_really_grounded(): state = &'fall-while-holding' elif jump_impetus: soft_force_change_state(&'jump-while-holding') elif !impetus.is_zero_approx(): state = &'walk-while-holding' elif action1_impetus || action2_impetus || interact_impetus: state = &'put-down' func _on_walk_while_holding_state_tick(delta: float) -> void: _do_standard_motion(delta) var top_speed_proximity: float = ( linear_velocity - linear_velocity.project(ground_normal) ).length()/_top_ground_speed if !is_really_grounded(): state = &'fall-while-holding' elif jump_impetus: soft_force_change_state(&'jump-while-holding') elif top_speed_proximity >= 0.125: state = &'run-while-holding' elif impetus.is_zero_approx() && top_speed_proximity < 0.03125: state = &'idle-while-holding' elif action1_impetus || action2_impetus || interact_impetus: state = &'put-down' func _on_run_while_holding_state_tick(delta: float) -> void: _do_standard_motion(delta) var top_speed_proximity: float = ( linear_velocity - linear_velocity.project(ground_normal) ).length()/_top_ground_speed if !is_really_grounded() && !air_coyote_time_active(): state = &'fall-while-holding' elif jump_impetus: soft_force_change_state(&'jump-while-holding') elif top_speed_proximity < 0.125: state = &'walk-while-holding' elif action1_impetus || action2_impetus || interact_impetus: state = &'throw' func _on_jump_while_holding_state_start() -> void: _apply_jump_impulse() func _on_jump_while_holding_state_tick(delta: float) -> void: _do_standard_motion(delta) if !jump_impetus: _apply_jump_deceleration(delta) if is_really_grounded() && !state_coyote_time_active(): play_sound(_landing_sound, _landing_sound_volume_db) state = &'run-while-holding' elif linear_velocity.dot(ground_normal) <= -0.25: state = &'fall-while-holding' elif action1_impetus || action2_impetus || interact_impetus: state = &'throw' func _on_launch_state_tick(delta: float) -> void: _do_standard_motion(delta) if is_really_grounded() && !state_coyote_time_active(): play_sound(_landing_sound, _landing_sound_volume_db) state = &'run' elif linear_velocity.dot(ground_normal) <= -0.25: state = &'fall' elif is_touching_wall(): var ledge_distance := _get_ledge() if ledge_distance.is_zero_approx(): state = &'wall-slide' else: state = &'hang' set_freeze_lerp(global_position + ledge_distance) func _on_launch_while_holding_state_tick(delta: float) -> void: _do_standard_motion(delta) if is_really_grounded() && !state_coyote_time_active(): play_sound(_landing_sound, _landing_sound_volume_db) state = &'run-while-holding' elif linear_velocity.dot(ground_normal) <= -0.25: state = &'fall-while-holding' elif action1_impetus || action2_impetus || interact_impetus: state = &'throw' func _on_fall_while_holding_state_tick(delta: float) -> void: _do_standard_motion(delta) if is_really_grounded() && !state_coyote_time_active(): play_sound(_landing_sound, _landing_sound_volume_db) state = &'run-while-holding' elif action1_impetus || action2_impetus || interact_impetus: state = &'throw' func _on_swim_state_tick(delta: float) -> void: _do_standard_motion(delta) func _on_swim_while_holding_state_tick(delta: float) -> void: _do_standard_motion(delta) func _on_throw_state_tick(_delta: float) -> void: if _state_animation_done(): if _carrying: _put_down_without_animation() force_change_state(&'run') elif _carrying && ( _anim_player.current_animation_position / _anim_player.current_animation_length ) >= 0.5: call_deferred(&'_throw_deferred_callback', _carrying) _carrying = null #endregion