1424 lines
57 KiB
GDScript
1424 lines
57 KiB
GDScript
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"
|
|
|
|
## 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 _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 _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 _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_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 := _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 := _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 not _inventory_error.is_empty():
|
|
return
|
|
if not _last_inventory_snapshot.is_empty():
|
|
_render_inventory_label()
|
|
|
|
|
|
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 _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()
|
|
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()
|
|
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()
|
|
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 _locked_target_id_from_state(state: Dictionary) -> String:
|
|
var locked_variant: Variant = state.get("lockedTargetId", null)
|
|
if locked_variant is String:
|
|
return (locked_variant as String).strip_edges()
|
|
return ""
|
|
|
|
|
|
func _render_combat_target_hp_label() -> void:
|
|
if not is_instance_valid(_combat_target_hp_label):
|
|
return
|
|
var lock_id := _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()
|
|
)
|
|
|
|
|
|
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
|
|
|
|
|
|
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:
|
|
_render_npc_state_label()
|
|
_render_telegraph_label()
|
|
_npc_combat_hud_needs_tick = _has_interpolated_telegraph_display()
|
|
|
|
|
|
func _has_interpolated_telegraph_display() -> bool:
|
|
return NpcCombatHudHelpers.has_interpolated_telegraph_display(
|
|
_resolve_hud_npc_rows(), _npc_runtime_hud_state
|
|
)
|
|
|
|
|
|
func _render_npc_state_label() -> void:
|
|
if not is_instance_valid(_npc_state_label):
|
|
return
|
|
_npc_state_label.text = NpcCombatHudHelpers.build_npc_state_label(
|
|
_resolve_hud_npc_rows(), _npc_runtime_sync_error
|
|
)
|
|
|
|
|
|
func _render_telegraph_label() -> void:
|
|
if not is_instance_valid(_telegraph_label):
|
|
return
|
|
_telegraph_label.text = NpcCombatHudHelpers.build_telegraph_label(
|
|
_resolve_hud_npc_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_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]
|
|
)
|
|
)
|