feat: shared creature root, enemy definitions, defend duty, and player downing

This commit is contained in:
Rijad Zuzo
2026-08-12 00:16:48 +02:00
parent 6de6d25bf5
commit 661d53f31a
23 changed files with 653 additions and 76 deletions
+39 -1
View File
@@ -33,6 +33,7 @@ signal combatant_died(combatant_id: StringName)
signal raid_started(raider_ids: Array[StringName])
signal war_resolved(outcome: StringName)
signal war_aborted
signal player_downed
var village := SimVillage.new()
var player_system := PlayerCitizenSystem.new()
@@ -98,6 +99,8 @@ func _ready() -> void:
conflict_system.raid_started.connect(func(raider_ids): raid_started.emit(raider_ids))
conflict_system.war_resolved.connect(func(outcome): war_resolved.emit(outcome))
conflict_system.war_aborted.connect(func(): war_aborted.emit())
conflict_system.player_damaged.connect(_on_player_damaged)
conflict_system.player_downed.connect(func(): player_downed.emit())
action_selector.relationship_system = relationship_system
var definition_errors := SimulationDefinitions.validate()
if not definition_errors.is_empty():
@@ -258,8 +261,16 @@ func _select_action_if_idle(npc: SimNPC, previous_state: StringName) -> void:
record_narrative_event(SimulationIds.EVENT_VILLAGER_WEAK, npc.id)
var helper := get_active_opportunity_helper()
var animal_feed_available := animal_care.has_available_feed_target(npc.id)
var war_raid_active := conflict_system.is_raiding()
var selection := action_selector.select_action(
npc, village, clock.time_of_day(), npcs, helper, _population_view, animal_feed_available
npc,
village,
clock.time_of_day(),
npcs,
helper,
_population_view,
animal_feed_available,
war_raid_active
)
if selection == null:
return
@@ -390,6 +401,8 @@ func _apply_action_completion(
pass
SimulationIds.ACTION_PATROL, SimulationIds.ACTION_STUDY, SimulationIds.ACTION_REST, SimulationIds.ACTION_WANDER:
village.apply_npc_task(npc)
SimulationIds.ACTION_DEFEND:
village.apply_metric_delta(&"safety", 0.5)
if debug_logs and completed_task == SimulationIds.ACTION_SLEEP:
print("[SimulationManager] ", npc.npc_name, " completed sleep at home")
@@ -1138,6 +1151,31 @@ func harvest_resource_node(node: ResourceNode) -> float:
func advance_player_needs() -> void:
player_system.advance_needs(village.food_modifier)
player_system.player_state.advance_health()
_sync_player_combatant()
func _sync_player_combatant() -> void:
conflict_system.set_player_health(
player_system.player_state.get_health(), player_system.player_state.is_downed()
)
func _on_player_damaged(amount: float) -> void:
player_system.player_state.take_damage(amount)
_sync_player_combatant()
func update_player_combatant(position: Vector3) -> void:
conflict_system.set_player_combatant_position(position)
func player_is_downed() -> bool:
return player_system.player_state.is_downed()
func get_player_health() -> float:
return player_system.player_state.get_health()
func get_season() -> StringName:
+10 -1
View File
@@ -24,7 +24,8 @@ func select_action(
all_npcs: Array = [],
opportunity_helper: OpportunityHelperResult = null,
population_view: SimulationPopulationView = null,
animal_feed_available: bool = false
animal_feed_available: bool = false,
war_raid_active: bool = false
) -> ActionSelectionResult:
if npc.is_dead:
return null
@@ -115,6 +116,10 @@ func select_action(
-1.0,
"Responding to village need: %s" % opportunity_helper.reason
)
if war_raid_active and period == SchedulePeriod.WORK and _is_eligible_defender(npc):
return ActionSelectionResult.new(
SimulationIds.ACTION_DEFEND, -1.0, "Rallying to defend the village"
)
if npc.get_inventory_amount(SimulationIds.RESOURCE_FOOD) > 0.0:
return ActionSelectionResult.new(
SimulationIds.ACTION_DEPOSIT_FOOD, -1.0, "Carrying food for storage"
@@ -297,6 +302,10 @@ func _calculate_score(
return score
static func _is_eligible_defender(npc: SimNPC) -> bool:
return npc.strength >= 5.0 or npc.profession == SimulationIds.PROFESSION_GUARD
static func _get_period(time_of_day: float) -> int:
if _in_wrap_range(time_of_day, SLEEP_BEGIN, SLEEP_END):
return SchedulePeriod.SLEEP
+69 -17
View File
@@ -15,6 +15,8 @@ signal combatant_died(combatant_id: StringName)
signal raid_started(raider_ids: Array[StringName])
signal war_resolved(outcome: StringName)
signal war_aborted
signal player_damaged(amount: float)
signal player_downed
const NPC_MAX_HEALTH := 100.0
const STRONG_STRENGTH := 5.0
@@ -24,9 +26,7 @@ const WOLF_HUNT_RADIUS := 18.0
const RAID_SPAWN_OFFSET := Vector3(42.0, 0.0, 42.0)
const RAID_SPEED := 1.2
const RAID_SIZE := 4
const RAIDER_HEALTH := 60.0
const WOLF_HEALTH := 40.0
const RAIDER_REACH_FACTOR := 1.6
const PLAYER_COMBATANT_ID := &"player_combatant"
var combatants: Dictionary = {}
var factions: Dictionary = {}
@@ -52,6 +52,53 @@ func initialize_factions() -> void:
factions[SimulationIds.FACTION_TRIBE] = FactionStateRecord.create(
SimulationIds.FACTION_TRIBE, "Hill Tribe", 4.0, RAID_SIZE, 0.7, 0.7
)
_ensure_player_combatant()
func _ensure_player_combatant() -> void:
if combatants.has(PLAYER_COMBATANT_ID):
return
var sword := SimulationItems.get_weapon(SimulationIds.ITEM_SWORD)
combatants[PLAYER_COMBATANT_ID] = CombatantStateRecord.create(
PLAYER_COMBATANT_ID,
SimulationIds.COMBATANT_KIND_PLAYER,
SimulationIds.FACTION_VILLAGE,
"Player",
village_center,
100.0,
sword.item_id if sword != null else SimulationIds.ITEM_SWORD,
CombatantStateRecord.NO_NPC_ID
)
func set_player_combatant_position(position: Vector3) -> void:
var player_combatant := get_combatant(PLAYER_COMBATANT_ID)
if player_combatant == null:
_ensure_player_combatant()
player_combatant = get_combatant(PLAYER_COMBATANT_ID)
if player_combatant != null:
player_combatant.set_position(position)
func set_player_health(health: float, downed: bool) -> void:
var player_combatant := get_combatant(PLAYER_COMBATANT_ID)
if player_combatant == null:
_ensure_player_combatant()
player_combatant = get_combatant(PLAYER_COMBATANT_ID)
if player_combatant == null:
return
if not downed and player_combatant.is_alive():
var target := clampf(health, 0.0, player_combatant.get_max_health())
var current := player_combatant.get_health()
if target < current:
player_combatant.take_damage(current - target)
elif target > current:
player_combatant.data["health"] = target
func is_player_downed() -> bool:
var player_combatant := get_combatant(PLAYER_COMBATANT_ID)
return player_combatant != null and not player_combatant.is_alive()
func register_npc_combatants(npc_list: Array[SimNPC]) -> void:
@@ -100,7 +147,7 @@ func advance(simulation_tick: int) -> void:
func _advance_raider_positions() -> void:
if not _is_raiding():
if not is_raiding():
return
for combatant_id in _sorted_combatant_ids():
var combatant := combatants[combatant_id] as CombatantStateRecord
@@ -132,16 +179,17 @@ func player_attack(combatant_id: StringName) -> float:
func spawn_wolf(position: Vector3) -> StringName:
var definition := SimulationEnemies.get_enemy(&"enemy_wolf")
var wolf_id := StringName("wolf_%d" % next_wolf_id)
next_wolf_id += 1
var wolf := CombatantStateRecord.create(
wolf_id,
SimulationIds.COMBATANT_KIND_WOLF,
SimulationIds.FACTION_TRIBE,
"Wolf",
definition.display_name if definition != null else "Wolf",
position,
WOLF_HEALTH,
SimulationIds.ITEM_CLAW,
definition.health if definition != null else 40.0,
definition.weapon_id if definition != null else SimulationIds.ITEM_CLAW,
CombatantStateRecord.NO_NPC_ID,
true
)
@@ -165,6 +213,11 @@ func _apply_damage(target: CombatantStateRecord, damage: float, actor_id: int) -
target.get_weapon_id(),
applied
)
if target.get_kind() == SimulationIds.COMBATANT_KIND_PLAYER:
player_damaged.emit(applied)
if not target.is_alive():
player_downed.emit()
return applied
if not target.is_alive():
_kill_combatant(target, actor_id)
return applied
@@ -313,6 +366,7 @@ func village_defender_count() -> int:
func _start_raid(tribe: FactionStateRecord, simulation_tick: int) -> void:
var definition := SimulationEnemies.get_enemy(&"enemy_raider")
tribe.set_war_plan(SimulationIds.WAR_PLAN_RAIDING, simulation_tick)
tribe.set_stance(SimulationIds.STANCE_HOSTILE)
var raider_ids: Array[StringName] = []
@@ -324,10 +378,10 @@ func _start_raid(tribe: FactionStateRecord, simulation_tick: int) -> void:
raider_id,
SimulationIds.COMBATANT_KIND_RAIDER,
SimulationIds.FACTION_TRIBE,
"Raider",
definition.display_name if definition != null else "Raider",
village_center + RAID_SPAWN_OFFSET + offset,
RAIDER_HEALTH,
SimulationIds.ITEM_SWORD,
definition.health if definition != null else 60.0,
definition.weapon_id if definition != null else SimulationIds.ITEM_SWORD,
CombatantStateRecord.NO_NPC_ID,
true
)
@@ -347,7 +401,7 @@ func _start_raid(tribe: FactionStateRecord, simulation_tick: int) -> void:
func _run_battle() -> void:
var raid_active := _is_raiding()
var raid_active := is_raiding()
var living_hostiles: Array[CombatantStateRecord] = []
for combatant_id in _sorted_combatant_ids():
var combatant := combatants[combatant_id] as CombatantStateRecord
@@ -483,11 +537,7 @@ func _reach(combatant: CombatantStateRecord) -> float:
var weapon := SimulationItems.get_weapon(combatant.get_weapon_id())
if weapon == null:
return 1.5
return (
weapon.reach * RAIDER_REACH_FACTOR
if combatant.get_kind() == SimulationIds.COMBATANT_KIND_WOLF
else weapon.reach
)
return weapon.reach
func _actor_id_for(combatant: CombatantStateRecord) -> int:
@@ -506,7 +556,7 @@ func _living_village_npc_count() -> int:
return count
func _is_raiding() -> bool:
func is_raiding() -> bool:
var tribe := _get_faction(SimulationIds.FACTION_TRIBE)
return tribe != null and tribe.get_war_plan() == SimulationIds.WAR_PLAN_RAIDING
@@ -596,6 +646,8 @@ func _narrative_event_requested(
func append_state_records(record: SimulationStateRecord) -> void:
var combatant_keys: Array[String] = []
for combatant_id in combatants.keys():
if String(combatant_id) == String(PLAYER_COMBATANT_ID):
continue
combatant_keys.append(String(combatant_id))
combatant_keys.sort()
for combatant_key in combatant_keys:
+31
View File
@@ -0,0 +1,31 @@
class_name EnemyDefinition
extends Resource
@export var enemy_id: StringName
@export var display_name: String
@export var kind: StringName = SimulationIds.COMBATANT_KIND_RAIDER
@export var weapon_id: StringName = SimulationIds.ITEM_SWORD
@export var health := 60.0
@export var move_speed := 3.5
@export var body_color := Color(0.4, 0.13, 0.1, 1.0)
@export var accent_color := Color(0.55, 0.55, 0.58, 1.0)
@export var visual_scale := Vector3.ONE
func validate() -> Array[String]:
var errors: Array[String] = []
if enemy_id.is_empty():
errors.append("enemy_id is empty")
if display_name.is_empty():
errors.append("display_name is empty for '%s'" % enemy_id)
if (
kind
not in [
SimulationIds.COMBATANT_KIND_RAIDER,
SimulationIds.COMBATANT_KIND_WOLF,
]
):
errors.append("kind '%s' is invalid for '%s'" % [kind, enemy_id])
if health <= 0.0:
errors.append("health must be positive for '%s'" % enemy_id)
return errors
@@ -0,0 +1 @@
uid://5tfgb1oww0e0
@@ -13,7 +13,8 @@ const ACTION_PATHS := [
"res://simulation/definitions/actions/deposit_food.tres",
"res://simulation/definitions/actions/withdraw_food.tres",
"res://simulation/definitions/actions/sleep.tres",
"res://simulation/definitions/actions/deposit_wood.tres"
"res://simulation/definitions/actions/deposit_wood.tres",
"res://simulation/definitions/actions/defend.tres"
]
const PROFESSION_PATHS := [
@@ -0,0 +1,51 @@
class_name SimulationEnemies
extends RefCounted
static var _enemies: Dictionary = {}
static var _cache_valid := false
static func _ensure_cache() -> void:
if _cache_valid:
return
_enemies = {}
var raider := EnemyDefinition.new()
raider.enemy_id = &"enemy_raider"
raider.display_name = "Raider"
raider.kind = SimulationIds.COMBATANT_KIND_RAIDER
raider.weapon_id = SimulationIds.ITEM_SWORD
raider.health = 60.0
raider.move_speed = 3.5
raider.body_color = Color(0.4, 0.13, 0.1, 1.0)
raider.accent_color = Color(0.55, 0.55, 0.58, 1.0)
_enemies[raider.enemy_id] = raider
var wolf := EnemyDefinition.new()
wolf.enemy_id = &"enemy_wolf"
wolf.display_name = "Wolf"
wolf.kind = SimulationIds.COMBATANT_KIND_WOLF
wolf.weapon_id = SimulationIds.ITEM_CLAW
wolf.health = 40.0
wolf.move_speed = 4.2
wolf.body_color = Color(0.32, 0.32, 0.34, 1.0)
wolf.accent_color = Color(0.95, 0.5, 0.12, 1.0)
wolf.visual_scale = Vector3(0.8, 0.8, 0.8)
_enemies[wolf.enemy_id] = wolf
_cache_valid = true
static func invalidate_cache() -> void:
_cache_valid = false
static func get_enemy(enemy_id: StringName) -> EnemyDefinition:
_ensure_cache()
return _enemies.get(enemy_id) as EnemyDefinition
static func get_all() -> Array[EnemyDefinition]:
_ensure_cache()
var values: Array[EnemyDefinition] = []
for enemy in _enemies.values():
values.append(enemy)
return values
@@ -0,0 +1 @@
uid://daurjkbl4hcd8
+1
View File
@@ -18,6 +18,7 @@ const ACTION_WANDER := &"wander"
const ACTION_DEPOSIT_FOOD := &"deposit_food"
const ACTION_WITHDRAW_FOOD := &"withdraw_food"
const ACTION_DEPOSIT_WOOD := &"deposit_wood"
const ACTION_DEFEND := &"defend"
const PROFESSION_FARMER := &"farmer"
const PROFESSION_WOODCUTTER := &"woodcutter"
@@ -0,0 +1,11 @@
[gd_resource type="Resource" script_class="ActionDefinition" load_steps=2 format=3]
[ext_resource type="Script" path="res://simulation/definitions/ActionDefinition.gd" id="1"]
[resource]
script = ExtResource("1")
action_id = &"defend"
display_name = "Defend"
default_duration = 5.0
preferred_profession_id = &"guard"
target_type = &"activity"
+74 -4
View File
@@ -1,7 +1,9 @@
class_name PlayerStateRecord
extends RefCounted
const SCHEMA_VERSION := 1
const SCHEMA_VERSION := 2
const LEGACY_SCHEMA_VERSION := 1
const MAX_HEALTH := 100.0
var data: Dictionary
@@ -18,6 +20,10 @@ static func create_default() -> PlayerStateRecord:
"schema_version": SCHEMA_VERSION,
"hunger": 40.0,
"energy": 100.0,
"health": MAX_HEALTH,
"max_health": MAX_HEALTH,
"downed": false,
"downed_ticks": 0,
"inventory": {},
}
)
@@ -25,15 +31,27 @@ static func create_default() -> PlayerStateRecord:
static func from_dictionary(record_data: Dictionary) -> PlayerStateRecord:
if int(record_data.get("schema_version", -1)) != SCHEMA_VERSION:
var version := int(record_data.get("schema_version", -1))
if version == LEGACY_SCHEMA_VERSION:
record_data = _migrate_v1(record_data)
elif version != SCHEMA_VERSION:
return null
if not record_data.has_all(["hunger", "energy", "inventory"]):
if not record_data.has_all(
["hunger", "energy", "health", "max_health", "downed", "downed_ticks", "inventory"]
):
return null
if not record_data["inventory"] is Dictionary:
return null
var hunger := float(record_data["hunger"])
var energy := float(record_data["energy"])
if not is_finite(hunger) or not is_finite(energy):
var health := float(record_data["health"])
var max_health := float(record_data["max_health"])
if (
not is_finite(hunger)
or not is_finite(energy)
or not is_finite(health)
or not is_finite(max_health)
):
return null
var normalized_inventory := {}
for raw_item_id in record_data["inventory"]:
@@ -54,12 +72,26 @@ static func from_dictionary(record_data: Dictionary) -> PlayerStateRecord:
"schema_version": SCHEMA_VERSION,
"hunger": clampf(hunger, 0.0, 100.0),
"energy": clampf(energy, 0.0, 100.0),
"health": clampf(health, 0.0, max_health),
"max_health": max_health,
"downed": bool(record_data["downed"]),
"downed_ticks": int(record_data["downed_ticks"]),
"inventory": normalized_inventory,
}
)
)
static func _migrate_v1(legacy_data: Dictionary) -> Dictionary:
var migrated := legacy_data.duplicate(true)
migrated["schema_version"] = SCHEMA_VERSION
migrated["health"] = MAX_HEALTH
migrated["max_health"] = MAX_HEALTH
migrated["downed"] = false
migrated["downed_ticks"] = 0
return migrated
func get_hunger() -> float:
return clampf(float(data["hunger"]), 0.0, 100.0)
@@ -68,6 +100,22 @@ func get_energy() -> float:
return clampf(float(data["energy"]), 0.0, 100.0)
func get_health() -> float:
return clampf(float(data["health"]), 0.0, get_max_health())
func get_max_health() -> float:
return maxf(float(data["max_health"]), 1.0)
func is_downed() -> bool:
return bool(data["downed"])
func get_downed_ticks() -> int:
return int(data["downed_ticks"])
func set_hunger(amount: float) -> void:
data["hunger"] = clampf(amount, 0.0, 100.0)
@@ -76,6 +124,28 @@ func set_energy(amount: float) -> void:
data["energy"] = clampf(amount, 0.0, 100.0)
func take_damage(amount: float) -> float:
if not is_finite(amount) or amount <= 0.0 or is_downed():
return 0.0
var previous := get_health()
data["health"] = clampf(previous - amount, 0.0, get_max_health())
if get_health() <= 0.0:
data["downed"] = true
data["downed_ticks"] = 20
return previous - get_health()
func advance_health() -> void:
if is_downed():
data["downed_ticks"] = maxi(get_downed_ticks() - 1, 0)
if get_downed_ticks() <= 0:
data["downed"] = false
data["health"] = get_max_health() * 0.5
return
if get_health() < get_max_health():
data["health"] = clampf(get_health() + 0.25, 0.0, get_max_health())
func get_inventory_amount(item_id: StringName) -> float:
return float(data["inventory"].get(String(item_id), 0.0))