Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
152 changes: 152 additions & 0 deletions abilities/climb_step_ability_3d.gd
Original file line number Diff line number Diff line change
@@ -0,0 +1,152 @@
extends MovementAbility3D
class_name ClimbStepAbility3D

## Ability that allows the character to climb up steps/stairs.
## Uses a ShapeCast3D to detect obstacles ahead and applies upward movement when a step is detected.

## Maximum step height that can be climbed
@export var step_size: float = 0.5

## Minimum step height to trigger climbing (ignores very small steps)
@export var min_step_size: float = 0.25

## Distance ahead in movement direction to check for steps (distance from edge to step)
@export var check_distance: float = 0.5

## Reference to the character controller (to access position and collision)
var character_body: CharacterBody3D

## Reference to the character's collision shape (to calculate foot position)
var collision_shape: CollisionShape3D

## Minimum horizontal velocity magnitude to trigger step climbing
@export var min_velocity_threshold: float = 0.01

## Base velocity multiplier for climbing (multiplied by step height)
@export var climb_velocity_multiplier: float = 8.0

## Maximum climb velocity to prevent excessive jumping
@export var max_climb_velocity: float = 15.0

## Apply step climbing logic
func apply(velocity: Vector3, speed: float, is_on_floor: bool, direction: Vector3, delta: float) -> Vector3:
if not is_actived():
return velocity

# Allow climbing even when slightly off floor (in case we're already climbing)
var is_near_floor = is_on_floor or (character_body and character_body.is_on_floor())
if not is_near_floor and velocity.y <= 0:
return velocity

# Use input direction instead of velocity direction to detect steps
# This ensures we can climb even when velocity is low
var input_direction = direction
input_direction.y = 0.0
input_direction = input_direction.normalized()

# Also check current velocity direction as fallback
var horizontal_velocity = Vector3(velocity.x, 0.0, velocity.z)
var horizontal_speed = horizontal_velocity.length()
var move_direction = input_direction

# If we have significant horizontal velocity, use that direction instead
if horizontal_speed > min_velocity_threshold:
move_direction = horizontal_velocity.normalized()
# If no input and no velocity, can't determine direction
elif input_direction.length() < 0.1:
return velocity

var step_height = _can_climb_step(move_direction)
if step_height > 0.0:
# Calculate climb velocity based on step height
var base_climb_velocity = step_height * climb_velocity_multiplier

# Ensure we have minimum velocity to overcome gravity and actually climb
# Need enough velocity to: 1) overcome gravity, 2) gain height equal to step_height
# Gravity is ~29.4 (9.8 * 3.0 multiplier) per second, so per frame at 60fps is ~0.49
# We need velocity that can overcome this AND gain the step height
var min_required_velocity = step_height * 15.0 # Increased minimum to ensure climbing
var climb_velocity = max(base_climb_velocity, min_required_velocity)
climb_velocity = min(climb_velocity, max_climb_velocity)

# Apply upward velocity to climb the step
# IMPORTANT: Always apply velocity when step is detected, regardless of current velocity
# This ensures we can climb even when stuck or with low momentum
velocity.y = climb_velocity

# Also ensure we maintain horizontal movement when climbing
# If horizontal velocity is too low, add some forward push
if horizontal_speed < min_velocity_threshold:
var forward_push = move_direction * speed * 0.3 # 30% of normal speed
velocity.x = forward_push.x
velocity.z = forward_push.z

return velocity

## Alternative approach: Test if we can move forward, and if blocked, try moving up
func _test_step_climb(move_direction: Vector3, delta: float) -> bool:
if not character_body:
return false

# Test if we can move forward horizontally
var test_velocity = move_direction * check_distance / delta
var collision = character_body.test_move(character_body.global_transform, test_velocity * delta)

if collision:
# We're blocked, try moving up and forward
var test_velocity_up = test_velocity + Vector3(0, step_size * 10.0, 0)
var collision_up = character_body.test_move(character_body.global_transform, test_velocity_up * delta)

# If we can move up and forward, it's a step we can climb
return not collision_up

return false

## Checks if a step can be climbed using test_move
## Returns the step height if climbable, or 0.0 if not
func _can_climb_step(move_direction: Vector3) -> float:
if not character_body:
return 0.0

# Use test_move to check if we're blocked when trying to move forward
# This is more reliable than raycasts because it uses the actual collision shape
var current_transform = character_body.global_transform

# Test 1: Try to move forward horizontally (this should be blocked by the step)
var forward_movement = move_direction * check_distance
var has_collision_forward = character_body.test_move(current_transform, forward_movement)

if not has_collision_forward:
# No collision when moving forward, so no step to climb
return 0.0

