├── README.md ├── .github ├── FUNDING.yml └── workflows │ ├── tests.yml │ └── release.yml ├── packages ├── bucket │ ├── README.md │ ├── src │ │ ├── index.ts │ │ └── Bucket.ts │ ├── CHANGELOG.md │ ├── package.json │ ├── LICENSE.md │ └── test │ │ └── Bucket.test.ts ├── react │ ├── src │ │ ├── index.ts │ │ ├── lib │ │ │ └── mergeRefs.ts │ │ ├── isomorphicLayoutEffect.ts │ │ ├── hooks.ts │ │ └── createReactAPI.tsx │ ├── package.json │ ├── LICENSE.md │ ├── test │ │ ├── hooks.test.tsx │ │ └── createReactAPI.test.tsx │ ├── CHANGELOG.md │ └── README.md └── core │ ├── test │ ├── queue.test.ts │ └── core.test.ts │ ├── src │ ├── index.ts │ └── core.ts │ ├── LICENSE.md │ ├── package.json │ ├── benchmark.ts │ ├── CHANGELOG.md │ └── README.md ├── .gitignore ├── apps ├── demo │ ├── src │ │ ├── vite-env.d.ts │ │ ├── index.css │ │ ├── main.tsx │ │ ├── systems │ │ │ ├── ApplyForcesSystem.tsx │ │ │ ├── VelocitySystem.tsx │ │ │ ├── AlignmentSystem.tsx │ │ │ ├── CoherenceSystem.tsx │ │ │ ├── WorldSetupSystem.tsx │ │ │ ├── AvoidEdgesSystem.tsx │ │ │ ├── SeparationSystem.tsx │ │ │ ├── IdentifyNeighborSystem.tsx │ │ │ └── SpatialHashingSystem.tsx │ │ ├── state.ts │ │ ├── Boids.tsx │ │ └── Demo.tsx │ ├── README.md │ ├── vite.config.ts │ ├── tsconfig.node.json │ ├── .gitignore │ ├── index.html │ ├── tsconfig.json │ ├── package.json │ └── public │ │ └── vite.svg └── vanilla-demo │ ├── src │ ├── vite-env.d.ts │ ├── style.css │ ├── systems │ │ ├── autorotate.ts │ │ ├── transform.ts │ │ ├── instancing.ts │ │ └── engine.ts │ └── main.ts │ ├── .gitignore │ ├── index.html │ ├── package.json │ ├── tsconfig.json │ └── public │ └── vite.svg ├── pnpm-workspace.yaml ├── babel.config.js ├── jest.config.cjs ├── .editorconfig ├── tsconfig.json ├── .changeset └── config.json ├── LICENSE.md ├── CHANGELOG.md └── package.json /README.md: -------------------------------------------------------------------------------- 1 | packages/core/README.md -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | github: [hmans] 2 | -------------------------------------------------------------------------------- /packages/bucket/README.md: -------------------------------------------------------------------------------- 1 | # @miniplex/bucket 2 | -------------------------------------------------------------------------------- /packages/bucket/src/index.ts: -------------------------------------------------------------------------------- 1 | export * from "./Bucket" 2 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /.vscode/ 2 | **/node_modules/ 3 | **/dist/ 4 | *.log 5 | -------------------------------------------------------------------------------- /apps/demo/src/vite-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | -------------------------------------------------------------------------------- /pnpm-workspace.yaml: -------------------------------------------------------------------------------- 1 | packages: 2 | - "packages/*" 3 | - "apps/*" 4 | -------------------------------------------------------------------------------- /apps/vanilla-demo/src/vite-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | -------------------------------------------------------------------------------- /babel.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | presets: ["@babel/preset-env", "@babel/preset-react", "@babel/preset-typescript"] 3 | } 4 | -------------------------------------------------------------------------------- /packages/react/src/index.ts: -------------------------------------------------------------------------------- 1 | export * from "./createReactAPI" 2 | export { createReactAPI as default } from "./createReactAPI" 3 | export * from "./hooks" 4 | -------------------------------------------------------------------------------- /apps/demo/README.md: -------------------------------------------------------------------------------- 1 | # Miniplex React Demo 2 | 3 | This is a simple demo of Miniplex in React. It implements a 3D boids swarm simulation using react-three-fiber for rendering. 4 | -------------------------------------------------------------------------------- /apps/demo/vite.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from 'vite' 2 | import react from '@vitejs/plugin-react' 3 | 4 | // https://vitejs.dev/config/ 5 | export default defineConfig({ 6 | plugins: [react()] 7 | }) 8 | -------------------------------------------------------------------------------- /apps/demo/tsconfig.node.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "composite": true, 4 | "module": "ESNext", 5 | "moduleResolution": "Node", 6 | "allowSyntheticDefaultImports": true 7 | }, 8 | "include": ["vite.config.ts"] 9 | } 10 | -------------------------------------------------------------------------------- /jest.config.cjs: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | verbose: true, 3 | preset: "ts-jest", 4 | testMatch: ["**/?(*.)+(spec|test).+(ts|tsx)"], 5 | testPathIgnorePatterns: ["node_modules"], 6 | testEnvironment: "jsdom", 7 | moduleFileExtensions: ["js", "ts", "tsx"] 8 | } 9 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | # editorconfig.org 2 | root = true 3 | 4 | [*] 5 | indent_style = space 6 | indent_size = 2 7 | tab_width = 2 8 | end_of_line = lf 9 | charset = utf-8 10 | trim_trailing_whitespace = true 11 | insert_final_newline = true 12 | 13 | [*.md] 14 | trim_trailing_whitespace = false 15 | -------------------------------------------------------------------------------- /apps/demo/src/index.css: -------------------------------------------------------------------------------- 1 | html, 2 | body { 3 | height: 100vh; 4 | width: 100vw; 5 | margin: 0; 6 | padding: 0; 7 | overflow: hidden; 8 | } 9 | 10 | body { 11 | background-color: #000; 12 | } 13 | 14 | div#root { 15 | height: 100%; 16 | width: 100%; 17 | overflow: hidden; 18 | } 19 | -------------------------------------------------------------------------------- /apps/vanilla-demo/src/style.css: -------------------------------------------------------------------------------- 1 | html, 2 | body { 3 | height: 100vh; 4 | width: 100vw; 5 | margin: 0; 6 | padding: 0; 7 | overflow: hidden; 8 | } 9 | 10 | body { 11 | background-color: #000; 12 | } 13 | 14 | div#root { 15 | height: 100%; 16 | width: 100%; 17 | overflow: hidden; 18 | } 19 | -------------------------------------------------------------------------------- /apps/demo/src/main.tsx: -------------------------------------------------------------------------------- 1 | import React from "react" 2 | import ReactDOM from "react-dom/client" 3 | import Demo from "./Demo" 4 | import "./index.css" 5 | 6 | ReactDOM.createRoot(document.getElementById("root") as HTMLElement).render( 7 | 8 | 9 | 10 | ) 11 | -------------------------------------------------------------------------------- /packages/bucket/CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # @miniplex/bucket 2 | 3 | ## 2.0.0 4 | 5 | ### Major Changes 6 | 7 | - 8a7a315: Miniplex 2.0 now implements its core "reactive bucket" functionality in a small micropackage called `@miniplex/bucket`. It can be pretty useful even outside of Miniplex, and will eventually be extracted into its own project. 8 | -------------------------------------------------------------------------------- /packages/react/src/lib/mergeRefs.ts: -------------------------------------------------------------------------------- 1 | import type * as React from "react" 2 | 3 | export const mergeRefs = 4 | (refs: Array>): React.Ref => 5 | (v: T) => { 6 | refs.forEach((ref) => { 7 | if (typeof ref === "function") ref(v) 8 | else if (!!ref) (ref as any).current = v 9 | }) 10 | } 11 | -------------------------------------------------------------------------------- /packages/react/src/isomorphicLayoutEffect.ts: -------------------------------------------------------------------------------- 1 | /* https://medium.com/@alexandereardon/uselayouteffect-and-ssr-192986cdcf7a */ 2 | 3 | import { useLayoutEffect, useEffect } from "react" 4 | 5 | const useIsomorphicLayoutEffect = 6 | typeof window !== "undefined" ? useLayoutEffect : useEffect 7 | 8 | export default useIsomorphicLayoutEffect 9 | -------------------------------------------------------------------------------- /packages/core/test/queue.test.ts: -------------------------------------------------------------------------------- 1 | import { queue } from "../src" 2 | 3 | describe(queue, () => { 4 | it("can queue work to be executed later", () => { 5 | const work = jest.fn() 6 | 7 | queue(work) 8 | expect(work).not.toHaveBeenCalled() 9 | 10 | queue.flush() 11 | expect(work).toHaveBeenCalled() 12 | }) 13 | }) 14 | -------------------------------------------------------------------------------- /apps/demo/.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | pnpm-debug.log* 8 | lerna-debug.log* 9 | 10 | node_modules 11 | dist 12 | dist-ssr 13 | *.local 14 | 15 | # Editor directories and files 16 | .vscode/* 17 | !.vscode/extensions.json 18 | .idea 19 | .DS_Store 20 | *.suo 21 | *.ntvs* 22 | *.njsproj 23 | *.sln 24 | *.sw? 25 | -------------------------------------------------------------------------------- /apps/vanilla-demo/.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | pnpm-debug.log* 8 | lerna-debug.log* 9 | 10 | node_modules 11 | dist 12 | dist-ssr 13 | *.local 14 | 15 | # Editor directories and files 16 | .vscode/* 17 | !.vscode/extensions.json 18 | .idea 19 | .DS_Store 20 | *.suo 21 | *.ntvs* 22 | *.njsproj 23 | *.sln 24 | *.sw? 25 | -------------------------------------------------------------------------------- /apps/vanilla-demo/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | Miniplex Vanilla Demo 8 | 9 | 10 | 11 | 12 | 13 | -------------------------------------------------------------------------------- /apps/demo/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | Miniplex Demo 8 | 9 | 10 |
11 | 12 | 13 | 14 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "esnext", 4 | "module": "esnext", 5 | "moduleResolution": "node", 6 | "declaration": true, 7 | "strict": true, 8 | "jsx": "react-jsx", 9 | "allowSyntheticDefaultImports": true, 10 | "esModuleInterop": true, 11 | "isolatedModules": true 12 | }, 13 | "include": ["packages/**/src/**/*", "packages/**/test/**/*"], 14 | "exclude": ["node_modules", "dist"] 15 | } 16 | -------------------------------------------------------------------------------- /.changeset/config.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://unpkg.com/@changesets/config@2.0.0/schema.json", 3 | "changelog": "@changesets/cli/changelog", 4 | "commit": false, 5 | "fixed": [], 6 | "linked": [["miniplex", "@miniplex/*"]], 7 | "access": "public", 8 | "baseBranch": "main", 9 | "updateInternalDependencies": "patch", 10 | "ignore": ["demo", "vanilla-demo"], 11 | "___experimentalUnsafeOptions_WILL_CHANGE_IN_PATCH": { 12 | "onlyUpdatePeerDependentsWhenOutOfRange": true 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /apps/vanilla-demo/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "vanilla-demo", 3 | "private": true, 4 | "version": "0.0.0", 5 | "type": "module", 6 | "scripts": { 7 | "dev": "vite", 8 | "build": "tsc && vite build", 9 | "preview": "vite preview" 10 | }, 11 | "devDependencies": { 12 | "@types/three": "^0.154.0", 13 | "vite": "^4.4.4" 14 | }, 15 | "dependencies": { 16 | "fp-ts": "^2.16.0", 17 | "miniplex": "workspace:2.0.0", 18 | "randomish": "^0.1.6", 19 | "three": "^0.154.0" 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /apps/vanilla-demo/src/systems/autorotate.ts: -------------------------------------------------------------------------------- 1 | import { World } from "miniplex" 2 | import { Entity } from "./engine" 3 | 4 | export function createAutorotateSystem(world: World) { 5 | const entities = world.with("autorotate", "transform") 6 | 7 | return function (dt: number) { 8 | for (const { transform: transform, autorotate } of entities) { 9 | transform.rotation.x += autorotate.x * dt 10 | transform.rotation.y += autorotate.y * dt 11 | transform.rotation.z += autorotate.z * dt 12 | } 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /apps/vanilla-demo/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ESNext", 4 | "useDefineForClassFields": true, 5 | "module": "ESNext", 6 | "lib": ["ESNext", "DOM"], 7 | "moduleResolution": "Node", 8 | "strict": true, 9 | "sourceMap": true, 10 | "resolveJsonModule": true, 11 | "isolatedModules": true, 12 | "esModuleInterop": true, 13 | "noEmit": true, 14 | "noUnusedLocals": true, 15 | "noUnusedParameters": true, 16 | "noImplicitReturns": true, 17 | "skipLibCheck": true 18 | }, 19 | "include": ["src"] 20 | } 21 | -------------------------------------------------------------------------------- /apps/demo/src/systems/ApplyForcesSystem.tsx: -------------------------------------------------------------------------------- 1 | import { useFrame } from "@react-three/fiber" 2 | import { ECS } from "../state" 3 | 4 | const entities = ECS.world.with("forces", "velocity") 5 | 6 | export default function () { 7 | useFrame(function ApplyForcesSystem(_, dt) { 8 | for (const { forces, velocity } of entities) { 9 | velocity.addScaledVector(forces.coherence, dt) 10 | velocity.addScaledVector(forces.separation, dt) 11 | velocity.addScaledVector(forces.alignment, dt) 12 | velocity.addScaledVector(forces.avoidEdges, dt) 13 | } 14 | }) 15 | 16 | return null 17 | } 18 | -------------------------------------------------------------------------------- /packages/core/src/index.ts: -------------------------------------------------------------------------------- 1 | export { Query, World, Bucket } from "./core" 2 | export type { Strict, With, Without } from "./core" 3 | 4 | /* Create and export a queue instance */ 5 | import { createQueue } from "@hmans/queue" 6 | 7 | /** 8 | * A simple queue (powered by [@hmans/queue](https://github.com/hmans/things/tree/main/packages/hmans-queue)) 9 | * that can be used to schedule work to be done later. This is mostly provided as a convenience 10 | * to make upgrading from Miniplex 1.0 (which had queuing functionality built-in) a little easier, 11 | * and it will be deprecated in a future version. 12 | */ 13 | export const queue = createQueue() 14 | -------------------------------------------------------------------------------- /apps/demo/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ESNext", 4 | "useDefineForClassFields": true, 5 | "lib": ["DOM", "DOM.Iterable", "ESNext"], 6 | "allowJs": false, 7 | "skipLibCheck": true, 8 | "esModuleInterop": false, 9 | "allowSyntheticDefaultImports": true, 10 | "strict": true, 11 | "forceConsistentCasingInFileNames": true, 12 | "module": "ESNext", 13 | "moduleResolution": "Node", 14 | "resolveJsonModule": true, 15 | "isolatedModules": true, 16 | "noEmit": true, 17 | "jsx": "react-jsx" 18 | }, 19 | "include": ["src"], 20 | "references": [{ "path": "./tsconfig.node.json" }] 21 | } 22 | -------------------------------------------------------------------------------- /apps/demo/src/systems/VelocitySystem.tsx: -------------------------------------------------------------------------------- 1 | import { useFrame } from "@react-three/fiber" 2 | import { ECS } from "../state" 3 | 4 | const entities = ECS.world.with("transform", "velocity") 5 | 6 | export default function ({ maxVelocity = 1 }: { maxVelocity?: number }) { 7 | useFrame(function VelocitySystem(_, dt) { 8 | for (const { transform, velocity } of entities) { 9 | /* Dampen velocity */ 10 | // velocity.multiplyScalar(0.999) 11 | 12 | /* Clamp velocity */ 13 | velocity.clampLength(0, maxVelocity) 14 | 15 | /* Apply velocity */ 16 | transform.position.addScaledVector(velocity, dt) 17 | } 18 | }) 19 | 20 | return null 21 | } 22 | -------------------------------------------------------------------------------- /apps/demo/src/state.ts: -------------------------------------------------------------------------------- 1 | import { With, World } from "miniplex" 2 | import createECS from "miniplex-react" 3 | import { ReactNode } from "react" 4 | import { Object3D, Vector3 } from "three" 5 | import { SpatialHashMap } from "./systems/SpatialHashingSystem" 6 | 7 | export type Entity = { 8 | boid?: true 9 | 10 | velocity?: Vector3 11 | neighbors?: With[] 12 | 13 | spatialHashMap?: SpatialHashMap 14 | 15 | forces: { 16 | coherence: Vector3 17 | separation: Vector3 18 | alignment: Vector3 19 | avoidEdges: Vector3 20 | } 21 | 22 | transform?: Object3D 23 | jsx?: ReactNode 24 | } 25 | 26 | export const ECS = createECS(new World()) 27 | -------------------------------------------------------------------------------- /apps/vanilla-demo/src/systems/transform.ts: -------------------------------------------------------------------------------- 1 | import { World } from "miniplex" 2 | import { Entity } from "./engine" 3 | 4 | export function createTransformSystem(world: World) { 5 | const entities = world.with("transform") 6 | const engines = world.with("engine") 7 | 8 | entities.onEntityAdded.subscribe((entity) => { 9 | const [{ engine }] = engines 10 | 11 | if (entity.parent?.transform) { 12 | entity.parent.transform.add(entity.transform) 13 | } else { 14 | engine.scene.add(entity.transform) 15 | } 16 | }) 17 | 18 | entities.onEntityRemoved.subscribe((entity) => { 19 | entity.transform.parent?.remove(entity.transform) 20 | }) 21 | 22 | return () => {} 23 | } 24 | -------------------------------------------------------------------------------- /apps/demo/src/systems/AlignmentSystem.tsx: -------------------------------------------------------------------------------- 1 | import { useFrame } from "@react-three/fiber" 2 | import { ECS } from "../state" 3 | 4 | const entities = ECS.world.with("transform", "neighbors", "forces") 5 | 6 | export default function ({ factor = 1 }: { factor?: number }) { 7 | useFrame(function AlignmentSystem() { 8 | for (const { 9 | forces: { alignment }, 10 | neighbors 11 | } of entities) { 12 | alignment.set(0, 0, 0) 13 | 14 | if (neighbors.length === 0) continue 15 | 16 | for (const neighbor of neighbors) { 17 | alignment.add(neighbor.velocity) 18 | } 19 | 20 | alignment.divideScalar(neighbors.length) 21 | alignment.multiplyScalar(factor) 22 | } 23 | }) 24 | 25 | return null 26 | } 27 | -------------------------------------------------------------------------------- /apps/demo/src/systems/CoherenceSystem.tsx: -------------------------------------------------------------------------------- 1 | import { useFrame } from "@react-three/fiber" 2 | import { ECS } from "../state" 3 | 4 | const entities = ECS.world.with("transform", "neighbors", "forces") 5 | 6 | export default function ({ factor = 1 }: { factor?: number }) { 7 | useFrame(function CoherenceSystem() { 8 | for (const { 9 | forces: { coherence }, 10 | neighbors, 11 | transform 12 | } of entities) { 13 | coherence.set(0, 0, 0) 14 | 15 | if (neighbors.length === 0) continue 16 | 17 | for (const neighbor of neighbors) { 18 | coherence.add(neighbor.transform.position) 19 | } 20 | 21 | coherence.divideScalar(neighbors.length) 22 | coherence.sub(transform.position).multiplyScalar(factor) 23 | } 24 | }) 25 | 26 | return null 27 | } 28 | -------------------------------------------------------------------------------- /packages/bucket/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@miniplex/bucket", 3 | "author": { 4 | "name": "Hendrik Mans", 5 | "email": "hendrik@mans.de", 6 | "url": "https://hendrik.mans.de" 7 | }, 8 | "description": "", 9 | "homepage": "https://github.com/hmans/miniplex", 10 | "keywords": [ 11 | "gamedev", 12 | "ecs", 13 | "entity-component-system", 14 | "state", 15 | "state-management" 16 | ], 17 | "sideEffects": false, 18 | "version": "2.0.0", 19 | "main": "dist/miniplex-bucket.cjs.js", 20 | "module": "dist/miniplex-bucket.esm.js", 21 | "types": "dist/miniplex-bucket.cjs.d.ts", 22 | "files": [ 23 | "dist/**", 24 | "LICENSE", 25 | "README.md" 26 | ], 27 | "license": "MIT", 28 | "scripts": { 29 | "build": "preconstruct build" 30 | }, 31 | "dependencies": { 32 | "eventery": "^0.0.4" 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /apps/demo/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "demo", 3 | "private": true, 4 | "version": "0.0.0", 5 | "type": "module", 6 | "scripts": { 7 | "dev": "vite", 8 | "build": "tsc && vite build", 9 | "preview": "vite preview" 10 | }, 11 | "dependencies": { 12 | "@react-three/drei": "^9.78.2", 13 | "@react-three/fiber": "^8.13.5", 14 | "miniplex": "workspace:2.0.0", 15 | "miniplex-react": "workspace:2.0.1", 16 | "postprocessing": "^6.32.2", 17 | "r3f-perf": "^7.1.2", 18 | "randomish": "^0.1.6", 19 | "react": "^18.2.0", 20 | "react-dom": "^18.2.0", 21 | "three": "^0.154.0", 22 | "three-stdlib": "^2.23.13" 23 | }, 24 | "devDependencies": { 25 | "@types/react": "^18.2.15", 26 | "@types/react-dom": "^18.2.7", 27 | "@types/three": "^0.154.0", 28 | "@vitejs/plugin-react": "^4.0.3", 29 | "vite": "^4.4.4" 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /apps/demo/src/systems/WorldSetupSystem.tsx: -------------------------------------------------------------------------------- 1 | import { between } from "randomish" 2 | import { useLayoutEffect } from "react" 3 | import { Vector3 } from "three" 4 | import { spawnBoid } from "../Boids" 5 | import { ECS } from "../state" 6 | 7 | const useWorldSetup = () => 8 | useLayoutEffect(() => { 9 | console.log("Populating Miniplex world") 10 | 11 | for (let i = 0; i < 1000; i++) { 12 | const position = new Vector3() 13 | .randomDirection() 14 | .multiplyScalar(between(0, 10)) 15 | 16 | const velocity = new Vector3().randomDirection() 17 | 18 | spawnBoid({ 19 | position, 20 | velocity 21 | }) 22 | } 23 | 24 | return () => { 25 | console.log("Clearing Miniplex world") 26 | ECS.world.clear() 27 | } 28 | }, []) 29 | 30 | export default function WorldSetup() { 31 | useWorldSetup() 32 | 33 | return null 34 | } 35 | -------------------------------------------------------------------------------- /apps/demo/src/systems/AvoidEdgesSystem.tsx: -------------------------------------------------------------------------------- 1 | import { useFrame } from "@react-three/fiber" 2 | import { ECS } from "../state" 3 | 4 | const entities = ECS.world.with("transform", "forces") 5 | 6 | export default function ({ 7 | factor = 1, 8 | maxDistance = 2 9 | }: { 10 | factor?: number 11 | maxDistance?: number 12 | }) { 13 | useFrame(function AvoidEdgesSystem() { 14 | for (const { 15 | forces: { avoidEdges }, 16 | transform 17 | } of entities) { 18 | /* Calculate our distance from origin */ 19 | const distance = transform.position.length() 20 | 21 | /* If we're too far away, gently nudge the boid back towards origin */ 22 | if (distance > maxDistance) { 23 | avoidEdges 24 | .copy(transform.position) 25 | .normalize() 26 | .negate() 27 | .multiplyScalar(factor) 28 | } 29 | } 30 | }) 31 | 32 | return null 33 | } 34 | -------------------------------------------------------------------------------- /apps/vanilla-demo/src/systems/instancing.ts: -------------------------------------------------------------------------------- 1 | import { World } from "miniplex" 2 | import { Entity } from "./engine" 3 | 4 | export function createInstancingSystem(world: World) { 5 | const entities = world.with("instance", "transform") 6 | 7 | const imeshState = new Map() 8 | 9 | return function instancingSystem() { 10 | /* Clear counts */ 11 | imeshState.clear() 12 | 13 | for (const { 14 | instance: { imesh }, 15 | transform 16 | } of entities) { 17 | /* Get the current instance count */ 18 | const index = imeshState.get(imesh) ?? 0 19 | 20 | /* Set the instance matrix */ 21 | imesh.setMatrixAt(index, transform.matrix) 22 | 23 | /* Increment the instance count */ 24 | imeshState.set(imesh, index + 1) 25 | } 26 | 27 | /* Update instance mesh counts */ 28 | for (const [imesh, count] of imeshState) { 29 | imesh.count = count 30 | imesh.instanceMatrix.needsUpdate = true 31 | } 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /apps/demo/src/systems/SeparationSystem.tsx: -------------------------------------------------------------------------------- 1 | import { useFrame } from "@react-three/fiber" 2 | import { ECS } from "../state" 3 | 4 | const entities = ECS.world.with("transform", "neighbors", "forces") 5 | 6 | export default function ({ factor = 1 }: { factor?: number }) { 7 | useFrame(function SeparationSystem() { 8 | for (const { 9 | forces: { separation }, 10 | neighbors, 11 | transform 12 | } of entities) { 13 | separation.set(0, 0, 0) 14 | 15 | if (neighbors.length === 0) continue 16 | 17 | for (const neighbor of neighbors) { 18 | const distance = transform.position.distanceTo( 19 | neighbor.transform.position 20 | ) 21 | const direction = transform.position 22 | .clone() 23 | .sub(neighbor.transform.position) 24 | .normalize() 25 | separation.add(direction.divideScalar(distance)) 26 | } 27 | 28 | separation.divideScalar(neighbors.length) 29 | separation.multiplyScalar(factor) 30 | } 31 | }) 32 | 33 | return null 34 | } 35 | -------------------------------------------------------------------------------- /packages/react/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "miniplex-react", 3 | "author": { 4 | "name": "Hendrik Mans", 5 | "email": "hendrik@mans.de", 6 | "url": "https://hendrik.mans.de" 7 | }, 8 | "description": "React glue for Miniplex.", 9 | "homepage": "https://github.com/hmans/miniplex", 10 | "keywords": [ 11 | "gamedev", 12 | "ecs", 13 | "react", 14 | "entity-component-system", 15 | "state", 16 | "state-management", 17 | "hooks" 18 | ], 19 | "sideEffects": false, 20 | "version": "2.0.1", 21 | "main": "dist/miniplex-react.cjs.js", 22 | "module": "dist/miniplex-react.esm.js", 23 | "types": "dist/miniplex-react.cjs.d.ts", 24 | "files": [ 25 | "dist/**", 26 | "LICENSE", 27 | "README.md" 28 | ], 29 | "license": "MIT", 30 | "devDependencies": { 31 | "miniplex": "2.0.0", 32 | "react": "^18.2.0", 33 | "react-dom": "^18.2.0" 34 | }, 35 | "dependencies": { 36 | "@hmans/use-const": "^0.0.1", 37 | "@hmans/use-rerender": "^0.0.2" 38 | }, 39 | "peerDependencies": { 40 | "miniplex": "^2.0.0", 41 | "react": ">=16.8" 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | Copyright (c) 2023 Hendrik Mans 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining 4 | a copy of this software and associated documentation files (the 5 | "Software"), to deal in the Software without restriction, including 6 | without limitation the rights to use, copy, modify, merge, publish, 7 | distribute, sublicense, and/or sell copies of the Software, and to 8 | permit persons to whom the Software is furnished to do so, subject to 9 | the following conditions: 10 | 11 | The above copyright notice and this permission notice shall be 12 | included in all copies or substantial portions of the Software. 13 | 14 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 15 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 16 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND 17 | NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE 18 | LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION 19 | OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION 20 | WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 21 | -------------------------------------------------------------------------------- /packages/bucket/LICENSE.md: -------------------------------------------------------------------------------- 1 | Copyright (c) 2023 Hendrik Mans 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining 4 | a copy of this software and associated documentation files (the 5 | "Software"), to deal in the Software without restriction, including 6 | without limitation the rights to use, copy, modify, merge, publish, 7 | distribute, sublicense, and/or sell copies of the Software, and to 8 | permit persons to whom the Software is furnished to do so, subject to 9 | the following conditions: 10 | 11 | The above copyright notice and this permission notice shall be 12 | included in all copies or substantial portions of the Software. 13 | 14 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 15 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 16 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND 17 | NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE 18 | LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION 19 | OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION 20 | WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 21 | -------------------------------------------------------------------------------- /packages/core/LICENSE.md: -------------------------------------------------------------------------------- 1 | Copyright (c) 2023 Hendrik Mans 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining 4 | a copy of this software and associated documentation files (the 5 | "Software"), to deal in the Software without restriction, including 6 | without limitation the rights to use, copy, modify, merge, publish, 7 | distribute, sublicense, and/or sell copies of the Software, and to 8 | permit persons to whom the Software is furnished to do so, subject to 9 | the following conditions: 10 | 11 | The above copyright notice and this permission notice shall be 12 | included in all copies or substantial portions of the Software. 13 | 14 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 15 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 16 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND 17 | NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE 18 | LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION 19 | OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION 20 | WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 21 | -------------------------------------------------------------------------------- /packages/react/LICENSE.md: -------------------------------------------------------------------------------- 1 | Copyright (c) 2023 Hendrik Mans 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining 4 | a copy of this software and associated documentation files (the 5 | "Software"), to deal in the Software without restriction, including 6 | without limitation the rights to use, copy, modify, merge, publish, 7 | distribute, sublicense, and/or sell copies of the Software, and to 8 | permit persons to whom the Software is furnished to do so, subject to 9 | the following conditions: 10 | 11 | The above copyright notice and this permission notice shall be 12 | included in all copies or substantial portions of the Software. 13 | 14 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 15 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 16 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND 17 | NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE 18 | LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION 19 | OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION 20 | WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 21 | -------------------------------------------------------------------------------- /packages/core/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "miniplex", 3 | "author": { 4 | "name": "Hendrik Mans", 5 | "email": "hendrik@mans.de", 6 | "url": "https://hendrik.mans.de" 7 | }, 8 | "description": "A developer-friendly entity management system for games and similarly demanding applications, based on ECS architecture.", 9 | "homepage": "https://github.com/hmans/miniplex", 10 | "keywords": [ 11 | "gamedev", 12 | "ecs", 13 | "entity-component-system", 14 | "state", 15 | "state-management" 16 | ], 17 | "sideEffects": false, 18 | "version": "2.0.0", 19 | "main": "dist/miniplex.cjs.js", 20 | "module": "dist/miniplex.esm.js", 21 | "types": "dist/miniplex.cjs.d.ts", 22 | "files": [ 23 | "dist/**", 24 | "LICENSE", 25 | "README.md" 26 | ], 27 | "license": "MIT", 28 | "scripts": { 29 | "benchmark": "tsx benchmark.ts", 30 | "build": "preconstruct build" 31 | }, 32 | "devDependencies": { 33 | "chalk": "^5.3.0", 34 | "tsx": "^3.12.7" 35 | }, 36 | "dependencies": { 37 | "@hmans/id": "^0.0.1", 38 | "@hmans/queue": "^0.0.1", 39 | "@miniplex/bucket": "workspace:2.0.0" 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /apps/demo/src/Boids.tsx: -------------------------------------------------------------------------------- 1 | import { Instance, Instances } from "@react-three/drei" 2 | import { Vector3 } from "three" 3 | import { ECS } from "./state" 4 | import { SpatialHashMap } from "./systems/SpatialHashingSystem" 5 | 6 | const boids = ECS.world.with("boid", "jsx") 7 | 8 | export default function Boids() { 9 | return ( 10 | 11 | 12 | 13 | 14 | e.jsx} /> 15 | 16 | ) 17 | } 18 | 19 | const boidsSpatialHashMap = new SpatialHashMap(5) 20 | 21 | export const spawnBoid = ({ 22 | position, 23 | velocity = new Vector3() 24 | }: { 25 | position: Vector3 26 | velocity?: Vector3 27 | }) => { 28 | ECS.world.add({ 29 | boid: true, 30 | velocity, 31 | neighbors: [], 32 | spatialHashMap: boidsSpatialHashMap, 33 | forces: { 34 | coherence: new Vector3(), 35 | separation: new Vector3(), 36 | alignment: new Vector3(), 37 | avoidEdges: new Vector3() 38 | }, 39 | jsx: ( 40 | 41 | 42 | 43 | ) 44 | }) 45 | } 46 | -------------------------------------------------------------------------------- /apps/vanilla-demo/src/main.ts: -------------------------------------------------------------------------------- 1 | import { insideSphere, plusMinus } from "randomish" 2 | import * as THREE from "three" 3 | import { AmbientLight, InstancedMesh, Vector3 } from "three" 4 | import "./style.css" 5 | import * as engine from "./systems/engine" 6 | 7 | engine.start((world, _runner) => { 8 | /* Add some lights */ 9 | world.add({ transform: new AmbientLight("orange", 0.2) }) 10 | const light = world.add({ transform: new THREE.DirectionalLight() }) 11 | light.transform.position.set(10, 20, 30) 12 | 13 | /* Add an instanced mesh */ 14 | const imesh = world.add({ 15 | transform: new InstancedMesh( 16 | new THREE.IcosahedronGeometry(), 17 | new THREE.MeshStandardMaterial(), 18 | 1000 19 | ), 20 | autorotate: new Vector3(0.01, 0.02, 0.03) 21 | }) 22 | 23 | /* Add a few instances */ 24 | for (let i = 0; i < 1000; i++) { 25 | const pos = insideSphere(20) 26 | 27 | const entity = world.add({ 28 | transform: new THREE.Object3D(), 29 | instance: { imesh: imesh.transform }, 30 | parent: imesh, 31 | autorotate: new Vector3(plusMinus(1), plusMinus(1), plusMinus(1)) 32 | }) 33 | 34 | entity.transform.position.set(pos.x, pos.y, pos.z) 35 | } 36 | }) 37 | -------------------------------------------------------------------------------- /.github/workflows/tests.yml: -------------------------------------------------------------------------------- 1 | name: Tests 2 | 3 | on: 4 | push: 5 | branches: 6 | - "main" 7 | pull_request: {} 8 | 9 | jobs: 10 | build: 11 | name: Build and Run Tests 12 | runs-on: ubuntu-latest 13 | 14 | steps: 15 | - name: Checkout repo 16 | uses: actions/checkout@v3 17 | 18 | - name: Set up Node 19 | uses: actions/setup-node@v3 20 | with: 21 | node-version: 16 22 | 23 | - uses: pnpm/action-setup@v2.0.1 24 | name: Install pnpm 25 | id: pnpm-install 26 | with: 27 | version: 8 28 | run_install: false 29 | 30 | - name: Get pnpm store directory 31 | id: pnpm-cache 32 | run: | 33 | echo "::set-output name=pnpm_cache_dir::$(pnpm store path)" 34 | 35 | - uses: actions/cache@v3 36 | name: Setup pnpm cache 37 | with: 38 | path: ${{ steps.pnpm-cache.outputs.pnpm_cache_dir }} 39 | key: ${{ runner.os }}-pnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }} 40 | restore-keys: | 41 | ${{ runner.os }}-pnpm-store- 42 | 43 | - name: Install dependencies 44 | run: pnpm install 45 | 46 | - name: Run CI task 47 | run: pnpm run ci 48 | -------------------------------------------------------------------------------- /apps/demo/public/vite.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /apps/vanilla-demo/public/vite.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /apps/demo/src/systems/IdentifyNeighborSystem.tsx: -------------------------------------------------------------------------------- 1 | import { useFrame } from "@react-three/fiber" 2 | import { ECS, Entity } from "../state" 3 | 4 | const entities = ECS.world.with("transform", "neighbors", "spatialHashMap") 5 | 6 | export default function ({ maxDistance = 5 }: { maxDistance?: number }) { 7 | useFrame(function IdentifyNeighborsSystem(_, dt) { 8 | for (const entity of entities) { 9 | const { transform, neighbors, spatialHashMap } = entity 10 | 11 | /* Query the spatial hash map for nearby entities */ 12 | spatialHashMap.getNearbyEntities( 13 | transform.position.x, 14 | transform.position.y, 15 | transform.position.z, 16 | maxDistance, 17 | neighbors, 18 | 100 19 | ) 20 | 21 | /* Remove entity itself from neighbors */ 22 | neighbors.splice(neighbors.indexOf(entity as any), 1) 23 | 24 | // for (const otherEntity of entity.neighbors) { 25 | // /* The entity can't be its own neighbor */ 26 | // if (entity === otherEntity) { 27 | // /* remove */ 28 | // entity.neighbors.splice(entity.neighbors.indexOf(otherEntity), 1) 29 | // } 30 | 31 | // /* Calculate distance */ 32 | // const distance = entity.transform.position.distanceTo( 33 | // otherEntity.transform!.position 34 | // ) 35 | 36 | // if (distance >= maxDistance) { 37 | // /* remove */ 38 | // entity.neighbors.splice(entity.neighbors.indexOf(otherEntity), 1) 39 | // } 40 | // } 41 | } 42 | }) 43 | 44 | return null 45 | } 46 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | ## 0.8.0 4 | 5 | - **Breaking Change:** the `QueriedEntity` type has been replaced with the simpler `EntityWith` type. 6 | - **Breaking Change:** all the React glue has been moved to the new `miniplex-react` package. 7 | 8 | ## 0.7.1 9 | 10 | - **Change:** Miniplex now uses preconstruct for building ESM and CSJ bundles of the library. 11 | 12 | ## 0.7.0 13 | 14 | - **Breaking Change:** Removed the (experimental) complex query syntax for the time being to simplify things. Will re-add a better implementation once the need for it is actually proven. 15 | 16 | - **New:** Entities returned by archetypes are now properly typed to represent the required presence of the queried components. (Example: if your archetype contains the `velocity` component, the entities retrieved through `archetype.entities` will be typed to always have `velocity` present, because otherwise they wouldn't be part of this archetype.) 17 | 18 | ## 0.6.0 - 2022-03-09 19 | 20 | - **Breaking Change:** `world.createArchetype` is now just `world.archetype`. Its signature remains the same. 21 | - **Breaking Change:** `useArchetype` (from the React module) now returns the full archetype object (similar to `world.archetype`), and not just its entities. 22 | - **Breaking Change:** `createReactIntegration` has been changed to `createECS`. Instead of accepting an existing ECS world as an argument, it will create a world and return it as part of its return value. 23 | 24 | - **New:** React: The React glue provided by `createECS` now also provides a couple of experimental React components: ``, `` and ``. Documentation will follow once these stabilize. 25 | - **New:** React: The `` React component will now optionally accept a single React child whose ref will be assigned to the component's data. 26 | -------------------------------------------------------------------------------- /apps/demo/src/Demo.tsx: -------------------------------------------------------------------------------- 1 | import { OrbitControls, PerspectiveCamera } from "@react-three/drei" 2 | import { Canvas } from "@react-three/fiber" 3 | import { StrictMode } from "react" 4 | import Boids from "./Boids" 5 | import AlignmentSystem from "./systems/AlignmentSystem" 6 | import ApplyForcesSystem from "./systems/ApplyForcesSystem" 7 | import AvoidEdgesSystem from "./systems/AvoidEdgesSystem" 8 | import CoherenceSystem from "./systems/CoherenceSystem" 9 | import IdentifyNeighborSystem from "./systems/IdentifyNeighborSystem" 10 | import SeparationSystem from "./systems/SeparationSystem" 11 | import SpatialHashingSystem from "./systems/SpatialHashingSystem" 12 | import VelocitySystem from "./systems/VelocitySystem" 13 | import WorldSetupSystem from "./systems/WorldSetupSystem" 14 | 15 | export default function Demo() { 16 | return ( 17 | 18 | {/* 19 | R3F unfortunately doesn't inherit from outside 20 | its canvas, so we need to explicitly re-enable it if we want to make use of it. 21 | We're doing this here mostly to prove that Miniplex 2.0 works with it :-) 22 | */} 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | ) 43 | } 44 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "miniplex-project", 3 | "private": true, 4 | "version": "0.2.0", 5 | "license": "MIT", 6 | "scripts": { 7 | "benchmark": "pnpm -F miniplex benchmark", 8 | "postinstall": "preconstruct dev", 9 | "dev": "preconstruct dev", 10 | "demo:react": "pnpm dev && pnpm -F demo dev", 11 | "demo:vanilla": "pnpm dev && pnpm -F vanilla-demo dev", 12 | "build": "preconstruct build", 13 | "test": "jest", 14 | "ci": "preconstruct validate && pnpm build && pnpm test", 15 | "ci:version": "changeset version && pnpm install --no-frozen-lockfile", 16 | "ci:release": "pnpm run ci && pnpm changeset publish" 17 | }, 18 | "preconstruct": { 19 | "packages": [ 20 | "packages/*" 21 | ] 22 | }, 23 | "prettier": { 24 | "trailingComma": "none", 25 | "tabWidth": 2, 26 | "useTabs": false, 27 | "semi": false, 28 | "singleQuote": false, 29 | "arrowParens": "always", 30 | "printWidth": 80 31 | }, 32 | "dependencies": { 33 | "@babel/core": "^7.22.9", 34 | "@babel/preset-env": "^7.22.9", 35 | "@babel/preset-react": "^7.22.5", 36 | "@babel/preset-typescript": "^7.22.5", 37 | "@changesets/cli": "^2.26.2", 38 | "@preconstruct/cli": "^2.8.1", 39 | "@testing-library/jest-dom": "^5.16.5", 40 | "@testing-library/react": "^14.0.0", 41 | "@types/jest": "^29.5.3", 42 | "@types/react": "^18.2.15", 43 | "@types/react-dom": "^18.2.7", 44 | "@types/testing-library__jest-dom": "^5.14.8", 45 | "jest": "^29.6.1", 46 | "jest-environment-jsdom": "^29.6.1", 47 | "react": "^18.2.0", 48 | "react-dom": "^18.2.0", 49 | "rimraf": "^5.0.1", 50 | "ts-jest": "^29.1.1", 51 | "tslib": "^2.6.0", 52 | "typedoc": "^0.24.8", 53 | "typescript": "^5.1.6" 54 | }, 55 | "stackblitz": { 56 | "startCommand": "pnpm demo:react" 57 | }, 58 | "devDependencies": { 59 | "@types/node": "^20.4.2" 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Release 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | 8 | concurrency: ${{ github.workflow }}-${{ github.ref }} 9 | 10 | jobs: 11 | release: 12 | name: Stable Release / Version PR 13 | runs-on: ubuntu-latest 14 | steps: 15 | - name: Checkout repo 16 | uses: actions/checkout@v3 17 | 18 | - name: Set up Node 19 | uses: actions/setup-node@v3 20 | with: 21 | node-version: 16 22 | 23 | - uses: pnpm/action-setup@v2.0.1 24 | name: Install pnpm 25 | id: pnpm-install 26 | with: 27 | version: 8 28 | run_install: false 29 | 30 | - name: Get pnpm store directory 31 | id: pnpm-cache 32 | run: | 33 | echo "::set-output name=pnpm_cache_dir::$(pnpm store path)" 34 | 35 | - uses: actions/cache@v3 36 | name: Setup pnpm cache 37 | with: 38 | path: ${{ steps.pnpm-cache.outputs.pnpm_cache_dir }} 39 | key: ${{ runner.os }}-pnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }} 40 | restore-keys: | 41 | ${{ runner.os }}-pnpm-store- 42 | 43 | - name: Install dependencies 44 | run: pnpm install 45 | 46 | - name: Create Release Pull Request or Publish to npm 47 | id: changesets 48 | uses: changesets/action@v1 49 | with: 50 | version: pnpm ci:version 51 | publish: pnpm ci:release 52 | # commit: "chore: update versions" 53 | # title: "chore: update versions" 54 | env: 55 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 56 | NPM_TOKEN: ${{ secrets.NPM_TOKEN }} 57 | # - name: Send a Slack notification if a publish happens 58 | # if: steps.changesets.outputs.published == 'true' 59 | # # You can do something when a publish happens. 60 | # run: my-slack-bot send-notification --message "A new version of ${GITHUB_REPOSITORY} was published!" 61 | -------------------------------------------------------------------------------- /apps/vanilla-demo/src/systems/engine.ts: -------------------------------------------------------------------------------- 1 | import { Bucket, World } from "miniplex" 2 | import * as THREE from "three" 3 | import { MathUtils } from "three" 4 | import { createAutorotateSystem } from "./autorotate" 5 | import { createInstancingSystem } from "./instancing" 6 | import { createTransformSystem } from "./transform" 7 | 8 | export type Entity = { 9 | transform?: THREE.Object3D 10 | parent?: Entity 11 | autorotate?: THREE.Vector3 12 | 13 | instance?: { 14 | imesh: THREE.InstancedMesh 15 | } 16 | 17 | engine?: { 18 | renderer: THREE.WebGLRenderer 19 | camera: THREE.PerspectiveCamera 20 | scene: THREE.Scene 21 | } 22 | } 23 | 24 | export type System = (dt: number) => void 25 | 26 | export function start( 27 | init: (world: World, systems: Bucket) => void 28 | ) { 29 | const world = new World() 30 | const systems = new Bucket() 31 | 32 | systems.add(createTransformSystem(world)) 33 | systems.add(createAutorotateSystem(world)) 34 | systems.add(createInstancingSystem(world)) 35 | 36 | const { engine } = world.add({ 37 | engine: { 38 | renderer: new THREE.WebGLRenderer({ antialias: true }), 39 | scene: new THREE.Scene(), 40 | camera: new THREE.PerspectiveCamera( 41 | 75, 42 | window.innerWidth / window.innerHeight, 43 | 0.1, 44 | 1000 45 | ) 46 | } 47 | }) 48 | 49 | /* Set up renderer */ 50 | engine.renderer.setSize(window.innerWidth, window.innerHeight) 51 | document.body.appendChild(engine.renderer.domElement) 52 | 53 | /* Set up camera */ 54 | engine.camera.position.z = 50 55 | engine.scene.add(engine.camera) 56 | 57 | /* Run initializer function */ 58 | init(world, systems) 59 | 60 | /* Add rendering system */ 61 | systems.add(() => { 62 | engine.renderer.render(engine.scene, engine.camera) 63 | }) 64 | 65 | /* Let's go */ 66 | let lastTime = performance.now() 67 | 68 | function tick() { 69 | /* Determine deltatime */ 70 | const time = performance.now() 71 | const dt = MathUtils.clamp((time - lastTime) / 1000, 0, 0.2) 72 | lastTime = time 73 | 74 | for (const system of systems) { 75 | system(dt) 76 | } 77 | 78 | requestAnimationFrame(tick) 79 | } 80 | 81 | tick() 82 | } 83 | -------------------------------------------------------------------------------- /packages/react/test/hooks.test.tsx: -------------------------------------------------------------------------------- 1 | import { act, renderHook } from "@testing-library/react" 2 | import { World } from "miniplex" 3 | import { useEntities, useOnEntityAdded, useOnEntityRemoved } from "../src" 4 | 5 | describe("useEntities", () => { 6 | it("returns the entities of the specified archetype and re-renders the component when the archetype updates", () => { 7 | const world = new World<{ name: string }>() 8 | 9 | const alice = world.add({ name: "Alice" }) 10 | world.add({ name: "Bob" }) 11 | 12 | const { result } = renderHook(() => useEntities(world.with("name"))) 13 | 14 | const { entities } = result.current 15 | 16 | expect(entities).toHaveLength(2) 17 | expect(entities[0].name).toBe("Bob") 18 | expect(entities[1].name).toBe("Alice") 19 | 20 | act(() => { 21 | world.add({ name: "Charlie" }) 22 | }) 23 | 24 | expect(entities).toHaveLength(3) 25 | expect(entities[0].name).toBe("Bob") 26 | expect(entities[1].name).toBe("Alice") 27 | expect(entities[2].name).toBe("Charlie") 28 | 29 | act(() => { 30 | world.remove(alice) 31 | }) 32 | 33 | expect(entities).toHaveLength(2) 34 | expect(entities[0].name).toBe("Bob") 35 | expect(entities[1].name).toBe("Charlie") 36 | }) 37 | }) 38 | 39 | describe(useOnEntityAdded, () => { 40 | it("calls the callback when an entity is added to the world", () => { 41 | const world = new World<{ name: string }>() 42 | 43 | const callback = jest.fn() 44 | 45 | renderHook(() => useOnEntityAdded(world, callback)) 46 | 47 | act(() => { 48 | world.add({ name: "Alice" }) 49 | }) 50 | 51 | expect(callback).toHaveBeenCalledTimes(1) 52 | expect(callback).toHaveBeenCalledWith({ name: "Alice" }) 53 | }) 54 | }) 55 | 56 | describe(useOnEntityRemoved, () => { 57 | it("calls the callback when an entity is removed from the world", () => { 58 | const world = new World<{ name: string }>() 59 | 60 | const callback = jest.fn() 61 | 62 | renderHook(() => useOnEntityRemoved(world, callback)) 63 | 64 | const entity = world.add({ name: "Alice" }) 65 | 66 | act(() => { 67 | world.remove(entity) 68 | }) 69 | 70 | expect(callback).toHaveBeenCalledTimes(1) 71 | expect(callback).toHaveBeenCalledWith(entity) 72 | }) 73 | }) 74 | -------------------------------------------------------------------------------- /packages/react/src/hooks.ts: -------------------------------------------------------------------------------- 1 | import { useRerender } from "@hmans/use-rerender" 2 | import { Bucket } from "miniplex" 3 | import { useMemo } from "react" 4 | import useIsomorphicLayoutEffect from "./isomorphicLayoutEffect" 5 | 6 | /** 7 | * Subscribes to changes in the specified bucket, and re-renders the component 8 | * whenever entities are added to or removed from it. 9 | * 10 | * @param bucket The bucket to watch for changes 11 | * @returns The bucket passed in, for convenience 12 | */ 13 | export function useEntities>(bucket: T): T { 14 | const rerender = useRerender() 15 | 16 | /* Re-render any time the bucket changes */ 17 | useOnEntityAdded(bucket, rerender) 18 | useOnEntityRemoved(bucket, rerender) 19 | 20 | return bucket 21 | } 22 | 23 | export function useOnEntityAdded( 24 | bucket: Bucket, 25 | callback: (entity: E) => void 26 | ) { 27 | useOnceIfBucketVersionChanged(bucket, callback) 28 | 29 | useIsomorphicLayoutEffect( 30 | () => bucket.onEntityAdded.subscribe(callback), 31 | [bucket, callback] 32 | ) 33 | } 34 | 35 | export function useOnEntityRemoved( 36 | bucket: Bucket, 37 | callback: (entity: E) => void 38 | ) { 39 | useOnceIfBucketVersionChanged(bucket, callback) 40 | 41 | useIsomorphicLayoutEffect( 42 | () => bucket.onEntityRemoved.subscribe(callback), 43 | [bucket, callback] 44 | ) 45 | } 46 | 47 | /** 48 | * A utility function that will invoke the specified callback in a layout effect 49 | * if the version of the specified bucket has changed since the component was 50 | * initially rendered. 51 | * 52 | * This solves the problem of useEntities and similar callbacks registering their 53 | * bucket change callbacks in a layout effect, which can sometimes cause them to 54 | * miss entities being created or destroyed within the same render cycle (since 55 | * this will often also happen in layout effects.) 56 | * 57 | * @param bucket The bucket to watch for changes 58 | * @param callback The callback to invoke if the bucket version has changed 59 | */ 60 | function useOnceIfBucketVersionChanged( 61 | bucket: Bucket, 62 | callback: Function 63 | ) { 64 | const originalVersion = useMemo(() => bucket.version, [bucket]) 65 | 66 | useIsomorphicLayoutEffect(() => { 67 | if (bucket.version !== originalVersion) callback() 68 | }, [bucket]) 69 | } 70 | -------------------------------------------------------------------------------- /apps/demo/src/systems/SpatialHashingSystem.tsx: -------------------------------------------------------------------------------- 1 | import { useFrame } from "@react-three/fiber" 2 | import { ECS, Entity } from "../state" 3 | 4 | type Cell = Set 5 | 6 | export class SpatialHashMap { 7 | protected cells = new Map() 8 | protected entityToCell = new WeakMap() 9 | 10 | constructor(public cellSize: number) {} 11 | 12 | setEntity(entity: Entity, x: number, y: number, z: number) { 13 | const cell = this.getCell(x, y, z) 14 | 15 | /* Remove from previous hash if known */ 16 | const oldCell = this.entityToCell.get(entity) 17 | 18 | if (oldCell) { 19 | /* If hash didn't change, do nothing */ 20 | if (oldCell === cell) return 21 | 22 | /* Remove from previous hash */ 23 | oldCell.delete(entity) 24 | } 25 | 26 | cell.add(entity) 27 | this.entityToCell.set(entity, cell) 28 | } 29 | 30 | removeEntity(entity: Entity) { 31 | const cell = this.entityToCell.get(entity) 32 | cell?.delete(entity) 33 | this.entityToCell.delete(entity) 34 | } 35 | 36 | getNearbyEntities( 37 | x: number, 38 | y: number, 39 | z: number, 40 | radius: number, 41 | entities: Entity[] = [], 42 | maxEntities = Infinity 43 | ) { 44 | let count = 0 45 | entities.length = 0 46 | 47 | for (let dx = x - radius; dx <= x + radius; dx += this.cellSize) { 48 | for (let dy = y - radius; dy <= y + radius; dy += this.cellSize) { 49 | for (let dz = z - radius; dz <= z + radius; dz += this.cellSize) { 50 | const cell = this.getCell(dx, dy, dz) 51 | 52 | for (const entity of cell) { 53 | entities.push(entity) 54 | count++ 55 | 56 | if (count >= maxEntities) return entities 57 | } 58 | } 59 | } 60 | } 61 | 62 | return entities 63 | } 64 | 65 | protected getCell(x: number, y: number, z: number) { 66 | const hash = this.calculateHash(x, y, z, this.cellSize) 67 | 68 | if (!this.cells.has(hash)) { 69 | this.cells.set(hash, new Set()) 70 | } 71 | 72 | return this.cells.get(hash)! 73 | } 74 | 75 | protected calculateHash(x: number, y: number, z: number, cellSize: number) { 76 | const hx = Math.floor(x / cellSize) 77 | const hy = Math.floor(y / cellSize) 78 | const hz = Math.floor(z / cellSize) 79 | 80 | return `${hx}:${hy}:${hz}` 81 | } 82 | } 83 | 84 | const entities = ECS.world.with("transform", "spatialHashMap") 85 | 86 | export default function ({ cellSize = 1 }: { cellSize?: number }) { 87 | useFrame(function SpatialHashingSystem() { 88 | for (const entity of entities) { 89 | entity.spatialHashMap.setEntity( 90 | entity, 91 | entity.transform.position.x, 92 | entity.transform.position.y, 93 | entity.transform.position.z 94 | ) 95 | } 96 | }) 97 | 98 | return null 99 | } 100 | -------------------------------------------------------------------------------- /packages/bucket/test/Bucket.test.ts: -------------------------------------------------------------------------------- 1 | import { Bucket } from "../src" 2 | 3 | describe(Bucket, () => { 4 | it("can be instantiated", () => { 5 | const bucket = new Bucket() 6 | expect(bucket).toBeInstanceOf(Bucket) 7 | }) 8 | 9 | it("can be instantiated with a list of entities", () => { 10 | const bucket = new Bucket([1, 2, 3]) 11 | expect(bucket).toBeInstanceOf(Bucket) 12 | expect(bucket.size).toBe(3) 13 | }) 14 | 15 | it("has a version number", () => { 16 | const bucket = new Bucket() 17 | expect(bucket.version).toBe(0) 18 | bucket.add({}) 19 | expect(bucket.version).toBe(1) 20 | }) 21 | 22 | describe("add", () => { 23 | it("adds the entity to the bucket", () => { 24 | const bucket = new Bucket() 25 | const entity = { id: "1" } 26 | 27 | bucket.add(entity) 28 | 29 | expect(bucket.entities).toContain(entity) 30 | }) 31 | 32 | it("returns the entity", () => { 33 | const bucket = new Bucket() 34 | const entity = { id: "1" } 35 | 36 | const result = bucket.add(entity) 37 | 38 | expect(result).toBe(entity) 39 | }) 40 | 41 | it("ignores nullish entities", () => { 42 | const bucket = new Bucket() 43 | 44 | bucket.add(null) 45 | bucket.add(undefined) 46 | 47 | expect(bucket.entities).toHaveLength(0) 48 | }) 49 | 50 | it("emits the onEntityAdded event", () => { 51 | const bucket = new Bucket() 52 | const entity = { id: "1" } 53 | const listener = jest.fn() 54 | 55 | bucket.onEntityAdded.subscribe(listener) 56 | bucket.add(entity) 57 | 58 | expect(listener).toHaveBeenCalledWith(entity) 59 | }) 60 | 61 | it("increases the bucket's version number", () => { 62 | const bucket = new Bucket() 63 | expect(bucket.version).toBe(0) 64 | 65 | bucket.add({}) 66 | bucket.add({}) 67 | 68 | expect(bucket.version).toBe(2) 69 | }) 70 | }) 71 | 72 | describe("remove", () => { 73 | it("removes the entity from the bucket", () => { 74 | const bucket = new Bucket() 75 | const entity = { id: "1" } 76 | 77 | bucket.add(entity) 78 | bucket.remove(entity) 79 | 80 | expect(bucket.entities).not.toContain(entity) 81 | }) 82 | 83 | it("returns the entity", () => { 84 | const bucket = new Bucket() 85 | const entity = { id: "1" } 86 | 87 | const result = bucket.remove(entity) 88 | 89 | expect(result).toBe(entity) 90 | }) 91 | 92 | it("emits the onEntityRemoved event", () => { 93 | const bucket = new Bucket() 94 | const entity = bucket.add({ id: "1" }) 95 | const listener = jest.fn() 96 | 97 | bucket.onEntityRemoved.subscribe(listener) 98 | bucket.remove(entity) 99 | 100 | expect(listener).toHaveBeenCalledWith(entity) 101 | }) 102 | 103 | it("increases the bucket's version number", () => { 104 | const bucket = new Bucket([1, 2]) 105 | expect(bucket.version).toBe(0) 106 | 107 | bucket.remove(1) 108 | bucket.remove(2) 109 | 110 | expect(bucket.version).toBe(2) 111 | }) 112 | }) 113 | 114 | describe("size", () => { 115 | it("returns the number of entities in the bucket", () => { 116 | const bucket = new Bucket() 117 | 118 | bucket.add({ id: "1" }) 119 | bucket.add({ id: "2" }) 120 | 121 | expect(bucket.size).toBe(2) 122 | }) 123 | }) 124 | 125 | describe("first", () => { 126 | it("returns the first entity in the bucket", () => { 127 | const bucket = new Bucket() 128 | const entity = bucket.add({ id: "1" }) 129 | bucket.add({ id: "2" }) 130 | 131 | expect(bucket.first).toBe(entity) 132 | }) 133 | }) 134 | 135 | describe("clear", () => { 136 | it("removes all entities from the bucket", () => { 137 | const bucket = new Bucket() 138 | 139 | bucket.add({ id: "1" }) 140 | bucket.add({ id: "2" }) 141 | bucket.clear() 142 | 143 | expect(bucket.size).toBe(0) 144 | }) 145 | 146 | it("emits the onEntityRemoved event for each entity", () => { 147 | const bucket = new Bucket() 148 | const entity1 = bucket.add({ id: "1" }) 149 | const entity2 = bucket.add({ id: "2" }) 150 | const listener = jest.fn() 151 | 152 | bucket.onEntityRemoved.subscribe(listener) 153 | bucket.clear() 154 | 155 | expect(listener).toHaveBeenCalledWith(entity1) 156 | expect(listener).toHaveBeenCalledWith(entity2) 157 | }) 158 | }) 159 | }) 160 | -------------------------------------------------------------------------------- /packages/react/src/createReactAPI.tsx: -------------------------------------------------------------------------------- 1 | import { Bucket, World } from "miniplex" 2 | import React, { 3 | ForwardedRef, 4 | PropsWithRef, 5 | ReactElement, 6 | ReactNode, 7 | createContext, 8 | forwardRef, 9 | memo, 10 | useContext, 11 | useEffect, 12 | useImperativeHandle, 13 | useLayoutEffect, 14 | useRef, 15 | useState 16 | } from "react" 17 | import { useEntities as useEntitiesGlobal } from "./hooks" 18 | import { mergeRefs } from "./lib/mergeRefs" 19 | 20 | const useIsomorphicLayoutEffect = 21 | typeof window !== "undefined" ? useLayoutEffect : useEffect 22 | 23 | export type EntityChildren = ReactNode | ((entity: E) => ReactNode) 24 | 25 | type CommonProps = { 26 | children?: EntityChildren 27 | } 28 | 29 | export const createReactAPI = (world: World) => { 30 | const EntityContext = createContext(null) 31 | 32 | const useCurrentEntity = () => { 33 | const entity = useContext(EntityContext) 34 | 35 | if (!entity) { 36 | throw new Error( 37 | "useCurrentEntity must be called from a child of ." 38 | ) 39 | } 40 | 41 | return entity 42 | } 43 | 44 | type EntityProps = CommonProps & { entity?: D } 45 | 46 | const RawEntity = ( 47 | { children: givenChildren, entity: givenEntity }: EntityProps, 48 | ref: ForwardedRef 49 | ) => { 50 | const [defaultEntity] = useState(() => ({} as D)) 51 | const entity = givenEntity || defaultEntity 52 | 53 | /* Add the entity to the bucket represented by this component if it isn't already part of it. */ 54 | useIsomorphicLayoutEffect(() => { 55 | if (world.has(entity)) return 56 | 57 | world.add(entity) 58 | return () => { 59 | world.remove(entity) 60 | } 61 | }, [world, entity]) 62 | 63 | const children = 64 | typeof givenChildren === "function" 65 | ? givenChildren(entity) 66 | : givenChildren 67 | 68 | useImperativeHandle(ref, () => entity) 69 | 70 | return ( 71 | {children} 72 | ) 73 | } 74 | 75 | /* We need to typecast here because forwardRef doesn't support generics. */ 76 | const Entity = memo(forwardRef(RawEntity)) as ( 77 | props: PropsWithRef & { ref?: ForwardedRef }> 78 | ) => ReactElement 79 | 80 | const EntitiesInList = ({ 81 | entities, 82 | ...props 83 | }: CommonProps & { 84 | entities: D[] 85 | }) => ( 86 | <> 87 | {entities.map((entity) => ( 88 | 89 | ))} 90 | 91 | ) 92 | 93 | const RawEntitiesInBucket = ({ 94 | bucket, 95 | ...props 96 | }: CommonProps & { 97 | bucket: Bucket 98 | }) => ( 99 | 103 | ) 104 | 105 | const EntitiesInBucket = memo( 106 | RawEntitiesInBucket 107 | ) as typeof RawEntitiesInBucket 108 | 109 | function Entities({ 110 | in: source, 111 | ...props 112 | }: CommonProps & { 113 | in: Bucket | D[] 114 | }): JSX.Element { 115 | if (source instanceof Bucket) { 116 | return 117 | } else { 118 | return 119 | } 120 | } 121 | 122 | const Component =

