refactor: separate content from presentation assets
This commit is contained in:
@@ -8,13 +8,8 @@ const BEHAVIOR_GRAZER_ROUTINE := &"grazer_routine"
|
|||||||
@export var display_name: String
|
@export var display_name: String
|
||||||
@export var behavior_id: StringName = BEHAVIOR_GRAZER_ROUTINE
|
@export var behavior_id: StringName = BEHAVIOR_GRAZER_ROUTINE
|
||||||
@export var supported_action_ids: Array[StringName] = []
|
@export var supported_action_ids: Array[StringName] = []
|
||||||
@export_file("*.tscn") var presentation_scene_path: String
|
@export var presentation_cue_id: StringName
|
||||||
@export_range(0.1, 10.0, 0.1) var movement_speed := 1.6
|
@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]:
|
func validate() -> Array[String]:
|
||||||
@@ -29,36 +24,10 @@ func validate() -> Array[String]:
|
|||||||
errors.append(
|
errors.append(
|
||||||
"behavior_id '%s' is unsupported for '%s'" % [behavior_id, animal_definition_id]
|
"behavior_id '%s' is unsupported for '%s'" % [behavior_id, animal_definition_id]
|
||||||
)
|
)
|
||||||
if (
|
if not is_valid_stable_id(presentation_cue_id):
|
||||||
presentation_scene_path.is_empty()
|
errors.append("presentation_cue_id is invalid for '%s'" % animal_definition_id)
|
||||||
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:
|
if not is_finite(movement_speed) or movement_speed <= 0.0:
|
||||||
errors.append("movement_speed must be positive for '%s'" % animal_definition_id)
|
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 = {}
|
var seen: Dictionary = {}
|
||||||
for action_id in supported_action_ids:
|
for action_id in supported_action_ids:
|
||||||
if not is_valid_stable_id(action_id):
|
if not is_valid_stable_id(action_id):
|
||||||
|
|||||||
@@ -14,10 +14,12 @@ const CONFIGURATION_KEYS := [
|
|||||||
var last_error := ""
|
var last_error := ""
|
||||||
|
|
||||||
var _catalog: AnimalCatalog
|
var _catalog: AnimalCatalog
|
||||||
|
var _presentation_catalog: PresentationCatalog
|
||||||
|
|
||||||
|
|
||||||
func _init(catalog: AnimalCatalog = null) -> void:
|
func _init(catalog: AnimalCatalog = null, presentation_catalog: PresentationCatalog = null) -> void:
|
||||||
_catalog = catalog if catalog != null else AnimalCatalog.create_core()
|
_catalog = catalog if catalog != null else AnimalCatalog.create_core()
|
||||||
|
_presentation_catalog = presentation_catalog
|
||||||
if _catalog == null:
|
if _catalog == null:
|
||||||
last_error = "Animal catalog could not be loaded"
|
last_error = "Animal catalog could not be loaded"
|
||||||
elif not _catalog.rebuild().is_empty():
|
elif not _catalog.rebuild().is_empty():
|
||||||
@@ -28,6 +30,10 @@ func get_catalog() -> AnimalCatalog:
|
|||||||
return _catalog
|
return _catalog
|
||||||
|
|
||||||
|
|
||||||
|
func get_presentation_catalog() -> PresentationCatalog:
|
||||||
|
return _presentation_catalog
|
||||||
|
|
||||||
|
|
||||||
func create_presentation(
|
func create_presentation(
|
||||||
animal_definition_id: StringName,
|
animal_definition_id: StringName,
|
||||||
animal_id: StringName,
|
animal_id: StringName,
|
||||||
@@ -44,15 +50,26 @@ func create_presentation(
|
|||||||
if not _configuration_is_valid(configuration):
|
if not _configuration_is_valid(configuration):
|
||||||
return null
|
return null
|
||||||
var definition := _catalog.get_definition(animal_definition_id)
|
var definition := _catalog.get_definition(animal_definition_id)
|
||||||
var presentation_scene := load(definition.presentation_scene_path) as PackedScene
|
if _presentation_catalog == null:
|
||||||
|
return _fail("Presentation catalog must be injected before creating presentation")
|
||||||
|
if not _presentation_catalog.is_valid():
|
||||||
|
return _fail("Injected presentation catalog is invalid")
|
||||||
|
var cue := _presentation_catalog.get_definition(definition.presentation_cue_id)
|
||||||
|
if cue == null:
|
||||||
|
return _fail(
|
||||||
|
(
|
||||||
|
"Unknown presentation cue '%s' for '%s'"
|
||||||
|
% [definition.presentation_cue_id, animal_definition_id]
|
||||||
|
)
|
||||||
|
)
|
||||||
var node := (
|
var node := (
|
||||||
presentation_scene.instantiate() as AnimalNode if presentation_scene != null else null
|
_presentation_catalog.instantiate_scene(definition.presentation_cue_id) as AnimalNode
|
||||||
)
|
)
|
||||||
if node == null:
|
if node == null:
|
||||||
return _fail(
|
return _fail(
|
||||||
"Presentation scene for '%s' must instantiate AnimalNode" % animal_definition_id
|
"Presentation scene for '%s' must instantiate AnimalNode" % animal_definition_id
|
||||||
)
|
)
|
||||||
if not node.apply_animal_definition(definition):
|
if not node.apply_animal_definition(definition, cue.get_parameters()):
|
||||||
node.free()
|
node.free()
|
||||||
return _fail("Presentation rejected definition '%s'" % animal_definition_id)
|
return _fail("Presentation rejected definition '%s'" % animal_definition_id)
|
||||||
node.animal_id = animal_id
|
node.animal_id = animal_id
|
||||||
|
|||||||
@@ -9,10 +9,5 @@ species_id = &"goat"
|
|||||||
display_name = "Domestic goat"
|
display_name = "Domestic goat"
|
||||||
behavior_id = &"grazer_routine"
|
behavior_id = &"grazer_routine"
|
||||||
supported_action_ids = Array[StringName]([&"feed_animal"])
|
supported_action_ids = Array[StringName]([&"feed_animal"])
|
||||||
presentation_scene_path = "res://world/animals/grazer/cozy_grazer.tscn"
|
presentation_cue_id = &"animal.domestic_goat"
|
||||||
movement_speed = 1.6
|
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
|
|
||||||
|
|||||||
@@ -9,10 +9,5 @@ species_id = &"sheep"
|
|||||||
display_name = "Domestic sheep"
|
display_name = "Domestic sheep"
|
||||||
behavior_id = &"grazer_routine"
|
behavior_id = &"grazer_routine"
|
||||||
supported_action_ids = Array[StringName]([&"feed_animal"])
|
supported_action_ids = Array[StringName]([&"feed_animal"])
|
||||||
presentation_scene_path = "res://world/animals/grazer/cozy_grazer.tscn"
|
presentation_cue_id = &"animal.domestic_sheep"
|
||||||
movement_speed = 1.4
|
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
|
|
||||||
|
|||||||
@@ -148,7 +148,7 @@ func rebuild(
|
|||||||
storages, items_by_id, actions_by_id, capability_tags_by_id, supported_cues, errors
|
storages, items_by_id, actions_by_id, capability_tags_by_id, supported_cues, errors
|
||||||
)
|
)
|
||||||
_validate_enemy_references(enemies, items_by_id, errors)
|
_validate_enemy_references(enemies, items_by_id, errors)
|
||||||
_validate_animal_references(animals, actions_by_id, errors)
|
_validate_animal_references(animals, actions_by_id, supported_cues, errors)
|
||||||
errors.sort()
|
errors.sort()
|
||||||
_errors = errors
|
_errors = errors
|
||||||
if not errors.is_empty():
|
if not errors.is_empty():
|
||||||
@@ -708,9 +708,18 @@ static func _validate_enemy_references(
|
|||||||
|
|
||||||
|
|
||||||
static func _validate_animal_references(
|
static func _validate_animal_references(
|
||||||
animals: Array[AnimalDefinition], actions_by_id: Dictionary, errors: Array[String]
|
animals: Array[AnimalDefinition],
|
||||||
|
actions_by_id: Dictionary,
|
||||||
|
supported_cues: Dictionary,
|
||||||
|
errors: Array[String]
|
||||||
) -> void:
|
) -> void:
|
||||||
for definition in animals:
|
for definition in animals:
|
||||||
|
_validate_presentation_cue(
|
||||||
|
"Animal '%s'" % definition.animal_definition_id,
|
||||||
|
definition.presentation_cue_id,
|
||||||
|
supported_cues,
|
||||||
|
errors
|
||||||
|
)
|
||||||
for action_id in definition.supported_action_ids:
|
for action_id in definition.supported_action_ids:
|
||||||
if not actions_by_id.has(action_id):
|
if not actions_by_id.has(action_id):
|
||||||
errors.append(
|
errors.append(
|
||||||
|
|||||||
@@ -6,6 +6,8 @@ extends RefCounted
|
|||||||
const ITEM_FOOD := &"item.food"
|
const ITEM_FOOD := &"item.food"
|
||||||
const ITEM_HERBS := &"item.herbs"
|
const ITEM_HERBS := &"item.herbs"
|
||||||
const ITEM_WOOD := &"item.wood"
|
const ITEM_WOOD := &"item.wood"
|
||||||
|
const ANIMAL_DOMESTIC_GOAT := &"animal.domestic_goat"
|
||||||
|
const ANIMAL_DOMESTIC_SHEEP := &"animal.domestic_sheep"
|
||||||
const RESOURCE_BERRY_PATCH := &"resource.berry_patch"
|
const RESOURCE_BERRY_PATCH := &"resource.berry_patch"
|
||||||
const RESOURCE_HERB_PATCH := &"resource.herb_patch"
|
const RESOURCE_HERB_PATCH := &"resource.herb_patch"
|
||||||
const RESOURCE_TREE := &"resource.tree"
|
const RESOURCE_TREE := &"resource.tree"
|
||||||
@@ -16,6 +18,8 @@ const STORAGE_WOODPILE := &"storage.woodpile"
|
|||||||
|
|
||||||
static func get_all() -> Array[StringName]:
|
static func get_all() -> Array[StringName]:
|
||||||
var cue_ids: Array[StringName] = [
|
var cue_ids: Array[StringName] = [
|
||||||
|
ANIMAL_DOMESTIC_GOAT,
|
||||||
|
ANIMAL_DOMESTIC_SHEEP,
|
||||||
ITEM_FOOD,
|
ITEM_FOOD,
|
||||||
ITEM_HERBS,
|
ITEM_HERBS,
|
||||||
ITEM_WOOD,
|
ITEM_WOOD,
|
||||||
|
|||||||
@@ -51,9 +51,10 @@ func _run() -> void:
|
|||||||
(
|
(
|
||||||
goat_definition.behavior_id == AnimalDefinition.BEHAVIOR_GRAZER_ROUTINE
|
goat_definition.behavior_id == AnimalDefinition.BEHAVIOR_GRAZER_ROUTINE
|
||||||
and sheep_definition.behavior_id == AnimalDefinition.BEHAVIOR_GRAZER_ROUTINE
|
and sheep_definition.behavior_id == AnimalDefinition.BEHAVIOR_GRAZER_ROUTINE
|
||||||
and goat_definition.presentation_scene_path == sheep_definition.presentation_scene_path
|
and goat_definition.presentation_cue_id == PresentationCueIds.ANIMAL_DOMESTIC_GOAT
|
||||||
|
and sheep_definition.presentation_cue_id == PresentationCueIds.ANIMAL_DOMESTIC_SHEEP
|
||||||
),
|
),
|
||||||
"Both animals should reuse one grazer behavior and presentation contract"
|
"Both animals should reuse one grazer behavior with stable presentation cues"
|
||||||
)
|
)
|
||||||
_check(
|
_check(
|
||||||
(
|
(
|
||||||
@@ -63,7 +64,29 @@ func _run() -> void:
|
|||||||
"Both data definitions should advertise the existing care action"
|
"Both data definitions should advertise the existing care action"
|
||||||
)
|
)
|
||||||
|
|
||||||
var generic_scene := load(goat_definition.presentation_scene_path) as PackedScene
|
var presentation_catalog := PresentationCatalog.create_core()
|
||||||
|
_check(
|
||||||
|
presentation_catalog != null and presentation_catalog.is_valid(),
|
||||||
|
"The presentation-side catalog should validate independently"
|
||||||
|
)
|
||||||
|
if presentation_catalog == null or not presentation_catalog.is_valid():
|
||||||
|
_finish()
|
||||||
|
return
|
||||||
|
var goat_cue := presentation_catalog.get_definition(goat_definition.presentation_cue_id)
|
||||||
|
var sheep_cue := presentation_catalog.get_definition(sheep_definition.presentation_cue_id)
|
||||||
|
_check(
|
||||||
|
(
|
||||||
|
goat_cue != null
|
||||||
|
and sheep_cue != null
|
||||||
|
and goat_cue.scene_path == sheep_cue.scene_path
|
||||||
|
and goat_cue.scene_path == "res://world/animals/grazer/cozy_grazer.tscn"
|
||||||
|
),
|
||||||
|
"Presentation cues should map both data-only animals to the shared grazer scene"
|
||||||
|
)
|
||||||
|
if goat_cue == null or sheep_cue == null:
|
||||||
|
_finish()
|
||||||
|
return
|
||||||
|
var generic_scene := load(goat_cue.scene_path) as PackedScene
|
||||||
var generic := generic_scene.instantiate() as AnimalNode
|
var generic := generic_scene.instantiate() as AnimalNode
|
||||||
_check(
|
_check(
|
||||||
(
|
(
|
||||||
@@ -79,7 +102,7 @@ func _run() -> void:
|
|||||||
if generic != null:
|
if generic != null:
|
||||||
generic.free()
|
generic.free()
|
||||||
|
|
||||||
var factory := AnimalFactory.new(catalog)
|
var factory := AnimalFactory.new(catalog, presentation_catalog)
|
||||||
var goat := (
|
var goat := (
|
||||||
factory
|
factory
|
||||||
. create_presentation(
|
. create_presentation(
|
||||||
|
|||||||
@@ -257,7 +257,7 @@ func _animal(animal_definition_id: StringName, action_id: StringName) -> AnimalD
|
|||||||
animal.species_id = &"test_species"
|
animal.species_id = &"test_species"
|
||||||
animal.display_name = String(animal_definition_id).capitalize()
|
animal.display_name = String(animal_definition_id).capitalize()
|
||||||
animal.supported_action_ids = [action_id]
|
animal.supported_action_ids = [action_id]
|
||||||
animal.presentation_scene_path = "res://world/animals/grazer/cozy_grazer.tscn"
|
animal.presentation_cue_id = PresentationCueIds.ANIMAL_DOMESTIC_GOAT
|
||||||
return animal
|
return animal
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,148 @@
|
|||||||
|
extends GutTest
|
||||||
|
|
||||||
|
|
||||||
|
func test_headless_animal_content_uses_only_stable_cues_and_never_asset_paths() -> void:
|
||||||
|
var content := ContentCatalog.create_core()
|
||||||
|
assert_true(content.is_valid(), "%s" % [content.get_errors()])
|
||||||
|
var animals := content.get_animals()
|
||||||
|
assert_eq(animals.size(), 2)
|
||||||
|
for animal in animals:
|
||||||
|
assert_true(AnimalDefinition.is_valid_stable_id(animal.presentation_cue_id))
|
||||||
|
assert_false("res://" in String(animal.presentation_cue_id))
|
||||||
|
assert_false(_resource_has_asset_path_property(animal))
|
||||||
|
assert_true(animal.validate().is_empty())
|
||||||
|
|
||||||
|
var missing_asset_cue := PresentationCueDefinition.new()
|
||||||
|
missing_asset_cue.cue_id = &"animal.missing_asset"
|
||||||
|
missing_asset_cue.scene_path = "res://does/not/exist/animal.tscn"
|
||||||
|
assert_true(missing_asset_cue.validate_metadata().is_empty())
|
||||||
|
assert_false(missing_asset_cue.validate_assets().is_empty())
|
||||||
|
|
||||||
|
var headless_catalog := ContentCatalog.new()
|
||||||
|
var pack := SimulationContentPack.new()
|
||||||
|
pack.pack_id = &"headless_animals"
|
||||||
|
pack.display_name = "Headless animals"
|
||||||
|
pack.animals = [_animal(&"headless_goat", missing_asset_cue.cue_id)]
|
||||||
|
var errors := headless_catalog.rebuild(
|
||||||
|
[pack] as Array[SimulationContentPack], [], [missing_asset_cue.cue_id]
|
||||||
|
)
|
||||||
|
assert_true(errors.is_empty(), "%s" % [errors])
|
||||||
|
assert_true(headless_catalog.is_valid())
|
||||||
|
errors = headless_catalog.rebuild(
|
||||||
|
[pack] as Array[SimulationContentPack], [], [&"animal.some_other_cue"]
|
||||||
|
)
|
||||||
|
assert_true(_has_error(errors, "unknown presentation cue 'animal.missing_asset'"))
|
||||||
|
assert_false(headless_catalog.is_valid())
|
||||||
|
|
||||||
|
|
||||||
|
func test_presentation_catalog_rejects_missing_assets_and_duplicate_cues_transactionally() -> void:
|
||||||
|
var missing_id := PresentationCueDefinition.new()
|
||||||
|
var catalog := PresentationCatalog.new()
|
||||||
|
var errors := catalog.rebuild([missing_id] as Array[PresentationCueDefinition], false)
|
||||||
|
assert_true(_has_error(errors, "cue_id is invalid"))
|
||||||
|
assert_false(catalog.is_valid())
|
||||||
|
|
||||||
|
var valid := _cue(&"animal.valid", "res://world/animals/grazer/cozy_grazer.tscn")
|
||||||
|
var duplicate := _cue(&"animal.valid", "res://world/animals/goat/cozy_goat.tscn")
|
||||||
|
errors = catalog.rebuild([valid, duplicate] as Array[PresentationCueDefinition], false)
|
||||||
|
assert_true(_has_error(errors, "Duplicate presentation cue 'animal.valid'"))
|
||||||
|
assert_false(catalog.is_valid())
|
||||||
|
assert_true(catalog.get_cue_ids().is_empty())
|
||||||
|
assert_null(catalog.get_definition(&"animal.valid"))
|
||||||
|
|
||||||
|
var missing := _cue(&"animal.missing", "res://world/animals/absent.tscn")
|
||||||
|
errors = catalog.rebuild([missing] as Array[PresentationCueDefinition], true)
|
||||||
|
assert_true(_has_error(errors, "scene_path does not resolve"), "%s" % [errors])
|
||||||
|
assert_false(catalog.is_valid())
|
||||||
|
assert_true(catalog.get_cue_ids().is_empty())
|
||||||
|
|
||||||
|
|
||||||
|
func test_goat_and_sheep_are_data_only_content_additions_using_one_scene_contract() -> void:
|
||||||
|
var animal_catalog := AnimalCatalog.create_core()
|
||||||
|
var presentation_catalog := PresentationCatalog.create_core()
|
||||||
|
assert_not_null(animal_catalog)
|
||||||
|
assert_true(presentation_catalog.is_valid(), "%s" % [presentation_catalog.get_errors()])
|
||||||
|
assert_eq(
|
||||||
|
presentation_catalog.get_cue_ids(),
|
||||||
|
[PresentationCueIds.ANIMAL_DOMESTIC_GOAT, PresentationCueIds.ANIMAL_DOMESTIC_SHEEP]
|
||||||
|
)
|
||||||
|
var goat := animal_catalog.get_definition(&"domestic_goat")
|
||||||
|
var sheep := animal_catalog.get_definition(&"domestic_sheep")
|
||||||
|
var goat_cue := presentation_catalog.get_definition(goat.presentation_cue_id)
|
||||||
|
var sheep_cue := presentation_catalog.get_definition(sheep.presentation_cue_id)
|
||||||
|
|
||||||
|
assert_eq(goat.behavior_id, AnimalDefinition.BEHAVIOR_GRAZER_ROUTINE)
|
||||||
|
assert_eq(sheep.behavior_id, AnimalDefinition.BEHAVIOR_GRAZER_ROUTINE)
|
||||||
|
assert_ne(goat.presentation_cue_id, sheep.presentation_cue_id)
|
||||||
|
assert_eq(goat_cue.scene_path, sheep_cue.scene_path)
|
||||||
|
assert_eq(goat_cue.animation_hook_id, sheep_cue.animation_hook_id)
|
||||||
|
assert_eq(goat_cue.animation_hook_id, AnimalDefinition.BEHAVIOR_GRAZER_ROUTINE)
|
||||||
|
assert_true(bool(goat_cue.parameters["show_horns"]))
|
||||||
|
assert_false(bool(sheep_cue.parameters["show_horns"]))
|
||||||
|
|
||||||
|
var factory := AnimalFactory.new(animal_catalog, presentation_catalog)
|
||||||
|
var goat_node := factory.create_presentation(&"domestic_goat", &"factory_goat", "Goat")
|
||||||
|
var sheep_node := factory.create_presentation(&"domestic_sheep", &"factory_sheep", "Sheep")
|
||||||
|
assert_not_null(goat_node)
|
||||||
|
assert_not_null(sheep_node)
|
||||||
|
add_child_autofree(goat_node)
|
||||||
|
add_child_autofree(sheep_node)
|
||||||
|
await wait_process_frames(1)
|
||||||
|
assert_eq(goat_node.get_script(), sheep_node.get_script())
|
||||||
|
assert_eq(goat_node.get_node("Visual").get_script(), sheep_node.get_node("Visual").get_script())
|
||||||
|
assert_true(goat_node.get_node("Visual/HeadRoot/HornLeft").visible)
|
||||||
|
assert_false(sheep_node.get_node("Visual/HeadRoot/HornLeft").visible)
|
||||||
|
|
||||||
|
|
||||||
|
func test_factory_requires_an_injected_mapping_for_every_animal_cue() -> void:
|
||||||
|
var animal_catalog := AnimalCatalog.create_core()
|
||||||
|
var headless_factory := AnimalFactory.new(animal_catalog)
|
||||||
|
assert_null(headless_factory.get_presentation_catalog())
|
||||||
|
assert_eq(headless_factory.last_error, "")
|
||||||
|
assert_null(headless_factory.create_presentation(&"domestic_goat", &"headless_goat", "Goat"))
|
||||||
|
assert_true("must be injected" in headless_factory.last_error)
|
||||||
|
|
||||||
|
var presentation_catalog := PresentationCatalog.new()
|
||||||
|
var only_goat := PresentationCatalog.create_core().get_definition(
|
||||||
|
PresentationCueIds.ANIMAL_DOMESTIC_GOAT
|
||||||
|
)
|
||||||
|
assert_true(
|
||||||
|
presentation_catalog.rebuild([only_goat] as Array[PresentationCueDefinition]).is_empty()
|
||||||
|
)
|
||||||
|
var factory := AnimalFactory.new(animal_catalog, presentation_catalog)
|
||||||
|
assert_null(factory.create_presentation(&"domestic_sheep", &"missing_sheep", "Sheep"))
|
||||||
|
assert_true("Unknown presentation cue 'animal.domestic_sheep'" in factory.last_error)
|
||||||
|
|
||||||
|
|
||||||
|
func _animal(animal_id: StringName, cue_id: StringName) -> AnimalDefinition:
|
||||||
|
var animal := AnimalDefinition.new()
|
||||||
|
animal.animal_definition_id = animal_id
|
||||||
|
animal.species_id = &"test_species"
|
||||||
|
animal.display_name = "Headless animal"
|
||||||
|
animal.supported_action_ids = []
|
||||||
|
animal.presentation_cue_id = cue_id
|
||||||
|
return animal
|
||||||
|
|
||||||
|
|
||||||
|
func _cue(cue_id: StringName, scene_path: String) -> PresentationCueDefinition:
|
||||||
|
var cue := PresentationCueDefinition.new()
|
||||||
|
cue.cue_id = cue_id
|
||||||
|
cue.scene_path = scene_path
|
||||||
|
return cue
|
||||||
|
|
||||||
|
|
||||||
|
func _resource_has_asset_path_property(resource: Resource) -> bool:
|
||||||
|
for property in resource.get_property_list():
|
||||||
|
if (int(property["usage"]) & PROPERTY_USAGE_SCRIPT_VARIABLE) == 0:
|
||||||
|
continue
|
||||||
|
var property_name := String(property["name"])
|
||||||
|
if property_name.ends_with("_path") or property_name in ["scene", "icon", "material"]:
|
||||||
|
return true
|
||||||
|
return false
|
||||||
|
|
||||||
|
|
||||||
|
func _has_error(errors: Array[String], fragment: String) -> bool:
|
||||||
|
for error in errors:
|
||||||
|
if fragment in error:
|
||||||
|
return true
|
||||||
|
return false
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
uid://7leb0ri32j4u
|
||||||
@@ -10,6 +10,7 @@ signal navigation_failed(animal_id: StringName, site_id: StringName)
|
|||||||
|
|
||||||
static var _all: Array[AnimalNode] = []
|
static var _all: Array[AnimalNode] = []
|
||||||
static var _core_animal_catalog: AnimalCatalog
|
static var _core_animal_catalog: AnimalCatalog
|
||||||
|
static var _core_presentation_catalog: PresentationCatalog
|
||||||
|
|
||||||
@export var animal_id: StringName
|
@export var animal_id: StringName
|
||||||
@export var display_name := "Animal"
|
@export var display_name := "Animal"
|
||||||
@@ -39,6 +40,7 @@ var navigation_request_id := 0
|
|||||||
var path_pending := false
|
var path_pending := false
|
||||||
var has_reported_navigation_result := true
|
var has_reported_navigation_result := true
|
||||||
var _animal_definition: AnimalDefinition
|
var _animal_definition: AnimalDefinition
|
||||||
|
var _presentation_parameters: Dictionary = {}
|
||||||
|
|
||||||
|
|
||||||
func _ready() -> void:
|
func _ready() -> void:
|
||||||
@@ -132,7 +134,9 @@ func supports_action(action_id: StringName) -> bool:
|
|||||||
return action_id in supported_action_ids
|
return action_id in supported_action_ids
|
||||||
|
|
||||||
|
|
||||||
func apply_animal_definition(definition: AnimalDefinition) -> bool:
|
func apply_animal_definition(
|
||||||
|
definition: AnimalDefinition, presentation_parameters: Dictionary = {}
|
||||||
|
) -> bool:
|
||||||
if definition == null or not definition.validate().is_empty():
|
if definition == null or not definition.validate().is_empty():
|
||||||
return false
|
return false
|
||||||
if state != null and state.get_animal_definition_id() != definition.animal_definition_id:
|
if state != null and state.get_animal_definition_id() != definition.animal_definition_id:
|
||||||
@@ -142,9 +146,10 @@ func apply_animal_definition(definition: AnimalDefinition) -> bool:
|
|||||||
species_id = definition.species_id
|
species_id = definition.species_id
|
||||||
supported_action_ids = definition.supported_action_ids.duplicate()
|
supported_action_ids = definition.supported_action_ids.duplicate()
|
||||||
move_speed = definition.movement_speed
|
move_speed = definition.movement_speed
|
||||||
|
_presentation_parameters = presentation_parameters.duplicate(true)
|
||||||
var visual := get_node_or_null("Visual")
|
var visual := get_node_or_null("Visual")
|
||||||
if visual != null and visual.has_method("apply_animal_definition"):
|
if visual != null and visual.has_method("apply_animal_definition"):
|
||||||
return bool(visual.apply_animal_definition(definition))
|
return bool(visual.apply_animal_definition(definition, _presentation_parameters))
|
||||||
return true
|
return true
|
||||||
|
|
||||||
|
|
||||||
@@ -164,7 +169,17 @@ func _ensure_animal_definition() -> bool:
|
|||||||
_core_animal_catalog = AnimalCatalog.create_core()
|
_core_animal_catalog = AnimalCatalog.create_core()
|
||||||
if _core_animal_catalog == null:
|
if _core_animal_catalog == null:
|
||||||
return false
|
return false
|
||||||
return apply_animal_definition(_core_animal_catalog.get_definition(animal_definition_id))
|
var definition := _core_animal_catalog.get_definition(animal_definition_id)
|
||||||
|
if _core_presentation_catalog == null:
|
||||||
|
_core_presentation_catalog = PresentationCatalog.create_core()
|
||||||
|
if (
|
||||||
|
definition == null
|
||||||
|
or _core_presentation_catalog == null
|
||||||
|
or not _core_presentation_catalog.is_valid()
|
||||||
|
):
|
||||||
|
return false
|
||||||
|
var cue := _core_presentation_catalog.get_definition(definition.presentation_cue_id)
|
||||||
|
return cue != null and apply_animal_definition(definition, cue.get_parameters())
|
||||||
|
|
||||||
|
|
||||||
func get_hunger() -> float:
|
func get_hunger() -> float:
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ var body_base_scale: Vector3
|
|||||||
var fluff_front_base_scale: Vector3
|
var fluff_front_base_scale: Vector3
|
||||||
var fluff_back_base_scale: Vector3
|
var fluff_back_base_scale: Vector3
|
||||||
var _animal_definition: AnimalDefinition
|
var _animal_definition: AnimalDefinition
|
||||||
|
var _presentation_parameters: Dictionary = {}
|
||||||
|
|
||||||
|
|
||||||
func _ready() -> void:
|
func _ready() -> void:
|
||||||
@@ -40,10 +41,13 @@ func _ready() -> void:
|
|||||||
reset_transient_feedback()
|
reset_transient_feedback()
|
||||||
|
|
||||||
|
|
||||||
func apply_animal_definition(definition: AnimalDefinition) -> bool:
|
func apply_animal_definition(
|
||||||
|
definition: AnimalDefinition, presentation_parameters: Dictionary = {}
|
||||||
|
) -> bool:
|
||||||
if definition == null or not definition.validate().is_empty():
|
if definition == null or not definition.validate().is_empty():
|
||||||
return false
|
return false
|
||||||
_animal_definition = definition
|
_animal_definition = definition
|
||||||
|
_presentation_parameters = presentation_parameters.duplicate(true)
|
||||||
if is_node_ready():
|
if is_node_ready():
|
||||||
_apply_definition_presentation()
|
_apply_definition_presentation()
|
||||||
return true
|
return true
|
||||||
@@ -52,13 +56,18 @@ func apply_animal_definition(definition: AnimalDefinition) -> bool:
|
|||||||
func _apply_definition_presentation() -> void:
|
func _apply_definition_presentation() -> void:
|
||||||
if _animal_definition == null:
|
if _animal_definition == null:
|
||||||
return
|
return
|
||||||
scale = _animal_definition.visual_scale
|
scale = _parameter_vector("visual_scale", Vector3.ONE)
|
||||||
body.scale = body_base_scale * _animal_definition.body_scale
|
body.scale = body_base_scale * _parameter_vector("body_scale", Vector3.ONE)
|
||||||
fluff_front.scale = fluff_front_base_scale * _animal_definition.fluff_scale
|
fluff_front.scale = fluff_front_base_scale * _parameter_vector("fluff_scale", Vector3.ONE)
|
||||||
fluff_back.scale = fluff_back_base_scale * _animal_definition.fluff_scale
|
fluff_back.scale = fluff_back_base_scale * _parameter_vector("fluff_scale", Vector3.ONE)
|
||||||
horn_left.visible = _animal_definition.show_horns
|
horn_left.visible = bool(_presentation_parameters.get("show_horns", true))
|
||||||
horn_right.visible = _animal_definition.show_horns
|
horn_right.visible = bool(_presentation_parameters.get("show_horns", true))
|
||||||
beard.visible = _animal_definition.show_beard
|
beard.visible = bool(_presentation_parameters.get("show_beard", true))
|
||||||
|
|
||||||
|
|
||||||
|
func _parameter_vector(key: String, fallback: Vector3) -> Vector3:
|
||||||
|
var value: Variant = _presentation_parameters.get(key, fallback)
|
||||||
|
return value as Vector3 if value is Vector3 else fallback
|
||||||
|
|
||||||
|
|
||||||
func _process(delta: float) -> void:
|
func _process(delta: float) -> void:
|
||||||
|
|||||||
@@ -0,0 +1,84 @@
|
|||||||
|
class_name PresentationCatalog
|
||||||
|
extends RefCounted
|
||||||
|
|
||||||
|
const CORE_CATALOG_PATH := "res://world/presentation/catalogs/core_presentation.tres"
|
||||||
|
|
||||||
|
var _valid := false
|
||||||
|
var _errors: Array[String] = []
|
||||||
|
var _definitions: Array[PresentationCueDefinition] = []
|
||||||
|
var _definitions_by_id: Dictionary = {}
|
||||||
|
|
||||||
|
|
||||||
|
static func create_core(validate_assets := true) -> PresentationCatalog:
|
||||||
|
var catalog := PresentationCatalog.new()
|
||||||
|
var resource := load(CORE_CATALOG_PATH) as PresentationCatalogResource
|
||||||
|
if resource == null:
|
||||||
|
catalog._errors = ["Core presentation catalog failed to load"]
|
||||||
|
return catalog
|
||||||
|
catalog.rebuild(resource.cues, validate_assets)
|
||||||
|
return catalog
|
||||||
|
|
||||||
|
|
||||||
|
func rebuild(
|
||||||
|
definitions: Array[PresentationCueDefinition], validate_assets := true
|
||||||
|
) -> Array[String]:
|
||||||
|
_valid = false
|
||||||
|
_errors.clear()
|
||||||
|
_definitions.clear()
|
||||||
|
_definitions_by_id.clear()
|
||||||
|
var ordered := definitions.duplicate()
|
||||||
|
ordered.sort_custom(_definition_before)
|
||||||
|
for definition in ordered:
|
||||||
|
if definition == null:
|
||||||
|
_errors.append("Presentation catalog contains a null cue")
|
||||||
|
continue
|
||||||
|
for error in (
|
||||||
|
definition.validate_assets() if validate_assets else definition.validate_metadata()
|
||||||
|
):
|
||||||
|
_errors.append("Cue '%s': %s" % [definition.cue_id, error])
|
||||||
|
if _definitions_by_id.has(definition.cue_id):
|
||||||
|
_errors.append("Duplicate presentation cue '%s'" % definition.cue_id)
|
||||||
|
else:
|
||||||
|
_definitions_by_id[definition.cue_id] = definition
|
||||||
|
_definitions.append(definition)
|
||||||
|
_errors.sort()
|
||||||
|
if not _errors.is_empty():
|
||||||
|
_definitions.clear()
|
||||||
|
_definitions_by_id.clear()
|
||||||
|
return get_errors()
|
||||||
|
_valid = true
|
||||||
|
return []
|
||||||
|
|
||||||
|
|
||||||
|
func is_valid() -> bool:
|
||||||
|
return _valid
|
||||||
|
|
||||||
|
|
||||||
|
func get_errors() -> Array[String]:
|
||||||
|
return _errors.duplicate()
|
||||||
|
|
||||||
|
|
||||||
|
func get_definition(cue_id: StringName) -> PresentationCueDefinition:
|
||||||
|
return _definitions_by_id.get(cue_id) as PresentationCueDefinition
|
||||||
|
|
||||||
|
|
||||||
|
func get_cue_ids() -> Array[StringName]:
|
||||||
|
var result: Array[StringName] = []
|
||||||
|
for definition in _definitions:
|
||||||
|
result.append(definition.cue_id)
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
func instantiate_scene(cue_id: StringName) -> Node:
|
||||||
|
var definition := get_definition(cue_id)
|
||||||
|
return definition.instantiate_scene() if definition != null else null
|
||||||
|
|
||||||
|
|
||||||
|
func _definition_before(
|
||||||
|
first: PresentationCueDefinition, second: PresentationCueDefinition
|
||||||
|
) -> bool:
|
||||||
|
if first == null:
|
||||||
|
return second != null
|
||||||
|
if second == null:
|
||||||
|
return false
|
||||||
|
return String(first.cue_id) < String(second.cue_id)
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
uid://dd1bsnixqs5hr
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
class_name PresentationCatalogResource
|
||||||
|
extends Resource
|
||||||
|
|
||||||
|
@export var cues: Array[PresentationCueDefinition] = []
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
uid://g61fe7gl8hqo
|
||||||
@@ -0,0 +1,89 @@
|
|||||||
|
class_name PresentationCueDefinition
|
||||||
|
extends Resource
|
||||||
|
|
||||||
|
const ALLOWED_PARAMETER_KEYS := {
|
||||||
|
"visual_scale": true,
|
||||||
|
"body_scale": true,
|
||||||
|
"fluff_scale": true,
|
||||||
|
"show_horns": true,
|
||||||
|
"show_beard": true,
|
||||||
|
}
|
||||||
|
|
||||||
|
@export var cue_id: StringName
|
||||||
|
@export_file("*.tscn") var scene_path: String
|
||||||
|
@export_file var icon_path: String
|
||||||
|
@export_file var material_path: String
|
||||||
|
@export var animation_hook_id: StringName
|
||||||
|
@export var parameters: Dictionary = {}
|
||||||
|
|
||||||
|
|
||||||
|
func validate_metadata() -> Array[String]:
|
||||||
|
var errors: Array[String] = []
|
||||||
|
if not AnimalDefinition.is_valid_stable_id(cue_id):
|
||||||
|
errors.append("cue_id is invalid")
|
||||||
|
_validate_optional_path(scene_path, "scene_path", ["tscn", "scn"], errors)
|
||||||
|
_validate_optional_path(icon_path, "icon_path", ["png", "svg", "webp"], errors)
|
||||||
|
_validate_optional_path(material_path, "material_path", ["tres", "res", "material"], errors)
|
||||||
|
if (
|
||||||
|
not animation_hook_id.is_empty()
|
||||||
|
and not AnimalDefinition.is_valid_stable_id(animation_hook_id)
|
||||||
|
):
|
||||||
|
errors.append("animation_hook_id is invalid for '%s'" % cue_id)
|
||||||
|
_validate_parameters(errors)
|
||||||
|
return errors
|
||||||
|
|
||||||
|
|
||||||
|
func validate_assets() -> Array[String]:
|
||||||
|
var errors := validate_metadata()
|
||||||
|
if not errors.is_empty():
|
||||||
|
return errors
|
||||||
|
for path_field in [
|
||||||
|
["scene_path", scene_path, "PackedScene"],
|
||||||
|
["icon_path", icon_path, "Texture2D"],
|
||||||
|
["material_path", material_path, "Material"],
|
||||||
|
]:
|
||||||
|
var path: String = path_field[1]
|
||||||
|
if not path.is_empty() and not ResourceLoader.exists(path, path_field[2]):
|
||||||
|
errors.append("%s does not resolve for '%s'" % [path_field[0], cue_id])
|
||||||
|
return errors
|
||||||
|
|
||||||
|
|
||||||
|
func get_parameters() -> Dictionary:
|
||||||
|
return parameters.duplicate(true)
|
||||||
|
|
||||||
|
|
||||||
|
func instantiate_scene() -> Node:
|
||||||
|
if scene_path.is_empty():
|
||||||
|
return null
|
||||||
|
var scene := load(scene_path) as PackedScene
|
||||||
|
return scene.instantiate() if scene != null else null
|
||||||
|
|
||||||
|
|
||||||
|
func _validate_parameters(errors: Array[String]) -> void:
|
||||||
|
for raw_key in parameters:
|
||||||
|
if raw_key is not String and raw_key is not StringName:
|
||||||
|
errors.append("parameter key must be a string for '%s'" % cue_id)
|
||||||
|
continue
|
||||||
|
var key := String(raw_key)
|
||||||
|
if not ALLOWED_PARAMETER_KEYS.has(key):
|
||||||
|
errors.append("unsupported parameter '%s' for '%s'" % [key, cue_id])
|
||||||
|
continue
|
||||||
|
var value: Variant = parameters[raw_key]
|
||||||
|
if key.ends_with("_scale"):
|
||||||
|
if value is not Vector3 or not _is_positive_vector(value):
|
||||||
|
errors.append("parameter '%s' must be a positive Vector3 for '%s'" % [key, cue_id])
|
||||||
|
elif value is not bool:
|
||||||
|
errors.append("parameter '%s' must be bool for '%s'" % [key, cue_id])
|
||||||
|
|
||||||
|
|
||||||
|
func _validate_optional_path(
|
||||||
|
path: String, field: String, extensions: Array[String], errors: Array[String]
|
||||||
|
) -> void:
|
||||||
|
if path.is_empty():
|
||||||
|
return
|
||||||
|
if not path.begins_with("res://") or path.get_extension().to_lower() not in extensions:
|
||||||
|
errors.append("%s is invalid for '%s'" % [field, cue_id])
|
||||||
|
|
||||||
|
|
||||||
|
func _is_positive_vector(value: Vector3) -> bool:
|
||||||
|
return value.is_finite() and value.x > 0.0 and value.y > 0.0 and value.z > 0.0
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
uid://bqm8k67sgvnb0
|
||||||
@@ -53,7 +53,7 @@ func get_pending_ids() -> Array[StringName]:
|
|||||||
var result: Array[StringName] = []
|
var result: Array[StringName] = []
|
||||||
for stable_id in _pending_by_id:
|
for stable_id in _pending_by_id:
|
||||||
result.append(StringName(stable_id))
|
result.append(StringName(stable_id))
|
||||||
result.sort()
|
result.sort_custom(_stable_id_before)
|
||||||
return result
|
return result
|
||||||
|
|
||||||
|
|
||||||
@@ -61,7 +61,7 @@ func get_in_flight_ids() -> Array[StringName]:
|
|||||||
var result: Array[StringName] = []
|
var result: Array[StringName] = []
|
||||||
for stable_id in _in_flight_ids:
|
for stable_id in _in_flight_ids:
|
||||||
result.append(StringName(stable_id))
|
result.append(StringName(stable_id))
|
||||||
result.sort()
|
result.sort_custom(_stable_id_before)
|
||||||
return result
|
return result
|
||||||
|
|
||||||
|
|
||||||
@@ -75,3 +75,7 @@ func _request_before(first: Dictionary, second: Dictionary) -> bool:
|
|||||||
if int(first["priority"]) != int(second["priority"]):
|
if int(first["priority"]) != int(second["priority"]):
|
||||||
return int(first["priority"]) > int(second["priority"])
|
return int(first["priority"]) > int(second["priority"])
|
||||||
return String(first["stable_id"]) < String(second["stable_id"])
|
return String(first["stable_id"]) < String(second["stable_id"])
|
||||||
|
|
||||||
|
|
||||||
|
func _stable_id_before(first: StringName, second: StringName) -> bool:
|
||||||
|
return String(first) < String(second)
|
||||||
|
|||||||
@@ -0,0 +1,16 @@
|
|||||||
|
[gd_resource type="Resource" script_class="PresentationCueDefinition" load_steps=2 format=3]
|
||||||
|
|
||||||
|
[ext_resource type="Script" path="res://world/presentation/PresentationCueDefinition.gd" id="1_cue"]
|
||||||
|
|
||||||
|
[resource]
|
||||||
|
script = ExtResource("1_cue")
|
||||||
|
cue_id = &"animal.domestic_goat"
|
||||||
|
scene_path = "res://world/animals/grazer/cozy_grazer.tscn"
|
||||||
|
animation_hook_id = &"grazer_routine"
|
||||||
|
parameters = {
|
||||||
|
"body_scale": Vector3(1, 1, 1),
|
||||||
|
"fluff_scale": Vector3(1, 1, 1),
|
||||||
|
"show_beard": true,
|
||||||
|
"show_horns": true,
|
||||||
|
"visual_scale": Vector3(1, 1, 1)
|
||||||
|
}
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
[gd_resource type="Resource" script_class="PresentationCueDefinition" load_steps=2 format=3]
|
||||||
|
|
||||||
|
[ext_resource type="Script" path="res://world/presentation/PresentationCueDefinition.gd" id="1_cue"]
|
||||||
|
|
||||||
|
[resource]
|
||||||
|
script = ExtResource("1_cue")
|
||||||
|
cue_id = &"animal.domestic_sheep"
|
||||||
|
scene_path = "res://world/animals/grazer/cozy_grazer.tscn"
|
||||||
|
animation_hook_id = &"grazer_routine"
|
||||||
|
parameters = {
|
||||||
|
"body_scale": Vector3(1.12, 1.08, 1.12),
|
||||||
|
"fluff_scale": Vector3(1.16, 1.14, 1.16),
|
||||||
|
"show_beard": false,
|
||||||
|
"show_horns": false,
|
||||||
|
"visual_scale": Vector3(1.08, 1.04, 1.08)
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
[gd_resource type="Resource" script_class="PresentationCatalogResource" load_steps=4 format=3]
|
||||||
|
|
||||||
|
[ext_resource type="Script" path="res://world/presentation/PresentationCatalogResource.gd" id="1_catalog"]
|
||||||
|
[ext_resource type="Resource" path="res://world/presentation/catalogs/animal_domestic_goat.tres" id="2_goat"]
|
||||||
|
[ext_resource type="Resource" path="res://world/presentation/catalogs/animal_domestic_sheep.tres" id="3_sheep"]
|
||||||
|
|
||||||
|
[resource]
|
||||||
|
script = ExtResource("1_catalog")
|
||||||
|
cues = Array[ExtResource("2_goat")]([ExtResource("2_goat"), ExtResource("3_sheep")])
|
||||||
Reference in New Issue
Block a user