363 lines
14 KiB
GDScript
363 lines
14 KiB
GDScript
extends CharacterBody3D
|
||
|
||
## NS-23: Follow server move target on walkable geometry.
|
||
## NS-24: Idle — **`velocity = 0`**, **`floor_snap_length = FLOOR_SNAP_IDLE`** (~**11 cm**), **`move_and_slide()`**
|
||
## only. (Post-slide **ray Y lerp** was removed: on bump **rims** hit counts / spread can cross thresholds
|
||
## frame-to-frame and **fight** the solver.) Walking snap **`FLOOR_SNAP_MOVING`**. **`snap_to_server`** calls
|
||
## **`reset_physics_interpolation()`** when **`physics/common/physics_interpolation`** is on.
|
||
## **`project.godot`:** **`physics/3d/physics_engine`** = **Jolt Physics**; **`physics/common/physics_interpolation`**
|
||
## = **off** — when **on**, micro **position** changes on bump **rims** blend across frames and read as **ghosting / jitter**.
|
||
## **`Player`** under **`World`**.
|
||
## **Rendering (main.tscn / project):** curved default capsule + directional light → **specular highlight**
|
||
## and **floor z-fight** often read as “jitter” even when `global_position` is stable — player uses
|
||
## **no-specular** material, capsule mesh **Y** lift vs **`CapsuleShape3D`** (**z-fight** vs walkables), **no cast shadow** on mesh, **MSAA 3D**, **`light_specular=0`**.
|
||
## **Timing:** **120** physics TPS. Jitter that vanishes at **2× run speed** points at **render vs physics** cadence.
|
||
##
|
||
## Horizontal velocity toward nav waypoint or goal only; `velocity.y` stays 0 while walking.
|
||
## Move **legality** is server-only.
|
||
##
|
||
## Nav detours except when goal Y is below us: then xz toward goal (waypoints sit under bump rims).
|
||
##
|
||
## **Upright + floor angle (NS-24):** **`_snap_capsule_upright()`** each tick after motion — **`Transform3D(Basis.IDENTITY, pos)`**
|
||
## so the capsule cannot **bank** on rim contacts. **`floor_max_angle`** is **looser while walking**; **stricter when idle** on
|
||
## flat floor; **`_floor_angle_loose_ticks`** after a walk **stops**. **Rim / straddle idle:** **moving** `floor_max_angle` when
|
||
## **`get_floor_normal()`** is **shallow** or **`_idle_ridged_floor_contacts()`** (slab + cylinder **edge**). After idle slide,
|
||
## **`_maybe_idle_rim_settle_nudge()`** + **`_maybe_idle_bump_proximity_escape()`** (uses **`random_floor_bump_mesh`** group mesh radius vs
|
||
## **`random_floor_bump_collision_constants.gd`** collision lip, including **cylinder wall** hug). Do **not** reset **`global_transform`** in **`_process`** — that can **desync**
|
||
## **`CharacterBody3D`** from the physics/render path and read as **ghosting**. **`NavigationAgent3D.avoidance_enabled`** = **false**.
|
||
## **`_try_walk_step_assist()`** — **Y nudge** when the nav goal is **higher**, **`test_move`** says there is **headroom**,
|
||
## we have **support** (floor or slide normal with upward component), a **wall-ish** slide opposes **XZ** toward the goal,
|
||
## and **horizontal motion** is **not** clearly advancing. A **second** **`move_and_slide()`** runs after a nudge to fix overlap
|
||
## (avoids “floating” off the bump edge).
|
||
|
||
const MOVE_SPEED: float = 5.0
|
||
## `is_on_floor` / snap: **moving** — seam / curved-side transitions onto NS-19 bumps (~original 50°).
|
||
const FLOOR_MAX_ANGLE_MOVING_DEG: float = 50.0
|
||
## **Idle** — tighter to reject **vertex / vertical-face** flicker when stably at rest.
|
||
const FLOOR_MAX_ANGLE_IDLE_DEG: float = 35.0
|
||
## Physics ticks to keep **moving** floor angle after **`_has_walk_goal`** becomes false (arrival / clear).
|
||
const FLOOR_ANGLE_LOOSE_TICKS_AFTER_STOP: int = 96
|
||
const ARRIVE_EPS: float = 0.35
|
||
const VERT_ARRIVE_EPS: float = 0.055
|
||
const DIRECT_APPROACH_RADIUS: float = 0.85
|
||
const FLOOR_SNAP_MOVING: float = 0.32
|
||
## Idle snap — slightly **strong** to pin capsule on **rim** contacts without a second slide pass.
|
||
const FLOOR_SNAP_IDLE: float = 0.11
|
||
## Idle on a **rim**: if **`get_floor_normal().y`** dot world up is **below** this, use **moving** `floor_max_angle` for that tick.
|
||
const IDLE_RIM_MIN_FLOOR_UP_DOT: float = 0.968
|
||
## Per-tick horizontal pull when idle on a **rim** or **straddling** slab + bump (see **`_maybe_idle_rim_settle_nudge`**).
|
||
const IDLE_RIM_SETTLE_STEP: float = 0.004
|
||
const DESCEND_GOAL_Y_MARGIN: float = 0.06
|
||
## Per-tick lift when **stuck** on a low vertical face while the nav goal is **higher** (NS-19 bump height ~0.15 m).
|
||
const WALK_STEP_ASSIST_DELTA: float = 0.11
|
||
## If **`vel_h.dot(want) >` this**, we are making enough XZ progress — do not assist (corners may still slide slowly).
|
||
const WALK_STEP_ASSIST_MAX_FORWARD_DOT: float = 0.52
|
||
const WALK_STEP_ASSIST_COOLDOWN_TICKS: int = 8
|
||
## Capsule **`height`** in **`main.tscn`** (**1.0**) → origin at **center**; feet ≈ **`y - 0.5`**.
|
||
const CAPSULE_HALF_HEIGHT: float = 0.5
|
||
const _random_floor_bump_collision: Script = preload(
|
||
"res://scripts/random_floor_bump_collision_constants.gd"
|
||
)
|
||
## Idle-only radial push off bump **lip** / **wall** (m/tick at **120** TPS).
|
||
const IDLE_BUMP_ESCAPE_STEP: float = 0.014
|
||
## Player **`CapsuleShape3D.radius`** in **`main.tscn`** — wall contact can occur with axis distance **>** **`col_r`**.
|
||
const PLAYER_CAPSULE_RADIUS: float = 0.4
|
||
|
||
var _has_walk_goal: bool = false
|
||
var _auth_walk_goal: Vector3 = Vector3.ZERO
|
||
var _floor_angle_loose_ticks: int = 0
|
||
var _step_assist_cooldown: int = 0
|
||
|
||
@onready var _nav_agent: NavigationAgent3D = $NavigationAgent3D
|
||
|
||
|
||
func _ready() -> void:
|
||
physics_interpolation_mode = Node.PHYSICS_INTERPOLATION_MODE_OFF
|
||
_nav_agent.avoidance_enabled = false
|
||
_nav_agent.set_target_position(global_position)
|
||
floor_block_on_wall = true
|
||
|
||
|
||
func set_authoritative_nav_goal(world_pos: Vector3) -> void:
|
||
_auth_walk_goal = world_pos
|
||
_has_walk_goal = true
|
||
_floor_angle_loose_ticks = 0
|
||
_nav_agent.set_target_position(world_pos)
|
||
|
||
|
||
func clear_nav_goal() -> void:
|
||
velocity = Vector3.ZERO
|
||
_has_walk_goal = false
|
||
_floor_angle_loose_ticks = FLOOR_ANGLE_LOOSE_TICKS_AFTER_STOP
|
||
_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
|
||
_floor_angle_loose_ticks = 0
|
||
_step_assist_cooldown = 0
|
||
_nav_agent.set_target_position(world_pos)
|
||
reset_physics_interpolation()
|
||
|
||
|
||
func _step_assist_has_support() -> bool:
|
||
if is_on_floor():
|
||
return true
|
||
for i: int in get_slide_collision_count():
|
||
if get_slide_collision(i).get_normal().y > 0.35:
|
||
return true
|
||
return false
|
||
|
||
|
||
func _step_assist_wallish_blocks(want_dir_xz: Vector3) -> bool:
|
||
if is_on_wall():
|
||
return true
|
||
var w2 := Vector2(want_dir_xz.x, want_dir_xz.z)
|
||
if w2.length_squared() < 1e-8:
|
||
return false
|
||
w2 = w2.normalized()
|
||
for i: int in get_slide_collision_count():
|
||
var n: Vector3 = get_slide_collision(i).get_normal()
|
||
if n.y > 0.52:
|
||
continue
|
||
var n2 := Vector2(n.x, n.z)
|
||
if n2.length_squared() < 1e-8:
|
||
continue
|
||
n2 = n2.normalized()
|
||
if n2.dot(w2) < -0.04:
|
||
return true
|
||
return false
|
||
|
||
|
||
func _step_assist_can_raise_by(dy: float) -> bool:
|
||
if dy <= 0.0001:
|
||
return false
|
||
return not test_move(global_transform, Vector3(0.0, dy, 0.0), null, safe_margin)
|
||
|
||
|
||
func _try_walk_step_assist() -> bool:
|
||
if not _has_walk_goal:
|
||
return false
|
||
if _auth_walk_goal.y < global_position.y + 0.025:
|
||
return false
|
||
if not _step_assist_has_support():
|
||
return false
|
||
var pos: Vector3 = global_position
|
||
var to_h: Vector3 = Vector3(_auth_walk_goal.x - pos.x, 0.0, _auth_walk_goal.z - pos.z)
|
||
if to_h.length_squared() < 0.03:
|
||
return false
|
||
var want: Vector3 = to_h.normalized()
|
||
if not _step_assist_wallish_blocks(want):
|
||
return false
|
||
var vel_h: Vector3 = Vector3(velocity.x, 0.0, velocity.z)
|
||
if vel_h.dot(want) > WALK_STEP_ASSIST_MAX_FORWARD_DOT:
|
||
return false
|
||
var lift: float = minf(WALK_STEP_ASSIST_DELTA, _auth_walk_goal.y - global_position.y + 0.45)
|
||
lift = minf(lift, maxf(0.0, _auth_walk_goal.y + 0.55 - global_position.y))
|
||
if lift <= 0.002:
|
||
return false
|
||
if not _step_assist_can_raise_by(lift):
|
||
return false
|
||
global_position.y += lift
|
||
return true
|
||
|
||
|
||
func _after_walk_move_and_slide() -> void:
|
||
if _step_assist_cooldown > 0:
|
||
_step_assist_cooldown -= 1
|
||
if _step_assist_cooldown > 0:
|
||
return
|
||
if _try_walk_step_assist():
|
||
_step_assist_cooldown = WALK_STEP_ASSIST_COOLDOWN_TICKS
|
||
floor_snap_length = FLOOR_SNAP_MOVING
|
||
move_and_slide()
|
||
|
||
|
||
func _set_horizontal_velocity_toward(point: Vector3) -> void:
|
||
var pos := global_position
|
||
var dh: Vector3 = Vector3(point.x - pos.x, 0.0, point.z - pos.z)
|
||
if dh.length_squared() < 1e-10:
|
||
dh = Vector3(1.0, 0.0, 0.0)
|
||
velocity = dh.normalized() * MOVE_SPEED
|
||
velocity.y = 0.0
|
||
|
||
|
||
func _physics_idle_tick() -> void:
|
||
velocity = Vector3.ZERO
|
||
floor_snap_length = FLOOR_SNAP_IDLE
|
||
move_and_slide()
|
||
|
||
|
||
func _idle_ridged_floor_contacts() -> bool:
|
||
var upish := 0
|
||
var wallish := 0
|
||
for i: int in get_slide_collision_count():
|
||
var ny: float = get_slide_collision(i).get_normal().y
|
||
if ny > 0.65:
|
||
upish += 1
|
||
elif ny < 0.45:
|
||
wallish += 1
|
||
return upish >= 1 and wallish >= 1
|
||
|
||
|
||
func _maybe_idle_rim_settle_nudge() -> void:
|
||
if get_slide_collision_count() < 1:
|
||
return
|
||
var shallow_floor: bool = (
|
||
is_on_floor()
|
||
and get_floor_normal().dot(Vector3.UP) < IDLE_RIM_MIN_FLOOR_UP_DOT
|
||
)
|
||
if not shallow_floor and not _idle_ridged_floor_contacts():
|
||
return
|
||
var h := Vector3.ZERO
|
||
if shallow_floor:
|
||
h = Vector3(get_floor_normal().x, 0.0, get_floor_normal().z)
|
||
if h.length_squared() < 1e-12:
|
||
for i: int in get_slide_collision_count():
|
||
var n: Vector3 = get_slide_collision(i).get_normal()
|
||
if n.y < 0.5:
|
||
var nh: Vector3 = Vector3(n.x, 0.0, n.z)
|
||
if nh.length_squared() > h.length_squared():
|
||
h = nh
|
||
if h.length_squared() < 1e-12:
|
||
return
|
||
var step: Vector3 = -h.normalized() * IDLE_RIM_SETTLE_STEP
|
||
if test_move(global_transform, step, null, safe_margin):
|
||
return
|
||
global_position += step
|
||
|
||
|
||
func _maybe_idle_bump_proximity_escape() -> void:
|
||
var feet_y: float = global_position.y - CAPSULE_HALF_HEIGHT
|
||
for node: Node in get_tree().get_nodes_in_group(
|
||
_random_floor_bump_collision.RANDOM_FLOOR_BUMP_MESH_GROUP
|
||
):
|
||
if not node is MeshInstance3D:
|
||
continue
|
||
var mi := node as MeshInstance3D
|
||
var mesh: Mesh = mi.mesh
|
||
if not mesh is CylinderMesh:
|
||
continue
|
||
var cyl := mesh as CylinderMesh
|
||
var c: Vector3 = mi.global_position
|
||
var r_mesh: float = cyl.top_radius
|
||
var h: float = cyl.height
|
||
var col_r: float = minf(
|
||
r_mesh + _random_floor_bump_collision.COLLISION_RADIUS_EXTRA,
|
||
_random_floor_bump_collision.COLLISION_RADIUS_MAX
|
||
)
|
||
var dx: float = global_position.x - c.x
|
||
var dz: float = global_position.z - c.z
|
||
var d: float = sqrt(dx * dx + dz * dz)
|
||
if d < 1e-5:
|
||
continue
|
||
var bottom_y: float = c.y - h * 0.5
|
||
var top_y: float = c.y + h * 0.5
|
||
if feet_y < bottom_y - 0.22 or feet_y > top_y + 0.4:
|
||
continue
|
||
var radial: Vector3 = Vector3(dx / d, 0.0, dz / d)
|
||
var away: Vector3 = radial * IDLE_BUMP_ESCAPE_STEP
|
||
var wall_band_outer: float = col_r + PLAYER_CAPSULE_RADIUS + 0.22
|
||
# Outside visual disc but inside fat collision — clear the invisible lip.
|
||
if d > r_mesh * 0.9 and d < col_r + 0.32:
|
||
if not test_move(global_transform, away, null, safe_margin):
|
||
global_position += away
|
||
return
|
||
# On / above the disc, hugging the **visual** rim — step onto open slab.
|
||
if feet_y >= bottom_y - 0.06 and feet_y <= top_y + 0.1 and d >= r_mesh * 0.86 and d <= r_mesh + 0.14:
|
||
if not test_move(global_transform, away, null, safe_margin):
|
||
global_position += away
|
||
return
|
||
# Floor beside **vertical** cylinder side: axis distance can exceed **`col_r`** while capsule still touches wall.
|
||
if (
|
||
feet_y <= top_y + 0.14
|
||
and d > r_mesh * 0.82
|
||
and d < wall_band_outer
|
||
and (is_on_wall() or _idle_ridged_floor_contacts())
|
||
):
|
||
if not test_move(global_transform, away, null, safe_margin):
|
||
global_position += away
|
||
return
|
||
|
||
|
||
func _snap_capsule_upright() -> void:
|
||
# TODO(NS-24 follow-on): When the avatar has **facing yaw**, clear **pitch/roll** only — keep **Y** rotation;
|
||
# full **`Basis.IDENTITY`** would erase look direction.
|
||
var p: Vector3 = global_position
|
||
global_transform = Transform3D(Basis.IDENTITY, p)
|
||
|
||
|
||
func _physics_process(_delta: float) -> void:
|
||
var use_loose_floor_angle: bool = (
|
||
_has_walk_goal or _floor_angle_loose_ticks > 0
|
||
)
|
||
if not _has_walk_goal and (
|
||
(
|
||
is_on_floor()
|
||
and get_floor_normal().dot(Vector3.UP) < IDLE_RIM_MIN_FLOOR_UP_DOT
|
||
)
|
||
or _idle_ridged_floor_contacts()
|
||
):
|
||
use_loose_floor_angle = true
|
||
floor_max_angle = deg_to_rad(
|
||
FLOOR_MAX_ANGLE_MOVING_DEG if use_loose_floor_angle else FLOOR_MAX_ANGLE_IDLE_DEG
|
||
)
|
||
if not _has_walk_goal:
|
||
_physics_idle_tick()
|
||
_maybe_idle_rim_settle_nudge()
|
||
_maybe_idle_bump_proximity_escape()
|
||
if _floor_angle_loose_ticks > 0:
|
||
_floor_angle_loose_ticks -= 1
|
||
_snap_capsule_upright()
|
||
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
|
||
_floor_angle_loose_ticks = FLOOR_ANGLE_LOOSE_TICKS_AFTER_STOP
|
||
_nav_agent.set_target_position(global_position)
|
||
_physics_idle_tick()
|
||
_maybe_idle_rim_settle_nudge()
|
||
_maybe_idle_bump_proximity_escape()
|
||
_snap_capsule_upright()
|
||
return
|
||
|
||
var nav_map: RID = _nav_agent.get_navigation_map()
|
||
if NavigationServer3D.map_get_iteration_id(nav_map) == 0:
|
||
_set_horizontal_velocity_toward(_auth_walk_goal)
|
||
floor_snap_length = FLOOR_SNAP_MOVING
|
||
move_and_slide()
|
||
_after_walk_move_and_slide()
|
||
_snap_capsule_upright()
|
||
return
|
||
|
||
if _auth_walk_goal.y < global_position.y - DESCEND_GOAL_Y_MARGIN:
|
||
_set_horizontal_velocity_toward(_auth_walk_goal)
|
||
floor_snap_length = FLOOR_SNAP_MOVING
|
||
move_and_slide()
|
||
_after_walk_move_and_slide()
|
||
_snap_capsule_upright()
|
||
return
|
||
|
||
if horiz_dist <= DIRECT_APPROACH_RADIUS:
|
||
_set_horizontal_velocity_toward(_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:
|
||
_set_horizontal_velocity_toward(next_path_position)
|
||
else:
|
||
_set_horizontal_velocity_toward(_auth_walk_goal)
|
||
|
||
floor_snap_length = FLOOR_SNAP_MOVING
|
||
move_and_slide()
|
||
_after_walk_move_and_slide()
|
||
_snap_capsule_upright()
|