feat: add basic canvas / improve inspector
This commit is contained in:
+324
-1
@@ -36,6 +36,10 @@
|
||||
#include <destrum/Components/Physics/Collider.h>
|
||||
#include <destrum/Components/Animator.h>
|
||||
#include <destrum/Components/CameraComponent.h>
|
||||
#include <destrum/Components/UI/Canvas.h>
|
||||
#include <destrum/Components/UI/RectTransform.h>
|
||||
#include <destrum/Components/UI/UIImage.h>
|
||||
#include <destrum/Components/UI/UIText.h>
|
||||
#include <destrum/Graphics/Resources/PrimitiveMeshes.h>
|
||||
#include <destrum/Graphics/Managers/Renderer2D.h>
|
||||
#include <destrum/Graphics/Resources/Font.h>
|
||||
@@ -49,6 +53,12 @@
|
||||
|
||||
#include <components/Activatable.h>
|
||||
#include <components/InteractableButton.h>
|
||||
#include <components/Checkpoint.h>
|
||||
#include <components/CrosshairUI.h>
|
||||
#include <components/FirstPersonController.h>
|
||||
#include <components/PlayerRespawn.h>
|
||||
#include <components/WinGoal.h>
|
||||
#include <components/WinScreenUI.h>
|
||||
|
||||
namespace {
|
||||
class TestComponent final : public Component {
|
||||
@@ -252,7 +262,11 @@ namespace {
|
||||
"Rigidbody",
|
||||
"BoxCollider",
|
||||
"SphereCollider",
|
||||
"CapsuleCollider"
|
||||
"CapsuleCollider",
|
||||
"Canvas",
|
||||
"RectTransform",
|
||||
"UIImage",
|
||||
"UIText"
|
||||
};
|
||||
for (const char* name : canonicalNames) {
|
||||
check(ComponentFactory::IsRegistered(name),
|
||||
@@ -1745,6 +1759,309 @@ namespace {
|
||||
const json future = json::parse(R"({"version":99,"objects":[{"id":1}]})");
|
||||
check(PrefabSerializer::Validate(future).has_value(), "an unknown prefab version must be rejected");
|
||||
}
|
||||
|
||||
void testWinGoalButton()
|
||||
{
|
||||
ComponentFactory::Register<InteractableButton>("InteractableButton");
|
||||
ComponentFactory::Register<WinGoal>("WinGoal");
|
||||
|
||||
Scene& scene = SceneManager::GetInstance().CreateScene("win goal");
|
||||
GameObject* goal = scene.CreateGameObject("goal");
|
||||
auto* winGoal = goal->AddComponent<WinGoal>();
|
||||
GameObject* buttonObject = scene.CreateGameObject("button");
|
||||
auto* button = buttonObject->AddComponent<InteractableButton>();
|
||||
button->SetCycleColors(false);
|
||||
button->AddTarget(*goal);
|
||||
scene.CommitPendingAdditions();
|
||||
|
||||
check(!winGoal->IsReached(), "the goal must start unreached");
|
||||
button->Interact(*buttonObject);
|
||||
check(winGoal->IsReached(), "pressing the win button must reach the goal");
|
||||
|
||||
const auto path = std::filesystem::temp_directory_path() / "destrum_win_goal_scene.json";
|
||||
check(SceneSerializer::Save(scene, path), "the win goal scene must save");
|
||||
check(SceneSerializer::Load(scene, path), "the win goal scene must load");
|
||||
std::filesystem::remove(path);
|
||||
scene.CommitPendingAdditions();
|
||||
const GameObject* loadedGoal = findByName(scene, "goal");
|
||||
check(loadedGoal != nullptr && loadedGoal->GetComponent<WinGoal>()->IsReached(),
|
||||
"a reached goal must stay reached after a round-trip");
|
||||
|
||||
SceneManager::GetInstance().Destroy();
|
||||
}
|
||||
|
||||
void testCheckpointRespawn()
|
||||
{
|
||||
Scene& scene = SceneManager::GetInstance().CreateScene("respawn");
|
||||
GameObject* player = scene.CreateGameObject("player");
|
||||
player->GetTransform().SetWorldPosition(glm::vec3{1.0f, 2.0f, 3.0f});
|
||||
player->AddComponent<CapsuleCollider>(0.4f, 1.8f);
|
||||
auto* rigidbody = player->AddComponent<Rigidbody>();
|
||||
auto* respawn = player->AddComponent<PlayerRespawn>();
|
||||
respawn->SetKillHeight(-10.0f);
|
||||
|
||||
GameObject* checkpointObject = scene.CreateGameObject("checkpoint");
|
||||
checkpointObject->GetTransform().SetWorldPosition(glm::vec3{-5.0f, 4.0f, 0.0f});
|
||||
auto* checkpoint = checkpointObject->AddComponent<Checkpoint>();
|
||||
checkpoint->SetSpawnOffset({0.0f, 0.5f, 0.0f});
|
||||
scene.CommitPendingAdditions();
|
||||
check(rigidbody->HasPhysicsBody(), "the player must have a physics body");
|
||||
|
||||
respawn->Start();
|
||||
check(respawn->GetRespawnPoint() == glm::vec3(1.0f, 2.0f, 3.0f),
|
||||
"the respawn point must default to where the player starts");
|
||||
|
||||
checkpoint->OnTriggerEnter(checkpointObject);
|
||||
check(respawn->GetRespawnPoint() == glm::vec3(1.0f, 2.0f, 3.0f),
|
||||
"a checkpoint must ignore objects without PlayerRespawn");
|
||||
checkpoint->OnTriggerEnter(player);
|
||||
check(respawn->GetRespawnPoint() == glm::vec3(-5.0f, 4.5f, 0.0f),
|
||||
"touching a checkpoint must move the respawn point");
|
||||
|
||||
rigidbody->Teleport(glm::vec3{0.0f, -20.0f, 0.0f});
|
||||
rigidbody->SetLinearVelocity(glm::vec3{0.0f, -30.0f, 0.0f});
|
||||
respawn->FixedUpdate(1.0f / 60.0f);
|
||||
const glm::vec3 position = player->GetTransform().GetWorldPosition();
|
||||
check(close(position.x, -5.0f) && close(position.y, 4.5f) && close(position.z, 0.0f),
|
||||
"falling below the kill height must respawn at the checkpoint");
|
||||
check(glm::length(rigidbody->GetLinearVelocity()) < 0.001f, "respawning must stop the fall");
|
||||
|
||||
scene.GetPhysics().FixedUpdate(1.0f / 60.0f);
|
||||
check(player->GetTransform().GetWorldPosition().y > 4.0f,
|
||||
"the physics body must have moved with the teleport");
|
||||
|
||||
SceneManager::GetInstance().Destroy();
|
||||
}
|
||||
|
||||
void testCanvasLayout()
|
||||
{
|
||||
Scene& scene = SceneManager::GetInstance().CreateScene("canvas");
|
||||
GameObject* hud = scene.CreateGameObject("hud");
|
||||
auto* canvas = hud->AddComponent<Canvas>();
|
||||
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<RectTransform>();
|
||||
panelRect->SetSize({100.0f, 50.0f});
|
||||
panelRect->SetPosition({10.0f, 0.0f});
|
||||
panel->AddComponent<UIImage>(glm::vec4{1.0f, 0.0f, 0.0f, 1.0f});
|
||||
|
||||
GameObject* fill = addChild("fill", *panel);
|
||||
fill->AddComponent<UIImage>(glm::vec4{0.0f, 1.0f, 0.0f, 1.0f})->SetShape(UIImageShape::Ellipse);
|
||||
|
||||
GameObject* corner = addChild("corner", *hud);
|
||||
auto* cornerRect = corner->AddComponent<RectTransform>();
|
||||
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<UIImage>();
|
||||
|
||||
GameObject* hidden = addChild("hidden", *hud);
|
||||
auto* hiddenRect = hidden->AddComponent<RectTransform>();
|
||||
hiddenRect->SetSize({20.0f, 20.0f});
|
||||
hidden->AddComponent<UIImage>();
|
||||
hidden->SetActive(false);
|
||||
|
||||
GameObject* nested = addChild("nested", *hud);
|
||||
nested->AddComponent<Canvas>();
|
||||
nested->AddComponent<UIImage>();
|
||||
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<Canvas>(),
|
||||
"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<UIText>("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>("CrosshairUI");
|
||||
ComponentFactory::Register<WinScreenUI>("WinScreenUI");
|
||||
ComponentFactory::Register<WinGoal>("WinGoal");
|
||||
ComponentFactory::Register<FirstPersonController>("FirstPersonController");
|
||||
|
||||
Scene& scene = SceneManager::GetInstance().CreateScene("hud");
|
||||
GameObject* player = scene.CreateGameObject("player");
|
||||
player->AddComponent<FirstPersonController>();
|
||||
GameObject* goal = scene.CreateGameObject("goal");
|
||||
auto* winGoal = goal->AddComponent<WinGoal>();
|
||||
|
||||
GameObject* hud = scene.CreateGameObject("hud");
|
||||
hud->AddComponent<Canvas>()->SetReferenceHeight(720.0f);
|
||||
GameObject* crosshair = scene.CreateGameObject("crosshair");
|
||||
crosshair->GetTransform().SetParent(&hud->GetTransform(), false);
|
||||
auto* crosshairRect = crosshair->AddComponent<RectTransform>();
|
||||
crosshairRect->SetAnchors({0.25f, 0.5f}, {0.75f, 0.5f});
|
||||
crosshair->AddComponent<UIImage>()->SetOutline(3.0f);
|
||||
GameObject* screen = scene.CreateGameObject("screen");
|
||||
screen->GetTransform().SetParent(&hud->GetTransform(), false);
|
||||
auto* label = screen->AddComponent<UIText>("YOU WIN", 64.0f);
|
||||
label->SetAlignment(UITextAlign::Start, UITextAlign::End);
|
||||
|
||||
auto* crosshairUi = hud->AddComponent<CrosshairUI>();
|
||||
crosshairUi->SetPlayer(*player);
|
||||
crosshairUi->SetCrosshair(*crosshair);
|
||||
auto* winScreenUi = hud->AddComponent<WinScreenUI>();
|
||||
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<Canvas>();
|
||||
check(loadedCanvas != nullptr && loadedCanvas->GetReferenceHeight() == 720.0f, "canvas settings must round-trip");
|
||||
const auto* loadedRect = loadedCrosshair->GetComponent<RectTransform>();
|
||||
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<UIImage>();
|
||||
check(loadedImage != nullptr && loadedImage->GetOutline() == 3.0f, "ui images must round-trip");
|
||||
const auto* loadedLabel = loadedScreen->GetComponent<UIText>();
|
||||
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<CrosshairUI>();
|
||||
const auto* loadedWinScreenUi = loadedHud->GetComponent<WinScreenUI>();
|
||||
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()
|
||||
@@ -1796,6 +2113,12 @@ int main()
|
||||
testPrefabRoundTrip();
|
||||
testPrefabDuplicate();
|
||||
testPrefabValidation();
|
||||
testWinGoalButton();
|
||||
testCheckpointRespawn();
|
||||
testCanvasLayout();
|
||||
testUITextPlacement();
|
||||
testHudRoundTrip();
|
||||
testHierarchyMoves();
|
||||
std::cout << "destrum tests passed\n";
|
||||
return EXIT_SUCCESS;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user