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: player position in PlayerPositionLabel (HudRootScroll/HudRoot); ## updated in `_physics_process` to match 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") const PrototypeInteractablePicker := preload("res://scripts/prototype_interactable_picker.gd") const HotbarCastSlotResolver := preload("res://scripts/hotbar_cast_slot_resolver.gd") const CooldownStateScript := preload("res://scripts/cooldown_state.gd") const NpcRuntimeClientScript := preload("res://scripts/npc_runtime_client.gd") const PlayerCombatHealthClientScript := preload("res://scripts/player_combat_health_client.gd") const NpcRuntimeHudStateScript := preload("res://scripts/npc_runtime_hud_state.gd") const NpcCombatHudHelpers := preload("res://scripts/npc_combat_hud_helpers.gd") const PrototypeHudTheme := preload("res://scripts/prototype_hud_theme.gd") const COMBAT_POLL_INTERVAL_SEC: float = 1.0 const COOLDOWN_REASON_ON_CD := "on_cooldown" const DEV_BOOTSTRAP_CAST_ABILITY_ID := "prototype_pulse" const SCRAP_METAL_BULK_ID := "scrap_metal_bulk" const SALVAGE_SKILL_ID := "salvage" const REFINE_SKILL_ID := "refine" const BREACH_GIG_ID := "breach" const PROTOTYPE_ENCOUNTER_ID := "prototype_combat_pocket" const PROTOTYPE_ENCOUNTER_REQUIRED_TARGETS := 3 const QuestHudController := preload("res://scripts/quest_hud_controller.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 var _hotbar_state: Node = null var _hotbar_client: Node = null var _ability_cast_client: Node = null var _combat_targets_client: Node = null var _combat_targets_snapshot: Dictionary = {} var _npc_runtime_client: Node = null var _player_combat_health_client: Node = null var _npc_runtime_hud_state: RefCounted = null var _npc_runtime_snapshot: Dictionary = {} var _npc_runtime_sync_error: String = "" var _player_combat_health_snapshot: Dictionary = {} var _player_combat_health_error: String = "" var _cooldown_state: RefCounted = null var _cooldown_client: Node = null var _cooldown_poll_timer: Timer = null var _combat_poll_timer: Timer = null var _npc_combat_hud_needs_tick: bool = false var _last_inventory_snapshot: Dictionary = {} var _inventory_error: String = "" var _interactables_catalog: Array = [] var _last_progression_snapshot: Dictionary = {} var _progression_error: String = "" var _last_gig_snapshot: Dictionary = {} var _gig_error: String = "" var _last_encounter_progress_snapshot: Dictionary = {} var _encounter_progress_error: String = "" var _quest_hud: Node = null var _gather_pre_scrap_qty: int = 0 var _gather_pending_interactable_id: String = "" var _gather_awaiting_inventory_finalize: bool = false var _cached_recipe_rows: Array = [] var _recipe_defs_error: String = "" var _dev_pulse_bootstrap_attempted: bool = false @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 _hud_root: VBoxContainer = $UICanvas/HudRootScroll/HudRoot @onready var _player_pos_label: Label = _hud_root.get_node("PlayerPositionLabel") @onready var _target_lock_label: Label = _hud_root.get_node("TargetLockLabel") @onready var _cast_feedback_label: Label = _hud_root.get_node("CastFeedbackLabel") @onready var _combat_target_hp_label: Label = _hud_root.get_node("CombatTargetHpLabel") @onready var _player_combat_hp_label: Label = _hud_root.get_node("PlayerCombatHpLabel") @onready var _encounter_progress_label: Label = _hud_root.get_node("EncounterProgressLabel") @onready var _encounter_complete_label: Label = _hud_root.get_node("EncounterCompleteLabel") @onready var _quest_progress_label: Label = _hud_root.get_node("QuestProgressLabel") @onready var _quest_accept_feedback_label: Label = _hud_root.get_node("QuestAcceptFeedbackLabel") @onready var _quest_reward_delivery_label: Label = _hud_root.get_node("QuestRewardDeliveryLabel") @onready var _npc_state_label: Label = _hud_root.get_node("NpcStateLabel") @onready var _telegraph_label: Label = _hud_root.get_node("TelegraphLabel") @onready var _cooldown_slots_label: Label = _hud_root.get_node("CooldownSlotsLabel") @onready var _economy_hud_section: VBoxContainer = $UICanvas/EconomyHudSection @onready var _economy_body: Node = _economy_hud_section.get_node("BodyScroll/Body") @onready var _inventory_label: Label = _economy_body.get_node("InventoryLabel") @onready var _gather_feedback_label: Label = _hud_root.get_node("GatherFeedbackLabel") @onready var _craft_feedback_label: Label = _hud_root.get_node("CraftFeedbackLabel") @onready var _skill_progression_label: Label = _economy_body.get_node("SkillProgressionLabel") @onready var _gig_xp_label: Label = _economy_body.get_node("GigXpLabel") @onready var _craft_recipe_panel: Node = _economy_body.get_node("CraftRecipePanel") @onready var _inventory_client: Node = $InventoryClient @onready var _item_defs_client: Node = $ItemDefinitionsClient @onready var _skill_progression_client: Node = $SkillProgressionClient @onready var _gig_progression_client: Node = $GigProgressionClient @onready var _encounter_progress_client: Node = $EncounterProgressClient @onready var _quest_progress_client: Node = $QuestProgressClient @onready var _quest_defs_client: Node = $QuestDefinitionsClient @onready var _recipe_defs_client: Node = $RecipeDefinitionsClient @onready var _craft_client: Node = $CraftClient @onready var _target_client: Node = $TargetSelectionClient @onready var _target_markers: Node3D = $World/PrototypeTargetMarkers @onready var _interaction_client: Node = $InteractionRequestClient @onready var _floor: StaticBody3D = $World/NavigationRegion3D/Floor func _ready() -> void: set_physics_process(true) set_process_unhandled_key_input(true) _apply_prototype_hud_theme() 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() _setup_inventory_sync() _setup_skill_progression_sync() _setup_gig_progression_sync() _setup_encounter_progress_sync() _setup_quest_progress_sync() _setup_gather_interact_feedback() _setup_craft_ui() 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) if _cooldown_state != null and _cooldown_state.has_method("any_slot_cooling"): if bool(_cooldown_state.call("any_slot_cooling")): _render_cooldown_slots_label() if _npc_combat_hud_needs_tick: _render_npc_combat_hud_labels() ## 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 / NEO-31 / NEO-30: Slice 3 [code]ability_cast_requested[/code] — [code]print[/code] from ## [method _request_hotbar_cast_slot] after [code]request_cast[/code] returns [code]true[/code] ## (POST queued on [code]HTTPRequest[/code]). ## Server hooks: [code]AbilityCastApi.cs[/code] (accept path). [code]ability_cast_denied[/code]: ## [code]push_warning[/code] in [code]ability_cast_client.gd[/code]. ## HUD [member _cast_feedback_label] (NEO-28). Deny branches in [code]AbilityCastApi.cs[/code]. ## TODO(E9.M1): catalog + ingest. func _apply_prototype_hud_theme() -> void: PrototypeHudTheme.apply_to_label(_move_reject_label, true) if is_instance_valid(_hud_root): PrototypeHudTheme.apply_to_hud_root(_hud_root) if is_instance_valid(_economy_hud_section): PrototypeHudTheme.apply_to_economy_section(_economy_hud_section) 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: var prev_lock := NpcCombatHudHelpers.locked_target_id_from_state(_last_target_state) _last_target_state = state.duplicate() if is_instance_valid(_player): _render_target_lock_label(_player.global_position) _render_combat_target_hp_label() var new_lock := NpcCombatHudHelpers.locked_target_id_from_state(_last_target_state) if is_instance_valid(_target_markers) and _target_markers.has_method("set_locked_target"): _target_markers.call("set_locked_target", new_lock) if new_lock != prev_lock: _request_combat_targets_refresh() _update_combat_poll_gate() if _is_combat_active(): _request_npc_combat_sync() 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) _ability_cast_client = load("res://scripts/ability_cast_client.gd").new() _ability_cast_client.name = "AbilityCastClient" add_child(_ability_cast_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) _ability_cast_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) _ability_cast_client.set("dev_player_id", authority_player_id) if _ability_cast_client.has_signal("cast_result_received"): _ability_cast_client.connect( "cast_result_received", Callable(self, "_on_cast_result_received") ) 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") _setup_combat_targets_sync() _setup_npc_combat_sync() _setup_cooldown_sync() func _setup_combat_targets_sync() -> void: # NEO-85: authoritative combat dummy HP for locked target HUD line. _combat_targets_client = load("res://scripts/combat_targets_client.gd").new() _combat_targets_client.name = "CombatTargetsClient" add_child(_combat_targets_client) _apply_authority_http_config_to_client(_combat_targets_client) if _combat_targets_client.has_signal("targets_received"): _combat_targets_client.connect( "targets_received", Callable(self, "_on_combat_targets_received") ) func _setup_npc_combat_sync() -> void: # NEO-97: npc-runtime snapshot + session player combat HP for telegraph HUD. _npc_runtime_hud_state = NpcRuntimeHudStateScript.new() _npc_runtime_client = NpcRuntimeClientScript.new() _npc_runtime_client.name = "NpcRuntimeClient" add_child(_npc_runtime_client) _apply_authority_http_config_to_client(_npc_runtime_client) _player_combat_health_client = PlayerCombatHealthClientScript.new() _player_combat_health_client.name = "PlayerCombatHealthClient" add_child(_player_combat_health_client) _apply_authority_http_config_to_client(_player_combat_health_client) if _npc_runtime_client.has_signal("runtime_received"): _npc_runtime_client.connect("runtime_received", Callable(self, "_on_npc_runtime_received")) if _npc_runtime_client.has_signal("runtime_sync_failed"): _npc_runtime_client.connect( "runtime_sync_failed", Callable(self, "_on_npc_runtime_sync_failed") ) if _player_combat_health_client.has_signal("health_received"): _player_combat_health_client.connect( "health_received", Callable(self, "_on_player_combat_health_received") ) if _player_combat_health_client.has_signal("health_sync_failed"): _player_combat_health_client.connect( "health_sync_failed", Callable(self, "_on_player_combat_health_sync_failed") ) _ensure_combat_poll_timer() _render_npc_combat_hud_labels() func _setup_cooldown_sync() -> void: # NEO-32: server cooldown snapshot + local mirror for HUD and cast spam guard. _cooldown_state = CooldownStateScript.new() _cooldown_client = load("res://scripts/cooldown_snapshot_client.gd").new() _cooldown_client.name = "CooldownSnapshotClient" add_child(_cooldown_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(): _cooldown_client.set("base_url", authority_base_url) if authority_player_id is String and not (authority_player_id as String).is_empty(): _cooldown_client.set("dev_player_id", authority_player_id) if _cooldown_client.has_signal("snapshot_received"): _cooldown_client.connect( "snapshot_received", Callable(self, "_on_cooldown_snapshot_received") ) if _hotbar_client.has_signal("loadout_changed"): _hotbar_client.connect("loadout_changed", Callable(self, "_on_hotbar_loadout_changed")) _ensure_cooldown_poll_timer() func _ensure_cooldown_poll_timer() -> void: if is_instance_valid(_cooldown_poll_timer): return _cooldown_poll_timer = Timer.new() _cooldown_poll_timer.name = "CooldownPollTimer" _cooldown_poll_timer.one_shot = true _cooldown_poll_timer.wait_time = 0.25 _cooldown_poll_timer.timeout.connect(_on_cooldown_poll_timeout) add_child(_cooldown_poll_timer) func _ensure_combat_poll_timer() -> void: if is_instance_valid(_combat_poll_timer): return _combat_poll_timer = Timer.new() _combat_poll_timer.name = "CombatPollTimer" _combat_poll_timer.one_shot = false _combat_poll_timer.wait_time = COMBAT_POLL_INTERVAL_SEC _combat_poll_timer.timeout.connect(_on_combat_poll_timeout) add_child(_combat_poll_timer) func _setup_inventory_sync() -> void: # NEO-72: item defs for display names, then inventory GET for bag/equipment HUD. _apply_authority_http_config_to_client(_item_defs_client) _apply_authority_http_config_to_client(_inventory_client) if _item_defs_client.has_signal("definitions_ready"): _item_defs_client.connect("definitions_ready", Callable(self, "_on_item_definitions_ready")) if _inventory_client.has_signal("inventory_received"): _inventory_client.connect("inventory_received", Callable(self, "_on_inventory_received")) if _inventory_client.has_signal("inventory_sync_failed"): _inventory_client.connect( "inventory_sync_failed", Callable(self, "_on_inventory_sync_failed") ) _render_inventory_label() if _item_defs_client.has_method("request_sync_from_server"): _item_defs_client.call("request_sync_from_server") if _inventory_client.has_method("request_sync_from_server"): _inventory_client.call("request_sync_from_server") func _apply_authority_http_config_to_client(client: Node) -> void: if not is_instance_valid(client) or not is_instance_valid(_authority): return 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(): client.set("base_url", authority_base_url) if authority_player_id is String and not (authority_player_id as String).is_empty(): if client.get("dev_player_id") != null: client.set("dev_player_id", authority_player_id) func _on_item_definitions_ready(_definitions_by_id: Dictionary) -> void: _populate_craft_recipe_panel_if_ready() if _inventory_error.is_empty() and not _last_inventory_snapshot.is_empty(): _render_inventory_label() if _encounter_progress_error.is_empty() and not _last_encounter_progress_snapshot.is_empty(): _render_encounter_progress_labels() func _on_inventory_received(snapshot: Dictionary) -> void: _inventory_error = "" _last_inventory_snapshot = snapshot.duplicate(true) _render_inventory_label() _finalize_pending_gather_feedback() func _on_inventory_sync_failed(reason: String) -> void: _inventory_error = reason _last_inventory_snapshot = {} _render_inventory_label() if _gather_awaiting_inventory_finalize: _clear_gather_session() _render_gather_feedback_label("Gather: inventory refresh failed") func _render_inventory_label() -> void: if not is_instance_valid(_inventory_label): return var header := "Inventory: (refresh: I)" if not _inventory_error.is_empty(): _inventory_label.text = "%s\nerror — %s" % [header, _inventory_error] return if _last_inventory_snapshot.is_empty(): _inventory_label.text = "%s\nLoading…" % header return var lines: PackedStringArray = [header, "Bag:"] var bag_lines: PackedStringArray = _format_inventory_slot_lines( _last_inventory_snapshot.get("bagSlots", []), false ) if bag_lines.is_empty(): lines.append(" (empty)") else: for line in bag_lines: lines.append(line) lines.append("Equipment:") var equip_lines: PackedStringArray = _format_inventory_slot_lines( _last_inventory_snapshot.get("equipmentSlots", []), true ) if equip_lines.is_empty(): lines.append(" slot 0: — empty") else: for line in equip_lines: lines.append(line) _inventory_label.text = "\n".join(lines) func _format_inventory_slot_lines( raw_slots: Variant, always_show_empty_stub: bool ) -> PackedStringArray: var out: PackedStringArray = PackedStringArray() if raw_slots == null or not raw_slots is Array: if always_show_empty_stub: out.append(" slot 0: — empty") return out for slot_variant in raw_slots as Array: if not slot_variant is Dictionary: continue var slot: Dictionary = slot_variant var slot_index: int = int(slot.get("slotIndex", -1)) var quantity: int = int(slot.get("quantity", 0)) if quantity <= 0: if always_show_empty_stub and slot_index == 0: out.append(" slot 0: — empty") continue var item_id: String = str(slot.get("itemId", "")) var label: String = _inventory_item_label(item_id) out.append(" slot %d: %s x%d" % [slot_index, label, quantity]) return out func _inventory_item_label(item_id: String) -> String: if not is_instance_valid(_item_defs_client): return item_id if not _item_defs_client.has_method("display_name_for"): return item_id return str(_item_defs_client.call("display_name_for", item_id)) func _request_inventory_refresh() -> void: if not is_instance_valid(_inventory_client): return if _inventory_client.has_method("request_sync_from_server"): _inventory_client.call("request_sync_from_server") func _setup_skill_progression_sync() -> void: # NEO-73: salvage XP/level HUD row; boot hydrate + refresh after gather. _apply_authority_http_config_to_client(_skill_progression_client) if _skill_progression_client.has_signal("progression_received"): _skill_progression_client.connect( "progression_received", Callable(self, "_on_progression_received") ) if _skill_progression_client.has_signal("progression_sync_failed"): _skill_progression_client.connect( "progression_sync_failed", Callable(self, "_on_progression_sync_failed") ) _render_skill_progression_label() if _skill_progression_client.has_method("request_sync_from_server"): _skill_progression_client.call("request_sync_from_server") func _setup_gig_progression_sync() -> void: # NEO-86: breach gig XP HUD row; boot hydrate + refresh after defeat cast. _apply_authority_http_config_to_client(_gig_progression_client) if _gig_progression_client.has_signal("progression_received"): _gig_progression_client.connect( "progression_received", Callable(self, "_on_gig_progression_received") ) if _gig_progression_client.has_signal("progression_sync_failed"): _gig_progression_client.connect( "progression_sync_failed", Callable(self, "_on_gig_progression_sync_failed") ) _render_gig_xp_label() if _gig_progression_client.has_method("request_sync_from_server"): _gig_progression_client.call("request_sync_from_server") func _setup_gather_interact_feedback() -> void: if not is_instance_valid(_interaction_client): return if _interaction_client.has_signal("interaction_result_received"): _interaction_client.connect( "interaction_result_received", Callable(self, "_on_interaction_result_for_gather") ) if _interaction_client.has_signal("interaction_request_failed"): _interaction_client.connect( "interaction_request_failed", Callable(self, "_on_interaction_request_failed_for_gather") ) _render_gather_feedback_label() func _on_progression_received(snapshot: Dictionary) -> void: _progression_error = "" _last_progression_snapshot = snapshot.duplicate(true) _render_skill_progression_label() func _on_progression_sync_failed(reason: String) -> void: _progression_error = reason _last_progression_snapshot = {} _render_skill_progression_label() func _render_skill_progression_label() -> void: if not is_instance_valid(_skill_progression_label): return var header := "Skills:" if not _progression_error.is_empty(): _skill_progression_label.text = "%s\nerror — %s" % [header, _progression_error] return if _last_progression_snapshot.is_empty(): _skill_progression_label.text = "%s\nLoading…" % header return var salvage_row: Dictionary = _skill_row(SALVAGE_SKILL_ID) var refine_row: Dictionary = _skill_row(REFINE_SKILL_ID) if salvage_row.is_empty() or refine_row.is_empty(): _skill_progression_label.text = "%s\nsalvage/refine: — (missing row)" % header return var salvage_level: int = int(salvage_row.get("level", 0)) var salvage_xp: int = int(salvage_row.get("xp", 0)) var refine_level: int = int(refine_row.get("level", 0)) var refine_xp: int = int(refine_row.get("xp", 0)) _skill_progression_label.text = ( "%s\nsalvage: level %d, xp %d\nrefine: level %d, xp %d" % [header, salvage_level, salvage_xp, refine_level, refine_xp] ) func _skill_row(skill_id: String) -> Dictionary: if not is_instance_valid(_skill_progression_client): return {} if not _skill_progression_client.has_method("skill_row"): return {} var row: Variant = _skill_progression_client.call( "skill_row", skill_id, _last_progression_snapshot ) return row as Dictionary func _on_gig_progression_received(snapshot: Dictionary) -> void: _gig_error = "" _last_gig_snapshot = snapshot.duplicate(true) _render_gig_xp_label() func _on_gig_progression_sync_failed(reason: String) -> void: _gig_error = reason _last_gig_snapshot = {} _render_gig_xp_label() func _render_gig_xp_label() -> void: if not is_instance_valid(_gig_xp_label): return var header := "Gig XP:" if not _gig_error.is_empty(): _gig_xp_label.text = "%s\nerror — %s" % [header, _gig_error] return if _last_gig_snapshot.is_empty(): _gig_xp_label.text = "%s\nLoading…" % header return var breach_row: Dictionary = _gig_row(BREACH_GIG_ID) if breach_row.is_empty(): _gig_xp_label.text = "%s\nbreach: — (missing row)" % header return var level: int = int(breach_row.get("level", 0)) var xp: int = int(breach_row.get("xp", 0)) _gig_xp_label.text = "%s\nbreach: L%d · %d xp" % [header, level, xp] func _gig_row(gig_id: String) -> Dictionary: if not is_instance_valid(_gig_progression_client): return {} if not _gig_progression_client.has_method("gig_row"): return {} var row: Variant = _gig_progression_client.call("gig_row", gig_id, _last_gig_snapshot) return row as Dictionary func _request_gig_progression_refresh() -> void: if not is_instance_valid(_gig_progression_client): return if _gig_progression_client.has_method("request_sync_from_server"): _gig_progression_client.call("request_sync_from_server") func _setup_encounter_progress_sync() -> void: # NEO-110: encounter progress + loot HUD; boot hydrate + refresh after NPC defeat cast. _apply_authority_http_config_to_client(_encounter_progress_client) if _encounter_progress_client.has_signal("encounter_progress_received"): _encounter_progress_client.connect( "encounter_progress_received", Callable(self, "_on_encounter_progress_received") ) if _encounter_progress_client.has_signal("encounter_sync_failed"): _encounter_progress_client.connect( "encounter_sync_failed", Callable(self, "_on_encounter_progress_sync_failed") ) _render_encounter_progress_labels() if _encounter_progress_client.has_method("request_sync_from_server"): _encounter_progress_client.call("request_sync_from_server") func _on_encounter_progress_received(snapshot: Dictionary) -> void: _encounter_progress_error = "" _last_encounter_progress_snapshot = snapshot.duplicate(true) _render_encounter_progress_labels() var row: Dictionary = _encounter_row(PROTOTYPE_ENCOUNTER_ID) if str(row.get("state", "")) == "completed": _request_inventory_refresh() _request_quest_progress_refresh() func _on_encounter_progress_sync_failed(reason: String) -> void: _encounter_progress_error = reason _last_encounter_progress_snapshot = {} _render_encounter_progress_labels() func _render_encounter_progress_labels() -> void: _render_encounter_progress_label() _render_encounter_complete_label() func _render_encounter_progress_label() -> void: if not is_instance_valid(_encounter_progress_label): return var header := "Encounter:" if not _encounter_progress_error.is_empty(): _encounter_progress_label.text = "%s\nerror — %s" % [header, _encounter_progress_error] return if _last_encounter_progress_snapshot.is_empty(): _encounter_progress_label.text = "%s\nLoading…" % header return var row: Dictionary = _encounter_row(PROTOTYPE_ENCOUNTER_ID) if row.is_empty(): _encounter_progress_label.text = ( "%s\n%s: — (missing row)" % [header, PROTOTYPE_ENCOUNTER_ID] ) return var state: String = str(row.get("state", "")) var body: String = "" match state: "inactive": body = ( "%s: not started (0/%d)" % [PROTOTYPE_ENCOUNTER_ID, PROTOTYPE_ENCOUNTER_REQUIRED_TARGETS] ) "active": var defeated: Variant = row.get("defeatedTargetIds", []) var n: int = defeated.size() if defeated is Array else 0 body = "%s: %d/%d" % [PROTOTYPE_ENCOUNTER_ID, n, PROTOTYPE_ENCOUNTER_REQUIRED_TARGETS] "completed": body = ( "%s: completed (%d/%d)" % [ PROTOTYPE_ENCOUNTER_ID, PROTOTYPE_ENCOUNTER_REQUIRED_TARGETS, PROTOTYPE_ENCOUNTER_REQUIRED_TARGETS, ] ) _: body = "%s: unknown state (%s)" % [PROTOTYPE_ENCOUNTER_ID, state] _encounter_progress_label.text = "%s\n%s" % [header, body] func _render_encounter_complete_label() -> void: if not is_instance_valid(_encounter_complete_label): return var header := "Loot:" if not _encounter_progress_error.is_empty(): _encounter_complete_label.text = "%s\n—" % header return if _last_encounter_progress_snapshot.is_empty(): _encounter_complete_label.text = "%s\n—" % header return var row: Dictionary = _encounter_row(PROTOTYPE_ENCOUNTER_ID) if row.is_empty() or str(row.get("state", "")) != "completed": _encounter_complete_label.text = "%s\n—" % header return var lines: PackedStringArray = [header] var completed_at: String = str(row.get("completedAt", "")).strip_edges() if not completed_at.is_empty(): lines[0] = "%s (completed %s)" % [header, completed_at] var grants: Variant = row.get("rewardGrantSummary", []) if grants is Array: for grant_variant in grants as Array: if not grant_variant is Dictionary: continue var grant: Dictionary = grant_variant var item_id: String = str(grant.get("itemId", "")) var qty: int = int(grant.get("quantity", 0)) var label: String = _inventory_item_label(item_id) if not item_id.is_empty() else "?" lines.append(" %s ×%d" % [label, qty]) if lines.size() == 1: lines.append(" (no grants)") _encounter_complete_label.text = "\n".join(lines) func _encounter_row(encounter_id: String) -> Dictionary: if not is_instance_valid(_encounter_progress_client): return {} if not _encounter_progress_client.has_method("encounter_row"): return {} var row: Variant = _encounter_progress_client.call( "encounter_row", encounter_id, _last_encounter_progress_snapshot ) return row as Dictionary func _request_encounter_progress_refresh() -> void: if not is_instance_valid(_encounter_progress_client): return if _encounter_progress_client.has_method("request_sync_from_server"): _encounter_progress_client.call("request_sync_from_server") func _setup_quest_progress_sync() -> void: # NEO-122: quest progress + accept HUD; boot hydrate + refresh after gather/craft/defeat. _quest_hud = QuestHudController.new() add_child(_quest_hud) _quest_hud.call( "setup", _quest_progress_client, _quest_defs_client, _quest_progress_label, _quest_accept_feedback_label, Callable(self, "_apply_authority_http_config_to_client"), _quest_reward_delivery_label, _item_defs_client ) func _request_quest_progress_refresh() -> void: if is_instance_valid(_quest_hud): _quest_hud.call("request_progress_refresh") func _try_quest_accept_key_input(event: InputEvent) -> bool: if is_instance_valid(_quest_hud): return bool(_quest_hud.call("try_accept_key_input", event)) return false func _render_gather_feedback_label(text: String = "Gather: —") -> void: if is_instance_valid(_gather_feedback_label): _gather_feedback_label.text = text func _setup_craft_ui() -> void: # NEO-74: recipe defs + craft POST + scrollable recipe panel. _apply_authority_http_config_to_client(_recipe_defs_client) _apply_authority_http_config_to_client(_craft_client) if _craft_recipe_panel.has_method("setup"): _craft_recipe_panel.call("setup", _item_defs_client) if _recipe_defs_client.has_signal("recipes_ready"): _recipe_defs_client.connect("recipes_ready", Callable(self, "_on_recipes_ready")) if _recipe_defs_client.has_signal("recipes_sync_failed"): _recipe_defs_client.connect( "recipes_sync_failed", Callable(self, "_on_recipes_sync_failed") ) if _craft_recipe_panel.has_signal("craft_requested"): _craft_recipe_panel.connect("craft_requested", Callable(self, "_on_craft_requested")) if _craft_client.has_signal("craft_result_received"): _craft_client.connect("craft_result_received", Callable(self, "_on_craft_result_received")) if _craft_client.has_signal("craft_request_failed"): _craft_client.connect("craft_request_failed", Callable(self, "_on_craft_request_failed")) _render_craft_feedback_label() if _recipe_defs_client.has_method("request_sync_from_server"): _recipe_defs_client.call("request_sync_from_server") func _on_recipes_ready(recipes: Array) -> void: if not _recipe_defs_error.is_empty(): _render_craft_feedback_label() _recipe_defs_error = "" _cached_recipe_rows = recipes.duplicate(true) _populate_craft_recipe_panel_if_ready() func _on_recipes_sync_failed(reason: String) -> void: _recipe_defs_error = reason _cached_recipe_rows = [] _render_craft_feedback_label("Recipes: error — %s" % reason) func _populate_craft_recipe_panel_if_ready() -> void: if _cached_recipe_rows.is_empty(): return if is_instance_valid(_craft_recipe_panel) and _craft_recipe_panel.has_method("populate"): _craft_recipe_panel.call("populate", _cached_recipe_rows) func _on_craft_requested(recipe_id: String) -> void: if not is_instance_valid(_craft_client): return if _craft_client.has_method("is_busy") and bool(_craft_client.call("is_busy")): return if _craft_recipe_panel.has_method("set_craft_busy"): _craft_recipe_panel.call("set_craft_busy", true) if _craft_client.has_method("request_craft"): var started: bool = bool(_craft_client.call("request_craft", recipe_id, 1)) if not started and _craft_recipe_panel.has_method("set_craft_busy"): _craft_recipe_panel.call("set_craft_busy", false) func _on_craft_result_received(_recipe_id: String, result: Dictionary) -> void: if _craft_recipe_panel.has_method("set_craft_busy"): _craft_recipe_panel.call("set_craft_busy", false) if bool(result.get("success", false)): _render_craft_feedback_label(_format_craft_success_line(result.get("outputsGranted", []))) _request_inventory_refresh() _request_quest_progress_refresh() if ( is_instance_valid(_skill_progression_client) and _skill_progression_client.has_method("request_sync_from_server") ): _skill_progression_client.call("request_sync_from_server") return var rc := str(result.get("reasonCode", "")).strip_edges() if rc.is_empty(): _render_craft_feedback_label("Craft: denied (no reasonCode)") else: _render_craft_feedback_label("Craft: denied — %s" % rc) func _on_craft_request_failed(_recipe_id: String, reason: String) -> void: if _craft_recipe_panel.has_method("set_craft_busy"): _craft_recipe_panel.call("set_craft_busy", false) _render_craft_feedback_label("Craft: failed — %s" % reason) func _format_craft_success_line(raw_outputs: Variant) -> String: if raw_outputs == null or not raw_outputs is Array: return "Craft: ok (no outputsGranted)" var parts: PackedStringArray = PackedStringArray() for row_variant in raw_outputs as Array: if not row_variant is Dictionary: continue var row: Dictionary = row_variant var item_id: String = str(row.get("itemId", "")) var qty: int = int(row.get("quantity", 0)) if item_id.is_empty() or qty <= 0: continue parts.append("+%d %s" % [qty, _inventory_item_label(item_id)]) if parts.is_empty(): return "Craft: ok (empty outputsGranted)" return "Craft: %s" % ", ".join(parts) func _render_craft_feedback_label(text: String = "Craft: —") -> void: if is_instance_valid(_craft_feedback_label): _craft_feedback_label.text = text func _clear_gather_session() -> void: _gather_pending_interactable_id = "" _gather_awaiting_inventory_finalize = false func _on_interaction_result_for_gather( interactable_id: String, allowed: bool, reason_code: String ) -> void: if _gather_pending_interactable_id.is_empty(): return if interactable_id != _gather_pending_interactable_id: if _gather_awaiting_inventory_finalize: return _clear_gather_session() return if not PrototypeInteractablePicker.is_resource_node_id(_interactables_catalog, interactable_id): _clear_gather_session() return if not allowed: _clear_gather_session() var rc := reason_code.strip_edges() if rc.is_empty(): _render_gather_feedback_label("Gather: denied (no reasonCode)") else: _render_gather_feedback_label("Gather: denied — %s" % rc) return _gather_awaiting_inventory_finalize = true _request_inventory_refresh() _request_quest_progress_refresh() if ( is_instance_valid(_skill_progression_client) and _skill_progression_client.has_method("request_sync_from_server") ): _skill_progression_client.call("request_sync_from_server") func _on_interaction_request_failed_for_gather(interactable_id: String, _reason: String) -> void: if _gather_pending_interactable_id.is_empty(): return if interactable_id != _gather_pending_interactable_id: if not _gather_awaiting_inventory_finalize: _clear_gather_session() return if not _gather_awaiting_inventory_finalize: _clear_gather_session() func _total_bag_qty(item_id: String, snapshot: Dictionary = {}) -> int: var source: Dictionary = snapshot if source.is_empty(): source = _last_inventory_snapshot var bag: Variant = source.get("bagSlots", null) if bag == null or not bag is Array: return 0 var total: int = 0 for slot_variant in bag as Array: if not slot_variant is Dictionary: continue var slot: Dictionary = slot_variant if str(slot.get("itemId", "")) != item_id: continue total += int(slot.get("quantity", 0)) return total func _finalize_pending_gather_feedback() -> void: if not _gather_awaiting_inventory_finalize: return var pre_qty: int = _gather_pre_scrap_qty _clear_gather_session() var new_qty: int = _total_bag_qty(SCRAP_METAL_BULK_ID) var delta: int = new_qty - pre_qty if delta <= 0: _render_gather_feedback_label("Gather: ok (no scrap delta)") return var label: String = _inventory_item_label(SCRAP_METAL_BULK_ID) _render_gather_feedback_label("Gather: +%d %s" % [delta, label]) func _on_hotbar_loadout_changed(loadout: Dictionary) -> void: _on_hotbar_loadout_changed_sync_cooldown(loadout) _maybe_dev_bind_pulse_slot0() func _maybe_dev_bind_pulse_slot0() -> void: if _dev_pulse_bootstrap_attempted: return if not is_instance_valid(_hotbar_state) or not is_instance_valid(_hotbar_client): return var slots: Array = _hotbar_state.call("slots_snapshot") as Array if slots.is_empty(): return var slot0: Variant = slots[0] if slot0 is String and not (slot0 as String).strip_edges().is_empty(): return _dev_pulse_bootstrap_attempted = true if _hotbar_client.has_method("request_bind_slot"): _hotbar_client.call("request_bind_slot", 0, DEV_BOOTSTRAP_CAST_ABILITY_ID) func _on_hotbar_loadout_changed_sync_cooldown(_loadout: Dictionary) -> void: if ( is_instance_valid(_cooldown_client) and _cooldown_client.has_method("request_sync_from_server") ): _cooldown_client.call("request_sync_from_server") func _on_cooldown_poll_timeout() -> void: if ( is_instance_valid(_cooldown_client) and _cooldown_client.has_method("request_sync_from_server") ): _cooldown_client.call("request_sync_from_server") func _on_cooldown_snapshot_received(snapshot: Dictionary) -> void: if _cooldown_state == null or not _cooldown_state.has_method("apply_snapshot"): return _cooldown_state.call("apply_snapshot", snapshot) _render_cooldown_slots_label() if not _cooldown_state.has_method("any_slot_cooling"): return if bool(_cooldown_state.call("any_slot_cooling")): if is_instance_valid(_cooldown_poll_timer) and _cooldown_poll_timer.is_stopped(): _cooldown_poll_timer.start() return if is_instance_valid(_cooldown_poll_timer): _cooldown_poll_timer.stop() func _render_cooldown_slots_label() -> void: if not is_instance_valid(_cooldown_slots_label): return if _cooldown_state == null or not _cooldown_state.has_method("build_hud_line"): return var line: Variant = _cooldown_state.call("build_hud_line") if line is String: _cooldown_slots_label.text = line as String ## 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_cast_result_received(accepted: bool, reason_code: String, resolution: Dictionary) -> void: if not is_instance_valid(_cast_feedback_label): return if accepted: if not resolution.is_empty(): _cast_feedback_label.text = _format_cast_resolution_feedback(resolution) else: _cast_feedback_label.text = "Cast: accepted" if is_instance_valid(_cooldown_client): if _cooldown_client.has_method("request_sync_from_server"): _cooldown_client.call("request_sync_from_server") _request_combat_targets_refresh() _update_combat_poll_gate() if _is_combat_active(): _request_npc_combat_sync() if bool(resolution.get("targetDefeated", false)): _request_gig_progression_refresh() _request_encounter_progress_refresh() _request_quest_progress_refresh() return var rc := reason_code.strip_edges() if rc.is_empty(): _cast_feedback_label.text = "Cast: denied (no reasonCode)" else: _cast_feedback_label.text = "ability_cast_denied: %s" % rc func _format_cast_resolution_feedback(resolution: Dictionary) -> String: var damage: int = int(resolution.get("damageDealt", 0)) var remaining: int = int(resolution.get("targetRemainingHp", 0)) if bool(resolution.get("targetDefeated", false)): return "Cast: %d dmg → 0 HP — defeated!" % damage return "Cast: %d dmg → %d HP" % [damage, remaining] func _on_combat_targets_received(snapshot: Dictionary) -> void: _combat_targets_snapshot = snapshot _render_combat_target_hp_label() func _request_combat_targets_refresh() -> void: if not is_instance_valid(_combat_targets_client): return if _combat_targets_client.has_method("request_sync_from_server"): _combat_targets_client.call("request_sync_from_server") func _render_combat_target_hp_label() -> void: if not is_instance_valid(_combat_target_hp_label): return var lock_id := NpcCombatHudHelpers.locked_target_id_from_state(_last_target_state) if lock_id.is_empty(): _combat_target_hp_label.text = ("Target HP: — (Tab → elite/melee/ranged near spawn)") return var row: Dictionary = {} if ( is_instance_valid(_combat_targets_client) and _combat_targets_client.has_method("target_row") ): row = ( _combat_targets_client.call("target_row", lock_id, _combat_targets_snapshot) as Dictionary ) if row.is_empty(): _combat_target_hp_label.text = "Target HP: %s …" % lock_id return var current: int = int(row.get("currentHp", 0)) var max_hp: int = int(row.get("maxHp", 0)) var defeated_suffix := " (defeated)" if bool(row.get("defeated", false)) else "" _combat_target_hp_label.text = ( "Target HP: %s %d/%d%s" % [lock_id, current, max_hp, defeated_suffix] ) func _dev_player_id() -> String: if is_instance_valid(_authority): var player_id_variant: Variant = _authority.get("dev_player_id") if player_id_variant is String and not (player_id_variant as String).is_empty(): return (player_id_variant as String).strip_edges() return "dev-local-1" func _is_combat_active() -> bool: return NpcCombatHudHelpers.is_combat_active( _last_target_state, _npc_runtime_snapshot, _dev_player_id() ) func _resolve_hud_npc_rows() -> Dictionary: return NpcCombatHudHelpers.resolve_hud_npc_rows( _last_target_state, _npc_runtime_snapshot, _dev_player_id(), _npc_runtime_client ) func _request_npc_combat_sync() -> void: if is_instance_valid(_npc_runtime_client): if _npc_runtime_client.has_method("request_sync_from_server"): _npc_runtime_client.call("request_sync_from_server") if is_instance_valid(_player_combat_health_client): if _player_combat_health_client.has_method("request_sync_from_server"): _player_combat_health_client.call("request_sync_from_server") func _update_combat_poll_gate() -> void: _ensure_combat_poll_timer() if not is_instance_valid(_combat_poll_timer): return if _is_combat_active(): if _combat_poll_timer.is_stopped(): _combat_poll_timer.start() return _combat_poll_timer.stop() _npc_combat_hud_needs_tick = false _render_npc_combat_hud_labels() func _on_combat_poll_timeout() -> void: if not _is_combat_active(): _update_combat_poll_gate() return _request_npc_combat_sync() func _on_npc_runtime_received(snapshot: Dictionary) -> void: _npc_runtime_sync_error = "" _npc_runtime_snapshot = snapshot.duplicate(true) if _npc_runtime_hud_state != null and _npc_runtime_hud_state.has_method("apply_snapshot"): _npc_runtime_hud_state.call("apply_snapshot", _npc_runtime_snapshot) _update_combat_poll_gate() _render_npc_combat_hud_labels() func _on_npc_runtime_sync_failed(reason: String) -> void: _npc_runtime_sync_error = reason _render_npc_combat_hud_labels() func _on_player_combat_health_received(snapshot: Dictionary) -> void: _player_combat_health_error = "" _player_combat_health_snapshot = snapshot.duplicate(true) _render_player_combat_hp_label() func _on_player_combat_health_sync_failed(reason: String) -> void: _player_combat_health_error = reason _player_combat_health_snapshot = {} _render_player_combat_hp_label() func _render_player_combat_hp_label() -> void: if not is_instance_valid(_player_combat_hp_label): return if not _player_combat_health_error.is_empty(): _player_combat_hp_label.text = "Player HP: — (%s)" % _player_combat_health_error return if _player_combat_health_snapshot.is_empty(): _player_combat_hp_label.text = "Player HP: —" return var current: int = int(_player_combat_health_snapshot.get("currentHp", 0)) var max_hp: int = int(_player_combat_health_snapshot.get("maxHp", 0)) var defeated: bool = bool(_player_combat_health_snapshot.get("defeated", false)) var defeated_suffix := " (defeated)" if defeated else "" _player_combat_hp_label.text = "Player HP: %d/%d%s" % [current, max_hp, defeated_suffix] func _render_npc_combat_hud_labels() -> void: var rows: Dictionary = _resolve_hud_npc_rows() _render_npc_state_label(rows) _render_telegraph_label(rows) _npc_combat_hud_needs_tick = NpcCombatHudHelpers.has_interpolated_telegraph_display( rows, _npc_runtime_hud_state ) func _render_npc_state_label(rows: Dictionary) -> void: if not is_instance_valid(_npc_state_label): return _npc_state_label.text = NpcCombatHudHelpers.build_npc_state_label(rows, _npc_runtime_sync_error) func _render_telegraph_label(rows: Dictionary) -> void: if not is_instance_valid(_telegraph_label): return _telegraph_label.text = NpcCombatHudHelpers.build_telegraph_label( rows, _npc_runtime_hud_state, _npc_runtime_sync_error ) 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: if _try_route_gameplay_key_input(event): return if not event.is_action_pressed("dev_toggle_occluder_obstacle"): return call_deferred("_dev_toggle_obstacle_smoke_deferred") ## NEO-25 / NEO-72: route interact + inventory keys here — `InteractionRequestClient._input` ## is unreliable in the embedded Game dock; `_unhandled_key_input` on the scene root still fires. func _try_route_gameplay_key_input(event: InputEvent) -> bool: var hotbar_slot := _hotbar_slot_index_from_event(event) if hotbar_slot >= 0: _request_hotbar_cast_slot(hotbar_slot) return true if _try_interact_key_input(event): return true if _try_quest_accept_key_input(event): return true if _try_inventory_refresh_input(event): return true return false func _try_interact_key_input(event: InputEvent) -> bool: if event.is_action_pressed("interact"): _forward_interact_post("post_interact_terminal") return true if event.is_action_pressed("interact_secondary"): _forward_interact_resource_nearest() return true if event is InputEventKey: var k := event as InputEventKey if k.pressed and not k.echo: if k.physical_keycode == KEY_E or k.keycode == KEY_E: _forward_interact_post("post_interact_terminal") return true if k.physical_keycode == KEY_R or k.keycode == KEY_R: _forward_interact_resource_nearest() return true return false func _hotbar_slot_index_from_event(event: InputEvent) -> int: for slot_digit in range(1, 9): if event.is_action_pressed("hotbar_slot_%d" % slot_digit): return slot_digit - 1 return -1 func _try_inventory_refresh_input(event: InputEvent) -> bool: if event.is_action_pressed("inventory_refresh"): _request_inventory_refresh() return true if event is InputEventKey: var k := event as InputEventKey if k.pressed and not k.echo and (k.physical_keycode == KEY_I or k.keycode == KEY_I): _request_inventory_refresh() return true return false func _request_hotbar_cast_slot(slot_index: int) -> void: if not is_instance_valid(_hotbar_state) or not _hotbar_state.has_method("slots_snapshot"): if is_instance_valid(_cast_feedback_label): _cast_feedback_label.text = "Hotbar: not ready" push_warning("Hotbar cast ignored: hotbar_state unavailable") return var slots: Array = _hotbar_state.call("slots_snapshot") as Array var target_state: Dictionary = {} if is_instance_valid(_target_client) and _target_client.has_method("cached_state"): var st: Variant = _target_client.call("cached_state") if st is Dictionary: target_state = st as Dictionary var outcome: Dictionary = HotbarCastSlotResolver.resolve(slot_index, slots, target_state) if not bool(outcome.get(HotbarCastSlotResolver.KEY_OK, false)): var reason: String = str(outcome.get(HotbarCastSlotResolver.KEY_REASON, "")) if reason == HotbarCastSlotResolver.REASON_INVALID_SLOT: push_warning("Hotbar cast ignored: slot %d invalid_slot_index" % slot_index) if is_instance_valid(_cast_feedback_label): _cast_feedback_label.text = "Hotbar: slot %d invalid" % (slot_index + 1) elif reason == HotbarCastSlotResolver.REASON_EMPTY_SLOT: push_warning("Hotbar cast ignored: slot %d empty_slot" % slot_index) if is_instance_valid(_cast_feedback_label): _cast_feedback_label.text = ( "Hotbar: slot %d empty (bind ability or wait for sync)" % (slot_index + 1) ) return var ability_id: String = outcome[HotbarCastSlotResolver.KEY_ABILITY_ID] as String var resolved_slot: int = int(outcome.get(HotbarCastSlotResolver.KEY_SLOT_INDEX, slot_index)) var target_id: Variant = outcome.get(HotbarCastSlotResolver.KEY_TARGET_ID, null) if _cooldown_state != null and _cooldown_state.has_method("is_slot_cooling"): if bool(_cooldown_state.call("is_slot_cooling", resolved_slot)): if is_instance_valid(_cast_feedback_label): _cast_feedback_label.text = ( "ability_cast_denied: %s (client guard)" % COOLDOWN_REASON_ON_CD ) push_warning("ability_cast_denied reasonCode=%s (client guard)" % COOLDOWN_REASON_ON_CD) return var started: bool = false if is_instance_valid(_ability_cast_client) and _ability_cast_client.has_method("request_cast"): started = bool( _ability_cast_client.call("request_cast", resolved_slot, ability_id, target_id) ) if started: var target_label: String = "null" if target_id != null: target_label = str(target_id) print( ( "ability_cast_requested slot=%d ability_id=%s targetId=%s" % [resolved_slot, ability_id, target_label] ) ) elif is_instance_valid(_cast_feedback_label): _cast_feedback_label.text = "Cast: failed to send (busy or server offline?)" 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 _forward_interact_resource_nearest() -> void: if not is_instance_valid(_interaction_client) or not is_instance_valid(_player): return var node_id: String = PrototypeInteractablePicker.nearest_resource_node_id( _interactables_catalog, _player.global_position ) _gather_pre_scrap_qty = _total_bag_qty(SCRAP_METAL_BULK_ID) _gather_pending_interactable_id = node_id _gather_awaiting_inventory_finalize = false if _interaction_client.has_method("post_interact_id"): _interaction_client.call("post_interact_id", node_id) 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: _interactables_catalog = descriptors.duplicate(true) 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] ) )