extends Node3D ## NS-16: composes ground pick + server authority; see `ground_pick.gd` / ## `position_authority_client.gd`. NS-18: interaction POST + radius glow preview (see child nodes). ## NS-23: bakes `NavigationRegion3D` on startup; boot snap vs nav goal from authority signal. ## NEON-25: follow camera is `World/IsometricFollowCamera/Camera3D` ## (see `isometric_follow_camera.gd`). ## Prototype: two random short bumps (sibling StaticBody3D under NavigationRegion3D; see ## `random_floor_bumps.gd`) before nav bake. const MOVE_REJECT_MSG_SECONDS: float = 4.0 ## Bump on each rejection so older one-shot timers do not clear a newer message. 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 ## Cached `Obstacle` (path breaks after reparent for nav bake; no fixed [NodePath]). var _dev_obstacle_smoke: Node3D @onready var _camera: Camera3D = $World/IsometricFollowCamera/Camera3D @onready var _world: Node3D = $World @onready var _nav_region: NavigationRegion3D = $World/NavigationRegion3D @onready var _player: CharacterBody3D = $World/Player @onready var _ground_pick: Node3D = $GroundPick @onready var _authority: Node = $PositionAuthorityClient @onready var _radius_preview: Node3D = $World/InteractionMarkers @onready var _move_reject_label: Label = $UICanvas/MoveRejectLabel @onready var _floor: StaticBody3D = $World/NavigationRegion3D/Floor func _ready() -> void: set_process_unhandled_key_input(true) 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. 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 # exists before any `NavigationAgent3D` queries (see Godot navigation_using_navigationagents). _nav_region.bake_navigation_mesh(false) await get_tree().physics_frame await get_tree().physics_frame _ground_pick.set("fallback_camera", _camera) _ground_pick.connect("target_chosen", Callable(self, "_on_target_chosen")) _authority.connect( "authoritative_position_received", Callable(self, "_on_authoritative_position") ) _authority.connect("move_rejected", Callable(self, "_on_move_rejected")) _authority.call("sync_from_server") if _radius_preview.has_method("setup_player"): _radius_preview.call("setup_player", _player) func _on_target_chosen(world: Vector3) -> void: _authority.call("submit_move_target", world) func _on_authoritative_position(world: Vector3, apply_as_snap: bool) -> void: if apply_as_snap: _player.snap_to_server(world) else: _player.set_authoritative_nav_goal(world) func _on_move_rejected(reason_code: String) -> void: # Rejected POST never reached VERIFY_GET, so no new nav target was applied; do not clear an # in-flight path from a prior successful move. push_warning("Move rejected: %s" % reason_code) _move_reject_msg_token += 1 var token: int = _move_reject_msg_token _move_reject_label.text = "Move rejected: %s" % reason_code get_tree().create_timer(MOVE_REJECT_MSG_SECONDS).timeout.connect( func(): if token == _move_reject_msg_token: _move_reject_label.text = "", CONNECT_ONE_SHOT ) ## Dev smoke (NEON-28 occluder): action [code]dev_toggle_occluder_obstacle[/code] ## (default **Ctrl+Shift+K**). Toggles `Obstacle` **visibility + collision** — ## does not call [method Node.queue_free]. ## Uses [member Node.process_mode] [code]PROCESS_MODE_DISABLED[/code] so the [StaticBody3D] is ## **removed from the physics simulation** ## (default [member CollisionObject3D.disable_mode] is [code]REMOVE[/code]). ## 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: if not event.is_action_pressed("dev_toggle_occluder_obstacle"): return call_deferred("_dev_toggle_obstacle_smoke_deferred") func _dev_toggle_obstacle_smoke_deferred() -> void: 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): return _nav_region.bake_navigation_mesh(false) if is_instance_valid(_player): _player.sync_navigation_agent_after_map_rebuild(_nav_region) func _dev_collision_shapes_set_disabled(root: Node, disabled: bool) -> void: if root is CollisionShape3D: (root as CollisionShape3D).disabled = disabled for c in root.get_children(): _dev_collision_shapes_set_disabled(c, disabled)