diff --git a/CMakeLists.txt b/CMakeLists.txt index bfcb015..6daba31 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -39,6 +39,9 @@ add_library(ecs_engine STATIC src/engine/Entity.h src/engine/EntityRegistry.h src/engine/IComponentStorage.h + src/engine/PackedComponentStorage.h + src/engine/ComponentRegistry.h + src/engine/EcsCore.h src/engine/EngineConfig.h src/engine/EngineRuntime.h src/engine/EngineState.h @@ -46,7 +49,6 @@ add_library(ecs_engine STATIC src/engine/EngineStepContext.h src/engine/EngineSystem.h src/engine/FrameClock.h - src/engine/PackedComponentStorage.h src/engine/EntityRegistry.cpp src/engine/EngineRuntime.cpp src/engine/FrameClock.cpp @@ -87,6 +89,7 @@ if (BUILD_TESTING) tests/EngineRuntimeTests.cpp tests/PackedComponentStorageTests.cpp tests/SafeCrowdDomainTests.cpp + tests/EcsCoreTests.cpp ) target_include_directories(safecrowd_tests diff --git a/src/engine/ComponentRegistry.h b/src/engine/ComponentRegistry.h new file mode 100644 index 0000000..7b78257 --- /dev/null +++ b/src/engine/ComponentRegistry.h @@ -0,0 +1,105 @@ +#pragma once + +#include +#include +#include +#include +#include + +#include "engine/EntityRegistry.h" +#include "engine/IComponentStorage.h" +#include "engine/PackedComponentStorage.h" + +namespace safecrowd::engine { + +using ComponentType = std::size_t; + +// ComponentRegistry +// +// 컴포넌트 타입(C++ 타입)을 고유 ID(ComponentType)에 매핑하고, +// 타입별로 PackedComponentStorage 인스턴스를 하나씩 보관한다. +// +// - 타입은 처음 addComponent 시 자동 등록된다(getOrRegister). +// - entity가 삭제될 때 notifyEntityDestroyed()를 호출하면 +// 등록된 모든 storage에서 해당 entity 데이터를 일괄 제거한다(cleanup flow). +class ComponentRegistry { +public: + // T를 레지스트리에 등록한다. + // 이미 등록된 경우 기존 ID를 그대로 반환한다. + // 처음 등록이면 PackedComponentStorage를 함께 생성한다. + template + ComponentType getOrRegister() { + const std::type_index key = typeid(T); + + if (const auto it = typeIds_.find(key); it != typeIds_.end()) { + return it->second; + } + + if (nextTypeId_ >= kMaxComponentTypes) { + throw std::runtime_error( + "ComponentRegistry: 최대 컴포넌트 타입 수를 초과했습니다."); + } + + const ComponentType id = nextTypeId_++; + typeIds_.emplace(key, id); + storages_.emplace(key, std::make_unique>()); + + return id; + } + + // T의 ComponentType ID를 반환한다. + // 등록되지 않은 타입이면 std::nullopt를 반환한다(예외 없음). + template + [[nodiscard]] std::optional tryTypeOf() const noexcept { + const auto it = typeIds_.find(typeid(T)); + if (it == typeIds_.end()) { + return std::nullopt; + } + return it->second; + } + + // T가 레지스트리에 등록되어 있는지 확인한다. + template + [[nodiscard]] bool isRegistered() const noexcept { + return typeIds_.contains(typeid(T)); + } + + // T의 PackedComponentStorage 참조를 반환한다. + // T가 등록되지 않은 경우 예외를 던진다. + template + [[nodiscard]] PackedComponentStorage& storageFor() { + const auto it = storages_.find(typeid(T)); + if (it == storages_.end()) { + throw std::runtime_error( + "ComponentRegistry: 등록되지 않은 컴포넌트 타입입니다."); + } + return static_cast&>(*it->second); + } + + template + [[nodiscard]] const PackedComponentStorage& storageFor() const { + const auto it = storages_.find(typeid(T)); + if (it == storages_.end()) { + throw std::runtime_error( + "ComponentRegistry: 등록되지 않은 컴포넌트 타입입니다."); + } + return static_cast&>(*it->second); + } + + // EcsCore cleanup flow 진입점. + // entity가 destroyEntity()될 때 호출되며, + // 등록된 모든 storage에 entityDestroyed()를 전달해 + // 해당 entity의 컴포넌트 데이터를 일괄 제거한다. + void notifyEntityDestroyed(Entity entity) { + for (auto& [key, storage] : storages_) { + storage->entityDestroyed(entity); + } + } + +private: + std::unordered_map typeIds_; + std::unordered_map> storages_; + ComponentType nextTypeId_{0}; +}; + +} // namespace safecrowd::engine diff --git a/src/engine/EcsCore.h b/src/engine/EcsCore.h new file mode 100644 index 0000000..f1b208e --- /dev/null +++ b/src/engine/EcsCore.h @@ -0,0 +1,138 @@ +#pragma once + +#include + +#include "engine/ComponentRegistry.h" +#include "engine/Entity.h" +#include "engine/EntityRegistry.h" + +namespace safecrowd::engine { + +// EcsCore +// +// ECS 저장 코어. EntityRegistry와 ComponentRegistry를 하나로 묶어 +// 외부에서 raw 레지스트리를 직접 다루지 않고도 엔티티/컴포넌트를 조작하게 한다. +// +// 책임: +// - 엔티티 생성/소멸 (EntityRegistry 위임) +// - 컴포넌트 추가/제거 및 entity Signature 자동 갱신 (ComponentRegistry 위임) +// - 엔티티 소멸 시 cleanup flow 실행 (ComponentRegistry::notifyEntityDestroyed) +// +// 이 클래스는 domain 용어를 알지 않는다. +// "군중", "에이전트" 같은 개념은 domain 계층이 컴포넌트 타입으로 표현한다. +class EcsCore { +public: + explicit EcsCore(std::size_t maxEntityCount = 4096) + : entityRegistry_(maxEntityCount) {} + + // ---------------------------------------------------------------- + // 엔티티 생명주기 + // ---------------------------------------------------------------- + + // 새 엔티티를 할당하고 핸들을 반환한다. + [[nodiscard]] Entity createEntity() { + return entityRegistry_.allocate(); + } + + // 엔티티와 그에 속한 모든 컴포넌트를 삭제한다. + // + // cleanup flow: + // 1. ComponentRegistry::notifyEntityDestroyed() → 등록된 모든 storage에 + // entityDestroyed()를 호출해 컴포넌트 데이터를 제거 + // 2. EntityRegistry::release() → 해당 슬롯을 free-list에 반환하고 + // generation을 증가시켜 stale handle을 무효화 + void destroyEntity(Entity entity) { + componentRegistry_.notifyEntityDestroyed(entity); + entityRegistry_.release(entity); + } + + // 엔티티가 현재 살아있는지 확인한다. + [[nodiscard]] bool isAlive(Entity entity) const noexcept { + return entityRegistry_.isAlive(entity); + } + + // ---------------------------------------------------------------- + // 컴포넌트 조작 + // ---------------------------------------------------------------- + + // 엔티티에 컴포넌트 T를 추가하고 signature를 갱신한다. + // + // T가 처음 추가되는 타입이면 ComponentRegistry에 자동 등록된다. + // 이미 해당 컴포넌트가 있는 경우 PackedComponentStorage::insert에서 예외 발생. + template + void addComponent(Entity entity, T component) { + const ComponentType typeId = componentRegistry_.getOrRegister(); + componentRegistry_.storageFor().insert(entity, std::move(component)); + + Signature sig = entityRegistry_.signatureOf(entity); + sig.set(typeId); + entityRegistry_.setSignature(entity, sig); + } + + // 엔티티에서 컴포넌트 T를 제거하고 signature를 갱신한다. + // + // T가 등록되지 않았거나 해당 entity에 T가 없으면 조용히 무시한다. + template + void removeComponent(Entity entity) { + const auto typeId = componentRegistry_.tryTypeOf(); + if (!typeId.has_value()) { + return; + } + + auto& storage = componentRegistry_.storageFor(); + if (!storage.contains(entity)) { + return; + } + + storage.remove(entity); + + Signature sig = entityRegistry_.signatureOf(entity); + sig.reset(typeId.value()); + entityRegistry_.setSignature(entity, sig); + } + + // 엔티티의 컴포넌트 T를 mutable 참조로 반환한다. + // T가 없으면 PackedComponentStorage::get에서 예외 발생. + template + [[nodiscard]] T& getComponent(Entity entity) { + return componentRegistry_.storageFor().get(entity); + } + + template + [[nodiscard]] const T& getComponent(Entity entity) const { + return componentRegistry_.storageFor().get(entity); + } + + // entity가 컴포넌트 T를 보유하고 있는지 확인한다. + // T가 한 번도 등록된 적 없으면 false를 반환한다. + template + [[nodiscard]] bool hasComponent(Entity entity) const { + if (!componentRegistry_.isRegistered()) { + return false; + } + return componentRegistry_.storageFor().contains(entity); + } + + // ---------------------------------------------------------------- + // 내부 레지스트리 접근자 + // ---------------------------------------------------------------- + + [[nodiscard]] EntityRegistry& entityRegistry() noexcept { + return entityRegistry_; + } + [[nodiscard]] const EntityRegistry& entityRegistry() const noexcept { + return entityRegistry_; + } + [[nodiscard]] ComponentRegistry& componentRegistry() noexcept { + return componentRegistry_; + } + [[nodiscard]] const ComponentRegistry& componentRegistry() const noexcept { + return componentRegistry_; + } + +private: + EntityRegistry entityRegistry_; + ComponentRegistry componentRegistry_; +}; + +} // namespace safecrowd::engine diff --git a/tests/EcsCoreTests.cpp b/tests/EcsCoreTests.cpp new file mode 100644 index 0000000..0ceb4e1 --- /dev/null +++ b/tests/EcsCoreTests.cpp @@ -0,0 +1,106 @@ +#include "TestSupport.h" + +#include "engine/EcsCore.h" + +namespace { + +struct Position { + float x{0.0f}; + float y{0.0f}; +}; + +struct Velocity { + float vx{0.0f}; + float vy{0.0f}; +}; + +} // namespace + +SC_TEST(EcsCore_CreateAndDestroyEntity) { + safecrowd::engine::EcsCore core; + + const auto e = core.createEntity(); + SC_EXPECT_TRUE(core.isAlive(e)); + + core.destroyEntity(e); + SC_EXPECT_TRUE(!core.isAlive(e)); +} + +SC_TEST(EcsCore_AddComponent_UpdatesSignatureAndData) { + safecrowd::engine::EcsCore core; + const auto e = core.createEntity(); + + SC_EXPECT_TRUE(!core.hasComponent(e)); + + core.addComponent(e, Position{1.0f, 2.0f}); + + SC_EXPECT_TRUE(core.hasComponent(e)); + + const auto& pos = core.getComponent(e); + SC_EXPECT_NEAR(pos.x, 1.0f, 1e-6); + SC_EXPECT_NEAR(pos.y, 2.0f, 1e-6); +} + +SC_TEST(EcsCore_RemoveComponent_UpdatesSignature) { + safecrowd::engine::EcsCore core; + const auto e = core.createEntity(); + + core.addComponent(e, Position{3.0f, 4.0f}); + SC_EXPECT_TRUE(core.hasComponent(e)); + + core.removeComponent(e); + SC_EXPECT_TRUE(!core.hasComponent(e)); +} + +SC_TEST(EcsCore_RemoveComponent_NonExistent_IsSafe) { + safecrowd::engine::EcsCore core; + const auto e = core.createEntity(); + + core.removeComponent(e); + SC_EXPECT_TRUE(!core.hasComponent(e)); +} + +SC_TEST(EcsCore_DestroyEntity_CleansUpAllComponents) { + safecrowd::engine::EcsCore core; + const auto e = core.createEntity(); + + core.addComponent(e, Position{5.0f, 6.0f}); + core.addComponent(e, Velocity{1.0f, 0.0f}); + + SC_EXPECT_TRUE(core.hasComponent(e)); + SC_EXPECT_TRUE(core.hasComponent(e)); + + core.destroyEntity(e); + + SC_EXPECT_TRUE(!core.isAlive(e)); +} + +SC_TEST(EcsCore_EntityIndex_Reuse_DoesNotLeakComponents) { + safecrowd::engine::EcsCore core; + + const auto e1 = core.createEntity(); + core.addComponent(e1, Position{7.0f, 8.0f}); + + core.destroyEntity(e1); + + const auto e2 = core.createEntity(); + SC_EXPECT_TRUE(e1.index == e2.index); + + SC_EXPECT_TRUE(!core.hasComponent(e2)); +} + +SC_TEST(EcsCore_MultipleComponents_IndependentSignatureBits) { + safecrowd::engine::EcsCore core; + const auto e = core.createEntity(); + + core.addComponent(e, Position{0.0f, 0.0f}); + core.addComponent(e, Velocity{1.0f, 1.0f}); + + SC_EXPECT_TRUE(core.hasComponent(e)); + SC_EXPECT_TRUE(core.hasComponent(e)); + + core.removeComponent(e); + + SC_EXPECT_TRUE(!core.hasComponent(e)); + SC_EXPECT_TRUE(core.hasComponent(e)); +}