class_name LoadedResourceSpatialIndex extends RefCounted const DEFAULT_CELL_SIZE := 24.0 var cell_size := DEFAULT_CELL_SIZE var _entries: Dictionary = {} var _action_cells: Dictionary = {} var _all_cells: Dictionary = {} var _profiles: Dictionary = {} var _dirty_profiles: Dictionary = {} var _next_registration_order := 0 func configure(configured_cell_size: float) -> void: cell_size = maxf(configured_cell_size, 1.0) func clear() -> void: _entries.clear() _action_cells.clear() _all_cells.clear() _profiles.clear() _dirty_profiles.clear() _next_registration_order = 0 func register_node(node: ResourceNode, state: ResourceStateRecord = null) -> bool: if node == null or node.node_id.is_empty() or node.interaction_point == null: return false var registration_order := _next_registration_order var existing: Dictionary = _entries.get(node.node_id, {}) if not existing.is_empty(): registration_order = int(existing["registration_order"]) _remove_entry(existing) else: _next_registration_order += 1 var action_id := state.get_action_id() if state != null else node.action_id var resource_id := state.get_resource_id() if state != null else node.resource_id var position := node.global_transform * node.interaction_point.position var safety_risk := state.get_safety_risk() if state != null else node.safety_risk var comfort_distance := state.get_comfort_distance() if state != null else node.comfort_distance var discovery_priority := ( state.get_discovery_priority() if state != null else node.discovery_priority ) var entry := { "target_id": node.node_id, "action_id": action_id, "resource_id": resource_id, "position": position, "safety_risk": safety_risk, "comfort_distance": comfort_distance, "discovery_priority": discovery_priority, "registration_order": registration_order, "cell": _cell_for(position), "node": node, } _entries[node.node_id] = entry _add_to_cell(_all_cells, entry["cell"], node.node_id) var cells: Dictionary = _action_cells.get(action_id, {}) _add_to_cell(cells, entry["cell"], node.node_id) _action_cells[action_id] = cells _dirty_profiles[action_id] = true return true func unregister_node(node_id: StringName) -> void: var entry: Dictionary = _entries.get(node_id, {}) if entry.is_empty(): return _remove_entry(entry) func get_all_candidates(action_id: StringName) -> Array[Dictionary]: var matching_entries: Array[Dictionary] = [] for entry_value in _entries.values(): var entry: Dictionary = entry_value if entry["action_id"] == action_id: matching_entries.append(entry) matching_entries.sort_custom(_entry_order_less) return _entries_to_candidates(matching_entries) func get_candidates_in_radius( action_id: StringName, origin: Vector3, max_distance: float, min_distance_exclusive: float = -1.0 ) -> Array[Dictionary]: var cells: Dictionary = _action_cells.get(action_id, {}) var matching_entries := _get_entries_in_radius( cells, origin, max_distance, min_distance_exclusive ) return _entries_to_candidates(matching_entries) func get_nodes_in_radius(origin: Vector3, max_distance: float) -> Array[ResourceNode]: var nodes: Array[ResourceNode] = [] for entry in _get_entries_in_radius(_all_cells, origin, max_distance): var node := entry["node"] as ResourceNode if is_instance_valid(node): nodes.append(node) return nodes func get_query_profile(action_id: StringName, origin: Vector3) -> Dictionary: _ensure_profile(action_id) var profile: Dictionary = _profiles.get(action_id, {}) if profile.is_empty(): return {"candidate_count": 0, "initial_radius": cell_size, "max_distance": 0.0} var minimum: Vector3 = profile["minimum"] var maximum: Vector3 = profile["maximum"] var farthest_delta := Vector3( maxf(absf(origin.x - minimum.x), absf(origin.x - maximum.x)), maxf(absf(origin.y - minimum.y), absf(origin.y - maximum.y)), maxf(absf(origin.z - minimum.z), absf(origin.z - maximum.z)) ) var result := profile.duplicate() result.erase("minimum") result.erase("maximum") result["initial_radius"] = cell_size result["max_distance"] = farthest_delta.length() return result func get_stats() -> Dictionary: var action_counts := {} for entry_value in _entries.values(): var action_id: StringName = entry_value["action_id"] action_counts[String(action_id)] = int(action_counts.get(String(action_id), 0)) + 1 return { "candidate_count": _entries.size(), "occupied_cell_count": _all_cells.size(), "cell_size": cell_size, "action_counts": action_counts, } func _ensure_profile(action_id: StringName) -> void: if not _dirty_profiles.has(action_id) and _profiles.has(action_id): return var count := 0 var minimum := Vector3.ZERO var maximum := Vector3.ZERO var min_safety_risk := INF var max_comfort_distance := 0.0 var max_discovery_priority := -INF for entry_value in _entries.values(): var entry: Dictionary = entry_value if entry["action_id"] != action_id: continue var position: Vector3 = entry["position"] if count == 0: minimum = position maximum = position else: minimum = minimum.min(position) maximum = maximum.max(position) count += 1 min_safety_risk = minf(min_safety_risk, float(entry["safety_risk"])) max_comfort_distance = maxf(max_comfort_distance, float(entry["comfort_distance"])) max_discovery_priority = maxf(max_discovery_priority, float(entry["discovery_priority"])) if count == 0: _profiles.erase(action_id) else: _profiles[action_id] = { "candidate_count": count, "occupied_cell_count": (_action_cells.get(action_id, {}) as Dictionary).size(), "minimum": minimum, "maximum": maximum, "min_safety_risk": min_safety_risk, "max_comfort_distance": max_comfort_distance, "max_discovery_priority": max_discovery_priority, } _dirty_profiles.erase(action_id) func _get_entries_in_radius( cells: Dictionary, origin: Vector3, max_distance: float, min_distance_exclusive: float = -1.0 ) -> Array[Dictionary]: var matching_entries: Array[Dictionary] = [] if cells.is_empty() or max_distance < 0.0: return matching_entries var minimum_cell := _cell_for( Vector3(origin.x - max_distance, origin.y, origin.z - max_distance) ) var maximum_cell := _cell_for( Vector3(origin.x + max_distance, origin.y, origin.z + max_distance) ) var candidate_ids := _get_candidate_ids(cells, minimum_cell, maximum_cell) var maximum_distance_squared := max_distance * max_distance var minimum_distance_squared := min_distance_exclusive * min_distance_exclusive for node_id in candidate_ids: var entry: Dictionary = _entries.get(node_id, {}) if entry.is_empty(): continue var distance_squared := origin.distance_squared_to(entry["position"]) if distance_squared > maximum_distance_squared: continue if min_distance_exclusive >= 0.0 and distance_squared <= minimum_distance_squared: continue matching_entries.append(entry) matching_entries.sort_custom(_entry_order_less) return matching_entries func _get_candidate_ids(cells: Dictionary, minimum_cell: Vector2i, maximum_cell: Vector2i) -> Array: var candidate_ids: Array = [] var rectangle_cell_count := ( (maximum_cell.x - minimum_cell.x + 1) * (maximum_cell.y - minimum_cell.y + 1) ) if rectangle_cell_count <= cells.size() * 4: for cell_x in range(minimum_cell.x, maximum_cell.x + 1): for cell_y in range(minimum_cell.y, maximum_cell.y + 1): candidate_ids.append_array(cells.get(Vector2i(cell_x, cell_y), [])) return candidate_ids for cell_value in cells: var cell: Vector2i = cell_value if ( cell.x >= minimum_cell.x and cell.x <= maximum_cell.x and cell.y >= minimum_cell.y and cell.y <= maximum_cell.y ): candidate_ids.append_array(cells[cell]) return candidate_ids func _entries_to_candidates(entries: Array[Dictionary]) -> Array[Dictionary]: var candidates: Array[Dictionary] = [] for entry in entries: var candidate := { "target_id": String(entry["target_id"]), "position": entry["position"], "resource_id": String(entry["resource_id"]), "safety_risk": entry["safety_risk"], "comfort_distance": entry["comfort_distance"], "discovery_priority": entry["discovery_priority"], "registration_order": entry["registration_order"], } candidates.append(candidate) return candidates func _remove_entry(entry: Dictionary) -> void: var node_id := StringName(entry["target_id"]) var action_id := StringName(entry["action_id"]) _remove_from_cell(_all_cells, entry["cell"], node_id) var cells: Dictionary = _action_cells.get(action_id, {}) _remove_from_cell(cells, entry["cell"], node_id) if cells.is_empty(): _action_cells.erase(action_id) else: _action_cells[action_id] = cells _entries.erase(node_id) _dirty_profiles[action_id] = true func _cell_for(position: Vector3) -> Vector2i: return Vector2i(floori(position.x / cell_size), floori(position.z / cell_size)) func _add_to_cell(cells: Dictionary, cell: Vector2i, node_id: StringName) -> void: var ids: Array = cells.get(cell, []) ids.append(node_id) cells[cell] = ids func _remove_from_cell(cells: Dictionary, cell: Vector2i, node_id: StringName) -> void: var ids: Array = cells.get(cell, []) ids.erase(node_id) if ids.is_empty(): cells.erase(cell) else: cells[cell] = ids func _entry_order_less(first: Dictionary, second: Dictionary) -> bool: return int(first["registration_order"]) < int(second["registration_order"])