This commit is contained in:
kalrnlo 2024-06-23 19:10:00 -04:00
commit ca8e1376ca
26 changed files with 3855 additions and 1252 deletions

73
.github/workflows/release.yaml vendored Normal file
View file

@ -0,0 +1,73 @@
name: Release
on:
push:
tags: ["v*"]
jobs:
build:
name: Build
runs-on: ubuntu-latest
steps:
- name: Checkout Project
uses: actions/checkout@v3
- name: Install Aftman
uses: ok-nick/setup-aftman@v0.3.0
- name: Install Dependencies
run: wally install
- name: Build
run: rojo build --output build.rbxm default.project.json
- name: Upload Build Artifact
uses: actions/upload-artifact@v3
with:
name: build
path: build.rbxm
release:
name: Release
needs: [build]
runs-on: ubuntu-latest
permissions:
contents: write
steps:
- name: Checkout Project
uses: actions/checkout@v3
- name: Download Jecs Build
uses: actions/download-artifact@v3
with:
name: build
path: build
- name: Rename Build
run: mv build/build.rbxm jecs.rbxm
- name: Create Release
uses: softprops/action-gh-release@v1
with:
name: Matter ${{ github.ref_name }}
body: |
Matter ${{ github.ref_name }} is now available!
files: |
jecs.rbxm
publish:
name: Publish
needs: [release]
runs-on: ubuntu-latest
steps:
- name: Checkout Project
uses: actions/checkout@v3
- name: Install Aftman
uses: ok-nick/setup-aftman@v0.3.0
- name: Wally Login
run: wally login --token ${{ secrets.WALLY_AUTH_TOKEN }}
- name: Publish
run: wally publish

4
.gitignore vendored
View file

@ -46,6 +46,10 @@ Packages
wally.lock wally.lock
WallyPatches WallyPatches
# Typescript
/node_modules
/include
# Misc # Misc
roblox.toml roblox.toml
sourcemap.json sourcemap.json

21
LICENSE Normal file
View file

@ -0,0 +1,21 @@
MIT License
Copyright (c) 2024 jecs authors
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:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
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.

View file

