diff --git a/destrum/CMakeLists.txt b/destrum/CMakeLists.txt index 538fdb6..40ec410 100644 --- a/destrum/CMakeLists.txt +++ b/destrum/CMakeLists.txt @@ -135,6 +135,7 @@ set(SRC_FILES "src/Serialization/MaterialSerialization.cpp" "src/Serialization/PrefabCache.cpp" "src/Serialization/ComponentRegistry.cpp" + "src/Serialization/Reflect.cpp" "src/Util/DeltaTime.cpp" "src/Util/ImGuiUtils.cpp" diff --git a/destrum/include/destrum/ObjectModel/Component.h b/destrum/include/destrum/ObjectModel/Component.h index 8e1ac15..ac5bd25 100644 --- a/destrum/include/destrum/ObjectModel/Component.h +++ b/destrum/include/destrum/ObjectModel/Component.h @@ -10,6 +10,7 @@ #include #include +class FieldVisitor; class GameObject; class Transform; @@ -51,15 +52,16 @@ public: // Serialization. virtual std::string GetTypeName() const = 0; - virtual nlohmann::json Serialize() const { - return nlohmann::json::object(); + // The defaults save, load, resolve and draw the fields listed by Reflect + virtual void Reflect(FieldVisitor&) { } - virtual void Deserialize(const nlohmann::json&) { + virtual void OnFieldsEdited() { } - virtual void ResolveReferences(const ObjectMap&) { - } + virtual nlohmann::json Serialize() const; + virtual void Deserialize(const nlohmann::json& data); + virtual void ResolveReferences(const ObjectMap& objects); // Trigger callbacks called when this component's owner overlaps a sensor. // Only called when the owner has a Collider set as a trigger (or overlaps one). diff --git a/destrum/include/destrum/Serialization/Reflect.h b/destrum/include/destrum/Serialization/Reflect.h new file mode 100644 index 0000000..51f1020 --- /dev/null +++ b/destrum/include/destrum/Serialization/Reflect.h @@ -0,0 +1,264 @@ +#ifndef DESTRUM_REFLECT_H +#define DESTRUM_REFLECT_H + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include +#include + +#include +#include + +class Component; +class GameObject; +class Scene; + +// Designated initializers have to follow this order +struct FieldOptions { + float speed = 0.1f; + std::optional min; + std::optional max; + const char* format = nullptr; + bool color = false; + bool readOnly = false; + bool hidden = false; + bool transient = false; + const char* label = nullptr; + std::span enumNames; + std::function filter; +}; + +struct FieldOps { + void (*save)(const void* value, nlohmann::json& out, const FieldOptions& options); + void (*load)(void* value, const nlohmann::json& in, const FieldOptions& options); + bool (*draw)(const char* label, void* value, const FieldOptions& options, const Scene* scene); + void (*resolve)(void* value, const ObjectRef::ObjectMap& objects, std::vector& dropped); +}; + +namespace reflect_detail { + enum class Scalar { S8, U8, S16, U16, S32, U32, S64, U64, Float, Double }; + + template + consteval Scalar ScalarOf() + { + if constexpr (std::same_as) return Scalar::Float; + else if constexpr (std::same_as) return Scalar::Double; + else if constexpr (std::is_signed_v) { + if constexpr (sizeof(T) == 1) return Scalar::S8; + else if constexpr (sizeof(T) == 2) return Scalar::S16; + else if constexpr (sizeof(T) == 4) return Scalar::S32; + else return Scalar::S64; + } else { + if constexpr (sizeof(T) == 1) return Scalar::U8; + else if constexpr (sizeof(T) == 2) return Scalar::U16; + else if constexpr (sizeof(T) == 4) return Scalar::U32; + else return Scalar::U64; + } + } + + bool DrawScalar(const char* label, Scalar type, void* value, int components, const void* min, const void* max, const FieldOptions& options); + bool DrawEnum(const char* label, std::int64_t& value, const FieldOptions& options); + + void BeginList(const char* label); + [[nodiscard]] bool ListRemoveButton(std::size_t index); + void EndListRow(); + [[nodiscard]] bool ListAddButton(); + + [[nodiscard]] std::string PrettifyFieldName(std::string_view key); + + template + concept Numeric = std::is_arithmetic_v && !std::same_as; +} + +bool DrawField(const char* label, bool& value, const FieldOptions& options, const Scene* scene); +bool DrawField(const char* label, std::string& value, const FieldOptions& options, const Scene* scene); +bool DrawField(const char* label, glm::vec2& value, const FieldOptions& options, const Scene* scene); +bool DrawField(const char* label, glm::vec3& value, const FieldOptions& options, const Scene* scene); +bool DrawField(const char* label, glm::vec4& value, const FieldOptions& options, const Scene* scene); +bool DrawField(const char* label, ObjectRef& value, const FieldOptions& options, const Scene* scene); +bool DrawField(const char* label, std::vector& value, const FieldOptions& options, const Scene* scene); + +template +bool DrawField(const char* label, T& value, const FieldOptions& options, const Scene*) +{ + const T min = options.min ? static_cast(*options.min) : T{}; + const T max = options.max ? static_cast(*options.max) : T{}; + return reflect_detail::DrawScalar(label, reflect_detail::ScalarOf(), &value, 1, + options.min ? &min : nullptr, options.max ? &max : nullptr, options); +} + +template + requires std::is_enum_v +bool DrawField(const char* label, E& value, const FieldOptions& options, const Scene*) +{ + auto raw = static_cast(value); + if (!reflect_detail::DrawEnum(label, raw, options)) { + return false; + } + value = static_cast(raw); + return true; +} + +template +bool DrawField(const char* label, std::vector& values, const FieldOptions& options, const Scene* scene) +{ + reflect_detail::BeginList(label); + bool changed = false; + std::optional removeIndex; + for (std::size_t i = 0; i < values.size(); ++i) { + if (reflect_detail::ListRemoveButton(i)) { + removeIndex = i; + } + changed |= DrawField("##value", values[i], options, scene); + reflect_detail::EndListRow(); + } + if (removeIndex) { + values.erase(values.begin() + static_cast(*removeIndex)); + changed = true; + } + if (reflect_detail::ListAddButton()) { + values.emplace_back(); + changed = true; + } + return changed; +} + +void ResolveField(ObjectRef& value, const ObjectRef::ObjectMap& objects, std::vector& dropped); +void ResolveField(std::vector& value, const ObjectRef::ObjectMap& objects, std::vector& dropped); + +template +concept Reflectable = std::is_constructible_v && + requires(T& value, const FieldOptions& options, const Scene* scene) { + { DrawField("", value, options, scene) } -> std::same_as; + }; + +template +concept HasReferences = requires(T& value, const ObjectRef::ObjectMap& objects, std::vector& dropped) { + ResolveField(value, objects, dropped); +}; + +template +struct FieldCodec { + static void Save(const void* value, nlohmann::json& out, const FieldOptions& options) + { + const T& typed = *static_cast(value); + if constexpr (std::is_enum_v) { + const auto index = static_cast(typed); + if (index < options.enumNames.size()) { + out = options.enumNames[index]; + return; + } + } + out = typed; + } + + static void Load(void* value, const nlohmann::json& in, const FieldOptions& options) + { + T& typed = *static_cast(value); + if constexpr (std::is_enum_v) { + if (in.is_string()) { + const auto name = in.get(); + for (std::size_t i = 0; i < options.enumNames.size(); ++i) { + if (name == options.enumNames[i]) { + typed = static_cast(i); + return; + } + } + throw std::runtime_error("unknown value \"" + name + "\""); + } + } + + typed = in.get(); + if constexpr (reflect_detail::Numeric) { + if (options.min && typed < static_cast(*options.min)) typed = static_cast(*options.min); + if (options.max && typed > static_cast(*options.max)) typed = static_cast(*options.max); + } + } + + static bool Draw(const char* label, void* value, const FieldOptions& options, const Scene* scene) + { + return DrawField(label, *static_cast(value), options, scene); + } + + static void Resolve(void* value, const ObjectRef::ObjectMap& objects, std::vector& dropped) + { + if constexpr (HasReferences) { + ResolveField(*static_cast(value), objects, dropped); + } + } + + static constexpr FieldOps Ops{&Save, &Load, &Draw, HasReferences ? &Resolve : nullptr}; +}; + +class FieldVisitor { +public: + virtual ~FieldVisitor() = default; + + template + void Field(std::string_view key, T& value, const FieldOptions& options = {}) + { + Visit(key, &value, options, FieldCodec::Ops); + } + +protected: + virtual void Visit(std::string_view key, void* value, const FieldOptions& options, const FieldOps& ops) = 0; +}; + +class JsonWriter final : public FieldVisitor { +public: + [[nodiscard]] nlohmann::json& Result() { return m_Result; } + +protected: + void Visit(std::string_view key, void* value, const FieldOptions& options, const FieldOps& ops) override; + +private: + nlohmann::json m_Result = nlohmann::json::object(); +}; + +class JsonReader final : public FieldVisitor { +public: + explicit JsonReader(const nlohmann::json& data) : m_Data(data) {} + +protected: + void Visit(std::string_view key, void* value, const FieldOptions& options, const FieldOps& ops) override; + +private: + const nlohmann::json& m_Data; +}; + +class ReferenceResolver final : public FieldVisitor { +public: + ReferenceResolver(const Component& owner, const ObjectRef::ObjectMap& objects) : m_Owner(owner), m_Objects(objects) {} + +protected: + void Visit(std::string_view key, void* value, const FieldOptions& options, const FieldOps& ops) override; + +private: + const Component& m_Owner; + const ObjectRef::ObjectMap& m_Objects; +}; + +class InspectorDrawer final : public FieldVisitor { +public: + explicit InspectorDrawer(const Scene* scene) : m_Scene(scene) {} + + [[nodiscard]] bool Changed() const { return m_Changed; } + +protected: + void Visit(std::string_view key, void* value, const FieldOptions& options, const FieldOps& ops) override; + +private: + const Scene* m_Scene; + bool m_Changed{false}; +}; + +#endif // DESTRUM_REFLECT_H diff --git a/destrum/src/ObjectModel/Component.cpp b/destrum/src/ObjectModel/Component.cpp index 202fea5..c77a67a 100644 --- a/destrum/src/ObjectModel/Component.cpp +++ b/destrum/src/ObjectModel/Component.cpp @@ -4,6 +4,7 @@ #include #include #include +#include Component::Component(GameObject& pParent, const std::string& name) : Object(name), @@ -50,6 +51,28 @@ void Component::FixedUpdate(float fixedDt) { } void Component::ImGuiInspector() { + InspectorDrawer drawer(m_ParentGameObjectPtr != nullptr ? m_ParentGameObjectPtr->GetScene() : nullptr); + Reflect(drawer); + if (drawer.Changed()) { + OnFieldsEdited(); + } +} + +// Reflect also loads, so it can't be const, but the writer only reads the fields +nlohmann::json Component::Serialize() const { + JsonWriter writer; + const_cast(this)->Reflect(writer); + return std::move(writer.Result()); +} + +void Component::Deserialize(const nlohmann::json& data) { + JsonReader reader(data); + Reflect(reader); +} + +void Component::ResolveReferences(const ObjectMap& objects) { + ReferenceResolver resolver(*this, objects); + Reflect(resolver); } void Component::ImGuiRender() { diff --git a/destrum/src/Serialization/Reflect.cpp b/destrum/src/Serialization/Reflect.cpp new file mode 100644 index 0000000..077ed8e --- /dev/null +++ b/destrum/src/Serialization/Reflect.cpp @@ -0,0 +1,292 @@ +#include + +#include +#include + +#include +#include + +#include +#include +#include +#include + +namespace { + [[nodiscard]] ImGuiDataType ToImGui(reflect_detail::Scalar type) + { + switch (type) { + case reflect_detail::Scalar::S8: return ImGuiDataType_S8; + case reflect_detail::Scalar::U8: return ImGuiDataType_U8; + case reflect_detail::Scalar::S16: return ImGuiDataType_S16; + case reflect_detail::Scalar::U16: return ImGuiDataType_U16; + case reflect_detail::Scalar::S32: return ImGuiDataType_S32; + case reflect_detail::Scalar::U32: return ImGuiDataType_U32; + case reflect_detail::Scalar::S64: return ImGuiDataType_S64; + case reflect_detail::Scalar::U64: return ImGuiDataType_U64; + case reflect_detail::Scalar::Float: return ImGuiDataType_Float; + case reflect_detail::Scalar::Double: return ImGuiDataType_Double; + } + return ImGuiDataType_Float; + } + + [[nodiscard]] ImGuiSliderFlags ClampFlags(const FieldOptions& options) + { + return options.min || options.max ? ImGuiSliderFlags_AlwaysClamp : ImGuiSliderFlags_None; + } + + template + bool DrawFloats(const char* label, float* value, const FieldOptions& options) + { + if constexpr (N == 3) { + if (options.color) { + return ImGui::ColorEdit3(label, value); + } + } else if constexpr (N == 4) { + if (options.color) { + return ImGui::ColorEdit4(label, value); + } + } + const float min = options.min ? static_cast(*options.min) : 0.0f; + const float max = options.max ? static_cast(*options.max) : 0.0f; + return reflect_detail::DrawScalar(label, reflect_detail::Scalar::Float, value, N, + options.min ? &min : nullptr, options.max ? &max : nullptr, options); + } + + [[nodiscard]] std::string ObjectLabel(const ObjectRef& ref, const Scene& scene) + { + const GameObject* object = ref.Get(scene); + return object != nullptr ? object->GetName() : "Missing object " + std::to_string(ref.GetId()); + } +} + +namespace reflect_detail { + bool DrawScalar(const char* label, Scalar type, void* value, int components, const void* min, const void* max, const FieldOptions& options) + { + return ImGui::DragScalarN(label, ToImGui(type), value, components, options.speed, min, max, options.format, ClampFlags(options)); + } + + bool DrawEnum(const char* label, std::int64_t& value, const FieldOptions& options) + { + if (options.enumNames.empty()) { + return ImGui::InputScalar(label, ImGuiDataType_S64, &value); + } + + const auto count = static_cast(options.enumNames.size()); + const char* preview = value >= 0 && value < count ? options.enumNames[static_cast(value)] : "?"; + bool changed = false; + if (ImGui::BeginCombo(label, preview)) { + for (std::int64_t i = 0; i < count; ++i) { + if (ImGui::Selectable(options.enumNames[static_cast(i)], i == value)) { + value = i; + changed = true; + } + } + ImGui::EndCombo(); + } + return changed; + } + + void BeginList(const char* label) + { + ImGui::SeparatorText(label); + } + + bool ListRemoveButton(std::size_t index) + { + ImGui::PushID(static_cast(index)); + const bool clicked = ImGui::SmallButton("x"); + ImGui::SameLine(); + return clicked; + } + + void EndListRow() + { + ImGui::PopID(); + } + + bool ListAddButton() + { + return ImGui::SmallButton("+"); + } + + std::string PrettifyFieldName(std::string_view key) + { + std::string label; + label.reserve(key.size() + 4); + for (std::size_t i = 0; i < key.size(); ++i) { + const auto c = static_cast(key[i]); + if (c == '_') { + label += ' '; + continue; + } + const bool wordStart = i > 0 && std::isupper(c) && !std::isupper(static_cast(key[i - 1])) && key[i - 1] != '_'; + if (wordStart) { + label += ' '; + } + label += static_cast(label.empty() || label.back() == ' ' ? std::toupper(c) : c); + } + return label; + } +} + +bool DrawField(const char* label, bool& value, const FieldOptions&, const Scene*) +{ + return ImGui::Checkbox(label, &value); +} + +bool DrawField(const char* label, std::string& value, const FieldOptions&, const Scene*) +{ + return ImGuiUtils::InputString(label, value); +} + +bool DrawField(const char* label, glm::vec2& value, const FieldOptions& options, const Scene*) +{ + return DrawFloats<2>(label, &value.x, options); +} + +bool DrawField(const char* label, glm::vec3& value, const FieldOptions& options, const Scene*) +{ + return DrawFloats<3>(label, &value.x, options); +} + +bool DrawField(const char* label, glm::vec4& value, const FieldOptions& options, const Scene*) +{ + return DrawFloats<4>(label, &value.x, options); +} + +bool DrawField(const char* label, ObjectRef& value, const FieldOptions& options, const Scene* scene) +{ + if (scene == nullptr) { + ImGui::TextDisabled("%s: not in a scene", label); + return false; + } + return ImGuiUtils::DrawObjectRefPicker(label, value, *scene, options.filter); +} + +bool DrawField(const char* label, std::vector& value, const FieldOptions& options, const Scene* scene) +{ + reflect_detail::BeginList(label); + if (scene == nullptr) { + ImGui::TextDisabled("Not in a scene"); + return false; + } + + bool changed = false; + std::optional removeIndex; + for (std::size_t i = 0; i < value.size(); ++i) { + if (reflect_detail::ListRemoveButton(i)) { + removeIndex = i; + } + if (value[i].Get(*scene) != nullptr) { + ImGui::TextUnformatted(ObjectLabel(value[i], *scene).c_str()); + } else { + ImGui::TextDisabled("%s", ObjectLabel(value[i], *scene).c_str()); + } + reflect_detail::EndListRow(); + } + if (removeIndex) { + value.erase(value.begin() + static_cast(*removeIndex)); + changed = true; + } + + const auto canAdd = [&](const GameObject& object) { + return !object.IsBeingDestroyed() && std::ranges::find(value, ObjectRef(object)) == value.end() && + (!options.filter || options.filter(object)); + }; + + if (ImGui::BeginCombo("##Add", "Add...")) { + for (const auto& object : scene->GetObjects()) { + if (object == nullptr || !canAdd(*object)) { + continue; + } + ImGui::PushID(static_cast(object->GetId())); + if (ImGui::Selectable(object->GetName().c_str())) { + value.emplace_back(*object); + changed = true; + } + ImGui::PopID(); + } + ImGui::EndCombo(); + } + if (ImGui::BeginDragDropTarget()) { + if (const ImGuiPayload* payload = ImGui::AcceptDragDropPayload(ImGuiUtils::GameObjectPayload)) { + const GameObject* dropped = scene->FindGameObject(*static_cast(payload->Data)); + if (dropped != nullptr && canAdd(*dropped)) { + value.emplace_back(*dropped); + changed = true; + } + } + ImGui::EndDragDropTarget(); + } + return changed; +} + +void ResolveField(ObjectRef& value, const ObjectRef::ObjectMap& objects, std::vector& dropped) +{ + const ObjectId fileId = value.GetId(); + if (!value.Resolve(objects)) { + dropped.push_back(fileId); + } +} + +void ResolveField(std::vector& value, const ObjectRef::ObjectMap& objects, std::vector& dropped) +{ + std::erase_if(value, [&](ObjectRef& ref) { + const ObjectId fileId = ref.GetId(); + if (!ref.Resolve(objects)) { + dropped.push_back(fileId); + return true; + } + return !ref.IsSet(); + }); +} + +void JsonWriter::Visit(std::string_view key, void* value, const FieldOptions& options, const FieldOps& ops) +{ + if (!options.transient) { + ops.save(value, m_Result[std::string{key}], options); + } +} + +void JsonReader::Visit(std::string_view key, void* value, const FieldOptions& options, const FieldOps& ops) +{ + if (options.transient || !m_Data.is_object()) { + return; + } + const auto it = m_Data.find(std::string{key}); + if (it == m_Data.end()) { + return; + } + try { + ops.load(value, *it, options); + } catch (const std::exception& exception) { + throw std::runtime_error("\"" + std::string{key} + "\": " + exception.what()); + } +} + +void ReferenceResolver::Visit(std::string_view key, void* value, const FieldOptions&, const FieldOps& ops) +{ + if (ops.resolve == nullptr) { + return; + } + std::vector dropped; + ops.resolve(value, m_Objects, dropped); + const GameObject* owner = m_Owner.GetGameObject(); + for (const ObjectId id : dropped) { + spdlog::warn("{} on {}: {} references object {} which is not in the scene file, dropping it", + m_Owner.GetTypeName(), owner != nullptr ? owner->GetName() : "?", key, id); + } +} + +void InspectorDrawer::Visit(std::string_view key, void* value, const FieldOptions& options, const FieldOps& ops) +{ + if (options.hidden) { + return; + } + const std::string label = options.label != nullptr ? options.label : reflect_detail::PrettifyFieldName(key); + ImGui::PushID(key.data(), key.data() + key.size()); + ImGui::BeginDisabled(options.readOnly); + m_Changed |= ops.draw(label.c_str(), value, options, m_Scene); + ImGui::EndDisabled(); + ImGui::PopID(); +} diff --git a/lightkeeper/include/components/Door.h b/lightkeeper/include/components/Door.h index fee5bc4..0199fb2 100644 --- a/lightkeeper/include/components/Door.h +++ b/lightkeeper/include/components/Door.h @@ -14,8 +14,7 @@ public: void Start() override; void Update(float dt) override; - nlohmann::json Serialize() const override; - void Deserialize(const nlohmann::json& data) override; + void Reflect(FieldVisitor& v) override; void ImGuiInspector() override; void Open() { m_IsOpen = true; } diff --git a/lightkeeper/include/components/InteractableButton.h b/lightkeeper/include/components/InteractableButton.h index ff2d9ee..fa86087 100644 --- a/lightkeeper/include/components/InteractableButton.h +++ b/lightkeeper/include/components/InteractableButton.h @@ -17,9 +17,8 @@ public: void Start() override; void Interact(GameObject& interactor) override; - nlohmann::json Serialize() const override; - void Deserialize(const nlohmann::json& data) override; - void ResolveReferences(const ObjectMap& objects) override; + void Reflect(FieldVisitor& v) override; + void OnFieldsEdited() override; void ImGuiInspector() override; [[nodiscard]] std::size_t GetColorIndex() const { return m_ColorIndex; } @@ -35,7 +34,6 @@ public: private: void ApplyColor(); void ActivateTargets(GameObject& interactor); - void DrawTargetsInspector(); std::size_t m_ColorIndex{0}; int m_PressCount{0}; diff --git a/lightkeeper/src/components/Door.cpp b/lightkeeper/src/components/Door.cpp index 90c79cf..8b6339f 100644 --- a/lightkeeper/src/components/Door.cpp +++ b/lightkeeper/src/components/Door.cpp @@ -3,7 +3,7 @@ #include #include -#include +#include #include @@ -58,24 +58,13 @@ void Door::ApplyPosition() GetGameObject()->GetTransform().SetLocalPosition(m_ClosedPosition + GetOffsetFor(m_OpenAmount)); } -nlohmann::json Door::Serialize() const +void Door::Reflect(FieldVisitor& v) { - return { - {"openOffset", m_OpenOffset}, - {"openDuration", m_OpenDuration}, - {"toggleable", m_Toggleable}, - {"open", m_IsOpen}, - {"openAmount", m_OpenAmount}, - }; -} - -void Door::Deserialize(const nlohmann::json& data) -{ - m_OpenOffset = data.value("openOffset", m_OpenOffset); - m_OpenDuration = std::max(data.value("openDuration", m_OpenDuration), 0.0f); - m_Toggleable = data.value("toggleable", m_Toggleable); - m_IsOpen = data.value("open", m_IsOpen); - m_OpenAmount = std::clamp(data.value("openAmount", m_OpenAmount), 0.0f, 1.0f); + v.Field("openOffset", m_OpenOffset, {.speed = 0.05f}); + v.Field("openDuration", m_OpenDuration, {.speed = 0.05f, .min = 0.0, .format = "%.2f s"}); + v.Field("toggleable", m_Toggleable); + v.Field("open", m_IsOpen, {.hidden = true}); + v.Field("openAmount", m_OpenAmount, {.min = 0.0, .max = 1.0, .hidden = true}); } void Door::ImGuiInspector() @@ -85,7 +74,5 @@ void Door::ImGuiInspector() Toggle(); } - ImGui::DragFloat3("Open Offset", &m_OpenOffset.x, 0.05f); - ImGui::DragFloat("Open Duration", &m_OpenDuration, 0.05f, 0.0f, 10.0f, "%.2f s"); - ImGui::Checkbox("Toggleable", &m_Toggleable); + Activatable::ImGuiInspector(); } diff --git a/lightkeeper/src/components/InteractableButton.cpp b/lightkeeper/src/components/InteractableButton.cpp index a306aa4..06eec0b 100644 --- a/lightkeeper/src/components/InteractableButton.cpp +++ b/lightkeeper/src/components/InteractableButton.cpp @@ -6,13 +6,13 @@ #include #include #include +#include #include #include #include #include -#include #include @@ -108,93 +108,28 @@ void InteractableButton::ApplyColor() renderer.updateMaterialGPU(meshRenderer->GetMaterialID()); } -nlohmann::json InteractableButton::Serialize() const +void InteractableButton::Reflect(FieldVisitor& v) { - return { - {"colorIndex", m_ColorIndex}, - {"cycleColors", m_CycleColors}, - {"pressCount", m_PressCount}, - {"targets", m_Targets}, - }; + v.Field("colorIndex", m_ColorIndex, {.max = static_cast(ButtonColors.size() - 1)}); + v.Field("cycleColors", m_CycleColors); + v.Field("pressCount", m_PressCount, {.readOnly = true}); + v.Field("targets", m_Targets, {.filter = [this](const GameObject& object) { + return &object != GetGameObject() && object.GetComponent() != nullptr; + }}); } -void InteractableButton::Deserialize(const nlohmann::json& data) +void InteractableButton::OnFieldsEdited() { - m_ColorIndex = data.value("colorIndex", m_ColorIndex) % ButtonColors.size(); - m_CycleColors = data.value("cycleColors", m_CycleColors); - m_PressCount = data.value("pressCount", m_PressCount); - m_Targets = data.value("targets", std::vector{}); -} - -void InteractableButton::ResolveReferences(const ObjectMap& objects) -{ - std::erase_if(m_Targets, [&](ObjectRef& target) { - const ObjectId fileId = target.GetId(); - if (!target.Resolve(objects)) { - spdlog::warn("{} targets object {} which is not in the scene file, dropping it", GetGameObject()->GetName(), fileId); - return true; - } - return !target.IsSet(); - }); + if (m_CycleColors) { + ApplyColor(); + } } void InteractableButton::ImGuiInspector() { - ImGui::Text("Presses: %d", m_PressCount); if (ImGui::Button("Press")) { Interact(*GetGameObject()); } - if (ImGui::Checkbox("Cycle Colors", &m_CycleColors) && m_CycleColors) { - ApplyColor(); - } - DrawTargetsInspector(); -} - -void InteractableButton::DrawTargetsInspector() -{ - ImGui::SeparatorText("Targets"); - - const Scene* scene = GetGameObject()->GetScene(); - if (scene == nullptr) { - ImGui::TextDisabled("Not in a scene"); - return; - } - - std::optional targetToRemove; - for (const ObjectRef& targetRef : m_Targets) { - const ObjectId targetId = targetRef.GetId(); - const GameObject* target = targetRef.Get(*scene); - ImGui::PushID(static_cast(targetId)); - if (ImGui::SmallButton("x")) { - targetToRemove = targetId; - } - ImGui::SameLine(); - if (target != nullptr) { - ImGui::TextUnformatted(target->GetName().c_str()); - } else { - ImGui::TextDisabled("Missing object %llu", static_cast(targetId)); - } - ImGui::PopID(); - } - if (targetToRemove) { - RemoveTarget(*targetToRemove); - } - - if (ImGui::BeginCombo("##AddTarget", "Add target...")) { - for (const auto& object : scene->GetObjects()) { - const bool alreadyTargeted = object != nullptr && - std::ranges::find(m_Targets, ObjectRef(*object)) != m_Targets.end(); - if (object == nullptr || object.get() == GetGameObject() || alreadyTargeted || - object->GetComponent() == nullptr) { - continue; - } - ImGui::PushID(static_cast(object->GetId())); - if (ImGui::Selectable(object->GetName().c_str())) { - AddTarget(*object); - } - ImGui::PopID(); - } - ImGui::EndCombo(); - } + Interactable::ImGuiInspector(); } diff --git a/tests/serialization_tests.cpp b/tests/serialization_tests.cpp index f5ecd71..9cfe27a 100644 --- a/tests/serialization_tests.cpp +++ b/tests/serialization_tests.cpp @@ -1,3 +1,4 @@ +#include #include #include #include @@ -9,7 +10,9 @@ #include #include #include +#include #include +#include #include #include #include @@ -386,8 +389,157 @@ namespace { } } +namespace { + enum class TestMode { Off, Slow, Fast }; + constexpr std::array TestModeNames{"Off", "Slow", "Fast"}; + + class ReflectedComponent final : public Component { + public: + explicit ReflectedComponent(GameObject& owner) : Component(owner, "ReflectedComponent") {} + + std::string GetTypeName() const override { return "ReflectedComponent"; } + + void Reflect(FieldVisitor& v) override + { + v.Field("flag", flag); + v.Field("count", count, {.min = 0, .max = 10}); + v.Field("index", index); + v.Field("speed", speed, {.min = 0.0}); + v.Field("ratio", ratio); + v.Field("offset", offset); + v.Field("tint", tint, {.color = true}); + v.Field("name", name); + v.Field("mode", mode, {.enumNames = TestModeNames}); + v.Field("rawMode", rawMode); + v.Field("values", values); + v.Field("target", target); + v.Field("targets", targets); + v.Field("cache", cache, {.transient = true}); + } + + bool flag{false}; + int count{3}; + std::size_t index{0}; + float speed{1.0f}; + double ratio{0.5}; + glm::vec3 offset{0.0f}; + glm::vec4 tint{1.0f}; + std::string name{"default"}; + TestMode mode{TestMode::Off}; + TestMode rawMode{TestMode::Off}; + std::vector values; + ObjectRef target; + std::vector targets; + int cache{0}; + }; + + [[nodiscard]] bool throwsMentioning(ReflectedComponent& component, const nlohmann::json& data, const char* key) + { + try { + component.Deserialize(data); + } catch (const std::exception& exception) { + return std::string{exception.what()}.find(key) != std::string::npos; + } + return false; + } + + void testReflectedRoundTrip() + { + GameObject object{"reflected"}; + auto* component = object.AddComponent(); + component->flag = true; + component->count = 7; + component->index = 4; + component->speed = 2.5f; + component->ratio = 0.25; + component->offset = glm::vec3{1.0f, -2.0f, 3.5f}; + component->tint = glm::vec4{0.1f, 0.2f, 0.3f, 0.4f}; + component->name = "named"; + component->mode = TestMode::Fast; + component->rawMode = TestMode::Slow; + component->values = {1.0f, 2.0f}; + component->target = ObjectRef(ObjectId{12}); + component->targets = {ObjectRef(ObjectId{13}), ObjectRef(ObjectId{14})}; + component->cache = 99; + + const nlohmann::json data = component->Serialize(); + check(data.at("mode") == "Fast", "enums with names must save their name"); + check(data.at("rawMode") == 1, "enums without names must save their value"); + check(data.at("targets") == nlohmann::json::array({13, 14}), "object refs must save their ids"); + check(!data.contains("cache"), "transient fields must not be saved"); + + GameObject loadedObject{"loaded"}; + auto* loaded = loadedObject.AddComponent(); + loaded->Deserialize(data); + check(loaded->flag && loaded->count == 7 && loaded->index == 4, "bool and integer fields must round-trip"); + check(loaded->speed == 2.5f && loaded->ratio == 0.25, "floating point fields must round-trip"); + check(loaded->offset == component->offset && loaded->tint == component->tint, "vector fields must round-trip"); + check(loaded->name == "named", "string fields must round-trip"); + check(loaded->mode == TestMode::Fast && loaded->rawMode == TestMode::Slow, "enum fields must round-trip"); + check(loaded->values == component->values, "vector fields must round-trip"); + check(loaded->target == component->target && loaded->targets == component->targets, "object refs must round-trip"); + check(loaded->cache == 0, "transient fields must not be loaded"); + check(loaded->Serialize() == data, "a loaded component must save the same data"); + } + + void testReflectedLoadClampsAndKeepsDefaults() + { + GameObject object{"reflected"}; + auto* component = object.AddComponent(); + component->Deserialize({{"count", 50}, {"speed", -3.0f}, {"unknown", true}}); + check(component->count == 10 && component->speed == 0.0f, "loading must clamp to min and max"); + check(component->name == "default" && component->ratio == 0.5, "missing keys must keep their defaults"); + + component->Deserialize({{"mode", 2}}); + check(component->mode == TestMode::Fast, "named enums must still load from a number"); + + check(throwsMentioning(*component, {{"mode", "Turbo"}}, "\"mode\""), "an unknown enum name must fail and name the field"); + check(throwsMentioning(*component, {{"offset", "up"}}, "\"offset\""), "a wrong type must fail and name the field"); + } + + void testReflectedReferencesResolve() + { + ComponentFactory::Register("ReflectedComponent"); + + Scene& scene = SceneManager::GetInstance().CreateScene("reflected references"); + const auto path = std::filesystem::temp_directory_path() / "destrum_reflected_refs_scene.json"; + std::ofstream{path} + << R"({"version":2,"objects":[)" + R"({"id":5,"name":"source","components":[{"type":"ReflectedComponent","data":{"target":7,"targets":[7,99,5]}}]},)" + R"({"id":7,"name":"other","components":[{"type":"ReflectedComponent","data":{"target":42}}]}]})"; + + check(SceneSerializer::Load(scene, path), "a scene with reflected references must load"); + std::filesystem::remove(path); + scene.CommitPendingAdditions(); + + const GameObject* source = findByName(scene, "source"); + const GameObject* other = findByName(scene, "other"); + check(source != nullptr && other != nullptr, "both objects must load"); + + const auto* sourceComponent = source->GetComponent(); + check(sourceComponent->target.Get(scene) == other, "an object ref must be remapped to the loaded object"); + check(sourceComponent->targets.size() == 2 && sourceComponent->targets[0].Get(scene) == other && + sourceComponent->targets[1].Get(scene) == source, + "object ref lists must be remapped and drop missing objects"); + check(!other->GetComponent()->target.IsSet(), "a missing object ref must be cleared"); + + SceneManager::GetInstance().Destroy(); + } + + void testFieldLabels() + { + check(reflect_detail::PrettifyFieldName("openDuration") == "Open Duration", "camelCase keys must become words"); + check(reflect_detail::PrettifyFieldName("cast_shadows") == "Cast Shadows", "snake_case keys must become words"); + check(reflect_detail::PrettifyFieldName("useHDR") == "Use HDR", "acronyms must stay together"); + } +} + void RunSerializationTests() { + testReflectedRoundTrip(); + testReflectedLoadClampsAndKeepsDefaults(); + testReflectedReferencesResolve(); + testFieldLabels(); testTonemapperNames(); testSceneRoundTrip(); testLegacySceneLoad();