├── .github └── workflows │ └── coverage.yaml ├── .gitignore ├── .nvmrc ├── LICENSE ├── README.md ├── docs ├── .nojekyll ├── assets │ ├── highlight.css │ ├── icons.css │ ├── icons.png │ ├── icons@2x.png │ ├── main.js │ ├── search.js │ ├── style.css │ ├── widgets.png │ └── widgets@2x.png ├── classes │ ├── Types.OpaqueTag.html │ └── World.EntityNotRealError.html ├── enums │ └── Format.FormatKind.html ├── index.html ├── modules.html └── modules │ ├── Cache.html │ ├── Entity.html │ ├── Format.html │ ├── Query.html │ ├── Schema.html │ ├── Signal.html │ ├── SparseMap.html │ ├── Type.html │ ├── Types.html │ └── World.html ├── examples ├── compat │ ├── README.md │ ├── index.html │ ├── src │ │ └── index.ts │ ├── tsconfig.json │ └── vite.config.js ├── graph │ ├── README.md │ ├── index.html │ ├── src │ │ └── index.ts │ ├── tsconfig.json │ └── vite.config.js └── noise │ ├── README.md │ ├── index.html │ ├── src │ └── index.ts │ ├── tsconfig.json │ └── vite.config.js ├── graph.png ├── jest.config.js ├── lib ├── build │ ├── optimize.js │ └── plugins │ │ └── transform-remove-invariant.js ├── src │ ├── archetype.ts │ ├── archetype_graph.ts │ ├── cache.spec.ts │ ├── cache.ts │ ├── component.ts │ ├── component_set.ts │ ├── debug.ts │ ├── encode.ts │ ├── entity.spec.ts │ ├── entity.ts │ ├── format.ts │ ├── index.ts │ ├── query.ts │ ├── schema.ts │ ├── signal.ts │ ├── sparse_map.spec.ts │ ├── sparse_map.ts │ ├── symbols.ts │ ├── type.spec.ts │ ├── type.ts │ ├── types.ts │ ├── world.spec.ts │ └── world.ts └── tsconfig.json ├── package.json ├── perf ├── index.html ├── src │ ├── index.ts │ ├── perf.ts │ └── suite │ │ ├── archetype_insert.perf.ts │ │ ├── index.ts │ │ ├── iter_binary.perf.ts │ │ ├── iter_hybrid.perf.ts │ │ ├── iter_native.perf.ts │ │ └── types.ts ├── tsconfig.json └── vite.config.js ├── pnpm-lock.yaml ├── prettier.config.cjs ├── test ├── add_remove.spec.ts ├── query_counts.spec.ts └── query_dynamic.spec.ts └── tsconfig.json /.github/workflows/coverage.yaml: -------------------------------------------------------------------------------- 1 | name: codecov 2 | 3 | on: [push, pull_request] 4 | 5 | jobs: 6 | build: 7 | runs-on: ubuntu-latest 8 | 9 | strategy: 10 | matrix: 11 | node-version: [14.x, 16.x] 12 | 13 | steps: 14 | - name: checkout 15 | uses: actions/checkout@v2 16 | with: 17 | fetch-depth: 2 18 | 19 | - name: setup node ${{ matrix.node-version }} 20 | uses: actions/setup-node@v1 21 | with: 22 | node-version: ${{ matrix.node-version }} 23 | 24 | - name: install dependencies 25 | run: npm install 26 | 27 | - name: run tests 28 | run: npm test -- --coverage 29 | 30 | - name: upload coverage 31 | uses: codecov/codecov-action@v1 32 | with: 33 | token: ${{ secrets.CODECOV_TOKEN }} 34 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | tsconfig.tsbuildinfo 3 | dist 4 | .pnpm-debug.log 5 | coverage -------------------------------------------------------------------------------- /.nvmrc: -------------------------------------------------------------------------------- 1 | v16.8.0 -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright 2021 Eric McDaniel 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 4 | 5 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 6 | 7 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 8 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # harmony-ecs 2 | 3 | A compatibility and performance-focused Entity-Component-System (ECS) for JavaScript. 4 | 5 |

6 | NPM 7 | node-current 8 | 9 | Codecov 10 | 11 | npm bundle size 12 |

13 | 14 | ## Features 15 | 16 | - Hybrid [SoA and AoS](https://en.wikipedia.org/wiki/AoS_and_SoA) storage 17 | - Complex, scalar, and tag components 18 | - Fast iteration and mutation [[1]](https://github.com/ddmills/js-ecs-benchmarks) [[2]](https://github.com/noctjs/ecs-benchmark) 19 | - Fast insert/relocate and auto-updating queries via [connected archetype graph](./graph.png) 20 | - Compatible with third-party libraries like Three.js, Pixi, and Cannon 21 | 22 | ## Installation 23 | 24 | ```sh 25 | npm install harmony-ecs 26 | ``` 27 | 28 | ## Documentation 29 | 30 | - [Wiki](https://github.com/3mcd/harmony-ecs/wiki) 31 | - [API docs](https://3mcd.github.io/harmony-ecs) 32 | 33 | ## Examples 34 | 35 | This repo contains examples in the [`examples`](./examples) directory. You can run each project using `npm run example:*`, where `*` is the name of an example subdirectory. 36 | 37 | Below is a sample of Harmony's API, where a TypedArray `Velocity` component is used to update an object `Position` component: 38 | 39 | ```ts 40 | import { World, Schema, Entity, Query, Format } from "harmony-ecs" 41 | 42 | const Vector2 = { 43 | x: Format.float64, 44 | y: Format.float64, 45 | } 46 | const world = World.make(1_000_000) 47 | const Position = Schema.make(world, Vector2) 48 | const Velocity = Schema.makeBinary(world, Vector2) 49 | const Kinetic = [Position, Velocity] as const 50 | 51 | for (let i = 0; i < 1_000_000; i++) { 52 | Entity.make(world, Kinetic) 53 | } 54 | 55 | const kinetics = Query.make(world, Kinetic) 56 | 57 | for (const [entities, [p, v]] of kinetics) { 58 | for (let i = 0; i < entities.length; i++) { 59 | p[i].x += v.x[i] 60 | p[i].y += v.y[i] 61 | } 62 | } 63 | ``` 64 | 65 | Harmony does not modify objects, making it highly compatible with third-party libraries. Take the following example where an entity is composed of a Three.js mesh, Cannon.js rigid body, and some proprietary TypedArray-backed data. 66 | 67 | ```ts 68 | const Vector3 = { x: Format.float64 /* etc */ } 69 | const Mesh = Schema.make(world, { position: Vector3 }) 70 | const Body = Schema.make(world, { position: Vector3 }) 71 | const PlayerInfo = Schema.makeBinary(world, { id: Format.uint32 }) 72 | const Player = [Mesh, Body, PlayerInfo] as const 73 | 74 | const mesh = new Three.Mesh(new Three.SphereGeometry(), new Three.MeshBasicMaterial()) 75 | const body = new Cannon.Body({ mass: 1, shape: new Cannon.Sphere(1) }) 76 | 77 | Entity.make(world, Player, [mesh, body, { id: 123 }]) 78 | ``` 79 | 80 | Note that we still need to define the shape of third party objects, as seen in the `Mesh` and `Body` variables. This supplies Harmony with static type information for queries and provides the ECS with important runtime information for serialization, etc. 81 | 82 | ## Performance Tests 83 | 84 | Run the performance test suite using `npm run perf:node` or `npm perf:browser`. Example output: 85 | 86 | ``` 87 | iterBinary 88 | ---------- 89 | iter 90 | iterations 100 91 | ┌─────────┬────────────┐ 92 | │ (index) │ Values │ 93 | ├─────────┼────────────┤ 94 | │ average │ '12.46 ms' │ 95 | │ median │ '12.03 ms' │ 96 | │ min │ '11.71 ms' │ 97 | │ max │ '18.76 ms' │ 98 | └─────────┴────────────┘ 99 | ``` 100 | -------------------------------------------------------------------------------- /docs/.nojekyll: -------------------------------------------------------------------------------- 1 | TypeDoc added this file to prevent GitHub Pages from using Jekyll. You can turn off this behavior by setting the `githubPages` option to false. -------------------------------------------------------------------------------- /docs/assets/highlight.css: -------------------------------------------------------------------------------- 1 | :root { 2 | --light-hl-0: #000000; 3 | --dark-hl-0: #D4D4D4; 4 | --light-hl-1: #AF00DB; 5 | --dark-hl-1: #C586C0; 6 | --light-hl-2: #001080; 7 | --dark-hl-2: #9CDCFE; 8 | --light-hl-3: #A31515; 9 | --dark-hl-3: #CE9178; 10 | --light-hl-4: #0000FF; 11 | --dark-hl-4: #569CD6; 12 | --light-hl-5: #0070C1; 13 | --dark-hl-5: #4FC1FF; 14 | --light-hl-6: #795E26; 15 | --dark-hl-6: #DCDCAA; 16 | --light-hl-7: #098658; 17 | --dark-hl-7: #B5CEA8; 18 | --light-hl-8: #008000; 19 | --dark-hl-8: #6A9955; 20 | --light-hl-9: #267F99; 21 | --dark-hl-9: #4EC9B0; 22 | --light-code-background: #FFFFFF; 23 | --dark-code-background: #1E1E1E; 24 | } 25 | 26 | @media (prefers-color-scheme: light) { :root { 27 | --hl-0: var(--light-hl-0); 28 | --hl-1: var(--light-hl-1); 29 | --hl-2: var(--light-hl-2); 30 | --hl-3: var(--light-hl-3); 31 | --hl-4: var(--light-hl-4); 32 | --hl-5: var(--light-hl-5); 33 | --hl-6: var(--light-hl-6); 34 | --hl-7: var(--light-hl-7); 35 | --hl-8: var(--light-hl-8); 36 | --hl-9: var(--light-hl-9); 37 | --code-background: var(--light-code-background); 38 | } } 39 | 40 | @media (prefers-color-scheme: dark) { :root { 41 | --hl-0: var(--dark-hl-0); 42 | --hl-1: var(--dark-hl-1); 43 | --hl-2: var(--dark-hl-2); 44 | --hl-3: var(--dark-hl-3); 45 | --hl-4: var(--dark-hl-4); 46 | --hl-5: var(--dark-hl-5); 47 | --hl-6: var(--dark-hl-6); 48 | --hl-7: var(--dark-hl-7); 49 | --hl-8: var(--dark-hl-8); 50 | --hl-9: var(--dark-hl-9); 51 | --code-background: var(--dark-code-background); 52 | } } 53 | 54 | body.light { 55 | --hl-0: var(--light-hl-0); 56 | --hl-1: var(--light-hl-1); 57 | --hl-2: var(--light-hl-2); 58 | --hl-3: var(--light-hl-3); 59 | --hl-4: var(--light-hl-4); 60 | --hl-5: var(--light-hl-5); 61 | --hl-6: var(--light-hl-6); 62 | --hl-7: var(--light-hl-7); 63 | --hl-8: var(--light-hl-8); 64 | --hl-9: var(--light-hl-9); 65 | --code-background: var(--light-code-background); 66 | } 67 | 68 | body.dark { 69 | --hl-0: var(--dark-hl-0); 70 | --hl-1: var(--dark-hl-1); 71 | --hl-2: var(--dark-hl-2); 72 | --hl-3: var(--dark-hl-3); 73 | --hl-4: var(--dark-hl-4); 74 | --hl-5: var(--dark-hl-5); 75 | --hl-6: var(--dark-hl-6); 76 | --hl-7: var(--dark-hl-7); 77 | --hl-8: var(--dark-hl-8); 78 | --hl-9: var(--dark-hl-9); 79 | --code-background: var(--dark-code-background); 80 | } 81 | 82 | .hl-0 { color: var(--hl-0); } 83 | .hl-1 { color: var(--hl-1); } 84 | .hl-2 { color: var(--hl-2); } 85 | .hl-3 { color: var(--hl-3); } 86 | .hl-4 { color: var(--hl-4); } 87 | .hl-5 { color: var(--hl-5); } 88 | .hl-6 { color: var(--hl-6); } 89 | .hl-7 { color: var(--hl-7); } 90 | .hl-8 { color: var(--hl-8); } 91 | .hl-9 { color: var(--hl-9); } 92 | pre, code { background: var(--code-background); } 93 | -------------------------------------------------------------------------------- /docs/assets/icons.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/3mcd/harmony-ecs/74acbe129d8e7234c6a885bfd83aae335dd290c6/docs/assets/icons.png -------------------------------------------------------------------------------- /docs/assets/icons@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/3mcd/harmony-ecs/74acbe129d8e7234c6a885bfd83aae335dd290c6/docs/assets/icons@2x.png -------------------------------------------------------------------------------- /docs/assets/widgets.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/3mcd/harmony-ecs/74acbe129d8e7234c6a885bfd83aae335dd290c6/docs/assets/widgets.png -------------------------------------------------------------------------------- /docs/assets/widgets@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/3mcd/harmony-ecs/74acbe129d8e7234c6a885bfd83aae335dd290c6/docs/assets/widgets@2x.png -------------------------------------------------------------------------------- /docs/classes/Types.OpaqueTag.html: -------------------------------------------------------------------------------- 1 | OpaqueTag | harmony-ecs
Options
All
  • Public
  • Public/Protected
  • All
Menu

Class OpaqueTag<$Tag>

Type parameters

  • $Tag

Hierarchy

  • OpaqueTag

Index

Constructors

Properties

Constructors

constructor

Properties

Protected tag

tag: $Tag

Generated using TypeDoc

-------------------------------------------------------------------------------- /docs/classes/World.EntityNotRealError.html: -------------------------------------------------------------------------------- 1 | EntityNotRealError | harmony-ecs
Options
All
  • Public
  • Public/Protected
  • All
Menu

Class EntityNotRealError

Hierarchy

  • Error
    • EntityNotRealError

Index

Methods

Static captureStackTrace

  • captureStackTrace(targetObject: object, constructorOpt?: Function): void
  • 2 |

    Create .stack property on a target object

    3 |

    Parameters

    • targetObject: object
    • Optional constructorOpt: Function

    Returns void

Properties

Static Optional prepareStackTrace

prepareStackTrace?: (err: Error, stackTraces: CallSite[]) => any

Type declaration

Static stackTraceLimit

stackTraceLimit: number

name

name: string

message

message: string

Optional stack

stack?: string

Constructors

constructor

Generated using TypeDoc

-------------------------------------------------------------------------------- /docs/enums/Format.FormatKind.html: -------------------------------------------------------------------------------- 1 | FormatKind | harmony-ecs
Options
All
  • Public
  • Public/Protected
  • All
Menu

Enumeration FormatKind

Index

Enumeration members

Uint8

Uint8 = 0

Uint16

Uint16 = 1

Uint32

Uint32 = 2

Int8

Int8 = 3

Int16

Int16 = 4

Int32

Int32 = 5

Float32

Float32 = 6

Float64

Float64 = 7

Generated using TypeDoc

-------------------------------------------------------------------------------- /docs/modules.html: -------------------------------------------------------------------------------- 1 | harmony-ecs
Options
All
  • Public
  • Public/Protected
  • All
Menu

harmony-ecs

Index

Type aliases

Data

Data<$SchemaId>: $SchemaId extends Schema.Id<infer $Schema> ? $Schema extends Schema.ComplexSchema ? DataOfShape<Schema.Shape<$Schema>> : never : never

Type parameters

Generated using TypeDoc

-------------------------------------------------------------------------------- /docs/modules/Format.html: -------------------------------------------------------------------------------- 1 | Format | harmony-ecs
Options
All
  • Public
  • Public/Protected
  • All
Menu

Namespace Format

2 |

An enum of Harmony's supported data types.

3 |

Index

Type aliases

Format

Format<$Kind, $Binary>: { kind: $Kind; binary: $Binary }
4 |

Number format. Encloses a TypedArray constructor which corresponds to the 5 | format kind, (e.g. FormatKind.Uint8->Uint8Array).

6 |

Type parameters

Type declaration

  • kind: $Kind
  • binary: $Binary

Variables

float32

float32: Format<Float32, Float32ArrayConstructor> = ...

float64

float64: Format<Float64, Float64ArrayConstructor> = ...

uint8

uint8: Format<Uint8, Uint8ArrayConstructor> = ...

uint16

uint16: Format<Uint16, Uint16ArrayConstructor> = ...

uint32

uint32: Format<Uint32, Uint32ArrayConstructor> = ...

int8

int8: Format<Int8, Int8ArrayConstructor> = ...

int16

int16: Format<Int16, Int16ArrayConstructor> = ...

int32

int32: Format<Int32, Int32ArrayConstructor> = ...

Generated using TypeDoc

-------------------------------------------------------------------------------- /docs/modules/Signal.html: -------------------------------------------------------------------------------- 1 | Signal | harmony-ecs
Options
All
  • Public
  • Public/Protected
  • All
Menu

Namespace Signal

2 |

A small, stringless, strongly-typed event system.

3 |

Index

Type aliases

Subscriber

Subscriber<T>: (t: T) => void

Type parameters

  • T

Type declaration

    • (t: T): void
    • Parameters

      • t: T

      Returns void

Struct

Struct<T>: Subscriber<T>[]

Type parameters

  • T = void

Functions

make

subscribe

dispatch

Generated using TypeDoc

-------------------------------------------------------------------------------- /docs/modules/World.html: -------------------------------------------------------------------------------- 1 | World | harmony-ecs
Options
All
  • Public
  • Public/Protected
  • All
Menu

Namespace World

2 |

A module used to create and manage worlds—the root object of a Harmony game.

3 |

Index

Classes

Type aliases

Functions

Type aliases

Struct

Struct: { rootArchetype: Archetype.Struct; entityHead: number; entityIndex: (Archetype.Struct | undefined)[]; schemaIndex: Schema.AnySchema[]; size: number }
4 |

The root object of the ECS. A world maintains entity identifiers, stores 5 | schemas, and associates components with entities in tables called 6 | archetypes: collections of entities that share the same component makeup.

7 |

Archetypes are stored in a connected graph structure, where each node is 8 | linked to adjacent archetypes by the addition or removal or a single 9 | component type. An entity's archetype is selected based on its current 10 | component composition. An entity can belong only to a single archetype at a 11 | time.

12 |

Entities without any components (e.g. destroyed entities) are never 13 | discarded, but moved to the world's root archetype.

14 |

Type declaration

  • rootArchetype: Archetype.Struct
  • entityHead: number
  • entityIndex: (Archetype.Struct | undefined)[]
  • schemaIndex: Schema.AnySchema[]
  • size: number

Functions

make

  • 15 |

    Create a world. Requires a maximum entity size which is used to allocate 16 | fixed memory for binary component arrays.

    17 |
    example

    Create a world capable of storing one million entities

    18 |
    const world = World.make(1e6)
    19 | 
    20 |

    Parameters

    • size: number

    Returns World.Struct

Generated using TypeDoc

