Initial Commit
This commit is contained in:
82
packages/gaming/games/ecs/src/bitset/bitset.md
Normal file
82
packages/gaming/games/ecs/src/bitset/bitset.md
Normal file
@@ -0,0 +1,82 @@
|
||||
# BitSet
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
`BitSet` is the foundational mask primitive used by archetypes, queries, and the component registry inside `@suaveplan/ecs`. Storing component membership as bits packed into `Uint32Array` words gives the engine cache-friendly storage and O(words) algebra for subset, superset, and intersection tests — the hot paths driving every archetype-graph transition.
|
||||
|
||||
The class is intentionally narrow: it offers only the operators needed by the engine itself (`set`, `unset`, `has`, `containsAll`, `intersects`, `equals`, `count`, `clone`, `clear`, `toKey`, iterator). It is universal — no `node:*`, browser, or React dependency — so it runs identically in Bun, Node, and the browser.
|
||||
|
||||
## Features
|
||||
|
||||
- `Uint32Array`-backed storage; one word per 32 bits.
|
||||
- O(words) `containsAll` and `intersects`, safe across mismatched sizes.
|
||||
- Population count (`count`) via the SWAR Hamming-weight trick.
|
||||
- Sorted-ascending iteration through `Symbol.iterator`.
|
||||
- Stable serialised key from `toKey()` used as the archetype-graph map key.
|
||||
- Defensive `set` / `unset` guards: out-of-range bit indices throw `RangeError`; the constructor rejects non-positive sizes.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { BitSet } from "@suaveplan/ecs";
|
||||
|
||||
const mask = new BitSet(64);
|
||||
mask.set(0);
|
||||
mask.set(5);
|
||||
mask.set(32);
|
||||
|
||||
console.log(mask.has(5)); // true
|
||||
console.log([...mask]); // [0, 5, 32]
|
||||
console.log(mask.toKey()); // "0,5,32"
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import { BitSet } from "@suaveplan/ecs";
|
||||
|
||||
const include = new BitSet(128);
|
||||
const archetype = new BitSet(128);
|
||||
include.set(3);
|
||||
include.set(7);
|
||||
for (const cid of [3, 7, 11, 18]) archetype.set(cid);
|
||||
|
||||
if (archetype.containsAll(include)) {
|
||||
// Archetype has every required component — yield its entities.
|
||||
}
|
||||
|
||||
const exclude = new BitSet(128);
|
||||
exclude.set(11);
|
||||
if (archetype.intersects(exclude)) {
|
||||
// Skip — archetype contains a banned component.
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
| Member | Description |
|
||||
|---|---|
|
||||
| `new BitSet(size)` | Allocate a bitset of `size` bits. Throws `RangeError` for non-positive integer sizes. |
|
||||
| `size` | Read-only declared bit-width. |
|
||||
| `set(bit)` / `unset(bit)` | Flip a single bit. `RangeError` on out-of-range index. |
|
||||
| `has(bit)` | Test a bit. Returns `false` for out-of-range indices. |
|
||||
| `containsAll(other)` | `true` when every bit set in `other` is also set here. Safe across mismatched word counts. |
|
||||
| `intersects(other)` | `true` when at least one bit is set in both bitsets. |
|
||||
| `equals(other)` | Structural word-by-word equality. |
|
||||
| `count()` | Population count via SWAR. |
|
||||
| `clone()` | Deep copy with the same size. |
|
||||
| `clear()` | Reset every bit to zero. |
|
||||
| `toKey()` | Comma-separated string of set indices, ascending. |
|
||||
| `[Symbol.iterator]()` | Yield set indices in ascending order. |
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
- Words are accessed with `>>> 5` (divide by 32) and `& 31` (mod 32). Using bitwise math keeps everything in the 32-bit-integer fast path.
|
||||
- `containsAll` walks each word in lock-step and treats trailing words of `other` as zero when `this` is shorter — this lets the world build mask-only diffs between archetypes without forcing both sides to the same allocation size.
|
||||
- `count` is the classic 5-step parallel bit-population trick, fast and branch-free.
|
||||
- `toKey` produces a stable, comparable string used as the archetype-graph map key. It is O(size) but only paid once per archetype-create event.
|
||||
- The iterator is a generator; `for (const bit of set)` consumes it lazily.
|
||||
@@ -0,0 +1,18 @@
|
||||
import { describe, expect, expectTypeOf, it } from "@suaveplan/testing/runner";
|
||||
import { BitSet } from "./bitset.js";
|
||||
|
||||
describe("BitSet — spec conformance", () => {
|
||||
it("iterator yields number indices", { timeout: 5000 }, () => {
|
||||
const set = new BitSet(8);
|
||||
set.set(3);
|
||||
const iter = set[Symbol.iterator]();
|
||||
expectTypeOf(iter).toEqualTypeOf<Generator<number>>();
|
||||
expect(iter.next().value).toBe(3);
|
||||
});
|
||||
|
||||
it("size is publicly readable", { timeout: 5000 }, () => {
|
||||
const set = new BitSet(128);
|
||||
expectTypeOf(set.size).toEqualTypeOf<number>();
|
||||
expect(set.size).toBe(128);
|
||||
});
|
||||
});
|
||||
129
packages/gaming/games/ecs/src/bitset/bitset.test.ts
Normal file
129
packages/gaming/games/ecs/src/bitset/bitset.test.ts
Normal file
@@ -0,0 +1,129 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import { BitSet } from "./bitset.js";
|
||||
|
||||
describe("BitSet", () => {
|
||||
it("set, has, and unset propagate across multiple Uint32 words", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const set = new BitSet(96);
|
||||
for (const bit of [2, 31, 32, 64, 95]) set.set(bit);
|
||||
for (const bit of [2, 31, 32, 64, 95]) expect(set.has(bit)).toBe(true);
|
||||
expect(set.has(33)).toBe(false);
|
||||
set.unset(32);
|
||||
expect(set.has(32)).toBe(false);
|
||||
expect(set.has(31)).toBe(true);
|
||||
expect(set.has(64)).toBe(true);
|
||||
});
|
||||
|
||||
it("out-of-range has returns false rather than throwing", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const set = new BitSet(8);
|
||||
expect(set.has(-1)).toBe(false);
|
||||
expect(set.has(99)).toBe(false);
|
||||
});
|
||||
|
||||
it("out-of-range set and unset throw RangeError", { timeout: 5000 }, () => {
|
||||
const set = new BitSet(8);
|
||||
expect(() => set.set(8)).toThrow(RangeError);
|
||||
expect(() => set.unset(-1)).toThrow(RangeError);
|
||||
expect(() => set.set(1.5)).toThrow(RangeError);
|
||||
});
|
||||
|
||||
it("constructor rejects non-positive sizes", { timeout: 5000 }, () => {
|
||||
expect(() => new BitSet(0)).toThrow(RangeError);
|
||||
expect(() => new BitSet(-1)).toThrow(RangeError);
|
||||
expect(() => new BitSet(1.5)).toThrow(RangeError);
|
||||
});
|
||||
|
||||
it("containsAll returns false when other has bits in trailing words this lacks", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const small = new BitSet(32);
|
||||
const big = new BitSet(96);
|
||||
big.set(64);
|
||||
expect(small.containsAll(big)).toBe(false);
|
||||
big.unset(64);
|
||||
expect(small.containsAll(big)).toBe(true);
|
||||
});
|
||||
|
||||
it("containsAll matches the brute-force scan", { timeout: 5000 }, () => {
|
||||
const a = new BitSet(64);
|
||||
const b = new BitSet(64);
|
||||
for (const bit of [1, 5, 10, 31, 32, 63]) a.set(bit);
|
||||
for (const bit of [5, 10, 31]) b.set(bit);
|
||||
expect(a.containsAll(b)).toBe(true);
|
||||
b.set(2);
|
||||
expect(a.containsAll(b)).toBe(false);
|
||||
});
|
||||
|
||||
it("intersects is symmetric and detects shared bits", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const a = new BitSet(64);
|
||||
const b = new BitSet(64);
|
||||
a.set(7);
|
||||
b.set(7);
|
||||
expect(a.intersects(b)).toBe(true);
|
||||
expect(b.intersects(a)).toBe(true);
|
||||
b.unset(7);
|
||||
expect(a.intersects(b)).toBe(false);
|
||||
});
|
||||
|
||||
it("the iterator yields set bit indices in ascending order", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const set = new BitSet(96);
|
||||
for (const bit of [64, 2, 32, 31]) set.set(bit);
|
||||
expect([...set]).toEqual([2, 31, 32, 64]);
|
||||
});
|
||||
|
||||
it("toKey produces a stable comma-separated string", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const set = new BitSet(32);
|
||||
set.set(3);
|
||||
set.set(1);
|
||||
expect(set.toKey()).toBe("1,3");
|
||||
expect(new BitSet(32).toKey()).toBe("");
|
||||
});
|
||||
|
||||
it("clone produces an independent copy", { timeout: 5000 }, () => {
|
||||
const a = new BitSet(32);
|
||||
a.set(5);
|
||||
const b = a.clone();
|
||||
b.set(10);
|
||||
expect(a.has(10)).toBe(false);
|
||||
expect(b.has(5)).toBe(true);
|
||||
expect(b.has(10)).toBe(true);
|
||||
});
|
||||
|
||||
it("equals returns true for structurally identical sets including different word lengths", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const a = new BitSet(32);
|
||||
const b = new BitSet(64);
|
||||
a.set(5);
|
||||
b.set(5);
|
||||
expect(a.equals(b)).toBe(true);
|
||||
expect(b.equals(a)).toBe(true);
|
||||
b.set(33);
|
||||
expect(a.equals(b)).toBe(false);
|
||||
expect(b.equals(a)).toBe(false);
|
||||
});
|
||||
|
||||
it("count returns the population of set bits", { timeout: 5000 }, () => {
|
||||
const set = new BitSet(96);
|
||||
expect(set.count()).toBe(0);
|
||||
for (const bit of [0, 31, 32, 63, 64, 95]) set.set(bit);
|
||||
expect(set.count()).toBe(6);
|
||||
});
|
||||
|
||||
it("clear resets every bit", { timeout: 5000 }, () => {
|
||||
const set = new BitSet(64);
|
||||
set.set(5);
|
||||
set.set(40);
|
||||
set.clear();
|
||||
expect(set.count()).toBe(0);
|
||||
});
|
||||
});
|
||||
127
packages/gaming/games/ecs/src/bitset/bitset.ts
Normal file
127
packages/gaming/games/ecs/src/bitset/bitset.ts
Normal file
@@ -0,0 +1,127 @@
|
||||
/**
|
||||
* Fixed-size bitset backed by `Uint32Array` storage for fast component-mask
|
||||
* arithmetic. Used by archetypes and queries for O(words) subset/superset
|
||||
* checks.
|
||||
*/
|
||||
export class BitSet {
|
||||
private readonly data: Uint32Array;
|
||||
public readonly size: number;
|
||||
|
||||
public constructor(size: number) {
|
||||
if (!Number.isInteger(size) || size <= 0) {
|
||||
throw new RangeError(
|
||||
`BitSet size must be a positive integer, got ${size}`
|
||||
);
|
||||
}
|
||||
this.size = size;
|
||||
this.data = new Uint32Array(Math.ceil(size / 32));
|
||||
}
|
||||
|
||||
/** Set the bit at `bit` to 1. */
|
||||
public set(bit: number): void {
|
||||
this.checkBit(bit);
|
||||
const index = bit >>> 5;
|
||||
const offset = bit & 31;
|
||||
this.data[index] = (this.data[index] as number) | (1 << offset);
|
||||
}
|
||||
|
||||
/** Clear the bit at `bit`. */
|
||||
public unset(bit: number): void {
|
||||
this.checkBit(bit);
|
||||
const index = bit >>> 5;
|
||||
const offset = bit & 31;
|
||||
this.data[index] = (this.data[index] as number) & ~(1 << offset);
|
||||
}
|
||||
|
||||
/** Test whether `bit` is set. */
|
||||
public has(bit: number): boolean {
|
||||
if (bit < 0 || bit >= this.size) return false;
|
||||
const index = bit >>> 5;
|
||||
const offset = bit & 31;
|
||||
return ((this.data[index] as number) & (1 << offset)) !== 0;
|
||||
}
|
||||
|
||||
/** True when this bitset has every bit set in `other`. */
|
||||
public containsAll(other: BitSet): boolean {
|
||||
const shared = Math.min(this.data.length, other.data.length);
|
||||
for (let i = 0; i < shared; i++) {
|
||||
const a = this.data[i] as number;
|
||||
const b = other.data[i] as number;
|
||||
if ((a & b) >>> 0 !== b) return false;
|
||||
}
|
||||
for (let i = shared; i < other.data.length; i++) {
|
||||
if ((other.data[i] as number) !== 0) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/** True when this bitset shares at least one set bit with `other`. */
|
||||
public intersects(other: BitSet): boolean {
|
||||
const shared = Math.min(this.data.length, other.data.length);
|
||||
for (let i = 0; i < shared; i++) {
|
||||
if (((this.data[i] as number) & (other.data[i] as number)) !== 0) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/** Comma-separated string of set bit indices, ascending. Stable across runs. */
|
||||
public toKey(): string {
|
||||
const bits: number[] = [];
|
||||
for (let i = 0; i < this.size; i++) {
|
||||
if (this.has(i)) bits.push(i);
|
||||
}
|
||||
return bits.join(",");
|
||||
}
|
||||
|
||||
/** Return a new BitSet with the same size and bits. */
|
||||
public clone(): BitSet {
|
||||
const copy = new BitSet(this.size);
|
||||
copy.data.set(this.data);
|
||||
return copy;
|
||||
}
|
||||
|
||||
/** Structural equality — true when every word matches. */
|
||||
public equals(other: BitSet): boolean {
|
||||
const len = Math.max(this.data.length, other.data.length);
|
||||
for (let i = 0; i < len; i++) {
|
||||
const a = i < this.data.length ? (this.data[i] as number) : 0;
|
||||
const b = i < other.data.length ? (other.data[i] as number) : 0;
|
||||
if (a !== b) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/** Population count — total number of set bits. */
|
||||
public count(): number {
|
||||
let total = 0;
|
||||
for (let i = 0; i < this.data.length; i++) {
|
||||
let v = this.data[i] as number;
|
||||
v = v - ((v >>> 1) & 0x55555555);
|
||||
v = (v & 0x33333333) + ((v >>> 2) & 0x33333333);
|
||||
total += (((v + (v >>> 4)) & 0x0f0f0f0f) * 0x01010101) >>> 24;
|
||||
}
|
||||
return total;
|
||||
}
|
||||
|
||||
/** Clear every bit. */
|
||||
public clear(): void {
|
||||
this.data.fill(0);
|
||||
}
|
||||
|
||||
/** Iterate set bit indices in ascending order. */
|
||||
public *[Symbol.iterator](): Generator<number> {
|
||||
for (let i = 0; i < this.size; i++) {
|
||||
if (this.has(i)) yield i;
|
||||
}
|
||||
}
|
||||
|
||||
private checkBit(bit: number): void {
|
||||
if (!Number.isInteger(bit) || bit < 0 || bit >= this.size) {
|
||||
throw new RangeError(
|
||||
`bit index ${bit} out of range [0, ${this.size})`
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
1
packages/gaming/games/ecs/src/bitset/index.ts
Normal file
1
packages/gaming/games/ecs/src/bitset/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { BitSet } from "./bitset.js";
|
||||
Reference in New Issue
Block a user