diff --git a/content/scenes/player.tscn b/content/scenes/player.tscn new file mode 100644 index 0000000..eecd4dd --- /dev/null +++ b/content/scenes/player.tscn @@ -0,0 +1,33 @@ +[gd_scene load_steps=5 format=3 uid="uid://ul8o2n823qod"] + +[ext_resource type="Script" path="res://content/scripts/player.gd" id="1_i6r2s"] +[ext_resource type="Script" path="res://content/scripts/smoother.gd" id="2_dpu6i"] +[ext_resource type="Script" path="res://content/scripts/dampened_camera.gd" id="3_24qwg"] + +[sub_resource type="CylinderShape3D" id="CylinderShape3D_tpgoe"] +height = 1.8 +radius = 0.25 + +[node name="Player" type="StaticBody3D" node_paths=PackedStringArray("head", "body", "camera", "collision_shape")] +script = ExtResource("1_i6r2s") +head = NodePath("Body/Head") +body = NodePath("Body") +camera = NodePath("Body/Head/Smoother/Camera3D") +collision_shape = NodePath("MoveCollider") + +[node name="MoveCollider" type="CollisionShape3D" parent="."] +transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 0.9, 0) +shape = SubResource("CylinderShape3D_tpgoe") + +[node name="Body" type="Node3D" parent="."] +transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 1.6, 0) + +[node name="Head" type="Node3D" parent="Body"] + +[node name="Smoother" type="Node3D" parent="Body/Head" node_paths=PackedStringArray("target")] +script = ExtResource("2_dpu6i") +target = NodePath("..") + +[node name="Camera3D" type="Camera3D" parent="Body/Head/Smoother" node_paths=PackedStringArray("target")] +script = ExtResource("3_24qwg") +target = NodePath("..") diff --git a/content/scenes/playground.tscn b/content/scenes/playground.tscn new file mode 100644 index 0000000..9250e78 --- /dev/null +++ b/content/scenes/playground.tscn @@ -0,0 +1,58 @@ +[gd_scene load_steps=5 format=3 uid="uid://dd4qlg15cchu0"] + +[ext_resource type="PackedScene" uid="uid://ul8o2n823qod" path="res://content/scenes/player.tscn" id="1_qpwbx"] + +[sub_resource type="ProceduralSkyMaterial" id="ProceduralSkyMaterial_2t7xo"] + +[sub_resource type="Sky" id="Sky_ypbkl"] +sky_material = SubResource("ProceduralSkyMaterial_2t7xo") + +[sub_resource type="Environment" id="Environment_d286k"] +background_mode = 2 +sky = SubResource("Sky_ypbkl") + +[node name="World" type="Node3D"] + +[node name="Environment" type="Node3D" parent="."] + +[node name="WorldEnvironment" type="WorldEnvironment" parent="Environment"] +environment = SubResource("Environment_d286k") + +[node name="DirectionalLight3D" type="DirectionalLight3D" parent="Environment"] +transform = Transform3D(-0.866025, -0.433013, 0.25, 0, 0.5, 0.866025, -0.5, 0.75, -0.433013, 0, 0, 0) +shadow_enabled = true + +[node name="Terrain" type="Node3D" parent="."] + +[node name="CSGBox3D" type="CSGBox3D" parent="Terrain"] +transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 0.5, 0) +use_collision = true +size = Vector3(50, 1, 50) + +[node name="CSGBox3D2" type="CSGBox3D" parent="Terrain"] +transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 3.5, 1.1, 0) +use_collision = true +size = Vector3(2, 0.2, 1) + +[node name="CSGBox3D3" type="CSGBox3D" parent="Terrain"] +transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 3.6, 1.3, 0) +use_collision = true +size = Vector3(1.8, 0.2, 1) + +[node name="CSGBox3D4" type="CSGBox3D" parent="Terrain"] +transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 3.7, 1.5, 0) +use_collision = true +size = Vector3(1.6, 0.2, 1) + +[node name="CSGBox3D5" type="CSGBox3D" parent="Terrain"] +transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 3.8, 1.7, 0) +use_collision = true +size = Vector3(1.4, 0.2, 1) + +[node name="CSGBox3D6" type="CSGBox3D" parent="Terrain"] +transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 3.9, 1.9, 0) +use_collision = true +size = Vector3(1.2, 0.2, 1) + +[node name="Player" parent="." instance=ExtResource("1_qpwbx")] +transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 1, 0) diff --git a/content/scripts/dampened_camera.gd b/content/scripts/dampened_camera.gd new file mode 100644 index 0000000..02cf46c --- /dev/null +++ b/content/scripts/dampened_camera.gd @@ -0,0 +1,31 @@ +class_name DampenedCamera3D extends Camera3D + +@export var target : Node3D +@export var damping : bool + +# Called when the node enters the scene tree for the first time. +func _ready() -> void: + damping = false + set_process_priority(100) + set_as_top_level(true) + Engine.set_physics_jitter_fix(0.0) + + +# Called every frame. 'delta' is the elapsed time since the previous frame. +func _process(delta: float) -> void: + if target == null: + pass + global_rotation = target.global_rotation + var target_pos := target.global_position + if not damping: + global_position = target_pos + pass + + target_pos.y = lerp(global_position.y, target_pos.y, 20 * delta) + global_position = target_pos + +func damp() -> void: + damping = true + +func donmp() -> void: + damping = false diff --git a/content/scripts/player.gd b/content/scripts/player.gd new file mode 100644 index 0000000..46fee0b --- /dev/null +++ b/content/scripts/player.gd @@ -0,0 +1,433 @@ +extends PhysicsBody3D + +@export var head : Node3D +@export var body : Node3D +@export var camera : DampenedCamera3D + +## A reference to the collision shape this physics body is using +## (It's just a bit easier rather than aquiring the reference via code) +@export var collision_shape : CollisionShape3D + +## The character will be blocked from moving up slopes steeper than this angle +## The character will be not be flagged as 'grounded' when stood on slopes steeper than this angle +@export var slope_limit : float = 45 + +## The character will automatically adjust height to step over obstacles this high +@export var step_height := 0.2 + +## When grounded, the character will snap down this distance +## This keeps the character on steps, slopes, and helps keep behaviour consistent +@export var snap_to_ground_distance := 0.2 + + +@export_group("Advanced") +## Stop movement under this distance, but only if the movement touches at least 2 steep slopes +## The slope movement code in this class does not handle all edge cases; this is a hack to eliminate +## jitter movement +@export var steep_slope_jitter_reduce := 0.03 + +## The godot move_and_collide method has built in depenetration +## Higher values can eliminate jittery movement against obscure geometry, but in my experience +## this comes at the cost of making movement across flush collision planes a bit unreliable +@export var depenetration_margin := 0.001 + +## The distance under the player to check for ground at the start of movement +## This is in addition to the usual method of setting grounded state by collision +@export var ground_cast_distance := 0.004 + +## If a collision happens within this distance of the bottom of the collider +## it's considered the "bottom" +## This value is used to determine if slopes should actually make the player +## rise, or if they should be considered a wall, in the case where the slope +## is above the players feet +@export var bottom_height := 0.05 + +## The movement code in this class tries to adjust translation to confirm to the collision plane +## This means the same plane should never be hit more than once within 1 frame +## This sometimes happens anyway, typically when there is a small safe margin +## If it happens, the movement will be blocked and the rest of the movement iterations will be +## consumed +## This is a little hack to slightly adjust the translation to break out of this infinite loop +@export var same_surface_adjust_distance := 0.001 + +## How many times to move_and_collide. The algorithm early exits anyway +## The value of turning this up is to make movement in very complicated terrain more +## accurate. 4 is a decent number for low poly terrain! +@export var max_iteration_count := 4 + + +var gravity := 9.8 +var max_speed := 8 +var slow_speed := 2 +var mouse_sensitivity := 0.002 # radians/pixel + +var _velocity : Vector3 = Vector3() +var at_max_speed : bool = true + +var grounded : bool = false +var ground_normal : Vector3 +var steep_slope_normals : Array[Vector3] = [] +var total_stepped_height : float = 0 + +var escape_pressed : int +var vertical_collisions : Array[KinematicCollision3D] +var lateral_collisions : Array[KinematicCollision3D] +var snap_collisions : Array[KinematicCollision3D] + +enum MovementType {VERTICAL, LATERAL} + +func _ready() -> void: + lock_mouse() + +func lock_mouse() -> void: + Input.mouse_mode = Input.MOUSE_MODE_CAPTURED + +func unlock_mouse() -> void: + Input.mouse_mode = Input.MOUSE_MODE_VISIBLE + +# TODO should this have an action associated? +# TODO should it be in unhandled? +func _input(event: InputEvent) -> void: + if event is InputEventMouseMotion and Input.get_mouse_mode() == Input.MOUSE_MODE_CAPTURED: + var motion := event as InputEventMouseMotion + body.rotate_y(-motion.relative.x * mouse_sensitivity) + head.rotate_x(-motion.relative.y * mouse_sensitivity) + head.rotation.x = clamp(head.rotation.x, -1.4, 1.4) + +# TODO should this be in unhandled input? +# Input buffering? lol lmao +func get_input() -> Vector2: + if !Input.is_key_pressed(KEY_ESCAPE) && escape_pressed == 1: + if Input.get_mouse_mode() == Input.MOUSE_MODE_CAPTURED: + unlock_mouse() + else: + lock_mouse() + + if Input.is_key_pressed(KEY_ESCAPE): + escape_pressed = 1 + elif !Input.is_key_pressed(KEY_ESCAPE): + escape_pressed = 0 + + if Input.get_mouse_mode() != Input.MOUSE_MODE_CAPTURED: + return Vector2(0,0) + + if Input.is_action_just_pressed("cc_sprint"): + at_max_speed = !at_max_speed + + var input_dir : Vector2 = Vector2() + if Input.is_action_pressed("cc_forward"): + input_dir += Vector2.UP + if Input.is_action_pressed("cc_backward"): + input_dir -= Vector2.UP + if Input.is_action_pressed("cc_left"): + input_dir += Vector2.LEFT + if Input.is_action_pressed("cc_right"): + input_dir -= Vector2.LEFT + input_dir = input_dir.normalized() + + # Local rotation is fine given the parent isn't rotating ever + return input_dir.rotated(-body.rotation.y) + +func _physics_process(delta: float) -> void: + + # Before Move + var _desired_horz_velocity := get_input() + var desired_horz_velocity := Vector3.ZERO + desired_horz_velocity.x = _desired_horz_velocity.x + desired_horz_velocity.z = _desired_horz_velocity.y + + if at_max_speed: + desired_horz_velocity *= max_speed + else: + desired_horz_velocity *= slow_speed + + var desired_vertical_velocity := Vector3.ZERO + + if !grounded: + desired_vertical_velocity.y = _velocity.y + + desired_vertical_velocity.y -= gravity * delta + + # Could have calculated them together + # But separating them makes it easier to experiment with different movement algorithms + var desired_velocity := desired_horz_velocity + desired_vertical_velocity + move(desired_velocity, delta) + + +# Entry point to moving +func move(intended_velocity : Vector3, delta : float) -> void: + var start_position := position + + var lateral_translation := horz(intended_velocity * delta) + var initial_lateral_translation := lateral_translation + var vertical_translation := vert(intended_velocity * delta) + var initial_vertical_translation := vertical_translation + + grounded = false + steep_slope_normals = [] + total_stepped_height = 0 + + vertical_collisions.clear() + lateral_collisions.clear() + snap_collisions.clear() + + # An initial grounded check is important because ground normal is used + # to detect seams with steep slopes; which often are collided with before the ground + if vertical_translation.y <= 0: + var initial_grounded_collision := move_and_collide(Vector3.DOWN * ground_cast_distance, true, depenetration_margin) + if initial_grounded_collision: + if initial_grounded_collision.get_normal(0).angle_to(Vector3.UP) < deg_to_rad(slope_limit): + grounded = true + ground_normal = initial_grounded_collision.get_normal(0) + + # === Iterate Movement Laterally + var lateral_iterations := 0 + while lateral_translation.length() > 0 and lateral_iterations < max_iteration_count: + + lateral_translation = move_iteration(MovementType.LATERAL, lateral_collisions, initial_lateral_translation, lateral_translation) + lateral_iterations += 1 + + # De-jitter by just ignoring lateral movement + # (multiple steep slopes have been collided, but movement is very small) + if steep_slope_normals.size() > 1 and horz(position - start_position).length() < steep_slope_jitter_reduce: + position = start_position + + # === Iterate Movement Vertically + var vertical_iterations := 0 + while vertical_translation.length() > 0 and vertical_iterations < max_iteration_count: + + vertical_translation = move_iteration(MovementType.VERTICAL, vertical_collisions, initial_vertical_translation, vertical_translation) + vertical_iterations += 1 + + # Don't include step height in actual velocity + var actual_translation := position - start_position + var actual_translation_no_step := actual_translation - Vector3.UP * total_stepped_height + var actual_velocity := actual_translation_no_step / delta + + # HACK! + # For some reason it's difficult to accumulate velocity when sliding down steep slopes + # Here I just ignore the actual velocity in favour of: + # "If intended travel was down, and actual travel was down, just keep the intended velocity" + # This means the user is responsible for resetting vertical velocity when grounded + if intended_velocity.y < 0 and actual_velocity.y < 0: + _velocity = Vector3(actual_velocity.x, intended_velocity.y, actual_velocity.z) + else: + _velocity = actual_velocity + + # Snap Down + # Happens last so it doesn't affect velocity + # Keeps the character on slopes and on steps when travelling down + if grounded: + camera.damp() + + # === Iterate Movement Vertically (Snap) + # We allow snap to slide down slopes + # It really helps reduce jitter on steep slopes + var before_snap_pos := position + var ground_snap_iterations := 0 + var ground_snap_translation := Vector3.DOWN * snap_to_ground_distance + while ground_snap_translation.length() > 0 and ground_snap_iterations < max_iteration_count: + + ground_snap_translation = move_iteration(MovementType.VERTICAL, snap_collisions, Vector3.DOWN, ground_snap_translation) + ground_snap_iterations += 1 + + # Decide whether to keep the snap or not + if snap_collisions.is_empty(): + var after_snap_ground_test := move_and_collide(Vector3.DOWN * ground_cast_distance, true, depenetration_margin) + if after_snap_ground_test and after_snap_ground_test.get_normal(0).angle_to(Vector3.UP) < deg_to_rad(slope_limit): + # There was no snap collisions, but there is ground underneath + # This can be due to an edge case where the snap movement falls through the ground + # Why does this check not fall through the ground? I don't know + # In any case, manually set the y + position.y = after_snap_ground_test.get_position(0).y + else: + # No snap collisions and no floor, reset + position = before_snap_pos + elif !(snap_collisions[snap_collisions.size() - 1].get_normal(0).angle_to(Vector3.UP) < deg_to_rad(slope_limit)): + # Collided with steep ground, reset + position = before_snap_pos + else: + camera.donmp() + + +# Moves are composed of multiple iterates +# In each iteration, move until collision, then calculate and return the next movement +func move_iteration(movement_type: MovementType, collision_array : Array, initial_direction: Vector3, translation: Vector3) -> Vector3: + + var collisions : KinematicCollision3D + + # If Lateral movement, try stepping + if movement_type == MovementType.LATERAL: + var do_step := false + var temp_position := position + + var walk_test_collision := move_and_collide(translation, true, 0) + + var current_step_height := step_height + var step_up_collisions := move_and_collide(Vector3.UP * step_height, false, 0) + if (step_up_collisions): + current_step_height = step_up_collisions.get_travel().length() + var raised_forward_collisions := move_and_collide(translation, false, 0) + var down_collision := move_and_collide(Vector3.DOWN * current_step_height, false, 0) + + # Only step if the step algorithm landed on a walkable surface + # AND the walk lands on a non-walkable surface + # This stops stepping up ramps + if (down_collision and + down_collision.get_normal(0).angle_to(Vector3.UP) < deg_to_rad(slope_limit) and + walk_test_collision and + !walk_test_collision.get_normal(0).angle_to(Vector3.UP) < deg_to_rad(slope_limit)): + do_step = true + + if do_step: # Keep track of stepepd distance to cancel it out later + total_stepped_height += position.y - temp_position.y + collisions = raised_forward_collisions + camera.damp() + else: # Reset and move normally + position = temp_position + collisions = move_and_collide(translation, false, depenetration_margin) + + # If Vertical movement, just move; no need to step + else: + collisions = move_and_collide(translation, false, depenetration_margin) + + # Moved all remaining distance + if !collisions: + return Vector3.ZERO + + collision_array.append(collisions) + + # If any ground collisions happen during movement, the character is grounded + # Imporant to keep this up-to-date rather than just rely on the initial grounded state + if collisions.get_normal(0).angle_to(Vector3.UP) < deg_to_rad(slope_limit): + grounded = true + ground_normal = collisions.get_normal(0) + + # Surface Angle will be used to "block" movement in some directions + var surface_angle := collisions.get_normal(0).angle_to(Vector3.UP) + + # For Vertical, blocking angle is between 0 - slopeLimit + # For Lateral, blocking angle is slopeLimit - 360 (grounded) or 90 (not grounded) + # The latter allows players to slide down ceilings while in the air + # + # These values shouldn't be calculated every frame; they only need to change + # when the user defines the slope limit + # But I'm lazy :) + var min_block_angle : float + var max_block_angle : float + if movement_type == MovementType.LATERAL: + min_block_angle = deg_to_rad(slope_limit) + if grounded: + max_block_angle = 2 * PI + else: + max_block_angle = PI / 2 + if movement_type == MovementType.VERTICAL: + min_block_angle = 0 + max_block_angle = deg_to_rad(slope_limit) + + + # This algorithm for determining where to move on a collisions uses "projection plane" + # Whatever surface the character hits, we generate a blocking "plane" that we will slide along + # + # We calculate the normal of the plane we want to use, projection_normal, then + # transform into a plane at the end + # + # By default, projection normal is just the normal of the surface + # This may be unecessary after we account for all edge cases + # I'm leaving it here to help understand the algorithm + var projection_normal := collisions.get_normal(0) + + var cylinder := collision_shape.shape as CylinderShape3D + var collision_point := collisions.get_position(0) + + # If collision happens on the "side" of the cylinder, treat it as a vertical + # wall in all cases (we use the tangent of the cylinder) + if (movement_type == MovementType.LATERAL and + (collision_point.y > (collision_shape.global_position.y - cylinder.height / 2) + bottom_height)): + projection_normal = collision_shape.global_position - collision_point + projection_normal.y = 0 + projection_normal = projection_normal.normalized() + + # Otherwise, determine if the surface is a blocking surface + elif surface_angle >= min_block_angle and surface_angle <= max_block_angle: + if movement_type == MovementType.LATERAL: + # "Wall off" the slope + projection_normal = horz(collisions.get_normal(0)).normalized() + + # Or, "Wall off" the slope by figuring out the seam with the ground + if grounded and surface_angle < PI / 2: + if !already_touched_slope_close_match(collisions.get_normal(0)): + steep_slope_normals.append(collisions.get_normal(0)) + + var seam := collisions.get_normal(0).cross(ground_normal) + var temp_projection_plane := Plane(Vector3.ZERO, seam, seam + Vector3.UP) + projection_normal = temp_projection_plane.normal + + if movement_type == MovementType.VERTICAL: + # If vertical is blocked, you're on solid ground - just stop moving + return Vector3.ZERO + + # Otherwise force the direction to align with input direction + # (projecting translation over the normal of a slope does not align with input direction) + elif movement_type == MovementType.LATERAL and surface_angle < (PI / 2): + projection_normal = relative_slope_normal(collisions.get_normal(0), translation) + + # Don't let one move call ping pong around + var projection_plane := Plane(projection_normal) + var continued_translation := projection_plane.project(collisions.get_remainder()) + var initial_influenced_translation := projection_plane.project(initial_direction) + + var next_translation : Vector3 + if initial_influenced_translation.dot(continued_translation) >= 0: + next_translation = continued_translation + else: + next_translation = initial_influenced_translation.normalized() * continued_translation.length() + + # See same_surface_adjust_distance + if next_translation.normalized() == translation.normalized(): + next_translation += collisions.get_normal(0) * same_surface_adjust_distance + + return next_translation + + +func already_touched_slope_close_match(normal : Vector3) -> bool: + for steep_slope_normal in steep_slope_normals: + if steep_slope_normal.distance_squared_to(normal) < 0.001: + return true + + return false + +# I wrote this a while ago in Unity +# I ported it here but I only have a vague grasp of how it works +func relative_slope_normal(slope_normal : Vector3, lateral_desired_direction : Vector3) -> Vector3: + var slope_normal_horz := horz(slope_normal) + var angle_to_straight := slope_normal_horz.angle_to(-lateral_desired_direction) + var angle_to_up := slope_normal.angle_to(Vector3.UP) + var complementary_angle_to_up := PI / 2 - angle_to_up + + if angle_to_up >= (PI / 2): + push_error("Trying to calculate relative slope normal for a ceiling") + + # Geometry! + + # This is the component of the desired travel that points straight into the slope + var straight_length := cos(angle_to_straight) * lateral_desired_direction.length() + + # Which helps us calculate the height on the slope at the end of the desired travel + var height := straight_length / tan(complementary_angle_to_up) + + # Which gives us the actual desired movement + var vector_up_slope := Vector3(lateral_desired_direction.x, height, lateral_desired_direction.z) + + # Due to the way the movement algorithm works we need to figure out the normal that defines + # the plane that will give this result + var rotation_axis := vector_up_slope.cross(Vector3.UP).normalized() + var emulated_normal := vector_up_slope.rotated(rotation_axis, PI / 2) + + return emulated_normal.normalized() + +func horz(value: Vector3) -> Vector3: + return Vector3(value.x,0,value.z) + +func vert(value: Vector3) -> Vector3: + return Vector3(0,value.y,0) diff --git a/content/scripts/smoother.gd b/content/scripts/smoother.gd new file mode 100644 index 0000000..efac48d --- /dev/null +++ b/content/scripts/smoother.gd @@ -0,0 +1,39 @@ +extends Node3D + +@export var target : Node3D +var currTransform: Transform3D +var prevTransform: Transform3D + +# Called when the node enters the scene tree for the first time. +func _ready() -> void: + currTransform = Transform3D() + prevTransform = Transform3D() + set_process_priority(100) + set_as_top_level(true) + Engine.set_physics_jitter_fix(0.0) + + +# Called every frame. 'delta' is the elapsed time since the previous frame. +func _process(_delta: float) -> void: + if target == null: + return + var f := Engine.get_physics_interpolation_fraction() + var new_transform: Transform3D = Transform3D() + + var ptDiff := currTransform.origin - prevTransform.origin + new_transform.origin = prevTransform.origin + (ptDiff * f) + new_transform.basis = _LerpBasis(prevTransform.basis, currTransform.basis, f) + + transform = new_transform + +func _physics_process(_delta: float) -> void: + prevTransform = currTransform + currTransform = target.global_transform + + +func _LerpBasis(from: Basis, to: Basis, f: float) -> Basis: + var res: Basis = Basis() + res.x = from.x.lerp(to.x, f) + res.y = from.y.lerp(to.y, f) + res.z = from.z.lerp(to.z, f) + return res