-------------------------------------------------------------------------------- /examples/compat/README.md: -------------------------------------------------------------------------------- 1 | # harmony/compat 2 | 3 | A simple example of using third-party libraries Three.js and cannon-es with Harmony. 4 | -------------------------------------------------------------------------------- /examples/compat/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | harmony-ecs/examples/compat 8 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | -------------------------------------------------------------------------------- /examples/compat/src/index.ts: -------------------------------------------------------------------------------- 1 | import * as Cannon from "cannon-es" 2 | import * as Three from "three" 3 | import { OrbitControls } from "three/examples/jsm/controls/OrbitControls" 4 | import { Format, World, Schema, Query, Entity, Debug } from "../../../lib/src" 5 | 6 | const BOUNCE_IMPULSE = new Cannon.Vec3(0, 10, 0) 7 | 8 | const Vec3 = { x: Format.float64, y: Format.float64, z: Format.float64 } 9 | const Quaternion = { 10 | x: Format.float64, 11 | y: Format.float64, 12 | z: Format.float64, 13 | w: Format.float64, 14 | } 15 | const Object3D = { position: Vec3, quaternion: Quaternion } 16 | const world = World.make(1_000) 17 | const Body = Schema.make(world, Object3D) 18 | const Mesh = Schema.make(world, Object3D) 19 | const Bounce = Schema.makeBinary(world, Format.float64) 20 | const Tag = Schema.makeTag(world) 21 | 22 | const Tagged = [Tag] as const 23 | const Box = [Body, Mesh] as const 24 | const BoxBounce = [...Box, Bounce] as const 25 | const BoxBounceTagged = [...BoxBounce, ...Tagged] as const 26 | 27 | const canvas = document.getElementById("game") as HTMLCanvasElement 28 | const renderer = new Three.WebGLRenderer({ antialias: true, canvas }) 29 | const camera = new Three.PerspectiveCamera(45, 1, 0.1, 2000000) 30 | const controls = new OrbitControls(camera, renderer.domElement) 31 | const scene = new Three.Scene() 32 | const simulation = new Cannon.World({ gravity: new Cannon.Vec3(0, -9.81, 0) }) 33 | const bodies = Query.make(world, Box) 34 | const bouncing = Query.make(world, BoxBounce) 35 | const bouncingWithTag = Query.make(world, BoxBounceTagged) 36 | const bouncingWithoutTag = Query.make(world, BoxBounce, Query.not([Tag])) 37 | 38 | scene.add(new Three.AmbientLight(0x404040), new Three.DirectionalLight(0xffffff, 0.5)) 39 | 40 | function scale() { 41 | camera.aspect = window.innerWidth / window.innerHeight 42 | camera.updateProjectionMatrix() 43 | renderer.setSize(window.innerWidth, window.innerHeight) 44 | } 45 | 46 | window.addEventListener("resize", scale, false) 47 | scale() 48 | 49 | function createBox( 50 | position = new Cannon.Vec3(0, 0, 0), 51 | halfExtents = new Cannon.Vec3(0.5, 0.5, 0.5), 52 | type: Cannon.BodyType = Cannon.Body.DYNAMIC, 53 | color = 0xff0000, 54 | mass = 1, 55 | ) { 56 | const shape = new Cannon.Box(halfExtents) 57 | const body = new Cannon.Body({ mass, type, position, shape }) 58 | const geometry = new Three.BoxGeometry( 59 | halfExtents.x * 2, 60 | halfExtents.y * 2, 61 | halfExtents.z * 2, 62 | ) 63 | const material = new Three.MeshLambertMaterial({ color, transparent: true }) 64 | const mesh = new Three.Mesh(geometry, material) 65 | return [body, mesh] 66 | } 67 | 68 | function createGround() { 69 | return createBox( 70 | new Cannon.Vec3(0, 0, 0), 71 | new Cannon.Vec3(25, 0.1, 25), 72 | Cannon.Body.STATIC, 73 | 0xffffff, 74 | 0, 75 | ) 76 | } 77 | 78 | function copyBodyToMesh(body: Cannon.Body, mesh: Three.Mesh) { 79 | const { x, y, z } = body.interpolatedPosition 80 | const { x: qx, y: qy, z: qz, w: qw } = body.interpolatedQuaternion 81 | mesh.position.x = x 82 | mesh.position.y = y 83 | mesh.position.z = z 84 | mesh.quaternion.x = qx 85 | mesh.quaternion.y = qy 86 | mesh.quaternion.z = qz 87 | mesh.quaternion.w = qw 88 | } 89 | 90 | function random(scale = 2) { 91 | return (0.5 - Math.random()) * scale 92 | } 93 | 94 | let spawnInit = true 95 | 96 | function spawn() { 97 | if (spawnInit) { 98 | // spawn ground 99 | Entity.make(world, [Body, Mesh], createGround()) 100 | // spawn boxes 101 | for (let i = 0; i < 100; i++) { 102 | const entity = Entity.make( 103 | world, 104 | [Body, Mesh], 105 | createBox(new Cannon.Vec3(random(25), 20, random(25))), 106 | ) 107 | if (i % 2 === 0) Entity.set(world, entity, [Bounce], [0]) 108 | } 109 | spawnInit = false 110 | } 111 | } 112 | 113 | let physicsInit = true 114 | 115 | function physics(dt: number) { 116 | const now = performance.now() 117 | if (physicsInit) { 118 | for (let i = 0; i < bodies.length; i++) { 119 | const [entities, [b]] = bodies[i]! 120 | for (let j = 0; j < entities.length; j++) { 121 | simulation.addBody( 122 | // manually cast the component to it's true type since we lose type 123 | // information bb storing it in Harmony 124 | b[j] as Cannon.Body, 125 | ) 126 | } 127 | } 128 | physicsInit = false 129 | } 130 | for (let i = 0; i < bouncing.length; i++) { 131 | const [entities, [b, , bo]] = bouncing[i]! 132 | for (let j = 0; j < entities.length; j++) { 133 | const entity = entities[j]! 134 | const body = b[j] as Cannon.Body 135 | if (now - bo[j]! >= 5000) { 136 | bo[j] = now 137 | body.applyImpulse(BOUNCE_IMPULSE) 138 | if (Entity.has(world, entity, Tagged)) { 139 | Entity.unset(world, entity, Tagged) 140 | } else { 141 | Entity.set(world, entity, Tagged) 142 | } 143 | } 144 | } 145 | } 146 | simulation.step(1 / 60, dt / 1000, 5) 147 | } 148 | 149 | let renderInit = true 150 | 151 | function render() { 152 | // add camera to scene 153 | if (renderInit) { 154 | scene.add(camera) 155 | camera.position.x = 50 156 | camera.position.y = 50 157 | camera.position.z = 50 158 | for (let i = 0; i < bodies.length; i++) { 159 | const [entities, [, m]] = bodies[i]! 160 | for (let j = 0; j < entities.length; j++) { 161 | scene.add(m[j] as Three.Mesh) 162 | } 163 | } 164 | renderInit = false 165 | } 166 | for (let i = 0; i < bodies.length; i++) { 167 | const [entities, [b, m]] = bodies[i]! 168 | for (let j = 0; j < entities.length; j++) { 169 | copyBodyToMesh(b[j] as Cannon.Body, m[j] as Three.Mesh) 170 | } 171 | } 172 | for (let i = 0; i < bouncingWithTag.length; i++) { 173 | const [entities, [, m]] = bouncingWithTag[i]! 174 | for (let j = 0; j < entities.length; j++) { 175 | ;((m[j] as Three.Mesh).material as Three.MeshLambertMaterial).color.set(0xff0000) 176 | } 177 | } 178 | for (let i = 0; i < bouncingWithoutTag.length; i++) { 179 | const [entities, [, m]] = bouncingWithoutTag[i]! 180 | for (let j = 0; j < entities.length; j++) { 181 | ;((m[j] as Three.Mesh).material as Three.MeshLambertMaterial).color.set(0x00ffff) 182 | } 183 | } 184 | 185 | // render scene 186 | controls.update() 187 | renderer.render(scene, camera) 188 | } 189 | 190 | let prev = 0 191 | 192 | function step(now: number) { 193 | spawn() 194 | physics(now - (prev || now)) 195 | render() 196 | requestAnimationFrame(step) 197 | prev = now 198 | } 199 | 200 | requestAnimationFrame(step) 201 | -------------------------------------------------------------------------------- /examples/compat/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../../tsconfig.json", 3 | "compilerOptions": { 4 | "rootDir": "src", 5 | "noEmit": true 6 | }, 7 | "references": [{ "path": "../../lib" }] 8 | } 9 | -------------------------------------------------------------------------------- /examples/compat/vite.config.js: -------------------------------------------------------------------------------- 1 | import { defineConfig } from "vite" 2 | 3 | export default defineConfig({ 4 | root: ".", 5 | build: { 6 | rollupOptions: { 7 | output: { 8 | entryFileNames: `assets/[name].js`, 9 | chunkFileNames: `assets/[name].js`, 10 | assetFileNames: `assets/[name].[ext]`, 11 | }, 12 | }, 13 | }, 14 | }) 15 | -------------------------------------------------------------------------------- /examples/graph/README.md: -------------------------------------------------------------------------------- 1 | # harmony/graph 2 | -------------------------------------------------------------------------------- /examples/graph/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | harmony-ecs/examples/compat 8 | 25 | 26 | 27 |
28 |
29 | 30 | 31 |

