feat: index loaded resource discovery

This commit is contained in:
Rijad Zuzo
2026-07-17 00:34:30 +02:00
parent 2c88ed6ea8
commit 5356c34fd7
21 changed files with 1392 additions and 69 deletions
+51 -15
View File
@@ -1,26 +1,62 @@
class_name ActiveWorldAdapter
extends Node
const LoadedResourceSpatialIndexScript := preload(
"res://world/resource_nodes/LoadedResourceSpatialIndex.gd"
)
@export var pantry_storage: StorageNode
@export var woodpile_storage: StorageNode
@export_range(4.0, 128.0, 1.0) var resource_cell_size := 24.0
var _resource_index := LoadedResourceSpatialIndexScript.new()
func _ready() -> void:
_resource_index.configure(resource_cell_size)
add_to_group("active_world_adapter")
rebuild_resource_index()
func rebuild_resource_index() -> void:
_resource_index.clear()
for node in ResourceNode.get_all():
_resource_index.register_node(node, node.state)
func register_resource_node(node: ResourceNode, state: ResourceStateRecord = null) -> bool:
return _resource_index.register_node(node, state)
func unregister_resource_node(node_id: StringName) -> void:
_resource_index.unregister_node(node_id)
func get_resource_candidates(action_id: StringName) -> Array[Dictionary]:
var candidates: Array[Dictionary] = []
for node in ResourceNode.get_all():
if node.action_id != action_id or node.interaction_point == null:
continue
candidates.append(
{
"target_id": String(node.node_id),
"position": node.interaction_point.global_position,
"resource_id": String(node.resource_id),
"safety_risk": node.safety_risk,
"comfort_distance": node.comfort_distance,
"discovery_priority": node.discovery_priority
}
)
return candidates
return _resource_index.get_all_candidates(action_id)
func get_resource_candidates_in_radius(
action_id: StringName,
origin: Vector3,
max_distance: float,
min_distance_exclusive: float = -1.0
) -> Array[Dictionary]:
return _resource_index.get_candidates_in_radius(
action_id, origin, max_distance, min_distance_exclusive
)
func get_resource_query_profile(action_id: StringName, origin: Vector3) -> Dictionary:
return _resource_index.get_query_profile(action_id, origin)
func get_resource_nodes_in_radius(origin: Vector3, max_distance: float) -> Array[ResourceNode]:
return _resource_index.get_nodes_in_radius(origin, max_distance)
func get_resource_index_stats() -> Dictionary:
return _resource_index.get_stats()
func get_activity_target(action_id: StringName, origin: Vector3 = Vector3.ZERO) -> Dictionary:
@@ -0,0 +1,279 @@
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"])
@@ -0,0 +1 @@
uid://c3afuxcwkexf7
+25
View File
@@ -45,11 +45,14 @@ func _ready() -> void:
return
_all.append(self)
add_to_group("resource_nodes")
set_notify_transform(true)
_try_register_with_simulation()
_notify_world_adapters()
_update_presentation()
func _exit_tree() -> void:
_unregister_from_world_adapters()
_all.erase(self)
if state != null and state.changed.is_connected(_on_state_changed):
state.changed.disconnect(_on_state_changed)
@@ -71,6 +74,7 @@ func bind_state(resource_state: ResourceStateRecord) -> bool:
if not state.depleted.is_connected(_on_state_depleted):
state.depleted.connect(_on_state_depleted)
_update_presentation()
_notify_world_adapters()
return true
@@ -83,6 +87,27 @@ func _try_register_with_simulation() -> void:
manager.register_resource_node(self)
func _notification(what: int) -> void:
if what == NOTIFICATION_TRANSFORM_CHANGED and is_node_ready():
_notify_world_adapters()
func _notify_world_adapters() -> void:
if not is_inside_tree():
return
for adapter in get_tree().get_nodes_in_group("active_world_adapter"):
if adapter.has_method("register_resource_node"):
adapter.register_resource_node(self, state)
func _unregister_from_world_adapters() -> void:
if not is_inside_tree():
return
for adapter in get_tree().get_nodes_in_group("active_world_adapter"):
if adapter.has_method("unregister_resource_node"):
adapter.unregister_resource_node(node_id)
func get_amount_remaining() -> float:
return state.get_amount_remaining() if state != null else initial_amount