diff --git a/.github/workflows/ci-windows.yml b/.github/workflows/ci-windows.yml index a1980c2..62d992e 100644 --- a/.github/workflows/ci-windows.yml +++ b/.github/workflows/ci-windows.yml @@ -19,7 +19,7 @@ jobs: # Windows with MSVC - name: "Windows MSVC" compiler: msvc - cmake-generator: 'Visual Studio 17 2022' + cmake-generator: '' cmake-options: '-DGAMECOE_USE_TESTCOE=ON' # Windows with MinGW (GCC) @@ -47,7 +47,12 @@ jobs: - name: Configure CMake run: | - cmake -B build -G "${{ matrix.cmake-generator }}" ${{ matrix.cmake-options }} + if [ -n "${{ matrix.cmake-generator }}" ]; then + cmake -B build -G "${{ matrix.cmake-generator }}" ${{ matrix.cmake-options }} + else + cmake -B build ${{ matrix.cmake-options }} + fi + shell: bash - name: Build run: | diff --git a/include/gamecoe/component/parent_child.hpp b/include/gamecoe/component/parent_child.hpp new file mode 100644 index 0000000..c288d29 --- /dev/null +++ b/include/gamecoe/component/parent_child.hpp @@ -0,0 +1,28 @@ +#pragma once + +#include +#include +#include + +namespace gamecoe +{ + namespace components + { + struct parent + { + entity handle = entity::invalid(); + + bool has_parent() const { return handle != entity::invalid(); } + }; + + struct children + { + std::vector handles; + + bool has_children() const { return !handles.empty(); } + }; + + static_assert(std::is_standard_layout_v); + static_assert(std::is_standard_layout_v); + } // namespace components +} // namespace gamecoe diff --git a/include/gamecoe/component/transform.hpp b/include/gamecoe/component/transform.hpp new file mode 100644 index 0000000..5fe1dff --- /dev/null +++ b/include/gamecoe/component/transform.hpp @@ -0,0 +1,44 @@ +#pragma once + +#include +#include +#include +#include + +namespace gamecoe +{ + namespace components + { + struct transform + { + glm::vec3 position{0.0f}; + glm::quat rotation{1.0f, 0.0f, 0.0f, 0.0f}; + glm::vec3 scale{1.0f}; + + glm::mat4 translation_matrix() const { return glm::translate(glm::mat4(1.0f), position); } + glm::mat4 rotation_matrix() const { return glm::mat4_cast(rotation); } + glm::mat4 scale_matrix() const { return glm::scale(glm::mat4(1.0f), scale); } + glm::mat4 local_matrix() const + { + glm::mat4 m = glm::mat4_cast(rotation); + m[0] *= scale.x; + m[1] *= scale.y; + m[2] *= scale.z; + m[3] = glm::vec4(position, 1.0f); + return m; + } + + glm::vec3 forward() const { return glm::normalize(rotation * glm::vec3(0.0f, 0.0f, -1.0f)); } + glm::vec3 right() const { return glm::normalize(rotation * glm::vec3(1.0f, 0.0f, 0.0f)); } + glm::vec3 up() const { return glm::normalize(rotation * glm::vec3(0.0f, 1.0f, 0.0f)); } + + glm::vec3 euler_rotation() const { return glm::eulerAngles(rotation); } + + void translate(glm::vec3 offset) { position += offset; } + void rotate(glm::vec3 euler_offset) { rotation = glm::quat(euler_offset) * rotation; } + void rotate_around(glm::vec3 axis, float angle) { rotation = rotation * glm::angleAxis(angle, glm::normalize(axis)); } + }; + + static_assert(std::is_standard_layout_v); + } // namespace components +} // namespace gamecoe diff --git a/include/gamecoe/component/velocity.hpp b/include/gamecoe/component/velocity.hpp new file mode 100644 index 0000000..4e2cfe2 --- /dev/null +++ b/include/gamecoe/component/velocity.hpp @@ -0,0 +1,18 @@ +#pragma once + +#include +#include + +namespace gamecoe +{ + namespace components + { + struct velocity + { + glm::vec3 linear{0.0f}; + glm::vec3 angular{0.0f}; + }; + + static_assert(std::is_standard_layout_v); + } // namespace components +} // namespace gamecoe diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index 780fc8f..a74fcfa 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -5,6 +5,8 @@ add_executable(gamecoe_tests entity/component_pool_tests.cpp entity/entities_tests.cpp entity/extraction_tests.cpp + component/transform_tests.cpp + component/parent_child_tests.cpp ) target_link_libraries(gamecoe_tests diff --git a/tests/component/parent_child_tests.cpp b/tests/component/parent_child_tests.cpp new file mode 100644 index 0000000..0ef5a82 --- /dev/null +++ b/tests/component/parent_child_tests.cpp @@ -0,0 +1,93 @@ +#include +#include + +using namespace gamecoe; + +//============================================================================== +// ParentTests - parent component tests +//============================================================================== + +class ParentTests : public ::testing::Test +{ +protected: + components::parent p; +}; + +//============================================================================== +// Default State +//============================================================================== + +TEST_F(ParentTests, DefaultState) +{ + // Test 1: Default-constructed parent has no parent entity + { + EXPECT_EQ(p.handle, entity::invalid()); + EXPECT_FALSE(p.has_parent()); + } +} + +//============================================================================== +// Assigning a Parent +//============================================================================== + +TEST_F(ParentTests, AssignParent) +{ + // Test 1: Setting parent to a valid entity updates has_parent() + { + entity e = entity::create(5, 0); + p.handle = e; + + EXPECT_TRUE(p.has_parent()); + EXPECT_EQ(p.handle, e); + } +} + +//============================================================================== +// ChildrenTests - children component tests +//============================================================================== + +class ChildrenTests : public ::testing::Test +{ +protected: + components::children c; +}; + +//============================================================================== +// Default State +//============================================================================== + +TEST_F(ChildrenTests, DefaultState) +{ + // Test 1: Default-constructed children has an empty list + { + EXPECT_TRUE(c.handles.empty()); + EXPECT_FALSE(c.has_children()); + } +} + +//============================================================================== +// Populating Children +//============================================================================== + +TEST_F(ChildrenTests, PopulateChildren) +{ + entity e1 = entity::create(1, 0); + entity e2 = entity::create(2, 0); + + // Test 1: Adding a single child updates has_children() + { + c.handles.push_back(e1); + + EXPECT_TRUE(c.has_children()); + EXPECT_EQ(c.handles.size(), 1); + } + + // Test 2: Adding a second child preserves both entities, in order + { + c.handles.push_back(e2); + + EXPECT_EQ(c.handles.size(), 2); + EXPECT_EQ(c.handles[0], e1); + EXPECT_EQ(c.handles[1], e2); + } +} diff --git a/tests/component/transform_tests.cpp b/tests/component/transform_tests.cpp new file mode 100644 index 0000000..77b6f34 --- /dev/null +++ b/tests/component/transform_tests.cpp @@ -0,0 +1,210 @@ +#include +#include +#include + +using namespace gamecoe; + +namespace +{ + void expect_vec3_near(const glm::vec3 &actual, const glm::vec3 &expected, float epsilon = 1e-5f) + { + EXPECT_NEAR(actual.x, expected.x, epsilon); + EXPECT_NEAR(actual.y, expected.y, epsilon); + EXPECT_NEAR(actual.z, expected.z, epsilon); + } + + void expect_quat_near(const glm::quat &actual, const glm::quat &expected, float epsilon = 1e-5f) + { + EXPECT_NEAR(actual.w, expected.w, epsilon); + EXPECT_NEAR(actual.x, expected.x, epsilon); + EXPECT_NEAR(actual.y, expected.y, epsilon); + EXPECT_NEAR(actual.z, expected.z, epsilon); + } + + void expect_mat4_near(const glm::mat4 &actual, const glm::mat4 &expected, float epsilon = 1e-5f) + { + for (int col = 0; col < 4; ++col) + for (int row = 0; row < 4; ++row) + EXPECT_NEAR(actual[col][row], expected[col][row], epsilon); + } + + // Boolean-only comparison used to assert two quaternions are NOT approximately + // equal (used to prove the two composition-order candidates in the + // rotate()/rotate_around() regression test actually differ). No per-component + // diagnostics needed here, unlike expect_quat_near(), since the check is just + // "these must not be equal" - matches the existing glm::epsilonEqual pattern + // used for the same purpose in src/gamecoe/utils/collision.cpp.old. + bool quat_near(const glm::quat &a, const glm::quat &b, float epsilon = 1e-4f) + { + return glm::all(glm::epsilonEqual(a, b, epsilon)); + } +} // namespace + +//============================================================================== +// TransformTests - transform component tests +//============================================================================== + +class TransformTests : public ::testing::Test +{ +protected: + components::transform t; +}; + +//============================================================================== +// Default Construction +//============================================================================== + +TEST_F(TransformTests, DefaultConstruction) +{ + // Test 1: Default position, rotation, scale + { + expect_vec3_near(t.position, glm::vec3(0.0f, 0.0f, 0.0f)); + expect_quat_near(t.rotation, glm::quat(1.0f, 0.0f, 0.0f, 0.0f)); + expect_vec3_near(t.scale, glm::vec3(1.0f, 1.0f, 1.0f)); + } +} + +//============================================================================== +// Matrix Composition +//============================================================================== + +TEST_F(TransformTests, MatrixComposition) +{ + float rotation_angle = glm::radians(90.0f); + glm::vec3 rotation_axis(0.0f, 1.0f, 0.0f); + + // Known, non-trivial position/rotation/scale + t.position = glm::vec3(1.0f, 2.0f, 3.0f); + t.rotation = glm::angleAxis(rotation_angle, rotation_axis); + t.scale = glm::vec3(2.0f, 1.0f, 0.5f); + + // Test 1: translation_matrix() - identity with translation column set + { + glm::mat4 expected = glm::translate(glm::mat4(1.0f), t.position); + expect_mat4_near(t.translation_matrix(), expected); + } + + // Test 2: rotation_matrix() - matches independently-derived axis-angle rotation matrix + { + glm::mat4 expected = glm::rotate(glm::mat4(1.0f), rotation_angle, rotation_axis); + expect_mat4_near(t.rotation_matrix(), expected); + } + + // Test 3: scale_matrix() - identity with diagonal set to scale components + { + glm::mat4 expected = glm::scale(glm::mat4(1.0f), t.scale); + expect_mat4_near(t.scale_matrix(), expected); + } + + // Test 4: local_matrix() equals the naive translation * rotation * scale composition + { + glm::mat4 expected = t.translation_matrix() * t.rotation_matrix() * t.scale_matrix(); + expect_mat4_near(t.local_matrix(), expected); + } +} + +//============================================================================== +// Direction Vectors +//============================================================================== + +TEST_F(TransformTests, DirectionVectors) +{ + // Test 1: Default (identity) rotation + { + expect_vec3_near(t.forward(), glm::vec3(0.0f, 0.0f, -1.0f)); + expect_vec3_near(t.right(), glm::vec3(1.0f, 0.0f, 0.0f)); + expect_vec3_near(t.up(), glm::vec3(0.0f, 1.0f, 0.0f)); + } + + // Test 2: Known 90-degree rotation around Y + { + t.rotation = glm::angleAxis(glm::radians(90.0f), glm::vec3(0.0f, 1.0f, 0.0f)); + + // Analytically-derived expected value (not just re-deriving forward()'s own + // formula, which would make this check tautological) + expect_vec3_near(t.forward(), glm::vec3(-1.0f, 0.0f, 0.0f)); // right-handed: -Z rotated +90 about Y -> -X + } +} + +//============================================================================== +// Euler Rotation +//============================================================================== + +TEST_F(TransformTests, EulerRotation) +{ + // Test 1: Pure single-axis (X) rotation round-trips through euler_rotation() + { + glm::vec3 euler(glm::radians(30.0f), 0.0f, 0.0f); + t.rotation = glm::quat(euler); + + expect_vec3_near(t.euler_rotation(), euler); + } +} + +//============================================================================== +// Translate +//============================================================================== + +TEST_F(TransformTests, Translate) +{ + // Test 1: Single translate() call offsets position + { + t.position = glm::vec3(1.0f, 2.0f, 3.0f); + t.translate(glm::vec3(1.0f, 1.0f, 1.0f)); + + expect_vec3_near(t.position, glm::vec3(2.0f, 3.0f, 4.0f)); + } + + // Test 2: Successive translate() calls accumulate, not overwrite + { + t.translate(glm::vec3(0.5f, -1.0f, 2.0f)); + + expect_vec3_near(t.position, glm::vec3(2.5f, 2.0f, 6.0f)); + } +} + +//============================================================================== +// rotate() vs rotate_around() Composition Order +//============================================================================== + +TEST_F(TransformTests, RotateCompositionOrder) +{ + glm::quat initial_rotation = glm::angleAxis(glm::radians(45.0f), glm::vec3(0.0f, 1.0f, 0.0f)); + + // Test 1: rotate_around() post-multiplies (intrinsic rotation) + { + t.rotation = initial_rotation; + + glm::vec3 axis(1.0f, 0.0f, 0.0f); + float angle = glm::radians(30.0f); + + t.rotate_around(axis, angle); + + glm::quat delta = glm::angleAxis(angle, glm::normalize(axis)); + glm::quat post_multiply_expected = initial_rotation * delta; + glm::quat pre_multiply_wrong = delta * initial_rotation; + + // The two candidate results must actually differ, otherwise this test + // wouldn't catch a regression to the old (buggy) pre-multiply order. + EXPECT_FALSE(quat_near(post_multiply_expected, pre_multiply_wrong)); + + expect_quat_near(t.rotation, post_multiply_expected); + } + + // Test 2: rotate() pre-multiplies (extrinsic rotation) - mirror check + { + t.rotation = initial_rotation; + + glm::vec3 euler_offset(glm::radians(20.0f), 0.0f, 0.0f); + + t.rotate(euler_offset); + + glm::quat delta = glm::quat(euler_offset); + glm::quat pre_multiply_expected = delta * initial_rotation; + glm::quat post_multiply_wrong = initial_rotation * delta; + + EXPECT_FALSE(quat_near(pre_multiply_expected, post_multiply_wrong)); + + expect_quat_near(t.rotation, pre_multiply_expected); + } +} diff --git a/tests/main.cpp b/tests/main.cpp index 0dabc50..8abed37 100644 --- a/tests/main.cpp +++ b/tests/main.cpp @@ -16,6 +16,9 @@ int printHelp() std::cout << " SparseSetTests - Sparse set data structure tests" << std::endl; std::cout << " ComponentPoolTests - Component pool wrapper tests" << std::endl; std::cout << " EntitiesTests - Entities manager tests" << std::endl; + std::cout << " TransformTests - Transform component tests" << std::endl; + std::cout << " ParentTests - Parent component tests" << std::endl; + std::cout << " ChildrenTests - Children component tests" << std::endl; std::cout << std::endl; std::cout << "Example usage:" << std::endl; std::cout << " ./gamecoe_tests --suite=EntityTests" << std::endl; @@ -31,6 +34,7 @@ int main(int argc, char **argv) std::cout << std::endl; std::cout << "Comprehensive testing for gamecoe." << std::endl; std::cout << "Testing Entity Module: entity, sparse_set, component_pool, entities" << std::endl; + std::cout << "Testing Component Module: transform, parent, children" << std::endl; std::cout << std::endl; testcoe::init(&argc, argv);