feat: added grapple mechanic

This commit is contained in:
DottsGit
2026-06-05 02:05:07 -04:00
parent 0912669759
commit ecefeb9f7e
8 changed files with 232 additions and 0 deletions
+1
View File
@@ -478,6 +478,7 @@ func _build_hud() -> void:
wp_style.content_margin_right = 16 wp_style.content_margin_right = 16
wp_style.content_margin_bottom = 12 wp_style.content_margin_bottom = 12
wp_panel.add_theme_stylebox_override("panel", wp_style) wp_panel.add_theme_stylebox_override("panel", wp_style)
wp_panel.grow_horizontal = Control.GROW_DIRECTION_BEGIN
canvas.add_child(wp_panel) canvas.add_child(wp_panel)
_weapon_label = Label.new() _weapon_label = Label.new()
+1
View File
@@ -8,6 +8,7 @@ var default_bindings = {
"dash": {"type": "key", "code": KEY_SHIFT}, "dash": {"type": "key", "code": KEY_SHIFT},
"fire": {"type": "mouse", "code": MOUSE_BUTTON_LEFT}, "fire": {"type": "mouse", "code": MOUSE_BUTTON_LEFT},
"fire_alt": {"type": "mouse", "code": MOUSE_BUTTON_RIGHT}, "fire_alt": {"type": "mouse", "code": MOUSE_BUTTON_RIGHT},
"grapple": {"type": "mouse", "code": MOUSE_BUTTON_MIDDLE},
"reload": {"type": "key", "code": KEY_R}, "reload": {"type": "key", "code": KEY_R},
"interact": {"type": "key", "code": KEY_E}, "interact": {"type": "key", "code": KEY_E},
"move_forward": {"type": "key", "code": KEY_W}, "move_forward": {"type": "key", "code": KEY_W},
+7
View File
@@ -57,6 +57,13 @@ class_name MovementParams
@export var wall_cling_stamina_drain: float = 2.0 @export var wall_cling_stamina_drain: float = 2.0
@export var wall_cling_max_stamina: float = 2.0 @export var wall_cling_max_stamina: float = 2.0
# ── Grapple ───────────────────────────────────────────────────────────────────
@export var grapple_range: float = 30.0
@export var grapple_pull_force: float = 25.0
@export var grapple_jump_boost: float = 12.0
@export var grapple_spring_strength: float = 10.0
@export var grapple_air_control: float = 20.0
# ── Dash ────────────────────────────────────────────────────────────────────── # ── Dash ──────────────────────────────────────────────────────────────────────
@export var dash_speed: float = 10.0 @export var dash_speed: float = 10.0
@export var dash_duration: float = 0.25 @export var dash_duration: float = 0.25
+47
View File
@@ -20,6 +20,15 @@ var input_jump_pressed: bool = false
var input_jump_just_pressed: bool = false var input_jump_just_pressed: bool = false
var input_crouch: bool = false var input_crouch: bool = false
var input_dash: bool = false var input_dash: bool = false
var input_grapple: bool = false
var input_grapple_just_pressed: bool = false
# Grapple state
var grapple_point: Vector3 = Vector3.ZERO
var grapple_length: float = 0.0
var grapple_travel_time: float = 0.0
var grapple_shoot_time: float = 0.0
var is_grapple_shooting: bool = false
# ── State tracking ──────────────────────────────────────────────────────────── # ── State tracking ────────────────────────────────────────────────────────────
var wall_normal: Vector3 = Vector3.ZERO var wall_normal: Vector3 = Vector3.ZERO
@@ -40,6 +49,12 @@ var is_crouched: bool = false
func _ready() -> void: func _ready() -> void:
# Ensure grapple state is injected
var grapple_state = Node.new()
grapple_state.name = "state_grapple"
grapple_state.set_script(load("res://movement/states/state_grapple.gd"))
add_child(grapple_state)
for child in get_children(): for child in get_children():
if child is Node and child.name.begins_with("state_"): if child is Node and child.name.begins_with("state_"):
states[child.name.replace("state_", "")] = child states[child.name.replace("state_", "")] = child
@@ -72,6 +87,17 @@ func _physics_process(delta: float) -> void:
chain_count = 0 chain_count = 0
current_chain_bonus = 0.0 current_chain_bonus = 0.0
# Global grapple check: if just pressed, raycast from camera
if input_grapple_just_pressed and current_state != "grapple" and not is_grapple_shooting:
_try_start_grapple()
if is_grapple_shooting:
grapple_shoot_time += delta
if grapple_shoot_time >= grapple_travel_time:
is_grapple_shooting = false
movement_event.emit("grapple_latch", {})
switch_to("grapple")
# Manage global crouch state # Manage global crouch state
var want_crouch = input_crouch var want_crouch = input_crouch
if current_state == "slide": if current_state == "slide":
@@ -117,6 +143,27 @@ func switch_to(new_state_name: String, data: Dictionary = {}) -> void:
state_changed.emit(prev, new_state_name) state_changed.emit(prev, new_state_name)
func _try_start_grapple() -> void:
if not player or not player.camera:
return
var camera: Camera3D = player.camera
var space_state := player.get_world_3d().direct_space_state
var origin := camera.global_position
var end := origin - camera.global_transform.basis.z * params.grapple_range
var ray := PhysicsRayQueryParameters3D.create(origin, end)
ray.exclude = [player.get_rid()]
var hit := space_state.intersect_ray(ray)
if not hit.is_empty():
grapple_point = hit.position
grapple_length = origin.distance_to(grapple_point)
# Travel at 45 m/s
grapple_travel_time = grapple_length / 45
grapple_shoot_time = 0.0
is_grapple_shooting = true
movement_event.emit("grapple_shoot", {})
func register_chain_mechanic(_mechanic_name: String) -> void: func register_chain_mechanic(_mechanic_name: String) -> void:
if chain_timer > 0.0 and chain_count > 0: if chain_timer > 0.0 and chain_count > 0:
chain_count += 1 chain_count += 1
+84
View File
@@ -20,10 +20,17 @@ var wind_player: AudioStreamPlayer
var jump_player: AudioStreamPlayer var jump_player: AudioStreamPlayer
var double_jump_player: AudioStreamPlayer var double_jump_player: AudioStreamPlayer
var grapple_shoot_player: AudioStreamPlayer
var grapple_latch_player: AudioStreamPlayer
var grapple_swing_player: AudioStreamPlayer
# UI # UI
var hit_marker: Control var hit_marker: Control
var _hit_marker_tween: Tween var _hit_marker_tween: Tween
# Grapple
var grapple_rope: MeshInstance3D
func _ready() -> void: func _ready() -> void:
var sm := _ensure_machine() var sm := _ensure_machine()
@@ -42,6 +49,7 @@ func _ready() -> void:
_setup_audio() _setup_audio()
_setup_hit_marker() _setup_hit_marker()
_setup_grapple()
set_process(true) set_process(true)
set_physics_process(true) set_physics_process(true)
@@ -84,6 +92,24 @@ func _setup_audio() -> void:
wind_player.volume_db = -80.0 # Start silent wind_player.volume_db = -80.0 # Start silent
add_child(wind_player) add_child(wind_player)
wind_player.play() wind_player.play()
grapple_shoot_player = AudioStreamPlayer.new()
grapple_shoot_player.stream = load("res://assets/sounds/dash.wav")
grapple_shoot_player.volume_db = -5.0
grapple_shoot_player.pitch_scale = 1.5
add_child(grapple_shoot_player)
grapple_latch_player = AudioStreamPlayer.new()
grapple_latch_player.stream = load("res://assets/sounds/hit_confirm.wav")
grapple_latch_player.pitch_scale = 0.8
add_child(grapple_latch_player)
grapple_swing_player = AudioStreamPlayer.new()
grapple_swing_player.stream = load("res://assets/sounds/wind.wav")
grapple_swing_player.pitch_scale = 1.5
grapple_swing_player.volume_db = -80.0
add_child(grapple_swing_player)
grapple_swing_player.play()
func _setup_hit_marker() -> void: func _setup_hit_marker() -> void:
hit_marker = Control.new() hit_marker = Control.new()
@@ -106,6 +132,22 @@ func _setup_hit_marker() -> void:
rect.rotation = angle rect.rotation = angle
hit_marker.add_child(rect) hit_marker.add_child(rect)
func _setup_grapple() -> void:
grapple_rope = MeshInstance3D.new()
var rope_mesh = CylinderMesh.new()
rope_mesh.top_radius = 0.04
rope_mesh.bottom_radius = 0.04
rope_mesh.height = 1.0 # Will be scaled dynamically
var mat = StandardMaterial3D.new()
mat.albedo_color = Color(0.1, 0.1, 0.1) # Dark grey cable
mat.roughness = 0.8
rope_mesh.material = mat
grapple_rope.mesh = rope_mesh
grapple_rope.visible = false
get_tree().current_scene.call_deferred("add_child", grapple_rope)
var _last_hit_sound_time: int = 0 var _last_hit_sound_time: int = 0
func spawn_damage_number(amount: float, hit_pos: Vector3) -> void: func spawn_damage_number(amount: float, hit_pos: Vector3) -> void:
@@ -179,6 +221,44 @@ func _physics_process(_delta: float) -> void:
sm.input_jump_pressed = Input.is_action_pressed("jump") sm.input_jump_pressed = Input.is_action_pressed("jump")
sm.input_crouch = Input.is_action_pressed("crouch") sm.input_crouch = Input.is_action_pressed("crouch")
sm.input_dash = Input.is_action_just_pressed("dash") sm.input_dash = Input.is_action_just_pressed("dash")
sm.input_grapple = Input.is_action_pressed("grapple")
sm.input_grapple_just_pressed = Input.is_action_just_pressed("grapple")
# Update grapple rope visuals
if (sm.current_state == "grapple" or sm.is_grapple_shooting) and camera:
grapple_rope.visible = true
# For shoot, start at right side of camera slightly below and closer
var start_pos = camera.global_position + camera.global_transform.basis * Vector3(0.3, -0.3, -0.15)
var end_pos = sm.grapple_point
if sm.is_grapple_shooting and sm.grapple_travel_time > 0.0:
# Interpolate end_pos
var t = clampf(sm.grapple_shoot_time / sm.grapple_travel_time, 0.0, 1.0)
end_pos = start_pos.lerp(sm.grapple_point, t)
var dist = start_pos.distance_to(end_pos)
if dist > 0.01:
grapple_rope.global_position = (start_pos + end_pos) / 2.0
# Look at end point
var up_vec = Vector3.UP
var dir = (end_pos - start_pos).normalized()
if abs(dir.dot(up_vec)) > 0.99:
up_vec = Vector3.RIGHT
# Set rotation first, which resets scale
grapple_rope.transform.basis = Basis.looking_at(dir, up_vec) * Basis.from_euler(Vector3(PI/2.0, 0, 0))
# Then apply the scale
grapple_rope.scale = Vector3(1.0, dist, 1.0)
else:
if grapple_rope:
grapple_rope.visible = false
# Swing sound
if sm.current_state == "grapple" and sm.player.velocity.length() > 5.0:
grapple_swing_player.volume_db = lerpf(grapple_swing_player.volume_db, -15.0, _delta * 10.0)
else:
grapple_swing_player.volume_db = lerpf(grapple_swing_player.volume_db, -80.0, _delta * 15.0)
# Raw 2D input # Raw 2D input
var raw_input := Input.get_vector("move_left", "move_right", "move_forward", "move_back") var raw_input := Input.get_vector("move_left", "move_right", "move_forward", "move_back")
@@ -202,3 +282,7 @@ func _physics_process(_delta: float) -> void:
func _on_movement_event(ev: String, data: Dictionary) -> void: func _on_movement_event(ev: String, data: Dictionary) -> void:
if ev == "chain_updated": if ev == "chain_updated":
chain_updated.emit(data.count, data.bonus) chain_updated.emit(data.count, data.bonus)
elif ev == "grapple_shoot":
grapple_shoot_player.play()
elif ev == "grapple_latch":
grapple_latch_player.play()
+86
View File
@@ -0,0 +1,86 @@
extends Node
class_name StateGrapple
var machine: MovementStateMachine
var params: MovementParams:
get: return machine.params
func enter(_data: Dictionary = {}) -> void:
machine.on_ground = false
machine.wall_normal = Vector3.ZERO
machine.wall_side = 0.0
machine.register_chain_mechanic("grapple")
func exit() -> void:
pass
func update(delta: float) -> void:
var player := machine.player
var vel: Vector3 = player.velocity
# If player releases the button, disconnect
if not machine.input_grapple:
machine.switch_to("air")
return
var grapple_pos: Vector3 = machine.grapple_point
var current_dist: float = player.global_position.distance_to(grapple_pos)
var dir_to_point: Vector3 = (grapple_pos - player.global_position).normalized()
# Jump to detach and get a boost
if machine.input_jump_just_pressed and machine.jump_cooldown_timer <= 0.0:
vel.y = params.jump_velocity
# Add a directional boost if jumping while swinging
var h_look = machine.wish_dir_world
if h_look.length_squared() > 0.1:
var h_boost = h_look.normalized() * params.grapple_jump_boost
vel.x += h_boost.x
vel.z += h_boost.z
player.velocity = vel
machine.jump_cooldown_timer = params.jump_cooldown
machine.register_chain_mechanic("grapple_jump")
machine.switch_to("air")
if player.jump_player:
player.jump_player.play()
return
# Apply gravity
vel.y -= params.gravity * delta
# Apply slight inward pull (reeling in)
vel += dir_to_point * params.grapple_pull_force * delta
# Pendulum / Rope physics
# If the player tries to go further than the original rope length, pull them back aggressively
var max_len: float = machine.grapple_length
if current_dist > max_len:
# Project velocity onto the tangent of the sphere (prevent moving further away)
var radial_vel = vel.project(dir_to_point)
# If radial_vel is pointing AWAY from the grapple point (dot < 0), kill it
if radial_vel.dot(dir_to_point) < 0:
vel -= radial_vel
# Add a spring force to pull them back to the sphere
var diff = current_dist - max_len
var spring_force = dir_to_point * diff * params.grapple_spring_strength
vel += spring_force * delta
# Air control while swinging
var wish_dir: Vector3 = machine.wish_dir_world
if wish_dir.length_squared() > 0.01:
wish_dir = wish_dir.normalized()
# Add force tangentially to the rope
# We want to steer, but not pull away from or directly into the rope.
var tangent_wish = wish_dir - wish_dir.project(dir_to_point)
if tangent_wish.length_squared() > 0.01:
vel += tangent_wish.normalized() * params.grapple_air_control * delta
player.velocity = vel
player.move_and_slide()
# Remove ground detach so players can grapple along the floor
# if player.is_on_floor():
# machine.on_ground = true
# machine.switch_to("ground")
# return
+1
View File
@@ -0,0 +1 @@
uid://8uc46pvajxdf
+5
View File
@@ -83,6 +83,11 @@ reload={
"events": [Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":82,"physical_keycode":0,"key_label":0,"unicode":0,"location":0,"echo":false,"script":null) "events": [Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":82,"physical_keycode":0,"key_label":0,"unicode":0,"location":0,"echo":false,"script":null)
] ]
} }
grapple={
"deadzone": 0.0,
"events": [Object(InputEventMouseButton,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"button_mask":0,"position":Vector2(0, 0),"global_position":Vector2(0, 0),"factor":1.0,"button_index":3,"canceled":false,"pressed":false,"double_click":false,"script":null)
]
}
[physics] [physics]