147 lines
5.0 KiB
GDScript
147 lines
5.0 KiB
GDScript
extends CharacterBody3D
|
||
|
||
## NS-23: Nav waypoints + stored server goal for arrival.
|
||
## NS-19: convex frustum bumps; `ns19_bump` group — on flat plateau skip nav waypoints (departure).
|
||
|
||
const MOVE_SPEED: float = 5.0
|
||
const ARRIVE_EPS: float = 0.35
|
||
## Must be **below** NS-19 bump rise (~0.12–0.15) or we "arrive" while still on the plateau when the
|
||
## goal is on the floor beside the bump (small horiz offset + ~0.15 m vertical).
|
||
const VERT_ARRIVE_EPS: float = 0.055
|
||
const MAX_CLIMB_SPEED: float = 2.6
|
||
const MAX_DESCENT_SPEED: float = 2.2
|
||
const DIRECT_APPROACH_RADIUS: float = 0.85
|
||
## Floor normal must be this “flat” to count as bump plateau (slopes use nav waypoints).
|
||
const NS19_BUMP_TOP_NORMAL_DOT: float = 0.992
|
||
|
||
var _has_walk_goal: bool = false
|
||
var _auth_walk_goal: Vector3 = Vector3.ZERO
|
||
|
||
@onready var _nav_agent: NavigationAgent3D = $NavigationAgent3D
|
||
|
||
|
||
func _ready() -> void:
|
||
_nav_agent.set_target_position(global_position)
|
||
|
||
|
||
func set_authoritative_nav_goal(world_pos: Vector3) -> void:
|
||
_auth_walk_goal = world_pos
|
||
_has_walk_goal = true
|
||
_nav_agent.set_target_position(world_pos)
|
||
|
||
|
||
func clear_nav_goal() -> void:
|
||
velocity = Vector3.ZERO
|
||
_has_walk_goal = false
|
||
_nav_agent.set_target_position(global_position)
|
||
|
||
|
||
func snap_to_server(world_pos: Vector3) -> void:
|
||
global_position = world_pos
|
||
velocity = Vector3.ZERO
|
||
_has_walk_goal = false
|
||
_nav_agent.set_target_position(world_pos)
|
||
|
||
|
||
func _on_ns19_bump_plateau() -> bool:
|
||
if not is_on_floor():
|
||
return false
|
||
if get_floor_normal().dot(Vector3.UP) < NS19_BUMP_TOP_NORMAL_DOT:
|
||
return false
|
||
return _raycast_down_hits_group("ns19_bump", 2.5)
|
||
|
||
|
||
func _raycast_down_hits_group(group_name: String, cast_len: float) -> bool:
|
||
var space: PhysicsDirectSpaceState3D = get_world_3d().direct_space_state
|
||
var from: Vector3 = global_position
|
||
var to: Vector3 = from + Vector3(0.0, -cast_len, 0.0)
|
||
var q: PhysicsRayQueryParameters3D = PhysicsRayQueryParameters3D.create(from, to)
|
||
q.collision_mask = 1
|
||
q.exclude = [get_rid()]
|
||
var hit: Dictionary = space.intersect_ray(q)
|
||
if hit.is_empty():
|
||
return false
|
||
var col: Variant = hit.get("collider")
|
||
var n: Node = col as Node
|
||
while n:
|
||
if n.is_in_group(group_name):
|
||
return true
|
||
n = n.get_parent()
|
||
return false
|
||
|
||
|
||
func _steer_toward_world_point(point: Vector3) -> void:
|
||
var pos := global_position
|
||
var delta_h: Vector3 = Vector3(point.x - pos.x, 0.0, point.z - pos.z)
|
||
# Full 3D toward a waypoint that is mostly *below* (same xz, on the slope) while standing on
|
||
# the plateau yields almost no horizontal speed, so you never reach the rim. Nav in xz + floor
|
||
# slide handles ramps and departure from bump tops.
|
||
if delta_h.length_squared() > 1e-8:
|
||
velocity = delta_h.normalized() * MOVE_SPEED
|
||
return
|
||
var dy: float = point.y - pos.y
|
||
if dy > 0.08:
|
||
var delta: Vector3 = point - pos
|
||
if delta.length_squared() < 1e-8:
|
||
velocity = Vector3.ZERO
|
||
return
|
||
var desired: Vector3 = delta.normalized() * MOVE_SPEED
|
||
desired.y = clampf(desired.y, -MAX_DESCENT_SPEED, MAX_CLIMB_SPEED)
|
||
velocity = desired
|
||
elif dy < -0.04:
|
||
# Goal is lower and almost same xz (e.g. floor under the plateau): still need rim escape.
|
||
var escape_h := Vector3(-global_transform.basis.z.x, 0.0, -global_transform.basis.z.z)
|
||
if escape_h.length_squared() < 1e-8:
|
||
escape_h = Vector3(1.0, 0.0, 0.0)
|
||
velocity = escape_h.normalized() * MOVE_SPEED
|
||
else:
|
||
velocity = Vector3.ZERO
|
||
|
||
|
||
func _physics_process(_delta: float) -> void:
|
||
if not _has_walk_goal:
|
||
velocity = Vector3.ZERO
|
||
move_and_slide()
|
||
return
|
||
|
||
var full_to_goal: Vector3 = _auth_walk_goal - global_position
|
||
var horiz_dist: float = Vector2(full_to_goal.x, full_to_goal.z).length()
|
||
var vert_err: float = absf(full_to_goal.y)
|
||
if horiz_dist <= ARRIVE_EPS and vert_err <= VERT_ARRIVE_EPS:
|
||
velocity = Vector3.ZERO
|
||
_has_walk_goal = false
|
||
_nav_agent.set_target_position(global_position)
|
||
move_and_slide()
|
||
return
|
||
|
||
# Waypoints route around obstacles; steering stays horizontal-only (see _steer_toward_world_point)
|
||
# so bump plateaus do not get a mostly-vertical first segment. If next waypoint aligns under the
|
||
# player (small xz delta), fall back to steering toward the goal xz.
|
||
var nav_map: RID = _nav_agent.get_navigation_map()
|
||
if NavigationServer3D.map_get_iteration_id(nav_map) == 0:
|
||
_steer_toward_world_point(_auth_walk_goal)
|
||
move_and_slide()
|
||
return
|
||
|
||
# Nav waypoints on the plateau often point down the slope first; goal-only + horizontal steer
|
||
# matches the NS-19 departure fix. Slopes stay on waypoints so obstacle routing still applies
|
||
# when crossing the district.
|
||
if _on_ns19_bump_plateau():
|
||
_steer_toward_world_point(_auth_walk_goal)
|
||
move_and_slide()
|
||
return
|
||
|
||
if horiz_dist <= DIRECT_APPROACH_RADIUS:
|
||
_steer_toward_world_point(_auth_walk_goal)
|
||
else:
|
||
var next_path_position: Vector3 = _nav_agent.get_next_path_position()
|
||
var to_next_h: Vector3 = Vector3(
|
||
next_path_position.x - global_position.x, 0.0, next_path_position.z - global_position.z
|
||
)
|
||
if to_next_h.length_squared() > 0.0025:
|
||
_steer_toward_world_point(next_path_position)
|
||
else:
|
||
_steer_toward_world_point(_auth_walk_goal)
|
||
|
||
move_and_slide()
|