feat: added grapple mechanic
This commit is contained in:
@@ -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()
|
||||||
|
|||||||
@@ -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},
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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)
|
||||||
@@ -85,6 +93,24 @@ func _setup_audio() -> void:
|
|||||||
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()
|
||||||
hit_marker.set_anchors_preset(Control.PRESET_CENTER)
|
hit_marker.set_anchors_preset(Control.PRESET_CENTER)
|
||||||
@@ -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:
|
||||||
@@ -180,6 +222,44 @@ func _physics_process(_delta: float) -> void:
|
|||||||
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()
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
uid://8uc46pvajxdf
|
||||||
@@ -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]
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user