OverviewFilesCommitsIssuesDocsReleasesPeers

hyper-tps / scenes / player / procedural / grapple_system.gd

116 lines · gd ref:
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()