feat: add reflection

This commit is contained in:
2026-10-30 17:09:53 +01:00
parent 0fbc023018
commit 08acd84404
10 changed files with 763 additions and 110 deletions
+1
View File
@@ -135,6 +135,7 @@ set(SRC_FILES
"src/Serialization/MaterialSerialization.cpp" "src/Serialization/MaterialSerialization.cpp"
"src/Serialization/PrefabCache.cpp" "src/Serialization/PrefabCache.cpp"
"src/Serialization/ComponentRegistry.cpp" "src/Serialization/ComponentRegistry.cpp"
"src/Serialization/Reflect.cpp"
"src/Util/DeltaTime.cpp" "src/Util/DeltaTime.cpp"
"src/Util/ImGuiUtils.cpp" "src/Util/ImGuiUtils.cpp"
@@ -10,6 +10,7 @@
#include <destrum/ObjectModel/Object.h> #include <destrum/ObjectModel/Object.h>
#include <destrum/ObjectModel/ObjectId.h> #include <destrum/ObjectModel/ObjectId.h>
class FieldVisitor;
class GameObject; class GameObject;
class Transform; class Transform;
@@ -51,15 +52,16 @@ public:
// Serialization. // Serialization.
virtual std::string GetTypeName() const = 0; virtual std::string GetTypeName() const = 0;
virtual nlohmann::json Serialize() const { // The defaults save, load, resolve and draw the fields listed by Reflect
return nlohmann::json::object(); 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. // 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). // Only called when the owner has a Collider set as a trigger (or overlaps one).
@@ -0,0 +1,264 @@
#ifndef DESTRUM_REFLECT_H
#define DESTRUM_REFLECT_H
#include <concepts>
#include <cstdint>
#include <functional>
#include <optional>
#include <span>
#include <stdexcept>
#include <string>
#include <string_view>
#include <type_traits>
#include <vector>
#include <glm/glm.hpp>
#include <nlohmann/json.hpp>
#include <destrum/ObjectModel/ObjectRef.h>
#include <destrum/Serialization/GlmJson.h>
class Component;
class GameObject;
class Scene;
// Designated initializers have to follow this order
struct FieldOptions {
float speed = 0.1f;
std::optional<double> min;
std::optional<double> max;
const char* format = nullptr;
bool color = false;
bool readOnly = false;
bool hidden = false;
bool transient = false;
const char* label = nullptr;
std::span<const char* const> enumNames;
std::function<bool(const GameObject&)> 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<ObjectId>& dropped);
};
namespace reflect_detail {
enum class Scalar { S8, U8, S16, U16, S32, U32, S64, U64, Float, Double };
template <class T>
consteval Scalar ScalarOf()
{
if constexpr (std::same_as<T, float>) return Scalar::Float;
else if constexpr (std::same_as<T, double>) return Scalar::Double;
else if constexpr (std::is_signed_v<T>) {
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 <class T>
concept Numeric = std::is_arithmetic_v<T> && !std::same_as<T, bool>;
}
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<ObjectRef>& value, const FieldOptions& options, const Scene* scene);
template <reflect_detail::Numeric T>
bool DrawField(const char* label, T& value, const FieldOptions& options, const Scene*)
{
const T min = options.min ? static_cast<T>(*options.min) : T{};
const T max = options.max ? static_cast<T>(*options.max) : T{};
return reflect_detail::DrawScalar(label, reflect_detail::ScalarOf<T>(), &value, 1,
options.min ? &min : nullptr, options.max ? &max : nullptr, options);
}
template <class E>
requires std::is_enum_v<E>
bool DrawField(const char* label, E& value, const FieldOptions& options, const Scene*)
{
auto raw = static_cast<std::int64_t>(value);
if (!reflect_detail::DrawEnum(label, raw, options)) {
return false;
}
value = static_cast<E>(raw);
return true;
}
template <class T>
bool DrawField(const char* label, std::vector<T>& values, const FieldOptions& options, const Scene* scene)
{
reflect_detail::BeginList(label);
bool changed = false;
std::optional<std::size_t> 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<std::ptrdiff_t>(*removeIndex));
changed = true;
}
if (reflect_detail::ListAddButton()) {
values.emplace_back();
changed = true;
}
return changed;
}
void ResolveField(ObjectRef& value, const ObjectRef::ObjectMap& objects, std::vector<ObjectId>& dropped);
void ResolveField(std::vector<ObjectRef>& value, const ObjectRef::ObjectMap& objects, std::vector<ObjectId>& dropped);
template <class T>
concept Reflectable = std::is_constructible_v<nlohmann::json, const T&> &&
requires(T& value, const FieldOptions& options, const Scene* scene) {
{ DrawField("", value, options, scene) } -> std::same_as<bool>;
};
template <class T>
concept HasReferences = requires(T& value, const ObjectRef::ObjectMap& objects, std::vector<ObjectId>& dropped) {
ResolveField(value, objects, dropped);
};
template <Reflectable T>
struct FieldCodec {
static void Save(const void* value, nlohmann::json& out, const FieldOptions& options)
{
const T& typed = *static_cast<const T*>(value);
if constexpr (std::is_enum_v<T>) {
const auto index = static_cast<std::size_t>(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<T*>(value);
if constexpr (std::is_enum_v<T>) {
if (in.is_string()) {
const auto name = in.get<std::string>();
for (std::size_t i = 0; i < options.enumNames.size(); ++i) {
if (name == options.enumNames[i]) {
typed = static_cast<T>(i);
return;
}
}
throw std::runtime_error("unknown value \"" + name + "\"");
}
}
typed = in.get<T>();
if constexpr (reflect_detail::Numeric<T>) {
if (options.min && typed < static_cast<T>(*options.min)) typed = static_cast<T>(*options.min);
if (options.max && typed > static_cast<T>(*options.max)) typed = static_cast<T>(*options.max);
}
}
static bool Draw(const char* label, void* value, const FieldOptions& options, const Scene* scene)
{
return DrawField(label, *static_cast<T*>(value), options, scene);
}
static void Resolve(void* value, const ObjectRef::ObjectMap& objects, std::vector<ObjectId>& dropped)
{
if constexpr (HasReferences<T>) {
ResolveField(*static_cast<T*>(value), objects, dropped);
}
}
static constexpr FieldOps Ops{&Save, &Load, &Draw, HasReferences<T> ? &Resolve : nullptr};
};
class FieldVisitor {
public:
virtual ~FieldVisitor() = default;
template <Reflectable T>
void Field(std::string_view key, T& value, const FieldOptions& options = {})
{
Visit(key, &value, options, FieldCodec<T>::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
+23
View File
@@ -4,6 +4,7 @@
#include <destrum/ObjectModel/GameObject.h> #include <destrum/ObjectModel/GameObject.h>
#include <destrum/Components/Physics/Rigidbody.h> #include <destrum/Components/Physics/Rigidbody.h>
#include <destrum/Scene/Scene.h> #include <destrum/Scene/Scene.h>
#include <destrum/Serialization/Reflect.h>
Component::Component(GameObject& pParent, const std::string& name) Component::Component(GameObject& pParent, const std::string& name)
: Object(name), : Object(name),
@@ -50,6 +51,28 @@ void Component::FixedUpdate(float fixedDt) {
} }
void Component::ImGuiInspector() { 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<Component*>(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() { void Component::ImGuiRender() {
+292
View File
@@ -0,0 +1,292 @@
#include <destrum/Serialization/Reflect.h>
#include <algorithm>
#include <cctype>
#include <imgui.h>
#include <spdlog/spdlog.h>
#include <destrum/ObjectModel/Component.h>
#include <destrum/ObjectModel/GameObject.h>
#include <destrum/Scene/Scene.h>
#include <destrum/Util/ImGuiUtils.h>
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 <int N>
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<float>(*options.min) : 0.0f;
const float max = options.max ? static_cast<float>(*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<std::int64_t>(options.enumNames.size());
const char* preview = value >= 0 && value < count ? options.enumNames[static_cast<std::size_t>(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<std::size_t>(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<int>(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<unsigned char>(key[i]);
if (c == '_') {
label += ' ';
continue;
}
const bool wordStart = i > 0 && std::isupper(c) && !std::isupper(static_cast<unsigned char>(key[i - 1])) && key[i - 1] != '_';
if (wordStart) {
label += ' ';
}
label += static_cast<char>(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<ObjectRef>& 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<std::size_t> 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<std::ptrdiff_t>(*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<int>(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<const ObjectId*>(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<ObjectId>& dropped)
{
const ObjectId fileId = value.GetId();
if (!value.Resolve(objects)) {
dropped.push_back(fileId);
}
}
void ResolveField(std::vector<ObjectRef>& value, const ObjectRef::ObjectMap& objects, std::vector<ObjectId>& 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<ObjectId> 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();
}
+1 -2
View File
@@ -14,8 +14,7 @@ public:
void Start() override; void Start() override;
void Update(float dt) override; void Update(float dt) override;
nlohmann::json Serialize() const override; void Reflect(FieldVisitor& v) override;
void Deserialize(const nlohmann::json& data) override;
void ImGuiInspector() override; void ImGuiInspector() override;
void Open() { m_IsOpen = true; } void Open() { m_IsOpen = true; }
@@ -17,9 +17,8 @@ public:
void Start() override; void Start() override;
void Interact(GameObject& interactor) override; void Interact(GameObject& interactor) override;
nlohmann::json Serialize() const override; void Reflect(FieldVisitor& v) override;
void Deserialize(const nlohmann::json& data) override; void OnFieldsEdited() override;
void ResolveReferences(const ObjectMap& objects) override;
void ImGuiInspector() override; void ImGuiInspector() override;
[[nodiscard]] std::size_t GetColorIndex() const { return m_ColorIndex; } [[nodiscard]] std::size_t GetColorIndex() const { return m_ColorIndex; }
@@ -35,7 +34,6 @@ public:
private: private:
void ApplyColor(); void ApplyColor();
void ActivateTargets(GameObject& interactor); void ActivateTargets(GameObject& interactor);
void DrawTargetsInspector();
std::size_t m_ColorIndex{0}; std::size_t m_ColorIndex{0};
int m_PressCount{0}; int m_PressCount{0};
+8 -21
View File
@@ -3,7 +3,7 @@
#include <algorithm> #include <algorithm>
#include <destrum/ObjectModel/GameObject.h> #include <destrum/ObjectModel/GameObject.h>
#include <destrum/Serialization/GlmJson.h> #include <destrum/Serialization/Reflect.h>
#include <imgui.h> #include <imgui.h>
@@ -58,24 +58,13 @@ void Door::ApplyPosition()
GetGameObject()->GetTransform().SetLocalPosition(m_ClosedPosition + GetOffsetFor(m_OpenAmount)); GetGameObject()->GetTransform().SetLocalPosition(m_ClosedPosition + GetOffsetFor(m_OpenAmount));
} }
nlohmann::json Door::Serialize() const void Door::Reflect(FieldVisitor& v)
{ {
return { v.Field("openOffset", m_OpenOffset, {.speed = 0.05f});
{"openOffset", m_OpenOffset}, v.Field("openDuration", m_OpenDuration, {.speed = 0.05f, .min = 0.0, .format = "%.2f s"});
{"openDuration", m_OpenDuration}, v.Field("toggleable", m_Toggleable);
{"toggleable", m_Toggleable}, v.Field("open", m_IsOpen, {.hidden = true});
{"open", m_IsOpen}, v.Field("openAmount", m_OpenAmount, {.min = 0.0, .max = 1.0, .hidden = true});
{"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);
} }
void Door::ImGuiInspector() void Door::ImGuiInspector()
@@ -85,7 +74,5 @@ void Door::ImGuiInspector()
Toggle(); Toggle();
} }
ImGui::DragFloat3("Open Offset", &m_OpenOffset.x, 0.05f); Activatable::ImGuiInspector();
ImGui::DragFloat("Open Duration", &m_OpenDuration, 0.05f, 0.0f, 10.0f, "%.2f s");
ImGui::Checkbox("Toggleable", &m_Toggleable);
} }
@@ -6,13 +6,13 @@
#include <destrum/Graphics/Renderer.h> #include <destrum/Graphics/Renderer.h>
#include <destrum/ObjectModel/GameObject.h> #include <destrum/ObjectModel/GameObject.h>
#include <destrum/Scene/Scene.h> #include <destrum/Scene/Scene.h>
#include <destrum/Serialization/Reflect.h>
#include <destrum/Util/GameState.h> #include <destrum/Util/GameState.h>
#include <spdlog/spdlog.h> #include <spdlog/spdlog.h>
#include <algorithm> #include <algorithm>
#include <array> #include <array>
#include <optional>
#include <imgui.h> #include <imgui.h>
@@ -108,93 +108,28 @@ void InteractableButton::ApplyColor()
renderer.updateMaterialGPU(meshRenderer->GetMaterialID()); renderer.updateMaterialGPU(meshRenderer->GetMaterialID());
} }
nlohmann::json InteractableButton::Serialize() const void InteractableButton::Reflect(FieldVisitor& v)
{ {
return { v.Field("colorIndex", m_ColorIndex, {.max = static_cast<double>(ButtonColors.size() - 1)});
{"colorIndex", m_ColorIndex}, v.Field("cycleColors", m_CycleColors);
{"cycleColors", m_CycleColors}, v.Field("pressCount", m_PressCount, {.readOnly = true});
{"pressCount", m_PressCount}, v.Field("targets", m_Targets, {.filter = [this](const GameObject& object) {
{"targets", m_Targets}, return &object != GetGameObject() && object.GetComponent<Activatable>() != nullptr;
}; }});
} }
void InteractableButton::Deserialize(const nlohmann::json& data) void InteractableButton::OnFieldsEdited()
{ {
m_ColorIndex = data.value("colorIndex", m_ColorIndex) % ButtonColors.size(); if (m_CycleColors) {
m_CycleColors = data.value("cycleColors", m_CycleColors); ApplyColor();
m_PressCount = data.value("pressCount", m_PressCount); }
m_Targets = data.value("targets", std::vector<ObjectRef>{});
}
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();
});
} }
void InteractableButton::ImGuiInspector() void InteractableButton::ImGuiInspector()
{ {
ImGui::Text("Presses: %d", m_PressCount);
if (ImGui::Button("Press")) { if (ImGui::Button("Press")) {
Interact(*GetGameObject()); Interact(*GetGameObject());
} }
if (ImGui::Checkbox("Cycle Colors", &m_CycleColors) && m_CycleColors) {
ApplyColor();
}
DrawTargetsInspector(); Interactable::ImGuiInspector();
}
void InteractableButton::DrawTargetsInspector()
{
ImGui::SeparatorText("Targets");
const Scene* scene = GetGameObject()->GetScene();
if (scene == nullptr) {
ImGui::TextDisabled("Not in a scene");
return;
}
std::optional<ObjectId> targetToRemove;
for (const ObjectRef& targetRef : m_Targets) {
const ObjectId targetId = targetRef.GetId();
const GameObject* target = targetRef.Get(*scene);
ImGui::PushID(static_cast<int>(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<unsigned long long>(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<Activatable>() == nullptr) {
continue;
}
ImGui::PushID(static_cast<int>(object->GetId()));
if (ImGui::Selectable(object->GetName().c_str())) {
AddTarget(*object);
}
ImGui::PopID();
}
ImGui::EndCombo();
}
} }
+152
View File
@@ -1,3 +1,4 @@
#include <array>
#include <cmath> #include <cmath>
#include <filesystem> #include <filesystem>
#include <fstream> #include <fstream>
@@ -9,7 +10,9 @@
#include <destrum/Scene/Scene.h> #include <destrum/Scene/Scene.h>
#include <destrum/Scene/SceneManager.h> #include <destrum/Scene/SceneManager.h>
#include <destrum/Serialization/GlmJson.h> #include <destrum/Serialization/GlmJson.h>
#include <destrum/Serialization/ComponentFactory.h>
#include <destrum/Serialization/MaterialSerialization.h> #include <destrum/Serialization/MaterialSerialization.h>
#include <destrum/Serialization/Reflect.h>
#include <destrum/Serialization/SceneSerializer.h> #include <destrum/Serialization/SceneSerializer.h>
#include <destrum/Components/CameraComponent.h> #include <destrum/Components/CameraComponent.h>
#include <destrum/Components/LightComponent.h> #include <destrum/Components/LightComponent.h>
@@ -386,8 +389,157 @@ namespace {
} }
} }
namespace {
enum class TestMode { Off, Slow, Fast };
constexpr std::array<const char*, 3> 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<float> values;
ObjectRef target;
std::vector<ObjectRef> 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<ReflectedComponent>();
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<ReflectedComponent>();
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<ReflectedComponent>();
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>("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<ReflectedComponent>();
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<ReflectedComponent>()->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() void RunSerializationTests()
{ {
testReflectedRoundTrip();
testReflectedLoadClampsAndKeepsDefaults();
testReflectedReferencesResolve();
testFieldLabels();
testTonemapperNames(); testTonemapperNames();
testSceneRoundTrip(); testSceneRoundTrip();
testLegacySceneLoad(); testLegacySceneLoad();