diff --git a/destrum/CMakeLists.txt b/destrum/CMakeLists.txt index 98e53ca..aec80db 100644 --- a/destrum/CMakeLists.txt +++ b/destrum/CMakeLists.txt @@ -50,6 +50,11 @@ set(SRC_FILES "src/Components/Physics/Collider.cpp" "src/Components/Physics/SphereCollider.cpp" + "src/Components/UI/Canvas.cpp" + "src/Components/UI/RectTransform.cpp" + "src/Components/UI/UIImage.cpp" + "src/Components/UI/UIText.cpp" + "src/Graphics/BindlessSetManager.cpp" "src/Graphics/Camera.cpp" "src/Graphics/DebugDraw.cpp" @@ -80,6 +85,7 @@ set(SRC_FILES "src/Graphics/Resources/NBuffer.cpp" "src/Graphics/Resources/Cubemap.cpp" "src/Graphics/Resources/Font.cpp" + "src/Graphics/Resources/FontCache.cpp" "src/Graphics/Resources/PrimitiveMeshes.cpp" "src/Graphics/Pipelines/MeshPipeline.cpp" @@ -117,6 +123,8 @@ set(SRC_FILES "src/Util/DeltaTime.cpp" "src/Util/ImGuiUtils.cpp" "src/Util/TransformGizmo.cpp" + "src/Util/RectGizmo.cpp" + "src/Util/SceneInspector.cpp" "src/Physics/PhysicsWorld.cpp" "src/Physics/SimplePhysicsWorld.cpp" diff --git a/destrum/include/destrum/Components/Physics/Rigidbody.h b/destrum/include/destrum/Components/Physics/Rigidbody.h index d004ce0..5d63d6a 100644 --- a/destrum/include/destrum/Components/Physics/Rigidbody.h +++ b/destrum/include/destrum/Components/Physics/Rigidbody.h @@ -40,6 +40,7 @@ public: void AddImpulse(const glm::vec3& impulse); void SetLinearVelocity(const glm::vec3& velocity); + void Teleport(const glm::vec3& worldPosition); [[nodiscard]] glm::vec3 GetLinearVelocity() const; [[nodiscard]] RigidbodyType GetType() const { return m_Type; } diff --git a/destrum/include/destrum/Components/UI/Canvas.h b/destrum/include/destrum/Components/UI/Canvas.h new file mode 100644 index 0000000..c20713b --- /dev/null +++ b/destrum/include/destrum/Components/UI/Canvas.h @@ -0,0 +1,52 @@ +#ifndef DESTRUM_CANVAS_H +#define DESTRUM_CANVAS_H + +#include +#include + +#include + +enum class CanvasScaleMode { + ConstantPixelSize, + ScaleWithScreenHeight, +}; + +// Draws the UIGraphics on its object and every active child, depth first, so later siblings draw on top. +// A child with its own Canvas is skipped and draws itself. +class Canvas final : public Component { +public: + explicit Canvas(GameObject& parent); + + std::string GetTypeName() const override { return "Canvas"; } + + void Start() override; + void Render(const RenderContext& ctx) override; + void Draw(const glm::vec2& screenSize); + + nlohmann::json Serialize() const override; + void Deserialize(const nlohmann::json& data) override; + void ImGuiInspector() override; + + [[nodiscard]] float GetScale(const glm::vec2& screenSize) const; + + [[nodiscard]] static Canvas* FindFor(GameObject& object); + + void SetScaleMode(CanvasScaleMode mode) { m_ScaleMode = mode; } + void SetReferenceHeight(float height) { m_ReferenceHeight = height; } + void SetScaleFactor(float factor) { m_ScaleFactor = factor; } + void SetDrawDebugRects(bool draw) { m_DrawDebugRects = draw; } + + [[nodiscard]] CanvasScaleMode GetScaleMode() const { return m_ScaleMode; } + [[nodiscard]] float GetReferenceHeight() const { return m_ReferenceHeight; } + [[nodiscard]] float GetScaleFactor() const { return m_ScaleFactor; } + +private: + void DrawObject(GameObject& object, const UIRect& rect, float scale, bool visible) const; + + CanvasScaleMode m_ScaleMode{CanvasScaleMode::ScaleWithScreenHeight}; + float m_ReferenceHeight{1080.0f}; + float m_ScaleFactor{1.0f}; + bool m_DrawDebugRects{false}; +}; + +#endif // DESTRUM_CANVAS_H diff --git a/destrum/include/destrum/Components/UI/RectTransform.h b/destrum/include/destrum/Components/UI/RectTransform.h new file mode 100644 index 0000000..1f6c61e --- /dev/null +++ b/destrum/include/destrum/Components/UI/RectTransform.h @@ -0,0 +1,68 @@ +#ifndef DESTRUM_RECTTRANSFORM_H +#define DESTRUM_RECTTRANSFORM_H + +#include + +#include + +// Screen space rectangle in draw image pixels, origin top left, y down +struct UIRect { + glm::vec2 min{0.0f}; + glm::vec2 size{0.0f}; + + [[nodiscard]] glm::vec2 Max() const { return min + size; } + [[nodiscard]] glm::vec2 Center() const { return min + size * 0.5f; } + [[nodiscard]] bool Contains(const glm::vec2& point) const; +}; + +// Anchors and pivot are normalized (0,0 top left), position and size are in canvas units +class RectTransform final : public Component { +public: + explicit RectTransform(GameObject& parent); + + std::string GetTypeName() const override { return "RectTransform"; } + + nlohmann::json Serialize() const override; + void Deserialize(const nlohmann::json& data) override; + void ImGuiInspector() override; + + [[nodiscard]] UIRect ComputeRect(const UIRect& parentRect, float scale) const; + + void SetAnchors(const glm::vec2& anchorMin, const glm::vec2& anchorMax); + void SetAnchor(const glm::vec2& anchor) { SetAnchors(anchor, anchor); } + void SetPivot(const glm::vec2& pivot) { m_Pivot = pivot; } + void SetPosition(const glm::vec2& position) { m_Position = position; } + void SetSize(const glm::vec2& size) { m_Size = size; } + void Stretch(); + + [[nodiscard]] const glm::vec2& GetAnchorMin() const { return m_AnchorMin; } + [[nodiscard]] const glm::vec2& GetAnchorMax() const { return m_AnchorMax; } + [[nodiscard]] const glm::vec2& GetPivot() const { return m_Pivot; } + [[nodiscard]] const glm::vec2& GetPosition() const { return m_Position; } + [[nodiscard]] const glm::vec2& GetSize() const { return m_Size; } + + // Where the canvas last drew this object + [[nodiscard]] const UIRect& GetScreenRect() const { return m_ScreenRect; } + [[nodiscard]] const UIRect& GetParentRect() const { return m_ParentRect; } + void SetScreenRect(const UIRect& rect, const UIRect& parentRect, float scale) + { + m_ScreenRect = rect; + m_ParentRect = parentRect; + m_LastScale = scale; + } + + void ApplyAnchorPreset(const glm::vec2& preset); + +private: + glm::vec2 m_AnchorMin{0.5f}; + glm::vec2 m_AnchorMax{0.5f}; + glm::vec2 m_Pivot{0.5f}; + glm::vec2 m_Position{0.0f}; + glm::vec2 m_Size{100.0f}; + + UIRect m_ScreenRect{}; + UIRect m_ParentRect{}; + float m_LastScale{1.0f}; +}; + +#endif // DESTRUM_RECTTRANSFORM_H diff --git a/destrum/include/destrum/Components/UI/UIGraphic.h b/destrum/include/destrum/Components/UI/UIGraphic.h new file mode 100644 index 0000000..0e4af6f --- /dev/null +++ b/destrum/include/destrum/Components/UI/UIGraphic.h @@ -0,0 +1,23 @@ +#ifndef DESTRUM_UIGRAPHIC_H +#define DESTRUM_UIGRAPHIC_H + +#include +#include + +#include + +// Anything a Canvas draws into the rect of the object it sits on +class UIGraphic : public Component { +public: + virtual void DrawUI(const UIRect& rect, float scale) = 0; + + [[nodiscard]] const glm::vec4& GetColor() const { return m_Color; } + void SetColor(const glm::vec4& color) { m_Color = color; } + +protected: + UIGraphic(GameObject& parent, const std::string& name) : Component(parent, name) {} + + glm::vec4 m_Color{1.0f}; +}; + +#endif // DESTRUM_UIGRAPHIC_H diff --git a/destrum/include/destrum/Components/UI/UIImage.h b/destrum/include/destrum/Components/UI/UIImage.h new file mode 100644 index 0000000..f0d6415 --- /dev/null +++ b/destrum/include/destrum/Components/UI/UIImage.h @@ -0,0 +1,51 @@ +#ifndef DESTRUM_UIIMAGE_H +#define DESTRUM_UIIMAGE_H + +#include + +#include +#include +#include + +enum class UIImageShape { + Rect, + Ellipse, +}; + +class UIImage final : public UIGraphic { +public: + explicit UIImage(GameObject& parent, const glm::vec4& color = glm::vec4{1.0f}); + + std::string GetTypeName() const override { return "UIImage"; } + + void DrawUI(const UIRect& rect, float scale) override; + + nlohmann::json Serialize() const override; + void Deserialize(const nlohmann::json& data) override; + void ImGuiInspector() override; + + void SetShape(UIImageShape shape) { m_Shape = shape; } + void SetOutline(float thickness) { m_Outline = thickness; } + void SetCornerRadius(float radius) { m_CornerRadius = radius; } + void SetTexture(const std::string& assetPath) { m_TexturePath = assetPath; } + void SetFilter(TextureFilter filter) { m_Filter = filter; } + + [[nodiscard]] UIImageShape GetShape() const { return m_Shape; } + [[nodiscard]] float GetOutline() const { return m_Outline; } + [[nodiscard]] float GetCornerRadius() const { return m_CornerRadius; } + [[nodiscard]] const std::string& GetTexture() const { return m_TexturePath; } + +private: + ImageID ResolveTexture(); + + UIImageShape m_Shape{UIImageShape::Rect}; + float m_Outline{0.0f}; + float m_CornerRadius{0.0f}; + std::string m_TexturePath; + TextureFilter m_Filter{TextureFilter::Linear}; + + std::string m_LoadedTexturePath; + ImageID m_Texture{NULL_IMAGE_ID}; +}; + +#endif // DESTRUM_UIIMAGE_H diff --git a/destrum/include/destrum/Components/UI/UIText.h b/destrum/include/destrum/Components/UI/UIText.h new file mode 100644 index 0000000..ab0d7a2 --- /dev/null +++ b/destrum/include/destrum/Components/UI/UIText.h @@ -0,0 +1,52 @@ +#ifndef DESTRUM_UITEXT_H +#define DESTRUM_UITEXT_H + +#include + +#include + +class Font; + +enum class UITextAlign { + Start, + Center, + End, +}; + +class UIText final : public UIGraphic { +public: + explicit UIText(GameObject& parent, std::string text = {}, float fontSize = 24.0f); + + std::string GetTypeName() const override { return "UIText"; } + + void DrawUI(const UIRect& rect, float scale) override; + + nlohmann::json Serialize() const override; + void Deserialize(const nlohmann::json& data) override; + void ImGuiInspector() override; + + void SetText(std::string text) { m_Text = std::move(text); } + void SetFont(std::string assetPath) { m_FontPath = std::move(assetPath); } + void SetFontSize(float size) { m_FontSize = size; } + void SetOutline(float thickness, const glm::vec4& color); + void SetAlignment(UITextAlign horizontal, UITextAlign vertical); + + [[nodiscard]] const std::string& GetText() const { return m_Text; } + [[nodiscard]] const std::string& GetFont() const { return m_FontPath; } + [[nodiscard]] float GetFontSize() const { return m_FontSize; } + [[nodiscard]] UITextAlign GetHorizontalAlign() const { return m_HorizontalAlign; } + [[nodiscard]] UITextAlign GetVerticalAlign() const { return m_VerticalAlign; } + + [[nodiscard]] glm::vec2 PlaceText(const UIRect& rect, const glm::vec2& textSize) const; + +private: + std::string m_Text; + std::string m_FontPath; + float m_FontSize{24.0f}; + float m_Outline{0.0f}; + glm::vec4 m_OutlineColor{0.0f, 0.0f, 0.0f, 1.0f}; + UITextAlign m_HorizontalAlign{UITextAlign::Center}; + UITextAlign m_VerticalAlign{UITextAlign::Center}; +}; + +#endif // DESTRUM_UITEXT_H diff --git a/destrum/include/destrum/Graphics/Resources/FontCache.h b/destrum/include/destrum/Graphics/Resources/FontCache.h new file mode 100644 index 0000000..12c22a5 --- /dev/null +++ b/destrum/include/destrum/Graphics/Resources/FontCache.h @@ -0,0 +1,29 @@ +#ifndef DESTRUM_FONTCACHE_H +#define DESTRUM_FONTCACHE_H + +#include +#include +#include +#include + +#include +#include + +class FontCache final : public Singleton { +public: + friend class Singleton; + + static constexpr std::string_view DefaultFont = "engine://fonts/Roboto-Medium.ttf"; + + [[nodiscard]] const Font* Get(std::string_view assetPath); + + // The atlases belong to RenderResources, call before it is cleaned up + void Clear() { fonts.clear(); } + +private: + FontCache() = default; + + std::unordered_map> fonts; +}; + +#endif // DESTRUM_FONTCACHE_H diff --git a/destrum/include/destrum/ObjectModel/Transform.h b/destrum/include/destrum/ObjectModel/Transform.h index 30ad012..76bdd02 100644 --- a/destrum/include/destrum/ObjectModel/Transform.h +++ b/destrum/include/destrum/ObjectModel/Transform.h @@ -59,6 +59,9 @@ public: [[nodiscard]] const std::vector& GetChildren() const; [[nodiscard]] GameObject* GetOwner() const; + [[nodiscard]] int GetSiblingIndex() const; + void SetSiblingIndex(int index); + private: void AddChild(Transform* transform); void RemoveChild(Transform* transform); diff --git a/destrum/include/destrum/Scene/Scene.h b/destrum/include/destrum/Scene/Scene.h index 78a82a5..df23ce1 100644 --- a/destrum/include/destrum/Scene/Scene.h +++ b/destrum/include/destrum/Scene/Scene.h @@ -8,6 +8,7 @@ #include #include #include +#include #include "destrum/Physics/JoltPhysicsWorld.h" #include "destrum/Physics/PhysicsSceneBridge.h" @@ -66,7 +67,15 @@ public: [[nodiscard]] const std::vector>& GetObjects() const { return m_objects; } + [[nodiscard]] const std::vector>& GetPendingAdditions() const { + return m_pendingAdditions; + } [[nodiscard]] GameObject* FindGameObject(ObjectId id) const; + + // Reparents object (nullptr is the scene root) and puts it right before insertBefore, or last when that is + // nullptr. Also reorders the object list so the order survives a save. False if the move isn't allowed. + bool MoveInHierarchy(GameObject& object, GameObject* parent, const GameObject* insertBefore = nullptr); + void SetSelectedObject(ObjectId id) { m_SelectedObjectId = id; } [[nodiscard]] GameObject* GetSelectedObject() const { return FindGameObject(m_SelectedObjectId); } void SetRegisterBindings(std::function registerBindings) { @@ -150,6 +159,7 @@ private: //Imgui vars bool m_ShowDemoWindow{false}; ObjectId m_SelectedObjectId{InvalidObjectId}; + SceneInspector m_Inspector; ObjectId m_CurrentCameraId{InvalidObjectId}; diff --git a/destrum/include/destrum/Serialization/EngineComponentList.h b/destrum/include/destrum/Serialization/EngineComponentList.h index 88bd728..a212a45 100644 --- a/destrum/include/destrum/Serialization/EngineComponentList.h +++ b/destrum/include/destrum/Serialization/EngineComponentList.h @@ -12,6 +12,10 @@ #include #include #include +#include +#include +#include +#include #define DESTRUM_ENGINE_COMPONENTS(X) \ X(MeshRendererComponent) \ @@ -25,5 +29,9 @@ X(BoxCollider) \ X(SphereCollider) \ X(CapsuleCollider) \ + X(Canvas) \ + X(RectTransform) \ + X(UIImage) \ + X(UIText) \ #endif // DESTRUM_ENGINECOMPONENTLIST_H diff --git a/destrum/include/destrum/Util/ImGuiUtils.h b/destrum/include/destrum/Util/ImGuiUtils.h index d3e1457..6433f03 100644 --- a/destrum/include/destrum/Util/ImGuiUtils.h +++ b/destrum/include/destrum/Util/ImGuiUtils.h @@ -3,14 +3,19 @@ #include #include +#include #include #include #include #include +#include class GameObject; +class Scene; + +using ImGuiInputTextFlags = int; namespace ImGuiUtils { [[nodiscard]] bool DrawVec3Control( @@ -18,14 +23,17 @@ namespace ImGuiUtils { glm::vec3& value, float speed = 0.1f); - void RenderSceneInspector( - std::string_view sceneName, - const std::vector>& objects, - const std::vector>& pendingAdditions, - ObjectId& selectedObjectId, - const std::function& createGameObject, - const std::function& drawSceneSettings, - bool* open = nullptr); + inline constexpr const char* GameObjectPayload = "DESTRUM_GAMEOBJECT"; + + bool InputString(const char* label, std::string& value, ImGuiInputTextFlags flags = 0, const char* hint = nullptr); + bool InputStringMultiline(const char* label, std::string& value); + + // Combo over the scene's objects, filter can hide objects that can't be picked + bool DrawObjectRefPicker( + const char* label, + ObjectRef& ref, + const Scene& scene, + const std::function& filter = {}); } #endif // DESTRUM_IMGUIUTILS_H diff --git a/destrum/include/destrum/Util/RectGizmo.h b/destrum/include/destrum/Util/RectGizmo.h new file mode 100644 index 0000000..f52b117 --- /dev/null +++ b/destrum/include/destrum/Util/RectGizmo.h @@ -0,0 +1,27 @@ +#ifndef DESTRUM_RECTGIZMO_H +#define DESTRUM_RECTGIZMO_H + +#include + +class GameObject; + +class RectGizmo final { +public: + [[nodiscard]] static bool CanManipulate(GameObject& object); + + // drawImageSize is what the canvas renders into, it is stretched over the main ImGui viewport + bool Manipulate(GameObject& object, const glm::vec2& drawImageSize); + + [[nodiscard]] bool IsUsing() const { return m_ActiveHandle >= 0; } + + bool snap{false}; + float snapStep{5.0f}; + +private: + int m_ActiveHandle{-1}; + glm::vec2 m_DragStartMouse{0.0f}; + glm::vec2 m_DragStartPosition{0.0f}; + glm::vec2 m_DragStartSize{0.0f}; +}; + +#endif // DESTRUM_RECTGIZMO_H diff --git a/destrum/include/destrum/Util/SceneInspector.h b/destrum/include/destrum/Util/SceneInspector.h new file mode 100644 index 0000000..6072514 --- /dev/null +++ b/destrum/include/destrum/Util/SceneInspector.h @@ -0,0 +1,60 @@ +#ifndef DESTRUM_SCENEINSPECTOR_H +#define DESTRUM_SCENEINSPECTOR_H + +#include +#include +#include + +#include + +#include + +class Component; +class GameObject; +class Scene; + +class SceneInspector final { +public: + void Draw(Scene& scene, ObjectId& selectedObjectId, const std::function& drawSceneSettings, bool* open); + +private: + struct PendingMove { + ObjectId object{InvalidObjectId}; + ObjectId parent{InvalidObjectId}; + ObjectId anchor{InvalidObjectId}; + bool after{false}; + }; + + void DrawHierarchy(Scene& scene, ObjectId& selectedObjectId); + void DrawNode(Scene& scene, GameObject& object, ObjectId& selectedObjectId, std::unordered_set& visited); + void DrawRenameField(GameObject& object); + void DrawNodeDragDrop(GameObject& object); + void DrawObjectMenu(Scene& scene, GameObject* object, ObjectId& selectedObjectId); + void HandleShortcuts(Scene& scene, ObjectId& selectedObjectId); + void ApplyPendingActions(Scene& scene, ObjectId& selectedObjectId); + + void DrawObject(GameObject& object); + void DrawTransform(GameObject& object); + void DrawComponents(GameObject& object); + void DrawComponentMenu(GameObject& object, Component& component, std::size_t index, bool removable); + void DrawAddComponent(GameObject& object); + + void BeginRename(const GameObject& object); + + std::string m_Filter; + std::string m_ComponentFilter; + + ObjectId m_RenamingId{InvalidObjectId}; + std::string m_RenameBuffer; + bool m_FocusRename{false}; + + ObjectId m_OpenNextFrame{InvalidObjectId}; + PendingMove m_PendingMove; + ObjectId m_PendingDelete{InvalidObjectId}; + ObjectId m_PendingDuplicate{InvalidObjectId}; + + std::string m_ClipboardType; + nlohmann::json m_Clipboard; +}; + +#endif // DESTRUM_SCENEINSPECTOR_H diff --git a/destrum/src/App.cpp b/destrum/src/App.cpp index 41dd4a0..6308217 100644 --- a/destrum/src/App.cpp +++ b/destrum/src/App.cpp @@ -6,6 +6,7 @@ #include #include +#include #include #include @@ -358,6 +359,7 @@ void App::cleanup() attempt([this] { customCleanup(); }); } attempt([] { SceneManager::GetInstance().Destroy(); }); + attempt([] { FontCache::GetInstance().Clear(); }); attempt([this] { renderer.cleanup(gfxDevice); }); attempt([this] { imguiPass.cleanup(); }); attempt([this] { resources.cleanup(gfxDevice); }); diff --git a/destrum/src/Components/Physics/Rigidbody.cpp b/destrum/src/Components/Physics/Rigidbody.cpp index 67020df..8be12cf 100644 --- a/destrum/src/Components/Physics/Rigidbody.cpp +++ b/destrum/src/Components/Physics/Rigidbody.cpp @@ -91,6 +91,17 @@ void Rigidbody::SetLinearVelocity(const glm::vec3& velocity) { m_World->SetLinearVelocity(m_Body, velocity); } +void Rigidbody::Teleport(const glm::vec3& worldPosition) { + Transform& transform = GetTransform(); + transform.SetWorldPosition(worldPosition); + if (!HasPhysicsBody()) { + return; + } + + m_World->SetBodyTransform(m_Body, PhysicsTransform{worldPosition, transform.GetWorldRotation()}); + m_World->SetLinearVelocity(m_Body, glm::vec3{0.0f}); +} + glm::vec3 Rigidbody::GetLinearVelocity() const { if (!HasPhysicsBody()) { return glm::vec3{0.0f}; diff --git a/destrum/src/Components/UI/Canvas.cpp b/destrum/src/Components/UI/Canvas.cpp new file mode 100644 index 0000000..7bba718 --- /dev/null +++ b/destrum/src/Components/UI/Canvas.cpp @@ -0,0 +1,147 @@ +#include + +#include + +#include +#include +#include +#include +#include + +#include + +#include "tracy/Tracy.hpp" + +Canvas::Canvas(GameObject& parent) + : Component(parent, "Canvas") +{ +} + +namespace { + void WarmFonts(const GameObject& object) + { + for (const auto& component : object.GetComponents()) { + if (const auto* text = dynamic_cast(component.get())) { + (void)FontCache::GetInstance().Get(text->GetFont()); + } + } + for (const Transform* child : object.GetTransform().GetChildren()) { + if (child != nullptr && child->GetOwner() != nullptr) { + WarmFonts(*child->GetOwner()); + } + } + } +} + +// Building a font atlas takes seconds in Debug, so do it now instead of the first time hidden text shows up +void Canvas::Start() +{ + WarmFonts(*GetGameObject()); +} + +Canvas* Canvas::FindFor(GameObject& object) +{ + for (Transform* transform = &object.GetTransform(); transform != nullptr; transform = transform->GetParent()) { + GameObject* owner = transform->GetOwner(); + if (owner == nullptr) { + continue; + } + if (auto* canvas = owner->GetComponent()) { + return canvas; + } + } + return nullptr; +} + +void Canvas::Render(const RenderContext& ctx) +{ + const VkExtent3D extent = ctx.renderer.getDrawImage().extent; + Draw(glm::vec2{static_cast(extent.width), static_cast(extent.height)}); +} + +void Canvas::Draw(const glm::vec2& screenSize) +{ + ZoneScopedN("Canvas::Draw"); + + const UIRect screenRect{.min = glm::vec2{0.0f}, .size = screenSize}; + if (auto* rectTransform = GetGameObject()->GetComponent()) { + rectTransform->SetScreenRect(screenRect, screenRect, GetScale(screenSize)); + } + DrawObject(*GetGameObject(), screenRect, GetScale(screenSize), true); +} + +float Canvas::GetScale(const glm::vec2& screenSize) const +{ + if (m_ScaleMode == CanvasScaleMode::ScaleWithScreenHeight && m_ReferenceHeight > 0.0f) { + return screenSize.y / m_ReferenceHeight; + } + return m_ScaleFactor; +} + +// Hidden objects are still laid out so the editor can show and move them +void Canvas::DrawObject(GameObject& object, const UIRect& rect, float scale, bool visible) const +{ + if (visible) { + for (const auto& component : object.GetComponents()) { + auto* graphic = dynamic_cast(component.get()); + if (graphic != nullptr && graphic->isEnabled() && !graphic->IsBeingDestroyed()) { + graphic->DrawUI(rect, scale); + } + } + + if (m_DrawDebugRects) { + Renderer2D::GetInstance().DrawRectOutline(rect.min, rect.size, 1.0f, glm::vec4{1.0f, 0.0f, 1.0f, 0.8f}); + } + } + + for (Transform* childTransform : object.GetTransform().GetChildren()) { + GameObject* child = childTransform != nullptr ? childTransform->GetOwner() : nullptr; + if (child == nullptr || child->IsBeingDestroyed() || child->HasComponent()) { + continue; + } + + UIRect childRect = rect; + if (auto* rectTransform = child->GetComponent()) { + childRect = rectTransform->ComputeRect(rect, scale); + rectTransform->SetScreenRect(childRect, rect, scale); + } + DrawObject(*child, childRect, scale, visible && child->IsActive()); + } +} + +nlohmann::json Canvas::Serialize() const +{ + return { + {"scaleMode", m_ScaleMode == CanvasScaleMode::ConstantPixelSize ? "constantPixelSize" : "scaleWithScreenHeight"}, + {"referenceHeight", m_ReferenceHeight}, + {"scaleFactor", m_ScaleFactor}, + {"drawDebugRects", m_DrawDebugRects}, + }; +} + +void Canvas::Deserialize(const nlohmann::json& data) +{ + m_ScaleMode = data.value("scaleMode", std::string{"scaleWithScreenHeight"}) == "constantPixelSize" + ? CanvasScaleMode::ConstantPixelSize + : CanvasScaleMode::ScaleWithScreenHeight; + m_ReferenceHeight = data.value("referenceHeight", m_ReferenceHeight); + m_ScaleFactor = data.value("scaleFactor", m_ScaleFactor); + m_DrawDebugRects = data.value("drawDebugRects", m_DrawDebugRects); +} + +void Canvas::ImGuiInspector() +{ + constexpr std::array modeNames{"Constant Pixel Size", "Scale With Screen Height"}; + int mode = static_cast(m_ScaleMode); + if (ImGui::Combo("Scale Mode", &mode, modeNames.data(), static_cast(modeNames.size()))) { + m_ScaleMode = static_cast(mode); + } + + if (m_ScaleMode == CanvasScaleMode::ScaleWithScreenHeight) { + ImGui::DragFloat("Reference Height", &m_ReferenceHeight, 1.0f, 1.0f, 8192.0f); + } else { + ImGui::DragFloat("Scale Factor", &m_ScaleFactor, 0.01f, 0.01f, 10.0f); + } + + ImGui::Checkbox("Draw Debug Rects", &m_DrawDebugRects); +} diff --git a/destrum/src/Components/UI/RectTransform.cpp b/destrum/src/Components/UI/RectTransform.cpp new file mode 100644 index 0000000..27cb4d9 --- /dev/null +++ b/destrum/src/Components/UI/RectTransform.cpp @@ -0,0 +1,129 @@ +#include + +#include + +#include + +#include + +bool UIRect::Contains(const glm::vec2& point) const +{ + const glm::vec2 max = Max(); + return point.x >= min.x && point.y >= min.y && point.x <= max.x && point.y <= max.y; +} + +RectTransform::RectTransform(GameObject& parent) + : Component(parent, "RectTransform") +{ +} + +UIRect RectTransform::ComputeRect(const UIRect& parentRect, float scale) const +{ + const glm::vec2 anchorMin = parentRect.min + parentRect.size * m_AnchorMin; + const glm::vec2 anchorMax = parentRect.min + parentRect.size * m_AnchorMax; + const glm::vec2 anchorSize = anchorMax - anchorMin; + + const glm::vec2 size = glm::max(anchorSize + m_Size * scale, glm::vec2{0.0f}); + const glm::vec2 pivotPosition = anchorMin + anchorSize * m_Pivot + m_Position * scale; + return UIRect{.min = pivotPosition - size * m_Pivot, .size = size}; +} + +void RectTransform::SetAnchors(const glm::vec2& anchorMin, const glm::vec2& anchorMax) +{ + m_AnchorMin = anchorMin; + m_AnchorMax = anchorMax; +} + +void RectTransform::ApplyAnchorPreset(const glm::vec2& preset) +{ + for (int axis = 0; axis < 2; ++axis) { + const float value = preset[axis]; + if (value == 0.0f || value == 0.5f || value == 1.0f) { + m_Size[axis] = m_LastScale > 0.0f ? m_ScreenRect.size[axis] / m_LastScale : m_Size[axis]; + m_AnchorMin[axis] = value; + m_AnchorMax[axis] = value; + m_Pivot[axis] = value; + } else { + m_Size[axis] = 0.0f; + m_AnchorMin[axis] = 0.0f; + m_AnchorMax[axis] = 1.0f; + m_Pivot[axis] = 0.5f; + } + m_Position[axis] = 0.0f; + } +} + +void RectTransform::Stretch() +{ + SetAnchors(glm::vec2{0.0f}, glm::vec2{1.0f}); + m_Pivot = glm::vec2{0.5f}; + m_Position = glm::vec2{0.0f}; + m_Size = glm::vec2{0.0f}; +} + +nlohmann::json RectTransform::Serialize() const +{ + return { + {"anchorMin", m_AnchorMin}, + {"anchorMax", m_AnchorMax}, + {"pivot", m_Pivot}, + {"position", m_Position}, + {"size", m_Size}, + }; +} + +void RectTransform::Deserialize(const nlohmann::json& data) +{ + m_AnchorMin = data.value("anchorMin", m_AnchorMin); + m_AnchorMax = data.value("anchorMax", m_AnchorMax); + m_Pivot = data.value("pivot", m_Pivot); + m_Position = data.value("position", m_Position); + m_Size = data.value("size", m_Size); +} + +void RectTransform::ImGuiInspector() +{ + constexpr float StretchPreset = -1.0f; + constexpr std::array presetValues{0.0f, 0.5f, 1.0f, StretchPreset}; + constexpr std::array rowNames{"Top", "Middle", "Bottom", "Stretch"}; + constexpr std::array columnNames{"Left", "Center", "Right", "Stretch"}; + constexpr std::array rowLetters{'T', 'M', 'B', 'S'}; + constexpr std::array columnLetters{'L', 'C', 'R', 'S'}; + + const auto matches = [this](int axis, float value) { + return value == StretchPreset ? m_AnchorMin[axis] == 0.0f && m_AnchorMax[axis] == 1.0f + : m_AnchorMin[axis] == value && m_AnchorMax[axis] == value; + }; + + ImGui::TextDisabled("Anchor presets"); + for (std::size_t row = 0; row < presetValues.size(); ++row) { + for (std::size_t column = 0; column < presetValues.size(); ++column) { + if (column > 0) { + ImGui::SameLine(); + } + const bool selected = matches(0, presetValues[column]) && matches(1, presetValues[row]); + const char label[] = {rowLetters[row], columnLetters[column], '\0'}; + ImGui::PushID(static_cast(row * presetValues.size() + column)); + if (selected) { + ImGui::PushStyleColor(ImGuiCol_Button, ImGui::GetStyleColorVec4(ImGuiCol_ButtonActive)); + } + if (ImGui::Button(label, ImVec2{28.0f, 0.0f})) { + ApplyAnchorPreset({presetValues[column], presetValues[row]}); + } + if (selected) { + ImGui::PopStyleColor(); + } + ImGui::SetItemTooltip("%s %s", rowNames[row], columnNames[column]); + ImGui::PopID(); + } + } + + ImGui::DragFloat2("Anchor Min", &m_AnchorMin.x, 0.01f, 0.0f, 1.0f); + ImGui::DragFloat2("Anchor Max", &m_AnchorMax.x, 0.01f, 0.0f, 1.0f); + ImGui::DragFloat2("Pivot", &m_Pivot.x, 0.01f, 0.0f, 1.0f); + ImGui::DragFloat2("Position", &m_Position.x, 1.0f); + ImGui::DragFloat2("Size", &m_Size.x, 1.0f); + + ImGui::TextDisabled("Screen: %.0f, %.0f %.0f x %.0f", + m_ScreenRect.min.x, m_ScreenRect.min.y, m_ScreenRect.size.x, m_ScreenRect.size.y); +} diff --git a/destrum/src/Components/UI/UIImage.cpp b/destrum/src/Components/UI/UIImage.cpp new file mode 100644 index 0000000..2c88c15 --- /dev/null +++ b/destrum/src/Components/UI/UIImage.cpp @@ -0,0 +1,108 @@ +#include + +#include + +#include +#include +#include +#include +#include +#include + +#include +#include + +UIImage::UIImage(GameObject& parent, const glm::vec4& color) + : UIGraphic(parent, "UIImage") +{ + m_Color = color; +} + +void UIImage::DrawUI(const UIRect& rect, float scale) +{ + if (rect.size.x <= 0.0f || rect.size.y <= 0.0f || m_Color.w <= 0.0f) { + return; + } + + const ShapeStyle style{ + .color = m_Color, + .outline = m_Outline * scale, + .cornerRadius = m_CornerRadius * scale, + .texture = {.id = ResolveTexture(), .filter = m_Filter}, + }; + + auto& renderer2D = Renderer2D::GetInstance(); + if (m_Shape == UIImageShape::Ellipse) { + renderer2D.DrawEllipse(rect.Center(), rect.size * 0.5f, style); + } else { + renderer2D.DrawRect(rect.min, rect.size, style); + } +} + +ImageID UIImage::ResolveTexture() +{ + if (m_TexturePath == m_LoadedTexturePath) { + return m_Texture; + } + + auto& gameState = GameState::GetInstance(); + if (m_TexturePath.empty()) { + m_Texture = NULL_IMAGE_ID; + } else if (gameState.HasGfxDevice() && gameState.HasRenderer() && gameState.Renderer().getResources() != nullptr) { + try { + m_Texture = gameState.Renderer().getResources()->loadImageFromFile( + gameState.Gfx(), AssetFS::GetInstance().GetFullPath(m_TexturePath)); + } catch (const std::exception& exception) { + spdlog::warn("UIImage: failed to load texture '{}': {}", m_TexturePath, exception.what()); + m_Texture = NULL_IMAGE_ID; + } + } else { + return NULL_IMAGE_ID; + } + + m_LoadedTexturePath = m_TexturePath; + return m_Texture; +} + +nlohmann::json UIImage::Serialize() const +{ + return { + {"color", m_Color}, + {"shape", m_Shape == UIImageShape::Ellipse ? "ellipse" : "rect"}, + {"outline", m_Outline}, + {"cornerRadius", m_CornerRadius}, + {"texture", m_TexturePath}, + {"filter", m_Filter == TextureFilter::Nearest ? "nearest" : "linear"}, + }; +} + +void UIImage::Deserialize(const nlohmann::json& data) +{ + m_Color = data.value("color", m_Color); + m_Shape = data.value("shape", std::string{"rect"}) == "ellipse" ? UIImageShape::Ellipse : UIImageShape::Rect; + m_Outline = data.value("outline", m_Outline); + m_CornerRadius = data.value("cornerRadius", m_CornerRadius); + m_TexturePath = data.value("texture", m_TexturePath); + m_Filter = data.value("filter", std::string{"linear"}) == "nearest" ? TextureFilter::Nearest : TextureFilter::Linear; +} + +void UIImage::ImGuiInspector() +{ + constexpr std::array shapeNames{"Rect", "Ellipse"}; + int shape = static_cast(m_Shape); + if (ImGui::Combo("Shape", &shape, shapeNames.data(), static_cast(shapeNames.size()))) { + m_Shape = static_cast(shape); + } + + ImGui::ColorEdit4("Color", &m_Color.x); + ImGui::DragFloat("Outline", &m_Outline, 0.1f, 0.0f, 100.0f); + if (m_Shape == UIImageShape::Rect) { + ImGui::DragFloat("Corner Radius", &m_CornerRadius, 0.1f, 0.0f, 500.0f); + } + + ImGuiUtils::InputString("Texture", m_TexturePath); + bool nearest = m_Filter == TextureFilter::Nearest; + if (ImGui::Checkbox("Nearest Filter", &nearest)) { + m_Filter = nearest ? TextureFilter::Nearest : TextureFilter::Linear; + } +} diff --git a/destrum/src/Components/UI/UIText.cpp b/destrum/src/Components/UI/UIText.cpp new file mode 100644 index 0000000..3e14349 --- /dev/null +++ b/destrum/src/Components/UI/UIText.cpp @@ -0,0 +1,133 @@ +#include + +#include + +#include +#include +#include +#include + +#include + +namespace { + [[nodiscard]] float AlignFactor(UITextAlign align) + { + switch (align) { + case UITextAlign::Start: return 0.0f; + case UITextAlign::Center: return 0.5f; + case UITextAlign::End: return 1.0f; + } + return 0.0f; + } + + [[nodiscard]] const char* AlignName(UITextAlign align) + { + switch (align) { + case UITextAlign::Start: return "start"; + case UITextAlign::Center: return "center"; + case UITextAlign::End: return "end"; + } + return "start"; + } + + [[nodiscard]] UITextAlign ParseAlign(const std::string& name, UITextAlign fallback) + { + if (name == "start") return UITextAlign::Start; + if (name == "center") return UITextAlign::Center; + if (name == "end") return UITextAlign::End; + return fallback; + } +} + +UIText::UIText(GameObject& parent, std::string text, float fontSize) + : UIGraphic(parent, "UIText") + , m_Text(std::move(text)) + , m_FontPath(FontCache::DefaultFont) + , m_FontSize(fontSize) +{ +} + +void UIText::SetOutline(float thickness, const glm::vec4& color) +{ + m_Outline = thickness; + m_OutlineColor = color; +} + +void UIText::SetAlignment(UITextAlign horizontal, UITextAlign vertical) +{ + m_HorizontalAlign = horizontal; + m_VerticalAlign = vertical; +} + +glm::vec2 UIText::PlaceText(const UIRect& rect, const glm::vec2& textSize) const +{ + return rect.min + (rect.size - textSize) * + glm::vec2{AlignFactor(m_HorizontalAlign), AlignFactor(m_VerticalAlign)}; +} + +void UIText::DrawUI(const UIRect& rect, float scale) +{ + if (m_Text.empty() || m_FontSize <= 0.0f || m_Color.w <= 0.0f) { + return; + } + + const Font* font = FontCache::GetInstance().Get(m_FontPath); + if (font == nullptr) { + return; + } + + const float size = m_FontSize * scale; + const glm::vec2 topLeft = PlaceText(rect, font->MeasureText(m_Text, size)); + Renderer2D::GetInstance().DrawText( + *font, + m_Text, + topLeft, + size, + TextStyle{.color = m_Color, .outline = m_Outline * scale, .outlineColor = m_OutlineColor}); +} + +nlohmann::json UIText::Serialize() const +{ + return { + {"text", m_Text}, + {"font", m_FontPath}, + {"fontSize", m_FontSize}, + {"color", m_Color}, + {"outline", m_Outline}, + {"outlineColor", m_OutlineColor}, + {"horizontalAlign", AlignName(m_HorizontalAlign)}, + {"verticalAlign", AlignName(m_VerticalAlign)}, + }; +} + +void UIText::Deserialize(const nlohmann::json& data) +{ + m_Text = data.value("text", m_Text); + m_FontPath = data.value("font", m_FontPath); + m_FontSize = data.value("fontSize", m_FontSize); + m_Color = data.value("color", m_Color); + m_Outline = data.value("outline", m_Outline); + m_OutlineColor = data.value("outlineColor", m_OutlineColor); + m_HorizontalAlign = ParseAlign(data.value("horizontalAlign", std::string{}), m_HorizontalAlign); + m_VerticalAlign = ParseAlign(data.value("verticalAlign", std::string{}), m_VerticalAlign); +} + +void UIText::ImGuiInspector() +{ + ImGuiUtils::InputStringMultiline("Text", m_Text); + ImGuiUtils::InputString("Font", m_FontPath); + ImGui::DragFloat("Font Size", &m_FontSize, 0.5f, 1.0f, 1000.0f); + ImGui::ColorEdit4("Color", &m_Color.x); + ImGui::DragFloat("Outline", &m_Outline, 0.1f, 0.0f, 16.0f); + ImGui::ColorEdit4("Outline Color", &m_OutlineColor.x); + + constexpr std::array alignNames{"Start", "Center", "End"}; + int horizontal = static_cast(m_HorizontalAlign); + if (ImGui::Combo("Horizontal", &horizontal, alignNames.data(), static_cast(alignNames.size()))) { + m_HorizontalAlign = static_cast(horizontal); + } + int vertical = static_cast(m_VerticalAlign); + if (ImGui::Combo("Vertical", &vertical, alignNames.data(), static_cast(alignNames.size()))) { + m_VerticalAlign = static_cast(vertical); + } +} diff --git a/destrum/src/Graphics/Resources/FontCache.cpp b/destrum/src/Graphics/Resources/FontCache.cpp new file mode 100644 index 0000000..b00bf0f --- /dev/null +++ b/destrum/src/Graphics/Resources/FontCache.cpp @@ -0,0 +1,34 @@ +#include + +#include +#include +#include + +#include + +const Font* FontCache::Get(std::string_view assetPath) +{ + auto it = fonts.find(std::string{assetPath}); + if (it == fonts.end()) { + std::unique_ptr font; + try { + font = std::make_unique(Font::LoadFromFile(AssetFS::GetInstance().GetFullPath(assetPath))); + } catch (const std::exception& exception) { + spdlog::warn("FontCache: failed to load '{}': {}", assetPath, exception.what()); + } + // Failures are cached too so a bad path doesn't hit the disk every frame + it = fonts.emplace(std::string{assetPath}, std::move(font)).first; + } + + Font* font = it->second.get(); + if (font == nullptr) { + return nullptr; + } + + auto& gameState = GameState::GetInstance(); + if (font->GetAtlas() == NULL_IMAGE_ID && gameState.HasGfxDevice() && gameState.HasRenderer() && + gameState.Renderer().getResources() != nullptr) { + font->Upload(gameState.Gfx(), *gameState.Renderer().getResources()); + } + return font; +} diff --git a/destrum/src/ObjectModel/Transform.cpp b/destrum/src/ObjectModel/Transform.cpp index 566d844..93a32c8 100644 --- a/destrum/src/ObjectModel/Transform.cpp +++ b/destrum/src/ObjectModel/Transform.cpp @@ -195,6 +195,28 @@ void Transform::RemoveChild(Transform* transform) { } } +int Transform::GetSiblingIndex() const { + if (m_Parent == nullptr) { + return 0; + } + const auto& siblings = m_Parent->m_Children; + return static_cast(std::find(siblings.begin(), siblings.end(), this) - siblings.begin()); +} + +void Transform::SetSiblingIndex(int index) { + if (m_Parent == nullptr) { + return; + } + auto& siblings = m_Parent->m_Children; + const auto it = std::find(siblings.begin(), siblings.end(), this); + if (it == siblings.end()) { + return; + } + siblings.erase(it); + const int clamped = std::clamp(index, 0, static_cast(siblings.size())); + siblings.insert(siblings.begin() + clamped, this); +} + void Transform::AddChild(Transform* transform) { if (transform == nullptr || transform == this) { return; diff --git a/destrum/src/Scene/Scene.cpp b/destrum/src/Scene/Scene.cpp index 23379c8..e6cd576 100644 --- a/destrum/src/Scene/Scene.cpp +++ b/destrum/src/Scene/Scene.cpp @@ -1,7 +1,6 @@ #include #include #include -#include #include #include @@ -238,14 +237,68 @@ void Scene::SetCurrentCamera(const CameraComponent* camera) { } void Scene::RenderImgui(bool* open) { - ImGuiUtils::RenderSceneInspector( - m_name, - m_objects, - m_pendingAdditions, - m_SelectedObjectId, - [this] { return CreateGameObject("GameObject"); }, - [this] { DrawSettingsImGui(); }, - open); + m_Inspector.Draw(*this, m_SelectedObjectId, [this] { DrawSettingsImGui(); }, open); +} + +bool Scene::MoveInHierarchy(GameObject& object, GameObject* parent, const GameObject* insertBefore) { + if (IsIterating() || object.IsBeingDestroyed() || object.GetScene() != this || parent == &object) { + return false; + } + + Transform& transform = object.GetTransform(); + Transform* parentTransform = parent != nullptr ? &parent->GetTransform() : nullptr; + if (parentTransform != nullptr && transform.IsChild(parentTransform)) { + return false; + } + + transform.SetParent(parentTransform, true); + if (transform.GetParent() != parentTransform) { + return false; + } + + const auto findIn = [](auto& objects, const GameObject* target) { + return std::find_if(objects.begin(), objects.end(), [target](const auto& candidate) { + return candidate.get() == target; + }); + }; + + if (parentTransform != nullptr) { + int index = parentTransform->GetChildCount(); + if (insertBefore != nullptr && insertBefore->GetTransform().GetParent() == parentTransform) { + index = insertBefore->GetTransform().GetSiblingIndex(); + if (transform.GetSiblingIndex() < index) { + --index; + } + } + transform.SetSiblingIndex(index); + } + + // Loading rebuilds sibling order from the object list, so keep the list in the same order + const auto it = findIn(m_objects, &object); + if (it == m_objects.end()) { + return true; + } + std::shared_ptr moved = *it; + m_objects.erase(it); + + auto position = m_objects.end(); + if (insertBefore != nullptr) { + position = findIn(m_objects, insertBefore); + } else if (parentTransform != nullptr) { + // After the last sibling that is already in the list, or right after the parent + const GameObject* anchor = parent; + for (const Transform* sibling : parentTransform->GetChildren()) { + if (sibling != &transform && findIn(m_objects, sibling->GetOwner()) != m_objects.end()) { + anchor = sibling->GetOwner(); + } + } + position = findIn(m_objects, anchor); + if (position != m_objects.end()) { + ++position; + } + } + m_objects.insert(position, std::move(moved)); + return true; } void Scene::CleanupDestroyedGameObjects() { diff --git a/destrum/src/Util/ImGuiUtils.cpp b/destrum/src/Util/ImGuiUtils.cpp index 9c7ea7c..8eaba5a 100644 --- a/destrum/src/Util/ImGuiUtils.cpp +++ b/destrum/src/Util/ImGuiUtils.cpp @@ -4,7 +4,6 @@ #include #include #include -#include #include #include @@ -15,90 +14,21 @@ #include #include #include -#include -#include -#include +#include namespace { + int ResizeStringCallback(ImGuiInputTextCallbackData* data) { + if (data->EventFlag == ImGuiInputTextFlags_CallbackResize) { + auto* string = static_cast(data->UserData); + string->resize(static_cast(data->BufTextLen)); + data->Buf = string->data(); + } + return 0; + } + [[nodiscard]] bool IsInspectableObject(const GameObject* object) { return object != nullptr && !object->IsBeingDestroyed(); } - - [[nodiscard]] bool HasInspectableChildren(const GameObject& object) { - return std::ranges::any_of(object.GetTransform().GetChildren(), - [](const Transform* child) { - return child != nullptr && IsInspectableObject(child->GetOwner()); - }); - } - - void DrawGameObjectNode( - GameObject& object, - ObjectId& selectedObjectId, - std::unordered_set& visited, - bool defaultOpen = false) { - if (!IsInspectableObject(&object) || !visited.insert(&object).second) { - return; - } - - const bool hasChildren = HasInspectableChildren(object); - ImGuiTreeNodeFlags treeFlags = ImGuiTreeNodeFlags_SpanAvailWidth; - if (object.GetId() == selectedObjectId) { - treeFlags |= ImGuiTreeNodeFlags_Selected; - } - if (defaultOpen) { - treeFlags |= ImGuiTreeNodeFlags_DefaultOpen; - } - if (!hasChildren) { - treeFlags |= ImGuiTreeNodeFlags_Leaf | ImGuiTreeNodeFlags_NoTreePushOnOpen; - } - - const std::string displayName = object.GetName().empty() - ? "" - : object.GetName(); - const bool isOpen = ImGui::TreeNodeEx( - static_cast(&object), - treeFlags, - "%s", - displayName.c_str()); - - if (ImGui::IsItemClicked()) { - selectedObjectId = object.GetId(); - } - - if (!object.IsActive()) { - ImGui::SameLine(); - ImGui::TextDisabled("[inactive]"); - } else if (!object.IsActiveInHierarchy()) { - ImGui::SameLine(); - ImGui::TextDisabled("[parent inactive]"); - } - - if (isOpen && hasChildren) { - for (Transform* childTransform : object.GetTransform().GetChildren()) { - if (childTransform == nullptr) { - continue; - } - - GameObject* child = childTransform->GetOwner(); - if (IsInspectableObject(child)) { - DrawGameObjectNode(*child, selectedObjectId, visited); - } - } - ImGui::TreePop(); - } - } - - [[nodiscard]] GameObject* FindSelectedObject( - const std::vector& objects, - ObjectId selectedObjectId) { - const auto it = std::find_if( - objects.begin(), - objects.end(), - [selectedObjectId](const GameObject* object) { - return object != nullptr && object->GetId() == selectedObjectId; - }); - return it == objects.end() ? nullptr : *it; - } } namespace ImGuiUtils { @@ -110,7 +40,7 @@ namespace ImGuiUtils { ImGui::PushID(label); ImGui::AlignTextToFramePadding(); ImGui::TextUnformatted(label); - ImGui::SameLine(); + ImGui::SameLine(ImGui::GetFontSize() * 5.0f); ImGui::SetNextItemWidth(-FLT_MIN); const bool changed = ImGui::DragFloat3( "##value", @@ -120,272 +50,59 @@ namespace ImGuiUtils { return changed; } - void RenderSceneInspector( - std::string_view sceneName, - const std::vector>& objectsSource, - const std::vector>& pendingAdditions, - ObjectId& selectedObjectId, - const std::function& createGameObject, - const std::function& drawSceneSettings, - bool* open) { - RegisterEngineComponents(); + bool InputString(const char* label, std::string& value, ImGuiInputTextFlags flags, const char* hint) + { + flags |= ImGuiInputTextFlags_CallbackResize; + if (hint != nullptr) { + return ImGui::InputTextWithHint(label, hint, value.data(), value.capacity() + 1, flags, ResizeStringCallback, &value); + } + return ImGui::InputText(label, value.data(), value.capacity() + 1, flags, ResizeStringCallback, &value); + } - std::vector objects; - objects.reserve(objectsSource.size() + pendingAdditions.size()); + bool InputStringMultiline(const char* label, std::string& value) + { + return ImGui::InputTextMultiline(label, value.data(), value.capacity() + 1, ImVec2{0.0f, ImGui::GetTextLineHeight() * 4.0f}, + ImGuiInputTextFlags_CallbackResize, ResizeStringCallback, &value); + } - const auto appendInspectableObjects = [&objects](const auto& source) { - for (const auto& object : source) { - if (IsInspectableObject(object.get())) { - objects.push_back(object.get()); + bool DrawObjectRefPicker(const char* label, ObjectRef& ref, const Scene& scene, + const std::function& filter) + { + const GameObject* current = ref.Get(scene); + const std::string preview = current != nullptr ? current->GetName() + : ref.IsSet() ? "Missing object " + std::to_string(ref.GetId()) + : "None"; + + bool changed = false; + if (ImGui::BeginCombo(label, preview.c_str())) { + if (ImGui::Selectable("None", !ref.IsSet())) { + ref = ObjectRef{}; + changed = true; + } + for (const auto& object : scene.GetObjects()) { + if (!IsInspectableObject(object.get()) || (filter && !filter(*object))) { + continue; + } + ImGui::PushID(static_cast(object->GetId())); + if (ImGui::Selectable(object->GetName().c_str(), object.get() == current)) { + ref = ObjectRef(*object); + changed = true; + } + ImGui::PopID(); + } + ImGui::EndCombo(); + } + + if (ImGui::BeginDragDropTarget()) { + if (const ImGuiPayload* payload = ImGui::AcceptDragDropPayload(GameObjectPayload)) { + GameObject* dropped = scene.FindGameObject(*static_cast(payload->Data)); + if (dropped != nullptr && (!filter || filter(*dropped))) { + ref = ObjectRef(*dropped); + changed = true; } } - }; - - appendInspectableObjects(objectsSource); - appendInspectableObjects(pendingAdditions); - - std::vector roots; - roots.reserve(objects.size()); - for (GameObject* object : objects) { - Transform* parent = object->GetTransform().GetParent(); - if (parent == nullptr || !IsInspectableObject(parent->GetOwner())) { - roots.push_back(object); - } + ImGui::EndDragDropTarget(); } - - if (FindSelectedObject(objects, selectedObjectId) == nullptr) { - selectedObjectId = InvalidObjectId; - } - - ImGui::SetNextWindowSize(ImVec2(640.0f, 520.0f), ImGuiCond_FirstUseEver); - if (!ImGui::Begin("Inspector", open)) { - ImGui::End(); - return; - } - - const std::string displaySceneName = sceneName.empty() - ? "" - : std::string{sceneName}; - ImGui::Text("Scene: %s", displaySceneName.c_str()); - ImGui::SameLine(); - ImGui::TextDisabled("%zu GameObjects | %zu roots", objects.size(), roots.size()); - if (createGameObject) { - ImGui::SameLine(); - if (ImGui::Button("+ Add GameObject")) { - if (GameObject* created = createGameObject()) { - objects.push_back(created); - selectedObjectId = created->GetId(); - } - } - } - if (drawSceneSettings && ImGui::CollapsingHeader("Scene Settings")) { - drawSceneSettings(); - } - ImGui::Separator(); - - const float availableWidth = ImGui::GetContentRegionAvail().x; - const float hierarchyWidth = std::max(180.0f, availableWidth * 0.45f); - - if (ImGui::BeginChild( - "SceneHierarchy", - ImVec2(hierarchyWidth, 0.0f), - ImGuiChildFlags_Borders)) { - std::unordered_set visited; - visited.reserve(objects.size()); - for (GameObject* root : roots) { - DrawGameObjectNode(*root, selectedObjectId, visited, true); - } - - // Keep malformed or partially loaded hierarchies visible instead of - // silently dropping objects whose parent is not in this list. - bool hasUnvisitedObjects = false; - for (GameObject* object : objects) { - if (!visited.contains(object)) { - if (!hasUnvisitedObjects) { - ImGui::Separator(); - ImGui::TextDisabled("OTHER OBJECTS"); - hasUnvisitedObjects = true; - } - DrawGameObjectNode(*object, selectedObjectId, visited); - } - } - - if (objects.empty()) { - ImGui::TextDisabled("This scene has no GameObjects."); - } - } - ImGui::EndChild(); - - GameObject* selectedObject = FindSelectedObject(objects, selectedObjectId); - if (selectedObject == nullptr) { - selectedObjectId = InvalidObjectId; - } - - ImGui::SameLine(); - - if (ImGui::BeginChild( - "ObjectInspector", - ImVec2(0.0f, 0.0f), - ImGuiChildFlags_Borders)) { - if (selectedObject == nullptr) { - ImGui::TextDisabled("Select a GameObject to inspect it."); - } else { - const std::string displayName = selectedObject->GetName().empty() - ? "" - : selectedObject->GetName(); - ImGui::TextUnformatted(displayName.c_str()); - ImGui::TextDisabled( - "GameObject | ID %llu", - static_cast(selectedObject->GetId())); - ImGui::Separator(); - - bool active = selectedObject->IsActive(); - 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) { - ImGui::Text("Parent: %s", parent->GetOwner()->GetName().c_str()); - } else { - ImGui::Text("Parent: "); - } - ImGui::Text("Children: %d", selectedObject->GetTransform().GetChildCount()); - - if (ImGui::CollapsingHeader( - "Transform", - ImGuiTreeNodeFlags_DefaultOpen)) { - Transform& transform = selectedObject->GetTransform(); - bool transformChanged = false; - - glm::vec3 localPosition = transform.GetLocalPosition(); - if (DrawVec3Control("Position", localPosition, 0.05f)) { - transform.SetLocalPosition(localPosition); - transformChanged = true; - } - - glm::vec3 localRotation = glm::degrees( - glm::eulerAngles(transform.GetLocalRotation())); - if (DrawVec3Control("Rotation", localRotation, 0.5f)) { - transform.SetLocalRotation(localRotation); - transformChanged = true; - } - - glm::vec3 localScale = transform.GetLocalScale(); - if (DrawVec3Control("Scale", localScale, 0.05f)) { - transform.SetLocalScale(localScale); - transformChanged = true; - } - - if (transformChanged) { - selectedObject->RefreshPhysicsInHierarchy(); - } - - const glm::vec3& worldPosition = transform.GetWorldPosition(); - ImGui::Text( - "World position: %.2f, %.2f, %.2f", - worldPosition.x, - worldPosition.y, - worldPosition.z); - } - - const bool componentsOpen = ImGui::CollapsingHeader( - "Components", - ImGuiTreeNodeFlags_DefaultOpen); - - if (componentsOpen) { - if (ImGui::Button("+ Add Component")) { - ImGui::OpenPopup("AddComponentPopup"); - } - - std::string addComponentError; - if (ImGui::BeginPopup("AddComponentPopup")) { - std::vector componentTypes = - ComponentFactory::GetRegisteredTypeNames(); - std::sort(componentTypes.begin(), componentTypes.end()); - - bool hasAvailableComponent = false; - for (const std::string& componentType : componentTypes) { - const bool alreadyAdded = std::any_of( - selectedObject->GetComponents().begin(), - selectedObject->GetComponents().end(), - [&componentType](const auto& component) { - return component != nullptr && - !component->IsBeingDestroyed() && - component->GetTypeName() == componentType; - }); - - if (alreadyAdded) { - const std::string label = - componentType + " (already added)"; - ImGui::BeginDisabled(); - ImGui::MenuItem(label.c_str()); - ImGui::EndDisabled(); - continue; - } - - hasAvailableComponent = true; - if (ImGui::MenuItem(componentType.c_str())) { - try { - if (ComponentFactory::Create( - componentType, - *selectedObject) == nullptr) { - addComponentError = - "Component type is not registered: " + - componentType; - } else { - ImGui::CloseCurrentPopup(); - } - } catch (const std::exception& exception) { - addComponentError = exception.what(); - } - } - } - - if (!hasAvailableComponent) { - ImGui::TextDisabled("All components are already added."); - } - if (!addComponentError.empty()) { - ImGui::TextWrapped("Failed to add component: %s", - addComponentError.c_str()); - } - ImGui::EndPopup(); - } - - bool hasComponents = false; - for (const auto& component : selectedObject->GetComponents()) { - if (component == nullptr || component->IsBeingDestroyed()) { - continue; - } - - hasComponents = true; - ImGui::PushID(component.get()); - const std::string typeName = component->GetTypeName(); - if (ImGui::CollapsingHeader(typeName.c_str())) { - bool enabled = component->isEnabled(); - if (ImGui::Checkbox("Enabled", &enabled)) { - component->SetEnabled(enabled); - } - component->ImGuiInspector(); - } - ImGui::PopID(); - } - if (!hasComponents) { - ImGui::TextDisabled("No components."); - } - } - } - } - ImGui::EndChild(); - - ImGui::End(); + return changed; } } diff --git a/destrum/src/Util/RectGizmo.cpp b/destrum/src/Util/RectGizmo.cpp new file mode 100644 index 0000000..8ae6c76 --- /dev/null +++ b/destrum/src/Util/RectGizmo.cpp @@ -0,0 +1,167 @@ +#include + +#include +#include + +#include +#include +#include + +#include + +namespace { + constexpr int MoveHandle = 0; + constexpr float HandleRadius = 5.0f; + constexpr std::array HandleDirections{{ + {-1.0f, -1.0f}, {0.0f, -1.0f}, {1.0f, -1.0f}, {1.0f, 0.0f}, + {1.0f, 1.0f}, {0.0f, 1.0f}, {-1.0f, 1.0f}, {-1.0f, 0.0f}, + }}; + + constexpr ImU32 OutlineColor = IM_COL32(60, 200, 255, 255); + constexpr ImU32 ActiveColor = IM_COL32(255, 200, 60, 255); + constexpr ImU32 HiddenColor = IM_COL32(150, 150, 150, 200); + constexpr ImU32 AnchorColor = IM_COL32(255, 255, 255, 200); + + [[nodiscard]] ImVec2 ToImVec(const glm::vec2& value) { return ImVec2{value.x, value.y}; } + [[nodiscard]] glm::vec2 ToVec(const ImVec2& value) { return glm::vec2{value.x, value.y}; } + + [[nodiscard]] ImGuiMouseCursor CursorFor(int handle) + { + if (handle == MoveHandle) { + return ImGuiMouseCursor_ResizeAll; + } + const glm::vec2 direction = HandleDirections[static_cast(handle - 1)]; + if (direction.x == 0.0f) { + return ImGuiMouseCursor_ResizeNS; + } + if (direction.y == 0.0f) { + return ImGuiMouseCursor_ResizeEW; + } + return direction.x == direction.y ? ImGuiMouseCursor_ResizeNWSE : ImGuiMouseCursor_ResizeNESW; + } + + void DrawAnchor(ImDrawList& drawList, const glm::vec2& point, const glm::vec2& outwards) + { + constexpr float size = 8.0f; + const glm::vec2 tip = point; + const glm::vec2 a = point + glm::vec2{outwards.x * size, outwards.y * size * 0.4f}; + const glm::vec2 b = point + glm::vec2{outwards.x * size * 0.4f, outwards.y * size}; + drawList.AddTriangleFilled(ToImVec(tip), ToImVec(a), ToImVec(b), AnchorColor); + } +} + +bool RectGizmo::CanManipulate(GameObject& object) +{ + return object.GetComponent() != nullptr && object.GetComponent() == nullptr && + Canvas::FindFor(object) != nullptr; +} + +bool RectGizmo::Manipulate(GameObject& object, const glm::vec2& drawImageSize) +{ + auto* rectTransform = object.GetComponent(); + const Canvas* canvas = Canvas::FindFor(object); + if (!CanManipulate(object) || drawImageSize.x <= 0.0f || drawImageSize.y <= 0.0f) { + m_ActiveHandle = -1; + return false; + } + + const ImGuiViewport* viewport = ImGui::GetMainViewport(); + const glm::vec2 viewOrigin = ToVec(viewport->Pos); + const glm::vec2 viewScale = ToVec(viewport->Size) / drawImageSize; + const auto toView = [&](const glm::vec2& point) { return viewOrigin + point * viewScale; }; + + const UIRect& rect = rectTransform->GetScreenRect(); + const glm::vec2 viewMin = toView(rect.min); + const glm::vec2 viewMax = toView(rect.Max()); + const glm::vec2 viewCenter = (viewMin + viewMax) * 0.5f; + const glm::vec2 viewHalfSize = (viewMax - viewMin) * 0.5f; + + const glm::vec2 mouse = ToVec(ImGui::GetIO().MousePos); + int hovered = -1; + const bool overImGui = ImGui::IsWindowHovered(ImGuiHoveredFlags_AnyWindow) || ImGui::IsAnyItemActive(); + if (m_ActiveHandle < 0 && !overImGui) { + for (std::size_t i = 0; i < HandleDirections.size(); ++i) { + const glm::vec2 handle = viewCenter + HandleDirections[i] * viewHalfSize; + if (glm::length(mouse - handle) <= HandleRadius + 2.0f) { + hovered = static_cast(i) + 1; + break; + } + } + if (hovered < 0 && mouse.x >= viewMin.x && mouse.y >= viewMin.y && mouse.x <= viewMax.x && mouse.y <= viewMax.y) { + hovered = MoveHandle; + } + if (hovered >= 0 && ImGui::IsMouseClicked(ImGuiMouseButton_Left)) { + m_ActiveHandle = hovered; + m_DragStartMouse = mouse; + m_DragStartPosition = rectTransform->GetPosition(); + m_DragStartSize = rectTransform->GetSize(); + } + } + + bool changed = false; + if (m_ActiveHandle >= 0) { + if (!ImGui::IsMouseDown(ImGuiMouseButton_Left)) { + m_ActiveHandle = -1; + } else { + const float canvasScale = canvas->GetScale(drawImageSize); + glm::vec2 delta = (mouse - m_DragStartMouse) / (viewScale * canvasScale); + if (snap && snapStep > 0.0f) { + delta = glm::round(delta / snapStep) * snapStep; + } + + glm::vec2 position = m_DragStartPosition; + glm::vec2 size = m_DragStartSize; + if (m_ActiveHandle == MoveHandle) { + position += delta; + } else { + // The edge opposite the handle stays put, so the pivot moves by the pivot's share of the resize + const glm::vec2 direction = HandleDirections[static_cast(m_ActiveHandle - 1)]; + const glm::vec2& pivot = rectTransform->GetPivot(); + for (int axis = 0; axis < 2; ++axis) { + if (direction[axis] == 0.0f) { + continue; + } + size[axis] += direction[axis] * delta[axis]; + position[axis] += delta[axis] * (direction[axis] > 0.0f ? pivot[axis] : 1.0f - pivot[axis]); + } + } + + if (position != rectTransform->GetPosition() || size != rectTransform->GetSize()) { + rectTransform->SetPosition(position); + rectTransform->SetSize(size); + changed = true; + } + } + } + + const int highlighted = m_ActiveHandle >= 0 ? m_ActiveHandle : hovered; + if (highlighted >= 0) { + ImGui::SetMouseCursor(CursorFor(highlighted)); + // Keeps the click from reaching the game, same as the 3D gizmo + ImGui::SetNextFrameWantCaptureMouse(true); + } + + ImDrawList& drawList = *ImGui::GetBackgroundDrawList(); + const ImU32 outlineColor = object.IsActiveInHierarchy() ? OutlineColor : HiddenColor; + + const UIRect& parentRect = rectTransform->GetParentRect(); + const glm::vec2 anchorMin = toView(parentRect.min + parentRect.size * rectTransform->GetAnchorMin()); + const glm::vec2 anchorMax = toView(parentRect.min + parentRect.size * rectTransform->GetAnchorMax()); + DrawAnchor(drawList, {anchorMin.x, anchorMin.y}, {-1.0f, -1.0f}); + DrawAnchor(drawList, {anchorMax.x, anchorMin.y}, {1.0f, -1.0f}); + DrawAnchor(drawList, {anchorMax.x, anchorMax.y}, {1.0f, 1.0f}); + DrawAnchor(drawList, {anchorMin.x, anchorMax.y}, {-1.0f, 1.0f}); + + drawList.AddRect(ToImVec(viewMin), ToImVec(viewMax), highlighted == MoveHandle ? ActiveColor : outlineColor, 0.0f, 0, 1.5f); + + const glm::vec2 pivot = viewMin + (viewMax - viewMin) * rectTransform->GetPivot(); + drawList.AddCircle(ToImVec(pivot), 4.0f, outlineColor, 12, 1.5f); + + for (std::size_t i = 0; i < HandleDirections.size(); ++i) { + const glm::vec2 handle = viewCenter + HandleDirections[i] * viewHalfSize; + const ImU32 color = highlighted == static_cast(i) + 1 ? ActiveColor : outlineColor; + drawList.AddRectFilled(ToImVec(handle - glm::vec2{HandleRadius}), ToImVec(handle + glm::vec2{HandleRadius}), color); + } + + return changed; +} diff --git a/destrum/src/Util/SceneInspector.cpp b/destrum/src/Util/SceneInspector.cpp new file mode 100644 index 0000000..b73466f --- /dev/null +++ b/destrum/src/Util/SceneInspector.cpp @@ -0,0 +1,745 @@ +#include + +#include +#include +#include +#include +#include + +#include +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace { + [[nodiscard]] bool IsInspectable(const GameObject* object) + { + return object != nullptr && !object->IsBeingDestroyed(); + } + + [[nodiscard]] GameObject* ParentOf(const GameObject& object) + { + const Transform* parent = object.GetTransform().GetParent(); + return parent != nullptr ? parent->GetOwner() : nullptr; + } + + [[nodiscard]] bool HasInspectableChildren(const GameObject& object) + { + return std::ranges::any_of(object.GetTransform().GetChildren(), [](const Transform* child) { + return child != nullptr && IsInspectable(child->GetOwner()); + }); + } + + [[nodiscard]] std::vector CollectObjects(const Scene& scene) + { + std::vector objects; + objects.reserve(scene.GetObjects().size() + scene.GetPendingAdditions().size()); + for (const auto* source : {&scene.GetObjects(), &scene.GetPendingAdditions()}) { + for (const auto& object : *source) { + if (IsInspectable(object.get())) { + objects.push_back(object.get()); + } + } + } + return objects; + } + + [[nodiscard]] bool IsRoot(const GameObject& object) + { + return !IsInspectable(ParentOf(object)); + } + + [[nodiscard]] GameObject* NextSibling(const Scene& scene, const GameObject& object) + { + if (const Transform* parent = object.GetTransform().GetParent()) { + const auto& siblings = parent->GetChildren(); + const auto it = std::ranges::find(siblings, &object.GetTransform()); + for (auto next = it == siblings.end() ? it : it + 1; next != siblings.end(); ++next) { + if (IsInspectable((*next)->GetOwner())) { + return (*next)->GetOwner(); + } + } + return nullptr; + } + + const auto objects = CollectObjects(scene); + const auto it = std::ranges::find(objects, &object); + for (auto next = it == objects.end() ? it : it + 1; next != objects.end(); ++next) { + if (IsRoot(**next)) { + return *next; + } + } + return nullptr; + } + + [[nodiscard]] bool ContainsIgnoreCase(std::string_view text, std::string_view query) + { + const auto it = std::search(text.begin(), text.end(), query.begin(), query.end(), [](char a, char b) { + return std::tolower(static_cast(a)) == std::tolower(static_cast(b)); + }); + return it != text.end(); + } + + [[nodiscard]] const char* DisplayName(const GameObject& object) + { + return object.GetName().empty() ? "" : object.GetName().c_str(); + } + + GameObject* CreateChild(Scene& scene, GameObject* parent, const std::string& name) + { + GameObject* object = scene.CreateGameObject(name); + if (parent != nullptr) { + object->GetTransform().SetParent(&parent->GetTransform(), false); + } + return object; + } + + GameObject* CreateUIChild(Scene& scene, GameObject* parent, const std::string& name) + { + if (parent == nullptr || Canvas::FindFor(*parent) == nullptr) { + parent = CreateChild(scene, parent, "Canvas"); + parent->AddComponent(); + } + GameObject* object = CreateChild(scene, parent, name); + object->AddComponent(); + return object; + } + + // Returns the created object so the caller can select and rename it + GameObject* DrawCreateMenu(Scene& scene, GameObject* parent) + { + GameObject* created = nullptr; + if (ImGui::MenuItem("Empty")) { + created = CreateChild(scene, parent, "GameObject"); + } + if (ImGui::BeginMenu("UI")) { + if (ImGui::MenuItem("Canvas")) { + created = CreateChild(scene, parent, "Canvas"); + created->AddComponent(); + } + if (ImGui::MenuItem("Rect")) { + created = CreateUIChild(scene, parent, "Rect"); + } + if (ImGui::MenuItem("Image")) { + created = CreateUIChild(scene, parent, "Image"); + created->AddComponent(); + } + if (ImGui::MenuItem("Text")) { + created = CreateUIChild(scene, parent, "Text"); + created->GetComponent()->SetSize({300.0f, 60.0f}); + created->AddComponent("Text", 32.0f); + } + ImGui::EndMenu(); + } + return created; + } + + // Right aligned controls drawn over a collapsing header, x is from the start of the line + [[nodiscard]] float RightAlignedX(float lineStartX, float lineWidth, float itemsWidth) + { + return lineStartX + lineWidth - itemsWidth; + } +} + +void SceneInspector::Draw(Scene& scene, ObjectId& selectedObjectId, const std::function& drawSceneSettings, bool* open) +{ + RegisterEngineComponents(); + + if (scene.FindGameObject(selectedObjectId) == nullptr) { + selectedObjectId = InvalidObjectId; + } + + ImGui::SetNextWindowSize(ImVec2(720.0f, 560.0f), ImGuiCond_FirstUseEver); + if (!ImGui::Begin("Inspector", open)) { + ImGui::End(); + return; + } + + HandleShortcuts(scene, selectedObjectId); + + if (ImGui::BeginChild("Hierarchy", ImVec2(std::max(220.0f, ImGui::GetContentRegionAvail().x * 0.4f), 0.0f), + ImGuiChildFlags_Borders | ImGuiChildFlags_ResizeX)) { + DrawHierarchy(scene, selectedObjectId); + } + ImGui::EndChild(); + + ImGui::SameLine(); + + if (ImGui::BeginChild("Properties", ImVec2(0.0f, 0.0f), ImGuiChildFlags_Borders)) { + if (GameObject* selected = scene.FindGameObject(selectedObjectId)) { + DrawObject(*selected); + } else if (drawSceneSettings) { + drawSceneSettings(); + } + } + ImGui::EndChild(); + + ImGui::End(); + + ApplyPendingActions(scene, selectedObjectId); +} + +void SceneInspector::HandleShortcuts(Scene& scene, ObjectId& selectedObjectId) +{ + if (!ImGui::IsWindowFocused(ImGuiFocusedFlags_RootAndChildWindows) || ImGui::GetIO().WantTextInput || + selectedObjectId == InvalidObjectId) { + return; + } + + if (ImGui::IsKeyPressed(ImGuiKey_Delete, false)) { + m_PendingDelete = selectedObjectId; + } + if (ImGui::IsKeyChordPressed(ImGuiMod_Ctrl | ImGuiKey_D)) { + m_PendingDuplicate = selectedObjectId; + } + if (ImGui::IsKeyPressed(ImGuiKey_F2, false)) { + if (const GameObject* selected = scene.FindGameObject(selectedObjectId)) { + BeginRename(*selected); + } + } +} + +void SceneInspector::DrawHierarchy(Scene& scene, ObjectId& selectedObjectId) +{ + if (ImGui::Button("+")) { + ImGui::OpenPopup("CreateObject"); + } + if (ImGui::BeginPopup("CreateObject")) { + if (GameObject* created = DrawCreateMenu(scene, nullptr)) { + selectedObjectId = created->GetId(); + BeginRename(*created); + } + ImGui::EndPopup(); + } + ImGui::SameLine(); + ImGui::SetNextItemWidth(-FLT_MIN); + ImGuiUtils::InputString("##Search", m_Filter, 0, "Search"); + + if (!ImGui::BeginChild("Tree")) { + ImGui::EndChild(); + return; + } + + const std::vector objects = CollectObjects(scene); + std::unordered_set visited; + visited.reserve(objects.size()); + + if (m_Filter.empty()) { + for (GameObject* object : objects) { + if (IsRoot(*object)) { + DrawNode(scene, *object, selectedObjectId, visited); + } + } + // Anything not reachable from a root is stuck in a parent cycle, still show it + std::unordered_set reachable; + std::vector pending; + for (const GameObject* object : objects) { + if (IsRoot(*object)) { + pending.push_back(object); + } + } + while (!pending.empty()) { + const GameObject* object = pending.back(); + pending.pop_back(); + if (!reachable.insert(object).second) { + continue; + } + for (const Transform* child : object->GetTransform().GetChildren()) { + if (child != nullptr && IsInspectable(child->GetOwner())) { + pending.push_back(child->GetOwner()); + } + } + } + for (GameObject* object : objects) { + if (!reachable.contains(object)) { + DrawNode(scene, *object, selectedObjectId, visited); + } + } + } else { + for (GameObject* object : objects) { + if (ContainsIgnoreCase(object->GetName(), m_Filter)) { + DrawNode(scene, *object, selectedObjectId, visited); + } + } + } + + // The empty space below the tree: click to deselect, drop to move to the root, right click to create + const ImVec2 remaining = ImGui::GetContentRegionAvail(); + ImGui::InvisibleButton("##Background", ImVec2(std::max(remaining.x, 1.0f), std::max(remaining.y, ImGui::GetFrameHeight()))); + if (ImGui::IsItemClicked(ImGuiMouseButton_Left)) { + selectedObjectId = InvalidObjectId; + } + if (ImGui::BeginDragDropTarget()) { + if (const ImGuiPayload* payload = ImGui::AcceptDragDropPayload(ImGuiUtils::GameObjectPayload)) { + m_PendingMove = {.object = *static_cast(payload->Data)}; + } + ImGui::EndDragDropTarget(); + } + if (ImGui::BeginPopupContextItem("BackgroundMenu")) { + DrawObjectMenu(scene, nullptr, selectedObjectId); + ImGui::EndPopup(); + } + + ImGui::EndChild(); +} + +void SceneInspector::DrawNode(Scene& scene, GameObject& object, ObjectId& selectedObjectId, + std::unordered_set& visited) +{ + if (!IsInspectable(&object) || !visited.insert(&object).second) { + return; + } + + const bool flat = !m_Filter.empty(); + const bool hasChildren = !flat && HasInspectableChildren(object); + const bool renaming = m_RenamingId == object.GetId(); + + ImGuiTreeNodeFlags flags = ImGuiTreeNodeFlags_OpenOnArrow | ImGuiTreeNodeFlags_SpanFullWidth | + ImGuiTreeNodeFlags_DrawLinesToNodes; + if (object.GetId() == selectedObjectId) { + flags |= ImGuiTreeNodeFlags_Selected; + } + if (!hasChildren) { + flags |= ImGuiTreeNodeFlags_Leaf | ImGuiTreeNodeFlags_NoTreePushOnOpen; + } + if (m_OpenNextFrame == object.GetId()) { + ImGui::SetNextItemOpen(true); + m_OpenNextFrame = InvalidObjectId; + } + + const bool inactive = !object.IsActiveInHierarchy(); + if (inactive) { + ImGui::PushStyleColor(ImGuiCol_Text, ImGui::GetStyleColorVec4(ImGuiCol_TextDisabled)); + } + const bool open = ImGui::TreeNodeEx(reinterpret_cast(static_cast(object.GetId())), flags, "%s", + renaming ? "" : DisplayName(object)); + if (inactive) { + ImGui::PopStyleColor(); + } + + if ((ImGui::IsItemClicked(ImGuiMouseButton_Left) || ImGui::IsItemClicked(ImGuiMouseButton_Right)) && + !ImGui::IsItemToggledOpen()) { + selectedObjectId = object.GetId(); + } + if (ImGui::IsItemHovered() && ImGui::IsMouseDoubleClicked(ImGuiMouseButton_Left) && !ImGui::IsItemToggledOpen()) { + BeginRename(object); + } + + DrawNodeDragDrop(object); + + if (ImGui::BeginPopupContextItem("ObjectMenu")) { + DrawObjectMenu(scene, &object, selectedObjectId); + ImGui::EndPopup(); + } + + if (renaming) { + ImGui::SameLine(); + DrawRenameField(object); + } + + if (open && hasChildren) { + for (Transform* child : object.GetTransform().GetChildren()) { + if (child != nullptr && IsInspectable(child->GetOwner())) { + DrawNode(scene, *child->GetOwner(), selectedObjectId, visited); + } + } + ImGui::TreePop(); + } +} + +void SceneInspector::DrawNodeDragDrop(GameObject& object) +{ + const ObjectId id = object.GetId(); + if (ImGui::BeginDragDropSource()) { + ImGui::SetDragDropPayload(ImGuiUtils::GameObjectPayload, &id, sizeof(id)); + ImGui::TextUnformatted(DisplayName(object)); + ImGui::EndDragDropSource(); + } + + if (!ImGui::BeginDragDropTarget()) { + return; + } + + const ImGuiPayload* payload = ImGui::AcceptDragDropPayload( + ImGuiUtils::GameObjectPayload, + ImGuiDragDropFlags_AcceptBeforeDelivery | ImGuiDragDropFlags_AcceptNoDrawDefaultRect); + if (payload != nullptr) { + const ObjectId dragged = *static_cast(payload->Data); + const ImVec2 min = ImGui::GetItemRectMin(); + const ImVec2 max = ImGui::GetItemRectMax(); + const float zone = (max.y - min.y) * 0.25f; + const float mouseY = ImGui::GetMousePos().y; + const bool before = mouseY < min.y + zone; + const bool after = mouseY > max.y - zone; + + ImDrawList* drawList = ImGui::GetWindowDrawList(); + const ImU32 color = ImGui::GetColorU32(ImGuiCol_DragDropTarget); + if (before) { + drawList->AddLine(min, ImVec2(max.x, min.y), color, 2.0f); + } else if (after) { + drawList->AddLine(ImVec2(min.x, max.y), max, color, 2.0f); + } else { + drawList->AddRect(min, max, color, 0.0f, 0, 2.0f); + } + + if (payload->IsDelivery() && dragged != id) { + const GameObject* parent = ParentOf(object); + const ObjectId parentId = IsInspectable(parent) ? parent->GetId() : InvalidObjectId; + if (before || after) { + m_PendingMove = {.object = dragged, .parent = parentId, .anchor = id, .after = after}; + } else { + m_PendingMove = {.object = dragged, .parent = id}; + } + } + } + ImGui::EndDragDropTarget(); +} + +void SceneInspector::DrawRenameField(GameObject& object) +{ + if (m_FocusRename) { + ImGui::SetKeyboardFocusHere(); + m_FocusRename = false; + } + ImGui::SetNextItemWidth(-FLT_MIN); + const bool submitted = ImGuiUtils::InputString("##Rename", m_RenameBuffer, + ImGuiInputTextFlags_EnterReturnsTrue | ImGuiInputTextFlags_AutoSelectAll); + if (ImGui::IsKeyPressed(ImGuiKey_Escape)) { + m_RenamingId = InvalidObjectId; + return; + } + if (submitted || ImGui::IsItemDeactivated()) { + if (!m_RenameBuffer.empty()) { + object.SetName(m_RenameBuffer); + } + m_RenamingId = InvalidObjectId; + } +} + +void SceneInspector::BeginRename(const GameObject& object) +{ + m_RenamingId = object.GetId(); + m_RenameBuffer = object.GetName(); + m_FocusRename = true; +} + +void SceneInspector::DrawObjectMenu(Scene& scene, GameObject* object, ObjectId& selectedObjectId) +{ + if (ImGui::BeginMenu(object != nullptr ? "Create Child" : "Create")) { + if (GameObject* created = DrawCreateMenu(scene, object)) { + selectedObjectId = created->GetId(); + if (object != nullptr) { + m_OpenNextFrame = object->GetId(); + } + BeginRename(*created); + } + ImGui::EndMenu(); + } + + if (object == nullptr) { + return; + } + + ImGui::Separator(); + if (ImGui::MenuItem("Rename", "F2")) { + BeginRename(*object); + } + if (ImGui::MenuItem("Duplicate", "Ctrl+D")) { + m_PendingDuplicate = object->GetId(); + } + if (ImGui::MenuItem("Unparent", nullptr, false, !IsRoot(*object))) { + m_PendingMove = {.object = object->GetId()}; + } + bool active = object->IsActive(); + if (ImGui::MenuItem("Active", nullptr, &active)) { + object->SetActive(active); + } + ImGui::Separator(); + if (ImGui::MenuItem("Delete", "Del")) { + m_PendingDelete = object->GetId(); + } +} + +void SceneInspector::ApplyPendingActions(Scene& scene, ObjectId& selectedObjectId) +{ + if (m_PendingMove.object != InvalidObjectId) { + GameObject* object = scene.FindGameObject(m_PendingMove.object); + GameObject* parent = scene.FindGameObject(m_PendingMove.parent); + GameObject* insertBefore = scene.FindGameObject(m_PendingMove.anchor); + if (insertBefore != nullptr && m_PendingMove.after) { + insertBefore = NextSibling(scene, *insertBefore); + } + if (insertBefore == object && object != nullptr) { + insertBefore = NextSibling(scene, *object); + } + if (object != nullptr && scene.MoveInHierarchy(*object, parent, insertBefore) && parent != nullptr) { + m_OpenNextFrame = parent->GetId(); + } + m_PendingMove = {}; + } + + if (m_PendingDuplicate != InvalidObjectId) { + if (GameObject* object = scene.FindGameObject(m_PendingDuplicate)) { + if (const GameObject* duplicate = PrefabSerializer::Duplicate(*object)) { + selectedObjectId = duplicate->GetId(); + } + } + m_PendingDuplicate = InvalidObjectId; + } + + if (m_PendingDelete != InvalidObjectId) { + if (GameObject* object = scene.FindGameObject(m_PendingDelete)) { + object->Destroy(); + } + if (selectedObjectId == m_PendingDelete) { + selectedObjectId = InvalidObjectId; + } + m_PendingDelete = InvalidObjectId; + } +} + +void SceneInspector::DrawObject(GameObject& object) +{ + bool active = object.IsActive(); + if (ImGui::Checkbox("##Active", &active)) { + object.SetActive(active); + } + ImGui::SetItemTooltip("Active"); + ImGui::SameLine(); + ImGui::SetNextItemWidth(-FLT_MIN); + std::string name = object.GetName(); + if (ImGuiUtils::InputString("##Name", name)) { + object.SetName(name); + } + + ImGui::Spacing(); + DrawTransform(object); + DrawComponents(object); + DrawAddComponent(object); +} + +void SceneInspector::DrawTransform(GameObject& object) +{ + auto* rectTransform = object.GetComponent(); + // Everything under a canvas has a RectTransform, a stretched one keeps the old layout + if (rectTransform == nullptr && object.GetComponent() == nullptr && Canvas::FindFor(object) != nullptr) { + rectTransform = object.AddComponent(); + rectTransform->Stretch(); + } + + if (rectTransform != nullptr) { + const bool open = ImGui::CollapsingHeader("Rect Transform", ImGuiTreeNodeFlags_DefaultOpen); + if (ImGui::BeginPopupContextItem("RectTransformMenu")) { + if (ImGui::MenuItem("Stretch")) { + rectTransform->Stretch(); + } + ImGui::EndPopup(); + } + if (open) { + ImGui::PushID(rectTransform); + rectTransform->ImGuiInspector(); + ImGui::PopID(); + } + return; + } + + Transform& transform = object.GetTransform(); + const bool open = ImGui::CollapsingHeader("Transform", ImGuiTreeNodeFlags_DefaultOpen); + bool changed = false; + if (ImGui::BeginPopupContextItem("TransformMenu")) { + if (ImGui::MenuItem("Reset Position")) { + transform.SetLocalPosition(glm::vec3{0.0f}); + changed = true; + } + if (ImGui::MenuItem("Reset Rotation")) { + transform.SetLocalRotation(glm::quat{1.0f, 0.0f, 0.0f, 0.0f}); + changed = true; + } + if (ImGui::MenuItem("Reset Scale")) { + transform.SetLocalScale(glm::vec3{1.0f}); + changed = true; + } + ImGui::EndPopup(); + } + + if (open) { + glm::vec3 position = transform.GetLocalPosition(); + if (ImGuiUtils::DrawVec3Control("Position", position, 0.05f)) { + transform.SetLocalPosition(position); + changed = true; + } + glm::vec3 rotation = glm::degrees(glm::eulerAngles(transform.GetLocalRotation())); + if (ImGuiUtils::DrawVec3Control("Rotation", rotation, 0.5f)) { + transform.SetLocalRotation(rotation); + changed = true; + } + glm::vec3 scale = transform.GetLocalScale(); + if (ImGuiUtils::DrawVec3Control("Scale", scale, 0.05f)) { + transform.SetLocalScale(scale); + changed = true; + } + } + + if (changed) { + object.RefreshPhysicsInHierarchy(); + } +} + +void SceneInspector::DrawComponents(GameObject& object) +{ + auto& components = object.GetComponents(); + const auto* rectTransform = object.GetComponent(); + const bool underCanvas = Canvas::FindFor(object) != nullptr; + + for (std::size_t i = 0; i < components.size(); ++i) { + Component* component = components[i].get(); + if (component == nullptr || component->IsBeingDestroyed() || component == rectTransform) { + continue; + } + + ImGui::PushID(component); + + const float lineStart = ImGui::GetCursorPosX(); + const float lineWidth = ImGui::GetContentRegionAvail().x; + const bool enabled = component->isEnabled(); + if (!enabled) { + ImGui::PushStyleColor(ImGuiCol_Text, ImGui::GetStyleColorVec4(ImGuiCol_TextDisabled)); + } + const std::string typeName = component->GetTypeName(); + const bool open = ImGui::CollapsingHeader(typeName.c_str(), + ImGuiTreeNodeFlags_DefaultOpen | ImGuiTreeNodeFlags_AllowOverlap); + if (!enabled) { + ImGui::PopStyleColor(); + } + + const bool removable = !(underCanvas && dynamic_cast(component) != nullptr); + if (ImGui::BeginPopupContextItem("ComponentMenu")) { + DrawComponentMenu(object, *component, i, removable); + ImGui::EndPopup(); + } + + const ImGuiStyle& style = ImGui::GetStyle(); + const float buttonWidth = ImGui::CalcTextSize("...").x + style.FramePadding.x * 2.0f; + const float controlsWidth = ImGui::GetFrameHeight() + style.ItemSpacing.x + buttonWidth + style.FramePadding.x; + ImGui::SameLine(RightAlignedX(lineStart, lineWidth, controlsWidth)); + bool enabledToggle = enabled; + if (ImGui::Checkbox("##Enabled", &enabledToggle)) { + component->SetEnabled(enabledToggle); + } + ImGui::SetItemTooltip("Enabled"); + ImGui::SameLine(); + if (ImGui::Button("...")) { + ImGui::OpenPopup("ComponentMenu"); + } + + if (open) { + component->ImGuiInspector(); + ImGui::Spacing(); + } + + ImGui::PopID(); + + // A move or remove from the menu changed the list, the rest is drawn next frame + if (components.size() <= i || components[i].get() != component) { + break; + } + } +} + +void SceneInspector::DrawComponentMenu(GameObject& object, Component& component, std::size_t index, bool removable) +{ + auto& components = object.GetComponents(); + if (ImGui::MenuItem("Move Up", nullptr, false, index > 0)) { + std::swap(components[index], components[index - 1]); + } + if (ImGui::MenuItem("Move Down", nullptr, false, index + 1 < components.size())) { + std::swap(components[index], components[index + 1]); + } + + ImGui::Separator(); + const std::string typeName = component.GetTypeName(); + if (ImGui::MenuItem("Copy Values")) { + m_ClipboardType = typeName; + m_Clipboard = component.Serialize(); + } + if (ImGui::MenuItem("Paste Values", nullptr, false, m_ClipboardType == typeName)) { + try { + component.Deserialize(m_Clipboard); + if (component.AffectsPhysics()) { + object.RefreshPhysics(); + } + } catch (const std::exception& exception) { + spdlog::warn("Pasting {} values failed: {}", typeName, exception.what()); + } + } + + ImGui::Separator(); + if (ImGui::MenuItem("Remove", nullptr, false, removable)) { + const bool affectsPhysics = component.AffectsPhysics(); + component.Destroy(); + if (affectsPhysics) { + object.RefreshPhysics(); + } + } +} + +void SceneInspector::DrawAddComponent(GameObject& object) +{ + ImGui::Spacing(); + if (ImGui::Button("Add Component", ImVec2(-FLT_MIN, 0.0f))) { + m_ComponentFilter.clear(); + ImGui::OpenPopup("AddComponent"); + } + + ImGui::SetNextWindowSizeConstraints(ImVec2(ImGui::GetItemRectSize().x, 0.0f), ImVec2(FLT_MAX, 320.0f)); + if (!ImGui::BeginPopup("AddComponent")) { + return; + } + + if (ImGui::IsWindowAppearing()) { + ImGui::SetKeyboardFocusHere(); + } + ImGui::SetNextItemWidth(-FLT_MIN); + const bool submitted = ImGuiUtils::InputString("##ComponentSearch", m_ComponentFilter, + ImGuiInputTextFlags_EnterReturnsTrue, "Search"); + + std::vector types = ComponentFactory::GetRegisteredTypeNames(); + std::ranges::sort(types); + std::erase_if(types, [&](const std::string& type) { + const bool added = std::ranges::any_of(object.GetComponents(), [&](const auto& component) { + return component != nullptr && !component->IsBeingDestroyed() && component->GetTypeName() == type; + }); + return added || !ContainsIgnoreCase(type, m_ComponentFilter); + }); + + std::string chosen = submitted && !types.empty() ? types.front() : std::string{}; + for (const std::string& type : types) { + if (ImGui::Selectable(type.c_str())) { + chosen = type; + } + } + + if (!chosen.empty()) { + try { + if (ComponentFactory::Create(chosen, object) == nullptr) { + spdlog::warn("Component type is not registered: {}", chosen); + } + } catch (const std::exception& exception) { + spdlog::warn("Adding {} failed: {}", chosen, exception.what()); + } + ImGui::CloseCurrentPopup(); + } + + ImGui::EndPopup(); +} diff --git a/lightkeeper/CMakeLists.txt b/lightkeeper/CMakeLists.txt index a2d0e9d..0558c85 100644 --- a/lightkeeper/CMakeLists.txt +++ b/lightkeeper/CMakeLists.txt @@ -13,14 +13,19 @@ set(GAME_SRC src/components/GameComponentRegistry.cpp src/components/Activatable.cpp + src/components/Checkpoint.cpp + src/components/CrosshairUI.cpp src/components/Door.cpp src/components/FirstPersonController.cpp src/components/Interactable.cpp src/components/InteractableButton.cpp src/components/LightToggle.cpp src/components/MovingPlatform.cpp + src/components/PlayerRespawn.cpp src/components/PrintComponent.cpp src/components/TriggerLoggerComponent.cpp + src/components/WinGoal.cpp + src/components/WinScreenUI.cpp ) add_executable(lightkeeper ${GAME_SRC}) diff --git a/lightkeeper/include/Lightkeeper.h b/lightkeeper/include/Lightkeeper.h index 066aa76..f48c2a1 100644 --- a/lightkeeper/include/Lightkeeper.h +++ b/lightkeeper/include/Lightkeeper.h @@ -6,10 +6,10 @@ #include #include #include +#include #include #include "destrum/Graphics/Resources/Cubemap.h" -#include "destrum/Graphics/Resources/Font.h" #include "destrum/ObjectModel/GameObject.h" #include @@ -31,7 +31,6 @@ public: private: void setupSkybox(); - void drawCrosshair(); void updateViewMode(); [[nodiscard]] CameraComponent* getSceneCamera() const; [[nodiscard]] Camera& getViewCamera(); @@ -59,8 +58,8 @@ private: MainSceneObjects mainScene{}; ImageID debug2DTexture{NULL_IMAGE_ID}; - Font hudFont; TransformGizmo transformGizmo; + RectGizmo rectGizmo; char scenePath[512]{"scenes/debug_scene.json"}; char newSceneName[128]{"EmptyScene"}; diff --git a/lightkeeper/include/components/Checkpoint.h b/lightkeeper/include/components/Checkpoint.h new file mode 100644 index 0000000..4e84927 --- /dev/null +++ b/lightkeeper/include/components/Checkpoint.h @@ -0,0 +1,27 @@ +#ifndef LIGHTKEEPER_CHECKPOINT_H +#define LIGHTKEEPER_CHECKPOINT_H + +#include + +#include + +class Checkpoint final : public Component { +public: + explicit Checkpoint(GameObject& parent); + + std::string GetTypeName() const override { return "Checkpoint"; } + + void OnTriggerEnter(GameObject* other) override; + + nlohmann::json Serialize() const override; + void Deserialize(const nlohmann::json& data) override; + void ImGuiInspector() override; + + void SetSpawnOffset(const glm::vec3& offset) { m_SpawnOffset = offset; } + [[nodiscard]] glm::vec3 GetSpawnPoint() const; + +private: + glm::vec3 m_SpawnOffset{0.0f}; +}; + +#endif // LIGHTKEEPER_CHECKPOINT_H diff --git a/lightkeeper/include/components/CrosshairUI.h b/lightkeeper/include/components/CrosshairUI.h new file mode 100644 index 0000000..dac7b22 --- /dev/null +++ b/lightkeeper/include/components/CrosshairUI.h @@ -0,0 +1,43 @@ +#ifndef LIGHTKEEPER_CROSSHAIRUI_H +#define LIGHTKEEPER_CROSSHAIRUI_H + +#include + +#include +#include + +// Shows the crosshair while the player is possessed, tints and grows it and shows the focus +// indicator while the player looks at an Interactable +class CrosshairUI final : public Component { +public: + explicit CrosshairUI(GameObject& parent); + + std::string GetTypeName() const override { return "CrosshairUI"; } + + void LateUpdate(float dt) override; + + nlohmann::json Serialize() const override; + void Deserialize(const nlohmann::json& data) override; + void ResolveReferences(const ObjectMap& objects) override; + void ImGuiInspector() override; + + void SetPlayer(const GameObject& player) { m_Player = ObjectRef(player); } + void SetCrosshair(const GameObject& crosshair) { m_Crosshair = ObjectRef(crosshair); } + void SetFocusIndicator(const GameObject& indicator) { m_FocusIndicator = ObjectRef(indicator); } + + [[nodiscard]] const ObjectRef& GetPlayer() const { return m_Player; } + [[nodiscard]] const ObjectRef& GetCrosshair() const { return m_Crosshair; } + [[nodiscard]] const ObjectRef& GetFocusIndicator() const { return m_FocusIndicator; } + +private: + ObjectRef m_Player; + ObjectRef m_Crosshair; + ObjectRef m_FocusIndicator; + + glm::vec4 m_Color{1.0f, 1.0f, 1.0f, 0.8f}; + glm::vec4 m_FocusedColor{1.0f, 0.8f, 0.25f, 1.0f}; + float m_Size{10.0f}; + float m_FocusedSize{16.0f}; +}; + +#endif // LIGHTKEEPER_CROSSHAIRUI_H diff --git a/lightkeeper/include/components/FirstPersonController.h b/lightkeeper/include/components/FirstPersonController.h index 4656ee4..4e55bd4 100644 --- a/lightkeeper/include/components/FirstPersonController.h +++ b/lightkeeper/include/components/FirstPersonController.h @@ -39,6 +39,7 @@ public: [[nodiscard]] float GetMouseSensitivity() const { return m_MouseSensitivity; } [[nodiscard]] bool IsGrounded() const { return m_Grounded; } + void Teleport(const glm::vec3& worldPosition); [[nodiscard]] Interactable* GetFocusedInteractable() const { return m_FocusedInteractable; } [[nodiscard]] glm::vec3 GetLookDirection() const; diff --git a/lightkeeper/include/components/GameComponentList.h b/lightkeeper/include/components/GameComponentList.h index 8519d2c..a9b75b5 100644 --- a/lightkeeper/include/components/GameComponentList.h +++ b/lightkeeper/include/components/GameComponentList.h @@ -1,21 +1,31 @@ #ifndef LIGHTKEEPER_GAMECOMPONENTLIST_H #define LIGHTKEEPER_GAMECOMPONENTLIST_H +#include +#include #include #include #include #include #include +#include #include #include +#include +#include #define LIGHTKEEPER_GAME_COMPONENTS(X) \ + X(Checkpoint) \ + X(CrosshairUI) \ X(Door) \ X(FirstPersonController) \ X(InteractableButton) \ X(LightToggle) \ X(MovingPlatform) \ + X(PlayerRespawn) \ X(PrintComponent) \ - X(TriggerLoggerComponent) + X(TriggerLoggerComponent) \ + X(WinGoal) \ + X(WinScreenUI) #endif // LIGHTKEEPER_GAMECOMPONENTLIST_H diff --git a/lightkeeper/include/components/PlayerRespawn.h b/lightkeeper/include/components/PlayerRespawn.h new file mode 100644 index 0000000..07fff10 --- /dev/null +++ b/lightkeeper/include/components/PlayerRespawn.h @@ -0,0 +1,35 @@ +#ifndef LIGHTKEEPER_PLAYERRESPAWN_H +#define LIGHTKEEPER_PLAYERRESPAWN_H + +#include + +#include + +class PlayerRespawn final : public Component { +public: + explicit PlayerRespawn(GameObject& parent); + + std::string GetTypeName() const override { return "PlayerRespawn"; } + + void Start() override; + void FixedUpdate(float fixedDt) override; + + nlohmann::json Serialize() const override; + void Deserialize(const nlohmann::json& data) override; + void ImGuiInspector() override; + + void SetRespawnPoint(const glm::vec3& worldPosition); + [[nodiscard]] const glm::vec3& GetRespawnPoint() const { return m_RespawnPoint; } + + void SetKillHeight(float height) { m_KillHeight = height; } + [[nodiscard]] float GetKillHeight() const { return m_KillHeight; } + + void Respawn(); + +private: + glm::vec3 m_RespawnPoint{0.0f}; + bool m_HasRespawnPoint{false}; + float m_KillHeight{-15.0f}; +}; + +#endif // LIGHTKEEPER_PLAYERRESPAWN_H diff --git a/lightkeeper/include/components/WinGoal.h b/lightkeeper/include/components/WinGoal.h new file mode 100644 index 0000000..445e72f --- /dev/null +++ b/lightkeeper/include/components/WinGoal.h @@ -0,0 +1,26 @@ +#ifndef LIGHTKEEPER_WINGOAL_H +#define LIGHTKEEPER_WINGOAL_H + +#include + +class WinGoal final : public Activatable { +public: + explicit WinGoal(GameObject& parent); + + std::string GetTypeName() const override { return "WinGoal"; } + + nlohmann::json Serialize() const override; + void Deserialize(const nlohmann::json& data) override; + void ImGuiInspector() override; + + [[nodiscard]] bool IsReached() const { return m_Reached; } + void Reset() { m_Reached = false; } + +protected: + void OnActivate(GameObject& instigator) override; + +private: + bool m_Reached{false}; +}; + +#endif // LIGHTKEEPER_WINGOAL_H diff --git a/lightkeeper/include/components/WinScreenUI.h b/lightkeeper/include/components/WinScreenUI.h new file mode 100644 index 0000000..293cd4a --- /dev/null +++ b/lightkeeper/include/components/WinScreenUI.h @@ -0,0 +1,32 @@ +#ifndef LIGHTKEEPER_WINSCREENUI_H +#define LIGHTKEEPER_WINSCREENUI_H + +#include +#include + +// Keeps the screen object active while the goal's WinGoal is reached +class WinScreenUI final : public Component { +public: + explicit WinScreenUI(GameObject& parent); + + std::string GetTypeName() const override { return "WinScreenUI"; } + + void LateUpdate(float dt) override; + + nlohmann::json Serialize() const override; + void Deserialize(const nlohmann::json& data) override; + void ResolveReferences(const ObjectMap& objects) override; + void ImGuiInspector() override; + + void SetGoal(const GameObject& goal) { m_Goal = ObjectRef(goal); } + void SetScreen(const GameObject& screen) { m_Screen = ObjectRef(screen); } + + [[nodiscard]] const ObjectRef& GetGoal() const { return m_Goal; } + [[nodiscard]] const ObjectRef& GetScreen() const { return m_Screen; } + +private: + ObjectRef m_Goal; + ObjectRef m_Screen; +}; + +#endif // LIGHTKEEPER_WINSCREENUI_H diff --git a/lightkeeper/include/scenes/MainScene.h b/lightkeeper/include/scenes/MainScene.h index 3eb7500..0c76b2b 100644 --- a/lightkeeper/include/scenes/MainScene.h +++ b/lightkeeper/include/scenes/MainScene.h @@ -19,7 +19,7 @@ struct MainSceneObjects { MaterialID sphereMaterial{}; }; -// Sun, ground, parkour course, models, trigger zone and the player +// Sun, ground, parkour course, models, trigger zone, the player and the HUD MainSceneObjects BuildMainScene(Scene& scene, GfxDevice& gfxDevice, RenderResources& resources); #endif // LIGHTKEEPER_MAINSCENE_H diff --git a/lightkeeper/src/Lightkeeper.cpp b/lightkeeper/src/Lightkeeper.cpp index db9ddcb..e135f68 100644 --- a/lightkeeper/src/Lightkeeper.cpp +++ b/lightkeeper/src/Lightkeeper.cpp @@ -2,11 +2,9 @@ #include #include -#include #include #include -#include #include LightKeeper::LightKeeper() : App() @@ -43,9 +41,6 @@ void LightKeeper::customInit() debug2DTexture = resources.loadImageFromFile( gfxDevice, AssetFS::GetInstance().GetFullPath("engine://textures/kobe.png")); - hudFont = Font::LoadFromFile(AssetFS::GetInstance().GetFullPath("engine://fonts/Roboto-Medium.ttf")); - hudFont.Upload(gfxDevice, resources); - } void LightKeeper::setupSkybox() @@ -103,39 +98,6 @@ void LightKeeper::updateViewMode() } } -void LightKeeper::drawCrosshair() -{ - if (!isPlayerViewActive()) { - return; - } - - const bool focused = mainScene.playerController->GetFocusedInteractable() != nullptr; - const glm::vec4 color = focused ? glm::vec4{1.0f, 0.8f, 0.25f, 1.0f} : glm::vec4{1.0f, 1.0f, 1.0f, 0.8f}; - const float armLength = focused ? 16.0f : 10.0f; - constexpr float thickness = 2.0f; - - const VkExtent3D extent = renderer.getDrawImage().extent; - const glm::vec2 center{static_cast(extent.width) * 0.5f, static_cast(extent.height) * 0.5f}; - - auto& renderer2D = Renderer2D::GetInstance(); - const float halfArm = armLength * 0.5f; - renderer2D.DrawLine(center - glm::vec2{halfArm, 0.0f}, center + glm::vec2{halfArm, 0.0f}, thickness, color); - renderer2D.DrawLine(center - glm::vec2{0.0f, halfArm}, center + glm::vec2{0.0f, halfArm}, thickness, color); - if (focused) { - renderer2D.DrawEllipseOutline(center, glm::vec2{14.0f}, thickness, color); - - constexpr std::string_view prompt = "[E] Interact"; - constexpr float promptSize = 22.0f; - const glm::vec2 promptExtent = hudFont.MeasureText(prompt, promptSize); - renderer2D.DrawText( - hudFont, - prompt, - center + glm::vec2{-promptExtent.x * 0.5f, 28.0f}, - promptSize, - TextStyle{.color = color, .outline = 2.0f, .outlineColor = {0.0f, 0.0f, 0.0f, 0.75f}}); - } -} - bool LightKeeper::isPlayerViewActive() const { return mainScene.playerController != nullptr && mainScene.playerController->IsPossessed(); @@ -165,7 +127,6 @@ void LightKeeper::customDraw() renderer.beginDrawing(gfxDevice); submitDebugDraws(); - drawCrosshair(); if (isPlayerViewActive()) { diff --git a/lightkeeper/src/LightkeeperDebugUi.cpp b/lightkeeper/src/LightkeeperDebugUi.cpp index ef99243..4a9ec17 100644 --- a/lightkeeper/src/LightkeeperDebugUi.cpp +++ b/lightkeeper/src/LightkeeperDebugUi.cpp @@ -190,6 +190,12 @@ void LightKeeper::drawSelectionGizmo() return; } + if (RectGizmo::CanManipulate(*selected)) { + const VkExtent3D extent = renderer.getDrawImage().extent; + rectGizmo.Manipulate(*selected, glm::vec2{static_cast(extent.width), static_cast(extent.height)}); + return; + } + transformGizmo.HandleShortcuts(); transformGizmo.DrawToolbar(); transformGizmo.Manipulate(*selected, getViewCamera()); diff --git a/lightkeeper/src/components/Checkpoint.cpp b/lightkeeper/src/components/Checkpoint.cpp new file mode 100644 index 0000000..5270030 --- /dev/null +++ b/lightkeeper/src/components/Checkpoint.cpp @@ -0,0 +1,51 @@ +#include + +#include +#include + +#include + +#include +#include +#include + +Checkpoint::Checkpoint(GameObject& parent) + : Component(parent, "Checkpoint") +{ +} + +glm::vec3 Checkpoint::GetSpawnPoint() const +{ + return GetTransform().GetWorldPosition() + m_SpawnOffset; +} + +void Checkpoint::OnTriggerEnter(GameObject* other) +{ + if (other == nullptr) { + return; + } + + auto* respawn = other->GetComponent(); + const glm::vec3 spawnPoint = GetSpawnPoint(); + if (respawn == nullptr || respawn->GetRespawnPoint() == spawnPoint) { + return; + } + + respawn->SetRespawnPoint(spawnPoint); + spdlog::info("{} reached checkpoint {}", other->GetName(), GetGameObject()->GetName()); +} + +nlohmann::json Checkpoint::Serialize() const +{ + return {{"spawnOffset", m_SpawnOffset}}; +} + +void Checkpoint::Deserialize(const nlohmann::json& data) +{ + m_SpawnOffset = data.value("spawnOffset", m_SpawnOffset); +} + +void Checkpoint::ImGuiInspector() +{ + ImGui::DragFloat3("Spawn offset", glm::value_ptr(m_SpawnOffset), 0.05f); +} diff --git a/lightkeeper/src/components/CrosshairUI.cpp b/lightkeeper/src/components/CrosshairUI.cpp new file mode 100644 index 0000000..d4553db --- /dev/null +++ b/lightkeeper/src/components/CrosshairUI.cpp @@ -0,0 +1,107 @@ +#include + +#include + +#include +#include +#include +#include +#include +#include + +#include + +namespace { + void TintHierarchy(GameObject& object, const glm::vec4& color) + { + for (const auto& component : object.GetComponents()) { + if (auto* graphic = dynamic_cast(component.get())) { + graphic->SetColor(color); + } + } + for (Transform* child : object.GetTransform().GetChildren()) { + if (child != nullptr && child->GetOwner() != nullptr) { + TintHierarchy(*child->GetOwner(), color); + } + } + } +} + +CrosshairUI::CrosshairUI(GameObject& parent) + : Component(parent, "CrosshairUI") +{ +} + +void CrosshairUI::LateUpdate(float) +{ + const Scene* scene = GetGameObject()->GetScene(); + if (scene == nullptr) { + return; + } + + GameObject* player = m_Player.Get(*scene); + const auto* controller = player != nullptr ? player->GetComponent() : nullptr; + const bool possessed = controller != nullptr && controller->IsPossessed(); + const bool focused = possessed && controller->GetFocusedInteractable() != nullptr; + + if (GameObject* indicator = m_FocusIndicator.Get(*scene)) { + indicator->SetActive(focused); + } + + GameObject* crosshair = m_Crosshair.Get(*scene); + if (crosshair == nullptr) { + return; + } + crosshair->SetActive(possessed); + if (auto* rectTransform = crosshair->GetComponent()) { + rectTransform->SetSize(glm::vec2{focused ? m_FocusedSize : m_Size}); + } + TintHierarchy(*crosshair, focused ? m_FocusedColor : m_Color); +} + +nlohmann::json CrosshairUI::Serialize() const +{ + return { + {"player", m_Player}, + {"crosshair", m_Crosshair}, + {"focusIndicator", m_FocusIndicator}, + {"color", m_Color}, + {"focusedColor", m_FocusedColor}, + {"size", m_Size}, + {"focusedSize", m_FocusedSize}, + }; +} + +void CrosshairUI::Deserialize(const nlohmann::json& data) +{ + m_Player = data.value("player", ObjectRef{}); + m_Crosshair = data.value("crosshair", ObjectRef{}); + m_FocusIndicator = data.value("focusIndicator", ObjectRef{}); + m_Color = data.value("color", m_Color); + m_FocusedColor = data.value("focusedColor", m_FocusedColor); + m_Size = data.value("size", m_Size); + m_FocusedSize = data.value("focusedSize", m_FocusedSize); +} + +void CrosshairUI::ResolveReferences(const ObjectMap& objects) +{ + m_Player.Resolve(objects); + m_Crosshair.Resolve(objects); + m_FocusIndicator.Resolve(objects); +} + +void CrosshairUI::ImGuiInspector() +{ + if (const Scene* scene = GetGameObject()->GetScene()) { + ImGuiUtils::DrawObjectRefPicker("Player", m_Player, *scene, [](const GameObject& object) { + return object.GetComponent() != nullptr; + }); + ImGuiUtils::DrawObjectRefPicker("Crosshair", m_Crosshair, *scene); + ImGuiUtils::DrawObjectRefPicker("Focus Indicator", m_FocusIndicator, *scene); + } + + ImGui::ColorEdit4("Color", &m_Color.x); + ImGui::ColorEdit4("Focused Color", &m_FocusedColor.x); + ImGui::DragFloat("Size", &m_Size, 0.5f, 0.0f, 500.0f); + ImGui::DragFloat("Focused Size", &m_FocusedSize, 0.5f, 0.0f, 500.0f); +} diff --git a/lightkeeper/src/components/FirstPersonController.cpp b/lightkeeper/src/components/FirstPersonController.cpp index 36af014..8a523fc 100644 --- a/lightkeeper/src/components/FirstPersonController.cpp +++ b/lightkeeper/src/components/FirstPersonController.cpp @@ -132,6 +132,17 @@ Interactable* FirstPersonController::FindFocusedInteractable() const return Interactable::FindOnObjectOrParents(hit.object); } +void FirstPersonController::Teleport(const glm::vec3& worldPosition) +{ + if (m_Rigidbody != nullptr) { + m_Rigidbody->Teleport(worldPosition); + } else { + GetTransform().SetWorldPosition(worldPosition); + } + m_PreviousPosition = worldPosition; + m_HasPreviousPosition = true; +} + void FirstPersonController::FixedUpdate(float fixedDt) { if (m_Rigidbody == nullptr || !m_Rigidbody->HasPhysicsBody()) diff --git a/lightkeeper/src/components/PlayerRespawn.cpp b/lightkeeper/src/components/PlayerRespawn.cpp new file mode 100644 index 0000000..d834a00 --- /dev/null +++ b/lightkeeper/src/components/PlayerRespawn.cpp @@ -0,0 +1,73 @@ +#include + +#include +#include +#include + +#include + +#include +#include + +PlayerRespawn::PlayerRespawn(GameObject& parent) + : Component(parent, "PlayerRespawn") +{ +} + +void PlayerRespawn::Start() +{ + if (!m_HasRespawnPoint) { + SetRespawnPoint(GetTransform().GetWorldPosition()); + } +} + +void PlayerRespawn::FixedUpdate(float) +{ + if (GetTransform().GetWorldPosition().y < m_KillHeight) { + Respawn(); + } +} + +void PlayerRespawn::SetRespawnPoint(const glm::vec3& worldPosition) +{ + m_RespawnPoint = worldPosition; + m_HasRespawnPoint = true; +} + +void PlayerRespawn::Respawn() +{ + GameObject* object = GetGameObject(); + if (auto* controller = object->GetComponent()) { + controller->Teleport(m_RespawnPoint); + } else if (auto* rigidbody = object->GetComponent()) { + rigidbody->Teleport(m_RespawnPoint); + } else { + GetTransform().SetWorldPosition(m_RespawnPoint); + } +} + +nlohmann::json PlayerRespawn::Serialize() const +{ + nlohmann::json data{{"killHeight", m_KillHeight}}; + if (m_HasRespawnPoint) { + data["respawnPoint"] = m_RespawnPoint; + } + return data; +} + +void PlayerRespawn::Deserialize(const nlohmann::json& data) +{ + m_KillHeight = data.value("killHeight", m_KillHeight); + if (data.contains("respawnPoint")) { + SetRespawnPoint(data.at("respawnPoint").get()); + } +} + +void PlayerRespawn::ImGuiInspector() +{ + ImGui::DragFloat("Kill height", &m_KillHeight, 0.1f); + ImGui::DragFloat3("Respawn point", glm::value_ptr(m_RespawnPoint), 0.1f); + if (ImGui::Button("Respawn now")) { + Respawn(); + } +} diff --git a/lightkeeper/src/components/WinGoal.cpp b/lightkeeper/src/components/WinGoal.cpp new file mode 100644 index 0000000..ebb7374 --- /dev/null +++ b/lightkeeper/src/components/WinGoal.cpp @@ -0,0 +1,34 @@ +#include + +#include + +#include +#include + +WinGoal::WinGoal(GameObject& parent) + : Activatable(parent, "WinGoal") +{ +} + +void WinGoal::OnActivate(GameObject& instigator) +{ + if (!m_Reached) { + spdlog::info("{} reached the goal", instigator.GetName()); + } + m_Reached = true; +} + +nlohmann::json WinGoal::Serialize() const +{ + return {{"reached", m_Reached}}; +} + +void WinGoal::Deserialize(const nlohmann::json& data) +{ + m_Reached = data.value("reached", m_Reached); +} + +void WinGoal::ImGuiInspector() +{ + ImGui::Checkbox("Reached", &m_Reached); +} diff --git a/lightkeeper/src/components/WinScreenUI.cpp b/lightkeeper/src/components/WinScreenUI.cpp new file mode 100644 index 0000000..81f1634 --- /dev/null +++ b/lightkeeper/src/components/WinScreenUI.cpp @@ -0,0 +1,62 @@ +#include + +#include + +#include +#include +#include + +WinScreenUI::WinScreenUI(GameObject& parent) + : Component(parent, "WinScreenUI") +{ +} + +void WinScreenUI::LateUpdate(float) +{ + const Scene* scene = GetGameObject()->GetScene(); + if (scene == nullptr) { + return; + } + + GameObject* screen = m_Screen.Get(*scene); + if (screen == nullptr) { + return; + } + + GameObject* goal = m_Goal.Get(*scene); + const auto* winGoal = goal != nullptr ? goal->GetComponent() : nullptr; + screen->SetActive(winGoal != nullptr && winGoal->IsReached()); +} + +nlohmann::json WinScreenUI::Serialize() const +{ + return { + {"goal", m_Goal}, + {"screen", m_Screen}, + }; +} + +void WinScreenUI::Deserialize(const nlohmann::json& data) +{ + m_Goal = data.value("goal", ObjectRef{}); + m_Screen = data.value("screen", ObjectRef{}); +} + +void WinScreenUI::ResolveReferences(const ObjectMap& objects) +{ + m_Goal.Resolve(objects); + m_Screen.Resolve(objects); +} + +void WinScreenUI::ImGuiInspector() +{ + const Scene* scene = GetGameObject()->GetScene(); + if (scene == nullptr) { + return; + } + + ImGuiUtils::DrawObjectRefPicker("Goal", m_Goal, *scene, [](const GameObject& object) { + return object.GetComponent() != nullptr; + }); + ImGuiUtils::DrawObjectRefPicker("Screen", m_Screen, *scene); +} diff --git a/lightkeeper/src/scenes/MainScene.cpp b/lightkeeper/src/scenes/MainScene.cpp index 0650fef..98d7bd9 100644 --- a/lightkeeper/src/scenes/MainScene.cpp +++ b/lightkeeper/src/scenes/MainScene.cpp @@ -7,6 +7,10 @@ #include #include #include +#include +#include +#include +#include #include #include #include @@ -16,11 +20,16 @@ #include #include +#include +#include #include #include #include #include +#include #include +#include +#include #include #include @@ -262,6 +271,167 @@ namespace { {2.0f, platformHeight, platformSize.z}, rampMaterial, RigidbodyType::Static); } + // Floats over the void past the west edge of the ground. The controller jumps 1.3 m and sprints 8.5 m/s, + // which carries about 5 m on a level jump, the hardest gaps stay a little under that + WinGoal* CreateWinCourse(Scene& scene, GfxDevice& gfxDevice, RenderResources& resources) + { + const MaterialID checkpointMaterial = AddPrototypeMaterial(gfxDevice, resources, "Green", 0.6f); + const MaterialID pillarMaterial = AddPrototypeMaterial(gfxDevice, resources, "Orange", 0.6f); + const MaterialID stoneMaterial = AddPrototypeMaterial(gfxDevice, resources, "Purple", 0.5f); + const MaterialID beamMaterial = AddPrototypeMaterial(gfxDevice, resources, "Dark", 0.9f); + const MaterialID movingMaterial = AddPrototypeMaterial(gfxDevice, resources, "Light", 0.5f); + const MaterialID goalMaterial = AddPrototypeMaterial(gfxDevice, resources, "Red", 0.6f); + + constexpr float slabThickness = 0.5f; + + GameObject* course = scene.CreateGameObject("WinCourse"); + course->GetTransform().SetWorldPosition({-GroundHalfExtent, GroundHeight, 0.0f}); + const auto addToCourse = [&](GameObject* object) + { + object->GetTransform().SetParent(&course->GetTransform(), true); + return object; + }; + + // Top center and half size of the last placed platform, the course runs along -X + glm::vec3 top{-GroundHalfExtent - 2.5f, GroundHeight, 0.0f}; + glm::vec2 half{2.5f, 2.5f}; + int platformCount = 0; + int checkpointCount = 0; + + const auto slab = [&](const std::string& name, const glm::vec3& topCenter, const glm::vec2& size, + MaterialID materialID, RigidbodyType type) + { + return addToCourse(CreateBox(scene, gfxDevice, resources, "WinCourse_" + name, + topCenter - glm::vec3{0.0f, slabThickness * 0.5f, 0.0f}, + {size.x, slabThickness, size.y}, materialID, type)); + }; + + // gap is the edge to edge distance along X, rise and dz move the top center + const auto next = [&](float gap, float rise, float dz, const glm::vec2& size, MaterialID materialID, + RigidbodyType type = RigidbodyType::Static) + { + top = glm::vec3{top.x - half.x - gap - size.x * 0.5f, top.y + rise, top.z + dz}; + half = size * 0.5f; + return slab("Platform" + std::to_string(++platformCount), top, size, materialID, type); + }; + + const auto mover = [&](float gap, float rise, float dz, const glm::vec2& size, const glm::vec3& travel, + float travelDuration, float pauseDuration) + { + auto* platform = next(gap, rise, dz, size, movingMaterial, RigidbodyType::Kinematic) + ->AddComponent(); + platform->SetTravelOffset(travel); + platform->SetTravelDuration(travelDuration); + platform->SetPauseDuration(pauseDuration); + }; + + const auto addCheckpoint = [&]() + { + GameObject* trigger = addToCourse( + scene.CreateGameObject("WinCourse_Checkpoint" + std::to_string(++checkpointCount))); + trigger->GetTransform().SetWorldPosition(top + glm::vec3{0.0f, 1.0f, 0.0f}); + trigger->AddComponent(glm::vec3{half.x, 1.0f, half.y})->SetTrigger(true); + trigger->AddComponent()->SetType(RigidbodyType::Static); + trigger->AddComponent()->SetSpawnOffset({0.0f, 0.2f, 0.0f}); + scene.GetPhysics().RegisterGameObject(*trigger); + }; + + // The start pad touches the ground edge so it can be walked onto + slab("Start", top, {5.0f, 5.0f}, checkpointMaterial, RigidbodyType::Static); + addCheckpoint(); + + // Warm-up hops + next(2.0f, 0.0f, 0.0f, {2.0f, 2.0f}, pillarMaterial); + next(2.4f, 0.5f, 1.5f, {1.5f, 1.5f}, pillarMaterial); + next(2.6f, 0.5f, -3.0f, {1.5f, 1.5f}, pillarMaterial); + next(2.8f, 0.0f, 1.5f, {1.2f, 1.2f}, pillarMaterial); + next(2.2f, -0.5f, 0.0f, {3.0f, 3.0f}, checkpointMaterial); + addCheckpoint(); + + // Zig-zag over shrinking stones + next(2.6f, 0.0f, 1.0f, {1.0f, 1.0f}, stoneMaterial); + next(2.8f, 0.4f, -2.0f, {0.8f, 0.8f}, stoneMaterial); + next(2.8f, 0.4f, 2.0f, {0.7f, 0.7f}, stoneMaterial); + next(3.0f, 0.4f, -2.0f, {0.7f, 0.7f}, stoneMaterial); + next(3.2f, -0.8f, 1.0f, {0.7f, 0.7f}, stoneMaterial); + next(2.5f, 0.0f, 0.0f, {3.0f, 3.0f}, checkpointMaterial); + addCheckpoint(); + + // Offset balance beams, then long sprint jumps + next(1.2f, 0.0f, 0.0f, {6.0f, 0.4f}, beamMaterial); + next(1.4f, 0.3f, 0.9f, {5.0f, 0.35f}, beamMaterial); + next(1.4f, 0.3f, -1.8f, {4.0f, 0.3f}, beamMaterial); + next(3.6f, 0.0f, 0.9f, {1.5f, 1.5f}, pillarMaterial); + next(4.0f, -0.6f, 0.0f, {1.5f, 1.5f}, pillarMaterial); + next(3.8f, 0.0f, 0.0f, {1.2f, 1.2f}, pillarMaterial); + next(3.0f, 0.0f, 0.0f, {3.0f, 3.0f}, checkpointMaterial); + addCheckpoint(); + + // A ferry over a 9 m hole, then sideways movers that drift out of sync + constexpr float ferryTravel = 9.0f; + mover(0.6f, 0.0f, 0.0f, {2.5f, 2.5f}, {-ferryTravel, 0.0f, 0.0f}, 3.5f, 1.0f); + top.x -= ferryTravel; + next(0.6f, 0.0f, 0.0f, {2.0f, 2.0f}, pillarMaterial); + + constexpr float sway = 3.0f; + mover(2.2f, 0.0f, -sway, {1.5f, 1.5f}, {0.0f, 0.0f, sway * 2.0f}, 2.0f, 0.3f); + top.z += sway; + mover(2.2f, 0.0f, sway, {1.5f, 1.5f}, {0.0f, 0.0f, -sway * 2.0f}, 2.6f, 0.3f); + top.z -= sway; + mover(2.2f, 0.0f, -sway, {1.5f, 1.5f}, {0.0f, 0.0f, sway * 2.0f}, 1.7f, 0.3f); + top.z += sway; + next(2.2f, 0.0f, 0.0f, {3.0f, 3.0f}, checkpointMaterial); + addCheckpoint(); + + // An elevator up to the last stretch + constexpr float liftHeight = 5.0f; + mover(0.6f, 0.0f, 0.0f, {2.0f, 2.0f}, {0.0f, liftHeight, 0.0f}, 3.0f, 1.5f); + top.y += liftHeight; + next(1.2f, 0.0f, 0.0f, {1.5f, 1.5f}, pillarMaterial); + + next(2.8f, 0.5f, 1.2f, {0.8f, 0.8f}, stoneMaterial); + next(3.0f, 0.5f, -2.4f, {0.7f, 0.7f}, stoneMaterial); + next(3.2f, 0.0f, 2.4f, {0.6f, 0.6f}, stoneMaterial); + next(3.4f, -0.4f, -2.4f, {0.6f, 0.6f}, stoneMaterial); + next(2.5f, 0.0f, 1.2f, {3.0f, 3.0f}, checkpointMaterial); + addCheckpoint(); + + // Final gauntlet: a fast sideways mover and three tiny stones + constexpr float fastSway = 2.5f; + mover(2.4f, 0.0f, -fastSway, {1.2f, 1.2f}, {0.0f, 0.0f, fastSway * 2.0f}, 1.6f, 0.2f); + top.z += fastSway; + next(2.6f, 0.8f, 0.0f, {1.0f, 1.0f}, stoneMaterial); + next(3.4f, 0.0f, 1.0f, {0.8f, 0.8f}, stoneMaterial); + next(3.6f, -0.3f, -2.0f, {0.8f, 0.8f}, stoneMaterial); + + GameObject* goal = next(2.5f, 0.5f, 1.0f, {4.0f, 4.0f}, goalMaterial); + goal->SetName("WinCourse_Goal"); + auto* winGoal = goal->AddComponent(); + + const glm::vec3 buttonBase = top + glm::vec3{-1.0f, 0.0f, 0.0f}; + addToCourse(CreateBox(scene, gfxDevice, resources, "WinCourse_ButtonPedestal", + buttonBase + glm::vec3{0.0f, 0.55f, 0.0f}, {0.4f, 1.1f, 0.4f}, + beamMaterial, RigidbodyType::Static)); + + // Every button needs its own material, pressing it recolors the material + Material buttonMaterial = Material::SimpleColor(glm::vec3{1.0f, 0.85f, 0.2f}, "WinButtonMaterial"); + buttonMaterial.roughnessFactor = 0.4f; + GameObject* button = addToCourse(CreateBox(scene, gfxDevice, resources, "WinCourse_Button", + buttonBase + glm::vec3{0.0f, 1.15f, 0.0f}, {0.25f, 0.1f, 0.25f}, + resources.materials().addMaterial(std::move(buttonMaterial)), + RigidbodyType::Static)); + button->AddComponent()->AddTarget(*goal); + + GameObject* goalLight = addToCourse(scene.CreateGameObject("WinCourse_GoalLight")); + goalLight->GetTransform().SetWorldPosition(top + glm::vec3{0.0f, 3.0f, 0.0f}); + auto* light = goalLight->AddComponent(LightType::Point); + light->SetColor(glm::vec3{1.0f, 0.8f, 0.3f}); + light->SetIntensity(6.0f); + light->SetRange(8.0f); + + return winGoal; + } + struct WallOpening { float offset; float width; @@ -493,12 +663,97 @@ namespace { // Facing the parkour course auto* controller = playerObj->AddComponent(); controller->SetYaw(glm::half_pi()); + playerObj->AddComponent(); scene.GetPhysics().RegisterGameObject(*playerObj); objects.player = playerObj; objects.playerController = controller; } + // Fills its parent until it is given anchors or a size + RectTransform* CreateUIObject(Scene& scene, const std::string& name, GameObject& parent) + { + GameObject* object = scene.CreateGameObject(name); + object->GetTransform().SetParent(&parent.GetTransform(), false); + auto* rect = object->AddComponent(); + rect->Stretch(); + return rect; + } + + GameObject* CreateCrosshair(Scene& scene, GameObject& hud) + { + auto* crosshair = CreateUIObject(scene, "Crosshair", hud); + crosshair->SetAnchor({0.5f, 0.5f}); + crosshair->SetSize({10.0f, 10.0f}); + + constexpr float thickness = 2.0f; + auto* horizontal = CreateUIObject(scene, "Crosshair_Horizontal", *crosshair->GetGameObject()); + horizontal->SetAnchors({0.0f, 0.5f}, {1.0f, 0.5f}); + horizontal->SetSize({0.0f, thickness}); + horizontal->GetGameObject()->AddComponent(); + + auto* vertical = CreateUIObject(scene, "Crosshair_Vertical", *crosshair->GetGameObject()); + vertical->SetAnchors({0.5f, 0.0f}, {0.5f, 1.0f}); + vertical->SetSize({thickness, 0.0f}); + vertical->GetGameObject()->AddComponent(); + + return crosshair->GetGameObject(); + } + + GameObject* CreateFocusIndicator(Scene& scene, GameObject& crosshair) + { + GameObject* indicator = CreateUIObject(scene, "FocusIndicator", crosshair)->GetGameObject(); + + auto* ring = CreateUIObject(scene, "FocusRing", *indicator); + ring->SetAnchor({0.5f, 0.5f}); + ring->SetSize({28.0f, 28.0f}); + auto* ringImage = ring->GetGameObject()->AddComponent(); + ringImage->SetShape(UIImageShape::Ellipse); + ringImage->SetOutline(2.0f); + + auto* prompt = CreateUIObject(scene, "InteractPrompt", *indicator); + prompt->SetAnchor({0.5f, 0.5f}); + prompt->SetPivot({0.5f, 0.0f}); + prompt->SetPosition({0.0f, 28.0f}); + prompt->SetSize({400.0f, 30.0f}); + auto* promptText = prompt->GetGameObject()->AddComponent("[E] Interact", 22.0f); + promptText->SetOutline(2.0f, {0.0f, 0.0f, 0.0f, 0.75f}); + promptText->SetAlignment(UITextAlign::Center, UITextAlign::Start); + + return indicator; + } + + GameObject* CreateWinScreen(Scene& scene, GameObject& hud) + { + GameObject* screen = CreateUIObject(scene, "WinScreen", hud)->GetGameObject(); + screen->AddComponent(glm::vec4{0.0f, 0.0f, 0.0f, 0.35f}); + + auto* titleText = CreateUIObject(scene, "WinScreen_Title", *screen)->GetGameObject()->AddComponent("YOU WIN", 195.0f); + titleText->SetColor({1.0f, 0.85f, 0.2f, 1.0f}); + titleText->SetOutline(4.0f, {0.0f, 0.0f, 0.0f, 0.9f}); + + screen->SetActive(false); + return screen; + } + + // Sizes are in 1080p pixels, the canvas scales them with the screen height + void CreateHud(Scene& scene, const GameObject& player, const GameObject& winGoal) + { + GameObject* hud = scene.CreateGameObject("HUD"); + hud->AddComponent(); + + GameObject* crosshair = CreateCrosshair(scene, *hud); + const GameObject* focusIndicator = CreateFocusIndicator(scene, *crosshair); + auto* crosshairUi = hud->AddComponent(); + crosshairUi->SetPlayer(player); + crosshairUi->SetCrosshair(*crosshair); + crosshairUi->SetFocusIndicator(*focusIndicator); + + auto* winScreenUi = hud->AddComponent(); + winScreenUi->SetGoal(winGoal); + winScreenUi->SetScreen(*CreateWinScreen(scene, *hud)); + } + void LoadSpawnBallResources(GfxDevice& gfxDevice, RenderResources& resources, MainSceneObjects& objects) { const auto sphereModel = AssetManager::GetInstance().LoadModel("engine://sphere.fbx", StaticModelOptions()); @@ -528,7 +783,9 @@ MainSceneObjects BuildMainScene(Scene& scene, GfxDevice& gfxDevice, RenderResour CreateButton(scene, gfxDevice, resources, *door); CreateHouse(scene, gfxDevice, resources); CreateMovingPlatform(scene, gfxDevice, resources); + const WinGoal* winGoal = CreateWinCourse(scene, gfxDevice, resources); CreatePlayer(scene, objects); + CreateHud(scene, *objects.player, *winGoal->GetGameObject()); LoadSpawnBallResources(gfxDevice, resources, objects); return objects; diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index e387345..7db0349 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -3,6 +3,12 @@ add_executable(destrum_tests ${CMAKE_SOURCE_DIR}/lightkeeper/src/components/Activatable.cpp ${CMAKE_SOURCE_DIR}/lightkeeper/src/components/Interactable.cpp ${CMAKE_SOURCE_DIR}/lightkeeper/src/components/InteractableButton.cpp + ${CMAKE_SOURCE_DIR}/lightkeeper/src/components/WinGoal.cpp + ${CMAKE_SOURCE_DIR}/lightkeeper/src/components/Checkpoint.cpp + ${CMAKE_SOURCE_DIR}/lightkeeper/src/components/PlayerRespawn.cpp + ${CMAKE_SOURCE_DIR}/lightkeeper/src/components/FirstPersonController.cpp + ${CMAKE_SOURCE_DIR}/lightkeeper/src/components/CrosshairUI.cpp + ${CMAKE_SOURCE_DIR}/lightkeeper/src/components/WinScreenUI.cpp ) set_target_properties(destrum_tests PROPERTIES diff --git a/tests/destrum_tests.cpp b/tests/destrum_tests.cpp index f6e978c..7204647 100644 --- a/tests/destrum_tests.cpp +++ b/tests/destrum_tests.cpp @@ -36,6 +36,10 @@ #include #include #include +#include +#include +#include +#include #include #include #include @@ -49,6 +53,12 @@ #include #include +#include +#include +#include +#include +#include +#include namespace { class TestComponent final : public Component { @@ -252,7 +262,11 @@ namespace { "Rigidbody", "BoxCollider", "SphereCollider", - "CapsuleCollider" + "CapsuleCollider", + "Canvas", + "RectTransform", + "UIImage", + "UIText" }; for (const char* name : canonicalNames) { check(ComponentFactory::IsRegistered(name), @@ -1745,6 +1759,309 @@ namespace { const json future = json::parse(R"({"version":99,"objects":[{"id":1}]})"); check(PrefabSerializer::Validate(future).has_value(), "an unknown prefab version must be rejected"); } + + void testWinGoalButton() + { + ComponentFactory::Register("InteractableButton"); + ComponentFactory::Register("WinGoal"); + + Scene& scene = SceneManager::GetInstance().CreateScene("win goal"); + GameObject* goal = scene.CreateGameObject("goal"); + auto* winGoal = goal->AddComponent(); + GameObject* buttonObject = scene.CreateGameObject("button"); + auto* button = buttonObject->AddComponent(); + button->SetCycleColors(false); + button->AddTarget(*goal); + scene.CommitPendingAdditions(); + + check(!winGoal->IsReached(), "the goal must start unreached"); + button->Interact(*buttonObject); + check(winGoal->IsReached(), "pressing the win button must reach the goal"); + + const auto path = std::filesystem::temp_directory_path() / "destrum_win_goal_scene.json"; + check(SceneSerializer::Save(scene, path), "the win goal scene must save"); + check(SceneSerializer::Load(scene, path), "the win goal scene must load"); + std::filesystem::remove(path); + scene.CommitPendingAdditions(); + const GameObject* loadedGoal = findByName(scene, "goal"); + check(loadedGoal != nullptr && loadedGoal->GetComponent()->IsReached(), + "a reached goal must stay reached after a round-trip"); + + SceneManager::GetInstance().Destroy(); + } + + void testCheckpointRespawn() + { + Scene& scene = SceneManager::GetInstance().CreateScene("respawn"); + GameObject* player = scene.CreateGameObject("player"); + player->GetTransform().SetWorldPosition(glm::vec3{1.0f, 2.0f, 3.0f}); + player->AddComponent(0.4f, 1.8f); + auto* rigidbody = player->AddComponent(); + auto* respawn = player->AddComponent(); + respawn->SetKillHeight(-10.0f); + + GameObject* checkpointObject = scene.CreateGameObject("checkpoint"); + checkpointObject->GetTransform().SetWorldPosition(glm::vec3{-5.0f, 4.0f, 0.0f}); + auto* checkpoint = checkpointObject->AddComponent(); + checkpoint->SetSpawnOffset({0.0f, 0.5f, 0.0f}); + scene.CommitPendingAdditions(); + check(rigidbody->HasPhysicsBody(), "the player must have a physics body"); + + respawn->Start(); + check(respawn->GetRespawnPoint() == glm::vec3(1.0f, 2.0f, 3.0f), + "the respawn point must default to where the player starts"); + + checkpoint->OnTriggerEnter(checkpointObject); + check(respawn->GetRespawnPoint() == glm::vec3(1.0f, 2.0f, 3.0f), + "a checkpoint must ignore objects without PlayerRespawn"); + checkpoint->OnTriggerEnter(player); + check(respawn->GetRespawnPoint() == glm::vec3(-5.0f, 4.5f, 0.0f), + "touching a checkpoint must move the respawn point"); + + rigidbody->Teleport(glm::vec3{0.0f, -20.0f, 0.0f}); + rigidbody->SetLinearVelocity(glm::vec3{0.0f, -30.0f, 0.0f}); + respawn->FixedUpdate(1.0f / 60.0f); + const glm::vec3 position = player->GetTransform().GetWorldPosition(); + check(close(position.x, -5.0f) && close(position.y, 4.5f) && close(position.z, 0.0f), + "falling below the kill height must respawn at the checkpoint"); + check(glm::length(rigidbody->GetLinearVelocity()) < 0.001f, "respawning must stop the fall"); + + scene.GetPhysics().FixedUpdate(1.0f / 60.0f); + check(player->GetTransform().GetWorldPosition().y > 4.0f, + "the physics body must have moved with the teleport"); + + SceneManager::GetInstance().Destroy(); + } + + void testCanvasLayout() + { + Scene& scene = SceneManager::GetInstance().CreateScene("canvas"); + GameObject* hud = scene.CreateGameObject("hud"); + auto* canvas = hud->AddComponent(); + const auto addChild = [&](const std::string& name, GameObject& parent) { + GameObject* child = scene.CreateGameObject(name); + child->GetTransform().SetParent(&parent.GetTransform(), false); + return child; + }; + + GameObject* panel = addChild("panel", *hud); + auto* panelRect = panel->AddComponent(); + panelRect->SetSize({100.0f, 50.0f}); + panelRect->SetPosition({10.0f, 0.0f}); + panel->AddComponent(glm::vec4{1.0f, 0.0f, 0.0f, 1.0f}); + + GameObject* fill = addChild("fill", *panel); + fill->AddComponent(glm::vec4{0.0f, 1.0f, 0.0f, 1.0f})->SetShape(UIImageShape::Ellipse); + + GameObject* corner = addChild("corner", *hud); + auto* cornerRect = corner->AddComponent(); + cornerRect->SetAnchor({1.0f, 1.0f}); + cornerRect->SetPivot({1.0f, 1.0f}); + cornerRect->SetPosition({-20.0f, -20.0f}); + cornerRect->SetSize({40.0f, 40.0f}); + corner->AddComponent(); + + GameObject* hidden = addChild("hidden", *hud); + auto* hiddenRect = hidden->AddComponent(); + hiddenRect->SetSize({20.0f, 20.0f}); + hidden->AddComponent(); + hidden->SetActive(false); + + GameObject* nested = addChild("nested", *hud); + nested->AddComponent(); + nested->AddComponent(); + scene.CommitPendingAdditions(); + + auto& r2d = Renderer2D::GetInstance(); + r2d.Clear(); + canvas->Draw({1920.0f, 1080.0f}); + const auto& shapes = r2d.GetShapes(); + check(shapes.size() == 3, "a canvas must draw its active children and skip inactive ones and nested canvases"); + check(shapes[0].type == Shape2DType::Rect && shapes[0].center == glm::vec2{970.0f, 540.0f} && + shapes[0].halfSize == glm::vec2{50.0f, 25.0f}, + "a centered rect must be placed around its anchor, offset by its position"); + check(shapes[1].type == Shape2DType::Ellipse && shapes[1].center == shapes[0].center && + shapes[1].halfSize == shapes[0].halfSize, + "an object without a RectTransform must fill its parent and draw after it"); + check(shapes[2].center == glm::vec2{1880.0f, 1040.0f}, + "anchors and pivot must place a rect relative to the screen corner"); + check(panelRect->GetScreenRect().min == glm::vec2{920.0f, 515.0f}, + "the canvas must store where it drew each rect"); + check(hiddenRect->GetScreenRect().size == glm::vec2{20.0f, 20.0f}, + "hidden objects must still be laid out so the editor can show them"); + + r2d.Clear(); + canvas->Draw({3840.0f, 2160.0f}); + check(r2d.GetShapes().size() == 3 && r2d.GetShapes()[0].halfSize == glm::vec2{100.0f, 50.0f} && + r2d.GetShapes()[0].center == glm::vec2{1940.0f, 1080.0f}, + "sizes and offsets must scale with the screen height"); + + canvas->SetScaleMode(CanvasScaleMode::ConstantPixelSize); + r2d.Clear(); + canvas->Draw({3840.0f, 2160.0f}); + check(r2d.GetShapes()[0].halfSize == glm::vec2{50.0f, 25.0f}, "a constant pixel size canvas must not scale"); + + check(Canvas::FindFor(*fill) == canvas && Canvas::FindFor(*nested) == nested->GetComponent(), + "FindFor must return the closest canvas on the object or its parents"); + check(cornerRect->GetParentRect().size == glm::vec2{3840.0f, 2160.0f}, + "the canvas must store the parent rect each rect was laid out in"); + + cornerRect->ApplyAnchorPreset({0.0f, -1.0f}); + check(cornerRect->GetAnchorMin() == glm::vec2{0.0f, 0.0f} && cornerRect->GetAnchorMax() == glm::vec2{0.0f, 1.0f} && + cornerRect->GetPivot() == glm::vec2{0.0f, 0.5f} && cornerRect->GetPosition() == glm::vec2{0.0f} && + cornerRect->GetSize() == glm::vec2{40.0f, 0.0f}, + "an anchor preset must pin or stretch each axis and keep the size on pinned axes"); + + panelRect->Stretch(); + check(panelRect->ComputeRect(UIRect{.min = {10.0f, 20.0f}, .size = {30.0f, 40.0f}}, 2.0f).size == glm::vec2{30.0f, 40.0f}, + "a stretched rect must fill its parent"); + r2d.Clear(); + + SceneManager::GetInstance().Destroy(); + } + + void testUITextPlacement() + { + Scene& scene = SceneManager::GetInstance().CreateScene("ui text"); + auto* text = scene.CreateGameObject("text")->AddComponent("hi", 20.0f); + const UIRect rect{.min = {100.0f, 100.0f}, .size = {200.0f, 100.0f}}; + + check(text->PlaceText(rect, {50.0f, 20.0f}) == glm::vec2{175.0f, 140.0f}, "text must be centered by default"); + text->SetAlignment(UITextAlign::End, UITextAlign::Start); + check(text->PlaceText(rect, {50.0f, 20.0f}) == glm::vec2{250.0f, 100.0f}, "text must follow its alignment"); + + SceneManager::GetInstance().Destroy(); + } + + void testHudRoundTrip() + { + RegisterEngineComponents(); + ComponentFactory::Register("CrosshairUI"); + ComponentFactory::Register("WinScreenUI"); + ComponentFactory::Register("WinGoal"); + ComponentFactory::Register("FirstPersonController"); + + Scene& scene = SceneManager::GetInstance().CreateScene("hud"); + GameObject* player = scene.CreateGameObject("player"); + player->AddComponent(); + GameObject* goal = scene.CreateGameObject("goal"); + auto* winGoal = goal->AddComponent(); + + GameObject* hud = scene.CreateGameObject("hud"); + hud->AddComponent()->SetReferenceHeight(720.0f); + GameObject* crosshair = scene.CreateGameObject("crosshair"); + crosshair->GetTransform().SetParent(&hud->GetTransform(), false); + auto* crosshairRect = crosshair->AddComponent(); + crosshairRect->SetAnchors({0.25f, 0.5f}, {0.75f, 0.5f}); + crosshair->AddComponent()->SetOutline(3.0f); + GameObject* screen = scene.CreateGameObject("screen"); + screen->GetTransform().SetParent(&hud->GetTransform(), false); + auto* label = screen->AddComponent("YOU WIN", 64.0f); + label->SetAlignment(UITextAlign::Start, UITextAlign::End); + + auto* crosshairUi = hud->AddComponent(); + crosshairUi->SetPlayer(*player); + crosshairUi->SetCrosshair(*crosshair); + auto* winScreenUi = hud->AddComponent(); + winScreenUi->SetGoal(*goal); + winScreenUi->SetScreen(*screen); + scene.CommitPendingAdditions(); + + crosshairUi->LateUpdate(0.0f); + winScreenUi->LateUpdate(0.0f); + check(!crosshair->IsActive(), "the crosshair must hide while the player isn't possessed"); + check(!screen->IsActive(), "the win screen must hide until the goal is reached"); + winGoal->Activate(*player); + winScreenUi->LateUpdate(0.0f); + check(screen->IsActive(), "the win screen must show once the goal is reached"); + + const auto path = std::filesystem::temp_directory_path() / "destrum_hud_scene.json"; + check(SceneSerializer::Save(scene, path), "the hud scene must save"); + Scene& copy = SceneManager::GetInstance().CreateScene("hud copy"); + check(SceneSerializer::Load(copy, path), "the hud scene must load"); + std::filesystem::remove(path); + copy.CommitPendingAdditions(); + + GameObject* loadedHud = findByName(copy, "hud"); + GameObject* loadedCrosshair = findByName(copy, "crosshair"); + GameObject* loadedScreen = findByName(copy, "screen"); + check(loadedHud != nullptr && loadedCrosshair != nullptr && loadedScreen != nullptr, + "the hud objects must survive a round-trip"); + check(loadedCrosshair->GetTransform().GetParent() == &loadedHud->GetTransform(), + "ui objects must stay parented to their canvas"); + + const auto* loadedCanvas = loadedHud->GetComponent(); + check(loadedCanvas != nullptr && loadedCanvas->GetReferenceHeight() == 720.0f, "canvas settings must round-trip"); + const auto* loadedRect = loadedCrosshair->GetComponent(); + check(loadedRect != nullptr && loadedRect->GetAnchorMin() == glm::vec2{0.25f, 0.5f} && + loadedRect->GetAnchorMax() == glm::vec2{0.75f, 0.5f}, + "rect transforms must round-trip"); + const auto* loadedImage = loadedCrosshair->GetComponent(); + check(loadedImage != nullptr && loadedImage->GetOutline() == 3.0f, "ui images must round-trip"); + const auto* loadedLabel = loadedScreen->GetComponent(); + check(loadedLabel != nullptr && loadedLabel->GetText() == "YOU WIN" && loadedLabel->GetFontSize() == 64.0f && + loadedLabel->GetHorizontalAlign() == UITextAlign::Start && + loadedLabel->GetVerticalAlign() == UITextAlign::End, + "ui text must round-trip"); + + const auto* loadedCrosshairUi = loadedHud->GetComponent(); + const auto* loadedWinScreenUi = loadedHud->GetComponent(); + check(loadedCrosshairUi != nullptr && loadedCrosshairUi->GetPlayer().Get(copy) == findByName(copy, "player") && + loadedCrosshairUi->GetCrosshair().Get(copy) == loadedCrosshair, + "crosshair references must point at the loaded objects"); + check(loadedWinScreenUi != nullptr && loadedWinScreenUi->GetGoal().Get(copy) == findByName(copy, "goal") && + loadedWinScreenUi->GetScreen().Get(copy) == loadedScreen, + "win screen references must point at the loaded objects"); + + SceneManager::GetInstance().Destroy(); + } + + void testHierarchyMoves() + { + Scene& scene = SceneManager::GetInstance().CreateScene("hierarchy"); + GameObject* parent = scene.CreateGameObject("parent"); + GameObject* a = scene.CreateGameObject("a"); + GameObject* b = scene.CreateGameObject("b"); + GameObject* c = scene.CreateGameObject("c"); + GameObject* other = scene.CreateGameObject("other"); + for (GameObject* child : {a, b, c}) { + child->GetTransform().SetParent(&parent->GetTransform(), false); + } + scene.CommitPendingAdditions(); + + const auto childNames = [](const GameObject& object) { + std::string names; + for (const Transform* child : object.GetTransform().GetChildren()) { + names += child->GetOwner()->GetName(); + } + return names; + }; + + check(scene.MoveInHierarchy(*c, parent, a) && childNames(*parent) == "cab", + "moving before a sibling must reorder the children"); + check(scene.MoveInHierarchy(*c, parent) && childNames(*parent) == "abc", + "moving without a sibling must put the object last"); + check(scene.MoveInHierarchy(*a, other) && childNames(*parent) == "bc" && childNames(*other) == "a", + "moving onto another object must reparent it"); + check(!scene.MoveInHierarchy(*other, a) && a->GetTransform().GetParent() == &other->GetTransform(), + "an object must not be moved under its own child"); + check(scene.MoveInHierarchy(*a, nullptr, parent) && a->GetTransform().GetParent() == nullptr && + scene.GetObjects().front().get() == a, + "moving to the root must unparent and order the roots"); + check(scene.MoveInHierarchy(*c, parent, b) && childNames(*parent) == "cb", "reordering must still work"); + + const auto path = std::filesystem::temp_directory_path() / "destrum_hierarchy_scene.json"; + check(SceneSerializer::Save(scene, path), "the hierarchy scene must save"); + Scene& copy = SceneManager::GetInstance().CreateScene("hierarchy copy"); + check(SceneSerializer::Load(copy, path), "the hierarchy scene must load"); + std::filesystem::remove(path); + copy.CommitPendingAdditions(); + const GameObject* loadedParent = findByName(copy, "parent"); + check(loadedParent != nullptr && childNames(*loadedParent) == "cb", "sibling order must survive a save"); + check(copy.GetObjects().front()->GetName() == "a", "root order must survive a save"); + + SceneManager::GetInstance().Destroy(); + } } int main() @@ -1796,6 +2113,12 @@ int main() testPrefabRoundTrip(); testPrefabDuplicate(); testPrefabValidation(); + testWinGoalButton(); + testCheckpointRespawn(); + testCanvasLayout(); + testUITextPlacement(); + testHudRoundTrip(); + testHierarchyMoves(); std::cout << "destrum tests passed\n"; return EXIT_SUCCESS; }