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)
			if acos(contact_normal.dot(ground_normal)) <= _max_slope_angle:
				new_ground += contact_normal
			else:
				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
			):
				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' coyote time restrictions
## and need to be highly responsive, such as jumping.
func force_change_state(state_name: StringName) -> void:
	_state_machine.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,
		&'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,
		&'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:
		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:
		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:
		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:
		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:
		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:
		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:
		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:
		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_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
