#include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include "TestUtils.h" using namespace test; namespace { class TestActivatable final : public Activatable { public: explicit TestActivatable(GameObject& owner) : Activatable(owner, "TestActivatable") {} std::string GetTypeName() const override { return "TestActivatable"; } int activations{0}; protected: void OnActivate(GameObject&) override { ++activations; } }; void testButtonTargetsRoundTrip() { ComponentFactory::Register("InteractableButton"); ComponentFactory::Register("TestActivatable"); Scene& scene = SceneManager::GetInstance().CreateScene("button targets"); GameObject* door = scene.CreateGameObject("door"); door->AddComponent(); GameObject* lamp = scene.CreateGameObject("lamp"); lamp->AddComponent(); GameObject* buttonObject = scene.CreateGameObject("button"); auto* button = buttonObject->AddComponent(); button->SetCycleColors(false); button->AddTarget(*door); button->AddTarget(*lamp); button->AddTarget(*door); scene.CommitPendingAdditions(); check(button->GetTargets().size() == 2, "a button must not target the same object twice"); const auto path = std::filesystem::temp_directory_path() / "destrum_button_targets_scene.json"; check(SceneSerializer::Save(scene, path), "the button scene must save"); check(SceneSerializer::Load(scene, path), "the button scene must load"); Scene& copy = SceneManager::GetInstance().CreateScene("button targets copy"); check(SceneSerializer::Load(copy, path), "the button scene must load into a second scene"); std::filesystem::remove(path); for (Scene* loaded : {&scene, ©}) { loaded->CommitPendingAdditions(); GameObject* loadedButton = findByName(*loaded, "button"); auto* loadedComponent = loadedButton != nullptr ? loadedButton->GetComponent() : nullptr; check(loadedComponent != nullptr, "the button must survive a round-trip"); const auto& targets = loadedComponent->GetTargets(); check(targets.size() == 2, "button targets must survive a round-trip"); GameObject* loadedDoor = targets[0].Get(*loaded); GameObject* loadedLamp = targets[1].Get(*loaded); check(loadedDoor == findByName(*loaded, "door") && loadedDoor != nullptr && loadedLamp == findByName(*loaded, "lamp") && loadedLamp != nullptr, "button targets must point at the loaded objects in their own scene"); loadedComponent->Interact(*loadedButton); check(loadedDoor->GetComponent()->activations == 1 && loadedLamp->GetComponent()->activations == 1, "pressing a loaded button must activate its targets"); } SceneManager::GetInstance().Destroy(); } void testButtonDropsMissingTargets() { ComponentFactory::Register("InteractableButton"); Scene& scene = SceneManager::GetInstance().CreateScene("missing targets"); const auto path = std::filesystem::temp_directory_path() / "destrum_missing_targets_scene.json"; std::ofstream{path} << R"({"version":2,"objects":[{"id":5,"name":"button","components":)" R"([{"type":"InteractableButton","data":{"cycleColors":false,"targets":[5,99]}}]}]})"; check(SceneSerializer::Load(scene, path), "a button with a dangling target must still load"); std::filesystem::remove(path); scene.CommitPendingAdditions(); const GameObject* object = scene.GetObjects().front().get(); const auto* button = object->GetComponent(); check(button != nullptr && button->GetTargets().size() == 1 && button->GetTargets().front().GetId() == object->GetId(), "dangling targets must be dropped and valid ones remapped"); SceneManager::GetInstance().Destroy(); } GameObject* buildTurret(Scene& scene, const GameObject& door) { GameObject* turret = scene.CreateGameObject("turret"); turret->GetTransform().SetLocalPosition(glm::vec3{5.0f, 0.0f, 0.0f}); GameObject* barrel = scene.CreateGameObject("barrel"); barrel->GetTransform().SetParent(&turret->GetTransform(), false); barrel->GetTransform().SetLocalPosition(glm::vec3{0.0f, 1.0f, 0.0f}); barrel->AddComponent(); auto* button = turret->AddComponent(); button->SetCycleColors(false); button->AddTarget(*barrel); button->AddTarget(door); return turret; } GameObject* onlyChild(GameObject& object) { const auto& children = object.GetTransform().GetChildren(); return children.size() == 1 ? children.front()->GetOwner() : nullptr; } void testPrefabRoundTrip() { ComponentFactory::Register("InteractableButton"); ComponentFactory::Register("TestActivatable"); Scene& source = SceneManager::GetInstance().CreateScene("prefab source"); GameObject* door = source.CreateGameObject("door"); door->AddComponent(); GameObject* turret = buildTurret(source, *door); source.CommitPendingAdditions(); const auto path = std::filesystem::temp_directory_path() / "destrum_turret.prefab"; check(PrefabSerializer::Save(*turret, path), "a hierarchy must save as a prefab"); Scene& target = SceneManager::GetInstance().CreateScene("prefab target"); GameObject* holder = target.CreateGameObject("holder"); GameObject* first = PrefabSerializer::Instantiate(target, path.string()); GameObject* second = PrefabSerializer::Instantiate(target, path.string(), &holder->GetTransform()); target.CommitPendingAdditions(); check(first != nullptr && second != nullptr, "a saved prefab must instantiate"); check(target.GetObjects().size() == 5, "each instance must create the root and its child"); check(first != second && first->GetId() != turret->GetId(), "instances must get fresh objects"); check(first->GetName() == "turret" && first->GetTransform().GetParent() == nullptr, "an instance without a parent must be a scene root"); check(second->GetTransform().GetParent() == &holder->GetTransform(), "an instance must be parented to the given transform"); check(close(first->GetTransform().GetLocalPosition().x, 5.0f), "the root transform must come from the prefab"); for (GameObject* instance : {first, second}) { GameObject* barrel = onlyChild(*instance); check(barrel != nullptr && barrel->GetName() == "barrel" && close(barrel->GetTransform().GetLocalPosition().y, 1.0f), "prefab children must keep their local transform"); const auto* button = instance->GetComponent(); check(button != nullptr && button->GetTargets().size() == 1 && button->GetTargets().front().Get(target) == barrel, "references inside the prefab must point at the instance's own objects, outside ones are dropped"); } check(PrefabCache::GetInstance().Get(path.string()) == PrefabCache::GetInstance().Get(path.string()), "a cached prefab must be parsed once"); const auto before = PrefabCache::GetInstance().Get(path.string()); turret->SetName("tower"); check(PrefabSerializer::Save(*turret, path), "a prefab must save over an existing file"); const auto after = PrefabCache::GetInstance().Get(path.string()); check(after != before && after->at("name") == "tower", "saving a prefab must refresh the cache"); std::filesystem::remove(path); check(PrefabSerializer::Instantiate(target, path.string()) == nullptr, "instantiating a missing prefab must fail"); PrefabCache::GetInstance().Clear(); SceneManager::GetInstance().Destroy(); } void testPrefabDuplicate() { ComponentFactory::Register("InteractableButton"); ComponentFactory::Register("TestActivatable"); Scene& scene = SceneManager::GetInstance().CreateScene("duplicate"); GameObject* door = scene.CreateGameObject("door"); door->AddComponent(); GameObject* holder = scene.CreateGameObject("holder"); GameObject* turret = buildTurret(scene, *door); turret->GetTransform().SetParent(&holder->GetTransform(), false); GameObject* copy = PrefabSerializer::Duplicate(*turret); scene.CommitPendingAdditions(); check(copy != nullptr && copy != turret && copy->GetName() == "turret", "duplicate must create a new object"); check(copy->GetTransform().GetParent() == &holder->GetTransform(), "a duplicate must keep the original's parent"); GameObject* barrel = onlyChild(*copy); check(barrel != nullptr && barrel != onlyChild(*turret), "a duplicate must copy children"); const auto* button = copy->GetComponent(); check(button != nullptr && button->GetTargets().size() == 2 && button->GetTargets()[0].Get(scene) == barrel && button->GetTargets()[1].Get(scene) == door, "a duplicate must remap inner references and keep outer ones"); SceneManager::GetInstance().Destroy(); } void testPrefabValidation() { using nlohmann::json; check(PrefabSerializer::Validate(json::object()).has_value(), "an empty prefab must be rejected"); const json twoRoots = json::parse(R"({"version":1,"objects":[{"id":1},{"id":2}]})"); check(PrefabSerializer::Validate(twoRoots).has_value(), "a prefab with two roots must be rejected"); const json childFirst = json::parse(R"({"version":1,"objects":[{"id":2,"parent":1},{"id":1}]})"); check(PrefabSerializer::Validate(childFirst).has_value(), "the prefab root must come first"); const json valid = json::parse(R"({"version":1,"objects":[{"id":1},{"id":2,"parent":1}]})"); check(!PrefabSerializer::Validate(valid).has_value(), "a single-root prefab must validate"); 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 RunGameplayTests() { testButtonTargetsRoundTrip(); testButtonDropsMissingTargets(); testPrefabRoundTrip(); testPrefabDuplicate(); testPrefabValidation(); testWinGoalButton(); testCheckpointRespawn(); }