(props: { 123 | name: P 124 | data?: E[P] 125 | children?: ReactNode 126 | }) => { 127 | const entity = useContext(EntityContext) 128 | const ref = useRef(null!) 129 | 130 | if (!entity) { 131 | throw new Error(" must be a child of ") 132 | } 133 | 134 | /* Handle creation and removal of component with a value prop */ 135 | useIsomorphicLayoutEffect(() => { 136 | world.addComponent(entity, props.name, props.data || ref.current) 137 | return () => world.removeComponent(entity, props.name) 138 | }, [entity, props.name]) 139 | 140 | /* Handle updates to existing component */ 141 | useIsomorphicLayoutEffect(() => { 142 | if (props.data === undefined) return 143 | entity[props.name] = (props.data || ref.current) as (typeof entity)[P] 144 | }, [entity, props.name, props.data, ref.current]) 145 | 146 | /* Handle setting of child value */ 147 | if (props.children) { 148 | const child = React.Children.only(props.children) as ReactElement 149 | 150 | return React.cloneElement(child, { 151 | ref: mergeRefs([(child as any).ref, ref]) 152 | }) 153 | } 154 | 155 | return null 156 | } 157 | 158 | return { 159 | world, 160 | Component, 161 | Entity, 162 | Entities, 163 | useCurrentEntity 164 | } 165 | } 166 | -------------------------------------------------------------------------------- /packages/bucket/src/Bucket.ts: -------------------------------------------------------------------------------- 1 | import { Event } from "eventery" 2 | 3 | /** 4 | * A class wrapping an array of entities of a specific type, providing 5 | * performance-optimized methods for adding, looking up and removing entities, and events 6 | * for when entities are added or removed. 7 | */ 8 | export class Bucket implements Iterable { 9 | /* VERSIONING */ 10 | protected _version = 0 11 | 12 | /** 13 | * The current version of the bucket. Increases every time an entity is 14 | * added or removed. 15 | */ 16 | get version() { 17 | return this._version 18 | } 19 | 20 | /** 21 | * An array of all entities within the bucket. Please note that for iterating 22 | * over the entities in this bucket, it is recommended that you use the 23 | * `for (const entity of bucket)` iterator form. 24 | */ 25 | get entities() { 26 | return this._entities 27 | } 28 | 29 | /* Custom iterator that iterates over all entities in reverse order. */ 30 | [Symbol.iterator]() { 31 | let index = this._entities.length 32 | 33 | const result = { 34 | value: undefined as E, 35 | done: false 36 | } 37 | 38 | return { 39 | next: () => { 40 | result.value = this._entities[--index] 41 | result.done = index < 0 42 | return result 43 | } 44 | } 45 | } 46 | 47 | constructor(protected _entities: E[] = []) { 48 | this.add = this.add.bind(this) 49 | this.remove = this.remove.bind(this) 50 | 51 | /* Register all entity positions */ 52 | for (let i = 0; i < _entities.length; i++) { 53 | this.entityPositions.set(_entities[i], i) 54 | } 55 | } 56 | 57 | /** 58 | * Fired when an entity has been added to the bucket. 59 | */ 60 | onEntityAdded = new Event<[entity: E]>() 61 | 62 | /** 63 | * Fired when an entity is about to be removed from the bucket. 64 | */ 65 | onEntityRemoved = new Event<[entity: E]>() 66 | 67 | /** 68 | * A map of entity positions, used for fast lookups. 69 | */ 70 | private entityPositions = new Map() 71 | 72 | /** 73 | * Returns the total size of the bucket, i.e. the number of entities it contains. 74 | */ 75 | get size() { 76 | return this.entities.length 77 | } 78 | 79 | /** 80 | * Returns the first entity in the bucket, or `undefined` if the bucket is empty. 81 | */ 82 | get first(): E | undefined { 83 | return this.entities[0] 84 | } 85 | 86 | /** 87 | * Returns true if the bucket contains the given entity. 88 | * 89 | * @param entity The entity to check for. 90 | * @returns `true` if the specificed entity is in this bucket, `false` otherwise. 91 | */ 92 | has(entity: any): entity is E { 93 | return this.entityPositions.has(entity) 94 | } 95 | 96 | /** 97 | * Adds the given entity to the bucket. If the entity is already in the bucket, it is 98 | * not added again. 99 | * 100 | * @param entity The entity to add to the bucket. 101 | * @returns The entity passed into this function (regardless of whether it was added or not). 102 | */ 103 | add(entity: D): D & E { 104 | if (entity && !this.has(entity)) { 105 | this.entities.push(entity) 106 | this.entityPositions.set(entity, this.entities.length - 1) 107 | 108 | /* Increase version */ 109 | this._version++ 110 | 111 | /* Emit our own onEntityAdded event */ 112 | this.onEntityAdded.emit(entity) 113 | } 114 | 115 | return entity 116 | } 117 | 118 | /** 119 | * Removes the given entity from the bucket. If the entity is not in the bucket, nothing 120 | * happens. 121 | * 122 | * @param entity The entity to remove from the bucket. 123 | * @returns The entity passed into this function (regardless of whether it was removed or not). 124 | */ 125 | remove(entity: E) { 126 | /* TODO: Return early if entity is not in bucket. */ 127 | if (this.has(entity)) { 128 | /* Emit our own onEntityRemoved event. */ 129 | this.onEntityRemoved.emit(entity) 130 | 131 | /* Get the entity's current position. */ 132 | const index = this.entityPositions.get(entity)! 133 | this.entityPositions.delete(entity) 134 | 135 | /* Perform shuffle-pop if there is more than one entity. */ 136 | const other = this.entities[this.entities.length - 1] 137 | if (other !== entity) { 138 | this.entities[index] = other 139 | this.entityPositions.set(other, index) 140 | } 141 | 142 | /* Remove the entity from the entities array. */ 143 | this.entities.pop() 144 | 145 | /* Bump version */ 146 | this._version++ 147 | } 148 | 149 | return entity 150 | } 151 | 152 | /** 153 | * Removes all entities from the bucket. Will cause the `onEntityRemoved` event to be 154 | * fired for each entity. 155 | */ 156 | clear() { 157 | for (const entity of this) { 158 | this.remove(entity) 159 | } 160 | } 161 | } 162 | -------------------------------------------------------------------------------- /packages/react/CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | ## 2.0.1 4 | 5 | ### Patch Changes 6 | 7 | - 2303870: Bump minimum version of `miniplex` peer dependency. 8 | 9 | ## 2.0.0 10 | 11 | ### Major Changes 12 | 13 | - 8a7a315: - The library has been significantly simplified and an almost mind-boggling number of bugs have beens quashed. 14 | 15 | - The main import and initialization have changed: 16 | 17 | ```js 18 | import { World } from "miniplex" 19 | import createReactAPI from "miniplex-react" // ! 20 | 21 | /* It now expects a world as its argument, so you need to create one first: */ 22 | const world = new World() 23 | const ECS = createReactAPI(world) 24 | ``` 25 | 26 | - All lists of entities are now rendered through the upgraded `` component, which takes an array of entities or a query (or even a world) as its `in` prop: 27 | 28 | ```jsx 29 | {/* ... */} 30 | ``` 31 | 32 | If you're passing in a query or a world, the component will automatically re-render when the entities appear or disappear. If you don't want this, you can also just pass in a plain array containing entities: 33 | 34 | ```jsx 35 | {/* ... */} 36 | ``` 37 | 38 | - **`` has been removed.** You were probably not using it. If you were, you can replicate the same behavior using a combination of the `` component and a `useEffect` hook. 39 | - The `useEntity` hook has been renamed to `useCurrentEntity`. 40 | - The world-scoped `useArchetype` hook has been removed, and superseded by the new global `useEntities` hook: 41 | 42 | ```js 43 | /* Before */ 44 | const entities = useArchetype("position", "velocity") 45 | 46 | /* Now */ 47 | const entities = useEntities(world.with("position", "velocity")) 48 | ``` 49 | 50 | ## 1.0.1 51 | 52 | ### Patch Changes 53 | 54 | - a43c734: **Fixed:** When `` re-renders, it is expected to reactively update the component's data to the value of its `data` prop, or the `ref` of its React child. It has so far been doing that by removing and re-adding the entire component, which had the side-effect of making the entity disappear from and then reappear in archetypes indexing that component. This has now been fixed. 55 | 56 | The component will only be added and removed once (at the beginning and the end of the React component's lifetime, respectively); in re-renders during its lifetime, the data will simply be updated directly when a change is detected. This allows you to connect a `` to the usual reactive mechanisms in React. 57 | 58 | ## 1.0.0 59 | 60 | ### Major Changes 61 | 62 | - ce9cfb4: **Breaking Change:** The `useEntity` hook has been renamed to `useCurrentEntity` to better express what it does, and to make way for future `useEntity` and `useEntities` hooks that will create and destroy entities. 63 | 64 | ### Patch Changes 65 | 66 | - c102f2d: **New:** ``, a new component that (reactively) renders all entities of the specified archetype. This can be used as a replacement for the combination of `useArchetype` and ``, except now your component won't re-render when entities appear or disappear, because the subscription will be scoped to ``. 67 | 68 | Where before you may have done this: 69 | 70 | ```tsx 71 | const MyComponent = () => { 72 | const { entities } = useArchetype("my-archetype") 73 | /* This component will now re-render every time the archetype is updated */ 74 | return 75 | } 76 | ``` 77 | 78 | You can now do this: 79 | 80 | ```tsx 81 | const MyComponent = () => { 82 | /* This component will not rerender */ 83 | return 84 | } 85 | ``` 86 | 87 | The component will also accept arrays of component names: 88 | 89 | ```tsx 90 | const EnemyShips = () => { 91 | return 92 | } 93 | ``` 94 | 95 | - c38d7e5: **Fixed:** A couple of components were using `useEffect` where it should have been `useLayoutEffect`. 96 | - 54bb5ef: **Fixed:** no longer re-renders once after mounting. 97 | - 551dcd9: **New:** The `createECS` function now allows you to pass in an existing `World` instance as its first argument. If no world is passed, it will create a new one (using the specified type, if any), as it has previously. 98 | 99 | ## 1.0.0-next.8 100 | 101 | ### Patch Changes 102 | 103 | - 877dac5: **Fixed:** Make use of `useIsomorphicLayoutEffect`. 104 | 105 | ## 1.0.0-next.7 106 | 107 | ### Patch Changes 108 | 109 | - c38d7e5: **Fixed:** A couple of components were using `useEffect` where it should have been `useLayoutEffect`. 110 | - 54bb5ef: **Fixed:** no longer re-renders once after mounting. 111 | 112 | ## 1.0.0-next.6 113 | 114 | ### Patch Changes 115 | 116 | - efa21f2: Typing tweaks. 117 | 118 | ## 1.0.0-next.4 119 | 120 | ### Patch Changes 121 | 122 | - c102f2d: **New:** ``, a new component that (reactively) renders all entities of the specified archetype. 123 | 124 | ## 1.0.0-next.3 125 | 126 | ### Patch Changes 127 | 128 | - 1950b9b: General cleanup and typing improvements. 129 | 130 | ## 1.0.0-next.2 131 | 132 | ### Patch Changes 133 | 134 | - 551dcd9: The `createECS` function now allows you to pass in an existing `World` instance as its first argument. If no world is passed, it will create a new one (using the specified type, if any), as it has previously. 135 | 136 | ## 1.0.0-next.1 137 | 138 | ### Major Changes 139 | 140 | - 4016fb2: 1.0! 141 | 142 | ### Patch Changes 143 | 144 | - Updated dependencies [410e0f6] 145 | - Updated dependencies [4016fb2] 146 | - miniplex@1.0.0-next.1 147 | 148 | ## 0.4.3-next.0 149 | 150 | ### Patch Changes 151 | 152 | - dd047e9: This package now loads `miniplex` as a direct dependency; it is no longer necessary to install miniplex as a peer dependency. 153 | - Updated dependencies [769dba7] 154 | - Updated dependencies [b8b2c9b] 155 | - Updated dependencies [cb6d078] 156 | - Updated dependencies [4d9e51b] 157 | - miniplex@0.11.0-next.0 158 | 159 | ## 0.4.2 160 | 161 | ### Patch Changes 162 | 163 | - 1422853: Fixed return type of `useArchetype`. 164 | 165 | ## 0.4.1 166 | 167 | ### Patch Changes 168 | 169 | - cb09f35: **Fixed:** When you're passing a complete React element (through JSX) to a ``, you were not able to set a `ref` on it. This has now been fixed. 170 | 171 | ## 0.4.0 172 | 173 | ### Minor Changes 174 | 175 | - 0f01a94: **Breaking Change:** `` has been renamed to ``. 176 | - 0ad0e86: **Breaking Change:** `useEntity` has been changed back to its original functionality of returning the current entity context. `useEntities` has been removed. 177 | 178 | ## 0.3.1 179 | 180 | ### Patch Changes 181 | 182 | - db987cd: Improve typings within `useEntities`. 183 | 184 | ## 0.3.0 185 | 186 | ### Minor Changes 187 | 188 | - cc4032d: **New:** `useEntities` is a new hook that will create and return a specified number of entities, initialized through an optional entity factory. `useEntity` does the same, but just for a single entity. 189 | 190 | ## 0.2.4 191 | 192 | ### Patch Changes 193 | 194 | - 68cff32: Fix React 18 Strict Mode compatibility in ``. 195 | 196 | ## 0.2.3 197 | 198 | ### Patch Changes 199 | 200 | - c23681c: More tweaks to the sanity checks 201 | 202 | ## 0.2.2 203 | 204 | ### Patch Changes 205 | 206 | - 48e785d: Fix sanity check in `` 207 | 208 | ## 0.2.1 209 | 210 | ### Patch Changes 211 | 212 | - 0c1ce64: Now uses `useEffect` instead of `useLayoutEffect`, which should make it easier to use the components in server-side React. 213 | 214 | ## 0.2.0 215 | 216 | ### Minor Changes 217 | 218 | - b4fa0b4: `` and `` now use the new `addComponent` API introduced with miniplex 0.8.0. 219 | 220 | ### Patch Changes 221 | 222 | - Updated dependencies [011c384] 223 | - miniplex@0.8.1 224 | 225 | ## 0.1.0 226 | 227 | - First release 228 | -------------------------------------------------------------------------------- /packages/core/benchmark.ts: -------------------------------------------------------------------------------- 1 | import { World } from "./src" 2 | import chalk from "chalk" 3 | 4 | const entityCount = 1_000_000 5 | 6 | const heading = (text: string) => { 7 | console.log() 8 | console.log(chalk.bgCyanBright(` ${text} `)) 9 | console.log() 10 | } 11 | 12 | const profile = (name: string, setup: () => () => () => boolean) => { 13 | const test = setup() 14 | const before = performance.now() 15 | const assertion = test() 16 | const after = performance.now() 17 | 18 | /* Check assertion */ 19 | if (!assertion()) { 20 | throw new Error("Assertion failed!") 21 | } 22 | 23 | /* Results */ 24 | const duration = after - before 25 | const ops = entityCount / (after - before) 26 | 27 | console.log( 28 | `${name.padStart(50)} ${duration.toFixed(2).padStart(8)}ms ${ops 29 | .toFixed(1) 30 | .padStart(10)} ops/ms` 31 | ) 32 | } 33 | 34 | type Vector = { 35 | x: number 36 | y: number 37 | z: number 38 | } 39 | 40 | type Entity = { 41 | position: Vector 42 | velocity?: Vector 43 | } 44 | 45 | console.log(`Entity count: ${entityCount}\n`) 46 | 47 | heading("Entity Addition") 48 | 49 | profile("add", () => { 50 | const world = new World() 51 | 52 | return () => { 53 | for (let i = 0; i < entityCount; i++) { 54 | world.add({ 55 | position: { x: 0, y: i, z: 0 }, 56 | velocity: { x: 0, y: 0, z: 0 } 57 | }) 58 | } 59 | 60 | return () => world.size === entityCount 61 | } 62 | }) 63 | 64 | profile("add (with archetypes)", () => { 65 | const world = new World() 66 | const withPosition = world.with("position").connect() 67 | const withVelocity = world.with("velocity").connect() 68 | 69 | return () => { 70 | for (let i = 0; i < entityCount; i++) { 71 | world.add({ 72 | position: { x: 0, y: i, z: 0 }, 73 | velocity: { x: 0, y: 0, z: 0 } 74 | }) 75 | } 76 | 77 | return () => world.size === entityCount 78 | } 79 | }) 80 | 81 | heading("Entity Removal") 82 | 83 | profile("remove (random)", () => { 84 | const world = new World() 85 | for (let i = 0; i < entityCount; i++) 86 | world.add({ 87 | position: { x: 0, y: i, z: 0 }, 88 | velocity: { x: 0, y: 0, z: 0 } 89 | }) 90 | 91 | return () => { 92 | while (world.size > 0) { 93 | /* Get a random entity... */ 94 | const entity = world.entities[Math.floor(Math.random() * world.size)] 95 | 96 | /* ...and delete it */ 97 | world.remove(entity) 98 | } 99 | 100 | return () => world.size === 0 101 | } 102 | }) 103 | 104 | profile("remove (random, with archetypes)", () => { 105 | const world = new World() 106 | const withPosition = world.with("position").connect() 107 | const withVelocity = world.with("velocity").connect() 108 | 109 | for (let i = 0; i < entityCount; i++) 110 | world.add({ 111 | position: { x: 0, y: i, z: 0 }, 112 | velocity: { x: 0, y: 0, z: 0 } 113 | }) 114 | 115 | return () => { 116 | while (world.size > 0) { 117 | /* Get a random entity... */ 118 | const entity = world.entities[Math.floor(Math.random() * world.size)] 119 | 120 | /* ...and delete it */ 121 | world.remove(entity) 122 | } 123 | 124 | return () => 125 | world.size === 0 && withPosition.size === 0 && withVelocity.size === 0 126 | } 127 | }) 128 | 129 | profile("clear", () => { 130 | const world = new World() 131 | for (let i = 0; i < entityCount; i++) 132 | world.add({ 133 | position: { x: 0, y: i, z: 0 }, 134 | velocity: { x: 0, y: 0, z: 0 } 135 | }) 136 | 137 | return () => { 138 | world.clear() 139 | 140 | return () => world.size === 0 141 | } 142 | }) 143 | 144 | profile("clear (with archetypes)", () => { 145 | const world = new World() 146 | const withPosition = world.with("position").connect() 147 | const withVelocity = world.with("velocity").connect() 148 | 149 | for (let i = 0; i < entityCount; i++) 150 | world.add({ 151 | position: { x: 0, y: i, z: 0 }, 152 | velocity: { x: 0, y: 0, z: 0 } 153 | }) 154 | 155 | return () => { 156 | world.clear() 157 | 158 | return () => 159 | world.size === 0 && withPosition.size === 0 && withVelocity.size === 0 160 | } 161 | }) 162 | 163 | heading("Iteration") 164 | 165 | profile("simulate (iterator, world)", () => { 166 | const world = new World() 167 | 168 | for (let i = 0; i < entityCount; i++) 169 | world.add({ 170 | position: { x: Math.random() * 200 - 100, y: i, z: 0 }, 171 | velocity: { x: 1, y: 2, z: 3 } 172 | }) 173 | 174 | return () => { 175 | let i = 0 176 | for (const { position, velocity } of world) { 177 | i++ 178 | if (!velocity) continue 179 | position.x += velocity.x 180 | position.y += velocity.y 181 | position.z += velocity.z 182 | } 183 | 184 | return () => i === entityCount 185 | } 186 | }) 187 | 188 | profile("simulate (iterator, archetype)", () => { 189 | const world = new World() 190 | const withVelocity = world.with("velocity").connect() 191 | 192 | for (let i = 0; i < entityCount; i++) 193 | world.add({ 194 | position: { x: Math.random() * 200 - 100, y: i, z: 0 }, 195 | velocity: { x: 1, y: 2, z: 3 } 196 | }) 197 | 198 | return () => { 199 | let i = 0 200 | 201 | for (const { position, velocity } of withVelocity) { 202 | i++ 203 | position.x += velocity.x 204 | position.y += velocity.y 205 | position.z += velocity.z 206 | } 207 | 208 | return () => i === entityCount 209 | } 210 | }) 211 | 212 | profile("simulate (iterator, array)", () => { 213 | const world = new World() 214 | 215 | for (let i = 0; i < entityCount; i++) 216 | world.add({ 217 | position: { x: Math.random() * 200 - 100, y: i, z: 0 }, 218 | velocity: { x: 1, y: 2, z: 3 } 219 | }) 220 | 221 | return () => { 222 | let i = 0 223 | for (const { position, velocity } of world.entities) { 224 | i++ 225 | if (!velocity) continue 226 | position.x += velocity.x 227 | position.y += velocity.y 228 | position.z += velocity.z 229 | } 230 | 231 | return () => i === entityCount 232 | } 233 | }) 234 | 235 | profile("simulate (for, array)", () => { 236 | const world = new World() 237 | 238 | for (let i = 0; i < entityCount; i++) 239 | world.add({ 240 | position: { x: Math.random() * 200 - 100, y: i, z: 0 }, 241 | velocity: { x: 1, y: 2, z: 3 } 242 | }) 243 | 244 | return () => { 245 | let count = 0 246 | 247 | for (let i = 0; i < world.entities.length; i++) { 248 | count++ 249 | const { position, velocity } = world.entities[i] 250 | if (!velocity) continue 251 | 252 | position.x += velocity.x 253 | position.y += velocity.y 254 | position.z += velocity.z 255 | } 256 | 257 | return () => count === entityCount 258 | } 259 | }) 260 | 261 | heading("Iteration with predicates") 262 | 263 | profile(".where() query", () => { 264 | const world = new World() 265 | 266 | const positiveX = world.where((e) => e.position.x > 0).connect() 267 | 268 | for (let i = 0; i < entityCount; i++) 269 | world.add({ 270 | position: { x: Math.random() * 200 - 100, y: i, z: 0 }, 271 | velocity: { x: 1, y: 2, z: 3 } 272 | }) 273 | 274 | return () => { 275 | let i = 0 276 | 277 | for (const { position, velocity } of positiveX) { 278 | i++ 279 | if (!velocity) continue 280 | position.x += velocity.x 281 | position.y += velocity.y 282 | position.z += velocity.z 283 | } 284 | 285 | return () => i > 0 286 | } 287 | }) 288 | 289 | profile("value predicate check (filter 👎)", () => { 290 | const world = new World() 291 | 292 | for (let i = 0; i < entityCount; i++) 293 | world.add({ 294 | position: { x: Math.random() * 200 - 100, y: i, z: 0 }, 295 | velocity: { x: 1, y: 2, z: 3 } 296 | }) 297 | 298 | return () => { 299 | let i = 0 300 | 301 | for (const { position, velocity } of world.entities.filter( 302 | (e) => e.position.x > 0 303 | )) { 304 | i++ 305 | if (!velocity) continue 306 | position.x += velocity.x 307 | position.y += velocity.y 308 | position.z += velocity.z 309 | } 310 | 311 | return () => i > 0 312 | } 313 | }) 314 | 315 | heading("ooflorent's packed_5") 316 | 317 | profile("1000x for entity of 1000 entities", () => { 318 | const ecs = new World() 319 | 320 | for (let i = 0; i < 1000; i++) { 321 | ecs.add({ A: 1, B: 1, C: 1, D: 1, E: 1 }) 322 | } 323 | 324 | const withA = ecs.with("A") 325 | const withB = ecs.with("B") 326 | const withC = ecs.with("C") 327 | const withD = ecs.with("D") 328 | const withE = ecs.with("E") 329 | 330 | return () => { 331 | for (let i = 0; i < 1000; i++) { 332 | for (const entity of withA.entities) entity.A *= 2 333 | for (const entity of withB.entities) entity.B *= 2 334 | for (const entity of withC.entities) entity.C *= 2 335 | for (const entity of withD.entities) entity.D *= 2 336 | for (const entity of withE.entities) entity.E *= 2 337 | } 338 | 339 | return () => true 340 | } 341 | }) 342 | 343 | profile("1000x iterating over iterator with 1000 entities", () => { 344 | const ecs = new World() 345 | 346 | for (let i = 0; i < 1000; i++) { 347 | ecs.add({ A: 1, B: 1, C: 1, D: 1, E: 1 }) 348 | } 349 | 350 | const withA = ecs.with("A") 351 | const withB = ecs.with("B") 352 | const withC = ecs.with("C") 353 | const withD = ecs.with("D") 354 | const withE = ecs.with("E") 355 | 356 | return () => { 357 | for (let i = 0; i < 1000; i++) { 358 | for (const entity of withA) entity.A *= 2 359 | for (const entity of withB) entity.B *= 2 360 | for (const entity of withC) entity.C *= 2 361 | for (const entity of withD) entity.D *= 2 362 | for (const entity of withE) entity.E *= 2 363 | } 364 | 365 | return () => true 366 | } 367 | }) 368 | -------------------------------------------------------------------------------- /packages/react/test/createReactAPI.test.tsx: -------------------------------------------------------------------------------- 1 | import "@testing-library/jest-dom" 2 | import { act, render, screen } from "@testing-library/react" 3 | import { World } from "miniplex" 4 | import React, { StrictMode } from "react" 5 | import createReactAPI from "../src" 6 | 7 | type Entity = { 8 | name: string 9 | age?: number 10 | height?: number 11 | } 12 | 13 | /* 14 | Hide errors thrown by React (we're testing for them.) 15 | See: https://dev.to/martinemmert/hide-red-console-error-log-wall-while-testing-errors-with-jest-2bfn 16 | */ 17 | beforeEach(() => { 18 | jest.spyOn(console, "error") 19 | // @ts-ignore jest.spyOn adds this functionallity 20 | console.error.mockImplementation(() => null) 21 | }) 22 | 23 | afterEach(() => { 24 | // @ts-ignore jest.spyOn adds this functionallity 25 | console.error.mockRestore() 26 | }) 27 | 28 | describe("", () => { 29 | it("creates an entity", () => { 30 | const world = new World() 31 | const { Entity } = createReactAPI(world) 32 | 33 | expect(world.entities.length).toBe(0) 34 | render() 35 | expect(world.entities.length).toBe(1) 36 | }) 37 | 38 | it("allows ref forwarding", () => { 39 | const world = new World() 40 | const { Entity } = createReactAPI(world) 41 | const ref = React.createRef() 42 | 43 | render() 44 | 45 | expect(ref.current).not.toBeNull() 46 | expect(ref.current).toBe(world.first) 47 | }) 48 | 49 | it("keeps the entity when the component is rerendered", () => { 50 | const world = new World() 51 | const { Entity } = createReactAPI(world) 52 | 53 | expect(world.entities.length).toBe(0) 54 | 55 | /* Create a new entity and make sure the component is not memozied. */ 56 | const Test = () => {Math.random()} 57 | 58 | const { rerender } = render() 59 | expect(world.entities.length).toBe(1) 60 | const entity = world.first 61 | 62 | rerender() 63 | expect(world.entities.length).toBe(1) 64 | expect(world.first).toBe(entity) 65 | }) 66 | 67 | it("removes the entity on unmount", () => { 68 | const world = new World() 69 | const { Entity } = createReactAPI(world) 70 | 71 | const { unmount } = render() 72 | expect(world.entities.length).toBe(1) 73 | unmount() 74 | expect(world.entities.length).toBe(0) 75 | }) 76 | 77 | it("accepts a function as its child", () => { 78 | const world = new World() 79 | const { Entity } = createReactAPI(world) 80 | 81 | const entity = world.add({ name: "John" }) 82 | 83 | render( 84 | {(entity) =>

