Files
Destrum/tests/gameplay_tests.cpp

326 lines
15 KiB
C++

#include <filesystem>
#include <fstream>
#include <string>
#include <destrum/ObjectModel/Component.h>
#include <destrum/ObjectModel/GameObject.h>
#include <destrum/Scene/Scene.h>
#include <destrum/Scene/SceneManager.h>
#include <destrum/Serialization/ComponentFactory.h>
#include <destrum/Serialization/GlmJson.h>
#include <destrum/Serialization/PrefabCache.h>
#include <destrum/Serialization/PrefabSerializer.h>
#include <destrum/Serialization/SceneSerializer.h>
#include <destrum/Components/Physics/CapsuleCollider.h>
#include <destrum/Components/Physics/Collider.h>
#include <destrum/Components/Physics/Rigidbody.h>
#include <components/Activatable.h>
#include <components/InteractableButton.h>
#include <components/Checkpoint.h>
#include <components/PlayerRespawn.h>
#include <components/WinGoal.h>
#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>("InteractableButton");
ComponentFactory::Register<TestActivatable>("TestActivatable");
Scene& scene = SceneManager::GetInstance().CreateScene("button targets");
GameObject* door = scene.CreateGameObject("door");
door->AddComponent<TestActivatable>();
GameObject* lamp = scene.CreateGameObject("lamp");
lamp->AddComponent<TestActivatable>();
GameObject* buttonObject = scene.CreateGameObject("button");
auto* button = buttonObject->AddComponent<InteractableButton>();
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, &copy}) {
loaded->CommitPendingAdditions();
GameObject* loadedButton = findByName(*loaded, "button");
auto* loadedComponent = loadedButton != nullptr ? loadedButton->GetComponent<InteractableButton>() : 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<TestActivatable>()->activations == 1 &&
loadedLamp->GetComponent<TestActivatable>()->activations == 1,
"pressing a loaded button must activate its targets");
}
SceneManager::GetInstance().Destroy();
}
void testButtonDropsMissingTargets()
{
ComponentFactory::Register<InteractableButton>("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<InteractableButton>();
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<TestActivatable>();
auto* button = turret->AddComponent<InteractableButton>();
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>("InteractableButton");
ComponentFactory::Register<TestActivatable>("TestActivatable");
Scene& source = SceneManager::GetInstance().CreateScene("prefab source");
GameObject* door = source.CreateGameObject("door");
door->AddComponent<TestActivatable>();
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<InteractableButton>();
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>("InteractableButton");
ComponentFactory::Register<TestActivatable>("TestActivatable");
Scene& scene = SceneManager::GetInstance().CreateScene("duplicate");
GameObject* door = scene.CreateGameObject("door");
door->AddComponent<TestActivatable>();
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<InteractableButton>();
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>("InteractableButton");
ComponentFactory::Register<WinGoal>("WinGoal");
Scene& scene = SceneManager::GetInstance().CreateScene("win goal");
GameObject* goal = scene.CreateGameObject("goal");
auto* winGoal = goal->AddComponent<WinGoal>();
GameObject* buttonObject = scene.CreateGameObject("button");
auto* button = buttonObject->AddComponent<InteractableButton>();
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<WinGoal>()->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<CapsuleCollider>(0.4f, 1.8f);
auto* rigidbody = player->AddComponent<Rigidbody>();
auto* respawn = player->AddComponent<PlayerRespawn>();
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>();
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();
}