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
WallyPatches
# Typescript
/node_modules
/include
# Misc
roblox.toml
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).
- 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
- Zero-dependency package
- Optimized for column-major operations
@ -22,47 +22,40 @@ jecs is a stupidly fast Entity Component System (ECS).
### Example
```lua
local world = World.new()
local world = jecs.World.new()
local pair = jecs.pair
local player = world:entity()
local opponent = world:entity()
local ChildOf = world:component()
local Name = world:component()
local Health = world:component()
local Position = world:component()
-- Notice how components can just be entities as well?
-- It allows you to model relationships easily!
local Damage = world:entity()
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)
local function parent(entity)
return world:target(entity, ChildOf)
end
local function getName(entity)
return world:get(entity, Name)
end
-- Gets the damage inflicted by our specific opponent!
for playerId, health, inflicted in world:query(Health, ECS_PAIR(DamagedBy, opponent)) do
world:set(playerId, health - inflicted)
local alice = world:entity()
world:set(alice, Name, "alice")
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
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.

View file

@ -1,5 +1,5 @@
[tools]
wally = "upliftgames/wally@0.3.1"
wally = "upliftgames/wally@0.3.2"
rojo = "rojo-rbx/rojo@7.4.1"
stylua = "johnnymorganz/stylua@0.19.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
local ReplicatedStorage = game:GetService("ReplicatedStorage")
local rgb = require(ReplicatedStorage.rgb)
local Matter = require(ReplicatedStorage.DevPackages.Matter)
local jecs = require(ReplicatedStorage.Lib)
local ecr = require(ReplicatedStorage.DevPackages.ecr)

View file

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

@ -14,7 +14,7 @@ type Column = {any}
type Archetype = {
id: number,
edges: {
[i24]: {
[i53]: {
add: Archetype,
remove: Archetype,
},
@ -23,20 +23,39 @@ type Archetype = {
type: string | number,
entities: { number },
columns: { Column },
records: {},
records: { [number]: number },
}
type Record = {
archetype: Archetype,
row: number,
dense: i24,
componentRecord: ArchetypeMap,
}
type EntityIndex = { dense: { [i24]: i53 }, sparse: { [i53]: Record } }
type ComponentIndex = {[i24]: ArchetypeMap}
type ArchetypeRecord = number
type ArchetypeMap = {sparse: {[ArchetypeId]: ArchetypeRecord}, size: number}
--[[
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 = {
@ -75,6 +94,7 @@ local function addFlags(isPair: boolean)
return typeFlags
end
<<<<<<< HEAD
local function newId(source: number, target: number): number
return ((source * 2^28) + target) * ECS_ID_FLAGS_MASK
end
@ -87,20 +107,44 @@ local function separate(entity: number): (number, number, number)
local type_flags = entity % 0x10
local entity = entity // ECS_ID_FLAGS_MASK
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
-- HIGH 24 bits LOW 24 bits
local function ECS_GENERATION(e: i53)
<<<<<<< HEAD
return (e // 0x10) % ECS_GENERATION_MASK
end
local function ECS_ID(e: i53)
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
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
end
@ -132,21 +176,60 @@ end
local function getAlive(entityIndex: EntityIndex, id: i53)
return entityIndex.dense[id]
=======
return ECS_COMBINE(id, generation + 1) + flags
end
return ECS_COMBINE(e, 1)
>>>>>>> eae51988a9e3ca45e39ebcfdbea0f9f8706bd3cd
end
local function ecs_get_source(entityIndex, e)
assert(ECS_IS_PAIR(e))
return getAlive(entityIndex, ECS_PAIR_FIRST(e))
-- FIRST gets the high ID
local function ECS_ENTITY_T_HI(e: i53): i24
if e > ECS_ENTITY_MASK then
e = e // 0x10
return e % ECS_ENTITY_MASK
end
local function ecs_get_target(entityIndex, e)
assert(ECS_IS_PAIR(e))
return getAlive(entityIndex, ECS_PAIR_SECOND(e))
return e
end
<<<<<<< HEAD
local function nextEntityId(entityIndex, index: i24): i53
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] = {
dense = index
dense = index,
} :: Record
entityIndex.dense[index] = id
@ -198,7 +281,7 @@ local function transitionArchetype(
sourceEntities[sourceRow] = e2
end
sourceEntities[movedAway] = nil
sourceEntities[movedAway] = nil :: any
destinationEntities[destinationRow] = e1
local record1 = sparse[e1]
@ -222,7 +305,7 @@ local function newEntity(entityId: i53, record: Record, archetype: Archetype)
return record
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 from = record.archetype
local destinationRow = archetypeAppend(entityId, to)
@ -231,58 +314,75 @@ local function moveEntity(entityIndex, entityId: i53, record: Record, to: Archet
record.row = destinationRow
end
local function hash(arr): string | number
local function hash(arr): string
return table.concat(arr, "_")
end
local function createArchetypeRecord(componentIndex, id, componentId, i)
local function ensureComponentRecord(
componentIndex: ComponentIndex,
archetypeId: number,
componentId: number,
i: number
): ArchetypeMap
local archetypesMap = componentIndex[componentId]
if not archetypesMap then
archetypesMap = {size = 0, sparse = {}}
archetypesMap = ({ size = 0, cache = {} } :: any) :: ArchetypeMap
componentIndex[componentId] = archetypesMap
end
archetypesMap.sparse[id] = i
archetypesMap.cache[archetypeId] = i
archetypesMap.size += 1
return archetypesMap
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 id = world.nextArchetypeId + 1
world.nextArchetypeId = id
local length = #types
local columns = table.create(length)
local columns = (table.create(length) :: any) :: { Column }
local componentIndex = world.componentIndex
local records = {}
local componentIndex = world.componentIndex
local entityIndex = world.entityIndex
for i, componentId in types do
createArchetypeRecord(componentIndex, id, componentId, i)
ensureComponentRecord(componentIndex, id, componentId, i)
records[componentId] = i
columns[i] = {}
if ECS_IS_PAIR(componentId) then
local first = ecs_get_source(entityIndex, componentId)
local second = ecs_get_target(entityIndex, componentId)
local firstPair = ECS_PAIR(first, WILDCARD)
local secondPair = ECS_PAIR(WILDCARD, second)
createArchetypeRecord(componentIndex, id, firstPair, i)
createArchetypeRecord(componentIndex, id, secondPair, i)
records[firstPair] = i
records[secondPair] = i
local relation = ECS_PAIR_RELATION(world.entityIndex, componentId)
local object = ECS_PAIR_OBJECT(world.entityIndex, componentId)
local idr_r = ECS_PAIR(relation, WILDCARD)
ensureComponentRecord(componentIndex, id, idr_r, i)
records[idr_r] = i
local idr_t = ECS_PAIR(WILDCARD, object)
ensureComponentRecord(componentIndex, id, idr_t, i)
records[idr_t] = i
end
columns[i] = {}
end
local archetype = {
columns = columns;
edges = {};
entities = {};
id = id;
records = records;
type = ty;
types = types;
local archetype: Archetype = {
columns = columns,
edges = {},
entities = {},
id = id,
records = records,
type = ty,
types = types,
}
world.archetypeIndex[ty] = archetype
world.archetypes[id] = archetype
@ -293,25 +393,27 @@ local World = {}
World.__index = World
function World.new()
local self = setmetatable({
archetypeIndex = {};
archetypes = {} :: Archetypes;
componentIndex = {} :: ComponentIndex;
archetypeIndex = {} :: { [string]: Archetype },
archetypes = {} :: Archetypes,
componentIndex = {} :: ComponentIndex,
entityIndex = {
dense = {},
sparse = {}
} :: EntityIndex;
dense = {} :: { [i24]: i53 },
sparse = {} :: { [i53]: Record },
} :: EntityIndex,
hooks = {
[ON_ADD] = {};
};
nextArchetypeId = 0;
nextComponentId = 0;
nextEntityId = 0;
ROOT_ARCHETYPE = (nil :: any) :: Archetype;
[ON_ADD] = {},
},
nextArchetypeId = 0,
nextComponentId = 0,
nextEntityId = 0,
ROOT_ARCHETYPE = (nil :: any) :: Archetype,
}, World)
self.ROOT_ARCHETYPE = archetypeOf(self, {})
return self
end
export type World = typeof(World.new())
function World.component(world: World)
local componentId = world.nextComponentId + 1
if componentId > HI_COMPONENT_ID then
@ -329,8 +431,32 @@ function World.entity(world: World)
return nextEntityId(world.entityIndex, entityId + REST)
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
local function destructColumns(columns, count, row)
local function destructColumns(columns: { Column }, count: number, row: number)
if row == count then
for _, column in columns do
column[count] = nil
@ -343,46 +469,58 @@ local function destructColumns(columns, count, row)
end
end
local function archetypeDelete(entityIndex, record: Record, entityId: i53, destruct: boolean)
local function archetypeDelete(world: World, id: i53)
local componentIndex = world.componentIndex
local archetypesMap = componentIndex[id]
local archetypes = world.archetypes
if archetypesMap then
for archetypeId in archetypesMap.cache do
for _, entity in archetypes[archetypeId].entities do
world:remove(entity, id)
end
end
componentIndex[id] = nil :: any
end
end
function World.delete(world: World, entityId: i53)
local record = world.entityIndex.sparse[entityId]
if not record then
return
end
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
local entityToMove = entities[last]
if row ~= last then
local entityToMove = entities[last]
dense[record.dense] = entityToMove
sparse[entityToMove] = record
end
sparse[entityId] = nil
dense[#dense] = nil
entities[row], entities[last] = entities[last], nil
entities[row], entities[last] = entities[last], nil :: any
local columns = archetype.columns
if not destruct then
return
end
destructColumns(columns, last, row)
end
function World.delete(world: World, entityId: i53)
local entityIndex = world.entityIndex
local record = entityIndex.sparse[entityId]
if not record then
return
end
archetypeDelete(entityIndex, record, entityId, true)
sparse[entityId] = nil :: any
dense[#dense] = nil :: any
end
export type World = typeof(World.new())
local function ensureArchetype(world: World, types, prev)
local function ensureArchetype(world: World, types, prev): Archetype
if #types < 1 then
return world.ROOT_ARCHETYPE
end
@ -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
-- point in the types array.
local destinationType = table.clone(node.types)
local destinationType = table.clone(node.types) :: { i53 }
local at = findInsert(types, componentId)
if at == -1 then
-- 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
if not remove then
local to = table.clone(from.types)
local to = table.clone(from.types) :: { i53 }
local at = table.find(to, componentId)
if not at then
return from
@ -526,6 +664,10 @@ end
-- Keeping the function as small as possible to enable inlining
local function get(record: Record, componentId: i24)
local archetype = record.archetype
if not archetype then
return nil
end
local archetypeRecord = archetype.records[componentId]
if not archetypeRecord then
@ -535,7 +677,7 @@ local function get(record: Record, componentId: i24)
return archetype.columns[archetypeRecord][record.row]
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 record = world.entityIndex.sparse[id]
if not record then
@ -559,33 +701,35 @@ end
-- the less creation the better
local function actualNoOperation() end
local function noop(_self: Query, ...: i53): () -> (number, ...any)
local function noop(_self: Query, ...): () -> ()
return actualNoOperation :: any
end
local EmptyQuery = {
__iter = noop;
without = noop;
__iter = noop,
without = noop,
}
EmptyQuery.__index = EmptyQuery
setmetatable(EmptyQuery, 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?
if (...) == nil then
error("Missing components")
end
local compatibleArchetypes = {}
local compatibleArchetypes: { CompatibleArchetype } = {}
local length = 0
local components = { ... }
local archetypes = world.archetypes
local queryLength = #components
local firstArchetypeMap
local firstArchetypeMap: ArchetypeMap
local componentIndex = world.componentIndex
for _, componentId in components do
@ -599,9 +743,10 @@ function World.query(world: World, ...: i53): Query
end
end
for id in firstArchetypeMap.sparse do
for id in firstArchetypeMap.cache do
local archetype = archetypes[id]
local archetypeRecords = archetype.records
local indices = {}
local skip = false
@ -611,6 +756,7 @@ function World.query(world: World, ...: i53): Query
skip = true
break
end
-- index should be index.offset
indices[i] = index
end
@ -619,64 +765,46 @@ function World.query(world: World, ...: i53): Query
end
length += 1
compatibleArchetypes[length] = {archetype, indices}
compatibleArchetypes[length] = {
archetype = archetype,
indices = indices,
}
end
local lastArchetype, compatibleArchetype = next(compatibleArchetypes)
if not lastArchetype then
local lastArchetype = 1
local compatibleArchetype: CompatibleArchetype = compatibleArchetypes[lastArchetype]
if not compatibleArchetype then
return EmptyQuery
end
local preparedQuery = {}
preparedQuery.__index = preparedQuery
function preparedQuery:without(...)
local withoutComponents = {...}
for i = #compatibleArchetypes, 1, -1 do
local archetype = compatibleArchetypes[i][1]
local records = archetype.records
local shouldRemove = false
for _, componentId in withoutComponents do
if records[componentId] then
shouldRemove = true
break
end
end
if shouldRemove then
table.remove(compatibleArchetypes, i)
end
end
lastArchetype, compatibleArchetype = next(compatibleArchetypes)
if not lastArchetype then
return EmptyQuery
end
return self
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
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
archetype = compatibleArchetype[1]
row = next(archetype.entities, row) :: number
compatibleArchetype = compatibleArchetypes[lastArchetype]
archetype = compatibleArchetype.archetype
i = 1
entityId = archetype.entities[i]
end
lastRow = row
local entityId = archetype.entities[row :: number]
local row = i
i+=1
local columns = archetype.columns
local tr = compatibleArchetype[2]
local tr = compatibleArchetype.indices
if queryLength == 1 then
return entityId, columns[tr[1]][row]
@ -726,23 +854,58 @@ function World.query(world: World, ...: i53): Query
queryOutput[i] = columns[tr[i]][row]
end
return entityId, unpack(queryOutput, 1, queryLength)
return entityId, unpack(queryOutput :: any, 1, queryLength)
end
function preparedQuery:__iter()
return queryNext
end
function preparedQuery:next()
return queryNext()
end
function preparedQuery:without(...)
local withoutComponents = { ... }
for i = #compatibleArchetypes, 1, -1 do
local archetype = compatibleArchetypes[i].archetype
local records = archetype.records
local shouldRemove = false
for _, componentId in withoutComponents do
if records[componentId] then
shouldRemove = true
break
end
end
if shouldRemove then
table.remove(compatibleArchetypes, i)
end
end
lastArchetype, compatibleArchetype = next(compatibleArchetypes :: any)
if not lastArchetype then
return EmptyQuery
end
return self
end
return setmetatable({}, preparedQuery) :: any
end
function World.__iter(world: World): () -> (number?, unknown?)
function World.__iter(world: World): () -> any
local dense = world.entityIndex.dense
local sparse = world.entityIndex.sparse
local last
return function()
local lastEntity, entityId = next(dense, last)
local lastEntity: number?, entityId: number = next(dense, last)
if not lastEntity then
return
end
last = lastEntity
local record = sparse[entityId]
@ -766,24 +929,153 @@ function World.__iter(world: World): () -> (number?, unknown?)
end
end
return table.freeze({
World = World;
-- __nominal_type_dont_use could not be any or T as it causes a type error
-- or produces a union
export type Entity<T = any> = number & { __nominal_type_dont_use: T }
export type Pair = number
OnAdd = ON_ADD;
OnRemove = ON_REMOVE;
OnSet = ON_SET;
Wildcard = WILDCARD,
w = WILDCARD,
export type QueryShim<T...> = typeof(setmetatable({
without = function(...): QueryShim<T...>
return nil :: any
end,
}, {
__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,
ECS_ID = ECS_ID,
IS_PAIR = ECS_IS_PAIR,
ECS_ID = ECS_ENTITY_T_LO,
ECS_PAIR = ECS_PAIR,
ECS_GENERATION_INC = ECS_GENERATION_INC,
ECS_GENERATION = ECS_GENERATION,
ecs_get_target = ecs_get_target,
ecs_get_source = ecs_get_source,
ECS_PAIR_RELATION = ECS_PAIR_RELATION,
ECS_PAIR_OBJECT = ECS_PAIR_OBJECT,
pair = ECS_PAIR,
pair = (ECS_PAIR :: any) :: <R, T>(pred: Entity, obj: Entity) -> number,
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": {
"$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,19 +1,30 @@
local testkit = require("../testkit")
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_GENERATION_INC = jecs.ECS_GENERATION_INC
local IS_PAIR = jecs.IS_PAIR
local ECS_PAIR = jecs.ECS_PAIR
local getAlive = jecs.getAlive
local ecs_get_source = jecs.ecs_get_source
local ecs_get_target = jecs.ecs_get_target
local ECS_PAIR_RELATION = jecs.ECS_PAIR_RELATION
local ECS_PAIR_OBJECT = jecs.ECS_PAIR_OBJECT
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
TEST("world", function()
do CASE "should be iterable"
do
CASE("should be iterable")
local world = jecs.World.new()
local A = world:component()
local B = world:component()
@ -45,7 +56,8 @@ TEST("world", function()
CHECK(count == 3 + 2)
end
do CASE "should query all matching entities"
do
CASE("should query all matching entities")
local world = jecs.World.new()
local A = world:component()
local B = world:component()
@ -55,7 +67,9 @@ TEST("world", function()
local id = world:entity()
world:set(id, A, true)
if i > 5 then world:set(id, B, true) end
if i > 5 then
world:set(id, B, true)
end
entities[i] = id
end
@ -64,10 +78,10 @@ TEST("world", function()
end
CHECK(#entities == 0)
end
do CASE "should query all matching entities when irrelevant component is removed"
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()
@ -81,7 +95,9 @@ TEST("world", function()
-- https://github.com/Ukendio/jecs/pull/15
world:set(id, B, true)
world:set(id, A, true)
if i > 5 then world:remove(id, B) end
if i > 5 then
world:remove(id, B)
end
entities[i] = id
end
@ -94,7 +110,8 @@ TEST("world", function()
CHECK(added == N)
end
do CASE "should query all entities without B"
do
CASE("should query all entities without B")
local world = jecs.World.new()
local A = world:component()
local B = world:component()
@ -109,7 +126,6 @@ TEST("world", function()
else
world:set(id, B, true)
end
end
for id in world:query(A):without(B) do
@ -117,10 +133,10 @@ TEST("world", function()
end
CHECK(#entities == 0)
end
do CASE "should allow setting components in arbitrary order"
do
CASE("should allow setting components in arbitrary order")
local world = jecs.World.new()
local Health = world:entity()
@ -133,7 +149,8 @@ TEST("world", function()
CHECK(world:get(id, Poison) == 5)
end
do CASE "should allow deleting components"
do
CASE("should allow deleting components")
local world = jecs.World.new()
local Health = world:entity()
@ -152,10 +169,10 @@ TEST("world", function()
CHECK(world:get(id, Health) == nil)
CHECK(world:get(id1, Poison) == 500)
CHECK(world:get(id1, Health) == 50)
end
do CASE "should allow remove that doesn't exist on entity"
do
CASE("should allow remove that doesn't exist on entity")
local world = jecs.World.new()
local Health = world:entity()
@ -169,7 +186,8 @@ TEST("world", function()
CHECK(world:get(id, Health) == 50)
end
do CASE "should increment generation"
do
CASE("should increment generation")
local world = jecs.World.new()
local e = world:entity()
CHECK(ECS_ID(e) == 1 + jecs.Rest)
@ -179,7 +197,8 @@ TEST("world", function()
CHECK(ECS_GENERATION(e) == 1) -- 1
end
do CASE "should get alive from index in the dense array"
do
CASE("should get alive from index in the dense array")
local world = jecs.World.new()
local _e = world:entity()
local e2 = world:entity()
@ -189,11 +208,13 @@ TEST("world", function()
local pair = ECS_PAIR(e2, e3)
CHECK(IS_PAIR(pair) == true)
CHECK(ecs_get_source(world.entityIndex, pair) == e2)
CHECK(ecs_get_target(world.entityIndex, pair) == e3)
CHECK(ECS_PAIR_RELATION(world.entityIndex, pair) == e2)
CHECK(ECS_PAIR_OBJECT(world.entityIndex, pair) == e3)
end
do CASE "should allow querying for relations"
do
CASE("should allow querying for relations")
local world = jecs.World.new()
local Eats = world:entity()
local Apples = world:entity()
@ -206,7 +227,8 @@ TEST("world", function()
end
end
do CASE "should allow wildcards in queries"
do
CASE("should allow wildcards in queries")
local world = jecs.World.new()
local Eats = world:entity()
local Apples = world:entity()
@ -225,7 +247,8 @@ TEST("world", function()
end
end
do CASE "should match against multiple pairs"
do
CASE("should match against multiple pairs")
local world = jecs.World.new()
local Eats = world:entity()
local Apples = world:entity()
@ -256,6 +279,75 @@ TEST("world", function()
end
CHECK(count == 1)
end
do
CASE("should only relate alive entities")
local world = jecs.World.new()
local Eats = world:entity()
local Apples = world:entity()
local Oranges = world:entity()
local bob = world:entity()
local alice = world:entity()
world:set(bob, Apples, "apples")
world:set(bob, ECS_PAIR(Eats, Apples), "bob eats apples")
world:set(alice, ECS_PAIR(Eats, Oranges), "alice eats oranges")
world:delete(Apples)
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)
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]
name = "ukendio/jecs"
version = "0.1.0-rc.6"
version = "0.1.0"
registry = "https://github.com/UpliftGames/wally-index"
realm = "shared"
include = ["default.project.json", "lib/**", "lib", "wally.toml", "README.md"]
exclude = ["**"]
[dev-dependencies]
TestEZ = "roblox/testez@0.4.1"