class_name ActorPhysics extends Node ## Manages the physical behavior of an actor ## @remarks ## Using a RigidBody as the basis of a legged character will, invariably, result ## is a huge mess because the character will not stand still on slopes, ## bounce around when crossing colliders, get into trouble on stairs and more. ## ## Thus, you use a KinematicBody which does interact with the physics engine but ## its movement is completely controlled by code. ## ## This component is intended to be used with a KinematicBody but replicates ## a RigidBody's behavior in a simplistic way (no sliding, no friction). This has ## the advantage that the character's physics match the rest of the world, ## forces can be transferred from and to a RigidBody and that you can look up ## your math in any physics book (i.e. what jump-off impulse do I need if my ## character is supposed to jump exactly 2 meters high). # ----------------------------------------------------------------------------------------------- # ## Velocity change below which the tracked velocity will not be updated const VELOCITY_EPSILON : float = 1e-4 ## Half the value of PI const HALF_PI = 1.570796326794896619231321691639751442098584699687552910487472296153908203143104499 # ----------------------------------------------------------------------------------------------- # ## Path to the kinematic body this component is controlling ## @remarks ## The ActorPhysics component is designed to simulate physics for a KinematicBody ## node in Godot. This is the path in which the ActorPhysics component will look ## for the KinematicBody scene node. If left empty, a KinematicBody will be searched ## upwards in the scene hierarchy, beginning with the node holding this component. export(String) var kinematic_body_path : String \ setget _set_kinematic_body_path, _get_kinematic_body_path ## Whether the actor is being affected by gravity ## @remarks ## If set, the gravity from Unity's physics settings is applied as a force ## automatically before each update. Only set this to false if you do fancy ## things with gravity. export(bool) var is_affected_by_gravity : bool = true ## How much the actor is affected by gravity ## @remarks ## You may want to increase this for fast platformers since a realistic amount ## of gravity makes for very boring movements when combined with unrealistic ## jump heights (of multiple times the actor's own body height). export(float, 0.25, 25.0) var gravity_scale : float = 1.0 ## Mass of the actor ## @remarks ## This should include the equipment carried by the actor. ## ## Human 75 kilograms ## Dog 35 kilograms ## Horse 450 kilograms ## Car 1500 kilograms export(float, 0.1, 5000) var mass : float = 85.0 # Athletic "hero" human and equipment ## Maximum step height the character can traverse without jumping ## @remarks ## The character controller used by the actor physics component moves the actor ## the full horizontal distance desired and adjusts the height as needed ## (up to this height). The actor will move on top of the step without gaining ## any upward velocity. export(float, 0.0, 5.0) var maximum_step_height : float = 0.25 ## Current velocity of the actor export(Vector3) var velocity : Vector3 ## Whether the actor was grounded at the last physics update export(bool) var is_grounded : bool = false \ setget _set_is_grounded, _get_is_grounded ## Most recent gravity vector that was applied to the actor export(Vector3) var gravity_vector : Vector3 = Vector3(0.0, 1.0, 0.0) \ setget _set_gravity_vector, _get_gravity_vector # ----------------------------------------------------------------------------------------------- # ## KinematicBody node the ActorPhysics component is currently working with ## @remarks ## Updated when the kinematic_body_path string is changed. If the path is empty, ## a KinematicBody node will be searched upwards in the scene tree starting ## from the node carrying the ActorPhysics component. var _kinematic_body_node : KinematicBody ## Force that has been queued for the actor's velocity var _queued_forces : Vector3 ## Impulses that have been queued for the actor's velocity var _queued_impulses : Vector3 ## Movements that have been queued for the actor var _queued_movement : Vector3 ## Stores half of the acceleration from the last physics update ## @remarks ## This is used when doing Euler integration using the Midpoint Method, ## where half of the acceleration is integrated into velocity before updating ## the actor's position and half of the acceleration is integrated after. var _midpoint_velocity : Vector3 # acceleration * deltaSeconds ## Negative consumed height of steps the character has already traversed ## @remarks ## Careful: the step climbing budget is negative! 0.0 means the budget is full, ## and it is exhausted at -MaximumStepHeight. ## ## The step climbing budget allows characters to move across vertical steps up to ## a certain height. However, this might accidentally allow the character to scale ## steep walls if the step is traced anew each physics frame. In order to prevent ## this, a climbing budget is exhausted each time a step is climbed. It is recharged ## by horizontal movement. var _reversed_step_climbing_budget : float ## Whether the gravity vector affecting the actor is already known ## @remarks ## This is false until the actor queries the world gravity for the first time ## or until a custom gravity is applied via apply_custom_gravity() var _gravity_is_known : bool # ----------------------------------------------------------------------------------------------- # ## Queues a direct movement for the actor ## @param movement Movement that will be queued ## @remarks ## This will bypass acceleration/deceleration and attempt to move the actor ## directly by the specified amount during the next physics update. It is ## useful if you want to combine physics with animation-driven root motion. func queue_movement(movement : Vector3) -> void: _queued_movement += movement # ----------------------------------------------------------------------------------------------- # ## Queues an impulse to affect an actor's velocity ## @param impulse Impulse that will be queued for the actor's velocity ## @remarks ## An impulse is the total effect of a force applied over some time. ## A character jumping off the ground, for example, will want to achieve an exact ## change in total velocity. Doing this by applying a force over a certain time ## would be difficult, considering physics is stepped, not continuous. Using ## an impulse emulates the required force applied over an exact, short duration. func queue_impulse(impulse : Vector3) -> void: _queued_impulses += impulse # ----------------------------------------------------------------------------------------------- # ## Queues a force to affect an actor's velocity ## @param force Force that will be queued for the actor's velocity func queue_force(force : Vector3) -> void: _queued_forces += force # ----------------------------------------------------------------------------------------------- # ## Applies a custom gravity force to the actor ## @param gravity_to_apply Gravity direction and strength func apply_custom_gravity(gravity_to_apply : Vector3) -> void: gravity_vector = gravity_to_apply _gravity_is_known = true queue_force(gravity_to_apply * mass * gravity_scale) # ----------------------------------------------------------------------------------------------- # ## Applies the force of gravity to the actor func apply_world_gravity() -> void: var state : PhysicsDirectBodyState = ( PhysicsServer.body_get_direct_state(_get_kinematic_body_from_path().get_rid()) ) gravity_vector = state.total_gravity _gravity_is_known = true queue_force(gravity_vector * mass * gravity_scale) # ----------------------------------------------------------------------------------------------- # ## Called when the node enters the scene tree for the first time func _ready() -> void: var kinematic_body : KinematicBody = _get_kinematic_body_from_path() if kinematic_body == null: printerr("ERROR: ActorPhysics component found no KinematicBody node to control") return # Give a helpful warning of the KinematicBody is misconfigured var overlap : int = (kinematic_body.collision_layer & kinematic_body.collision_mask) if overlap != 0: printerr( "KinematicBody '" + kinematic_body.name + "' is on layers it is set to " + "collide against. The body will self-collide and not work unless you " + "move it to a different layer or fix its collision mask." ) # ----------------------------------------------------------------------------------------------- # ## Called each physics update to update the simulation ## @param delta_seconds Time that has passed since the previous update func _physics_process(delta_seconds : float) -> void: var kinematic_body : KinematicBody = _get_kinematic_body_from_path() if kinematic_body == null: return # Auto-apply gravity if enabled if is_affected_by_gravity: apply_world_gravity() # Determine the translation the actor should attempt this physics frame # according to its velocity, acceleration and forces. var translation : Vector3 = _integrate_via_midpoint_method(delta_seconds) # Now do the movement. This requires special tricks because the CharacterController # component has several issues. var reported_velocity : Vector3 if true: reported_velocity = _move_actor(translation, delta_seconds) # As the actor travels horizontally, recharge the step climb budget # by the amount the character controller's slope limit would allow # the character to climb vertically. _recharge_step_climb_budget(translation) # If the actor's movement was hampered, it obviously lost that velocity. # Update our velocity to what it is no higher than the actual movement performed, # to avoid running up huge impulses from pushing into a wall or the ground. # # This is filtered so that small errors will not accumulate, like when moving at # a speed of 5.0 up a slope and the movement logic says that the character only # moved 4.99 units, getting slower every cycle. var update_horizontal_velocity : bool = true _update_velocity(reported_velocity, update_horizontal_velocity) # ----------------------------------------------------------------------------------------------- # ## Retrieves the path to the controlled kinematic body ## @returns The path to the controlled kinematic body func _get_kinematic_body_path() -> String: return kinematic_body_path # ----------------------------------------------------------------------------------------------- # ## Changes the path to the controlled kinematic body ## @param path Node path in which the kinematic body is expected func _set_kinematic_body_path(new_path : String) -> void: kinematic_body_path = new_path _kinematic_body_node = null # Clear it so it's looked up again next update # ----------------------------------------------------------------------------------------------- # ## Retrieves the gravity vector that is currently affecting the actor ## @returns The current gravity vector applying to the actor func _get_gravity_vector() -> Vector3: if not _gravity_is_known: var state : PhysicsDirectBodyState = ( PhysicsServer.body_get_direct_state(_get_kinematic_body_from_path().get_rid()) ) gravity_vector = state.total_gravity _gravity_is_known = true # TODO: Ordering issue, can report world gravity for first frame # If the user wishes to use onlz custom gravity, the first frame, if this is # called before the user gets a chance to call apply_custom_gravity(), # for one frame the world gravity would be reported. return gravity_vector # ----------------------------------------------------------------------------------------------- # ## Reports an error when the user attempts to assign the gravity vector ## @param new_gravity_vector New gravity vector that will not be applied func _set_gravity_vector(new_gravity_vector : Vector3) -> void: if new_gravity_vector != gravity_vector: printerr("ERROR: The gravity_vector property cannot be written to") # ----------------------------------------------------------------------------------------------- # ## Moves the actor by the specified amount (unless blocked by colliders) ## @param translation Amount by which the actor will be moved ## @param delta_seconds Time elapsed since the previous physics update ## @returns The actual movement performed by the actor ## @remarks ## If the actor hits a wall, the reported velocity will change. func _move_actor(translation : Vector3, delta_seconds : float) -> Vector3: # The move_and_slide() method is "helpfully" multiplying velocity by deltaSeconds # for us, which is exactly what we don't want # # Presently, the method is documented to return the remaining movement, but it # actually returns the performed movement. It's the docs that are wrong, most likely. var actual_movement : Vector3 = self._kinematic_body_node.move_and_slide( translation / delta_seconds, self.gravity_vector.normalized() ) return actual_movement # ----------------------------------------------------------------------------------------------- # ## Recharge the step climb budget relative from the actor's horizontal movement ## @param translation Amount the actor has moved ## @remarks ## Realistically, this budget would also recover by time: an actor could walk ## up very steep stairs by moving only upwards, but the character controller ## performs horizontal movement in full and only then adjusts height based on ## obstacles, so it's either this or unlimited stair steepness. func _recharge_step_climb_budget(translation : Vector3) -> void: var horizontal_movement : float var vertical_movement : float # Determine the distance moved horizontally and vertically relative to the gravity vector if true: var distance : float = translation.length() # Total move distance var direction : Vector3 = translation / distance # Normalized movement direction # Calculate the angle relative to the floor plane (defined by the gravity vector). # Positive angles mean the actor is climbing, negative angles mean descending. var direction_dot_gravity : float = gravity_vector.dot(direction) var angle_to_floor_plane : float = HALF_PI - acos(direction_dot_gravity) vertical_movement = sin(angle_to_floor_plane) * distance horizontal_movement = cos(angle_to_floor_plane) * distance # Recharge or deplete the step climbing budget depending on the actor's horizontal # movement relative to its vertical movement var ratio : float = horizontal_movement - vertical_movement _reversed_step_climbing_budget = clamp( _reversed_step_climbing_budget + ratio, -maximum_step_height, 0.0 ) # ----------------------------------------------------------------------------------------------- # ## Updates the recorded velocity of the actor ## @param new_velocity New velocity the actor will assume ## @param update_horizontal_velocity Whether horizontal velocity will be updated ## @remarks ## If no collisions happen it's often a good idea to keep the horizontal ## velocity untouched. func _update_velocity( new_velocity : Vector3, update_horizontal_velocity : bool = true ) -> void: # Update the velocity only if it has changed more than the epsilon value. # Since the velocity is scaled by delta time, then descaled and scaled again, # blindly updating the recorded velocity would over time accumulate floating # point inaccuracies. if new_velocity.distance_squared_to(velocity) > VELOCITY_EPSILON: if update_horizontal_velocity: velocity = new_velocity else: velocity.y = new_velocity.y # If the velocity has been set to zero on any axis, apply this in any # case (since otherwise, a tiny drift might never be cleared from our # velocity vector, very slowly moving the actor around against an obstacle). if true: if update_horizontal_velocity: if abs(new_velocity.x) < VELOCITY_EPSILON: velocity.x = 0.0 if abs(new_velocity.z) < VELOCITY_EPSILON: velocity.z = 0.0 if abs(new_velocity.y) < VELOCITY_EPSILON: velocity.y = 0.0 # ----------------------------------------------------------------------------------------------- # ## Integrates acceleration and velocity using the Midpoint method ## @param delta_time Time by which the simulation will be advanced ## @returns The translation by which the actor should be moved ## @remarks ## Most games use simply Euler integration (velocity += acceleration * time), ## but this changes the outcome of physics depending on the framerate. ## Google "Quake3 framerate dependent jump height" for some fun anecdotes.# ## ## The midpoint method improves accuracy for a relatively low performance cost ## https://en.wikipedia.org/wiki/Midpoint_method func _integrate_via_midpoint_method(delta_seconds : float) -> Vector3: var translation : = Vector3(0.0, 0.0, 0.0) if true: # Apply the other half of the acceleration from the previous update cycle. # This is delayed into now in order to integrate velocity at the midpoint. velocity += _midpoint_velocity # Add impulses velocity += _queued_impulses / mass # Calculate the new acceleration var acceleration : Vector3 = _queued_forces / mass # Halve the acceleration so that half can be applied now, the other half at # the beginning of the next update cycle (remembered in _midpoint_velocity) acceleration /= 2.0 _midpoint_velocity = acceleration * delta_seconds; # Apply the first half of the force scaled by time velocity += _midpoint_velocity translation = velocity * delta_seconds # Finally, queued movements (root motion, etc.) go directly into translation translation += _queued_movement # Reset the queued influences _queued_forces = Vector3(0.0, 0.0, 0.0) _queued_impulses = Vector3(0.0, 0.0, 0.0) _queued_movement = Vector3(0.0, 0.0, 0.0) return translation # ----------------------------------------------------------------------------------------------- # ## Locates the kinematic body using the currently assigned path or default ## @remarks ## Updates the internal _kinematic_body variable, nothing more. ## If no KinematicBody is found, _kinematic_body may be null after this. func _get_kinematic_body_from_path() -> KinematicBody: if _kinematic_body_node == null: if len(kinematic_body_path) == 0: var current_node : Node = self while not current_node is KinematicBody: current_node = current_node.get_parent() if current_node == null: break _kinematic_body_node = current_node else: _kinematic_body_node = get_node(kinematic_body_path) return _kinematic_body_node # ----------------------------------------------------------------------------------------------- # ## Fetches the current grounding state of the actor func _get_is_grounded() -> bool: var kinematic_body : KinematicBody = _get_kinematic_body_from_path() if kinematic_body.is_on_floor(): pass#print("Check: is grounded") else: pass#print("Check: not grounded") return kinematic_body.is_on_floor() # TODO: Check if this handles arbitrary gravity # ----------------------------------------------------------------------------------------------- # ## Fetches the current grounding state of the actor func _set_is_grounded(new_is_grounded : bool) -> void: if new_is_grounded != is_grounded: printerr("ERROR: The is_grounded property cannot be written to")