diff --git a/README.md b/README.md index f9a8e29..36d7a22 100644 --- a/README.md +++ b/README.md @@ -41,8 +41,8 @@ The Flamework transformer must be configured in your `tsconfig.json`. The fields { "transform": "rbxts-transformer-flamework", }, - ] - } + ], + }, } ``` @@ -67,60 +67,135 @@ You should find the entry for `node_modules` and modify it to include `@flamewor You may need to delete the `out` folder and recompile for Flamework's transformer to begin working. Afterwards, you are ready to use flamecs. -## Demo +## Components -```ts -const positionEntity = spawn<[Vector3]>([new Vector3(10, 20, 30)]); -print(has(positionEntity)); +```typescript +interface Position { + x: number; + y: number; +} -start({}, () => { - if (useThrottle(5)) { - for (const [entity, position, orientation] of query<[Vector3, CFrame]>()) { - print(`Entity: ${entity}, Position: ${position}, Orientation: ${orientation}`); - } - } -}); +// Tag (no data) +interface Player extends Tag {} + +// Components can be wrapped to use non-table data +interface Name extends Wrap {} +interface Health extends Wrap {} +``` + +## Entities + +### Spawning Entities + +```typescript +const entity = spawn(); + +// When spawning with tags the bundle list can be omitted +const marcus = spawn<[Player]>(); + +const ketchup = spawn<[Position, Player]>([{ x: 0, y: 0 }]); + +// Get the runtime entity id from a component +const positionComponent = component(); +``` + +### Modifying Entities + +```typescript +add(entity); + +set(entity, { x: 10, y: 20 }); +set(entity, "Alice"); + +// Insert can be used to add/set multiple components +insert<[Name, Health, Player]>(entity, ["Bob", 100]); + +remove(entity); + +if (has(entity)) { + // ... +} + +const pos = get(entity); +const name = get(entity); + +despawn(entity); +``` + +## Queries + +```typescript +for (const [entity, pos, name] of query<[Position, Name]>()) { + print(`${name} at ${pos.x}, ${pos.y}`); +} -for (const [entity, position] of query<[Vector3, Without<[CFrame]>]>()) { - print(`Entity: ${entity}, Position: ${position}`); +for (const [entity, pos] of query<[Position, Without]>()) { + // ... } -// Example of using pair relationships between entities -interface Likes {} -interface Eats { - count: number; +for (const [entity, pos] of query<[Position, With<[Player, Health]>]>()) { + // ... } +``` -interface Fruit {} +## Relationships -const alice = spawn(); -const bob = spawn(); -const charlie = spawn(); +### Defining Relationships -const banana = spawn(); -add(banana); +```typescript +interface Likes extends Tag {} +interface Owns extends Wrap {} +// Alice likes Bob add>(alice, bob); -add>(alice, charlie); -add>(bob); +// Alice owns 5 items +set>(alice, 5); +``` -set>(bob, banana, { count: 5 }); -set>(alice, { count: 12 }); +### Querying Relationships -for (const [entity] of query().pair(alice)) { - const likedEntity = target(entity); - print(`Entity ${entity} likes ${likedEntity}`); +```typescript +// Query all entities that like something Pair +for (const [entity] of query<[Pair]>()) { + const target = target(entity); + print(`${entity} likes ${target}`); } -for (const [entity, eatsData] of query<[Pair]>()) { - const eatsTarget = target(entity); - print(`Entity ${entity} eats ${eatsData.count} fruit (${eatsTarget})`); +// Query specific relationships where the object is a runtime id +for (const [entity] of query().pair(bob)) { + // ... } +``` -// Using Pair

to match any target (wildcard), equivalent to Pair -for (const [entity] of query<[Pair]>()) { - const likedTarget = target(entity); - print(`Entity ${entity} likes ${likedTarget}`); -} +## Signals + +```typescript +added().connect((entity) => { + print(`Position added to ${entity}`); +}); + +removed().connect((entity) => { + print(`Position removed from ${entity}`); +}); + +changed().connect((entity, newValue) => { + print(`Position of ${entity} changed to ${newValue}`); +}); +``` + +## Hooks and Systems + +```typescript +// Hooks must be used within a `start()` function. +start({}, () => { + if (useThrottle(0.5)) { + // ... + } + + for (const [player] of useEvent(Players.PlayerAdded)) { + const entity = spawn<[Name, Player]>([player.Name]); + // ... + } +}); ``` diff --git a/src/index.ts b/src/index.ts index 61d8dd6..2a42b26 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,7 +1,7 @@ export * from "./hooks/use-event"; export * from "./hooks/use-throttle"; export * from "./query"; -export type { ChildOf, Entity, Id, Pair, Tag, Wildcard } from "./registry"; +export type { ChildOf, Entity, Id, Name, Pair, Tag, Wildcard, Wrap } from "./registry"; export { add, added, @@ -17,6 +17,7 @@ export { remove, removed, reserve, + reset, set, spawn, target, diff --git a/src/query.ts b/src/query.ts index 3deb522..1e6fd30 100644 --- a/src/query.ts +++ b/src/query.ts @@ -1,7 +1,6 @@ -import type { Modding } from "@flamework/core"; import * as ecs from "@rbxts/jecs"; -import type { Entity, FilterPairs, Id, ResolveKeys } from "./registry"; +import type { ComponentKey, Entity, Id, ResolveKeys, ResolveValues, Unwrap } from "./registry"; import { component, getId, registry } from "./registry"; // Almost full credits to @fireboltofdeath for all of these types. @@ -37,7 +36,10 @@ type Calculate, B extends Bounds = Bounds> = T extends : Calculate, PushBound>; type ToIds = T extends [] ? undefined : ResolveKeys; -type ExtractQueryTypes> = Reconstruct["query"]>>; + +type ExtractQueryTypes> = Reconstruct< + ResolveValues["query"]> +>; type QueryHandle> = { __iter(): IterableFunction>; @@ -53,19 +55,19 @@ type QueryHandle> = { * @returns A new QueryHandle with the pair filter added. * @metadata macro */ - pair

(object: Entity, predicate?: Modding.Generic): QueryHandle<[...T, P]>; + pair

(object: Entity, predicate?: ComponentKey

): QueryHandle<[...T, Unwrap

]>; terms?: Array; } & IterableFunction>; function queryPair, P>( this: QueryHandle, object: Entity, - predicate?: Modding.Generic, -): QueryHandle<[...T, P]> { + predicate?: ComponentKey

, +): QueryHandle<[...T, Unwrap

]> { assert(predicate); const id = ecs.pair(component(predicate), object); this.terms = this.terms ? [...this.terms, id] : [id]; - return this as unknown as QueryHandle<[...T, P]>; + return this as unknown as QueryHandle<[...T, Unwrap

]>; } function queryIter>( diff --git a/src/registry.ts b/src/registry.ts index c0930ea..3a68100 100644 --- a/src/registry.ts +++ b/src/registry.ts @@ -1,31 +1,41 @@ import type { Modding } from "@flamework/core"; import * as ecs from "@rbxts/jecs"; -import { createSignal, type Signal } from "./signal"; +import type { Signal } from "./signal"; +import { createSignal } from "./signal"; -export interface Wildcard {} -export interface ChildOf {} +export interface Wrap { + readonly _flamecs_type: T; +} +export interface Tag extends Wrap {} + +export interface ChildOf extends Tag {} +export interface Wildcard extends Tag {} +export interface Name extends Wrap {} -export type Entity = ecs.Entity; export type Id = ecs.Id; +export type Entity = ecs.Entity; export type Pair

= ecs.Pair; -export type Tag = ecs.Tag; + +export type ExtractPredicate = T extends Pair ? P : T; +export type Unwrap = T extends Wrap ? Inner : T; + +export type ResolveValue = Unwrap>; +export type ResolveValues = { [K in keyof T]: ResolveValue }; export type ComponentKey = Modding.Generic; export type PairKey = Modding.Many<{ obj: ComponentKey; pred: ComponentKey

; }>; - export type ResolveKey = T extends Pair ? PairKey : ComponentKey; export type ResolveKeys = Modding.Many<{ [K in keyof T]: ResolveKey; }>; -export type FilterPair = T extends Pair ? P : T; -export type FilterPairs = { - [K in keyof T]: FilterPair; -}; +type TrailingUndefined> = T extends [...infer Rest, undefined] + ? [...TrailingUndefined, undefined?] + : T; const components = new Map(); export const registry = new ecs.World(); @@ -36,22 +46,50 @@ export const signals = { removed: new Map>(), }; -export function added(id: Entity): Signal<[Entity]> { +/** + * Returns a signal that fires when a component is added to an entity. + * + * @template T - The type of the component. + * @param key - Flamework autogenerated key for the component. + * @returns A signal that fires when the component is added to any entity. + * @metadata macro + */ +export function added(key?: ComponentKey): Signal<[Entity]> { + const id = component(key); return signals.added.get(id)! as Signal<[Entity]>; } -export function removed(id: Entity): Signal<[Entity]> { +/** + * Returns a signal that fires when a component is removed from an entity. + * + * @template T - The type of the component. + * @param key - Flamework autogenerated key for the component. + * @returns A signal that fires when the component is removed from any entity. + * @metadata macro + */ +export function removed(key?: ComponentKey): Signal<[Entity]> { + const id = component(key); return signals.removed.get(id)! as Signal<[Entity]>; } -export function changed(id: Entity): Signal<[Entity, T]> { +/** + * Returns a signal that fires when a component's value changes on an entity. + * + * @template T - The type of the component. + * @param key - Flamework autogenerated key for the component. + * @returns A signal that fires when the component's value changes on any + * entity. + * @metadata macro + */ +export function changed(key?: ComponentKey): Signal<[Entity, T]> { + const id = component(key); return signals.changed.get(id)! as Signal<[Entity, T]>; } -function hookListeners(id: Entity): void { +function hookListeners(id: Entity): void { const addedSignal = createSignal<[Entity]>(); const removedSignal = createSignal<[Entity]>(); - const changedSignal = createSignal<[Entity, T]>(); + const changedSignal = createSignal<[Entity, unknown]>(); signals.added.set(id, addedSignal); signals.removed.set(id, removedSignal); signals.changed.set(id, changedSignal); @@ -63,7 +101,7 @@ function hookListeners(id: Entity): void { removedSignal.fire(entity); }); registry.set(id, ecs.OnSet, (entity, data) => { - changedSignal.fire(entity, data as T); + changedSignal.fire(entity, data); }); } @@ -75,11 +113,11 @@ function hookListeners(id: Entity): void { * @param key - Flamework autogenerated key. * @metadata macro */ -export function reserve(runtimeId: Entity, key?: Modding.Generic): void { +export function reserve(runtimeId: Entity>, key?: ComponentKey): void { assert(key); assert(!components.has(key), `A component with the key "${key}" already exists`); components.set(key, runtimeId); - hookListeners(runtimeId); + hookListeners(runtimeId); } /** @@ -94,12 +132,12 @@ export function reserve(runtimeId: Entity, key?: Modding.Generic) * @returns The component entity ID. * @metadata macro */ -export function component(key?: ComponentKey): Entity { +export function component(key?: ComponentKey): Entity> { assert(key); - let id = components.get(key) as Entity | undefined; + let id = components.get(key) as Entity> | undefined; if (id === undefined) { - id = registry.component(); + id = registry.component(); components.set(key, id); hookListeners(id); } @@ -115,7 +153,7 @@ export function component(key?: ComponentKey): Entity { * @returns The component or pair ID. * @metadata macro. */ -export function getId(key?: ResolveKey): Id> { +export function getId(key?: ResolveKey): Id> { assert(key); if (typeIs(key, "table")) { @@ -130,24 +168,59 @@ export function getId(key?: ResolveKey): Id> { } /** - * Creates a new entity with the specified components. + * Creates a new empty entity. + * + * @returns The created entity. + * @metadata macro + */ +export function spawn(): ecs.Tag; + +/** + * Creates a new entity with the specified tag components. + * + * @template T - The type of the components. + * @param keys - Flamework autogenerated keys. + * @returns The created entity. + * @metadata macro + */ +export function spawn>(keys?: ResolveKeys): ecs.Tag; + +/** + * Creates a new entity with the specified components and their values. * * @template T - The type of the components. - * @param bundle - The components to add to the entity. + * @param values - The values to set for the components. * @param keys - Flamework autogenerated keys. * @returns The created entity. * @metadata macro */ export function spawn>( - bundle?: FilterPairs, + values: TrailingUndefined>, keys?: ResolveKeys, -): Tag { +): ecs.Tag; + +export function spawn>(argument1?: unknown, argument2?: unknown): ecs.Tag { const entity = registry.entity(); - if (bundle && keys) { + if (argument2 !== undefined) { + // Spawn with components: spawn(values, keys) + const values = argument1 as TrailingUndefined>; + const keys = argument2 as ResolveKeys; + for (let index = 0; index < keys.size(); index++) { const id = getId(keys[index]); - registry.set(entity, id, bundle[index]); + const value = values[index]; + if (value !== undefined) { + registry.set(entity, id, value); + } else { + registry.add(entity, id); + } + } + } else if (argument1 !== undefined) { + // Spawn with tags only: spawn(keys) + const keys = argument1 as ResolveKeys; + for (const key of keys) { + registry.add(entity, getId(key)); } } @@ -163,6 +236,16 @@ export function despawn(entity: Entity): void { registry.delete(entity); } +/** + * Adds or updates multiple components for the specified entity. + * + * @template T - The type of the components. + * @param entity - The entity to modify. + * @param keys - Flamework autogenerated keys. + * @metadata macro + */ +export function insert>(entity: Entity, keys?: ResolveKeys): void; + /** * Adds or updates multiple components for the specified entity. * @@ -174,13 +257,36 @@ export function despawn(entity: Entity): void { */ export function insert>( entity: Entity, - values: FilterPairs, + values: TrailingUndefined>, keys?: ResolveKeys, +): void; + +export function insert>( + entity: Entity, + argument1?: unknown, + argument2?: unknown, ): void { - assert(keys); - for (let index = 0; index < keys.size(); index++) { - const id = getId(keys[index]); - registry.set(entity, id, values[index]); + assert(argument1 !== undefined); + if (argument2 !== undefined) { + // Insert components: insert(entity, values, keys) + const values = argument1 as TrailingUndefined>; + const keys = argument2 as ResolveKeys; + + for (let index = 0; index < keys.size(); index++) { + const id = getId(keys[index]); + const value = values[index]; + if (value !== undefined) { + registry.set(entity, id, value); + } else { + registry.add(entity, id); + } + } + } else { + // Insert tags only: insert(entity, keys) + const keys = argument1 as ResolveKeys; + for (const key of keys) { + registry.add(entity, getId(key)); + } } } @@ -193,10 +299,10 @@ export function insert>( * @param key - Flamework autogenerated key. * @metadata macro */ -export function add>( +export function add>( entity: Entity, object: Entity, - key?: ComponentKey>, + key?: ComponentKey>, ): void; /** @@ -208,7 +314,7 @@ export function add>( * @param key - Flamework autogenerated key. * @metadata macro */ -export function add>(entity: Entity, key?: ResolveKey): void; +export function add>(entity: Entity, key?: ResolveKey): void; /** * Adds a component to an entity. @@ -218,7 +324,7 @@ export function add>(entity: Entity, key?: Reso * @param key - Flamework autogenerated key. * @metadata macro */ -export function add(entity: Entity, key?: ComponentKey): void; +export function add(entity: Entity, key?: ComponentKey): void; export function add(entity: Entity, argument1?: unknown, argument2?: unknown): void { if (argument2 !== undefined) { @@ -245,7 +351,7 @@ export function add(entity: Entity, argument1?: unknown, argument2?: unknown): v export function remove>( entity: Entity, object: Entity, - key?: ComponentKey>, + key?: ComponentKey>, ): void; /** @@ -295,8 +401,8 @@ export function remove(entity: Entity, argument1?: unknown, argument2?: unknown) export function set>( entity: Entity, object: Entity, - value: FilterPair, - key?: ComponentKey>, + value: ResolveValue, + key?: ComponentKey>, ): void; /** @@ -311,7 +417,7 @@ export function set>( */ export function set>( entity: Entity, - value: FilterPair, + value: ResolveValue, key?: ResolveKey, ): void; @@ -324,7 +430,7 @@ export function set>( * @param key - Flamework autogenerated key. * @metadata macro */ -export function set(entity: Entity, value: T, key?: ComponentKey): void; +export function set(entity: Entity, value: Unwrap, key?: ComponentKey): void; export function set( entity: Entity, @@ -359,8 +465,8 @@ export function set( export function get>( entity: Entity, object: Entity, - key?: ComponentKey>, -): FilterPair | undefined; + key?: ComponentKey>, +): ResolveValue | undefined; /** * Retrieves the value of a component or pair for a specific entity. @@ -371,7 +477,7 @@ export function get>( * @returns The value associated with the component or pair. * @metadata macro */ -export function get(entity: Entity, key?: ResolveKey): FilterPair | undefined; +export function get(entity: Entity, key?: ResolveKey): ResolveValue | undefined; export function get(entity: Entity, argument1?: unknown, argument2?: unknown): unknown { if (argument2 !== undefined) { @@ -399,7 +505,7 @@ export function get(entity: Entity, argument1?: unknown, argument2?: unknown): u export function has>( entity: Entity, object: Entity, - key?: ComponentKey>, + key?: ComponentKey>, ): boolean; /** @@ -462,10 +568,23 @@ export function parent(entity: Entity): Entity | undefined { * @returns The pair ID. * @metadata macro */ -export function pair

(object: Entity, predicate?: ComponentKey

): Pair { +export function pair

(object: Entity, predicate?: ComponentKey

): Pair, unknown> { const predicateId = component(predicate); return ecs.pair(predicateId, object); } -reserve(ecs.Wildcard as Entity); -reserve(ecs.ChildOf as Entity); +/** + * Resets the ECS registry to its initial state. This should primarily be used + * for unit testing to ensure a clean state between tests. + */ +export function reset(): void { + const temporaryRegistry = new ecs.World(); + for (const [key, value] of pairs(temporaryRegistry)) { + (registry as unknown as Record)[key] = value; + } + + components.clear(); +} + +reserve(ecs.Wildcard as ecs.Tag); +reserve(ecs.ChildOf as ecs.Tag);