Skip to content

Commit

Permalink
feat(@mud/recs): add @mud/recs
Browse files Browse the repository at this point in the history
  • Loading branch information
alvrs committed May 13, 2022
1 parent 929ff36 commit aaf6d0f
Show file tree
Hide file tree
Showing 32 changed files with 3,636 additions and 1 deletion.
2 changes: 2 additions & 0 deletions packages/recs/.eslintignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
node_modules
dist
10 changes: 10 additions & 0 deletions packages/recs/.eslintrc
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"
]
}
2 changes: 2 additions & 0 deletions packages/recs/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
node_modules
dist
6 changes: 6 additions & 0 deletions packages/recs/.prettierrc
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
{
"printWidth": 120,
"semi": true,
"tabWidth": 2,
"useTabs": false
}
3 changes: 3 additions & 0 deletions packages/recs/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
# recs

Reactive Entity Component System, implemented in TypeScript
7 changes: 7 additions & 0 deletions packages/recs/jest.config.js
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"],
};
38 changes: 38 additions & 0 deletions packages/recs/package.json
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": {}
}
16 changes: 16 additions & 0 deletions packages/recs/rollup.config.js
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()],
});
225 changes: 225 additions & 0 deletions packages/recs/src/Component.ts
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>;
}
26 changes: 26 additions & 0 deletions packages/recs/src/Entity.ts
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);
}
}
Loading

0 comments on commit aaf6d0f

Please sign in to comment.