NEON-28: Dev obstacle smoke, nav reparent on hide, and docs

- Input action dev_toggle_occluder_obstacle (Ctrl+Shift+K): toggle obstacle off nav source via reparent to World, rebake NavigationRegion3D, sync NavigationAgent3D; physics/process/collision hardening unchanged intent.

- Occluder dictionary keys by instance id; related client fixes and README/project input note.

- Plan and review: PARSED_GEOMETRY_BOTH / runtime geometry removal lesson for future mechanics.
pull/38/head
VinPropane 2026-04-10 23:55:50 -04:00
parent 1ab3fc1dca
commit 398317d64f
9 changed files with 194 additions and 49 deletions

View File

@ -118,6 +118,8 @@ On **Linux** (including GitHub Actions), the path must be **`gdUnit4`** with a c
**Camera zoom:** Input actions **`camera_zoom_in`** / **`camera_zoom_out`** (mouse wheel, **`=`** / **`-`**, keypad **+** / ****) are defined in **`project.godot`**. On layouts where **`+`** requires **Shift**, remap or add a binding under **Project → Project Settings → Input Map** if needed. **Camera zoom:** Input actions **`camera_zoom_in`** / **`camera_zoom_out`** (mouse wheel, **`=`** / **`-`**, keypad **+** / ****) are defined in **`project.godot`**. On layouts where **`+`** requires **Shift**, remap or add a binding under **Project → Project Settings → Input Map** if needed.
**Dev (NEON-28):** **`dev_toggle_occluder_obstacle`** (default **Ctrl+Shift+K**) toggles the prototype `Obstacle` visibility/collision in **`main.gd`**. **F9** / **F10** are used by the embedded debugger; use this action instead when the game is running in the editor.
**Git hook (recommended):** install the repos local **pre-push** GDScript lint hook from the repo root: **Git hook (recommended):** install the repos local **pre-push** GDScript lint hook from the repo root:
```bash ```bash

View File

@ -58,6 +58,11 @@ camera_zoom_out={
, Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":4194435,"physical_keycode":4194435,"key_label":0,"unicode":0,"location":0,"echo":false,"script":null) , Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":4194435,"physical_keycode":4194435,"key_label":0,"unicode":0,"location":0,"echo":false,"script":null)
] ]
} }
dev_toggle_occluder_obstacle={
"deadzone": 0.5,
"events": [Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":true,"ctrl_pressed":true,"meta_pressed":false,"pressed":false,"keycode":75,"physical_keycode":75,"key_label":0,"unicode":107,"location":0,"echo":false,"script":null)
]
}
[physics] [physics]

View File

@ -27,6 +27,10 @@ const WALL_NORMAL_DOT_CUTOFF: float = 0.09
var fallback_camera: Camera3D var fallback_camera: Camera3D
func _ready() -> void:
set_process_input(true)
func _input(event: InputEvent) -> void: func _input(event: InputEvent) -> void:
if event is InputEventMouseButton: if event is InputEventMouseButton:
var mb := event as InputEventMouseButton var mb := event as InputEventMouseButton

View File

