From 661d53f31ab85e946e1ab6da2739bd864d49b67f Mon Sep 17 00:00:00 2001 From: Rijad Zuzo Date: Wed, 12 Aug 2026 00:16:48 +0200 Subject: [PATCH] feat: shared creature root, enemy definitions, defend duty, and player downing --- player/combat/player_combat_controller.gd | 15 ++ player/npc/NpcVisual.gd | 4 +- player/player.gd | 13 ++ simulation/SimulationManager.gd | 40 ++++- simulation/actions/ActionSelectionSystem.gd | 11 +- simulation/conflict/ConflictSystem.gd | 86 +++++++++-- simulation/definitions/EnemyDefinition.gd | 31 ++++ simulation/definitions/EnemyDefinition.gd.uid | 1 + .../definitions/SimulationDefinitions.gd | 3 +- simulation/definitions/SimulationEnemies.gd | 51 ++++++ .../definitions/SimulationEnemies.gd.uid | 1 + simulation/definitions/SimulationIds.gd | 1 + simulation/definitions/actions/defend.tres | 11 ++ simulation/state/player_state_record.gd | 78 +++++++++- tests/combat_patterns_test.gd | 145 ++++++++++++++++++ tests/combat_patterns_test.gd.uid | 1 + tests/conflict_war_system_test.gd | 1 + tests/simulation_definitions_test.gd | 2 +- world/activity/ActivitySite.gd | 7 +- world/combat/HostileCombatant.gd | 97 ++++++------ world/combat/combat_presentation.gd | 2 +- world/creatures/creature_visual.gd | 127 +++++++++++++++ world/creatures/creature_visual.gd.uid | 1 + 23 files changed, 653 insertions(+), 76 deletions(-) create mode 100644 simulation/definitions/EnemyDefinition.gd create mode 100644 simulation/definitions/EnemyDefinition.gd.uid create mode 100644 simulation/definitions/SimulationEnemies.gd create mode 100644 simulation/definitions/SimulationEnemies.gd.uid create mode 100644 simulation/definitions/actions/defend.tres create mode 100644 tests/combat_patterns_test.gd create mode 100644 tests/combat_patterns_test.gd.uid create mode 100644 world/creatures/creature_visual.gd create mode 100644 world/creatures/creature_visual.gd.uid diff --git a/player/combat/player_combat_controller.gd b/player/combat/player_combat_controller.gd index 83907d8..e733df3 100644 --- a/player/combat/player_combat_controller.gd +++ b/player/combat/player_combat_controller.gd @@ -44,8 +44,15 @@ func _resolve_references() -> void: func _physics_process(delta: float) -> void: if not _references_resolved: _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) 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: player.velocity = dash_velocity if dash_timer <= 0.0: @@ -58,6 +65,14 @@ func _physics_process(delta: float) -> void: _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: attack_timer = attack_cooldown_seconds _play_swing() diff --git a/player/npc/NpcVisual.gd b/player/npc/NpcVisual.gd index 7469605..ebb7ce2 100644 --- a/player/npc/NpcVisual.gd +++ b/player/npc/NpcVisual.gd @@ -492,7 +492,7 @@ func _create_task_glyph_mesh(action_id: StringName) -> PrimitiveMesh: var crate := BoxMesh.new() crate.size = Vector3(0.34, 0.22, 0.34) return crate - SimulationIds.ACTION_PATROL: + SimulationIds.ACTION_PATROL, SimulationIds.ACTION_DEFEND: var shield := CylinderMesh.new() shield.top_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) SimulationIds.ACTION_DEPOSIT_FOOD: 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) SimulationIds.ACTION_STUDY: return Color(0.62, 0.46, 0.9, 1.0) diff --git a/player/player.gd b/player/player.gd index 627b4bd..ed1e531 100644 --- a/player/player.gd +++ b/player/player.gd @@ -25,6 +25,11 @@ func _ready() -> 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 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) +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: if event.is_action_pressed("interact"): try_interact() diff --git a/simulation/SimulationManager.gd b/simulation/SimulationManager.gd index cf95045..a1c6a69 100644 --- a/simulation/SimulationManager.gd +++ b/simulation/SimulationManager.gd @@ -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: diff --git a/simulation/actions/ActionSelectionSystem.gd b/simulation/actions/ActionSelectionSystem.gd index 4b9f75a..5c5f550 100644 --- a/simulation/actions/ActionSelectionSystem.gd +++ b/simulation/actions/ActionSelectionSystem.gd @@ -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 diff --git a/simulation/conflict/ConflictSystem.gd b/simulation/conflict/ConflictSystem.gd index 6bc73ee..aeb35be 100644 --- a/simulation/conflict/ConflictSystem.gd +++ b/simulation/conflict/ConflictSystem.gd @@ -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: diff --git a/simulation/definitions/EnemyDefinition.gd b/simulation/definitions/EnemyDefinition.gd new file mode 100644 index 0000000..e39d7a5 --- /dev/null +++ b/simulation/definitions/EnemyDefinition.gd @@ -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 diff --git a/simulation/definitions/EnemyDefinition.gd.uid b/simulation/definitions/EnemyDefinition.gd.uid new file mode 100644 index 0000000..ab2bd64 --- /dev/null +++ b/simulation/definitions/EnemyDefinition.gd.uid @@ -0,0 +1 @@ +uid://5tfgb1oww0e0 diff --git a/simulation/definitions/SimulationDefinitions.gd b/simulation/definitions/SimulationDefinitions.gd index b4e0afe..28938fb 100644 --- a/simulation/definitions/SimulationDefinitions.gd +++ b/simulation/definitions/SimulationDefinitions.gd @@ -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 := [ diff --git a/simulation/definitions/SimulationEnemies.gd b/simulation/definitions/SimulationEnemies.gd new file mode 100644 index 0000000..e95175e --- /dev/null +++ b/simulation/definitions/SimulationEnemies.gd @@ -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 diff --git a/simulation/definitions/SimulationEnemies.gd.uid b/simulation/definitions/SimulationEnemies.gd.uid new file mode 100644 index 0000000..5713ad0 --- /dev/null +++ b/simulation/definitions/SimulationEnemies.gd.uid @@ -0,0 +1 @@ +uid://daurjkbl4hcd8 diff --git a/simulation/definitions/SimulationIds.gd b/simulation/definitions/SimulationIds.gd index fe3d821..ceda997 100644 --- a/simulation/definitions/SimulationIds.gd +++ b/simulation/definitions/SimulationIds.gd @@ -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" diff --git a/simulation/definitions/actions/defend.tres b/simulation/definitions/actions/defend.tres new file mode 100644 index 0000000..d378060 --- /dev/null +++ b/simulation/definitions/actions/defend.tres @@ -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" diff --git a/simulation/state/player_state_record.gd b/simulation/state/player_state_record.gd index e7d7dfb..58510fe 100644 --- a/simulation/state/player_state_record.gd +++ b/simulation/state/player_state_record.gd @@ -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)) diff --git a/tests/combat_patterns_test.gd b/tests/combat_patterns_test.gd new file mode 100644 index 0000000..b0d55ce --- /dev/null +++ b/tests/combat_patterns_test.gd @@ -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) diff --git a/tests/combat_patterns_test.gd.uid b/tests/combat_patterns_test.gd.uid new file mode 100644 index 0000000..61856b1 --- /dev/null +++ b/tests/combat_patterns_test.gd.uid @@ -0,0 +1 @@ +uid://c5x8usbsl6aku diff --git a/tests/conflict_war_system_test.gd b/tests/conflict_war_system_test.gd index d33bd10..2ad7983 100644 --- a/tests/conflict_war_system_test.gd +++ b/tests/conflict_war_system_test.gd @@ -17,6 +17,7 @@ func _run() -> void: func _test_combat_and_wolf() -> void: var manager := _create_manager(981) + manager.update_player_combatant(Vector3(100.0, 0.0, 100.0)) var npc: SimNPC = manager.npcs[0] npc.position = Vector3.ZERO var npc_combatant: CombatantStateRecord = manager.get_combatant( diff --git a/tests/simulation_definitions_test.gd b/tests/simulation_definitions_test.gd index 8b098b0..652ae7d 100644 --- a/tests/simulation_definitions_test.gd +++ b/tests/simulation_definitions_test.gd @@ -35,7 +35,7 @@ func _test_registry_integrity() -> void: "Action lookup should return '%s'" % definition.action_id ) _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() diff --git a/world/activity/ActivitySite.gd b/world/activity/ActivitySite.gd index 42127b1..41263df 100644 --- a/world/activity/ActivitySite.gd +++ b/world/activity/ActivitySite.gd @@ -48,7 +48,12 @@ func get_interaction_position() -> Vector3: 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: diff --git a/world/combat/HostileCombatant.gd b/world/combat/HostileCombatant.gd index d8838cd..634db5a 100644 --- a/world/combat/HostileCombatant.gd +++ b/world/combat/HostileCombatant.gd @@ -1,48 +1,39 @@ 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 simulation_manager: Node -var is_dead_visual := false -var flash_tween: Tween -var spawn_scale := Vector3.ONE +var _flash_tween: Tween +var _spawn_tween: Tween func _ready() -> void: - spawn_scale = Vector3.ONE + add_to_group("grass_interactors") scale = Vector3.ONE * 0.001 - var tween := create_tween() - tween.set_trans(Tween.TRANS_BACK).set_ease(Tween.EASE_OUT) - tween.tween_property(self, "scale", Vector3.ONE, 0.28) + _spawn_tween = create_tween() + _spawn_tween.set_trans(Tween.TRANS_BACK).set_ease(Tween.EASE_OUT) + _spawn_tween.tween_property(self, "scale", Vector3.ONE, 0.28) func bind(combatant_id_value: StringName, manager: Node) -> void: - combatant_id = combatant_id_value + creature_id = combatant_id_value simulation_manager = manager - combatant = manager.get_combatant(combatant_id) as CombatantStateRecord + combatant = manager.get_combatant(creature_id) as CombatantStateRecord if combatant == null: return global_position = combatant.get_position() - _build_body(combatant.get_kind()) + sim_position = combatant.get_position() + _build_from_definition() 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): return if not combatant.is_alive(): - _play_death() + play_death() return - var target_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) + set_sim_position(combatant.get_position()) func _on_combatant_changed(_state: CombatantStateRecord) -> void: @@ -52,42 +43,54 @@ func _on_combatant_changed(_state: CombatantStateRecord) -> void: func _flash_hit() -> void: - if flash_tween != null and flash_tween.is_valid(): - flash_tween.kill() - flash_tween = create_tween() - flash_tween.tween_property(self, "scale", Vector3.ONE * 0.85, 0.06) - flash_tween.tween_property(self, "scale", Vector3.ONE, 0.12) + if _flash_tween != null and _flash_tween.is_valid(): + _flash_tween.kill() + _flash_tween = create_tween() + _flash_tween.tween_property(self, "scale", _definition_scale() * 0.85, 0.06) + _flash_tween.tween_property(self, "scale", _definition_scale(), 0.12) -func _play_death() -> void: +func play_death() -> void: is_dead_visual = true - stop_travel_visual() - 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) + super.play_death() -func stop_travel_visual() -> void: - pass +func _definition_scale() -> Vector3: + var definition := _enemy_definition() + return definition.visual_scale if definition != null else Vector3.ONE -func _build_body(kind: StringName) -> void: - if kind == SimulationIds.COMBATANT_KIND_WOLF: - _build_wolf() - else: - _build_raider() +func _build_from_definition() -> void: + var definition := _enemy_definition() + if definition != null: + move_speed = definition.move_speed + scale = definition.visual_scale + if definition.kind == SimulationIds.COMBATANT_KIND_WOLF: + _build_wolf(definition) + else: + _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() - tunic.albedo_color = Color(0.4, 0.13, 0.1, 1.0) + tunic.albedo_color = definition.body_color tunic.roughness = 0.85 var skin := StandardMaterial3D.new() skin.albedo_color = Color(0.8, 0.58, 0.36, 1.0) skin.roughness = 0.9 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 _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() - fur.albedo_color = Color(0.32, 0.32, 0.34, 1.0) + fur.albedo_color = definition.body_color fur.roughness = 0.95 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 var body := SphereMesh.new() diff --git a/world/combat/combat_presentation.gd b/world/combat/combat_presentation.gd index 211acc1..4c1164a 100644 --- a/world/combat/combat_presentation.gd +++ b/world/combat/combat_presentation.gd @@ -66,7 +66,7 @@ func _on_combatant_spawned(combatant_id: StringName) -> void: func _on_combatant_died(combatant_id: StringName) -> void: var scene := hostiles.get(combatant_id) as HostileCombatant if scene != null: - scene._play_death() + scene.play_death() hostiles.erase(combatant_id) diff --git a/world/creatures/creature_visual.gd b/world/creatures/creature_visual.gd new file mode 100644 index 0000000..cf2e238 --- /dev/null +++ b/world/creatures/creature_visual.gd @@ -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) + ) diff --git a/world/creatures/creature_visual.gd.uid b/world/creatures/creature_visual.gd.uid new file mode 100644 index 0000000..73d089b --- /dev/null +++ b/world/creatures/creature_visual.gd.uid @@ -0,0 +1 @@ +uid://6r66un51bane