diff --git a/destrum/CMakeLists.txt b/destrum/CMakeLists.txt index 25ae391..98e53ca 100644 --- a/destrum/CMakeLists.txt +++ b/destrum/CMakeLists.txt @@ -109,6 +109,9 @@ set(SRC_FILES "src/Assets/AssetManager.cpp" "src/Serialization/SceneSerializer.cpp" + "src/Serialization/ObjectSerialization.cpp" + "src/Serialization/PrefabSerializer.cpp" + "src/Serialization/PrefabCache.cpp" "src/Serialization/ComponentRegistry.cpp" "src/Util/DeltaTime.cpp" diff --git a/destrum/include/destrum/Scene/Scene.h b/destrum/include/destrum/Scene/Scene.h index 1d23a2e..78a82a5 100644 --- a/destrum/include/destrum/Scene/Scene.h +++ b/destrum/include/destrum/Scene/Scene.h @@ -27,11 +27,13 @@ struct SceneEnvironment { float skyboxRotation{0.0f}; }; class SceneSerializer; +class PrefabSerializer; class PhysicsWorld; class Scene final { friend Scene& SceneManager::CreateScene(const std::string& name); friend class SceneSerializer; + friend class PrefabSerializer; public: // void Add(std::shared_ptr object); diff --git a/destrum/include/destrum/Serialization/ObjectSerialization.h b/destrum/include/destrum/Serialization/ObjectSerialization.h new file mode 100644 index 0000000..9ee8e5a --- /dev/null +++ b/destrum/include/destrum/Serialization/ObjectSerialization.h @@ -0,0 +1,44 @@ +#ifndef DESTRUM_OBJECTSERIALIZATION_H +#define DESTRUM_OBJECTSERIALIZATION_H + +#include +#include +#include +#include +#include + +#include + +#include + +class GameObject; +class Scene; + +namespace ObjectSerialization { + using ObjectMap = std::unordered_map; + using ValidationError = std::optional; + + constexpr int CurrentObjectVersion = 2; + + [[nodiscard]] std::optional FindNonFinite(const nlohmann::json& value, const std::string& path); + + [[nodiscard]] ValidationError ValidateObjects(const nlohmann::json& objects, int version); + + void WarnMissingAssets(const nlohmann::json& objects, const std::filesystem::path& source); + + [[nodiscard]] nlohmann::json SerializeObjects(const std::vector& objects); + + [[nodiscard]] std::vector CollectHierarchy(const GameObject& root); + + struct BuiltObjects { + std::vector objects; + ObjectMap idMap; + }; + + [[nodiscard]] BuiltObjects BuildObjects(Scene& scene, const nlohmann::json& objects, + const ObjectMap& externalObjects = {}); + + [[nodiscard]] bool WriteFileAtomically(const std::filesystem::path& path, const std::string& contents); +} + +#endif //DESTRUM_OBJECTSERIALIZATION_H diff --git a/destrum/include/destrum/Serialization/PrefabCache.h b/destrum/include/destrum/Serialization/PrefabCache.h new file mode 100644 index 0000000..67bc240 --- /dev/null +++ b/destrum/include/destrum/Serialization/PrefabCache.h @@ -0,0 +1,37 @@ +#ifndef DESTRUM_PREFABCACHE_H +#define DESTRUM_PREFABCACHE_H + +#include +#include +#include +#include +#include + +#include + +#include + +class PrefabCache final : public Singleton { + friend class Singleton; + +public: + [[nodiscard]] std::shared_ptr Get(std::string_view path); + void Invalidate(const std::filesystem::path& path); + void Clear(); + + [[nodiscard]] static std::filesystem::path ResolvePath(std::string_view path); + +private: + PrefabCache() = default; + + struct Entry { + std::shared_ptr prefab; + std::filesystem::file_time_type writeTime; + }; + + [[nodiscard]] static std::string MakeKey(const std::filesystem::path& path); + + std::unordered_map m_Prefabs; +}; + +#endif //DESTRUM_PREFABCACHE_H diff --git a/destrum/include/destrum/Serialization/PrefabSerializer.h b/destrum/include/destrum/Serialization/PrefabSerializer.h new file mode 100644 index 0000000..7a18ddb --- /dev/null +++ b/destrum/include/destrum/Serialization/PrefabSerializer.h @@ -0,0 +1,31 @@ +#ifndef DESTRUM_PREFABSERIALIZER_H +#define DESTRUM_PREFABSERIALIZER_H + +#include +#include +#include +#include + +#include + +class GameObject; +class Scene; +class Transform; + +class PrefabSerializer final { +public: + static constexpr int CurrentPrefabVersion = 1; + + [[nodiscard]] static std::optional Capture(const GameObject& root); + static bool Save(const GameObject& root, const std::filesystem::path& path); + + [[nodiscard]] static std::optional LoadFile(const std::filesystem::path& path); + [[nodiscard]] static std::optional Validate(const nlohmann::json& prefab); + + static GameObject* InstantiateFromJson(Scene& scene, const nlohmann::json& prefab, Transform* parent = nullptr); + static GameObject* Instantiate(Scene& scene, std::string_view path, Transform* parent = nullptr); + + static GameObject* Duplicate(GameObject& object); +}; + +#endif //DESTRUM_PREFABSERIALIZER_H diff --git a/destrum/src/Serialization/ObjectSerialization.cpp b/destrum/src/Serialization/ObjectSerialization.cpp new file mode 100644 index 0000000..3e1768a --- /dev/null +++ b/destrum/src/Serialization/ObjectSerialization.cpp @@ -0,0 +1,531 @@ +#include + +#include +#include +#include +#include +#include +#include + +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +using json = nlohmann::json; + +namespace { + using ObjectSerialization::ValidationError; + + [[nodiscard]] bool IsFiniteNumber(const json& value) { + if (!value.is_number()) { + return false; + } + + return std::isfinite(value.get()); + } + + [[nodiscard]] bool IsFiniteArray(const json& value, std::size_t size) { + if (!value.is_array() || value.size() != size) { + return false; + } + + for (const auto& element : value) { + if (!IsFiniteNumber(element)) { + return false; + } + } + + return true; + } + + [[nodiscard]] std::string DescribeObject(std::size_t index, const json& objectJson) { + std::string description = "objects[" + std::to_string(index) + "]"; + if (objectJson.is_object() && objectJson.contains("name") && objectJson.at("name").is_string()) { + description += " \"" + objectJson.at("name").get() + "\""; + } + return description; + } + + [[nodiscard]] ValidationError ValidateTransform(const json& objectJson, int version) { + if (!objectJson.contains("transform")) { + return std::nullopt; + } + + const auto& transform = objectJson.at("transform"); + if (!transform.is_object()) { + return "transform must be an object"; + } + + // Version 1 wrote an empty placeholder transform object. + if (version <= 1 && transform.empty()) { + return std::nullopt; + } + + for (const char* key : {"position", "rotation", "scale"}) { + if (!transform.contains(key)) { + return std::string{"transform is missing \""} + key + "\""; + } + } + + if (!IsFiniteArray(transform.at("position"), 3)) { + return "transform.position must be 3 finite numbers"; + } + if (!IsFiniteArray(transform.at("scale"), 3)) { + return "transform.scale must be 3 finite numbers"; + } + + const auto& rotation = transform.at("rotation"); + if (!IsFiniteArray(rotation, 3) && !IsFiniteArray(rotation, 4)) { + return "transform.rotation must be 3 (euler) or 4 (quaternion) finite numbers"; + } + return std::nullopt; + } + + [[nodiscard]] ValidationError ValidateComponents(const json& objectJson) { + if (!objectJson.contains("components")) { + return std::nullopt; + } + + const auto& components = objectJson.at("components"); + if (!components.is_array()) { + return "components must be an array"; + } + + for (std::size_t i = 0; i < components.size(); ++i) { + const auto& componentJson = components.at(i); + const std::string where = "components[" + std::to_string(i) + "]"; + + if (!componentJson.is_object() || !componentJson.contains("type") || + !componentJson.at("type").is_string()) { + return where + " must be an object with a string \"type\""; + } + + const std::string type = componentJson.at("type").get(); + const std::string named = where + " " + type; + + if (componentJson.contains("enabled") && !componentJson.at("enabled").is_boolean()) { + return named + ": \"enabled\" must be a boolean"; + } + + if (componentJson.contains("data")) { + const auto& data = componentJson.at("data"); + if (!data.is_object()) { + return named + ": \"data\" must be an object, got " + std::string{data.type_name()}; + } + if (auto bad = ObjectSerialization::FindNonFinite(data, "data")) { + return named + ": " + *bad + " is not a finite number"; + } + } + + if (!ComponentFactory::IsRegistered(type)) { + return named + ": unknown component type"; + } + } + return std::nullopt; + } + + [[nodiscard]] bool AssetFileExists(const std::string& path) { + if (path.empty() || PrimitiveMeshes::IsPrimitiveKey(path)) { + return true; + } + + std::filesystem::path fullPath; + if (path.find("://") != std::string::npos) { + try { + fullPath = AssetFS::GetInstance().GetFullPath(path); + } catch (const std::exception&) { + return false; + } + } else { + fullPath = path; + } + + return std::filesystem::exists(fullPath); + } + + void CollectHierarchyRecursive(const GameObject& object, std::vector& out, + std::unordered_set& visited) { + if (object.IsBeingDestroyed() || !visited.insert(&object).second) { + return; + } + + out.push_back(&object); + for (const Transform* child : object.GetTransform().GetChildren()) { + if (child != nullptr && child->GetOwner() != nullptr) { + CollectHierarchyRecursive(*child->GetOwner(), out, visited); + } + } + } +} + +namespace ObjectSerialization { + // Returns the JSON path of the first NaN/Inf, nlohmann would write it as null + std::optional FindNonFinite(const json& value, const std::string& path) { + if (value.is_number_float()) { + if (!std::isfinite(value.get())) { + return path; + } + return std::nullopt; + } + if (value.is_array()) { + for (std::size_t i = 0; i < value.size(); ++i) { + if (auto bad = FindNonFinite(value.at(i), path + "[" + std::to_string(i) + "]")) { + return bad; + } + } + return std::nullopt; + } + if (value.is_object()) { + for (auto it = value.begin(); it != value.end(); ++it) { + if (auto bad = FindNonFinite(it.value(), path + "." + it.key())) { + return bad; + } + } + } + return std::nullopt; + } + + ValidationError ValidateObjects(const json& objects, int version) { + if (!objects.is_array()) { + return "\"objects\" must be an array"; + } + + std::unordered_set ids; + std::unordered_map parents; + std::unordered_map descriptions; + + for (std::size_t index = 0; index < objects.size(); ++index) { + const auto& objectJson = objects.at(index); + const std::string where = DescribeObject(index, objectJson); + + if (!objectJson.is_object()) { + return where + " must be an object"; + } + if (!objectJson.contains("id") || !objectJson.at("id").is_number_unsigned()) { + return where + " must have a non-negative integer \"id\""; + } + + const ObjectId id = objectJson.at("id").get(); + if (id == InvalidObjectId || id >= std::numeric_limits::max() - 1) { + return where + " uses reserved id " + std::to_string(id); + } + if (!ids.insert(id).second) { + return where + " reuses id " + std::to_string(id); + } + descriptions.emplace(id, where); + + if (objectJson.contains("name") && !objectJson.at("name").is_string()) { + return where + ": \"name\" must be a string"; + } + if (objectJson.contains("active") && !objectJson.at("active").is_boolean()) { + return where + ": \"active\" must be a boolean"; + } + if (auto error = ValidateTransform(objectJson, version)) { + return where + ": " + *error; + } + + ObjectId parent = InvalidObjectId; + if (objectJson.contains("parent")) { + if (!objectJson.at("parent").is_number_unsigned()) { + return where + ": \"parent\" must be an object id"; + } + parent = objectJson.at("parent").get(); + } + if (parent == id) { + return where + " is its own parent"; + } + parents.emplace(id, parent); + + if (auto error = ValidateComponents(objectJson)) { + return where + " " + *error; + } + } + + for (const auto& [id, parent] : parents) { + if (parent != InvalidObjectId && !ids.contains(parent)) { + return descriptions.at(id) + " has missing parent id " + std::to_string(parent); + } + + std::unordered_set visited; + ObjectId current = id; + while (current != InvalidObjectId) { + if (!visited.insert(current).second) { + return descriptions.at(id) + " is part of a parent cycle"; + } + const auto parentIt = parents.find(current); + if (parentIt == parents.end()) { + return descriptions.at(id) + " has missing ancestor id " + std::to_string(current); + } + current = parentIt->second; + } + } + + return std::nullopt; + } + + void WarnMissingAssets(const json& objects, const std::filesystem::path& source) { + const auto report = [&](const std::string& objectName, const std::string& detail) { + spdlog::warn("Asset warning ({}): {} references \"{}\" which could not be found", + source.string(), objectName, detail); + }; + + for (const auto& objectJson : objects) { + const std::string objectName = objectJson.value("name", "GameObject"); + if (!objectJson.contains("components")) { + continue; + } + + for (const auto& componentJson : objectJson.at("components")) { + if (!componentJson.contains("data")) { + continue; + } + const auto& data = componentJson.at("data"); + + // MeshRendererComponent: meshKey / materialKey are asset cache keys. + for (const char* key : {"meshKey", "materialKey"}) { + if (data.contains(key) && data.at(key).is_string()) { + const std::string cacheKey = data.at(key).get(); + if (cacheKey.empty()) continue; + const auto asset = AssetReference::fromCacheKey(cacheKey); + // Runtime-only keys like "BulbMaterial#2" are not file paths + const bool looksLikePath = asset && + asset->path.find_first_of(":/\\") != std::string::npos; + if (looksLikePath && !AssetFileExists(asset->path)) { + report(objectName, cacheKey); + } + } + } + + // Generic: any nested object with a "path" field is an AssetReference. + std::function walk = [&](const json& node) { + if (node.is_object()) { + if (node.contains("path") && node.at("path").is_string()) { + const std::string path = node.at("path").get(); + if (!path.empty() && !AssetFileExists(path)) { + report(objectName, path); + } + } + for (auto it = node.begin(); it != node.end(); ++it) { + walk(it.value()); + } + } else if (node.is_array()) { + for (const auto& element : node) { + walk(element); + } + } + }; + walk(data); + } + } + } + + std::vector CollectHierarchy(const GameObject& root) { + std::vector objects; + std::unordered_set visited; + CollectHierarchyRecursive(root, objects, visited); + return objects; + } + + json SerializeObjects(const std::vector& objects) { + const std::unordered_set included(objects.begin(), objects.end()); + + json result = json::array(); + for (const GameObject* objectPtr : objects) { + if (objectPtr == nullptr || objectPtr->IsBeingDestroyed()) { + continue; + } + + const GameObject& object = *objectPtr; + if (object.GetComponent() != nullptr && + object.GetComponent() == nullptr) { + throw std::runtime_error("\"" + object.GetName() + "\" has a Rigidbody but no Collider"); + } + const Transform& transform = object.GetTransform(); + + json objectJson; + objectJson["id"] = object.GetId(); + objectJson["name"] = object.GetName(); + objectJson["active"] = object.IsActive(); + objectJson["transform"] = { + {"position", transform.GetLocalPosition()}, + {"rotation", transform.GetLocalRotation()}, + {"scale", transform.GetLocalScale()} + }; + + const Transform* parent = transform.GetParent(); + const GameObject* parentObject = parent != nullptr ? parent->GetOwner() : nullptr; + objectJson["parent"] = parentObject != nullptr && included.contains(parentObject) && + !parentObject->IsBeingDestroyed() + ? parentObject->GetId() + : InvalidObjectId; + + objectJson["components"] = json::array(); + for (const auto& componentPtr : object.GetComponents()) { + if (!componentPtr || componentPtr->IsBeingDestroyed()) { + continue; + } + + const Component& component = *componentPtr; + json data; + try { + data = component.Serialize(); + } catch (const std::exception& exception) { + throw std::runtime_error("failed to serialize " + component.GetTypeName() + + " on \"" + object.GetName() + "\": " + exception.what()); + } + + if (data.is_null()) { + data = json::object(); + } + if (auto bad = FindNonFinite(data, "data")) { + throw std::runtime_error(component.GetTypeName() + " on \"" + object.GetName() + + "\": " + *bad + " is NaN or Inf"); + } + + objectJson["components"].push_back({ + {"type", component.GetTypeName()}, + {"enabled", component.isEnabled()}, + {"data", std::move(data)} + }); + } + + result.push_back(std::move(objectJson)); + } + return result; + } + + BuiltObjects BuildObjects(Scene& scene, const json& objects, const ObjectMap& externalObjects) { + BuiltObjects built; + std::vector objectJsonList; + std::string context = "objects"; + + try { + for (const auto& objectJson : objects) { + const ObjectId id = objectJson.at("id").get(); + const std::string name = objectJson.value("name", "GameObject"); + const bool active = objectJson.value("active", true); + + // File ids are local to the file, every loaded object gets a fresh runtime id + GameObject* object = scene.CreateGameObject(name); + object->SetActive(active); + + built.objects.push_back(object); + built.idMap.emplace(id, object); + objectJsonList.push_back(&objectJson); + } + + for (std::size_t i = 0; i < objectJsonList.size(); ++i) { + const json& objectJson = *objectJsonList[i]; + GameObject* object = built.objects[i]; + context = "\"" + object->GetName() + "\" transform"; + + if (objectJson.contains("transform") && + !objectJson.at("transform").empty()) { + const auto& transformJson = objectJson.at("transform"); + const auto& rotation = transformJson.at("rotation"); + + object->GetTransform().SetLocalPosition(transformJson.at("position").get()); + + if (rotation.size() == 4) { + object->GetTransform().SetLocalRotation(rotation.get()); + } else { + // Version 1 scene files used three Euler angles in + // degrees. Continue reading that format safely. + object->GetTransform().SetLocalRotation(rotation.get()); + } + + object->GetTransform().SetLocalScale(transformJson.at("scale").get()); + } + + const ObjectId parentId = objectJson.value("parent", InvalidObjectId); + if (parentId != InvalidObjectId) { + object->GetTransform().SetParent(&built.idMap.at(parentId)->GetTransform(), false); + } + } + + for (std::size_t i = 0; i < objectJsonList.size(); ++i) { + const json& objectJson = *objectJsonList[i]; + GameObject* object = built.objects[i]; + if (!objectJson.contains("components")) { + continue; + } + + for (const auto& componentJson : objectJson.at("components")) { + const std::string type = componentJson.at("type").get(); + context = type + " on \"" + object->GetName() + "\""; + Component* component = ComponentFactory::Create(type, *object); + if (component == nullptr) { + throw std::runtime_error("unknown component type"); + } + + component->SetEnabled(componentJson.value("enabled", true)); + if (componentJson.contains("data")) { + component->Deserialize(componentJson.at("data")); + } + } + } + + ObjectMap referenceMap = built.idMap; + referenceMap.insert(externalObjects.begin(), externalObjects.end()); + + for (GameObject* object : built.objects) { + for (const auto& componentPtr : object->GetComponents()) { + if (componentPtr) { + context = componentPtr->GetTypeName() + " on \"" + object->GetName() + "\" references"; + componentPtr->ResolveReferences(referenceMap); + } + } + } + } catch (const std::exception& exception) { + for (GameObject* object : built.objects) { + object->Destroy(); + } + throw std::runtime_error(context + ": " + exception.what()); + } + + return built; + } + + bool WriteFileAtomically(const std::filesystem::path& path, const std::string& contents) { + std::filesystem::path tempPath = path; + tempPath += ".tmp"; + + { + std::ofstream file(tempPath, std::ios::binary | std::ios::trunc); + if (!file.is_open()) { + spdlog::error("Failed to open file for writing: {}", tempPath.string()); + return false; + } + + file << contents; + file.flush(); + if (!file.good()) { + spdlog::error("Failed to write file: {}", tempPath.string()); + file.close(); + std::error_code ignored; + std::filesystem::remove(tempPath, ignored); + return false; + } + } + + std::error_code error; + std::filesystem::rename(tempPath, path, error); + if (error) { + spdlog::error("Failed to replace file {}: {}", path.string(), error.message()); + std::error_code ignored; + std::filesystem::remove(tempPath, ignored); + return false; + } + return true; + } +} diff --git a/destrum/src/Serialization/PrefabCache.cpp b/destrum/src/Serialization/PrefabCache.cpp new file mode 100644 index 0000000..8012d55 --- /dev/null +++ b/destrum/src/Serialization/PrefabCache.cpp @@ -0,0 +1,59 @@ +#include + +#include + +#include +#include + +std::shared_ptr PrefabCache::Get(std::string_view path) { + std::filesystem::path fullPath; + try { + fullPath = ResolvePath(path); + } catch (const std::exception& exception) { + spdlog::error("Cannot resolve prefab path \"{}\": {}", path, exception.what()); + return nullptr; + } + + std::error_code error; + const auto writeTime = std::filesystem::last_write_time(fullPath, error); + if (error) { + spdlog::error("Prefab file not found: {}", fullPath.string()); + return nullptr; + } + + const std::string key = MakeKey(fullPath); + if (const auto it = m_Prefabs.find(key); it != m_Prefabs.end() && it->second.writeTime == writeTime) { + return it->second.prefab; + } + + auto prefab = PrefabSerializer::LoadFile(fullPath); + if (!prefab) { + m_Prefabs.erase(key); + return nullptr; + } + + auto shared = std::make_shared(std::move(*prefab)); + m_Prefabs[key] = Entry{shared, writeTime}; + return shared; +} + +void PrefabCache::Invalidate(const std::filesystem::path& path) { + m_Prefabs.erase(MakeKey(path)); +} + +void PrefabCache::Clear() { + m_Prefabs.clear(); +} + +std::filesystem::path PrefabCache::ResolvePath(std::string_view path) { + if (path.find("://") != std::string_view::npos) { + return AssetFS::GetInstance().GetFullPath(path); + } + return std::filesystem::path{path}; +} + +std::string PrefabCache::MakeKey(const std::filesystem::path& path) { + std::error_code error; + const auto absolute = std::filesystem::absolute(path, error); + return (error ? path : absolute).lexically_normal().generic_string(); +} diff --git a/destrum/src/Serialization/PrefabSerializer.cpp b/destrum/src/Serialization/PrefabSerializer.cpp new file mode 100644 index 0000000..74b5d8f --- /dev/null +++ b/destrum/src/Serialization/PrefabSerializer.cpp @@ -0,0 +1,190 @@ +#include + +#include + +#include + +#include +#include +#include +#include +#include +#include + +using json = nlohmann::json; + +namespace { + GameObject* InstantiateWith(Scene& scene, const json& prefab, Transform* parent, + const ObjectSerialization::ObjectMap& externalObjects) { + if (scene.IsBeingUnloaded()) { + spdlog::error("Cannot instantiate a prefab into a scene that is being unloaded: {}", scene.GetName()); + return nullptr; + } + if (auto error = PrefabSerializer::Validate(prefab)) { + spdlog::error("Cannot instantiate invalid prefab: {}", *error); + return nullptr; + } + + GameObject* parentObject = parent != nullptr ? parent->GetOwner() : nullptr; + if (parentObject != nullptr && parentObject->GetScene() != &scene) { + spdlog::error("Cannot instantiate a prefab under \"{}\", it belongs to another scene", + parentObject->GetName()); + return nullptr; + } + + try { + auto built = ObjectSerialization::BuildObjects(scene, prefab.at("objects"), externalObjects); + GameObject* root = built.objects.front(); + if (parent != nullptr) { + root->GetTransform().SetParent(parent, false); + } + return root; + } catch (const std::exception& exception) { + spdlog::error("Failed to instantiate prefab \"{}\" ({})", + prefab.value("name", "Prefab"), exception.what()); + return nullptr; + } + } +} + +std::optional PrefabSerializer::Capture(const GameObject& root) { + if (root.IsBeingDestroyed()) { + spdlog::error("Cannot capture a prefab from destroyed object \"{}\"", root.GetName()); + return std::nullopt; + } + + json prefab; + prefab["version"] = CurrentPrefabVersion; + prefab["name"] = root.GetName(); + try { + prefab["objects"] = ObjectSerialization::SerializeObjects(ObjectSerialization::CollectHierarchy(root)); + } catch (const std::exception& exception) { + spdlog::error("Cannot capture prefab \"{}\": {}", root.GetName(), exception.what()); + return std::nullopt; + } + + if (auto error = Validate(prefab)) { + spdlog::error("Cannot capture prefab \"{}\", it would not load back: {}", root.GetName(), *error); + return std::nullopt; + } + return prefab; +} + +bool PrefabSerializer::Save(const GameObject& root, const std::filesystem::path& path) { + const auto prefab = Capture(root); + if (!prefab) { + return false; + } + + std::string contents; + try { + contents = prefab->dump(4); + } catch (const std::exception& exception) { + spdlog::error("Failed to encode prefab \"{}\": {}", root.GetName(), exception.what()); + return false; + } + + if (!ObjectSerialization::WriteFileAtomically(path, contents)) { + return false; + } + PrefabCache::GetInstance().Invalidate(path); + return true; +} + +std::optional PrefabSerializer::LoadFile(const std::filesystem::path& path) { + std::ifstream file(path); + if (!file.is_open()) { + spdlog::error("Failed to open prefab file for reading: {}", path.string()); + return std::nullopt; + } + + json prefab; + try { + file >> prefab; + } catch (const std::exception& exception) { + spdlog::error("Failed to parse prefab file {}: {}", path.string(), exception.what()); + return std::nullopt; + } + + if (auto error = Validate(prefab)) { + spdlog::error("Invalid prefab data in {}: {}", path.string(), *error); + return std::nullopt; + } + + ObjectSerialization::WarnMissingAssets(prefab.at("objects"), path); + return prefab; +} + +std::optional PrefabSerializer::Validate(const json& prefab) { + RegisterEngineComponents(); + + if (!prefab.is_object()) { + return "root must be an object"; + } + if (!prefab.contains("version") || !prefab.at("version").is_number_integer()) { + return "\"version\" must be an integer"; + } + const int version = prefab.at("version").get(); + if (version < 1 || version > CurrentPrefabVersion) { + return "unsupported prefab version " + std::to_string(version); + } + if (prefab.contains("name") && !prefab.at("name").is_string()) { + return "\"name\" must be a string"; + } + if (!prefab.contains("objects") || !prefab.at("objects").is_array() || prefab.at("objects").empty()) { + return "prefab must contain a non-empty \"objects\" array"; + } + + const auto& objects = prefab.at("objects"); + if (auto error = ObjectSerialization::ValidateObjects(objects, ObjectSerialization::CurrentObjectVersion)) { + return error; + } + + for (std::size_t i = 0; i < objects.size(); ++i) { + const bool isRoot = objects.at(i).value("parent", InvalidObjectId) == InvalidObjectId; + if (i == 0 && !isRoot) { + return "objects[0] must be the prefab root"; + } + if (i != 0 && isRoot) { + return "objects[" + std::to_string(i) + "] is a second root, a prefab has exactly one"; + } + } + return std::nullopt; +} + +GameObject* PrefabSerializer::InstantiateFromJson(Scene& scene, const json& prefab, Transform* parent) { + return InstantiateWith(scene, prefab, parent, {}); +} + +GameObject* PrefabSerializer::Instantiate(Scene& scene, std::string_view path, Transform* parent) { + const auto prefab = PrefabCache::GetInstance().Get(path); + if (!prefab) { + return nullptr; + } + return InstantiateWith(scene, *prefab, parent, {}); +} + +GameObject* PrefabSerializer::Duplicate(GameObject& object) { + Scene* scene = object.GetScene(); + if (scene == nullptr) { + spdlog::error("Cannot duplicate \"{}\", it is not in a scene", object.GetName()); + return nullptr; + } + + const auto prefab = Capture(object); + if (!prefab) { + return nullptr; + } + + // References to objects outside the copied hierarchy keep pointing at the originals + ObjectSerialization::ObjectMap sceneObjects; + for (const auto* list : {&scene->m_objects, &scene->m_pendingAdditions}) { + for (const auto& sceneObject : *list) { + if (sceneObject && !sceneObject->IsBeingDestroyed()) { + sceneObjects.emplace(sceneObject->GetId(), sceneObject.get()); + } + } + } + + return InstantiateWith(*scene, *prefab, object.GetTransform().GetParent(), sceneObjects); +} diff --git a/destrum/src/Serialization/SceneSerializer.cpp b/destrum/src/Serialization/SceneSerializer.cpp index e6fdd56..5e1c25c 100644 --- a/destrum/src/Serialization/SceneSerializer.cpp +++ b/destrum/src/Serialization/SceneSerializer.cpp @@ -1,171 +1,26 @@ #include -#include -#include #include -#include -#include -#include -#include #include #include #include #include -#include #include #include -#include -#include #include #include -#include -#include -#include #include #include +#include using json = nlohmann::json; namespace { - using ValidationError = std::optional; + using ObjectSerialization::ValidationError; - constexpr int CurrentSceneVersion = 2; - - [[nodiscard]] bool IsFiniteNumber(const json& value) { - if (!value.is_number()) { - return false; - } - - return std::isfinite(value.get()); - } - - [[nodiscard]] bool IsFiniteArray(const json& value, std::size_t size) { - if (!value.is_array() || value.size() != size) { - return false; - } - - for (const auto& element : value) { - if (!IsFiniteNumber(element)) { - return false; - } - } - - return true; - } - - // Returns the JSON path of the first NaN/Inf, nlohmann would write it as null - [[nodiscard]] std::optional FindNonFinite(const json& value, const std::string& path) { - if (value.is_number_float()) { - if (!std::isfinite(value.get())) { - return path; - } - return std::nullopt; - } - if (value.is_array()) { - for (std::size_t i = 0; i < value.size(); ++i) { - if (auto bad = FindNonFinite(value.at(i), path + "[" + std::to_string(i) + "]")) { - return bad; - } - } - return std::nullopt; - } - if (value.is_object()) { - for (auto it = value.begin(); it != value.end(); ++it) { - if (auto bad = FindNonFinite(it.value(), path + "." + it.key())) { - return bad; - } - } - } - return std::nullopt; - } - - [[nodiscard]] std::string DescribeObject(std::size_t index, const json& objectJson) { - std::string description = "objects[" + std::to_string(index) + "]"; - if (objectJson.is_object() && objectJson.contains("name") && objectJson.at("name").is_string()) { - description += " \"" + objectJson.at("name").get() + "\""; - } - return description; - } - - [[nodiscard]] ValidationError ValidateTransform(const json& objectJson, int version) { - if (!objectJson.contains("transform")) { - return std::nullopt; - } - - const auto& transform = objectJson.at("transform"); - if (!transform.is_object()) { - return "transform must be an object"; - } - - // Version 1 wrote an empty placeholder transform object. - if (version <= 1 && transform.empty()) { - return std::nullopt; - } - - for (const char* key : {"position", "rotation", "scale"}) { - if (!transform.contains(key)) { - return std::string{"transform is missing \""} + key + "\""; - } - } - - if (!IsFiniteArray(transform.at("position"), 3)) { - return "transform.position must be 3 finite numbers"; - } - if (!IsFiniteArray(transform.at("scale"), 3)) { - return "transform.scale must be 3 finite numbers"; - } - - const auto& rotation = transform.at("rotation"); - if (!IsFiniteArray(rotation, 3) && !IsFiniteArray(rotation, 4)) { - return "transform.rotation must be 3 (euler) or 4 (quaternion) finite numbers"; - } - return std::nullopt; - } - - [[nodiscard]] ValidationError ValidateComponents(const json& objectJson) { - if (!objectJson.contains("components")) { - return std::nullopt; - } - - const auto& components = objectJson.at("components"); - if (!components.is_array()) { - return "components must be an array"; - } - - for (std::size_t i = 0; i < components.size(); ++i) { - const auto& componentJson = components.at(i); - const std::string where = "components[" + std::to_string(i) + "]"; - - if (!componentJson.is_object() || !componentJson.contains("type") || - !componentJson.at("type").is_string()) { - return where + " must be an object with a string \"type\""; - } - - const std::string type = componentJson.at("type").get(); - const std::string named = where + " " + type; - - if (componentJson.contains("enabled") && !componentJson.at("enabled").is_boolean()) { - return named + ": \"enabled\" must be a boolean"; - } - - if (componentJson.contains("data")) { - const auto& data = componentJson.at("data"); - if (!data.is_object()) { - return named + ": \"data\" must be an object, got " + std::string{data.type_name()}; - } - if (auto bad = FindNonFinite(data, "data")) { - return named + ": " + *bad + " is not a finite number"; - } - } - - if (!ComponentFactory::IsRegistered(type)) { - return named + ": unknown component type"; - } - } - return std::nullopt; - } + constexpr int CurrentSceneVersion = ObjectSerialization::CurrentObjectVersion; [[nodiscard]] ValidationError ValidateSceneJson(const json& root) { if (!root.is_object()) { @@ -194,186 +49,7 @@ namespace { return "\"currentCamera\" must be an object id"; } - std::unordered_set ids; - std::unordered_map parents; - std::unordered_map descriptions; - - const auto& objects = root.at("objects"); - for (std::size_t index = 0; index < objects.size(); ++index) { - const auto& objectJson = objects.at(index); - const std::string where = DescribeObject(index, objectJson); - - if (!objectJson.is_object()) { - return where + " must be an object"; - } - if (!objectJson.contains("id") || !objectJson.at("id").is_number_unsigned()) { - return where + " must have a non-negative integer \"id\""; - } - - const ObjectId id = objectJson.at("id").get(); - if (id == InvalidObjectId || id >= std::numeric_limits::max() - 1) { - return where + " uses reserved id " + std::to_string(id); - } - if (!ids.insert(id).second) { - return where + " reuses id " + std::to_string(id); - } - descriptions.emplace(id, where); - - if (objectJson.contains("name") && !objectJson.at("name").is_string()) { - return where + ": \"name\" must be a string"; - } - if (objectJson.contains("active") && !objectJson.at("active").is_boolean()) { - return where + ": \"active\" must be a boolean"; - } - if (auto error = ValidateTransform(objectJson, version)) { - return where + ": " + *error; - } - - ObjectId parent = InvalidObjectId; - if (objectJson.contains("parent")) { - if (!objectJson.at("parent").is_number_unsigned()) { - return where + ": \"parent\" must be an object id"; - } - parent = objectJson.at("parent").get(); - } - if (parent == id) { - return where + " is its own parent"; - } - parents.emplace(id, parent); - - if (auto error = ValidateComponents(objectJson)) { - return where + " " + *error; - } - } - - for (const auto& [id, parent] : parents) { - if (parent != InvalidObjectId && !ids.contains(parent)) { - return descriptions.at(id) + " has missing parent id " + std::to_string(parent); - } - - std::unordered_set visited; - ObjectId current = id; - while (current != InvalidObjectId) { - if (!visited.insert(current).second) { - return descriptions.at(id) + " is part of a parent cycle"; - } - const auto parentIt = parents.find(current); - if (parentIt == parents.end()) { - return descriptions.at(id) + " has missing ancestor id " + std::to_string(current); - } - current = parentIt->second; - } - } - - return std::nullopt; - } - - [[nodiscard]] bool AssetFileExists(const std::string& path) { - if (path.empty() || PrimitiveMeshes::IsPrimitiveKey(path)) { - return true; - } - - std::filesystem::path fullPath; - if (path.find("://") != std::string::npos) { - try { - fullPath = AssetFS::GetInstance().GetFullPath(path); - } catch (const std::exception&) { - return false; - } - } else { - fullPath = path; - } - - return std::filesystem::exists(fullPath); - } - - void ValidateAssetReferences(const json& root, const std::filesystem::path& scenePath) { - const auto report = [&](const std::string& objectName, const std::string& detail) { - spdlog::warn("Scene asset warning ({}): {} references \"{}\" which could not be found", - scenePath.string(), objectName, detail); - }; - - for (const auto& objectJson : root.at("objects")) { - const std::string objectName = objectJson.value("name", "GameObject"); - if (!objectJson.contains("components")) { - continue; - } - - for (const auto& componentJson : objectJson.at("components")) { - if (!componentJson.contains("data")) { - continue; - } - const auto& data = componentJson.at("data"); - - // MeshRendererComponent: meshKey / materialKey are asset cache keys. - for (const char* key : {"meshKey", "materialKey"}) { - if (data.contains(key) && data.at(key).is_string()) { - const std::string cacheKey = data.at(key).get(); - if (cacheKey.empty()) continue; - const auto asset = AssetReference::fromCacheKey(cacheKey); - // Runtime-only keys like "BulbMaterial#2" are not file paths - const bool looksLikePath = asset && - asset->path.find_first_of(":/\\") != std::string::npos; - if (looksLikePath && !AssetFileExists(asset->path)) { - report(objectName, cacheKey); - } - } - } - - // Generic: any nested object with a "path" field is an AssetReference. - std::function walk = [&](const json& node) { - if (node.is_object()) { - if (node.contains("path") && node.at("path").is_string()) { - const std::string path = node.at("path").get(); - if (!path.empty() && !AssetFileExists(path)) { - report(objectName, path); - } - } - for (auto it = node.begin(); it != node.end(); ++it) { - walk(it.value()); - } - } else if (node.is_array()) { - for (const auto& element : node) { - walk(element); - } - } - }; - walk(data); - } - } - } - - [[nodiscard]] bool WriteFileAtomically(const std::filesystem::path& path, const std::string& contents) { - std::filesystem::path tempPath = path; - tempPath += ".tmp"; - - { - std::ofstream file(tempPath, std::ios::binary | std::ios::trunc); - if (!file.is_open()) { - spdlog::error("Failed to open scene file for writing: {}", tempPath.string()); - return false; - } - - file << contents; - file.flush(); - if (!file.good()) { - spdlog::error("Failed to write scene file: {}", tempPath.string()); - file.close(); - std::error_code ignored; - std::filesystem::remove(tempPath, ignored); - return false; - } - } - - std::error_code error; - std::filesystem::rename(tempPath, path, error); - if (error) { - spdlog::error("Failed to replace scene file {}: {}", path.string(), error.message()); - std::error_code ignored; - std::filesystem::remove(tempPath, ignored); - return false; - } - return true; + return ObjectSerialization::ValidateObjects(root.at("objects"), version); } } @@ -408,72 +84,22 @@ bool SceneSerializer::Save(Scene& scene, const std::filesystem::path& path) { {"skyboxRotation", environment.skyboxRotation}, }; + std::vector objects; + objects.reserve(scene.GetObjects().size()); for (const auto& objectPtr : scene.GetObjects()) { - if (!objectPtr || objectPtr->IsBeingDestroyed()) { - continue; + if (objectPtr && !objectPtr->IsBeingDestroyed()) { + objects.push_back(objectPtr.get()); } - - const GameObject& object = *objectPtr; - if (object.GetComponent() != nullptr && - object.GetComponent() == nullptr) { - spdlog::error("Cannot save object with a Rigidbody but no Collider: {}", object.GetName()); - return false; - } - const Transform& transform = object.GetTransform(); - - json objectJson; - objectJson["id"] = object.GetId(); - objectJson["name"] = object.GetName(); - objectJson["active"] = object.IsActive(); - objectJson["transform"] = { - {"position", transform.GetLocalPosition()}, - {"rotation", transform.GetLocalRotation()}, - {"scale", transform.GetLocalScale()} - }; - - const Transform* parent = transform.GetParent(); - objectJson["parent"] = parent != nullptr && parent->GetOwner() != nullptr && - parent->GetOwner()->GetScene() == &scene && - !parent->GetOwner()->IsBeingDestroyed() - ? parent->GetOwner()->GetId() - : InvalidObjectId; - - objectJson["components"] = json::array(); - for (const auto& componentPtr : object.GetComponents()) { - if (!componentPtr || componentPtr->IsBeingDestroyed()) { - continue; - } - - const Component& component = *componentPtr; - json data; - try { - data = component.Serialize(); - } catch (const std::exception& exception) { - spdlog::error("Failed to serialize {} on \"{}\": {}", - component.GetTypeName(), object.GetName(), exception.what()); - return false; - } - - if (data.is_null()) { - data = json::object(); - } - if (auto bad = FindNonFinite(data, "data")) { - spdlog::error("Cannot save {} on \"{}\": {} is NaN or Inf", - component.GetTypeName(), object.GetName(), *bad); - return false; - } - - objectJson["components"].push_back({ - {"type", component.GetTypeName()}, - {"enabled", component.isEnabled()}, - {"data", std::move(data)} - }); - } - - root["objects"].push_back(std::move(objectJson)); } - if (auto bad = FindNonFinite(root, "scene")) { + try { + root["objects"] = ObjectSerialization::SerializeObjects(objects); + } catch (const std::exception& exception) { + spdlog::error("Cannot save scene \"{}\": {}", scene.GetName(), exception.what()); + return false; + } + + if (auto bad = ObjectSerialization::FindNonFinite(root, "scene")) { spdlog::error("Cannot save scene \"{}\": {} is NaN or Inf", scene.GetName(), *bad); return false; } @@ -492,7 +118,7 @@ bool SceneSerializer::Save(Scene& scene, const std::filesystem::path& path) { return false; } - return WriteFileAtomically(path, contents); + return ObjectSerialization::WriteFileAtomically(path, contents); } SceneSerializer::LoadResult SceneSerializer::LoadSceneFile(const std::filesystem::path& path) { @@ -520,7 +146,7 @@ SceneSerializer::LoadResult SceneSerializer::LoadSceneFile(const std::filesystem return result; } - ValidateAssetReferences(result.root, path); + ObjectSerialization::WarnMissingAssets(result.root.at("objects"), path); result.success = true; return result; } @@ -552,26 +178,12 @@ bool SceneSerializer::ConstructScene(Scene& scene, LoadResult& result, // No physics world is created for the staging scene - physics bodies are // registered directly in the live scene's world once loading succeeds. Scene stagingScene(scene.GetName(), nullptr); - std::unordered_map idMap; - std::vector objectJsonList; + ObjectSerialization::ObjectMap idMap; std::vector registeredObjects; std::string context = "scene"; try { - for (const auto& objectJson : root.at("objects")) { - const ObjectId id = objectJson.at("id").get(); - const std::string name = objectJson.value("name", "GameObject"); - const bool active = objectJson.value("active", true); - - // File ids are local to the file, every loaded object gets a fresh runtime id - GameObject* object = stagingScene.CreateGameObject(name); - object->SetActive(active); - - idMap.emplace(id, object); - objectJsonList.push_back(&objectJson); - } - - if (progressCallback) progressCallback(0.4f); + idMap = ObjectSerialization::BuildObjects(stagingScene, root.at("objects")).idMap; // Move objects from pending to objects list without physics registration // (staging scene has no physics world). @@ -582,73 +194,6 @@ bool SceneSerializer::ConstructScene(Scene& scene, LoadResult& result, } stagingScene.m_pendingAdditions.clear(); - for (const json* objectJson : objectJsonList) { - const ObjectId id = objectJson->at("id").get(); - GameObject* object = idMap.at(id); - context = "\"" + object->GetName() + "\" transform"; - - if (objectJson->contains("transform") && - !objectJson->at("transform").empty()) { - const auto& transformJson = objectJson->at("transform"); - const auto& rotation = transformJson.at("rotation"); - - object->GetTransform().SetLocalPosition(transformJson.at("position").get()); - - if (rotation.size() == 4) { - object->GetTransform().SetLocalRotation(rotation.get()); - } else { - // Version 1 scene files used three Euler angles in - // degrees. Continue reading that format safely. - object->GetTransform().SetLocalRotation(rotation.get()); - } - - object->GetTransform().SetLocalScale(transformJson.at("scale").get()); - } - - const ObjectId parentId = objectJson->value("parent", InvalidObjectId); - if (parentId != InvalidObjectId) { - object->GetTransform().SetParent(&idMap.at(parentId)->GetTransform(), false); - } - } - - if (progressCallback) progressCallback(0.5f); - - for (const json* objectJson : objectJsonList) { - GameObject* object = idMap.at(objectJson->at("id").get()); - if (!objectJson->contains("components")) { - continue; - } - - for (const auto& componentJson : objectJson->at("components")) { - const std::string type = componentJson.at("type").get(); - context = type + " on \"" + object->GetName() + "\""; - Component* component = ComponentFactory::Create(type, *object); - if (component == nullptr) { - throw std::runtime_error("unknown component type"); - } - - component->SetEnabled(componentJson.value("enabled", true)); - if (componentJson.contains("data")) { - component->Deserialize(componentJson.at("data")); - } - } - } - - if (progressCallback) progressCallback(0.6f); - - for (const auto& objectPtr : stagingScene.GetObjects()) { - if (!objectPtr) { - continue; - } - - for (const auto& componentPtr : objectPtr->GetComponents()) { - if (componentPtr) { - context = componentPtr->GetTypeName() + " on \"" + objectPtr->GetName() + "\" references"; - componentPtr->ResolveReferences(idMap); - } - } - } - if (progressCallback) progressCallback(0.7f); // Register the replacement bodies in the live physics world before diff --git a/destrum/src/Util/ImGuiUtils.cpp b/destrum/src/Util/ImGuiUtils.cpp index 8eec824..9c7ea7c 100644 --- a/destrum/src/Util/ImGuiUtils.cpp +++ b/destrum/src/Util/ImGuiUtils.cpp @@ -17,6 +17,7 @@ #include #include #include +#include namespace { [[nodiscard]] bool IsInspectableObject(const GameObject* object) { @@ -242,6 +243,14 @@ namespace ImGuiUtils { if (ImGui::Checkbox("Active", &active)) { selectedObject->SetActive(active); } + ImGui::SameLine(); + const bool duplicateShortcut = ImGui::IsWindowFocused(ImGuiFocusedFlags_RootAndChildWindows) && + ImGui::IsKeyChordPressed(ImGuiMod_Ctrl | ImGuiKey_D); + if (ImGui::Button("Duplicate") || duplicateShortcut) { + if (GameObject* duplicate = PrefabSerializer::Duplicate(*selectedObject)) { + selectedObjectId = duplicate->GetId(); + } + } const Transform* parent = selectedObject->GetTransform().GetParent(); if (parent != nullptr && parent->GetOwner() != nullptr) { diff --git a/lightkeeper/include/Lightkeeper.h b/lightkeeper/include/Lightkeeper.h index 8e2e4ab..066aa76 100644 --- a/lightkeeper/include/Lightkeeper.h +++ b/lightkeeper/include/Lightkeeper.h @@ -47,6 +47,8 @@ private: void saveCurrentScene(); void loadScene(); void createEmptyScene(); + void saveSelectedPrefab(); + void instantiatePrefab(); void findPlayer(); void submitDebugDraws(); void submitColliderDebugLines(); @@ -62,6 +64,7 @@ private: char scenePath[512]{"scenes/debug_scene.json"}; char newSceneName[128]{"EmptyScene"}; + char prefabPath[512]{"prefabs/selected.prefab"}; std::string sceneStatus; bool renderColliders{false}; bool render2DShapes{true}; diff --git a/lightkeeper/src/LightkeeperDebugUi.cpp b/lightkeeper/src/LightkeeperDebugUi.cpp index dec08c6..ef99243 100644 --- a/lightkeeper/src/LightkeeperDebugUi.cpp +++ b/lightkeeper/src/LightkeeperDebugUi.cpp @@ -9,7 +9,9 @@ #include #include #include +#include #include +#include #include #include @@ -120,6 +122,20 @@ void LightKeeper::drawDebugMenuBar() saveCurrentScene(); } + ImGui::Separator(); + ImGui::TextDisabled("Prefab"); + + ImGui::SetNextItemWidth(320.0f); + ImGui::InputText("Prefab path", prefabPath, sizeof(prefabPath)); + + const bool hasSelection = SceneManager::GetInstance().GetCurrentScene().GetSelectedObject() != nullptr; + if (ImGui::MenuItem("Save Selected as Prefab", nullptr, false, hasSelection)) { + saveSelectedPrefab(); + } + if (ImGui::MenuItem("Instantiate Prefab")) { + instantiatePrefab(); + } + if (!sceneStatus.empty()) { ImGui::Separator(); ImGui::TextWrapped("%s", sceneStatus.c_str()); @@ -270,6 +286,43 @@ void LightKeeper::saveCurrentScene() } } +void LightKeeper::saveSelectedPrefab() +{ + try { + const GameObject* selected = SceneManager::GetInstance().GetCurrentScene().GetSelectedObject(); + if (selected == nullptr) { + sceneStatus = "Select an object to save as a prefab"; + return; + } + + const auto path = ResolveScenePath(prefabPath, m_params.exeDir); + const auto parent = path.parent_path(); + if (!parent.empty()) { + std::filesystem::create_directories(parent); + } + + sceneStatus = PrefabSerializer::Save(*selected, path) + ? "Saved prefab \"" + selected->GetName() + "\" to " + path.string() + : "Failed to save prefab to " + path.string(); + } catch (const std::exception& exception) { + sceneStatus = std::string{"Prefab save failed: "} + exception.what(); + } +} + +void LightKeeper::instantiatePrefab() +{ + try { + const auto path = ResolveScenePath(prefabPath, m_params.exeDir); + const GameObject* instance = PrefabSerializer::Instantiate( + SceneManager::GetInstance().GetCurrentScene(), path.string()); + sceneStatus = instance != nullptr + ? "Instantiated \"" + instance->GetName() + "\" from " + path.string() + : "Failed to instantiate prefab " + path.string(); + } catch (const std::exception& exception) { + sceneStatus = std::string{"Prefab instantiate failed: "} + exception.what(); + } +} + void LightKeeper::loadScene() { try { diff --git a/lightkeeper/src/scenes/MainScene.cpp b/lightkeeper/src/scenes/MainScene.cpp index 0494be3..0650fef 100644 --- a/lightkeeper/src/scenes/MainScene.cpp +++ b/lightkeeper/src/scenes/MainScene.cpp @@ -31,6 +31,7 @@ #include #include #include +#include namespace { // The visible ground plane is at y = -1 @@ -268,11 +269,12 @@ namespace { float top; }; - void CreateWall(Scene& scene, GfxDevice& gfxDevice, RenderResources& resources, const std::string& name, + std::vector CreateWall(Scene& scene, GfxDevice& gfxDevice, RenderResources& resources, const std::string& name, const glm::vec3& floorCenter, float length, float height, bool alongX, MaterialID materialID, const std::optional& opening = std::nullopt) { constexpr float thickness = 0.3f; + std::vector pieces; const auto createPiece = [&](const std::string& suffix, float from, float to, float bottom, float top) { @@ -285,13 +287,14 @@ namespace { const glm::vec3 size = alongX ? glm::vec3{to - from, top - bottom, thickness} : glm::vec3{thickness, top - bottom, to - from}; - CreateBox(scene, gfxDevice, resources, name + suffix, center, size, materialID, RigidbodyType::Static); + pieces.push_back( + CreateBox(scene, gfxDevice, resources, name + suffix, center, size, materialID, RigidbodyType::Static)); }; const float halfLength = length * 0.5f; if (!opening) { createPiece("", -halfLength, halfLength, 0.0f, height); - return; + return pieces; } const float openingStart = opening->offset - opening->width * 0.5f; @@ -300,6 +303,7 @@ namespace { createPiece("_Right", openingEnd, halfLength, 0.0f, height); createPiece("_Below", openingStart, openingEnd, 0.0f, opening->bottom); createPiece("_Above", openingStart, openingEnd, opening->top, height); + return pieces; } GameObject* CreateCeilingLight(Scene& scene, GfxDevice& gfxDevice, RenderResources& resources, @@ -336,26 +340,40 @@ namespace { constexpr float doorOffset = -1.5f; constexpr float doorWidth = 1.2f; - CreateBox(scene, gfxDevice, resources, "House_Floor", center + glm::vec3{0.0f, 0.03f, 0.0f}, - {width, 0.06f, depth}, floorMaterial, RigidbodyType::Static); + GameObject* house = scene.CreateGameObject("House"); + house->GetTransform().SetWorldPosition(center); + const auto addToHouse = [&](GameObject* object) + { + object->GetTransform().SetParent(&house->GetTransform(), true); + }; + const auto addWallToHouse = [&](const std::vector& pieces) + { + for (GameObject* piece : pieces) { + addToHouse(piece); + } + }; - CreateWall(scene, gfxDevice, resources, "House_FrontWall", center + glm::vec3{0.0f, 0.0f, depth * 0.5f}, - width + 0.3f, wallHeight, true, wallMaterial, - WallOpening{.offset = doorOffset, .width = doorWidth, .bottom = 0.0f, .top = 2.2f}); - CreateWall(scene, gfxDevice, resources, "House_BackWall", center - glm::vec3{0.0f, 0.0f, depth * 0.5f}, - width + 0.3f, wallHeight, true, wallMaterial, - WallOpening{.offset = 1.5f, .width = 1.4f, .bottom = 1.0f, .top = 2.0f}); - CreateWall(scene, gfxDevice, resources, "House_LeftWall", center - glm::vec3{width * 0.5f, 0.0f, 0.0f}, - depth - 0.3f, wallHeight, false, wallMaterial); - CreateWall(scene, gfxDevice, resources, "House_RightWall", center + glm::vec3{width * 0.5f, 0.0f, 0.0f}, - depth - 0.3f, wallHeight, false, wallMaterial, - WallOpening{.offset = 0.0f, .width = 1.6f, .bottom = 1.0f, .top = 2.0f}); + addToHouse(CreateBox(scene, gfxDevice, resources, "House_Floor", center + glm::vec3{0.0f, 0.03f, 0.0f}, + {width, 0.06f, depth}, floorMaterial, RigidbodyType::Static)); - CreateBox(scene, gfxDevice, resources, "House_Roof", center + glm::vec3{0.0f, wallHeight + 0.15f, 0.0f}, - {width + 0.8f, 0.3f, depth + 0.8f}, roofMaterial, RigidbodyType::Static); + addWallToHouse(CreateWall(scene, gfxDevice, resources, "House_FrontWall", center + glm::vec3{0.0f, 0.0f, depth * 0.5f}, + width + 0.3f, wallHeight, true, wallMaterial, + WallOpening{.offset = doorOffset, .width = doorWidth, .bottom = 0.0f, .top = 2.2f})); + addWallToHouse(CreateWall(scene, gfxDevice, resources, "House_BackWall", center - glm::vec3{0.0f, 0.0f, depth * 0.5f}, + width + 0.3f, wallHeight, true, wallMaterial, + WallOpening{.offset = 1.5f, .width = 1.4f, .bottom = 1.0f, .top = 2.0f})); + addWallToHouse(CreateWall(scene, gfxDevice, resources, "House_LeftWall", center - glm::vec3{width * 0.5f, 0.0f, 0.0f}, + depth - 0.3f, wallHeight, false, wallMaterial)); + addWallToHouse(CreateWall(scene, gfxDevice, resources, "House_RightWall", center + glm::vec3{width * 0.5f, 0.0f, 0.0f}, + depth - 0.3f, wallHeight, false, wallMaterial, + WallOpening{.offset = 0.0f, .width = 1.6f, .bottom = 1.0f, .top = 2.0f})); - const GameObject* ceilingLight = + addToHouse(CreateBox(scene, gfxDevice, resources, "House_Roof", center + glm::vec3{0.0f, wallHeight + 0.15f, 0.0f}, + {width + 0.8f, 0.3f, depth + 0.8f}, roofMaterial, RigidbodyType::Static)); + + GameObject* ceilingLight = CreateCeilingLight(scene, gfxDevice, resources, center + glm::vec3{0.0f, wallHeight - 0.06f, 0.0f}); + addToHouse(ceilingLight); // Inside, next to the door Material switchMaterial = Material::SimpleColor(glm::vec3{0.9f}, "LightSwitchMaterial"); @@ -369,6 +387,7 @@ namespace { auto* switchButton = lightSwitch->AddComponent(); switchButton->SetCycleColors(false); switchButton->AddTarget(*ceilingLight); + addToHouse(lightSwitch); GameObject* sofa = ModelSpawner::SpawnStaticModel( scene, gfxDevice, resources, "game://sofa.glb", StaticModelOptions(), @@ -377,6 +396,7 @@ namespace { sofa->GetTransform().SetWorldPosition({-3.7, -1, -17.5}); sofa->GetTransform().SetWorldRotation({180, 0, 180}); sofa->GetTransform().SetWorldScale({3, 3, 3}); + addToHouse(sofa); } void CreateFlightHelmet(Scene& scene, GfxDevice& gfxDevice, RenderResources& resources) diff --git a/tests/destrum_tests.cpp b/tests/destrum_tests.cpp index 5ac1a1f..f6e978c 100644 --- a/tests/destrum_tests.cpp +++ b/tests/destrum_tests.cpp @@ -25,6 +25,8 @@ #include #include #include +#include +#include #include #include #include @@ -1615,6 +1617,134 @@ namespace { SceneManager::GetInstance().Destroy(); } + + GameObject* buildTurret(Scene& scene, const GameObject& door) + { + GameObject* turret = scene.CreateGameObject("turret"); + turret->GetTransform().SetLocalPosition(glm::vec3{5.0f, 0.0f, 0.0f}); + GameObject* barrel = scene.CreateGameObject("barrel"); + barrel->GetTransform().SetParent(&turret->GetTransform(), false); + barrel->GetTransform().SetLocalPosition(glm::vec3{0.0f, 1.0f, 0.0f}); + barrel->AddComponent(); + + auto* button = turret->AddComponent(); + button->SetCycleColors(false); + button->AddTarget(*barrel); + button->AddTarget(door); + return turret; + } + + GameObject* onlyChild(GameObject& object) + { + const auto& children = object.GetTransform().GetChildren(); + return children.size() == 1 ? children.front()->GetOwner() : nullptr; + } + + void testPrefabRoundTrip() + { + ComponentFactory::Register("InteractableButton"); + ComponentFactory::Register("TestActivatable"); + + Scene& source = SceneManager::GetInstance().CreateScene("prefab source"); + GameObject* door = source.CreateGameObject("door"); + door->AddComponent(); + GameObject* turret = buildTurret(source, *door); + source.CommitPendingAdditions(); + + const auto path = std::filesystem::temp_directory_path() / "destrum_turret.prefab"; + check(PrefabSerializer::Save(*turret, path), "a hierarchy must save as a prefab"); + + Scene& target = SceneManager::GetInstance().CreateScene("prefab target"); + GameObject* holder = target.CreateGameObject("holder"); + GameObject* first = PrefabSerializer::Instantiate(target, path.string()); + GameObject* second = PrefabSerializer::Instantiate(target, path.string(), &holder->GetTransform()); + target.CommitPendingAdditions(); + check(first != nullptr && second != nullptr, "a saved prefab must instantiate"); + check(target.GetObjects().size() == 5, "each instance must create the root and its child"); + check(first != second && first->GetId() != turret->GetId(), "instances must get fresh objects"); + check(first->GetName() == "turret" && first->GetTransform().GetParent() == nullptr, + "an instance without a parent must be a scene root"); + check(second->GetTransform().GetParent() == &holder->GetTransform(), + "an instance must be parented to the given transform"); + check(close(first->GetTransform().GetLocalPosition().x, 5.0f), + "the root transform must come from the prefab"); + + for (GameObject* instance : {first, second}) { + GameObject* barrel = onlyChild(*instance); + check(barrel != nullptr && barrel->GetName() == "barrel" && + close(barrel->GetTransform().GetLocalPosition().y, 1.0f), + "prefab children must keep their local transform"); + + const auto* button = instance->GetComponent(); + check(button != nullptr && button->GetTargets().size() == 1 && + button->GetTargets().front().Get(target) == barrel, + "references inside the prefab must point at the instance's own objects, outside ones are dropped"); + } + + check(PrefabCache::GetInstance().Get(path.string()) == PrefabCache::GetInstance().Get(path.string()), + "a cached prefab must be parsed once"); + const auto before = PrefabCache::GetInstance().Get(path.string()); + turret->SetName("tower"); + check(PrefabSerializer::Save(*turret, path), "a prefab must save over an existing file"); + const auto after = PrefabCache::GetInstance().Get(path.string()); + check(after != before && after->at("name") == "tower", "saving a prefab must refresh the cache"); + + std::filesystem::remove(path); + check(PrefabSerializer::Instantiate(target, path.string()) == nullptr, + "instantiating a missing prefab must fail"); + + PrefabCache::GetInstance().Clear(); + SceneManager::GetInstance().Destroy(); + } + + void testPrefabDuplicate() + { + ComponentFactory::Register("InteractableButton"); + ComponentFactory::Register("TestActivatable"); + + Scene& scene = SceneManager::GetInstance().CreateScene("duplicate"); + GameObject* door = scene.CreateGameObject("door"); + door->AddComponent(); + GameObject* holder = scene.CreateGameObject("holder"); + GameObject* turret = buildTurret(scene, *door); + turret->GetTransform().SetParent(&holder->GetTransform(), false); + + GameObject* copy = PrefabSerializer::Duplicate(*turret); + scene.CommitPendingAdditions(); + check(copy != nullptr && copy != turret && copy->GetName() == "turret", + "duplicate must create a new object"); + check(copy->GetTransform().GetParent() == &holder->GetTransform(), + "a duplicate must keep the original's parent"); + + GameObject* barrel = onlyChild(*copy); + check(barrel != nullptr && barrel != onlyChild(*turret), "a duplicate must copy children"); + + const auto* button = copy->GetComponent(); + check(button != nullptr && button->GetTargets().size() == 2 && + button->GetTargets()[0].Get(scene) == barrel && + button->GetTargets()[1].Get(scene) == door, + "a duplicate must remap inner references and keep outer ones"); + + SceneManager::GetInstance().Destroy(); + } + + void testPrefabValidation() + { + using nlohmann::json; + check(PrefabSerializer::Validate(json::object()).has_value(), "an empty prefab must be rejected"); + + const json twoRoots = json::parse(R"({"version":1,"objects":[{"id":1},{"id":2}]})"); + check(PrefabSerializer::Validate(twoRoots).has_value(), "a prefab with two roots must be rejected"); + + const json childFirst = json::parse(R"({"version":1,"objects":[{"id":2,"parent":1},{"id":1}]})"); + check(PrefabSerializer::Validate(childFirst).has_value(), "the prefab root must come first"); + + const json valid = json::parse(R"({"version":1,"objects":[{"id":1},{"id":2,"parent":1}]})"); + check(!PrefabSerializer::Validate(valid).has_value(), "a single-root prefab must validate"); + + const json future = json::parse(R"({"version":99,"objects":[{"id":1}]})"); + check(PrefabSerializer::Validate(future).has_value(), "an unknown prefab version must be rejected"); + } } int main() @@ -1663,6 +1793,9 @@ int main() testSceneFileIdsAreLocal(); testButtonTargetsRoundTrip(); testButtonDropsMissingTargets(); + testPrefabRoundTrip(); + testPrefabDuplicate(); + testPrefabValidation(); std::cout << "destrum tests passed\n"; return EXIT_SUCCESS; }