@ -2,8 +2,9 @@ extends Node3D
## NEON-25: client-local isometric follow; updates [CameraState] each physics tick. ## NEON-25: client-local isometric follow; updates [CameraState] each physics tick.
## NEON-26: discrete zoom bands via [member zoom_band_config]; wheel / `camera_zoom_*` actions. ## NEON-26: discrete zoom bands via [member zoom_band_config]; wheel / `camera_zoom_*` actions.
## NEON-28: purge freed occluder keys from [member _occluder_overrides] before ray pass (see ## NEON-28: occluder overrides are keyed by [method Object.get_instance_id] only — never [Object]
## [method occluder_override_key_is_valid]). ## keys, or [method Dictionary.keys] / [method Dictionary.erase] can freeze after an occluder is
## [method Node.queue_free]'d (dev smoke: [InputMap] [code]dev_toggle_occluder_obstacle[/code] in [code]main.gd[/code]).
## Pitch / roll are fixed by presentation exports; **orbit yaw** in state stays **0** while ## Pitch / roll are fixed by presentation exports; **orbit yaw** in state stays **0** while
## [member allow_yaw] is false (no rotate input bound). Future orbit: read relative input here, ## [member allow_yaw] is false (no rotate input bound). Future orbit: read relative input here,
## add to `_orbit_yaw_rad`, clamp with [member max_yaw_deg]. ## add to `_orbit_yaw_rad`, clamp with [member max_yaw_deg].
@ -48,7 +49,8 @@ var _smoothed_eye: Vector3 = Vector3.ZERO
var _orbit_yaw_rad: float = 0.0 var _orbit_yaw_rad: float = 0.0
var _zoom_band_index: int = 0 var _zoom_band_index: int = 0
var _warned_missing_follow_target: bool = false var _warned_missing_follow_target: bool = false
## Keys: Node3D occluder bodies. Values: Array of {mesh, surface, original} restore entries. ## Keys: [code]int[/code] [method Object.get_instance_id] of the occluder body. Values: Array of
## {mesh, surface, original} restore entries.
var _occluder_overrides: Dictionary = {} var _occluder_overrides: Dictionary = {}
@onready var camera: Camera3D = $Camera3D @onready var camera: Camera3D = $Camera3D
@ -251,16 +253,18 @@ func _restore_occluder_overrides_list(overrides: Array) -> void:
(mi as MeshInstance3D).set_surface_override_material(s, original) (mi as MeshInstance3D).set_surface_override_material(s, original)
## Drop invalid keys from [member _occluder_overrides] without touching meshes. ## Drop stale entries (freed nodes or non-int legacy keys) without touching meshes.
## When the occluder [Node3D] is freed, its [MeshInstance3D] children and materials are gone or
## tearing down — restoring overrides here triggers "freed instance" errors. Erasing the entry is
## enough; live bodies stay on the valid path ([method _restore_occluder]).
func _purge_invalid_occluder_override_keys() -> void: func _purge_invalid_occluder_override_keys() -> void:
var keys: Array = _occluder_overrides.keys() var next: Dictionary = {}
for body in keys: for k in _occluder_overrides.keys():
if occluder_override_key_is_valid(body): if typeof(k) != TYPE_INT:
continue continue
_occluder_overrides.erase(body) var occ_id: int = k as int
var node := instance_from_id(occ_id)
if not is_instance_valid(node) or not node is Node3D:
continue
next[occ_id] = _occluder_overrides[k]
_occluder_overrides = next
## Cast iterative rays from the smoothed eye to the player focus. ## Cast iterative rays from the smoothed eye to the player focus.
@ -302,14 +306,25 @@ func _update_occlusion(focus: Vector3) -> void:
) )
) )
var to_restore: Array = _occluder_overrides.keys() var blocking_ids: Dictionary = {}
for body in to_restore: for body in newly_blocking:
if body not in newly_blocking: if occluder_override_key_is_valid(body):
_restore_occluder(body) blocking_ids[(body as Node3D).get_instance_id()] = true
for occ_id in _occluder_overrides.keys():
if typeof(occ_id) != TYPE_INT:
continue
var id: int = occ_id as int
if blocking_ids.has(id):
continue
_restore_occluder_id(id)
for body in newly_blocking: for body in newly_blocking:
if body not in _occluder_overrides: if not occluder_override_key_is_valid(body):
_apply_occluder_fade(body) continue
var bid: int = (body as Node3D).get_instance_id()
if not _occluder_overrides.has(bid):
_apply_occluder_fade(body as Node3D)
## Apply per-surface fade overrides to all [MeshInstance3D] children of [param body]. ## Apply per-surface fade overrides to all [MeshInstance3D] children of [param body].
@ -344,19 +359,27 @@ func _apply_occluder_fade(body: Node3D) -> void:
faded.albedo_color.a = policy.fade_alpha faded.albedo_color.a = policy.fade_alpha
mi.set_surface_override_material(s, faded) mi.set_surface_override_material(s, faded)
overrides.append({"mesh": mi, "surface": s, "original": saved}) overrides.append({"mesh": mi, "surface": s, "original": saved})
_occluder_overrides[body] = overrides _occluder_overrides[body.get_instance_id()] = overrides
## Restore saved surface override materials on [param body] and remove it from tracking. ## Restore saved surface override materials for occluder [param occ_id] and remove it from tracking.
func _restore_occluder(body: Node3D) -> void: func _restore_occluder_id(occ_id: int) -> void:
if not _occluder_overrides.has(body): if not _occluder_overrides.has(occ_id):
return return
_restore_occluder_overrides_list(_occluder_overrides[body]) _restore_occluder_overrides_list(_occluder_overrides[occ_id])
_occluder_overrides.erase(body) _occluder_overrides.erase(occ_id)
## Restore all currently faded occluders (called on policy disable or node exit). ## Restore all currently faded occluders (called on policy disable or node exit).
func _restore_all_occluders() -> void: func _restore_all_occluders() -> void:
_purge_invalid_occluder_override_keys() _purge_invalid_occluder_override_keys()
for body in _occluder_overrides.keys(): for occ_id in _occluder_overrides.keys():
_restore_occluder(body) if typeof(occ_id) == TYPE_INT:
_restore_occluder_id(occ_id as int)
## Public hook for [code]dev_toggle_occluder_obstacle[/code] smoke ([code]main.gd[/code]): restore materials and clear [member _occluder_overrides] **while bodies
## are still live**, then the caller may [method Node.queue_free] the occluder. Avoids any post-free
## occlusion pass touching a tearing-down [StaticBody3D] (the semi-transparent faded box case).
func restore_all_occluder_materials_now() -> void:
_restore_all_occluders()

