refactor: separate action system responsibilities
This commit is contained in:
@@ -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:
|
||||
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user