- Added UI/UX specification for prop roles and set down mechanics in phase 17a. - Created `PropStageRoles` script to manage normalized behavior for props, including depth, motion, and collision settings. - Introduced `StagePhysicsPolicy` for shared physics configurations related to props and actors. - Developed comprehensive tests for prop roles, including default settings, legacy migration, serialization, and runtime behavior. - Ensured proper handling of set down actions, including clearance and orientation preservation.
1134 lines
51 KiB
GDScript
1134 lines
51 KiB
GDScript
class_name StickmanLocomotion
|
|
extends Node
|
|
## Owns navigation, walking/gait, terrain following, animated falls, and landings.
|
|
## StickmanRig schedules this component and exposes its public facade.
|
|
|
|
const ACTION_REGISTRY := preload("res://scripts/stage/action_registry.gd")
|
|
const STAGE_PHYSICS := preload("res://scripts/stage/stage_physics_policy.gd")
|
|
|
|
enum TerrainMotion { DISABLED, AIRBORNE, GROUNDED, LANDING_IMPACT }
|
|
|
|
const TERRAIN_PROBE_UP: float = 96.0
|
|
const TERRAIN_PROBE_DOWN: float = 700.0
|
|
const TERRAIN_NORMAL_MIN_Y: float = -0.15
|
|
const TERRAIN_SUPPORT_RANGE: float = 160.0
|
|
const TERRAIN_MAX_FOOT_SURFACE_DROP: float = 72.0
|
|
const TERRAIN_FOOT_GUARD_HEIGHT: float = 24.0
|
|
const TERRAIN_CENTER_PROBE_OFFSETS: Array[float] = [-24.0, 0.0, 24.0]
|
|
const TERRAIN_FOOT_PROBE_OFFSETS: Array[float] = [-4.0, 0.0, 4.0]
|
|
const TERRAIN_GRAVITY: float = 1400.0
|
|
const TERRAIN_MAX_FALL_SPEED: float = 1800.0
|
|
const RECOVERY_PROBE_START_OFFSET: float = 50.0
|
|
const RECOVERY_PROBE_END_OFFSET: float = 1500.0
|
|
const RECOVERY_PROBE_TOLERANCE: float = 200.0
|
|
const RECOVERY_PROBE_X_OFFSETS: Array[float] = [0.0, 25.0, -25.0, 50.0, -50.0, 100.0, -100.0]
|
|
const AIR_DRAG_X: float = 0.75
|
|
const AIR_POSE_START_SPEED: float = 240.0
|
|
const AIR_POSE_FULL_SPEED: float = 1200.0
|
|
const AIR_POSE_RESPONSE: float = 10.0
|
|
const AIR_HAND_FORWARD: float = 220.0
|
|
const AIR_HAND_FORWARD_START_SPEED: float = 80.0
|
|
const AIR_HAND_FORWARD_FULL_SPEED: float = 520.0
|
|
const AIR_HAND_LIFT: float = 680.0
|
|
const AIR_HAND_FLAIL_AMPLITUDE: Vector2 = Vector2(20.0, 16.0)
|
|
const AIR_FOOT_FLAIL_AMPLITUDE: Vector2 = Vector2(14.0, 10.0)
|
|
const AIR_FLAIL_HZ: float = 12.0
|
|
const HARD_LANDING_MIN_FALL_HEIGHT: float = 80.0
|
|
const HARD_LANDING_MAX_FALL_HEIGHT: float = 900.0
|
|
const LANDING_COMPRESSION_MIN: float = 32.0
|
|
const LANDING_COMPRESSION_MAX: float = 160.0
|
|
const LANDING_HAND_DROP_MIN: float = 270.0
|
|
const LANDING_HAND_DROP_MAX: float = 520.0
|
|
const LANDING_COMPRESS_DURATION: float = 0.08
|
|
const LANDING_RECOVER_DURATION: float = 0.22
|
|
const PELVIS_MAX_OFFSET: float = 240.0
|
|
const PELVIS_RESPONSE: float = 10.0
|
|
const FOOT_OFFSET: Vector2 = Vector2(0.0, -385.0)
|
|
const NAV_AGENT_LOCAL_POS: Vector2 = Vector2(0.0, 385.0)
|
|
const ARRIVE_DISTANCE: float = 8.0
|
|
const NAV_PATH_DESIRED_DISTANCE: float = 8.0
|
|
const NAV_TARGET_DESIRED_DISTANCE: float = 12.0
|
|
const LATCH_PROBE_MAX_FRAMES: int = 6
|
|
const WALK_CYCLE_DURATION: float = 0.8
|
|
const RUN_CYCLE_DURATION: float = 0.75
|
|
const RUN_RIGHT_CONTACT_START: float = 0.0
|
|
const RUN_LEFT_CONTACT_START: float = 0.375
|
|
const RUN_CONTACT_DURATION: float = 0.1875
|
|
const RUN_CONTACT_EDGE_FADE: float = 0.03125
|
|
const ARRIVAL_SETTLE_DURATION: float = 0.15
|
|
const LEFT_STANDING_ANIMATION: StringName = &"walk_left"
|
|
const RIGHT_STANDING_ANIMATION: StringName = &"walk_right"
|
|
const MOVE_STYLE_DATA: Dictionary = {
|
|
"walk": {"clip": "walk_right", "pace": 300.0, "cycle": WALK_CYCLE_DURATION, "run": false},
|
|
"run": {"clip": "run_right", "pace": 500.0, "cycle": RUN_CYCLE_DURATION, "run": true},
|
|
}
|
|
const MOVE_MARKER_NAMES: Array[String] = ["Torso", "Head", "Left_Hand", "Right_Hand", "Left_Leg", "Right_Leg"]
|
|
|
|
const HEAD_COLLISION_RADIUS: float = 90.0
|
|
const TORSO_COLLISION_RADIUS: float = 20.0
|
|
const ARM_COLLISION_RADIUS: float = 12.0
|
|
const LEG_COLLISION_RADIUS: float = 14.0
|
|
const HEAD_COLLISION_FALLBACK_CENTER: Vector2 = Vector2(0.0, -72.0)
|
|
const COLLISION_LAYER: int = 1
|
|
const WALK_BLOCKED_TIMEOUT: float = 0.45
|
|
const COLLISION_PARTS: Dictionary = {
|
|
"head": {"visual": "Head", "bone": "Torso/Head", "shape": "circle", "radius": HEAD_COLLISION_RADIUS},
|
|
"torso": {"visual": "Body", "bone": "Torso", "shape": "capsule", "radius": TORSO_COLLISION_RADIUS},
|
|
"left_upper_arm": {"visual": "LeftUpperArm", "bone": "Torso/LeftUpperArm", "shape": "capsule", "radius": ARM_COLLISION_RADIUS},
|
|
"left_lower_arm": {"visual": "LeftLowerArm", "bone": "Torso/LeftUpperArm/LeftLowerArm", "shape": "capsule", "radius": ARM_COLLISION_RADIUS},
|
|
"right_upper_arm": {"visual": "RightUpperArm", "bone": "Torso/RightUpperArm", "shape": "capsule", "radius": ARM_COLLISION_RADIUS},
|
|
"right_lower_arm": {"visual": "RightLowerArm", "bone": "Torso/RightUpperArm/RightLowerArm", "shape": "capsule", "radius": ARM_COLLISION_RADIUS},
|
|
"left_upper_leg": {"visual": "LeftUpperLeg", "bone": "Torso/LeftUpperLeg", "shape": "capsule", "radius": LEG_COLLISION_RADIUS},
|
|
"left_lower_leg": {"visual": "LeftLowerLeg", "bone": "Torso/LeftUpperLeg/LeftLowerLeg", "shape": "capsule", "radius": LEG_COLLISION_RADIUS},
|
|
"right_upper_leg": {"visual": "RightUpperLeg", "bone": "Torso/RightUpperLeg", "shape": "capsule", "radius": LEG_COLLISION_RADIUS},
|
|
"right_lower_leg": {"visual": "RightLowerLeg", "bone": "Torso/RightUpperLeg/RightLowerLeg", "shape": "capsule", "radius": LEG_COLLISION_RADIUS},
|
|
}
|
|
const REACTION_BASE_ANGLE: float = 0.35
|
|
const REACTION_REFERENCE_SPEED: float = 400.0
|
|
const REACTION_MAX_ANGLE: float = 0.6
|
|
const REACTION_LOCAL_X_EPSILON: float = 0.05
|
|
const REACTION_TARGETS: Dictionary = {
|
|
"head": [{"joint": "Head", "weight": 1.0, "decay": 0.50}],
|
|
"torso": [{"joint": "Torso", "weight": 0.5, "decay": 0.40}],
|
|
"left_upper_arm": [{"joint": "LeftUpperArm", "weight": 1.0, "decay": 0.40}],
|
|
"left_lower_arm": [{"joint": "LeftLowerArm", "weight": 1.0, "decay": 0.35}, {"joint": "LeftUpperArm", "weight": 0.4, "decay": 0.25}],
|
|
"right_upper_arm": [{"joint": "RightUpperArm", "weight": 1.0, "decay": 0.40}],
|
|
"right_lower_arm": [{"joint": "RightLowerArm", "weight": 1.0, "decay": 0.35}, {"joint": "RightUpperArm", "weight": 0.4, "decay": 0.25}],
|
|
"left_upper_leg": [{"joint": "LeftUpperLeg", "weight": 1.0, "decay": 0.40}],
|
|
"left_lower_leg": [{"joint": "LeftLowerLeg", "weight": 1.0, "decay": 0.35}, {"joint": "LeftUpperLeg", "weight": 0.4, "decay": 0.25}],
|
|
"right_upper_leg": [{"joint": "RightUpperLeg", "weight": 1.0, "decay": 0.40}],
|
|
"right_lower_leg": [{"joint": "RightLowerLeg", "weight": 1.0, "decay": 0.35}, {"joint": "RightUpperLeg", "weight": 0.4, "decay": 0.25}],
|
|
}
|
|
|
|
signal arrived(target: Vector2)
|
|
|
|
var _rig: StickmanRig = null
|
|
var foot_contacts: Dictionary = {"Left_Leg": {"valid": false, "locked": false}, "Right_Leg": {"valid": false, "locked": false}}
|
|
var terrain_pelvis_offset: float = 0.0
|
|
var terrain_air_velocity: Vector2 = Vector2.ZERO
|
|
var terrain_motion: TerrainMotion = TerrainMotion.DISABLED
|
|
var terrain_following_enabled: bool = false
|
|
var _air_time: float = 0.0
|
|
var air_pose_weight: float = 0.0
|
|
var air_pose_base: Dictionary = {}
|
|
var _air_entry_root_y: float = 0.0
|
|
var air_fall_distance: float = 0.0
|
|
var _landing_elapsed: float = 0.0
|
|
var landing_compression: float = 0.0
|
|
var _landing_hand_drop: float = 0.0
|
|
var _landing_torso_base: Vector2 = Vector2.ZERO
|
|
var _landing_hand_start: Dictionary = {}
|
|
var _landing_contacts: Dictionary = {}
|
|
var _terrain_paused_walk_animation: bool = false
|
|
var _nav_agent: NavigationAgent2D = null
|
|
var _nav_anchor: Node2D = null
|
|
var _walking: bool = false
|
|
var _settling: bool = false
|
|
var _settle_waiting_for_ground: bool = false
|
|
var _walk_target_feet: Vector2 = Vector2.ZERO
|
|
var _walk_speed_current: float = 300.0
|
|
var _active_move_style: String = "walk"
|
|
var _active_animation_name: StringName = &"walk_right"
|
|
var _active_cycle_duration: float = WALK_CYCLE_DURATION
|
|
var _walk_mode: String = "nav"
|
|
var _walk_mode_latched: bool = false
|
|
var _walk_latch_probe_frames: int = 0
|
|
var _walk_settle_elapsed: float = 0.0
|
|
var _walk_settle_start_pose: Dictionary = {}
|
|
var _walk_settle_end_pose: Dictionary = {}
|
|
var _walk_debug_settle_frames: int = 0
|
|
var _walk_debug_progress_elapsed: float = 0.0
|
|
var _arrival_emitted: bool = false
|
|
var _walk_done: bool = false
|
|
var _leg_base_markers: Dictionary = {}
|
|
var _last_grounded_walk_velocity_x: float = 0.0
|
|
var _blocked_walk_elapsed: float = 0.0
|
|
var _walk_failed: bool = false
|
|
var collision_bodies: Dictionary = {}
|
|
var collision_lengths: Dictionary = {}
|
|
var _collision_visuals: Dictionary = {}
|
|
var reaction_bones: Dictionary = {}
|
|
var impact_reactions: Dictionary = {}
|
|
|
|
func configure(rig: StickmanRig) -> void:
|
|
_rig = rig
|
|
|
|
for leg_name: String in ["Left_Leg", "Right_Leg"]:
|
|
var base: Marker2D = _rig.get_node_or_null(NodePath("AnimationTargets/%s_Base" % leg_name)) as Marker2D
|
|
if base == null:
|
|
push_warning("StickmanLocomotion: missing AnimationTargets/%s_Base." % leg_name)
|
|
else:
|
|
_leg_base_markers[leg_name] = base
|
|
_nav_anchor = Node2D.new()
|
|
_nav_anchor.name = "NavigationAgentAnchor"
|
|
_nav_anchor.position = NAV_AGENT_LOCAL_POS
|
|
_rig.add_child(_nav_anchor)
|
|
_nav_agent = NavigationAgent2D.new()
|
|
_nav_agent.name = "NavigationAgent2D"
|
|
_nav_agent.path_desired_distance = NAV_PATH_DESIRED_DISTANCE
|
|
_nav_agent.target_desired_distance = NAV_TARGET_DESIRED_DISTANCE
|
|
_nav_agent.path_max_distance = 100.0
|
|
_nav_agent.max_speed = _rig.walk_speed
|
|
_nav_agent.avoidance_enabled = false
|
|
_nav_anchor.add_child(_nav_agent)
|
|
|
|
|
|
func setup_collision_bodies() -> void:
|
|
collision_bodies.clear()
|
|
collision_lengths.clear()
|
|
_collision_visuals.clear()
|
|
reaction_bones.clear()
|
|
if _rig == null or _rig._body_container == null or _rig._skeleton == null:
|
|
return
|
|
for key: String in COLLISION_PARTS:
|
|
var definition: Dictionary = COLLISION_PARTS[key]
|
|
var bone: Bone2D = _rig._skeleton.get_node_or_null(NodePath(str(definition["bone"]))) as Bone2D
|
|
if bone == null or not is_instance_valid(bone):
|
|
push_warning("StickmanLocomotion: missing reaction bone '%s'." % str(definition["bone"]))
|
|
continue
|
|
var joint_name: String = "Torso" if str(definition["visual"]) == "Body" else str(definition["visual"])
|
|
reaction_bones[joint_name] = bone
|
|
var visual: Node2D = _rig._body_container.get_node_or_null(NodePath(str(definition["visual"]))) as Node2D
|
|
if visual == null:
|
|
push_warning("StickmanLocomotion: missing collision visual for '%s'." % key)
|
|
continue
|
|
var proxy := AnimatableBody2D.new()
|
|
proxy.name = "CollisionBody"
|
|
proxy.collision_layer = STAGE_PHYSICS.ACTOR_LAYER
|
|
proxy.collision_mask = STAGE_PHYSICS.actor_mask()
|
|
proxy.sync_to_physics = false
|
|
proxy.set_meta("stickman_rig", _rig)
|
|
proxy.set_meta("part_name", key)
|
|
var shape_node := CollisionShape2D.new()
|
|
shape_node.name = "CollisionShape2D"
|
|
if str(definition["shape"]) == "circle":
|
|
var circle := CircleShape2D.new()
|
|
var bounds: Dictionary = mounted_visual_bounds_local(visual)
|
|
if bool(bounds["valid"]):
|
|
var rect: Rect2 = bounds["rect"] as Rect2
|
|
shape_node.position = rect.get_center()
|
|
circle.radius = maxf(float(definition["radius"]), maxf(rect.size.x, rect.size.y) * 0.5)
|
|
else:
|
|
shape_node.position = HEAD_COLLISION_FALLBACK_CENTER
|
|
circle.radius = float(definition["radius"])
|
|
shape_node.shape = circle
|
|
else:
|
|
var length: float = absf(_rig._head_bone.position.y) if key == "torso" and _rig._head_bone != null else bone.length
|
|
collision_lengths[key] = length
|
|
var capsule := CapsuleShape2D.new()
|
|
capsule.height = length
|
|
capsule.radius = float(definition["radius"])
|
|
shape_node.shape = capsule
|
|
shape_node.position = Vector2(0.0, length * 0.5)
|
|
proxy.add_child(shape_node)
|
|
visual.add_child(proxy)
|
|
proxy.top_level = true
|
|
proxy.global_transform = visual.global_transform
|
|
collision_bodies[key] = proxy
|
|
_collision_visuals[key] = visual
|
|
|
|
|
|
func sync_collision_bodies() -> void:
|
|
for key: String in collision_bodies:
|
|
var proxy: AnimatableBody2D = collision_bodies.get(key) as AnimatableBody2D
|
|
var visual: Node2D = _collision_visuals.get(key) as Node2D
|
|
if proxy != null and visual != null and is_instance_valid(proxy) and is_instance_valid(visual):
|
|
proxy.global_transform = visual.global_transform
|
|
|
|
|
|
func mounted_visual_bounds_local(part: Node2D) -> Dictionary:
|
|
var points: Array[Vector2] = []
|
|
var nodes: Array[Node] = [part]
|
|
while not nodes.is_empty():
|
|
var child: Node = nodes.pop_back()
|
|
if child is Line2D:
|
|
for point: Vector2 in (child as Line2D).points:
|
|
points.append(part.to_local((child as Node2D).to_global(point)))
|
|
elif child is Polygon2D:
|
|
for point: Vector2 in (child as Polygon2D).polygon:
|
|
points.append(part.to_local((child as Node2D).to_global(point)))
|
|
for grandchild: Node in child.get_children():
|
|
nodes.append(grandchild)
|
|
if points.is_empty():
|
|
return {"valid": false, "rect": Rect2()}
|
|
var rect := Rect2(points[0], Vector2.ZERO)
|
|
for point: Vector2 in points:
|
|
rect = rect.expand(point)
|
|
return {"valid": true, "rect": rect}
|
|
|
|
|
|
func set_collision_bodies_enabled(enabled: bool) -> void:
|
|
var layer: int = STAGE_PHYSICS.ACTOR_LAYER if enabled else 0
|
|
var mask: int = STAGE_PHYSICS.actor_mask() if enabled else 0
|
|
for proxy: AnimatableBody2D in collision_bodies.values():
|
|
if proxy != null and is_instance_valid(proxy):
|
|
proxy.collision_layer = layer
|
|
proxy.collision_mask = mask
|
|
|
|
|
|
func apply_collision_reaction(part_name: String, impact_direction: Vector2, impact_speed: float, impact_position: Vector2 = Vector2.INF) -> void:
|
|
if _rig == null or _rig.state != StickmanRig.RigState.ANIMATED or impact_direction.is_zero_approx() or impact_speed <= 0.0:
|
|
return
|
|
for target: Dictionary in REACTION_TARGETS.get(part_name, []):
|
|
var joint: String = str(target["joint"])
|
|
var bone: Bone2D = reaction_bones.get(joint) as Bone2D
|
|
if bone == null or not is_instance_valid(bone):
|
|
continue
|
|
var direction: float = collision_contact_direction(part_name, impact_position, impact_direction)
|
|
if is_zero_approx(direction):
|
|
var local_dir: Vector2 = bone.global_transform.affine_inverse().basis_xform(impact_direction).normalized()
|
|
direction = signf(local_dir.x) if absf(local_dir.x) > REACTION_LOCAL_X_EPSILON else 0.0
|
|
if is_zero_approx(direction): direction = -1.0 if joint.begins_with("Left") else 1.0
|
|
var angle: float = direction * minf(REACTION_MAX_ANGLE, REACTION_BASE_ANGLE * impact_speed / REACTION_REFERENCE_SPEED) * float(target["weight"])
|
|
var old: Dictionary = impact_reactions.get(joint, {})
|
|
if old.is_empty():
|
|
impact_reactions[joint] = {"angle": angle, "strength": 1.0, "decay": float(target["decay"]), "base_rotation": bone.rotation}
|
|
else:
|
|
if absf(angle) >= absf(float(old.get("angle", 0.0))):
|
|
old["angle"] = angle
|
|
old["decay"] = float(target["decay"])
|
|
old["strength"] = 1.0
|
|
impact_reactions[joint] = old
|
|
|
|
|
|
func collision_contact_direction(part_name: String, impact_position: Vector2, impact_direction: Vector2) -> float:
|
|
if not impact_position.is_finite(): return 0.0
|
|
var proxy: AnimatableBody2D = collision_bodies.get(part_name) as AnimatableBody2D
|
|
if proxy == null or not is_instance_valid(proxy): return 0.0
|
|
var shape_node: CollisionShape2D = proxy.get_node_or_null(NodePath("CollisionShape2D")) as CollisionShape2D
|
|
if shape_node == null: return 0.0
|
|
var side: float = proxy.to_local(impact_position).x - shape_node.position.x
|
|
if absf(side) <= REACTION_LOCAL_X_EPSILON: return 0.0
|
|
var local_force: Vector2 = proxy.global_transform.affine_inverse().basis_xform(impact_direction).normalized()
|
|
return signf(side * local_force.y) if absf(local_force.y) > REACTION_LOCAL_X_EPSILON else signf(side)
|
|
|
|
|
|
func clear_impact_reactions(restore_bones: bool = false) -> void:
|
|
if restore_bones:
|
|
for joint: String in impact_reactions:
|
|
var reaction: Dictionary = impact_reactions[joint]
|
|
var bone: Bone2D = reaction_bones.get(joint) as Bone2D
|
|
if bone != null and is_instance_valid(bone) and reaction.has("base_rotation"):
|
|
bone.rotation = float(reaction["base_rotation"])
|
|
impact_reactions.clear()
|
|
|
|
|
|
func apply_impact_reactions(delta: float) -> void:
|
|
if _rig == null or _rig.state != StickmanRig.RigState.ANIMATED:
|
|
clear_impact_reactions()
|
|
return
|
|
for joint: String in impact_reactions.keys().duplicate():
|
|
var reaction: Dictionary = impact_reactions[joint]
|
|
var bone: Bone2D = reaction_bones.get(joint) as Bone2D
|
|
if bone == null or not is_instance_valid(bone):
|
|
impact_reactions.erase(joint)
|
|
continue
|
|
var base_rotation: float = float(reaction.get("base_rotation", bone.rotation))
|
|
bone.rotation = base_rotation + float(reaction["angle"]) * float(reaction["strength"])
|
|
reaction["strength"] = float(reaction["strength"]) - delta / maxf(float(reaction["decay"]), 0.001)
|
|
if float(reaction["strength"]) <= 0.0:
|
|
bone.rotation = base_rotation
|
|
impact_reactions.erase(joint)
|
|
else:
|
|
impact_reactions[joint] = reaction
|
|
|
|
func tick_movement(delta: float, _rig_state: int) -> void:
|
|
if _rig == null:
|
|
return
|
|
terrain_pelvis_offset = 0.0
|
|
if terrain_following_enabled and terrain_motion == TerrainMotion.GROUNDED:
|
|
_last_grounded_walk_velocity_x = 0.0
|
|
_update_walking(delta)
|
|
_settle_walk_markers(delta)
|
|
_update_terrain_following(delta)
|
|
_resume_settle_after_terrain()
|
|
if _walk_debug_enabled() and _walking:
|
|
_walk_debug_progress_elapsed += delta
|
|
if _walk_debug_progress_elapsed >= 1.0:
|
|
_walk_debug_progress_elapsed = 0.0
|
|
_debug_walk_pose("RUN_PROGRESS terrain=%s" % TerrainMotion.keys()[int(terrain_motion)])
|
|
|
|
func tick_post_skeleton(delta: float, _rig_state: int) -> void:
|
|
if _rig != null:
|
|
apply_impact_reactions(delta)
|
|
sync_collision_bodies()
|
|
if _walk_debug_settle_frames > 0:
|
|
_walk_debug_settle_frames -= 1
|
|
if _walk_debug_settle_frames == 0:
|
|
_debug_walk_pose("SETTLED")
|
|
|
|
func walk_to(target: Vector2, speed: float = -1.0) -> bool:
|
|
return move_to(target, "walk", speed)
|
|
|
|
|
|
func move_to(target: Vector2, style: String = ACTION_REGISTRY.WALK, speed_override: float = -1.0) -> bool:
|
|
if _walk_debug_enabled():
|
|
print("[WalkDebug] MOVE_TO style=%s target=%s root=%s" % [style, target, _rig.global_position if _rig != null else Vector2.ZERO])
|
|
if _rig == null or _rig.state != StickmanRig.RigState.ANIMATED:
|
|
push_warning("StickmanRig: Move To ignored; not ANIMATED.")
|
|
return false
|
|
if _rig._is_action_queue_blocked():
|
|
push_warning("StickmanRig: Move To ignored during landing impact.")
|
|
return false
|
|
if _nav_agent == null or _nav_anchor == null:
|
|
push_warning("StickmanLocomotion: Move To ignored; navigation is unavailable.")
|
|
return false
|
|
var checked_style: String = style if MOVE_STYLE_DATA.has(style) else ACTION_REGISTRY.WALK
|
|
var style_data: Dictionary = MOVE_STYLE_DATA[checked_style]
|
|
_active_move_style = checked_style
|
|
_active_animation_name = StringName(style_data.get("clip", "walk_right"))
|
|
_active_cycle_duration = float(style_data.get("cycle", WALK_CYCLE_DURATION))
|
|
_walk_target_feet = target
|
|
var default_pace: float = _rig.walk_speed if checked_style == "walk" else float(style_data.get("pace", 500.0))
|
|
_walk_speed_current = speed_override if speed_override > 0.0 else default_pace
|
|
_nav_agent.max_speed = _walk_speed_current
|
|
_walk_done = false
|
|
_walk_failed = false
|
|
_blocked_walk_elapsed = 0.0
|
|
_arrival_emitted = false
|
|
_settling = false
|
|
_settle_waiting_for_ground = false
|
|
_walk_settle_elapsed = 0.0
|
|
_walk_settle_start_pose.clear()
|
|
_walk_settle_end_pose.clear()
|
|
_reset_foot_contacts()
|
|
var target_root: Vector2 = target + FOOT_OFFSET
|
|
if (target - (_rig.global_position - FOOT_OFFSET)).length() <= ARRIVE_DISTANCE:
|
|
var close_motion: Vector2 = target_root - _rig.global_position
|
|
var close_sweep: Dictionary = _sweep_actor_blocking_props(close_motion)
|
|
if float(close_sweep.get("fraction", 1.0)) < 0.999:
|
|
return false
|
|
_rig.global_position = target_root
|
|
_walking = false
|
|
_walk_done = true
|
|
_arrival_emitted = true
|
|
arrived.emit(_walk_target_feet)
|
|
return true
|
|
_nav_agent.target_position = target
|
|
var dx: float = target.x - _rig.global_position.x
|
|
if dx < -0.5:
|
|
_rig.set_facing_profile(StickmanRig.FacingProfile.LEFT)
|
|
elif dx > 0.5:
|
|
_rig.set_facing_profile(StickmanRig.FacingProfile.RIGHT)
|
|
_rig._set_limb_mods_enabled(true)
|
|
if _rig._anim_player != null and is_instance_valid(_rig._anim_player) and _rig._anim_player.has_animation(_active_animation_name):
|
|
_rig._anim_player.play(_active_animation_name)
|
|
_walking = true
|
|
_walk_mode = "nav"
|
|
_walk_mode_latched = false
|
|
_walk_latch_probe_frames = 0
|
|
return true
|
|
|
|
func cancel_walk(restore_idle_pose: bool = true) -> void:
|
|
if _rig == null:
|
|
return
|
|
_walk_failed = false
|
|
_blocked_walk_elapsed = 0.0
|
|
var current_limb_rotations: Dictionary = _rig._capture_limb_rotations()
|
|
if _rig._anim_player != null and is_instance_valid(_rig._anim_player):
|
|
_rig._anim_player.stop(false)
|
|
if _nav_agent != null and _nav_anchor != null:
|
|
_nav_agent.target_position = _nav_anchor.global_position
|
|
_walking = false
|
|
_settling = false
|
|
_settle_waiting_for_ground = false
|
|
_walk_done = false
|
|
_walk_mode_latched = false
|
|
_walk_latch_probe_frames = 0
|
|
_walk_settle_elapsed = 0.0
|
|
_walk_settle_start_pose.clear()
|
|
_walk_settle_end_pose.clear()
|
|
_reset_foot_contacts()
|
|
if restore_idle_pose:
|
|
_rig._apply_idle_limb_solver_mode(current_limb_rotations)
|
|
|
|
func is_walking() -> bool:
|
|
return _walking or _settling
|
|
|
|
|
|
func ensure_grounded_walk_animation() -> void:
|
|
if _rig == null or _rig.state != StickmanRig.RigState.ANIMATED or not _walking:
|
|
return
|
|
if terrain_motion != TerrainMotion.GROUNDED or blocks_walking():
|
|
return
|
|
if _rig._anim_player == null or not is_instance_valid(_rig._anim_player) or not _rig._anim_player.has_animation(_active_animation_name):
|
|
return
|
|
if _terrain_paused_walk_animation:
|
|
if _rig._anim_player.is_playing():
|
|
_terrain_paused_walk_animation = false
|
|
else:
|
|
_resume_walk_animation()
|
|
elif not _rig._anim_player.is_playing():
|
|
_rig._anim_player.play(_active_animation_name)
|
|
|
|
func get_foot_offset() -> Vector2:
|
|
return FOOT_OFFSET
|
|
|
|
func consume_walk_completed() -> bool:
|
|
if not _walk_done:
|
|
return false
|
|
_walk_done = false
|
|
return true
|
|
|
|
func consume_walk_failed() -> bool:
|
|
if not _walk_failed:
|
|
return false
|
|
_walk_failed = false
|
|
return true
|
|
|
|
func _update_walking(delta: float) -> void:
|
|
if not _walking:
|
|
return
|
|
if _rig.state != StickmanRig.RigState.ANIMATED:
|
|
cancel_walk(false)
|
|
return
|
|
if blocks_walking() or _nav_agent == null:
|
|
return
|
|
if NavigationServer2D.map_get_iteration_id(_nav_agent.get_navigation_map()) == 0:
|
|
return
|
|
if not _walk_mode_latched:
|
|
if _walk_latch_probe_frames < LATCH_PROBE_MAX_FRAMES:
|
|
_walk_latch_probe_frames += 1
|
|
_nav_agent.get_next_path_position()
|
|
if _nav_agent.is_target_reachable():
|
|
_walk_mode_latched = true
|
|
_walk_mode = "nav"
|
|
else:
|
|
return
|
|
else:
|
|
_walk_mode_latched = true
|
|
_walk_mode = "direct"
|
|
var next_feet: Vector2 = _nav_agent.get_next_path_position()
|
|
var final_root: Vector2 = _walk_target_feet + FOOT_OFFSET
|
|
var dist_to_final: float = absf(_rig.global_position.x - final_root.x)
|
|
var root_target: Vector2
|
|
if _walk_mode == "nav":
|
|
if _nav_agent.is_navigation_finished() and dist_to_final <= 2.0 * ARRIVE_DISTANCE:
|
|
var snap_motion := Vector2(final_root.x - _rig.global_position.x, 0.0)
|
|
var snap_sweep: Dictionary = _sweep_actor_blocking_props(snap_motion)
|
|
if float(snap_sweep.get("fraction", 1.0)) < 0.999:
|
|
_update_blocked_walk(delta)
|
|
return
|
|
_blocked_walk_elapsed = 0.0
|
|
_rig.global_position.x = final_root.x
|
|
_finish_walk()
|
|
return
|
|
# A freshly synchronized agent may yield its own feet as the first
|
|
# waypoint. Treat that zero-progress waypoint as the final segment.
|
|
var next_progress: float = next_feet.x - _rig.global_position.x
|
|
var remaining_progress: float = final_root.x - _rig.global_position.x
|
|
if dist_to_final <= 2.0 * ARRIVE_DISTANCE or remaining_progress * next_progress <= 0.0:
|
|
root_target = Vector2(final_root.x, _rig.global_position.y)
|
|
else:
|
|
root_target = Vector2(next_feet.x, _rig.global_position.y)
|
|
else:
|
|
root_target = Vector2(final_root.x, _rig.global_position.y)
|
|
var before_x: float = _rig.global_position.x
|
|
var requested_motion := _rig.global_position.move_toward(root_target, _walk_speed_current * delta) - _rig.global_position
|
|
var sweep: Dictionary = _sweep_actor_blocking_props(requested_motion)
|
|
var safe_fraction: float = float(sweep.get("fraction", 1.0))
|
|
_rig.global_position += requested_motion * safe_fraction
|
|
if safe_fraction < 0.999:
|
|
_update_blocked_walk(delta)
|
|
else:
|
|
_blocked_walk_elapsed = 0.0
|
|
_last_grounded_walk_velocity_x = (_rig.global_position.x - before_x) / delta if delta > 0.0 else 0.0
|
|
if _walk_failed:
|
|
return
|
|
if dist_to_final <= ARRIVE_DISTANCE or absf(_rig.global_position.x - final_root.x) <= ARRIVE_DISTANCE:
|
|
var arrival_motion := Vector2(final_root.x - _rig.global_position.x, 0.0)
|
|
var arrival_sweep: Dictionary = _sweep_actor_blocking_props(arrival_motion)
|
|
if float(arrival_sweep.get("fraction", 1.0)) < 0.999:
|
|
_update_blocked_walk(delta)
|
|
return
|
|
_rig.global_position.x = final_root.x
|
|
_finish_walk()
|
|
|
|
|
|
func _update_blocked_walk(delta: float) -> void:
|
|
_blocked_walk_elapsed += delta
|
|
if _blocked_walk_elapsed < WALK_BLOCKED_TIMEOUT:
|
|
return
|
|
cancel_walk(false)
|
|
_walk_failed = true
|
|
_blocked_walk_elapsed = 0.0
|
|
|
|
|
|
func _sweep_actor_blocking_props(motion: Vector2) -> Dictionary:
|
|
if _rig == null or is_zero_approx(motion.x):
|
|
return {"fraction": 1.0}
|
|
var space_state: PhysicsDirectSpaceState2D = _rig.get_world_2d().direct_space_state
|
|
if space_state == null:
|
|
return {"fraction": 1.0}
|
|
var excluded: Array[RID] = []
|
|
for own_proxy: AnimatableBody2D in collision_bodies.values():
|
|
if own_proxy != null and is_instance_valid(own_proxy):
|
|
excluded.append(own_proxy.get_rid())
|
|
var held: PropBlock = _rig.get_held_prop() if _rig.has_method("get_held_prop") else null
|
|
if held != null and is_instance_valid(held):
|
|
excluded.append(held.get_rid())
|
|
var safe_fraction: float = 1.0
|
|
for part_name: String in ["torso", "head"]:
|
|
var proxy: AnimatableBody2D = collision_bodies.get(part_name) as AnimatableBody2D
|
|
if proxy == null or not is_instance_valid(proxy):
|
|
continue
|
|
var shape_node := proxy.get_node_or_null(NodePath("CollisionShape2D")) as CollisionShape2D
|
|
if shape_node == null or shape_node.shape == null:
|
|
continue
|
|
var query := PhysicsShapeQueryParameters2D.new()
|
|
query.shape = shape_node.shape
|
|
query.transform = shape_node.global_transform
|
|
query.motion = motion
|
|
query.collision_mask = STAGE_PHYSICS.ACTOR_BLOCKING_PROP_LAYER
|
|
query.exclude = excluded
|
|
var cast: PackedFloat32Array = space_state.cast_motion(query)
|
|
if cast.size() >= 1:
|
|
safe_fraction = minf(safe_fraction, cast[0])
|
|
return {"fraction": safe_fraction}
|
|
|
|
func _settle_walk_markers(delta: float) -> void:
|
|
if not _settling or _rig == null:
|
|
return
|
|
if _rig.state != StickmanRig.RigState.ANIMATED:
|
|
cancel_walk(false)
|
|
return
|
|
if terrain_following_enabled and terrain_motion != TerrainMotion.GROUNDED:
|
|
_settle_waiting_for_ground = true
|
|
return
|
|
_walk_settle_elapsed = minf(_walk_settle_elapsed + delta, ARRIVAL_SETTLE_DURATION)
|
|
var weight: float = smoothstep(0.0, ARRIVAL_SETTLE_DURATION, _walk_settle_elapsed)
|
|
_apply_walk_settle_pose(weight)
|
|
if _walk_settle_elapsed >= ARRIVAL_SETTLE_DURATION:
|
|
_settling = false
|
|
_settle_waiting_for_ground = false
|
|
_walk_settle_start_pose.clear()
|
|
_walk_settle_end_pose.clear()
|
|
var final_limb_rotations: Dictionary = _rig._capture_limb_rotations()
|
|
_rig._apply_idle_limb_solver_mode(final_limb_rotations)
|
|
_walk_debug_settle_frames = 2 if _walk_debug_enabled() else 0
|
|
_walk_done = true
|
|
|
|
func _finish_walk() -> void:
|
|
if not _walking or _arrival_emitted:
|
|
return
|
|
# Capture the evaluated cycle before stopping it so the settle can blend from
|
|
# the actual run pose rather than from the animation reset values.
|
|
_walk_settle_start_pose = _capture_walk_marker_pose()
|
|
_debug_walk_pose("ARRIVAL_RUN_POSE")
|
|
if _rig._anim_player != null and is_instance_valid(_rig._anim_player):
|
|
# Keep the evaluated run pose for the first frame of the settle blend.
|
|
_rig._anim_player.stop(true)
|
|
_walking = false
|
|
_settling = true
|
|
_settle_waiting_for_ground = false
|
|
_walk_settle_elapsed = 0.0
|
|
_arrival_emitted = true
|
|
_walk_done = false
|
|
_restore_walk_standing_pose()
|
|
_walk_settle_end_pose = _capture_walk_marker_pose()
|
|
_apply_walk_settle_pose(0.0)
|
|
if _walk_debug_enabled():
|
|
print("[WalkDebug] SETTLE_TARGETS start=%s end=%s" % [_walk_settle_start_pose, _walk_settle_end_pose])
|
|
_debug_walk_pose("SETTLE_BEGIN")
|
|
arrived.emit(_walk_target_feet)
|
|
|
|
|
|
func _capture_walk_marker_pose() -> Dictionary:
|
|
var pose: Dictionary = {}
|
|
for marker_name: String in MOVE_MARKER_NAMES:
|
|
var marker: Marker2D = _rig._get_ik_marker(marker_name)
|
|
if marker == null:
|
|
continue
|
|
pose[marker_name] = {"position": marker.position, "rotation": marker.rotation}
|
|
for leg_name: String in ["Left_Leg", "Right_Leg"]:
|
|
var base: Marker2D = _leg_base_markers.get(leg_name) as Marker2D
|
|
if base != null:
|
|
pose["base:" + leg_name] = {"position": base.position, "rotation": base.rotation}
|
|
return pose
|
|
|
|
|
|
func _apply_walk_settle_pose(weight: float) -> void:
|
|
for pose_key: String in _walk_settle_end_pose:
|
|
var marker: Marker2D
|
|
if pose_key.begins_with("base:"):
|
|
marker = _leg_base_markers.get(pose_key.trim_prefix("base:")) as Marker2D
|
|
else:
|
|
marker = _rig._get_ik_marker(pose_key)
|
|
var start: Dictionary = _walk_settle_start_pose.get(pose_key, {})
|
|
var finish: Dictionary = _walk_settle_end_pose.get(pose_key, {})
|
|
if marker == null or start.is_empty() or finish.is_empty():
|
|
continue
|
|
marker.position = (start["position"] as Vector2).lerp(finish["position"] as Vector2, weight)
|
|
marker.rotation = lerpf(float(start["rotation"]), float(finish["rotation"]), weight)
|
|
|
|
|
|
func _restore_walk_standing_pose() -> void:
|
|
_rig._restore_standing_markers()
|
|
var standing_pose: Dictionary = {}
|
|
var standing_animation: StringName = &""
|
|
if _rig.facing_profile == StickmanRig.FacingProfile.LEFT:
|
|
standing_animation = LEFT_STANDING_ANIMATION
|
|
elif _rig.facing_profile == StickmanRig.FacingProfile.RIGHT:
|
|
standing_animation = RIGHT_STANDING_ANIMATION
|
|
if not standing_animation.is_empty():
|
|
standing_pose = _sample_animation_first_frame(standing_animation)
|
|
_apply_walk_marker_pose(standing_pose)
|
|
for leg_name: String in ["Left_Leg", "Right_Leg"]:
|
|
var base: Marker2D = _leg_base_markers.get(leg_name) as Marker2D
|
|
var target: Marker2D = _rig._get_ik_marker(leg_name)
|
|
if base == null or target == null:
|
|
continue
|
|
var base_key := "base:" + leg_name
|
|
if standing_pose.has(base_key):
|
|
# The baked walk clip keys the animated base target. Seed the IK target
|
|
# from the same frame so it also works while terrain following is off.
|
|
target.position = base.position
|
|
else:
|
|
base.position = target.position
|
|
|
|
|
|
func _sample_animation_first_frame(animation_name: StringName) -> Dictionary:
|
|
var sampled: Dictionary = {}
|
|
if _rig == null or _rig._anim_player == null or not _rig._anim_player.has_animation(animation_name):
|
|
return sampled
|
|
var animation: Animation = _rig._anim_player.get_animation(animation_name)
|
|
for track_idx: int in animation.get_track_count():
|
|
var track_path := String(animation.track_get_path(track_idx))
|
|
var separator_idx := track_path.rfind(":")
|
|
if separator_idx < 0:
|
|
continue
|
|
var property_name := track_path.substr(separator_idx + 1)
|
|
if property_name != "position" and property_name != "rotation":
|
|
continue
|
|
var node_path := track_path.substr(0, separator_idx)
|
|
var pose_key := ""
|
|
if node_path.begins_with("IK_Targets/"):
|
|
pose_key = node_path.get_file()
|
|
elif node_path.begins_with("AnimationTargets/") and node_path.get_file().ends_with("_Base"):
|
|
pose_key = "base:" + node_path.get_file().trim_suffix("_Base")
|
|
if pose_key.is_empty():
|
|
continue
|
|
var key_count := animation.track_get_key_count(track_idx)
|
|
if key_count == 0:
|
|
continue
|
|
var first_key_idx := 0
|
|
for key_idx: int in range(1, key_count):
|
|
if animation.track_get_key_time(track_idx, key_idx) < animation.track_get_key_time(track_idx, first_key_idx):
|
|
first_key_idx = key_idx
|
|
var key_value: Variant = animation.track_get_key_value(track_idx, first_key_idx)
|
|
if not sampled.has(pose_key):
|
|
sampled[pose_key] = {}
|
|
var properties: Dictionary = sampled[pose_key]
|
|
properties[property_name] = key_value
|
|
return sampled
|
|
|
|
|
|
func _apply_walk_marker_pose(pose: Dictionary) -> void:
|
|
for pose_key: String in pose:
|
|
var marker: Marker2D
|
|
if pose_key.begins_with("base:"):
|
|
marker = _leg_base_markers.get(pose_key.trim_prefix("base:")) as Marker2D
|
|
else:
|
|
marker = _rig._get_ik_marker(pose_key)
|
|
if marker == null:
|
|
continue
|
|
var properties: Dictionary = pose[pose_key]
|
|
if properties.get("position") is Vector2:
|
|
marker.position = properties["position"]
|
|
if properties.get("rotation") is float or properties.get("rotation") is int:
|
|
marker.rotation = float(properties["rotation"])
|
|
|
|
|
|
func _resume_settle_after_terrain() -> void:
|
|
if not _settling or not _settle_waiting_for_ground or terrain_motion != TerrainMotion.GROUNDED:
|
|
return
|
|
_walk_settle_start_pose = _capture_walk_marker_pose()
|
|
_restore_walk_standing_pose()
|
|
_walk_settle_end_pose = _capture_walk_marker_pose()
|
|
_apply_walk_settle_pose(0.0)
|
|
_walk_settle_elapsed = 0.0
|
|
_settle_waiting_for_ground = false
|
|
if _walk_debug_enabled():
|
|
print("[WalkDebug] SETTLE_RESUMED start=%s end=%s" % [_walk_settle_start_pose, _walk_settle_end_pose])
|
|
|
|
|
|
func _walk_debug_enabled() -> bool:
|
|
return OS.get_environment("STICKMAN_WALK_DEBUG") == "1"
|
|
|
|
|
|
func _debug_walk_pose(label: String) -> void:
|
|
if not _walk_debug_enabled() or _rig == null:
|
|
return
|
|
var markers: Dictionary = {}
|
|
for marker_name: String in MOVE_MARKER_NAMES:
|
|
var marker: Marker2D = _rig._get_ik_marker(marker_name)
|
|
if marker != null:
|
|
markers[marker_name] = marker.position
|
|
var bases: Dictionary = {}
|
|
for leg_name: String in ["Left_Leg", "Right_Leg"]:
|
|
var base: Marker2D = _leg_base_markers.get(leg_name) as Marker2D
|
|
if base != null:
|
|
bases[leg_name] = base.position
|
|
var bend_flags: Dictionary = {}
|
|
for joint: String in ["LeftArm", "RightArm", "LeftLeg", "RightLeg"]:
|
|
bend_flags[joint] = _rig.get_joint_bend_flipped(joint)
|
|
var tips: Dictionary = {}
|
|
var skeleton := _rig.get_node_or_null(NodePath("Skeleton2D")) as Skeleton2D
|
|
if skeleton != null:
|
|
var bone_paths: Dictionary = {
|
|
"Left_Upper_Arm": "Torso/LeftUpperArm",
|
|
"Left_Lower_Arm": "Torso/LeftUpperArm/LeftLowerArm",
|
|
"Right_Upper_Arm": "Torso/RightUpperArm",
|
|
"Right_Lower_Arm": "Torso/RightUpperArm/RightLowerArm",
|
|
"Left_Upper_Leg": "Torso/LeftUpperLeg",
|
|
"Left_Lower_Leg": "Torso/LeftUpperLeg/LeftLowerLeg",
|
|
"Right_Upper_Leg": "Torso/RightUpperLeg",
|
|
"Right_Lower_Leg": "Torso/RightUpperLeg/RightLowerLeg",
|
|
}
|
|
for bone_name: String in bone_paths:
|
|
var bone := skeleton.get_node_or_null(NodePath(String(bone_paths[bone_name]))) as Bone2D
|
|
if bone == null:
|
|
continue
|
|
var tip_world := bone.to_global(Vector2(bone.length, 0.0).rotated(deg_to_rad(bone.bone_angle)))
|
|
tips[bone_name] = _rig.to_local(tip_world)
|
|
print("[WalkDebug] %s facing=%d bends=%s root=%s markers=%s leg_bases=%s limb_rotations=%s limb_tips=%s" % [label, int(_rig.facing_profile), bend_flags, _rig.global_position, markers, bases, _rig._capture_limb_rotations(), tips])
|
|
|
|
func begin_terrain_fall() -> void:
|
|
if _rig == null:
|
|
return
|
|
terrain_following_enabled = true
|
|
terrain_pelvis_offset = 0.0
|
|
_terrain_paused_walk_animation = false
|
|
_enter_airborne(0.0)
|
|
_reset_foot_contacts()
|
|
|
|
func set_terrain_following_enabled(enabled: bool) -> void:
|
|
if enabled:
|
|
if terrain_motion == TerrainMotion.DISABLED:
|
|
begin_terrain_fall()
|
|
return
|
|
terrain_following_enabled = false
|
|
_cancel_landing_impact(true)
|
|
_restore_air_pose()
|
|
terrain_pelvis_offset = 0.0
|
|
terrain_air_velocity = Vector2.ZERO
|
|
terrain_motion = TerrainMotion.DISABLED
|
|
_terrain_paused_walk_animation = false
|
|
_reset_foot_contacts()
|
|
for leg_name: String in ["Left_Leg", "Right_Leg"]:
|
|
var base: Marker2D = _leg_base_markers.get(leg_name) as Marker2D
|
|
var target: Marker2D = _rig._get_ik_marker(leg_name)
|
|
if base != null and target != null:
|
|
target.position = base.position
|
|
|
|
func prepare_for_ragdoll() -> void:
|
|
if terrain_motion != TerrainMotion.DISABLED:
|
|
set_terrain_following_enabled(false)
|
|
|
|
func reset_for_ragdoll_handoff() -> void:
|
|
_cancel_landing_impact(false)
|
|
air_pose_base.clear()
|
|
air_pose_weight = 0.0
|
|
_air_time = 0.0
|
|
_air_entry_root_y = 0.0
|
|
air_fall_distance = 0.0
|
|
terrain_motion = TerrainMotion.DISABLED
|
|
terrain_air_velocity = Vector2.ZERO
|
|
_reset_foot_contacts()
|
|
|
|
func is_action_blocked() -> bool:
|
|
return terrain_motion == TerrainMotion.LANDING_IMPACT
|
|
|
|
func blocks_walking() -> bool:
|
|
return terrain_following_enabled and terrain_motion != TerrainMotion.GROUNDED
|
|
|
|
func _reset_foot_contacts() -> void:
|
|
for leg_name: String in foot_contacts:
|
|
foot_contacts[leg_name] = {"valid": false, "locked": false}
|
|
|
|
func _probe_terrain_below(world_point: Vector2, probe_down: float = TERRAIN_PROBE_DOWN) -> Dictionary:
|
|
var space_state: PhysicsDirectSpaceState2D = _rig.get_world_2d().direct_space_state
|
|
if space_state == null:
|
|
return {"valid": false}
|
|
var query := PhysicsRayQueryParameters2D.create(world_point + Vector2.UP * TERRAIN_PROBE_UP, world_point + Vector2.DOWN * probe_down)
|
|
query.collision_mask = 1
|
|
query.collide_with_areas = false
|
|
query.exclude = _rig.get_collision_body_rids()
|
|
var hit: Dictionary = space_state.intersect_ray(query)
|
|
if hit.is_empty() or not (hit.get("collider") is TerrainBlock):
|
|
return {"valid": false}
|
|
var normal: Vector2 = hit.get("normal", Vector2.ZERO)
|
|
if normal.y > TERRAIN_NORMAL_MIN_Y:
|
|
return {"valid": false}
|
|
return {"valid": true, "world_pos": hit.get("position", world_point), "normal": normal}
|
|
|
|
func _probe_terrain_fan(world_point: Vector2, offsets: Array[float]) -> Dictionary:
|
|
var best: Dictionary = {"valid": false}
|
|
for x_offset: float in offsets:
|
|
var hit: Dictionary = _probe_terrain_below(world_point + Vector2(x_offset, 0.0))
|
|
if bool(hit.get("valid", false)) and (not bool(best.get("valid", false)) or (hit["world_pos"] as Vector2).y < (best["world_pos"] as Vector2).y):
|
|
best = hit
|
|
return best
|
|
|
|
func probe_ground_y(world_x: float, top_y: float, bottom_y: float, exclude: Array[RID]) -> float:
|
|
var best_y := bottom_y
|
|
var best_distance := INF
|
|
if _rig == null:
|
|
return best_y
|
|
var space_state: PhysicsDirectSpaceState2D = _rig.get_world_2d().direct_space_state
|
|
if space_state == null:
|
|
return best_y
|
|
for offset: float in RECOVERY_PROBE_X_OFFSETS:
|
|
var query := PhysicsRayQueryParameters2D.create(
|
|
Vector2(world_x + offset, top_y - RECOVERY_PROBE_START_OFFSET),
|
|
Vector2(world_x + offset, bottom_y + RECOVERY_PROBE_END_OFFSET))
|
|
query.collision_mask = 1
|
|
query.collide_with_areas = false
|
|
query.collide_with_bodies = true
|
|
query.exclude = exclude
|
|
var hit := space_state.intersect_ray(query)
|
|
if hit.is_empty():
|
|
continue
|
|
var hit_position: Vector2 = hit.get("position", Vector2.ZERO)
|
|
if hit_position.y < top_y - RECOVERY_PROBE_TOLERANCE or hit_position.y > bottom_y + RECOVERY_PROBE_TOLERANCE:
|
|
continue
|
|
var distance := absf(hit_position.y - bottom_y)
|
|
if distance < best_distance:
|
|
best_distance = distance
|
|
best_y = hit_position.y
|
|
return best_y
|
|
|
|
func _stance_weight(leg_name: String) -> float:
|
|
if not _walking or _rig._anim_player == null:
|
|
return 1.0
|
|
var phase: float = fmod(_rig._anim_player.get_current_animation_position(), _active_cycle_duration)
|
|
if _active_move_style == ACTION_REGISTRY.RUN:
|
|
var contact_start: float = RUN_RIGHT_CONTACT_START if leg_name == "Right_Leg" else RUN_LEFT_CONTACT_START
|
|
return _run_contact_weight(phase, contact_start)
|
|
var right_weight: float = 0.5 + 0.5 * cos(TAU * phase / WALK_CYCLE_DURATION)
|
|
return right_weight if leg_name == "Right_Leg" else 1.0 - right_weight
|
|
|
|
|
|
func _run_contact_weight(phase: float, contact_start: float) -> float:
|
|
var cycle_phase: float = fposmod(phase, RUN_CYCLE_DURATION)
|
|
var contact_phase: float = fposmod(cycle_phase - contact_start, RUN_CYCLE_DURATION)
|
|
if contact_phase >= RUN_CONTACT_DURATION:
|
|
return 0.0
|
|
var fade: float = minf(RUN_CONTACT_EDGE_FADE, RUN_CONTACT_DURATION * 0.5)
|
|
var fade_in: float = smoothstep(0.0, fade, contact_phase)
|
|
var fade_out: float = 1.0 - smoothstep(RUN_CONTACT_DURATION - fade, RUN_CONTACT_DURATION, contact_phase)
|
|
return clampf(fade_in * fade_out, 0.0, 1.0)
|
|
|
|
func _update_terrain_following(delta: float) -> void:
|
|
if not terrain_following_enabled or _rig.state != StickmanRig.RigState.ANIMATED or _leg_base_markers.size() != 2:
|
|
terrain_pelvis_offset = 0.0
|
|
return
|
|
_rig._set_limb_mods_enabled(true)
|
|
var center_world: Vector2 = _rig.to_global(NAV_AGENT_LOCAL_POS)
|
|
var center_probe: Dictionary = _probe_terrain_fan(center_world, TERRAIN_CENTER_PROBE_OFFSETS)
|
|
var center_delta: float = (center_probe["world_pos"] as Vector2).y - center_world.y if bool(center_probe.get("valid", false)) else INF
|
|
var center_supported: bool = bool(center_probe.get("valid", false)) and absf(center_delta) <= TERRAIN_SUPPORT_RANGE
|
|
var center_surface_y: float = (center_probe["world_pos"] as Vector2).y if bool(center_probe.get("valid", false)) else INF
|
|
var solved: Dictionary = {}
|
|
for leg_name: String in ["Left_Leg", "Right_Leg"]:
|
|
var base: Marker2D = _leg_base_markers[leg_name] as Marker2D
|
|
var target: Marker2D = _rig._get_ik_marker(leg_name)
|
|
if base == null or target == null:
|
|
continue
|
|
var base_world: Vector2 = base.global_position
|
|
var probe: Dictionary = _probe_terrain_fan(base_world, TERRAIN_FOOT_PROBE_OFFSETS)
|
|
var probe_delta: float = (probe["world_pos"] as Vector2).y - base_world.y if bool(probe.get("valid", false)) else INF
|
|
var within_reach: bool = absf(probe_delta) <= PELVIS_MAX_OFFSET
|
|
if within_reach and center_supported:
|
|
within_reach = (probe["world_pos"] as Vector2).y <= center_surface_y + TERRAIN_MAX_FOOT_SURFACE_DROP
|
|
var stance: float = _stance_weight(leg_name)
|
|
var contact: Dictionary = foot_contacts[leg_name]
|
|
if stance >= 0.5 and bool(probe.get("valid", false)) and within_reach:
|
|
if not bool(contact.get("locked", false)):
|
|
contact = {"valid": true, "locked": true, "world_pos": probe["world_pos"], "normal": probe["normal"]}
|
|
elif (contact.get("world_pos", base_world) as Vector2).distance_to(probe["world_pos"]) > 2.0:
|
|
contact["world_pos"] = probe["world_pos"]
|
|
elif stance < 0.5 or not within_reach:
|
|
contact = {"valid": bool(probe.get("valid", false)), "locked": false}
|
|
foot_contacts[leg_name] = contact
|
|
var floor_world: Vector2 = contact.get("world_pos", base_world) as Vector2 if bool(contact.get("locked", false)) else probe.get("world_pos", base_world) as Vector2
|
|
solved[leg_name] = {"base_marker": base, "floor": floor_world, "surface": probe.get("world_pos", base_world) if within_reach else base_world, "surface_valid": bool(probe.get("valid", false)) and within_reach, "weight": stance, "target": target}
|
|
if terrain_motion == TerrainMotion.LANDING_IMPACT:
|
|
_update_landing_impact(delta)
|
|
return
|
|
if terrain_motion == TerrainMotion.AIRBORNE:
|
|
_update_airborne(delta, solved)
|
|
return
|
|
if not center_supported:
|
|
_enter_airborne(_last_grounded_walk_velocity_x)
|
|
_update_airborne(delta, solved)
|
|
return
|
|
terrain_pelvis_offset = lerpf(0.0, clampf(center_delta, -PELVIS_MAX_OFFSET, PELVIS_MAX_OFFSET), 1.0 - exp(-PELVIS_RESPONSE * delta))
|
|
terrain_motion = TerrainMotion.GROUNDED
|
|
terrain_air_velocity = Vector2.ZERO
|
|
_resume_walk_animation()
|
|
_rig.global_position.y += terrain_pelvis_offset
|
|
for leg_name: String in solved:
|
|
var item: Dictionary = solved[leg_name]
|
|
var base_after: Vector2 = (item["base_marker"] as Marker2D).global_position
|
|
var weight: float = float(item["weight"])
|
|
var surface: Vector2 = item["surface"] as Vector2
|
|
if bool(item["surface_valid"]):
|
|
weight = maxf(weight, smoothstep(0.0, 1.0, 1.0 - clampf((surface.y - base_after.y) / TERRAIN_FOOT_GUARD_HEIGHT, 0.0, 1.0)))
|
|
var wanted: Vector2 = base_after
|
|
wanted.y = lerpf(base_after.y, (item["floor"] as Vector2).y, weight)
|
|
if bool(item["surface_valid"]) and wanted.y > surface.y:
|
|
wanted.y = surface.y
|
|
(item["target"] as Marker2D).global_position = wanted
|
|
|
|
func _enter_airborne(horizontal_velocity: float) -> void:
|
|
if terrain_motion != TerrainMotion.AIRBORNE:
|
|
air_pose_base.clear()
|
|
for marker_name: String in ["Left_Hand", "Right_Hand", "Left_Leg", "Right_Leg"]:
|
|
var marker: Marker2D = _rig._get_ik_marker(marker_name)
|
|
if marker != null:
|
|
air_pose_base[marker_name] = marker.position
|
|
_air_time = 0.0
|
|
air_pose_weight = 0.0
|
|
_air_entry_root_y = _rig.global_position.y
|
|
air_fall_distance = 0.0
|
|
terrain_motion = TerrainMotion.AIRBORNE
|
|
terrain_air_velocity = Vector2(horizontal_velocity, 0.0)
|
|
if _walking and _rig._anim_player != null and _rig._anim_player.is_playing():
|
|
_rig._anim_player.pause()
|
|
_terrain_paused_walk_animation = true
|
|
_reset_foot_contacts()
|
|
|
|
func _update_airborne(delta: float, solved: Dictionary) -> void:
|
|
terrain_air_velocity.x *= exp(-AIR_DRAG_X * delta)
|
|
terrain_air_velocity.y = minf(terrain_air_velocity.y + TERRAIN_GRAVITY * delta, TERRAIN_MAX_FALL_SPEED)
|
|
_rig.global_position += _sweep_airborne_horizontal_motion(Vector2(terrain_air_velocity.x * delta, 0.0))
|
|
_rig.global_position.y += terrain_air_velocity.y * delta
|
|
air_fall_distance = maxf(air_fall_distance, _rig.global_position.y - _air_entry_root_y)
|
|
_air_time += delta
|
|
_apply_air_pose(delta)
|
|
var center_world: Vector2 = _rig.to_global(NAV_AGENT_LOCAL_POS)
|
|
var center_probe: Dictionary = _probe_terrain_fan(center_world, TERRAIN_CENTER_PROBE_OFFSETS)
|
|
var landing_delta: float = (center_probe["world_pos"] as Vector2).y - center_world.y if bool(center_probe.get("valid", false)) else INF
|
|
if bool(center_probe.get("valid", false)) and terrain_air_velocity.y >= 0.0 and landing_delta >= -TERRAIN_SUPPORT_RANGE and landing_delta <= TERRAIN_SUPPORT_RANGE + terrain_air_velocity.y * delta:
|
|
_rig.global_position.y += landing_delta
|
|
var speed: float = maxf(terrain_air_velocity.y, 0.0)
|
|
terrain_air_velocity = Vector2.ZERO
|
|
if air_fall_distance >= HARD_LANDING_MIN_FALL_HEIGHT:
|
|
_begin_landing(speed, center_probe, solved)
|
|
else:
|
|
_restore_air_pose()
|
|
terrain_motion = TerrainMotion.GROUNDED
|
|
_resume_walk_animation()
|
|
|
|
func _apply_air_pose(delta: float) -> void:
|
|
if air_pose_base.is_empty():
|
|
return
|
|
var speed: float = maxf(terrain_air_velocity.y, 0.0)
|
|
air_pose_weight = lerpf(air_pose_weight, smoothstep(AIR_POSE_START_SPEED, AIR_POSE_FULL_SPEED, speed), 1.0 - exp(-AIR_POSE_RESPONSE * delta))
|
|
var forward_weight: float = smoothstep(AIR_HAND_FORWARD_START_SPEED, AIR_HAND_FORWARD_FULL_SPEED, speed)
|
|
var phase: float = TAU * AIR_FLAIL_HZ * _air_time
|
|
for marker_name: String in air_pose_base:
|
|
var marker: Marker2D = _rig._get_ik_marker(marker_name)
|
|
if marker == null:
|
|
continue
|
|
var base: Vector2 = air_pose_base[marker_name]
|
|
var local_phase: float = phase + (PI if marker_name.begins_with("Right") else 0.0)
|
|
var offset: Vector2
|
|
if marker_name.ends_with("Hand"):
|
|
offset = Vector2(AIR_HAND_FORWARD * forward_weight, -AIR_HAND_LIFT * air_pose_weight) + Vector2(sin(local_phase) * AIR_HAND_FLAIL_AMPLITUDE.x, cos(local_phase) * AIR_HAND_FLAIL_AMPLITUDE.y) * air_pose_weight
|
|
marker.position = base + offset
|
|
else:
|
|
offset = Vector2(sin(local_phase) * AIR_FOOT_FLAIL_AMPLITUDE.x, cos(local_phase) * AIR_FOOT_FLAIL_AMPLITUDE.y)
|
|
marker.position = base + offset * air_pose_weight
|
|
|
|
func _sweep_airborne_horizontal_motion(motion: Vector2) -> Vector2:
|
|
if is_zero_approx(motion.x):
|
|
return motion
|
|
var space_state: PhysicsDirectSpaceState2D = _rig.get_world_2d().direct_space_state
|
|
if space_state == null:
|
|
return motion
|
|
var safe_fraction: float = 1.0
|
|
var excluded: Array[RID] = []
|
|
for own_proxy: AnimatableBody2D in collision_bodies.values():
|
|
if own_proxy != null and is_instance_valid(own_proxy):
|
|
var own_rid: RID = own_proxy.get_rid()
|
|
if own_rid.is_valid():
|
|
excluded.append(own_rid)
|
|
for proxy: AnimatableBody2D in collision_bodies.values():
|
|
if proxy == null or not is_instance_valid(proxy):
|
|
continue
|
|
var shape_node: CollisionShape2D = proxy.get_node_or_null(NodePath("CollisionShape2D")) as CollisionShape2D
|
|
if shape_node == null or shape_node.shape == null:
|
|
continue
|
|
var query := PhysicsShapeQueryParameters2D.new()
|
|
query.shape = shape_node.shape
|
|
query.transform = shape_node.global_transform
|
|
query.motion = motion
|
|
query.collision_mask = STAGE_PHYSICS.ACTOR_QUERY_MASK
|
|
query.exclude = excluded
|
|
var cast: PackedFloat32Array = space_state.cast_motion(query)
|
|
if cast.size() >= 1:
|
|
safe_fraction = minf(safe_fraction, cast[0])
|
|
if safe_fraction < 0.9999:
|
|
terrain_air_velocity.x = 0.0
|
|
return motion * safe_fraction
|
|
|
|
func _begin_landing(_impact_speed: float, center_probe: Dictionary, _solved: Dictionary) -> void:
|
|
terrain_motion = TerrainMotion.LANDING_IMPACT
|
|
_landing_elapsed = 0.0
|
|
var weight: float = clampf(inverse_lerp(HARD_LANDING_MIN_FALL_HEIGHT, HARD_LANDING_MAX_FALL_HEIGHT, air_fall_distance), 0.0, 1.0)
|
|
landing_compression = lerpf(LANDING_COMPRESSION_MIN, LANDING_COMPRESSION_MAX, weight)
|
|
_landing_hand_drop = lerpf(LANDING_HAND_DROP_MIN, LANDING_HAND_DROP_MAX, weight)
|
|
var torso: Marker2D = _rig._get_ik_marker("Torso")
|
|
if torso != null: _landing_torso_base = torso.position
|
|
_landing_hand_start.clear(); _landing_contacts.clear()
|
|
for hand_name: String in ["Left_Hand", "Right_Hand"]:
|
|
var hand: Marker2D = _rig._get_ik_marker(hand_name)
|
|
if hand != null: _landing_hand_start[hand_name] = hand.position
|
|
var center_y: float = (center_probe.get("world_pos", _rig.global_position) as Vector2).y
|
|
for leg_name: String in ["Left_Leg", "Right_Leg"]:
|
|
var target: Marker2D = _rig._get_ik_marker(leg_name)
|
|
if target == null: continue
|
|
var base: Marker2D = _leg_base_markers.get(leg_name) as Marker2D
|
|
var probe: Dictionary = _probe_terrain_fan(base.global_position if base != null else target.global_position, TERRAIN_FOOT_PROBE_OFFSETS)
|
|
var contact: Vector2 = probe["world_pos"] as Vector2 if bool(probe.get("valid", false)) else Vector2(target.global_position.x, center_y)
|
|
_landing_contacts[leg_name] = contact; target.global_position = contact
|
|
|
|
func _update_landing_impact(delta: float) -> void:
|
|
_landing_elapsed += delta
|
|
var total: float = LANDING_COMPRESS_DURATION + LANDING_RECOVER_DURATION
|
|
var offset: float = landing_compression * (1.0 - pow(1.0 - clampf(_landing_elapsed / LANDING_COMPRESS_DURATION, 0.0, 1.0), 2.0)) if _landing_elapsed <= LANDING_COMPRESS_DURATION else landing_compression * cos(clampf((_landing_elapsed - LANDING_COMPRESS_DURATION) / LANDING_RECOVER_DURATION, 0.0, 1.0) * PI * 0.5)
|
|
var torso: Marker2D = _rig._get_ik_marker("Torso")
|
|
if torso != null: torso.position = _landing_torso_base + Vector2.DOWN * offset
|
|
for leg_name: String in _landing_contacts:
|
|
var target: Marker2D = _rig._get_ik_marker(leg_name)
|
|
if target != null: target.global_position = _landing_contacts[leg_name]
|
|
for hand_name: String in _landing_hand_start:
|
|
var hand: Marker2D = _rig._get_ik_marker(hand_name)
|
|
if hand != null: hand.position = (_landing_hand_start[hand_name] as Vector2).lerp(air_pose_base.get(hand_name, _landing_hand_start[hand_name]), clampf(_landing_elapsed / total, 0.0, 1.0)) + Vector2.DOWN * _landing_hand_drop * offset / maxf(landing_compression, 0.0001)
|
|
if _landing_elapsed >= total: _finish_landing_impact()
|
|
|
|
func _finish_landing_impact() -> void:
|
|
var torso: Marker2D = _rig._get_ik_marker("Torso")
|
|
if torso != null: torso.position = _landing_torso_base
|
|
_restore_air_pose(); _landing_contacts.clear(); _landing_hand_start.clear(); _landing_elapsed = 0.0; landing_compression = 0.0; _landing_hand_drop = 0.0; terrain_motion = TerrainMotion.GROUNDED
|
|
_resume_walk_animation()
|
|
|
|
func _cancel_landing_impact(restore_pose: bool) -> void:
|
|
if terrain_motion == TerrainMotion.LANDING_IMPACT and restore_pose:
|
|
var torso: Marker2D = _rig._get_ik_marker("Torso")
|
|
if torso != null: torso.position = _landing_torso_base
|
|
_landing_contacts.clear(); _landing_hand_start.clear(); _landing_elapsed = 0.0; landing_compression = 0.0; _landing_hand_drop = 0.0
|
|
|
|
func _restore_air_pose() -> void:
|
|
for marker_name: String in air_pose_base:
|
|
var marker: Marker2D = _rig._get_ik_marker(marker_name)
|
|
if marker != null: marker.position = air_pose_base[marker_name]
|
|
air_pose_base.clear(); air_pose_weight = 0.0; _air_time = 0.0; _air_entry_root_y = 0.0; air_fall_distance = 0.0
|
|
|
|
func _resume_walk_animation() -> void:
|
|
if _terrain_paused_walk_animation and _walking and _rig._anim_player != null:
|
|
_rig._anim_player.play()
|
|
_terrain_paused_walk_animation = false
|