extends Node3D ## NS-16: server authority; see `position_authority_client.gd`. NS-18: interaction POST + radius ## glow preview (see child nodes). ## NEO-22: WASD locomotion + `move-stream` samples (`player.gd`, ## `position_authority_client.gd`). ## NS-23: bakes `NavigationRegion3D` on startup; boot snap from authority `GET`. ## NEO-15: 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. ## Prototype: `PhysicsRampTest*` (+X extension: west slab ends before the dip; ## `PhysicsRampTestFloorExtensionNorthFill` / `SouthFill` restore walkable floor past the dip in ±Z; ## block; gentle ramp on **+Z**; steep on **+X**), plus `PhysicsSmoothHillTest` / ## `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` ## (updated in `_physics_process` so it matches physics ticks). const MOVE_REJECT_MSG_SECONDS: float = 4.0 ## Throttle `move-stream` POSTs (~20 Hz at 120 physics ticks/sec). const STREAM_PHYSICS_INTERVAL: int = 6 ## Skip `move-stream` while standing still so idle + anchor are not fighting HTTP snaps. const STREAM_IDLE_VEL_HZ_SQ: float = 0.0004 ## Ignore authority snaps smaller than this (m) to avoid micro-flicker from float / echo noise. const AUTH_SNAP_MIN_HORIZONTAL: float = 0.08 const AUTH_SNAP_MIN_VERTICAL: float = 0.04 ## While moving, ignore stream snaps smaller than this (XZ m): responses are often one RTT behind ## integrated `move_and_slide`, so snapping causes rubber-band / bounciness. const AUTH_SNAP_LOCOMOTION_SKIP_MAX_DH: float = 0.55 ## Horizontal speed² above this (m²/s²) counts as “moving” for snap suppression (~0.35 m/s). const AUTH_SNAP_LOCOMOTION_VEL_HZ_SQ: float = 0.12 const PrototypeTargetConstants := preload("res://scripts/prototype_target_constants.gd") ## Bump on each rejection so older one-shot timers do not clear a newer message. var _move_reject_msg_token: int = 0 var _stream_physics_counter: int = 0 ## Cached latest `target_state_changed` payload so the HUD can re-render with live ## distance readouts on every physics tick without missing server-side fields. var _last_target_state: Dictionary = {} ## Last server-acknowledged world position (from `PositionAuthorityClient.authoritative_ack`; ## boot GET 200 or `move-stream` 200). Used purely to render the server-vs-client divergence ## line in the HUD so "target says denied but I'm obviously in range" becomes self-explanatory. var _last_ack_world: Vector3 = Vector3.ZERO var _have_last_ack: bool = false var _last_ack_msec: int = 0 ## After the first `apply_as_snap` authority update, micro-snaps may be skipped ## (boot always applies). var _allow_authority_soft_snap_skip: bool = false ## Next `apply_as_snap` must not be suppressed (e.g. `move-stream` 400 → `sync_from_server`). var _authority_force_snap_next: bool = false ## [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 _authority: Node = $PositionAuthorityClient @onready var _catalog: Node = $InteractablesCatalogClient @onready var _interactables_root: Node3D = $World/NavigationRegion3D/InteractablesRoot @onready var _radius_preview: Node3D = $World/InteractionMarkers @onready var _move_reject_label: Label = $UICanvas/MoveRejectLabel @onready var _player_pos_label: Label = $UICanvas/PlayerPositionLabel @onready var _target_lock_label: Label = $UICanvas/TargetLockLabel @onready var _target_client: Node = $TargetSelectionClient @onready var _interaction_client: Node = $InteractionRequestClient @onready var _floor: StaticBody3D = $World/NavigationRegion3D/Floor var _hotbar_state: Node = null var _hotbar_client: Node = null func _ready() -> void: set_physics_process(true) 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 _authority.connect( "authoritative_position_received", Callable(self, "_on_authoritative_position") ) _authority.connect("move_rejected", Callable(self, "_on_move_rejected")) # NEO-24: movement-driven refresh — `TargetSelectionClient` no-ops on boot (no lock yet) # and throttles bursts (see Decision 2 in NEO-24 plan). `authoritative_ack` fires on # every server-confirmed position (boot GET 200 + `move-stream` 200) so a held lock is # revalidated during ordinary WASD locomotion without re-introducing snap rubber-banding. _authority.connect("authoritative_ack", Callable(_target_client, "on_authoritative_ack")) _authority.connect("authoritative_ack", Callable(self, "_on_authoritative_ack_for_hud")) _target_client.connect("target_state_changed", Callable(self, "_on_target_state_changed")) # NEO-24 post-merge: tell the target client how to nudge the authority with the current # capsule position before each POST /target/select. Shrinks the common "Tab right after # stopping" false-denial race by making the server's stored snapshot as fresh as possible. if _target_client.has_method("set_freshness_kick"): _target_client.call("set_freshness_kick", _authority, _player) _catalog.connect("catalog_ready", Callable(self, "_on_interactables_catalog_ready")) _catalog.connect("catalog_failed", Callable(self, "_on_interactables_catalog_failed")) if _radius_preview.has_method("setup_player"): _radius_preview.call("setup_player", _player) _catalog.call("request_catalog") _authority.call("sync_from_server") _target_client.call("request_sync_from_server") _setup_hotbar_loadout_sync() func _physics_process(_delta: float) -> void: if not is_instance_valid(_player) or not is_instance_valid(_player_pos_label): return var wish: Vector3 = _locomotion_wish_world_xz_from_camera() _player.call("set_locomotion_wish_world_xz", wish) var vel_hz_sq: float = Vector2(_player.velocity.x, _player.velocity.z).length_squared() var moving: bool = wish.length_squared() > 1e-10 or vel_hz_sq > STREAM_IDLE_VEL_HZ_SQ if moving: _stream_physics_counter += 1 if _stream_physics_counter >= STREAM_PHYSICS_INTERVAL: _stream_physics_counter = 0 var batch: Array = [_player.global_position] _authority.call("submit_stream_targets", batch) else: _stream_physics_counter = 0 var p: Vector3 = _player.global_position _player_pos_label.text = _build_player_position_text(p) _render_target_lock_label(p) ## HUD: client position on top, and (once a `move-stream` / boot GET has landed) the ## **server**-acknowledged position + horizontal delta underneath. When the server line ## is far from the client line the capsule has outrun the authoritative state — any ## targeting denials are being computed against the stale server position, and the next ## `move-stream` 200 (or the recovery resync on a 400) will reconcile. func _build_player_position_text(p: Vector3) -> String: var lines: PackedStringArray = ["x: %.3f" % p.x, "y: %.3f" % p.y, "z: %.3f" % p.z] if _have_last_ack: var s: Vector3 = _last_ack_world var dh: float = Vector2(p.x - s.x, p.z - s.z).length() var age_msec: int = max(0, Time.get_ticks_msec() - _last_ack_msec) lines.append("srv: (%.2f, %.2f, %.2f) Δ=%.2fm age=%dms" % [s.x, s.y, s.z, dh, age_msec]) else: lines.append("srv: — (no ack yet)") return "\n".join(lines) func _locomotion_wish_world_xz_from_camera() -> Vector3: var v: Vector2 = Input.get_vector("move_left", "move_right", "move_forward", "move_back") if v.length_squared() < 1e-8: return Vector3.ZERO if not is_instance_valid(_camera): return Vector3.ZERO var forward: Vector3 = -_camera.global_transform.basis.z forward.y = 0.0 if forward.length_squared() < 1e-10: return Vector3.ZERO forward = forward.normalized() var right: Vector3 = forward.cross(Vector3.UP) if right.length_squared() < 1e-10: return Vector3.ZERO right = right.normalized() var w: Vector3 = right * v.x + forward * (-v.y) if w.length_squared() < 1e-10: return Vector3.ZERO return w.normalized() func _on_authoritative_position(world: Vector3, apply_as_snap: bool) -> void: if not apply_as_snap: return var force: bool = _authority_force_snap_next _authority_force_snap_next = false var p: Vector3 = _player.global_position var dh: float = Vector2(world.x - p.x, world.z - p.z).length() var dy: float = absf(world.y - p.y) if _allow_authority_soft_snap_skip and not force: var wish_len2: float = _player._locomotion_wish_world_xz.length_squared() var vel_hz_sq: float = Vector2(_player.velocity.x, _player.velocity.z).length_squared() var locomoting: bool = wish_len2 > 1e-10 or vel_hz_sq > AUTH_SNAP_LOCOMOTION_VEL_HZ_SQ if locomoting and dh < AUTH_SNAP_LOCOMOTION_SKIP_MAX_DH: return if dh < AUTH_SNAP_MIN_HORIZONTAL and dy < AUTH_SNAP_MIN_VERTICAL: return _allow_authority_soft_snap_skip = true _player.snap_to_server(world) ## NEO-24 diagnostic: cache the server's last authoritative position so the HUD can ## surface client-vs-server divergence. This is the same signal `TargetSelectionClient` ## hooks for movement-driven lock refresh — we do not snap the capsule to this value ## (that is `authoritative_position_received`'s job on boot + move rejection). ## ## NEO-27 telemetry reserve (input/cast path not implemented in E1.M3): ## - `ability_cast_requested` hook will attach in E1.M4 input wiring before cast submit. ## - `ability_cast_denied` hook will attach on cast-response denial (`reasonCode`) in E1.M4+E5.M1. ## TODO(E9.M1): map both to the telemetry schema/catalog once available. func _on_authoritative_ack_for_hud(world: Vector3) -> void: _last_ack_world = world _have_last_ack = true _last_ack_msec = Time.get_ticks_msec() ## NEO-24: cache the last server-acknowledged target state; the label text is ## re-rendered every physics tick in [method _render_target_lock_label] so the ## live distance readouts stay in sync with the player capsule even while ## the server state is idle. func _on_target_state_changed(state: Dictionary) -> void: _last_target_state = state.duplicate() if is_instance_valid(_player): _render_target_lock_label(_player.global_position) func _setup_hotbar_loadout_sync() -> void: # NEO-29: hydrate a local hotbar state mirror from server-owned HotbarLoadout on boot. _hotbar_state = load("res://scripts/hotbar_state.gd").new() _hotbar_state.name = "HotbarState" add_child(_hotbar_state) _hotbar_client = load("res://scripts/hotbar_loadout_client.gd").new() _hotbar_client.name = "HotbarLoadoutClient" add_child(_hotbar_client) if is_instance_valid(_authority): var authority_base_url: Variant = _authority.get("base_url") var authority_player_id: Variant = _authority.get("dev_player_id") if authority_base_url is String and not (authority_base_url as String).is_empty(): _hotbar_client.set("base_url", authority_base_url) if authority_player_id is String and not (authority_player_id as String).is_empty(): _hotbar_client.set("dev_player_id", authority_player_id) if _hotbar_client.has_method("set_hotbar_state"): _hotbar_client.call("set_hotbar_state", _hotbar_state) if _hotbar_client.has_method("request_sync_from_server"): _hotbar_client.call("request_sync_from_server") ## Combines cached server state (`_last_target_state`) with per-anchor horizontal ## distance computed from the live capsule position. Distances are a **display-only** ## hint — the server remains authoritative for `validity`. If the HUD shows ## `Validity: ok` but `alpha: 10.3 m` (> 8 m radius), that means the server's ## position snapshot is lagging the visible capsule (NEO-23 `move-stream` 20 Hz + ## one-way latency); the refresh GET on `authoritative_ack` will reconcile within ## ~250 ms cooldown the next time a `move-stream` lands. func _render_target_lock_label(world: Vector3) -> void: if not is_instance_valid(_target_lock_label): return var state: Dictionary = _last_target_state var locked_variant: Variant = state.get("lockedTargetId", null) var locked_text: String = "—" if locked_variant is String and not (locked_variant as String).is_empty(): locked_text = locked_variant as String var validity: String = str(state.get("validity", "none")) var sequence: int = int(state.get("sequence", 0)) var lines: PackedStringArray = [ "Target: %s" % locked_text, "Validity: %s" % validity, "Seq: %d" % sequence, ] for id_variant: Variant in PrototypeTargetConstants.ordered_ids(): var tid: String = id_variant as String var dist: float = PrototypeTargetConstants.horizontal_distance_to(tid, world) var radius: float = PrototypeTargetConstants.anchor_radius(tid) var marker: String = "in" if dist <= radius else "out" lines.append("%s: %.2f m / %.1f (%s)" % [tid, dist, radius, marker]) if state.has("reasonCode"): lines.append("Denied: %s" % str(state["reasonCode"])) _target_lock_label.text = "\n".join(lines) func _on_move_rejected(reason_code: String) -> void: _authority_force_snap_next = true # Rejected stream: server state may differ; next snap is forced. 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 (NEO-18 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: # NEO-25: route interact keys here — `InteractionRequestClient._input` is unreliable in the # embedded Game dock / focus order; `_unhandled_key_input` on the scene root still fires. if event.is_action_pressed("interact"): _forward_interact_post("post_interact_terminal") return if event.is_action_pressed("interact_secondary"): _forward_interact_post("post_interact_resource") return if event is InputEventKey: var k := event as InputEventKey if k.pressed and not k.echo and (k.physical_keycode == KEY_E or k.keycode == KEY_E): _forward_interact_post("post_interact_terminal") return if k.pressed and not k.echo and (k.physical_keycode == KEY_R or k.keycode == KEY_R): _forward_interact_post("post_interact_resource") return if not event.is_action_pressed("dev_toggle_occluder_obstacle"): return call_deferred("_dev_toggle_obstacle_smoke_deferred") func _forward_interact_post(method: String) -> void: if not is_instance_valid(_interaction_client): return if _interaction_client.has_method(method): _interaction_client.call(method) 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) func _on_interactables_catalog_ready(descriptors: Array) -> void: var groups: Variant = load("res://scripts/interactable_world_builder.gd").call( "build_from_catalog", descriptors, _interactables_root, _radius_preview ) if _radius_preview.has_method("setup_glow_groups"): _radius_preview.call("setup_glow_groups", groups as Array) func _on_interactables_catalog_failed(reason: String) -> void: # `InteractablesCatalogClient` already `push_error`s; this hook exists for wiring clarity. var readme := "Interaction + range preview (NEO-9 + NEO-25)" push_warning( ( "Interactables catalog failed (%s) — start the game server first; see client README (%s)." % [reason, readme] ) )