View File

@ -13,7 +13,13 @@ const MOVE_REJECT_MSG_SECONDS: float = 4.0
## Bump on each rejection so older one-shot timers do not clear a newer message. ## Bump on each rejection so older one-shot timers do not clear a newer message.
var _move_reject_msg_token: int = 0 var _move_reject_msg_token: int = 0
## [InputMap] [code]dev_toggle_occluder_obstacle[/code] (default **Ctrl+Shift+K**): restored when the obstacle is shown again.
var _dev_saved_obstacle_layer: int = -1
var _dev_saved_obstacle_mask: int = -1
var _dev_saved_obstacle_process_mode: Node.ProcessMode = Node.PROCESS_MODE_INHERIT
@onready var _camera: Camera3D = $World/IsometricFollowCamera/Camera3D @onready var _camera: Camera3D = $World/IsometricFollowCamera/Camera3D
@onready var _world: Node3D = $World
@onready var _nav_region: NavigationRegion3D = $World/NavigationRegion3D @onready var _nav_region: NavigationRegion3D = $World/NavigationRegion3D
@onready var _player: CharacterBody3D = $World/Player @onready var _player: CharacterBody3D = $World/Player
@onready var _ground_pick: Node3D = $GroundPick @onready var _ground_pick: Node3D = $GroundPick
@ -22,9 +28,14 @@ var _move_reject_msg_token: int = 0
@onready var _move_reject_label: Label = $UICanvas/MoveRejectLabel @onready var _move_reject_label: Label = $UICanvas/MoveRejectLabel
@onready var _floor: StaticBody3D = $World/NavigationRegion3D/Floor @onready var _floor: StaticBody3D = $World/NavigationRegion3D/Floor
## Cached `Obstacle` body (path breaks after reparent for nav bake; do not use a fixed [NodePath]).
var _dev_obstacle_smoke: Node3D
func _ready() -> void: func _ready() -> void:
set_process_unhandled_key_input(true)
await get_tree().process_frame await get_tree().process_frame
_dev_obstacle_smoke = get_node_or_null("World/NavigationRegion3D/Obstacle") as Node3D
# Runtime load + .call avoids class_name / preload static resolution issues across Godot parses. # Runtime load + .call avoids class_name / preload static resolution issues across Godot parses.
load("res://scripts/random_floor_bumps.gd").call("spawn_short_random_bumps", _floor) load("res://scripts/random_floor_bumps.gd").call("spawn_short_random_bumps", _floor)
# Default `bake_navigation_mesh()` bakes on a background thread; sync bake ensures the map # Default `bake_navigation_mesh()` bakes on a background thread; sync bake ensures the map
@ -69,31 +80,71 @@ func _on_move_rejected(reason_code: String) -> void:
) )
## Dev smoke (NEON-28 occluder purge): **F9** frees `Obstacle` while testing; remove when no longer needed. ## Dev smoke (NEON-28 occluder): input action [code]dev_toggle_occluder_obstacle[/code] (default **Ctrl+Shift+K**)
## After removing nav source geometry, we **rebake** — otherwise `NavigationAgent3D` keeps a stale map ## toggles `Obstacle` **visibility + collision** — it does **not** call [method Node.queue_free].
## and click-to-move can stop working. ## Uses [member Node.process_mode] [code]PROCESS_MODE_DISABLED[/code] so the [StaticBody3D] is **removed
## Coroutine is started **directly** from input (not [method call_deferred]): deferred + [code]await[/code] ## from the physics simulation** (default [member CollisionObject3D.disable_mode] is [code]REMOVE[/code]).
## often never resumes, so the rebake never finished and the nav map stayed stale. ## Layer/shape tweaks alone left a **Jolt ghost collider** (invisible wall) in practice.
## When hidden, reparents `Obstacle` under [member _world]: [NavigationMesh] source geometry scans
## [NavigationRegion3D] children only ([code]SOURCE_GEOMETRY_ROOT_NODE_CHILDREN[/code]), and the
## default [code]geometry_parsed_geometry_type[/code] is [code]PARSED_GEOMETRY_BOTH[/code], so a
## child [MeshInstance3D] still carved a hole even with collision disabled — rebake alone was not enough.
## After each toggle, rebakes (main thread) and syncs the player [NavigationAgent3D].
## **F9** / **F10** are debugger shortcuts in the embedded game; use the Input Map action.
## For a true **freed-node** occluder test, reload the scene or remove the body in the editor.
func _unhandled_key_input(event: InputEvent) -> void: func _unhandled_key_input(event: InputEvent) -> void:
if not event is InputEventKey: if not event.is_action_pressed("dev_toggle_occluder_obstacle"):
return return
var k := event as InputEventKey call_deferred("_dev_toggle_obstacle_smoke_deferred")
if k.pressed and not k.echo and k.keycode == KEY_F9:
var obstacle := get_node_or_null("World/NavigationRegion3D/Obstacle")
if obstacle:
obstacle.queue_free()
_rebake_nav_after_obstacle_removed_async()
func _rebake_nav_after_obstacle_removed_async() -> void: func _dev_toggle_obstacle_smoke_deferred() -> void:
await get_tree().process_frame var obstacle := _dev_obstacle_smoke
if obstacle == null or not is_instance_valid(obstacle):
return
var rig: Node = get_node_or_null("World/IsometricFollowCamera")
if obstacle.visible:
if rig != null and rig.has_method("restore_all_occluder_materials_now"):
rig.call("restore_all_occluder_materials_now")
_dev_saved_obstacle_process_mode = obstacle.process_mode
var co := obstacle as CollisionObject3D
if co != null:
_dev_saved_obstacle_layer = co.collision_layer
_dev_saved_obstacle_mask = co.collision_mask
co.collision_layer = 0
co.collision_mask = 0
_dev_collision_shapes_set_disabled(obstacle, true)
obstacle.process_mode = Node.PROCESS_MODE_DISABLED
obstacle.visible = false
if obstacle.get_parent() == _nav_region:
obstacle.reparent(_world)
else:
if obstacle.get_parent() == _world:
obstacle.reparent(_nav_region)
obstacle.visible = true
obstacle.process_mode = _dev_saved_obstacle_process_mode
var co2 := obstacle as CollisionObject3D
if co2 != null and _dev_saved_obstacle_layer >= 0:
co2.collision_layer = _dev_saved_obstacle_layer
co2.collision_mask = _dev_saved_obstacle_mask
_dev_collision_shapes_set_disabled(obstacle, false)
call_deferred("_dev_rebake_nav_after_obstacle_toggle_deferred")
func _dev_rebake_nav_after_obstacle_toggle_deferred() -> void:
if not is_instance_valid(_nav_region):
return
if _nav_region.is_baking():
await _nav_region.bake_finished
if not is_instance_valid(_nav_region): if not is_instance_valid(_nav_region):
return return
_nav_region.bake_navigation_mesh(false) _nav_region.bake_navigation_mesh(false)
# Let NavigationServer3D apply the rebaked mesh before repathing. if is_instance_valid(_player):
await get_tree().physics_frame _player.sync_navigation_agent_after_map_rebuild(_nav_region)
await get_tree().physics_frame
await get_tree().physics_frame
if not is_instance_valid(_player): func _dev_collision_shapes_set_disabled(root: Node, disabled: bool) -> void:
return if root is CollisionShape3D:
_player.sync_navigation_agent_after_map_rebuild() (root as CollisionShape3D).disabled = disabled
for c in root.get_children():
_dev_collision_shapes_set_disabled(c, disabled)