@ -12,7 +12,7 @@ Just an ECS
jecs is a stupidly fast Entity Component System (ECS). jecs is a stupidly fast Entity Component System (ECS).
- Entity Relationships as first class citizens - Entity Relationships as first class citizens
- Process tens of thousands of entities with ease every frame - Iterate 350,000 entities at 60 frames per second
- Type-safe [Luau](https://luau-lang.org/) API - Type-safe [Luau](https://luau-lang.org/) API
- Zero-dependency package - Zero-dependency package
- Optimized for column-major operations - Optimized for column-major operations
@ -22,47 +22,40 @@ jecs is a stupidly fast Entity Component System (ECS).
### Example ### Example
```lua ```lua
local world = World.new() local world = jecs.World.new()
local pair = jecs.pair
local player = world:entity() local ChildOf = world:component()
local opponent = world:entity() local Name = world:component()
local Health = world:component() local function parent(entity)
local Position = world:component() return world:target(entity, ChildOf)
-- Notice how components can just be entities as well? end
-- It allows you to model relationships easily! local function getName(entity)
local Damage = world:entity() return world:get(entity, Name)
local DamagedBy = world:entity()
world:set(player, Health, 100)
world:set(player, Damage, 8)
world:set(player, Position, Vector3.new(0, 5, 0))
world:set(opponent, Health, 100)
world:set(opponent, Damage, 21)
world:set(opponent, Position, Vector3.new(0, 5, 3))
for playerId, playerPosition, health in world:query(Position, Health) do
local totalDamage = 0
for opponentId, opponentPosition, damage in world:query(Position, Damage) do
if playerId == opponentId then
continue
end
if (playerPosition - opponentPosition).Magnitude < 5 then
totalDamage += damage
end
-- We create a pair between the relation component `DamagedBy` and the entity id of the opponent.
-- This will allow us to specifically query for damage exerted by a specific opponent.
world:set(playerId, ECS_PAIR(DamagedBy, opponentId), totalDamage)
end
end end
-- Gets the damage inflicted by our specific opponent! local alice = world:entity()
for playerId, health, inflicted in world:query(Health, ECS_PAIR(DamagedBy, opponent)) do world:set(alice, Name, "alice")
world:set(playerId, health - inflicted)
local bob = world:entity()
world:add(bob, pair(ChildOf, alice))
world:set(bob, Name, "bob")
local sara = world:entity()
world:add(sara, pair(ChildOf, alice))
world:set(sara, Name, "sara")
print(getName(parent(sara)))
for e in world:query(pair(ChildOf, alice)) do
print(getName(e), "is the child of alice")
end end
assert(world:get(player, Health) == 79) -- Output
-- "alice"
-- bob is the child of alice
-- sara is the child of alice
``` ```
125 archetypes, 4 random components queried. 125 archetypes, 4 random components queried.

View file

@ -1,5 +1,5 @@
[tools] [tools]
wally = "upliftgames/wally@0.3.1" wally = "upliftgames/wally@0.3.2"
rojo = "rojo-rbx/rojo@7.4.1" rojo = "rojo-rbx/rojo@7.4.1"
stylua = "johnnymorganz/stylua@0.19.1" stylua = "johnnymorganz/stylua@0.19.1"
selene = "kampfkarren/selene@0.26.1" selene = "kampfkarren/selene@0.26.1"

31
bench.project.json Normal file
View file

@ -0,0 +1,31 @@
{
"name": "jecs-test",
"tree": {
"$className": "DataModel",
"StarterPlayer": {
"$className": "StarterPlayer",
"StarterPlayerScripts": {
"$className": "StarterPlayerScripts",
"$path": "tests"
}
},
"ReplicatedStorage": {
"$className": "ReplicatedStorage",
"Lib": {
"$path": "lib"
},
"rgb": {
"$path": "rgb.lua"
},
"benches": {
"$path": "benches"
},
"mirror": {
"$path": "mirror"
},
"DevPackages": {
"$path": "benches/visual/DevPackages"
}
}
}
}

View file

@ -1,372 +0,0 @@
local testkit = require("../testkit")
local jecs = require("../lib/init")
local ecr = require("../DevPackages/_Index/centau_ecr@0.8.0/ecr/src/ecr")
local BENCH, START = testkit.benchmark()
local function TITLE(title: string)
print()
print(testkit.color.white(title))
end
local N = 2^16-2
type i53 = number
do TITLE "create"
BENCH("entity", function()
local world = jecs.World.new()
for i = 1, START(N) do
world:entity()
end
end)
end
--- component benchmarks
--todo: perform the same benchmarks for multiple components.?
-- these kind of operations only support 1 component at a time, which is
-- a shame, especially for archetypes where moving components is expensive.
do TITLE "set"
BENCH("add 1 component", function()
local world = jecs.World.new()
local entities = {}
local A = world:component()
for i = 1, N do
entities[i] = world:entity()
end
for i = 1, START(N) do
world:set(entities[i], A, i)
end
end)
BENCH("change 1 component", function()
local world = jecs.World.new()
local entities = {}
local A = world:component()
local e = world:entity()
world:set(e, A, 1)
for i = 1, START(N) do
world:set(e, A, 2)
end
end)
end
do TITLE "remove"
BENCH("1 component", function()
local world = jecs.World.new()
local entities = {}
local A = world:component()
for i = 1, N do
local id = world:entity()
entities[i] = id
world:set(id, A, true)
end
for i = 1, START(N) do
world:remove(entities[i], A)
end
end)
end
do TITLE "get"
BENCH("1 component", function()
local world = jecs.World.new()
local entities = {}
local A = world:component()
for i = 1, N do
local id = world:entity()
entities[i] = id
world:set(id, A, true)
end
for i = 1, START(N) do
-- ? curious why the overhead is roughly 80 ns.
world:get(entities[i], A)
end
end)
BENCH("2 component", function()
local world = jecs.World.new()
local entities = {}
local A = world:component()
local B = world:component()
for i = 1, N do
local id = world:entity()
entities[i] = id
world:set(id, A, true)
world:set(id, B, true)
end
for i = 1, START(N) do
world:get(entities[i], A, B)
end
end)
BENCH("3 component", function()
local world = jecs.World.new()
local entities = {}
local A = world:component()
local B = world:component()
local C = world:component()
for i = 1, N do
local id = world:entity()
entities[i] = id
world:set(id, A, true)
world:set(id, B, true)
world:set(id, C, true)
end
for i = 1, START(N) do
world:get(entities[i], A, B, C)
end
end)
BENCH("4 component", function()
local world = jecs.World.new()
local entities = {}
local A = world:component()
local B = world:component()
local C = world:component()
local D = world:component()
for i = 1, N do
local id = world:entity()
entities[i] = id
world:set(id, A, true)
world:set(id, B, true)
world:set(id, C, true)
world:set(id, D, true)
end
for i = 1, START(N) do
world:get(entities[i], A, B, C, D)
end
end)
end
do TITLE (testkit.color.white_underline("Jecs query"))
local function count(query: () -> ())
local n = 0
for _ in query do
n += 1
end
return n
end
local function flip()
return math.random() > 0.5
end
local function view_bench(
world: jecs.World,
A: i53, B: i53, C: i53, D: i53, E: i53, F: i53, G: i53, H: i53, I: i53
)
BENCH("1 component", function()
START(count(world:query(A)))
for _ in world:query(A) do end
end)
BENCH("2 component", function()
START(count(world:query(A, B)))
for _ in world:query(A, B) do end
end)
BENCH("4 component", function()
START(count(world:query(A, B, C, D)))
for _ in world:query(A, B, C, D) do end
end)
BENCH("8 component", function()
START(count(world:query(A, B, C, D, E, F, G, H)))
for _ in world:query(A, B, C, D, E, F, G, H) do end
end)
end
do TITLE "random components"
local world = jecs.World.new()
local A = world:component()
local B = world:component()
local C = world:component()
local D = world:component()
local E = world:component()
local F = world:component()
local G = world:component()
local H = world:component()
local I = world:component()
for i = 1, N do
local id = world:entity()
if flip() then world:set(id, A, true) end
if flip() then world:set(id, B, true) end
if flip() then world:set(id, C, true) end
if flip() then world:set(id, D, true) end
if flip() then world:set(id, E, true) end
if flip() then world:set(id, F, true) end
if flip() then world:set(id, G, true) end
if flip() then world:set(id, H, true) end
if flip() then world:set(id, I, true) end
end
view_bench(world, A, B, C, D, E, F, G, H, I)
end
do TITLE "one component in common"
local world = jecs.World.new()
local A = world:component()
local B = world:component()
local C = world:component()
local D = world:component()
local E = world:component()
local F = world:component()
local G = world:component()
local H = world:component()
local I = world:component()
for i = 1, N do
local id = world:entity()
local a = true
if flip() then world:set(id, B, true) else a = false end
if flip() then world:set(id, C, true) else a = false end
if flip() then world:set(id, D, true) else a = false end
if flip() then world:set(id, E, true) else a = false end
if flip() then world:set(id, F, true) else a = false end
if flip() then world:set(id, G, true) else a = false end
if flip() then world:set(id, H, true) else a = false end
if flip() then world:set(id, I, true) else a = false end
if a then world:set(id, A, true) end
end
view_bench(world, A, B, C, D, E, F, G, H, I)
end
end
do TITLE (testkit.color.white_underline("ECR query"))
local A = ecr.component()
local B = ecr.component()
local C = ecr.component()
local D = ecr.component()
local E = ecr.component()
local F = ecr.component()
local G = ecr.component()
local H = ecr.component()
local I = ecr.component()
local function count(query: () -> ())
local n = 0
for _ in query do
n += 1
end
return n
end
local function flip()
return math.random() > 0.5
end
local function view_bench(
world: ecr.Registry,
A: i53, B: i53, C: i53, D: i53, E: i53, F: i53, G: i53, H: i53, I: i53
)
BENCH("1 component", function()
START(count(world:view(A)))
for _ in world:view(A) do end
end)
BENCH("2 component", function()
START(count(world:view(A, B)))
for _ in world:view(A, B) do end
end)
BENCH("4 component", function()
START(count(world:view(A, B, C, D)))
for _ in world:view(A, B, C, D) do end
end)
BENCH("8 component", function()
START(count(world:view(A, B, C, D, E, F, G, H)))
for _ in world:view(A, B, C, D, E, F, G, H) do end
end)
end
do TITLE "random components"
local world = ecr.registry()
for i = 1, N do
local id = world.create()
if flip() then world:set(id, A, true) end
if flip() then world:set(id, B, true) end
if flip() then world:set(id, C, true) end
if flip() then world:set(id, D, true) end
if flip() then world:set(id, E, true) end
if flip() then world:set(id, F, true) end
if flip() then world:set(id, G, true) end
if flip() then world:set(id, H, true) end
if flip() then world:set(id, I, true) end
end
view_bench(world, A, B, C, D, E, F, G, H, I)
end
do TITLE "one component in common"
local world = ecr.registry()
for i = 1, N do
local id = world.create()
local a = true
if flip() then world:set(id, B, true) else a = false end
if flip() then world:set(id, C, true) else a = false end
if flip() then world:set(id, D, true) else a = false end
if flip() then world:set(id, E, true) else a = false end
if flip() then world:set(id, F, true) else a = false end
if flip() then world:set(id, G, true) else a = false end
if flip() then world:set(id, H, true) else a = false end
if flip() then world:set(id, I, true) else a = false end
if a then world:set(id, A, true) end
end
view_bench(world, A, B, C, D, E, F, G, H, I)
end
end

View file

@ -2,7 +2,6 @@
--!native --!native
local ReplicatedStorage = game:GetService("ReplicatedStorage") local ReplicatedStorage = game:GetService("ReplicatedStorage")
local rgb = require(ReplicatedStorage.rgb)
local Matter = require(ReplicatedStorage.DevPackages.Matter) local Matter = require(ReplicatedStorage.DevPackages.Matter)
local jecs = require(ReplicatedStorage.Lib) local jecs = require(ReplicatedStorage.Lib)
local ecr = require(ReplicatedStorage.DevPackages.ecr) local ecr = require(ReplicatedStorage.DevPackages.ecr)

View file

@ -3,11 +3,11 @@
local ReplicatedStorage = game:GetService("ReplicatedStorage") local ReplicatedStorage = game:GetService("ReplicatedStorage")
local rgb = require(ReplicatedStorage.rgb) local rgb = require(ReplicatedStorage.rgb)
local Matter = require(ReplicatedStorage.DevPackages.Matter) local Matter = require(ReplicatedStorage.DevPackages["_Index"]["matter-ecs_matter@0.8.1"].matter)
local ecr = require(ReplicatedStorage.DevPackages.ecr) local ecr = require(ReplicatedStorage.DevPackages["_Index"]["centau_ecr@0.8.0"].ecr)
local newWorld = Matter.World.new() local newWorld = Matter.World.new()
local jecs = require(ReplicatedStorage.Lib) local jecs = require(ReplicatedStorage.Shim)
local mirror = require(ReplicatedStorage.mirror) local mirror = require(ReplicatedStorage.mirror)
local mcs = mirror.World.new() local mcs = mirror.World.new()
local ecs = jecs.World.new() local ecs = jecs.World.new()
@ -177,6 +177,13 @@ return {
end end
end, end,
Matter = function()
local matched = 0
for entityId, firstComponent in newWorld:query(A1, A4, A6, A8) do
matched += 1
end
end,
ECR = function() ECR = function()
local matched = 0 local matched = 0
for entityId, firstComponent in registry2:view(B1, B4, B6, B8) do for entityId, firstComponent in registry2:view(B1, B4, B6, B8) do

11
benches/visual/wally.toml Normal file
View file

@ -0,0 +1,11 @@
[package]
name = "private/private"
version = "0.1.0-rc.6"
registry = "https://github.com/UpliftGames/wally-index"
realm = "shared"
include = ["default.project.json", "lib/**", "lib", "wally.toml", "README.md"]
exclude = ["**"]
[dev-dependencies]
Matter = "matter-ecs/matter@0.8.0"
ecr = "centau/ecr@0.8.0"

45
docs/api-types.md Normal file
View file

@ -0,0 +1,45 @@
# World
A World contains all ECS data
Games can have multiple worlds, although typically only one is necessary. These worlds are isolated from each other, meaning they donot share the same entities nor component IDs.
---
# Entity
An unique id.
Entities consist out of a number unique to the entity in the lower 32 bits, and a counter used to track entity liveliness in the upper 32 bits. When an id is recycled, its generation count is increased. This causes recycled ids to be very large (>4 billion), which is normal.
---
# QueryIter
A result from the `World:query` function.
Queries are used to iterate over entities that match against the set collection of components.
Calling it in a loop will allow iteration over the results.
```lua
for id, enemy, charge, model in world:query(Enemy, Charge, Model) do
-- Do something
end
```
### QueryIter.without
QueryIter.without(iter: QueryIter
...: [Entity](#Entity)): QueryIter
Create a new Query Iterator from the filter
#### Parameters
world The world.
... The collection of components to filter archetypes against.
#### Returns
The new query iterator.

187
docs/api/world.md Normal file
View file

@ -0,0 +1,187 @@
# World
### World.new
World.new(): [World](../api-types.md#World)
Create a new world.
#### Returns
A new world
---
### World.entity
World.entity(world: [World](../api-types.md#World)): [Entity](../api-types.md#Entity)
Creates an entity in the world.
#### Returns
A new entiity id
---
### World.target
World.target(world: [World](../api-types.md#World),
entity: [Entity](../api-types.md#Entity),
rel: [Entity](../api-types.md#Entity)): [Entity](../api-types.md#Entity)
Get the target of a relationship.
This will return a target (second element of a pair) of the entity for the specified relationship.
#### Parameters
world The world.
entity The entity.
rel The relationship between the entity and the target.
#### Returns
The first target for the relationship
---
### World.add
World.add(world: [World](../api-types.md#World),
entity: [Entity](../api-types.md#Entity),
id: [Entity](../api-types.md#Entity)): [Entity](..#api-types.md#Entity)
Add a (component) id to an entity.
This operation adds a single (component) id to an entity.
If the entity already has the id, this operation will have no side effects.
#### Parameters
world The world.
entity The entity.
id The id to add.
---
### World.remove
World.remove(world: [World](../api-types#World),
entity: [Entity](../api-types#Entity),
id: [Entity](../api-types#Entity)): [Entity](../api-types#Entity)
Remove a (component) id to an entity.
This operation removes a single (component) id to an entity.
If the entity already has the id, this operation will have no side effects.
#### Parameters
world The world.
entity The entity.
id The id to add.
---
### World.get
World.get(world: [World](../api-types.md#World),
entity: [Entity](../api-types.md#Entity),
id: [Entity](../api-types.md#Entity)): any
Gets the component data.
#### Parameters
world The world.
entity The entity.
id The id of component to get.
#### Returns
The component data, nil if the entity does not have the componnet.
---
### World.set
World.set(world: [World](../api-types.md#World),
entity: [Entity](../api-types.md#Entity),
id: [Entity](../api-types.md#Entity)
data: any)
Set the value of a component.
#### Parameters
world The world.
entity The entity.
id The id of the componment set.
data The data to the component.
---
### World.query
World.query(world: [World](../api-types.md#World),
...: [Entity](../api-types.mdEntity)): [QueryIter](../api-types.md#QueryIter)
Create a QueryIter from the list of filters.
#### Parameters
world The world.
... The collection of components to match entities against.
#### Returns
The query iterator.
---
# Pair
### pair
pair(first: [Entity](../api-types#Entity), second: [Entity](../api-types#Entity)): [Entity](../api-types#Entity)
Creates a composite key.
#### Parameters
first The first element.
second The second element.
#### Returns
The pair of the two elements
---
### IS_PAIR
jecs.IS_PAIR(id: [Entity](../api-types#Entity)): boolean
Creates a composite key.
#### Parameters
id The id to check.
#### Returns
If id is a pair.
---
# Constants
### OnAdd
---
### OnRemove
---
### Rest
---
### OnSet
---
### Wildcard
Matches any id, returns all matches.

View file

@ -0,0 +1,19 @@
# Getting Started
This section will provide a walk through setting up your development environment and a quick overview of the different features and concepts in Jecs with short examples.
## Installing Jecs
To use Jecs, you will need to add the library to your project's source folder.
## Installing as standalone
Head over to the [Releases](https://github.com/ukendio/jecs/releases/latest) page and install the rbxm file.
![jecs.rbxm](rbxm.png)
## Installing with Wally
Jecs is available as a package on [wally.run](https://wally.run/package/ukendio/jecs)
Add it to your project's Wally.toml like this:
```toml
[dependencies]
jecs = "0.1.0" # Make sure this is the latest version
```

Binary file not shown.

After

Width:  |  Height:  |  Size: 34 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 121 KiB

After

Width:  |  Height:  |  Size: 76 KiB

153
lib/index.d.ts vendored Normal file
View file

@ -0,0 +1,153 @@
type Query<T extends unknown[]> = {
without: (...components: Entity[]) => Query<T>;
} & IterableFunction<LuaTuple<[Entity, ...T]>>;
// Utility Types
export type Entity<T = unknown> = number & { __nominal_type_dont_use: T };
export type EntityType<T> = T extends Entity<infer A> ? A : never;
export type InferComponents<A extends Entity[]> = {
[K in keyof A]: EntityType<A[K]>;
};
type Nullable<T extends unknown[]> = {
[K in keyof T]: T[K] | undefined;
};
export class World {
/**
* Creates a new World
*/
constructor();
/**
* Creates a new entity
* @returns Entity
*/
entity(): Entity;
/**
* Creates a new entity located in the first 256 ids.
* These should be used for static components for fast access.
* @returns Entity<T>
*/
component<T = unknown>(): Entity<T>;
/**
* Gets the target of a relationship. For example, when a user calls
* `world.target(id, ChildOf(parent))`, you will obtain the parent entity.
* @param id Entity
* @param relation The Relationship
* @returns The Parent Entity if it exists
*/
target(id: Entity, relation: Entity): Entity | undefined;
/**
* Deletes an entity and all its related components and relationships.
* @param id Entity to be destroyed
*/
delete(id: Entity): void;
/**
* Adds a component to the entity with no value
* @param id Target Entity
* @param component Component
*/
add<T>(id: Entity, component: Entity<T>): void;
/**
* Assigns a value to a component on the given entity
* @param id Target Entity
* @param component Target Component
* @param data Component Data
*/
set<T>(id: Entity, component: Entity<T>, data: T): void;
/**
* Removes a component from the given entity
* @param id Target Entity
* @param component Target Component
*/
remove(id: Entity, component: Entity): void;
// Manually typed out get since there is a hard limit.
/**
* Retrieves the value of one component. This value may be undefined.
* @param id Target Entity
* @param component Target Component
* @returns Data associated with the component if it exists
*/
get<A>(id: number, component: Entity<A>): A | undefined;
/**
* Retrieves the value of two components. This value may be undefined.
* @param id Target Entity
* @param component Target Component 1
* @param component2 Target Component 2
* @returns Data associated with the components if it exists
*/
get<A, B>(
id: number,
component: Entity<A>,
component2: Entity<B>
): LuaTuple<Nullable<[A, B]>>;
/**
* Retrieves the value of three components. This value may be undefined.
* @param id Target Entity
* @param component Target Component 1
* @param component2 Target Component 2
* @param component3 Target Component 3
* @returns Data associated with the components if it exists
*/
get<A, B, C>(
id: number,
component: Entity<A>,
component2: Entity<B>,
component3: Entity<C>
): LuaTuple<Nullable<[A, B, C]>>;
/**
* Retrieves the value of four components. This value may be undefined.
* @param id Target Entity
* @param component Target Component 1
* @param component2 Target Component 2
* @param component3 Target Component 3
* @param component4 Target Component 4
* @returns Data associated with the components if it exists
*/
get<A, B, C, D>(
id: number,
component: Entity<A>,
component2: Entity<B>,
component3: Entity<C>,
component4: Entity<D>
): LuaTuple<Nullable<[A, B, C, D]>>;
/**
* Searches the world for entities that match a given query
* @param components Queried Components
* @returns Iterable function
*/
query<T extends Entity[]>(...components: T): Query<InferComponents<T>>;
}
/**
* Creates a composite key.
* @param pred The first entity
* @param obj The second entity
* @returns The composite key
*/
export const pair: (pred: Entity, obj: Entity) => Entity;
/**
* Checks if the entity is a composite key
* @param e The entity to check
* @returns If the entity is a pair
*/
export const IS_PAIR: (e: Entity) => boolean;
export const OnAdd: Entity;
export const OnRemove: Entity;
export const OnSet: Entity;
export const Wildcard: Entity;
export const Rest: Entity;

View file

@ -6,38 +6,57 @@
type i53 = number type i53 = number
type i24 = number type i24 = number
type Ty = {i53} type Ty = { i53 }
type ArchetypeId = number type ArchetypeId = number
type Column = {any} type Column = { any }
type Archetype = { type Archetype = {
id: number, id: number,
edges: { edges: {
[i24]: { [i53]: {
add: Archetype, add: Archetype,
remove: Archetype, remove: Archetype,
}, },
}, },
types: Ty, types: Ty,
type: string | number, type: string | number,
entities: {number}, entities: { number },
columns: {Column}, columns: { Column },
records: {}, records: { [number]: number },
} }
type Record = { type Record = {
archetype: Archetype, archetype: Archetype,
row: number, row: number,
dense: i24, dense: i24,
componentRecord: ArchetypeMap,
} }
type EntityIndex = {dense: {[i24]: i53}, sparse: {[i53]: Record}} type EntityIndex = { dense: { [i24]: i53 }, sparse: { [i53]: Record } }
type ComponentIndex = {[i24]: ArchetypeMap}
type ArchetypeRecord = number type ArchetypeRecord = number
type ArchetypeMap = {sparse: {[ArchetypeId]: ArchetypeRecord}, size: number} --[[
type Archetypes = {[ArchetypeId]: Archetype} TODO:
{
index: number,
count: number,
column: number
}
]]
type ArchetypeMap = {
cache: { ArchetypeRecord },
first: ArchetypeMap,
second: ArchetypeMap,
parent: ArchetypeMap,
size: number,
}
type ComponentIndex = { [i24]: ArchetypeMap }
type Archetypes = { [ArchetypeId]: Archetype }
type ArchetypeDiff = { type ArchetypeDiff = {
added: Ty, added: Ty,
@ -57,24 +76,25 @@ local ECS_ENTITY_MASK = bit32.lshift(1, 24)
local ECS_GENERATION_MASK = bit32.lshift(1, 16) local ECS_GENERATION_MASK = bit32.lshift(1, 16)
local function addFlags(isPair: boolean) local function addFlags(isPair: boolean)
local typeFlags = 0x0 local typeFlags = 0x0
if isPair then if isPair then
typeFlags = bit32.bor(typeFlags, FLAGS_PAIR) -- HIGHEST bit in the ID. typeFlags = bit32.bor(typeFlags, FLAGS_PAIR) -- HIGHEST bit in the ID.
end end
if false then if false then
typeFlags = bit32.bor(typeFlags, 0x4) -- Set the second flag to true typeFlags = bit32.bor(typeFlags, 0x4) -- Set the second flag to true
end end
if false then if false then
typeFlags = bit32.bor(typeFlags, 0x2) -- Set the third flag to true typeFlags = bit32.bor(typeFlags, 0x2) -- Set the third flag to true
end end
if false then if false then
typeFlags = bit32.bor(typeFlags, 0x1) -- LAST BIT in the ID. typeFlags = bit32.bor(typeFlags, 0x1) -- LAST BIT in the ID.
end end
return typeFlags return typeFlags
end end
<<<<<<< HEAD
local function newId(source: number, target: number): number local function newId(source: number, target: number): number
return ((source * 2^28) + target) * ECS_ID_FLAGS_MASK return ((source * 2^28) + target) * ECS_ID_FLAGS_MASK
end end
@ -87,20 +107,44 @@ local function separate(entity: number): (number, number, number)
local type_flags = entity % 0x10 local type_flags = entity % 0x10
local entity = entity // ECS_ID_FLAGS_MASK local entity = entity // ECS_ID_FLAGS_MASK
return new_entity // ECS_ENTITY_MASK, new_entity % ECS_GENERATION_MASK, type_flags return new_entity // ECS_ENTITY_MASK, new_entity % ECS_GENERATION_MASK, type_flags
=======
local function ECS_COMBINE(source: number, target: number): i53
local e = source * 268435456 + target * ECS_ID_FLAGS_MASK
return e
end
local function ECS_IS_PAIR(e: number)
if e > ECS_ENTITY_MASK then
return (e % 2 ^ 4) // FLAGS_PAIR ~= 0
end
return false
>>>>>>> eae51988a9e3ca45e39ebcfdbea0f9f8706bd3cd
end end
-- HIGH 24 bits LOW 24 bits -- HIGH 24 bits LOW 24 bits
local function ECS_GENERATION(e: i53) local function ECS_GENERATION(e: i53)
<<<<<<< HEAD
return (e // 0x10) % ECS_GENERATION_MASK return (e // 0x10) % ECS_GENERATION_MASK
end end
local function ECS_ID(e: i53) local function ECS_ID(e: i53)
return (e // 0x10) // ECS_ENTITY_MASK return (e // 0x10) // ECS_ENTITY_MASK
=======
if e > ECS_ENTITY_MASK then
e = e // 0x10
return e % ECS_GENERATION_MASK
end
return 0
>>>>>>> eae51988a9e3ca45e39ebcfdbea0f9f8706bd3cd
end end
local function ECS_GENERATION_INC(e: i53) local function ECS_GENERATION_INC(e: i53)
local id, generation, flags = separate(e) if e > ECS_ENTITY_MASK then
local flags = e // 0x10
local id = flags // ECS_ENTITY_MASK
local generation = flags % ECS_GENERATION_MASK
<<<<<<< HEAD
return newId(id, generation + 1) + flags return newId(id, generation + 1) + flags
end end
@ -132,21 +176,60 @@ end
local function getAlive(entityIndex: EntityIndex, id: i53) local function getAlive(entityIndex: EntityIndex, id: i53)
return entityIndex.dense[id] return entityIndex.dense[id]
=======
return ECS_COMBINE(id, generation + 1) + flags
end
return ECS_COMBINE(e, 1)
>>>>>>> eae51988a9e3ca45e39ebcfdbea0f9f8706bd3cd
end end
local function ecs_get_source(entityIndex, e) -- FIRST gets the high ID
assert(ECS_IS_PAIR(e)) local function ECS_ENTITY_T_HI(e: i53): i24
return getAlive(entityIndex, ECS_PAIR_FIRST(e)) if e > ECS_ENTITY_MASK then
end e = e // 0x10
local function ecs_get_target(entityIndex, e) return e % ECS_ENTITY_MASK
assert(ECS_IS_PAIR(e)) end
return getAlive(entityIndex, ECS_PAIR_SECOND(e)) return e
end end
<<<<<<< HEAD
local function nextEntityId(entityIndex, index: i24): i53 local function nextEntityId(entityIndex, index: i24): i53
local id = newId(index, 0) local id = newId(index, 0)
=======
-- SECOND
local function ECS_ENTITY_T_LO(e: i53): i24
if e > ECS_ENTITY_MASK then
e = e // 0x10
return e // ECS_ENTITY_MASK
end
return e
end
local function ECS_PAIR(pred: i53, obj: i53): i53
return ECS_COMBINE(ECS_ENTITY_T_LO(obj), ECS_ENTITY_T_LO(pred)) + addFlags(--[[isPair]] true) :: i53
end
local function getAlive(entityIndex: EntityIndex, id: i24)
local entityId = entityIndex.dense[id]
return entityId
end
-- ECS_PAIR_FIRST, gets the relationship target / obj / HIGH bits
local function ECS_PAIR_RELATION(entityIndex, e)
return getAlive(entityIndex, ECS_ENTITY_T_HI(e))
end
-- ECS_PAIR_SECOND gets the relationship / pred / LOW bits
local function ECS_PAIR_OBJECT(entityIndex, e)
return getAlive(entityIndex, ECS_ENTITY_T_LO(e))
end
local function nextEntityId(entityIndex: EntityIndex, index: i24): i53
--local id = ECS_COMBINE(index, 0)
local id = index
>>>>>>> eae51988a9e3ca45e39ebcfdbea0f9f8706bd3cd
entityIndex.sparse[id] = { entityIndex.sparse[id] = {
dense = index dense = index,
} :: Record } :: Record
entityIndex.dense[index] = id entityIndex.dense[index] = id
@ -198,7 +281,7 @@ local function transitionArchetype(
sourceEntities[sourceRow] = e2 sourceEntities[sourceRow] = e2
end end
sourceEntities[movedAway] = nil sourceEntities[movedAway] = nil :: any
destinationEntities[destinationRow] = e1 destinationEntities[destinationRow] = e1
local record1 = sparse[e1] local record1 = sparse[e1]
@ -222,7 +305,7 @@ local function newEntity(entityId: i53, record: Record, archetype: Archetype)
return record return record
end end
local function moveEntity(entityIndex, entityId: i53, record: Record, to: Archetype) local function moveEntity(entityIndex: EntityIndex, entityId: i53, record: Record, to: Archetype)
local sourceRow = record.row local sourceRow = record.row
local from = record.archetype local from = record.archetype
local destinationRow = archetypeAppend(entityId, to) local destinationRow = archetypeAppend(entityId, to)
@ -231,58 +314,75 @@ local function moveEntity(entityIndex, entityId: i53, record: Record, to: Archet
record.row = destinationRow record.row = destinationRow
end end
local function hash(arr): string | number local function hash(arr): string
return table.concat(arr, "_") return table.concat(arr, "_")
end end
local function createArchetypeRecord(componentIndex, id, componentId, i) local function ensureComponentRecord(
componentIndex: ComponentIndex,
archetypeId: number,
componentId: number,
i: number
): ArchetypeMap
local archetypesMap = componentIndex[componentId] local archetypesMap = componentIndex[componentId]
if not archetypesMap then if not archetypesMap then
archetypesMap = {size = 0, sparse = {}} archetypesMap = ({ size = 0, cache = {} } :: any) :: ArchetypeMap
componentIndex[componentId] = archetypesMap componentIndex[componentId] = archetypesMap
end end
archetypesMap.sparse[id] = i
archetypesMap.cache[archetypeId] = i
archetypesMap.size += 1
return archetypesMap
end end
local function archetypeOf(world: World, types: {i24}, prev: Archetype?): Archetype local function ECS_ID_IS_WILDCARD(e)
assert(ECS_IS_PAIR(e))
local first = ECS_ENTITY_T_HI(e)
local second = ECS_ENTITY_T_LO(e)
return first == WILDCARD or second == WILDCARD
end
local function archetypeOf(world: any, types: { i24 }, prev: Archetype?): Archetype
local ty = hash(types) local ty = hash(types)
local id = world.nextArchetypeId + 1 local id = world.nextArchetypeId + 1
world.nextArchetypeId = id world.nextArchetypeId = id
local length = #types local length = #types
local columns = table.create(length) local columns = (table.create(length) :: any) :: { Column }
local componentIndex = world.componentIndex
local records = {} local records = {}
local componentIndex = world.componentIndex
local entityIndex = world.entityIndex
for i, componentId in types do for i, componentId in types do
createArchetypeRecord(componentIndex, id, componentId, i) ensureComponentRecord(componentIndex, id, componentId, i)
records[componentId] = i records[componentId] = i
columns[i] = {}
if ECS_IS_PAIR(componentId) then if ECS_IS_PAIR(componentId) then
local first = ecs_get_source(entityIndex, componentId) local relation = ECS_PAIR_RELATION(world.entityIndex, componentId)
local second = ecs_get_target(entityIndex, componentId) local object = ECS_PAIR_OBJECT(world.entityIndex, componentId)
local firstPair = ECS_PAIR(first, WILDCARD)
local secondPair = ECS_PAIR(WILDCARD, second) local idr_r = ECS_PAIR(relation, WILDCARD)
createArchetypeRecord(componentIndex, id, firstPair, i) ensureComponentRecord(componentIndex, id, idr_r, i)
createArchetypeRecord(componentIndex, id, secondPair, i) records[idr_r] = i
records[firstPair] = i
records[secondPair] = i local idr_t = ECS_PAIR(WILDCARD, object)
ensureComponentRecord(componentIndex, id, idr_t, i)
records[idr_t] = i
end end
columns[i] = {}
end end
local archetype = { local archetype: Archetype = {
columns = columns; columns = columns,
edges = {}; edges = {},
entities = {}; entities = {},
id = id; id = id,
records = records; records = records,
type = ty; type = ty,
types = types; types = types,
} }
world.archetypeIndex[ty] = archetype world.archetypeIndex[ty] = archetype
world.archetypes[id] = archetype world.archetypes[id] = archetype
@ -293,25 +393,27 @@ local World = {}
World.__index = World World.__index = World
function World.new() function World.new()
local self = setmetatable({ local self = setmetatable({
archetypeIndex = {}; archetypeIndex = {} :: { [string]: Archetype },
archetypes = {} :: Archetypes; archetypes = {} :: Archetypes,
componentIndex = {} :: ComponentIndex; componentIndex = {} :: ComponentIndex,
entityIndex = { entityIndex = {
dense = {}, dense = {} :: { [i24]: i53 },
sparse = {} sparse = {} :: { [i53]: Record },
} :: EntityIndex; } :: EntityIndex,
hooks = { hooks = {
[ON_ADD] = {}; [ON_ADD] = {},
}; },
nextArchetypeId = 0; nextArchetypeId = 0,
nextComponentId = 0; nextComponentId = 0,
nextEntityId = 0; nextEntityId = 0,
ROOT_ARCHETYPE = (nil :: any) :: Archetype; ROOT_ARCHETYPE = (nil :: any) :: Archetype,
}, World) }, World)
self.ROOT_ARCHETYPE = archetypeOf(self, {}) self.ROOT_ARCHETYPE = archetypeOf(self, {})
return self return self
end end
export type World = typeof(World.new())
function World.component(world: World) function World.component(world: World)
local componentId = world.nextComponentId + 1 local componentId = world.nextComponentId + 1
if componentId > HI_COMPONENT_ID then if componentId > HI_COMPONENT_ID then
@ -329,8 +431,32 @@ function World.entity(world: World)
return nextEntityId(world.entityIndex, entityId + REST) return nextEntityId(world.entityIndex, entityId + REST)
end end
-- TODO:
-- should have an additional `index` parameter which selects the nth target
-- this is important when an entity can have multiple relationships with the same target
function World.target(world: World, entity: i53, relation: i24): i24?
local entityIndex = world.entityIndex
local record = entityIndex.sparse[entity]
local archetype = record.archetype
if not archetype then
return nil
end
local componentRecord = world.componentIndex[ECS_PAIR(relation, WILDCARD)]
if not componentRecord then
return nil
end
local archetypeRecord = componentRecord.cache[archetype.id]
if not archetypeRecord then
return nil
end
return ECS_PAIR_OBJECT(entityIndex, archetype.types[archetypeRecord])
end
-- should reuse this logic in World.set instead of swap removing in transition archetype -- should reuse this logic in World.set instead of swap removing in transition archetype
local function destructColumns(columns, count, row) local function destructColumns(columns: { Column }, count: number, row: number)
if row == count then if row == count then
for _, column in columns do for _, column in columns do
column[count] = nil column[count] = nil
@ -343,46 +469,58 @@ local function destructColumns(columns, count, row)
end end
end end
local function archetypeDelete(entityIndex, record: Record, entityId: i53, destruct: boolean) local function archetypeDelete(world: World, id: i53)
local sparse, dense = entityIndex.sparse, entityIndex.dense local componentIndex = world.componentIndex
local archetype = record.archetype local archetypesMap = componentIndex[id]
local row = record.row local archetypes = world.archetypes
local entities = archetype.entities if archetypesMap then
local last = #entities for archetypeId in archetypesMap.cache do
for _, entity in archetypes[archetypeId].entities do
world:remove(entity, id)
end
end
local entityToMove = entities[last] componentIndex[id] = nil :: any
if row ~= last then
dense[record.dense] = entityToMove
sparse[entityToMove] = record
end end
sparse[entityId] = nil
dense[#dense] = nil
entities[row], entities[last] = entities[last], nil
local columns = archetype.columns
if not destruct then
return
end
destructColumns(columns, last, row)
end end
function World.delete(world: World, entityId: i53) function World.delete(world: World, entityId: i53)
local entityIndex = world.entityIndex local record = world.entityIndex.sparse[entityId]
local record = entityIndex.sparse[entityId]
if not record then if not record then
return return
end end
archetypeDelete(entityIndex, record, entityId, true) local entityIndex = world.entityIndex
local sparse, dense = entityIndex.sparse, entityIndex.dense
local archetype = record.archetype
local row = record.row
archetypeDelete(world, entityId)
-- TODO: should traverse linked )component records to pairs including entityId
archetypeDelete(world, ECS_PAIR(entityId, WILDCARD))
archetypeDelete(world, ECS_PAIR(WILDCARD, entityId))
if archetype then
local entities = archetype.entities
local last = #entities
if row ~= last then
local entityToMove = entities[last]
dense[record.dense] = entityToMove
sparse[entityToMove] = record
end
entities[row], entities[last] = entities[last], nil :: any
local columns = archetype.columns
destructColumns(columns, last, row)
end
sparse[entityId] = nil :: any
dense[#dense] = nil :: any
end end
export type World = typeof(World.new()) local function ensureArchetype(world: World, types, prev): Archetype
local function ensureArchetype(world: World, types, prev)
if #types < 1 then if #types < 1 then
return world.ROOT_ARCHETYPE return world.ROOT_ARCHETYPE
end end
@ -396,7 +534,7 @@ local function ensureArchetype(world: World, types, prev)
return archetypeOf(world, types, prev) return archetypeOf(world, types, prev)
end end
local function findInsert(types: {i53}, toAdd: i53) local function findInsert(types: { i53 }, toAdd: i53)
for i, id in types do for i, id in types do
if id == toAdd then if id == toAdd then
return -1 return -1
@ -414,7 +552,7 @@ local function findArchetypeWith(world: World, node: Archetype, componentId: i53
-- them each time would be expensive. Instead this insertion sort can find the insertion -- them each time would be expensive. Instead this insertion sort can find the insertion
-- point in the types array. -- point in the types array.
local destinationType = table.clone(node.types) local destinationType = table.clone(node.types) :: { i53 }
local at = findInsert(types, componentId) local at = findInsert(types, componentId)
if at == -1 then if at == -1 then
-- If it finds a duplicate, it just means it is the same archetype so it can return it -- If it finds a duplicate, it just means it is the same archetype so it can return it
@ -499,7 +637,7 @@ local function archetypeTraverseRemove(world: World, componentId: i53, from: Arc
local remove = edge.remove local remove = edge.remove
if not remove then if not remove then
local to = table.clone(from.types) local to = table.clone(from.types) :: { i53 }
local at = table.find(to, componentId) local at = table.find(to, componentId)
if not at then if not at then
return from return from
@ -526,6 +664,10 @@ end
-- Keeping the function as small as possible to enable inlining -- Keeping the function as small as possible to enable inlining
local function get(record: Record, componentId: i24) local function get(record: Record, componentId: i24)
local archetype = record.archetype local archetype = record.archetype
if not archetype then
return nil
end
local archetypeRecord = archetype.records[componentId] local archetypeRecord = archetype.records[componentId]
if not archetypeRecord then if not archetypeRecord then
@ -535,7 +677,7 @@ local function get(record: Record, componentId: i24)
return archetype.columns[archetypeRecord][record.row] return archetype.columns[archetypeRecord][record.row]
end end
function World.get(world: World, entityId: i53, a: i53, b: i53?, c: i53?, d: i53?, e: i53?) function World.get(world: World, entityId: i53, a: i53, b: i53?, c: i53?, d: i53?, e: i53?): any
local id = entityId local id = entityId
local record = world.entityIndex.sparse[id] local record = world.entityIndex.sparse[id]
if not record then if not record then
@ -559,33 +701,35 @@ end
-- the less creation the better -- the less creation the better
local function actualNoOperation() end local function actualNoOperation() end
local function noop(_self: Query, ...: i53): () -> (number, ...any) local function noop(_self: Query, ...): () -> ()
return actualNoOperation :: any return actualNoOperation :: any
end end
local EmptyQuery = { local EmptyQuery = {
__iter = noop; __iter = noop,
without = noop; without = noop,
} }
EmptyQuery.__index = EmptyQuery EmptyQuery.__index = EmptyQuery
setmetatable(EmptyQuery, EmptyQuery) setmetatable(EmptyQuery, EmptyQuery)
export type Query = typeof(EmptyQuery) export type Query = typeof(EmptyQuery)
function World.query(world: World, ...: i53): Query type CompatibleArchetype = { archetype: Archetype, indices: { number } }
function World.query(world: World, ...): Query
-- breaking? -- breaking?
if (...) == nil then if (...) == nil then
error("Missing components") error("Missing components")
end end
local compatibleArchetypes = {} local compatibleArchetypes: { CompatibleArchetype } = {}
local length = 0 local length = 0
local components = {...} local components = { ... }
local archetypes = world.archetypes local archetypes = world.archetypes
local queryLength = #components local queryLength = #components
local firstArchetypeMap local firstArchetypeMap: ArchetypeMap
local componentIndex = world.componentIndex local componentIndex = world.componentIndex
for _, componentId in components do for _, componentId in components do
@ -599,9 +743,10 @@ function World.query(world: World, ...: i53): Query
end end
end end
for id in firstArchetypeMap.sparse do for id in firstArchetypeMap.cache do
local archetype = archetypes[id] local archetype = archetypes[id]
local archetypeRecords = archetype.records local archetypeRecords = archetype.records
local indices = {} local indices = {}
local skip = false local skip = false
@ -611,6 +756,7 @@ function World.query(world: World, ...: i53): Query
skip = true skip = true
break break
end end
-- index should be index.offset
indices[i] = index indices[i] = index
end end
@ -619,21 +765,110 @@ function World.query(world: World, ...: i53): Query
end end
length += 1 length += 1
compatibleArchetypes[length] = {archetype, indices} compatibleArchetypes[length] = {
archetype = archetype,
indices = indices,
}
end end
local lastArchetype, compatibleArchetype = next(compatibleArchetypes) local lastArchetype = 1
if not lastArchetype then local compatibleArchetype: CompatibleArchetype = compatibleArchetypes[lastArchetype]
if not compatibleArchetype then
return EmptyQuery return EmptyQuery
end end
local preparedQuery = {} local preparedQuery = {}
preparedQuery.__index = preparedQuery preparedQuery.__index = preparedQuery
local queryOutput = {}
local i = 1
local function queryNext()
local archetype = compatibleArchetype.archetype
local entityId = archetype.entities[i]
while entityId == nil do
lastArchetype += 1
if lastArchetype > #compatibleArchetypes then
return
end
compatibleArchetype = compatibleArchetypes[lastArchetype]
archetype = compatibleArchetype.archetype
i = 1
entityId = archetype.entities[i]
end
local row = i
i+=1
local columns = archetype.columns
local tr = compatibleArchetype.indices
if queryLength == 1 then
return entityId, columns[tr[1]][row]
elseif queryLength == 2 then
return entityId, columns[tr[1]][row], columns[tr[2]][row]
elseif queryLength == 3 then
return entityId, columns[tr[1]][row], columns[tr[2]][row], columns[tr[3]][row]
elseif queryLength == 4 then
return entityId, columns[tr[1]][row], columns[tr[2]][row], columns[tr[3]][row], columns[tr[4]][row]
elseif queryLength == 5 then
return entityId,
columns[tr[1]][row],
columns[tr[2]][row],
columns[tr[3]][row],
columns[tr[4]][row],
columns[tr[5]][row]
elseif queryLength == 6 then
return entityId,
columns[tr[1]][row],
columns[tr[2]][row],
columns[tr[3]][row],
columns[tr[4]][row],
columns[tr[5]][row],
columns[tr[6]][row]
elseif queryLength == 7 then
return entityId,
columns[tr[1]][row],
columns[tr[2]][row],
columns[tr[3]][row],
columns[tr[4]][row],
columns[tr[5]][row],
columns[tr[6]][row],
columns[tr[7]][row]
elseif queryLength == 8 then
return entityId,
columns[tr[1]][row],
columns[tr[2]][row],
columns[tr[3]][row],
columns[tr[4]][row],
columns[tr[5]][row],
columns[tr[6]][row],
columns[tr[7]][row],
columns[tr[8]][row]
end
for i in components do
queryOutput[i] = columns[tr[i]][row]
end
return entityId, unpack(queryOutput :: any, 1, queryLength)
end
function preparedQuery:__iter()
return queryNext
end
function preparedQuery:next()
return queryNext()
end
function preparedQuery:without(...) function preparedQuery:without(...)
local withoutComponents = {...} local withoutComponents = { ... }
for i = #compatibleArchetypes, 1, -1 do for i = #compatibleArchetypes, 1, -1 do
local archetype = compatibleArchetypes[i][1] local archetype = compatibleArchetypes[i].archetype
local records = archetype.records local records = archetype.records
local shouldRemove = false local shouldRemove = false
@ -649,7 +884,7 @@ function World.query(world: World, ...: i53): Query
end end
end end
lastArchetype, compatibleArchetype = next(compatibleArchetypes) lastArchetype, compatibleArchetype = next(compatibleArchetypes :: any)
if not lastArchetype then if not lastArchetype then
return EmptyQuery return EmptyQuery
end end
@ -657,92 +892,20 @@ function World.query(world: World, ...: i53): Query
return self return self
end end
local lastRow
local queryOutput = {}
function preparedQuery:__iter()
return function()
local archetype = compatibleArchetype[1]
local row: number = next(archetype.entities, lastRow) :: number
while row == nil do
lastArchetype, compatibleArchetype = next(compatibleArchetypes, lastArchetype)
if lastArchetype == nil then
return
end
archetype = compatibleArchetype[1]
row = next(archetype.entities, row) :: number
end
lastRow = row
local entityId = archetype.entities[row :: number]
local columns = archetype.columns
local tr = compatibleArchetype[2]
if queryLength == 1 then
return entityId, columns[tr[1]][row]
elseif queryLength == 2 then
return entityId, columns[tr[1]][row], columns[tr[2]][row]
elseif queryLength == 3 then
return entityId, columns[tr[1]][row], columns[tr[2]][row], columns[tr[3]][row]
elseif queryLength == 4 then
return entityId, columns[tr[1]][row], columns[tr[2]][row], columns[tr[3]][row], columns[tr[4]][row]
elseif queryLength == 5 then
return entityId,
columns[tr[1]][row],
columns[tr[2]][row],
columns[tr[3]][row],
columns[tr[4]][row],
columns[tr[5]][row]
elseif queryLength == 6 then
return entityId,
columns[tr[1]][row],
columns[tr[2]][row],
columns[tr[3]][row],
columns[tr[4]][row],
columns[tr[5]][row],
columns[tr[6]][row]
elseif queryLength == 7 then
return entityId,
columns[tr[1]][row],
columns[tr[2]][row],
columns[tr[3]][row],
columns[tr[4]][row],
columns[tr[5]][row],
columns[tr[6]][row],
columns[tr[7]][row]
elseif queryLength == 8 then
return entityId,
columns[tr[1]][row],
columns[tr[2]][row],
columns[tr[3]][row],
columns[tr[4]][row],
columns[tr[5]][row],
columns[tr[6]][row],
columns[tr[7]][row],
columns[tr[8]][row]
end
for i in components do
queryOutput[i] = columns[tr[i]][row]
end
return entityId, unpack(queryOutput, 1, queryLength)
end
end
return setmetatable({}, preparedQuery) :: any return setmetatable({}, preparedQuery) :: any
end end
function World.__iter(world: World): () -> (number?, unknown?) function World.__iter(world: World): () -> any
local dense = world.entityIndex.dense local dense = world.entityIndex.dense
local sparse = world.entityIndex.sparse local sparse = world.entityIndex.sparse
local last local last
return function() return function()
local lastEntity, entityId = next(dense, last) local lastEntity: number?, entityId: number = next(dense, last)
if not lastEntity then if not lastEntity then
return return
end end
last = lastEntity last = lastEntity
local record = sparse[entityId] local record = sparse[entityId]
@ -766,24 +929,153 @@ function World.__iter(world: World): () -> (number?, unknown?)
end end
end end
return table.freeze({ -- __nominal_type_dont_use could not be any or T as it causes a type error
World = World; -- or produces a union
export type Entity<T = any> = number & { __nominal_type_dont_use: T }
export type Pair = number
OnAdd = ON_ADD; export type QueryShim<T...> = typeof(setmetatable({
OnRemove = ON_REMOVE; without = function(...): QueryShim<T...>
OnSet = ON_SET; return nil :: any
Wildcard = WILDCARD, end,
w = WILDCARD, }, {
__iter = function(): () -> (number, T...)
return nil :: any
end,
}))
export type WorldShim = typeof(setmetatable(
{} :: {
--- Creates a new entity
entity: (WorldShim) -> Entity,
--- Creates a new entity located in the first 256 ids.
--- These should be used for static components for fast access.
component: <T>(WorldShim) -> Entity<T>,
--- Gets the target of an relationship. For example, when a user calls
--- `world:target(id, ChildOf(parent))`, you will obtain the parent entity.
target: (WorldShim, id: Entity, relation: Entity) -> Entity?,
--- Deletes an entity and all it's related components and relationships.
delete: (WorldShim, id: Entity) -> (),
--- Adds a component to the entity with no value
add: <T>(WorldShim, id: Entity, component: Entity<T>) -> (),
--- Assigns a value to a component on the given entity
set: <T>(WorldShim, id: Entity, component: Entity<T>, data: T) -> (),
--- Removes a component from the given entity
remove: (WorldShim, id: Entity, component: Entity) -> (),
--- Retrieves the value of up to 4 components. These values may be nil.
get: (<A>(WorldShim, id: any, Entity<A>) -> A)
& (<A, B>(WorldShim, id: Entity, Entity<A>, Entity<B>) -> (A, B))
& (<A, B, C>(WorldShim, id: Entity, Entity<A>, Entity<B>, Entity<C>) -> (A, B, C))
& <A, B, C, D>(WorldShim, id: Entity, Entity<A>, Entity<B>, Entity<C>, Entity<D>) -> (A, B, C, D),
--- Searches the world for entities that match a given query
query: (<A>(WorldShim, Entity<A>) -> QueryShim<A>)
& (<A, B>(WorldShim, Entity<A>, Entity<B>) -> QueryShim<A, B>)
& (<A, B, C>(WorldShim, Entity<A>, Entity<B>, Entity<C>) -> QueryShim<A, B, C>)
& (<A, B, C, D>(WorldShim, Entity<A>, Entity<B>, Entity<C>, Entity<D>) -> QueryShim<A, B, C, D>)
& (<A, B, C, D, E>(
WorldShim,
Entity<A>,
Entity<B>,
Entity<C>,
Entity<D>,
Entity<E>
) -> QueryShim<A, B, C, D, E>)
& (<A, B, C, D, E, F>(
WorldShim,
Entity<A>,
Entity<B>,
Entity<C>,
Entity<D>,
Entity<E>,
Entity<F>
) -> QueryShim<A, B, C, D, E, F>)
& (<A, B, C, D, E, F, G>(
WorldShim,
Entity<A>,
Entity<B>,
Entity<C>,
Entity<D>,
Entity<E>,
Entity<F>,
Entity<G>
) -> QueryShim<A, B, C, D, E, F, G>)
& (<A, B, C, D, E, F, G, H>(
WorldShim,
Entity<A>,
Entity<B>,
Entity<C>,
Entity<D>,
Entity<E>,
Entity<F>,
Entity<G>,
Entity<H>
) -> QueryShim<A, B, C, D, E, F, G, H>)
& (<A, B, C, D, E, F, G, H, I>(
WorldShim,
Entity<A>,
Entity<B>,
Entity<C>,
Entity<D>,
Entity<E>,
Entity<F>,
Entity<G>,
Entity<H>,
Entity<I>
) -> QueryShim<A, B, C, D, E, F, G, H, I>)
& (<A, B, C, D, E, F, G, H, I, J>(
WorldShim,
Entity<A>,
Entity<B>,
Entity<C>,
Entity<D>,
Entity<E>,
Entity<F>,
Entity<G>,
Entity<H>,
Entity<I>,
Entity<J>
) -> QueryShim<A, B, C, D, E, F, G, H, I, J>)
& (<A, B, C, D, E, F, G, H, I, J, K>(
WorldShim,
Entity<A>,
Entity<B>,
Entity<C>,
Entity<D>,
Entity<E>,
Entity<F>,
Entity<G>,
Entity<H>,
Entity<I>,
Entity<J>,
Entity<K>,
...Entity<any>
) -> QueryShim<A, B, C, D, E, F, G, H, I, J, K>),
},
{} :: {
__iter: (world: WorldShim) -> () -> (number, { [unknown]: unknown? }),
}
))
return table.freeze({
World = (World :: any) :: { new: () -> WorldShim },
OnAdd = (ON_ADD :: any) :: Entity,
OnRemove = (ON_REMOVE :: any) :: Entity,
OnSet = (ON_SET :: any) :: Entity,
Wildcard = (WILDCARD :: any) :: Entity,
w = (WILDCARD :: any) :: Entity,
Rest = REST, Rest = REST,
ECS_ID = ECS_ID,
IS_PAIR = ECS_IS_PAIR, IS_PAIR = ECS_IS_PAIR,
ECS_ID = ECS_ENTITY_T_LO,
ECS_PAIR = ECS_PAIR, ECS_PAIR = ECS_PAIR,
ECS_GENERATION_INC = ECS_GENERATION_INC, ECS_GENERATION_INC = ECS_GENERATION_INC,
ECS_GENERATION = ECS_GENERATION, ECS_GENERATION = ECS_GENERATION,
ecs_get_target = ecs_get_target, ECS_PAIR_RELATION = ECS_PAIR_RELATION,
ecs_get_source = ecs_get_source, ECS_PAIR_OBJECT = ECS_PAIR_OBJECT,
pair = ECS_PAIR, pair = (ECS_PAIR :: any) :: <R, T>(pred: Entity, obj: Entity) -> number,
getAlive = getAlive, getAlive = getAlive,
}) })

View file

@ -1,382 +0,0 @@
local jecs = require(script.Parent)
local world = jecs.World.new()
local A, B, C, D = world:entity(), world:entity(), world:entity(), world:entity()
local E, F, G, H = world:entity(), world:entity(), world:entity(), world:entity()
print("A", A)
print("B", B)
print("C", C)
print("D", D)
print("E", E)
print("F", F)
print("G", G)
print("H", H)
local common = 0
local N = 2^16-2
local archetypes = {}
local function flip()
return math.random() >= 0.5
end
local amountOfCombination = 0
for i = 1, N do
local entity = world:entity()
local combination = ""
if flip() then
combination ..= "2_"
world:set(entity, B, { value = true})
end
if flip() then
combination ..= "3_"
world:set(entity, C, { value = true})
end
if flip() then
combination ..= "4_"
world:set(entity, D, { value = true})
end
if flip() then
combination ..= "5_"
world:set(entity, E, { value = true})
end
if flip() then
combination ..= "6_"
world:set(entity, F, { value = true})
end
if flip() then
combination ..= "7_"
world:set(entity, G, { value = true})
end
if flip() then
combination ..= "8"
world:set(entity, H, { value = true})
end
if #combination == 7 then
combination = "1_" .. combination
common += 1
world:set(entity, A, { value = true})
end
if combination:find("2")
and combination:find("3")
and combination:find("4")
and combination:find("6")
then
amountOfCombination += 1
end
archetypes[combination] = true
end
return function()
describe("World", function()
it("should add component", function()
local id = world:entity()
world:set(id, A, true)
world:set(id, B, 1)
local id1 = world:entity()
world:set(id1, A, "hello")
expect(world:get(id, A)).to.equal(true)
expect(world:get(id, B)).to.equal(1)
expect(world:get(id1, A)).to.equal("hello")
end)
it("should remove component", function()
local Tag = world:entity()
local entities = {}
for i = 1, 10 do
local entity = world:entity()
entities[i] = entity
world:set(entity, Tag)
end
for i = 1, 10 do
local entity = entities[i]
expect(world:get(entity, Tag)).to.equal(nil)
world:remove(entity, Tag)
end
end)
it("should override component data", function()
local id = world:entity()
world:set(id, A, true)
expect(world:get(id, A)).to.equal(true)
world:set(id, A, false)
expect(world:get(id, A)).to.equal(false)
end)
it("should not query a removed component", function()
local Tag = world:entity()
local AnotherTag = world:entity()
local entity = world:entity()
world:set(entity, Tag)
world:set(entity, AnotherTag)
world:remove(entity, AnotherTag)
local added = 0
for e, t, a in world:query(Tag, AnotherTag) do
added += 1
end
expect(added).to.equal(0)
end)
it("should query correct number of compatible archetypes", function()
local added = 0
for _ in world:query(B, C, D, F) do
added += 1
end
expect(added).to.equal(amountOfCombination)
end)
it("should not query poisoned players", function()
local Player = world:entity()
local Health = world:entity()
local Poison = world:entity()
local one = world:entity()
world:set(one, Player, { name = "alice"})
world:set(one, Health, 100)
world:set(one, Poison)
local two = world:entity()
world:set(two, Player, { name = "bob"})
world:set(two, Health, 90)
local withoutCount = 0
for _id, _player in world:query(Player):without(Poison) do
withoutCount += 1
end
expect(withoutCount).to.equal(1)
end)
it("should allow calling world:entity before world:component", function()
for _ = 1, 256 do
world:entity()
end
expect(world:component()).to.be.ok()
end)
it("should skip iteration", function()
local Position, Velocity = world:entity(), world:entity()
local e = world:entity()
world:set(e, Position, Vector3.zero)
world:set(e, Velocity, Vector3.one)
local added = 0
for i in world:query(Position):without(Velocity) do
added += 1
end
expect(added).to.equal(0)
end)
it("should query all matching entities", function()
local world = jecs.World.new()
local A = world:component()
local B = world:component()
local entities = {}
for i = 1, N do
local id = world:entity()
world:set(id, A, true)
if i > 5 then world:set(id, B, true) end
entities[i] = id
end
for id in world:query(A) do
local i = table.find(entities, id)
expect(i).to.be.ok()
table.remove(entities, i)
end
expect(#entities).to.equal(0)
end)
it("should query all matching entities when irrelevant component is removed", function()
local world = jecs.World.new()
local A = world:component()
local B = world:component()
local entities = {}
for i = 1, N do
local id = world:entity()
world:set(id, A, true)
world:set(id, B, true)
if i > 5 then world:remove(id, B, true) end
entities[i] = id
end
local added = 0
for id in world:query(A) do
added += 1
local i = table.find(entities, id)
expect(i).to.be.ok()
table.remove(entities, i)
end
expect(added).to.equal(N)
end)
it("should query all entities without B", function()
local world = jecs.World.new()
local A = world:component()
local B = world:component()
local entities = {}
for i = 1, N do
local id = world:entity()
world:set(id, A, true)
if i < 5 then
entities[i] = id
else
world:set(id, B, true)
end
end
for id in world:query(A):without(B) do
local i = table.find(entities, id)
expect(i).to.be.ok()
table.remove(entities, i)
end
expect(#entities).to.equal(0)
end)
it("should allow setting components in arbitrary order", function()
local world = jecs.World.new()
local Health = world:entity()
local Poison = world:component()
local id = world:entity()
world:set(id, Poison, 5)
world:set(id, Health, 50)
expect(world:get(id, Poison)).to.equal(5)
end)
it("Should allow deleting components", function()
local world = jecs.World.new()
local Health = world:entity()
local Poison = world:component()
local id = world:entity()
world:set(id, Poison, 5)
world:set(id, Health, 50)
world:delete(id)
expect(world:get(id, Poison)).to.never.be.ok()
expect(world:get(id, Health)).to.never.be.ok()
end)
it("should allow iterating the whole world", function()
local world = jecs.World.new()
local A, B = world:entity(), world:entity()
local eA = world:entity()
world:set(eA, A, true)
local eB = world:entity()
world:set(eB, B, true)
local eAB = world:entity()
world:set(eAB, A, true)
world:set(eAB, B, true)
local count = 0
for id, data in world do
count += 1
if id == eA then
expect(data[A]).to.be.ok()
expect(data[B]).to.never.be.ok()
elseif id == eB then
expect(data[B]).to.be.ok()
expect(data[A]).to.never.be.ok()
elseif id == eAB then
expect(data[A]).to.be.ok()
expect(data[B]).to.be.ok()
end
end
expect(count).to.equal(5)
end)
it("should allow querying for relations", function()
local world = jecs.World.new()
local Eats = world:entity()
local Apples = world:entity()
local bob = world:entity()
world:set(bob, jecs.pair(Eats, Apples), true)
for e, bool in world:query(jecs.pair(Eats, Apples)) do
expect(e).to.equal(bob)
expect(bool).to.equal(bool)
end
end)
it("should allow wildcards in queries", function()
local world = jecs.World.new()
local Eats = world:entity()
local Apples = world:entity()
local bob = world:entity()
world:set(bob, jecs.pair(Eats, Apples), "bob eats apples")
for e, data in world:query(jecs.pair(Eats, jecs.w)) do
expect(e).to.equal(bob)
expect(data).to.equal("bob eats apples")
end
for e, data in world:query(jecs.pair(jecs.w, Apples)) do
expect(e).to.equal(bob)
expect(data).to.equal("bob eats apples")
end
end)
it("should match against multiple pairs", function()
local world = jecs.World.new()
local pair = jecs.pair
local Eats = world:entity()
local Apples = world:entity()
local Oranges =world:entity()
local bob = world:entity()
local alice = world:entity()
world:set(bob, pair(Eats, Apples), "bob eats apples")
world:set(alice, pair(Eats, Oranges), "alice eats oranges")
local w = jecs.Wildcard
local count = 0
for e, data in world:query(pair(Eats, w)) do
count += 1
if e == bob then
expect(data).to.equal("bob eats apples")
else
expect(data).to.equal("alice eats oranges")
end
end
expect(count).to.equal(2)
count = 0
for e, data in world:query(pair(w, Apples)) do
count += 1
expect(data).to.equal("bob eats apples")
end
expect(count).to.equal(1)
end)
end)
end

186
mkdocs.yml Normal file
View file

@ -0,0 +1,186 @@
site_name: Jecs
site_url: jecs.github.io/jecs
repo_name: ukendio/jecs
repo_url: https://github.com/ukendio/jecs
extra:
version:
provider: mike
theme:
name: material
custom_dir: docs/assets/overrides
logo: assets/logo
favicon: assets/logo-dark.svg
palette:
- media: "(prefers-color-scheme: dark)"
scheme: fusiondoc-dark
toggle:
icon: octicons/sun-24
title: Switch to light theme
- media: "(prefers-color-scheme: light)"
scheme: fusiondoc-light
toggle:
icon: octicons/moon-24
title: Switch to dark theme
font:
text: Plus Jakarta Sans
code: JetBrains Mono
features:
- navigation.tabs
- navigation.top
- navigation.sections
- navigation.instant
- navigation.indexes
- search.suggest
- search.highlight
icon:
repo: octicons/mark-github-16
extra_css:
- assets/theme/fusiondoc.css
- assets/theme/colours.css
- assets/theme/code.css
- assets/theme/paragraph.css
- assets/theme/page.css
- assets/theme/admonition.css
- assets/theme/404.css
- assets/theme/api-reference.css
- assets/theme/dev-tools.css
extra_javascript:
- assets/scripts/smooth-scroll.js
nav:
- Home: index.md
- Tutorials:
- Get Started: tutorials/index.md
- Installing Fusion: tutorials/get-started/installing-fusion.md
- Developer Tools: tutorials/get-started/developer-tools.md
- Getting Help: tutorials/get-started/getting-help.md
- Fundamentals:
- Scopes: tutorials/fundamentals/scopes.md
- Values: tutorials/fundamentals/values.md
- Observers: tutorials/fundamentals/observers.md
- Computeds: tutorials/fundamentals/computeds.md
- Tables:
- ForValues: tutorials/tables/forvalues.md
- ForKeys: tutorials/tables/forkeys.md
- ForPairs: tutorials/tables/forpairs.md
- Animation:
- Tweens: tutorials/animation/tweens.md
- Springs: tutorials/animation/springs.md
- Roblox:
- Hydration: tutorials/roblox/hydration.md
- New Instances: tutorials/roblox/new-instances.md
- Parenting: tutorials/roblox/parenting.md
- Events: tutorials/roblox/events.md
- Change Events: tutorials/roblox/change-events.md
- Outputs: tutorials/roblox/outputs.md
- References: tutorials/roblox/references.md
- Best Practices:
- Components: tutorials/best-practices/components.md
- Instance Handling: tutorials/best-practices/instance-handling.md
- Callbacks: tutorials/best-practices/callbacks.md
- State: tutorials/best-practices/state.md
- Sharing Values: tutorials/best-practices/sharing-values.md
- Error Safety: tutorials/best-practices/error-safety.md
- Optimisation: tutorials/best-practices/optimisation.md
- Examples:
- Home: examples/index.md
- Cookbook:
- examples/cookbook/index.md
- Player List: examples/cookbook/player-list.md
- Animated Computed: examples/cookbook/animated-computed.md
- Fetch Data From Server: examples/cookbook/fetch-data-from-server.md
- Light & Dark Theme: examples/cookbook/light-and-dark-theme.md
- Button Component: examples/cookbook/button-component.md
- Loading Spinner: examples/cookbook/loading-spinner.md
- Drag & Drop: examples/cookbook/drag-and-drop.md
- API Reference:
- api-reference/index.md
- General:
- Errors: api-reference/general/errors.md
- Types:
- Contextual: api-reference/general/types/contextual.md
- Version: api-reference/general/types/version.md
- Members:
- Contextual: api-reference/general/members/contextual.md
- Safe: api-reference/general/members/safe.md
- version: api-reference/general/members/version.md
- Memory:
- Types:
- Scope: api-reference/memory/types/scope.md
- ScopedObject: api-reference/memory/types/scopedobject.md
- Task: api-reference/memory/types/task.md
- Members:
- deriveScope: api-reference/memory/members/derivescope.md
- doCleanup: api-reference/memory/members/docleanup.md
- scoped: api-reference/memory/members/scoped.md
- State:
- Types:
- UsedAs: api-reference/state/types/usedas.md
- Computed: api-reference/state/types/computed.md
- Dependency: api-reference/state/types/dependency.md
- Dependent: api-reference/state/types/dependent.md
- For: api-reference/state/types/for.md
- Observer: api-reference/state/types/observer.md
- StateObject: api-reference/state/types/stateobject.md
- Use: api-reference/state/types/use.md
- Value: api-reference/state/types/value.md
- Members:
- Computed: api-reference/state/members/computed.md
- ForKeys: api-reference/state/members/forkeys.md
- ForPairs: api-reference/state/members/forpairs.md
- ForValues: api-reference/state/members/forvalues.md
- Observer: api-reference/state/members/observer.md
- peek: api-reference/state/members/peek.md
- Value: api-reference/state/members/value.md
- Roblox:
- Types:
- Child: api-reference/roblox/types/child.md
- PropertyTable: api-reference/roblox/types/propertytable.md
- SpecialKey: api-reference/roblox/types/specialkey.md
- Members:
- Attribute: api-reference/roblox/members/attribute.md
- AttributeChange: api-reference/roblox/members/attributechange.md
- AttributeOut: api-reference/roblox/members/attributeout.md
- Children: api-reference/roblox/members/children.md
- Hydrate: api-reference/roblox/members/hydrate.md
- New: api-reference/roblox/members/new.md
- OnChange: api-reference/roblox/members/onchange.md
- OnEvent: api-reference/roblox/members/onevent.md
- Out: api-reference/roblox/members/out.md
- Ref: api-reference/roblox/members/ref.md
- Animation:
- Types:
- Animatable: api-reference/animation/types/animatable.md
- Spring: api-reference/animation/types/spring.md
- Tween: api-reference/animation/types/tween.md
- Members:
- Tween: api-reference/animation/members/tween.md
- Spring: api-reference/animation/members/spring.md
- Extras:
- Home: extras/index.md
- Backgrounds: extras/backgrounds.md
- Brand Guidelines: extras/brand-guidelines.md
markdown_extensions:
- admonition
- attr_list
- meta
- md_in_html
- pymdownx.superfences
- pymdownx.betterem
- pymdownx.details
- pymdownx.tabbed:
alternate_style: true
- pymdownx.inlinehilite
- toc:
permalink: true
- pymdownx.highlight:
guess_lang: false
- pymdownx.emoji:
emoji_index: !!python/name:materialx.emoji.twemoji
emoji_generator: !!python/name:materialx.emoji.to_svg

2199
package-lock.json generated Normal file

File diff suppressed because it is too large Load diff

43
package.json Normal file
View file

@ -0,0 +1,43 @@
{
"name": "@rbxts/jecs",
"version": "0.1.0",
"description": "Stupidly fast Entity Component System",
"main": "lib/init.lua",
"repository": {
"type": "git",
"url": "https://github.com/ukendio/jecs.git"
},
"scripts": {
"build": "rbxtsc",
"watch": "rbxtsc -w",
"prepublishOnly": "npm run build"
},
"keywords": [],
"author": "Ukendio",
"contributors": [
"Ukendio",
"EncodedVenom"
],
"homepage": "https://github.com/ukendio/jecs",
"license": "MIT",
"types": "lib/index.d.ts",
"files": [
"lib/"
],
"publishConfig": {
"access": "public"
},
"devDependencies": {
"@rbxts/compiler-types": "^2.3.0-types.1",
"@rbxts/types": "^1.0.781",
"@typescript-eslint/eslint-plugin": "^5.8.0",
"@typescript-eslint/parser": "^5.8.0",
"eslint": "^8.5.0",
"eslint-config-prettier": "^8.3.0",
"eslint-plugin-prettier": "^4.0.0",
"eslint-plugin-roblox-ts": "^0.0.32",
"prettier": "^2.5.1",
"roblox-ts": "^2.3.0",
"typescript": "^5.4.2"
}
}

View file

@ -22,18 +22,6 @@
}, },
"mirror": { "mirror": {
"$path": "mirror" "$path": "mirror"
},
"DevPackages": {
"$path": "DevPackages"
}
},
"TestService": {
"$properties": {
"ExecuteWithStudioRun": true
},
"$className": "TestService",
"run": {
"$path": "tests.server.lua"
} }
} }
} }

View file

@ -1,9 +0,0 @@
local ReplicatedStorage = game:GetService("ReplicatedStorage")
require(ReplicatedStorage.DevPackages.TestEZ).TestBootstrap:run({
ReplicatedStorage.Lib,
nil,
{
noXpcallByDefault = true,
},
})

View file

@ -1,261 +1,353 @@
local testkit = require("../testkit")
local jecs = require("../lib/init") local jecs = require("../lib/init")
local testkit = require("../testkit")
local __ = jecs.Wildcard
local ECS_ID, ECS_GENERATION = jecs.ECS_ID, jecs.ECS_GENERATION local ECS_ID, ECS_GENERATION = jecs.ECS_ID, jecs.ECS_GENERATION
local ECS_GENERATION_INC = jecs.ECS_GENERATION_INC local ECS_GENERATION_INC = jecs.ECS_GENERATION_INC
local IS_PAIR = jecs.IS_PAIR local IS_PAIR = jecs.IS_PAIR
local ECS_PAIR = jecs.ECS_PAIR local ECS_PAIR = jecs.ECS_PAIR
local getAlive = jecs.getAlive local getAlive = jecs.getAlive
local ecs_get_source = jecs.ecs_get_source local ECS_PAIR_RELATION = jecs.ECS_PAIR_RELATION
local ecs_get_target = jecs.ecs_get_target local ECS_PAIR_OBJECT = jecs.ECS_PAIR_OBJECT
local TEST, CASE, CHECK, FINISH, SKIP = testkit.test() local TEST, CASE, CHECK, FINISH, SKIP = testkit.test()
local function CHECK_NO_ERR<T...>(s: string, fn: (T...) -> (), ...: T...)
local ok, err: string? = pcall(fn, ...)
if not CHECK(not ok, 2) then
local i = string.find(err :: string, " ")
assert(i)
local msg = string.sub(err :: string, i + 1)
CHECK(msg == s, 2)
end
end
local N = 10 local N = 10
TEST("world", function() TEST("world", function()
do CASE "should be iterable" do
local world = jecs.World.new() CASE("should be iterable")
local A = world:component() local world = jecs.World.new()
local B = world:component() local A = world:component()
local eA = world:entity() local B = world:component()
world:set(eA, A, true) local eA = world:entity()
local eB = world:entity() world:set(eA, A, true)
world:set(eB, B, true) local eB = world:entity()
local eAB = world:entity() world:set(eB, B, true)
world:set(eAB, A, true) local eAB = world:entity()
world:set(eAB, B, true) world:set(eAB, A, true)
world:set(eAB, B, true)
local count = 0 local count = 0
for id, data in world do for id, data in world do
count += 1 count += 1
if id == eA then if id == eA then
CHECK(data[A] == true) CHECK(data[A] == true)
CHECK(data[B] == nil) CHECK(data[B] == nil)
elseif id == eB then elseif id == eB then
CHECK(data[A] == nil) CHECK(data[A] == nil)
CHECK(data[B] == true) CHECK(data[B] == true)
elseif id == eAB then elseif id == eAB then
CHECK(data[A] == true) CHECK(data[A] == true)
CHECK(data[B] == true) CHECK(data[B] == true)
end end
end end
-- components are registered in the entity index as well -- components are registered in the entity index as well
-- so this test has to add 2 to account for them -- so this test has to add 2 to account for them
CHECK(count == 3 + 2) CHECK(count == 3 + 2)
end end
do CASE "should query all matching entities" do
local world = jecs.World.new() CASE("should query all matching entities")
local A = world:component() local world = jecs.World.new()
local B = world:component() local A = world:component()
local B = world:component()
local entities = {} local entities = {}
for i = 1, N do for i = 1, N do
local id = world:entity() local id = world:entity()
world:set(id, A, true) world:set(id, A, true)
if i > 5 then world:set(id, B, true) end if i > 5 then
entities[i] = id world:set(id, B, true)
end end
entities[i] = id
end
for id in world:query(A) do for id in world:query(A) do
table.remove(entities, CHECK(table.find(entities, id))) table.remove(entities, CHECK(table.find(entities, id)))
end end
CHECK(#entities == 0) CHECK(#entities == 0)
end
end do
CASE("should query all matching entities when irrelevant component is removed")
local world = jecs.World.new()
local A = world:component()
local B = world:component()
local C = world:component()
do CASE "should query all matching entities when irrelevant component is removed" local entities = {}
local world = jecs.World.new() for i = 1, N do
local A = world:component() local id = world:entity()
local B = world:component()
local C = world:component()
local entities = {} -- specifically put them in disorder to track regression
for i = 1, N do -- https://github.com/Ukendio/jecs/pull/15
local id = world:entity() world:set(id, B, true)
world:set(id, A, true)
if i > 5 then
world:remove(id, B)
end
entities[i] = id
end
-- specifically put them in disorder to track regression local added = 0
-- https://github.com/Ukendio/jecs/pull/15 for id in world:query(A) do
world:set(id, B, true) added += 1
world:set(id, A, true) table.remove(entities, CHECK(table.find(entities, id)))
if i > 5 then world:remove(id, B) end end
entities[i] = id
end
local added = 0 CHECK(added == N)
for id in world:query(A) do end
added += 1
table.remove(entities, CHECK(table.find(entities, id)))
end
CHECK(added == N) do
end CASE("should query all entities without B")
local world = jecs.World.new()
local A = world:component()
local B = world:component()
do CASE "should query all entities without B" local entities = {}
local world = jecs.World.new() for i = 1, N do
local A = world:component() local id = world:entity()
local B = world:component()
local entities = {} world:set(id, A, true)
for i = 1, N do if i < 5 then
local id = world:entity() entities[i] = id
else
world:set(id, B, true)
end
end
world:set(id, A, true) for id in world:query(A):without(B) do
if i < 5 then table.remove(entities, CHECK(table.find(entities, id)))
entities[i] = id end
else
world:set(id, B, true)
end
end CHECK(#entities == 0)
end
for id in world:query(A):without(B) do do
table.remove(entities, CHECK(table.find(entities, id))) CASE("should allow setting components in arbitrary order")
end local world = jecs.World.new()
CHECK(#entities == 0) local Health = world:entity()
local Poison = world:component()
end local id = world:entity()
world:set(id, Poison, 5)
world:set(id, Health, 50)
do CASE "should allow setting components in arbitrary order" CHECK(world:get(id, Poison) == 5)
local world = jecs.World.new() end
local Health = world:entity() do
local Poison = world:component() CASE("should allow deleting components")
local world = jecs.World.new()
local id = world:entity() local Health = world:entity()
world:set(id, Poison, 5) local Poison = world:component()
world:set(id, Health, 50)
CHECK(world:get(id, Poison) == 5) local id = world:entity()
end world:set(id, Poison, 5)
world:set(id, Health, 50)
local id1 = world:entity()
world:set(id1, Poison, 500)
world:set(id1, Health, 50)
do CASE "should allow deleting components" world:delete(id)
local world = jecs.World.new()
local Health = world:entity() CHECK(world:get(id, Poison) == nil)
local Poison = world:component() CHECK(world:get(id, Health) == nil)
CHECK(world:get(id1, Poison) == 500)
CHECK(world:get(id1, Health) == 50)
end
local id = world:entity() do
world:set(id, Poison, 5) CASE("should allow remove that doesn't exist on entity")
world:set(id, Health, 50) local world = jecs.World.new()
local id1 = world:entity()
world:set(id1, Poison, 500)
world:set(id1, Health, 50)
world:delete(id) local Health = world:entity()
local Poison = world:component()
CHECK(world:get(id, Poison) == nil) local id = world:entity()
CHECK(world:get(id, Health) == nil) world:set(id, Health, 50)
CHECK(world:get(id1, Poison) == 500) world:remove(id, Poison)
CHECK(world:get(id1, Health) == 50)
end CHECK(world:get(id, Poison) == nil)
CHECK(world:get(id, Health) == 50)
end
do CASE "should allow remove that doesn't exist on entity" do
local world = jecs.World.new() CASE("should increment generation")
local world = jecs.World.new()
local e = world:entity()
CHECK(ECS_ID(e) == 1 + jecs.Rest)
CHECK(getAlive(world.entityIndex, ECS_ID(e)) == e)
CHECK(ECS_GENERATION(e) == 0) -- 0
e = ECS_GENERATION_INC(e)
CHECK(ECS_GENERATION(e) == 1) -- 1
end
local Health = world:entity() do
local Poison = world:component() CASE("should get alive from index in the dense array")
local world = jecs.World.new()
local _e = world:entity()
local e2 = world:entity()
local e3 = world:entity()
local id = world:entity() CHECK(IS_PAIR(world:entity()) == false)
world:set(id, Health, 50)
world:remove(id, Poison)
CHECK(world:get(id, Poison) == nil) local pair = ECS_PAIR(e2, e3)
CHECK(world:get(id, Health) == 50) CHECK(IS_PAIR(pair) == true)
end
do CASE "should increment generation" CHECK(ECS_PAIR_RELATION(world.entityIndex, pair) == e2)
local world = jecs.World.new() CHECK(ECS_PAIR_OBJECT(world.entityIndex, pair) == e3)
local e = world:entity() end
CHECK(ECS_ID(e) == 1 + jecs.Rest)
CHECK(getAlive(world.entityIndex, ECS_ID(e)) == e)
CHECK(ECS_GENERATION(e) == 0) -- 0
e = ECS_GENERATION_INC(e)
CHECK(ECS_GENERATION(e) == 1) -- 1
end
do CASE "should get alive from index in the dense array" do
local world = jecs.World.new() CASE("should allow querying for relations")
local _e = world:entity() local world = jecs.World.new()
local e2 = world:entity() local Eats = world:entity()
local e3 = world:entity() local Apples = world:entity()
local bob = world:entity()
CHECK(IS_PAIR(world:entity()) == false) world:set(bob, ECS_PAIR(Eats, Apples), true)
for e, bool in world:query(ECS_PAIR(Eats, Apples)) do
CHECK(e == bob)
CHECK(bool)
end
end
local pair = ECS_PAIR(e2, e3) do
CHECK(IS_PAIR(pair) == true) CASE("should allow wildcards in queries")
CHECK(ecs_get_source(world.entityIndex, pair) == e2) local world = jecs.World.new()
CHECK(ecs_get_target(world.entityIndex, pair) == e3) local Eats = world:entity()
end local Apples = world:entity()
local bob = world:entity()
do CASE "should allow querying for relations" world:set(bob, ECS_PAIR(Eats, Apples), "bob eats apples")
local world = jecs.World.new()
local Eats = world:entity()
local Apples = world:entity()
local bob = world:entity()
world:set(bob, ECS_PAIR(Eats, Apples), true) local w = jecs.Wildcard
for e, bool in world:query(ECS_PAIR(Eats, Apples)) do for e, data in world:query(ECS_PAIR(Eats, w)) do
CHECK(e == bob) CHECK(e == bob)
CHECK(bool) CHECK(data == "bob eats apples")
end end
end for e, data in world:query(ECS_PAIR(w, Apples)) do
CHECK(e == bob)
CHECK(data == "bob eats apples")
end
end
do CASE "should allow wildcards in queries" do
local world = jecs.World.new() CASE("should match against multiple pairs")
local Eats = world:entity() local world = jecs.World.new()
local Apples = world:entity() local Eats = world:entity()
local bob = world:entity() local Apples = world:entity()
local Oranges = world:entity()
local bob = world:entity()
local alice = world:entity()
world:set(bob, ECS_PAIR(Eats, Apples), "bob eats apples") world:set(bob, ECS_PAIR(Eats, Apples), "bob eats apples")
world:set(alice, ECS_PAIR(Eats, Oranges), "alice eats oranges")
local w = jecs.Wildcard local w = jecs.Wildcard
for e, data in world:query(ECS_PAIR(Eats, w)) do local count = 0
CHECK(e == bob) for e, data in world:query(ECS_PAIR(Eats, w)) do
CHECK(data == "bob eats apples") count += 1
end if e == bob then
for e, data in world:query(ECS_PAIR(w, Apples)) do CHECK(data == "bob eats apples")
CHECK(e == bob) else
CHECK(data == "bob eats apples") CHECK(data == "alice eats oranges")
end end
end end
do CASE "should match against multiple pairs" CHECK(count == 2)
local world = jecs.World.new() count = 0
local Eats = world:entity()
local Apples = world:entity()
local Oranges =world:entity()
local bob = world:entity()
local alice = world:entity()
world:set(bob, ECS_PAIR(Eats, Apples), "bob eats apples") for e, data in world:query(ECS_PAIR(w, Apples)) do
world:set(alice, ECS_PAIR(Eats, Oranges), "alice eats oranges") count += 1
CHECK(data == "bob eats apples")
end
CHECK(count == 1)
end
local w = jecs.Wildcard do
local count = 0 CASE("should only relate alive entities")
for e, data in world:query(ECS_PAIR(Eats, w)) do
count += 1
if e == bob then
CHECK(data == "bob eats apples")
else
CHECK(data == "alice eats oranges")
end
end
CHECK(count == 2) local world = jecs.World.new()
count = 0 local Eats = world:entity()
local Apples = world:entity()
local Oranges = world:entity()
local bob = world:entity()
local alice = world:entity()
for e, data in world:query(ECS_PAIR(w, Apples)) do world:set(bob, Apples, "apples")
count += 1 world:set(bob, ECS_PAIR(Eats, Apples), "bob eats apples")
CHECK(data == "bob eats apples") world:set(alice, ECS_PAIR(Eats, Oranges), "alice eats oranges")
end
CHECK(count == 1) world:delete(Apples)
end local Wildcard = jecs.Wildcard
local count = 0
for _, data in world:query(ECS_PAIR(Wildcard, Apples)) do
count += 1
end
world:delete(ECS_PAIR(Eats, Apples))
CHECK(count == 0)
CHECK(world:get(bob, ECS_PAIR(Eats, Apples)) == nil)
end
do
CASE("should error when setting invalid pair")
local world = jecs.World.new()
local Eats = world:entity()
local Apples = world:entity()
local bob = world:entity()
world:delete(Apples)
world:set(bob, ECS_PAIR(Eats, Apples), "bob eats apples")
end
do
CASE("should find target for ChildOf")
local world = jecs.World.new()
local ChildOf = world:component()
local Name = world:component()
local function parent(entity)
return world:target(entity, ChildOf)
end
local bob = world:entity()
local alice = world:entity()
local sara = world:entity()
world:add(bob, ECS_PAIR(ChildOf, alice))
world:set(bob, Name, "bob")
world:add(sara, ECS_PAIR(ChildOf, alice))
world:set(sara, Name, "sara")
CHECK(parent(bob) == alice) -- O(1)
local count = 0
for _, name in world:query(Name, ECS_PAIR(ChildOf, alice)) do
print(name)
count += 1
end
CHECK(count == 2)
end
end) end)
FINISH() FINISH()

26
tsconfig.json Normal file
View file

@ -0,0 +1,26 @@
{
"compilerOptions": {
// required
"allowSyntheticDefaultImports": true,
"downlevelIteration": true,
"jsx": "react",
"jsxFactory": "Roact.createElement",
"jsxFragmentFactory": "Roact.Fragment",
"module": "commonjs",
"moduleResolution": "Node",
"noLib": true,
"resolveJsonModule": true,
"strict": true,
"target": "ESNext",
"typeRoots": ["node_modules/@rbxts"],
// configurable
"rootDir": "lib",
"outDir": "out",
"baseUrl": "lib",
"incremental": true,
"tsBuildInfoFile": "out/tsconfig.tsbuildinfo",
"moduleDetection": "force"
}
}

View file

@ -1,10 +1,7 @@
[package] [package]
name = "ukendio/jecs" name = "ukendio/jecs"
version = "0.1.0-rc.6" version = "0.1.0"
registry = "https://github.com/UpliftGames/wally-index" registry = "https://github.com/UpliftGames/wally-index"
realm = "shared" realm = "shared"
include = ["default.project.json", "lib/**", "lib", "wally.toml", "README.md"] include = ["default.project.json", "lib/**", "lib", "wally.toml", "README.md"]
exclude = ["**"] exclude = ["**"]
[dev-dependencies]
TestEZ = "roblox/testez@0.4.1"