neon-sprawl/client/scripts/main.gd

1810 lines
71 KiB
GDScript
Raw Blame History

This file contains ambiguous Unicode characters!

This file contains ambiguous Unicode characters that may be confused with others in your current locale. If your use case is intentional and legitimate, you can safely ignore this warning. Use the Escape button to highlight these characters.

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/HudRoot/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")
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 PROTOTYPE_QUEST_GATHER_ID := "prototype_quest_gather_intro"
## 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 _last_quest_progress_snapshot: Dictionary = {}
var _quest_progress_error: String = ""
var _quest_defs_error: String = ""
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/HudRoot
@onready var _player_pos_label: Label = $UICanvas/HudRoot/PlayerPositionLabel
@onready var _target_lock_label: Label = $UICanvas/HudRoot/TargetLockLabel
@onready var _cast_feedback_label: Label = $UICanvas/HudRoot/CastFeedbackLabel
@onready var _combat_target_hp_label: Label = $UICanvas/HudRoot/CombatTargetHpLabel
@onready var _player_combat_hp_label: Label = $UICanvas/HudRoot/PlayerCombatHpLabel
@onready var _encounter_progress_label: Label = $UICanvas/HudRoot/EncounterProgressLabel
@onready var _encounter_complete_label: Label = $UICanvas/HudRoot/EncounterCompleteLabel
@onready var _quest_progress_label: Label = $UICanvas/HudRoot/QuestProgressLabel
@onready var _quest_accept_feedback_label: Label = $UICanvas/HudRoot/QuestAcceptFeedbackLabel
@onready var _npc_state_label: Label = $UICanvas/HudRoot/NpcStateLabel
@onready var _telegraph_label: Label = $UICanvas/HudRoot/TelegraphLabel
@onready var _cooldown_slots_label: Label = $UICanvas/HudRoot/CooldownSlotsLabel
@onready var _economy_hud_section: VBoxContainer = $UICanvas/HudRoot/EconomyHudSection
@onready var _inventory_label: Label = _economy_hud_section.get_node("Body/InventoryLabel")
@onready var _gather_feedback_label: Label = $UICanvas/HudRoot/GatherFeedbackLabel
@onready var _craft_feedback_label: Label = $UICanvas/HudRoot/CraftFeedbackLabel
@onready
var _skill_progression_label: Label = _economy_hud_section.get_node("Body/SkillProgressionLabel")
@onready var _gig_xp_label: Label = _economy_hud_section.get_node("Body/GigXpLabel")
@onready var _craft_recipe_panel: Node = _economy_hud_section.get_node("Body/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)
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()
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.
_apply_authority_http_config_to_client(_quest_progress_client)
_apply_authority_http_config_to_client(_quest_defs_client)
if _quest_progress_client.has_signal("quest_progress_received"):
_quest_progress_client.connect(
"quest_progress_received", Callable(self, "_on_quest_progress_received")
)
if _quest_progress_client.has_signal("quest_sync_failed"):
_quest_progress_client.connect(
"quest_sync_failed", Callable(self, "_on_quest_progress_sync_failed")
)
if _quest_progress_client.has_signal("quest_accept_result_received"):
_quest_progress_client.connect(
"quest_accept_result_received", Callable(self, "_on_quest_accept_result_received")
)
if _quest_progress_client.has_signal("quest_accept_failed"):
_quest_progress_client.connect(
"quest_accept_failed", Callable(self, "_on_quest_accept_failed")
)
if _quest_defs_client.has_signal("definitions_ready"):
_quest_defs_client.connect("definitions_ready", Callable(self, "_on_quest_definitions_ready"))
if _quest_defs_client.has_signal("definitions_sync_failed"):
_quest_defs_client.connect(
"definitions_sync_failed", Callable(self, "_on_quest_definitions_sync_failed")
)
_render_quest_progress_labels()
_render_quest_accept_feedback_label()
if _quest_defs_client.has_method("request_sync_from_server"):
_quest_defs_client.call("request_sync_from_server")
if _quest_progress_client.has_method("request_sync_from_server"):
_quest_progress_client.call("request_sync_from_server")
func _on_quest_progress_received(snapshot: Dictionary) -> void:
_quest_progress_error = ""
_last_quest_progress_snapshot = snapshot.duplicate(true)
_render_quest_progress_labels()
func _on_quest_progress_sync_failed(reason: String) -> void:
_quest_progress_error = reason
_last_quest_progress_snapshot = {}
_render_quest_progress_labels()
func _on_quest_accept_result_received(quest_id: String, result: Dictionary) -> void:
if bool(result.get("accepted", false)):
_render_quest_accept_feedback_label(
"Quest accept: %s accepted" % _quest_display_name(quest_id)
)
else:
var rc := str(result.get("reasonCode", "")).strip_edges()
if rc.is_empty():
_render_quest_accept_feedback_label("Quest accept: denied (no reasonCode)")
else:
_render_quest_accept_feedback_label("Quest accept: denied — %s" % rc)
_request_quest_progress_refresh()
func _on_quest_accept_failed(_quest_id: String, reason: String) -> void:
_render_quest_accept_feedback_label("Quest accept: failed — %s" % reason)
func _on_quest_definitions_ready(_quests: Array) -> void:
_quest_defs_error = ""
if _quest_progress_error.is_empty() and not _last_quest_progress_snapshot.is_empty():
_render_quest_progress_labels()
func _on_quest_definitions_sync_failed(reason: String) -> void:
_quest_defs_error = reason
_render_quest_progress_labels()
func _render_quest_progress_labels() -> void:
_render_quest_progress_label()
func _render_quest_progress_label() -> void:
if not is_instance_valid(_quest_progress_label):
return
var header := "Quests:"
if not _quest_progress_error.is_empty():
_quest_progress_label.text = "%s\nerror — %s" % [header, _quest_progress_error]
return
if _last_quest_progress_snapshot.is_empty():
_quest_progress_label.text = "%s\nLoading…" % header
return
var lines: PackedStringArray = [header]
var defs: Array = _quest_defs_snapshot()
if defs.is_empty():
var quests: Variant = _last_quest_progress_snapshot.get("quests", null)
if quests is Array:
for row_variant in quests as Array:
if not row_variant is Dictionary:
continue
var row: Dictionary = row_variant
var qid: String = str(row.get("questId", ""))
lines.append(" %s" % _format_quest_status_line(qid, qid, row))
else:
for def_variant in defs:
if not def_variant is Dictionary:
continue
var def: Dictionary = def_variant
var qid: String = str(def.get("id", ""))
if qid.is_empty():
continue
var row: Dictionary = _quest_row(qid)
var label: String = _quest_display_name(qid)
lines.append(" %s" % _format_quest_status_line(qid, label, row))
_quest_progress_label.text = "\n".join(lines)
func _format_quest_status_line(_quest_id: String, display_name: String, row: Dictionary) -> String:
var status: String = str(row.get("status", "not_started"))
match status:
"not_started":
return "%s: not started" % display_name
"active":
var step_index: int = int(row.get("currentStepIndex", 0))
var counter_text: String = _format_objective_counters_summary(
row.get("objectiveCounters", {})
)
if counter_text.is_empty():
return "%s: active step %d" % [display_name, step_index + 1]
return "%s: active step %d %s" % [display_name, step_index + 1, counter_text]
"completed":
var completed_at: String = str(row.get("completedAt", "")).strip_edges()
if completed_at.is_empty():
return "%s: completed" % display_name
return "%s: completed (%s)" % [display_name, completed_at]
_:
return "%s: unknown status (%s)" % [display_name, status]
func _format_objective_counters_summary(counters: Variant) -> String:
if counters == null or not counters is Dictionary:
return ""
var d: Dictionary = counters
if d.is_empty():
return ""
var parts: PackedStringArray = PackedStringArray()
for key in d.keys():
parts.append("%s=%s" % [str(key), str(d[key])])
return "(%s)" % ", ".join(parts)
func _render_quest_accept_feedback_label(text: String = "Quest accept: — (Q gather / Shift+Q next)") -> void:
if is_instance_valid(_quest_accept_feedback_label):
_quest_accept_feedback_label.text = text
func _quest_row(quest_id: String) -> Dictionary:
if not is_instance_valid(_quest_progress_client):
return {}
if not _quest_progress_client.has_method("quest_row"):
return {}
var row: Variant = _quest_progress_client.call(
"quest_row", quest_id, _last_quest_progress_snapshot
)
return row as Dictionary
func _quest_display_name(quest_id: String) -> String:
if not is_instance_valid(_quest_defs_client):
return quest_id
if not _quest_defs_client.has_method("display_name_for"):
return quest_id
return str(_quest_defs_client.call("display_name_for", quest_id))
func _quest_defs_snapshot() -> Array:
if not is_instance_valid(_quest_defs_client):
return []
if not _quest_defs_client.has_method("quests_snapshot"):
return []
return _quest_defs_client.call("quests_snapshot") as Array
func _request_quest_progress_refresh() -> void:
if not is_instance_valid(_quest_progress_client):
return
if _quest_progress_client.has_method("request_sync_from_server"):
_quest_progress_client.call("request_sync_from_server")
func _find_first_eligible_quest_id() -> String:
var defs: Array = _quest_defs_snapshot()
for def_variant in defs:
if not def_variant is Dictionary:
continue
var def: Dictionary = def_variant
var qid: String = str(def.get("id", ""))
if qid.is_empty():
continue
var row: Dictionary = _quest_row(qid)
if str(row.get("status", "")) != "not_started":
continue
var prereqs: Variant = def.get("prerequisiteQuestIds", [])
if not prereqs is Array:
continue
var prereqs_met := true
for p_variant in prereqs as Array:
var pid: String = str(p_variant)
if str(_quest_row(pid).get("status", "")) != "completed":
prereqs_met = false
break
if prereqs_met:
return qid
return ""
func _request_quest_accept(quest_id: String) -> void:
if not is_instance_valid(_quest_progress_client):
return
if _quest_progress_client.has_method("is_accept_busy") and bool(_quest_progress_client.call("is_accept_busy")):
return
if _quest_progress_client.has_method("request_accept"):
_quest_progress_client.call("request_accept", quest_id)
func _request_eligible_quest_accept() -> void:
var eligible_id: String = _find_first_eligible_quest_id()
if eligible_id.is_empty():
_render_quest_accept_feedback_label("Quest accept: no eligible quest")
return
_request_quest_accept(eligible_id)
func _try_quest_accept_key_input(event: InputEvent) -> bool:
if not event is InputEventKey:
return false
var k := event as InputEventKey
if not k.pressed or k.echo:
return false
if k.physical_keycode != KEY_Q and k.keycode != KEY_Q:
return false
if k.shift_pressed:
_request_eligible_quest_accept()
else:
_request_quest_accept(PROTOTYPE_QUEST_GATHER_ID)
return true
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]
)
)