#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); // Leaf nodes push no ID, without this every sibling shares one popup and draws it again ImGui::PushID(reinterpret_cast(static_cast(object.GetId()))); if (ImGui::BeginPopupContextItem("ObjectMenu")) { DrawObjectMenu(scene, &object, selectedObjectId); ImGui::EndPopup(); } ImGui::PopID(); 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(); }