8a955f3c29
ProfessionDefinition gains schedule_offset (-0.12 to +0.12 fraction of day). ActionSelectionSystem shifts each NPC's effective time_of_day by their profession offset before determining sleep/meal/work periods. Farmers wake earliest (-0.04h = ~2:38am), woodcutters slightly early (-0.02h), guards slightly late (+0.02h), scholars latest (+0.05h = ~5:45am). Wanderers have no offset. The village now has naturally staggered rhythms instead of everyone doing the same thing at the same time.
248 lines
7.2 KiB
GDScript
248 lines
7.2 KiB
GDScript
class_name ActionSelectionSystem
|
|
extends RefCounted
|
|
|
|
enum SchedulePeriod {
|
|
WORK,
|
|
SLEEP,
|
|
MEAL,
|
|
DISCRETIONARY
|
|
}
|
|
|
|
const SLEEP_BEGIN := 0.88
|
|
const SLEEP_END := 0.15
|
|
const BREAKFAST_BEGIN := 0.25
|
|
const BREAKFAST_END := 0.35
|
|
const DINNER_BEGIN := 0.7
|
|
const DINNER_END := 0.8
|
|
const WORK_BEGIN := 0.28
|
|
const WORK_END := 0.85
|
|
|
|
|
|
func select_action(npc: SimNPC, village: SimVillage, time_of_day: float = 0.5, all_npcs: Array = []) -> ActionSelectionResult:
|
|
if npc.is_dead:
|
|
return null
|
|
|
|
if npc.is_starving:
|
|
if npc.get_inventory_amount(SimulationIds.RESOURCE_FOOD) >= 1.0:
|
|
return ActionSelectionResult.new(
|
|
SimulationIds.ACTION_EAT, 1.0, "Starving and carrying food"
|
|
)
|
|
if village.food > 0:
|
|
return ActionSelectionResult.new(
|
|
SimulationIds.ACTION_WITHDRAW_FOOD, 1.0, "Starving; pantry has food"
|
|
)
|
|
return ActionSelectionResult.new(
|
|
SimulationIds.ACTION_GATHER_FOOD, -1.0, "Starving; must find food"
|
|
)
|
|
if npc.hunger > 80.0:
|
|
if npc.get_inventory_amount(SimulationIds.RESOURCE_FOOD) >= 1.0:
|
|
return ActionSelectionResult.new(
|
|
SimulationIds.ACTION_EAT, -1.0, "Critically hungry and carrying food"
|
|
)
|
|
if village.food > 0:
|
|
return ActionSelectionResult.new(
|
|
SimulationIds.ACTION_WITHDRAW_FOOD, -1.0, "Critically hungry; pantry has food"
|
|
)
|
|
return ActionSelectionResult.new(
|
|
SimulationIds.ACTION_GATHER_FOOD, -1.0, "Critically hungry; must find food"
|
|
)
|
|
|
|
var profession_def := SimulationDefinitions.get_profession(npc.profession)
|
|
var offset: float = profession_def.schedule_offset if profession_def != null else 0.0
|
|
var local_time := fmod(time_of_day + offset + 1.0, 1.0)
|
|
var period := _get_period(local_time)
|
|
|
|
if period == SchedulePeriod.SLEEP:
|
|
if npc.energy < 60.0:
|
|
return ActionSelectionResult.new(
|
|
SimulationIds.ACTION_SLEEP, -1.0, "Night-time sleep; energy is low"
|
|
)
|
|
if npc.hunger > 75.0 and npc.get_inventory_amount(SimulationIds.RESOURCE_FOOD) >= 1.0:
|
|
return ActionSelectionResult.new(
|
|
SimulationIds.ACTION_EAT, -1.0, "Night-time hunger; eating from inventory"
|
|
)
|
|
return ActionSelectionResult.new(
|
|
SimulationIds.ACTION_SLEEP, -1.0, "Night-time; going home"
|
|
)
|
|
|
|
if period == SchedulePeriod.MEAL:
|
|
if npc.hunger > 50.0:
|
|
if npc.get_inventory_amount(SimulationIds.RESOURCE_FOOD) >= 1.0:
|
|
return ActionSelectionResult.new(
|
|
SimulationIds.ACTION_EAT, -1.0, "Meal-time hunger; eating from inventory"
|
|
)
|
|
if village.food > 0:
|
|
return ActionSelectionResult.new(
|
|
SimulationIds.ACTION_WITHDRAW_FOOD, -1.0, "Meal-time; pantry has food"
|
|
)
|
|
return ActionSelectionResult.new(
|
|
SimulationIds.ACTION_GATHER_FOOD, -1.0, "Meal-time hunger; pantry is empty"
|
|
)
|
|
|
|
if npc.is_starving:
|
|
if npc.get_inventory_amount(SimulationIds.RESOURCE_FOOD) >= 1.0:
|
|
return ActionSelectionResult.new(
|
|
SimulationIds.ACTION_EAT, 1.0, "Starving and carrying food"
|
|
)
|
|
if village.food > 0:
|
|
return ActionSelectionResult.new(
|
|
SimulationIds.ACTION_WITHDRAW_FOOD, 1.0, "Starving; pantry has food"
|
|
)
|
|
return ActionSelectionResult.new(
|
|
SimulationIds.ACTION_GATHER_FOOD, 4.0, "Starving; pantry is empty"
|
|
)
|
|
if npc.hunger > 75.0:
|
|
if npc.get_inventory_amount(SimulationIds.RESOURCE_FOOD) >= 1.0:
|
|
return ActionSelectionResult.new(
|
|
SimulationIds.ACTION_EAT, -1.0, "Hungry and carrying food"
|
|
)
|
|
if village.food > 0:
|
|
return ActionSelectionResult.new(
|
|
SimulationIds.ACTION_WITHDRAW_FOOD, -1.0, "Hungry; pantry has food"
|
|
)
|
|
return ActionSelectionResult.new(
|
|
SimulationIds.ACTION_GATHER_FOOD, -1.0, "Hungry; pantry is empty"
|
|
)
|
|
if npc.get_inventory_amount(SimulationIds.RESOURCE_FOOD) > 0.0:
|
|
return ActionSelectionResult.new(
|
|
SimulationIds.ACTION_DEPOSIT_FOOD, -1.0, "Carrying food for storage"
|
|
)
|
|
if npc.get_inventory_amount(SimulationIds.RESOURCE_WOOD) > 0.0:
|
|
return ActionSelectionResult.new(
|
|
SimulationIds.ACTION_DEPOSIT_WOOD, -1.0, "Carrying wood for storage"
|
|
)
|
|
if npc.energy < 25.0:
|
|
return ActionSelectionResult.new(
|
|
SimulationIds.ACTION_REST, -1.0, "Energy is below the rest threshold"
|
|
)
|
|
|
|
if period == SchedulePeriod.DISCRETIONARY:
|
|
var roll := npc.random_source.randf()
|
|
if roll < 0.3:
|
|
return ActionSelectionResult.new(
|
|
SimulationIds.ACTION_WANDER, -1.0, "Discretionary wander"
|
|
)
|
|
|
|
return _choose_best_work_action(npc, village, all_npcs)
|
|
|
|
|
|
func _choose_best_work_action(npc: SimNPC, village: SimVillage, all_npcs: Array) -> ActionSelectionResult:
|
|
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
|
|
)
|
|
}
|
|
for familiar_id in npc.familiarity:
|
|
if not familiar_id is int:
|
|
continue
|
|
var other_task: StringName
|
|
for other in all_npcs:
|
|
if other.id == familiar_id and not other.is_dead:
|
|
other_task = other.current_task
|
|
break
|
|
if other_task in scores:
|
|
scores[other_task] = float(scores[other_task]) + 0.3
|
|
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 ActionSelectionResult.new(
|
|
best_action, -1.0, "Highest current work utility", scores
|
|
)
|
|
|
|
|
|
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
|
|
|
|
|
|
static func _get_period(time_of_day: float) -> int:
|
|
if _in_wrap_range(time_of_day, SLEEP_BEGIN, SLEEP_END):
|
|
return SchedulePeriod.SLEEP
|
|
if _in_range(time_of_day, BREAKFAST_BEGIN, BREAKFAST_END):
|
|
return SchedulePeriod.MEAL
|
|
if _in_range(time_of_day, DINNER_BEGIN, DINNER_END):
|
|
return SchedulePeriod.MEAL
|
|
if _in_range(time_of_day, WORK_BEGIN, WORK_END):
|
|
return SchedulePeriod.WORK
|
|
return SchedulePeriod.DISCRETIONARY
|
|
|
|
|
|
static func _in_range(value: float, begin: float, end: float) -> bool:
|
|
return value >= begin and value <= end
|
|
|
|
|
|
static func _in_wrap_range(value: float, begin: float, end: float) -> bool:
|
|
return value >= begin or value <= end
|