# Test at maximum step size first
var up_and_forward = forward_movement + Vector3(0, step_size, 0)
var has_collision_up_forward = character_body.test_move(current_transform, up_and_forward)

if has_collision_up_forward:
# Even at max height, we're blocked - step is too high or it's a wall
return 0.0

# Binary search to find minimum height needed (within min_step_size to step_size)
# Test a few heights to find the actual step height
var test_heights = [min_step_size, min_step_size * 2.0, step_size * 0.5, step_size]
var found_height = 0.0

for test_h in test_heights:
if test_h < min_step_size:
continue

var test_movement = forward_movement + Vector3(0, test_h, 0)
var can_move = not character_body.test_move(current_transform, test_movement)

if can_move:
found_height = test_h
break

if found_height < min_step_size:
# Step is too small to bother climbing
return 0.0

# We can move up and forward, so it's a step we can climb!
return found_height
98 changes: 98 additions & 0 deletions abilities/crouch_jump_boost_ability_3d.gd
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
extends MovementAbility3D
class_name CrouchJumpBoostAbility3D

## Ability that boosts jump height when crouch and jump are performed in quick succession.
## The player can crouch before jumping or jump before crouching, but both actions
## must happen within a short time window (combo_window).

## Whether this ability is enabled
@export var enabled: bool = true

## Jump height multiplier when crouch+jump combo is detected
@export var jump_boost: float = 1.5

## Time window (in seconds) to perform both actions for the combo
@export var combo_window: float = 1.0

## Reference to the jump ability to monitor jump events
var _jump_ability: JumpAbility3D

## Reference to the crouch ability to monitor crouch events
var _crouch_ability: CrouchAbility3D

## Timestamp of when jump was activated
var _jump_time: float = -1.0

## Timestamp of when crouch was activated
var _crouch_time: float = -1.0

## Whether boost has been applied for current jump (prevents cumulative boosts)
var _boost_applied: bool = false

## Whether player was on floor in previous frame
var _was_on_floor: bool = true

## Initialize references (called from character controller)
func setup(jump_ability_ref: JumpAbility3D, crouch_ability_ref: CrouchAbility3D) -> void:
_jump_ability = jump_ability_ref
_crouch_ability = crouch_ability_ref

## Apply jump boost if combo is detected
func apply(velocity: Vector3, speed: float, is_on_floor: bool, direction: Vector3, delta: float) -> Vector3:
if not enabled:
return velocity

# Reset boost flag when landing
if is_on_floor and not _was_on_floor:
_boost_applied = false
_jump_time = -1.0
_crouch_time = -1.0

_was_on_floor = is_on_floor

# Check for combo and apply boost (only once per jump, when ascending)
if not _boost_applied and not is_on_floor and velocity.y > 0:
if _check_combo():
velocity.y *= jump_boost
_boost_applied = true

return velocity

## Checks if crouch+jump combo happened
## Returns true if combo conditions are met
func _check_combo() -> bool:
if _jump_time < 0 or _crouch_time < 0:
return false

var time_diff = abs(_jump_time - _crouch_time)
return time_diff <= combo_window

## Called when jump signal is emitted (from character controller)
func on_jumped() -> void:
if not enabled:
return
_jump_time = Time.get_ticks_msec() / 1000.0
# Check if crouch already happened within window
if _crouch_time >= 0:
var time_diff = abs(_jump_time - _crouch_time)
if time_diff <= combo_window:
# Combo detected, will be applied in apply() when velocity.y > 0
pass

## Called when crouch signal is emitted (from character controller)
func on_crouched() -> void:
if not enabled:
return
_crouch_time = Time.get_ticks_msec() / 1000.0
# Check if jump already happened within window
if _jump_time >= 0:
var time_diff = abs(_jump_time - _crouch_time)
if time_diff <= combo_window:
# Combo detected, will be applied in apply() when velocity.y > 0
pass

## Called when landed signal is emitted (from character controller)
func on_landed() -> void:
_boost_applied = false
_jump_time = -1.0
_crouch_time = -1.0
18 changes: 18 additions & 0 deletions core/character_controller_3d.gd
Original file line number Diff line number Diff line change
Expand Up @@ -188,6 +188,12 @@ var _direction_base_node : Node3D
## Swimming ability.
@onready var swim_ability: SwimAbility3D = get_node(NodePath("Swim Ability 3D"))

## Ability that allows climbing steps/stairs.
@onready var climb_step_ability: ClimbStepAbility3D = get_node(NodePath("Climb Step Ability 3D"))

## Ability that boosts jump height when crouch+jump combo is performed.
@onready var crouch_jump_boost_ability: CrouchJumpBoostAbility3D = get_node(NodePath("Crouch Jump Boost Ability 3D"))

## Stores normal speed
@onready var _normal_speed : float = speed

