class_name GrappleSystem extends RefCounted
## Grapple hook state machine. Manages raycast, rope, entity tracking.
## Instantiated by player_procedural.gd, called each physics frame.
const GrappleRopeClass := preload("res://scenes/player/procedural/grapple_rope.gd")
# Configuration
var max_range: float = 50.0
var max_grapple_speed: float = 25.0
var rope_particle_count: int = 10
var rope_damping: float = 0.98
var reel_speed: float = 5.0
# State
var active: bool = false
var rope: GrappleRope = null
var anchor_point: Vector3 = Vector3.ZERO
var anchor_entity: Node3D = null ## Non-null if grappled onto an entity
var swing_velocity: Vector3 = Vector3.ZERO ## Velocity at moment of release
func _init() -> void:
rope = GrappleRopeClass.new()
rope.reel_speed = reel_speed
rope.damping = rope_damping
## Try to fire grapple. Returns true if hit something.
func fire(from: Vector3, direction: Vector3, space: PhysicsDirectSpaceState3D,
exclude: Array[RID] = []) -> bool:
if active:
return false
var query := PhysicsRayQueryParameters3D.new()
query.from = from
query.to = from + direction * max_range
query.exclude = exclude
# Hit terrain (layer 1) and entities (all layers)
query.collision_mask = 0xFFFFFFFF
var result: Dictionary = space.intersect_ray(query)
if result.is_empty():
return false
anchor_point = result["position"]
# Check if we hit an entity (anything that isn't StaticBody3D = dynamic)
var collider: Object = result["collider"]
if collider is Node3D and not (collider is StaticBody3D):
anchor_entity = collider as Node3D
else:
anchor_entity = null
rope.start(anchor_point, from, rope_particle_count)
active = true
return true
## Release the grapple. Returns the velocity to carry.
func release() -> Vector3:
if not active:
return Vector3.ZERO
var carry_vel: Vector3 = swing_velocity
rope.stop()
active = false
anchor_entity = null
return carry_vel.limit_length(max_grapple_speed)
## Update each physics frame while grappled.
func update(delta: float, player_pos: Vector3, player_velocity: Vector3,
reel_in_pressed: bool = false, reel_out_pressed: bool = false) -> Vector3:
if not active:
return Vector3.ZERO
# Entity tracking — update anchor to entity position
if anchor_entity != null:
if is_instance_valid(anchor_entity):
anchor_point = anchor_entity.global_position
else:
# Entity died — auto-detach
return release()
# Reel in/out
if reel_in_pressed:
rope.reel_in(delta)
if reel_out_pressed:
rope.reel_out(delta)
# Update rope simulation
rope.update(delta, player_pos, anchor_point)
# The rope constraint force is the swing force on the player
swing_velocity = player_velocity + rope.player_constraint_force * delta
return rope.player_constraint_force
## Get rope points for rendering.
func get_rope_points() -> PackedVector3Array:
if not active:
return PackedVector3Array()
return rope.get_render_points()
func get_anchor_direction(from: Vector3) -> Vector3:
if not active:
return Vector3.ZERO
return (anchor_point - from).normalized()
func get_rope_length() -> float:
if not active or rope == null:
return 0.0
return rope.get_total_length()