feat: spawn enemies from reusable definitions
This commit is contained in:
@@ -0,0 +1,81 @@
|
|||||||
|
class_name CombatantFactory
|
||||||
|
extends RefCounted
|
||||||
|
|
||||||
|
var _occupied_ids: Dictionary = {}
|
||||||
|
var _next_suffix_by_prefix: Dictionary = {}
|
||||||
|
|
||||||
|
|
||||||
|
func reset_allocator() -> void:
|
||||||
|
_occupied_ids.clear()
|
||||||
|
_next_suffix_by_prefix.clear()
|
||||||
|
|
||||||
|
|
||||||
|
func restore_allocator(records: Array[CombatantStateRecord]) -> void:
|
||||||
|
reset_allocator()
|
||||||
|
for record in records:
|
||||||
|
if record != null:
|
||||||
|
reserve_id(record.get_combatant_id())
|
||||||
|
|
||||||
|
|
||||||
|
func reserve_id(combatant_id: StringName) -> bool:
|
||||||
|
if combatant_id.is_empty() or _occupied_ids.has(combatant_id):
|
||||||
|
return false
|
||||||
|
_occupied_ids[combatant_id] = true
|
||||||
|
_observe_numeric_suffix(combatant_id)
|
||||||
|
return true
|
||||||
|
|
||||||
|
|
||||||
|
func create_enemy(enemy_definition_id: StringName, position: Vector3) -> CombatantStateRecord:
|
||||||
|
return create_from_definition(SimulationEnemies.get_enemy(enemy_definition_id), position)
|
||||||
|
|
||||||
|
|
||||||
|
func create_from_definition(definition: EnemyDefinition, position: Vector3) -> CombatantStateRecord:
|
||||||
|
if definition == null or not definition.validate().is_empty() or not position.is_finite():
|
||||||
|
return null
|
||||||
|
var combatant_id := _allocate_id(definition.combatant_id_prefix)
|
||||||
|
return CombatantStateRecord.create(
|
||||||
|
combatant_id,
|
||||||
|
definition.kind,
|
||||||
|
definition.faction_id,
|
||||||
|
definition.display_name,
|
||||||
|
position,
|
||||||
|
definition.health,
|
||||||
|
definition.weapon_id,
|
||||||
|
CombatantStateRecord.NO_NPC_ID,
|
||||||
|
definition.hostile,
|
||||||
|
definition.enemy_id
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
func get_next_suffix(prefix: String) -> int:
|
||||||
|
return int(_next_suffix_by_prefix.get(prefix, 0))
|
||||||
|
|
||||||
|
|
||||||
|
func is_reserved(combatant_id: StringName) -> bool:
|
||||||
|
return _occupied_ids.has(combatant_id)
|
||||||
|
|
||||||
|
|
||||||
|
func _allocate_id(prefix: String) -> StringName:
|
||||||
|
var suffix := get_next_suffix(prefix)
|
||||||
|
var candidate := StringName("%s_%d" % [prefix, suffix])
|
||||||
|
while _occupied_ids.has(candidate):
|
||||||
|
suffix += 1
|
||||||
|
candidate = StringName("%s_%d" % [prefix, suffix])
|
||||||
|
_occupied_ids[candidate] = true
|
||||||
|
_next_suffix_by_prefix[prefix] = suffix + 1
|
||||||
|
return candidate
|
||||||
|
|
||||||
|
|
||||||
|
func _observe_numeric_suffix(combatant_id: StringName) -> void:
|
||||||
|
var text_id := String(combatant_id)
|
||||||
|
var separator_index := text_id.rfind("_")
|
||||||
|
if separator_index <= 0 or separator_index >= text_id.length() - 1:
|
||||||
|
return
|
||||||
|
var suffix_text := text_id.substr(separator_index + 1)
|
||||||
|
if not suffix_text.is_valid_int():
|
||||||
|
return
|
||||||
|
var suffix := int(suffix_text)
|
||||||
|
if suffix < 0:
|
||||||
|
return
|
||||||
|
var prefix := text_id.substr(0, separator_index)
|
||||||
|
_next_suffix_by_prefix[prefix] = maxi(get_next_suffix(prefix), suffix + 1)
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
uid://dfxudn0m53uai
|
||||||
@@ -34,8 +34,7 @@ var factions: Dictionary = {}
|
|||||||
var economy: RefCounted
|
var economy: RefCounted
|
||||||
var npcs: Array[SimNPC] = []
|
var npcs: Array[SimNPC] = []
|
||||||
var _npcs_by_id: Dictionary = {}
|
var _npcs_by_id: Dictionary = {}
|
||||||
var next_combatant_id := 0
|
var combatant_factory := CombatantFactory.new()
|
||||||
var next_wolf_id := 0
|
|
||||||
var current_tick := 0
|
var current_tick := 0
|
||||||
var village_center := Vector3.ZERO
|
var village_center := Vector3.ZERO
|
||||||
var last_war_resolved_tick := -1
|
var last_war_resolved_tick := -1
|
||||||
@@ -222,24 +221,18 @@ 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")
|
return spawn_enemy(&"enemy_wolf", position)
|
||||||
var wolf_id := StringName("wolf_%d" % next_wolf_id)
|
|
||||||
next_wolf_id += 1
|
|
||||||
var wolf := CombatantStateRecord.create(
|
func spawn_enemy(enemy_definition_id: StringName, position: Vector3) -> StringName:
|
||||||
wolf_id,
|
var enemy := combatant_factory.create_enemy(enemy_definition_id, position)
|
||||||
SimulationIds.COMBATANT_KIND_WOLF,
|
if enemy == null:
|
||||||
SimulationIds.FACTION_TRIBE,
|
return &""
|
||||||
definition.display_name if definition != null else "Wolf",
|
var combatant_id := enemy.get_combatant_id()
|
||||||
position,
|
combatants[combatant_id] = enemy
|
||||||
definition.health if definition != null else 40.0,
|
|
||||||
definition.weapon_id if definition != null else SimulationIds.ITEM_CLAW,
|
|
||||||
CombatantStateRecord.NO_NPC_ID,
|
|
||||||
true
|
|
||||||
)
|
|
||||||
combatants[wolf_id] = wolf
|
|
||||||
_combatant_ids_dirty = true
|
_combatant_ids_dirty = true
|
||||||
combatant_spawned.emit(wolf_id)
|
combatant_spawned.emit(combatant_id)
|
||||||
return wolf_id
|
return combatant_id
|
||||||
|
|
||||||
|
|
||||||
func _apply_damage(target: CombatantStateRecord, damage: float, actor_id: int) -> float:
|
func _apply_damage(target: CombatantStateRecord, damage: float, actor_id: int) -> float:
|
||||||
@@ -291,7 +284,11 @@ func _advance_wolves() -> void:
|
|||||||
var combatant := combatants[combatant_id] as CombatantStateRecord
|
var combatant := combatants[combatant_id] as CombatantStateRecord
|
||||||
if combatant == null or not combatant.is_alive():
|
if combatant == null or not combatant.is_alive():
|
||||||
continue
|
continue
|
||||||
if combatant.get_kind() != SimulationIds.COMBATANT_KIND_WOLF:
|
var definition := _enemy_definition_for(combatant)
|
||||||
|
if (
|
||||||
|
definition == null
|
||||||
|
or definition.behavior_profile_id != EnemyDefinition.BEHAVIOR_PROFILE_WOLF_HUNT
|
||||||
|
):
|
||||||
continue
|
continue
|
||||||
var target := _find_nearest_defender(combatant.get_position(), WOLF_HUNT_RADIUS)
|
var target := _find_nearest_defender(combatant.get_position(), WOLF_HUNT_RADIUS)
|
||||||
if target == null:
|
if target == null:
|
||||||
@@ -302,8 +299,7 @@ func _advance_wolves() -> void:
|
|||||||
var to_target := target.get_position() - combatant.get_position()
|
var to_target := target.get_position() - combatant.get_position()
|
||||||
to_target.y = 0.0
|
to_target.y = 0.0
|
||||||
if to_target.length() > reach:
|
if to_target.length() > reach:
|
||||||
var definition := SimulationEnemies.get_enemy(&"enemy_wolf")
|
var speed := definition.move_speed
|
||||||
var speed := definition.move_speed if definition != null else 3.2
|
|
||||||
var step_distance := minf(speed * tick_interval, to_target.length() - reach)
|
var step_distance := minf(speed * tick_interval, to_target.length() - reach)
|
||||||
combatant.set_position(
|
combatant.set_position(
|
||||||
combatant.get_position() + to_target.normalized() * maxf(step_distance, 0.0)
|
combatant.get_position() + to_target.normalized() * maxf(step_distance, 0.0)
|
||||||
@@ -422,25 +418,17 @@ 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] = []
|
||||||
for index in range(tribe.get_warriors()):
|
for index in range(tribe.get_warriors()):
|
||||||
var raider_id := StringName("raider_%d" % next_combatant_id)
|
|
||||||
next_combatant_id += 1
|
|
||||||
var offset := Vector3(float(index * 2 - tribe.get_warriors()), 0.0, 0.0)
|
var offset := Vector3(float(index * 2 - tribe.get_warriors()), 0.0, 0.0)
|
||||||
var raider := CombatantStateRecord.create(
|
var raider := combatant_factory.create_enemy(
|
||||||
raider_id,
|
&"enemy_raider", village_center + RAID_SPAWN_OFFSET + offset
|
||||||
SimulationIds.COMBATANT_KIND_RAIDER,
|
|
||||||
SimulationIds.FACTION_TRIBE,
|
|
||||||
definition.display_name if definition != null else "Raider",
|
|
||||||
village_center + RAID_SPAWN_OFFSET + offset,
|
|
||||||
definition.health if definition != null else 60.0,
|
|
||||||
definition.weapon_id if definition != null else SimulationIds.ITEM_SWORD,
|
|
||||||
CombatantStateRecord.NO_NPC_ID,
|
|
||||||
true
|
|
||||||
)
|
)
|
||||||
|
if raider == null:
|
||||||
|
continue
|
||||||
|
var raider_id := raider.get_combatant_id()
|
||||||
combatants[raider_id] = raider
|
combatants[raider_id] = raider
|
||||||
_combatant_ids_dirty = true
|
_combatant_ids_dirty = true
|
||||||
raider_ids.append(raider_id)
|
raider_ids.append(raider_id)
|
||||||
@@ -695,6 +683,12 @@ func _get_faction(faction_id: StringName) -> FactionStateRecord:
|
|||||||
return factions.get(faction_id) as FactionStateRecord
|
return factions.get(faction_id) as FactionStateRecord
|
||||||
|
|
||||||
|
|
||||||
|
func _enemy_definition_for(combatant: CombatantStateRecord) -> EnemyDefinition:
|
||||||
|
if combatant == null or combatant.get_enemy_definition_id().is_empty():
|
||||||
|
return null
|
||||||
|
return SimulationEnemies.get_enemy(combatant.get_enemy_definition_id())
|
||||||
|
|
||||||
|
|
||||||
func _narrative_event_requested(
|
func _narrative_event_requested(
|
||||||
event_type: StringName,
|
event_type: StringName,
|
||||||
actor_id: int,
|
actor_id: int,
|
||||||
@@ -738,6 +732,7 @@ func restore_state_records(
|
|||||||
combatants.clear()
|
combatants.clear()
|
||||||
for combatant in combatant_records:
|
for combatant in combatant_records:
|
||||||
combatants[combatant.get_combatant_id()] = combatant
|
combatants[combatant.get_combatant_id()] = combatant
|
||||||
|
combatant_factory.restore_allocator(combatant_records)
|
||||||
_combatant_ids_dirty = true
|
_combatant_ids_dirty = true
|
||||||
factions.clear()
|
factions.clear()
|
||||||
for faction in faction_records:
|
for faction in faction_records:
|
||||||
|
|||||||
@@ -1,9 +1,20 @@
|
|||||||
class_name EnemyDefinition
|
class_name EnemyDefinition
|
||||||
extends Resource
|
extends Resource
|
||||||
|
|
||||||
|
const BEHAVIOR_PROFILE_RAID_ASSAULT := &"raid_assault"
|
||||||
|
const BEHAVIOR_PROFILE_WOLF_HUNT := &"wolf_hunt"
|
||||||
|
const VALID_BEHAVIOR_PROFILE_IDS := [
|
||||||
|
BEHAVIOR_PROFILE_RAID_ASSAULT,
|
||||||
|
BEHAVIOR_PROFILE_WOLF_HUNT,
|
||||||
|
]
|
||||||
|
|
||||||
@export var enemy_id: StringName
|
@export var enemy_id: StringName
|
||||||
@export var display_name: String
|
@export var display_name: String
|
||||||
@export var kind: StringName = SimulationIds.COMBATANT_KIND_RAIDER
|
@export var kind: StringName = SimulationIds.COMBATANT_KIND_RAIDER
|
||||||
|
@export var behavior_profile_id: StringName = BEHAVIOR_PROFILE_RAID_ASSAULT
|
||||||
|
@export var combatant_id_prefix := "enemy"
|
||||||
|
@export var faction_id: StringName = SimulationIds.FACTION_TRIBE
|
||||||
|
@export var hostile := true
|
||||||
@export var weapon_id: StringName = SimulationIds.ITEM_SWORD
|
@export var weapon_id: StringName = SimulationIds.ITEM_SWORD
|
||||||
@export var health := 60.0
|
@export var health := 60.0
|
||||||
@export var move_speed := 3.5
|
@export var move_speed := 3.5
|
||||||
@@ -18,6 +29,18 @@ func validate() -> Array[String]:
|
|||||||
errors.append("enemy_id is empty")
|
errors.append("enemy_id is empty")
|
||||||
if display_name.is_empty():
|
if display_name.is_empty():
|
||||||
errors.append("display_name is empty for '%s'" % enemy_id)
|
errors.append("display_name is empty for '%s'" % enemy_id)
|
||||||
|
if behavior_profile_id not in VALID_BEHAVIOR_PROFILE_IDS:
|
||||||
|
errors.append(
|
||||||
|
"behavior_profile_id '%s' is invalid for '%s'" % [behavior_profile_id, enemy_id]
|
||||||
|
)
|
||||||
|
if combatant_id_prefix.is_empty():
|
||||||
|
errors.append("combatant_id_prefix is empty for '%s'" % enemy_id)
|
||||||
|
elif not combatant_id_prefix.is_valid_identifier():
|
||||||
|
errors.append(
|
||||||
|
"combatant_id_prefix '%s' is invalid for '%s'" % [combatant_id_prefix, enemy_id]
|
||||||
|
)
|
||||||
|
if faction_id.is_empty():
|
||||||
|
errors.append("faction_id is empty for '%s'" % enemy_id)
|
||||||
if (
|
if (
|
||||||
kind
|
kind
|
||||||
not in [
|
not in [
|
||||||
|
|||||||
@@ -0,0 +1,19 @@
|
|||||||
|
[gd_resource type="Resource" script_class="EnemyDefinition" load_steps=2 format=3]
|
||||||
|
|
||||||
|
[ext_resource type="Script" path="res://simulation/definitions/EnemyDefinition.gd" id="1"]
|
||||||
|
|
||||||
|
[resource]
|
||||||
|
script = ExtResource("1")
|
||||||
|
enemy_id = &"enemy_boar"
|
||||||
|
display_name = "Wild Boar"
|
||||||
|
kind = &"wolf"
|
||||||
|
behavior_profile_id = &"wolf_hunt"
|
||||||
|
combatant_id_prefix = "boar"
|
||||||
|
faction_id = &"faction_tribe"
|
||||||
|
hostile = true
|
||||||
|
weapon_id = &"item_claw"
|
||||||
|
health = 55.0
|
||||||
|
move_speed = 3.6
|
||||||
|
body_color = Color(0.28, 0.16, 0.08, 1)
|
||||||
|
accent_color = Color(0.78, 0.68, 0.5, 1)
|
||||||
|
visual_scale = Vector3(1.05, 0.9, 1.15)
|
||||||
@@ -7,6 +7,10 @@ script = ExtResource("1")
|
|||||||
enemy_id = &"enemy_raider"
|
enemy_id = &"enemy_raider"
|
||||||
display_name = "Raider"
|
display_name = "Raider"
|
||||||
kind = &"raider"
|
kind = &"raider"
|
||||||
|
behavior_profile_id = &"raid_assault"
|
||||||
|
combatant_id_prefix = "raider"
|
||||||
|
faction_id = &"faction_tribe"
|
||||||
|
hostile = true
|
||||||
weapon_id = &"item_sword"
|
weapon_id = &"item_sword"
|
||||||
health = 60.0
|
health = 60.0
|
||||||
move_speed = 3.5
|
move_speed = 3.5
|
||||||
|
|||||||
@@ -7,6 +7,10 @@ script = ExtResource("1")
|
|||||||
enemy_id = &"enemy_wolf"
|
enemy_id = &"enemy_wolf"
|
||||||
display_name = "Wolf"
|
display_name = "Wolf"
|
||||||
kind = &"wolf"
|
kind = &"wolf"
|
||||||
|
behavior_profile_id = &"wolf_hunt"
|
||||||
|
combatant_id_prefix = "wolf"
|
||||||
|
faction_id = &"faction_tribe"
|
||||||
|
hostile = true
|
||||||
weapon_id = &"item_claw"
|
weapon_id = &"item_claw"
|
||||||
health = 40.0
|
health = 40.0
|
||||||
move_speed = 4.2
|
move_speed = 4.2
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
[gd_resource type="Resource" script_class="SimulationContentPack" load_steps=27 format=3]
|
[gd_resource type="Resource" script_class="SimulationContentPack" load_steps=28 format=3]
|
||||||
|
|
||||||
[ext_resource type="Script" path="res://simulation/definitions/SimulationContentPack.gd" id="1_pack"]
|
[ext_resource type="Script" path="res://simulation/definitions/SimulationContentPack.gd" id="1_pack"]
|
||||||
[ext_resource type="Resource" path="res://simulation/definitions/actions/defend.tres" id="2_defend"]
|
[ext_resource type="Resource" path="res://simulation/definitions/actions/defend.tres" id="2_defend"]
|
||||||
@@ -25,6 +25,7 @@
|
|||||||
[ext_resource type="Resource" path="res://simulation/definitions/items/wood.tres" id="23_wood"]
|
[ext_resource type="Resource" path="res://simulation/definitions/items/wood.tres" id="23_wood"]
|
||||||
[ext_resource type="Resource" path="res://simulation/definitions/enemies/raider.tres" id="24_raider"]
|
[ext_resource type="Resource" path="res://simulation/definitions/enemies/raider.tres" id="24_raider"]
|
||||||
[ext_resource type="Resource" path="res://simulation/definitions/enemies/wolf.tres" id="25_wolf"]
|
[ext_resource type="Resource" path="res://simulation/definitions/enemies/wolf.tres" id="25_wolf"]
|
||||||
|
[ext_resource type="Resource" path="res://simulation/definitions/enemies/boar.tres" id="26_boar"]
|
||||||
|
|
||||||
[resource]
|
[resource]
|
||||||
script = ExtResource("1_pack")
|
script = ExtResource("1_pack")
|
||||||
@@ -33,4 +34,4 @@ display_name = "The Steward Core"
|
|||||||
actions = [ExtResource("2_defend"), ExtResource("3_deposit_food"), ExtResource("4_deposit_wood"), ExtResource("5_eat"), ExtResource("6_feed_animal"), ExtResource("7_gather_food"), ExtResource("8_gather_wood"), ExtResource("9_patrol"), ExtResource("10_rest"), ExtResource("11_sleep"), ExtResource("12_study"), ExtResource("13_wander"), ExtResource("14_withdraw_food")]
|
actions = [ExtResource("2_defend"), ExtResource("3_deposit_food"), ExtResource("4_deposit_wood"), ExtResource("5_eat"), ExtResource("6_feed_animal"), ExtResource("7_gather_food"), ExtResource("8_gather_wood"), ExtResource("9_patrol"), ExtResource("10_rest"), ExtResource("11_sleep"), ExtResource("12_study"), ExtResource("13_wander"), ExtResource("14_withdraw_food")]
|
||||||
professions = [ExtResource("15_farmer"), ExtResource("16_guard"), ExtResource("17_scholar"), ExtResource("18_wanderer"), ExtResource("19_woodcutter")]
|
professions = [ExtResource("15_farmer"), ExtResource("16_guard"), ExtResource("17_scholar"), ExtResource("18_wanderer"), ExtResource("19_woodcutter")]
|
||||||
items = [ExtResource("20_claw"), ExtResource("21_food"), ExtResource("22_sword"), ExtResource("23_wood")]
|
items = [ExtResource("20_claw"), ExtResource("21_food"), ExtResource("22_sword"), ExtResource("23_wood")]
|
||||||
enemies = [ExtResource("24_raider"), ExtResource("25_wolf")]
|
enemies = [ExtResource("24_raider"), ExtResource("25_wolf"), ExtResource("26_boar")]
|
||||||
|
|||||||
@@ -3,8 +3,12 @@ extends RefCounted
|
|||||||
|
|
||||||
signal changed(state: CombatantStateRecord)
|
signal changed(state: CombatantStateRecord)
|
||||||
|
|
||||||
const SCHEMA_VERSION := 1
|
const SCHEMA_VERSION := 2
|
||||||
|
const LEGACY_SCHEMA_VERSION := 1
|
||||||
const NO_NPC_ID := -1
|
const NO_NPC_ID := -1
|
||||||
|
const NO_ENEMY_DEFINITION_ID := &""
|
||||||
|
const LEGACY_RAIDER_DEFINITION_ID := &"enemy_raider"
|
||||||
|
const LEGACY_WOLF_DEFINITION_ID := &"enemy_wolf"
|
||||||
|
|
||||||
var data: Dictionary
|
var data: Dictionary
|
||||||
|
|
||||||
@@ -26,9 +30,13 @@ static func create(
|
|||||||
max_health: float,
|
max_health: float,
|
||||||
weapon_id: StringName,
|
weapon_id: StringName,
|
||||||
npc_id: int = NO_NPC_ID,
|
npc_id: int = NO_NPC_ID,
|
||||||
hostile: bool = false
|
hostile: bool = false,
|
||||||
|
enemy_definition_id: StringName = NO_ENEMY_DEFINITION_ID
|
||||||
) -> CombatantStateRecord:
|
) -> CombatantStateRecord:
|
||||||
var snapped_position := Vector3(_snap(position.x), _snap(position.y), _snap(position.z))
|
var snapped_position := Vector3(_snap(position.x), _snap(position.y), _snap(position.z))
|
||||||
|
var canonical_enemy_definition_id := enemy_definition_id
|
||||||
|
if canonical_enemy_definition_id.is_empty():
|
||||||
|
canonical_enemy_definition_id = _legacy_definition_id_for_kind(kind)
|
||||||
return (
|
return (
|
||||||
CombatantStateRecord
|
CombatantStateRecord
|
||||||
. new(
|
. new(
|
||||||
@@ -39,6 +47,7 @@ static func create(
|
|||||||
"faction_id": String(faction_id),
|
"faction_id": String(faction_id),
|
||||||
"display_name": display_name,
|
"display_name": display_name,
|
||||||
"npc_id": npc_id,
|
"npc_id": npc_id,
|
||||||
|
"enemy_definition_id": String(canonical_enemy_definition_id),
|
||||||
"position": [snapped_position.x, snapped_position.y, snapped_position.z],
|
"position": [snapped_position.x, snapped_position.y, snapped_position.z],
|
||||||
"health": max_health,
|
"health": max_health,
|
||||||
"max_health": max_health,
|
"max_health": max_health,
|
||||||
@@ -52,10 +61,17 @@ static func create(
|
|||||||
|
|
||||||
|
|
||||||
static func from_dictionary(record_data: Dictionary) -> CombatantStateRecord:
|
static func from_dictionary(record_data: Dictionary) -> CombatantStateRecord:
|
||||||
if int(record_data.get("schema_version", -1)) != SCHEMA_VERSION:
|
var version := int(record_data.get("schema_version", -1))
|
||||||
|
var normalized := record_data.duplicate(true)
|
||||||
|
if version == LEGACY_SCHEMA_VERSION:
|
||||||
|
normalized["schema_version"] = SCHEMA_VERSION
|
||||||
|
normalized["enemy_definition_id"] = String(
|
||||||
|
_legacy_definition_id_for_kind(StringName(normalized.get("kind", &"")))
|
||||||
|
)
|
||||||
|
elif version != SCHEMA_VERSION:
|
||||||
return null
|
return null
|
||||||
if not (
|
if not (
|
||||||
record_data
|
normalized
|
||||||
. has_all(
|
. has_all(
|
||||||
[
|
[
|
||||||
"combatant_id",
|
"combatant_id",
|
||||||
@@ -63,6 +79,7 @@ static func from_dictionary(record_data: Dictionary) -> CombatantStateRecord:
|
|||||||
"faction_id",
|
"faction_id",
|
||||||
"display_name",
|
"display_name",
|
||||||
"npc_id",
|
"npc_id",
|
||||||
|
"enemy_definition_id",
|
||||||
"position",
|
"position",
|
||||||
"health",
|
"health",
|
||||||
"max_health",
|
"max_health",
|
||||||
@@ -74,12 +91,12 @@ static func from_dictionary(record_data: Dictionary) -> CombatantStateRecord:
|
|||||||
)
|
)
|
||||||
):
|
):
|
||||||
return null
|
return null
|
||||||
var combatant_id := String(record_data["combatant_id"])
|
var combatant_id := String(normalized["combatant_id"])
|
||||||
var display_name := String(record_data["display_name"])
|
var display_name := String(normalized["display_name"])
|
||||||
if combatant_id.is_empty() or display_name.is_empty():
|
if combatant_id.is_empty() or display_name.is_empty():
|
||||||
return null
|
return null
|
||||||
if (
|
if (
|
||||||
StringName(record_data["kind"])
|
StringName(normalized["kind"])
|
||||||
not in [
|
not in [
|
||||||
SimulationIds.COMBATANT_KIND_NPC,
|
SimulationIds.COMBATANT_KIND_NPC,
|
||||||
SimulationIds.COMBATANT_KIND_RAIDER,
|
SimulationIds.COMBATANT_KIND_RAIDER,
|
||||||
@@ -88,15 +105,20 @@ static func from_dictionary(record_data: Dictionary) -> CombatantStateRecord:
|
|||||||
]
|
]
|
||||||
):
|
):
|
||||||
return null
|
return null
|
||||||
var saved_position = record_data["position"]
|
var kind := StringName(normalized["kind"])
|
||||||
|
var enemy_definition_id := StringName(normalized["enemy_definition_id"])
|
||||||
|
var is_enemy := kind in [SimulationIds.COMBATANT_KIND_RAIDER, SimulationIds.COMBATANT_KIND_WOLF]
|
||||||
|
if is_enemy == enemy_definition_id.is_empty():
|
||||||
|
return null
|
||||||
|
var saved_position = normalized["position"]
|
||||||
if not saved_position is Array or saved_position.size() != 3:
|
if not saved_position is Array or saved_position.size() != 3:
|
||||||
return null
|
return null
|
||||||
for component in saved_position:
|
for component in saved_position:
|
||||||
if not is_finite(float(component)):
|
if not is_finite(float(component)):
|
||||||
return null
|
return null
|
||||||
var health := float(record_data["health"])
|
var health := float(normalized["health"])
|
||||||
var max_health := float(record_data["max_health"])
|
var max_health := float(normalized["max_health"])
|
||||||
var npc_id := int(record_data["npc_id"])
|
var npc_id := int(normalized["npc_id"])
|
||||||
if (
|
if (
|
||||||
not is_finite(health)
|
not is_finite(health)
|
||||||
or not is_finite(max_health)
|
or not is_finite(max_health)
|
||||||
@@ -104,16 +126,16 @@ static func from_dictionary(record_data: Dictionary) -> CombatantStateRecord:
|
|||||||
or max_health <= 0.0
|
or max_health <= 0.0
|
||||||
or health > max_health
|
or health > max_health
|
||||||
or npc_id < NO_NPC_ID
|
or npc_id < NO_NPC_ID
|
||||||
or int(record_data["attack_cooldown"]) < 0
|
or int(normalized["attack_cooldown"]) < 0
|
||||||
):
|
):
|
||||||
return null
|
return null
|
||||||
var normalized := record_data.duplicate(true)
|
|
||||||
normalized["schema_version"] = SCHEMA_VERSION
|
normalized["schema_version"] = SCHEMA_VERSION
|
||||||
normalized["combatant_id"] = combatant_id
|
normalized["combatant_id"] = combatant_id
|
||||||
normalized["kind"] = String(record_data["kind"])
|
normalized["kind"] = String(kind)
|
||||||
normalized["faction_id"] = String(record_data["faction_id"])
|
normalized["faction_id"] = String(normalized["faction_id"])
|
||||||
normalized["display_name"] = display_name
|
normalized["display_name"] = display_name
|
||||||
normalized["npc_id"] = npc_id
|
normalized["npc_id"] = npc_id
|
||||||
|
normalized["enemy_definition_id"] = String(enemy_definition_id)
|
||||||
normalized["position"] = [
|
normalized["position"] = [
|
||||||
_snap(float(saved_position[0])),
|
_snap(float(saved_position[0])),
|
||||||
_snap(float(saved_position[1])),
|
_snap(float(saved_position[1])),
|
||||||
@@ -121,10 +143,10 @@ static func from_dictionary(record_data: Dictionary) -> CombatantStateRecord:
|
|||||||
]
|
]
|
||||||
normalized["health"] = health
|
normalized["health"] = health
|
||||||
normalized["max_health"] = max_health
|
normalized["max_health"] = max_health
|
||||||
normalized["weapon_id"] = String(record_data["weapon_id"])
|
normalized["weapon_id"] = String(normalized["weapon_id"])
|
||||||
normalized["alive"] = bool(record_data["alive"])
|
normalized["alive"] = bool(normalized["alive"])
|
||||||
normalized["hostile"] = bool(record_data["hostile"])
|
normalized["hostile"] = bool(normalized["hostile"])
|
||||||
normalized["attack_cooldown"] = int(record_data["attack_cooldown"])
|
normalized["attack_cooldown"] = int(normalized["attack_cooldown"])
|
||||||
return CombatantStateRecord.new(normalized)
|
return CombatantStateRecord.new(normalized)
|
||||||
|
|
||||||
|
|
||||||
@@ -148,6 +170,10 @@ func get_npc_id() -> int:
|
|||||||
return int(data["npc_id"])
|
return int(data["npc_id"])
|
||||||
|
|
||||||
|
|
||||||
|
func get_enemy_definition_id() -> StringName:
|
||||||
|
return StringName(data["enemy_definition_id"])
|
||||||
|
|
||||||
|
|
||||||
func get_position() -> Vector3:
|
func get_position() -> Vector3:
|
||||||
var saved_position: Array = data["position"]
|
var saved_position: Array = data["position"]
|
||||||
return Vector3(float(saved_position[0]), float(saved_position[1]), float(saved_position[2]))
|
return Vector3(float(saved_position[0]), float(saved_position[1]), float(saved_position[2]))
|
||||||
@@ -235,3 +261,11 @@ func _weapon_cooldown_ticks(tick_interval: float) -> int:
|
|||||||
|
|
||||||
func to_dictionary() -> Dictionary:
|
func to_dictionary() -> Dictionary:
|
||||||
return data.duplicate(true)
|
return data.duplicate(true)
|
||||||
|
|
||||||
|
|
||||||
|
static func _legacy_definition_id_for_kind(kind: StringName) -> StringName:
|
||||||
|
if kind == SimulationIds.COMBATANT_KIND_RAIDER:
|
||||||
|
return LEGACY_RAIDER_DEFINITION_ID
|
||||||
|
if kind == SimulationIds.COMBATANT_KIND_WOLF:
|
||||||
|
return LEGACY_WOLF_DEFINITION_ID
|
||||||
|
return NO_ENEMY_DEFINITION_ID
|
||||||
|
|||||||
@@ -111,6 +111,7 @@ func _test_defend_selection() -> void:
|
|||||||
"A strong idle villager should rally to defend during an active raid"
|
"A strong idle villager should rally to defend during an active raid"
|
||||||
)
|
)
|
||||||
var worker: SimNPC = manager.npcs[1]
|
var worker: SimNPC = manager.npcs[1]
|
||||||
|
worker.profession = SimulationIds.PROFESSION_FARMER
|
||||||
worker.strength = 1.0
|
worker.strength = 1.0
|
||||||
worker.hunger = 20.0
|
worker.hunger = 20.0
|
||||||
worker.energy = 90.0
|
worker.energy = 90.0
|
||||||
|
|||||||
@@ -51,7 +51,7 @@ func _test_core_pack() -> void:
|
|||||||
"Core items should include food, wood, sword, and claw in stable-ID order"
|
"Core items should include food, wood, sword, and claw in stable-ID order"
|
||||||
)
|
)
|
||||||
_check(
|
_check(
|
||||||
_enemy_ids(catalog.get_enemies()) == [&"enemy_raider", &"enemy_wolf"],
|
_enemy_ids(catalog.get_enemies()) == [&"enemy_boar", &"enemy_raider", &"enemy_wolf"],
|
||||||
"Core enemies should enumerate by stable ID"
|
"Core enemies should enumerate by stable ID"
|
||||||
)
|
)
|
||||||
var food := catalog.get_item(SimulationIds.RESOURCE_FOOD)
|
var food := catalog.get_item(SimulationIds.RESOURCE_FOOD)
|
||||||
@@ -69,10 +69,15 @@ func _test_core_pack() -> void:
|
|||||||
"Claw should preserve its weapon contract"
|
"Claw should preserve its weapon contract"
|
||||||
)
|
)
|
||||||
var wolf := catalog.get_enemy(&"enemy_wolf")
|
var wolf := catalog.get_enemy(&"enemy_wolf")
|
||||||
|
var boar := catalog.get_enemy(&"enemy_boar")
|
||||||
_check(
|
_check(
|
||||||
wolf != null and wolf.weapon_id == SimulationIds.ITEM_CLAW,
|
wolf != null and wolf.weapon_id == SimulationIds.ITEM_CLAW,
|
||||||
"Enemy references should resolve against catalog items"
|
"Enemy references should resolve against catalog items"
|
||||||
)
|
)
|
||||||
|
_check(
|
||||||
|
boar != null and boar.behavior_profile_id == wolf.behavior_profile_id,
|
||||||
|
"New enemy types should be able to reuse an existing behavior profile"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
func _test_deterministic_enumeration_and_dependencies() -> void:
|
func _test_deterministic_enumeration_and_dependencies() -> void:
|
||||||
|
|||||||
@@ -0,0 +1,107 @@
|
|||||||
|
extends GutTest
|
||||||
|
|
||||||
|
|
||||||
|
func test_core_boar_reuses_wolf_behavior_and_spawns_from_data() -> void:
|
||||||
|
SimulationEnemies.invalidate_cache()
|
||||||
|
var wolf := SimulationEnemies.get_enemy(&"enemy_wolf")
|
||||||
|
var boar := SimulationEnemies.get_enemy(&"enemy_boar")
|
||||||
|
|
||||||
|
assert_not_null(wolf)
|
||||||
|
assert_not_null(boar)
|
||||||
|
assert_eq(boar.behavior_profile_id, wolf.behavior_profile_id)
|
||||||
|
assert_eq(boar.behavior_profile_id, EnemyDefinition.BEHAVIOR_PROFILE_WOLF_HUNT)
|
||||||
|
assert_eq(boar.combatant_id_prefix, "boar")
|
||||||
|
var factory := CombatantFactory.new()
|
||||||
|
var combatant := factory.create_enemy(boar.enemy_id, Vector3(1.0, 2.0, 3.0))
|
||||||
|
assert_not_null(combatant)
|
||||||
|
assert_eq(combatant.get_combatant_id(), &"boar_0")
|
||||||
|
assert_eq(combatant.get_enemy_definition_id(), boar.enemy_id)
|
||||||
|
assert_eq(combatant.get_kind(), boar.kind)
|
||||||
|
assert_eq(combatant.get_faction_id(), boar.faction_id)
|
||||||
|
assert_eq(combatant.get_weapon_id(), boar.weapon_id)
|
||||||
|
assert_eq(combatant.get_max_health(), boar.health)
|
||||||
|
assert_eq(combatant.is_hostile(), boar.hostile)
|
||||||
|
|
||||||
|
|
||||||
|
func test_factory_accepts_a_definition_without_enemy_specific_code() -> void:
|
||||||
|
var definition := EnemyDefinition.new()
|
||||||
|
definition.enemy_id = &"enemy_test_hound"
|
||||||
|
definition.display_name = "Test Hound"
|
||||||
|
definition.kind = SimulationIds.COMBATANT_KIND_WOLF
|
||||||
|
definition.behavior_profile_id = EnemyDefinition.BEHAVIOR_PROFILE_WOLF_HUNT
|
||||||
|
definition.combatant_id_prefix = "test_hound"
|
||||||
|
definition.faction_id = SimulationIds.FACTION_TRIBE
|
||||||
|
definition.hostile = true
|
||||||
|
definition.weapon_id = SimulationIds.ITEM_CLAW
|
||||||
|
definition.health = 37.0
|
||||||
|
var factory := CombatantFactory.new()
|
||||||
|
|
||||||
|
assert_true(definition.validate().is_empty())
|
||||||
|
var combatant := factory.create_from_definition(definition, Vector3.ZERO)
|
||||||
|
assert_not_null(combatant)
|
||||||
|
assert_eq(combatant.get_combatant_id(), &"test_hound_0")
|
||||||
|
assert_eq(combatant.get_enemy_definition_id(), definition.enemy_id)
|
||||||
|
assert_eq(combatant.get_max_health(), 37.0)
|
||||||
|
|
||||||
|
|
||||||
|
func test_enemy_definition_id_round_trips_and_v1_infers_legacy_ids() -> void:
|
||||||
|
var current := CombatantStateRecord.create(
|
||||||
|
&"boar_7",
|
||||||
|
SimulationIds.COMBATANT_KIND_WOLF,
|
||||||
|
SimulationIds.FACTION_TRIBE,
|
||||||
|
"Wild Boar",
|
||||||
|
Vector3.ZERO,
|
||||||
|
55.0,
|
||||||
|
SimulationIds.ITEM_CLAW,
|
||||||
|
CombatantStateRecord.NO_NPC_ID,
|
||||||
|
true,
|
||||||
|
&"enemy_boar"
|
||||||
|
)
|
||||||
|
var restored := CombatantStateRecord.from_dictionary(current.to_dictionary())
|
||||||
|
assert_not_null(restored)
|
||||||
|
assert_eq(restored.get_enemy_definition_id(), &"enemy_boar")
|
||||||
|
|
||||||
|
var legacy_data := current.to_dictionary()
|
||||||
|
legacy_data["schema_version"] = CombatantStateRecord.LEGACY_SCHEMA_VERSION
|
||||||
|
legacy_data.erase("enemy_definition_id")
|
||||||
|
var migrated := CombatantStateRecord.from_dictionary(legacy_data)
|
||||||
|
assert_not_null(migrated)
|
||||||
|
assert_eq(migrated.get_enemy_definition_id(), &"enemy_wolf")
|
||||||
|
assert_eq(int(migrated.to_dictionary()["schema_version"]), CombatantStateRecord.SCHEMA_VERSION)
|
||||||
|
|
||||||
|
|
||||||
|
func test_conflict_restore_rebuilds_collision_free_definition_allocators() -> void:
|
||||||
|
var records: Array[CombatantStateRecord] = [
|
||||||
|
_enemy_record(&"wolf_0", &"enemy_wolf"),
|
||||||
|
_enemy_record(&"wolf_4", &"enemy_wolf"),
|
||||||
|
_enemy_record(&"boar_2", &"enemy_boar"),
|
||||||
|
]
|
||||||
|
var no_factions: Array[FactionStateRecord] = []
|
||||||
|
var conflict := ConflictSystem.new()
|
||||||
|
conflict.restore_state_records(records, no_factions, 12)
|
||||||
|
|
||||||
|
var wolf_id := conflict.spawn_wolf(Vector3.ZERO)
|
||||||
|
var boar_id := conflict.spawn_enemy(&"enemy_boar", Vector3.ONE)
|
||||||
|
assert_eq(wolf_id, &"wolf_5")
|
||||||
|
assert_eq(boar_id, &"boar_3")
|
||||||
|
assert_eq(conflict.get_combatant(wolf_id).get_enemy_definition_id(), &"enemy_wolf")
|
||||||
|
assert_eq(conflict.get_combatant(boar_id).get_enemy_definition_id(), &"enemy_boar")
|
||||||
|
assert_eq(conflict.get_all_combatants().size(), 6)
|
||||||
|
|
||||||
|
|
||||||
|
func _enemy_record(
|
||||||
|
combatant_id: StringName, enemy_definition_id: StringName
|
||||||
|
) -> CombatantStateRecord:
|
||||||
|
var definition := SimulationEnemies.get_enemy(enemy_definition_id)
|
||||||
|
return CombatantStateRecord.create(
|
||||||
|
combatant_id,
|
||||||
|
definition.kind,
|
||||||
|
definition.faction_id,
|
||||||
|
definition.display_name,
|
||||||
|
Vector3.ZERO,
|
||||||
|
definition.health,
|
||||||
|
definition.weapon_id,
|
||||||
|
CombatantStateRecord.NO_NPC_ID,
|
||||||
|
definition.hostile,
|
||||||
|
definition.enemy_id
|
||||||
|
)
|
||||||
Reference in New Issue
Block a user