NEON-29: Prototype smooth hill piece, movement script updates, NEO-22 plan
Add prototype_smooth_hill_piece for QA geometry alongside main scene wiring. Refresh player locomotion and main scene integration from stashed work. Track docs/plans/NEO-22-implementation-plan.md (WASD replaces click-to-move).pull/41/head
parent
a7ad695143
commit
2b72d6bcb9
|
|
@ -9,6 +9,7 @@
|
||||||
[ext_resource type="Script" uid="uid://caaj3ohikfdx2" path="res://scripts/isometric_follow_camera.gd" id="7_iso_cam"]
|
[ext_resource type="Script" uid="uid://caaj3ohikfdx2" path="res://scripts/isometric_follow_camera.gd" id="7_iso_cam"]
|
||||||
[ext_resource type="Resource" path="res://resources/isometric_zoom_bands.tres" id="8_zoom_bands"]
|
[ext_resource type="Resource" path="res://resources/isometric_zoom_bands.tres" id="8_zoom_bands"]
|
||||||
[ext_resource type="Resource" path="res://resources/isometric_occlusion_policy.tres" id="9_occ_pol"]
|
[ext_resource type="Resource" path="res://resources/isometric_occlusion_policy.tres" id="9_occ_pol"]
|
||||||
|
[ext_resource type="Script" uid="uid://dqm8s4k2h1xwy" path="res://scripts/prototype_smooth_hill_piece.gd" id="10_smooth_hill"]
|
||||||
|
|
||||||
[sub_resource type="NavigationMesh" id="NavigationMesh_district"]
|
[sub_resource type="NavigationMesh" id="NavigationMesh_district"]
|
||||||
geometry_collision_mask = 1
|
geometry_collision_mask = 1
|
||||||
|
|
@ -176,10 +177,22 @@ size = Vector3(1, 0.104, 6.2)
|
||||||
size = Vector3(1, 0.104, 6.2)
|
size = Vector3(1, 0.104, 6.2)
|
||||||
|
|
||||||
[sub_resource type="BoxMesh" id="BoxMesh_physics_ramp_test_floor"]
|
[sub_resource type="BoxMesh" id="BoxMesh_physics_ramp_test_floor"]
|
||||||
size = Vector3(24, 0.2, 45)
|
size = Vector3(17.0, 0.2, 45)
|
||||||
|
|
||||||
[sub_resource type="BoxShape3D" id="BoxShape3D_physics_ramp_test_floor"]
|
[sub_resource type="BoxShape3D" id="BoxShape3D_physics_ramp_test_floor"]
|
||||||
size = Vector3(24, 0.2, 45)
|
size = Vector3(17.0, 0.2, 45)
|
||||||
|
|
||||||
|
[sub_resource type="BoxMesh" id="BoxMesh_physics_ramp_ext_fill_n"]
|
||||||
|
size = Vector3(7, 0.2, 20.048)
|
||||||
|
|
||||||
|
[sub_resource type="BoxShape3D" id="BoxShape3D_physics_ramp_ext_fill_n"]
|
||||||
|
size = Vector3(7, 0.2, 20.048)
|
||||||
|
|
||||||
|
[sub_resource type="BoxMesh" id="BoxMesh_physics_ramp_ext_fill_s"]
|
||||||
|
size = Vector3(7, 0.2, 20.432)
|
||||||
|
|
||||||
|
[sub_resource type="BoxShape3D" id="BoxShape3D_physics_ramp_ext_fill_s"]
|
||||||
|
size = Vector3(7, 0.2, 20.432)
|
||||||
|
|
||||||
[sub_resource type="BoxMesh" id="BoxMesh_physics_ramp_test_block"]
|
[sub_resource type="BoxMesh" id="BoxMesh_physics_ramp_test_block"]
|
||||||
size = Vector3(4, 2, 4)
|
size = Vector3(4, 2, 4)
|
||||||
|
|
@ -964,14 +977,42 @@ transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 34.5, 0, 0)
|
||||||
collision_mask = 3
|
collision_mask = 3
|
||||||
|
|
||||||
[node name="MeshInstance3D" type="MeshInstance3D" parent="World/NavigationRegion3D/PhysicsRampTestFloorExtension" unique_id=4201002]
|
[node name="MeshInstance3D" type="MeshInstance3D" parent="World/NavigationRegion3D/PhysicsRampTestFloorExtension" unique_id=4201002]
|
||||||
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, -0.1, -0.19204712)
|
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, -3.5, -0.1, -0.19204712)
|
||||||
mesh = SubResource("BoxMesh_physics_ramp_test_floor")
|
mesh = SubResource("BoxMesh_physics_ramp_test_floor")
|
||||||
surface_material_override/0 = SubResource("Mat_floor_slab")
|
surface_material_override/0 = SubResource("Mat_floor_slab")
|
||||||
|
|
||||||
[node name="CollisionShape3D" type="CollisionShape3D" parent="World/NavigationRegion3D/PhysicsRampTestFloorExtension" unique_id=4201003]
|
[node name="CollisionShape3D" type="CollisionShape3D" parent="World/NavigationRegion3D/PhysicsRampTestFloorExtension" unique_id=4201003]
|
||||||
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, -0.1, 0)
|
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, -3.5, -0.1, 0)
|
||||||
shape = SubResource("BoxShape3D_physics_ramp_test_floor")
|
shape = SubResource("BoxShape3D_physics_ramp_test_floor")
|
||||||
|
|
||||||
|
[node name="PhysicsRampTestFloorExtensionNorthFill" type="StaticBody3D" parent="World/NavigationRegion3D" unique_id=4201050 groups=["walkable"]]
|
||||||
|
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 43, 0, 12.284)
|
||||||
|
collision_layer = 1
|
||||||
|
collision_mask = 3
|
||||||
|
|
||||||
|
[node name="MeshInstance3D" type="MeshInstance3D" parent="World/NavigationRegion3D/PhysicsRampTestFloorExtensionNorthFill" unique_id=4201051]
|
||||||
|
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, -0.1, 0)
|
||||||
|
mesh = SubResource("BoxMesh_physics_ramp_ext_fill_n")
|
||||||
|
surface_material_override/0 = SubResource("Mat_floor_slab")
|
||||||
|
|
||||||
|
[node name="CollisionShape3D" type="CollisionShape3D" parent="World/NavigationRegion3D/PhysicsRampTestFloorExtensionNorthFill" unique_id=4201052]
|
||||||
|
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, -0.1, 0)
|
||||||
|
shape = SubResource("BoxShape3D_physics_ramp_ext_fill_n")
|
||||||
|
|
||||||
|
[node name="PhysicsRampTestFloorExtensionSouthFill" type="StaticBody3D" parent="World/NavigationRegion3D" unique_id=4201053 groups=["walkable"]]
|
||||||
|
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 43, 0, -12.476)
|
||||||
|
collision_layer = 1
|
||||||
|
collision_mask = 3
|
||||||
|
|
||||||
|
[node name="MeshInstance3D" type="MeshInstance3D" parent="World/NavigationRegion3D/PhysicsRampTestFloorExtensionSouthFill" unique_id=4201054]
|
||||||
|
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, -0.1, 0)
|
||||||
|
mesh = SubResource("BoxMesh_physics_ramp_ext_fill_s")
|
||||||
|
surface_material_override/0 = SubResource("Mat_floor_slab")
|
||||||
|
|
||||||
|
[node name="CollisionShape3D" type="CollisionShape3D" parent="World/NavigationRegion3D/PhysicsRampTestFloorExtensionSouthFill" unique_id=4201055]
|
||||||
|
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, -0.1, 0)
|
||||||
|
shape = SubResource("BoxShape3D_physics_ramp_ext_fill_s")
|
||||||
|
|
||||||
[node name="PhysicsRampTestBlock" type="StaticBody3D" parent="World/NavigationRegion3D" unique_id=4201010 groups=["occluder"]]
|
[node name="PhysicsRampTestBlock" type="StaticBody3D" parent="World/NavigationRegion3D" unique_id=4201010 groups=["occluder"]]
|
||||||
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 40, 0, 0)
|
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 40, 0, 0)
|
||||||
collision_mask = 3
|
collision_mask = 3
|
||||||
|
|
@ -996,6 +1037,27 @@ surface_material_override/0 = SubResource("Mat_physics_ramp_test")
|
||||||
[node name="CollisionShape3D" type="CollisionShape3D" parent="World/NavigationRegion3D/PhysicsRampTestWalkRamp" unique_id=4201022]
|
[node name="CollisionShape3D" type="CollisionShape3D" parent="World/NavigationRegion3D/PhysicsRampTestWalkRamp" unique_id=4201022]
|
||||||
shape = SubResource("BoxShape3D_physics_ramp_test_walk")
|
shape = SubResource("BoxShape3D_physics_ramp_test_walk")
|
||||||
|
|
||||||
|
[node name="PhysicsSmoothHillTest" type="StaticBody3D" parent="World/NavigationRegion3D" unique_id=4201040 groups=["walkable"]]
|
||||||
|
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 34, 0, 0)
|
||||||
|
collision_layer = 1
|
||||||
|
collision_mask = 3
|
||||||
|
script = ExtResource("10_smooth_hill")
|
||||||
|
|
||||||
|
[node name="MeshInstance3D" type="MeshInstance3D" parent="World/NavigationRegion3D/PhysicsSmoothHillTest" unique_id=4201041]
|
||||||
|
|
||||||
|
[node name="CollisionShape3D" type="CollisionShape3D" parent="World/NavigationRegion3D/PhysicsSmoothHillTest" unique_id=4201042]
|
||||||
|
|
||||||
|
[node name="PhysicsSmoothDipTest" type="StaticBody3D" parent="World/NavigationRegion3D" unique_id=4201043 groups=["walkable"]]
|
||||||
|
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 43, 0, 0)
|
||||||
|
collision_layer = 1
|
||||||
|
collision_mask = 3
|
||||||
|
script = ExtResource("10_smooth_hill")
|
||||||
|
kind = 1
|
||||||
|
|
||||||
|
[node name="MeshInstance3D" type="MeshInstance3D" parent="World/NavigationRegion3D/PhysicsSmoothDipTest" unique_id=4201044]
|
||||||
|
|
||||||
|
[node name="CollisionShape3D" type="CollisionShape3D" parent="World/NavigationRegion3D/PhysicsSmoothDipTest" unique_id=4201045]
|
||||||
|
|
||||||
[node name="PhysicsRampTestSteepRamp" type="StaticBody3D" parent="World" unique_id=4201030]
|
[node name="PhysicsRampTestSteepRamp" type="StaticBody3D" parent="World" unique_id=4201030]
|
||||||
transform = Transform3D(-0.6, -0.8, 0, 0.8, 0.6, 0, 0, 0, 1, 42.75, 1, 0)
|
transform = Transform3D(-0.6, -0.8, 0, 0.8, 0.6, 0, 0, 0, 1, 42.75, 1, 0)
|
||||||
collision_layer = 1
|
collision_layer = 1
|
||||||
|
|
|
||||||
|
|
@ -7,10 +7,13 @@ extends Node3D
|
||||||
## (see `isometric_follow_camera.gd`).
|
## (see `isometric_follow_camera.gd`).
|
||||||
## Prototype: two random short bumps (sibling StaticBody3D under NavigationRegion3D; see
|
## Prototype: two random short bumps (sibling StaticBody3D under NavigationRegion3D; see
|
||||||
## `random_floor_bumps.gd`) before nav bake.
|
## `random_floor_bumps.gd`) before nav bake.
|
||||||
## Prototype: `PhysicsRampTest*` (+X floor extension, block, gentle ramp on **+Z**, steep on **+X**).
|
## Prototype: `PhysicsRampTest*` (+X extension: west slab ends before the dip;
|
||||||
## Steep ramp: **2 m / 1.5 m** rise/run (~53°) so slope exceeds `Player` `floor_max_angle` (~50°) and is not
|
## `PhysicsRampTestFloorExtensionNorthFill` / `SouthFill` restore walkable floor past the dip in ±Z;
|
||||||
## walkable; gentle ramp stays shallower than `NavigationMesh.agent_max_climb`. Steep is under `World`
|
## block; gentle ramp on **+Z**; steep on **+X**), plus `PhysicsSmoothHillTest` /
|
||||||
## only (not nav source geometry).
|
## `PhysicsSmoothDipTest` (HeightMap collision).
|
||||||
|
## Steep ramp: **2 m / 1.5 m** rise/run (~53°) so slope exceeds `Player` `floor_max_angle` (~50°)
|
||||||
|
## and is not walkable; gentle ramp stays shallower than `NavigationMesh.agent_max_climb`. Steep is
|
||||||
|
## under `World` only (not nav source geometry).
|
||||||
## Prototype HUD: world `CharacterBody3D` position in `UICanvas/PlayerPositionLabel`
|
## Prototype HUD: world `CharacterBody3D` position in `UICanvas/PlayerPositionLabel`
|
||||||
## (updated in `_physics_process` so it matches physics ticks).
|
## (updated in `_physics_process` so it matches physics ticks).
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -12,7 +12,9 @@ const FLOOR_MAX_ANGLE_MOVING_DEG: float = 50.0
|
||||||
## Idle: tighter angle to reduce vertex / vertical-face flicker (~35°).
|
## Idle: tighter angle to reduce vertex / vertical-face flicker (~35°).
|
||||||
const FLOOR_MAX_ANGLE_IDLE_DEG: float = 35.0
|
const FLOOR_MAX_ANGLE_IDLE_DEG: float = 35.0
|
||||||
## Physics ticks to keep moving floor angle after walk goal clears (arrival).
|
## Physics ticks to keep moving floor angle after walk goal clears (arrival).
|
||||||
const FLOOR_ANGLE_LOOSE_TICKS_AFTER_STOP: int = 96
|
## Post-walk window for wider [member floor_max_angle] (~0.4 s @ 120 Hz); shorter tightens idle
|
||||||
|
## contact sooner and helps the anchor latch feel snappy on flat / mild slopes.
|
||||||
|
const FLOOR_ANGLE_LOOSE_TICKS_AFTER_STOP: int = 48
|
||||||
const ARRIVE_EPS: float = 0.35
|
const ARRIVE_EPS: float = 0.35
|
||||||
const VERT_ARRIVE_EPS: float = 0.055
|
const VERT_ARRIVE_EPS: float = 0.055
|
||||||
const DIRECT_APPROACH_RADIUS: float = 0.85
|
const DIRECT_APPROACH_RADIUS: float = 0.85
|
||||||
|
|
@ -28,16 +30,29 @@ const NAV_COLUMN_STEER_EXIT_DIST: float = 0.97
|
||||||
const NAV_PATH_STEER_MIN_LOOKAHEAD: float = 0.22
|
const NAV_PATH_STEER_MIN_LOOKAHEAD: float = 0.22
|
||||||
## If lookahead steering points more than ~105° from the click direction (XZ), use direct goal steer instead.
|
## If lookahead steering points more than ~105° from the click direction (XZ), use direct goal steer instead.
|
||||||
const NAV_PATH_STEER_MIN_GOAL_DOT: float = -0.25
|
const NAV_PATH_STEER_MIN_GOAL_DOT: float = -0.25
|
||||||
|
## When the new lookahead unit is almost opposite the last applied nav steer, slerp slowly — stops ±[code]MOVE_SPEED[/code]
|
||||||
|
## flips while [code]vlat && ncol[/code] (Jolt nudges XZ → different first waypoint past [member NAV_PATH_STEER_MIN_LOOKAHEAD]).
|
||||||
|
const NAV_PATH_STEER_SMOOTH_DOT: float = 0.22
|
||||||
|
const NAV_PATH_STEER_SMOOTH_SLERP: float = 0.58
|
||||||
|
const NAV_PATH_STEER_SMOOTH_SLERP_OPPOSING: float = 0.16
|
||||||
## While [code]vlat && ncol[/code], ledge peel + zero floor snap fight [method move_and_slide] on approach treads
|
## While [code]vlat && ncol[/code], ledge peel + zero floor snap fight [method move_and_slide] on approach treads
|
||||||
## (3-cycle NEON-16 traces: same XZ triplet, Y/feet_y ping-pong). Suspend peel and allow normal snap when the
|
## (3-cycle NEON-16 traces: same XZ triplet, Y/feet_y ping-pong). Suspend only when [code]vsep[/code] is
|
||||||
## feet are within this vertical distance of the goal **surface** (m).
|
## **tread-scale** — if this matched tall-pad descents (~0.48 m), column steering stays true while [code]vsep[/code]
|
||||||
const WALK_PEEL_SUSPEND_VERT_SEP: float = 0.48
|
## shrinks from metres to zero and peel was off for the last ~half metre (orange block slow float).
|
||||||
|
## Just above **two** QA tread drops (~0.208 m) so double risers still suspend.
|
||||||
|
const WALK_PEEL_COLUMN_SUSPEND_MAX_VSEP: float = 0.23
|
||||||
## Fallback when [code]vlat[/code] has unlatched (feet and goal both near the same slab Y) but
|
## Fallback when [code]vlat[/code] has unlatched (feet and goal both near the same slab Y) but
|
||||||
## [code]ncol[/code] is false because [code]horiz_dist[/code] is past column hysteresis — peel + zero
|
## [code]ncol[/code] is false because [code]horiz_dist[/code] is past column hysteresis — peel + zero
|
||||||
## snap still oscillates on violet treads (NEON-16 ~y=0.92). Slightly below [member WALK_PEEL_SUSPEND_VERT_SEP]
|
## snap still oscillates on violet treads (NEON-16 ~y=0.92). Slightly below a single tall lip so shallow
|
||||||
## so shallow lips (~0.48 m) still peel; widened from 0.34 to cover ~0.35–0.47 m stair/goal gaps.
|
## lips (~0.35–0.47 m) still peel vs [member WALK_PEEL_SUSPEND_HORIZ_VERT_SEP].
|
||||||
const WALK_PEEL_SUSPEND_HORIZ_VERT_SEP: float = 0.46
|
const WALK_PEEL_SUSPEND_HORIZ_VERT_SEP: float = 0.46
|
||||||
const WALK_PEEL_SUSPEND_HORIZ_DIST: float = 5.0
|
const WALK_PEEL_SUSPEND_HORIZ_DIST: float = 5.0
|
||||||
|
## Broad [method _walk_peel_suspend_near_goal] (horiz + [member WALK_PEEL_SUSPEND_HORIZ_VERT_SEP]) is for
|
||||||
|
## tread-height wobble when XZ is already near the click. On **tall pads** (orange reject block, terraces),
|
||||||
|
## feet can stay tens of cm above the goal while [code]horiz_dist < 5[/code] — suspend then kills ledge peel
|
||||||
|
## for the whole last ~0.46 m of drop → [code]is_on_floor()[/code] + no [code]velocity.y[/code] feels like a slow float.
|
||||||
|
## Only allow that broad branch once the feet are within this of the goal surface **while descending**.
|
||||||
|
const WALK_PEEL_SUSPEND_BROAD_MAX_FEET_ABOVE_GOAL: float = 0.22
|
||||||
## With [method _walk_peel_suspend_near_goal] (tread snap/peel stability), zero XZ [member velocity] when
|
## With [method _walk_peel_suspend_near_goal] (tread snap/peel stability), zero XZ [member velocity] when
|
||||||
## close in XZ but vertical arrival still unresolved — avoids band-edge flicker from a tight [code]vert_sep[/code]
|
## close in XZ but vertical arrival still unresolved — avoids band-edge flicker from a tight [code]vert_sep[/code]
|
||||||
## window. [member WALK_PEEL_SUSPEND_HORIZ_DIST] can be 5 m; cap XZ here so we never freeze long crosses.
|
## window. [member WALK_PEEL_SUSPEND_HORIZ_DIST] can be 5 m; cap XZ here so we never freeze long crosses.
|
||||||
|
|
@ -62,6 +77,15 @@ const WALK_COLUMN_NEAR_ARRIVE_GATE_VERT_ERR: float = 0.22
|
||||||
const WALK_COLUMN_STUCK_FRAMES: int = 28
|
const WALK_COLUMN_STUCK_FRAMES: int = 28
|
||||||
const WALK_COLUMN_STUCK_MAX_DRIFT: float = 0.045
|
const WALK_COLUMN_STUCK_MAX_DRIFT: float = 0.045
|
||||||
const WALK_COLUMN_STUCK_HORIZ_MAX: float = 0.68
|
const WALK_COLUMN_STUCK_HORIZ_MAX: float = 0.68
|
||||||
|
## After [method _walk_clip_horizontal_velocity_against_vertical_contacts], Jolt can alternate which
|
||||||
|
## vertical face “wins” on stair/column seams → post-clip [code]velocity.xz[/code] flips ~180° at
|
||||||
|
## ~[code]MOVE_SPEED[/code] while [code]horiz_dist[/code] stays large ([code]ncol[/code] hysteresis).
|
||||||
|
## Dot below this vs last tick’s post-clip XZ → zero horizontal + brief suppress (NEO-14 traces).
|
||||||
|
const WALK_COL_SEAM_FLIP_DOT: float = -0.7
|
||||||
|
## Treat post-clip speed as “full fight” when above this fraction of [member MOVE_SPEED].
|
||||||
|
const WALK_COL_SEAM_SPEED_FRAC: float = 0.82
|
||||||
|
## Physics ticks to hold [code]velocity.xz == 0[/code] after a seam flip (odd/even frame alternation).
|
||||||
|
const WALK_COL_SEAM_SUPPRESS_TICKS: int = 3
|
||||||
## Hysteresis for [code]needs_vertical_routing[/code]: [code]feet_y[/code] wobbles with
|
## Hysteresis for [code]needs_vertical_routing[/code]: [code]feet_y[/code] wobbles with
|
||||||
## [code]move_and_slide[/code] on treads (~7 cm), so a single margin (see [member DESCEND_GOAL_Y_MARGIN])
|
## [code]move_and_slide[/code] on treads (~7 cm), so a single margin (see [member DESCEND_GOAL_Y_MARGIN])
|
||||||
## can flip path vs direct steering every tick → velocity sign ping-pong while [code]has_goal[/code].
|
## can flip path vs direct steering every tick → velocity sign ping-pong while [code]has_goal[/code].
|
||||||
|
|
@ -85,9 +109,13 @@ const STABLE_IDLE_FLOOR_MIN_UP_DOT: float = 0.998
|
||||||
## While [member _idle_stable_latched], allow slightly tilted reported normals (tread/riser edge).
|
## While [member _idle_stable_latched], allow slightly tilted reported normals (tread/riser edge).
|
||||||
const STABLE_IDLE_FLOOR_HOLD_MIN_UP_DOT: float = 0.992
|
const STABLE_IDLE_FLOOR_HOLD_MIN_UP_DOT: float = 0.992
|
||||||
## Rare edge contacts can keep corrective nudges alive forever; budget them, then hold x/z.
|
## Rare edge contacts can keep corrective nudges alive forever; budget them, then hold x/z.
|
||||||
const IDLE_MANUAL_CORRECTION_MAX_TICKS: int = 8
|
const IDLE_MANUAL_CORRECTION_MAX_TICKS: int = 5
|
||||||
## Consecutive [code]raw_stable[/code] ticks required before first idle anchor latch (entry debounce).
|
## Consecutive [code]raw_stable[/code] ticks before first idle anchor latch. Two was safe but Jolt
|
||||||
const STABLE_IDLE_ENTER_STREAK_FRAMES: int = 2
|
## heightmaps / slopes often insert a one-frame false negative → ~1 s extra jitter before latch.
|
||||||
|
const STABLE_IDLE_ENTER_STREAK_FRAMES: int = 1
|
||||||
|
## When not [code]raw_stable[/code] and not yet latched, decay the enter streak instead of zeroing
|
||||||
|
## (brief floor-normal flicker no longer wipes two good ticks).
|
||||||
|
const STABLE_IDLE_ENTER_MISS_DECAY: int = 1
|
||||||
## Consecutive unstable-idle physics ticks before leaving latched stable idle (thin tread lip).
|
## Consecutive unstable-idle physics ticks before leaving latched stable idle (thin tread lip).
|
||||||
const STABLE_IDLE_UNLATCH_TICKS: int = 10
|
const STABLE_IDLE_UNLATCH_TICKS: int = 10
|
||||||
## Horizontal nudge per tick for rim / straddle settle (**`_maybe_idle_rim_settle_nudge`**).
|
## Horizontal nudge per tick for rim / straddle settle (**`_maybe_idle_rim_settle_nudge`**).
|
||||||
|
|
@ -177,6 +205,8 @@ var _debug_last_transform_xz: Vector2 = Vector2.INF
|
||||||
var _debug_trace_frame: int = 0
|
var _debug_trace_frame: int = 0
|
||||||
## True while we use [method _set_horizontal_velocity_from_nav_path_or_goal] for vertical routing.
|
## True while we use [method _set_horizontal_velocity_from_nav_path_or_goal] for vertical routing.
|
||||||
var _walk_nav_column_steering: bool = false
|
var _walk_nav_column_steering: bool = false
|
||||||
|
## Last horizontal nav-path steer unit (XZ); eases 180° waypoint switches (see [member NAV_PATH_STEER_SMOOTH_DOT]).
|
||||||
|
var _walk_nav_path_steer_hz: Vector3 = Vector3.ZERO
|
||||||
## Schmitt latch for vertical routing (see [member WALK_VERT_ROUTE_LATCH_ON_SEP]).
|
## Schmitt latch for vertical routing (see [member WALK_VERT_ROUTE_LATCH_ON_SEP]).
|
||||||
var _walk_vert_route_latched: bool = false
|
var _walk_vert_route_latched: bool = false
|
||||||
## GROUNDED [method move_and_slide] clears downward [code]velocity.y[/code] on floor; accumulate peel
|
## GROUNDED [method move_and_slide] clears downward [code]velocity.y[/code] on floor; accumulate peel
|
||||||
|
|
@ -188,6 +218,9 @@ var _walk_peel_no_probe_streak: int = 0
|
||||||
var _walk_debounced_wants_ledge_peel: bool = false
|
var _walk_debounced_wants_ledge_peel: bool = false
|
||||||
var _walk_column_stuck_frame: int = 0
|
var _walk_column_stuck_frame: int = 0
|
||||||
var _walk_column_stuck_origin: Vector3 = Vector3.ZERO
|
var _walk_column_stuck_origin: Vector3 = Vector3.ZERO
|
||||||
|
## Post-clip XZ from last physics tick; used with [member WALK_COL_SEAM_FLIP_DOT] while [code]vlat && ncol[/code].
|
||||||
|
var _walk_col_seam_prev_hz: Vector2 = Vector2.ZERO
|
||||||
|
var _walk_col_seam_suppress_ticks: int = 0
|
||||||
@onready var _nav_agent: NavigationAgent3D = $NavigationAgent3D
|
@onready var _nav_agent: NavigationAgent3D = $NavigationAgent3D
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -212,10 +245,13 @@ func set_authoritative_nav_goal(world_pos: Vector3) -> void:
|
||||||
_idle_stable_enter_streak = 0
|
_idle_stable_enter_streak = 0
|
||||||
_walk_nav_column_steering = false
|
_walk_nav_column_steering = false
|
||||||
_walk_vert_route_latched = false
|
_walk_vert_route_latched = false
|
||||||
|
_walk_nav_path_steer_hz = Vector3.ZERO
|
||||||
_walk_ledge_peel_vy = 0.0
|
_walk_ledge_peel_vy = 0.0
|
||||||
_walk_peel_no_probe_streak = 0
|
_walk_peel_no_probe_streak = 0
|
||||||
_walk_debounced_wants_ledge_peel = false
|
_walk_debounced_wants_ledge_peel = false
|
||||||
_walk_column_stuck_frame = 0
|
_walk_column_stuck_frame = 0
|
||||||
|
_walk_col_seam_prev_hz = Vector2.ZERO
|
||||||
|
_walk_col_seam_suppress_ticks = 0
|
||||||
_nav_agent.set_target_position(world_pos)
|
_nav_agent.set_target_position(world_pos)
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -232,9 +268,12 @@ func clear_nav_goal() -> void:
|
||||||
_idle_stable_enter_streak = 0
|
_idle_stable_enter_streak = 0
|
||||||
_walk_nav_column_steering = false
|
_walk_nav_column_steering = false
|
||||||
_walk_vert_route_latched = false
|
_walk_vert_route_latched = false
|
||||||
|
_walk_nav_path_steer_hz = Vector3.ZERO
|
||||||
_walk_peel_no_probe_streak = 0
|
_walk_peel_no_probe_streak = 0
|
||||||
_walk_debounced_wants_ledge_peel = false
|
_walk_debounced_wants_ledge_peel = false
|
||||||
_walk_column_stuck_frame = 0
|
_walk_column_stuck_frame = 0
|
||||||
|
_walk_col_seam_prev_hz = Vector2.ZERO
|
||||||
|
_walk_col_seam_suppress_ticks = 0
|
||||||
_nav_agent.set_target_position(global_position)
|
_nav_agent.set_target_position(global_position)
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -279,6 +318,9 @@ func snap_to_server(world_pos: Vector3) -> void:
|
||||||
_idle_stable_enter_streak = 0
|
_idle_stable_enter_streak = 0
|
||||||
_walk_nav_column_steering = false
|
_walk_nav_column_steering = false
|
||||||
_walk_vert_route_latched = false
|
_walk_vert_route_latched = false
|
||||||
|
_walk_nav_path_steer_hz = Vector3.ZERO
|
||||||
|
_walk_col_seam_prev_hz = Vector2.ZERO
|
||||||
|
_walk_col_seam_suppress_ticks = 0
|
||||||
_nav_agent.set_target_position(settled)
|
_nav_agent.set_target_position(settled)
|
||||||
reset_physics_interpolation()
|
reset_physics_interpolation()
|
||||||
|
|
||||||
|
|
@ -335,6 +377,82 @@ func _step_assist_wallish_blocks(want_dir_xz: Vector3) -> bool:
|
||||||
return false
|
return false
|
||||||
|
|
||||||
|
|
||||||
|
## Remove horizontal velocity into **vertical** contacts using last tick's slide normals (and
|
||||||
|
## [method get_wall_normal] when [method is_on_wall]). Capsule lips / floor seams often never set
|
||||||
|
## [method is_on_wall] while [method move_and_slide] still fights full [code]MOVE_SPEED[/code] into
|
||||||
|
## the face → jitter. Same plane test as [method _step_assist_wallish_blocks]: [code]dot < -0.02[/code].
|
||||||
|
func _walk_clip_horizontal_velocity_against_vertical_contacts(want_goal_h: Vector3) -> void:
|
||||||
|
var v2 := Vector2(velocity.x, velocity.z)
|
||||||
|
for _iter: int in range(4):
|
||||||
|
var v_before: Vector2 = v2
|
||||||
|
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-10:
|
||||||
|
continue
|
||||||
|
n2 = n2.normalized()
|
||||||
|
var dot_v: float = v2.dot(n2)
|
||||||
|
if dot_v < -0.02:
|
||||||
|
v2 -= n2 * dot_v
|
||||||
|
if is_on_wall():
|
||||||
|
var wn: Vector3 = get_wall_normal()
|
||||||
|
if wn.y <= 0.35:
|
||||||
|
var wnh := Vector2(wn.x, wn.z)
|
||||||
|
if wnh.length_squared() > 1e-10:
|
||||||
|
wnh = wnh.normalized()
|
||||||
|
var dot_w: float = v2.dot(wnh)
|
||||||
|
if dot_w < -0.02:
|
||||||
|
v2 -= wnh * dot_w
|
||||||
|
if v2.distance_squared_to(v_before) < 1e-12:
|
||||||
|
break
|
||||||
|
# Goal through a vertical blocker but only micro tangential slip survives → kill floaty XZ.
|
||||||
|
var want3 := Vector3(want_goal_h.x, 0.0, want_goal_h.z)
|
||||||
|
if want3.length_squared() > 1e-10 and _step_assist_wallish_blocks(want3.normalized()):
|
||||||
|
if v2.length_squared() < 0.0025:
|
||||||
|
v2 = Vector2.ZERO
|
||||||
|
velocity.x = v2.x
|
||||||
|
velocity.z = v2.y
|
||||||
|
|
||||||
|
|
||||||
|
## While [code]_walk_vert_route_latched && _walk_nav_column_steering[/code], seam contacts can make
|
||||||
|
## [method _walk_clip_horizontal_velocity_against_vertical_contacts] output ~opposite [code]velocity.xz[/code]
|
||||||
|
## on consecutive ticks at nearly [member MOVE_SPEED] (slide normal order / riser vs tread).
|
||||||
|
func _walk_damp_vert_route_column_seam_flip() -> void:
|
||||||
|
if not (_walk_vert_route_latched and _walk_nav_column_steering):
|
||||||
|
_walk_col_seam_prev_hz = Vector2.ZERO
|
||||||
|
_walk_col_seam_suppress_ticks = 0
|
||||||
|
return
|
||||||
|
if _walk_col_seam_suppress_ticks > 0:
|
||||||
|
_walk_col_seam_suppress_ticks -= 1
|
||||||
|
velocity.x = 0.0
|
||||||
|
velocity.z = 0.0
|
||||||
|
_walk_col_seam_prev_hz = Vector2.ZERO
|
||||||
|
return
|
||||||
|
var hz := Vector2(velocity.x, velocity.z)
|
||||||
|
var len2: float = hz.length_squared()
|
||||||
|
var thr: float = MOVE_SPEED * WALK_COL_SEAM_SPEED_FRAC
|
||||||
|
var thr2: float = thr * thr
|
||||||
|
if len2 < thr2 * 0.2:
|
||||||
|
if len2 > 1e-8:
|
||||||
|
_walk_col_seam_prev_hz = hz
|
||||||
|
else:
|
||||||
|
_walk_col_seam_prev_hz = Vector2.ZERO
|
||||||
|
return
|
||||||
|
var pl2: float = _walk_col_seam_prev_hz.length_squared()
|
||||||
|
if pl2 < thr2 * 0.16:
|
||||||
|
_walk_col_seam_prev_hz = hz
|
||||||
|
return
|
||||||
|
if hz.normalized().dot(_walk_col_seam_prev_hz.normalized()) < WALK_COL_SEAM_FLIP_DOT:
|
||||||
|
velocity.x = 0.0
|
||||||
|
velocity.z = 0.0
|
||||||
|
_walk_col_seam_prev_hz = Vector2.ZERO
|
||||||
|
_walk_col_seam_suppress_ticks = WALK_COL_SEAM_SUPPRESS_TICKS
|
||||||
|
else:
|
||||||
|
_walk_col_seam_prev_hz = hz
|
||||||
|
|
||||||
|
|
||||||
func _step_assist_can_raise_by(dy: float) -> bool:
|
func _step_assist_can_raise_by(dy: float) -> bool:
|
||||||
if dy <= 0.0001:
|
if dy <= 0.0001:
|
||||||
return false
|
return false
|
||||||
|
|
@ -407,8 +525,11 @@ func _set_horizontal_velocity_toward(
|
||||||
dh = Vector3(1.0, 0.0, 0.0)
|
dh = Vector3(1.0, 0.0, 0.0)
|
||||||
else:
|
else:
|
||||||
dh = dh.normalized()
|
dh = dh.normalized()
|
||||||
|
# Same tick as [method _apply_walk_air_gravity]: zeroing [code]velocity.y[/code] every frame while
|
||||||
|
# airborne capped fall to one gravity delta per tick (~constant sink rate off tall pads).
|
||||||
|
var vy_air: float = velocity.y
|
||||||
velocity = dh * MOVE_SPEED
|
velocity = dh * MOVE_SPEED
|
||||||
velocity.y = 0.0
|
velocity.y = 0.0 if is_on_floor() else vy_air
|
||||||
|
|
||||||
|
|
||||||
func _walk_probe_xz_offsets(move_dir_xz: Vector2) -> Array[Vector2]:
|
func _walk_probe_xz_offsets(move_dir_xz: Vector2) -> Array[Vector2]:
|
||||||
|
|
@ -549,8 +670,14 @@ func _walk_try_release_column_stuck_orbit(horiz_dist: float) -> bool:
|
||||||
|
|
||||||
func _walk_peel_suspend_near_goal(feet_y: float, horiz_dist: float) -> bool:
|
func _walk_peel_suspend_near_goal(feet_y: float, horiz_dist: float) -> bool:
|
||||||
var vsep: float = absf(_auth_walk_goal.y - feet_y)
|
var vsep: float = absf(_auth_walk_goal.y - feet_y)
|
||||||
if _walk_vert_route_latched and _walk_nav_column_steering and vsep < WALK_PEEL_SUSPEND_VERT_SEP:
|
if _walk_vert_route_latched and _walk_nav_column_steering and vsep < WALK_PEEL_COLUMN_SUSPEND_MAX_VSEP:
|
||||||
return true
|
return true
|
||||||
|
var descending: bool = _auth_walk_goal.y < feet_y - DESCEND_GOAL_Y_MARGIN
|
||||||
|
if (
|
||||||
|
descending
|
||||||
|
and feet_y > _auth_walk_goal.y + WALK_PEEL_SUSPEND_BROAD_MAX_FEET_ABOVE_GOAL
|
||||||
|
):
|
||||||
|
return false
|
||||||
return vsep <= WALK_PEEL_SUSPEND_HORIZ_VERT_SEP and horiz_dist <= WALK_PEEL_SUSPEND_HORIZ_DIST
|
return vsep <= WALK_PEEL_SUSPEND_HORIZ_VERT_SEP and horiz_dist <= WALK_PEEL_SUSPEND_HORIZ_DIST
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -699,6 +826,7 @@ func _set_horizontal_velocity_from_nav_path_or_goal(fallback_goal_xz: Vector3) -
|
||||||
# Scan the baked path for the first XZ waypoint at least [member NAV_PATH_STEER_MIN_LOOKAHEAD]
|
# Scan the baked path for the first XZ waypoint at least [member NAV_PATH_STEER_MIN_LOOKAHEAD]
|
||||||
# away (unlike the old 5 cm scan). Reject segments that point opposite the click (XZ).
|
# away (unlike the old 5 cm scan). Reject segments that point opposite the click (XZ).
|
||||||
if _nav_agent.is_navigation_finished():
|
if _nav_agent.is_navigation_finished():
|
||||||
|
_walk_nav_path_steer_hz = Vector3.ZERO
|
||||||
_set_horizontal_velocity_toward(_auth_walk_goal, fallback_goal_xz)
|
_set_horizontal_velocity_toward(_auth_walk_goal, fallback_goal_xz)
|
||||||
return
|
return
|
||||||
var pos := global_position
|
var pos := global_position
|
||||||
|
|
@ -719,6 +847,7 @@ func _set_horizontal_velocity_from_nav_path_or_goal(fallback_goal_xz: Vector3) -
|
||||||
steer_point = path[path.size() - 1]
|
steer_point = path[path.size() - 1]
|
||||||
var dh: Vector3 = Vector3(steer_point.x - pos.x, 0.0, steer_point.z - pos.z)
|
var dh: Vector3 = Vector3(steer_point.x - pos.x, 0.0, steer_point.z - pos.z)
|
||||||
if dh.length_squared() < 1e-10:
|
if dh.length_squared() < 1e-10:
|
||||||
|
_walk_nav_path_steer_hz = Vector3.ZERO
|
||||||
_set_horizontal_velocity_toward(_auth_walk_goal, fallback_goal_xz)
|
_set_horizontal_velocity_toward(_auth_walk_goal, fallback_goal_xz)
|
||||||
return
|
return
|
||||||
var dir: Vector3 = dh.normalized()
|
var dir: Vector3 = dh.normalized()
|
||||||
|
|
@ -726,10 +855,28 @@ func _set_horizontal_velocity_from_nav_path_or_goal(fallback_goal_xz: Vector3) -
|
||||||
if gh.length_squared() > 1e-10:
|
if gh.length_squared() > 1e-10:
|
||||||
gh = gh.normalized()
|
gh = gh.normalized()
|
||||||
if dir.dot(gh) < NAV_PATH_STEER_MIN_GOAL_DOT:
|
if dir.dot(gh) < NAV_PATH_STEER_MIN_GOAL_DOT:
|
||||||
|
_walk_nav_path_steer_hz = Vector3.ZERO
|
||||||
_set_horizontal_velocity_toward(_auth_walk_goal, fallback_goal_xz)
|
_set_horizontal_velocity_toward(_auth_walk_goal, fallback_goal_xz)
|
||||||
return
|
return
|
||||||
velocity = dir * MOVE_SPEED
|
var dir_hz := Vector3(dir.x, 0.0, dir.z)
|
||||||
velocity.y = 0.0
|
if dir_hz.length_squared() < 1e-10:
|
||||||
|
_walk_nav_path_steer_hz = Vector3.ZERO
|
||||||
|
_set_horizontal_velocity_toward(_auth_walk_goal, fallback_goal_xz)
|
||||||
|
return
|
||||||
|
dir_hz = dir_hz.normalized()
|
||||||
|
if _walk_nav_path_steer_hz.length_squared() > 1e-10:
|
||||||
|
var prev := Vector3(_walk_nav_path_steer_hz.x, 0.0, _walk_nav_path_steer_hz.z).normalized()
|
||||||
|
var dotp: float = dir_hz.dot(prev)
|
||||||
|
var t: float = (
|
||||||
|
NAV_PATH_STEER_SMOOTH_SLERP_OPPOSING
|
||||||
|
if dotp < NAV_PATH_STEER_SMOOTH_DOT
|
||||||
|
else NAV_PATH_STEER_SMOOTH_SLERP
|
||||||
|
)
|
||||||
|
dir_hz = prev.slerp(dir_hz, t).normalized()
|
||||||
|
_walk_nav_path_steer_hz = dir_hz
|
||||||
|
var vy_air: float = velocity.y
|
||||||
|
velocity = dir_hz * MOVE_SPEED
|
||||||
|
velocity.y = 0.0 if is_on_floor() else vy_air
|
||||||
|
|
||||||
|
|
||||||
func _effective_gravity(_delta: float) -> Vector3:
|
func _effective_gravity(_delta: float) -> Vector3:
|
||||||
|
|
@ -780,6 +927,23 @@ static func idle_slide_contacts_are_ridged(slide_normals: Array[Vector3]) -> boo
|
||||||
return upish >= 1 and wallish >= 1
|
return upish >= 1 and wallish >= 1
|
||||||
|
|
||||||
|
|
||||||
|
## [method idle_slide_contacts_are_ridged] is true on heightmap facet seams (up + shallow “wall”
|
||||||
|
## micro-normals) while the floor normal’s up component still sits in the smooth single-slope band. Treat
|
||||||
|
## that like a uniform ramp: do not run idle rim nudges / stair-lip stability against it (QA hill).
|
||||||
|
## Same for **nearly level** aggregate normals (hill crest / corner): facet pairs are not tread+riser.
|
||||||
|
static func idle_ridged_stair_lip_only(slide_normals: Array[Vector3], floor_up_dot: float) -> bool:
|
||||||
|
if not idle_slide_contacts_are_ridged(slide_normals):
|
||||||
|
return false
|
||||||
|
if floor_up_dot >= STABLE_IDLE_FLOOR_MIN_UP_DOT:
|
||||||
|
return false
|
||||||
|
if (
|
||||||
|
floor_up_dot >= IDLE_SLOPE_STABLE_MIN_UP_DOT
|
||||||
|
and floor_up_dot < IDLE_RIM_MIN_FLOOR_UP_DOT
|
||||||
|
):
|
||||||
|
return false
|
||||||
|
return true
|
||||||
|
|
||||||
|
|
||||||
static func idle_support_is_stable(
|
static func idle_support_is_stable(
|
||||||
on_floor: bool,
|
on_floor: bool,
|
||||||
floor_normal: Vector3,
|
floor_normal: Vector3,
|
||||||
|
|
@ -789,8 +953,8 @@ static func idle_support_is_stable(
|
||||||
) -> bool:
|
) -> bool:
|
||||||
if not on_floor:
|
if not on_floor:
|
||||||
return false
|
return false
|
||||||
var ridged: bool = idle_slide_contacts_are_ridged(slide_normals)
|
|
||||||
var up_dot: float = floor_normal.dot(Vector3.UP)
|
var up_dot: float = floor_normal.dot(Vector3.UP)
|
||||||
|
var ridged: bool = idle_ridged_stair_lip_only(slide_normals, up_dot)
|
||||||
# Single-surface inclines (walk ramps): flatter than [member STABLE_IDLE_FLOOR_MIN_UP_DOT] but not
|
# Single-surface inclines (walk ramps): flatter than [member STABLE_IDLE_FLOOR_MIN_UP_DOT] but not
|
||||||
# stair lip geometry — latch stable idle instead of endless corrective [method _physics_idle_tick].
|
# stair lip geometry — latch stable idle instead of endless corrective [method _physics_idle_tick].
|
||||||
if (
|
if (
|
||||||
|
|
@ -946,10 +1110,9 @@ func _idle_ridged_floor_contacts() -> bool:
|
||||||
func _maybe_idle_rim_settle_nudge() -> bool:
|
func _maybe_idle_rim_settle_nudge() -> bool:
|
||||||
if get_slide_collision_count() < 1:
|
if get_slide_collision_count() < 1:
|
||||||
return false
|
return false
|
||||||
var shallow_floor: bool = (
|
var floor_up_dot: float = get_floor_normal().dot(Vector3.UP)
|
||||||
is_on_floor() and get_floor_normal().dot(Vector3.UP) < IDLE_RIM_MIN_FLOOR_UP_DOT
|
var shallow_floor: bool = is_on_floor() and floor_up_dot < IDLE_RIM_MIN_FLOOR_UP_DOT
|
||||||
)
|
var ridged: bool = idle_ridged_stair_lip_only(_current_slide_normals(), floor_up_dot)
|
||||||
var ridged: bool = _idle_ridged_floor_contacts()
|
|
||||||
# Uniform walkable ramps (one dominant sloped floor, no tread+riser pair) still read as “shallow”
|
# Uniform walkable ramps (one dominant sloped floor, no tread+riser pair) still read as “shallow”
|
||||||
# vs [member IDLE_RIM_MIN_FLOOR_UP_DOT] — horizontal rim nudges slide along the ramp every tick → jitter.
|
# vs [member IDLE_RIM_MIN_FLOOR_UP_DOT] — horizontal rim nudges slide along the ramp every tick → jitter.
|
||||||
if shallow_floor and not ridged:
|
if shallow_floor and not ridged:
|
||||||
|
|
@ -1056,13 +1219,19 @@ func _snap_capsule_upright() -> void:
|
||||||
func _physics_process(delta: float) -> void:
|
func _physics_process(delta: float) -> void:
|
||||||
_debug_trace_frame += 1
|
_debug_trace_frame += 1
|
||||||
# Do not tie wide floor angle to `_floor_angle_loose_ticks` alone: after a walk on **flat**
|
# Do not tie wide floor angle to `_floor_angle_loose_ticks` alone: after a walk on **flat**
|
||||||
# ground that still widened angle for ~0.8 s, `move_and_slide` + idle snap caused visible XZ
|
# ground that still widened angle for the whole loose-tick window, `move_and_slide` + idle snap
|
||||||
# jitter. Use moving max angle only while walking or when idle support is shallow / ridged.
|
# caused visible XZ jitter. Use moving max angle only while walking or when idle support is shallow / ridged.
|
||||||
var use_loose_floor_angle: bool = _has_walk_goal
|
var use_loose_floor_angle: bool = _has_walk_goal
|
||||||
if not _has_walk_goal:
|
if not _has_walk_goal:
|
||||||
var floor_up_dot: float = get_floor_normal().dot(Vector3.UP)
|
var floor_up_dot: float = get_floor_normal().dot(Vector3.UP)
|
||||||
var shallow_idle_floor: bool = is_on_floor() and floor_up_dot < IDLE_RIM_MIN_FLOOR_UP_DOT
|
var shallow_idle_floor: bool = is_on_floor() and floor_up_dot < IDLE_RIM_MIN_FLOOR_UP_DOT
|
||||||
if shallow_idle_floor or _idle_ridged_floor_contacts():
|
# Raw facet ridging reads true on a heightmap **crest** while [code]get_floor_normal()[/code] is
|
||||||
|
# already ~level; widening [member floor_max_angle] there makes [method move_and_slide] hunt
|
||||||
|
# forever (HUD jitter on the pink hill tip).
|
||||||
|
if (
|
||||||
|
(shallow_idle_floor or idle_slide_contacts_are_ridged(_current_slide_normals()))
|
||||||
|
and floor_up_dot < STABLE_IDLE_FLOOR_MIN_UP_DOT
|
||||||
|
):
|
||||||
use_loose_floor_angle = true
|
use_loose_floor_angle = true
|
||||||
floor_max_angle = deg_to_rad(
|
floor_max_angle = deg_to_rad(
|
||||||
FLOOR_MAX_ANGLE_MOVING_DEG if use_loose_floor_angle else FLOOR_MAX_ANGLE_IDLE_DEG
|
FLOOR_MAX_ANGLE_MOVING_DEG if use_loose_floor_angle else FLOOR_MAX_ANGLE_IDLE_DEG
|
||||||
|
|
@ -1079,14 +1248,21 @@ func _physics_process(delta: float) -> void:
|
||||||
else:
|
else:
|
||||||
_idle_stable_enter_streak = 0
|
_idle_stable_enter_streak = 0
|
||||||
elif _idle_stable_latched:
|
elif _idle_stable_latched:
|
||||||
_idle_stable_unlatch_streak += 1
|
var crest_quiet: bool = (
|
||||||
|
is_on_floor()
|
||||||
|
and get_floor_normal().dot(Vector3.UP) >= STABLE_IDLE_FLOOR_MIN_UP_DOT - 0.001
|
||||||
|
)
|
||||||
|
if raw_stable or crest_quiet:
|
||||||
|
_idle_stable_unlatch_streak = 0
|
||||||
|
else:
|
||||||
|
_idle_stable_unlatch_streak += 1
|
||||||
if _idle_stable_unlatch_streak >= STABLE_IDLE_UNLATCH_TICKS:
|
if _idle_stable_unlatch_streak >= STABLE_IDLE_UNLATCH_TICKS:
|
||||||
_idle_stable_latched = false
|
_idle_stable_latched = false
|
||||||
_idle_stable_unlatch_streak = 0
|
_idle_stable_unlatch_streak = 0
|
||||||
_idle_stable_enter_streak = 0
|
_idle_stable_enter_streak = 0
|
||||||
_idle_anchor_active = false
|
_idle_anchor_active = false
|
||||||
else:
|
else:
|
||||||
_idle_stable_enter_streak = 0
|
_idle_stable_enter_streak = maxi(0, _idle_stable_enter_streak - STABLE_IDLE_ENTER_MISS_DECAY)
|
||||||
if _idle_stable_latched:
|
if _idle_stable_latched:
|
||||||
_idle_manual_correction_ticks = 0
|
_idle_manual_correction_ticks = 0
|
||||||
velocity = Vector3.ZERO
|
velocity = Vector3.ZERO
|
||||||
|
|
@ -1179,6 +1355,8 @@ func _physics_process(delta: float) -> void:
|
||||||
_idle_stable_enter_streak = 0
|
_idle_stable_enter_streak = 0
|
||||||
_walk_nav_column_steering = false
|
_walk_nav_column_steering = false
|
||||||
_walk_vert_route_latched = false
|
_walk_vert_route_latched = false
|
||||||
|
_walk_col_seam_prev_hz = Vector2.ZERO
|
||||||
|
_walk_col_seam_suppress_ticks = 0
|
||||||
_nav_agent.set_target_position(global_position)
|
_nav_agent.set_target_position(global_position)
|
||||||
_physics_idle_tick(delta)
|
_physics_idle_tick(delta)
|
||||||
if _stable_idle_support():
|
if _stable_idle_support():
|
||||||
|
|
@ -1212,21 +1390,20 @@ func _physics_process(delta: float) -> void:
|
||||||
_apply_floor_block_for_descend_at_wall(feet_y)
|
_apply_floor_block_for_descend_at_wall(feet_y)
|
||||||
|
|
||||||
# Horizontal motion toward the **authoritative** click target (XZ) by default. Use the baked
|
# Horizontal motion toward the **authoritative** click target (XZ) by default. Use the baked
|
||||||
# path only when vertical routing is latched **and** column hysteresis says so **and** we are
|
# path only while **airborne** with column vertical routing (`on_floor=false` + `vlat && ncol`):
|
||||||
# on floor — mid-air column steering flips XZ velocity each tick against `move_and_slide`
|
# mid-air the next waypoint can carry tangential intent around a column. On **floor**, the same
|
||||||
# (NEON-16: `on_floor=false` + `vlat && ncol` while falling).
|
# path sampling on a narrow nav ribbon + shallow rim normals still flipped ±[code]MOVE_SPEED[/code]
|
||||||
# On **shallow ramps** (dot below [member IDLE_RIM_MIN_FLOOR_UP_DOT]), path waypoints sit on a
|
# every tick (Z seam traces: [code]ncol=true[/code] stable [code]on_floor[/code], ~4 cm Z wobble).
|
||||||
# narrow ribbon — lookahead flips 180° each tick → violent oscillation (debug: alternating ±vel).
|
# Shallow on-floor column approach therefore uses direct goal steer + [method _walk_clip_horizontal_velocity_against_vertical_contacts].
|
||||||
if (
|
if needs_vertical_routing and _walk_nav_column_steering and not is_on_floor():
|
||||||
needs_vertical_routing
|
|
||||||
and _walk_nav_column_steering
|
|
||||||
and is_on_floor()
|
|
||||||
and get_floor_normal().dot(Vector3.UP) >= IDLE_RIM_MIN_FLOOR_UP_DOT
|
|
||||||
):
|
|
||||||
_set_horizontal_velocity_from_nav_path_or_goal(want_goal_h)
|
_set_horizontal_velocity_from_nav_path_or_goal(want_goal_h)
|
||||||
else:
|
else:
|
||||||
|
_walk_nav_path_steer_hz = Vector3.ZERO
|
||||||
_set_horizontal_velocity_toward(_auth_walk_goal, want_goal_h)
|
_set_horizontal_velocity_toward(_auth_walk_goal, want_goal_h)
|
||||||
|
|
||||||
|
_walk_clip_horizontal_velocity_against_vertical_contacts(want_goal_h)
|
||||||
|
_walk_damp_vert_route_column_seam_flip()
|
||||||
|
|
||||||
if _walk_tread_freeze_horizontal_active(feet_y, horiz_dist):
|
if _walk_tread_freeze_horizontal_active(feet_y, horiz_dist):
|
||||||
velocity.x = 0.0
|
velocity.x = 0.0
|
||||||
velocity.z = 0.0
|
velocity.z = 0.0
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,124 @@
|
||||||
|
extends StaticBody3D
|
||||||
|
## Procedural smooth grid for the physics ramp strip: raised hill or sunken dip.
|
||||||
|
## Uses HeightMapShape3D for collision (CharacterBody3D tunnels through ConcavePolygonShape3D
|
||||||
|
## trimesh).
|
||||||
|
## Keep the extension slab (`PhysicsRampTestFloorExtension`) short of this body's xz footprint so a
|
||||||
|
## dip is not covered by a flat floor collider/render.
|
||||||
|
|
||||||
|
enum PieceKind { HILL, DIP }
|
||||||
|
|
||||||
|
@export var kind: PieceKind = PieceKind.HILL
|
||||||
|
@export var footprint_x: float = 7.0
|
||||||
|
@export var footprint_z: float = 4.5
|
||||||
|
@export var amplitude: float = 0.42
|
||||||
|
@export var subdiv_x: int = 36
|
||||||
|
@export var subdiv_z: int = 22
|
||||||
|
## Slight Y offset so the rim is not coplanar with the slab (reduces z-fighting with the floor).
|
||||||
|
@export var base_lift: float = 0.022
|
||||||
|
|
||||||
|
|
||||||
|
func _ready() -> void:
|
||||||
|
collision_layer = 1
|
||||||
|
_build_mesh_and_visual()
|
||||||
|
_build_heightmap_collision()
|
||||||
|
|
||||||
|
|
||||||
|
func _build_mesh_and_visual() -> void:
|
||||||
|
var mi: MeshInstance3D = get_node_or_null("MeshInstance3D") as MeshInstance3D
|
||||||
|
if mi == null:
|
||||||
|
push_error("prototype_smooth_hill_piece: expected MeshInstance3D child.")
|
||||||
|
return
|
||||||
|
|
||||||
|
var sx: int = maxi(3, subdiv_x)
|
||||||
|
var sz: int = maxi(3, subdiv_z)
|
||||||
|
var half_x: float = footprint_x * 0.5
|
||||||
|
var half_z: float = footprint_z * 0.5
|
||||||
|
var height_sign := 1.0 if kind == PieceKind.HILL else -1.0
|
||||||
|
|
||||||
|
var verts := PackedVector3Array()
|
||||||
|
verts.resize((sx + 1) * (sz + 1))
|
||||||
|
var uvs := PackedVector2Array()
|
||||||
|
uvs.resize(verts.size())
|
||||||
|
|
||||||
|
var idx := 0
|
||||||
|
for j in sz + 1:
|
||||||
|
var v: float = float(j) / float(sz)
|
||||||
|
var z: float = lerpf(-half_z, half_z, v)
|
||||||
|
for i in sx + 1:
|
||||||
|
var u: float = float(i) / float(sx)
|
||||||
|
var x: float = lerpf(-half_x, half_x, u)
|
||||||
|
var envelope: float = sin(PI * u) * sin(PI * v)
|
||||||
|
var y: float = base_lift + height_sign * amplitude * envelope
|
||||||
|
verts[idx] = Vector3(x, y, z)
|
||||||
|
uvs[idx] = Vector2(u, v)
|
||||||
|
idx += 1
|
||||||
|
|
||||||
|
var st := SurfaceTool.new()
|
||||||
|
st.begin(Mesh.PRIMITIVE_TRIANGLES)
|
||||||
|
for j in sz:
|
||||||
|
for i in sx:
|
||||||
|
var a: int = j * (sx + 1) + i
|
||||||
|
var b: int = a + 1
|
||||||
|
var c: int = a + (sx + 1)
|
||||||
|
var d: int = c + 1
|
||||||
|
for tri in [[a, c, b], [b, c, d]]:
|
||||||
|
st.set_uv(uvs[tri[0]])
|
||||||
|
st.add_vertex(verts[tri[0]])
|
||||||
|
st.set_uv(uvs[tri[1]])
|
||||||
|
st.add_vertex(verts[tri[1]])
|
||||||
|
st.set_uv(uvs[tri[2]])
|
||||||
|
st.add_vertex(verts[tri[2]])
|
||||||
|
st.generate_normals()
|
||||||
|
var mesh: ArrayMesh = st.commit()
|
||||||
|
mi.mesh = mesh
|
||||||
|
mi.material_override = _qa_material_for_kind()
|
||||||
|
|
||||||
|
|
||||||
|
func _build_heightmap_collision() -> void:
|
||||||
|
var cs: CollisionShape3D = get_node_or_null("CollisionShape3D") as CollisionShape3D
|
||||||
|
if cs == null:
|
||||||
|
push_error("prototype_smooth_hill_piece: expected CollisionShape3D child.")
|
||||||
|
return
|
||||||
|
|
||||||
|
var sx: int = maxi(3, subdiv_x)
|
||||||
|
var height_sign := 1.0 if kind == PieceKind.HILL else -1.0
|
||||||
|
|
||||||
|
var mw: int = maxi(3, sx + 1)
|
||||||
|
var md: int = maxi(3, subdiv_z + 1)
|
||||||
|
var s: float = footprint_x / float(mw - 1)
|
||||||
|
while (md - 1) * s < footprint_z - 0.0001 and md < 512:
|
||||||
|
md += 1
|
||||||
|
|
||||||
|
var data := PackedFloat32Array()
|
||||||
|
data.resize(mw * md)
|
||||||
|
for j in md:
|
||||||
|
var v: float = float(j) / float(md - 1) if md > 1 else 0.0
|
||||||
|
for i in mw:
|
||||||
|
var u: float = float(i) / float(mw - 1) if mw > 1 else 0.0
|
||||||
|
var envelope: float = sin(PI * u) * sin(PI * v)
|
||||||
|
var y_world: float = base_lift + height_sign * amplitude * envelope
|
||||||
|
data[j * mw + i] = y_world / s
|
||||||
|
|
||||||
|
var hm := HeightMapShape3D.new()
|
||||||
|
hm.map_width = mw
|
||||||
|
hm.map_depth = md
|
||||||
|
hm.map_data = data
|
||||||
|
cs.shape = hm
|
||||||
|
cs.scale = Vector3(s, s, s)
|
||||||
|
cs.position = Vector3.ZERO
|
||||||
|
|
||||||
|
|
||||||
|
func _qa_material_for_kind() -> StandardMaterial3D:
|
||||||
|
var m := StandardMaterial3D.new()
|
||||||
|
m.cull_mode = BaseMaterial3D.CULL_DISABLED
|
||||||
|
m.roughness = 0.45
|
||||||
|
m.metallic = 0.0
|
||||||
|
m.emission_enabled = true
|
||||||
|
m.emission_energy_multiplier = 0.85
|
||||||
|
if kind == PieceKind.HILL:
|
||||||
|
m.albedo_color = Color(0.98, 0.12, 0.42)
|
||||||
|
m.emission = Color(0.45, 0.02, 0.15)
|
||||||
|
else:
|
||||||
|
m.albedo_color = Color(0.08, 0.35, 0.98)
|
||||||
|
m.emission = Color(0.02, 0.12, 0.45)
|
||||||
|
return m
|
||||||
|
|
@ -0,0 +1 @@
|
||||||
|
uid://dqm8s4k2h1xwy
|
||||||
|
|
@ -0,0 +1,96 @@
|
||||||
|
# NEO-22 — Implementation plan
|
||||||
|
|
||||||
|
## Story reference
|
||||||
|
|
||||||
|
| Field | Value |
|
||||||
|
|--------|--------|
|
||||||
|
| **Key** | NEO-22 |
|
||||||
|
| **Title** | Retire prototype click-to-move; ship WASD locomotion on the client |
|
||||||
|
| **Linear** | [NEO-22](https://linear.app/neon-sprawl/issue/NEO-22) |
|
||||||
|
| **Parent** | [E1.M1 InputAndMovementRuntime](https://linear.app/neon-sprawl/project/e1m1-inputandmovementruntime-20eb28dd3db4) (or current movement project) |
|
||||||
|
| **Supersedes / deprecates (prototype)** | [NEO-5](NEO-5-implementation-plan.md), [NEO-11](NEO-11-implementation-plan.md) click-to-move surface; [NEO-14](NEO-14-implementation-plan.md) seam/jitter mitigations tied to nav + goal steering |
|
||||||
|
|
||||||
|
## Goal, scope, and out-of-scope
|
||||||
|
|
||||||
|
**Goal:** Replace **mouse click → ground pick → `MoveCommand` → nav path → `player.gd` goal steering** with **keyboard WASD** (and optional gamepad later) as the **primary** locomotion input in the Godot prototype. Movement should feel predictable on stepped geometry without the column / nav-ribbon / slide-normal fight that click-to-move accumulated.
|
||||||
|
|
||||||
|
**In scope**
|
||||||
|
|
||||||
|
- **Input:** `project.godot` actions (or code) for **W/A/S/D**; read them in a thin place (`main.gd` or a small `locomotion_input.gd`) and drive the player’s **horizontal** intent each physics tick.
|
||||||
|
- **Locomotion model:** `CharacterBody3D` + `move_and_slide()` with velocity derived from **wish direction × speed**, camera-relative or world-relative (decide in [Decisions](#decisions)); **no `NavigationAgent3D` path goal** for routine walking unless a later slice reintroduces hybrid “click to mark + auto-run” explicitly.
|
||||||
|
- **Remove / disable** left-click ground pick as the default move trigger (`ground_pick.gd` → `main.gd` → `PositionAuthorityClient.submit_move_target`). Either delete the flow for this slice or gate it behind a dev flag documented as off by default.
|
||||||
|
- **Server / authority:** Resolve how WASD maps to **NEO-7 `MoveCommand`** and **authoritative position** (see [Open questions](#open-questions--risks)). Minimum acceptable outcomes are spelled in acceptance criteria — pick one path and document it in `client/README.md` + decomposition.
|
||||||
|
- **Cleanup:** Delete or drastically narrow **NEO-11 / NEO-14 / NEO-16** workaround blocks in `player.gd` (column seam damp, nav column steering, idle trace scaffolding tied to click goals) **only** after WASD path is stable; prefer one commit that switches input then a follow-up that deletes dead code.
|
||||||
|
- **Docs:** Update `client/README.md`, `docs/decomposition/modules/E1_M1_InputAndMovementRuntime.md`, and this plan’s checklist when shipped.
|
||||||
|
|
||||||
|
**Out of scope**
|
||||||
|
|
||||||
|
- Full **client prediction + server reconciliation** for FPS-grade netcode (unless explicitly pulled into this story by product).
|
||||||
|
- **Click-to-interact** changes (NEO-12-style interaction) except where click conflicts with removed move binding (document if unchanged).
|
||||||
|
- **Occlusion / camera** feature work (NEO-17/20) beyond what’s needed so WASD direction feels correct with the follow camera.
|
||||||
|
- **Production** navmesh tooling for NPCs — only player move input changes here.
|
||||||
|
|
||||||
|
## Acceptance criteria checklist
|
||||||
|
|
||||||
|
- [ ] **WASD** moves the avatar on the default **`main.tscn`** floor with **no** left-click requirement; speed and direction are stable on flat ground and on **stepped** QA props without the prior ±`MOVE_SPEED` seam oscillation at column/stair corners (subjective + optional `debug_idle_trace` spot-check).
|
||||||
|
- [ ] **Left-click** no longer starts a walk by default (or is clearly dev-only and off in normal play, documented).
|
||||||
|
- [ ] **Authoritative position** still converges with the server: document whether **continuous** sync, **throttled** `MoveCommand`, or **temporary client-only** prototype applies — and add the minimal client/server change so CI + manual steps do not lie.
|
||||||
|
- [ ] **Cold boot `snap_to_server`** still works; no spawn underground / stale goal regressions.
|
||||||
|
- [ ] **NEO-10** rejection UX: if clicks are removed, another path still exercises reject payloads in dev (automated test or documented console-only command) **or** clicks remain only for “set goal” debug, not default locomotion.
|
||||||
|
- [ ] **GdUnit** (or existing harness) covers at least one pure helper for wish-dir / speed clamp if logic is non-trivial.
|
||||||
|
|
||||||
|
## Technical approach
|
||||||
|
|
||||||
|
1. **Linear + branch:** Issue **NEO-22** exists in Backlog; on kickoff set **In Progress** and branch `neo-22-retire-click-to-move-implement-wasd-locomotion-godot-client` (or team naming).
|
||||||
|
2. **Authority model (blocking):** Decide with product/server owner:
|
||||||
|
- **A)** Client integrates WASD locally only for prototype; server receives **rate-limited** position or move deltas (may need new endpoint — likely **out of this slice** unless already planned), or
|
||||||
|
- **B)** Keep **discrete `MoveCommand`** but drive target from **keyboard-sampled “stick to last intent”** (awkward), or
|
||||||
|
- **C)** Server accepts a **stream** of small moves (largest change — schedule separately).
|
||||||
|
Document chosen option in README before merging locomotion feel work.
|
||||||
|
3. **Input layer:** Map WASD to `Vector2` in screen/camera space → world XZ tangent to `Camera3D` basis (same pattern as many third-person controllers); normalize; zero when no keys.
|
||||||
|
4. **`player.gd` refactor:** Replace `_has_walk_goal` / `_auth_walk_goal` steering path with **wish velocity** from parent or injected each `_physics_process`; keep gravity, floor snap policy, and `snap_to_server` entry points. Strip `NavigationAgent3D` dependency for default walk if decision says so (agent node can stay in scene unused until a later hybrid story).
|
||||||
|
5. **Remove click wiring:** Disconnect `target_chosen` → `submit_move_target` in `main.gd` for default build; optionally keep `ground_pick.gd` for debug “teleport goal” behind `OS.is_debug_build()` flag.
|
||||||
|
6. **Tests + manual:** Run README manual path; add unit test for direction math if extracted.
|
||||||
|
|
||||||
|
## Decisions
|
||||||
|
|
||||||
|
| Topic | Choice | Rationale |
|
||||||
|
|------|--------|-----------|
|
||||||
|
| **Issue id** | **NEO-22** | Created in Linear 2026-04-16; plan filename matches this key. |
|
||||||
|
| **Camera vs world steering** | **TBD in kickoff** — default recommendation: **camera-relative XZ** | Matches isometric follow rig; document if world-axis is chosen instead. |
|
||||||
|
| **NavAgent** | Default **off** for routine WASD in this slice | Removes nav-ribbon + column latch class of bugs; re-add only with explicit hybrid design. |
|
||||||
|
|
||||||
|
## Files to add
|
||||||
|
|
||||||
|
| Path | Purpose |
|
||||||
|
|------|---------|
|
||||||
|
| None required. | Prefer extending existing scripts unless `locomotion_input.gd` (or similar) is needed to keep `main.gd` thin per `godot-client-script-organization`. If extracted, add here and list in “Files to modify” as N/A for that line. |
|
||||||
|
|
||||||
|
## Files to modify
|
||||||
|
|
||||||
|
| Path | Rationale |
|
||||||
|
|------|-----------|
|
||||||
|
| `client/project.godot` | Declare **`move_forward` / `move_back` / `move_left` / `move_right`** (or reuse Godot defaults) bound to WASD. |
|
||||||
|
| `client/scripts/main.gd` | Stop wiring click pick to `submit_move_target` by default; optionally forward WASD intent to player/authority per chosen model. |
|
||||||
|
| `client/scripts/ground_pick.gd` | Disable default `_input` pick **or** gate behind export/debug; update header comments (NS-16 lineage). |
|
||||||
|
| `client/scripts/player.gd` | Replace goal/nav horizontal steering with WASD-driven velocity; delete obsolete seam/nav state once stable. |
|
||||||
|
| `client/scripts/position_authority_client.gd` | Align with authority model (throttle, stream, or document client-only prototype). |
|
||||||
|
| `client/scenes/main.tscn` | Only if `NavigationAgent3D` or nodes are removed/changed or input singleton paths change. |
|
||||||
|
| `client/README.md` | Replace “click-move” manual checks with WASD + server steps; note deprecation of click-to-move prototype. |
|
||||||
|
| `docs/decomposition/modules/E1_M1_InputAndMovementRuntime.md` | Snapshot: click-to-move retired in favor of WASD for prototype; link this plan. |
|
||||||
|
| `docs/plans/NEO-11-implementation-plan.md` | Add one-line “superseded for locomotion input by NEO-22” in header or Resolution if policy is to annotate predecessors. |
|
||||||
|
|
||||||
|
## Tests
|
||||||
|
|
||||||
|
| File | Coverage |
|
||||||
|
|------|----------|
|
||||||
|
| `client/test/player_test.gd` | **Change:** remove or replace tests that assumed `_has_walk_goal` / nav steering; add tests for any extracted **wish-direction** or speed clamp helper. |
|
||||||
|
| `client/test/position_authority_client_test.gd` | **Change:** align with new submit/sync cadence if `PositionAuthorityClient` changes. |
|
||||||
|
| **Manual (required)** | README path: boot with server, WASD on flat + stepped geometry, `snap_to_server`, and whichever NEO-10 rejection path remains after click removal. |
|
||||||
|
|
||||||
|
## Open questions / risks
|
||||||
|
|
||||||
|
- **Server contract:** Today’s **`MoveCommand`** is **discrete target**-shaped ([NEO-7](NEO-7-implementation-plan.md)). WASD needs **continuous** intent — either extend the protocol, rate-limit many small commands, or temporarily diverge prototype authority. **This is the main schedule risk**; do not merge feel-only client changes without an explicit written decision in README + this plan.
|
||||||
|
- **Interaction vs pick:** Confirm **E** / interaction does not rely on the same mouse button in a way that breaks when pick is disabled.
|
||||||
|
- **Gamepad:** Out of scope unless trivial (`Input.get_vector`); note for follow-up story.
|
||||||
|
- **Jira naming:** If the team uses **NEON-*** keys elsewhere, align Linear/Git subject lines per [linear-git-naming](../../.cursor/rules/jira-git-naming.md).
|
||||||
Loading…
Reference in New Issue