Feat/3 grapple fundamentals #7
@@ -478,6 +478,7 @@ func _build_hud() -> void:
|
||||
wp_style.content_margin_right = 16
|
||||
wp_style.content_margin_bottom = 12
|
||||
wp_panel.add_theme_stylebox_override("panel", wp_style)
|
||||
wp_panel.grow_horizontal = Control.GROW_DIRECTION_BEGIN
|
||||
canvas.add_child(wp_panel)
|
||||
|
||||
_weapon_label = Label.new()
|
||||
|
||||
@@ -8,6 +8,7 @@ var default_bindings = {
|
||||
"dash": {"type": "key", "code": KEY_SHIFT},
|
||||
"fire": {"type": "mouse", "code": MOUSE_BUTTON_LEFT},
|
||||
"fire_alt": {"type": "mouse", "code": MOUSE_BUTTON_RIGHT},
|
||||
"grapple": {"type": "mouse", "code": MOUSE_BUTTON_MIDDLE},
|
||||
"reload": {"type": "key", "code": KEY_R},
|
||||
"interact": {"type": "key", "code": KEY_E},
|
||||
"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_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 ──────────────────────────────────────────────────────────────────────
|
||||
@export var dash_speed: float = 10.0
|
||||
@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_crouch: 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 ────────────────────────────────────────────────────────────
|
||||
var wall_normal: Vector3 = Vector3.ZERO
|
||||
@@ -40,6 +49,12 @@ var is_crouched: bool = false
|
||||
|
||||
|
||||
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():
|
||||
if child is Node and child.name.begins_with("state_"):
|
||||
states[child.name.replace("state_", "")] = child
|
||||
@@ -72,6 +87,17 @@ func _physics_process(delta: float) -> void:
|
||||
chain_count = 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
|
||||
var want_crouch = input_crouch
|
||||
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)
|
||||
|
||||
|
||||
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:
|
||||
if chain_timer > 0.0 and chain_count > 0:
|
||||
chain_count += 1
|
||||
|
||||
@@ -20,10 +20,17 @@ var wind_player: AudioStreamPlayer
|
||||
var jump_player: AudioStreamPlayer
|
||||
var double_jump_player: AudioStreamPlayer
|
||||
|
||||
var grapple_shoot_player: AudioStreamPlayer
|
||||
var grapple_latch_player: AudioStreamPlayer
|
||||
var grapple_swing_player: AudioStreamPlayer
|
||||
|
||||
# UI
|
||||
var hit_marker: Control
|
||||
var _hit_marker_tween: Tween
|
||||
|
||||
# Grapple
|
||||
var grapple_rope: MeshInstance3D
|
||||
|
||||
|
||||
func _ready() -> void:
|
||||
var sm := _ensure_machine()
|
||||
@@ -42,6 +49,7 @@ func _ready() -> void:
|
||||
|
||||
_setup_audio()
|
||||
_setup_hit_marker()
|
||||
_setup_grapple()
|
||||
|
||||
set_process(true)
|
||||
set_physics_process(true)
|
||||
@@ -85,6 +93,24 @@ func _setup_audio() -> void:
|
||||
add_child(wind_player)
|
||||
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:
|
||||
hit_marker = Control.new()
|
||||
hit_marker.set_anchors_preset(Control.PRESET_CENTER)
|
||||
@@ -106,6 +132,22 @@ func _setup_hit_marker() -> void:
|
||||
rect.rotation = angle
|
||||
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
|
||||
|
||||
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_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
|
||||
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:
|
||||
if ev == "chain_updated":
|
||||
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)
|
||||
]
|
||||
}
|
||||
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]
|
||||
|
||||
|
||||
Reference in New Issue
Block a user