feat: add ResourceNode system with target selection and reservation

Phase 1-2 of the ResourceNode migration plan:

- Create ResourceNode class (world/resource_nodes/) with extraction,
  reservation, nearest-available selection, and debug label
- Place 4 resource instances in main.tscn (2 berry bushes, 2 trees)
- Add target_id to SimNPC for tracking which node an NPC is using
- Add set_npc_target_id and release_npc_reservation to SimulationManager
  with cleanup on task change, completion, and death
- Update WorldViewManager to query available ResourceNodes first,
  falling back to zone markers with a logged warning
This commit is contained in:
2026-07-03 18:18:51 +02:00
parent e30d208c3f
commit 9cd38a1adb
8 changed files with 217 additions and 0 deletions
+100
View File
@@ -0,0 +1,100 @@
class_name ResourceNode
extends Node3D
signal amount_changed(node_id: StringName, amount_remaining: float)
signal depleted(node_id: StringName)
signal reservation_changed(node_id: StringName, agent_id: int)
static var _all: Array[ResourceNode] = []
@export var node_id: StringName
@export var action_id: StringName = &"gather_food"
@export var resource_id: StringName = &"food"
@export var amount_remaining: float = 10.0
@export var yield_per_action: float = 2.0
@export var enabled: bool = true
@export var can_npcs_use: bool = true
@export var can_player_use: bool = true
@export var hide_visual_when_depleted: bool = false
@onready var interaction_point: Marker3D = $InteractionPoint
var reserved_by: int = -1
func _ready():
if node_id.is_empty():
push_error("ResourceNode at %s has empty node_id" % get_path())
_all.append(self)
add_to_group("resource_nodes")
amount_changed.connect(_update_debug_label)
reservation_changed.connect(_update_debug_label)
_update_debug_label()
func _exit_tree():
_all.erase(self)
func is_depleted() -> bool:
return amount_remaining <= 0.0
func can_extract() -> bool:
return enabled and not is_depleted()
func is_available_for(agent_id: int) -> bool:
return enabled and not is_depleted() and (reserved_by == -1 or reserved_by == agent_id)
func reserve(agent_id: int) -> bool:
if not is_available_for(agent_id):
return false
reserved_by = agent_id
reservation_changed.emit(node_id, agent_id)
return true
func release(agent_id: int) -> void:
if reserved_by == agent_id:
reserved_by = -1
reservation_changed.emit(node_id, -1)
func extract(requested_amount: float = yield_per_action) -> float:
if not can_extract():
return 0.0
var extracted := minf(requested_amount, amount_remaining)
amount_remaining -= extracted
amount_changed.emit(node_id, amount_remaining)
if is_depleted():
depleted.emit(node_id)
if hide_visual_when_depleted and has_node("Visual"):
$Visual.visible = false
return extracted
func _update_debug_label(_a = null, _b = null) -> void:
if not has_node("DebugLabel"):
return
var label := $DebugLabel as Label3D
var text := "%s\n%s %.1f" % [node_id, resource_id, amount_remaining]
if reserved_by >= 0:
text += "\nheld:%d" % reserved_by
if is_depleted():
text += "\nDEPLETED"
label.text = text
static func get_by_id(node_id: StringName) -> ResourceNode:
for node in _all:
if node.node_id == node_id:
return node
return null
static func find_available(action: StringName, agent_id: int, from_position: Vector3) -> ResourceNode:
var best: ResourceNode
var best_dist := INF
for node in _all:
if node.action_id != action:
continue
if not node.can_npcs_use:
continue
if not node.is_available_for(agent_id):
continue
var dist := from_position.distance_squared_to(node.global_position)
if dist < best_dist:
best_dist = dist
best = node
return best
+1
View File
@@ -0,0 +1 @@
uid://r3s0urc3n0d3v1
+25
View File
@@ -0,0 +1,25 @@
[gd_scene load_steps=3 format=3 uid="uid://r3s0urc3tscn01"]
[ext_resource type="Script" uid="uid://r3s0urc3n0d3v1" path="res://world/resource_nodes/ResourceNode.gd" id="1_abcde"]
[sub_resource type="BoxMesh" id="BoxMesh_visual"]
[node name="ResourceNode" type="Node3D"]
script = ExtResource("1_abcde")
[node name="Visual" type="Node3D" parent="."]
[node name="MeshInstance3D" type="MeshInstance3D" parent="Visual"]
mesh = SubResource("BoxMesh_visual")
[node name="InteractionPoint" type="Marker3D" parent="."]
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 0, 2.0)
[node name="DebugLabel" type="Label3D" parent="."]
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 2.0, 0)
billboard = 1
no_depth_test = true
font_size = 24
outline_modulate = Color(0, 0, 0, 1)
outline_size = 4
text = "INIT"
+36
View File
@@ -83,6 +83,11 @@ func update_npc_targets() -> void:
continue
var visual = active_npc_visuals[npc.id]
var resource_pos := _try_get_resource_target(npc, npc.current_task)
if resource_pos != null:
visual.set_target_position(resource_pos)
continue
var target := get_target_for_task(npc.current_task)
if target == null:
@@ -108,6 +113,32 @@ func get_target_for_task(task: String) -> Marker3D:
_:
return rest_zone
func _try_get_resource_target(npc: SimNPC, task: String):
var action_map := {
"gather_food": &"gather_food",
"gather_wood": &"gather_wood"
}
var action_id := action_map.get(task) as StringName
if action_id == null:
return null
var visual = active_npc_visuals.get(npc.id) as Node3D
if visual == null:
return null
var node := ResourceNode.find_available(action_id, npc.id, visual.global_position)
if node == null:
debug_log("No available resource node for %s, using zone fallback" % task)
return null
if not node.reserve(npc.id):
debug_log("Failed to reserve %s for %s" % [node.node_id, npc.npc_name])
return null
npc.target_id = node.node_id
debug_log("%s reserved %s for %s" % [npc.npc_name, node.node_id, task])
return node.interaction_point.global_position
func _on_npc_task_changed(npc: SimNPC, old_task: String, new_task: String) -> void:
if DEBUG_LOGS:
debug_log("%s changed task: %s -> %s" % [npc.npc_name, old_task, new_task])
@@ -116,6 +147,11 @@ func _on_npc_task_changed(npc: SimNPC, old_task: String, new_task: String) -> vo
return
var visual = active_npc_visuals[npc.id]
var resource_pos := _try_get_resource_target(npc, new_task)
if resource_pos != null:
visual.set_target_position(resource_pos)
return
var target := get_target_for_task(new_task)
if target != null: