Skip to content

Commit 757b8bc

Browse files
committed
refactor(entity): split entity.ts into focused modules
Extract entity.ts into four separate modules for better organization: - entity-types.ts: EntityId types, constants, basic type guards - entity-relation.ts: relation ID creation, decoding, inspection - entity-manager.ts: EntityIdManager and ComponentIdAllocator classes - component-registry.ts: component registration and options management The original entity.ts now re-exports everything for backwards compatibility.
1 parent ff60821 commit 757b8bc

File tree

5 files changed

+798
-709
lines changed

5 files changed

+798
-709
lines changed

src/core/component-registry.ts

Lines changed: 238 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,238 @@
1+
import { BitSet } from "../utils/bit-set";
2+
import { ComponentIdAllocator } from "./entity-manager";
3+
import { decodeRelationRaw } from "./entity-relation";
4+
import type { ComponentId, EntityId } from "./entity-types";
5+
import {
6+
COMPONENT_ID_MAX,
7+
ENTITY_ID_START,
8+
isComponentId,
9+
isValidComponentId,
10+
WILDCARD_TARGET_ID,
11+
} from "./entity-types";
12+
13+
const globalComponentIdAllocator = new ComponentIdAllocator();
14+
15+
const ComponentIdForNames: Map<string, ComponentId<any>> = new Map();
16+
17+
/**
18+
* Component options that define intrinsic properties
19+
*/
20+
export interface ComponentOptions {
21+
/**
22+
* Optional name for the component (for serialization/debugging)
23+
*/
24+
name?: string;
25+
/**
26+
* If true, an entity can have at most one relation per base component.
27+
* When adding a new relation with the same base component, any existing relations
28+
* with that base component are automatically removed.
29+
* Only applicable to relation components.
30+
*/
31+
exclusive?: boolean;
32+
/**
33+
* If true, when a relation target entity is deleted, all entities that reference
34+
* it through this component will also be deleted (cascade delete).
35+
* Only applicable to entity-relation components.
36+
*/
37+
cascadeDelete?: boolean;
38+
/**
39+
* If true, relations with this component will not cause archetype fragmentation.
40+
* Entities with different target entities for this relation component will be stored
41+
* in the same archetype, preventing fragmentation when there are many different targets.
42+
* Only applicable to relation components.
43+
* Inspired by Flecs' DontFragment trait.
44+
*/
45+
dontFragment?: boolean;
46+
}
47+
48+
// Array for component names (Component ID range: 1-1023)
49+
const componentNames: (string | undefined)[] = new Array(COMPONENT_ID_MAX + 1);
50+
51+
// BitSets for fast component option checks (Component ID range: 1-1023)
52+
const exclusiveFlags = new BitSet(COMPONENT_ID_MAX + 1);
53+
const cascadeDeleteFlags = new BitSet(COMPONENT_ID_MAX + 1);
54+
const dontFragmentFlags = new BitSet(COMPONENT_ID_MAX + 1);
55+
56+
/**
57+
* Allocate a new component ID from the global allocator.
58+
* @param nameOrOptions Optional name for the component (for serialization/debugging) or options object
59+
* @returns The allocated component ID
60+
* @example
61+
* // Just a name
62+
* const Position = component<Position>("Position");
63+
*
64+
* // With options
65+
* const ChildOf = component({ exclusive: true, cascadeDelete: true });
66+
*
67+
* // With name and options
68+
* const ChildOf = component({ name: "ChildOf", exclusive: true });
69+
*/
70+
export function component<T = void>(nameOrOptions?: string | ComponentOptions): ComponentId<T> {
71+
const id = globalComponentIdAllocator.allocate<T>();
72+
73+
let name: string | undefined;
74+
let options: ComponentOptions | undefined;
75+
76+
// Parse the parameter
77+
if (typeof nameOrOptions === "string") {
78+
name = nameOrOptions;
79+
} else if (typeof nameOrOptions === "object" && nameOrOptions !== null) {
80+
options = nameOrOptions;
81+
name = options.name;
82+
}
83+
84+
// Register name if provided
85+
if (name) {
86+
if (ComponentIdForNames.has(name)) {
87+
throw new Error(`Component name "${name}" is already registered`);
88+
}
89+
90+
componentNames[id] = name;
91+
ComponentIdForNames.set(name, id);
92+
}
93+
94+
// Register options if provided
95+
if (options) {
96+
// Set bitset flags for fast lookup
97+
if (options.exclusive) exclusiveFlags.set(id);
98+
if (options.cascadeDelete) cascadeDeleteFlags.set(id);
99+
if (options.dontFragment) dontFragmentFlags.set(id);
100+
}
101+
102+
return id;
103+
}
104+
105+
/**
106+
* Get a component ID by its registered name
107+
* @param name The component name
108+
* @returns The component ID if found, undefined otherwise
109+
*/
110+
export function getComponentIdByName(name: string): ComponentId<any> | undefined {
111+
return ComponentIdForNames.get(name);
112+
}
113+
114+
/** Get a component name by its ID
115+
* @param id The component ID
116+
* @returns The component name if found, undefined otherwise
117+
*/
118+
export function getComponentNameById(id: ComponentId<any>): string | undefined {
119+
return componentNames[id];
120+
}
121+
122+
/**
123+
* Get component options by its ID
124+
* @param id The component ID
125+
* @returns The component options
126+
*/
127+
export function getComponentOptions(id: ComponentId<any>): ComponentOptions {
128+
if (!isComponentId(id)) {
129+
throw new Error("Invalid component ID");
130+
}
131+
const hasName = componentNames[id] !== undefined;
132+
const hasExclusive = exclusiveFlags.has(id);
133+
const hasCascadeDelete = cascadeDeleteFlags.has(id);
134+
const hasDontFragment = dontFragmentFlags.has(id);
135+
return {
136+
name: hasName ? componentNames[id] : undefined,
137+
exclusive: hasExclusive ? true : undefined,
138+
cascadeDelete: hasCascadeDelete ? true : undefined,
139+
dontFragment: hasDontFragment ? true : undefined,
140+
};
141+
}
142+
143+
/**
144+
* Check if a component is marked as exclusive
145+
* @param id The component ID
146+
* @returns true if the component is exclusive, false otherwise
147+
*/
148+
export function isExclusiveComponent(id: ComponentId<any>): boolean {
149+
return exclusiveFlags.has(id);
150+
}
151+
152+
/**
153+
* Check if a component is marked as cascade delete
154+
* @param id The component ID
155+
* @returns true if the component is cascade delete, false otherwise
156+
*/
157+
export function isCascadeDeleteComponent(id: ComponentId<any>): boolean {
158+
return cascadeDeleteFlags.has(id);
159+
}
160+
161+
/**
162+
* Check if a component is marked as dontFragment
163+
* @param id The component ID
164+
* @returns true if the component is dontFragment, false otherwise
165+
*/
166+
export function isDontFragmentComponent(id: ComponentId<any>): boolean {
167+
return dontFragmentFlags.has(id);
168+
}
169+
170+
/**
171+
* Generic function to check relation flags with specific target conditions
172+
* @param id The entity/relation ID to check
173+
* @param flagBitSet The bitset for the flag
174+
* @param targetCondition Function to check target ID condition
175+
* @returns true if the condition is met, false otherwise
176+
*/
177+
function checkRelationFlag(
178+
id: EntityId<any>,
179+
flagBitSet: BitSet,
180+
targetCondition: (targetId: number) => boolean,
181+
): boolean {
182+
const decoded = decodeRelationRaw(id);
183+
if (decoded === null) return false;
184+
const { componentId, targetId } = decoded;
185+
return isValidComponentId(componentId) && targetCondition(targetId) && flagBitSet.has(componentId);
186+
}
187+
188+
/**
189+
* Check if a relation ID is a dontFragment relation (entity-relation or component-relation with dontFragment component)
190+
* This is an optimized function that avoids the overhead of getDetailedIdType
191+
* @param id The entity/relation ID to check
192+
* @returns true if this is a dontFragment relation, false otherwise
193+
*/
194+
export function isDontFragmentRelation(id: EntityId<any>): boolean {
195+
return checkRelationFlag(id, dontFragmentFlags, (targetId) => targetId !== WILDCARD_TARGET_ID);
196+
}
197+
198+
/**
199+
* Check if an ID is a wildcard relation with dontFragment component
200+
* This is an optimized function for filtering archetype component types
201+
* @param id The entity/relation ID to check
202+
* @returns true if this is a wildcard relation with dontFragment component, false otherwise
203+
*/
204+
export function isDontFragmentWildcard(id: EntityId<any>): boolean {
205+
return checkRelationFlag(id, dontFragmentFlags, (targetId) => targetId === WILDCARD_TARGET_ID);
206+
}
207+
208+
/**
209+
* Check if a relation ID is an exclusive relation (entity-relation or component-relation with exclusive component)
210+
* This avoids the full getDetailedIdType overhead for hot paths
211+
* @param id The entity/relation ID to check
212+
* @returns true if this is an exclusive relation, false otherwise
213+
*/
214+
export function isExclusiveRelation(id: EntityId<any>): boolean {
215+
return checkRelationFlag(id, exclusiveFlags, (targetId) => targetId !== WILDCARD_TARGET_ID);
216+
}
217+
218+
/**
219+
* Check if a relation ID is a wildcard relation with exclusive component
220+
*/
221+
export function isExclusiveWildcard(id: EntityId<any>): boolean {
222+
return checkRelationFlag(id, exclusiveFlags, (targetId) => targetId === WILDCARD_TARGET_ID);
223+
}
224+
225+
/**
226+
* Check if a relation ID is a cascade delete entity-relation
227+
* This is an optimized function that avoids the overhead of getDetailedIdType
228+
* Note: Cascade delete only applies to entity-relations (not component-relations or wildcards)
229+
* @param id The entity/relation ID to check
230+
* @returns true if this is an entity-relation with cascade delete, false otherwise
231+
*/
232+
export function isCascadeDeleteRelation(id: EntityId<any>): boolean {
233+
return checkRelationFlag(
234+
id,
235+
cascadeDeleteFlags,
236+
(targetId) => targetId !== WILDCARD_TARGET_ID && targetId >= ENTITY_ID_START,
237+
);
238+
}

src/core/entity-manager.ts

Lines changed: 113 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,113 @@
1+
import type { ComponentId, EntityId } from "./entity-types";
2+
import { COMPONENT_ID_MAX, ENTITY_ID_START, isEntityId } from "./entity-types";
3+
4+
/**
5+
* Entity ID Manager for automatic allocation and freelist recycling
6+
*/
7+
export class EntityIdManager {
8+
private nextId: number = ENTITY_ID_START;
9+
private freelist: Set<EntityId> = new Set();
10+
11+
/**
12+
* Allocate a new entity ID
13+
* Uses freelist if available, otherwise increments counter
14+
*/
15+
allocate(): EntityId {
16+
if (this.freelist.size > 0) {
17+
const id = this.freelist.values().next().value!;
18+
this.freelist.delete(id);
19+
return id;
20+
} else {
21+
const id = this.nextId;
22+
this.nextId++;
23+
// Check for overflow (though unlikely in practice)
24+
if (this.nextId >= Number.MAX_SAFE_INTEGER) {
25+
throw new Error("Entity ID overflow: reached maximum safe integer");
26+
}
27+
return id as EntityId;
28+
}
29+
}
30+
31+
/**
32+
* Deallocate an entity ID, adding it to the freelist for reuse
33+
* @param id The entity ID to deallocate
34+
*/
35+
deallocate(id: EntityId<any>): void {
36+
if (!isEntityId(id)) {
37+
throw new Error("Can only deallocate valid entity IDs");
38+
}
39+
if (id >= this.nextId) {
40+
throw new Error("Cannot deallocate an ID that was never allocated");
41+
}
42+
this.freelist.add(id);
43+
}
44+
45+
/**
46+
* Get the current freelist size (for debugging/monitoring)
47+
*/
48+
getFreelistSize(): number {
49+
return this.freelist.size;
50+
}
51+
52+
/**
53+
* Get the next ID that would be allocated (for debugging)
54+
*/
55+
getNextId(): number {
56+
return this.nextId;
57+
}
58+
59+
/**
60+
* Serialize internal state for persistence.
61+
* Returns a plain object representing allocator state. Values may be non-JSON-serializable.
62+
*/
63+
serializeState(): { nextId: number; freelist: number[] } {
64+
return { nextId: this.nextId, freelist: Array.from(this.freelist) };
65+
}
66+
67+
/**
68+
* Restore internal state from a previously-serialized object.
69+
* Overwrites the current nextId and freelist.
70+
*/
71+
deserializeState(state: { nextId: number; freelist?: number[] }): void {
72+
if (typeof state.nextId !== "number") {
73+
throw new Error("Invalid state for EntityIdManager.deserializeState");
74+
}
75+
this.nextId = state.nextId;
76+
this.freelist = new Set((state.freelist || []) as EntityId[]);
77+
}
78+
}
79+
80+
/**
81+
* Component ID Manager for automatic allocation
82+
* Components are typically registered once and not recycled
83+
*/
84+
export class ComponentIdAllocator {
85+
private nextId: number = 1;
86+
87+
/**
88+
* Allocate a new component ID
89+
* Increments counter sequentially from 1
90+
*/
91+
allocate<T = void>(): ComponentId<T> {
92+
if (this.nextId > COMPONENT_ID_MAX) {
93+
throw new Error(`Component ID overflow: maximum ${COMPONENT_ID_MAX} components allowed`);
94+
}
95+
const id = this.nextId;
96+
this.nextId++;
97+
return id as ComponentId<T>;
98+
}
99+
100+
/**
101+
* Get the next ID that would be allocated (for debugging)
102+
*/
103+
getNextId(): number {
104+
return this.nextId;
105+
}
106+
107+
/**
108+
* Check if more component IDs are available
109+
*/
110+
hasAvailableIds(): boolean {
111+
return this.nextId <= COMPONENT_ID_MAX;
112+
}
113+
}

0 commit comments

Comments
 (0)