View File

@ -90,7 +90,16 @@ func clear_nav_goal() -> void:
## Repath on a new nav map (e.g. after [NavigationRegion3D] rebake when geometry is removed). ## Repath on a new nav map (e.g. after [NavigationRegion3D] rebake when geometry is removed).
func sync_navigation_agent_after_map_rebuild() -> void: ## Rebaking can leave [NavigationAgent3D] on a stale map RID; rebind to the world's map first.
func sync_navigation_agent_after_map_rebuild(nav_region: NavigationRegion3D) -> void:
var map_rid: RID = RID()
var w3d := get_world_3d()
if w3d != null:
map_rid = w3d.navigation_map
if map_rid == RID() and is_instance_valid(nav_region):
map_rid = NavigationServer3D.region_get_map(nav_region.get_region_rid())
if map_rid != RID():
_nav_agent.set_navigation_map(map_rid)
if _has_walk_goal: if _has_walk_goal:
_nav_agent.set_target_position(_auth_walk_goal) _nav_agent.set_target_position(_auth_walk_goal)
else: else:

View File

@ -18,6 +18,9 @@ var _http: Node
var _busy: bool = false var _busy: bool = false
var _phase: Phase = Phase.BOOT_GET var _phase: Phase = Phase.BOOT_GET
## Latest click target while [_busy] (POST or VERIFY in flight); coalesced so clicks are not lost.
var _pending_move_target: Variant = null
func _create_http_request() -> Node: func _create_http_request() -> Node:
return HTTPRequest.new() return HTTPRequest.new()
@ -26,6 +29,8 @@ func _create_http_request() -> Node:
func _ready() -> void: func _ready() -> void:
_http = _create_http_request() _http = _create_http_request()
add_child(_http) add_child(_http)
if _http is HTTPRequest:
(_http as HTTPRequest).timeout = 30.0
@warning_ignore("unsafe_method_access") @warning_ignore("unsafe_method_access")
_http.request_completed.connect(_on_request_completed) _http.request_completed.connect(_on_request_completed)
@ -40,7 +45,22 @@ func sync_from_server() -> void:
func submit_move_target(world: Vector3) -> void: func submit_move_target(world: Vector3) -> void:
if _busy: if _busy:
_pending_move_target = world
return return
_pending_move_target = null
_start_move_post(world)
func _try_flush_pending_move() -> void:
if _busy:
return
if _pending_move_target is Vector3:
var w: Vector3 = _pending_move_target as Vector3
_pending_move_target = null
_start_move_post(w)
func _start_move_post(world: Vector3) -> void:
_busy = true _busy = true
_phase = Phase.POST_MOVE _phase = Phase.POST_MOVE
var url := "%s/game/players/%s/move" % [_base_root(), _player_path_segment()] var url := "%s/game/players/%s/move" % [_base_root(), _player_path_segment()]
@ -54,6 +74,7 @@ func submit_move_target(world: Vector3) -> void:
if err != OK: if err != OK:
push_warning("PositionAuthorityClient: POST failed to start (%s)" % err) push_warning("PositionAuthorityClient: POST failed to start (%s)" % err)
_busy = false _busy = false
_try_flush_pending_move()
func _base_root() -> String: func _base_root() -> String:
@ -71,13 +92,18 @@ func _request_get() -> void:
if err != OK: if err != OK:
push_warning("PositionAuthorityClient: GET failed to start (%s)" % err) push_warning("PositionAuthorityClient: GET failed to start (%s)" % err)
_busy = false _busy = false
_try_flush_pending_move()
func _on_request_completed( func _on_request_completed(
_result: int, response_code: int, _headers: PackedStringArray, body: PackedByteArray _result: int, response_code: int, _headers: PackedStringArray, body: PackedByteArray
) -> void: ) -> void:
if _result != HTTPRequest.RESULT_SUCCESS: if _result != HTTPRequest.RESULT_SUCCESS:
push_warning(
"PositionAuthorityClient: HTTP failed (result=%s); releasing busy." % _result
)
_busy = false _busy = false
_try_flush_pending_move()
return return
var text := body.get_string_from_utf8() var text := body.get_string_from_utf8()
@ -87,16 +113,19 @@ func _on_request_completed(
_busy = false _busy = false
if response_code == 200: if response_code == 200:
_emit_position_from_response(text, true) _emit_position_from_response(text, true)
_try_flush_pending_move()
Phase.POST_MOVE: Phase.POST_MOVE:
if response_code == 400: if response_code == 400:
_emit_move_rejection_if_present(text) _emit_move_rejection_if_present(text)
_busy = false _busy = false
_try_flush_pending_move()
return return
if response_code != 200: if response_code != 200:
push_warning( push_warning(
"PositionAuthorityClient: move POST unexpected code %s" % response_code "PositionAuthorityClient: move POST unexpected code %s" % response_code
) )
_busy = false _busy = false
_try_flush_pending_move()
return return
_phase = Phase.VERIFY_GET _phase = Phase.VERIFY_GET
_request_get() _request_get()
@ -104,6 +133,11 @@ func _on_request_completed(
_busy = false _busy = false
if response_code == 200: if response_code == 200:
_emit_position_from_response(text, false) _emit_position_from_response(text, false)
else:
push_warning(
"PositionAuthorityClient: VERIFY GET unexpected code %s" % response_code
)
_try_flush_pending_move()
func _emit_move_rejection_if_present(json_text: String) -> void: func _emit_move_rejection_if_present(json_text: String) -> void:
@ -124,12 +158,15 @@ func _emit_move_rejection_if_present(json_text: String) -> void:
func _emit_position_from_response(json_text: String, apply_as_snap: bool) -> void: func _emit_position_from_response(json_text: String, apply_as_snap: bool) -> void:
var parsed: Variant = JSON.parse_string(json_text) var parsed: Variant = JSON.parse_string(json_text)
if parsed == null: if parsed == null:
push_warning("PositionAuthorityClient: position response JSON parse failed")
return return
if not parsed is Dictionary: if not parsed is Dictionary:
push_warning("PositionAuthorityClient: position response is not a JSON object")
return return
var data: Dictionary = parsed var data: Dictionary = parsed
var pos_variant: Variant = data.get("position", null) var pos_variant: Variant = data.get("position", null)
if not pos_variant is Dictionary: if not pos_variant is Dictionary:
push_warning("PositionAuthorityClient: position response missing `position` object")
return return
var pos: Dictionary = pos_variant var pos: Dictionary = pos_variant
var x: float = float(pos.get("x", 0.0)) var x: float = float(pos.get("x", 0.0))

View File

@ -79,6 +79,11 @@ Jira acceptance criteria (verbatim intent):
| **Invalid occluder keys** | **Erase only** | Freed body ⇒ subtree is gone; restoring overrides hits freed `MeshInstance3D` / materials. Drop dict entry; live bodies restore when they leave the ray. | | **Invalid occluder keys** | **Erase only** | Freed body ⇒ subtree is gone; restoring overrides hits freed `MeshInstance3D` / materials. Drop dict entry; live bodies restore when they leave the ray. |
| **Epic Slice 2 wording** | **Align with rotation policy** | Satisfies Jira AC on “yaw not exposed / default in UX” vs misleading “no rotation.” | | **Epic Slice 2 wording** | **Align with rotation policy** | Satisfies Jira AC on “yaw not exposed / default in UX” vs misleading “no rotation.” |
| **Rig tick (jitter follow-up)** | **`_physics_process` + `process_physics_priority = 1`** | `Player` default **0** runs before **1**; camera reads post`move_and_slide`. Avoids `_process` vs 120Hz physics jitter (camera was above `Player` in scene order). | | **Rig tick (jitter follow-up)** | **`_physics_process` + `process_physics_priority = 1`** | `Player` default **0** runs before **1**; camera reads post`move_and_slide`. Avoids `_process` vs 120Hz physics jitter (camera was above `Player` in scene order). |
| **Runtime “remove obstacle” + `NavigationAgent3D`** | **Reparent out of `NavigationRegion3D`, then rebake + sync agent** | Default `NavigationMesh.geometry_parsed_geometry_type` is **`PARSED_GEOMETRY_BOTH`**: **mesh instances** under the region are baked as source geometry alongside static colliders. Disabling collision (or rebaking alone) does **not** reopen nav if the bodys **`MeshInstance3D` remains a child of the region** — the hole persists. For any **gameplay mechanic that removes or opens geometry** while using baked nav, either **exclude that mesh from source** (project/settings), **reparent** the subtree **outside** the region before rebake, **`queue_free`** (if safe), or switch to an explicit geometry pipeline. Prototype dev toggle: `main.gd` reparents `Obstacle` to `World` when hidden, rebakes, `player.sync_navigation_agent_after_map_rebuild`. |
### Runtime navigation (lesson for future mechanics)
If the client uses **`NavigationRegion3D`** with default parsing (**colliders + meshes**), **hiding** or **disabling collision** on a prop is **not** enough for **click-to-move** to cross that tile: **`bake_navigation_mesh`** will still see **`MeshInstance3D`** children of the region. **Rebake + `NavigationAgent3D` map sync** only fixes the path once that geometry is **no longer under the region** (or no longer parsed). Plan any **destructible door**, **removed barricade**, or **temporary wall** with this in mind — same pitfall as NEON-28 dev smoke.
## Files to add ## Files to add
@ -106,7 +111,7 @@ Jira acceptance criteria (verbatim intent):
**Manual verification:** Main scene — zoom bands, occlusion fade, fixed yaw UX; spot-check Slice 2 behaviors (readable motion, occlusion active). **Manual verification:** Main scene — zoom bands, occlusion fade, fixed yaw UX; spot-check Slice 2 behaviors (readable motion, occlusion active).
**Freed occluder (end-to-end, editor):** Position player so `Obstacle` is between camera and player (fade active), then `queue_free` the occluder. Expect **no** recurring script errors and **no** stuck transparent materials on other meshes. **`Obstacle` lives under `NavigationRegion3D`** — after removing it, **rebake** the nav mesh (see **`main.gd` F9**: `await` a frame after `queue_free`, then `bake_navigation_mesh(false)`, then a few physics frames, then **`Player.sync_navigation_agent_after_map_rebuild()`**) or click-to-move can stop. Do **not** drive that sequence with **`call_deferred` + `await`** on the same method — the coroutine may never resume. Complements static `occluder_override_key_is_valid` tests, which do not execute the full purge path in CI. **Occluder smoke (embedded editor):** Input action **`dev_toggle_occluder_obstacle`** (default **Ctrl+Shift+K** in `project.godot`) toggles `Obstacle` visibility + collision in `main.gd` (does not `queue_free`). **F9** / **F10** are debugger shortcuts (**Suspend/Resume**, **next frame**) when the embedded game has focus. Position the player so the obstacle is on the occlusion ray to test fade + `restore_all_occluder_materials_now` before hide. After hide, confirm **click-to-move through the former cell**: the implementation **reparents** the obstacle under **`World`**, **rebakes** the region mesh, and **`sync_navigation_agent_after_map_rebuild`** — see **Decisions** table and **Runtime navigation** note (**`PARSED_GEOMETRY_BOTH`**). Complements static `occluder_override_key_is_valid` tests, which do not execute the full purge path in CI.
## Open questions / risks ## Open questions / risks
@ -118,3 +123,4 @@ Jira acceptance criteria (verbatim intent):
- `camera_state.gd` consumer contract header; `isometric_follow_camera.gd` invalid occluder purge + `occluder_override_key_is_valid`; tests in `isometric_follow_camera_test.gd`. - `camera_state.gd` consumer contract header; `isometric_follow_camera.gd` invalid occluder purge + `occluder_override_key_is_valid`; tests in `isometric_follow_camera_test.gd`.
- Docs: `E1_M2_IsometricCameraController.md` (Ready, consumer contract, NEON-28 snapshot), `E6_M2_ConsentAndRiskUxSignals.md` (E1.M2 adjacency), `module_dependency_register.md`, `documentation_and_implementation_alignment.md`, `epic_01_core_player_runtime.md` Slice 2. - Docs: `E1_M2_IsometricCameraController.md` (Ready, consumer contract, NEON-28 snapshot), `E6_M2_ConsentAndRiskUxSignals.md` (E1.M2 adjacency), `module_dependency_register.md`, `documentation_and_implementation_alignment.md`, `epic_01_core_player_runtime.md` Slice 2.
- Follow-up: rig **`_physics_process`** + priority (see **Decisions**). - Follow-up: rig **`_physics_process`** + priority (see **Decisions**).
- Client dev smoke (`main.gd`): obstacle toggle + **nav**: reparent out of `NavigationRegion3D`, rebake, agent sync — documents the **`PARSED_GEOMETRY_BOTH`** / mesh-as-source pitfall for future **runtime geometry removal** (see **Decisions** and **Runtime navigation**).

View File

@ -16,7 +16,7 @@ This branch closes the E1.M2 prototype slice in a coherent way: `CameraState` is
| Document | Assessment | | Document | Assessment |
|----------|------------| |----------|------------|
| `docs/plans/NEON-28-implementation-plan.md` | **Matches** — Scope, decisions, shipped notes, acceptance checklist, and Technical approach (`_physics_process` + priority) align with the branch. | | `docs/plans/NEON-28-implementation-plan.md` | **Matches** — Scope, decisions, shipped notes, acceptance checklist, and Technical approach (`_physics_process` + priority) align with the branch. **Supplement:** plan now records **runtime nav mesh** behavior (`PARSED_GEOMETRY_BOTH`, reparent + rebake for dev smoke); see plan **Decisions** and **Runtime navigation (lesson for future mechanics)**. |
| `docs/decomposition/modules/E1_M2_IsometricCameraController.md` | **Matches** — Consumer contract, E1.M2 Ready status, yaw semantics, occlusion hardening, and the `_physics_process` update all reflect the implementation. | | `docs/decomposition/modules/E1_M2_IsometricCameraController.md` | **Matches** — Consumer contract, E1.M2 Ready status, yaw semantics, occlusion hardening, and the `_physics_process` update all reflect the implementation. |
| `docs/decomposition/modules/E6_M2_ConsentAndRiskUxSignals.md` | **Matches** — Camera-adjacent consumer guidance and authority split are consistent with the code and E1.M2 doc. | | `docs/decomposition/modules/E6_M2_ConsentAndRiskUxSignals.md` | **Matches** — Camera-adjacent consumer guidance and authority split are consistent with the code and E1.M2 doc. |
| `docs/decomposition/modules/module_dependency_register.md` | **Matches** — E1.M2 is marked Ready with appropriate script/scene pointers. | | `docs/decomposition/modules/module_dependency_register.md` | **Matches** — E1.M2 is marked Ready with appropriate script/scene pointers. |
@ -45,6 +45,14 @@ _None identified in the implementation reviewed._
- Godot / GdUnit client tests were not run in this session. - Godot / GdUnit client tests were not run in this session.
- Before merge, I would still run: `dotnet build NeonSprawl.sln` with the dev server stopped, then the documented camera manual checks in `client/README.md` plus the NEON-28 plan step that frees an occluder while it is faded. - Before merge, I would still run: `dotnet build NeonSprawl.sln` with the dev server stopped, then the documented camera manual checks in `client/README.md` plus the NEON-28 plan step that frees an occluder while it is faded.
## Supplement — runtime navigation (2026-04-10)
**Context:** Dev smoke for the prototype `Obstacle` must match **click-to-move**, not only physics and occlusion.
**Lesson:** With Godots default **`NavigationMesh.geometry_parsed_geometry_type`** (**`PARSED_GEOMETRY_BOTH`**), **`MeshInstance3D` nodes under `NavigationRegion3D`** still contribute to bakes. **Disabling collision and rebaking** can leave the **nav hole** unchanged. **`main.gd`** fixes dev smoke by **reparenting** the obstacle **out of the region**, **rebaking**, and **`sync_navigation_agent_after_map_rebuild`**.
**Product impact:** Any future mechanic that **removes or opens world geometry** during play (barricades, doors, destroyed cover) must account for **nav source geometry**, not just **collision** — see `docs/plans/NEON-28-implementation-plan.md` (**Decisions** table row and **Runtime navigation** subsection).
--- ---
*Review produced per [code review agent](.cursor/rules/code-review-agent.md).* *Review produced per [code review agent](.cursor/rules/code-review-agent.md).*