Expand Down Expand Up @@ -221,6 +227,9 @@ func move(_delta: float, input_axis := Vector2.ZERO, input_jump := false, input_
walk_ability.set_active(not is_fly_mode() and not swim_ability.is_floating())
crouch_ability.set_active(input_crouch and is_on_floor() and not is_floating() and not is_submerged() and not is_fly_mode() or (crouch_ability.is_actived() and crouch_ability.head_check.is_colliding()))
sprint_ability.set_active(input_sprint and is_on_floor() and input_axis.y >= 0.5 and !is_crouching() and not is_fly_mode() and not swim_ability.is_floating() and not swim_ability.is_submerged())
var climb_step_active = not is_fly_mode() and not swim_ability.is_floating() and not swim_ability.is_submerged()
climb_step_ability.set_active(climb_step_active)
crouch_jump_boost_ability.set_active(true) # Always active, uses enabled flag internally

var multiplier = 1.0
for ability in _abilities:
Expand Down Expand Up @@ -299,6 +308,10 @@ func _connect_signals():
swim_ability.stopped_floating.connect(_on_swim_ability_stopped_floating.bind())
swim_ability.entered_the_water.connect(_on_swim_ability_entered_the_water.bind())
swim_ability.exit_the_water.connect(_on_swim_ability_exit_the_water.bind())
# Connect signals for crouch+jump boost ability
jumped.connect(crouch_jump_boost_ability.on_jumped.bind())
crouched.connect(crouch_jump_boost_ability.on_crouched.bind())
landed.connect(crouch_jump_boost_ability.on_landed.bind())


func _start_variables():
Expand All @@ -317,6 +330,11 @@ func _start_variables():
swim_ability.floating_height = floating_height
swim_ability.on_water_speed_multiplier = on_water_speed_multiplier
swim_ability.submerged_speed_multiplier = submerged_speed_multiplier
# Setup climb step ability references
climb_step_ability.character_body = self
climb_step_ability.collision_shape = collision
# Setup crouch jump boost ability references
crouch_jump_boost_ability.setup(jump_ability, crouch_ability)


func _check_landed():
Expand Down
18 changes: 17 additions & 1 deletion core/controller.tscn
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@
[ext_resource type="Script" uid="uid://bx7pp0eao8dne" path="res://addons/character-controller/abilities/jump_ability_3d.gd" id="10_abxar"]
[ext_resource type="Script" uid="uid://bhhgclxxaf853" path="res://addons/character-controller/abilities/fly_ability_3d.gd" id="11_obyn2"]
[ext_resource type="Script" uid="uid://brs53isg6clxe" path="res://addons/character-controller/abilities/swim_ability_3d.gd" id="12_3irqv"]
[ext_resource type="Script" path="res://addons/character-controller/abilities/climb_step_ability_3d.gd" id="13_climb"]
[ext_resource type="Script" path="res://addons/character-controller/abilities/crouch_jump_boost_ability_3d.gd" id="14_crouchjump"]

[sub_resource type="CapsuleShape3D" id="CapsuleShape3D_fn1rj"]

Expand All @@ -16,7 +18,7 @@ radius = 0.4
[node name="CharacterController3D" type="CharacterBody3D"]
floor_snap_length = 0.5
script = ExtResource("1_snllq")
abilities_path = Array[NodePath]([NodePath("Crouch Ability 3D"), NodePath("Sprint Ability 3D"), NodePath("Walk Ability 3D"), NodePath("Jump Ability 3D"), NodePath("Fly Ability 3D"), NodePath("Swim Ability 3D")])
abilities_path = Array[NodePath]([NodePath("Crouch Ability 3D"), NodePath("Sprint Ability 3D"), NodePath("Walk Ability 3D"), NodePath("Jump Ability 3D"), NodePath("Fly Ability 3D"), NodePath("Swim Ability 3D"), NodePath("Climb Step Ability 3D"), NodePath("Crouch Jump Boost Ability 3D")])

[node name="Collision" type="CollisionShape3D" parent="."]
shape = SubResource("CapsuleShape3D_fn1rj")
Expand Down Expand Up @@ -50,3 +52,17 @@ collision_mask = 8
hit_from_inside = true
collide_with_areas = true
collide_with_bodies = false

[node name="Climb Step Ability 3D" type="Node3D" parent="."]
script = ExtResource("13_climb")
step_size = 0.5
min_step_size = 0.15
check_distance = 0.3
climb_velocity_multiplier = 8.0
max_climb_velocity = 15.0

[node name="Crouch Jump Boost Ability 3D" type="Node3D" parent="."]
script = ExtResource("14_crouchjump")
enabled = true
jump_boost = 1.5
combo_window = 0.3