feat: define reusable animal archetypes
This commit is contained in:
@@ -0,0 +1,93 @@
|
||||
class_name AnimalCatalog
|
||||
extends Resource
|
||||
|
||||
const CORE_CATALOG_PATH := "res://simulation/animals/definitions/core_animals.tres"
|
||||
|
||||
@export var animals: Array[AnimalDefinition] = []
|
||||
|
||||
var _definitions_by_id: Dictionary = {}
|
||||
var _definition_ids: Array[StringName] = []
|
||||
var _errors: Array[String] = []
|
||||
|
||||
|
||||
static func create_core() -> AnimalCatalog:
|
||||
var catalog := load(CORE_CATALOG_PATH) as AnimalCatalog
|
||||
if catalog != null:
|
||||
catalog.rebuild()
|
||||
return catalog
|
||||
|
||||
|
||||
func rebuild() -> Array[String]:
|
||||
_definitions_by_id.clear()
|
||||
_definition_ids.clear()
|
||||
_errors.clear()
|
||||
for definition in animals:
|
||||
if definition == null:
|
||||
_errors.append("Animal catalog contains a null definition")
|
||||
continue
|
||||
for error in definition.validate():
|
||||
_errors.append("Animal '%s': %s" % [definition.animal_definition_id, error])
|
||||
if _definitions_by_id.has(definition.animal_definition_id):
|
||||
_errors.append("Duplicate animal definition '%s'" % definition.animal_definition_id)
|
||||
else:
|
||||
_definitions_by_id[definition.animal_definition_id] = definition
|
||||
_definition_ids.append(definition.animal_definition_id)
|
||||
_definition_ids.sort_custom(_id_less)
|
||||
_errors.sort()
|
||||
return get_errors()
|
||||
|
||||
|
||||
func is_valid() -> bool:
|
||||
return rebuild().is_empty()
|
||||
|
||||
|
||||
func get_errors() -> Array[String]:
|
||||
return _errors.duplicate()
|
||||
|
||||
|
||||
func has_definition(animal_definition_id: StringName) -> bool:
|
||||
_ensure_built()
|
||||
return _definitions_by_id.has(animal_definition_id)
|
||||
|
||||
|
||||
func get_definition(animal_definition_id: StringName) -> AnimalDefinition:
|
||||
_ensure_built()
|
||||
return _definitions_by_id.get(animal_definition_id) as AnimalDefinition
|
||||
|
||||
|
||||
func matches_identity(animal_definition_id: StringName, species_id: StringName) -> bool:
|
||||
var definition := get_definition(animal_definition_id)
|
||||
return definition != null and definition.species_id == species_id
|
||||
|
||||
|
||||
func get_definition_ids() -> Array[StringName]:
|
||||
_ensure_built()
|
||||
return _definition_ids.duplicate()
|
||||
|
||||
|
||||
func get_definitions_for_behavior(behavior_id: StringName) -> Array[AnimalDefinition]:
|
||||
_ensure_built()
|
||||
var result: Array[AnimalDefinition] = []
|
||||
for definition_id in _definition_ids:
|
||||
var definition := _definitions_by_id[definition_id] as AnimalDefinition
|
||||
if definition.behavior_id == behavior_id:
|
||||
result.append(definition)
|
||||
return result
|
||||
|
||||
|
||||
func find_definition_for_species(species_id: StringName) -> AnimalDefinition:
|
||||
_ensure_built()
|
||||
for definition_id in _definition_ids:
|
||||
var definition := _definitions_by_id[definition_id] as AnimalDefinition
|
||||
if definition.species_id == species_id:
|
||||
return definition
|
||||
return null
|
||||
|
||||
|
||||
func _ensure_built() -> void:
|
||||
if _definitions_by_id.is_empty() and not animals.is_empty():
|
||||
rebuild()
|
||||
|
||||
|
||||
static func _id_less(left: StringName, right: StringName) -> bool:
|
||||
return String(left) < String(right)
|
||||
@@ -0,0 +1 @@
|
||||
uid://bbd405aahq78w
|
||||
@@ -0,0 +1,94 @@
|
||||
class_name AnimalDefinition
|
||||
extends Resource
|
||||
|
||||
const BEHAVIOR_GRAZER_ROUTINE := &"grazer_routine"
|
||||
|
||||
@export var animal_definition_id: StringName
|
||||
@export var species_id: StringName
|
||||
@export var display_name: String
|
||||
@export var behavior_id: StringName = BEHAVIOR_GRAZER_ROUTINE
|
||||
@export var supported_action_ids: Array[StringName] = []
|
||||
@export_file("*.tscn") var presentation_scene_path: String
|
||||
@export_range(0.1, 10.0, 0.1) var movement_speed := 1.6
|
||||
@export var visual_scale := Vector3.ONE
|
||||
@export var body_scale := Vector3.ONE
|
||||
@export var fluff_scale := Vector3.ONE
|
||||
@export var show_horns := true
|
||||
@export var show_beard := true
|
||||
|
||||
|
||||
func validate() -> Array[String]:
|
||||
var errors: Array[String] = []
|
||||
if not is_valid_stable_id(animal_definition_id):
|
||||
errors.append("animal_definition_id is invalid")
|
||||
if not is_valid_stable_id(species_id):
|
||||
errors.append("species_id is invalid for '%s'" % animal_definition_id)
|
||||
if display_name.strip_edges().is_empty():
|
||||
errors.append("display_name is empty for '%s'" % animal_definition_id)
|
||||
if behavior_id != BEHAVIOR_GRAZER_ROUTINE:
|
||||
errors.append(
|
||||
"behavior_id '%s' is unsupported for '%s'" % [behavior_id, animal_definition_id]
|
||||
)
|
||||
if (
|
||||
presentation_scene_path.is_empty()
|
||||
or not presentation_scene_path.begins_with("res://")
|
||||
or presentation_scene_path.get_extension() != "tscn"
|
||||
or not ResourceLoader.exists(presentation_scene_path, "PackedScene")
|
||||
):
|
||||
errors.append("presentation_scene_path is invalid for '%s'" % animal_definition_id)
|
||||
if not is_finite(movement_speed) or movement_speed <= 0.0:
|
||||
errors.append("movement_speed must be positive for '%s'" % animal_definition_id)
|
||||
if (
|
||||
not visual_scale.is_finite()
|
||||
or visual_scale.x <= 0.0
|
||||
or visual_scale.y <= 0.0
|
||||
or visual_scale.z <= 0.0
|
||||
):
|
||||
errors.append("visual_scale must be finite and positive for '%s'" % animal_definition_id)
|
||||
if (
|
||||
not body_scale.is_finite()
|
||||
or body_scale.x <= 0.0
|
||||
or body_scale.y <= 0.0
|
||||
or body_scale.z <= 0.0
|
||||
):
|
||||
errors.append("body_scale must be finite and positive for '%s'" % animal_definition_id)
|
||||
if (
|
||||
not fluff_scale.is_finite()
|
||||
or fluff_scale.x <= 0.0
|
||||
or fluff_scale.y <= 0.0
|
||||
or fluff_scale.z <= 0.0
|
||||
):
|
||||
errors.append("fluff_scale must be finite and positive for '%s'" % animal_definition_id)
|
||||
var seen: Dictionary = {}
|
||||
for action_id in supported_action_ids:
|
||||
if not is_valid_stable_id(action_id):
|
||||
errors.append("supported action ID is invalid for '%s'" % animal_definition_id)
|
||||
elif seen.has(action_id):
|
||||
errors.append(
|
||||
"duplicate supported action '%s' for '%s'" % [action_id, animal_definition_id]
|
||||
)
|
||||
else:
|
||||
seen[action_id] = true
|
||||
return errors
|
||||
|
||||
|
||||
func supports_action(action_id: StringName) -> bool:
|
||||
return action_id in supported_action_ids
|
||||
|
||||
|
||||
static func is_valid_stable_id(value: Variant) -> bool:
|
||||
if value is not String and value is not StringName:
|
||||
return false
|
||||
var text := String(value)
|
||||
if text.is_empty() or text.length() > 96:
|
||||
return false
|
||||
for character in text:
|
||||
var code := character.unicode_at(0)
|
||||
if not (
|
||||
(code >= 48 and code <= 57)
|
||||
or (code >= 65 and code <= 90)
|
||||
or (code >= 97 and code <= 122)
|
||||
or character in ["_", "-", ".", ":"]
|
||||
):
|
||||
return false
|
||||
return true
|
||||
@@ -0,0 +1 @@
|
||||
uid://cg5ou2nnnvcr2
|
||||
@@ -0,0 +1,117 @@
|
||||
class_name AnimalFactory
|
||||
extends RefCounted
|
||||
|
||||
const CONFIGURATION_KEYS := [
|
||||
"initial_hunger",
|
||||
"initial_enabled",
|
||||
"can_npcs_feed",
|
||||
"can_player_feed",
|
||||
"debug_label_enabled",
|
||||
"initial_routine_site_id",
|
||||
"initial_next_routine_tick",
|
||||
]
|
||||
|
||||
var last_error := ""
|
||||
|
||||
var _catalog: AnimalCatalog
|
||||
|
||||
|
||||
func _init(catalog: AnimalCatalog = null) -> void:
|
||||
_catalog = catalog if catalog != null else AnimalCatalog.create_core()
|
||||
if _catalog == null:
|
||||
last_error = "Animal catalog could not be loaded"
|
||||
elif not _catalog.rebuild().is_empty():
|
||||
last_error = "; ".join(_catalog.get_errors())
|
||||
|
||||
|
||||
func get_catalog() -> AnimalCatalog:
|
||||
return _catalog
|
||||
|
||||
|
||||
func create_presentation(
|
||||
animal_definition_id: StringName,
|
||||
animal_id: StringName,
|
||||
display_name: String,
|
||||
configuration: Dictionary = {}
|
||||
) -> AnimalNode:
|
||||
last_error = ""
|
||||
if _catalog == null or not _catalog.has_definition(animal_definition_id):
|
||||
return _fail("Unknown animal definition '%s'" % animal_definition_id)
|
||||
if not AnimalDefinition.is_valid_stable_id(animal_id):
|
||||
return _fail("animal_id is invalid")
|
||||
if display_name.strip_edges().is_empty():
|
||||
return _fail("display_name is empty")
|
||||
if not _configuration_is_valid(configuration):
|
||||
return null
|
||||
var definition := _catalog.get_definition(animal_definition_id)
|
||||
var presentation_scene := load(definition.presentation_scene_path) as PackedScene
|
||||
var node := (
|
||||
presentation_scene.instantiate() as AnimalNode if presentation_scene != null else null
|
||||
)
|
||||
if node == null:
|
||||
return _fail(
|
||||
"Presentation scene for '%s' must instantiate AnimalNode" % animal_definition_id
|
||||
)
|
||||
if not node.apply_animal_definition(definition):
|
||||
node.free()
|
||||
return _fail("Presentation rejected definition '%s'" % animal_definition_id)
|
||||
node.animal_id = animal_id
|
||||
node.display_name = display_name
|
||||
_apply_configuration(node, configuration)
|
||||
return node
|
||||
|
||||
|
||||
func _configuration_is_valid(configuration: Dictionary) -> bool:
|
||||
for key in configuration:
|
||||
if (key is not String and key is not StringName) or String(key) not in CONFIGURATION_KEYS:
|
||||
return _fail_bool("Unknown animal presentation configuration '%s'" % key)
|
||||
if configuration.has("initial_hunger"):
|
||||
if (
|
||||
configuration["initial_hunger"] is not int
|
||||
and configuration["initial_hunger"] is not float
|
||||
):
|
||||
return _fail_bool("initial_hunger must be numeric")
|
||||
var hunger := float(configuration["initial_hunger"])
|
||||
if not is_finite(hunger) or hunger < 0.0 or hunger > 100.0:
|
||||
return _fail_bool("initial_hunger must be between 0 and 100")
|
||||
if (
|
||||
configuration.has("initial_routine_site_id")
|
||||
and not AnimalDefinition.is_valid_stable_id(configuration["initial_routine_site_id"])
|
||||
):
|
||||
return _fail_bool("initial_routine_site_id is invalid")
|
||||
if configuration.has("initial_next_routine_tick"):
|
||||
if configuration["initial_next_routine_tick"] is not int:
|
||||
return _fail_bool("initial_next_routine_tick must be an int")
|
||||
if int(configuration["initial_next_routine_tick"]) < 0:
|
||||
return _fail_bool("initial_next_routine_tick must not be negative")
|
||||
for key in ["initial_enabled", "can_npcs_feed", "can_player_feed", "debug_label_enabled"]:
|
||||
if configuration.has(key) and configuration[key] is not bool:
|
||||
return _fail_bool("%s must be a bool" % key)
|
||||
return true
|
||||
|
||||
|
||||
func _apply_configuration(node: AnimalNode, configuration: Dictionary) -> void:
|
||||
if configuration.has("initial_hunger"):
|
||||
node.initial_hunger = float(configuration["initial_hunger"])
|
||||
if configuration.has("initial_enabled"):
|
||||
node.initial_enabled = bool(configuration["initial_enabled"])
|
||||
if configuration.has("can_npcs_feed"):
|
||||
node.can_npcs_feed = bool(configuration["can_npcs_feed"])
|
||||
if configuration.has("can_player_feed"):
|
||||
node.can_player_feed = bool(configuration["can_player_feed"])
|
||||
if configuration.has("debug_label_enabled"):
|
||||
node.debug_label_enabled = bool(configuration["debug_label_enabled"])
|
||||
if configuration.has("initial_routine_site_id"):
|
||||
node.initial_routine_site_id = StringName(configuration["initial_routine_site_id"])
|
||||
if configuration.has("initial_next_routine_tick"):
|
||||
node.initial_next_routine_tick = int(configuration["initial_next_routine_tick"])
|
||||
|
||||
|
||||
func _fail(message: String) -> AnimalNode:
|
||||
last_error = message
|
||||
return null
|
||||
|
||||
|
||||
func _fail_bool(message: String) -> bool:
|
||||
last_error = message
|
||||
return false
|
||||
@@ -0,0 +1 @@
|
||||
uid://cgpayfff6eeug
|
||||
@@ -29,6 +29,7 @@ var states: Dictionary = {}
|
||||
var economy: RefCounted
|
||||
var active_world_adapter: Node
|
||||
var current_tick := 0
|
||||
var _animal_catalog: AnimalCatalog
|
||||
|
||||
|
||||
func configure(economy_service: RefCounted, world_adapter: Node) -> void:
|
||||
@@ -36,6 +37,19 @@ func configure(economy_service: RefCounted, world_adapter: Node) -> void:
|
||||
active_world_adapter = world_adapter
|
||||
|
||||
|
||||
func configure_animal_catalog(catalog: AnimalCatalog) -> bool:
|
||||
if catalog == null or not catalog.rebuild().is_empty():
|
||||
return false
|
||||
_animal_catalog = catalog
|
||||
return true
|
||||
|
||||
|
||||
func get_animal_catalog() -> AnimalCatalog:
|
||||
if _animal_catalog == null:
|
||||
_animal_catalog = AnimalCatalog.create_core()
|
||||
return _animal_catalog
|
||||
|
||||
|
||||
func advance(simulation_tick: int) -> void:
|
||||
current_tick = simulation_tick
|
||||
var animal_ids: Array = states.keys()
|
||||
@@ -67,15 +81,23 @@ func register_node(node: AnimalNode) -> bool:
|
||||
push_error("AnimalCareSystem: Animal ID '%s' collides with a world target" % node.animal_id)
|
||||
return false
|
||||
var action_definition := SimulationDefinitions.get_action(SimulationIds.ACTION_FEED_ANIMAL)
|
||||
var animal_catalog := get_animal_catalog()
|
||||
var animal_definition := (
|
||||
animal_catalog.get_definition(node.animal_definition_id) if animal_catalog != null else null
|
||||
)
|
||||
if (
|
||||
action_definition == null
|
||||
or action_definition.target_type != SimulationIds.TARGET_ANIMAL
|
||||
or node.species_id != SimulationIds.SPECIES_GOAT
|
||||
or animal_definition == null
|
||||
or animal_definition.behavior_id != AnimalDefinition.BEHAVIOR_GRAZER_ROUTINE
|
||||
or animal_definition.species_id != node.species_id
|
||||
or not animal_definition.supports_action(SimulationIds.ACTION_FEED_ANIMAL)
|
||||
or not node.supports_action(SimulationIds.ACTION_FEED_ANIMAL)
|
||||
):
|
||||
push_error(
|
||||
(
|
||||
"AnimalCareSystem: AnimalNode '%s' has an invalid feed action or species '%s'"
|
||||
% [node.animal_id, node.species_id]
|
||||
"AnimalCareSystem: AnimalNode '%s' has invalid definition '%s' or species '%s'"
|
||||
% [node.animal_id, node.animal_definition_id, node.species_id]
|
||||
)
|
||||
)
|
||||
return false
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
[gd_resource type="Resource" script_class="AnimalCatalog" load_steps=4 format=3]
|
||||
|
||||
[ext_resource type="Script" path="res://simulation/animals/AnimalCatalog.gd" id="1_catalog"]
|
||||
[ext_resource type="Resource" path="res://simulation/animals/definitions/domestic_goat.tres" id="2_goat"]
|
||||
[ext_resource type="Resource" path="res://simulation/animals/definitions/domestic_sheep.tres" id="3_sheep"]
|
||||
|
||||
[resource]
|
||||
script = ExtResource("1_catalog")
|
||||
animals = Array[ExtResource("2_goat")]([ExtResource("2_goat"), ExtResource("3_sheep")])
|
||||
@@ -0,0 +1,18 @@
|
||||
[gd_resource type="Resource" script_class="AnimalDefinition" load_steps=2 format=3]
|
||||
|
||||
[ext_resource type="Script" path="res://simulation/animals/AnimalDefinition.gd" id="1_definition"]
|
||||
|
||||
[resource]
|
||||
script = ExtResource("1_definition")
|
||||
animal_definition_id = &"domestic_goat"
|
||||
species_id = &"goat"
|
||||
display_name = "Domestic goat"
|
||||
behavior_id = &"grazer_routine"
|
||||
supported_action_ids = Array[StringName]([&"feed_animal"])
|
||||
presentation_scene_path = "res://world/animals/grazer/cozy_grazer.tscn"
|
||||
movement_speed = 1.6
|
||||
visual_scale = Vector3(1, 1, 1)
|
||||
body_scale = Vector3(1, 1, 1)
|
||||
fluff_scale = Vector3(1, 1, 1)
|
||||
show_horns = true
|
||||
show_beard = true
|
||||
@@ -0,0 +1,18 @@
|
||||
[gd_resource type="Resource" script_class="AnimalDefinition" load_steps=2 format=3]
|
||||
|
||||
[ext_resource type="Script" path="res://simulation/animals/AnimalDefinition.gd" id="1_definition"]
|
||||
|
||||
[resource]
|
||||
script = ExtResource("1_definition")
|
||||
animal_definition_id = &"domestic_sheep"
|
||||
species_id = &"sheep"
|
||||
display_name = "Domestic sheep"
|
||||
behavior_id = &"grazer_routine"
|
||||
supported_action_ids = Array[StringName]([&"feed_animal"])
|
||||
presentation_scene_path = "res://world/animals/grazer/cozy_grazer.tscn"
|
||||
movement_speed = 1.4
|
||||
visual_scale = Vector3(1.08, 1.04, 1.08)
|
||||
body_scale = Vector3(1.12, 1.08, 1.12)
|
||||
fluff_scale = Vector3(1.16, 1.14, 1.16)
|
||||
show_horns = false
|
||||
show_beard = false
|
||||
Reference in New Issue
Block a user