diff --git a/destrum/include/destrum/Scene/Scene.h b/destrum/include/destrum/Scene/Scene.h index 678ee1f..0b32f2b 100644 --- a/destrum/include/destrum/Scene/Scene.h +++ b/destrum/include/destrum/Scene/Scene.h @@ -64,6 +64,7 @@ public: [[nodiscard]] const std::vector>& GetObjects() const { return m_objects; } + [[nodiscard]] GameObject* FindGameObject(ObjectId id) const; void SetRegisterBindings(std::function registerBindings) { m_registerBindings = std::move(registerBindings); diff --git a/destrum/src/Scene/Scene.cpp b/destrum/src/Scene/Scene.cpp index cbdd0de..23379c8 100644 --- a/destrum/src/Scene/Scene.cpp +++ b/destrum/src/Scene/Scene.cpp @@ -205,6 +205,20 @@ CameraComponent* Scene::GetCurrentCamera() const { return cameras.empty() ? nullptr : cameras.front(); } +GameObject* Scene::FindGameObject(ObjectId id) const { + if (id == InvalidObjectId) { + return nullptr; + } + for (const auto* objects : {&m_objects, &m_pendingAdditions}) { + for (const auto& object : *objects) { + if (object != nullptr && !object->IsBeingDestroyed() && object->GetId() == id) { + return object.get(); + } + } + } + return nullptr; +} + std::vector Scene::GetCameras() const { std::vector cameras; for (const auto& object : m_objects) { diff --git a/lightkeeper/CMakeLists.txt b/lightkeeper/CMakeLists.txt index 914b623..ec291bb 100644 --- a/lightkeeper/CMakeLists.txt +++ b/lightkeeper/CMakeLists.txt @@ -12,6 +12,8 @@ set(GAME_SRC src/scenes/Experiments.cpp src/components/GameComponentRegistry.cpp + src/components/Activatable.cpp + src/components/Door.cpp src/components/FirstPersonController.cpp src/components/Interactable.cpp src/components/InteractableButton.cpp diff --git a/lightkeeper/include/components/Activatable.h b/lightkeeper/include/components/Activatable.h new file mode 100644 index 0000000..7191c83 --- /dev/null +++ b/lightkeeper/include/components/Activatable.h @@ -0,0 +1,20 @@ +#ifndef LIGHTKEEPER_ACTIVATABLE_H +#define LIGHTKEEPER_ACTIVATABLE_H + +#include +#include + +class Activatable : public Component { +public: + void Activate(GameObject& instigator); + + Event OnActivated; + +protected: + Activatable(GameObject& parent, const std::string& name) + : Component(parent, name) {} + + virtual void OnActivate(GameObject& instigator) = 0; +}; + +#endif // LIGHTKEEPER_ACTIVATABLE_H diff --git a/lightkeeper/include/components/Door.h b/lightkeeper/include/components/Door.h new file mode 100644 index 0000000..fee5bc4 --- /dev/null +++ b/lightkeeper/include/components/Door.h @@ -0,0 +1,50 @@ +#ifndef LIGHTKEEPER_DOOR_H +#define LIGHTKEEPER_DOOR_H + +#include + +#include + +class Door final : public Activatable { +public: + explicit Door(GameObject& parent); + + std::string GetTypeName() const override { return "Door"; } + + void Start() override; + void Update(float dt) override; + + nlohmann::json Serialize() const override; + void Deserialize(const nlohmann::json& data) override; + void ImGuiInspector() override; + + void Open() { m_IsOpen = true; } + void Close() { m_IsOpen = false; } + void Toggle() { m_IsOpen = !m_IsOpen; } + + [[nodiscard]] bool IsOpen() const { return m_IsOpen; } + [[nodiscard]] float GetOpenAmount() const { return m_OpenAmount; } + + void SetOpenOffset(const glm::vec3& offset) { m_OpenOffset = offset; } + void SetOpenDuration(float seconds) { m_OpenDuration = seconds; } + void SetToggleable(bool toggleable) { m_Toggleable = toggleable; } + +protected: + void OnActivate(GameObject& instigator) override; + +private: + [[nodiscard]] glm::vec3 GetOffsetFor(float openAmount) const; + void ApplyPosition(); + + glm::vec3 m_OpenOffset{0.0f, -2.4f, 0.0f}; + float m_OpenDuration{1.0f}; + bool m_Toggleable{true}; + + bool m_IsOpen{false}; + float m_OpenAmount{0.0f}; + + glm::vec3 m_ClosedPosition{0.0f}; + bool m_Started{false}; +}; + +#endif // LIGHTKEEPER_DOOR_H diff --git a/lightkeeper/include/components/GameComponentList.h b/lightkeeper/include/components/GameComponentList.h index 5b8015c..8193f43 100644 --- a/lightkeeper/include/components/GameComponentList.h +++ b/lightkeeper/include/components/GameComponentList.h @@ -1,12 +1,14 @@ #ifndef LIGHTKEEPER_GAMECOMPONENTLIST_H #define LIGHTKEEPER_GAMECOMPONENTLIST_H +#include #include #include #include #include #define LIGHTKEEPER_GAME_COMPONENTS(X) \ + X(Door) \ X(FirstPersonController) \ X(InteractableButton) \ X(PrintComponent) \ diff --git a/lightkeeper/include/components/InteractableButton.h b/lightkeeper/include/components/InteractableButton.h index cea6ad5..5178223 100644 --- a/lightkeeper/include/components/InteractableButton.h +++ b/lightkeeper/include/components/InteractableButton.h @@ -4,6 +4,9 @@ #include #include +#include + +#include class InteractableButton final : public Interactable { public: @@ -21,11 +24,19 @@ public: [[nodiscard]] std::size_t GetColorIndex() const { return m_ColorIndex; } [[nodiscard]] int GetPressCount() const { return m_PressCount; } + // Every Activatable on a target object is activated when the button is pressed + void AddTarget(const GameObject& target); + void RemoveTarget(ObjectId target); + [[nodiscard]] const std::vector& GetTargets() const { return m_Targets; } + private: void ApplyColor(); + void ActivateTargets(GameObject& interactor); + void DrawTargetsInspector(); std::size_t m_ColorIndex{0}; int m_PressCount{0}; + std::vector m_Targets; }; #endif // LIGHTKEEPER_INTERACTABLEBUTTON_H diff --git a/lightkeeper/src/components/Activatable.cpp b/lightkeeper/src/components/Activatable.cpp new file mode 100644 index 0000000..1be489f --- /dev/null +++ b/lightkeeper/src/components/Activatable.cpp @@ -0,0 +1,11 @@ +#include + +void Activatable::Activate(GameObject& instigator) +{ + if (!isEnabled() || IsBeingDestroyed()) { + return; + } + + OnActivate(instigator); + OnActivated.Invoke(instigator); +} diff --git a/lightkeeper/src/components/Door.cpp b/lightkeeper/src/components/Door.cpp new file mode 100644 index 0000000..0e91ba0 --- /dev/null +++ b/lightkeeper/src/components/Door.cpp @@ -0,0 +1,93 @@ +#include + +#include + +#include + +#include + +Door::Door(GameObject& parent) + : Activatable(parent, "Door") +{ +} + +void Door::Start() +{ + const glm::vec3 currentPosition = GetGameObject()->GetTransform().GetLocalPosition(); + m_ClosedPosition = currentPosition - GetOffsetFor(m_OpenAmount); + m_Started = true; +} + +void Door::Update(float dt) +{ + if (!m_Started) { + return; + } + + const float targetAmount = m_IsOpen ? 1.0f : 0.0f; + if (m_OpenAmount == targetAmount) { + return; + } + + const float step = m_OpenDuration > 0.0f ? dt / m_OpenDuration : 1.0f; + m_OpenAmount = m_IsOpen + ? std::min(m_OpenAmount + step, targetAmount) + : std::max(m_OpenAmount - step, targetAmount); + + ApplyPosition(); +} + +void Door::OnActivate(GameObject&) +{ + if (m_Toggleable) { + Toggle(); + } else { + Open(); + } +} + +glm::vec3 Door::GetOffsetFor(float openAmount) const +{ + const float eased = openAmount * openAmount * (3.0f - 2.0f * openAmount); + return m_OpenOffset * eased; +} + +void Door::ApplyPosition() +{ + GetGameObject()->GetTransform().SetLocalPosition(m_ClosedPosition + GetOffsetFor(m_OpenAmount)); +} + +nlohmann::json Door::Serialize() const +{ + return { + {"openOffset", {m_OpenOffset.x, m_OpenOffset.y, m_OpenOffset.z}}, + {"openDuration", m_OpenDuration}, + {"toggleable", m_Toggleable}, + {"open", m_IsOpen}, + {"openAmount", m_OpenAmount}, + }; +} + +void Door::Deserialize(const nlohmann::json& data) +{ + if (data.contains("openOffset")) { + const auto& offset = data.at("openOffset"); + m_OpenOffset = {offset.at(0).get(), offset.at(1).get(), offset.at(2).get()}; + } + m_OpenDuration = std::max(data.value("openDuration", m_OpenDuration), 0.0f); + m_Toggleable = data.value("toggleable", m_Toggleable); + m_IsOpen = data.value("open", m_IsOpen); + m_OpenAmount = std::clamp(data.value("openAmount", m_OpenAmount), 0.0f, 1.0f); +} + +void Door::ImGuiInspector() +{ + ImGui::Text("State: %s (%.0f%%)", m_IsOpen ? "Open" : "Closed", m_OpenAmount * 100.0f); + if (ImGui::Button(m_IsOpen ? "Close" : "Open")) { + Toggle(); + } + + ImGui::DragFloat3("Open Offset", &m_OpenOffset.x, 0.05f); + ImGui::DragFloat("Open Duration", &m_OpenDuration, 0.05f, 0.0f, 10.0f, "%.2f s"); + ImGui::Checkbox("Toggleable", &m_Toggleable); +} diff --git a/lightkeeper/src/components/FirstPersonController.cpp b/lightkeeper/src/components/FirstPersonController.cpp index 94f1d2e..35172c3 100644 --- a/lightkeeper/src/components/FirstPersonController.cpp +++ b/lightkeeper/src/components/FirstPersonController.cpp @@ -183,8 +183,6 @@ bool FirstPersonController::CheckGrounded() const const float radius = m_Capsule->GetRadius(); const float bottom = center.y - m_Capsule->GetHeight() * 0.5f; - // Rays start inside the capsule (ignoring it) so they are always above the floor, even when the solver - // leaves the capsule sunk in a little. The outer rays catch standing on an edge. const float spread = radius * 0.7f; const std::array offsets{ glm::vec2{0.0f, 0.0f}, diff --git a/lightkeeper/src/components/InteractableButton.cpp b/lightkeeper/src/components/InteractableButton.cpp index 396e702..386c461 100644 --- a/lightkeeper/src/components/InteractableButton.cpp +++ b/lightkeeper/src/components/InteractableButton.cpp @@ -1,13 +1,18 @@ #include +#include + #include #include #include +#include #include #include +#include #include +#include #include @@ -38,6 +43,50 @@ void InteractableButton::Interact(GameObject& interactor) ApplyColor(); spdlog::info("{} pressed {} by {}", GetGameObject()->GetName(), m_PressCount, interactor.GetName()); + ActivateTargets(interactor); +} + +void InteractableButton::AddTarget(const GameObject& target) +{ + if (target.GetId() == InvalidObjectId || std::ranges::find(m_Targets, target.GetId()) != m_Targets.end()) { + return; + } + m_Targets.push_back(target.GetId()); +} + +void InteractableButton::RemoveTarget(ObjectId target) +{ + std::erase(m_Targets, target); +} + +void InteractableButton::ActivateTargets(GameObject& interactor) +{ + const Scene* scene = GetGameObject()->GetScene(); + if (scene == nullptr) { + return; + } + + for (const ObjectId targetId : m_Targets) { + GameObject* target = scene->FindGameObject(targetId); + if (target == nullptr) { + spdlog::warn("{} targets object {} which no longer exists", GetGameObject()->GetName(), targetId); + continue; + } + + std::vector activatables; + for (const auto& component : target->GetComponents()) { + if (auto* activatable = dynamic_cast(component.get())) { + activatables.push_back(activatable); + } + } + + if (activatables.empty()) { + spdlog::warn("{} targets {} which has nothing to activate", GetGameObject()->GetName(), target->GetName()); + } + for (Activatable* activatable : activatables) { + activatable->Activate(interactor); + } + } } void InteractableButton::ApplyColor() @@ -59,6 +108,7 @@ nlohmann::json InteractableButton::Serialize() const return { {"colorIndex", m_ColorIndex}, {"pressCount", m_PressCount}, + {"targets", m_Targets}, }; } @@ -66,6 +116,7 @@ void InteractableButton::Deserialize(const nlohmann::json& data) { m_ColorIndex = data.value("colorIndex", m_ColorIndex) % ButtonColors.size(); m_PressCount = data.value("pressCount", m_PressCount); + m_Targets = data.value("targets", std::vector{}); } void InteractableButton::ImGuiInspector() @@ -74,4 +125,53 @@ void InteractableButton::ImGuiInspector() if (ImGui::Button("Press")) { Interact(*GetGameObject()); } + + DrawTargetsInspector(); +} + +void InteractableButton::DrawTargetsInspector() +{ + ImGui::SeparatorText("Targets"); + + const Scene* scene = GetGameObject()->GetScene(); + if (scene == nullptr) { + ImGui::TextDisabled("Not in a scene"); + return; + } + + std::optional targetToRemove; + for (const ObjectId targetId : m_Targets) { + const GameObject* target = scene->FindGameObject(targetId); + ImGui::PushID(static_cast(targetId)); + if (ImGui::SmallButton("x")) { + targetToRemove = targetId; + } + ImGui::SameLine(); + if (target != nullptr) { + ImGui::TextUnformatted(target->GetName().c_str()); + } else { + ImGui::TextDisabled("Missing object %llu", static_cast(targetId)); + } + ImGui::PopID(); + } + if (targetToRemove) { + RemoveTarget(*targetToRemove); + } + + if (ImGui::BeginCombo("##AddTarget", "Add target...")) { + for (const auto& object : scene->GetObjects()) { + const bool alreadyTargeted = object != nullptr && + std::ranges::find(m_Targets, object->GetId()) != m_Targets.end(); + if (object == nullptr || object.get() == GetGameObject() || alreadyTargeted || + object->GetComponent() == nullptr) { + continue; + } + ImGui::PushID(static_cast(object->GetId())); + if (ImGui::Selectable(object->GetName().c_str())) { + AddTarget(*object); + } + ImGui::PopID(); + } + ImGui::EndCombo(); + } } diff --git a/lightkeeper/src/scenes/MainScene.cpp b/lightkeeper/src/scenes/MainScene.cpp index 9cf939b..f6d44fd 100644 --- a/lightkeeper/src/scenes/MainScene.cpp +++ b/lightkeeper/src/scenes/MainScene.cpp @@ -16,6 +16,7 @@ #include #include +#include #include #include @@ -159,7 +160,32 @@ namespace { } } - void CreateButton(Scene& scene, GfxDevice& gfxDevice, RenderResources& resources) + GameObject* CreateDoorway(Scene& scene, GfxDevice& gfxDevice, RenderResources& resources) + { + const MaterialID frameMaterial = AddPrototypeMaterial(gfxDevice, resources, "Dark", 0.9f); + const MaterialID doorMaterial = AddPrototypeMaterial(gfxDevice, resources, "Purple", 0.5f); + + constexpr float x = 4.0f; + constexpr float z = -4.5f; + constexpr float frameHeight = 2.8f; + constexpr float doorWidth = 1.6f; + constexpr float doorHeight = 2.4f; + + for (const float side : {-1.0f, 1.0f}) { + CreateBox(scene, gfxDevice, resources, side < 0.0f ? "DoorFrame_Left" : "DoorFrame_Right", + {x, GroundHeight + frameHeight * 0.5f, z + side * (doorWidth * 0.5f + 0.2f)}, + {0.4f, frameHeight, 0.4f}, frameMaterial, RigidbodyType::Static); + } + CreateBox(scene, gfxDevice, resources, "DoorFrame_Top", {x, GroundHeight + frameHeight + 0.2f, z}, + {0.4f, 0.4f, doorWidth + 0.8f}, frameMaterial, RigidbodyType::Static); + + GameObject* door = CreateBox(scene, gfxDevice, resources, "Door", {x, GroundHeight + doorHeight * 0.5f, z}, + {0.3f, doorHeight, doorWidth}, doorMaterial, RigidbodyType::Kinematic); + door->AddComponent()->SetOpenOffset({0.0f, 0.0f, -doorWidth - 0.1f}); + return door; + } + + void CreateButton(Scene& scene, GfxDevice& gfxDevice, RenderResources& resources, const GameObject& target) { const MaterialID pedestalMaterial = AddPrototypeMaterial(gfxDevice, resources, "Dark", 0.9f); CreateBox(scene, gfxDevice, resources, "ButtonPedestal", {1.5f, -0.45f, -4.5f}, {0.4f, 1.1f, 0.4f}, @@ -172,7 +198,7 @@ namespace { GameObject* button = CreateBox(scene, gfxDevice, resources, "Button", {1.5f, 0.15f, -4.5f}, {0.25f, 0.1f, 0.25f}, buttonMaterialID, RigidbodyType::Static); - button->AddComponent(); + button->AddComponent()->AddTarget(target); } void CreateFlightHelmet(Scene& scene, GfxDevice& gfxDevice, RenderResources& resources) @@ -299,7 +325,8 @@ MainSceneObjects BuildMainScene(Scene& scene, GfxDevice& gfxDevice, RenderResour CreateTree(scene, gfxDevice, resources); CreateCapybara(scene, gfxDevice, resources); CreateTriggerZone(scene); - CreateButton(scene, gfxDevice, resources); + const GameObject* door = CreateDoorway(scene, gfxDevice, resources); + CreateButton(scene, gfxDevice, resources, *door); CreatePlayer(scene, objects); LoadSpawnBallResources(gfxDevice, resources, objects); diff --git a/tests/destrum_tests.cpp b/tests/destrum_tests.cpp index f552bd8..344ad51 100644 --- a/tests/destrum_tests.cpp +++ b/tests/destrum_tests.cpp @@ -107,6 +107,25 @@ namespace { "destroying a parent must mark descendants"); } + void testSceneFindGameObject() + { + Scene& scene = SceneManager::GetInstance().CreateScene("find-object"); + GameObject* pending = scene.CreateGameObject("pending"); + check(scene.FindGameObject(pending->GetId()) == pending, "objects must be found while still pending"); + + scene.CommitPendingAdditions(); + GameObject* committed = scene.CreateGameObject("committed"); + scene.CommitPendingAdditions(); + check(scene.FindGameObject(committed->GetId()) == committed, "committed objects must be found by id"); + check(scene.FindGameObject(InvalidObjectId) == nullptr, "the invalid id must never match"); + + const ObjectId destroyedId = committed->GetId(); + committed->Destroy(); + check(scene.FindGameObject(destroyedId) == nullptr, "objects being destroyed must not be found"); + + SceneManager::GetInstance().Destroy(); + } + void testSceneRoundTrip() { Scene& scene = SceneManager::GetInstance().CreateScene("round-trip"); @@ -1198,6 +1217,7 @@ namespace { int main() { testTransforms(); + testSceneFindGameObject(); testSceneRoundTrip(); testEventMutation(); testEventRemoval();