From 9cd38a1adb1896679cc219686f6b4a4e05e293b9 Mon Sep 17 00:00:00 2001 From: Rijad Zuzo Date: Fri, 3 Jul 2026 18:18:51 +0200 Subject: [PATCH] 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 --- .gitignore | 1 + main.tscn | 28 +++++++ simulation/SimNPC.gd | 1 + simulation/SimulationManager.gd | 25 ++++++ world/resource_nodes/ResourceNode.gd | 100 +++++++++++++++++++++++ world/resource_nodes/ResourceNode.gd.uid | 1 + world/resource_nodes/ResourceNode.tscn | 25 ++++++ world/world_view_manager.gd | 36 ++++++++ 8 files changed, 217 insertions(+) create mode 100644 world/resource_nodes/ResourceNode.gd create mode 100644 world/resource_nodes/ResourceNode.gd.uid create mode 100644 world/resource_nodes/ResourceNode.tscn diff --git a/.gitignore b/.gitignore index f3891b5..373815a 100644 --- a/.gitignore +++ b/.gitignore @@ -48,6 +48,7 @@ crash_handler* *.temp *.tmp *~ +~*.dll .#* \#*\# diff --git a/main.tscn b/main.tscn index ebc668d..644b13d 100644 --- a/main.tscn +++ b/main.tscn @@ -8,6 +8,7 @@ [ext_resource type="PackedScene" uid="uid://q0w3en4bfk42" path="res://assets/foliage/tree.glb" id="6_5vw27"] [ext_resource type="Script" uid="uid://cjvbgqx8rao0t" path="res://world/ui/ui.gd" id="6_7mycd"] [ext_resource type="PackedScene" uid="uid://dyu3r0cl0judq" path="res://assets/foliage/tree-high.glb" id="7_kek77"] +[ext_resource type="PackedScene" uid="uid://dvd3qjpjor4sq" path="res://world/resource_nodes/ResourceNode.tscn" id="8_r3s0u"] [sub_resource type="NavigationMesh" id="NavigationMesh_lquwl"] vertices = PackedVector3Array(-14.5, 0.5, 0, -1, 0.5, 0, -1, 0.5, -0.75, 0, 0.5, -1, 0, 0.5, -14.5, -14.5, 0.5, -14.5, 0.75, 0.5, -1, 0.75, 0.75, -0.5, 14.5, 0.5, -0.5, 14.5, 0.5, -14.5, 0.75, 0.75, 0, 0, 1, 0, 0, 0.75, 0.75, -0.5, 0.75, 0.75, -0.5, 0.5, 14.5, 14.5, 0.5, 14.5, -1, 0.5, 0.75, -14.5, 0.5, 14.5) @@ -211,3 +212,30 @@ theme_override_constants/margin_bottom = 4 [node name="VillageStatsLabel" type="Label" parent="UI/VillagePanel/MarginContainer" unique_id=1554319259] layout_mode = 2 theme_override_font_sizes/font_size = 10 + +[node name="ResourceNodes" type="Node3D" parent="." unique_id=1364178452] + +[node name="BerryBush_01" parent="ResourceNodes" unique_id=42005298 instance=ExtResource("8_r3s0u")] +transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, -6, 0, -8) +node_id = &"berry_bush_01" + +[node name="BerryBush_02" parent="ResourceNodes" unique_id=2100422483 instance=ExtResource("8_r3s0u")] +transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, -10, 0, -4) +node_id = &"berry_bush_02" +amount_remaining = 8.0 + +[node name="Tree_01" parent="ResourceNodes" unique_id=1162125845 instance=ExtResource("8_r3s0u")] +transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 6, 0, -8) +node_id = &"tree_01" +action_id = &"gather_wood" +resource_id = &"wood" +amount_remaining = 15.0 +yield_per_action = 3.0 + +[node name="Tree_02" parent="ResourceNodes" unique_id=1626186397 instance=ExtResource("8_r3s0u")] +transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 10, 0, -4) +node_id = &"tree_02" +action_id = &"gather_wood" +resource_id = &"wood" +amount_remaining = 12.0 +yield_per_action = 3.0 diff --git a/simulation/SimNPC.gd b/simulation/SimNPC.gd index e680364..dc36816 100644 --- a/simulation/SimNPC.gd +++ b/simulation/SimNPC.gd @@ -27,6 +27,7 @@ var is_dead := false var task_duration := 0.0 var task_progress := 0.0 var task_complete := true +var target_id: StringName = &"" func _init( _id: int, diff --git a/simulation/SimulationManager.gd b/simulation/SimulationManager.gd index 06ca920..46af4d6 100644 --- a/simulation/SimulationManager.gd +++ b/simulation/SimulationManager.gd @@ -59,13 +59,19 @@ func simulate_tick() -> void: for npc in npcs: var old_task := npc.current_task + var old_target := npc.target_id var old_state := npc.task_state var was_complete := npc.task_complete var was_dead := npc.is_dead npc.simulate_tick(village) + if old_task != npc.current_task and old_target != &"": + release_npc_reservation(npc.id) + if not was_dead and npc.is_dead: + if old_target != &"": + release_npc_reservation(npc.id) npc_died.emit(npc) npc_task_changed.emit(npc, old_task, npc.current_task) @@ -79,6 +85,9 @@ func simulate_tick() -> void: var completed_task := npc.current_task var needs_immediate_food_after_gather := npc.should_eat_immediately_after_task() + if npc.target_id != &"": + release_npc_reservation(npc.id) + village.apply_npc_task(npc) village_was_changed = true @@ -129,6 +138,22 @@ func simulate_tick() -> void: if DEBUG_LOGS: print(village.get_summary()) +func set_npc_target_id(npc_id: int, target_id: StringName) -> void: + for npc in npcs: + if npc.id == npc_id: + npc.target_id = target_id + return + +func release_npc_reservation(npc_id: int) -> void: + for npc in npcs: + if npc.id == npc_id: + if npc.target_id != &"": + var node := ResourceNode.get_by_id(npc.target_id) + if node != null: + node.release(npc.id) + npc.target_id = &"" + return + func notify_npc_arrived(npc_id: int) -> void: for npc in npcs: if npc.id == npc_id: diff --git a/world/resource_nodes/ResourceNode.gd b/world/resource_nodes/ResourceNode.gd new file mode 100644 index 0000000..7f75a32 --- /dev/null +++ b/world/resource_nodes/ResourceNode.gd @@ -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 diff --git a/world/resource_nodes/ResourceNode.gd.uid b/world/resource_nodes/ResourceNode.gd.uid new file mode 100644 index 0000000..59708ef --- /dev/null +++ b/world/resource_nodes/ResourceNode.gd.uid @@ -0,0 +1 @@ +uid://r3s0urc3n0d3v1 \ No newline at end of file diff --git a/world/resource_nodes/ResourceNode.tscn b/world/resource_nodes/ResourceNode.tscn new file mode 100644 index 0000000..aacf1ab --- /dev/null +++ b/world/resource_nodes/ResourceNode.tscn @@ -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" diff --git a/world/world_view_manager.gd b/world/world_view_manager.gd index b0ac94d..9d6451d 100644 --- a/world/world_view_manager.gd +++ b/world/world_view_manager.gd @@ -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: