-
Notifications
You must be signed in to change notification settings - Fork 194
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
- Loading branch information
Showing
32 changed files
with
3,636 additions
and
1 deletion.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,2 @@ | ||
node_modules | ||
dist |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,10 @@ | ||
{ | ||
"root": true, | ||
"parser": "@typescript-eslint/parser", | ||
"plugins": ["@typescript-eslint"], | ||
"extends": [ | ||
"eslint:recommended", | ||
"plugin:@typescript-eslint/eslint-recommended", | ||
"plugin:@typescript-eslint/recommended" | ||
] | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,2 @@ | ||
node_modules | ||
dist |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,6 @@ | ||
{ | ||
"printWidth": 120, | ||
"semi": true, | ||
"tabWidth": 2, | ||
"useTabs": false | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,3 @@ | ||
# recs | ||
|
||
Reactive Entity Component System, implemented in TypeScript |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,7 @@ | ||
/* eslint-disable no-undef */ | ||
/** @type {import('ts-jest/dist/types').InitialOptionsTsJest} */ | ||
module.exports = { | ||
preset: "ts-jest", | ||
testEnvironment: "node", | ||
roots: ["tests"], | ||
}; |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,38 @@ | ||
{ | ||
"name": "@mud/recs", | ||
"license": "MIT", | ||
"version": "0.0.1", | ||
"main": "src/index.ts", | ||
"scripts": { | ||
"lint": "eslint . --ext .ts", | ||
"test": "jest", | ||
"build": "rimraf dist && rollup -c rollup.config.js" | ||
}, | ||
"devDependencies": { | ||
"@mud/utils": "0.0.1", | ||
"@rollup/plugin-node-resolve": "^13.1.3", | ||
"@rollup/plugin-typescript": "^8.3.1", | ||
"@types/jest": "^27.4.1", | ||
"@types/uuid": "^8.3.4", | ||
"@typescript-eslint/eslint-plugin": "^5.12.1", | ||
"@typescript-eslint/parser": "^5.12.1", | ||
"eslint": "^8.9.0", | ||
"jest": "^27.5.1", | ||
"mobx": "^6.4.2", | ||
"prettier": "^2.6.0", | ||
"rimraf": "^3.0.2", | ||
"rollup": "^2.69.0", | ||
"rollup-plugin-commonjs": "^10.1.0", | ||
"rollup-plugin-peer-deps-external": "^2.2.4", | ||
"rxjs": "^7.5.5", | ||
"ts-jest": "^27.1.3", | ||
"tslib": "^2.3.1", | ||
"typescript": "^4.5.5" | ||
}, | ||
"peerDependencies": { | ||
"@mud/utils": "0.0.1", | ||
"mobx": "^6.4.2", | ||
"rxjs": "7.5.5" | ||
}, | ||
"dependencies": {} | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,16 @@ | ||
import typescript from "@rollup/plugin-typescript"; | ||
import { nodeResolve } from "@rollup/plugin-node-resolve"; | ||
import commonjs from "rollup-plugin-commonjs"; | ||
import peerDepsExternal from "rollup-plugin-peer-deps-external"; | ||
|
||
import { defineConfig } from "rollup"; | ||
|
||
export default defineConfig({ | ||
input: "./src/index.ts", | ||
treeshake: true, | ||
output: { | ||
dir: "dist", | ||
sourcemap: true, | ||
}, | ||
plugins: [nodeResolve(), typescript(), commonjs(), peerDepsExternal()], | ||
}); |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,225 @@ | ||
import { uuid } from "@mud/utils"; | ||
import { runInAction, keys, toJS, isObservable, observable, action } from "mobx"; | ||
import { Subject } from "rxjs"; | ||
import { | ||
AnyComponent, | ||
AnyComponentValue, | ||
Component, | ||
ComponentValue, | ||
ComponentWithStream, | ||
ComponentWithValue, | ||
Entity, | ||
OverridableComponent, | ||
Override, | ||
Schema, | ||
ValueType, | ||
World, | ||
} from "./types"; | ||
|
||
export function defineComponent<T extends Schema>(world: World, schema: T, options?: { name?: string }): Component<T> { | ||
const component: AnyComponent = { | ||
id: options?.name || uuid(), | ||
values: {}, | ||
entities: new Set<Entity>(), | ||
stream$: new Subject(), | ||
}; | ||
|
||
for (const [key, val] of Object.entries(schema)) { | ||
component.values[key] = new Map<Entity, ValueType[typeof val]>(); | ||
} | ||
|
||
return world.registerComponent(component) as Component<T>; | ||
} | ||
|
||
export function setComponent<T extends Schema>(component: Component<T>, entity: Entity, value: ComponentValue<T>) { | ||
runInAction(() => { | ||
for (const [key, val] of Object.entries(value)) { | ||
component.values[key]?.set(entity, val); | ||
} | ||
|
||
component.entities.add(entity); | ||
}); | ||
(component as ComponentWithStream<T>).stream$.next({ entity, value }); | ||
} | ||
|
||
export function updateComponent<T extends Schema>( | ||
component: Component<T>, | ||
entity: Entity, | ||
value: Partial<ComponentValue<T>> | ||
) { | ||
const currentValue = getComponentValueStrict(component, entity); | ||
setComponent(component, entity, { ...currentValue, ...value }); | ||
} | ||
|
||
export function removeComponent<T extends Schema>(component: Component<T>, entity: Entity) { | ||
runInAction(() => { | ||
for (const key of Object.keys(component.values)) { | ||
component.values[key].delete(entity); | ||
} | ||
|
||
component.entities.delete(entity); | ||
}); | ||
(component as ComponentWithStream<T>).stream$.next({ entity, value: undefined }); | ||
} | ||
|
||
export function hasComponent<T extends Schema>(component: Component<T>, entity: Entity): boolean { | ||
return component.entities.has(entity); | ||
} | ||
|
||
export function getComponentValue<T extends Schema>( | ||
component: Component<T>, | ||
entity: Entity | ||
): ComponentValue<T> | undefined { | ||
const value: AnyComponentValue = {}; | ||
|
||
// Get the value of each schema key | ||
const schemaKeys = isObservable(component.values) ? keys(component.values) : Object.keys(component.values); | ||
for (const key of schemaKeys) { | ||
const val = component.values[key as string].get(entity); | ||
if (val === undefined) return undefined; | ||
value[key as string] = val; | ||
} | ||
|
||
// If the schema has no keys, return undefined instead of {} | ||
if (schemaKeys.length === 0 && !component.entities.has(entity)) return undefined; | ||
|
||
return value as ComponentValue<T>; | ||
} | ||
|
||
export function getComponentValueStrict<T extends Schema>(component: Component<T>, entity: Entity): ComponentValue<T> { | ||
const value = getComponentValue(component, entity); | ||
if (!value) { | ||
console.warn("No component value for this entity", toJS(component), entity); | ||
throw new Error("No component value for this entity"); | ||
} | ||
return value; | ||
} | ||
|
||
export function componentValueEquals<T extends Schema>(a?: Partial<ComponentValue<T>>, b?: ComponentValue<T>): boolean { | ||
if (!a && !b) return true; | ||
if (!a || !b) return false; | ||
|
||
let equals = true; | ||
for (const key of Object.keys(a)) { | ||
equals = a[key] === b[key]; | ||
if (!equals) return false; | ||
} | ||
return equals; | ||
} | ||
|
||
export function withValue<T extends Schema>(component: Component<T>, value: ComponentValue<T>): ComponentWithValue<T> { | ||
return { component, value }; | ||
} | ||
|
||
export function getEntitiesWithValue<T extends Schema>( | ||
component: Component<T>, | ||
value: Partial<ComponentValue<T>> | ||
): Set<Entity> { | ||
// Trivial implementation, needs to be more efficient | ||
const entities = new Set<Entity>(); | ||
for (const entity of component.entities) { | ||
const val = getComponentValue(component, entity); | ||
if (componentValueEquals(value, val)) { | ||
entities.add(entity); | ||
} | ||
} | ||
return entities; | ||
} | ||
|
||
/** | ||
* Returns a copy of the component at the current state. | ||
* Note: the cloned component will be disconnected from the original world and won't be updated if the original component changes. | ||
* @param component | ||
* @returns | ||
*/ | ||
export function cloneComponent<T extends Schema>(component: Component<T>): Component<T> { | ||
const clonedComponent: AnyComponent = { | ||
id: `${component.id}-copy`, | ||
values: {}, | ||
entities: new Set<Entity>(...component.entities), | ||
stream$: new Subject(), | ||
}; | ||
|
||
for (const key of Object.keys(component.values)) { | ||
const value = component.values[key]; | ||
const entries = [...value.entries()]; | ||
clonedComponent.values[key] = new Map(entries) as typeof value; | ||
} | ||
|
||
return clonedComponent as Component<T>; | ||
} | ||
|
||
/** | ||
* An overidable component is a mirror of the source component, with functions to lazily override specific entity values. | ||
* Lazily override means the values are not actually set to the source component, but the override is only returned if the value is read. | ||
* @param component source component | ||
* @returns overridable component | ||
*/ | ||
export function overridableComponent<T extends Schema>(component: Component<T>): OverridableComponent<T> { | ||
let nonce = 0; | ||
const overrides = new Map<string, { update: Override<T>; nonce: number }>(); | ||
|
||
// Store overridden entity values in an observable map, | ||
// so that observers of this components get triggered when a | ||
// specific component values changes. | ||
const overriddenEntityValues = observable(new Map<Entity, ComponentValue<T>>()); | ||
|
||
const addOverride = action((id: string, update: Override<T>) => { | ||
overrides.set(id, { update, nonce: nonce++ }); | ||
overriddenEntityValues.set(update.entity, update.value); | ||
}); | ||
|
||
const removeOverride = action((id: string) => { | ||
const affectedEntity = overrides.get(id)?.update.entity; | ||
overrides.delete(id); | ||
|
||
if (!affectedEntity) return; | ||
|
||
// If there are more overries affecting this entity, | ||
// set the overriddenEntityValue to the last override | ||
const relevantOverrides = [...overrides.values()] | ||
.filter((o) => o.update.entity === affectedEntity) | ||
.sort((a, b) => (a.nonce < b.nonce ? -1 : 1)); | ||
|
||
if (relevantOverrides.length > 0) { | ||
const lastOverride = relevantOverrides[relevantOverrides.length - 1]; | ||
overriddenEntityValues.set(affectedEntity, lastOverride.update.value); | ||
} else { | ||
overriddenEntityValues.delete(affectedEntity); | ||
} | ||
}); | ||
|
||
const valueProxyHandler: (key: keyof T) => ProxyHandler<typeof component.values[typeof key]> = (key: keyof T) => ({ | ||
get(target, prop) { | ||
// Intercept calls to component.value[key].get(entity) | ||
if (prop === "get") { | ||
return (entity: Entity) => { | ||
const originalValue = target.get(entity); | ||
const overriddenValue = overriddenEntityValues.get(entity); | ||
return overriddenValue ? overriddenValue[key] : originalValue; | ||
}; | ||
} | ||
return Reflect.get(target, prop); | ||
}, | ||
}); | ||
|
||
const partialValues: Partial<Component<T>["values"]> = {}; | ||
for (const key of keys(component.values) as (keyof T)[]) { | ||
partialValues[key] = new Proxy(component.values[key], valueProxyHandler(key)); | ||
} | ||
const valuesProxy = partialValues as Component<T>["values"]; | ||
|
||
return new Proxy(component, { | ||
get(target, prop) { | ||
if (prop === "addOverride") return addOverride; | ||
if (prop === "removeOverride") return removeOverride; | ||
if (prop === "values") return valuesProxy; | ||
|
||
return Reflect.get(target, prop); | ||
}, | ||
has(target, prop) { | ||
if (prop === "addOverride" || prop === "removeOverride") return true; | ||
return prop in target; | ||
}, | ||
}) as OverridableComponent<T>; | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,26 @@ | ||
import { getEntityComponents } from "./World"; | ||
import { setComponent, removeComponent } from "./Component"; | ||
import { ComponentWithValue, Entity, Schema, Unpacked, World } from "./types"; | ||
|
||
export function createEntity<Cs extends Schema[]>( | ||
world: World, | ||
components?: ComponentWithValue<Unpacked<Cs>>[], | ||
options?: { id?: string } | ||
): Entity { | ||
const entity = world.registerEntity(options?.id); | ||
|
||
if (components) { | ||
for (const { component, value } of components) { | ||
setComponent(component, entity, value); | ||
} | ||
} | ||
|
||
return entity; | ||
} | ||
|
||
export function removeEntity(world: World, entity: Entity) { | ||
const components = getEntityComponents(world, entity); | ||
for(const component of components) { | ||
removeComponent(component, entity); | ||
} | ||
} |
Oops, something went wrong.