{entity.name}
}
85 | ) 86 | 87 | expect(world.entities[0].name).toBe("John") 88 | expect(screen.getByText("John")).toBeInTheDocument() 89 | }) 90 | 91 | it("accepts a React function component as a child", () => { 92 | const world = new World() 93 | const { Entity } = createReactAPI(world) 94 | 95 | const entity = world.add({ name: "Alice", age: 30 }) 96 | 97 | const User = (entity: Entity) =>

Name: {entity.name}

98 | 99 | render() 100 | 101 | expect(screen.getByText("Name: Alice")).toBeInTheDocument() 102 | }) 103 | 104 | describe("with a given entity that is not yet part of the bucket", () => { 105 | it("adds the entity to the bucket", () => { 106 | const world = new World() 107 | const { Entity } = createReactAPI(world) 108 | const entity = { name: "John" } 109 | 110 | expect(world.entities.length).toBe(0) 111 | render() 112 | expect(world.entities.length).toBe(1) 113 | expect(world.entities[0]).toBe(entity) 114 | }) 115 | 116 | it("removes the entity on unmount", () => { 117 | const world = new World() 118 | const { Entity } = createReactAPI(world) 119 | const entity = { name: "John" } 120 | 121 | const { unmount } = render() 122 | expect(world.entities.length).toBe(1) 123 | unmount() 124 | expect(world.entities.length).toBe(0) 125 | }) 126 | }) 127 | 128 | describe("given `children` prop", () => { 129 | it("renders the entity using that component, passing the entity to it", () => { 130 | const world = new World() 131 | const { Entity } = createReactAPI(world) 132 | 133 | const entity = world.add({ name: "John" }) 134 | 135 | const Person = ({ name }: { name: string }) =>
{name}
136 | 137 | render() 138 | 139 | expect(screen.getByText("John")).toBeInTheDocument() 140 | }) 141 | }) 142 | }) 143 | 144 | describe("", () => { 145 | it("assigns the specified component", () => { 146 | const world = new World() 147 | const { Entity, Component } = createReactAPI(world) 148 | 149 | render( 150 | 151 | 152 | 153 | ) 154 | expect(world.entities[0]).toMatchObject({}) 155 | expect(world.entities[0].name).toBe("John") 156 | }) 157 | 158 | it("updates the specified component on re-rendering", () => { 159 | const world = new World() 160 | const { Entity, Component } = createReactAPI(world) 161 | 162 | const { rerender } = render( 163 | 164 | 165 | 166 | ) 167 | expect(world.entities[0].name).toBe("John") 168 | 169 | rerender( 170 | 171 | 172 | 173 | ) 174 | expect(world.entities[0].name).toBe("Jane") 175 | }) 176 | 177 | it("removes the component when the component is unmounted", () => { 178 | const world = new World() 179 | const entity = world.add({ name: "John" }) 180 | const { Entity, Component } = createReactAPI(world) 181 | 182 | const { unmount } = render( 183 | 184 | 185 | 186 | ) 187 | expect(world.entities[0].age).toBe(50) 188 | 189 | unmount() 190 | expect(world.entities[0]).toEqual({ name: "John" }) 191 | }) 192 | 193 | it("captures the ref of the child when it has one", () => { 194 | const world = new World<{ div?: HTMLDivElement }>() 195 | const entity = world.add({}) 196 | 197 | const { Entity, Component } = createReactAPI(world) 198 | 199 | const ref = React.createRef() 200 | 201 | const { unmount } = render( 202 | 203 | 204 |
205 | 206 | 207 | ) 208 | 209 | expect(entity.div).toBe(ref.current) 210 | 211 | unmount() 212 | 213 | expect(entity.div).toBe(undefined) 214 | }) 215 | 216 | describe("when the entity already has the component", () => { 217 | it("updates the component", () => { 218 | const world = new World() 219 | const { Entity, Component } = createReactAPI(world) 220 | const entity = world.add({ name: "John" }) 221 | 222 | render( 223 | 224 | 225 | 226 | ) 227 | 228 | expect(world.entities[0].name).toBe("Jane") 229 | }) 230 | }) 231 | }) 232 | 233 | describe("", () => { 234 | describe("with an array of entities", () => { 235 | it("renders the entities", () => { 236 | const world = new World() 237 | const { Entities } = createReactAPI(world) 238 | 239 | const entities = [ 240 | world.add({ name: "John" }), 241 | world.add({ name: "Jane" }) 242 | ] 243 | 244 | render( 245 | {(entity) =>

{entity.name}

}
246 | ) 247 | 248 | expect(screen.getByText("John")).toBeInTheDocument() 249 | expect(screen.getByText("Jane")).toBeInTheDocument() 250 | }) 251 | }) 252 | 253 | describe("with a bucket", () => { 254 | it("renders the entities within the given bucket", () => { 255 | const world = new World() 256 | const { Entities } = createReactAPI(world) 257 | 258 | world.add({ name: "Alice" }) 259 | world.add({ name: "Bob" }) 260 | 261 | render({(entity) =>

{entity.name}

}
) 262 | 263 | expect(screen.getByText("Alice")).toBeInTheDocument() 264 | expect(screen.getByText("Bob")).toBeInTheDocument() 265 | }) 266 | 267 | it("re-renders the entities when the bucket contents change", () => { 268 | const world = new World() 269 | const { Entities } = createReactAPI(world) 270 | 271 | const alice = world.add({ name: "Alice" }) 272 | world.add({ name: "Bob" }) 273 | 274 | render( 275 | 276 | {(entity) =>

{entity.name}

}
277 |
278 | ) 279 | 280 | expect(screen.getByText("Alice")).toBeInTheDocument() 281 | expect(screen.getByText("Bob")).toBeInTheDocument() 282 | 283 | act(() => { 284 | world.add({ name: "Charlie" }) 285 | }) 286 | 287 | expect(screen.getByText("Alice")).toBeInTheDocument() 288 | expect(screen.getByText("Bob")).toBeInTheDocument() 289 | expect(screen.getByText("Charlie")).toBeInTheDocument() 290 | 291 | act(() => { 292 | world.remove(alice) 293 | }) 294 | 295 | expect(screen.queryByText("Alice")).toBeNull() 296 | expect(screen.getByText("Bob")).toBeInTheDocument() 297 | expect(screen.getByText("Charlie")).toBeInTheDocument() 298 | }) 299 | }) 300 | 301 | describe("given an `children` prop", () => { 302 | it("renders the entities using the given component, passing the entity to it", () => { 303 | const world = new World() 304 | const { Entities } = createReactAPI(world) 305 | 306 | world.add({ name: "Alice" }) 307 | world.add({ name: "Bob" }) 308 | 309 | const User = ({ name }: { name: string }) =>
{name}
310 | 311 | render() 312 | 313 | expect(screen.getByText("Alice")).toBeInTheDocument() 314 | expect(screen.getByText("Bob")).toBeInTheDocument() 315 | }) 316 | }) 317 | }) 318 | 319 | describe("useCurrentEntity", () => { 320 | describe("when invoked within an entity context", () => { 321 | it("returns the context's entity", () => { 322 | const world = new World() 323 | const { Entity, useCurrentEntity } = createReactAPI(world) 324 | 325 | const entity = world.add({ name: "John" }) 326 | 327 | const Test = () => { 328 | const currentEntity = useCurrentEntity() 329 | return

{currentEntity.name}

330 | } 331 | 332 | render( 333 | 334 | 335 | 336 | ) 337 | 338 | expect(screen.getByText("John")).toBeInTheDocument() 339 | }) 340 | }) 341 | 342 | describe("when invoked outside of an entity context", () => { 343 | it("throws an error", () => { 344 | const { useCurrentEntity } = createReactAPI(new World()) 345 | 346 | const Test = () => { 347 | useCurrentEntity() 348 | return null 349 | } 350 | 351 | expect(() => render()).toThrow( 352 | "useCurrentEntity must be called from a child of ." 353 | ) 354 | }) 355 | }) 356 | }) 357 | 358 | describe("world", () => { 359 | it("is a reference to the world originally passed into createReactAPI", () => { 360 | const world = new World<{ name: string }>() 361 | const api = createReactAPI(world) 362 | expect(api.world).toBe(world) 363 | }) 364 | }) 365 | -------------------------------------------------------------------------------- /packages/core/test/core.test.ts: -------------------------------------------------------------------------------- 1 | import { Query, World } from "../src/core" 2 | 3 | type Entity = { 4 | name: string 5 | age?: number 6 | height?: number 7 | } 8 | 9 | describe(World, () => { 10 | it("can be instantiated with a list of entities", () => { 11 | const world = new World([ 12 | { name: "John", age: 30 }, 13 | { name: "Jane", age: 28 } 14 | ]) 15 | expect(world.entities).toHaveLength(2) 16 | }) 17 | 18 | describe("add", () => { 19 | it("adds an entity", () => { 20 | const world = new World() 21 | const entity = world.add({ name: "John" }) 22 | expect(world.entities).toEqual([entity]) 23 | }) 24 | }) 25 | 26 | describe("remove", () => { 27 | it("removes an entity", () => { 28 | const world = new World() 29 | const entity = world.add({ name: "John" }) 30 | expect(world.entities).toEqual([entity]) 31 | 32 | world.remove(entity) 33 | expect(world.entities).toEqual([]) 34 | }) 35 | }) 36 | 37 | describe("update", () => { 38 | it("updates the entity", () => { 39 | const world = new World() 40 | const entity = world.add({ name: "John" }) 41 | expect(entity.name).toEqual("John") 42 | 43 | world.update(entity, { name: "Jane" }) 44 | expect(entity.name).toEqual("Jane") 45 | }) 46 | 47 | it("triggers a reindexing of the entity", () => { 48 | const world = new World() 49 | const entity = world.add({ name: "John" }) 50 | expect(entity.name).toEqual("John") 51 | 52 | const hasAge = world.with("age") 53 | expect(hasAge.entities).toEqual([]) 54 | 55 | world.update(entity, { age: 28 }) 56 | expect(hasAge.entities).toEqual([entity]) 57 | }) 58 | 59 | it("returns the entity", () => { 60 | const world = new World() 61 | const entity = world.add({ name: "John" }) 62 | expect(world.update(entity, { name: "Jane" })).toEqual(entity) 63 | }) 64 | 65 | it("accepts a function that returns an object containing changes", () => { 66 | const world = new World() 67 | const entity = world.add({ name: "John" }) 68 | expect(entity.name).toEqual("John") 69 | 70 | world.update(entity, () => ({ name: "Jane" })) 71 | expect(entity.name).toEqual("Jane") 72 | }) 73 | 74 | it("accepts a function that mutates the entity directly", () => { 75 | const world = new World() 76 | const entity = world.add({ name: "John" }) 77 | expect(entity.name).toEqual("John") 78 | 79 | world.update(entity, (entity) => { 80 | entity.name = "Jane" 81 | }) 82 | 83 | expect(entity.name).toEqual("Jane") 84 | }) 85 | 86 | it("accepts a component name and value", () => { 87 | const world = new World() 88 | const entity = world.add({ name: "John" }) 89 | expect(entity.name).toEqual("John") 90 | 91 | world.update(entity, "name", "Jane") 92 | expect(entity.name).toEqual("Jane") 93 | }) 94 | 95 | it("can be called without a second argument to trigger the reindexing only", () => { 96 | const world = new World() 97 | const entity = world.add({ name: "John" }) 98 | expect(entity.name).toEqual("John") 99 | 100 | const hasAge = world.with("age") 101 | expect(hasAge.entities).toEqual([]) 102 | 103 | entity.age = 45 104 | world.update(entity) 105 | expect(hasAge.entities).toEqual([entity]) 106 | }) 107 | }) 108 | 109 | describe("id", () => { 110 | it("returns undefined for entities not in the world", () => { 111 | const world = new World() 112 | const entity = { name: "John" } 113 | expect(world.id(entity)).toBeUndefined() 114 | }) 115 | 116 | it("returns a unique ID for each entity", () => { 117 | const world = new World() 118 | const entity1 = world.add({ name: "John" }) 119 | const entity2 = world.add({ name: "Jane" }) 120 | expect(world.id(entity1)).not.toBeUndefined() 121 | expect(world.id(entity2)).not.toBeUndefined() 122 | expect(world.id(entity1)).not.toEqual(world.id(entity2)) 123 | }) 124 | }) 125 | 126 | describe("entity", () => { 127 | it("returns undefined for IDs not in the world", () => { 128 | const world = new World() 129 | expect(world.entity(0)).toBeUndefined() 130 | }) 131 | 132 | it("returns the entity for a given ID", () => { 133 | const world = new World() 134 | const entity = world.add({ name: "John" }) 135 | const id = world.id(entity)! 136 | expect(world.entity(id)).toBe(entity) 137 | }) 138 | }) 139 | 140 | describe("produceQuery", () => { 141 | it("returns a query for the given configuration", () => { 142 | const world = new World() 143 | 144 | const query = world.query({ 145 | with: ["age"], 146 | without: ["height"], 147 | predicates: [] 148 | }) 149 | 150 | expect(query).toBeInstanceOf(Query) 151 | expect(query.config).toEqual({ 152 | with: ["age"], 153 | without: ["height"], 154 | predicates: [] 155 | }) 156 | }) 157 | 158 | it("normalizes the incoming query configuration", () => { 159 | const world = new World() 160 | 161 | const query = world.query({ 162 | with: ["age", "age"], 163 | without: ["height", "height", "dead"], 164 | predicates: [] 165 | }) 166 | 167 | expect(query.config).toEqual({ 168 | with: ["age"], 169 | without: ["dead", "height"], 170 | predicates: [] 171 | }) 172 | }) 173 | 174 | it("reuses existing connected queries if they have the same configuration", () => { 175 | const world = new World() 176 | 177 | const query1 = world.query({ 178 | with: ["age"], 179 | without: ["height"], 180 | predicates: [] 181 | }) 182 | 183 | const query2 = world.query({ 184 | with: ["age"], 185 | without: ["height"], 186 | predicates: [] 187 | }) 188 | 189 | expect(query1).toBe(query2) 190 | }) 191 | }) 192 | 193 | describe("with and without", () => { 194 | it("returns a new query instance", () => { 195 | const world = new World() 196 | const withAge = world.with("age") 197 | expect(withAge).toBeInstanceOf(Query) 198 | }) 199 | 200 | it("reuses existing query instances", () => { 201 | const world = new World() 202 | const withAge = world.with("age") 203 | 204 | const withAge2 = world.with("age") 205 | expect(withAge).toBe(withAge2) 206 | }) 207 | 208 | it("can be chained", () => { 209 | const world = new World() 210 | const withAge = world.with("age") 211 | const withoutHeight = withAge.without("height") 212 | 213 | expect(withAge).toBeInstanceOf(Query) 214 | expect(withoutHeight).toBeInstanceOf(Query) 215 | 216 | for (const _ of withoutHeight) { 217 | /* no-op */ 218 | } 219 | 220 | expect(withAge.isConnected).toBe(false) 221 | expect(withoutHeight.isConnected).toBe(true) 222 | }) 223 | 224 | it("when chained, also reuses matching query instances", () => { 225 | const world = new World() 226 | const queryA = world.with("age").without("height") 227 | const queryB = world.without("height").with("age") 228 | 229 | expect(queryA).toBe(queryB) 230 | }) 231 | 232 | it("does not automatically connect the new query instance", () => { 233 | const world = new World() 234 | const withAge = world.with("age") 235 | expect(withAge.isConnected).toBe(false) 236 | 237 | withAge.connect() 238 | expect(withAge.isConnected).toBe(true) 239 | }) 240 | 241 | it("automatically connects the query the first time its entities are accessed", () => { 242 | const world = new World() 243 | const withAge = world.with("age") 244 | expect(withAge.isConnected).toBe(false) 245 | 246 | withAge.entities 247 | expect(withAge.isConnected).toBe(true) 248 | }) 249 | 250 | it("automatically connects the query the first time it is iterated over", () => { 251 | const world = new World() 252 | world.add({ name: "John", age: 30 }) 253 | 254 | const withAge = world.with("age") 255 | expect(withAge.isConnected).toBe(false) 256 | 257 | for (const entity of withAge) { 258 | expect(entity).toBeDefined() 259 | } 260 | 261 | expect(withAge.isConnected).toBe(true) 262 | }) 263 | 264 | it("automatically connects the query when something subscribes to onEntityAdded", () => { 265 | const world = new World() 266 | const withAge = world.with("age") 267 | expect(withAge.isConnected).toBe(false) 268 | 269 | withAge.onEntityAdded.subscribe(() => {}) 270 | expect(withAge.isConnected).toBe(true) 271 | }) 272 | 273 | it("automatically connects the query when something subscribes to onEntityRemoves", () => { 274 | const world = new World() 275 | const withAge = world.with("age") 276 | expect(withAge.isConnected).toBe(false) 277 | 278 | withAge.onEntityRemoved.subscribe(() => {}) 279 | expect(withAge.isConnected).toBe(true) 280 | }) 281 | }) 282 | 283 | describe("where", () => { 284 | it("returns a query that applies the given predicate", () => { 285 | const world = new World() 286 | 287 | const john = world.add({ name: "John", age: 40 }) 288 | const jane = world.add({ name: "Jane", age: 25 }) 289 | const paul = world.add({ name: "Paul", age: 32 }) 290 | 291 | const over30 = world.with("age").where(({ age }) => age > 30) 292 | expect(over30.entities).toEqual([paul, john]) 293 | 294 | const startsWithJ = over30.where(({ name }) => name.startsWith("J")) 295 | expect(startsWithJ.entities).toEqual([john]) 296 | }) 297 | 298 | it("requires the use of reindex in order to react to changes", () => { 299 | const world = new World() 300 | 301 | const john = world.add({ name: "John", age: 40 }) 302 | const paul = world.add({ name: "Paul", age: 29 }) 303 | 304 | const over30 = world.with("age").where(({ age }) => age >= 30) 305 | expect(over30.entities).toEqual([john]) 306 | 307 | /* Mutate an entity to make it match the predicate */ 308 | paul.age++ 309 | 310 | /* The query is not updated automatically */ 311 | expect(over30.entities).toEqual([john]) 312 | 313 | /* Reindex the entity */ 314 | world.reindex(paul) 315 | 316 | /* The query is updated */ 317 | expect(over30.entities).toEqual([john, paul]) 318 | }) 319 | 320 | it("supports type narrowing", () => { 321 | type NamedPerson = { name: string } 322 | 323 | function isNamedPerson(entity: Entity): entity is NamedPerson { 324 | return "name" in entity 325 | } 326 | 327 | const world = new World() 328 | const named = world.where(isNamedPerson) 329 | }) 330 | 331 | it("returns the same query for the same predicate", () => { 332 | const world = new World() 333 | const john = world.add({ name: "John", age: 40 }) 334 | const jane = world.add({ name: "Jane", age: 25 }) 335 | const paul = world.add({ name: "Paul", age: 32 }) 336 | 337 | const isOver30 = ({ age }: { age: number }) => age > 30 338 | 339 | const over30s = world.with("age").where(isOver30) 340 | 341 | expect(over30s).toBe(world.with("age").where(isOver30)) 342 | expect(over30s.entities).toEqual([paul, john]) 343 | 344 | const isOver35 = ({ age }: { age: number }) => age > 35 345 | 346 | const over35s = over30s.where(isOver35) 347 | 348 | expect(over35s).not.toBe(over30s) 349 | expect(over35s.entities).toEqual([john]) 350 | }) 351 | }) 352 | 353 | describe("addComponent", () => { 354 | it("adds a component to an entity", () => { 355 | const world = new World() 356 | const entity = world.add({ name: "John" }) 357 | world.addComponent(entity, "age", 30) 358 | expect(entity).toEqual({ name: "John", age: 30 }) 359 | }) 360 | 361 | it("adds the entity to any relevant queries", () => { 362 | const world = new World() 363 | const query = world.with("age").without("height") 364 | const entity = world.add({ name: "John" }) 365 | world.addComponent(entity, "age", 30) 366 | expect(query.entities).toEqual([entity]) 367 | }) 368 | }) 369 | 370 | describe("removeComponent", () => { 371 | it("removes a component from an entity", () => { 372 | const world = new World() 373 | const entity = world.add({ name: "John", age: 30 }) 374 | world.removeComponent(entity, "age") 375 | expect(entity).toEqual({ name: "John" }) 376 | }) 377 | 378 | it("removes the entity from any relevant archetypes", () => { 379 | const world = new World() 380 | const query = world.with("age").without("height") 381 | const entity = world.add({ name: "John", age: 30 }) 382 | expect(query.entities).toEqual([entity]) 383 | 384 | world.removeComponent(entity, "age") 385 | expect(query.entities).toEqual([]) 386 | }) 387 | 388 | it("uses a future check, so in onEntityRemoved, the entity is still intact", () => { 389 | const world = new World() 390 | const query = world.with("age").without("height") 391 | const entity = world.add({ name: "John", age: 30 }) 392 | expect(query.entities).toEqual([entity]) 393 | 394 | query.onEntityRemoved.subscribe((removedEntity) => { 395 | expect(removedEntity).toEqual(entity) 396 | expect(removedEntity.age).toEqual(30) 397 | }) 398 | 399 | world.removeComponent(entity, "age") 400 | expect(query.entities).toEqual([]) 401 | }) 402 | }) 403 | }) 404 | -------------------------------------------------------------------------------- /packages/react/README.md: -------------------------------------------------------------------------------- 1 | ![GitHub Workflow Status (with event)](https://img.shields.io/github/actions/workflow/status/hmans/miniplex/tests.yml?style=for-the-badge) 2 | [![Downloads](https://img.shields.io/npm/dt/miniplex-react.svg?style=for-the-badge)](https://www.npmjs.com/package/miniplex-react) 3 | [![Bundle Size](https://img.shields.io/bundlephobia/min/miniplex-react?style=for-the-badge&label=bundle%20size)](https://bundlephobia.com/result?p=miniplex-react) 4 | 5 | # miniplex-react 6 | 7 | ### React glue for [miniplex], the gentle game entity manager. 8 | 9 | > **Note** This package contains the React glue for Miniplex. This documentation assumes that you are familiar with how Miniplex works. If you haven't done so already, please read the [Miniplex documentation](https://github.com/hmans/miniplex/tree/main/packages/core#readme) first. 10 | 11 | ## Installation 12 | 13 | Add `miniplex-react` and its peer dependency `miniplex` to your application using your favorite package manager, eg. 14 | 15 | ```sh 16 | npm install miniplex miniplex-react 17 | yarn add miniplex miniplex-react 18 | pnpm add miniplex miniplex-react 19 | ``` 20 | 21 | ## Usage 22 | 23 | This package's default export is a function that returns an object with React bindings for an existing miniplex world. 24 | 25 | It is recommended that you invoke this function from a module in your application that exports the generated object, and then have the rest of your project import that module, similar to how you would provide a global store: 26 | 27 | ```ts 28 | /* state.ts */ 29 | import { World } from "miniplex" 30 | import createReactAPI from "miniplex-react" 31 | 32 | /* Our entity type */ 33 | export type Entity = { 34 | /* ... */ 35 | } 36 | 37 | /* Create a Miniplex world that holds our entities */ 38 | const world = new World() 39 | 40 | /* Create and export React bindings */ 41 | export const ECS = createReactAPI(world) 42 | ``` 43 | 44 | **TypeScript note:** `createReactAPI` will automatically pick up the entity type attached to your world. All the React components and hooks will automatically make use of this type. 45 | 46 | ### The World 47 | 48 | The object returned by `createReactAPI` includes a `world` property containing the actual ECS world. You can interact with it like you would usually do to imperatively create, modify and destroy entities: 49 | 50 | ```ts 51 | const entity = ECS.world.add({ position: { x: 0, y: 0 } }) 52 | ``` 53 | 54 | For more details on how to interact with the ECS world, please refer to the [miniplex] core package's documentation. 55 | 56 | ### Describing Entities and Components 57 | 58 | As a first step, let's add a single entity to your React application. We use `` to declare the entity, and `` to add components to it. 59 | 60 | ```tsx 61 | import { ECS } from "./state" 62 | 63 | const Player = () => ( 64 | 65 | 66 | 67 | 68 | ) 69 | ``` 70 | 71 | This will, once mounted, create a single entity in your ECS world, and add the `position` and `health` components to it. Once unmounted, it will also automatically destroy the entity. 72 | 73 | ### Capturing object refs into components 74 | 75 | If your components are designed to store rich objects, and these can be expressed as React components providing Refs, you can pass a single React child to ``, and its Ref value will automatically be picked up. For example, let's imagine a react-three-fiber based game that allows entities to have a scene object stored on the `three` component: 76 | 77 | ```tsx 78 | import { ECS } from "./state" 79 | 80 | const Player = () => ( 81 | 82 | 83 | 84 | 85 | 86 | 87 | 88 | 89 | 90 | 91 | ) 92 | ``` 93 | 94 | Now the player's `three` component will be set to a reference to the Three.js scene object created by the `` element. 95 | 96 | ### Enhancing existing entities 97 | 98 | `` can also represent _previously created_ entities, which can be used to enhance them with additional components. This is useful if your entities are created somewhere else, but at the time when they are rendered, you still need to enhance them with additional components. For example: 99 | 100 | ```tsx 101 | import { ECS } from "./state" 102 | 103 | const Game = () => { 104 | const [player] = useState(() => 105 | ECS.world.add({ 106 | position: { x: 0, y: 0, z: 0 }, 107 | health: 100 108 | }) 109 | ) 110 | 111 | return ( 112 | <> 113 | {/* All sorts of stuff */} 114 | 115 | {/* More stuff */} 116 | 117 | ) 118 | } 119 | 120 | const RenderPlayer = ({ player }) => ( 121 | 122 | 123 | 124 | 125 | 126 | 127 | 128 | 129 | ) 130 | ``` 131 | 132 | When `` is used to represent and enhance an existing entity, the entity will _not_ be destroyed once the component is unmounted. 133 | 134 | ### Rendering lists of entities using `` 135 | 136 | The `` component will render a list of entities. It takes a `in` prop that can be either a Miniplex query, world, or just an array of entities. It is most commonly used together with a Miniplex query: 137 | 138 | ```tsx 139 | import { ECS } from "./state" 140 | import { AsteroidModel } from "./models" 141 | 142 | const asterois = ECS.world.with("isAsteroid") 143 | 144 | const Asteroids = () => ( 145 | 146 | 147 | 148 | 149 | 150 | ) 151 | ``` 152 | 153 | When used this way, it will automatically re-render every time the list of entities represented by the given query changes. If for some reason you do _not_ want it to re-render in those cases, you can just pass an array of entities instead: 154 | 155 | ```tsx 156 | import { ECS } from "./state" 157 | import { AsteroidModel } from "./models" 158 | 159 | const asterois = ECS.world.with("isAsteroid") 160 | 161 | /* Note the .entities property! */ 162 | const Asteroids = () => ( 163 | 164 | 165 | 166 | 167 | 168 | ) 169 | ``` 170 | 171 | ## Using `useEntities` to react to changes 172 | 173 | This package also provides the `useEntities` hook that will subscribe your React component to changes in a query or world and will automatically re-render it every time entities are added or removed. This can be useful for implementing side effects that need to run for one-off entities: 174 | 175 | ```tsx 176 | const cameraTargets = ECS.world.with("cameraTarget", "object3d") 177 | 178 | const MyCamera = () => { 179 | const camera = useRef() 180 | 181 | /* Grab the first entity that matches the query */ 182 | const [cameraTarget] = useEntities(cameraTargets) 183 | 184 | /* Run a side effect when the camera target changes */ 185 | useEffect(() => { 186 | if (!camera.current) return 187 | if (!cameraTarget) return 188 | 189 | camera.current.lookAt(cameraTarget.object3d.position) 190 | }, [cameraTarget]) 191 | 192 | return 193 | } 194 | ``` 195 | 196 | ### Using Render Props 197 | 198 | `` and `` support the optional use of [children render props](https://reactjs.org/docs/render-props.html), where instead of JSX children, you pass a _function_ that receives each entity as its first and only argument, and is expected to _return_ the JSX that is to be rendered. This is useful if you're rendering a collection of entities and need access to their data, or need some code to run _for each entity_, for example when setting random values like in this example: 199 | 200 | ```tsx 201 | const enemies = ECS.world.with("enemy") 202 | 203 | const EnemyShips = () => ( 204 | 205 | {(entity) => { 206 | const health = Math.random() * 1000 207 | 208 | return ( 209 | 210 | 211 | 212 | 213 | 214 | 215 | 216 | ) 217 | }} 218 | 219 | ) 220 | ``` 221 | 222 | ### Hooking into the current entities 223 | 224 | When you're composing entities from nested components, you may need to get the current entity context the React component is in. You can do this using the `useCurrentEntity` hook: 225 | 226 | ```tsx 227 | const Health = () => { 228 | /* Retrieve the entity represented by the neares `` component */ 229 | const entity = ECS.useCurrentEntity() 230 | 231 | useEffect(() => { 232 | /* Do something with the entity here */ 233 | }) 234 | 235 | return null 236 | } 237 | ``` 238 | 239 | ## Recommended Patterns and Best Practices 240 | 241 | ### Implementing Systems 242 | 243 | Since Miniplex doesn't have any built-in notion of what a system is, their implementation is entirely left up to you. This is by design; while other ECS implementations often force their own system scheduler setup on you, Miniplex neatly snuggles into your existing codebase and lets you use it with whatever scheduling functionality the framework you're using provides. 244 | 245 | In a react-three-fiber application, for example, you would use the `useFrame` hook to execute a system function once per frame: 246 | 247 | ```tsx 248 | import { useEntities } from "miniplex-react" 249 | import { useFrame } from "@react-three/fiber" 250 | import { ECS } from "./state" 251 | 252 | const movingEntities = ECS.world.with("position", "velocity") 253 | 254 | const MovementSystem = () => { 255 | useFrame((_, dt) => { 256 | for (const entity of movingEntities) { 257 | entity.position.x += entity.velocity.x * dt 258 | entity.position.y += entity.velocity.y * dt 259 | entity.position.z += entity.velocity.z * dt 260 | } 261 | }) 262 | 263 | return null 264 | } 265 | ``` 266 | 267 | ### Write imperative code for mutating the world 268 | 269 | While the `` component can be used to spawn (and later destroy) a new entity, you will typically only use this for one-off entities (like the player, or some other entity that only exists once and is expected to be managed by a React component.) 270 | 271 | For everything else, you should write imperative code that mutates the world, and design your React components to _react_ to these changes. Consider the following module, which co-locates both an `` component that renders the currently active enemies, and a `spawnEnemy` function that spawns a new one: 272 | 273 | ```tsx 274 | const enemies = ECS.world.with("enemy") 275 | 276 | export const Enemies = () => ( 277 | 278 | 279 | 280 | 281 | 282 | ) 283 | 284 | export const spawnEnemy = () => 285 | ECS.world.add({ 286 | position: { x: 0, y: 0, z: 0 }, 287 | velocity: { x: 0, y: 0, z: 0 }, 288 | health: 100, 289 | enemy: true 290 | }) 291 | ``` 292 | 293 | In another React component that manages your game's state, you may now use this function to spawn an initial number of enemies: 294 | 295 | ```tsx 296 | import { spawnEnemy } from "./enemies" 297 | 298 | export const GameState = () => { 299 | useEffect(() => { 300 | /* Initialize game state */ 301 | for (let i = 0; i < 10; i++) { 302 | spawnEnemy() 303 | } 304 | 305 | /* When unmounting, reset game state */ 306 | return () => { 307 | ECS.world.clear() 308 | } 309 | }, []) 310 | } 311 | ``` 312 | 313 | ### Using the `children` prop on `` 314 | 315 | You've already seen how the `` component optionally accepts a render prop as its child; this can be used to defer the rendering of an entity to a separate function: 316 | 317 | ```tsx 318 | const enemies = ECS.world.with("enemy") 319 | 320 | export const Enemies = () => 321 | 322 | export const Enemy = (entity) => ( 323 | 324 | 325 | 326 | 327 | 328 | ) 329 | ``` 330 | 331 | This is particularly useful if you want to provide a component that renders out a single entity of a specific type, and then want to re-use it when rendering a complete list of them. The `` component above is functionally equivalent to: 332 | 333 | ```tsx 334 | export const Enemies = () => ( 335 | {(entity) => } 336 | ) 337 | ``` 338 | 339 | Or event this, since React components are just functions: 340 | 341 | ```tsx 342 | export const Enemies = () => {Enemy} 343 | ``` 344 | 345 | ## Questions? 346 | 347 | If you have questions about this package, you're invited to post them in our [Discussions section](https://github.com/hmans/miniplex/discussions) on GitHub. 348 | 349 | [miniplex]: https://github.com/hmans/miniplex 350 | 351 | ## License 352 | 353 | ``` 354 | 355 | Copyright (c) 2023 Hendrik Mans 356 | 357 | Permission is hereby granted, free of charge, to any person obtaining 358 | a copy of this software and associated documentation files (the 359 | "Software"), to deal in the Software without restriction, including 360 | without limitation the rights to use, copy, modify, merge, publish, 361 | distribute, sublicense, and/or sell copies of the Software, and to 362 | permit persons to whom the Software is furnished to do so, subject to 363 | the following conditions: 364 | 365 | The above copyright notice and this permission notice shall be 366 | included in all copies or substantial portions of the Software. 367 | 368 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 369 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 370 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND 371 | NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE 372 | LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION 373 | OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION 374 | WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 375 | 376 | ``` 377 | 378 | ``` 379 | 380 | ``` 381 | -------------------------------------------------------------------------------- /packages/core/src/core.ts: -------------------------------------------------------------------------------- 1 | import { Bucket } from "@miniplex/bucket" 2 | import { id } from "@hmans/id" 3 | export * from "@miniplex/bucket" 4 | 5 | export type Predicate = 6 | | ((v: E) => v is D) 7 | | ((entity: E) => boolean) 8 | 9 | /** 10 | * A utility type that marks the specified properties as required. 11 | */ 12 | export type With = E & Required> 13 | 14 | export type Without = Omit 15 | 16 | /** 17 | * A utility type that removes all optional properties. 18 | */ 19 | export type Strict = WithoutOptional 20 | 21 | /* Utility types */ 22 | 23 | type OptionalKeys = { 24 | [K in keyof T]-?: undefined extends T[K] ? K : never 25 | } 26 | 27 | type WithoutOptional = Pick[keyof T]>> 28 | 29 | /* Query configuration */ 30 | 31 | type QueryConfiguration = { 32 | with: any[] 33 | without: any[] 34 | predicates: Function[] 35 | } 36 | 37 | interface IQueryableBucket { 38 | /** 39 | * Queries for entities that have all of the given components. If this is called on 40 | * an existing query, the query will be extended to include this new criterion. 41 | * 42 | * @param components The components to query for. 43 | */ 44 | with(...components: C[]): Query> 45 | 46 | /** 47 | * Queries for entities that have none of the given components. If this is called on 48 | * an existing query, the query will be extended to include this new criterion. 49 | * 50 | * @param components The components to query for. 51 | */ 52 | without(...components: C[]): Query> 53 | 54 | /** 55 | * Queries for entities that match the given predicate. If this is called on 56 | * an existing query, the query will be extended to include this new criterion. 57 | * 58 | * @param predicate The predicate to query for. 59 | */ 60 | where(predicate: Predicate): Query 61 | } 62 | 63 | export class World 64 | extends Bucket 65 | implements IQueryableBucket 66 | { 67 | constructor(entities: E[] = []) { 68 | super(entities) 69 | 70 | /* When entities are added, reindex them immediately */ 71 | this.onEntityAdded.subscribe((entity) => { 72 | this.reindex(entity) 73 | }) 74 | 75 | /* When entities are removed, remove them from all known queries, and delete 76 | their IDs */ 77 | this.onEntityRemoved.subscribe((entity) => { 78 | this.queries.forEach((query) => query.remove(entity)) 79 | 80 | if (this.entityToId.has(entity)) { 81 | const id = this.entityToId.get(entity)! 82 | this.idToEntity.delete(id) 83 | this.entityToId.delete(entity) 84 | } 85 | }) 86 | } 87 | 88 | update(entity: E): E 89 | 90 | update(entity: E, component: C, value: E[C]): E 91 | 92 | update(entity: E, update: Partial): E 93 | 94 | update(entity: E, fun: (entity: E) => Partial | void): E 95 | 96 | update( 97 | entity: E, 98 | update?: Partial | keyof E | ((entity: E) => Partial | void), 99 | value?: any 100 | ) { 101 | /* Apply the update */ 102 | if (typeof update === "function") { 103 | const partial = update(entity) 104 | partial && Object.assign(entity, partial) 105 | } else if (typeof update === "string") { 106 | entity[update] = value 107 | } else if (update) { 108 | Object.assign(entity, update) 109 | } 110 | 111 | /* If this world knows about the entity, reindex it. */ 112 | this.reindex(entity) 113 | 114 | return entity 115 | } 116 | 117 | /** 118 | * Adds a component to an entity. If the entity already has the component, the 119 | * existing component will not be overwritten. 120 | * 121 | * After the component was added, the entity will be reindexed, causing it to be 122 | * added to or removed from any queries depending on their criteria. 123 | * 124 | * @param entity The entity to modify. 125 | * @param component The name of the component to add. 126 | * @param value The value of the component to add. 127 | */ 128 | addComponent(entity: E, component: C, value: E[C]) { 129 | /* Return early if the entity already has the component. */ 130 | if (entity[component] !== undefined) return 131 | 132 | /* Set the component */ 133 | entity[component] = value 134 | 135 | /* Trigger a reindexing */ 136 | this.reindex(entity) 137 | } 138 | 139 | /** 140 | * Removes a component from an entity. If the entity does not have the component, 141 | * this function does nothing. 142 | * 143 | * After the component was removed, the entity will be reindexed, causing it to be 144 | * added to or removed from any queries depending on their criteria. 145 | * 146 | * @param entity The entity to modify. 147 | * @param component The name of the component to remove. 148 | */ 149 | removeComponent(entity: E, component: keyof E) { 150 | /* Return early if the entity doesn't even have the component. */ 151 | if (entity[component] === undefined) return 152 | 153 | /* If this world knows about the entity, notify any derived buckets about the change. */ 154 | if (this.has(entity)) { 155 | const future = { ...entity } 156 | delete future[component] 157 | 158 | this.reindex(entity, future) 159 | } 160 | 161 | /* Remove the component. */ 162 | delete entity[component] 163 | } 164 | 165 | /* QUERIES */ 166 | 167 | protected queries = new Set>() 168 | 169 | /** 170 | * Creates (or reuses) a query that matches the given configuration. 171 | * 172 | * @param config The query configuration. 173 | * @returns A query that matches the given configuration. 174 | */ 175 | query(config: QueryConfiguration): Query { 176 | const normalizedConfig = normalizeQueryConfiguration(config) 177 | const key = configKey(normalizedConfig) 178 | 179 | /* Use existing query if we can find one */ 180 | for (const query of this.queries) { 181 | if (query.key === key) { 182 | return query as Query 183 | } 184 | } 185 | 186 | /* Otherwise, create new query */ 187 | const query = new Query(this, normalizedConfig) 188 | this.queries.add(query) 189 | return query 190 | } 191 | 192 | /** 193 | * Creates (or reuses) a query that holds entities that have all of the specified 194 | * components. 195 | * 196 | * @param components One or more component names to query for. 197 | * @returns A query that holds entities that have all of the given components. 198 | */ 199 | with(...components: C[]) { 200 | return this.query>({ 201 | with: components, 202 | without: [], 203 | predicates: [] 204 | }) 205 | } 206 | 207 | /** 208 | * Creates (or reuses) a query that holds entities that do not have any of the 209 | * specified components. 210 | * 211 | * @param components One or more component names to query for. 212 | * @returns A query that holds entities that do not have any of the given components. 213 | */ 214 | without(...components: C[]) { 215 | return this.query>({ 216 | with: [], 217 | without: components, 218 | predicates: [] 219 | }) 220 | } 221 | 222 | /** 223 | * Creates (or reuses) a query that holds entities that match the given predicate. 224 | * Please note that as soon as you are building queries that use predicates, you 225 | * will need to explicitly reindex entities when their properties change. 226 | * 227 | * @param predicate The predicate that entities must match. 228 | * @returns A query that holds entities that match the given predicate. 229 | */ 230 | where(predicate: Predicate) { 231 | return this.query({ 232 | with: [], 233 | without: [], 234 | predicates: [predicate] 235 | }) 236 | } 237 | 238 | /** 239 | * Reindexes the specified entity. This will iteratere over all registered queries 240 | * and ask them to reevaluate the entity. 241 | * 242 | * If the `future` parameter is specified, 243 | * it will be used in the evaluation instead of the entity itself. This is useful 244 | * if you are about to perform a destructive change on the entity (like removing 245 | * a component), but want emitted events to still have access to the unmodified entity 246 | * before the change. 247 | * 248 | * @param entity The entity to reindex. 249 | * @param future The entity that the entity will become in the future. 250 | */ 251 | reindex(entity: E, future = entity) { 252 | /* Return early if this world doesn't know about the entity. */ 253 | if (!this.has(entity)) return 254 | 255 | /* Notify all queries about the change. */ 256 | for (const query of this.queries) { 257 | query.evaluate(entity, future) 258 | } 259 | } 260 | 261 | /* IDs */ 262 | 263 | private entityToId = new Map() 264 | private idToEntity = new Map() 265 | private nextId = 0 266 | 267 | /** 268 | * Generate and return a numerical identifier for the given entity. The ID can later 269 | * be used to retrieve the entity again through the `entity(id)` method. 270 | * 271 | * @param entity The entity to get the ID for. 272 | * @returns An ID for the entity, or undefined if the entity is not in the world. 273 | */ 274 | id(entity: E) { 275 | /* We only ever want to generate IDs for entities that are actually in the world. */ 276 | if (!this.has(entity)) return undefined 277 | 278 | /* Lazily generate an ID. */ 279 | if (!this.entityToId.has(entity)) { 280 | const id = this.nextId++ 281 | this.entityToId.set(entity, id) 282 | this.idToEntity.set(id, entity) 283 | } 284 | 285 | return this.entityToId.get(entity)! 286 | } 287 | 288 | /** 289 | * Given an entity ID that was previously generated through the `id(entity)` function, 290 | * returns the entity matching that ID, or undefined if no such entity exists. 291 | * 292 | * @param id The ID of the entity to retrieve. 293 | * @returns The entity with the given ID, or undefined if no such entity exists. 294 | */ 295 | entity(id: number) { 296 | return this.idToEntity.get(id) 297 | } 298 | } 299 | 300 | export class Query extends Bucket implements IQueryableBucket { 301 | protected _isConnected = false 302 | 303 | /** 304 | * True if this query is connected to the world, and will automatically 305 | * re-evaluate when entities are added or removed. 306 | */ 307 | get isConnected() { 308 | return this._isConnected 309 | } 310 | 311 | /** 312 | * A unique, string-based key for this query, based on its configuration. 313 | */ 314 | public readonly key: string 315 | 316 | constructor(public world: World, public config: QueryConfiguration) { 317 | super() 318 | 319 | this.key = configKey(config) 320 | 321 | /* Automatically connect this query if event listeners are added to our 322 | onEntityAdded or onEntityRemoved events. */ 323 | this.onEntityAdded.onSubscribe.subscribe(() => this.connect()) 324 | this.onEntityRemoved.onSubscribe.subscribe(() => this.connect()) 325 | } 326 | 327 | /** 328 | * An array containing all entities that match this query. For iteration, it 329 | * is recommended to use the `for (const entity of query) {}` syntax instead. 330 | */ 331 | get entities() { 332 | if (!this._isConnected) this.connect() 333 | return super.entities 334 | } 335 | 336 | [Symbol.iterator]() { 337 | if (!this._isConnected) this.connect() 338 | return super[Symbol.iterator]() 339 | } 340 | 341 | /** 342 | * Connects this query to the world. While connected, it will automatically 343 | * re-evaluate when entities are added or removed, and store those that match 344 | * its query configuration. 345 | * 346 | * @returns The query object. 347 | */ 348 | connect() { 349 | if (!this._isConnected) { 350 | this._isConnected = true 351 | 352 | /* Evaluate all entities in the world */ 353 | for (const entity of this.world) { 354 | this.evaluate(entity) 355 | } 356 | } 357 | 358 | return this 359 | } 360 | 361 | /** 362 | * Disconnects this query from the world. This essentially stops the query from 363 | * automatically receiving entities. 364 | */ 365 | disconnect() { 366 | this._isConnected = false 367 | return this 368 | } 369 | 370 | /** 371 | * Returns a new query that extends this query and also matches entities that 372 | * have all of the components specified. 373 | * 374 | * @param components The components that entities must have. 375 | * @returns A new query representing the extended query configuration. 376 | */ 377 | with(...components: C[]) { 378 | return this.world.query>({ 379 | ...this.config, 380 | with: [...this.config.with, ...components] 381 | }) 382 | } 383 | 384 | /** 385 | * Returns a new query that extends this query and also matches entities that 386 | * have none of the components specified. 387 | * 388 | * @param components The components that entities must not have. 389 | * @returns A new query representing the extended query configuration. 390 | */ 391 | without(...components: C[]) { 392 | return this.world.query>({ 393 | ...this.config, 394 | without: [...this.config.without, ...components] 395 | }) 396 | } 397 | 398 | /** 399 | * Returns a new query that extends this query and also matches entities that 400 | * match the given predicate. 401 | * 402 | * @param predicate The predicate that entities must match. 403 | * @returns A new query representing the extended query configuration. 404 | */ 405 | where(predicate: Predicate) { 406 | return this.world.query({ 407 | ...this.config, 408 | predicates: [...this.config.predicates, predicate] 409 | }) 410 | } 411 | 412 | /** 413 | * Checks the given entity against this query's configuration, and returns 414 | * true if the entity matches the query, false otherwise. 415 | * 416 | * @param entity The entity to check. 417 | * @returns True if the entity matches this query, false otherwise. 418 | */ 419 | want(entity: E) { 420 | return ( 421 | this.config.with.every( 422 | (component) => entity[component as keyof typeof entity] !== undefined 423 | ) && 424 | this.config.without.every( 425 | (component) => entity[component as keyof typeof entity] === undefined 426 | ) && 427 | this.config.predicates.every((predicate) => predicate(entity)) 428 | ) 429 | } 430 | 431 | /** 432 | * Evaluate the given entity against this query's configuration, and add or 433 | * remove it from the query if necessary. 434 | * 435 | * If `future` is specified, the entity will be evaluated against that entity 436 | * instead. This is useful for checking if an entity will match the query 437 | * after some potentially destructive change has been made to it, before 438 | * actually applying that change to the entity itself. 439 | * 440 | * @param entity The entity to evaluate. 441 | * @param future The entity to evaluate against. If not specified, the entity will be evaluated against itself. 442 | */ 443 | evaluate(entity: any, future = entity) { 444 | if (!this.isConnected) return 445 | 446 | const wanted = this.want(future) 447 | const has = this.has(entity) 448 | 449 | if (wanted && !has) { 450 | this.add(entity) 451 | } else if (!wanted && has) { 452 | this.remove(entity) 453 | } 454 | } 455 | } 456 | 457 | const normalizeComponents = (components: any[]) => [ 458 | ...new Set(components.sort().filter((c) => !!c && c !== "")) 459 | ] 460 | 461 | function normalizePredicates(predicates: Function[]) { 462 | return [...new Set(predicates)] 463 | } 464 | 465 | function normalizeQueryConfiguration(config: QueryConfiguration) { 466 | return { 467 | with: normalizeComponents(config.with), 468 | without: normalizeComponents(config.without), 469 | predicates: normalizePredicates(config.predicates) 470 | } 471 | } 472 | 473 | function configKey(config: QueryConfiguration) { 474 | return `${config.with.join(",")}:${config.without.join( 475 | "," 476 | )}:${config.predicates.map((p) => id(p)).join(",")}` 477 | } 478 | -------------------------------------------------------------------------------- /packages/core/CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # miniplex 2 | 3 | ## 2.0.0 4 | 5 | ### Major Changes 6 | 7 | - 8a7a315: Miniplex 2.0 is a complete rewrite of the library, with a heavy focus on further improving the developer experience, and squashing some significant bugs. 8 | 9 | While it does bring some breaking changes, it will still allow you to do everything that you've been doing with 1.0; when upgrading a 1.0 project to 2.0, most changes you will need to do are related to things having been renamed. 10 | 11 | The headline changes in 2.0: 12 | 13 | - **A lot more relaxed and lightweight!** Where Miniplex 1.0 would immediately crash your entire application when, for example, adding a component to an entity that already has the component, Miniplex 2.0 will simply no-op and continue. 14 | - **Much more flexible!** Miniplex 2.0 simplifies and extends the query API (formerly often referred to as "archetypes"); you can now create queries through `world.with("componentName")`, chain these together, use the new `without`, or even create advanced predicate-based queries using `where`. 15 | - **Improved type support!** If you're using TypeScript, you will be happy to hear that type support has been significantly improved, with much better type narrowing for queries. 16 | - The **React API** has been significantly simplified, and some pretty big bugs have been squashed. (Turns out Miniplex 1.0's React API really didn't like React's ``` much. Whoops!) 17 | 18 | Some more details on the changes: 19 | 20 | - `world.createEntity` has been renamed and simplified to just `world.add` (which now returns the correct type for the entity, too), and `world.destroyEntity` to `world.remove`. 21 | 22 | ```js 23 | const entity = world.add({ position: { x: 0, y: 0 } }) 24 | world.addComponent(entity, "velocity", { x: 0, y: 0 }) 25 | world.remove(entity) 26 | ``` 27 | 28 | - The `Tag` type and constant have been removed. For tag-like components, simply use `true` (which `Tag` was just an alias for.) 29 | - Entities added to a world no longer receive a `__miniplex` component. This component has always been an internal implementation detail, but you might have used it in the past to get a unique identifier for an entity. This can now be done through `world.id(entity)`, with ID lookups being available through `world.entity(id)`. 30 | - Queries can now be iterated over directly. Example: 31 | 32 | ```js 33 | const moving = world.with("position", "velocity") 34 | 35 | for (const { position, velocity } of moving) { 36 | position.x += velocity.x 37 | position.y += velocity.y 38 | } 39 | ``` 40 | 41 | This is, in fact, now the recommended way to iterate over entities, since it is extremely efficient, _and_ will make sure that the list of entities is being iterated over _in reverse_, which makes it safe to modify it during iteration. 42 | 43 | You can also use this to neatly fetch the first entity from an archetype that you only expect to have a single entity in it: 44 | 45 | ```js 46 | const [player] = world.with("player") 47 | ``` 48 | 49 | - **The queuing functionality that was built into the `World` class has been removed.** If you've relied on this in the past, `miniplex` now exports a `queue` object that you can use instead. Example: 50 | 51 | ```js 52 | import { queue } from "miniplex" 53 | 54 | queue(() => { 55 | // Do something 56 | }) 57 | 58 | /* Later */ 59 | queue.flush() 60 | ``` 61 | 62 | Please note that this is being provided to make upgrading to 2.0 a little easier, and will likely be removed in a future version. 63 | 64 | - `with` and `without` are the new API for building queries. Examples: 65 | 66 | ```js 67 | const moving = world.with("position", "velocity") 68 | const alive = world.without("dead") 69 | ``` 70 | 71 | - The new `world.where` now allows you to build predicate-based queries! You can use this as an escape hatch for creating any kind of query based on any conditions you specify. Example: 72 | 73 | ```js 74 | const almostDead = world.where((entity) => entity.health < 10) 75 | ``` 76 | 77 | Please note that his requires entities with the `health` component to be reindexed using the `world.reindex` function in order to keep the archetype up to date. Please refer to the documentation for more details. 78 | 79 | - You can use `where` to create a predicate-based iterator. This allows you to quickly filter a set of entities without creating new archetypes or other objects. Example: 80 | 81 | ```js 82 | for (const entity of world.where((entity) => entity.health < 10)) { 83 | // Do something 84 | } 85 | ``` 86 | 87 | - **All of these can be chained!** 88 | 89 | ```js 90 | world 91 | .with("position", "velocity") 92 | .without("dead") 93 | .where((entity) => entity.health < 10) 94 | ``` 95 | 96 | - Entities fetched from a query will have much improved types, but you can also specify a type to narrow to via these functions' generics: 97 | 98 | ```ts 99 | const player = world.with("player") 100 | ``` 101 | 102 | - Miniplex provides the new `Strict` and `With` types which you can use to compose types from your entity main type: 103 | 104 | ```ts 105 | type Entity = { 106 | position: { x: number; y: number } 107 | velocity: { x: number; y: number } 108 | } 109 | 110 | type Player = Strict> 111 | 112 | const player = world.archetype("player") 113 | ``` 114 | 115 | - The event library Miniplex uses has been changed to [Eventery](https://github.com/hmans/eventery), which brings a change in API. Where before you would have done `onEntityAdded.add(listener)`, you will now to `onEntityAdded.subscribe(listener)`. 116 | 117 | ### Patch Changes 118 | 119 | - Updated dependencies [8a7a315] 120 | - @miniplex/bucket@2.0.0 121 | 122 | ## 1.0.0 123 | 124 | ### Major Changes 125 | 126 | - 769dba7: **Major Breaking Change:** The signature of `addComponent` has been simplified to accept an entity, a component name, and the value of the component: 127 | 128 | ```ts 129 | /* Before */ 130 | world.addComponent(entity, { position: { x: 0, y: 0 } }) 131 | 132 | /* After */ 133 | world.addComponent(entity, "position", { x: 0, y: 0 }) 134 | ``` 135 | 136 | The previous API for `addComponent` is now available as `extendEntity`, with the caveat that it now only accepts two arguments, the entity and the component object: 137 | 138 | ```ts 139 | world.extendEntity(entity, { 140 | position: { x: 0, y: 0 }, 141 | velocity: { x: 10, y: 20 } 142 | }) 143 | ``` 144 | 145 | - b8b2c9b: **Major Breaking Change:** The API signature of `createEntity` has been simplified in order to improve clarity of the API and reduce complexity in both implementation and types. `createEntity` now only supports a single argument, which _must_ satisfy the world's entity type. 146 | 147 | This will only affect you if you have been using `createEntity` with more than one argument in order to compose entities from partial entities, like so: 148 | 149 | ```js 150 | const entity = createEntity(position(0, 0), velocity(1, 1), health(100)) 151 | ``` 152 | 153 | This always had the issue of `createEntity` not checking the initial state of the entity against the world's entity type. Theoretically, the library could invest some additional effort into complex type assembly to ensure that the entity is valid, but there are enough object composition tools available already, so it felt like an unneccessary duplication. 154 | 155 | Instead, composition is now deferred into userland, where one of the most simple tools is the spread operator: 156 | 157 | ```js 158 | const entity = createEntity({ 159 | ...position(0, 0), 160 | ...velocity(1, 1), 161 | ...health(100) 162 | }) 163 | ``` 164 | 165 | - 54c59c8: **Breaking Change:** The `Archetype.first` getter has been removed in the interest of reducing API surface where things can also be expressed using common JavaScript constructs: 166 | 167 | ```jsx 168 | /* Before: */ 169 | const player = world.archetype("player").first 170 | 171 | /* Now: */ 172 | const [player] = world.archetype("player") 173 | ``` 174 | 175 | - cb6d078: **Breaking Change:** When destroying entities, they are now removed from the world's global list of entities as well as the archetypes' lists of entities using the shuffle-and-pop pattern. This has the following side-effects that _may_ impact your code: 176 | 177 | - Entities are no longer guaranteed to stay in the same order. 178 | - The entity ID storied in its internal `__miniplex` component no longer corresponds to its index in the `entities` array. 179 | 180 | This change provides significantly improved performance in situations where a large number of entities are continuously being created and destroyed. 181 | 182 | - 4d9e51b: **Breaking Change:** Removed the `EntityID` and `ComponentData` types. 183 | - c08f39a: **Breaking Change:** The `ComponentName` type has been removed in favor of just using `keyof T`. 184 | 185 | ### Patch Changes 186 | 187 | - 410e0f6: **New:** The `World` class can now be instantiated with an initial list of entities like so: 188 | 189 | ```js 190 | const world = new World({ entities: [entity1, entity2] }) 191 | ``` 192 | 193 | - c12dfc1: **Fixed:** `createEntity` was not checking against the world's entity type; this has been fixed. 194 | 195 | ## 1.0.0-next.6 196 | 197 | ### Patch Changes 198 | 199 | - efa21f2: Typing tweaks. 200 | 201 | ## 1.0.0-next.5 202 | 203 | ### Patch Changes 204 | 205 | - c12dfc1: **Fixed:** Improved typing of `createEntity`. 206 | 207 | ## 1.0.0-next.1 208 | 209 | ### Major Changes 210 | 211 | - 4016fb2: 1.0! 212 | 213 | ### Patch Changes 214 | 215 | - 410e0f6: The `World` class can now be instantiated with an initial list of entities like so: 216 | 217 | ```js 218 | const world = new World({ entities: [entity1, entity2] }) 219 | ``` 220 | 221 | ## 0.11.0-next.0 222 | 223 | ### Minor Changes 224 | 225 | - 769dba7: **Major Breaking Change:** The signature of `addComponent` has been simplified to accept an entity, a component name, and the value of the component: 226 | 227 | ```ts 228 | /* Before */ 229 | world.addComponent(entity, { position: { x: 0, y: 0 } }) 230 | 231 | /* After */ 232 | world.addComponent(entity, "position", { x: 0, y: 0 }) 233 | ``` 234 | 235 | The previous API for `addComponent` is now available as `extendEntity`, but _marked as deprecated_. 236 | 237 | - b8b2c9b: **Breaking Change:** The API signature of `createEntity` has been simplified in order to improve clarity of the API and reduce complexity in both implementation and types. `createEntity` now only supports a single argument, which _must_ satisfy the world's entity type. 238 | 239 | This will only affect you if you have been using `createEntity` with more than one argument in order to compose entities from partial entities, like so: 240 | 241 | ```js 242 | const entity = createEntity(position(0, 0), velocity(1, 1), health(100)) 243 | ``` 244 | 245 | This always had the issue of `createEntity` not checking the initial state of the entity against the world's entity type. Theoretically, the library could invest some additional effort into complex type assembly to ensure that the entity is valid, but there are enough object composition tools available already, so it felt like an unneccessary duplication. 246 | 247 | Instead, composition is now deferred into userland, where one of the most simple tools is the spread operator: 248 | 249 | ```js 250 | const entity = createEntity({ 251 | ...position(0, 0), 252 | ...velocity(1, 1), 253 | ...health(100) 254 | }) 255 | ``` 256 | 257 | - cb6d078: **Breaking Change:** When destroying entities, they are now removed from the world's global list of entities as well as the archetypes' lists of entities using the shuffle-and-pop pattern. This has the following side-effects that _may_ impact your code: 258 | 259 | - Entities are no longer guaranteed to stay in the same order. 260 | - The entity ID storied in its internal `__miniplex` component no longer corresponds to its index in the `entities` array. 261 | 262 | This change provides significantly improved performance in situations where a large number of entities are continuously being created and destroyed. 263 | 264 | - 4d9e51b: **Breaking Change:** Removed the `EntityID` and `ComponentData` types. 265 | 266 | ## 0.10.5 267 | 268 | ### Patch Changes 269 | 270 | - 5ef5f95: Included RegisteredEntity in ArchetypeEntity. (@benwest) 271 | - c680bdd: Narrowed return type for createEntity (with one argument). (@benwest) 272 | 273 | ## 0.10.4 274 | 275 | ### Patch Changes 276 | 277 | - 74e34c7: **Fixed:** Fixed an issue with the new iterator syntax on archetypes. (@benwest) 278 | 279 | ## 0.10.3 280 | 281 | ### Patch Changes 282 | 283 | - 1cee12c: Typing improvements, thanks to @benwest. 284 | - 65d2b77: **Added:** Archtypes now implement a `[Symbol.iterator]`, meaning they can be iterated over directly: 285 | 286 | ```js 287 | const withVelocity = world.archetype("velocity") 288 | 289 | for (const { velocity } of withVelocity) { 290 | /* ... */ 291 | } 292 | ``` 293 | 294 | (Thanks @benwest.) 295 | 296 | ## 0.10.2 297 | 298 | ### Patch Changes 299 | 300 | - 821a45c: **Fixed:** When the world is cleared, archetypes now also get their entities lists cleared. 301 | 302 | ## 0.10.1 303 | 304 | ### Patch Changes 305 | 306 | - cca39cd: **New:** Archetypes now expose a `first` getter that returns the first of the entities in the archetype (or `null` if it doesn't have any entities.) This streamlines situations where you deal with singleton entities (like a player, camera, and so on.) For example, in `miniplex-react`, you can now do the following: 307 | 308 | ```tsx 309 | export const CameraRigSystem: FC = () => { 310 | const player = ECS.useArchetype("isPlayer").first 311 | const camera = ECS.useArchetype("isCamera").first 312 | 313 | /* Do things with player and camera */ 314 | } 315 | ``` 316 | 317 | ## 0.10.0 318 | 319 | ### Minor Changes 320 | 321 | - cc4032d: **Breaking Change:** `createEntity` will now, like in earlier versions of this library, mutate the first argument that is passed into it (and return it). This allows for patterns where you create the actual entity _object_ before you actually convert it into an entity through _createEntity_. 322 | 323 | ### Patch Changes 324 | 325 | - b93a831: The internal IDs that are being generated for entities have been changed slightly, as they now start at `0` (instead of `1`) and are always equal to the position of the entity within the world's `entities` array. The behavior of `destroyEntity` has also been changed to `null` the destroyed entity's entry in that array, instead of cutting the entity from it. 326 | 327 | This change allows you to confidently and reliably use the entity ID (found in the internal miniplex component, `entity.__miniplex.id`) when integrating with non-miniplex systems, including storing data in TypedArrays (for which miniplex may gain built-in support at some point in the future; this change is also in preparation for that.) 328 | 329 | ## 0.9.2 330 | 331 | ### Patch Changes 332 | 333 | - 92cf103: Safer sanity check in `addComponent` 334 | 335 | ## 0.9.1 336 | 337 | ### Patch Changes 338 | 339 | - 48e785d: Fix sanity check in `removeComponent` 340 | 341 | ## 0.9.0 342 | 343 | ### Minor Changes 344 | 345 | - b4cee80: **Breaking Change:** `createEntity` will now always return a new object, and not return the one passed to it. 346 | - 544f231: **Typescript:** You no longer need to mix in `IEntity` into your own entity types, as part of a wider refactoring of the library's typings. Also, `createWorld` will now return a `RegisteredEntity` type that reflects the presence of the automatically added internal `__miniplex` component, and makes a lot of interactions with the world instance safer than it was previously. 347 | - 544f231: **Breaking Change:** Miniplex will no longer automatically add an `id` component to created entities. If your project has been making use of these automatically generated IDs, you will now need to add them yourself. 348 | 349 | Example: 350 | 351 | ```js 352 | let nextId = 0 353 | 354 | /* Some component factories */ 355 | const id = () => ({ id: nextId++ }) 356 | const name = (name) => ({ name }) 357 | 358 | const world = new World() 359 | const entity = world.createEntity(id(), name("Alice")) 360 | ``` 361 | 362 | **Note:** Keep in mind that Miniplex doesn't care about entity IDs much, since all interactions with the world are done through object references. Your project may not need to add IDs to entities at all; if it does, this can now be done using any schema that your project requires (numerical IDs, UUIDs, ...). 363 | 364 | ### Patch Changes 365 | 366 | - b4cee80: `createEntity` now allows you to pass multiple parameters, each representing a partial entity. This makes the use of component factory functions more convenient. Example: 367 | 368 | ```js 369 | /* Provide a bunch of component factories */ 370 | const position = (x = 0, y = 0) => ({ position: { x, y } }) 371 | const velocity = (x = 0, y = 0) => ({ velocity: { x, y } }) 372 | const health = (initial) => ({ 373 | health: { max: initial, current: initial } 374 | }) 375 | 376 | const world = new World() 377 | 378 | const entity = world.createEntity( 379 | position(0, 0), 380 | velocity(5, 7), 381 | health(1000) 382 | ) 383 | ``` 384 | 385 | **Typescript Note:** The first argument will always be typechecked against your entity type, so if your entity type has required components, you will need to pass a first argument that satisfies these. The remaining arguments are expected to be partials of your entity type. 386 | 387 | - b4cee80: **Breaking Change:** `world.queue.createEntity` no longer returns an entity (which didn't make a whole lot of semantic sense to begin with.) 388 | 389 | ## 0.8.1 390 | 391 | ### Patch Changes 392 | 393 | - 011c384: Change the API signature of `addComponent` to expect a partial entity instead of name and value, to provide a better interface for component factories: 394 | 395 | ```ts 396 | const position = (x: number = 0, y: number = 0) => ({ position: { x, y } }) 397 | const health = (amount: number) => ({ 398 | health: { max: amount, current: amount } 399 | }) 400 | 401 | world.addComponent(entity, { ...position(), ...health(100) }) 402 | ``` 403 | -------------------------------------------------------------------------------- /packages/core/README.md: -------------------------------------------------------------------------------- 1 | ![Miniplex](https://user-images.githubusercontent.com/1061/193760498-fb6b4d42-f48b-48b4-b7c1-b5b5674df55c.jpg) 2 | [![Version](https://img.shields.io/npm/v/miniplex?style=for-the-badge)](https://www.npmjs.com/package/miniplex) 3 | ![GitHub Workflow Status (with event)](https://img.shields.io/github/actions/workflow/status/hmans/miniplex/tests.yml?style=for-the-badge) 4 | [![Downloads](https://img.shields.io/npm/dt/miniplex.svg?style=for-the-badge)](https://www.npmjs.com/package/miniplex) 5 | [![Bundle Size](https://img.shields.io/bundlephobia/min/miniplex?style=for-the-badge&label=bundle%20size)](https://bundlephobia.com/result?p=miniplex) 6 | 7 | # Miniplex - the gentle game entity manager. 8 | 9 | - 🚀 Manages your game entities using the Entity Component System pattern. 10 | - 🍳 Focuses on ease of use and developer experience. 11 | - 💪 Can power your entire project, or just parts of it. 12 | - 🧩 Written in TypeScript, for TypeScript. (But works in plain JavaScript, too!) 13 | - ⚛️ [React bindings available](https://www.npmjs.com/package/miniplex-react). They're great! (But Miniplex works in any environment.) 14 | - 📦 Tiny package size and minimal dependencies. 15 | 16 | ## Testimonials 17 | 18 | From [verekia](https://twitter.com/verekia): 19 | 20 | > **Miniplex has been the backbone of my games for the past year and it has been a delightful experience.** The TypeScript support and React integration are excellent, and the API is very clear and easy to use, even as a first ECS experience. 21 | 22 | From [Brian Breiholz](https://twitter.com/BrianBreiholz/status/1577182839509962752): 23 | 24 | > Tested @hmans' Miniplex library over the weekend and after having previously implemented an ECS for my wip browser game, I have to say **Miniplex feels like the "right" way to do ECS in #r3f**. 25 | 26 | From [VERYBOMB](https://twitter.com/verybomb): 27 | 28 | > Rewrote my game with Miniplex and my **productivity has improved immeasurably** ever since. Everything about it is so intuitive and elegant. 29 | 30 | ## Table of Contents 31 | 32 | - [Example](#example) 33 | - [Overview](#overview) 34 | - [Installation](#installation) 35 | - [Basic Usage](#basic-usage) 36 | - [Advanced Usage](#advanced-usage) 37 | - [Best Practices](#best-practices) 38 | 39 | ## Example 40 | 41 | ```ts 42 | /* Define an entity type */ 43 | type Entity = { 44 | position: { x: number; y: number } 45 | velocity?: { x: number; y: number } 46 | health?: { 47 | current: number 48 | max: number 49 | } 50 | poisoned?: true 51 | } 52 | 53 | /* Create a world with entities of that type */ 54 | const world = new World() 55 | 56 | /* Create an entity */ 57 | const player = world.add({ 58 | position: { x: 0, y: 0 }, 59 | velocity: { x: 0, y: 0 }, 60 | health: { current: 100, max: 100 } 61 | }) 62 | 63 | /* Create another entity */ 64 | const enemy = world.add({ 65 | position: { x: 10, y: 10 }, 66 | velocity: { x: 0, y: 0 }, 67 | health: { current: 100, max: 100 } 68 | }) 69 | 70 | /* Create some queries: */ 71 | const queries = { 72 | moving: world.with("position", "velocity"), 73 | health: world.with("health"), 74 | poisoned: queries.health.with("poisoned") 75 | } 76 | 77 | /* Create functions that perform actions on entities: */ 78 | function damage({ health }: With, amount: number) { 79 | health.current -= amount 80 | } 81 | 82 | function poison(entity: With) { 83 | world.addComponent(entity, "poisoned", true) 84 | } 85 | 86 | /* Create a bunch of systems: */ 87 | function moveSystem() { 88 | for (const { position, velocity } of queries.moving) { 89 | position.x += velocity.x 90 | position.y += velocity.y 91 | } 92 | } 93 | 94 | function poisonSystem() { 95 | for (const { health, poisoned } of queries.poisoned) { 96 | health.current -= 1 97 | } 98 | } 99 | 100 | function healthSystem() { 101 | for (const entity of queries.health) { 102 | if (entity.health.current <= 0) { 103 | world.remove(entity) 104 | } 105 | } 106 | } 107 | 108 | /* React to entities appearing/disappearing in queries: */ 109 | queries.poisoned.onEntityAdded.subscribe((entity) => { 110 | console.log("Poisoned:", entity) 111 | }) 112 | ``` 113 | 114 | ## Overview 115 | 116 | **Miniplex is an entity management system for games and similarly demanding applications.** Instead of creating separate buckets for different types of entities (eg. asteroids, enemies, pickups, the player, etc.), you throw all of them into a single store, describe their properties through components, and then write code that performs updates on entities that have specific component configurations. 117 | 118 | If you're familiar with **Entity Component System** architecture, this will sound familiar to you – and rightfully so, for Miniplex is, first and foremost, a very straight-forward implementation of this pattern! 119 | 120 | If you're hearing about this approach for the first time, maybe it will sound a little counter-intuitive – but once you dive into it, you will understand how it can help you decouple concerns and keep your codebase well-structured and maintainable. A nice forum post that I can't link to because it's gone offline had a nice explanation: 121 | 122 | > An ECS library can essentially be thought of as an API for performing a loop over a homogeneous set of entities, filtering them by some condition, and pulling out a subset of the data associated with each entity. The goal of the library is to provide a usable API for this, and to do it as fast as possible. 123 | 124 | For a more in-depth explanation, please also see Sander Mertens' wonderful [Entity Component System FAQ](https://github.com/SanderMertens/ecs-faq). 125 | 126 | ### Differences from other ECS libraries 127 | 128 | If you've used other Entity Component System libraries before, here's how Miniplex is different from some of them: 129 | 130 | #### Entities are just normal JavaScript objects 131 | 132 | Entities are just **plain JavaScript objects**, and components are just **properties on those objects**. Component data can be **anything** you need, from primitive values to entire class instances, or even [entire reactive stores](https://github.com/hmans/statery). Miniplex puts developer experience first, and the most important way it does this is by making its usage feel as natural as possible in a JavaScript environment. 133 | 134 | Miniplex does not expect you to programmatically declare component types before using them; if you're using TypeScript, you can provide a type describing your entities and Miniplex will provide full edit- and compile-time type hints and safety. (Hint: you can even write some classes and use their instances as entities!) 135 | 136 | #### Miniplex does not have a built-in notion of systems 137 | 138 | Unlike the majority of ECS libraries, Miniplex does not have any built-in notion of systems, and does not perform any of its own scheduling. This is by design; your project will likely already have an opinion on how to schedule code execution, informed by whatever framework you are using; instead of providing its own and potentially conflicting setup, Miniplex will neatly snuggle into the one you already have. 139 | 140 | Systems are extremely straight-forward: just write simple functions that operate on the Miniplex world, and run them in whatever fashion fits best to your project (`setInterval`, `requestAnimationFrame`, `useFrame`, your custom ticker implementation, and so on.) 141 | 142 | #### Archetypal Queries 143 | 144 | Entity queries are performed through **archetypal queries**, with individual queries indexing and holding a subset of your world's entities that have (or don't have) a specific set of components. 145 | 146 | #### Focus on Object Identities over numerical IDs 147 | 148 | Most interactions with Miniplex are using **object identity** to identify entities (instead of numerical IDs). Miniplex provides an optional lightweight mechanism to generate unique IDs for your entities if you need them. In more complex projects that need stable entity IDs, especially when synchronizing entities across the network, the user is encouraged to implement their own ID generation and management. 149 | 150 | ## Installation 151 | 152 | Add the `miniplex` package to your project using your favorite package manager: 153 | 154 | ```bash 155 | npm add miniplex 156 | yarn add miniplex 157 | pnpm add miniplex 158 | ``` 159 | 160 | ## Basic Usage 161 | 162 | Miniplex can be used in any JavaScript or TypeScript project, regardless of which extra frameworks you might be using. This document focuses on how to use Miniplex without a framework, but please also check out the framework-specific documentation available: 163 | 164 | - [miniplex-react](https://github.com/hmans/miniplex/blob/main/packages/react/README.md) 165 | 166 | ### Creating a World 167 | 168 | Miniplex manages entities in **worlds**, which act as containers for entities as well as an API for interacting with them. You can have one big world in your project, or several smaller worlds handling separate sections of your game. 169 | 170 | ```ts 171 | import { World } from "miniplex" 172 | 173 | const world = new World() 174 | ``` 175 | 176 | ### Typing your Entities (optional, but recommended!) 177 | 178 | If you're using TypeScript, you can define a type that describes your entities and provide it to the `World` constructor to get full type support in all interactions with it: 179 | 180 | ```ts 181 | import { World } from "miniplex" 182 | 183 | type Entity = { 184 | position: { x: number; y: number; z: number } 185 | velocity?: { x: number; y: number; z: number } 186 | health?: number 187 | paused?: true 188 | } 189 | 190 | const world = new World() 191 | ``` 192 | 193 | ### Creating Entities 194 | 195 | The main interactions with a Miniplex world are creating and destroying entities, and adding or removing components from these entities. Entities are just plain JavaScript objects that you pass into the world's `add` and `remove` functions, like here: 196 | 197 | ```ts 198 | const entity = world.add({ position: { x: 0, y: 0, z: 0 } }) 199 | ``` 200 | 201 | We've directly added a `position` component to the entity. If you're using TypeScript, the component values here will be type-checked against the type you provided to the `World` constructor. 202 | 203 | > **Note** Adding the entity will make it known to the world and all relevant queries, but it will not change the entity object itself in any way. In Miniplex, entities can _live in multiple worlds at the same time_! This allows you to split complex simulations into entirely separate worlds, each with their own queries, even though they might share some (or all) entities. 204 | 205 | ### Adding Components 206 | 207 | The `World` instance provides `addComponent` and `removeComponent` functions for adding and removing components from entities. Let's add a `velocity` component to the entity. Note that we're passing the entity itself as the first argument: 208 | 209 | ```ts 210 | world.addComponent(entity, "velocity", { x: 10, y: 0, z: 0 }) 211 | ``` 212 | 213 | Now the entity has two components: `position` and `velocity`. 214 | 215 | ### Querying Entities 216 | 217 | Let's write some code that moves entities, which have a `position`, according to their `velocity`. You will typically implement this as something called a **system**, which, in Miniplex, is typically just a normal function that fetches the entities it is interested in, and then performs some operation on them. 218 | 219 | Fetching only the entities that a system is interested in is the most important part in all this, and it is done through something called **queries** that can be thought of as something similar to database indices. 220 | 221 | Since we're going to _move_ entities, we're interested in entities that have both the `position` and `velocity` components, so let's create a query for that: 222 | 223 | ```ts 224 | /* Get all entities with position and velocity */ 225 | const movingEntities = world.with("position", "velocity") 226 | ``` 227 | 228 | > **Note** There is also `without`, which will return all entities that do _not_ have the specified components: 229 | > 230 | > ```ts 231 | > const active = world.without("paused") 232 | > ``` 233 | > 234 | > Queries can also be nested: 235 | > 236 | > ```ts 237 | > const movingEntities = world.with("position", "velocity").without("paused") 238 | > ``` 239 | 240 | ### Implementing Systems 241 | 242 | Now we can implement a system that operates on these entities! Miniplex doesn't have an opinion on how you implement systems – they can be as simple as a function. Here's a system that uses the `movingEntities` query we created in the previous step, iterates over all entities in it, and moves them according to their velocity: 243 | 244 | ```ts 245 | function movementSystem() { 246 | for (const { position, velocity } of movingEntities) { 247 | position.x += velocity.x 248 | position.y += velocity.y 249 | position.z += velocity.z 250 | } 251 | } 252 | ``` 253 | 254 | **Note:** Since entities are just plain JavaScript objects, they can easily be destructured into their components, like we're doing above. 255 | 256 | Now all we need to do is make sure that this system is run on a regular basis. If you're writing a game, the framework you are using will already have a mechanism that allows you to execute code once per frame; just call the `movementSystem` function from there! 257 | 258 | ### Destroying Entities 259 | 260 | At some point we may want to remove an entity from the world (for example, an enemy spaceship that got destroyed by the player). We can do this through the world's `remove` function: 261 | 262 | ```ts 263 | world.remove(entity) 264 | ``` 265 | 266 | This will immediately remove the entity from the Miniplex world and all existing queries. 267 | 268 | > **Note** While this will remove the entity object from the world, it will not destroy or otherwise change the object itself. In fact, you can just add it right back into the world if you want to! 269 | 270 | ## Advanced Usage 271 | 272 | We're about to dive into some advanced usage patterns. Please make sure you're familiar with the basics before continuing. 273 | 274 | ### Reacting to added/removed entities 275 | 276 | Instances of `World` and `Query` provide the built-in `onEntityAdded` and `onEntityRemoved` events that you can subscribe to to be notified about entities appearing or disappearing. 277 | 278 | For example, in order to be notified about any entity being added to the world, you may do this: 279 | 280 | ```ts 281 | world.onEntityAdded.subscribe((entity) => { 282 | console.log("A new entity has been spawned:", entity) 283 | }) 284 | ``` 285 | 286 | This is useful for running system-specific initialization code on entities that appear in specific queries: 287 | 288 | ```ts 289 | const withHealth = world.with("health") 290 | 291 | withHealth.onEntityAdded.subscribe((entity) => { 292 | entity.health.current = entity.health.max 293 | }) 294 | ``` 295 | 296 | ### Predicate Queries using `where` 297 | 298 | Typically, you'll want to build queries the check entities for the _presence_ of specific components; you have been using the `with` and `without` functions for this so far. But there may be the rare case where you want to query by _value_; for this, Miniplex provides the `where` function. It allows you to specify a predicate function that your entity will be checked against: 299 | 300 | ```ts 301 | const damagedEntities = world 302 | .with("health") 303 | .where(({ health }) => health.current < health.max) 304 | 305 | const deadEntities = world.with("health").where(({ health }) => health <= 0) 306 | ``` 307 | 308 | It is _extremely_ important to note that queries that use `where` are in no way reactive; if the values within the entity change in a way that would change the result of your predicate function, Miniplex will _not_ pick this up automatically. 309 | 310 | Instead, once you know that you are using `where` to inspect component _values_, you are required to signal an updated entity by calling the `reindex` function: 311 | 312 | ```ts 313 | function damageEntity(entity: With, amount: number) { 314 | entity.health.current -= amount 315 | world.reindex(entity) 316 | } 317 | ``` 318 | 319 | Depending on the total number of queries you've created, reindexing can be a relatively expensive operation, so it is recommended that you use this functionality with care. Most of the time, it is more efficient to model things using additional components. The above example could, for example, be rewritten like this: 320 | 321 | ```ts 322 | const damagedEntities = world.with("health", "damaged") 323 | 324 | const deadEntities = world.with("health", "dead") 325 | 326 | function damageEntity(entity: With, amount: number) { 327 | entity.health.current -= amount 328 | 329 | if (entity.health.current < entity.health.max) { 330 | world.addComponent(entity, "damaged") 331 | } 332 | 333 | if (entity.health.current <= 0) { 334 | world.addComponent(entity, "dead") 335 | } 336 | } 337 | ``` 338 | 339 | ### ID Generation 340 | 341 | When interacting with Miniplex, entities are typically identified using their _object identities_, which is one of the ways where Miniplex is different from typical ECS implementations, which usually make use of numerical IDs. 342 | 343 | Most Miniplex workloads can be implemented without the use of numerical IDs, but if you ever _do_ need such an identifier for your entities – possibly because you're wiring them up to another non-Miniplex system that expects them – Miniplex worlds provide a lightweight mechanism to generate them: 344 | 345 | ```ts 346 | const entity = world.add({ count: 10 }) 347 | const id = world.id(entity) 348 | ``` 349 | 350 | You can later use this ID to look up the entity in the world: 351 | 352 | ```ts 353 | const entity = world.entity(id) 354 | ``` 355 | 356 | ## Best Practices 357 | 358 | ### Use `addComponent` and `removeComponent` for adding and removing components 359 | 360 | Since entities are just normal objects, you might be tempted to just add new properties to (or delete properties from) them directly. **This is a bad idea** because it will skip the indexing step needed to make sure the entity is listed in the correct queries. Please always go through `addComponent` and `removeComponent`! 361 | 362 | It is perfectly fine to mutate component _values_ directly, though. 363 | 364 | ```ts 365 | /* ✅ This is fine: */ 366 | const entity = world.add({ position: { x: 0, y: 0, z: 0 } }) 367 | entity.position.x = 10 368 | 369 | /* ⛔️ This is not: */ 370 | const entity = world.add({ position: { x: 0, y: 0, z: 0 } }) 371 | entity.velocity = { x: 10, y: 0, z: 0 } 372 | ``` 373 | 374 | ### Iterate over queries using `for...of` 375 | 376 | The world as well as all queries derived from it are _iterable_, meaning you can use them in `for...of` loops. This is the recommended way to iterate over entities in a query, as it is highly performant, and iterates over the entities _in reverse order_, which allows you to safely remove entities from within the loop. 377 | 378 | ```ts 379 | const withHealth = world.with("health") 380 | 381 | /* ✅ Recommended: */ 382 | for (const entity of withHealth) { 383 | if (entity.health <= 0) { 384 | world.remove(entity) 385 | } 386 | } 387 | 388 | /* ⛔️ Avoid: */ 389 | for (const entity of withHealth.entities) { 390 | if (entity.health <= 0) { 391 | world.remove(entity) 392 | } 393 | } 394 | 395 | /* ⛔️ Especially avoid: */ 396 | withHealth.entities.forEach((entity) => { 397 | if (entity.health <= 0) { 398 | world.remove(entity) 399 | } 400 | }) 401 | ``` 402 | 403 | ### Reuse queries where possible 404 | 405 | The functions creating and returning queries (`with`, `without`, `where`) aim to be idempotent and will reuse existing queries for the same set of query attributes. Checking if a query for a specific set of query attributes already exists is a comparatively heavyweight function, though, and you are advised to, wherever possible, reuse previously created queries. 406 | 407 | ```ts 408 | /* ✅ Recommended: */ 409 | const movingEntities = world.with("position", "velocity") 410 | 411 | function movementSystem() { 412 | for (const { position, velocity } of movingEntities) { 413 | position.x += velocity.x 414 | position.y += velocity.y 415 | position.z += velocity.z 416 | } 417 | } 418 | 419 | /* ⛔️ Avoid: */ 420 | function movementSystem(world) { 421 | /* This will work, but now the world needs to check if a query for "position" and "velocity" already exists every time this function is called, which is pure overhead. */ 422 | for (const { position, velocity } of world.with("position", "velocity")) { 423 | position.x += velocity.x 424 | position.y += velocity.y 425 | position.z += velocity.z 426 | } 427 | } 428 | ``` 429 | 430 | ## Questions? 431 | 432 | If you have questions about Miniplex, you're invited to post them in our [Discussions section](https://github.com/hmans/miniplex/discussions) on GitHub. 433 | 434 | ## License 435 | 436 | ``` 437 | Copyright (c) 2023 Hendrik Mans 438 | 439 | Permission is hereby granted, free of charge, to any person obtaining 440 | a copy of this software and associated documentation files (the 441 | "Software"), to deal in the Software without restriction, including 442 | without limitation the rights to use, copy, modify, merge, publish, 443 | distribute, sublicense, and/or sell copies of the Software, and to 444 | permit persons to whom the Software is furnished to do so, subject to 445 | the following conditions: 446 | 447 | The above copyright notice and this permission notice shall be 448 | included in all copies or substantial portions of the Software. 449 | 450 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 451 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 452 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND 453 | NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE 454 | LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION 455 | OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION 456 | WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 457 | ``` 458 | --------------------------------------------------------------------------------