32 |     
33 | 41 | 42 | 43 | 44 | -------------------------------------------------------------------------------- /examples/graph/src/index.ts: -------------------------------------------------------------------------------- 1 | import * as Archetype from "../../../lib/src/archetype" 2 | import { Edge, Network, Node } from "vis-network" 3 | import { DataSet } from "vis-data" 4 | import { Entity, Schema, World } from "../../../lib/src" 5 | import * as Type from "../../../lib/src/type" 6 | 7 | function getColor(value: number) { 8 | return ["hsl(", ((1 - value) * 120).toString(10), ",100%,50%)"].join("") 9 | } 10 | 11 | function makeTypeId(type: Type.Struct) { 12 | return type.join(",") 13 | } 14 | 15 | const $log = document.getElementById("log")! 16 | const $Type = document.getElementById("type") as HTMLInputElement 17 | const $network = document.getElementById("network")! 18 | const $insert = document.getElementById("insert")! 19 | 20 | const world = World.make(1_000_000) 21 | const nodes = new DataSet([] as (Node & { count: number })[]) 22 | const edges = new DataSet([] as Edge[]) 23 | const schemas = Array(10) 24 | .fill(undefined) 25 | .map(() => Schema.make(world, {})) 26 | const edgeIds = new Set() 27 | 28 | function maybeMakeEdge(a: Type.Struct, b: Type.Struct) { 29 | const from = makeTypeId(a) 30 | const to = makeTypeId(b) 31 | const id = a.length > b.length ? `${to}–${from}` : `${from}–${to}` 32 | if (edgeIds.has(id)) { 33 | return 34 | } 35 | edgeIds.add(id) 36 | edges.add({ id, from, to }) 37 | } 38 | 39 | function onTableInsert(archetype: Archetype.Struct) { 40 | const id = makeTypeId(archetype.type) 41 | nodes.add({ id: id, label: `(${id})`, count: 0, level: archetype.type.length }) 42 | archetype.edgesSet.forEach(({ type }) => maybeMakeEdge(archetype.type, type)) 43 | archetype.edgesUnset.forEach(({ type }) => maybeMakeEdge(archetype.type, type)) 44 | } 45 | 46 | subscribe(world.rootArchetype.onTableInsert, onTableInsert) 47 | onTableInsert(world.rootArchetype) 48 | 49 | let max = 0 50 | 51 | function onInsertEntity() { 52 | const type = Array.from($Type.value.split(/[\s,]+/).map(Number)) 53 | .sort((a, b) => a - b) 54 | .map(id => schemas[id]!) 55 | const id = makeTypeId(type) 56 | Entity.make(world, type) 57 | const node = nodes.get(id)! 58 | const count = node.count + 1 59 | if (count > max) { 60 | max = count 61 | } 62 | nodes.update({ id, count }) 63 | nodes.forEach(node => 64 | nodes.update({ 65 | id: node.id, 66 | color: { background: node.count > 0 ? getColor(node.count / max) : undefined }, 67 | }), 68 | ) 69 | $log.textContent += `${id}\n` 70 | $Type.value = "" 71 | } 72 | 73 | $Type.addEventListener("keydown", e => { 74 | if (e.key === "Enter") { 75 | onInsertEntity() 76 | } 77 | }) 78 | $insert.addEventListener("click", onInsertEntity) 79 | 80 | new Network( 81 | $network, 82 | { 83 | nodes, 84 | edges, 85 | }, 86 | { 87 | nodes: { 88 | physics: false, 89 | }, 90 | layout: { 91 | hierarchical: { 92 | // enabled: true, 93 | levelSeparation: 200, 94 | nodeSpacing: 70, 95 | treeSpacing: 100, 96 | blockShifting: true, 97 | edgeMinimization: true, 98 | parentCentralization: true, 99 | direction: "LR", 100 | sortMethod: "directed", // hubsize, directed, 101 | }, 102 | }, 103 | }, 104 | ) 105 | 106 | import * as Harmony from "../../../lib/src" 107 | import { subscribe } from "../../../lib/src/signal" 108 | 109 | //@ts-ignore 110 | globalThis.world = world 111 | // @ts-ignore 112 | globalThis.Harmony = Harmony 113 | -------------------------------------------------------------------------------- /examples/graph/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../../tsconfig.json", 3 | "compilerOptions": { 4 | "rootDir": "src", 5 | "noEmit": true 6 | }, 7 | "references": [{ "path": "../../lib" }] 8 | } 9 | -------------------------------------------------------------------------------- /examples/graph/vite.config.js: -------------------------------------------------------------------------------- 1 | import { defineConfig } from "vite" 2 | 3 | export default defineConfig({ 4 | root: ".", 5 | build: { 6 | rollupOptions: { 7 | output: { 8 | entryFileNames: `assets/[name].js`, 9 | chunkFileNames: `assets/[name].js`, 10 | assetFileNames: `assets/[name].[ext]`, 11 | }, 12 | }, 13 | }, 14 | }) 15 | -------------------------------------------------------------------------------- /examples/noise/README.md: -------------------------------------------------------------------------------- 1 | # harmony/noise 2 | 3 | A 500x500 canvas where each pixel is a simulated entity. 4 | 5 | Demonstrates use of `not` queries, binary components and entity-component manipulation. 6 | -------------------------------------------------------------------------------- /examples/noise/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | harmony-ecs/examples/noise 8 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | -------------------------------------------------------------------------------- /examples/noise/src/index.ts: -------------------------------------------------------------------------------- 1 | import { Format, Schema, Entity, Query, World } from "../../../lib/src" 2 | 3 | const SIZE = 500 4 | const COUNT = SIZE * SIZE 5 | const canvas = document.getElementById("game") as HTMLCanvasElement 6 | const world = World.make(COUNT) 7 | const context = canvas.getContext("2d")! 8 | const image = context.getImageData(0, 0, SIZE, SIZE) 9 | const buf = new ArrayBuffer(image.data.length) 10 | const buf8 = new Uint8ClampedArray(buf) 11 | const buf32 = new Uint32Array(buf) 12 | 13 | const Position = Schema.makeBinary(world, { x: Format.uint32, y: Format.uint32 }) 14 | const Fixed = Schema.makeBinary(world, Format.uint32) 15 | const Point = [Position] as const 16 | const PointFixed = [...Point, Fixed] as const 17 | 18 | for (let i = 0; i < SIZE; i++) { 19 | for (let j = 0; j < SIZE; j++) { 20 | Entity.make(world, Point, [{ x: i, y: j }]) 21 | } 22 | } 23 | 24 | const noise = Query.make(world, Point, Query.not([Fixed])) 25 | const fixed = Query.make(world, PointFixed) 26 | const rand: number[] = [] 27 | 28 | let randomHead = 1e6 + 90_000 29 | while (randomHead--) rand.push(Math.round(Math.random())) 30 | 31 | function step() { 32 | for (let i = 0; i < noise.length; i++) { 33 | const [e, [p]] = noise[i]! 34 | for (let j = 0; j < e.length; j++) { 35 | const x = p.x[j]! 36 | const y = p.y[j]! 37 | const random = 38 | (++randomHead >= rand.length ? rand[(randomHead = 0)]! : rand[randomHead]!) * 50 39 | buf32[y * SIZE + x] = (255 << 24) | (random << 16) | (random << 8) | random 40 | } 41 | } 42 | for (let i = 0; i < fixed.length; i++) { 43 | const [e, [p, f]] = fixed[i]! 44 | for (let j = 0; j < e.length; j++) { 45 | const x = p.x[j]! 46 | const y = p.y[j]! 47 | buf32[y * SIZE + x] = f[j]! 48 | } 49 | } 50 | image.data.set(buf8) 51 | context.putImageData(image, 0, 0) 52 | requestAnimationFrame(step) 53 | } 54 | 55 | let rect = canvas.getBoundingClientRect() 56 | let sx = 0 57 | let sy = 0 58 | 59 | function resize() { 60 | rect = canvas.getBoundingClientRect() 61 | sx = canvas.width / rect.width 62 | sy = canvas.height / rect.height 63 | } 64 | 65 | function onMouseMove(event: MouseEvent) { 66 | const x = Math.round((event.clientX - rect.left) * sx) 67 | const y = Math.round((event.clientY - rect.top) * sy) 68 | for (let i = 0; i < noise.length; i++) { 69 | const [e, [p]] = noise[i]! 70 | for (let j = 0; j < e.length; j++) { 71 | if (p.x[j] === x && p.y[j] === y) { 72 | Entity.set(world, e[j]!, PointFixed, [ 73 | , 74 | buf32[y * SIZE + x]! | (100 << 16) | (50 << 8), 75 | ]) 76 | break 77 | } 78 | } 79 | } 80 | } 81 | 82 | window.addEventListener("resize", resize) 83 | canvas.addEventListener("mousemove", onMouseMove) 84 | 85 | resize() 86 | step() 87 | -------------------------------------------------------------------------------- /examples/noise/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../../tsconfig.json", 3 | "compilerOptions": { 4 | "rootDir": "src", 5 | "noEmit": true 6 | }, 7 | "references": [{ "path": "../../lib" }] 8 | } 9 | -------------------------------------------------------------------------------- /examples/noise/vite.config.js: -------------------------------------------------------------------------------- 1 | import { defineConfig } from "vite" 2 | 3 | export default defineConfig({ 4 | root: ".", 5 | build: { 6 | rollupOptions: { 7 | output: { 8 | entryFileNames: `assets/[name].js`, 9 | chunkFileNames: `assets/[name].js`, 10 | assetFileNames: `assets/[name].[ext]`, 11 | }, 12 | }, 13 | }, 14 | }) 15 | -------------------------------------------------------------------------------- /graph.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/3mcd/harmony-ecs/74acbe129d8e7234c6a885bfd83aae335dd290c6/graph.png -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | export default { 2 | preset: "ts-jest", 3 | testEnvironment: "node", 4 | } 5 | -------------------------------------------------------------------------------- /lib/build/optimize.js: -------------------------------------------------------------------------------- 1 | import babel from "@babel/core" 2 | import { resolve } from "path" 3 | import { promises } from "fs" 4 | 5 | const { readdir, writeFile } = promises 6 | 7 | const options = { 8 | plugins: [ 9 | "./lib/build/plugins/transform-remove-invariant.js", 10 | ["add-import-extension", { extension: "js", replace: true }], 11 | ], 12 | } 13 | 14 | async function* getFiles(dir) { 15 | const dirents = await readdir(dir, { withFileTypes: true }) 16 | for (const dirent of dirents) { 17 | const res = resolve(dir, dirent.name) 18 | if (dirent.isDirectory()) { 19 | yield* getFiles(res) 20 | } else { 21 | yield res 22 | } 23 | } 24 | } 25 | 26 | ;(async () => { 27 | for await (const file of getFiles(`./lib/dist`)) { 28 | if (!/\.js$/.test(file)) continue 29 | const transformed = await babel.transformFileAsync(file, options) 30 | await writeFile(file, transformed.code) 31 | } 32 | })() 33 | -------------------------------------------------------------------------------- /lib/build/plugins/transform-remove-invariant.js: -------------------------------------------------------------------------------- 1 | function isInvariant(name) { 2 | return /^invariant/.test(name) || /^[A-Z].*\.invariant/.test(name) 3 | } 4 | export default function () { 5 | return { 6 | name: "transform-remove-invariant", 7 | visitor: { 8 | ImportDeclaration({ node }) { 9 | node.specifiers = node.specifiers.filter( 10 | specifier => !isInvariant(specifier.local.name), 11 | ) 12 | }, 13 | FunctionDeclaration(path) { 14 | if (isInvariant(path.node.id.name)) { 15 | path.remove() 16 | path.stop() 17 | } 18 | }, 19 | CallExpression(path) { 20 | const calleePath = path.get("callee") 21 | if (isInvariant(calleePath)) { 22 | path.remove() 23 | } 24 | }, 25 | }, 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /lib/src/archetype.ts: -------------------------------------------------------------------------------- 1 | import * as Component from "./component" 2 | import * as Debug from "./debug" 3 | import * as Entity from "./entity" 4 | import * as Format from "./format" 5 | import * as Schema from "./schema" 6 | import * as Signal from "./signal" 7 | import * as Type from "./type" 8 | import * as Types from "./types" 9 | import * as World from "./world" 10 | import * as ComponentSet from "./component_set" 11 | 12 | export type BinaryData<$Shape extends Schema.Shape> = 13 | $Shape extends Format.Format ? number : { [K in keyof $Shape]: number } 14 | 15 | export type NativeData<$Shape extends Schema.Shape> = 16 | $Shape extends Format.Format 17 | ? number 18 | : { 19 | [K in keyof $Shape]: $Shape[K] extends Format.Format 20 | ? number 21 | : $Shape[K] extends Schema.Shape 22 | ? NativeData<$Shape[K]> 23 | : never 24 | } 25 | 26 | export type DataOfShape<$Shape extends Schema.Shape> = 27 | $Shape extends Schema.Shape 28 | ? BinaryData<$Shape> 29 | : $Shape extends Schema.Shape 30 | ? NativeData<$Shape> 31 | : never 32 | 33 | export type Data<$SchemaId extends Schema.Id> = $SchemaId extends Schema.Id 34 | ? $Schema extends Schema.ComplexSchema 35 | ? DataOfShape> 36 | : never 37 | : never 38 | 39 | type ScalarBinaryColumn< 40 | $Schema extends Schema.BinaryScalarSchema = Schema.BinaryScalarSchema, 41 | > = { 42 | kind: Schema.SchemaKind.BinaryScalar 43 | schema: $Schema 44 | data: Types.Construct["binary"]> 45 | } 46 | 47 | type ComplexBinaryColumn< 48 | $Schema extends Schema.BinaryStructSchema = Schema.BinaryStructSchema, 49 | > = { 50 | kind: Schema.SchemaKind.BinaryStruct 51 | schema: $Schema 52 | data: { 53 | [K in keyof Schema.Shape<$Schema>]: Types.Construct< 54 | Schema.Shape<$Schema>[K]["binary"] 55 | > 56 | } 57 | } 58 | 59 | type ScalarNativeColumn< 60 | $Schema extends Schema.NativeScalarSchema = Schema.NativeScalarSchema, 61 | > = { 62 | kind: Schema.SchemaKind.NativeScalar 63 | schema: $Schema 64 | data: number[] 65 | } 66 | 67 | type ComplexNativeColumn< 68 | $Schema extends Schema.NativeObjectSchema = Schema.NativeObjectSchema, 69 | > = { 70 | kind: Schema.SchemaKind.NativeObject 71 | schema: $Schema 72 | data: NativeData>[] 73 | } 74 | 75 | type TagColumn<$Schema extends Schema.TagSchema = Schema.TagSchema> = { 76 | kind: Schema.SchemaKind.Tag 77 | schema: $Schema 78 | data: null 79 | } 80 | 81 | export type ColumnOfSchema<$Schema extends Schema.AnySchema> = 82 | $Schema extends Schema.BinaryScalarSchema 83 | ? ScalarBinaryColumn<$Schema> 84 | : $Schema extends Schema.BinaryStructSchema 85 | ? ComplexBinaryColumn<$Schema> 86 | : $Schema extends Schema.NativeScalarSchema 87 | ? ScalarNativeColumn<$Schema> 88 | : $Schema extends Schema.NativeObjectSchema 89 | ? ComplexNativeColumn<$Schema> 90 | : $Schema extends Schema.TagSchema 91 | ? TagColumn 92 | : never 93 | 94 | export type Column<$SchemaId extends Schema.Id = Schema.Id> = $SchemaId extends Schema.Id< 95 | infer $Schema 96 | > 97 | ? ColumnOfSchema<$Schema> 98 | : never 99 | 100 | export type Store<$Type extends Type.Struct> = { 101 | [K in keyof $Type]: $Type[K] extends Schema.Id ? Column<$Type[K]> : never 102 | } 103 | 104 | export type Struct<$Type extends Type.Struct = Type.Struct> = { 105 | edgesSet: Struct[] 106 | edgesUnset: Struct[] 107 | entities: Entity.Id[] 108 | entityIndex: number[] 109 | layout: number[] 110 | real: boolean 111 | onTableInsert: Signal.Struct 112 | onRealize: Signal.Struct 113 | store: Store<$Type> 114 | type: $Type 115 | } 116 | 117 | export type RowData<$Type extends Type.Struct> = { 118 | [K in keyof $Type]: $Type[K] extends Schema.Id ? Data<$Type[K]> : never 119 | } 120 | 121 | const ArrayBufferConstructor = globalThis.SharedArrayBuffer ?? globalThis.ArrayBuffer 122 | 123 | function makeColumn(schema: Schema.AnySchema, size: number): Column { 124 | let data: Column["data"] 125 | switch (schema.kind) { 126 | case Schema.SchemaKind.BinaryScalar: { 127 | const buffer = new ArrayBufferConstructor( 128 | size * schema.shape.binary.BYTES_PER_ELEMENT, 129 | ) 130 | data = new schema.shape.binary(buffer) 131 | break 132 | } 133 | case Schema.SchemaKind.BinaryStruct: { 134 | data = Object.entries(schema.shape).reduce((a, [memberName, memberNode]) => { 135 | const buffer = new ArrayBufferConstructor( 136 | size * memberNode.binary.BYTES_PER_ELEMENT, 137 | ) 138 | a[memberName] = new memberNode.binary(buffer) 139 | return a 140 | }, {} as { [key: string]: Types.TypedArray }) 141 | break 142 | } 143 | case Schema.SchemaKind.NativeScalar: 144 | case Schema.SchemaKind.NativeObject: 145 | data = [] 146 | break 147 | case Schema.SchemaKind.Tag: 148 | data = null 149 | break 150 | } 151 | return { kind: schema.kind, schema, data } as Column 152 | } 153 | 154 | export function makeStore<$Type extends Type.Struct>( 155 | world: World.Struct, 156 | type: $Type, 157 | ): Store<$Type> { 158 | return type.map(id => 159 | makeColumn(World.findSchemaById(world, id), world.size), 160 | ) as unknown as Store<$Type> 161 | } 162 | 163 | export function makeInner<$Type extends Type.Struct>( 164 | type: $Type, 165 | store: Store<$Type>, 166 | ): Struct<$Type> { 167 | Type.invariantNormalized(type) 168 | const layout: number[] = [] 169 | for (let i = 0; i < type.length; i++) { 170 | const id = type[i] 171 | Debug.invariant(id !== undefined) 172 | layout[id] = i 173 | } 174 | return { 175 | edgesSet: [], 176 | edgesUnset: [], 177 | entities: [], 178 | entityIndex: [], 179 | layout, 180 | real: false, 181 | onTableInsert: Signal.make(), 182 | onRealize: Signal.make(), 183 | store, 184 | type, 185 | } 186 | } 187 | 188 | export function make<$Type extends Type.Struct>( 189 | world: World.Struct, 190 | type: $Type, 191 | ): Struct<$Type> { 192 | const store = makeStore(world, type) 193 | return makeInner(type, store) 194 | } 195 | 196 | export function ensureReal(archetype: Struct) { 197 | if (!archetype.real) { 198 | archetype.real = true 199 | Signal.dispatch(archetype.onRealize, undefined) 200 | } 201 | } 202 | 203 | export function insert( 204 | archetype: Struct, 205 | entity: Entity.Id, 206 | components: ComponentSet.Struct, 207 | ) { 208 | const index = archetype.entities.length 209 | for (let i = 0; i < archetype.type.length; i++) { 210 | const id = archetype.type[i] 211 | Debug.invariant(id !== undefined) 212 | const columnIndex = archetype.layout[id as number] 213 | Debug.invariant(columnIndex !== undefined) 214 | const column = archetype.store[columnIndex] 215 | Debug.invariant(column !== undefined) 216 | const kind = column.kind 217 | // insert components, skipping over tag schema 218 | if (kind !== Schema.SchemaKind.Tag) { 219 | const data = components[id] ?? Component.expressSchema(column.schema) 220 | Debug.invariant(data !== undefined) 221 | writeColumnData(column, archetype.entities.length, data) 222 | } 223 | } 224 | archetype.entities[index] = entity 225 | archetype.entityIndex[entity] = index 226 | ensureReal(archetype) 227 | } 228 | 229 | export function remove(archetype: Struct, entity: number) { 230 | const index = archetype.entityIndex[entity] 231 | const head = archetype.entities.pop() 232 | 233 | Debug.invariant(index !== undefined) 234 | Debug.invariant(head !== undefined) 235 | 236 | if (entity === head) { 237 | // pop 238 | for (let i = 0; i < archetype.store.length; i++) { 239 | const column = archetype.store[i] 240 | Debug.invariant(column !== undefined) 241 | switch (column.kind) { 242 | case Schema.SchemaKind.BinaryScalar: 243 | column.data[index] = 0 244 | break 245 | case Schema.SchemaKind.BinaryStruct: 246 | for (const key in column.schema.shape) { 247 | const array = column.data[key] 248 | Debug.invariant(array !== undefined) 249 | array[index] = 0 250 | } 251 | break 252 | case Schema.SchemaKind.NativeScalar: 253 | case Schema.SchemaKind.NativeObject: 254 | column.data.pop() 255 | break 256 | } 257 | } 258 | } else { 259 | // swap 260 | const from = archetype.entities.length - 1 261 | for (let i = 0; i < archetype.store.length; i++) { 262 | const column = archetype.store[i] 263 | Debug.invariant(column !== undefined) 264 | switch (column.kind) { 265 | case Schema.SchemaKind.BinaryScalar: 266 | const data = column.data[from] 267 | Debug.invariant(data !== undefined) 268 | column.data[from] = 0 269 | column.data[index] = data 270 | break 271 | case Schema.SchemaKind.BinaryStruct: 272 | for (const key in column.schema.shape) { 273 | const array = column.data[key] 274 | Debug.invariant(array !== undefined) 275 | const data = array[from] 276 | Debug.invariant(data !== undefined) 277 | array[from] = 0 278 | array[index] = data 279 | } 280 | break 281 | case Schema.SchemaKind.NativeScalar: 282 | case Schema.SchemaKind.NativeObject: { 283 | const data = column.data.pop() 284 | Debug.invariant(data !== undefined) 285 | column.data[index] = data 286 | break 287 | } 288 | } 289 | } 290 | archetype.entities[index] = head 291 | archetype.entityIndex[head] = index 292 | } 293 | 294 | archetype.entityIndex[entity] = -1 295 | } 296 | 297 | export function writeColumnData(column: Column, index: number, data: Data) { 298 | switch (column.kind) { 299 | case Schema.SchemaKind.BinaryStruct: 300 | Debug.invariant(typeof data === "object") 301 | for (const key in column.schema.shape) { 302 | const array = column.data[key] 303 | const value = data[key] 304 | Debug.invariant(array !== undefined) 305 | Debug.invariant(typeof value === "number") 306 | array[index] = value 307 | } 308 | break 309 | case Schema.SchemaKind.BinaryScalar: 310 | case Schema.SchemaKind.NativeScalar: 311 | case Schema.SchemaKind.NativeObject: 312 | column.data[index] = data 313 | break 314 | } 315 | } 316 | 317 | export function copyColumnData( 318 | prev: Column, 319 | next: Column, 320 | prevIndex: number, 321 | nextIndex: number, 322 | ) { 323 | switch (prev.kind) { 324 | case Schema.SchemaKind.BinaryStruct: 325 | Debug.invariant(prev.kind === next.kind) 326 | for (const key in prev.schema.shape) { 327 | const prevArray = prev.data[key] 328 | const nextArray = next.data[key] 329 | Debug.invariant(prevArray !== undefined) 330 | Debug.invariant(nextArray !== undefined) 331 | const value = prevArray[prevIndex] 332 | Debug.invariant(typeof value === "number") 333 | nextArray[nextIndex] = value 334 | } 335 | break 336 | case Schema.SchemaKind.BinaryScalar: 337 | case Schema.SchemaKind.NativeScalar: 338 | case Schema.SchemaKind.NativeObject: { 339 | Debug.invariant(prev.kind === next.kind) 340 | const value = prev.data[prevIndex] 341 | Debug.invariant(value !== undefined) 342 | next.data[nextIndex] = value 343 | break 344 | } 345 | } 346 | } 347 | 348 | export function move( 349 | entity: Entity.Id, 350 | prev: Struct, 351 | next: Struct, 352 | data?: ComponentSet.Struct, 353 | ) { 354 | const nextIndex = next.entities.length 355 | for (let i = 0; i < next.type.length; i++) { 356 | const id = next.type[i] 357 | Debug.invariant(id !== undefined) 358 | const columnIndex = prev.layout[id] 359 | const nextColumn = next.store[i] 360 | Debug.invariant(nextColumn !== undefined) 361 | if (nextColumn.kind === Schema.SchemaKind.Tag) { 362 | continue 363 | } 364 | if (columnIndex === undefined) { 365 | Debug.invariant(data !== undefined) 366 | const value = data[id] ?? Component.expressSchema(nextColumn.schema) 367 | Debug.invariant(value !== undefined) 368 | writeColumnData(nextColumn, nextIndex, value) 369 | } else { 370 | Debug.invariant(columnIndex !== undefined) 371 | const prevIndex = prev.entityIndex[entity] 372 | const prevColumn = prev.store[columnIndex] 373 | const value = data?.[id] 374 | Debug.invariant(prevIndex !== undefined) 375 | Debug.invariant(prevColumn !== undefined) 376 | if (value === undefined) { 377 | copyColumnData(prevColumn, nextColumn, prevIndex, nextIndex) 378 | } else { 379 | writeColumnData(nextColumn, nextIndex, value) 380 | } 381 | } 382 | } 383 | next.entities[nextIndex] = entity 384 | next.entityIndex[entity] = nextIndex 385 | ensureReal(next) 386 | remove(prev, entity) 387 | } 388 | 389 | export function read<$Type extends Type.Struct>( 390 | entity: Entity.Id, 391 | archetype: Struct, 392 | layout: $Type, 393 | out: unknown[], 394 | ) { 395 | const index = archetype.entityIndex[entity] 396 | Debug.invariant(index !== undefined) 397 | for (let i = 0; i < layout.length; i++) { 398 | const schemaId = layout[i] 399 | Debug.invariant(schemaId !== undefined) 400 | const columnIndex = archetype.layout[schemaId] 401 | Debug.invariant(columnIndex !== undefined) 402 | const column = archetype.store[columnIndex] 403 | Debug.invariant(column !== undefined) 404 | let value: unknown 405 | switch (column.kind) { 406 | case Schema.SchemaKind.BinaryStruct: 407 | const data = Component.expressBinaryShape(column.schema.shape) 408 | for (const prop in data) { 409 | data[prop] = column.data[prop]![index]! 410 | } 411 | value = data 412 | break 413 | case Schema.SchemaKind.Tag: 414 | value = undefined 415 | break 416 | default: 417 | value = column.data[index] 418 | break 419 | } 420 | out.push(value) 421 | } 422 | return out as unknown as RowData<$Type> 423 | } 424 | 425 | export function write( 426 | entity: Entity.Id, 427 | archetype: Struct, 428 | components: ComponentSet.Struct, 429 | ) { 430 | const index = archetype.entityIndex[entity] 431 | Debug.invariant(index !== undefined) 432 | for (let i = 0; i < archetype.store.length; i++) { 433 | const column = archetype.store[i] 434 | Debug.invariant(column !== undefined) 435 | const data = components[column.schema.id] 436 | if (data !== undefined) { 437 | writeColumnData(column, index, data) 438 | } 439 | } 440 | } 441 | -------------------------------------------------------------------------------- /lib/src/archetype_graph.ts: -------------------------------------------------------------------------------- 1 | import * as Archetype from "./archetype" 2 | import * as Debug from "./debug" 3 | import * as Schema from "./schema" 4 | import * as Signal from "./signal" 5 | import * as Type from "./type" 6 | import * as World from "./world" 7 | 8 | export function traverseLeft( 9 | archetype: Archetype.Struct, 10 | iteratee: (archetype: Archetype.Struct) => unknown, 11 | visited = new Set(), 12 | ) { 13 | const stack: (Archetype.Struct | number)[] = [0, archetype] 14 | let i = stack.length 15 | while (i > 0) { 16 | const node = stack[--i] as Archetype.Struct 17 | const index = stack[--i] as number 18 | if (index < node.edgesUnset.length - 1) { 19 | stack[i++] = index + 1 20 | stack[i++] = node 21 | } 22 | const next = node.edgesUnset[index] 23 | if (next && !visited.has(next)) { 24 | visited.add(next) 25 | iteratee(next) 26 | stack[i++] = 0 27 | stack[i++] = next 28 | } 29 | } 30 | } 31 | 32 | export function traverse( 33 | archetype: Archetype.Struct, 34 | iteratee: (archetype: Archetype.Struct) => unknown, 35 | visited = new Set(), 36 | ) { 37 | const stack: (Archetype.Struct | number)[] = [0, archetype] 38 | let i = stack.length 39 | while (i > 0) { 40 | const node = stack[--i] as Archetype.Struct 41 | const index = stack[--i] as number 42 | if (index < node.edgesSet.length - 1) { 43 | stack[i++] = index + 1 44 | stack[i++] = node 45 | } 46 | const next = node.edgesSet[index] 47 | if (next && !visited.has(next)) { 48 | visited.add(next) 49 | iteratee(next) 50 | stack[i++] = 0 51 | stack[i++] = next 52 | } 53 | } 54 | } 55 | 56 | function emitTable(archetype: Archetype.Struct) { 57 | traverseLeft(archetype, t => Signal.dispatch(t.onTableInsert, archetype)) 58 | } 59 | 60 | export function find(world: World.Struct, type: Type.Struct) { 61 | let left = world.rootArchetype 62 | for (let i = 0; i < type.length; i++) { 63 | const id = type[i] 64 | Debug.invariant(id !== undefined) 65 | const right = left.edgesSet[id] 66 | if (right === undefined) { 67 | return 68 | } 69 | left = right 70 | } 71 | return left 72 | } 73 | 74 | function makeEdge(left: Archetype.Struct, right: Archetype.Struct, id: Schema.Id) { 75 | left.edgesSet[id] = right 76 | right.edgesUnset[id] = left 77 | } 78 | 79 | function makeArchetypeEnsurePath( 80 | world: World.Struct, 81 | root: Archetype.Struct, 82 | type: Type.Struct, 83 | emit: Archetype.Struct[], 84 | ) { 85 | let left = root 86 | for (let i = 0; i < type.length; i++) { 87 | const id = type[i]! 88 | let right = left.edgesSet[id] 89 | if (right === undefined) { 90 | const type = Type.add(left.type, id) 91 | right = find(world, type) 92 | if (right === undefined) { 93 | right = Archetype.make(world, type) 94 | emit.push(right) 95 | } 96 | makeEdge(left, right, id) 97 | } 98 | left = right 99 | } 100 | return left 101 | } 102 | 103 | function ensurePath( 104 | world: World.Struct, 105 | right: Archetype.Struct, 106 | left: Archetype.Struct, 107 | emit: Archetype.Struct[], 108 | ) { 109 | if (hasPath(left, right)) return 110 | const ids = Type.getIdsBetween(right.type, left.type) 111 | let node = left 112 | for (let i = 0; i < ids.length; i++) { 113 | const id = ids[i] 114 | Debug.invariant(id !== undefined) 115 | const type = Type.add(node.type, id) 116 | let right = find(world, type) 117 | if (right === undefined) { 118 | right = makeArchetypeEnsurePath(world, world.rootArchetype, type, emit) 119 | } 120 | makeEdge(node, right, id) 121 | node = right 122 | } 123 | return node 124 | } 125 | 126 | function hasPathTraverse(left: Archetype.Struct, ids: number[]) { 127 | for (let i = 0; i < ids.length; i++) { 128 | const id = ids[i] 129 | Debug.invariant(id !== undefined) 130 | const next = left.edgesSet[id] 131 | if (next !== undefined) { 132 | if (ids.length === 1) { 133 | return true 134 | } 135 | const nextIds = ids.slice() 136 | const swapId = nextIds.pop() 137 | if (i !== nextIds.length) { 138 | Debug.invariant(swapId !== undefined) 139 | nextIds[i] = swapId 140 | } 141 | if (hasPathTraverse(next, nextIds)) { 142 | return true 143 | } 144 | } 145 | } 146 | return false 147 | } 148 | 149 | function hasPath(left: Archetype.Struct, right: Archetype.Struct) { 150 | const ids = Type.getIdsBetween(right.type, left.type) 151 | return hasPathTraverse(left, ids) 152 | } 153 | 154 | function connectArchetypeTraverse( 155 | world: World.Struct, 156 | visiting: Archetype.Struct, 157 | inserted: Archetype.Struct, 158 | emit: Archetype.Struct[], 159 | visited = new Set(emit), 160 | ) { 161 | visited.add(visiting) 162 | if (Type.isSupersetOf(visiting.type, inserted.type)) { 163 | ensurePath(world, visiting, inserted, emit) 164 | return 165 | } 166 | if ( 167 | Type.isSupersetOf(inserted.type, visiting.type) && 168 | visiting !== world.rootArchetype 169 | ) { 170 | ensurePath(world, inserted, visiting, emit) 171 | } 172 | visiting.edgesSet.forEach(function connectNextArchetype(next) { 173 | if ( 174 | !visited.has(next) && 175 | (Type.maybeSupersetOf(next.type, inserted.type) || 176 | Type.maybeSupersetOf(inserted.type, next.type)) 177 | ) { 178 | connectArchetypeTraverse(world, next, inserted, emit, visited) 179 | } 180 | }) 181 | } 182 | 183 | function connectArchetype( 184 | world: World.Struct, 185 | inserted: Archetype.Struct, 186 | emit: Archetype.Struct[], 187 | ) { 188 | world.rootArchetype.edgesSet.forEach(function connectArchetypeFromBase(node) { 189 | connectArchetypeTraverse(world, node, inserted, emit) 190 | }) 191 | } 192 | 193 | function insertArchetype( 194 | world: World.Struct, 195 | root: Archetype.Struct, 196 | type: Type.Struct, 197 | emit: Archetype.Struct[], 198 | ) { 199 | let left = root 200 | for (let i = 0; i < type.length; i++) { 201 | const id = type[i]! 202 | let right = left.edgesSet[id] 203 | if (right === undefined) { 204 | const type = Type.add(left.type, id) 205 | right = find(world, type) 206 | let connect = false 207 | if (right === undefined) { 208 | right = Archetype.make(world, type) 209 | emit.push(right) 210 | connect = true 211 | } 212 | makeEdge(left, right, id) 213 | if (connect) { 214 | connectArchetype(world, right, emit) 215 | } 216 | } 217 | left = right 218 | } 219 | return left 220 | } 221 | 222 | export function findOrMakeArchetype(world: World.Struct, type: Type.Struct) { 223 | let emit: Archetype.Struct[] = [] 224 | const archetype = insertArchetype(world, world.rootArchetype, type, emit) 225 | for (let i = 0; i < emit.length; i++) { 226 | emitTable(emit[i]!) 227 | } 228 | return archetype 229 | } 230 | -------------------------------------------------------------------------------- /lib/src/cache.spec.ts: -------------------------------------------------------------------------------- 1 | import * as World from "./world" 2 | import * as Cache from "./cache" 3 | import * as Schema from "./schema" 4 | import * as Query from "./query" 5 | import * as Entity from "./entity" 6 | 7 | describe("Cache", () => { 8 | describe("set", () => { 9 | it("stores and applies set operations", () => { 10 | const world = World.make(10) 11 | const cache = Cache.make() 12 | const A = Schema.make(world, {}) 13 | const a = Query.make(world, [A]) 14 | Cache.set(cache, Entity.make(world), [A], [{}]) 15 | Cache.set(cache, Entity.make(world), [A], [{}]) 16 | for (let i = 0; i < a.length; i++) { 17 | const [e] = a[i]! 18 | expect(e!.length).toBe(0) 19 | } 20 | Cache.apply(cache, world) 21 | for (let i = 0; i < a.length; i++) { 22 | const [e] = a[i]! 23 | expect(e!.length).toBe(2) 24 | } 25 | }) 26 | }) 27 | describe("unset", () => { 28 | it("stores and applies unset operations", () => { 29 | const world = World.make(10) 30 | const cache = Cache.make() 31 | const A = Schema.make(world, {}) 32 | const a = Query.make(world, [A]) 33 | const e1 = Entity.make(world, [A]) 34 | const e2 = Entity.make(world, [A]) 35 | for (let i = 0; i < a.length; i++) { 36 | const [e] = a[i]! 37 | expect(e!.length).toBe(2) 38 | } 39 | Cache.unset(cache, e1, [A]) 40 | Cache.unset(cache, e2, [A]) 41 | Cache.apply(cache, world) 42 | for (let i = 0; i < a.length; i++) { 43 | const [e] = a[i]! 44 | expect(e!.length).toBe(0) 45 | } 46 | }) 47 | }) 48 | }) 49 | -------------------------------------------------------------------------------- /lib/src/cache.ts: -------------------------------------------------------------------------------- 1 | import * as Archetype from "./archetype" 2 | import * as Debug from "./debug" 3 | import * as Entity from "./entity" 4 | import * as Schema from "./schema" 5 | import * as SparseMap from "./sparse_map" 6 | import * as Type from "./type" 7 | import * as World from "./world" 8 | import * as Symbols from "./symbols" 9 | 10 | // An EntityDelta describes a change made to an entity. 11 | export type EntityDelta = 12 | // Remove (destroy) operations are expressed with a tombstone symbol: 13 | | typeof Symbols.$tombstone 14 | // Component changes (add/remove) are expressed as a sparse map, where the 15 | // keys are schema ids, and the values are component data or tombstones, in 16 | // the case a component was removed. 17 | | SparseMap.Struct 18 | 19 | export type Struct = SparseMap.Struct 20 | 21 | function ensureEntityDelta(cache: Struct, entity: Entity.Id) { 22 | let delta = SparseMap.get(cache, entity) 23 | if (delta === undefined) { 24 | delta = SparseMap.make() 25 | SparseMap.set(cache, entity, delta) 26 | } 27 | return delta 28 | } 29 | 30 | export function set<$Type extends Type.Struct>( 31 | cache: Struct, 32 | entity: Entity.Id, 33 | type: Type.Struct, 34 | data: Archetype.RowData<$Type>, 35 | ) { 36 | const delta = ensureEntityDelta(cache, entity) 37 | if (delta === Symbols.$tombstone) return 38 | for (let i = 0; i < type.length; i++) { 39 | const id = type[i] 40 | Debug.invariant(id !== undefined) 41 | SparseMap.set(delta, id, data[i]) 42 | } 43 | } 44 | 45 | export function unset(cache: Struct, entity: Entity.Id, type: Type.Struct) { 46 | const delta = ensureEntityDelta(cache, entity) 47 | if (delta === Symbols.$tombstone) return 48 | for (let i = 0; i < type.length; i++) { 49 | const id = type[i] 50 | Debug.invariant(id !== undefined) 51 | SparseMap.set(delta, id, Symbols.$tombstone) 52 | } 53 | } 54 | 55 | export function destroy(cache: Struct, entity: Entity.Id) { 56 | SparseMap.set(cache, entity, Symbols.$tombstone) 57 | } 58 | 59 | export function make(): Struct { 60 | return SparseMap.make() 61 | } 62 | 63 | export function apply(cache: Struct, world: World.Struct) { 64 | SparseMap.forEach(cache, function applyEntityDelta(delta, entity) { 65 | if (delta === Symbols.$tombstone) { 66 | Entity.destroy(world, entity) 67 | } else { 68 | SparseMap.forEach(delta, function applyComponentDelta(data, id) { 69 | if (data === Symbols.$tombstone) { 70 | Entity.unset(world, entity, [id]) 71 | } else { 72 | Entity.set(world, entity, [id], [data as any]) 73 | } 74 | }) 75 | } 76 | }) 77 | } 78 | 79 | export function clear(cache: Struct) { 80 | SparseMap.clear(cache) 81 | return cache 82 | } 83 | -------------------------------------------------------------------------------- /lib/src/component.ts: -------------------------------------------------------------------------------- 1 | import * as Archetype from "./archetype" 2 | import * as Schema from "./schema" 3 | import * as Type from "./type" 4 | import * as World from "./world" 5 | 6 | export function expressBinaryShape<$Shape extends Schema.Shape>( 7 | shape: $Shape, 8 | ): Archetype.BinaryData<$Shape> { 9 | if (Schema.isFormat(shape)) { 10 | return 0 as Archetype.BinaryData<$Shape> 11 | } 12 | const object: { [key: string]: unknown } = {} 13 | for (const key in shape) { 14 | object[key] = 0 15 | } 16 | return object as Archetype.BinaryData<$Shape> 17 | } 18 | 19 | export function expressNativeShape<$Shape extends Schema.Shape>( 20 | shape: $Shape, 21 | ): Archetype.NativeData<$Shape> { 22 | if (Schema.isFormat(shape)) { 23 | return 0 as Archetype.NativeData<$Shape> 24 | } 25 | const object: { [key: string]: unknown } = {} 26 | for (const key in shape) { 27 | object[key] = 0 28 | } 29 | return object as Archetype.NativeData<$Shape> 30 | } 31 | 32 | export function expressSchema<$Schema extends Schema.AnySchema>(schema: $Schema) { 33 | switch (schema.kind) { 34 | case Schema.SchemaKind.BinaryScalar: 35 | case Schema.SchemaKind.BinaryStruct: 36 | return expressBinaryShape(schema.shape) 37 | case Schema.SchemaKind.NativeScalar: 38 | case Schema.SchemaKind.NativeObject: 39 | return expressNativeShape(schema.shape) 40 | default: 41 | return undefined 42 | } 43 | } 44 | 45 | export function expressType<$Type extends Type.Struct>( 46 | world: World.Struct, 47 | type: $Type, 48 | ): Archetype.RowData<$Type> { 49 | return type.map(id => 50 | expressSchema(World.findSchemaById(world, id)), 51 | ) as unknown as Archetype.RowData<$Type> 52 | } 53 | -------------------------------------------------------------------------------- /lib/src/component_set.ts: -------------------------------------------------------------------------------- 1 | import * as Archetype from "./archetype" 2 | import * as Debug from "./debug" 3 | import * as Schema from "./schema" 4 | import * as Type from "./type" 5 | import * as World from "./world" 6 | 7 | export type Struct = (Archetype.Data | undefined)[] 8 | export type Init<$Type extends Type.Struct = Type.Struct> = { 9 | [K in keyof $Type]?: Archetype.RowData<$Type>[K] 10 | } 11 | 12 | export function make(world: World.Struct, type: Type.Struct, init: Init): Struct { 13 | const values: Struct = [] 14 | for (let i = 0; i < type.length; i++) { 15 | const id = type[i] 16 | Debug.invariant(id !== undefined) 17 | values[id] = init[i] 18 | } 19 | return values 20 | } 21 | -------------------------------------------------------------------------------- /lib/src/debug.ts: -------------------------------------------------------------------------------- 1 | export class AssertionError extends Error { 2 | readonly inner?: Error 3 | constructor(message: string, inner?: Error) { 4 | super(message) 5 | this.inner = inner 6 | } 7 | } 8 | 9 | export function unwrap(error: unknown) { 10 | let final = error 11 | if (error instanceof AssertionError && error.inner !== undefined) { 12 | final = error.inner 13 | } 14 | return final 15 | } 16 | 17 | export function assert( 18 | predicate: boolean, 19 | message: string = "", 20 | inner?: Error, 21 | ): asserts predicate { 22 | if (predicate === false) { 23 | throw new AssertionError(message, inner) 24 | } 25 | } 26 | 27 | export function invariant( 28 | predicate: boolean, 29 | message: string = "", 30 | inner?: Error, 31 | ): asserts predicate { 32 | if (predicate === false) { 33 | throw new AssertionError(message, inner) 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /lib/src/encode.ts: -------------------------------------------------------------------------------- 1 | import * as Format from "./format" 2 | import * as Query from "./query" 3 | import * as Symbols from "./symbols" 4 | import * as Schema from "./schema" 5 | import * as Type from "./type" 6 | import * as World from "./world" 7 | import * as Debug from "./debug" 8 | import * as Entity from "./entity" 9 | import * as Archetype from "./archetype" 10 | 11 | type Part = { 12 | data: number[] 13 | view: Format.Format[] 14 | byteLength: number 15 | } 16 | 17 | function push(part: Part, data: number, format: Format.Format) { 18 | const dataLength = part.data.push(data) 19 | const viewLength = part.view.push(format) 20 | Debug.invariant(dataLength === viewLength) 21 | part.byteLength += format.binary.BYTES_PER_ELEMENT 22 | return dataLength - 1 23 | } 24 | 25 | function write( 26 | part: Part, 27 | handle: number, 28 | data: number, 29 | format: Format.Format = part.view[handle]!, 30 | ) { 31 | part.byteLength += 32 | format.binary.BYTES_PER_ELEMENT - part.view[handle]!.binary.BYTES_PER_ELEMENT 33 | part.data[handle] = data 34 | part.view[handle] = format 35 | } 36 | 37 | function reset(part: Part) { 38 | while (part.data.pop()) {} 39 | while (part.view.pop()) {} 40 | part.byteLength = 0 41 | } 42 | 43 | function encodeRow<$Type extends Type.Struct>( 44 | part: Part, 45 | index: number, 46 | schemas: Schema.AnySchema[], 47 | recordData: Query.RecordData<$Type>, 48 | ) { 49 | for (let j = 0; j < schemas.length; j++) { 50 | const schema = schemas[j] 51 | Debug.invariant(schema !== undefined) 52 | const data = recordData[j] 53 | Debug.invariant(data !== undefined) 54 | switch (schema.kind) { 55 | case Schema.SchemaKind.BinaryScalar: 56 | case Schema.SchemaKind.NativeScalar: 57 | const value = (data as Archetype.ColumnOfSchema["data"])[index] 58 | Debug.invariant(typeof value === "number") 59 | push(part, value, schema.shape) 60 | break 61 | case Schema.SchemaKind.BinaryStruct: { 62 | for (const prop in schema.shape) { 63 | const format = schema.shape[prop] 64 | Debug.invariant(format !== undefined) 65 | const field = (data as Archetype.ColumnOfSchema["data"])[prop] 66 | Debug.invariant(field !== undefined) 67 | const value = field[index] 68 | Debug.invariant(typeof value === "number") 69 | push(part, value, format) 70 | } 71 | break 72 | } 73 | case Schema.SchemaKind.NativeObject: { 74 | const component = (data as Archetype.ColumnOfSchema["data"])[index] 75 | Debug.invariant(component !== undefined) 76 | for (const prop in schema.shape) { 77 | const value = component[prop] 78 | Debug.invariant(typeof value === "number") 79 | const format = schema.shape[prop] 80 | Debug.invariant(format !== undefined) 81 | push(part, value, format) 82 | } 83 | break 84 | } 85 | } 86 | } 87 | } 88 | 89 | export function encodeQuery<$Type extends Type.Struct>( 90 | world: World.Struct, 91 | query: Query.Struct<$Type>, 92 | part: Part, 93 | filter?: (entity: Entity.Id) => boolean, 94 | ) { 95 | const type = query[Symbols.$type] 96 | const schemas = type.map(schemaId => World.findSchemaById(world, schemaId)) 97 | for (let i = 0; i < type.length; i++) { 98 | const schemaId = type[i] 99 | Debug.invariant(schemaId !== undefined) 100 | push(part, schemaId, Format.uint32) 101 | } 102 | let size = 0 103 | let sizeHandle = push(part, size, Format.uint32) 104 | if (filter === undefined) { 105 | for (const [entities, recordData] of query) { 106 | size += entities.length 107 | for (let i = 0; i < entities.length; i++) { 108 | const entity = entities[i] 109 | Debug.invariant(entity !== undefined) 110 | push(part, entity, Format.uint32) 111 | encodeRow(part, i, schemas, recordData) 112 | } 113 | } 114 | } else { 115 | for (const [entities, recordData] of query) { 116 | for (let i = 0; i < entities.length; i++) { 117 | const entity = entities[i] 118 | Debug.invariant(entity !== undefined) 119 | if (filter(entity) === false) continue 120 | size++ 121 | push(part, entity, Format.uint32) 122 | encodeRow(part, i, schemas, recordData) 123 | } 124 | } 125 | } 126 | write(part, sizeHandle, size) 127 | } 128 | 129 | export function serialize( 130 | part: Part, 131 | view: DataView = new DataView(new ArrayBuffer(part.byteLength)), 132 | offset = 0, 133 | ) { 134 | Debug.assert( 135 | offset + part.byteLength > view.byteLength, 136 | "Failed to serialize part", 137 | new RangeError("Part will not fit in target ArrayBuffer"), 138 | ) 139 | for (let i = 0; i < part.data.length; i++) { 140 | const value = part.data[i] 141 | const format = part.view[i] 142 | Debug.invariant(value !== undefined) 143 | Debug.invariant(format !== undefined) 144 | switch (format.kind) { 145 | case Format.Kind.Uint8: 146 | view.setUint8(offset, value) 147 | break 148 | case Format.Kind.Uint16: 149 | view.setUint16(offset, value) 150 | break 151 | case Format.Kind.Uint32: 152 | view.setUint32(offset, value) 153 | break 154 | case Format.Kind.Int8: 155 | view.setInt8(offset, value) 156 | break 157 | case Format.Kind.Int16: 158 | view.setInt16(offset, value) 159 | break 160 | case Format.Kind.Int32: 161 | view.setInt32(offset, value) 162 | break 163 | case Format.Kind.Float32: 164 | view.setFloat32(offset, value) 165 | break 166 | case Format.Kind.Float64: 167 | view.setFloat64(offset, value) 168 | break 169 | } 170 | offset += format.binary.BYTES_PER_ELEMENT 171 | } 172 | return view.buffer 173 | } 174 | 175 | export function deserializeQuery(world: World.Struct, view: DataView, offset = 0) {} 176 | -------------------------------------------------------------------------------- /lib/src/entity.spec.ts: -------------------------------------------------------------------------------- 1 | import * as World from "./world" 2 | import * as Entity from "./entity" 3 | import * as Schema from "./schema" 4 | import * as Format from "./format" 5 | 6 | describe("Entity", () => { 7 | describe("make", () => { 8 | it("makes an entity with configured components", () => { 9 | const world = World.make(1) 10 | const Type = [Schema.make(world, Format.uint8)] 11 | const entity = Entity.make(world, Type) 12 | expect(Entity.has(world, entity, Type)).toBe(true) 13 | }) 14 | }) 15 | 16 | describe("get", () => { 17 | it("returns a slice of entity component data", () => { 18 | const world = World.make(1) 19 | const Type = [ 20 | Schema.make(world, { squad: Format.uint8 }), 21 | Schema.makeTag(world), 22 | Schema.makeBinary(world, Format.uint8), 23 | Schema.makeBinary(world, { x: Format.float64, y: Format.float64 }), 24 | ] 25 | const entity = Entity.make(world, Type) 26 | const data = [{ squad: 0 }, undefined, 0, { x: 0, y: 0 }] 27 | expect(Entity.get(world, entity, Type)).toEqual(data) 28 | }) 29 | }) 30 | 31 | describe("set", () => { 32 | it("updates entity component data", () => { 33 | const world = World.make(1) 34 | const Type = [ 35 | Schema.make(world, { squad: Format.uint8 }), 36 | Schema.makeTag(world), 37 | Schema.makeBinary(world, Format.uint8), 38 | Schema.makeBinary(world, { x: Format.float64, y: Format.float64 }), 39 | ] 40 | const entity = Entity.make(world, Type) 41 | const data = [{ squad: 1 }, , 9, { x: 10, y: 11 }] 42 | Entity.set(world, entity, Type, data) 43 | expect(Entity.get(world, entity, Type)).toEqual(data) 44 | }) 45 | it("adds new components", () => { 46 | const world = World.make(1) 47 | const TypePrev = [Schema.make(world, Format.uint8)] 48 | const TypeNext = [...TypePrev, Schema.make(world, Format.uint8)] 49 | const entity = Entity.make(world, TypePrev) 50 | expect(Entity.has(world, entity, TypePrev)).toBe(true) 51 | expect(Entity.has(world, entity, TypeNext)).toBe(false) 52 | Entity.set(world, entity, TypeNext) 53 | expect(Entity.has(world, entity, TypePrev)).toBe(true) 54 | expect(Entity.has(world, entity, TypeNext)).toBe(true) 55 | }) 56 | it("throws an error if the entity does not exist", () => { 57 | const world = World.make(1) 58 | const Type = [Schema.make(world, Format.uint8)] 59 | expect(() => Entity.set(world, 99, Type)).toThrow() 60 | }) 61 | }) 62 | 63 | describe("unset", () => { 64 | it("removes entity components", () => { 65 | const world = World.make(1) 66 | const TypePrev = [ 67 | Schema.make(world, Format.uint8), 68 | Schema.make(world, Format.uint8), 69 | ] 70 | const TypeRemove = TypePrev.slice(0, 1) 71 | const TypeFinal = TypePrev.slice(1) 72 | const entity = Entity.make(world, TypePrev) 73 | Entity.unset(world, entity, TypeRemove) 74 | expect(Entity.has(world, entity, TypePrev)).toBe(false) 75 | expect(Entity.has(world, entity, TypeFinal)).toBe(true) 76 | }) 77 | it("throws an error if the entity does not exist", () => { 78 | const world = World.make(1) 79 | const Type = [Schema.make(world, Format.uint8)] 80 | expect(() => Entity.set(world, 99, Type)).toThrow() 81 | }) 82 | }) 83 | 84 | describe("destroy", () => { 85 | it("removes all entity components", () => { 86 | const world = World.make(1) 87 | const Type = [Schema.make(world, Format.uint8), Schema.make(world, Format.float64)] 88 | const entity = Entity.make(world, Type) 89 | Entity.destroy(world, entity) 90 | expect(Entity.tryHas(world, entity, Type)).toBe(false) 91 | }) 92 | it("throws an error if the entity does not exist", () => { 93 | const world = World.make(1) 94 | expect(() => Entity.destroy(world, 99)).toThrow() 95 | }) 96 | }) 97 | }) 98 | -------------------------------------------------------------------------------- /lib/src/entity.ts: -------------------------------------------------------------------------------- 1 | import * as Archetype from "./archetype" 2 | import * as Graph from "./archetype_graph" 3 | import * as ComponentSet from "./component_set" 4 | import * as Debug from "./debug" 5 | import * as Type from "./type" 6 | import * as World from "./world" 7 | 8 | /** 9 | * An unsigned integer between 0 and `Number.MAX_SAFE_INTEGER` that uniquely 10 | * identifies an entity or schema within the ECS. 11 | */ 12 | export type Id = number 13 | 14 | /** 15 | * Reserve an entity id without inserting it into the world. 16 | * 17 | * @example Add a component to a reserved entity 18 | * ```ts 19 | * const entity = Entity.reserve(world) 20 | * Entity.has(world, entity, [Position]) // Error: Failed ... entity is not real 21 | * Entity.set(world, entity, [Position]) 22 | * Entity.has(world, entity, [Position]) // true 23 | * ``` 24 | */ 25 | export function reserve(world: World.Struct, entity = world.entityHead) { 26 | while ( 27 | !(world.entityIndex[entity] === undefined && world.schemaIndex[entity] === undefined) 28 | ) { 29 | entity = world.entityHead++ 30 | } 31 | return entity 32 | } 33 | 34 | /** 35 | * Create an entity using an array of schema ids as a template. Optionally 36 | * accepts an array of data used to initialize components. Undefined values 37 | * within the initializer array are filled with defaults (e.g. `0` for numeric 38 | * Format). 39 | * 40 | * @example Make an entity with no components 41 | * ```ts 42 | * Entity.make(world, []) 43 | * ``` 44 | * @example Make an entity with one or more components 45 | * ```ts 46 | * Entity.make(world, [Health, Stamina]) 47 | * ``` 48 | * @example Initialize component values 49 | * ```ts 50 | * Entity.make(world, [Health, Stamina], [120, 100]) 51 | * ``` 52 | * @example Initialize a single component value 53 | * ```ts 54 | * Entity.make(world, [Position, Velocity], [, { x: -10, y: 42 }]) 55 | * ``` 56 | */ 57 | export function make<$Type extends Type.Struct>( 58 | world: World.Struct, 59 | layout = [] as unknown as $Type, 60 | init = [] as unknown as ComponentSet.Init<$Type>, 61 | ) { 62 | const entity = reserve(world) 63 | const components = ComponentSet.make(world, layout, init) 64 | const archetype = Graph.findOrMakeArchetype(world, Type.normalize(layout)) 65 | Archetype.insert(archetype, entity, components) 66 | World.setEntityArchetype(world, entity, archetype) 67 | return entity 68 | } 69 | 70 | /** 71 | * Get the value of one or more components for an entity. Throws an error if 72 | * the entity is not real, or if it does not have a component of each of 73 | * the provided schema ids. 74 | * 75 | * @example Get the value of a single component 76 | * ```ts 77 | * const [position] = Entity.get(world, entity, [Position]) 78 | * ``` 79 | * @example Get the value of multiple components 80 | * ```ts 81 | * const [health, inventory] = Entity.get(world, entity, [Health, Inventory]) 82 | * ``` 83 | * @example Re-use an array to avoid allocating intermediate array 84 | * ```ts 85 | * const results = [] 86 | * const [health, stats] = Entity.get(world, entity, Player, results) 87 | * ``` 88 | */ 89 | export function get<$Type extends Type.Struct>( 90 | world: World.Struct, 91 | entity: Id, 92 | layout: $Type, 93 | out: unknown[] = [], 94 | ) { 95 | const archetype = World.getEntityArchetype(world, entity) 96 | Debug.invariant(has(world, entity, layout)) 97 | return Archetype.read(entity, archetype, layout, out) 98 | } 99 | 100 | /** 101 | * Update the value of one or more components for an entity. If the entity does 102 | * not yet have components of the provided schema ids, add them. Throws an 103 | * error if the entity is not real. 104 | * 105 | * This function has the same interface as `Entity.make`, that is, it 106 | * optionally accepts a sparse array of initial component values. 107 | * 108 | * @example Add or update a single component 109 | * ```ts 110 | * Entity.set(world, entity, [Position]) 111 | * ``` 112 | * @example Initialize component values 113 | * ```ts 114 | * Entity.set(world, entity, [Health, Stamina], [100, 120]) 115 | * ``` 116 | * @example Update an existing component and add a new component 117 | * ```ts 118 | * const entity = Entity.make(world, [Health], [100]) 119 | * Entity.set(world, entity, [Health, Stamina], [99]) 120 | * ``` 121 | */ 122 | export function set<$Type extends Type.Struct>( 123 | world: World.Struct, 124 | entity: Id, 125 | layout: $Type, 126 | init = [] as unknown as ComponentSet.Init<$Type>, 127 | ) { 128 | const components = ComponentSet.make(world, layout, init) 129 | const prevArchetype = World.getEntityArchetype(world, entity) 130 | const nextArchetype = Graph.findOrMakeArchetype( 131 | world, 132 | Type.and(prevArchetype.type, layout), 133 | ) 134 | if (prevArchetype === nextArchetype) { 135 | Archetype.write(entity, prevArchetype, components) 136 | } else { 137 | Archetype.move(entity, prevArchetype, nextArchetype, components) 138 | } 139 | World.setEntityArchetype(world, entity, nextArchetype) 140 | } 141 | 142 | /** 143 | * Remove one or more components from an entity. Throws an error if the entity 144 | * does not exist. 145 | * 146 | * @example Remove a single component from an entity 147 | * ```ts 148 | * Entity.unset(world, entity, [Health]) 149 | * ``` 150 | * @example Remove multiple components from an entity 151 | * ```ts 152 | * Entity.unset(world, entity, [Health, Faction]) 153 | * ``` 154 | */ 155 | export function unset<$Type extends Type.Struct>( 156 | world: World.Struct, 157 | entity: Id, 158 | layout: $Type, 159 | ) { 160 | const prevArchetype = World.getEntityArchetype(world, entity) 161 | const nextArchetype = Graph.findOrMakeArchetype( 162 | world, 163 | Type.xor(prevArchetype.type, layout), 164 | ) 165 | Archetype.move(entity, prevArchetype, nextArchetype) 166 | World.setEntityArchetype(world, entity, nextArchetype) 167 | } 168 | 169 | /** 170 | * Check if an entity has one or more components. Returns true if the entity 171 | * has a component corresponding to all of the provided schema ids, otherwise 172 | * returns false. Throws an error if the entity is not real. 173 | * 174 | * @example Check if an entity has a single component 175 | * ```ts 176 | * Entity.has(world, entity, [Health]) 177 | * ``` 178 | * @example Check if an entity has multiple components 179 | * ```ts 180 | * Entity.has(world, entity, [Position, Velocity]) 181 | * ``` 182 | */ 183 | export function has(world: World.Struct, entity: Id, layout: Type.Struct) { 184 | const archetype = World.getEntityArchetype(world, entity) 185 | const type = Type.normalize(layout) 186 | return Type.isEqual(archetype.type, type) || Type.isSupersetOf(archetype.type, type) 187 | } 188 | 189 | /** 190 | * Check if an entity has one or more components. Unlike `has`, `tryHas` will 191 | * not throw if the entity is not real. 192 | */ 193 | export function tryHas(world: World.Struct, entity: Id, layout: Type.Struct) { 194 | try { 195 | return has(world, entity, layout) 196 | } catch (error) { 197 | const final = Debug.unwrap(error) 198 | if (final instanceof World.EntityNotRealError) { 199 | return false 200 | } 201 | throw final 202 | } 203 | } 204 | 205 | /** 206 | * Remove all components from an entity. Throws an error if the entity does 207 | * not exist. 208 | * 209 | * @example Destroy an entity 210 | * ```ts 211 | * Entity.destroy(world, entity) 212 | * ``` 213 | */ 214 | export function destroy(world: World.Struct, entity: Id) { 215 | const archetype = World.getEntityArchetype(world, entity) 216 | Archetype.remove(archetype, entity) 217 | World.unsetEntityArchetype(world, entity) 218 | } 219 | -------------------------------------------------------------------------------- /lib/src/format.ts: -------------------------------------------------------------------------------- 1 | import * as Types from "./types" 2 | import * as Symbols from "./symbols" 3 | 4 | export enum Kind { 5 | Uint8, 6 | Uint16, 7 | Uint32, 8 | Int8, 9 | Int16, 10 | Int32, 11 | Float32, 12 | Float64, 13 | } 14 | 15 | /** 16 | * Number format. Encloses a TypedArray constructor which corresponds to the 17 | * format kind, (e.g. `FormatKind.Uint8`->`Uint8Array`). 18 | */ 19 | export type Format< 20 | $Kind extends Kind = Kind, 21 | $Binary extends Types.TypedArrayConstructor = Types.TypedArrayConstructor, 22 | > = { kind: $Kind; binary: $Binary } 23 | 24 | function makeFormat<$Kind extends Kind, $Binary extends Types.TypedArrayConstructor>( 25 | kind: $Kind, 26 | binary: $Binary, 27 | ): Format<$Kind, $Binary> { 28 | return { 29 | [Symbols.$format]: true, 30 | kind, 31 | binary, 32 | } as { kind: $Kind; binary: $Binary } 33 | } 34 | 35 | export const float32 = makeFormat(Kind.Float32, Float32Array) 36 | export const float64 = makeFormat(Kind.Float64, Float64Array) 37 | export const uint8 = makeFormat(Kind.Uint8, Uint8Array) 38 | export const uint16 = makeFormat(Kind.Uint16, Uint16Array) 39 | export const uint32 = makeFormat(Kind.Uint32, Uint32Array) 40 | export const int8 = makeFormat(Kind.Int8, Int8Array) 41 | export const int16 = makeFormat(Kind.Int16, Int16Array) 42 | export const int32 = makeFormat(Kind.Int32, Int32Array) 43 | -------------------------------------------------------------------------------- /lib/src/index.ts: -------------------------------------------------------------------------------- 1 | export type { Data } from "./archetype" 2 | /** 3 | * A module used to create and maintain **caches**—temporary stores for world 4 | * operations that can be deferred and applied at a later time, and to other 5 | * worlds. 6 | */ 7 | export * as Cache from "./cache" 8 | /** 9 | * A module used to manage entities and their components. 10 | */ 11 | export * as Entity from "./entity" 12 | /** 13 | * A module used to locate entities based on their component composition. 14 | */ 15 | export * as Query from "./query" 16 | /** 17 | * A module used to create **schema**—component templates. 18 | */ 19 | export * as Schema from "./schema" 20 | /** 21 | * A small, stringless, strongly-typed event system. 22 | */ 23 | export * as Signal from "./signal" 24 | /** 25 | * A high-performance map that references values using unsigned integers. 26 | */ 27 | export * as SparseMap from "./sparse_map" 28 | /** 29 | * A module used to create, combine, and examine **types**—entity component 30 | * types. 31 | */ 32 | export * as Type from "./type" 33 | /** 34 | * General-purpose, static TypeScript types. 35 | */ 36 | export * as Types from "./types" 37 | /** 38 | * A module used to create and manage **worlds**—the root object of a Harmony game. 39 | */ 40 | export * as World from "./world" 41 | /** @internal */ 42 | export * as Symbols from "./symbols" 43 | /** @internal */ 44 | export * as Debug from "./debug" 45 | 46 | /** 47 | * An enum of Harmony's supported data types. 48 | */ 49 | export * as Format from "./format" 50 | -------------------------------------------------------------------------------- /lib/src/query.ts: -------------------------------------------------------------------------------- 1 | import * as Archetype from "./archetype" 2 | import * as Graph from "./archetype_graph" 3 | import * as Debug from "./debug" 4 | import * as Entity from "./entity" 5 | import * as Schema from "./schema" 6 | import * as Signal from "./signal" 7 | import * as Type from "./type" 8 | import * as World from "./world" 9 | import * as Symbols from "./symbols" 10 | 11 | /** 12 | * Component data sorted to match a query selector layout. 13 | * 14 | * @example Scalar binary query data 15 | * ```ts 16 | * const data: Query.RecordData<[Health]> = [ 17 | * Float64Array, 18 | * ] 19 | * ``` 20 | * @example Complex binary query data (struct-of-array) 21 | * ```ts 22 | * const data: Query.RecordData<[Position, Health]> = [ 23 | * [{ x: Float64Array, y: Float64Array }], 24 | * Float64Array, 25 | * ] 26 | * ``` 27 | * @example Complex native query data (array-of-struct) 28 | * ```ts 29 | * const data: Query.RecordData<[Position, Health]> = [ 30 | * [{ x: 0, y: 0 }], 31 | * [0], 32 | * ] 33 | * ``` 34 | */ 35 | export type RecordData<$Type extends Type.Struct> = { 36 | [K in keyof $Type]: $Type[K] extends Schema.Id 37 | ? Archetype.Column<$Type[K]>["data"] 38 | : never 39 | } 40 | 41 | /** 42 | * A single query result. 43 | * 44 | * @example Iterating a query record (array-of-struct) 45 | * ```ts 46 | * const [e, [p, v]] = record 47 | * for (let i = 0; i < e.length; i++) { 48 | * const entity = e[i] 49 | * const position = p[i] 50 | * const velocity = v[i] 51 | * // ... 52 | * } 53 | * ``` 54 | */ 55 | export type Record<$Type extends Type.Struct> = [ 56 | entities: ReadonlyArray, 57 | data: RecordData<$Type>, 58 | ] 59 | 60 | /** 61 | * An iterable list of query records, where each result corresponds to an 62 | * archetype (or archetype) of entities that match the query's selector. 63 | * 64 | * @example Iterate a query 65 | * ```ts 66 | * const query = Query.make(world, [Position, Velocity]) 67 | * for (let i = 0; i < query.length; i++) { 68 | * const [entities, [p, v]] = record 69 | * // ... 70 | * } 71 | * ``` 72 | */ 73 | export type Struct<$Type extends Type.Struct = Type.Struct> = Record<$Type>[] & { 74 | [Symbols.$type]: Type.Struct 75 | } 76 | /** 77 | * A function that is executed when an entity is considered by a query. Returning 78 | * false will exclude the entity from the query results. 79 | */ 80 | export type Filter = (type: Type.Struct, archetype: Archetype.Struct) => boolean 81 | 82 | function bindArchetype<$Type extends Type.Struct>( 83 | records: Struct<$Type>, 84 | layout: $Type, 85 | archetype: Archetype.Struct, 86 | ) { 87 | const columns = layout.map(function findColumnDataById(id) { 88 | const columnIndex = archetype.layout[id] 89 | Debug.invariant(columnIndex !== undefined) 90 | const column = archetype.store[columnIndex] 91 | Debug.invariant(column !== undefined) 92 | return column.data 93 | }) 94 | records.push([archetype.entities, columns as unknown as RecordData<$Type>]) 95 | } 96 | 97 | function maybeBindArchetype<$Type extends Type.Struct>( 98 | records: Struct<$Type>, 99 | type: Type.Struct, 100 | layout: $Type, 101 | archetype: Archetype.Struct, 102 | filters: Filter[], 103 | ) { 104 | if ( 105 | filters.every(function testPredicate(predicate) { 106 | return predicate(type, archetype) 107 | }) 108 | ) { 109 | if (archetype.real) { 110 | bindArchetype(records, layout, archetype) 111 | } else { 112 | const unsubscribe = Signal.subscribe( 113 | archetype.onRealize, 114 | function bindArchetypeAndUnsubscribe() { 115 | bindArchetype(records, layout, archetype) 116 | unsubscribe() 117 | }, 118 | ) 119 | } 120 | } 121 | } 122 | 123 | function makeStaticQueryInternal<$Type extends Type.Struct>( 124 | type: Type.Struct, 125 | layout: $Type, 126 | archetype: Archetype.Struct, 127 | filters: Filter[], 128 | ): Struct<$Type> { 129 | Type.invariantNormalized(type) 130 | const query: Struct<$Type> = Object.assign([], { [Symbols.$type]: type }) 131 | maybeBindArchetype(query, type, layout, archetype, filters) 132 | Graph.traverse(archetype, function maybeBindNextArchetype(archetype) { 133 | maybeBindArchetype(query, type, layout, archetype, filters) 134 | }) 135 | return query 136 | } 137 | 138 | /** 139 | * Create an auto-updating list of entities and matching components that match 140 | * both a set of schema ids and provided query filters, if any. The query will 141 | * attempt to include newly-incorporated archetypes as the ECS grows in 142 | * complexity. 143 | * 144 | * @example A simple motion system (struct-of-array) 145 | * ```ts 146 | * const Kinetic = [Position, Velocity] as const 147 | * const kinetics = Query.make(world, Kinetic) 148 | * for (let i = 0; i < kinetics.length; i++) { 149 | * const [e, [p, v]] = kinetics 150 | * for (let j = 0; j < e.length; j++) { 151 | * // apply motion 152 | * p.x[j] += v.x[j] 153 | * p.y[j] += v.y[j] 154 | * } 155 | * } 156 | * ``` 157 | */ 158 | export function make<$Type extends Type.Struct>( 159 | world: World.Struct, 160 | layout: $Type, 161 | ...filters: Filter[] 162 | ): Struct<$Type> { 163 | const type = Type.normalize(layout) 164 | const identity = Graph.findOrMakeArchetype(world, type) 165 | const query = makeStaticQueryInternal(type, layout, identity, filters) 166 | Signal.subscribe( 167 | identity.onTableInsert, 168 | function maybeBindInsertedArchetype(archetype) { 169 | maybeBindArchetype(query, type, layout, archetype, filters) 170 | }, 171 | ) 172 | return query 173 | } 174 | 175 | /** 176 | * Create an auto-updating list of results that match both a set of schema ids 177 | * and provided query filters, if any. The query will **not** attempt to 178 | * include newly-incorporated archetypes. 179 | * 180 | * @example Ignoring new archetypes 181 | * ```ts 182 | * Entity.make(world, [Point]) 183 | * const points = Query.make(world, [Point]) 184 | * points.reduce((a, [e]) => a + e.length, 0) // 1 185 | * Entity.make(world, [Point, Velocity]) 186 | * points.reduce((a, [e]) => a + e.length, 0) // 1 (did not detect (P, V) archetype) 187 | * ``` 188 | */ 189 | export function makeStatic<$Type extends Type.Struct>( 190 | world: World.Struct, 191 | layout: $Type, 192 | ...filters: Filter[] 193 | ): Struct<$Type> { 194 | const type = Type.normalize(layout) 195 | const identity = Graph.findOrMakeArchetype(world, type) 196 | return makeStaticQueryInternal(type, layout, identity, filters) 197 | } 198 | 199 | /** 200 | * A query filter that will exclude entities with all of the specified 201 | * components. 202 | */ 203 | export function not(layout: Type.Struct) { 204 | const type = Type.normalize(layout) 205 | return function isArchetypeNotSupersetOfType( 206 | _: Type.Struct, 207 | archetype: Archetype.Struct, 208 | ) { 209 | return !Type.isSupersetOf(archetype.type, type) 210 | } 211 | } 212 | -------------------------------------------------------------------------------- /lib/src/schema.ts: -------------------------------------------------------------------------------- 1 | import * as Entity from "./entity" 2 | import * as Format from "./format" 3 | import * as Symbols from "./symbols" 4 | import * as Types from "./types" 5 | import * as World from "./world" 6 | 7 | /** @internal */ 8 | export enum SchemaKind { 9 | NativeScalar, 10 | NativeObject, 11 | BinaryScalar, 12 | BinaryStruct, 13 | Tag, 14 | } 15 | 16 | /** @internal */ 17 | export type NativeScalarSchema<$Shape extends Format.Format = Format.Format> = { 18 | id: number 19 | kind: SchemaKind.NativeScalar 20 | shape: $Shape 21 | } 22 | 23 | /** @internal */ 24 | type NativeObjectShape = { [key: string]: Format.Format } 25 | 26 | /** @internal */ 27 | export type NativeObjectSchema<$Shape extends NativeObjectShape = NativeObjectShape> = { 28 | id: number 29 | kind: SchemaKind.NativeObject 30 | shape: $Shape 31 | } 32 | 33 | /** @internal */ 34 | export type BinaryScalarSchema<$Shape extends Format.Format = Format.Format> = { 35 | id: number 36 | kind: SchemaKind.BinaryScalar 37 | shape: $Shape 38 | } 39 | 40 | /** @internal */ 41 | export type BinaryStructSchema< 42 | $Shape extends { [key: string]: Format.Format } = { [key: string]: Format.Format }, 43 | > = { 44 | id: number 45 | kind: SchemaKind.BinaryStruct 46 | shape: $Shape 47 | } 48 | 49 | /** @internal */ 50 | export type TagSchema = { 51 | id: number 52 | kind: SchemaKind.Tag 53 | shape: null 54 | } 55 | 56 | /** @internal */ 57 | export type AnyNativeSchema = NativeScalarSchema | NativeObjectSchema 58 | /** @internal */ 59 | export type AnyBinarySchema = BinaryScalarSchema | BinaryStructSchema 60 | /** @internal */ 61 | export type ComplexSchema = AnyBinarySchema | AnyNativeSchema 62 | /** @internal */ 63 | export type AnySchema = ComplexSchema | TagSchema 64 | /** @internal */ 65 | export type Shape<$Type extends { shape: unknown }> = $Type["shape"] 66 | 67 | /** 68 | * An entity id wrapped in a generic type that allows Harmony to infer the 69 | * component shape from the underlying primitive type. 70 | */ 71 | export type Id<$Schema extends AnySchema = AnySchema> = Types.Opaque 72 | 73 | /** 74 | * A schema whose components are standard JavaScript objects or scalar values. 75 | * Unlike binary components, native components are stored in an array-of- 76 | * structs architecture. 77 | */ 78 | export type NativeSchema<$Shape extends Shape> = 79 | $Shape extends Shape 80 | ? Id> 81 | : $Shape extends Shape 82 | ? Id> 83 | : never 84 | 85 | /** 86 | * A schema whose components are stored in one or more TypedArrays. If the 87 | * schema shape is complex (i.e non-scalar), derived components will be stored 88 | * in a struct-of-array architecture, where each component field is allocated a 89 | * separate, tightly-packed TypedArray. 90 | */ 91 | export type BinarySchema<$Shape extends Shape> = 92 | $Shape extends Shape 93 | ? Id> 94 | : $Shape extends Shape 95 | ? Id> 96 | : never 97 | 98 | /** @internal */ 99 | export function isFormat(object: object): object is Format.Format { 100 | return Symbols.$format in object 101 | } 102 | 103 | /** @internal */ 104 | export function isNativeSchema(schema: ComplexSchema): schema is AnyNativeSchema { 105 | return ( 106 | schema.kind === SchemaKind.NativeScalar || schema.kind === SchemaKind.NativeObject 107 | ) 108 | } 109 | 110 | /** @internal */ 111 | export function isBinarySchema(schema: ComplexSchema): schema is AnyBinarySchema { 112 | return ( 113 | schema.kind === SchemaKind.BinaryScalar || schema.kind === SchemaKind.BinaryStruct 114 | ) 115 | } 116 | 117 | /** 118 | * Create a native schema. Returns an id that can be used to reference the 119 | * schema throughout Harmony's API. 120 | * 121 | * @example Create a scalar native schema 122 | * ```ts 123 | * const Health = Schema.make(world, Format.uint32) 124 | * ``` 125 | * @example Create a complex native schema 126 | * ```ts 127 | * const Position = Schema.make(world, { 128 | * x: Format.float64, 129 | * y: Format.float64 130 | * }) 131 | * ``` 132 | */ 133 | export function make<$Shape extends Shape>( 134 | world: World.Struct, 135 | shape: $Shape, 136 | reserve?: number, 137 | ): NativeSchema<$Shape> { 138 | const id = Entity.reserve(world, reserve) 139 | let schema: AnyNativeSchema 140 | if (isFormat(shape)) { 141 | schema = { id, kind: SchemaKind.NativeScalar, shape } 142 | } else { 143 | schema = { id, kind: SchemaKind.NativeObject, shape } 144 | } 145 | World.registerSchema(world, id, schema) 146 | return id as NativeSchema<$Shape> 147 | } 148 | 149 | /** 150 | * Create a binary schema. Returns an id that is used to reference the schema 151 | * throughout Harmony's API. 152 | * 153 | * @example Create a scalar binary schema 154 | * ```ts 155 | * const Health = Schema.makeBinary(world, Format.uint32) 156 | * ``` 157 | * @example Create a complex binary schema 158 | * ```ts 159 | * const Position = Schema.makeBinary(world, { 160 | * x: Format.float64, 161 | * y: Format.float64 162 | * }) 163 | * ``` 164 | */ 165 | export function makeBinary<$Shape extends Shape>( 166 | world: World.Struct, 167 | shape: $Shape, 168 | reserve?: number, 169 | ): BinarySchema<$Shape> { 170 | const id = Entity.reserve(world, reserve) 171 | let schema: AnyBinarySchema 172 | if (isFormat(shape)) { 173 | schema = { id, kind: SchemaKind.BinaryScalar, shape } 174 | } else { 175 | schema = { id, kind: SchemaKind.BinaryStruct, shape } 176 | } 177 | World.registerSchema(world, id, schema) 178 | return id as BinarySchema<$Shape> 179 | } 180 | 181 | /** 182 | * Create a tag schema – a data-less, lightweight component type that is faster 183 | * than complex or scalar components since there is no data to copy when moving 184 | * an entity between archetypes. 185 | * 186 | * Returns an id that is used to reference the schema throughout Harmony's API. 187 | * 188 | * @example Create a tag schema 189 | * ```ts 190 | * const Frozen = Schema.makeTag(world) 191 | * ``` 192 | */ 193 | export function makeTag(world: World.Struct, reserve?: number): Id { 194 | const id = Entity.reserve(world, reserve) 195 | World.registerSchema(world, id, { id, kind: SchemaKind.Tag, shape: null }) 196 | return id as Id 197 | } 198 | -------------------------------------------------------------------------------- /lib/src/signal.ts: -------------------------------------------------------------------------------- 1 | import * as Debug from "./debug" 2 | 3 | export type Subscriber = (t: T) => void 4 | export type Struct = Subscriber[] 5 | 6 | export function make(): Struct { 7 | return [] 8 | } 9 | 10 | export function subscribe(subscribers: Struct, subscriber: Subscriber) { 11 | const index = subscribers.push(subscriber) - 1 12 | return function unsubscribe() { 13 | subscribers.splice(index, 1) 14 | } 15 | } 16 | 17 | export function dispatch(subscribers: Struct, t: T) { 18 | for (let i = subscribers.length - 1; i >= 0; i--) { 19 | const subscriber = subscribers[i] 20 | Debug.invariant(subscriber !== undefined) 21 | subscriber(t!) 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /lib/src/sparse_map.spec.ts: -------------------------------------------------------------------------------- 1 | import * as SparseMap from "./sparse_map" 2 | 3 | describe("SparseMap", () => { 4 | describe("make", () => { 5 | it("creates an empty SparseMap when no values are provided", () => { 6 | const sparseMap = SparseMap.make() 7 | expect(sparseMap.size).toBe(0) 8 | }) 9 | it("creates a SparseMap using a sparse array, initializing an entry for each index-value pair", () => { 10 | const sparseMap = SparseMap.make([, , , "a"]) 11 | expect(SparseMap.get(sparseMap, 3)).toBe("a") 12 | expect(sparseMap.size).toBe(1) 13 | }) 14 | }) 15 | 16 | describe("get", () => { 17 | it("returns the value of a entry at the provided key", () => { 18 | const sparseMap = SparseMap.make(["a", "b"]) 19 | expect(SparseMap.get(sparseMap, 0)).toBe("a") 20 | expect(SparseMap.get(sparseMap, 1)).toBe("b") 21 | }) 22 | it("returns undefined for non-existing keys", () => { 23 | const sparseMap = SparseMap.make([, "a", "b"]) 24 | expect(SparseMap.get(sparseMap, 0)).toBe(undefined) 25 | }) 26 | }) 27 | 28 | describe("set", () => { 29 | it("creates new entries at non-existing keys", () => { 30 | const sparseMap = SparseMap.make() 31 | SparseMap.set(sparseMap, 99, "a") 32 | SparseMap.set(sparseMap, 42, "b") 33 | expect(SparseMap.get(sparseMap, 99)).toBe("a") 34 | expect(SparseMap.get(sparseMap, 42)).toBe("b") 35 | expect(sparseMap.size).toBe(2) 36 | }) 37 | it("updates existing entries", () => { 38 | const sparseMap = SparseMap.make() 39 | SparseMap.set(sparseMap, 0, "a") 40 | SparseMap.set(sparseMap, 1, "b") 41 | SparseMap.set(sparseMap, 0, "c") 42 | SparseMap.set(sparseMap, 1, "d") 43 | expect(SparseMap.get(sparseMap, 0)).toBe("c") 44 | expect(SparseMap.get(sparseMap, 1)).toBe("d") 45 | expect(sparseMap.size).toBe(2) 46 | }) 47 | }) 48 | 49 | describe("remove", () => { 50 | it("removes the entry of the specified key", () => { 51 | const sparseMap = SparseMap.make(["a", "b", "c"]) 52 | SparseMap.remove(sparseMap, 1) 53 | expect(SparseMap.get(sparseMap, 0)).toBe("a") 54 | expect(SparseMap.get(sparseMap, 1)).toBe(undefined) 55 | expect(SparseMap.get(sparseMap, 2)).toBe("c") 56 | expect(sparseMap.size).toBe(2) 57 | }) 58 | it("does not alter the SparseMap when called with a non-existing key", () => { 59 | const sparseMap = SparseMap.make(["a", , "c"]) 60 | SparseMap.remove(sparseMap, 1) 61 | expect(SparseMap.get(sparseMap, 0)).toBe("a") 62 | expect(SparseMap.get(sparseMap, 1)).toBe(undefined) 63 | expect(SparseMap.get(sparseMap, 2)).toBe("c") 64 | expect(sparseMap.size).toBe(2) 65 | }) 66 | }) 67 | 68 | describe("forEach", () => { 69 | it("executes a callback function with the value and key of each entry in the SparseMap", () => { 70 | const data: [number, string][] = [ 71 | [0, "a"], 72 | [10_100, "b"], 73 | [9, "c"], 74 | [23, "d"], 75 | [1_000_000, "e"], 76 | [34, "f"], 77 | ] 78 | const entries: [number, string][] = [] 79 | const sparseMap = SparseMap.make( 80 | data.reduce((a, [key, value]) => { 81 | a[key] = value 82 | return a 83 | }, [] as string[]), 84 | ) 85 | SparseMap.forEach(sparseMap, (value, key) => entries.push([key, value])) 86 | expect(entries).toEqual(data.sort(([keyA], [keyB]) => keyA - keyB)) 87 | }) 88 | }) 89 | }) 90 | -------------------------------------------------------------------------------- /lib/src/sparse_map.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * A callback function passed to `SparseMap.forEach` 3 | */ 4 | export type Iteratee<$Value, $Key extends number> = (value: $Value, key: $Key) => void 5 | 6 | /** 7 | * A map that references values using unsigned integers. Uses a packed array in 8 | * conjunction with a key-value index for fast lookup, add/remove operations, 9 | * and iteration. SparseMap is used frequently in Harmony since entities 10 | * (including schema and game objects) are represented as unsigned integers. 11 | * 12 | * SparseMap is faster than ES Maps in all cases. It is slower than sparse 13 | * arrays for read/write operations, but faster to iterate. 14 | */ 15 | export type Struct<$Value = unknown, $Key extends number = number> = { 16 | /** @readonly */ 17 | size: number 18 | /** @internal */ 19 | keys: $Key[] 20 | /** @internal */ 21 | values: $Value[] 22 | /** @internal */ 23 | index: Record<$Key, number | undefined> 24 | } 25 | 26 | /** 27 | * Create a SparseMap. 28 | */ 29 | export function make<$Value, $Key extends number = number>( 30 | init: ($Value | undefined)[] = [], 31 | ): Struct<$Value, $Key> { 32 | let size = 0 33 | const index: (number | undefined)[] = [] 34 | const keys: $Key[] = [] 35 | const values: $Value[] = [] 36 | for (let i = 0; i < init.length; i++) { 37 | const value = init[i] 38 | if (value !== undefined) { 39 | keys.push(i as $Key) 40 | values[i] = value 41 | index[i] = size 42 | size++ 43 | } 44 | } 45 | return { 46 | size, 47 | keys, 48 | values, 49 | index, 50 | } 51 | } 52 | 53 | /** 54 | * Retrieve the value for a given key from a SparseMap. Returns `undefined` if 55 | * no record exists for the given key. 56 | */ 57 | export function get<$Value, $Key extends number>( 58 | map: Struct<$Value, $Key>, 59 | key: $Key, 60 | ): $Value | undefined { 61 | return map.values[key] 62 | } 63 | 64 | /** 65 | * Add or update the value of an entry with the given key within a SparseMap. 66 | */ 67 | export function set<$Value, $Key extends number>( 68 | map: Struct<$Value, $Key>, 69 | key: $Key, 70 | value: $Value, 71 | ) { 72 | if (!has(map, key)) { 73 | map.index[key] = map.keys.push(key) - 1 74 | map.size++ 75 | } 76 | map.values[key] = value 77 | } 78 | 79 | /** 80 | * Remove an entry by key from a SparseMap. 81 | */ 82 | export function remove<$Key extends number>(map: Struct, key: $Key) { 83 | const i = map.index[key] 84 | if (i === undefined) return 85 | const k = map.keys.pop() 86 | const h = --map.size 87 | map.index[key] = map.values[key] = undefined 88 | if (h !== i) { 89 | map.keys[i!] = k! 90 | map.index[k!] = i 91 | } 92 | } 93 | 94 | /** 95 | * Check for the existence of a value by key within a SparseMap. Returns true 96 | * if the SparseMap contains an entry for the provided key. 97 | */ 98 | export function has(map: Struct, key: number) { 99 | return map.index[key] !== undefined 100 | } 101 | 102 | /** 103 | * Clear all entries from a SparseMap. 104 | */ 105 | export function clear(map: Struct) { 106 | map.keys.length = 0 107 | map.values.length = 0 108 | ;(map.index as number[]).length = 0 109 | map.size = 0 110 | } 111 | 112 | /** 113 | * Iterate a SparseMap using a iteratee function. 114 | */ 115 | export function forEach<$Value, $Key extends number>( 116 | map: Struct<$Value, $Key>, 117 | iteratee: Iteratee<$Value, $Key>, 118 | ) { 119 | for (let i = 0; i < map.size; i++) { 120 | const k = map.keys[i]! 121 | const d = map.values[k]! 122 | iteratee(d, k) 123 | } 124 | } 125 | -------------------------------------------------------------------------------- /lib/src/symbols.ts: -------------------------------------------------------------------------------- 1 | export const $type = Symbol("harmony_type") 2 | export const $format = Symbol("harmony_format") 3 | export const $tombstone = Symbol("harmony_tombstone") 4 | export const $timestamp = Symbol("harmony_timestamp") 5 | -------------------------------------------------------------------------------- /lib/src/type.spec.ts: -------------------------------------------------------------------------------- 1 | import * as Schema from "./schema" 2 | import * as Type from "./type" 3 | 4 | describe("Type", () => { 5 | describe("add", () => { 6 | it("refuses an abnormal type", () => { 7 | const type = [2, 1, 0] as Schema.Id[] 8 | expect(() => Type.add(type, 0 as Schema.Id)).toThrowError("abnormal type") 9 | }) 10 | it("prepends a type to a normal type", () => { 11 | const type = [1, 2, 3] as Schema.Id[] 12 | expect(Type.add(type, 0 as Schema.Id)).toEqual([0, 1, 2, 3]) 13 | }) 14 | it("inserts a type within a normal type", () => { 15 | const type = [0, 1, 3] as Schema.Id[] 16 | expect(Type.add(type, 2 as Schema.Id)).toEqual([0, 1, 2, 3]) 17 | }) 18 | it("appends a type to a normal type", () => { 19 | const type = [0, 1, 2] as Schema.Id[] 20 | expect(Type.add(type, 3 as Schema.Id)).toEqual([0, 1, 2, 3]) 21 | }) 22 | }) 23 | 24 | describe("remove", () => { 25 | it("refuses an abnormal type", () => { 26 | const type = [2, 1, 0] as Schema.Id[] 27 | expect(() => Type.remove(type, 0 as Schema.Id)).toThrowError("abnormal type") 28 | }) 29 | it("shifts a sub-type from a normal type", () => { 30 | const type = [0, 1, 2, 3] as Schema.Id[] 31 | expect(Type.remove(type, 0 as Schema.Id)).toEqual([1, 2, 3]) 32 | }) 33 | it("splices a sub-type from a normal type", () => { 34 | const type = [0, 1, 2, 3] as Schema.Id[] 35 | expect(Type.remove(type, 2 as Schema.Id)).toEqual([0, 1, 3]) 36 | }) 37 | it("pops a sub-type from a normal type", () => { 38 | const type = [0, 1, 2, 3] as Schema.Id[] 39 | expect(Type.remove(type, 3 as Schema.Id)).toEqual([0, 1, 2]) 40 | }) 41 | }) 42 | 43 | describe("normalize", () => { 44 | it("sorts a sub-type in ascending order", () => { 45 | const type = [7, 3, 1, 9, 6] as Schema.Id[] 46 | expect(Type.normalize(type)).toEqual([1, 3, 6, 7, 9]) 47 | }) 48 | }) 49 | 50 | describe("isSupersetOf", () => { 51 | it("asserts normal types", () => { 52 | const normal = [0, 1, 2] as Schema.Id[] 53 | const abnormal = [2, 0] as Schema.Id[] 54 | expect(() => Type.isSupersetOf(normal, abnormal)).toThrowError("abnormal type") 55 | expect(() => Type.isSupersetOf(abnormal, normal)).toThrowError("abnormal type") 56 | }) 57 | it("matches type where type contains each element of sub-type", () => { 58 | const outer = [0, 3, 8, 12, 17] as Schema.Id[] 59 | const inner = [0, 12, 17] as Schema.Id[] 60 | expect(Type.isSupersetOf(outer, inner)).toBe(true) 61 | }) 62 | it("fails to match type where type does not contain each element of sub-type", () => { 63 | const outer = [0, 3, 8, 12, 17] as Schema.Id[] 64 | const inner = [0, 11, 17] as Schema.Id[] 65 | expect(Type.isSupersetOf(outer, inner)).toBe(false) 66 | }) 67 | }) 68 | 69 | describe("maybeSupersetOf", () => { 70 | it("asserts normal types", () => { 71 | const normal = [0, 1, 2] as Schema.Id[] 72 | const abnormal = [2, 0] as Schema.Id[] 73 | expect(() => Type.maybeSupersetOf(normal, abnormal)).toThrowError("abnormal type") 74 | expect(() => Type.maybeSupersetOf(abnormal, normal)).toThrowError("abnormal type") 75 | }) 76 | it("matches type where outer type may be subset of eventual superset of inner type", () => { 77 | expect(Type.maybeSupersetOf([0] as Schema.Id[], [1, 2, 3, 4] as Schema.Id[])).toBe( 78 | true, 79 | ) 80 | expect(Type.maybeSupersetOf([7, 9] as Schema.Id[], [9, 10] as Schema.Id[])).toBe( 81 | true, 82 | ) 83 | expect( 84 | Type.maybeSupersetOf([5, 6, 7, 8] as Schema.Id[], [6, 99] as Schema.Id[]), 85 | ).toBe(true) 86 | }) 87 | it("fails to mach type where outer type may never be subset of superset of inner type", () => { 88 | expect(Type.maybeSupersetOf([0, 5] as Schema.Id[], [4, 5, 6] as Schema.Id[])).toBe( 89 | false, 90 | ) 91 | expect(Type.maybeSupersetOf([9, 10] as Schema.Id[], [7, 9] as Schema.Id[])).toBe( 92 | false, 93 | ) 94 | expect(Type.maybeSupersetOf([5] as Schema.Id[], [6, 12] as Schema.Id[])).toBe(true) 95 | }) 96 | }) 97 | 98 | describe("getIdsBetween", () => { 99 | it("asserts outer is superset of subset", () => { 100 | const outer = [0, 2] as Schema.Id[] 101 | const inner = [0, 1, 2] as Schema.Id[] 102 | expect(() => Type.getIdsBetween(outer, inner)).toThrowError("type is not superset") 103 | }) 104 | it("builds an array of type ids between inner and outer", () => { 105 | expect( 106 | Type.getIdsBetween([0, 1, 4, 9, 12, 17] as Schema.Id[], [9, 17] as Schema.Id[]), 107 | ).toEqual([0, 1, 4, 12]) 108 | expect(Type.getIdsBetween([0, 1, 2] as Schema.Id[], [1] as Schema.Id[])).toEqual([ 109 | 0, 110 | ]) 111 | expect(Type.getIdsBetween([0, 1, 2, 3] as Schema.Id[], [3] as Schema.Id[])).toEqual( 112 | [0, 1, 2], 113 | ) 114 | }) 115 | }) 116 | }) 117 | -------------------------------------------------------------------------------- /lib/src/type.ts: -------------------------------------------------------------------------------- 1 | import { invariant } from "./debug" 2 | import { Id } from "./schema" 3 | 4 | /** 5 | * An array of schema ids that fully defines the component makeup of an entity. 6 | */ 7 | export type Struct = ReadonlyArray 8 | 9 | export function add(type: Struct, add: Id): Struct { 10 | invariantNormalized(type) 11 | const next: number[] = [] 12 | let added = false 13 | for (let i = 0; i < type.length; i++) { 14 | const id = type[i] 15 | invariant(id !== undefined) 16 | if (id >= add && !added) { 17 | if (id !== add) { 18 | next.push(add) 19 | } 20 | added = true 21 | } 22 | next.push(id) 23 | } 24 | if (!added) { 25 | next.push(add) 26 | } 27 | return next as unknown as Struct 28 | } 29 | 30 | export function and(a: Struct, b: Struct): Struct { 31 | invariantNormalized(a) 32 | let next = a.slice() as Struct 33 | for (let i = 0; i < b.length; i++) { 34 | const id = b[i] 35 | invariant(id !== undefined) 36 | next = add(next, id) 37 | } 38 | return next 39 | } 40 | 41 | export function xor(a: Struct, b: Struct): Struct { 42 | invariantNormalized(a) 43 | let next = a.slice() as Struct 44 | for (let i = 0; i < b.length; i++) { 45 | const id = b[i] 46 | invariant(id !== undefined) 47 | next = remove(next, id) 48 | } 49 | return next 50 | } 51 | 52 | export function remove(type: Struct, remove: Id): Struct { 53 | invariantNormalized(type) 54 | const next: number[] = [] 55 | for (let i = 0; i < type.length; i++) { 56 | const e = type[i] 57 | if (e !== remove) { 58 | invariant(e !== undefined) 59 | next.push(e) 60 | } 61 | } 62 | return next as unknown as Struct 63 | } 64 | 65 | export function normalize(type: Struct) { 66 | return Object.freeze(type.slice().sort((a, b) => a - b)) 67 | } 68 | 69 | export function invariantNormalized(type: Struct) { 70 | for (let i = 0; i < type.length - 1; i++) { 71 | const a = type[i] 72 | const b = type[i + 1] 73 | invariant(a !== undefined) 74 | invariant(b !== undefined) 75 | if (a > b) { 76 | throw new TypeError("abnormal type") 77 | } 78 | } 79 | } 80 | 81 | export function isEqual(outer: Struct, inner: Struct) { 82 | if (outer.length !== inner.length) { 83 | return false 84 | } 85 | for (let i = 0; i < outer.length; i++) { 86 | if (outer[i] !== inner[i]) return false 87 | } 88 | return true 89 | } 90 | 91 | export function isSupersetOf(outer: Struct, inner: Struct) { 92 | invariantNormalized(outer) 93 | invariantNormalized(inner) 94 | let o = 0 95 | let i = 0 96 | if (outer.length <= inner.length) { 97 | return false 98 | } 99 | while (o < outer.length && i < inner.length) { 100 | const outerId = outer[o] 101 | const innerId = inner[i] 102 | invariant(outerId !== undefined) 103 | invariant(innerId !== undefined) 104 | if (outerId < innerId) { 105 | o++ 106 | } else if (outerId === innerId) { 107 | o++ 108 | i++ 109 | } else { 110 | return false 111 | } 112 | } 113 | return i === inner.length 114 | } 115 | 116 | export function invariantIsSupersetOf(outer: Struct, inner: Struct) { 117 | if (!isSupersetOf(outer, inner)) { 118 | throw new RangeError("type is not superset") 119 | } 120 | } 121 | 122 | export function maybeSupersetOf(outer: Struct, inner: Struct) { 123 | invariantNormalized(outer) 124 | invariantNormalized(inner) 125 | let o = 0 126 | let i = 0 127 | if (outer.length === 0) { 128 | return true 129 | } 130 | while (o < outer.length && i < inner.length) { 131 | const outerId = outer[o] 132 | const innerId = inner[i] 133 | invariant(outerId !== undefined) 134 | invariant(innerId !== undefined) 135 | if (outerId < innerId) { 136 | o++ 137 | } else if (outerId === innerId) { 138 | o++ 139 | i++ 140 | } else { 141 | return false 142 | } 143 | } 144 | return true 145 | } 146 | 147 | export function getIdsBetween(right: Struct, left: Struct) { 148 | invariantIsSupersetOf(right, left) 149 | let o = 0 150 | let i = 0 151 | const path: Id[] = [] 152 | if (right.length - left.length === 1) { 153 | let j = 0 154 | let length = right.length 155 | for (; j < length && right[j] === left[j]; j++) {} 156 | const t = right[j] 157 | invariant(t !== undefined) 158 | path.push(t) 159 | return path 160 | } 161 | while (o < right.length - 1) { 162 | const outerId = right[o] 163 | const innerId = left[i] 164 | invariant(outerId !== undefined) 165 | if (innerId === undefined || outerId < innerId) { 166 | path.push(outerId) 167 | o++ 168 | } else if (outerId === innerId) { 169 | o++ 170 | i++ 171 | } 172 | } 173 | return path 174 | } 175 | -------------------------------------------------------------------------------- /lib/src/types.ts: -------------------------------------------------------------------------------- 1 | export type TypedArray = 2 | | Float32Array 3 | | Float64Array 4 | | Int8Array 5 | | Int16Array 6 | | Int32Array 7 | | Uint8Array 8 | | Uint8ClampedArray 9 | | Uint16Array 10 | | Uint32Array 11 | 12 | export type TypedArrayConstructor = { 13 | new (length: number | ArrayBufferLike): TypedArray 14 | BYTES_PER_ELEMENT: number 15 | } 16 | 17 | export type Construct<$Ctor> = $Ctor extends { new (...args: any[]): infer _ } ? _ : never 18 | 19 | export declare class OpaqueTag<$Tag> { 20 | protected tag: $Tag 21 | } 22 | 23 | export type Opaque<$Type, $Tag> = $Type & OpaqueTag<$Tag> 24 | -------------------------------------------------------------------------------- /lib/src/world.spec.ts: -------------------------------------------------------------------------------- 1 | import * as World from "./world" 2 | import * as Schema from "./schema" 3 | import * as Entity from "./entity" 4 | 5 | describe("World", () => { 6 | let world: World.Struct 7 | 8 | beforeEach(() => (world = World.make(10))) 9 | 10 | describe("make", () => { 11 | it("creates a world with a configurable entity size", () => { 12 | const size = 8 13 | const world = World.make(size) 14 | expect(world.size).toBe(size) 15 | }) 16 | it("starts entity id counter at 0", () => { 17 | expect(world.entityHead).toBe(0) 18 | }) 19 | }) 20 | 21 | describe("registerSchema", () => { 22 | it("registers a schema with the world", () => { 23 | const id = 1 24 | const schema: Schema.NativeObjectSchema = { 25 | id, 26 | kind: Schema.SchemaKind.NativeObject, 27 | shape: {}, 28 | } 29 | World.registerSchema(world, id, schema) 30 | expect(World.findSchemaById(world, id)).toBe(schema) 31 | }) 32 | }) 33 | 34 | describe("getEntityArchetype", () => { 35 | it("returns an entity's archetype", () => { 36 | const A = Schema.make(world, {}) 37 | const entity = Entity.make(world, [A]) 38 | const archetype = World.getEntityArchetype(world, entity) 39 | expect(archetype.type).toEqual([A]) 40 | }) 41 | it("throws when entity does not exist", () => { 42 | expect(() => World.getEntityArchetype(world, 9)).toThrow() 43 | }) 44 | }) 45 | 46 | describe("tryGetEntityArchetype", () => { 47 | it("returns undefined when entity does not exist", () => { 48 | expect(World.tryGetEntityArchetype(world, 9)).toBeUndefined() 49 | }) 50 | }) 51 | 52 | describe("setEntityArchetype", () => { 53 | it("associates an entity with an archetype", () => { 54 | const entity = Entity.reserve(world) 55 | World.setEntityArchetype(world, entity, world.rootArchetype) 56 | expect(World.getEntityArchetype(world, entity)).toBe(world.rootArchetype) 57 | }) 58 | }) 59 | 60 | describe("unsetEntityArchetype", () => { 61 | it("disassociates an entity from an archetype", () => { 62 | const entity = Entity.reserve(world) 63 | World.setEntityArchetype(world, entity, world.rootArchetype) 64 | World.unsetEntityArchetype(world, entity) 65 | expect(World.tryGetEntityArchetype(world, entity)).toBe(undefined) 66 | }) 67 | }) 68 | }) 69 | -------------------------------------------------------------------------------- /lib/src/world.ts: -------------------------------------------------------------------------------- 1 | import * as Archetype from "./archetype" 2 | import * as Debug from "./debug" 3 | import * as Entity from "./entity" 4 | import * as Schema from "./schema" 5 | import * as Type from "./type" 6 | 7 | export class EntityNotRealError extends Error { 8 | constructor(entity: Entity.Id) { 9 | super(`Entity "${entity}" is not real`) 10 | } 11 | } 12 | 13 | /** 14 | * The root object of the ECS. A world maintains entity identifiers, stores 15 | * schemas, and associates components with entities in tables called 16 | * archetypes: collections of entities that share the same component makeup. 17 | * 18 | * Archetypes are stored in a connected graph structure, where each node is 19 | * linked to adjacent archetypes by the addition or removal or a single 20 | * component type. An entity's archetype is selected based on its current 21 | * component composition. An entity can belong only to a single archetype at a 22 | * time. 23 | * 24 | * Entities without any components (e.g. destroyed entities) are never 25 | * discarded, but moved to the world's root archetype. 26 | */ 27 | export type Struct = { 28 | rootArchetype: Archetype.Struct 29 | entityHead: number 30 | entityIndex: (Archetype.Struct | undefined)[] 31 | schemaIndex: Schema.AnySchema[] 32 | size: number 33 | } 34 | 35 | /** @internal */ 36 | export function registerSchema(world: Struct, id: Entity.Id, schema: Schema.AnySchema) { 37 | world.schemaIndex[id] = schema 38 | } 39 | 40 | /** @internal */ 41 | export function findSchemaById(world: Struct, id: Entity.Id) { 42 | const schema = world.schemaIndex[id] 43 | Debug.invariant( 44 | schema !== undefined, 45 | `Failed to locate schema: entity "${id}" is not a schema`, 46 | ) 47 | return schema 48 | } 49 | 50 | /** @internal */ 51 | export function getEntityArchetype(world: Struct, entity: Entity.Id) { 52 | const archetype = world.entityIndex[entity] 53 | Debug.invariant( 54 | archetype !== undefined, 55 | `Failed to locate archetype`, 56 | new EntityNotRealError(entity), 57 | ) 58 | return archetype 59 | } 60 | 61 | /** @internal */ 62 | export function tryGetEntityArchetype(world: Struct, entity: Entity.Id) { 63 | return world.entityIndex[entity] 64 | } 65 | 66 | /** @internal */ 67 | export function setEntityArchetype( 68 | world: Struct, 69 | entity: Entity.Id, 70 | archetype: Archetype.Struct, 71 | ) { 72 | world.entityIndex[entity] = archetype 73 | } 74 | 75 | /** @internal */ 76 | export function unsetEntityArchetype(world: Struct, entity: Entity.Id) { 77 | world.entityIndex[entity] = undefined 78 | } 79 | 80 | /** 81 | * Create a world. Requires a maximum entity size which is used to allocate 82 | * fixed memory for binary component arrays. 83 | * 84 | * @example Create a world capable of storing one million entities 85 | * ```ts 86 | * const world = World.make(1e6) 87 | * ``` 88 | */ 89 | export function make(size: number): Struct { 90 | const type: Type.Struct = [] 91 | return { 92 | rootArchetype: Archetype.makeInner(type, []), 93 | entityHead: 0, 94 | entityIndex: [], 95 | schemaIndex: [], 96 | size, 97 | } 98 | } 99 | -------------------------------------------------------------------------------- /lib/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../tsconfig.json", 3 | "compilerOptions": { 4 | "rootDir": "src", 5 | "outDir": "dist/esm" 6 | }, 7 | "include": ["src"], 8 | "exclude": ["**/*.spec.ts", "**/__mocks__/**/*"] 9 | } 10 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "harmony-ecs", 3 | "author": "Eric McDaniel ", 4 | "description": "A small archetypal ECS focused on compatibility and performance", 5 | "version": "0.0.12", 6 | "license": "MIT", 7 | "type": "module", 8 | "types": "./lib/dist/esm/index.d.ts", 9 | "main": "./lib/dist/cjs/index.js", 10 | "exports": { 11 | "import": "./lib/dist/esm/index.js", 12 | "require": "./lib/dist/cjs/index.js" 13 | }, 14 | "files": [ 15 | "lib/dist" 16 | ], 17 | "engines": { 18 | "node": ">=14.18.1" 19 | }, 20 | "scripts": { 21 | "perf": "npm run perf:node", 22 | "perf:node": "npm run build && node --loader ts-node/esm --experimental-specifier-resolution=node perf/src/index.ts", 23 | "perf:browser": "npm run build && cd ./perf && vite", 24 | "example:compat": " cd ./examples/compat && vite", 25 | "example:noise": " cd ./examples/noise && vite", 26 | "example:graph": " cd ./examples/graph && vite", 27 | "build": "tsc -b lib && npm run build:optimize && npm run build:cjs && tsc -b perf", 28 | "build:optimize": "node lib/build/optimize.js", 29 | "build:cjs": "esbuild --bundle --target=node12.22 --outdir=lib/dist/cjs --format=cjs lib/dist/esm/index.js", 30 | "build:docs": "typedoc lib/src/index.ts --excludeInternal --sort source-order", 31 | "prepare": "npm run build", 32 | "test": "jest --verbose" 33 | }, 34 | "devDependencies": { 35 | "@babel/core": "^7.15.5", 36 | "@types/jest": "^27.0.1", 37 | "@types/three": "^0.131.0", 38 | "babel-plugin-add-import-extension": "^1.6.0", 39 | "cannon-es": "^0.18.0", 40 | "esbuild": "^0.12.25", 41 | "jest": "^27.1.1", 42 | "three": "^0.132.2", 43 | "ts-jest": "^27.0.5", 44 | "ts-node": "^10.2.1", 45 | "typedoc": "^0.22.7", 46 | "typescript": "^4.4.2", 47 | "vis-data": "^7.1.2", 48 | "vis-network": "^9.1.0", 49 | "vite": "^2.5.3" 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /perf/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | harmony-ecs/perf 8 | 15 | 16 | 17 |
18 | 19 | 27 | 28 | 29 | -------------------------------------------------------------------------------- /perf/src/index.ts: -------------------------------------------------------------------------------- 1 | // TODO(3mcd): add cli args for filtering 2 | 3 | import { printPerfResults, runPerf } from "./perf" 4 | import { suite } from "./suite" 5 | 6 | for (const [name, module] of Object.entries(suite)) { 7 | console.log(name) 8 | console.log(Array(name.length).fill("-").join("")) 9 | for (const [id, executor] of Object.entries(module)) { 10 | printPerfResults(runPerf(executor, id)) 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /perf/src/perf.ts: -------------------------------------------------------------------------------- 1 | const performance = globalThis.performance 2 | 3 | type Perf = { run: () => void; once: boolean } 4 | type PerfStats = { duration: number } 5 | type PerfResults = { 6 | id: string 7 | iterations: number 8 | timing: { average: number; median: number; min: number; max: number } 9 | stats: PerfStats[] 10 | } 11 | 12 | export function makePerf(run: () => void, once = false): Perf { 13 | return { 14 | run, 15 | once, 16 | } 17 | } 18 | 19 | export function makePerfOnce(run: () => void): Perf { 20 | return makePerf(run, true) 21 | } 22 | 23 | function median(arr: T[], iteratee: (element: T) => number) { 24 | const mid = Math.floor(arr.length / 2) 25 | const num = arr.map(iteratee).sort((a, b) => a - b) 26 | return arr.length % 2 !== 0 ? num[mid]! : (num[mid - 1]! + num[mid]!) / 2 27 | } 28 | 29 | export function runPerf(perf: Perf, id: string, iterations = 100): PerfResults { 30 | const stats: PerfStats[] = [] 31 | 32 | if (perf.once) { 33 | iterations = 1 34 | } 35 | 36 | for (let i = 0; i < iterations; i++) { 37 | const start = performance.now() 38 | perf.run() 39 | stats.push({ duration: performance.now() - start }) 40 | } 41 | 42 | return { 43 | id, 44 | iterations, 45 | timing: { 46 | average: stats.reduce((a, x) => a + x.duration, 0) / stats.length, 47 | median: median(stats, stat => stat.duration), 48 | min: stats.reduce((a, x) => Math.min(a, x.duration), Infinity), 49 | max: stats.reduce((a, x) => Math.max(a, x.duration), 0), 50 | }, 51 | stats, 52 | } 53 | } 54 | 55 | function prettyMs(x: number) { 56 | return `${parseFloat(x.toFixed(2)).toLocaleString()} ms` 57 | } 58 | 59 | function mapObject<$Object extends { [key: string]: unknown }, $Value>( 60 | object: $Object, 61 | iteratee: (value: $Object[keyof $Object], key: string) => $Value, 62 | ): { [K in keyof $Object]: $Value } { 63 | return Object.entries(object).reduce((a, [key, value]) => { 64 | a[key as keyof $Object] = iteratee(value as $Object[keyof $Object], key) 65 | return a 66 | }, {} as { [K in keyof $Object]: $Value }) 67 | } 68 | 69 | export function printPerfResults(results: PerfResults) { 70 | const { id, timing, iterations } = results 71 | console.log(id) 72 | console.log(`iterations ${iterations}`) 73 | console.log(mapObject(timing, prettyMs)) 74 | } 75 | -------------------------------------------------------------------------------- /perf/src/suite/archetype_insert.perf.ts: -------------------------------------------------------------------------------- 1 | import { Entity, Schema, World } from "../../../lib/src" 2 | import { Struct } from "../../../lib/src/archetype" 3 | import { makePerfOnce } from "../perf" 4 | 5 | function makeFixture() { 6 | const world = World.make(1_000_000) 7 | 8 | const A = Schema.makeBinary(world, {}) 9 | const B = Schema.makeBinary(world, {}) 10 | const C = Schema.makeBinary(world, {}) 11 | const D = Schema.makeBinary(world, {}) 12 | const E = Schema.makeBinary(world, {}) 13 | const F = Schema.makeBinary(world, {}) 14 | 15 | const types = [ 16 | [A, B, C], 17 | [A], 18 | [C], 19 | [F], 20 | [A, B], 21 | [A, C], 22 | [B, C], 23 | [B, F], 24 | [A, C, E], 25 | [B, D, F], 26 | [E, F], 27 | [A, C, E, F], 28 | [B, C, D, F], 29 | [C, D, E, F], 30 | [B, C, D, E, F], 31 | [A, B, C, D, E, F], 32 | ] 33 | return { world, types } 34 | } 35 | 36 | const results: number[] = [] 37 | 38 | let world: World.Struct 39 | 40 | for (let i = 0; i < 10; i++) { 41 | const fixture = makeFixture() 42 | world = fixture.world 43 | const start = performance.now() 44 | for (let i = fixture.types.length - 1; i >= 0; i--) { 45 | const count = Math.random() * 100 46 | for (let j = 0; j < count; j++) { 47 | Entity.make(world, fixture.types[i]!) 48 | } 49 | } 50 | const end = performance.now() 51 | results.push(end - start) 52 | } 53 | 54 | const avgTime = results.reduce((a, x) => a + x, 0) / 100 55 | 56 | function getUniqueArchetypes(archetype: Struct, visited = new Set()) { 57 | visited.add(archetype) 58 | archetype.edgesSet.forEach(a => getUniqueArchetypes(a, visited)) 59 | return visited 60 | } 61 | 62 | console.log( 63 | `${ 64 | getUniqueArchetypes(world!.rootArchetype).size 65 | } archetype insert took ${avgTime.toFixed(2)}ms`, 66 | ) 67 | 68 | export const run = makePerfOnce(() => {}) 69 | -------------------------------------------------------------------------------- /perf/src/suite/index.ts: -------------------------------------------------------------------------------- 1 | import * as iterBinary from "./iter_binary.perf" 2 | import * as iterHybrid from "./iter_hybrid.perf" 3 | import * as iterNative from "./iter_hybrid.perf" 4 | import * as archetypeInsert from "./archetype_insert.perf" 5 | 6 | export const suite = { iterBinary, iterHybrid, iterNative, archetypeInsert } 7 | -------------------------------------------------------------------------------- /perf/src/suite/iter_binary.perf.ts: -------------------------------------------------------------------------------- 1 | import { Schema, World, Query, Entity } from "../../../lib/src" 2 | import { makePerf, makePerfOnce } from "../perf" 3 | import { Vector3 } from "./types" 4 | 5 | const world = World.make(1_000_000) 6 | const Position = Schema.makeBinary(world, Vector3) 7 | const Velocity = Schema.makeBinary(world, Vector3) 8 | const Body = [Position, Velocity] as const 9 | const bodies = Query.make(world, Body) 10 | 11 | export const insert = makePerfOnce(() => { 12 | for (let i = 0; i < 1_000_000; i++) { 13 | Entity.make(world, Body, [ 14 | { x: 1, y: 1, z: 1 }, 15 | { x: 1, y: 1, z: 1 }, 16 | ]) 17 | } 18 | }) 19 | 20 | export const iter = makePerf(() => { 21 | for (const [entities, [p, v]] of bodies) { 22 | for (let i = 0; i < entities.length; i++) { 23 | p.x[i] += v.x[i]! 24 | p.y[i] += v.y[i]! 25 | p.z[i] += v.z[i]! 26 | } 27 | } 28 | }) 29 | -------------------------------------------------------------------------------- /perf/src/suite/iter_hybrid.perf.ts: -------------------------------------------------------------------------------- 1 | import { Schema, Entity, Query, World } from "../../../lib/src" 2 | import { makePerf, makePerfOnce } from "../perf" 3 | import { Vector3 } from "./types" 4 | 5 | const world = World.make(1_000_000) 6 | const Position = Schema.makeBinary(world, Vector3) 7 | const Velocity = Schema.make(world, Vector3) 8 | const Body = [Position, Velocity] as const 9 | const bodies = Query.make(world, Body) 10 | 11 | export const insert = makePerfOnce(() => { 12 | for (let i = 0; i < 1_000_000; i++) { 13 | Entity.make(world, Body, [ 14 | { x: 1, y: 1, z: 1 }, 15 | { x: 1, y: 1, z: 1 }, 16 | ]) 17 | } 18 | }) 19 | 20 | export const iter = makePerf(() => { 21 | for (const [entities, [p, v]] of bodies) { 22 | for (let i = 0; i < entities.length; i++) { 23 | p.x[i] += v[i]!.x 24 | p.y[i] += v[i]!.y 25 | p.z[i] += v[i]!.z 26 | } 27 | } 28 | }) 29 | 30 | export const iterUpdateNative = makePerf(() => { 31 | for (const [entities, [p, v]] of bodies) { 32 | for (let i = 0; i < entities.length; i++) { 33 | v[i]!.x += p.x[i]! 34 | v[i]!.y += p.y[i]! 35 | v[i]!.z += p.z[i]! 36 | } 37 | } 38 | }) 39 | -------------------------------------------------------------------------------- /perf/src/suite/iter_native.perf.ts: -------------------------------------------------------------------------------- 1 | import { Entity, Query, Schema, World } from "../../../lib/src" 2 | import { makePerf, makePerfOnce } from "../perf" 3 | import { Vector3 } from "./types" 4 | 5 | const world = World.make(1_000_000) 6 | const Position = Schema.make(world, Vector3) 7 | const Velocity = Schema.make(world, Vector3) 8 | const Body = [Position, Velocity] as const 9 | const bodies = Query.make(world, Body) 10 | 11 | export const insert = makePerfOnce(() => { 12 | for (let i = 0; i < 1_000_000; i++) { 13 | Entity.make(world, Body, [ 14 | { x: 1, y: 1, z: 1 }, 15 | { x: 1, y: 1, z: 1 }, 16 | ]) 17 | } 18 | }) 19 | 20 | export const iter = makePerf(() => { 21 | for (const [entities, [p, v]] of bodies) { 22 | for (let i = 0; i < entities.length; i++) { 23 | p[i]!.x += v[i]!.x 24 | p[i]!.y += v[i]!.y 25 | p[i]!.z += v[i]!.z 26 | } 27 | } 28 | }) 29 | -------------------------------------------------------------------------------- /perf/src/suite/types.ts: -------------------------------------------------------------------------------- 1 | import { Format } from "../../../lib/src" 2 | 3 | export const Vector3 = { x: Format.float64, y: Format.float64, z: Format.float64 } 4 | -------------------------------------------------------------------------------- /perf/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../tsconfig.json", 3 | "compilerOptions": { 4 | "rootDir": "src", 5 | "noEmit": true 6 | }, 7 | "references": [{ "path": "../lib" }] 8 | } 9 | -------------------------------------------------------------------------------- /perf/vite.config.js: -------------------------------------------------------------------------------- 1 | export default { 2 | root: ".", 3 | } 4 | -------------------------------------------------------------------------------- /prettier.config.cjs: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | arrowParens: "avoid", 3 | bracketSpacing: true, 4 | insertPragma: false, 5 | jsxBracketSameLine: false, 6 | printWidth: 90, 7 | proseWrap: "preserve", 8 | requirePragma: false, 9 | semi: false, 10 | singleQuote: false, 11 | tabWidth: 2, 12 | trailingComma: "all", 13 | useTabs: false, 14 | } 15 | -------------------------------------------------------------------------------- /test/add_remove.spec.ts: -------------------------------------------------------------------------------- 1 | import { Entity, Format, Query, Schema, World } from "../lib/src" 2 | 3 | describe("add_remove", () => { 4 | it("moves entities between archetypes", () => { 5 | const ENTITY_COUNT = 2 6 | const world = World.make(ENTITY_COUNT) 7 | const A = Schema.makeBinary(world, Format.float64) 8 | const B = Schema.makeBinary(world, Format.float64) 9 | const qa = Query.make(world, [A], Query.not([B])) 10 | const qab = Query.make(world, [A, B]) 11 | 12 | for (let i = 0; i < ENTITY_COUNT; i++) { 13 | Entity.make(world, [A]) 14 | } 15 | 16 | for (let i = 0; i < qa.length; i++) { 17 | const [e] = qa[i]! 18 | for (let j = e.length - 1; j >= 0; j--) { 19 | Entity.set(world, e[j]!, [B]) 20 | } 21 | } 22 | 23 | let qaCountPreUnset = 0 24 | for (let i = 0; i < qa.length; i++) { 25 | qaCountPreUnset += qa[i]![0].length 26 | } 27 | 28 | let qabCountPreUnset = 0 29 | for (let i = 0; i < qab.length; i++) { 30 | qabCountPreUnset += qab[i]![0].length 31 | } 32 | 33 | for (let i = 0; i < qab.length; i++) { 34 | const [e] = qab[i]! 35 | for (let j = e.length - 1; j >= 0; j--) { 36 | Entity.unset(world, e[j]!, [B]) 37 | } 38 | } 39 | 40 | let qaCountPostUnset = 0 41 | for (let i = 0; i < qa.length; i++) { 42 | qaCountPostUnset += qa[i]![0].length 43 | } 44 | 45 | let qabCountPostUnset = 0 46 | for (let i = 0; i < qab.length; i++) { 47 | qabCountPostUnset += qab[i]![0].length 48 | } 49 | 50 | for (let i = 0; i < qa.length; i++) { 51 | const [e] = qa[i]! 52 | for (let j = e.length - 1; j >= 0; j--) { 53 | Entity.set(world, e[j]!, [B]) 54 | } 55 | } 56 | 57 | // console.log(world.rootArchetype.edgesSet[A]!.edgesSet[B]) 58 | 59 | let qaCountPostReset = 0 60 | for (let i = 0; i < qa.length; i++) { 61 | qaCountPostReset += qa[i]![0].length 62 | } 63 | 64 | let qabCountPostReset = 0 65 | for (let i = 0; i < qa.length; i++) { 66 | qabCountPostReset += qab[i]![0].length 67 | } 68 | 69 | expect(qaCountPreUnset).toBe(0) 70 | expect(qabCountPreUnset).toBe(ENTITY_COUNT) 71 | expect(qaCountPostUnset).toBe(ENTITY_COUNT) 72 | expect(qabCountPostUnset).toBe(0) 73 | expect(qaCountPostReset).toBe(0) 74 | expect(qabCountPostReset).toBe(ENTITY_COUNT) 75 | }) 76 | }) 77 | -------------------------------------------------------------------------------- /test/query_counts.spec.ts: -------------------------------------------------------------------------------- 1 | import { Entity, Query, Schema, World } from "../lib/src" 2 | import { isEqual, isSupersetOf } from "../lib/src/type" 3 | 4 | describe("query counts", () => { 5 | const ENTITY_COUNT = 1_500 6 | const world = World.make(ENTITY_COUNT) 7 | 8 | const A = Schema.makeBinary(world, {}) 9 | const B = Schema.makeBinary(world, {}) 10 | const C = Schema.makeBinary(world, {}) 11 | const D = Schema.makeBinary(world, {}) 12 | const E = Schema.makeBinary(world, {}) 13 | const F = Schema.makeBinary(world, {}) 14 | 15 | const types = [ 16 | [A], 17 | [C], 18 | [F], 19 | [A, B], 20 | [A, C], 21 | [B, C], 22 | [B, F], 23 | [A, C, E], 24 | [B, D, F], 25 | [E, F], 26 | [A, C, E, F], 27 | [B, C, D, F], 28 | [C, D, E, F], 29 | [B, C, D, E, F], 30 | [A, B, C, D, E, F], 31 | ] 32 | 33 | const queries = types.map(type => Query.make(world, type)) 34 | const entitiesPerType = ENTITY_COUNT / types.length 35 | 36 | types.forEach(type => { 37 | for (let i = 0; i < entitiesPerType; i++) Entity.make(world, type) 38 | }) 39 | 40 | const expectedEntityCounts = types.map(type => 41 | types.reduce( 42 | (a, t) => a + (isEqual(t, type) || isSupersetOf(t, type) ? entitiesPerType : 0), 43 | 0, 44 | ), 45 | ) 46 | 47 | it("yields correct entity counts", () => { 48 | queries.forEach((query, i) => { 49 | let count = 0 50 | for (let i = 0; i < query.length; i++) { 51 | const [entities] = query[i]! 52 | count += entities.length 53 | } 54 | expect(count).toBe(expectedEntityCounts[i]) 55 | }) 56 | }) 57 | }) 58 | -------------------------------------------------------------------------------- /test/query_dynamic.spec.ts: -------------------------------------------------------------------------------- 1 | import { Format, Schema, Entity, Query, World } from "../lib/src" 2 | 3 | describe("query_dynamic", () => { 4 | it("updates dynamic queries with newly created archetypes", () => { 5 | const world = World.make(4) 6 | const A = Schema.makeBinary(world, Format.float64) 7 | const B = Schema.makeBinary(world, Format.float64) 8 | const C = Schema.makeBinary(world, Format.float64) 9 | const D = Schema.makeBinary(world, Format.float64) 10 | const E = Schema.makeBinary(world, Format.float64) 11 | const qab = Query.make(world, [A, B]) 12 | const qcd = Query.make(world, [C, D]) 13 | const qce = Query.make(world, [C, E]) 14 | 15 | Entity.make(world, [A, B], [0, 1]) 16 | Entity.make(world, [A, B, C], [0, 1, 2]) 17 | Entity.make(world, [A, B, C, D], [0, 1, 2, 3]) 18 | Entity.make(world, [A, B, C, E], [0, 1, 2, 4]) 19 | 20 | expect(qab).toHaveLength(4) 21 | expect(qcd).toHaveLength(1) 22 | expect(qce).toHaveLength(1) 23 | }) 24 | }) 25 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "composite": true, 4 | "declaration": true, 5 | "declarationMap": true, 6 | "downlevelIteration": true, 7 | "esModuleInterop": true, 8 | "module": "esnext", 9 | "moduleResolution": "node", 10 | "noImplicitReturns": true, 11 | "noPropertyAccessFromIndexSignature": true, 12 | "noUncheckedIndexedAccess": true, 13 | "skipLibCheck": true, 14 | "sourceMap": true, 15 | "strict": true, 16 | "target": "esnext" 17 | } 18 | } 19 | --------------------------------------------------------------------------------