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
+15
View File
@@ -44,8 +44,15 @@ func _resolve_references() -> void:
func _physics_process(delta: float) -> void: func _physics_process(delta: float) -> void:
if not _references_resolved: if not _references_resolved:
_resolve_references() _resolve_references()
if simulation_manager != null and simulation_manager.has_method("update_player_combatant"):
simulation_manager.update_player_combatant(player.global_position)
attack_timer = maxf(attack_timer - delta, 0.0) attack_timer = maxf(attack_timer - delta, 0.0)
dash_timer = maxf(dash_timer - delta, 0.0) dash_timer = maxf(dash_timer - delta, 0.0)
if _player_is_downed():
attack_timer = 0.0
dash_timer = 0.0
dashing = false
return
if dashing: if dashing:
player.velocity = dash_velocity player.velocity = dash_velocity
if dash_timer <= 0.0: if dash_timer <= 0.0:
@@ -58,6 +65,14 @@ func _physics_process(delta: float) -> void:
_perform_dash() _perform_dash()
func _player_is_downed() -> bool:
return (
simulation_manager != null
and simulation_manager.has_method("player_is_downed")
and simulation_manager.player_is_downed()
)
func _perform_attack() -> void: func _perform_attack() -> void:
attack_timer = attack_cooldown_seconds attack_timer = attack_cooldown_seconds
_play_swing() _play_swing()
+2 -2
View File
@@ -492,7 +492,7 @@ func _create_task_glyph_mesh(action_id: StringName) -> PrimitiveMesh:
var crate := BoxMesh.new() var crate := BoxMesh.new()
crate.size = Vector3(0.34, 0.22, 0.34) crate.size = Vector3(0.34, 0.22, 0.34)
return crate return crate
SimulationIds.ACTION_PATROL: SimulationIds.ACTION_PATROL, SimulationIds.ACTION_DEFEND:
var shield := CylinderMesh.new() var shield := CylinderMesh.new()
shield.top_radius = 0.2 shield.top_radius = 0.2
shield.bottom_radius = 0.2 shield.bottom_radius = 0.2
@@ -532,7 +532,7 @@ func _get_task_glyph_color(action_id: StringName) -> Color:
return Color(0.58, 0.34, 0.16, 1.0) return Color(0.58, 0.34, 0.16, 1.0)
SimulationIds.ACTION_DEPOSIT_FOOD: SimulationIds.ACTION_DEPOSIT_FOOD:
return Color(0.95, 0.72, 0.32, 1.0) return Color(0.95, 0.72, 0.32, 1.0)
SimulationIds.ACTION_PATROL: SimulationIds.ACTION_PATROL, SimulationIds.ACTION_DEFEND:
return Color(0.36, 0.58, 0.95, 1.0) return Color(0.36, 0.58, 0.95, 1.0)
SimulationIds.ACTION_STUDY: SimulationIds.ACTION_STUDY:
return Color(0.62, 0.46, 0.9, 1.0) return Color(0.62, 0.46, 0.9, 1.0)
+13
View File
@@ -25,6 +25,11 @@ func _ready() -> void:
func _physics_process(delta: float) -> void: func _physics_process(delta: float) -> void:
if _is_player_downed():
velocity.x = move_toward(velocity.x, 0.0, acceleration * delta)
velocity.z = move_toward(velocity.z, 0.0, acceleration * delta)
move_and_slide()
return
var input := Input.get_vector("move_left", "move_right", "move_forward", "move_backward") var input := Input.get_vector("move_left", "move_right", "move_forward", "move_backward")
var forward := -camera_rig.global_transform.basis.z var forward := -camera_rig.global_transform.basis.z
@@ -55,6 +60,14 @@ func _physics_process(delta: float) -> void:
rotation.y = lerp_angle(rotation.y, target_angle, rotation_speed * delta) rotation.y = lerp_angle(rotation.y, target_angle, rotation_speed * delta)
func _is_player_downed() -> bool:
return (
simulation_manager != null
and simulation_manager.has_method("player_is_downed")
and simulation_manager.player_is_downed()
)
func _unhandled_input(event: InputEvent) -> void: func _unhandled_input(event: InputEvent) -> void:
if event.is_action_pressed("interact"): if event.is_action_pressed("interact"):
try_interact() try_interact()
+39 -1
View File
@@ -33,6 +33,7 @@ signal combatant_died(combatant_id: StringName)
signal raid_started(raider_ids: Array[StringName]) signal raid_started(raider_ids: Array[StringName])
signal war_resolved(outcome: StringName) signal war_resolved(outcome: StringName)
signal war_aborted signal war_aborted
signal player_downed
var village := SimVillage.new() var village := SimVillage.new()
var player_system := PlayerCitizenSystem.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.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_resolved.connect(func(outcome): war_resolved.emit(outcome))
conflict_system.war_aborted.connect(func(): war_aborted.emit()) 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 action_selector.relationship_system = relationship_system
var definition_errors := SimulationDefinitions.validate() var definition_errors := SimulationDefinitions.validate()
if not definition_errors.is_empty(): 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) record_narrative_event(SimulationIds.EVENT_VILLAGER_WEAK, npc.id)
var helper := get_active_opportunity_helper() var helper := get_active_opportunity_helper()
var animal_feed_available := animal_care.has_available_feed_target(npc.id) 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( 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: if selection == null:
return return
@@ -390,6 +401,8 @@ func _apply_action_completion(
pass pass
SimulationIds.ACTION_PATROL, SimulationIds.ACTION_STUDY, SimulationIds.ACTION_REST, SimulationIds.ACTION_WANDER: SimulationIds.ACTION_PATROL, SimulationIds.ACTION_STUDY, SimulationIds.ACTION_REST, SimulationIds.ACTION_WANDER:
village.apply_npc_task(npc) village.apply_npc_task(npc)
SimulationIds.ACTION_DEFEND:
village.apply_metric_delta(&"safety", 0.5)
if debug_logs and completed_task == SimulationIds.ACTION_SLEEP: if debug_logs and completed_task == SimulationIds.ACTION_SLEEP:
print("[SimulationManager] ", npc.npc_name, " completed sleep at home") 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: func advance_player_needs() -> void:
player_system.advance_needs(village.food_modifier) 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: func get_season() -> StringName:
+10 -1
View File
@@ -24,7 +24,8 @@ func select_action(
all_npcs: Array = [], all_npcs: Array = [],
opportunity_helper: OpportunityHelperResult = null, opportunity_helper: OpportunityHelperResult = null,
population_view: SimulationPopulationView = null, population_view: SimulationPopulationView = null,
animal_feed_available: bool = false animal_feed_available: bool = false,
war_raid_active: bool = false
) -> ActionSelectionResult: ) -> ActionSelectionResult:
if npc.is_dead: if npc.is_dead:
return null return null
@@ -115,6 +116,10 @@ func select_action(
-1.0, -1.0,
"Responding to village need: %s" % opportunity_helper.reason "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: if npc.get_inventory_amount(SimulationIds.RESOURCE_FOOD) > 0.0:
return ActionSelectionResult.new( return ActionSelectionResult.new(
SimulationIds.ACTION_DEPOSIT_FOOD, -1.0, "Carrying food for storage" SimulationIds.ACTION_DEPOSIT_FOOD, -1.0, "Carrying food for storage"
@@ -297,6 +302,10 @@ func _calculate_score(
return 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: static func _get_period(time_of_day: float) -> int:
if _in_wrap_range(time_of_day, SLEEP_BEGIN, SLEEP_END): if _in_wrap_range(time_of_day, SLEEP_BEGIN, SLEEP_END):
return SchedulePeriod.SLEEP 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 raid_started(raider_ids: Array[StringName])
signal war_resolved(outcome: StringName) signal war_resolved(outcome: StringName)
signal war_aborted signal war_aborted
signal player_damaged(amount: float)
signal player_downed
const NPC_MAX_HEALTH := 100.0 const NPC_MAX_HEALTH := 100.0
const STRONG_STRENGTH := 5.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_SPAWN_OFFSET := Vector3(42.0, 0.0, 42.0)
const RAID_SPEED := 1.2 const RAID_SPEED := 1.2
const RAID_SIZE := 4 const RAID_SIZE := 4
const RAIDER_HEALTH := 60.0 const PLAYER_COMBATANT_ID := &"player_combatant"
const WOLF_HEALTH := 40.0
const RAIDER_REACH_FACTOR := 1.6
var combatants: Dictionary = {} var combatants: Dictionary = {}
var factions: Dictionary = {} var factions: Dictionary = {}
@@ -52,6 +52,53 @@ func initialize_factions() -> void:
factions[SimulationIds.FACTION_TRIBE] = FactionStateRecord.create( factions[SimulationIds.FACTION_TRIBE] = FactionStateRecord.create(
SimulationIds.FACTION_TRIBE, "Hill Tribe", 4.0, RAID_SIZE, 0.7, 0.7 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: func register_npc_combatants(npc_list: Array[SimNPC]) -> void:
@@ -100,7 +147,7 @@ func advance(simulation_tick: int) -> void:
func _advance_raider_positions() -> void: func _advance_raider_positions() -> void:
if not _is_raiding(): if not is_raiding():
return return
for combatant_id in _sorted_combatant_ids(): for combatant_id in _sorted_combatant_ids():
var combatant := combatants[combatant_id] as CombatantStateRecord var combatant := combatants[combatant_id] as CombatantStateRecord
@@ -132,16 +179,17 @@ func player_attack(combatant_id: StringName) -> float:
func spawn_wolf(position: Vector3) -> StringName: func spawn_wolf(position: Vector3) -> StringName:
var definition := SimulationEnemies.get_enemy(&"enemy_wolf")
var wolf_id := StringName("wolf_%d" % next_wolf_id) var wolf_id := StringName("wolf_%d" % next_wolf_id)
next_wolf_id += 1 next_wolf_id += 1
var wolf := CombatantStateRecord.create( var wolf := CombatantStateRecord.create(
wolf_id, wolf_id,
SimulationIds.COMBATANT_KIND_WOLF, SimulationIds.COMBATANT_KIND_WOLF,
SimulationIds.FACTION_TRIBE, SimulationIds.FACTION_TRIBE,
"Wolf", definition.display_name if definition != null else "Wolf",
position, position,
WOLF_HEALTH, definition.health if definition != null else 40.0,
SimulationIds.ITEM_CLAW, definition.weapon_id if definition != null else SimulationIds.ITEM_CLAW,
CombatantStateRecord.NO_NPC_ID, CombatantStateRecord.NO_NPC_ID,
true true
) )
@@ -165,6 +213,11 @@ func _apply_damage(target: CombatantStateRecord, damage: float, actor_id: int) -
target.get_weapon_id(), target.get_weapon_id(),
applied 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(): if not target.is_alive():
_kill_combatant(target, actor_id) _kill_combatant(target, actor_id)
return applied return applied
@@ -313,6 +366,7 @@ func village_defender_count() -> int:
func _start_raid(tribe: FactionStateRecord, simulation_tick: int) -> void: 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_war_plan(SimulationIds.WAR_PLAN_RAIDING, simulation_tick)
tribe.set_stance(SimulationIds.STANCE_HOSTILE) tribe.set_stance(SimulationIds.STANCE_HOSTILE)
var raider_ids: Array[StringName] = [] var raider_ids: Array[StringName] = []
@@ -324,10 +378,10 @@ func _start_raid(tribe: FactionStateRecord, simulation_tick: int) -> void:
raider_id, raider_id,
SimulationIds.COMBATANT_KIND_RAIDER, SimulationIds.COMBATANT_KIND_RAIDER,
SimulationIds.FACTION_TRIBE, SimulationIds.FACTION_TRIBE,
"Raider", definition.display_name if definition != null else "Raider",
village_center + RAID_SPAWN_OFFSET + offset, village_center + RAID_SPAWN_OFFSET + offset,
RAIDER_HEALTH, definition.health if definition != null else 60.0,
SimulationIds.ITEM_SWORD, definition.weapon_id if definition != null else SimulationIds.ITEM_SWORD,
CombatantStateRecord.NO_NPC_ID, CombatantStateRecord.NO_NPC_ID,
true true
) )
@@ -347,7 +401,7 @@ func _start_raid(tribe: FactionStateRecord, simulation_tick: int) -> void:
func _run_battle() -> void: func _run_battle() -> void:
var raid_active := _is_raiding() var raid_active := is_raiding()
var living_hostiles: Array[CombatantStateRecord] = [] var living_hostiles: Array[CombatantStateRecord] = []
for combatant_id in _sorted_combatant_ids(): for combatant_id in _sorted_combatant_ids():
var combatant := combatants[combatant_id] as CombatantStateRecord 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()) var weapon := SimulationItems.get_weapon(combatant.get_weapon_id())
if weapon == null: if weapon == null:
return 1.5 return 1.5
return ( return weapon.reach
weapon.reach * RAIDER_REACH_FACTOR
if combatant.get_kind() == SimulationIds.COMBATANT_KIND_WOLF
else weapon.reach
)
func _actor_id_for(combatant: CombatantStateRecord) -> int: func _actor_id_for(combatant: CombatantStateRecord) -> int:
@@ -506,7 +556,7 @@ func _living_village_npc_count() -> int:
return count return count
func _is_raiding() -> bool: func is_raiding() -> bool:
var tribe := _get_faction(SimulationIds.FACTION_TRIBE) var tribe := _get_faction(SimulationIds.FACTION_TRIBE)
return tribe != null and tribe.get_war_plan() == SimulationIds.WAR_PLAN_RAIDING 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: func append_state_records(record: SimulationStateRecord) -> void:
var combatant_keys: Array[String] = [] var combatant_keys: Array[String] = []
for combatant_id in combatants.keys(): for combatant_id in combatants.keys():
if String(combatant_id) == String(PLAYER_COMBATANT_ID):
continue
combatant_keys.append(String(combatant_id)) combatant_keys.append(String(combatant_id))
combatant_keys.sort() combatant_keys.sort()
for combatant_key in combatant_keys: 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/deposit_food.tres",
"res://simulation/definitions/actions/withdraw_food.tres", "res://simulation/definitions/actions/withdraw_food.tres",
"res://simulation/definitions/actions/sleep.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 := [ 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_DEPOSIT_FOOD := &"deposit_food"
const ACTION_WITHDRAW_FOOD := &"withdraw_food" const ACTION_WITHDRAW_FOOD := &"withdraw_food"
const ACTION_DEPOSIT_WOOD := &"deposit_wood" const ACTION_DEPOSIT_WOOD := &"deposit_wood"
const ACTION_DEFEND := &"defend"
const PROFESSION_FARMER := &"farmer" const PROFESSION_FARMER := &"farmer"
const PROFESSION_WOODCUTTER := &"woodcutter" 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 class_name PlayerStateRecord
extends RefCounted extends RefCounted
const SCHEMA_VERSION := 1 const SCHEMA_VERSION := 2
const LEGACY_SCHEMA_VERSION := 1
const MAX_HEALTH := 100.0
var data: Dictionary var data: Dictionary
@@ -18,6 +20,10 @@ static func create_default() -> PlayerStateRecord:
"schema_version": SCHEMA_VERSION, "schema_version": SCHEMA_VERSION,
"hunger": 40.0, "hunger": 40.0,
"energy": 100.0, "energy": 100.0,
"health": MAX_HEALTH,
"max_health": MAX_HEALTH,
"downed": false,
"downed_ticks": 0,
"inventory": {}, "inventory": {},
} }
) )
@@ -25,15 +31,27 @@ static func create_default() -> PlayerStateRecord:
static func from_dictionary(record_data: Dictionary) -> 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 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 return null
if not record_data["inventory"] is Dictionary: if not record_data["inventory"] is Dictionary:
return null return null
var hunger := float(record_data["hunger"]) var hunger := float(record_data["hunger"])
var energy := float(record_data["energy"]) 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 return null
var normalized_inventory := {} var normalized_inventory := {}
for raw_item_id in record_data["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, "schema_version": SCHEMA_VERSION,
"hunger": clampf(hunger, 0.0, 100.0), "hunger": clampf(hunger, 0.0, 100.0),
"energy": clampf(energy, 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, "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: func get_hunger() -> float:
return clampf(float(data["hunger"]), 0.0, 100.0) 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) 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: func set_hunger(amount: float) -> void:
data["hunger"] = clampf(amount, 0.0, 100.0) 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) 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: func get_inventory_amount(item_id: StringName) -> float:
return float(data["inventory"].get(String(item_id), 0.0)) return float(data["inventory"].get(String(item_id), 0.0))
+145
View File
@@ -0,0 +1,145 @@
extends SceneTree
var failures: Array[String] = []
func _initialize() -> void:
call_deferred("_run")
func _run() -> void:
_test_enemy_definitions()
_test_player_downing()
_test_defend_selection()
_finish()
func _test_enemy_definitions() -> void:
var raider: EnemyDefinition = SimulationEnemies.get_enemy(&"enemy_raider")
var wolf: EnemyDefinition = SimulationEnemies.get_enemy(&"enemy_wolf")
_check(
(
raider != null
and wolf != null
and raider.kind == SimulationIds.COMBATANT_KIND_RAIDER
and wolf.kind == SimulationIds.COMBATANT_KIND_WOLF
and raider.weapon_id == SimulationIds.ITEM_SWORD
and wolf.weapon_id == SimulationIds.ITEM_CLAW
),
"Enemy definitions should describe raiders and wolves as data"
)
var manager := _create_manager(991)
var wolf_id: StringName = manager.spawn_wolf(Vector3.ZERO)
var wolf_combatant: CombatantStateRecord = manager.get_combatant(wolf_id)
_check(
(
is_equal_approx(wolf_combatant.get_health(), wolf.health)
and wolf_combatant.get_weapon_id() == wolf.weapon_id
),
"Spawned enemies should inherit their definition's health and weapon"
)
manager.free()
func _test_player_downing() -> void:
var manager := _create_manager(992)
var player_state: PlayerStateRecord = manager.get_player_state()
_check(
is_equal_approx(player_state.get_health(), 100.0) and not player_state.is_downed(),
"The player should begin healthy and standing"
)
var wolf_id: StringName = manager.spawn_wolf(Vector3.ZERO)
manager.update_player_combatant(Vector3.ZERO)
for _tick in 3:
manager.simulate_tick()
_check(
player_state.get_health() < 100.0 and not player_state.is_downed(),
"A hungry wolf should wound the player during a hunt"
)
var downed := false
for _tick in 60:
manager.simulate_tick()
if player_state.is_downed():
downed = true
break
_check(downed, "A prolonged wolf attack should eventually down the player")
if downed:
for _tick in 22:
manager.simulate_tick()
_check(not player_state.is_downed(), "The downed player should recover and stand again")
_check(
player_state.get_health() > 0.0, "Recovery should restore the player to a fighting baseline"
)
manager.free()
func _test_defend_selection() -> void:
var manager := _create_manager(993)
manager.clock.elapsed_ticks = 100
var tribe: FactionStateRecord = manager.get_faction(SimulationIds.FACTION_TRIBE)
tribe.set_war_plan(SimulationIds.WAR_PLAN_RAIDING, 1)
var defender: SimNPC = manager.npcs[0]
defender.strength = 9.0
defender.hunger = 20.0
defender.energy = 90.0
defender.is_starving = false
defender.mourning_ticks = 0
defender.inventory.clear()
defender.task_state = SimNPC.TASK_STATE_IDLE
defender.task_complete = true
manager.call("_select_action_if_idle", defender, SimNPC.TASK_STATE_IDLE)
_check(
(
defender.current_task == SimulationIds.ACTION_DEFEND
and manager.conflict_system.is_raiding()
),
"A strong idle villager should rally to defend during an active raid"
)
var worker: SimNPC = manager.npcs[1]
worker.strength = 1.0
worker.hunger = 20.0
worker.energy = 90.0
worker.is_starving = false
worker.mourning_ticks = 0
worker.inventory.clear()
worker.task_state = SimNPC.TASK_STATE_IDLE
worker.task_complete = true
manager.call("_select_action_if_idle", worker, SimNPC.TASK_STATE_IDLE)
_check(
worker.current_task != SimulationIds.ACTION_DEFEND,
"A weak non-guard villager should not be conscripted as a defender"
)
manager.free()
func _create_manager(seed_value: int = 991) -> Node:
var manager: Node = load("res://simulation/SimulationManager.gd").new()
manager.simulation_seed = seed_value
manager.debug_logs = false
var home_positions: Array[Vector3] = [
Vector3(0.0, 0.0, 0.0),
Vector3(2.0, 0.0, 0.0),
Vector3(10.0, 0.0, 0.0),
Vector3(12.0, 0.0, 0.0),
Vector3(30.0, 0.0, 30.0),
Vector3(40.0, 0.0, 40.0),
]
manager.home_positions = home_positions
root.add_child(manager)
manager.set_process(false)
return manager
func _finish() -> void:
if failures.is_empty():
print("[TEST] Combat patterns passed: enemy data, player downing, defend duty")
quit(0)
return
for failure in failures:
push_error("[TEST] " + failure)
quit(1)
func _check(condition: bool, message: String) -> void:
if not condition:
failures.append(message)
+1
View File
@@ -0,0 +1 @@
uid://c5x8usbsl6aku
+1
View File
@@ -17,6 +17,7 @@ func _run() -> void:
func _test_combat_and_wolf() -> void: func _test_combat_and_wolf() -> void:
var manager := _create_manager(981) var manager := _create_manager(981)
manager.update_player_combatant(Vector3(100.0, 0.0, 100.0))
var npc: SimNPC = manager.npcs[0] var npc: SimNPC = manager.npcs[0]
npc.position = Vector3.ZERO npc.position = Vector3.ZERO
var npc_combatant: CombatantStateRecord = manager.get_combatant( var npc_combatant: CombatantStateRecord = manager.get_combatant(
+1 -1
View File
@@ -35,7 +35,7 @@ func _test_registry_integrity() -> void:
"Action lookup should return '%s'" % definition.action_id "Action lookup should return '%s'" % definition.action_id
) )
_check( _check(
action_ids.size() == 12, "Registry should contain all twelve executable prototype actions" action_ids.size() == 13, "Registry should contain all thirteen executable prototype actions"
) )
var profession_ids := SimulationDefinitions.get_profession_ids() var profession_ids := SimulationDefinitions.get_profession_ids()
+6 -1
View File
@@ -48,7 +48,12 @@ func get_interaction_position() -> Vector3:
func supports_action(candidate_action_id: StringName) -> bool: func supports_action(candidate_action_id: StringName) -> bool:
return action_id == candidate_action_id if action_id == candidate_action_id:
return true
return (
action_id == SimulationIds.ACTION_PATROL
and candidate_action_id == SimulationIds.ACTION_DEFEND
)
func _update_presentation() -> void: func _update_presentation() -> void:
+49 -46
View File
@@ -1,48 +1,39 @@
class_name HostileCombatant class_name HostileCombatant
extends Node3D extends CreatureVisual
const FOLLOW_SPEED := 6.0
const ARRIVE_DISTANCE := 0.5
var combatant_id: StringName
var combatant: CombatantStateRecord var combatant: CombatantStateRecord
var simulation_manager: Node var simulation_manager: Node
var is_dead_visual := false var _flash_tween: Tween
var flash_tween: Tween var _spawn_tween: Tween
var spawn_scale := Vector3.ONE
func _ready() -> void: func _ready() -> void:
spawn_scale = Vector3.ONE add_to_group("grass_interactors")
scale = Vector3.ONE * 0.001 scale = Vector3.ONE * 0.001
var tween := create_tween() _spawn_tween = create_tween()
tween.set_trans(Tween.TRANS_BACK).set_ease(Tween.EASE_OUT) _spawn_tween.set_trans(Tween.TRANS_BACK).set_ease(Tween.EASE_OUT)
tween.tween_property(self, "scale", Vector3.ONE, 0.28) _spawn_tween.tween_property(self, "scale", Vector3.ONE, 0.28)
func bind(combatant_id_value: StringName, manager: Node) -> void: func bind(combatant_id_value: StringName, manager: Node) -> void:
combatant_id = combatant_id_value creature_id = combatant_id_value
simulation_manager = manager simulation_manager = manager
combatant = manager.get_combatant(combatant_id) as CombatantStateRecord combatant = manager.get_combatant(creature_id) as CombatantStateRecord
if combatant == null: if combatant == null:
return return
global_position = combatant.get_position() global_position = combatant.get_position()
_build_body(combatant.get_kind()) sim_position = combatant.get_position()
_build_from_definition()
combatant.changed.connect(_on_combatant_changed) combatant.changed.connect(_on_combatant_changed)
func _process(delta: float) -> void: func _process(_delta: float) -> void:
if is_dead_visual or combatant == null or not is_instance_valid(combatant): if is_dead_visual or combatant == null or not is_instance_valid(combatant):
return return
if not combatant.is_alive(): if not combatant.is_alive():
_play_death() play_death()
return return
var target_position := combatant.get_position() set_sim_position(combatant.get_position())
var to_target := target_position - global_position
if to_target.length() <= ARRIVE_DISTANCE:
return
global_position += to_target.normalized() * minf(FOLLOW_SPEED * delta, to_target.length())
rotation.y = lerp_angle(rotation.y, atan2(to_target.x, to_target.z), delta * 8.0)
func _on_combatant_changed(_state: CombatantStateRecord) -> void: func _on_combatant_changed(_state: CombatantStateRecord) -> void:
@@ -52,42 +43,54 @@ func _on_combatant_changed(_state: CombatantStateRecord) -> void:
func _flash_hit() -> void: func _flash_hit() -> void:
if flash_tween != null and flash_tween.is_valid(): if _flash_tween != null and _flash_tween.is_valid():
flash_tween.kill() _flash_tween.kill()
flash_tween = create_tween() _flash_tween = create_tween()
flash_tween.tween_property(self, "scale", Vector3.ONE * 0.85, 0.06) _flash_tween.tween_property(self, "scale", _definition_scale() * 0.85, 0.06)
flash_tween.tween_property(self, "scale", Vector3.ONE, 0.12) _flash_tween.tween_property(self, "scale", _definition_scale(), 0.12)
func _play_death() -> void: func play_death() -> void:
is_dead_visual = true is_dead_visual = true
stop_travel_visual() super.play_death()
var tween := create_tween()
tween.set_trans(Tween.TRANS_QUAD).set_ease(Tween.EASE_IN)
tween.tween_property(self, "scale", Vector3(0.001, 0.001, 0.001), 0.45)
tween.tween_callback(queue_free)
func stop_travel_visual() -> void: func _definition_scale() -> Vector3:
pass var definition := _enemy_definition()
return definition.visual_scale if definition != null else Vector3.ONE
func _build_body(kind: StringName) -> void: func _build_from_definition() -> void:
if kind == SimulationIds.COMBATANT_KIND_WOLF: var definition := _enemy_definition()
_build_wolf() if definition != null:
move_speed = definition.move_speed
scale = definition.visual_scale
if definition.kind == SimulationIds.COMBATANT_KIND_WOLF:
_build_wolf(definition)
else: else:
_build_raider() _build_raider(definition)
func _build_raider() -> void: func _enemy_definition() -> EnemyDefinition:
if combatant == null:
return null
var enemy_id: StringName = (
&"enemy_wolf"
if combatant.get_kind() == SimulationIds.COMBATANT_KIND_WOLF
else &"enemy_raider"
)
return SimulationEnemies.get_enemy(enemy_id)
func _build_raider(definition: EnemyDefinition) -> void:
var tunic := StandardMaterial3D.new() var tunic := StandardMaterial3D.new()
tunic.albedo_color = Color(0.4, 0.13, 0.1, 1.0) tunic.albedo_color = definition.body_color
tunic.roughness = 0.85 tunic.roughness = 0.85
var skin := StandardMaterial3D.new() var skin := StandardMaterial3D.new()
skin.albedo_color = Color(0.8, 0.58, 0.36, 1.0) skin.albedo_color = Color(0.8, 0.58, 0.36, 1.0)
skin.roughness = 0.9 skin.roughness = 0.9
var metal := StandardMaterial3D.new() var metal := StandardMaterial3D.new()
metal.albedo_color = Color(0.55, 0.55, 0.58, 1.0) metal.albedo_color = definition.accent_color
metal.roughness = 0.35 metal.roughness = 0.35
_add_mesh("Body", CylinderMesh.new(), Vector3(0.0, 1.1, 0.0), tunic) _add_mesh("Body", CylinderMesh.new(), Vector3(0.0, 1.1, 0.0), tunic)
@@ -104,12 +107,12 @@ func _build_raider() -> void:
) )
func _build_wolf() -> void: func _build_wolf(definition: EnemyDefinition) -> void:
var fur := StandardMaterial3D.new() var fur := StandardMaterial3D.new()
fur.albedo_color = Color(0.32, 0.32, 0.34, 1.0) fur.albedo_color = definition.body_color
fur.roughness = 0.95 fur.roughness = 0.95
var eye := StandardMaterial3D.new() var eye := StandardMaterial3D.new()
eye.albedo_color = Color(0.95, 0.5, 0.12, 1.0) eye.albedo_color = definition.accent_color
eye.roughness = 0.3 eye.roughness = 0.3
var body := SphereMesh.new() var body := SphereMesh.new()
+1 -1
View File
@@ -66,7 +66,7 @@ func _on_combatant_spawned(combatant_id: StringName) -> void:
func _on_combatant_died(combatant_id: StringName) -> void: func _on_combatant_died(combatant_id: StringName) -> void:
var scene := hostiles.get(combatant_id) as HostileCombatant var scene := hostiles.get(combatant_id) as HostileCombatant
if scene != null: if scene != null:
scene._play_death() scene.play_death()
hostiles.erase(combatant_id) hostiles.erase(combatant_id)
+127
View File
@@ -0,0 +1,127 @@
class_name CreatureVisual
extends Node3D
signal position_changed(creature_id: StringName, active_position: Vector3)
signal arrived_at_sim_position(creature_id: StringName)
const ARRIVAL_DISTANCE := 0.5
const PATH_RETARGET_DISTANCE := 0.8
@export_range(0.5, 12.0, 0.1) var move_speed := 3.5
@export_range(0.05, 1.0, 0.05) var waypoint_reached_distance := 0.3
@export_range(1.0, 20.0, 0.5) var rotation_speed := 8.0
var creature_id: StringName
var sim_position := Vector3.INF
var is_dead_visual := false
var current_path := PackedVector3Array()
var path_index := 0
var path_target := Vector3.INF
var path_request_id := 0
var path_pending := false
func _physics_process(delta: float) -> void:
if is_dead_visual:
return
if not sim_position.is_finite():
return
if _horizontal_distance_to(sim_position) <= ARRIVAL_DISTANCE:
_stop_moving()
return
_ensure_path()
if path_pending:
return
if current_path.is_empty() or path_index >= current_path.size():
_request_path()
return
var next_position := current_path[path_index]
var offset := next_position - global_position
if offset.length() <= waypoint_reached_distance:
path_index += 1
return
var direction := offset.normalized()
global_position = global_position.move_toward(next_position, move_speed * delta)
position_changed.emit(creature_id, global_position)
var target_angle := atan2(direction.x, direction.z)
rotation.y = lerp_angle(rotation.y, target_angle, rotation_speed * delta)
func set_sim_position(position: Vector3) -> void:
if not position.is_finite():
return
var moved := (
not path_target.is_finite()
or _horizontal_distance_to_position(sim_position, position) > PATH_RETARGET_DISTANCE
)
sim_position = position
if moved and not is_dead_visual:
_request_path()
func play_death() -> void:
is_dead_visual = true
_stop_moving()
var tween := create_tween()
tween.set_trans(Tween.TRANS_QUAD).set_ease(Tween.EASE_IN)
tween.tween_property(self, "scale", Vector3(0.001, 0.001, 0.001), 0.45)
tween.tween_callback(queue_free)
func build_visual() -> void:
pass
func _ensure_path() -> void:
if (
path_pending
or (
path_target.is_finite()
and (
_horizontal_distance_to_position(path_target, sim_position)
<= PATH_RETARGET_DISTANCE
)
)
):
return
_request_path()
func _request_path() -> void:
path_request_id += 1
var request_id := path_request_id
path_target = sim_position
current_path = PackedVector3Array()
path_index = 0
path_pending = true
await get_tree().physics_frame
if request_id != path_request_id or is_dead_visual:
return
if _horizontal_distance_to(sim_position) <= ARRIVAL_DISTANCE:
path_pending = false
return
current_path = NavigationServer3D.map_get_path(
get_world_3d().navigation_map, global_position, sim_position, true
)
path_pending = false
if current_path.is_empty():
_stop_moving()
func _stop_moving() -> void:
path_request_id += 1
current_path = PackedVector3Array()
path_index = 0
path_pending = false
path_target = Vector3.INF
func _horizontal_distance_to(target_position: Vector3) -> float:
return _horizontal_distance_to_position(global_position, target_position)
static func _horizontal_distance_to_position(from_position: Vector3, to_position: Vector3) -> float:
return Vector2(from_position.x, from_position.z).distance_to(
Vector2(to_position.x, to_position.z)
)
+1
View File
@@ -0,0 +1 @@
uid://6r66un51bane