Skip to content

Commit 6a4e0a6

Browse files
feat(engine): ComponentRegistry, EcsCore, signature 갱신 및 cleanup flow 구현 (#8)
## Summary - ComponentRegistry: 컴포넌트 타입별 고유 ID 부여 및 PackedComponentStorage 중앙 관리 - EcsCore: EntityRegistry + ComponentRegistry 통합 코어 - addComponent/removeComponent 시 entity Signature 자동 갱신 - destroyEntity 시 notifyEntityDestroyed로 등록된 모든 storage cleanup - EcsCoreTests: 생명주기, 컴포넌트 추가/제거, cleanup flow, 인덱스 재사용 검증 ## Related Issue - Closes #8 ## Area - [x] Engine ## Architecture Check - [x] I kept the dependency direction `application -> domain -> engine`. - [x] I did not add Qt UI code to `src/domain`. - [x] I did not add `domain` or `application` dependencies to `src/engine`. - [x] I used `src/` as the include root. ## Verification - [ ] `cmake --preset windows-debug` - [ ] `cmake --build --preset build-debug` - [ ] `ctest --preset test-debug` - [x] Not run (reason below) 로컬 환경의 PATH에 cmake가 없어 직접 실행 불가. CI pipeline에서 검증됨. ## Risks / Follow-up - WorldQuery(#9) 구현 시 EntityRegistry::eachAlive() 추가 예정
1 parent e3964d1 commit 6a4e0a6

4 files changed

Lines changed: 353 additions & 1 deletion

File tree

CMakeLists.txt

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -39,14 +39,16 @@ add_library(ecs_engine STATIC
3939
src/engine/Entity.h
4040
src/engine/EntityRegistry.h
4141
src/engine/IComponentStorage.h
42+
src/engine/PackedComponentStorage.h
43+
src/engine/ComponentRegistry.h
44+
src/engine/EcsCore.h
4245
src/engine/EngineConfig.h
4346
src/engine/EngineRuntime.h
4447
src/engine/EngineState.h
4548
src/engine/EngineStats.h
4649
src/engine/EngineStepContext.h
4750
src/engine/EngineSystem.h
4851
src/engine/FrameClock.h
49-
src/engine/PackedComponentStorage.h
5052
src/engine/EntityRegistry.cpp
5153
src/engine/EngineRuntime.cpp
5254
src/engine/FrameClock.cpp
@@ -87,6 +89,7 @@ if (BUILD_TESTING)
8789
tests/EngineRuntimeTests.cpp
8890
tests/PackedComponentStorageTests.cpp
8991
tests/SafeCrowdDomainTests.cpp
92+
tests/EcsCoreTests.cpp
9093
)
9194

9295
target_include_directories(safecrowd_tests

src/engine/ComponentRegistry.h

Lines changed: 105 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,105 @@
1+
#pragma once
2+
3+
#include <memory>
4+
#include <optional>
5+
#include <stdexcept>
6+
#include <typeindex>
7+
#include <unordered_map>
8+
9+
#include "engine/EntityRegistry.h"
10+
#include "engine/IComponentStorage.h"
11+
#include "engine/PackedComponentStorage.h"
12+
13+
namespace safecrowd::engine {
14+
15+
using ComponentType = std::size_t;
16+
17+
// ComponentRegistry
18+
//
19+
// 컴포넌트 타입(C++ 타입)을 고유 ID(ComponentType)에 매핑하고,
20+
// 타입별로 PackedComponentStorage<T> 인스턴스를 하나씩 보관한다.
21+
//
22+
// - 타입은 처음 addComponent 시 자동 등록된다(getOrRegister<T>).
23+
// - entity가 삭제될 때 notifyEntityDestroyed()를 호출하면
24+
// 등록된 모든 storage에서 해당 entity 데이터를 일괄 제거한다(cleanup flow).
25+
class ComponentRegistry {
26+
public:
27+
// T를 레지스트리에 등록한다.
28+
// 이미 등록된 경우 기존 ID를 그대로 반환한다.
29+
// 처음 등록이면 PackedComponentStorage<T>를 함께 생성한다.
30+
template <typename T>
31+
ComponentType getOrRegister() {
32+
const std::type_index key = typeid(T);
33+
34+
if (const auto it = typeIds_.find(key); it != typeIds_.end()) {
35+
return it->second;
36+
}
37+
38+
if (nextTypeId_ >= kMaxComponentTypes) {
39+
throw std::runtime_error(
40+
"ComponentRegistry: 최대 컴포넌트 타입 수를 초과했습니다.");
41+
}
42+
43+
const ComponentType id = nextTypeId_++;
44+
typeIds_.emplace(key, id);
45+
storages_.emplace(key, std::make_unique<PackedComponentStorage<T>>());
46+
47+
return id;
48+
}
49+
50+
// T의 ComponentType ID를 반환한다.
51+
// 등록되지 않은 타입이면 std::nullopt를 반환한다(예외 없음).
52+
template <typename T>
53+
[[nodiscard]] std::optional<ComponentType> tryTypeOf() const noexcept {
54+
const auto it = typeIds_.find(typeid(T));
55+
if (it == typeIds_.end()) {
56+
return std::nullopt;
57+
}
58+
return it->second;
59+
}
60+
61+
// T가 레지스트리에 등록되어 있는지 확인한다.
62+
template <typename T>
63+
[[nodiscard]] bool isRegistered() const noexcept {
64+
return typeIds_.contains(typeid(T));
65+
}
66+
67+
// T의 PackedComponentStorage 참조를 반환한다.
68+
// T가 등록되지 않은 경우 예외를 던진다.
69+
template <typename T>
70+
[[nodiscard]] PackedComponentStorage<T>& storageFor() {
71+
const auto it = storages_.find(typeid(T));
72+
if (it == storages_.end()) {
73+
throw std::runtime_error(
74+
"ComponentRegistry: 등록되지 않은 컴포넌트 타입입니다.");
75+
}
76+
return static_cast<PackedComponentStorage<T>&>(*it->second);
77+
}
78+
79+
template <typename T>
80+
[[nodiscard]] const PackedComponentStorage<T>& storageFor() const {
81+
const auto it = storages_.find(typeid(T));
82+
if (it == storages_.end()) {
83+
throw std::runtime_error(
84+
"ComponentRegistry: 등록되지 않은 컴포넌트 타입입니다.");
85+
}
86+
return static_cast<const PackedComponentStorage<T>&>(*it->second);
87+
}
88+
89+
// EcsCore cleanup flow 진입점.
90+
// entity가 destroyEntity()될 때 호출되며,
91+
// 등록된 모든 storage에 entityDestroyed()를 전달해
92+
// 해당 entity의 컴포넌트 데이터를 일괄 제거한다.
93+
void notifyEntityDestroyed(Entity entity) {
94+
for (auto& [key, storage] : storages_) {
95+
storage->entityDestroyed(entity);
96+
}
97+
}
98+
99+
private:
100+
std::unordered_map<std::type_index, ComponentType> typeIds_;
101+
std::unordered_map<std::type_index, std::unique_ptr<IComponentStorage>> storages_;
102+
ComponentType nextTypeId_{0};
103+
};
104+
105+
} // namespace safecrowd::engine

src/engine/EcsCore.h

Lines changed: 138 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,138 @@
1+
#pragma once
2+
3+
#include <cstddef>
4+
5+
#include "engine/ComponentRegistry.h"
6+
#include "engine/Entity.h"
7+
#include "engine/EntityRegistry.h"
8+
9+
namespace safecrowd::engine {
10+
11+
// EcsCore
12+
//
13+
// ECS 저장 코어. EntityRegistry와 ComponentRegistry를 하나로 묶어
14+
// 외부에서 raw 레지스트리를 직접 다루지 않고도 엔티티/컴포넌트를 조작하게 한다.
15+
//
16+
// 책임:
17+
// - 엔티티 생성/소멸 (EntityRegistry 위임)
18+
// - 컴포넌트 추가/제거 및 entity Signature 자동 갱신 (ComponentRegistry 위임)
19+
// - 엔티티 소멸 시 cleanup flow 실행 (ComponentRegistry::notifyEntityDestroyed)
20+
//
21+
// 이 클래스는 domain 용어를 알지 않는다.
22+
// "군중", "에이전트" 같은 개념은 domain 계층이 컴포넌트 타입으로 표현한다.
23+
class EcsCore {
24+
public:
25+
explicit EcsCore(std::size_t maxEntityCount = 4096)
26+
: entityRegistry_(maxEntityCount) {}
27+
28+
// ----------------------------------------------------------------
29+
// 엔티티 생명주기
30+
// ----------------------------------------------------------------
31+
32+
// 새 엔티티를 할당하고 핸들을 반환한다.
33+
[[nodiscard]] Entity createEntity() {
34+
return entityRegistry_.allocate();
35+
}
36+
37+
// 엔티티와 그에 속한 모든 컴포넌트를 삭제한다.
38+
//
39+
// cleanup flow:
40+
// 1. ComponentRegistry::notifyEntityDestroyed() → 등록된 모든 storage에
41+
// entityDestroyed()를 호출해 컴포넌트 데이터를 제거
42+
// 2. EntityRegistry::release() → 해당 슬롯을 free-list에 반환하고
43+
// generation을 증가시켜 stale handle을 무효화
44+
void destroyEntity(Entity entity) {
45+
componentRegistry_.notifyEntityDestroyed(entity);
46+
entityRegistry_.release(entity);
47+
}
48+
49+
// 엔티티가 현재 살아있는지 확인한다.
50+
[[nodiscard]] bool isAlive(Entity entity) const noexcept {
51+
return entityRegistry_.isAlive(entity);
52+
}
53+
54+
// ----------------------------------------------------------------
55+
// 컴포넌트 조작
56+
// ----------------------------------------------------------------
57+
58+
// 엔티티에 컴포넌트 T를 추가하고 signature를 갱신한다.
59+
//
60+
// T가 처음 추가되는 타입이면 ComponentRegistry에 자동 등록된다.
61+
// 이미 해당 컴포넌트가 있는 경우 PackedComponentStorage::insert에서 예외 발생.
62+
template <typename T>
63+
void addComponent(Entity entity, T component) {
64+
const ComponentType typeId = componentRegistry_.getOrRegister<T>();
65+
componentRegistry_.storageFor<T>().insert(entity, std::move(component));
66+
67+
Signature sig = entityRegistry_.signatureOf(entity);
68+
sig.set(typeId);
69+
entityRegistry_.setSignature(entity, sig);
70+
}
71+
72+
// 엔티티에서 컴포넌트 T를 제거하고 signature를 갱신한다.
73+
//
74+
// T가 등록되지 않았거나 해당 entity에 T가 없으면 조용히 무시한다.
75+
template <typename T>
76+
void removeComponent(Entity entity) {
77+
const auto typeId = componentRegistry_.tryTypeOf<T>();
78+
if (!typeId.has_value()) {
79+
return;
80+
}
81+
82+
auto& storage = componentRegistry_.storageFor<T>();
83+
if (!storage.contains(entity)) {
84+
return;
85+
}
86+
87+
storage.remove(entity);
88+
89+
Signature sig = entityRegistry_.signatureOf(entity);
90+
sig.reset(typeId.value());
91+
entityRegistry_.setSignature(entity, sig);
92+
}
93+
94+
// 엔티티의 컴포넌트 T를 mutable 참조로 반환한다.
95+
// T가 없으면 PackedComponentStorage::get에서 예외 발생.
96+
template <typename T>
97+
[[nodiscard]] T& getComponent(Entity entity) {
98+
return componentRegistry_.storageFor<T>().get(entity);
99+
}
100+
101+
template <typename T>
102+
[[nodiscard]] const T& getComponent(Entity entity) const {
103+
return componentRegistry_.storageFor<T>().get(entity);
104+
}
105+
106+
// entity가 컴포넌트 T를 보유하고 있는지 확인한다.
107+
// T가 한 번도 등록된 적 없으면 false를 반환한다.
108+
template <typename T>
109+
[[nodiscard]] bool hasComponent(Entity entity) const {
110+
if (!componentRegistry_.isRegistered<T>()) {
111+
return false;
112+
}
113+
return componentRegistry_.storageFor<T>().contains(entity);
114+
}
115+
116+
// ----------------------------------------------------------------
117+
// 내부 레지스트리 접근자
118+
// ----------------------------------------------------------------
119+
120+
[[nodiscard]] EntityRegistry& entityRegistry() noexcept {
121+
return entityRegistry_;
122+
}
123+
[[nodiscard]] const EntityRegistry& entityRegistry() const noexcept {
124+
return entityRegistry_;
125+
}
126+
[[nodiscard]] ComponentRegistry& componentRegistry() noexcept {
127+
return componentRegistry_;
128+
}
129+
[[nodiscard]] const ComponentRegistry& componentRegistry() const noexcept {
130+
return componentRegistry_;
131+
}
132+
133+
private:
134+
EntityRegistry entityRegistry_;
135+
ComponentRegistry componentRegistry_;
136+
};
137+
138+
} // namespace safecrowd::engine

tests/EcsCoreTests.cpp

Lines changed: 106 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,106 @@
1+
#include "TestSupport.h"
2+
3+
#include "engine/EcsCore.h"
4+
5+
namespace {
6+
7+
struct Position {
8+
float x{0.0f};
9+
float y{0.0f};
10+
};
11+
12+
struct Velocity {
13+
float vx{0.0f};
14+
float vy{0.0f};
15+
};
16+
17+
} // namespace
18+
19+
SC_TEST(EcsCore_CreateAndDestroyEntity) {
20+
safecrowd::engine::EcsCore core;
21+
22+
const auto e = core.createEntity();
23+
SC_EXPECT_TRUE(core.isAlive(e));
24+
25+
core.destroyEntity(e);
26+
SC_EXPECT_TRUE(!core.isAlive(e));
27+
}
28+
29+
SC_TEST(EcsCore_AddComponent_UpdatesSignatureAndData) {
30+
safecrowd::engine::EcsCore core;
31+
const auto e = core.createEntity();
32+
33+
SC_EXPECT_TRUE(!core.hasComponent<Position>(e));
34+
35+
core.addComponent(e, Position{1.0f, 2.0f});
36+
37+
SC_EXPECT_TRUE(core.hasComponent<Position>(e));
38+
39+
const auto& pos = core.getComponent<Position>(e);
40+
SC_EXPECT_NEAR(pos.x, 1.0f, 1e-6);
41+
SC_EXPECT_NEAR(pos.y, 2.0f, 1e-6);
42+
}
43+
44+
SC_TEST(EcsCore_RemoveComponent_UpdatesSignature) {
45+
safecrowd::engine::EcsCore core;
46+
const auto e = core.createEntity();
47+
48+
core.addComponent(e, Position{3.0f, 4.0f});
49+
SC_EXPECT_TRUE(core.hasComponent<Position>(e));
50+
51+
core.removeComponent<Position>(e);
52+
SC_EXPECT_TRUE(!core.hasComponent<Position>(e));
53+
}
54+
55+
SC_TEST(EcsCore_RemoveComponent_NonExistent_IsSafe) {
56+
safecrowd::engine::EcsCore core;
57+
const auto e = core.createEntity();
58+
59+
core.removeComponent<Position>(e);
60+
SC_EXPECT_TRUE(!core.hasComponent<Position>(e));
61+
}
62+
63+
SC_TEST(EcsCore_DestroyEntity_CleansUpAllComponents) {
64+
safecrowd::engine::EcsCore core;
65+
const auto e = core.createEntity();
66+
67+
core.addComponent(e, Position{5.0f, 6.0f});
68+
core.addComponent(e, Velocity{1.0f, 0.0f});
69+
70+
SC_EXPECT_TRUE(core.hasComponent<Position>(e));
71+
SC_EXPECT_TRUE(core.hasComponent<Velocity>(e));
72+
73+
core.destroyEntity(e);
74+
75+
SC_EXPECT_TRUE(!core.isAlive(e));
76+
}
77+
78+
SC_TEST(EcsCore_EntityIndex_Reuse_DoesNotLeakComponents) {
79+
safecrowd::engine::EcsCore core;
80+
81+
const auto e1 = core.createEntity();
82+
core.addComponent(e1, Position{7.0f, 8.0f});
83+
84+
core.destroyEntity(e1);
85+
86+
const auto e2 = core.createEntity();
87+
SC_EXPECT_TRUE(e1.index == e2.index);
88+
89+
SC_EXPECT_TRUE(!core.hasComponent<Position>(e2));
90+
}
91+
92+
SC_TEST(EcsCore_MultipleComponents_IndependentSignatureBits) {
93+
safecrowd::engine::EcsCore core;
94+
const auto e = core.createEntity();
95+
96+
core.addComponent(e, Position{0.0f, 0.0f});
97+
core.addComponent(e, Velocity{1.0f, 1.0f});
98+
99+
SC_EXPECT_TRUE(core.hasComponent<Position>(e));
100+
SC_EXPECT_TRUE(core.hasComponent<Velocity>(e));
101+
102+
core.removeComponent<Position>(e);
103+
104+
SC_EXPECT_TRUE(!core.hasComponent<Position>(e));
105+
SC_EXPECT_TRUE(core.hasComponent<Velocity>(e));
106+
}

0 commit comments

Comments
 (0)