feat: define reusable animal archetypes

This commit is contained in:
Rijad Zuzo
2026-08-12 20:50:52 +02:00
parent d5eb2b81f9
commit 4e4ae1a969
20 changed files with 1141 additions and 411 deletions
+93
View File
@@ -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)
+1
View File
@@ -0,0 +1 @@
uid://bbd405aahq78w
+94
View File
@@ -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
+117
View File
@@ -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
+1
View File
@@ -0,0 +1 @@
uid://cgpayfff6eeug
+25 -3
View File
@@ -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