From 3a04c1884ac27bd6d1d03c33055a59be813d65d1 Mon Sep 17 00:00:00 2001 From: Bram Verhulst Date: Mon, 19 Oct 2026 00:45:29 +0200 Subject: [PATCH] feat: split tests into per-suite files --- tests/CMakeLists.txt | 14 +- tests/TestUtils.h | 43 + tests/assets_tests.cpp | 463 +++++++ tests/core_tests.cpp | 283 +++++ tests/destrum_tests.cpp | 2126 --------------------------------- tests/gameplay_tests.cpp | 325 +++++ tests/graphics_tests.cpp | 362 ++++++ tests/main.cpp | 49 + tests/physics_tests.cpp | 372 ++++++ tests/serialization_tests.cpp | 298 +++++ tests/ui_tests.cpp | 218 ++++ 11 files changed, 2425 insertions(+), 2128 deletions(-) create mode 100644 tests/TestUtils.h create mode 100644 tests/assets_tests.cpp create mode 100644 tests/core_tests.cpp delete mode 100644 tests/destrum_tests.cpp create mode 100644 tests/gameplay_tests.cpp create mode 100644 tests/graphics_tests.cpp create mode 100644 tests/main.cpp create mode 100644 tests/physics_tests.cpp create mode 100644 tests/serialization_tests.cpp create mode 100644 tests/ui_tests.cpp diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index 7db0349..1806873 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -1,5 +1,13 @@ add_executable(destrum_tests - destrum_tests.cpp + main.cpp + TestUtils.h + core_tests.cpp + serialization_tests.cpp + physics_tests.cpp + assets_tests.cpp + graphics_tests.cpp + ui_tests.cpp + gameplay_tests.cpp ${CMAKE_SOURCE_DIR}/lightkeeper/src/components/Activatable.cpp ${CMAKE_SOURCE_DIR}/lightkeeper/src/components/Interactable.cpp ${CMAKE_SOURCE_DIR}/lightkeeper/src/components/InteractableButton.cpp @@ -26,4 +34,6 @@ target_link_libraries(destrum_tests PRIVATE destrum::destrum) destrum_enable_warnings(destrum_tests) -add_test(NAME destrum_tests COMMAND destrum_tests) +foreach(suite core serialization physics assets graphics ui gameplay) + add_test(NAME destrum_tests.${suite} COMMAND destrum_tests ${suite}) +endforeach() diff --git a/tests/TestUtils.h b/tests/TestUtils.h new file mode 100644 index 0000000..20bac7c --- /dev/null +++ b/tests/TestUtils.h @@ -0,0 +1,43 @@ +#pragma once + +#include +#include +#include +#include +#include +#include +#include + +#include +#include + +namespace test { + inline void check(bool condition, const char* message) + { + if (!condition) { + std::cerr << "FAILED: " << message << '\n'; + std::exit(EXIT_FAILURE); + } + } + + inline bool close(float a, float b) + { + return std::abs(a - b) < 0.001f; + } + + inline std::string readFile(const std::filesystem::path& path) + { + std::ifstream file(path, std::ios::binary); + return {std::istreambuf_iterator(file), std::istreambuf_iterator()}; + } + + inline GameObject* findByName(const Scene& scene, const std::string& name) + { + for (const auto& object : scene.GetObjects()) { + if (object->GetName() == name) { + return object.get(); + } + } + return nullptr; + } +} diff --git a/tests/assets_tests.cpp b/tests/assets_tests.cpp new file mode 100644 index 0000000..6f6e198 --- /dev/null +++ b/tests/assets_tests.cpp @@ -0,0 +1,463 @@ +#include +#include +#include +#include +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "TestUtils.h" + +using namespace test; + +namespace { + void testAssetPathValidation() + { + const auto root = std::filesystem::temp_directory_path() / "destrum_asset_test"; + std::filesystem::create_directories(root / "assets" / "engine"); + std::filesystem::create_directories(root / "assets" / "game"); + std::ofstream{root / "assets" / "engine" / "asset.txt"} << "asset"; + std::ofstream{root / "assets" / "engine" / "cooked.asset"} << "cooked"; + std::ofstream{root / "assets" / "engine" / "manifest.json"} + << R"({"version":1,"assets":[{"src":"asset.txt","out":"cooked.asset","type":"raw","mtime_epoch_ns":-5020137004799419216,"size_bytes":355972}]})"; + + auto& assetFs = AssetFS::GetInstance(); + assetFs.Init(root); + check(std::filesystem::exists(assetFs.GetFullPath("engine://asset.txt")), + "valid virtual asset paths must resolve"); + check(assetFs.GetCookedPathForFile("engine://asset.txt").filename() == "cooked.asset", + "manifest output paths must resolve to cooked assets"); + + bool rejected = false; + try { + (void)assetFs.GetFullPath("engine://../outside.txt"); + } catch (const std::exception&) { + rejected = true; + } + check(rejected, "asset traversal must be rejected"); + + const auto outside = root / "outside"; + const auto link = root / "assets" / "engine" / "link"; + std::filesystem::create_directories(outside); + std::error_code symlinkError; + std::filesystem::create_directory_symlink(outside, link, symlinkError); + if (!symlinkError) { + bool symlinkEscapeRejected = false; + try { + (void)assetFs.GetFullPath("engine://link/missing.txt"); + } catch (const std::exception&) { + symlinkEscapeRejected = true; + } + check(symlinkEscapeRejected, + "symlink traversal must be rejected for missing targets"); + } + + std::filesystem::remove_all(root); + assetFs.Reset(); + } + + void testAnimatorAssetReferences() + { + GameObject object{"animated"}; + auto* animator = object.AddComponent(); + + Skeleton skeleton; + Joint joint; + joint.id = 0; + joint.localTranslation = glm::vec3{0.0f}; + joint.localRotation = glm::identity(); + joint.localScale = glm::vec3{1.0f}; + skeleton.joints.push_back(joint); + skeleton.inverseBindMatrices.push_back(glm::mat4{1.0f}); + skeleton.jointNames.push_back("root"); + skeleton.assetReference.path = "engine://character.fbx"; + buildParentIndex(skeleton); + animator->setSkeleton(std::move(skeleton)); + + auto clip = std::make_shared(); + clip->name = "walk"; + clip->assetReference.path = "engine://walk.fbx"; + clip->assetReference.subresource = "animation:0"; + clip->duration = 1.0f; + SkeletalAnimation::Keyframe keyframe; + keyframe.time = 0.0f; + keyframe.translation = glm::vec3{0.0f}; + keyframe.rotation = glm::identity(); + keyframe.scale = glm::vec3{1.0f}; + SkeletalAnimation::Track track; + track.jointIndex = 0; + track.keyframes.push_back(keyframe); + clip->tracks.push_back(std::move(track)); + animator->addClip(std::move(clip)); + animator->play("walk"); + + const auto data = animator->Serialize(); + check(data.contains("assetReferences"), + "Animator scenes must store asset references"); + check(data.at("assetReferences").at("skeleton").at("path") == "engine://character.fbx", + "Animator serialization must store the skeleton source"); + check(data.at("assetReferences").at("animations").at(0).at("asset").at("subresource") == + "animation:0", + "Animator serialization must store the animation subresource"); + check(data.dump().find("keyframes") == std::string::npos, + "Animator scene data must not embed animation keyframes"); + } + + ModelDoc::ModelAsset makeTestModelAsset() + { + Skeleton skeleton; + skeleton.assetReference = {.path = "engine://character.fbx", .subresource = "skeleton"}; + skeleton.hierarchy = {{.id = 0, .children = {1}}, {.id = 1, .children = {}}}; + skeleton.inverseBindMatrices = {glm::mat4{1.0f}, glm::mat4{2.0f}}; + skeleton.joints = { + {.id = 0, .localTranslation = glm::vec3{0.0f, 1.0f, 0.0f}}, + {.id = 1, .localScale = glm::vec3{2.0f}}, + }; + skeleton.jointNames = {"root", "spine"}; + buildParentIndex(skeleton); + + CPUMesh mesh; + mesh.indices = {0, 1, 2}; + mesh.vertices = { + {.position = glm::vec3{0.0f}, .uv_x = 0.25f, .normal = glm::vec3{0.0f, 1.0f, 0.0f}}, + {.position = glm::vec3{1.0f, 0.0f, 0.0f}, .tangent = glm::vec4{1.0f, 0.0f, 0.0f, -1.0f}}, + {.position = glm::vec3{0.0f, 0.0f, 1.0f}, .uv_y = 0.75f}, + }; + mesh.skinningData = { + {.jointIds = glm::uvec4{0, 1, 0, 0}, .weights = glm::vec4{0.5f, 0.5f, 0.0f, 0.0f}}, + {.jointIds = glm::uvec4{1, 0, 0, 0}, .weights = glm::vec4{1.0f, 0.0f, 0.0f, 0.0f}}, + {.jointIds = glm::uvec4{0, 0, 0, 0}, .weights = glm::vec4{1.0f, 0.0f, 0.0f, 0.0f}}, + }; + mesh.name = "body"; + mesh.assetReference = {.path = "engine://character.fbx", .subresource = "mesh:0"}; + mesh.minPos = glm::vec3{0.0f}; + mesh.maxPos = glm::vec3{1.0f}; + mesh.skeleton = skeleton; + + ModelDoc::MeshPrimitive primitive; + primitive.mesh = mesh; + primitive.materialIndex = 0; + primitive.nodeName = "Body"; + primitive.sourceMeshIndex = 3; + primitive.skinned = true; + primitive.nodeWorldTransform = glm::mat4{3.0f}; + + ModelDoc::MaterialInfo material; + material.name = "skin"; + material.assetReference = {.path = "engine://character.fbx", .subresource = "material:0"}; + material.baseColor = glm::vec4{0.8f, 0.6f, 0.4f, 1.0f}; + material.emissiveColor = glm::vec3{0.1f}; + material.metallicFactor = 0.2f; + material.roughnessFactor = 0.9f; + material.opacity = 0.5f; + material.textures = {{ + .semantic = ModelDoc::TextureSemantic::BaseColor, + .rawPath = "skin.png", + .path = "textures/skin.png", + .assimpType = aiTextureType_DIFFUSE, + .slot = 1, + .embedded = false, + }}; + + SkeletalAnimation animation; + animation.assetReference = {.path = "engine://character.fbx", .subresource = "animation:0"}; + animation.tracks = {{ + .jointIndex = 1, + .keyframes = { + {.time = 0.0f, .translation = glm::vec3{0.0f}, .rotation = glm::identity(), .scale = glm::vec3{1.0f}}, + {.time = 0.5f, .translation = glm::vec3{0.0f, 2.0f, 0.0f}, .rotation = glm::quat{0.0f, 1.0f, 0.0f, 0.0f}, .scale = glm::vec3{1.0f}}, + }, + }}; + animation.duration = 0.5f; + animation.looped = false; + animation.name = "wave"; + animation.startFrame = 4; + animation.events = {{2, {"footstep", "dust"}}}; + + ModelDoc::ModelAsset model; + model.sourcePath = "engine://character.fbx"; + model.primitives = {primitive}; + model.materials = {material}; + model.skeleton = skeleton; + model.animations = {animation}; + return model; + } + + bool modelFileReadThrows(const std::vector& bytes) + { + try { + (void)ModelFile::Read(bytes); + } catch (const std::exception&) { + return true; + } + return false; + } + + void testModelFileRoundTrip() + { + const auto bytes = ModelFile::Write(makeTestModelAsset()); + const auto loaded = ModelFile::Read(bytes); + + check(ModelFile::Write(loaded) == bytes, + "a cooked model must write back to exactly the same bytes"); + + check(loaded.sourcePath == "engine://character.fbx", + "cooked models must keep their source path"); + + check(loaded.primitives.size() == 1, "cooked models must keep their primitives"); + const auto& primitive = loaded.primitives.front(); + check(primitive.nodeName == "Body" && primitive.sourceMeshIndex == 3 && primitive.skinned, + "cooked primitives must keep their import metadata"); + check(primitive.mesh.indices == std::vector{0, 1, 2}, + "cooked meshes must keep their indices"); + check(primitive.mesh.vertices.size() == 3 && + primitive.mesh.vertices[0].uv_x == 0.25f && + primitive.mesh.vertices[1].tangent.w == -1.0f, + "cooked meshes must keep their vertices"); + check(primitive.mesh.skinningData[0].jointIds.y == 1 && + primitive.mesh.skinningData[0].weights.x == 0.5f, + "cooked meshes must keep their skinning data"); + check(primitive.mesh.skeleton.has_value() && + primitive.mesh.skeleton->jointNames.size() == 2, + "cooked skinned meshes must keep their skeleton"); + + check(loaded.materials.size() == 1 && loaded.materials.front().opacity == 0.5f, + "cooked models must keep their materials"); + const auto& texture = loaded.materials.front().textures.front(); + check(texture.semantic == ModelDoc::TextureSemantic::BaseColor && + texture.path == "textures/skin.png" && + texture.assimpType == aiTextureType_DIFFUSE && + texture.slot == 1 && + !texture.embedded, + "cooked materials must keep their textures"); + + check(loaded.skeleton.jointNames == std::vector{"root", "spine"} && + loaded.skeleton.parentIndex == std::vector{-1, 0} && + loaded.skeleton.joints[1].localScale == glm::vec3{2.0f}, + "cooked models must keep their skeleton"); + + check(loaded.animations.size() == 1, "cooked models must keep their animations"); + const auto& animation = loaded.animations.front(); + check(animation.name == "wave" && !animation.looped && animation.startFrame == 4, + "cooked animations must keep their settings"); + check(animation.tracks.front().keyframes[1].translation.y == 2.0f && + animation.tracks.front().keyframes[1].rotation.x == 1.0f, + "cooked animations must keep their keyframes"); + check(animation.events.at(2) == std::vector{"footstep", "dust"}, + "cooked animations must keep their events"); + } + + void testModelFileRejectsBadData() + { + const auto bytes = ModelFile::Write(makeTestModelAsset()); + + auto truncated = bytes; + truncated.resize(bytes.size() / 2); + check(modelFileReadThrows(truncated), "truncated cooked models must be rejected"); + + auto wrongMagic = bytes; + wrongMagic[0] = 'X'; + check(modelFileReadThrows(wrongMagic), "files that aren't cooked models must be rejected"); + + auto wrongVersion = bytes; + wrongVersion[4] = static_cast(wrongVersion[4] + 1); + check(modelFileReadThrows(wrongVersion), "cooked models from another version must be rejected"); + + auto trailingData = bytes; + trailingData.push_back(0); + check(modelFileReadThrows(trailingData), "cooked models with trailing data must be rejected"); + } + + bool loadModelThrows(std::string_view reference, const ModelDoc::LoadOptions& options) + { + try { + (void)AssetManager::GetInstance().LoadModel(reference, options); + } catch (const std::exception&) { + return true; + } + return false; + } + + void testModelFileFromImportedModel() + { + const auto source = std::filesystem::path{DESTRUM_ENGINE_ASSETS_SRC_DIR} / "sphere.fbx"; + const auto imported = ModelDoc::LoadModel(source.generic_string()); + + const auto path = std::filesystem::temp_directory_path() / "destrum_model_test.dmodel"; + ModelFile::WriteToFile(imported, path); + const auto loaded = ModelFile::ReadFromFile(path); + std::filesystem::remove(path); + + check(!imported.primitives.empty(), "the test model must import with at least one primitive"); + check(loaded.primitives.size() == imported.primitives.size() && + loaded.primitives.front().mesh.vertices.size() == imported.primitives.front().mesh.vertices.size() && + loaded.primitives.front().mesh.indices == imported.primitives.front().mesh.indices, + "an imported model must survive being cooked"); + check(ModelFile::Write(loaded) == ModelFile::Write(imported), + "a cooked imported model must match the import exactly"); + } + + void testAssetManagerLoadsCookedModels() + { + const auto root = std::filesystem::temp_directory_path() / "destrum_cooked_model_test"; + const auto engineDir = root / "assets" / "engine"; + std::filesystem::remove_all(root); + std::filesystem::create_directories(engineDir); + std::filesystem::create_directories(root / "assets" / "game"); + + // The source can't be imported, so a successful load has to come from the cooked file + std::ofstream{engineDir / "model.fbx"} << "not a model"; + ModelFile::WriteToFile(makeTestModelAsset(), engineDir / "model.fbx.dmodel"); + std::ofstream{engineDir / "manifest.json"} + << R"({"version":1,"assets":[{"src":"model.fbx","out":"model.fbx.dmodel","type":"model","mtime_epoch_ns":0,"size_bytes":0}]})"; + + auto& assetFs = AssetFS::GetInstance(); + assetFs.Init(root); + + const auto model = AssetManager::GetInstance().LoadModel("engine://model.fbx"); + check(model.primitives.size() == 1 && model.animations.size() == 1, + "cooked models must be loaded instead of importing the source"); + check(model.sourcePath == "engine://model.fbx" && + model.primitives.front().mesh.assetReference.path == "engine://model.fbx" && + model.primitives.front().mesh.skeleton->assetReference.path == "engine://model.fbx", + "cooked models must be referenced by their virtual path"); + check(model.materials.front().textures.front().path == + std::filesystem::weakly_canonical(engineDir / "textures/skin.png").generic_string(), + "cooked texture paths must be resolved next to the cooked model"); + + ModelDoc::LoadOptions withoutExtras{}; + withoutExtras.loadMaterials = false; + withoutExtras.loadAnimations = false; + const auto stripped = AssetManager::GetInstance().LoadModel("engine://model.fbx", withoutExtras); + check(stripped.materials.empty() && + stripped.animations.empty() && + stripped.primitives.front().materialIndex == ModelDoc::InvalidIndex, + "cooked models must drop what the load options didn't ask for"); + + ModelDoc::LoadOptions withoutSkeleton{}; + withoutSkeleton.loadSkeleton = false; + check(loadModelThrows("engine://model.fbx", withoutSkeleton), + "skinned cooked models must be imported when loaded without a skeleton"); + + ModelDoc::LoadOptions otherImport{}; + otherImport.flipUVs = false; + check(loadModelThrows("engine://model.fbx", otherImport), + "cooked models must be imported when the import options differ from the cook options"); + + assetFs.Reset(); + std::filesystem::remove_all(root); + } + + TextureFile::Texture makeTestTexture() + { + const std::vector rgba{ + 0.0f, 0.25f, 0.5f, 1.0f, + 2.0f, 16.0f, 100.0f, 1.0f, + }; + return TextureFile::FromRGBA32Float(2, 1, rgba); + } + + bool textureFileReadThrows(const std::vector& bytes) + { + try { + (void)TextureFile::Read(bytes); + } catch (const std::exception&) { + return true; + } + return false; + } + + void testTextureFileRoundTrip() + { + const auto texture = makeTestTexture(); + check(texture.pixels.size() == 2 * TextureFile::BytesPerPixel(TextureFile::PixelFormat::RGBA16Float), + "cooked textures must store every pixel"); + + std::uint64_t secondPixel = 0; + std::memcpy(&secondPixel, texture.pixels.data() + 8, sizeof(secondPixel)); + const glm::vec4 unpacked = glm::unpackHalf4x16(secondPixel); + check(unpacked == glm::vec4{2.0f, 16.0f, 100.0f, 1.0f}, + "half float conversion must keep HDR values above 1"); + + const auto bytes = TextureFile::Write(texture); + const auto loaded = TextureFile::Read(bytes); + check(loaded.width == 2 && loaded.height == 1 && + loaded.format == TextureFile::PixelFormat::RGBA16Float && + loaded.pixels == texture.pixels, + "cooked textures must round-trip"); + + auto truncated = bytes; + truncated.resize(bytes.size() - 1); + check(textureFileReadThrows(truncated), "truncated cooked textures must be rejected"); + + auto wrongMagic = bytes; + wrongMagic[0] = 'X'; + check(textureFileReadThrows(wrongMagic), "files that aren't cooked textures must be rejected"); + + auto wrongSize = bytes; + wrongSize[8] = 3; + check(textureFileReadThrows(wrongSize), "cooked textures whose size doesn't match their pixels must be rejected"); + } + + void testImageLoaderReadsCookedTextures() + { + const auto path = std::filesystem::temp_directory_path() / "destrum_texture_test.dtex"; + const auto texture = makeTestTexture(); + TextureFile::WriteToFile(texture, path); + + const auto image = util::loadImage(path, TextureIntent::ColorSrgb); + std::filesystem::remove(path); + + check(image.vkFormat == VK_FORMAT_R16G16B16A16_SFLOAT && + image.width == 2 && image.height == 1 && + image.byteSize == texture.pixels.size(), + "cooked textures must load as half float images"); + check(image.pixels != nullptr && + std::memcmp(image.pixels, texture.pixels.data(), texture.pixels.size()) == 0, + "cooked texture pixels must be uploaded as stored"); + } + + void testAssetReferenceCacheKeys() + { + const AssetReference reference{ + .path = "game://CharacterMedium.fbx", + .subresource = "mesh:0", + }; + + const auto key = reference.cacheKey(); + check(key == "game://CharacterMedium.fbx#mesh:0", + "asset references must produce stable cache keys"); + + const auto parsed = AssetReference::fromCacheKey(key); + check(parsed.has_value() && + parsed->path == reference.path && + parsed->subresource == reference.subresource, + "asset cache keys must recover their source reference"); + check(!AssetReference::fromCacheKey("CharacterMedium.fbx").has_value(), + "plain cache names must not be treated as file-backed assets"); + } +} + +void RunAssetsTests() +{ + testAssetPathValidation(); + testAnimatorAssetReferences(); + testModelFileRoundTrip(); + testModelFileRejectsBadData(); + testModelFileFromImportedModel(); + testAssetManagerLoadsCookedModels(); + testTextureFileRoundTrip(); + testImageLoaderReadsCookedTextures(); + testAssetReferenceCacheKeys(); +} diff --git a/tests/core_tests.cpp b/tests/core_tests.cpp new file mode 100644 index 0000000..4dc9da7 --- /dev/null +++ b/tests/core_tests.cpp @@ -0,0 +1,283 @@ +#include +#include +#include + +#include +#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 TestComponent final : public Component { + public: + explicit TestComponent(GameObject& owner) : Component(owner, "TestComponent") {} + + void Update(float) override {} + std::string GetTypeName() const override { return "TestComponent"; } + }; + + class RemovingListener final : public EventListener { + public: + explicit RemovingListener(std::unique_ptr* victim) + : victim(victim) {} + + void OnEvent(int) { + if (victim != nullptr) { + victim->reset(); + victim = nullptr; + } + ++calls; + } + + int calls{0}; + + private: + std::unique_ptr* victim; + }; + + void testTransforms() + { + GameObject parent{"parent"}; + GameObject child{"child"}; + + parent.GetTransform().SetLocalPosition({10.f, 2.f, -4.f}); + parent.GetTransform().SetLocalRotation({0.f, 0.f, 90.f}); + parent.GetTransform().SetLocalScale({2.f, 2.f, 2.f}); + child.GetTransform().SetLocalPosition({1.f, 0.f, 0.f}); + + child.GetTransform().SetParent(&parent.GetTransform(), true); + const auto worldPosition = child.GetTransform().GetWorldPosition(); + check(close(worldPosition.x, 1.f) && close(worldPosition.y, 0.f), + "reparenting must preserve world position"); + + child.GetTransform().SetWorldPosition({20.f, 10.f, 0.f}); + const auto movedPosition = child.GetTransform().GetWorldPosition(); + check(close(movedPosition.x, 20.f) && close(movedPosition.y, 10.f), + "world position must account for parent rotation and scale"); + + parent.Destroy(); + check(parent.IsBeingDestroyed() && child.IsBeingDestroyed(), + "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 testEventMutation() + { + Event event; + int calls = 0; + event.AddListener([&](int) { ++calls; }); + event.AddListener([&](int) { + ++calls; + event.AddListener([&](int) { ++calls; }); + }); + + event.Invoke(1); + check(calls == 2, "event mutation must not invalidate the current invocation"); + event.Invoke(1); + check(calls == 5, "new event listeners must run on subsequent invocations"); + } + + void testEventRemoval() + { + Event event; + std::unique_ptr victim; + RemovingListener remover{&victim}; + victim = std::make_unique(nullptr); + + event.AddListener(&remover, &RemovingListener::OnEvent); + event.AddListener(victim.get(), &RemovingListener::OnEvent); + event.Invoke(1); + + check(remover.calls == 1, "event remover must run once"); + check(victim == nullptr, "event listener must be removable during invocation"); + } + + void testComponentRemoval() + { + Scene& scene = SceneManager::GetInstance().CreateScene("component-removal"); + GameObject* object = scene.CreateGameObject("object"); + object->AddComponent(); + scene.CommitPendingAdditions(); + + check(object->GetComponent() != nullptr, + "test component must be discoverable before removal"); + check(object->DestroyComponent() != nullptr, + "destroying a component must find it"); + scene.CleanupDestroyedGameObjects(); + check(object->GetComponent() == nullptr, + "destroyed components must be removed at the scene boundary"); + + SceneManager::GetInstance().Destroy(); + } + + void testEngineComponentRegistration() + { + RegisterEngineComponents(); + + const char* canonicalNames[] = { + "MeshRendererComponent", + "LightComponent", + "Rotator", + "Spinner", + "OrbitAndSpin", + "Animator", + "Rigidbody", + "BoxCollider", + "SphereCollider", + "CapsuleCollider", + "Canvas", + "RectTransform", + "UIImage", + "UIText" + }; + for (const char* name : canonicalNames) { + check(ComponentFactory::IsRegistered(name), + "all canonical engine component names must be registered"); + } + + check(!ComponentFactory::IsRegistered("MeshRenderer"), + "MeshRenderer legacy alias must not be registered"); + check(!ComponentFactory::IsRegistered("RigidBody"), + "RigidBody legacy alias must not be registered"); + } + + void testSceneCurrentCamera() + { + Scene& scene = SceneManager::GetInstance().CreateScene("cameras"); + check(scene.GetCurrentCamera() == nullptr, "a scene without cameras must have no current camera"); + + GameObject* first = scene.CreateGameObject("first camera"); + auto* firstCamera = first->AddComponent(); + scene.CommitPendingAdditions(); + check(scene.GetCurrentCamera() == firstCamera, "the only camera in a scene must be the current camera"); + + GameObject* second = scene.CreateGameObject("second camera"); + auto* secondCamera = second->AddComponent(); + secondCamera->SetFieldOfView(60.0f); + second->GetTransform().SetWorldPosition({1.0f, 2.0f, 3.0f}); + scene.CommitPendingAdditions(); + secondCamera->MakeCurrent(); + check(scene.GetCurrentCamera() == secondCamera && !firstCamera->IsCurrent(), + "the selected camera must be the current camera"); + + const Camera& camera = secondCamera->GetCamera(16.0f / 9.0f); + check(camera.GetPosition() == glm::vec3{1.0f, 2.0f, 3.0f} && + close(camera.GetForward().z, -1.0f) && camera.GetFOVY() == 60.0f, + "a camera component must place its camera at its transform, looking down -Z"); + + const auto path = std::filesystem::temp_directory_path() / "destrum_camera_scene_test.json"; + check(SceneSerializer::Save(scene, path), "scene with cameras must save"); + check(SceneSerializer::Load(scene, path), "scene with cameras must load"); + std::filesystem::remove(path); + + const CameraComponent* loadedCamera = scene.GetCurrentCamera(); + check(loadedCamera != nullptr && loadedCamera->GetGameObject()->GetName() == "second camera" && + loadedCamera->GetFieldOfView() == 60.0f, + "scene round-trip must preserve the current camera"); + + scene.GetCurrentCamera()->GetGameObject()->SetActive(false); + check(scene.GetCurrentCamera() != nullptr && + scene.GetCurrentCamera()->GetGameObject()->GetName() == "first camera", + "an inactive current camera must fall back to another camera"); + + 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(); + } +} + +void RunCoreTests() +{ + testTransforms(); + testSceneFindGameObject(); + testEventMutation(); + testEventRemoval(); + testComponentRemoval(); + testEngineComponentRegistration(); + testSceneCurrentCamera(); + testHierarchyMoves(); +} diff --git a/tests/destrum_tests.cpp b/tests/destrum_tests.cpp deleted file mode 100644 index e5a68ff..0000000 --- a/tests/destrum_tests.cpp +++ /dev/null @@ -1,2126 +0,0 @@ -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include - -#include -#include - -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include - -#include -#include -#include -#include -#include -#include -#include -#include - -namespace { - class TestComponent final : public Component { - public: - explicit TestComponent(GameObject& owner) : Component(owner, "TestComponent") {} - - void Update(float) override {} - std::string GetTypeName() const override { return "TestComponent"; } - }; - - 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; } - }; - - class RemovingListener final : public EventListener { - public: - explicit RemovingListener(std::unique_ptr* victim) - : victim(victim) {} - - void OnEvent(int) { - if (victim != nullptr) { - victim->reset(); - victim = nullptr; - } - ++calls; - } - - int calls{0}; - - private: - std::unique_ptr* victim; - }; - - void check(bool condition, const char* message) - { - if (!condition) { - std::cerr << "FAILED: " << message << '\n'; - std::exit(EXIT_FAILURE); - } - } - - bool close(float a, float b) - { - return std::abs(a - b) < 0.001f; - } - - void testTransforms() - { - GameObject parent{"parent"}; - GameObject child{"child"}; - - parent.GetTransform().SetLocalPosition({10.f, 2.f, -4.f}); - parent.GetTransform().SetLocalRotation({0.f, 0.f, 90.f}); - parent.GetTransform().SetLocalScale({2.f, 2.f, 2.f}); - child.GetTransform().SetLocalPosition({1.f, 0.f, 0.f}); - - child.GetTransform().SetParent(&parent.GetTransform(), true); - const auto worldPosition = child.GetTransform().GetWorldPosition(); - check(close(worldPosition.x, 1.f) && close(worldPosition.y, 0.f), - "reparenting must preserve world position"); - - child.GetTransform().SetWorldPosition({20.f, 10.f, 0.f}); - const auto movedPosition = child.GetTransform().GetWorldPosition(); - check(close(movedPosition.x, 20.f) && close(movedPosition.y, 10.f), - "world position must account for parent rotation and scale"); - - parent.Destroy(); - check(parent.IsBeingDestroyed() && child.IsBeingDestroyed(), - "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"); - GameObject* parent = scene.CreateGameObject("parent"); - GameObject* child = scene.CreateGameObject("child"); - child->GetTransform().SetLocalPosition({1.f, 2.f, 3.f}); - child->GetTransform().SetParent(&parent->GetTransform(), false); - scene.CommitPendingAdditions(); - - SceneEnvironment& environment = scene.GetEnvironment(); - environment.ambientColor = {0.2f, 0.3f, 0.4f}; - environment.ambientIntensity = 2.0f; - environment.fogColor = {0.6f, 0.7f, 0.8f}; - environment.fogDensity = 0.05f; - environment.skyboxRotationSpeed = 0.5f; - environment.skyboxRotation = 1.0f; - scene.Update(1.0f); - check(close(environment.skyboxRotation, 1.5f), "scene updates must spin the skybox"); - - const auto path = std::filesystem::temp_directory_path() / "destrum_scene_test.json"; - check(SceneSerializer::Save(scene, path), "scene save must succeed"); - check(SceneSerializer::Load(scene, path), "scene load must succeed"); - std::filesystem::remove(path); - - check(scene.GetObjects().size() == 2, "scene round-trip must preserve objects"); - const auto* loadedChild = scene.GetObjects().at(1).get(); - check(loadedChild->GetTransform().GetParent() != nullptr, - "scene round-trip must preserve hierarchy"); - check(close(loadedChild->GetTransform().GetLocalPosition().x, 1.f), - "scene round-trip must preserve local transforms"); - const SceneEnvironment& loadedEnvironment = scene.GetEnvironment(); - check(loadedEnvironment.ambientColor == glm::vec3{0.2f, 0.3f, 0.4f} && - close(loadedEnvironment.ambientIntensity, 2.0f) && - loadedEnvironment.fogColor == glm::vec3{0.6f, 0.7f, 0.8f} && - close(loadedEnvironment.fogDensity, 0.05f) && - close(loadedEnvironment.skyboxRotationSpeed, 0.5f) && - close(loadedEnvironment.skyboxRotation, 1.5f), - "scene round-trip must preserve the environment settings"); - - SceneManager::GetInstance().Destroy(); - } - - void testEventMutation() - { - Event event; - int calls = 0; - event.AddListener([&](int) { ++calls; }); - event.AddListener([&](int) { - ++calls; - event.AddListener([&](int) { ++calls; }); - }); - - event.Invoke(1); - check(calls == 2, "event mutation must not invalidate the current invocation"); - event.Invoke(1); - check(calls == 5, "new event listeners must run on subsequent invocations"); - } - - void testEventRemoval() - { - Event event; - std::unique_ptr victim; - RemovingListener remover{&victim}; - victim = std::make_unique(nullptr); - - event.AddListener(&remover, &RemovingListener::OnEvent); - event.AddListener(victim.get(), &RemovingListener::OnEvent); - event.Invoke(1); - - check(remover.calls == 1, "event remover must run once"); - check(victim == nullptr, "event listener must be removable during invocation"); - } - - void testComponentRemoval() - { - Scene& scene = SceneManager::GetInstance().CreateScene("component-removal"); - GameObject* object = scene.CreateGameObject("object"); - object->AddComponent(); - scene.CommitPendingAdditions(); - - check(object->GetComponent() != nullptr, - "test component must be discoverable before removal"); - check(object->DestroyComponent() != nullptr, - "destroying a component must find it"); - scene.CleanupDestroyedGameObjects(); - check(object->GetComponent() == nullptr, - "destroyed components must be removed at the scene boundary"); - - SceneManager::GetInstance().Destroy(); - } - - void testEngineComponentRegistration() - { - RegisterEngineComponents(); - - const char* canonicalNames[] = { - "MeshRendererComponent", - "LightComponent", - "Rotator", - "Spinner", - "OrbitAndSpin", - "Animator", - "Rigidbody", - "BoxCollider", - "SphereCollider", - "CapsuleCollider", - "Canvas", - "RectTransform", - "UIImage", - "UIText" - }; - for (const char* name : canonicalNames) { - check(ComponentFactory::IsRegistered(name), - "all canonical engine component names must be registered"); - } - - check(!ComponentFactory::IsRegistered("MeshRenderer"), - "MeshRenderer legacy alias must not be registered"); - check(!ComponentFactory::IsRegistered("RigidBody"), - "RigidBody legacy alias must not be registered"); - } - - void testLineRenderingManager() - { - auto& lineManager = LineRenderingManager::GetInstance(); - lineManager.ClearLines(); - - lineManager.SubmitLine( - glm::vec3{0.0f}, - glm::vec3{1.0f, 0.0f, 0.0f}, - glm::vec4{1.0f, 0.0f, 0.0f, 1.0f}); - lineManager.SubmitLine(Line{ - .start = glm::vec3{1.0f}, - .end = glm::vec3{2.0f}, - .color = glm::vec4{0.0f, 1.0f, 0.0f, 1.0f}, - }); - - check(lineManager.GetLines().size() == 2, - "line rendering manager must retain submitted lines"); - - lineManager.ClearLines(); - check(lineManager.GetLines().empty(), - "line rendering manager must clear transient lines"); - } - - void testAssetPathValidation() - { - const auto root = std::filesystem::temp_directory_path() / "destrum_asset_test"; - std::filesystem::create_directories(root / "assets" / "engine"); - std::filesystem::create_directories(root / "assets" / "game"); - std::ofstream{root / "assets" / "engine" / "asset.txt"} << "asset"; - std::ofstream{root / "assets" / "engine" / "cooked.asset"} << "cooked"; - std::ofstream{root / "assets" / "engine" / "manifest.json"} - << R"({"version":1,"assets":[{"src":"asset.txt","out":"cooked.asset","type":"raw","mtime_epoch_ns":-5020137004799419216,"size_bytes":355972}]})"; - - auto& assetFs = AssetFS::GetInstance(); - assetFs.Init(root); - check(std::filesystem::exists(assetFs.GetFullPath("engine://asset.txt")), - "valid virtual asset paths must resolve"); - check(assetFs.GetCookedPathForFile("engine://asset.txt").filename() == "cooked.asset", - "manifest output paths must resolve to cooked assets"); - - bool rejected = false; - try { - (void)assetFs.GetFullPath("engine://../outside.txt"); - } catch (const std::exception&) { - rejected = true; - } - check(rejected, "asset traversal must be rejected"); - - const auto outside = root / "outside"; - const auto link = root / "assets" / "engine" / "link"; - std::filesystem::create_directories(outside); - std::error_code symlinkError; - std::filesystem::create_directory_symlink(outside, link, symlinkError); - if (!symlinkError) { - bool symlinkEscapeRejected = false; - try { - (void)assetFs.GetFullPath("engine://link/missing.txt"); - } catch (const std::exception&) { - symlinkEscapeRejected = true; - } - check(symlinkEscapeRejected, - "symlink traversal must be rejected for missing targets"); - } - - std::filesystem::remove_all(root); - assetFs.Reset(); - } - - void testLegacySceneLoad() - { - Scene& scene = SceneManager::GetInstance().CreateScene("legacy"); - const auto path = std::filesystem::temp_directory_path() / "destrum_legacy_scene.json"; - std::ofstream{path} - << R"({"version":1,"name":"legacy","objects":[{"id":9001,"name":"object","active":true,"transform":{},"parent":0,"components":[]}]})"; - - check(SceneSerializer::Load(scene, path), - "version 1 placeholder transforms must remain loadable"); - check(scene.GetObjects().size() == 1, - "legacy scene load must create its object"); - - std::filesystem::remove(path); - SceneManager::GetInstance().Destroy(); - } - - void testSceneLoadRollback() - { - Scene& scene = SceneManager::GetInstance().CreateScene("rollback"); - scene.CreateGameObject("original"); - scene.CommitPendingAdditions(); - - const auto path = std::filesystem::temp_directory_path() / "destrum_bad_scene.json"; - std::ofstream{path} - << R"({"version":2,"name":"bad","objects":[{"id":9101,"name":"bad","transform":{"position":[0,0,0],"rotation":[0,0,0,1],"scale":[1,1,1]},"parent":0,"components":[{"type":"Rigidbody","enabled":true,"data":{}}]}]})"; - - check(!SceneSerializer::Load(scene, path), - "scene loading must reject a rigidbody without a collider"); - check(scene.GetObjects().size() == 1 && scene.GetObjects().at(0)->GetName() == "original", - "failed scene loading must preserve the live scene"); - - std::filesystem::remove(path); - SceneManager::GetInstance().Destroy(); - } - - void testPhysicsWorldUnits() - { - JoltPhysicsWorld world{{ - .workerThreadCount = 1, - .gravity = glm::vec3{0.0f} - }}; - GameObject object{"scaled-physics"}; - object.GetTransform().SetLocalScale(glm::vec3{0.5f}); - object.AddComponent(1.0f); - Rigidbody* rigidbody = object.AddComponent(); - world.RegisterRigidbody(*rigidbody); - - PhysicsRaycastHit hit; - check(world.Raycast({-2.0f, 0.0f, 0.0f}, {1.0f, 0.0f, 0.0f}, 5.0f, hit), - "scaled physics bodies must remain raycastable"); - check(close(hit.distance, 1.0f), - "physics collider dimensions must remain in world units"); - - object.GetTransform().SetLocalPosition({10.0f, 0.0f, 0.0f}); - world.RefreshRigidbody(*rigidbody); - check(world.Raycast({8.0f, 0.0f, 0.0f}, {1.0f, 0.0f, 0.0f}, 5.0f, hit), - "refreshing a physics body must use the current transform position"); - check(close(hit.distance, 1.0f), - "refreshed physics bodies must remain aligned with rendered transforms"); - } - - void testJoltSphereCollisions() - { - JoltPhysicsWorld world{{ - .workerThreadCount = 1, - .gravity = {0.0f, -9.81f, 0.0f} - }}; - - GameObject floor{"floor"}; - floor.GetTransform().SetLocalPosition({0.0f, -1.0f, 0.0f}); - floor.AddComponent(glm::vec3{10.0f, 0.5f, 10.0f}); - auto* floorBody = floor.AddComponent(); - floorBody->SetType(RigidbodyType::Static); - world.RegisterRigidbody(*floorBody); - - GameObject first{"first"}; - first.AddComponent(1.5f); - auto* firstBody = first.AddComponent(); - world.RegisterRigidbody(*firstBody); - first.GetTransform().SetLocalPosition({0.0f, 100.0f, 0.0f}); - first.GetTransform().SetLocalScale(glm::vec3{0.015f}); - world.RefreshRigidbody(*firstBody); - - GameObject second{"second"}; - second.AddComponent(1.5f); - auto* secondBody = second.AddComponent(); - world.RegisterRigidbody(*secondBody); - second.GetTransform().SetLocalPosition({0.0f, 100.0f, 0.0f}); - second.GetTransform().SetLocalScale(glm::vec3{0.015f}); - world.RefreshRigidbody(*secondBody); - - for (int step = 0; step < 240; ++step) { - world.Step(1.0f / 60.0f); - world.SyncDynamicBodiesToTransforms(); - } - - check(first.GetTransform().GetWorldPosition().y > 0.8f && - second.GetTransform().GetWorldPosition().y > 0.8f, - "Jolt spheres must collide with the ground plane"); - check(glm::distance(first.GetTransform().GetWorldPosition(), - second.GetTransform().GetWorldPosition()) > 2.5f, - "Jolt spheres must collide with one another instead of occupying the same point"); - } - - void testSceneJoltSphereCollisions() - { - Scene& scene = SceneManager::GetInstance().CreateScene("scene-physics"); - - GameObject* floor = scene.CreateGameObject("floor"); - floor->GetTransform().SetLocalPosition({0.0f, -1.0f, 0.0f}); - floor->AddComponent(glm::vec3{10.0f, 0.5f, 10.0f}); - auto* floorBody = floor->AddComponent(); - floorBody->SetType(RigidbodyType::Static); - - GameObject* first = scene.CreateGameObject("first"); - first->AddComponent(1.5f); - first->AddComponent(); - first->GetTransform().SetWorldPosition({0.0f, 100.0f, 0.0f}); - first->GetTransform().SetWorldScale(glm::vec3{0.015f}); - - GameObject* second = scene.CreateGameObject("second"); - second->AddComponent(1.5f); - second->AddComponent(); - second->GetTransform().SetWorldPosition({0.0f, 100.0f, 0.0f}); - second->GetTransform().SetWorldScale(glm::vec3{0.015f}); - - scene.CommitPendingAdditions(); - scene.GetPhysics().RefreshGameObject(*first); - scene.GetPhysics().RefreshGameObject(*second); - - for (int step = 0; step < 240; ++step) { - scene.FixedUpdate(1.0f / 60.0f); - } - - check(first->GetTransform().GetWorldPosition().y > 0.8f && - second->GetTransform().GetWorldPosition().y > 0.8f, - "scene physics spheres must collide with the ground plane"); - check(glm::distance(first->GetTransform().GetWorldPosition(), - second->GetTransform().GetWorldPosition()) > 2.5f, - "scene physics spheres must not overlap after settling"); - - SceneManager::GetInstance().Destroy(); - } - - class TriggerCounter final : public Component { - public: - explicit TriggerCounter(GameObject& parent) - : Component(parent, "TriggerCounter") {} - - std::string GetTypeName() const override { return "TriggerCounter"; } - void OnTriggerEnter(GameObject*) override { ++enters; } - void OnTriggerExit(GameObject*) override { ++exits; } - - int enters{0}; - int exits{0}; - }; - - void testNonPhysicsChangesKeepBodies() - { - Scene& scene = SceneManager::GetInstance().CreateScene("physics-refresh"); - - GameObject* trigger = scene.CreateGameObject("trigger"); - trigger->AddComponent(glm::vec3{3.0f})->SetTrigger(true); - trigger->AddComponent()->SetType(RigidbodyType::Static); - auto* counter = trigger->AddComponent(); - - GameObject* crate = scene.CreateGameObject("crate"); - crate->AddComponent(glm::vec3{0.5f}); - auto* crateBody = crate->AddComponent(); - crateBody->SetUseGravity(false); - crateBody->SetAllowSleep(false); - - GameObject* lamp = scene.CreateGameObject("lamp"); - auto* light = lamp->AddComponent(); - - scene.CommitPendingAdditions(); - scene.GetPhysics().RefreshGameObject(*trigger); - scene.GetPhysics().RefreshGameObject(*crate); - - const auto step = [&scene] { - for (int i = 0; i < 10; ++i) { - scene.FixedUpdate(1.0f / 60.0f); - } - }; - step(); - check(counter->enters == 1 && counter->exits == 0, "a body inside a trigger must enter it once"); - - light->SetEnabled(false); - light->SetEnabled(true); - crate->AddComponent()->SetEnabled(false); - step(); - check(counter->enters == 1 && counter->exits == 0, - "non physics components must not rebuild physics bodies"); - - crateBody->SetMass(5.0f); - step(); - check(counter->enters == 2 && counter->exits == 1, - "rebuilding a body inside a trigger must exit the old body and enter the new one"); - - SceneManager::GetInstance().Destroy(); - } - - void testFrustumFromViewProj() - { - const auto lookingAlongPositiveZ = [](const glm::vec3& position, float range) { - const glm::mat4 view = glm::lookAt(position, position + glm::vec3{0.0f, 0.0f, 1.0f}, glm::vec3{0.0f, 1.0f, 0.0f}); - return glm::perspectiveRH_ZO(glm::radians(60.0f), 1.0f, 0.1f, range) * view; - }; - - const glm::mat4 viewProj = lookingAlongPositiveZ(glm::vec3{0.0f}, 20.0f); - const Frustum frustum = edge::createFrustumFromViewProj(viewProj); - - check(edge::isInFrustum(frustum, Sphere{{0.0f, 0.0f, 10.0f}, 0.5f}), "a sphere in front must be inside"); - check(!edge::isInFrustum(frustum, Sphere{{0.0f, 0.0f, -5.0f}, 0.5f}), "a sphere behind must be outside"); - check(!edge::isInFrustum(frustum, Sphere{{0.0f, 0.0f, 30.0f}, 0.5f}), "a sphere past the far plane must be outside"); - check(!edge::isInFrustum(frustum, Sphere{{15.0f, 0.0f, 10.0f}, 0.5f}), "a sphere to the side must be outside"); - - const auto corners = edge::calculateFrustumCorners(viewProj); - const bool cornersOnNearAndFar = std::ranges::all_of(corners, [](const glm::vec3& corner) { - return std::abs(corner.z - 0.1f) < 0.001f || std::abs(corner.z - 20.0f) < 0.01f; - }); - check(cornersOnNearAndFar, "frustum corners must lie on the near and far planes"); - - check(edge::frustumsOverlap(frustum, lookingAlongPositiveZ(glm::vec3{0.0f, 0.0f, 5.0f}, 10.0f)), - "a frustum in front of the camera must overlap"); - check(!edge::frustumsOverlap(frustum, lookingAlongPositiveZ(glm::vec3{0.0f, 0.0f, -30.0f}, 10.0f)), - "a frustum entirely behind the camera must not overlap"); - } - - void testKinematicBodiesMoveWithVelocity() - { - constexpr float fixedDt = 1.0f / 60.0f; - Scene& scene = SceneManager::GetInstance().CreateScene("kinematic-velocity"); - - GameObject* platform = scene.CreateGameObject("platform"); - platform->AddComponent(glm::vec3{2.0f, 0.25f, 2.0f}); - auto* platformBody = platform->AddComponent(); - platformBody->SetType(RigidbodyType::Kinematic); - - GameObject* crate = scene.CreateGameObject("crate"); - crate->GetTransform().SetWorldPosition({0.0f, 0.76f, 0.0f}); - crate->AddComponent(glm::vec3{0.5f}); - auto* crateBody = crate->AddComponent(); - crateBody->SetFriction(1.0f); - crateBody->SetAllowSleep(false); - - scene.CommitPendingAdditions(); - scene.GetPhysics().RefreshGameObject(*platform); - scene.GetPhysics().RefreshGameObject(*crate); - - for (int step = 0; step < 30; ++step) { - scene.FixedUpdate(fixedDt); - } - check(glm::length(platformBody->GetLinearVelocity()) < 0.001f, - "a kinematic body that is not moved must have no velocity"); - - constexpr float platformSpeed = 2.0f; - for (int step = 0; step < 60; ++step) { - platform->GetTransform().SetWorldPosition( - platform->GetTransform().GetWorldPosition() + glm::vec3{platformSpeed * fixedDt, 0.0f, 0.0f}); - scene.FixedUpdate(fixedDt); - } - - check(close(platformBody->GetLinearVelocity().x, platformSpeed), - "a kinematic body moved through its transform must get the matching velocity"); - check(crate->GetTransform().GetWorldPosition().x > 1.5f, - "a dynamic body resting on a moving kinematic platform must be carried along"); - - SceneManager::GetInstance().Destroy(); - } - - void testRenderer2DShapes() - { - auto& r2d = Renderer2D::GetInstance(); - r2d.Clear(); - - r2d.DrawRect({10.0f, 20.0f}, {30.0f, 40.0f}, glm::vec4{0.5f}); - const Shape2D& rect = r2d.GetShapes().back(); - check(rect.type == Shape2DType::Rect && - rect.center == glm::vec2{25.0f, 40.0f} && - rect.halfSize == glm::vec2{15.0f, 20.0f} && - rect.textureId == NULL_IMAGE_ID && - rect.outline == 0.0f, - "rects must be anchored at their top left corner and filled by default"); - - r2d.DrawEllipseOutline({50.0f, 50.0f}, {20.0f, 10.0f}, 2.0f, glm::vec4{1.0f}); - const Shape2D& ellipse = r2d.GetShapes().back(); - check(ellipse.type == Shape2DType::Ellipse && - ellipse.center == glm::vec2{50.0f} && - ellipse.halfSize == glm::vec2{20.0f, 10.0f} && - ellipse.outline == 2.0f, - "ellipses must be centered with their radii as half size"); - - r2d.DrawTriangle({0.0f, 10.0f}, {5.0f, 0.0f}, {10.0f, 10.0f}, glm::vec4{1.0f}); - const Shape2D& triangle = r2d.GetShapes().back(); - check(triangle.type == Shape2DType::Triangle && - triangle.center == glm::vec2{5.0f} && - triangle.halfSize == glm::vec2{5.0f} && - triangle.p0 == glm::vec2{-5.0f, 5.0f} && - triangle.p1 == glm::vec2{0.0f, -5.0f} && - triangle.p2 == glm::vec2{5.0f, 5.0f}, - "triangle points must be stored relative to their bounding box center"); - - r2d.DrawLine({0.0f, 0.0f}, {0.0f, 10.0f}, 4.0f, glm::vec4{1.0f}, LineCap::Round); - const Shape2D& line = r2d.GetShapes().back(); - check(line.type == Shape2DType::Rect && - line.center == glm::vec2{0.0f, 5.0f} && - close(line.halfSize.x, 7.0f) && close(line.halfSize.y, 2.0f) && - close(line.rotation, glm::half_pi()) && - close(line.cornerRadius, 2.0f), - "round capped lines must be rotated rects extended by half their thickness"); - - check(r2d.GetShapes().size() == 4, "every draw call must submit exactly one shape"); - r2d.Clear(); - check(r2d.GetShapes().empty(), "clear must drop all submitted shapes"); - } - - void testFontAtlasAndLayout() - { - const std::filesystem::path fontPath = - std::filesystem::path{DESTRUM_ENGINE_ASSETS_SRC_DIR} / "fonts" / "Roboto-Medium.ttf"; - const Font font = Font::LoadFromFile(fontPath, 32.0f); - - const FontGlyph* a = font.GetGlyph(U'A'); - const FontGlyph* space = font.GetGlyph(U' '); - check(a != nullptr && a->size.x > 0.0f && a->size.y > 0.0f && a->advance > 0.0f && - a->uvMax.x > a->uvMin.x && a->uvMax.y > a->uvMin.y, - "printable glyphs must be rasterized into the atlas"); - check(space != nullptr && space->size == glm::vec2{0.0f} && space->advance > 0.0f, - "spaces must only advance the pen"); - check(font.GetGlyph(U'中') == font.GetGlyph(U'?'), "missing glyphs must fall back to '?'"); - - const auto& pixels = font.GetAtlasPixels(); - const auto [minIt, maxIt] = std::minmax_element(pixels.begin(), pixels.end()); - check(!pixels.empty() && *minIt < 128 && *maxIt > 128, - "the atlas must hold a signed distance field with the edge at 128"); - - const glm::vec2 one = font.MeasureText("A", 64.0f); - const glm::vec2 two = font.MeasureText("A\nAA", 64.0f); - check(close(one.x, a->advance * 2.0f) && close(one.y, font.GetLineHeight() * 2.0f), - "text must be measured at the requested size"); - check(close(two.x, (a->advance * 2.0f + font.GetKerning(U'A', U'A')) * 2.0f) && - close(two.y, one.y * 2.0f), - "multi line text must measure its widest line and every line"); - - std::vector placed; - font.Layout("A b", {100.0f, 50.0f}, 32.0f, [&](const PlacedGlyph& glyph) { placed.push_back(glyph); }); - check(placed.size() == 2 && placed[0].glyph == a && - close(placed[0].topLeft.x, 100.0f + a->offset.x) && - close(placed[0].topLeft.y, 50.0f + font.GetAscender() + a->offset.y), - "layout must skip spaces and start on the baseline below the top left"); - - const std::string_view utf8 = "\xC3\xA9\xFF"; - std::size_t index = 0; - check(Font::DecodeUtf8(utf8, index) == U'é' && index == 2, "utf8 must decode two byte sequences"); - check(Font::DecodeUtf8(utf8, index) == 0xFFFD && index == 3, "invalid utf8 must decode to U+FFFD"); - - auto& r2d = Renderer2D::GetInstance(); - r2d.Clear(); - r2d.DrawText(font, "not uploaded", {0.0f, 0.0f}, 16.0f, glm::vec4{1.0f}); - check(r2d.GetShapes().empty(), "text must not be drawn before the font atlas is uploaded"); - } - - void testCapsuleColliderDebugLines() - { - GameObject object{"capsule"}; - object.GetTransform().SetWorldPosition({1.0f, 2.0f, 3.0f}); - object.GetTransform().SetWorldScale(glm::vec3{5.0f}); - auto* capsule = object.AddComponent(0.4f, 1.8f); - capsule->SetCenterOffset({0.0f, 0.5f, 0.0f}); - - auto& lines = LineRenderingManager::GetInstance(); - lines.ClearLines(); - capsule->DrawDebugLines(glm::vec4{1.0f}); - - const glm::vec3 center{1.0f, 2.5f, 3.0f}; - float top = -1000.0f; - float bottom = 1000.0f; - bool inside = true; - for (const Line& line : lines.GetLines()) { - for (const glm::vec3& point : {line.start, line.end}) { - const glm::vec3 local = point - center; - top = std::max(top, local.y); - bottom = std::min(bottom, local.y); - inside = inside && glm::length(glm::vec2{local.x, local.z}) <= 0.4f + 0.001f; - } - } - lines.ClearLines(); - - check(inside && close(top, 0.9f) && close(bottom, -0.9f), - "capsule debug lines must match the collider shape in world units, ignoring the object scale"); - } - - void testColliderCenterOffset() - { - Scene& scene = SceneManager::GetInstance().CreateScene("collider-offset"); - - GameObject* box = scene.CreateGameObject("offset box"); - box->AddComponent(glm::vec3{0.5f})->SetCenterOffset({0.0f, 5.0f, 0.0f}); - box->AddComponent()->SetType(RigidbodyType::Static); - scene.CommitPendingAdditions(); - scene.GetPhysics().RefreshGameObject(*box); - - PhysicsRaycastHit hit{}; - const bool found = box->GetComponent()->GetPhysicsWorld()->Raycast( - {0.0f, 10.0f, 0.0f}, {0.0f, -1.0f, 0.0f}, 20.0f, hit); - check(found && std::abs(hit.distance - 4.5f) < 0.01f, - "a collider center offset must move the shape, not just the center of mass"); - - SceneManager::GetInstance().Destroy(); - } - - void testPrimitiveBoxMesh() - { - const glm::vec3 size{4.0f, 1.0f, 2.0f}; - const CPUMesh box = PrimitiveMeshes::MakeBox(size); - - check(box.vertices.size() == 24 && box.indices.size() == 36, "a box must have 6 faces of 2 triangles"); - check(box.minPos == -size * 0.5f && box.maxPos == size * 0.5f, "a box must be centered on the origin"); - - float maxU = 0.0f; - bool outward = true; - for (const auto& vertex : box.vertices) { - if (vertex.normal == glm::vec3{0.0f, 0.0f, 1.0f}) { - maxU = std::max(maxU, vertex.uv_x); - } - outward = outward && glm::dot(vertex.position, vertex.normal) > 0.0f; - } - check(close(maxU, size.x), "box uvs must be in meters so textures tile at the same size"); - check(outward, "box normals must point away from the center"); - - bool counterClockwise = true; - for (std::size_t i = 0; i < box.indices.size(); i += 3) { - const auto& a = box.vertices[box.indices[i]]; - const auto& b = box.vertices[box.indices[i + 1]]; - const auto& c = box.vertices[box.indices[i + 2]]; - const glm::vec3 faceNormal = glm::cross(b.position - a.position, c.position - a.position); - counterClockwise = counterClockwise && glm::dot(faceNormal, a.normal) > 0.0f; - } - check(counterClockwise, "box triangles must be counter clockwise seen from outside"); - } - - void testFrozenRotationCapsule() - { - Scene& scene = SceneManager::GetInstance().CreateScene("frozen-capsule"); - - GameObject* floor = scene.CreateGameObject("floor"); - floor->GetTransform().SetLocalPosition({0.0f, -1.0f, 0.0f}); - floor->AddComponent(glm::vec3{10.0f, 0.5f, 10.0f}); - floor->AddComponent()->SetType(RigidbodyType::Static); - - GameObject* character = scene.CreateGameObject("character"); - character->AddComponent(0.4f, 1.8f); - auto* body = character->AddComponent(); - body->SetFriction(1.0f); - body->SetFreezeRotation(true); - character->GetTransform().SetWorldPosition({0.0f, 1.0f, 0.0f}); - - GameObject* trigger = scene.CreateGameObject("trigger"); - trigger->GetTransform().SetWorldPosition({2.0f, 0.0f, 0.0f}); - trigger->AddComponent(glm::vec3{2.0f})->SetTrigger(true); - trigger->AddComponent()->SetType(RigidbodyType::Static); - - scene.CommitPendingAdditions(); - scene.GetPhysics().RefreshGameObject(*floor); - scene.GetPhysics().RefreshGameObject(*character); - scene.GetPhysics().RefreshGameObject(*trigger); - - // Sliding with friction would tip a free capsule over - body->SetLinearVelocity({4.0f, 0.0f, 0.0f}); - for (int step = 0; step < 180; ++step) { - scene.FixedUpdate(1.0f / 60.0f); - } - - const glm::vec3 position = character->GetTransform().GetWorldPosition(); - const glm::quat rotation = character->GetTransform().GetWorldRotation(); - check(std::abs(rotation.w) > 0.999f, "a capsule with frozen rotation must stay upright"); - check(std::abs(position.y - (-0.5f + 0.9f)) < 0.05f, "a capsule must rest on top of the floor"); - - // Cast from inside the capsule (and the trigger it is standing in), like a ground check does - PhysicsRaycastHit hit{}; - const bool hitGround = body->GetPhysicsWorld()->Raycast( - position - glm::vec3{0.0f, 0.7f, 0.0f}, glm::vec3{0.0f, -1.0f, 0.0f}, 0.3f, hit, body->GetBody()); - check(hitGround && hit.object == floor && hit.normal.y > 0.9f, - "a ground ray must skip the caster and triggers and hit the floor"); - - PhysicsRaycastHit selfHit{}; - check(body->GetPhysicsWorld()->Raycast( - position - glm::vec3{0.0f, 0.7f, 0.0f}, glm::vec3{0.0f, -1.0f, 0.0f}, 0.3f, selfHit) && - selfHit.object == character, - "a ray that doesn't ignore the caster must hit it"); - - SceneManager::GetInstance().Destroy(); - } - - void testAnimatorAssetReferences() - { - GameObject object{"animated"}; - auto* animator = object.AddComponent(); - - Skeleton skeleton; - Joint joint; - joint.id = 0; - joint.localTranslation = glm::vec3{0.0f}; - joint.localRotation = glm::identity(); - joint.localScale = glm::vec3{1.0f}; - skeleton.joints.push_back(joint); - skeleton.inverseBindMatrices.push_back(glm::mat4{1.0f}); - skeleton.jointNames.push_back("root"); - skeleton.assetReference.path = "engine://character.fbx"; - buildParentIndex(skeleton); - animator->setSkeleton(std::move(skeleton)); - - auto clip = std::make_shared(); - clip->name = "walk"; - clip->assetReference.path = "engine://walk.fbx"; - clip->assetReference.subresource = "animation:0"; - clip->duration = 1.0f; - SkeletalAnimation::Keyframe keyframe; - keyframe.time = 0.0f; - keyframe.translation = glm::vec3{0.0f}; - keyframe.rotation = glm::identity(); - keyframe.scale = glm::vec3{1.0f}; - SkeletalAnimation::Track track; - track.jointIndex = 0; - track.keyframes.push_back(keyframe); - clip->tracks.push_back(std::move(track)); - animator->addClip(std::move(clip)); - animator->play("walk"); - - const auto data = animator->Serialize(); - check(data.contains("assetReferences"), - "Animator scenes must store asset references"); - check(data.at("assetReferences").at("skeleton").at("path") == "engine://character.fbx", - "Animator serialization must store the skeleton source"); - check(data.at("assetReferences").at("animations").at(0).at("asset").at("subresource") == - "animation:0", - "Animator serialization must store the animation subresource"); - check(data.dump().find("keyframes") == std::string::npos, - "Animator scene data must not embed animation keyframes"); - } - - void testLightComponentRoundTrip() - { - GameObject object{"light"}; - auto* light = object.AddComponent(LightType::Spot); - light->SetColor(glm::vec3{1.0f, 0.5f, 0.25f}); - light->SetIntensity(4.0f); - light->SetRange(7.5f); - light->SetConeAngles(0.2f, 0.4f); - light->SetCastShadows(true); - - const auto data = light->Serialize(); - - GameObject loadedObject{"loaded light"}; - auto* loaded = loadedObject.AddComponent(); - loaded->Deserialize(data); - - check(loaded->GetType() == LightType::Spot, - "LightComponent must round-trip its type"); - check(loaded->GetColor() == glm::vec3{1.0f, 0.5f, 0.25f}, - "LightComponent must round-trip its color"); - check(loaded->GetIntensity() == 4.0f && loaded->GetRange() == 7.5f, - "LightComponent must round-trip intensity and range"); - check(loaded->GetInnerConeAngle() == 0.2f && loaded->GetOuterConeAngle() == 0.4f, - "LightComponent must round-trip its cone angles"); - check(loaded->GetCastShadows(), - "LightComponent must round-trip its cast shadows flag"); - - loaded->SetConeAngles(0.5f, 0.1f); - check(loaded->GetOuterConeAngle() >= loaded->GetInnerConeAngle(), - "LightComponent outer cone must never be smaller than the inner cone"); - - loaded->SetRange(0.0f); - check(loaded->GetRange() > 0.0f, - "LightComponent range must stay positive"); - } - - void testSceneCurrentCamera() - { - Scene& scene = SceneManager::GetInstance().CreateScene("cameras"); - check(scene.GetCurrentCamera() == nullptr, "a scene without cameras must have no current camera"); - - GameObject* first = scene.CreateGameObject("first camera"); - auto* firstCamera = first->AddComponent(); - scene.CommitPendingAdditions(); - check(scene.GetCurrentCamera() == firstCamera, "the only camera in a scene must be the current camera"); - - GameObject* second = scene.CreateGameObject("second camera"); - auto* secondCamera = second->AddComponent(); - secondCamera->SetFieldOfView(60.0f); - second->GetTransform().SetWorldPosition({1.0f, 2.0f, 3.0f}); - scene.CommitPendingAdditions(); - secondCamera->MakeCurrent(); - check(scene.GetCurrentCamera() == secondCamera && !firstCamera->IsCurrent(), - "the selected camera must be the current camera"); - - const Camera& camera = secondCamera->GetCamera(16.0f / 9.0f); - check(camera.GetPosition() == glm::vec3{1.0f, 2.0f, 3.0f} && - close(camera.GetForward().z, -1.0f) && camera.GetFOVY() == 60.0f, - "a camera component must place its camera at its transform, looking down -Z"); - - const auto path = std::filesystem::temp_directory_path() / "destrum_camera_scene_test.json"; - check(SceneSerializer::Save(scene, path), "scene with cameras must save"); - check(SceneSerializer::Load(scene, path), "scene with cameras must load"); - std::filesystem::remove(path); - - const CameraComponent* loadedCamera = scene.GetCurrentCamera(); - check(loadedCamera != nullptr && loadedCamera->GetGameObject()->GetName() == "second camera" && - loadedCamera->GetFieldOfView() == 60.0f, - "scene round-trip must preserve the current camera"); - - scene.GetCurrentCamera()->GetGameObject()->SetActive(false); - check(scene.GetCurrentCamera() != nullptr && - scene.GetCurrentCamera()->GetGameObject()->GetName() == "first camera", - "an inactive current camera must fall back to another camera"); - - SceneManager::GetInstance().Destroy(); - } - - ModelDoc::ModelAsset makeTestModelAsset() - { - Skeleton skeleton; - skeleton.assetReference = {.path = "engine://character.fbx", .subresource = "skeleton"}; - skeleton.hierarchy = {{.id = 0, .children = {1}}, {.id = 1, .children = {}}}; - skeleton.inverseBindMatrices = {glm::mat4{1.0f}, glm::mat4{2.0f}}; - skeleton.joints = { - {.id = 0, .localTranslation = glm::vec3{0.0f, 1.0f, 0.0f}}, - {.id = 1, .localScale = glm::vec3{2.0f}}, - }; - skeleton.jointNames = {"root", "spine"}; - buildParentIndex(skeleton); - - CPUMesh mesh; - mesh.indices = {0, 1, 2}; - mesh.vertices = { - {.position = glm::vec3{0.0f}, .uv_x = 0.25f, .normal = glm::vec3{0.0f, 1.0f, 0.0f}}, - {.position = glm::vec3{1.0f, 0.0f, 0.0f}, .tangent = glm::vec4{1.0f, 0.0f, 0.0f, -1.0f}}, - {.position = glm::vec3{0.0f, 0.0f, 1.0f}, .uv_y = 0.75f}, - }; - mesh.skinningData = { - {.jointIds = glm::uvec4{0, 1, 0, 0}, .weights = glm::vec4{0.5f, 0.5f, 0.0f, 0.0f}}, - {.jointIds = glm::uvec4{1, 0, 0, 0}, .weights = glm::vec4{1.0f, 0.0f, 0.0f, 0.0f}}, - {.jointIds = glm::uvec4{0, 0, 0, 0}, .weights = glm::vec4{1.0f, 0.0f, 0.0f, 0.0f}}, - }; - mesh.name = "body"; - mesh.assetReference = {.path = "engine://character.fbx", .subresource = "mesh:0"}; - mesh.minPos = glm::vec3{0.0f}; - mesh.maxPos = glm::vec3{1.0f}; - mesh.skeleton = skeleton; - - ModelDoc::MeshPrimitive primitive; - primitive.mesh = mesh; - primitive.materialIndex = 0; - primitive.nodeName = "Body"; - primitive.sourceMeshIndex = 3; - primitive.skinned = true; - primitive.nodeWorldTransform = glm::mat4{3.0f}; - - ModelDoc::MaterialInfo material; - material.name = "skin"; - material.assetReference = {.path = "engine://character.fbx", .subresource = "material:0"}; - material.baseColor = glm::vec4{0.8f, 0.6f, 0.4f, 1.0f}; - material.emissiveColor = glm::vec3{0.1f}; - material.metallicFactor = 0.2f; - material.roughnessFactor = 0.9f; - material.opacity = 0.5f; - material.textures = {{ - .semantic = ModelDoc::TextureSemantic::BaseColor, - .rawPath = "skin.png", - .path = "textures/skin.png", - .assimpType = aiTextureType_DIFFUSE, - .slot = 1, - .embedded = false, - }}; - - SkeletalAnimation animation; - animation.assetReference = {.path = "engine://character.fbx", .subresource = "animation:0"}; - animation.tracks = {{ - .jointIndex = 1, - .keyframes = { - {.time = 0.0f, .translation = glm::vec3{0.0f}, .rotation = glm::identity(), .scale = glm::vec3{1.0f}}, - {.time = 0.5f, .translation = glm::vec3{0.0f, 2.0f, 0.0f}, .rotation = glm::quat{0.0f, 1.0f, 0.0f, 0.0f}, .scale = glm::vec3{1.0f}}, - }, - }}; - animation.duration = 0.5f; - animation.looped = false; - animation.name = "wave"; - animation.startFrame = 4; - animation.events = {{2, {"footstep", "dust"}}}; - - ModelDoc::ModelAsset model; - model.sourcePath = "engine://character.fbx"; - model.primitives = {primitive}; - model.materials = {material}; - model.skeleton = skeleton; - model.animations = {animation}; - return model; - } - - bool modelFileReadThrows(const std::vector& bytes) - { - try { - (void)ModelFile::Read(bytes); - } catch (const std::exception&) { - return true; - } - return false; - } - - void testModelFileRoundTrip() - { - const auto bytes = ModelFile::Write(makeTestModelAsset()); - const auto loaded = ModelFile::Read(bytes); - - check(ModelFile::Write(loaded) == bytes, - "a cooked model must write back to exactly the same bytes"); - - check(loaded.sourcePath == "engine://character.fbx", - "cooked models must keep their source path"); - - check(loaded.primitives.size() == 1, "cooked models must keep their primitives"); - const auto& primitive = loaded.primitives.front(); - check(primitive.nodeName == "Body" && primitive.sourceMeshIndex == 3 && primitive.skinned, - "cooked primitives must keep their import metadata"); - check(primitive.mesh.indices == std::vector{0, 1, 2}, - "cooked meshes must keep their indices"); - check(primitive.mesh.vertices.size() == 3 && - primitive.mesh.vertices[0].uv_x == 0.25f && - primitive.mesh.vertices[1].tangent.w == -1.0f, - "cooked meshes must keep their vertices"); - check(primitive.mesh.skinningData[0].jointIds.y == 1 && - primitive.mesh.skinningData[0].weights.x == 0.5f, - "cooked meshes must keep their skinning data"); - check(primitive.mesh.skeleton.has_value() && - primitive.mesh.skeleton->jointNames.size() == 2, - "cooked skinned meshes must keep their skeleton"); - - check(loaded.materials.size() == 1 && loaded.materials.front().opacity == 0.5f, - "cooked models must keep their materials"); - const auto& texture = loaded.materials.front().textures.front(); - check(texture.semantic == ModelDoc::TextureSemantic::BaseColor && - texture.path == "textures/skin.png" && - texture.assimpType == aiTextureType_DIFFUSE && - texture.slot == 1 && - !texture.embedded, - "cooked materials must keep their textures"); - - check(loaded.skeleton.jointNames == std::vector{"root", "spine"} && - loaded.skeleton.parentIndex == std::vector{-1, 0} && - loaded.skeleton.joints[1].localScale == glm::vec3{2.0f}, - "cooked models must keep their skeleton"); - - check(loaded.animations.size() == 1, "cooked models must keep their animations"); - const auto& animation = loaded.animations.front(); - check(animation.name == "wave" && !animation.looped && animation.startFrame == 4, - "cooked animations must keep their settings"); - check(animation.tracks.front().keyframes[1].translation.y == 2.0f && - animation.tracks.front().keyframes[1].rotation.x == 1.0f, - "cooked animations must keep their keyframes"); - check(animation.events.at(2) == std::vector{"footstep", "dust"}, - "cooked animations must keep their events"); - } - - void testModelFileRejectsBadData() - { - const auto bytes = ModelFile::Write(makeTestModelAsset()); - - auto truncated = bytes; - truncated.resize(bytes.size() / 2); - check(modelFileReadThrows(truncated), "truncated cooked models must be rejected"); - - auto wrongMagic = bytes; - wrongMagic[0] = 'X'; - check(modelFileReadThrows(wrongMagic), "files that aren't cooked models must be rejected"); - - auto wrongVersion = bytes; - wrongVersion[4] = static_cast(wrongVersion[4] + 1); - check(modelFileReadThrows(wrongVersion), "cooked models from another version must be rejected"); - - auto trailingData = bytes; - trailingData.push_back(0); - check(modelFileReadThrows(trailingData), "cooked models with trailing data must be rejected"); - } - - void testModelFileFromImportedModel() - { - const auto source = std::filesystem::path{DESTRUM_ENGINE_ASSETS_SRC_DIR} / "sphere.fbx"; - const auto imported = ModelDoc::LoadModel(source.generic_string()); - - const auto path = std::filesystem::temp_directory_path() / "destrum_model_test.dmodel"; - ModelFile::WriteToFile(imported, path); - const auto loaded = ModelFile::ReadFromFile(path); - std::filesystem::remove(path); - - check(!imported.primitives.empty(), "the test model must import with at least one primitive"); - check(loaded.primitives.size() == imported.primitives.size() && - loaded.primitives.front().mesh.vertices.size() == imported.primitives.front().mesh.vertices.size() && - loaded.primitives.front().mesh.indices == imported.primitives.front().mesh.indices, - "an imported model must survive being cooked"); - check(ModelFile::Write(loaded) == ModelFile::Write(imported), - "a cooked imported model must match the import exactly"); - } - - bool loadModelThrows(std::string_view reference, const ModelDoc::LoadOptions& options) - { - try { - (void)AssetManager::GetInstance().LoadModel(reference, options); - } catch (const std::exception&) { - return true; - } - return false; - } - - void testAssetManagerLoadsCookedModels() - { - const auto root = std::filesystem::temp_directory_path() / "destrum_cooked_model_test"; - const auto engineDir = root / "assets" / "engine"; - std::filesystem::remove_all(root); - std::filesystem::create_directories(engineDir); - std::filesystem::create_directories(root / "assets" / "game"); - - // The source can't be imported, so a successful load has to come from the cooked file - std::ofstream{engineDir / "model.fbx"} << "not a model"; - ModelFile::WriteToFile(makeTestModelAsset(), engineDir / "model.fbx.dmodel"); - std::ofstream{engineDir / "manifest.json"} - << R"({"version":1,"assets":[{"src":"model.fbx","out":"model.fbx.dmodel","type":"model","mtime_epoch_ns":0,"size_bytes":0}]})"; - - auto& assetFs = AssetFS::GetInstance(); - assetFs.Init(root); - - const auto model = AssetManager::GetInstance().LoadModel("engine://model.fbx"); - check(model.primitives.size() == 1 && model.animations.size() == 1, - "cooked models must be loaded instead of importing the source"); - check(model.sourcePath == "engine://model.fbx" && - model.primitives.front().mesh.assetReference.path == "engine://model.fbx" && - model.primitives.front().mesh.skeleton->assetReference.path == "engine://model.fbx", - "cooked models must be referenced by their virtual path"); - check(model.materials.front().textures.front().path == - std::filesystem::weakly_canonical(engineDir / "textures/skin.png").generic_string(), - "cooked texture paths must be resolved next to the cooked model"); - - ModelDoc::LoadOptions withoutExtras{}; - withoutExtras.loadMaterials = false; - withoutExtras.loadAnimations = false; - const auto stripped = AssetManager::GetInstance().LoadModel("engine://model.fbx", withoutExtras); - check(stripped.materials.empty() && - stripped.animations.empty() && - stripped.primitives.front().materialIndex == ModelDoc::InvalidIndex, - "cooked models must drop what the load options didn't ask for"); - - ModelDoc::LoadOptions withoutSkeleton{}; - withoutSkeleton.loadSkeleton = false; - check(loadModelThrows("engine://model.fbx", withoutSkeleton), - "skinned cooked models must be imported when loaded without a skeleton"); - - ModelDoc::LoadOptions otherImport{}; - otherImport.flipUVs = false; - check(loadModelThrows("engine://model.fbx", otherImport), - "cooked models must be imported when the import options differ from the cook options"); - - assetFs.Reset(); - std::filesystem::remove_all(root); - } - - TextureFile::Texture makeTestTexture() - { - const std::vector rgba{ - 0.0f, 0.25f, 0.5f, 1.0f, - 2.0f, 16.0f, 100.0f, 1.0f, - }; - return TextureFile::FromRGBA32Float(2, 1, rgba); - } - - bool textureFileReadThrows(const std::vector& bytes) - { - try { - (void)TextureFile::Read(bytes); - } catch (const std::exception&) { - return true; - } - return false; - } - - void testTextureFileRoundTrip() - { - const auto texture = makeTestTexture(); - check(texture.pixels.size() == 2 * TextureFile::BytesPerPixel(TextureFile::PixelFormat::RGBA16Float), - "cooked textures must store every pixel"); - - std::uint64_t secondPixel = 0; - std::memcpy(&secondPixel, texture.pixels.data() + 8, sizeof(secondPixel)); - const glm::vec4 unpacked = glm::unpackHalf4x16(secondPixel); - check(unpacked == glm::vec4{2.0f, 16.0f, 100.0f, 1.0f}, - "half float conversion must keep HDR values above 1"); - - const auto bytes = TextureFile::Write(texture); - const auto loaded = TextureFile::Read(bytes); - check(loaded.width == 2 && loaded.height == 1 && - loaded.format == TextureFile::PixelFormat::RGBA16Float && - loaded.pixels == texture.pixels, - "cooked textures must round-trip"); - - auto truncated = bytes; - truncated.resize(bytes.size() - 1); - check(textureFileReadThrows(truncated), "truncated cooked textures must be rejected"); - - auto wrongMagic = bytes; - wrongMagic[0] = 'X'; - check(textureFileReadThrows(wrongMagic), "files that aren't cooked textures must be rejected"); - - auto wrongSize = bytes; - wrongSize[8] = 3; - check(textureFileReadThrows(wrongSize), "cooked textures whose size doesn't match their pixels must be rejected"); - } - - void testImageLoaderReadsCookedTextures() - { - const auto path = std::filesystem::temp_directory_path() / "destrum_texture_test.dtex"; - const auto texture = makeTestTexture(); - TextureFile::WriteToFile(texture, path); - - const auto image = util::loadImage(path, TextureIntent::ColorSrgb); - std::filesystem::remove(path); - - check(image.vkFormat == VK_FORMAT_R16G16B16A16_SFLOAT && - image.width == 2 && image.height == 1 && - image.byteSize == texture.pixels.size(), - "cooked textures must load as half float images"); - check(image.pixels != nullptr && - std::memcmp(image.pixels, texture.pixels.data(), texture.pixels.size()) == 0, - "cooked texture pixels must be uploaded as stored"); - } - - void testSimpleColorMaterial() - { - const auto material = Material::SimpleColor( - glm::vec3{0.2f, 0.4f, 0.8f}, - "blue material"); - - check(material.baseColor == glm::vec3{0.2f, 0.4f, 0.8f}, - "simple materials must preserve their color"); - check(material.metallicFactor == 0.0f && - material.roughnessFactor == 1.0f && - material.emissiveColor == glm::vec3{0.0f}, - "simple materials must use non-metallic default factors"); - check(material.diffuseTexture == NULL_IMAGE_ID, - "simple materials must not require a diffuse image"); - check(material.name == "blue material", - "simple materials must preserve their optional name"); - } - - void testAssetReferenceCacheKeys() - { - const AssetReference reference{ - .path = "game://CharacterMedium.fbx", - .subresource = "mesh:0", - }; - - const auto key = reference.cacheKey(); - check(key == "game://CharacterMedium.fbx#mesh:0", - "asset references must produce stable cache keys"); - - const auto parsed = AssetReference::fromCacheKey(key); - check(parsed.has_value() && - parsed->path == reference.path && - parsed->subresource == reference.subresource, - "asset cache keys must recover their source reference"); - check(!AssetReference::fromCacheKey("CharacterMedium.fbx").has_value(), - "plain cache names must not be treated as file-backed assets"); - } - - class TriggerTracker final : public Component { - public: - explicit TriggerTracker(GameObject& owner) - : Component(owner, "TriggerTracker") {} - - void Update(float) override {} - std::string GetTypeName() const override { return "TriggerTracker"; } - - void OnTriggerEnter(GameObject* other) override { - ++enterCount; - lastOther = other; - enterObjectName = other ? other->GetName() : "null"; - } - - void OnTriggerExit(GameObject* other) override { - ++exitCount; - } - - int enterCount{0}; - int exitCount{0}; - GameObject* lastOther{nullptr}; - std::string enterObjectName; - }; - - void testTriggerZone() - { - Scene& scene = SceneManager::GetInstance().CreateScene("trigger-test"); - - // Trigger box at origin (Static rigidbody + BoxCollider as trigger). - GameObject* triggerObj = scene.CreateGameObject("TriggerZone"); - triggerObj->AddComponent(glm::vec3{3.0f, 3.0f, 3.0f}); - triggerObj->GetComponent()->SetTrigger(true); - auto* triggerRb = triggerObj->AddComponent(); - triggerRb->SetType(RigidbodyType::Static); - auto* tracker = triggerObj->AddComponent(); - - // Falling sphere above the trigger. - GameObject* sphere = scene.CreateGameObject("FallingSphere"); - sphere->AddComponent(0.5f); - sphere->AddComponent(); - sphere->GetTransform().SetWorldPosition({0.0f, 8.0f, 0.0f}); - - scene.CommitPendingAdditions(); - - // Run physics for enough steps that the sphere falls into the trigger. - for (int step = 0; step < 120; ++step) { - scene.FixedUpdate(1.0f / 60.0f); - } - - check(tracker->enterCount >= 1, - "falling sphere must trigger OnTriggerEnter at least once"); - check(tracker->exitCount >= 1, - "falling sphere passing through trigger must exit"); - check(tracker->enterObjectName == "FallingSphere", - "OnTriggerEnter must report the correct entering object"); - - SceneManager::GetInstance().Destroy(); - } - std::string readFile(const std::filesystem::path& path) - { - std::ifstream file(path, std::ios::binary); - return {std::istreambuf_iterator(file), std::istreambuf_iterator()}; - } - - void testSceneSaveRejectsNonFinite() - { - Scene& scene = SceneManager::GetInstance().CreateScene("non-finite"); - GameObject* object = scene.CreateGameObject("object"); - scene.CommitPendingAdditions(); - - const auto path = std::filesystem::temp_directory_path() / "destrum_non_finite_scene.json"; - check(SceneSerializer::Save(scene, path), "a finite scene must save"); - const std::string goodContents = readFile(path); - - object->GetTransform().SetLocalPosition({std::nanf(""), 0.0f, 0.0f}); - check(!SceneSerializer::Save(scene, path), "a scene with NaN values must not save"); - check(readFile(path) == goodContents, "a failed save must leave the previous file untouched"); - check(!std::filesystem::exists(std::filesystem::path{path} += ".tmp"), - "a failed save must not leave its temp file behind"); - - std::filesystem::remove(path); - SceneManager::GetInstance().Destroy(); - } - - void testSceneValidationErrors() - { - const auto path = std::filesystem::temp_directory_path() / "destrum_invalid_scene.json"; - std::ofstream{path} - << R"({"version":2,"name":"bad","objects":[{"id":7,"name":"Lamp","transform":{"position":[0,0,0],"rotation":[0,0,0,1],"scale":[1,1,1]},"parent":0,"components":[{"type":"NotAComponent","enabled":true,"data":{}}]}]})"; - - const auto result = SceneSerializer::LoadSceneFile(path); - check(!result.success, "a scene with an unknown component must not load"); - check(result.errorMessage.find("\"Lamp\"") != std::string::npos && - result.errorMessage.find("NotAComponent") != std::string::npos && - result.errorMessage.find("unknown component type") != std::string::npos, - "scene validation errors must name the object, component and problem"); - - std::ofstream{path} - << R"({"version":2,"objects":[{"id":7,"name":"Lamp","components":[{"type":"Spinner","data":["message","hi"]}]}]})"; - const auto arrayData = SceneSerializer::LoadSceneFile(path); - check(!arrayData.success && arrayData.errorMessage.find("must be an object") != std::string::npos, - "component data that is not an object must be rejected with a reason"); - - std::filesystem::remove(path); - } - - void testSceneLoadRestoresBindings() - { - Scene& scene = SceneManager::GetInstance().CreateScene("bindings"); - int registered = 0; - int unregistered = 0; - scene.SetRegisterBindings([®istered] { ++registered; }); - scene.SetUnregisterBindings([&unregistered] { ++unregistered; }); - scene.Load(); - const int registeredBefore = registered; - const int unregisteredBefore = unregistered; - scene.CreateGameObject("object"); - scene.CommitPendingAdditions(); - - const auto path = std::filesystem::temp_directory_path() / "destrum_bindings_scene.json"; - check(SceneSerializer::Save(scene, path), "the bindings scene must save"); - check(SceneSerializer::Load(scene, path), "the bindings scene must load"); - std::filesystem::remove(path); - - check(registered - registeredBefore == 1 && unregistered - unregisteredBefore == 1, "loading a scene in place must register its bindings again"); - - scene.SetRegisterBindings(nullptr); - scene.SetUnregisterBindings(nullptr); - SceneManager::GetInstance().Destroy(); - } - - void testSceneLoadDropsMissingCamera() - { - Scene& scene = SceneManager::GetInstance().CreateScene("missing camera"); - const auto path = std::filesystem::temp_directory_path() / "destrum_missing_camera_scene.json"; - std::ofstream{path} - << R"({"version":2,"currentCamera":4242,"objects":[{"id":11,"name":"object"}]})"; - - check(SceneSerializer::Load(scene, path), "a scene with a dangling camera id must still load"); - check(scene.GetCurrentCamera() == nullptr, "a dangling current camera id must be ignored"); - - std::filesystem::remove(path); - SceneManager::GetInstance().Destroy(); - } - - void testGlmJson() - { - const nlohmann::json vec = glm::vec3{1.0f, 2.5f, -3.0f}; - check(vec == nlohmann::json::array({1.0f, 2.5f, -3.0f}), "vec3 must serialize as a 3 number array"); - check(vec.get() == glm::vec3{1.0f, 2.5f, -3.0f}, "vec3 must round-trip through json"); - - const glm::quat rotation = glm::angleAxis(0.5f, glm::vec3{0.0f, 1.0f, 0.0f}); - const nlohmann::json quat = rotation; - check(quat.size() == 4 && close(quat.at(3).get(), rotation.w), - "quaternions must serialize as [x, y, z, w]"); - check(quat.get() == rotation, "quaternions must round-trip through json"); - - bool rejected = false; - try { - (void)nlohmann::json::array({1.0f, 2.0f}).get(); - } catch (const std::exception&) { - rejected = true; - } - check(rejected, "a vec3 must reject arrays of the wrong size"); - - const nlohmann::json data = {{"axis", nlohmann::json::array({0, 1, 0})}}; - check(data.value("axis", glm::vec3{0.0f}) == glm::vec3{0.0f, 1.0f, 0.0f} && - data.value("missing", glm::vec3{7.0f}) == glm::vec3{7.0f}, - "json value() must work with glm defaults"); - } - - void testPrimitiveMeshKeys() - { - const glm::vec3 size{0.35f, 0.12f, 3.0f}; - const std::string key = PrimitiveMeshes::BoxKey(size); - check(PrimitiveMeshes::IsPrimitiveKey(key), "box keys must be primitive keys"); - check(PrimitiveMeshes::MakeBox(size).name == key, "boxes must be named by their key"); - - const auto rebuilt = PrimitiveMeshes::MakeFromKey(key + "#3"); - check(rebuilt.has_value() && rebuilt->maxPos == size * 0.5f, - "a box must rebuild exactly from its key, ignoring the duplicate suffix"); - check(PrimitiveMeshes::StripDuplicateSuffix("BulbMaterial#12") == "BulbMaterial" && - PrimitiveMeshes::StripDuplicateSuffix("game://a.glb#mesh") == "game://a.glb#mesh", - "only numeric duplicate suffixes must be stripped"); - check(!PrimitiveMeshes::MakeFromKey("prim://box?size=1,2").has_value() && - !PrimitiveMeshes::MakeFromKey("prim://box?size=1,-2,3").has_value() && - !PrimitiveMeshes::MakeFromKey("Box").has_value(), - "invalid primitive keys must not build a mesh"); - } - - void testSceneFileIdsAreLocal() - { - const auto path = std::filesystem::temp_directory_path() / "destrum_local_ids_scene.json"; - std::ofstream{path} - << R"({"version":2,"currentCamera":2,"objects":[)" - R"({"id":1,"name":"root"},)" - R"({"id":2,"name":"child","parent":1,"components":[{"type":"CameraComponent"}]}]})"; - - Scene& first = SceneManager::GetInstance().CreateScene("local ids a"); - Scene& second = SceneManager::GetInstance().CreateScene("local ids b"); - check(SceneSerializer::Load(first, path), "the first copy of the scene must load"); - check(SceneSerializer::Load(second, path), "the second copy of the scene must load"); - std::filesystem::remove(path); - first.CommitPendingAdditions(); - second.CommitPendingAdditions(); - - std::unordered_set ids; - std::size_t count = 0; - for (const Scene* scene : {&first, &second}) { - for (const auto& object : scene->GetObjects()) { - ids.insert(object->GetId()); - ++count; - } - } - check(count == 4 && ids.size() == 4, "loading one file into two scenes must not duplicate ids"); - - for (const Scene* scene : {&first, &second}) { - const CameraComponent* camera = scene->GetCurrentCamera(); - check(camera != nullptr && camera->GetGameObject()->GetName() == "child" && - camera->GetGameObject()->GetScene() == scene, - "the current camera must be remapped to the object in its own scene"); - const Transform* parent = camera->GetGameObject()->GetTransform().GetParent(); - check(parent != nullptr && parent->GetOwner()->GetName() == "root" && - parent->GetOwner()->GetScene() == scene, - "parents must be remapped within each loaded scene"); - } - - const GameObject* fresh = first.CreateGameObject("fresh"); - check(!ids.contains(fresh->GetId()), "objects created after a load must get unused ids"); - - SceneManager::GetInstance().Destroy(); - } - - GameObject* findByName(const Scene& scene, const std::string& name) - { - for (const auto& object : scene.GetObjects()) { - if (object->GetName() == name) { - return object.get(); - } - } - return nullptr; - } - - 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 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() -{ - testTransforms(); - testSceneFindGameObject(); - testSceneRoundTrip(); - testEventMutation(); - testEventRemoval(); - testComponentRemoval(); - testEngineComponentRegistration(); - testLineRenderingManager(); - testAssetPathValidation(); - testLegacySceneLoad(); - testSceneLoadRollback(); - testPhysicsWorldUnits(); - testJoltSphereCollisions(); - testSceneJoltSphereCollisions(); - testNonPhysicsChangesKeepBodies(); - testAnimatorAssetReferences(); - testLightComponentRoundTrip(); - testModelFileRoundTrip(); - testModelFileRejectsBadData(); - testModelFileFromImportedModel(); - testAssetManagerLoadsCookedModels(); - testTextureFileRoundTrip(); - testImageLoaderReadsCookedTextures(); - testSimpleColorMaterial(); - testAssetReferenceCacheKeys(); - testTriggerZone(); - testSceneCurrentCamera(); - testFrozenRotationCapsule(); - testPrimitiveBoxMesh(); - testColliderCenterOffset(); - testCapsuleColliderDebugLines(); - testFrustumFromViewProj(); - testKinematicBodiesMoveWithVelocity(); - testRenderer2DShapes(); - testFontAtlasAndLayout(); - testSceneSaveRejectsNonFinite(); - testSceneValidationErrors(); - testSceneLoadRestoresBindings(); - testSceneLoadDropsMissingCamera(); - testGlmJson(); - testPrimitiveMeshKeys(); - testSceneFileIdsAreLocal(); - testButtonTargetsRoundTrip(); - testButtonDropsMissingTargets(); - testPrefabRoundTrip(); - testPrefabDuplicate(); - testPrefabValidation(); - testWinGoalButton(); - testCheckpointRespawn(); - testCanvasLayout(); - testUITextPlacement(); - testHudRoundTrip(); - testHierarchyMoves(); - std::cout << "destrum tests passed\n"; - return EXIT_SUCCESS; -} diff --git a/tests/gameplay_tests.cpp b/tests/gameplay_tests.cpp new file mode 100644 index 0000000..12abec7 --- /dev/null +++ b/tests/gameplay_tests.cpp @@ -0,0 +1,325 @@ +#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(); +} diff --git a/tests/graphics_tests.cpp b/tests/graphics_tests.cpp new file mode 100644 index 0000000..d4213f3 --- /dev/null +++ b/tests/graphics_tests.cpp @@ -0,0 +1,362 @@ +#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 { + void testLineRenderingManager() + { + auto& lineManager = LineRenderingManager::GetInstance(); + lineManager.ClearLines(); + + lineManager.SubmitLine( + glm::vec3{0.0f}, + glm::vec3{1.0f, 0.0f, 0.0f}, + glm::vec4{1.0f, 0.0f, 0.0f, 1.0f}); + lineManager.SubmitLine(Line{ + .start = glm::vec3{1.0f}, + .end = glm::vec3{2.0f}, + .color = glm::vec4{0.0f, 1.0f, 0.0f, 1.0f}, + }); + + check(lineManager.GetLines().size() == 2, + "line rendering manager must retain submitted lines"); + + lineManager.ClearLines(); + check(lineManager.GetLines().empty(), + "line rendering manager must clear transient lines"); + } + + void testFrustumFromViewProj() + { + const auto lookingAlongPositiveZ = [](const glm::vec3& position, float range) { + const glm::mat4 view = glm::lookAt(position, position + glm::vec3{0.0f, 0.0f, 1.0f}, glm::vec3{0.0f, 1.0f, 0.0f}); + return glm::perspectiveRH_ZO(glm::radians(60.0f), 1.0f, 0.1f, range) * view; + }; + + const glm::mat4 viewProj = lookingAlongPositiveZ(glm::vec3{0.0f}, 20.0f); + const Frustum frustum = edge::createFrustumFromViewProj(viewProj); + + check(edge::isInFrustum(frustum, Sphere{{0.0f, 0.0f, 10.0f}, 0.5f}), "a sphere in front must be inside"); + check(!edge::isInFrustum(frustum, Sphere{{0.0f, 0.0f, -5.0f}, 0.5f}), "a sphere behind must be outside"); + check(!edge::isInFrustum(frustum, Sphere{{0.0f, 0.0f, 30.0f}, 0.5f}), "a sphere past the far plane must be outside"); + check(!edge::isInFrustum(frustum, Sphere{{15.0f, 0.0f, 10.0f}, 0.5f}), "a sphere to the side must be outside"); + + const auto corners = edge::calculateFrustumCorners(viewProj); + const bool cornersOnNearAndFar = std::ranges::all_of(corners, [](const glm::vec3& corner) { + return std::abs(corner.z - 0.1f) < 0.001f || std::abs(corner.z - 20.0f) < 0.01f; + }); + check(cornersOnNearAndFar, "frustum corners must lie on the near and far planes"); + + check(edge::frustumsOverlap(frustum, lookingAlongPositiveZ(glm::vec3{0.0f, 0.0f, 5.0f}, 10.0f)), + "a frustum in front of the camera must overlap"); + check(!edge::frustumsOverlap(frustum, lookingAlongPositiveZ(glm::vec3{0.0f, 0.0f, -30.0f}, 10.0f)), + "a frustum entirely behind the camera must not overlap"); + } + + void testRenderer2DShapes() + { + auto& r2d = Renderer2D::GetInstance(); + r2d.Clear(); + + r2d.DrawRect({10.0f, 20.0f}, {30.0f, 40.0f}, glm::vec4{0.5f}); + const Shape2D& rect = r2d.GetShapes().back(); + check(rect.type == Shape2DType::Rect && + rect.center == glm::vec2{25.0f, 40.0f} && + rect.halfSize == glm::vec2{15.0f, 20.0f} && + rect.textureId == NULL_IMAGE_ID && + rect.outline == 0.0f, + "rects must be anchored at their top left corner and filled by default"); + + r2d.DrawEllipseOutline({50.0f, 50.0f}, {20.0f, 10.0f}, 2.0f, glm::vec4{1.0f}); + const Shape2D& ellipse = r2d.GetShapes().back(); + check(ellipse.type == Shape2DType::Ellipse && + ellipse.center == glm::vec2{50.0f} && + ellipse.halfSize == glm::vec2{20.0f, 10.0f} && + ellipse.outline == 2.0f, + "ellipses must be centered with their radii as half size"); + + r2d.DrawTriangle({0.0f, 10.0f}, {5.0f, 0.0f}, {10.0f, 10.0f}, glm::vec4{1.0f}); + const Shape2D& triangle = r2d.GetShapes().back(); + check(triangle.type == Shape2DType::Triangle && + triangle.center == glm::vec2{5.0f} && + triangle.halfSize == glm::vec2{5.0f} && + triangle.p0 == glm::vec2{-5.0f, 5.0f} && + triangle.p1 == glm::vec2{0.0f, -5.0f} && + triangle.p2 == glm::vec2{5.0f, 5.0f}, + "triangle points must be stored relative to their bounding box center"); + + r2d.DrawLine({0.0f, 0.0f}, {0.0f, 10.0f}, 4.0f, glm::vec4{1.0f}, LineCap::Round); + const Shape2D& line = r2d.GetShapes().back(); + check(line.type == Shape2DType::Rect && + line.center == glm::vec2{0.0f, 5.0f} && + close(line.halfSize.x, 7.0f) && close(line.halfSize.y, 2.0f) && + close(line.rotation, glm::half_pi()) && + close(line.cornerRadius, 2.0f), + "round capped lines must be rotated rects extended by half their thickness"); + + check(r2d.GetShapes().size() == 4, "every draw call must submit exactly one shape"); + r2d.Clear(); + check(r2d.GetShapes().empty(), "clear must drop all submitted shapes"); + } + + void testFontAtlasAndLayout() + { + const std::filesystem::path fontPath = + std::filesystem::path{DESTRUM_ENGINE_ASSETS_SRC_DIR} / "fonts" / "Roboto-Medium.ttf"; + const Font font = Font::LoadFromFile(fontPath, 32.0f); + + const FontGlyph* a = font.GetGlyph(U'A'); + const FontGlyph* space = font.GetGlyph(U' '); + check(a != nullptr && a->size.x > 0.0f && a->size.y > 0.0f && a->advance > 0.0f && + a->uvMax.x > a->uvMin.x && a->uvMax.y > a->uvMin.y, + "printable glyphs must be rasterized into the atlas"); + check(space != nullptr && space->size == glm::vec2{0.0f} && space->advance > 0.0f, + "spaces must only advance the pen"); + check(font.GetGlyph(U'中') == font.GetGlyph(U'?'), "missing glyphs must fall back to '?'"); + + const auto& pixels = font.GetAtlasPixels(); + const auto [minIt, maxIt] = std::minmax_element(pixels.begin(), pixels.end()); + check(!pixels.empty() && *minIt < 128 && *maxIt > 128, + "the atlas must hold a signed distance field with the edge at 128"); + + const glm::vec2 one = font.MeasureText("A", 64.0f); + const glm::vec2 two = font.MeasureText("A\nAA", 64.0f); + check(close(one.x, a->advance * 2.0f) && close(one.y, font.GetLineHeight() * 2.0f), + "text must be measured at the requested size"); + check(close(two.x, (a->advance * 2.0f + font.GetKerning(U'A', U'A')) * 2.0f) && + close(two.y, one.y * 2.0f), + "multi line text must measure its widest line and every line"); + + std::vector placed; + font.Layout("A b", {100.0f, 50.0f}, 32.0f, [&](const PlacedGlyph& glyph) { placed.push_back(glyph); }); + check(placed.size() == 2 && placed[0].glyph == a && + close(placed[0].topLeft.x, 100.0f + a->offset.x) && + close(placed[0].topLeft.y, 50.0f + font.GetAscender() + a->offset.y), + "layout must skip spaces and start on the baseline below the top left"); + + const std::string_view utf8 = "\xC3\xA9\xFF"; + std::size_t index = 0; + check(Font::DecodeUtf8(utf8, index) == U'é' && index == 2, "utf8 must decode two byte sequences"); + check(Font::DecodeUtf8(utf8, index) == 0xFFFD && index == 3, "invalid utf8 must decode to U+FFFD"); + + auto& r2d = Renderer2D::GetInstance(); + r2d.Clear(); + r2d.DrawText(font, "not uploaded", {0.0f, 0.0f}, 16.0f, glm::vec4{1.0f}); + check(r2d.GetShapes().empty(), "text must not be drawn before the font atlas is uploaded"); + } + + void testCapsuleColliderDebugLines() + { + GameObject object{"capsule"}; + object.GetTransform().SetWorldPosition({1.0f, 2.0f, 3.0f}); + object.GetTransform().SetWorldScale(glm::vec3{5.0f}); + auto* capsule = object.AddComponent(0.4f, 1.8f); + capsule->SetCenterOffset({0.0f, 0.5f, 0.0f}); + + auto& lines = LineRenderingManager::GetInstance(); + lines.ClearLines(); + capsule->DrawDebugLines(glm::vec4{1.0f}); + + const glm::vec3 center{1.0f, 2.5f, 3.0f}; + float top = -1000.0f; + float bottom = 1000.0f; + bool inside = true; + for (const Line& line : lines.GetLines()) { + for (const glm::vec3& point : {line.start, line.end}) { + const glm::vec3 local = point - center; + top = std::max(top, local.y); + bottom = std::min(bottom, local.y); + inside = inside && glm::length(glm::vec2{local.x, local.z}) <= 0.4f + 0.001f; + } + } + lines.ClearLines(); + + check(inside && close(top, 0.9f) && close(bottom, -0.9f), + "capsule debug lines must match the collider shape in world units, ignoring the object scale"); + } + + void testPrimitiveBoxMesh() + { + const glm::vec3 size{4.0f, 1.0f, 2.0f}; + const CPUMesh box = PrimitiveMeshes::MakeBox(size); + + check(box.vertices.size() == 24 && box.indices.size() == 36, "a box must have 6 faces of 2 triangles"); + check(box.minPos == -size * 0.5f && box.maxPos == size * 0.5f, "a box must be centered on the origin"); + + float maxU = 0.0f; + bool outward = true; + for (const auto& vertex : box.vertices) { + if (vertex.normal == glm::vec3{0.0f, 0.0f, 1.0f}) { + maxU = std::max(maxU, vertex.uv_x); + } + outward = outward && glm::dot(vertex.position, vertex.normal) > 0.0f; + } + check(close(maxU, size.x), "box uvs must be in meters so textures tile at the same size"); + check(outward, "box normals must point away from the center"); + + bool counterClockwise = true; + for (std::size_t i = 0; i < box.indices.size(); i += 3) { + const auto& a = box.vertices[box.indices[i]]; + const auto& b = box.vertices[box.indices[i + 1]]; + const auto& c = box.vertices[box.indices[i + 2]]; + const glm::vec3 faceNormal = glm::cross(b.position - a.position, c.position - a.position); + counterClockwise = counterClockwise && glm::dot(faceNormal, a.normal) > 0.0f; + } + check(counterClockwise, "box triangles must be counter clockwise seen from outside"); + } + + void testSimpleColorMaterial() + { + const auto material = Material::SimpleColor( + glm::vec3{0.2f, 0.4f, 0.8f}, + "blue material"); + + check(material.baseColor == glm::vec3{0.2f, 0.4f, 0.8f}, + "simple materials must preserve their color"); + check(material.metallicFactor == 0.0f && + material.roughnessFactor == 1.0f && + material.emissiveColor == glm::vec3{0.0f}, + "simple materials must use non-metallic default factors"); + check(material.diffuseTexture == NULL_IMAGE_ID, + "simple materials must not require a diffuse image"); + check(material.name == "blue material", + "simple materials must preserve their optional name"); + } + + void testPrimitiveMeshKeys() + { + const glm::vec3 size{0.35f, 0.12f, 3.0f}; + const std::string key = PrimitiveMeshes::BoxKey(size); + check(PrimitiveMeshes::IsPrimitiveKey(key), "box keys must be primitive keys"); + check(PrimitiveMeshes::MakeBox(size).name == key, "boxes must be named by their key"); + + const auto rebuilt = PrimitiveMeshes::MakeFromKey(key + "#3"); + check(rebuilt.has_value() && rebuilt->maxPos == size * 0.5f, + "a box must rebuild exactly from its key, ignoring the duplicate suffix"); + check(PrimitiveMeshes::StripDuplicateSuffix("BulbMaterial#12") == "BulbMaterial" && + PrimitiveMeshes::StripDuplicateSuffix("game://a.glb#mesh") == "game://a.glb#mesh", + "only numeric duplicate suffixes must be stripped"); + check(!PrimitiveMeshes::MakeFromKey("prim://box?size=1,2").has_value() && + !PrimitiveMeshes::MakeFromKey("prim://box?size=1,-2,3").has_value() && + !PrimitiveMeshes::MakeFromKey("Box").has_value(), + "invalid primitive keys must not build a mesh"); + } + + void testIndirectDrawListBatchesByMesh() + { + std::vector meshes(2); + meshes[0].numIndices = 36; + meshes[0].firstIndex = 0; + meshes[0].vertexBuffer.address = 0x1000; + meshes[1].numIndices = 6; + meshes[1].firstIndex = 36; + meshes[1].vertexBuffer.address = 0x2000; + + SkinnedMesh skinned; + skinned.skinnedVertexBuffer.address = 0x3000; + + const auto makeDraw = [](MeshID mesh, MaterialID material, glm::vec3 position) { + MeshDrawCommand dc; + dc.meshId = mesh; + dc.materialId = material; + dc.transformMatrix = glm::translate(glm::mat4{1.0f}, position); + dc.worldBoundingSphere = Sphere{position, 0.5f}; + return dc; + }; + + std::vector draws{ + makeDraw(0, 1, {0.0f, 0.0f, 5.0f}), + makeDraw(1, 2, {1.0f, 0.0f, 5.0f}), + makeDraw(0, 3, {-1.0f, 0.0f, 5.0f}), + makeDraw(0, 4, {0.0f, 1.0f, 5.0f}), + makeDraw(1, 2, {0.0f, 0.0f, -50.0f}), + }; + draws[2].skinnedMesh = &skinned; + draws[3].castShadow = false; + const std::vector sorted{0, 2, 3, 1, 4}; + + const glm::mat4 view = glm::lookAt(glm::vec3{0.0f}, glm::vec3{0.0f, 0.0f, 1.0f}, glm::vec3{0.0f, 1.0f, 0.0f}); + const Frustum frustum = edge::createFrustumFromViewProj( + glm::perspectiveRH_ZO(glm::radians(90.0f), 1.0f, 0.1f, 20.0f) * view); + + IndirectDrawList list; + list.begin(0); + const auto camera = list.addView(meshes, draws, sorted, frustum, IndirectDrawList::Filter::All); + const auto shadow = list.addView(meshes, draws, sorted, frustum, IndirectDrawList::Filter::ShadowCasters); + + const auto& commands = list.getCommands(); + const auto& instances = list.getInstances(); + + check(camera.firstCommand == 0 && camera.commandCount == 2 && camera.instanceCount == 4, + "a view must merge draws of the same mesh into one command and cull the rest"); + check(commands[0].indexCount == 36 && commands[0].firstIndex == 0 && commands[0].instanceCount == 3 && + commands[0].firstInstance == 0, + "the first batch must cover the three visible draws of mesh 0"); + check(commands[1].indexCount == 6 && commands[1].firstIndex == 36 && commands[1].instanceCount == 1 && + commands[1].firstInstance == 3, + "the second batch must start after the first batch's instances"); + check(instances[0].materialId == 1 && instances[1].materialId == 3 && instances[2].materialId == 4, + "materials must be per instance so they don't split batches"); + check(instances[0].vertexBuffer == 0x1000 && instances[1].vertexBuffer == 0x3000, + "skinned draws must point at their own skinned vertex buffer"); + + check(shadow.firstCommand == 2 && shadow.commandCount == 2 && shadow.instanceCount == 3, + "the shadow view must skip non-casters and get its own command range"); + check(commands[2].instanceCount == 2 && commands[2].firstInstance == 4, + "shadow instances must follow the camera view's instances"); + } + + void testDeletionQueueWaitsForFrame() + { + DeletionQueue queue; + std::vector deleted; + queue.push(3, [&] { deleted.push_back(1); }); + queue.push(3, [&] { deleted.push_back(2); }); + queue.push(5, [&] { deleted.push_back(3); }); + + queue.flush(2); + check(deleted.empty(), "resources retired in frame 3 must survive until frame 3 completes"); + + queue.flush(3); + check(deleted == std::vector{1, 2}, "deleters must run in retirement order once their frame completes"); + check(queue.size() == 1, "later frames must stay queued"); + + queue.flushAll(); + check(deleted.size() == 3 && queue.empty(), "flushAll must run every pending deleter"); + } +} + +void RunGraphicsTests() +{ + testLineRenderingManager(); + testFrustumFromViewProj(); + testRenderer2DShapes(); + testFontAtlasAndLayout(); + testCapsuleColliderDebugLines(); + testPrimitiveBoxMesh(); + testSimpleColorMaterial(); + testPrimitiveMeshKeys(); + testDeletionQueueWaitsForFrame(); + testIndirectDrawListBatchesByMesh(); +} diff --git a/tests/main.cpp b/tests/main.cpp new file mode 100644 index 0000000..ca7d102 --- /dev/null +++ b/tests/main.cpp @@ -0,0 +1,49 @@ +#include +#include +#include + +void RunCoreTests(); +void RunSerializationTests(); +void RunPhysicsTests(); +void RunAssetsTests(); +void RunGraphicsTests(); +void RunUiTests(); +void RunGameplayTests(); + +namespace { + struct Suite { + const char* name; + void (*run)(); + }; + + constexpr Suite suites[] = { + {"core", RunCoreTests}, + {"serialization", RunSerializationTests}, + {"physics", RunPhysicsTests}, + {"assets", RunAssetsTests}, + {"graphics", RunGraphicsTests}, + {"ui", RunUiTests}, + {"gameplay", RunGameplayTests}, + }; +} + +int main(int argc, char* argv[]) +{ + const char* only = argc > 1 ? argv[1] : nullptr; + bool ranAny = false; + + for (const Suite& suite : suites) { + if (only != nullptr && std::strcmp(only, suite.name) != 0) { + continue; + } + suite.run(); + ranAny = true; + std::cout << suite.name << " tests passed\n"; + } + + if (!ranAny) { + std::cerr << "unknown test suite: " << only << '\n'; + return EXIT_FAILURE; + } + return EXIT_SUCCESS; +} diff --git a/tests/physics_tests.cpp b/tests/physics_tests.cpp new file mode 100644 index 0000000..5062a8e --- /dev/null +++ b/tests/physics_tests.cpp @@ -0,0 +1,372 @@ +#include +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "TestUtils.h" + +using namespace test; + +namespace { + void testPhysicsWorldUnits() + { + JoltPhysicsWorld world{{ + .workerThreadCount = 1, + .gravity = glm::vec3{0.0f} + }}; + GameObject object{"scaled-physics"}; + object.GetTransform().SetLocalScale(glm::vec3{0.5f}); + object.AddComponent(1.0f); + Rigidbody* rigidbody = object.AddComponent(); + world.RegisterRigidbody(*rigidbody); + + PhysicsRaycastHit hit; + check(world.Raycast({-2.0f, 0.0f, 0.0f}, {1.0f, 0.0f, 0.0f}, 5.0f, hit), + "scaled physics bodies must remain raycastable"); + check(close(hit.distance, 1.0f), + "physics collider dimensions must remain in world units"); + + object.GetTransform().SetLocalPosition({10.0f, 0.0f, 0.0f}); + world.RefreshRigidbody(*rigidbody); + check(world.Raycast({8.0f, 0.0f, 0.0f}, {1.0f, 0.0f, 0.0f}, 5.0f, hit), + "refreshing a physics body must use the current transform position"); + check(close(hit.distance, 1.0f), + "refreshed physics bodies must remain aligned with rendered transforms"); + } + + void testJoltSphereCollisions() + { + JoltPhysicsWorld world{{ + .workerThreadCount = 1, + .gravity = {0.0f, -9.81f, 0.0f} + }}; + + GameObject floor{"floor"}; + floor.GetTransform().SetLocalPosition({0.0f, -1.0f, 0.0f}); + floor.AddComponent(glm::vec3{10.0f, 0.5f, 10.0f}); + auto* floorBody = floor.AddComponent(); + floorBody->SetType(RigidbodyType::Static); + world.RegisterRigidbody(*floorBody); + + GameObject first{"first"}; + first.AddComponent(1.5f); + auto* firstBody = first.AddComponent(); + world.RegisterRigidbody(*firstBody); + first.GetTransform().SetLocalPosition({0.0f, 100.0f, 0.0f}); + first.GetTransform().SetLocalScale(glm::vec3{0.015f}); + world.RefreshRigidbody(*firstBody); + + GameObject second{"second"}; + second.AddComponent(1.5f); + auto* secondBody = second.AddComponent(); + world.RegisterRigidbody(*secondBody); + second.GetTransform().SetLocalPosition({0.0f, 100.0f, 0.0f}); + second.GetTransform().SetLocalScale(glm::vec3{0.015f}); + world.RefreshRigidbody(*secondBody); + + for (int step = 0; step < 240; ++step) { + world.Step(1.0f / 60.0f); + world.SyncDynamicBodiesToTransforms(); + } + + check(first.GetTransform().GetWorldPosition().y > 0.8f && + second.GetTransform().GetWorldPosition().y > 0.8f, + "Jolt spheres must collide with the ground plane"); + check(glm::distance(first.GetTransform().GetWorldPosition(), + second.GetTransform().GetWorldPosition()) > 2.5f, + "Jolt spheres must collide with one another instead of occupying the same point"); + } + + void testSceneJoltSphereCollisions() + { + Scene& scene = SceneManager::GetInstance().CreateScene("scene-physics"); + + GameObject* floor = scene.CreateGameObject("floor"); + floor->GetTransform().SetLocalPosition({0.0f, -1.0f, 0.0f}); + floor->AddComponent(glm::vec3{10.0f, 0.5f, 10.0f}); + auto* floorBody = floor->AddComponent(); + floorBody->SetType(RigidbodyType::Static); + + GameObject* first = scene.CreateGameObject("first"); + first->AddComponent(1.5f); + first->AddComponent(); + first->GetTransform().SetWorldPosition({0.0f, 100.0f, 0.0f}); + first->GetTransform().SetWorldScale(glm::vec3{0.015f}); + + GameObject* second = scene.CreateGameObject("second"); + second->AddComponent(1.5f); + second->AddComponent(); + second->GetTransform().SetWorldPosition({0.0f, 100.0f, 0.0f}); + second->GetTransform().SetWorldScale(glm::vec3{0.015f}); + + scene.CommitPendingAdditions(); + scene.GetPhysics().RefreshGameObject(*first); + scene.GetPhysics().RefreshGameObject(*second); + + for (int step = 0; step < 240; ++step) { + scene.FixedUpdate(1.0f / 60.0f); + } + + check(first->GetTransform().GetWorldPosition().y > 0.8f && + second->GetTransform().GetWorldPosition().y > 0.8f, + "scene physics spheres must collide with the ground plane"); + check(glm::distance(first->GetTransform().GetWorldPosition(), + second->GetTransform().GetWorldPosition()) > 2.5f, + "scene physics spheres must not overlap after settling"); + + SceneManager::GetInstance().Destroy(); + } + + class TriggerCounter final : public Component { + public: + explicit TriggerCounter(GameObject& parent) + : Component(parent, "TriggerCounter") {} + + std::string GetTypeName() const override { return "TriggerCounter"; } + void OnTriggerEnter(GameObject*) override { ++enters; } + void OnTriggerExit(GameObject*) override { ++exits; } + + int enters{0}; + int exits{0}; + }; + + void testNonPhysicsChangesKeepBodies() + { + Scene& scene = SceneManager::GetInstance().CreateScene("physics-refresh"); + + GameObject* trigger = scene.CreateGameObject("trigger"); + trigger->AddComponent(glm::vec3{3.0f})->SetTrigger(true); + trigger->AddComponent()->SetType(RigidbodyType::Static); + auto* counter = trigger->AddComponent(); + + GameObject* crate = scene.CreateGameObject("crate"); + crate->AddComponent(glm::vec3{0.5f}); + auto* crateBody = crate->AddComponent(); + crateBody->SetUseGravity(false); + crateBody->SetAllowSleep(false); + + GameObject* lamp = scene.CreateGameObject("lamp"); + auto* light = lamp->AddComponent(); + + scene.CommitPendingAdditions(); + scene.GetPhysics().RefreshGameObject(*trigger); + scene.GetPhysics().RefreshGameObject(*crate); + + const auto step = [&scene] { + for (int i = 0; i < 10; ++i) { + scene.FixedUpdate(1.0f / 60.0f); + } + }; + step(); + check(counter->enters == 1 && counter->exits == 0, "a body inside a trigger must enter it once"); + + light->SetEnabled(false); + light->SetEnabled(true); + crate->AddComponent()->SetEnabled(false); + step(); + check(counter->enters == 1 && counter->exits == 0, + "non physics components must not rebuild physics bodies"); + + crateBody->SetMass(5.0f); + step(); + check(counter->enters == 2 && counter->exits == 1, + "rebuilding a body inside a trigger must exit the old body and enter the new one"); + + SceneManager::GetInstance().Destroy(); + } + + void testKinematicBodiesMoveWithVelocity() + { + constexpr float fixedDt = 1.0f / 60.0f; + Scene& scene = SceneManager::GetInstance().CreateScene("kinematic-velocity"); + + GameObject* platform = scene.CreateGameObject("platform"); + platform->AddComponent(glm::vec3{2.0f, 0.25f, 2.0f}); + auto* platformBody = platform->AddComponent(); + platformBody->SetType(RigidbodyType::Kinematic); + + GameObject* crate = scene.CreateGameObject("crate"); + crate->GetTransform().SetWorldPosition({0.0f, 0.76f, 0.0f}); + crate->AddComponent(glm::vec3{0.5f}); + auto* crateBody = crate->AddComponent(); + crateBody->SetFriction(1.0f); + crateBody->SetAllowSleep(false); + + scene.CommitPendingAdditions(); + scene.GetPhysics().RefreshGameObject(*platform); + scene.GetPhysics().RefreshGameObject(*crate); + + for (int step = 0; step < 30; ++step) { + scene.FixedUpdate(fixedDt); + } + check(glm::length(platformBody->GetLinearVelocity()) < 0.001f, + "a kinematic body that is not moved must have no velocity"); + + constexpr float platformSpeed = 2.0f; + for (int step = 0; step < 60; ++step) { + platform->GetTransform().SetWorldPosition( + platform->GetTransform().GetWorldPosition() + glm::vec3{platformSpeed * fixedDt, 0.0f, 0.0f}); + scene.FixedUpdate(fixedDt); + } + + check(close(platformBody->GetLinearVelocity().x, platformSpeed), + "a kinematic body moved through its transform must get the matching velocity"); + check(crate->GetTransform().GetWorldPosition().x > 1.5f, + "a dynamic body resting on a moving kinematic platform must be carried along"); + + SceneManager::GetInstance().Destroy(); + } + + void testColliderCenterOffset() + { + Scene& scene = SceneManager::GetInstance().CreateScene("collider-offset"); + + GameObject* box = scene.CreateGameObject("offset box"); + box->AddComponent(glm::vec3{0.5f})->SetCenterOffset({0.0f, 5.0f, 0.0f}); + box->AddComponent()->SetType(RigidbodyType::Static); + scene.CommitPendingAdditions(); + scene.GetPhysics().RefreshGameObject(*box); + + PhysicsRaycastHit hit{}; + const bool found = box->GetComponent()->GetPhysicsWorld()->Raycast( + {0.0f, 10.0f, 0.0f}, {0.0f, -1.0f, 0.0f}, 20.0f, hit); + check(found && std::abs(hit.distance - 4.5f) < 0.01f, + "a collider center offset must move the shape, not just the center of mass"); + + SceneManager::GetInstance().Destroy(); + } + + void testFrozenRotationCapsule() + { + Scene& scene = SceneManager::GetInstance().CreateScene("frozen-capsule"); + + GameObject* floor = scene.CreateGameObject("floor"); + floor->GetTransform().SetLocalPosition({0.0f, -1.0f, 0.0f}); + floor->AddComponent(glm::vec3{10.0f, 0.5f, 10.0f}); + floor->AddComponent()->SetType(RigidbodyType::Static); + + GameObject* character = scene.CreateGameObject("character"); + character->AddComponent(0.4f, 1.8f); + auto* body = character->AddComponent(); + body->SetFriction(1.0f); + body->SetFreezeRotation(true); + character->GetTransform().SetWorldPosition({0.0f, 1.0f, 0.0f}); + + GameObject* trigger = scene.CreateGameObject("trigger"); + trigger->GetTransform().SetWorldPosition({2.0f, 0.0f, 0.0f}); + trigger->AddComponent(glm::vec3{2.0f})->SetTrigger(true); + trigger->AddComponent()->SetType(RigidbodyType::Static); + + scene.CommitPendingAdditions(); + scene.GetPhysics().RefreshGameObject(*floor); + scene.GetPhysics().RefreshGameObject(*character); + scene.GetPhysics().RefreshGameObject(*trigger); + + // Sliding with friction would tip a free capsule over + body->SetLinearVelocity({4.0f, 0.0f, 0.0f}); + for (int step = 0; step < 180; ++step) { + scene.FixedUpdate(1.0f / 60.0f); + } + + const glm::vec3 position = character->GetTransform().GetWorldPosition(); + const glm::quat rotation = character->GetTransform().GetWorldRotation(); + check(std::abs(rotation.w) > 0.999f, "a capsule with frozen rotation must stay upright"); + check(std::abs(position.y - (-0.5f + 0.9f)) < 0.05f, "a capsule must rest on top of the floor"); + + // Cast from inside the capsule (and the trigger it is standing in), like a ground check does + PhysicsRaycastHit hit{}; + const bool hitGround = body->GetPhysicsWorld()->Raycast( + position - glm::vec3{0.0f, 0.7f, 0.0f}, glm::vec3{0.0f, -1.0f, 0.0f}, 0.3f, hit, body->GetBody()); + check(hitGround && hit.object == floor && hit.normal.y > 0.9f, + "a ground ray must skip the caster and triggers and hit the floor"); + + PhysicsRaycastHit selfHit{}; + check(body->GetPhysicsWorld()->Raycast( + position - glm::vec3{0.0f, 0.7f, 0.0f}, glm::vec3{0.0f, -1.0f, 0.0f}, 0.3f, selfHit) && + selfHit.object == character, + "a ray that doesn't ignore the caster must hit it"); + + SceneManager::GetInstance().Destroy(); + } + + class TriggerTracker final : public Component { + public: + explicit TriggerTracker(GameObject& owner) + : Component(owner, "TriggerTracker") {} + + void Update(float) override {} + std::string GetTypeName() const override { return "TriggerTracker"; } + + void OnTriggerEnter(GameObject* other) override { + ++enterCount; + lastOther = other; + enterObjectName = other ? other->GetName() : "null"; + } + + void OnTriggerExit(GameObject*) override { + ++exitCount; + } + + int enterCount{0}; + int exitCount{0}; + GameObject* lastOther{nullptr}; + std::string enterObjectName; + }; + + void testTriggerZone() + { + Scene& scene = SceneManager::GetInstance().CreateScene("trigger-test"); + + // Trigger box at origin (Static rigidbody + BoxCollider as trigger). + GameObject* triggerObj = scene.CreateGameObject("TriggerZone"); + triggerObj->AddComponent(glm::vec3{3.0f, 3.0f, 3.0f}); + triggerObj->GetComponent()->SetTrigger(true); + auto* triggerRb = triggerObj->AddComponent(); + triggerRb->SetType(RigidbodyType::Static); + auto* tracker = triggerObj->AddComponent(); + + // Falling sphere above the trigger. + GameObject* sphere = scene.CreateGameObject("FallingSphere"); + sphere->AddComponent(0.5f); + sphere->AddComponent(); + sphere->GetTransform().SetWorldPosition({0.0f, 8.0f, 0.0f}); + + scene.CommitPendingAdditions(); + + // Run physics for enough steps that the sphere falls into the trigger. + for (int step = 0; step < 120; ++step) { + scene.FixedUpdate(1.0f / 60.0f); + } + + check(tracker->enterCount >= 1, + "falling sphere must trigger OnTriggerEnter at least once"); + check(tracker->exitCount >= 1, + "falling sphere passing through trigger must exit"); + check(tracker->enterObjectName == "FallingSphere", + "OnTriggerEnter must report the correct entering object"); + + SceneManager::GetInstance().Destroy(); + } +} + +void RunPhysicsTests() +{ + testPhysicsWorldUnits(); + testJoltSphereCollisions(); + testSceneJoltSphereCollisions(); + testNonPhysicsChangesKeepBodies(); + testKinematicBodiesMoveWithVelocity(); + testColliderCenterOffset(); + testFrozenRotationCapsule(); + testTriggerZone(); +} diff --git a/tests/serialization_tests.cpp b/tests/serialization_tests.cpp new file mode 100644 index 0000000..286ad84 --- /dev/null +++ b/tests/serialization_tests.cpp @@ -0,0 +1,298 @@ +#include +#include +#include +#include +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "TestUtils.h" + +using namespace test; + +namespace { + void testSceneRoundTrip() + { + Scene& scene = SceneManager::GetInstance().CreateScene("round-trip"); + GameObject* parent = scene.CreateGameObject("parent"); + GameObject* child = scene.CreateGameObject("child"); + child->GetTransform().SetLocalPosition({1.f, 2.f, 3.f}); + child->GetTransform().SetParent(&parent->GetTransform(), false); + scene.CommitPendingAdditions(); + + SceneEnvironment& environment = scene.GetEnvironment(); + environment.ambientColor = {0.2f, 0.3f, 0.4f}; + environment.ambientIntensity = 2.0f; + environment.fogColor = {0.6f, 0.7f, 0.8f}; + environment.fogDensity = 0.05f; + environment.skyboxRotationSpeed = 0.5f; + environment.skyboxRotation = 1.0f; + scene.Update(1.0f); + check(close(environment.skyboxRotation, 1.5f), "scene updates must spin the skybox"); + + const auto path = std::filesystem::temp_directory_path() / "destrum_scene_test.json"; + check(SceneSerializer::Save(scene, path), "scene save must succeed"); + check(SceneSerializer::Load(scene, path), "scene load must succeed"); + std::filesystem::remove(path); + + check(scene.GetObjects().size() == 2, "scene round-trip must preserve objects"); + const auto* loadedChild = scene.GetObjects().at(1).get(); + check(loadedChild->GetTransform().GetParent() != nullptr, + "scene round-trip must preserve hierarchy"); + check(close(loadedChild->GetTransform().GetLocalPosition().x, 1.f), + "scene round-trip must preserve local transforms"); + const SceneEnvironment& loadedEnvironment = scene.GetEnvironment(); + check(loadedEnvironment.ambientColor == glm::vec3{0.2f, 0.3f, 0.4f} && + close(loadedEnvironment.ambientIntensity, 2.0f) && + loadedEnvironment.fogColor == glm::vec3{0.6f, 0.7f, 0.8f} && + close(loadedEnvironment.fogDensity, 0.05f) && + close(loadedEnvironment.skyboxRotationSpeed, 0.5f) && + close(loadedEnvironment.skyboxRotation, 1.5f), + "scene round-trip must preserve the environment settings"); + + SceneManager::GetInstance().Destroy(); + } + + void testLegacySceneLoad() + { + Scene& scene = SceneManager::GetInstance().CreateScene("legacy"); + const auto path = std::filesystem::temp_directory_path() / "destrum_legacy_scene.json"; + std::ofstream{path} + << R"({"version":1,"name":"legacy","objects":[{"id":9001,"name":"object","active":true,"transform":{},"parent":0,"components":[]}]})"; + + check(SceneSerializer::Load(scene, path), + "version 1 placeholder transforms must remain loadable"); + check(scene.GetObjects().size() == 1, + "legacy scene load must create its object"); + + std::filesystem::remove(path); + SceneManager::GetInstance().Destroy(); + } + + void testSceneLoadRollback() + { + Scene& scene = SceneManager::GetInstance().CreateScene("rollback"); + scene.CreateGameObject("original"); + scene.CommitPendingAdditions(); + + const auto path = std::filesystem::temp_directory_path() / "destrum_bad_scene.json"; + std::ofstream{path} + << R"({"version":2,"name":"bad","objects":[{"id":9101,"name":"bad","transform":{"position":[0,0,0],"rotation":[0,0,0,1],"scale":[1,1,1]},"parent":0,"components":[{"type":"Rigidbody","enabled":true,"data":{}}]}]})"; + + check(!SceneSerializer::Load(scene, path), + "scene loading must reject a rigidbody without a collider"); + check(scene.GetObjects().size() == 1 && scene.GetObjects().at(0)->GetName() == "original", + "failed scene loading must preserve the live scene"); + + std::filesystem::remove(path); + SceneManager::GetInstance().Destroy(); + } + + void testLightComponentRoundTrip() + { + GameObject object{"light"}; + auto* light = object.AddComponent(LightType::Spot); + light->SetColor(glm::vec3{1.0f, 0.5f, 0.25f}); + light->SetIntensity(4.0f); + light->SetRange(7.5f); + light->SetConeAngles(0.2f, 0.4f); + light->SetCastShadows(true); + + const auto data = light->Serialize(); + + GameObject loadedObject{"loaded light"}; + auto* loaded = loadedObject.AddComponent(); + loaded->Deserialize(data); + + check(loaded->GetType() == LightType::Spot, + "LightComponent must round-trip its type"); + check(loaded->GetColor() == glm::vec3{1.0f, 0.5f, 0.25f}, + "LightComponent must round-trip its color"); + check(loaded->GetIntensity() == 4.0f && loaded->GetRange() == 7.5f, + "LightComponent must round-trip intensity and range"); + check(loaded->GetInnerConeAngle() == 0.2f && loaded->GetOuterConeAngle() == 0.4f, + "LightComponent must round-trip its cone angles"); + check(loaded->GetCastShadows(), + "LightComponent must round-trip its cast shadows flag"); + + loaded->SetConeAngles(0.5f, 0.1f); + check(loaded->GetOuterConeAngle() >= loaded->GetInnerConeAngle(), + "LightComponent outer cone must never be smaller than the inner cone"); + + loaded->SetRange(0.0f); + check(loaded->GetRange() > 0.0f, + "LightComponent range must stay positive"); + } + + void testSceneSaveRejectsNonFinite() + { + Scene& scene = SceneManager::GetInstance().CreateScene("non-finite"); + GameObject* object = scene.CreateGameObject("object"); + scene.CommitPendingAdditions(); + + const auto path = std::filesystem::temp_directory_path() / "destrum_non_finite_scene.json"; + check(SceneSerializer::Save(scene, path), "a finite scene must save"); + const std::string goodContents = readFile(path); + + object->GetTransform().SetLocalPosition({std::nanf(""), 0.0f, 0.0f}); + check(!SceneSerializer::Save(scene, path), "a scene with NaN values must not save"); + check(readFile(path) == goodContents, "a failed save must leave the previous file untouched"); + check(!std::filesystem::exists(std::filesystem::path{path} += ".tmp"), + "a failed save must not leave its temp file behind"); + + std::filesystem::remove(path); + SceneManager::GetInstance().Destroy(); + } + + void testSceneValidationErrors() + { + const auto path = std::filesystem::temp_directory_path() / "destrum_invalid_scene.json"; + std::ofstream{path} + << R"({"version":2,"name":"bad","objects":[{"id":7,"name":"Lamp","transform":{"position":[0,0,0],"rotation":[0,0,0,1],"scale":[1,1,1]},"parent":0,"components":[{"type":"NotAComponent","enabled":true,"data":{}}]}]})"; + + const auto result = SceneSerializer::LoadSceneFile(path); + check(!result.success, "a scene with an unknown component must not load"); + check(result.errorMessage.find("\"Lamp\"") != std::string::npos && + result.errorMessage.find("NotAComponent") != std::string::npos && + result.errorMessage.find("unknown component type") != std::string::npos, + "scene validation errors must name the object, component and problem"); + + std::ofstream{path} + << R"({"version":2,"objects":[{"id":7,"name":"Lamp","components":[{"type":"Spinner","data":["message","hi"]}]}]})"; + const auto arrayData = SceneSerializer::LoadSceneFile(path); + check(!arrayData.success && arrayData.errorMessage.find("must be an object") != std::string::npos, + "component data that is not an object must be rejected with a reason"); + + std::filesystem::remove(path); + } + + void testSceneLoadRestoresBindings() + { + Scene& scene = SceneManager::GetInstance().CreateScene("bindings"); + int registered = 0; + int unregistered = 0; + scene.SetRegisterBindings([®istered] { ++registered; }); + scene.SetUnregisterBindings([&unregistered] { ++unregistered; }); + scene.Load(); + const int registeredBefore = registered; + const int unregisteredBefore = unregistered; + scene.CreateGameObject("object"); + scene.CommitPendingAdditions(); + + const auto path = std::filesystem::temp_directory_path() / "destrum_bindings_scene.json"; + check(SceneSerializer::Save(scene, path), "the bindings scene must save"); + check(SceneSerializer::Load(scene, path), "the bindings scene must load"); + std::filesystem::remove(path); + + check(registered - registeredBefore == 1 && unregistered - unregisteredBefore == 1, "loading a scene in place must register its bindings again"); + + scene.SetRegisterBindings(nullptr); + scene.SetUnregisterBindings(nullptr); + SceneManager::GetInstance().Destroy(); + } + + void testSceneLoadDropsMissingCamera() + { + Scene& scene = SceneManager::GetInstance().CreateScene("missing camera"); + const auto path = std::filesystem::temp_directory_path() / "destrum_missing_camera_scene.json"; + std::ofstream{path} + << R"({"version":2,"currentCamera":4242,"objects":[{"id":11,"name":"object"}]})"; + + check(SceneSerializer::Load(scene, path), "a scene with a dangling camera id must still load"); + check(scene.GetCurrentCamera() == nullptr, "a dangling current camera id must be ignored"); + + std::filesystem::remove(path); + SceneManager::GetInstance().Destroy(); + } + + void testGlmJson() + { + const nlohmann::json vec = glm::vec3{1.0f, 2.5f, -3.0f}; + check(vec == nlohmann::json::array({1.0f, 2.5f, -3.0f}), "vec3 must serialize as a 3 number array"); + check(vec.get() == glm::vec3{1.0f, 2.5f, -3.0f}, "vec3 must round-trip through json"); + + const glm::quat rotation = glm::angleAxis(0.5f, glm::vec3{0.0f, 1.0f, 0.0f}); + const nlohmann::json quat = rotation; + check(quat.size() == 4 && close(quat.at(3).get(), rotation.w), + "quaternions must serialize as [x, y, z, w]"); + check(quat.get() == rotation, "quaternions must round-trip through json"); + + bool rejected = false; + try { + (void)nlohmann::json::array({1.0f, 2.0f}).get(); + } catch (const std::exception&) { + rejected = true; + } + check(rejected, "a vec3 must reject arrays of the wrong size"); + + const nlohmann::json data = {{"axis", nlohmann::json::array({0, 1, 0})}}; + check(data.value("axis", glm::vec3{0.0f}) == glm::vec3{0.0f, 1.0f, 0.0f} && + data.value("missing", glm::vec3{7.0f}) == glm::vec3{7.0f}, + "json value() must work with glm defaults"); + } + + void testSceneFileIdsAreLocal() + { + const auto path = std::filesystem::temp_directory_path() / "destrum_local_ids_scene.json"; + std::ofstream{path} + << R"({"version":2,"currentCamera":2,"objects":[)" + R"({"id":1,"name":"root"},)" + R"({"id":2,"name":"child","parent":1,"components":[{"type":"CameraComponent"}]}]})"; + + Scene& first = SceneManager::GetInstance().CreateScene("local ids a"); + Scene& second = SceneManager::GetInstance().CreateScene("local ids b"); + check(SceneSerializer::Load(first, path), "the first copy of the scene must load"); + check(SceneSerializer::Load(second, path), "the second copy of the scene must load"); + std::filesystem::remove(path); + first.CommitPendingAdditions(); + second.CommitPendingAdditions(); + + std::unordered_set ids; + std::size_t count = 0; + for (const Scene* scene : {&first, &second}) { + for (const auto& object : scene->GetObjects()) { + ids.insert(object->GetId()); + ++count; + } + } + check(count == 4 && ids.size() == 4, "loading one file into two scenes must not duplicate ids"); + + for (const Scene* scene : {&first, &second}) { + const CameraComponent* camera = scene->GetCurrentCamera(); + check(camera != nullptr && camera->GetGameObject()->GetName() == "child" && + camera->GetGameObject()->GetScene() == scene, + "the current camera must be remapped to the object in its own scene"); + const Transform* parent = camera->GetGameObject()->GetTransform().GetParent(); + check(parent != nullptr && parent->GetOwner()->GetName() == "root" && + parent->GetOwner()->GetScene() == scene, + "parents must be remapped within each loaded scene"); + } + + const GameObject* fresh = first.CreateGameObject("fresh"); + check(!ids.contains(fresh->GetId()), "objects created after a load must get unused ids"); + + SceneManager::GetInstance().Destroy(); + } +} + +void RunSerializationTests() +{ + testSceneRoundTrip(); + testLegacySceneLoad(); + testSceneLoadRollback(); + testLightComponentRoundTrip(); + testSceneSaveRejectsNonFinite(); + testSceneValidationErrors(); + testSceneLoadRestoresBindings(); + testSceneLoadDropsMissingCamera(); + testGlmJson(); + testSceneFileIdsAreLocal(); +} diff --git a/tests/ui_tests.cpp b/tests/ui_tests.cpp new file mode 100644 index 0000000..b65e246 --- /dev/null +++ b/tests/ui_tests.cpp @@ -0,0 +1,218 @@ +#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 { + 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 RunUiTests() +{ + testCanvasLayout(); + testUITextPlacement(); + testHudRoundTrip(); +}