refactor: separate action system responsibilities

This commit is contained in:
2026-07-05 14:17:45 +02:00
parent 81df7b5939
commit 4673f0698d
18 changed files with 458 additions and 383 deletions
-173
View File
@@ -55,56 +55,6 @@ func _init(
random_source.randf_range(-8.0, 8.0)
)
func simulate_tick(village: SimVillage) -> void:
if is_dead:
return
update_needs(village)
if is_dead:
return
if task_state == TASK_STATE_IDLE or task_state == TASK_STATE_COMPLETE:
choose_new_task(village)
return
if task_state == TASK_STATE_TRAVELING:
return
if task_state == TASK_STATE_WORKING:
task_progress += 1.0
if task_progress >= task_duration:
task_complete = true
task_state = TASK_STATE_COMPLETE
func update_needs(village: SimVillage) -> void:
hunger += 3.0 * village.food_modifier
match task_state:
TASK_STATE_IDLE:
energy -= 0.5
TASK_STATE_TRAVELING:
energy -= 2.0
TASK_STATE_WORKING:
if current_task != SimulationIds.ACTION_REST:
energy -= 3.0
TASK_STATE_COMPLETE:
energy -= 0.25
hunger = clamp(hunger, 0.0, 100.0)
energy = clamp(energy, 0.0, 100.0)
is_starving = hunger >= 90.0
if is_starving:
starvation_ticks += 1
else:
starvation_ticks = 0
if starvation_ticks >= starvation_death_threshold:
die_from_starvation()
func die_from_starvation() -> void:
is_dead = true
current_task = SimulationIds.ACTION_DEAD
@@ -116,30 +66,6 @@ func die_from_starvation() -> void:
if debug_logs:
print("[SimNPC] ", npc_name, " died from starvation.")
func choose_new_task(village: SimVillage) -> void:
if is_dead:
return
if is_starving:
if village.food > 0:
set_task(SimulationIds.ACTION_EAT, 1.0)
else:
set_task(SimulationIds.ACTION_GATHER_FOOD, 4.0)
return
if hunger > 75.0:
if village.food > 0:
set_task(SimulationIds.ACTION_EAT)
else:
set_task(SimulationIds.ACTION_GATHER_FOOD)
return
if energy < 25.0:
set_task(SimulationIds.ACTION_REST)
return
set_task(choose_best_work_task(village))
func should_eat_immediately_after_task() -> bool:
return (
current_task == SimulationIds.ACTION_GATHER_FOOD
@@ -147,105 +73,6 @@ func should_eat_immediately_after_task() -> bool:
and not is_dead
)
func choose_best_work_task(village: SimVillage) -> StringName:
var food_score := calculate_task_score(
SimulationIds.ACTION_GATHER_FOOD,
village.food_priority,
village.food,
15.0,
30.0,
3.0,
1.5
)
var wood_score := calculate_task_score(
SimulationIds.ACTION_GATHER_WOOD,
village.wood_priority,
village.wood,
10.0,
20.0,
2.5,
1.0
)
var safety_score := calculate_task_score(
SimulationIds.ACTION_PATROL,
village.safety_priority,
village.safety,
30.0,
50.0,
3.0,
1.5
)
var knowledge_score := calculate_task_score(
SimulationIds.ACTION_STUDY,
village.knowledge_priority,
village.knowledge,
10.0,
25.0,
1.5,
0.75
)
var best_task := SimulationIds.ACTION_GATHER_FOOD
var best_score := food_score
if wood_score > best_score:
best_task = SimulationIds.ACTION_GATHER_WOOD
best_score = wood_score
if safety_score > best_score:
best_task = SimulationIds.ACTION_PATROL
best_score = safety_score
if knowledge_score > best_score:
best_task = SimulationIds.ACTION_STUDY
best_score = knowledge_score
return best_task
func calculate_task_score(
action_id: StringName,
base_priority: float,
resource_amount: float,
critical_threshold: float,
low_threshold: float,
critical_bonus: float,
low_bonus: float
) -> float:
var score := base_priority
if resource_amount < critical_threshold:
score += critical_bonus
elif resource_amount < low_threshold:
score += low_bonus
var definition := SimulationDefinitions.get_action(action_id)
if (
definition != null
and not definition.preferred_profession_id.is_empty()
and profession == definition.preferred_profession_id
):
score += 2.0
if action_id == last_task:
score -= 1.5
score += random_source.randf_range(0.0, 0.5)
if debug_logs:
print(
"[SimNPC] ",
npc_name,
" score ",
action_id,
" = ",
score
)
return score
func set_task(action_id: StringName, duration: float = -1.0) -> void:
var definition := SimulationDefinitions.get_action(action_id)
if definition == null:
+50 -28
View File
@@ -7,6 +7,8 @@ signal npc_task_changed(
)
signal village_changed(village: SimVillage)
signal npc_died(npc: SimNPC)
signal npc_target_requested(npc: SimNPC)
signal npc_travel_requested(npc: SimNPC, target_position: Vector3)
var village := SimVillage.new()
@@ -14,11 +16,15 @@ var npcs: Array[SimNPC] = []
@export var tick_interval := 1.2
@export var simulation_seed: int = 1337
@export var debug_logs := true
@export var active_world_adapter: Node
var clock: SimulationClock
var tick_count := 0
var wander_random_sources := {}
var resource_states: Dictionary = {}
var action_selector := ActionSelectionSystem.new()
var action_executor := ActionExecutionSystem.new()
var target_resolver := ActionTargetResolver.new()
var names := [
"Amina", "Tarik", "Jasmin"
@@ -97,7 +103,24 @@ func simulate_tick() -> void:
var was_complete := npc.task_complete
var was_dead := npc.is_dead
npc.simulate_tick(village)
action_executor.advance_npc(npc, village)
if (
not npc.is_dead
and old_state in [
SimNPC.TASK_STATE_IDLE,
SimNPC.TASK_STATE_COMPLETE
]
and npc.task_state in [
SimNPC.TASK_STATE_IDLE,
SimNPC.TASK_STATE_COMPLETE
]
):
var selection := action_selector.select_action(npc, village)
if selection != null:
npc.set_task(
selection.action_id,
selection.duration_override
)
if old_task != npc.current_task and old_target != &"":
release_npc_reservation(npc.id)
@@ -113,6 +136,7 @@ func simulate_tick() -> void:
if old_task != npc.current_task and not npc.is_dead:
npc_task_changed.emit(npc, old_task, npc.current_task)
npc_target_requested.emit(npc)
if not was_complete and npc.task_complete and not npc.is_dead:
var completed_task := npc.current_task
@@ -162,6 +186,7 @@ func simulate_tick() -> void:
if needs_immediate_food_after_gather and village.food > 0:
npc.set_task(SimulationIds.ACTION_EAT, 1.0)
npc_task_changed.emit(npc, completed_task, npc.current_task)
npc_target_requested.emit(npc)
if debug_logs:
print("[SimulationManager] ", npc.npc_name, " gathered food while starving and is going to eat immediately.")
@@ -255,6 +280,7 @@ func notify_npc_navigation_failed(npc_id: int) -> void:
npc.last_task = failed_task
npc.set_task(SimulationIds.ACTION_WANDER, 2.0)
npc_task_changed.emit(npc, failed_task, npc.current_task)
npc_target_requested.emit(npc)
if debug_logs:
print("[SimulationManager] ", npc.npc_name, " could not navigate for ", failed_task, " and is trying a wander target")
@@ -263,6 +289,29 @@ func notify_npc_navigation_failed(npc_id: int) -> void:
func notify_npc_target_unavailable(npc_id: int) -> void:
notify_npc_navigation_failed(npc_id)
func resolve_npc_target(npc_id: int, origin: Vector3) -> bool:
if active_world_adapter == null:
push_error("SimulationManager: active_world_adapter is missing")
return false
for npc in npcs:
if npc.id != npc_id:
continue
if npc.is_dead or npc.task_state != SimNPC.TASK_STATE_TRAVELING:
return false
var result := target_resolver.resolve(
npc,
origin,
self,
active_world_adapter
)
if result.is_empty():
notify_npc_target_unavailable(npc.id)
return false
npc.target_id = StringName(result.get("target_id", ""))
npc_travel_requested.emit(npc, result["position"])
return true
return false
func register_loaded_resource_nodes() -> void:
for node in ResourceNode.get_all():
register_resource_node(node)
@@ -305,33 +354,6 @@ func release_resource(node_id: StringName, agent_id: int) -> void:
if resource_state != null:
resource_state.release(agent_id)
func acquire_resource_target(
action_id: StringName,
agent_id: int,
from_position: Vector3
) -> ResourceNode:
var best: ResourceNode
var best_distance := INF
for node in ResourceNode.get_all():
var resource_state := get_resource_state(node.node_id)
if resource_state == null:
continue
if resource_state.get_action_id() != action_id:
continue
if not resource_state.can_npc_use():
continue
if not resource_state.is_available_for(agent_id):
continue
var distance := from_position.distance_squared_to(
node.interaction_point.global_position
)
if distance < best_distance:
best_distance = distance
best = node
if best == null or not reserve_resource(best.node_id, agent_id):
return null
return best
func find_resource_node_for_player(
from_position: Vector3,
max_distance: float
@@ -0,0 +1,32 @@
class_name ActionExecutionSystem
extends RefCounted
func advance_npc(npc: SimNPC, village: SimVillage) -> void:
if npc.is_dead:
return
_update_needs(npc, village)
if npc.is_dead or npc.task_state != SimNPC.TASK_STATE_WORKING:
return
npc.task_progress += 1.0
if npc.task_progress >= npc.task_duration:
npc.task_complete = true
npc.task_state = SimNPC.TASK_STATE_COMPLETE
func _update_needs(npc: SimNPC, village: SimVillage) -> void:
npc.hunger += 3.0 * village.food_modifier
match npc.task_state:
SimNPC.TASK_STATE_IDLE:
npc.energy -= 0.5
SimNPC.TASK_STATE_TRAVELING:
npc.energy -= 2.0
SimNPC.TASK_STATE_WORKING:
if npc.current_task != SimulationIds.ACTION_REST:
npc.energy -= 3.0
SimNPC.TASK_STATE_COMPLETE:
npc.energy -= 0.25
npc.hunger = clamp(npc.hunger, 0.0, 100.0)
npc.energy = clamp(npc.energy, 0.0, 100.0)
npc.is_starving = npc.hunger >= 90.0
npc.starvation_ticks = npc.starvation_ticks + 1 if npc.is_starving else 0
if npc.starvation_ticks >= npc.starvation_death_threshold:
npc.die_from_starvation()
@@ -0,0 +1 @@
uid://bcoht6j0fvva3
@@ -0,0 +1,12 @@
class_name ActionSelectionResult
extends RefCounted
var action_id: StringName
var duration_override := -1.0
func _init(
selected_action_id: StringName,
selected_duration_override: float = -1.0
) -> void:
action_id = selected_action_id
duration_override = selected_duration_override
@@ -0,0 +1 @@
uid://dy0n3lije1lec
@@ -0,0 +1,81 @@
class_name ActionSelectionSystem
extends RefCounted
func select_action(npc: SimNPC, village: SimVillage) -> ActionSelectionResult:
if npc.is_dead:
return null
if npc.is_starving:
if village.food > 0:
return ActionSelectionResult.new(SimulationIds.ACTION_EAT, 1.0)
return ActionSelectionResult.new(SimulationIds.ACTION_GATHER_FOOD, 4.0)
if npc.hunger > 75.0:
if village.food > 0:
return ActionSelectionResult.new(SimulationIds.ACTION_EAT)
return ActionSelectionResult.new(SimulationIds.ACTION_GATHER_FOOD)
if npc.energy < 25.0:
return ActionSelectionResult.new(SimulationIds.ACTION_REST)
return ActionSelectionResult.new(_choose_best_work_action(npc, village))
func _choose_best_work_action(
npc: SimNPC,
village: SimVillage
) -> StringName:
var scores := {
SimulationIds.ACTION_GATHER_FOOD: _calculate_score(
npc, SimulationIds.ACTION_GATHER_FOOD, village.food_priority,
village.food, 15.0, 30.0, 3.0, 1.5
),
SimulationIds.ACTION_GATHER_WOOD: _calculate_score(
npc, SimulationIds.ACTION_GATHER_WOOD, village.wood_priority,
village.wood, 10.0, 20.0, 2.5, 1.0
),
SimulationIds.ACTION_PATROL: _calculate_score(
npc, SimulationIds.ACTION_PATROL, village.safety_priority,
village.safety, 30.0, 50.0, 3.0, 1.5
),
SimulationIds.ACTION_STUDY: _calculate_score(
npc, SimulationIds.ACTION_STUDY, village.knowledge_priority,
village.knowledge, 10.0, 25.0, 1.5, 0.75
)
}
var best_action := SimulationIds.ACTION_GATHER_FOOD
var best_score: float = scores[best_action]
for action_id in [
SimulationIds.ACTION_GATHER_WOOD,
SimulationIds.ACTION_PATROL,
SimulationIds.ACTION_STUDY
]:
var score: float = scores[action_id]
if score > best_score:
best_action = action_id
best_score = score
return best_action
func _calculate_score(
npc: SimNPC,
action_id: StringName,
base_priority: float,
resource_amount: float,
critical_threshold: float,
low_threshold: float,
critical_bonus: float,
low_bonus: float
) -> float:
var score := base_priority
if resource_amount < critical_threshold:
score += critical_bonus
elif resource_amount < low_threshold:
score += low_bonus
var definition := SimulationDefinitions.get_action(action_id)
if (
definition != null
and not definition.preferred_profession_id.is_empty()
and npc.profession == definition.preferred_profession_id
):
score += 2.0
if action_id == npc.last_task:
score -= 1.5
score += npc.random_source.randf_range(0.0, 0.5)
if npc.debug_logs:
print("[ActionSelection] ", npc.npc_name, " score ", action_id, " = ", score)
return score
@@ -0,0 +1 @@
uid://c550deaitwskn
@@ -0,0 +1,60 @@
class_name ActionTargetResolver
extends RefCounted
func resolve(
npc: SimNPC,
origin: Vector3,
simulation_manager: Node,
active_world_adapter: Node
) -> Dictionary:
var definition := SimulationDefinitions.get_action(npc.current_task)
if definition == null:
return {}
match definition.target_type:
SimulationIds.TARGET_RESOURCE:
return _resolve_resource(
npc, origin, definition, simulation_manager,
active_world_adapter
)
SimulationIds.TARGET_ACTIVITY:
return active_world_adapter.get_activity_target(npc.current_task)
SimulationIds.TARGET_FREE:
return {
"target_id": "",
"position": origin + simulation_manager.get_wander_offset(npc.id)
}
return {}
func _resolve_resource(
npc: SimNPC,
origin: Vector3,
definition: ActionDefinition,
simulation_manager: Node,
active_world_adapter: Node
) -> Dictionary:
var best: Dictionary = {}
var best_distance := INF
for candidate in active_world_adapter.get_resource_candidates(
definition.resource_action_id
):
var node_id := StringName(candidate["target_id"])
var state: ResourceStateRecord = simulation_manager.get_resource_state(
node_id
)
if (
state == null
or not state.can_npc_use()
or not state.is_available_for(npc.id)
):
continue
var position: Vector3 = candidate["position"]
var distance := origin.distance_squared_to(position)
if distance < best_distance:
best_distance = distance
best = candidate
if best.is_empty():
return {}
var target_id := StringName(best["target_id"])
if not simulation_manager.reserve_resource(target_id, npc.id):
return {}
return best
@@ -0,0 +1 @@
uid://cq8srsm762mnx