From 0ce9e8928415765bad4253bbc1ec1fa6a98ff00e Mon Sep 17 00:00:00 2001 From: Zach Dahl Date: Tue, 2 Nov 2021 19:39:50 -0500 Subject: [PATCH 1/8] slot map --- src/__tests__/slot_map.spec.ts | 76 ++++++++++++++++++++++++++++++++++ src/slot_map.ts | 75 +++++++++++++++++++++++++++++++++ 2 files changed, 151 insertions(+) create mode 100644 src/__tests__/slot_map.spec.ts create mode 100644 src/slot_map.ts diff --git a/src/__tests__/slot_map.spec.ts b/src/__tests__/slot_map.spec.ts new file mode 100644 index 0000000..a237563 --- /dev/null +++ b/src/__tests__/slot_map.spec.ts @@ -0,0 +1,76 @@ +import { SlotMap } from "../slot_map"; + +describe("SlotMap", () => { + it("should store items", () => { + // Arrange + const slot_map = SlotMap(); + const key1 = slot_map.add({ foo: "bar" }); + const key2 = slot_map.add({ biz: "baz" }); + + // Act + const result1 = slot_map.get(key1); + const result2 = slot_map.get(key2); + + // Assert + expect(key1.index).toEqual(0); + expect(key1.generation).toEqual(1); + expect(key2.index).toEqual(1); + expect(key2.generation).toEqual(1); + expect(result1).toEqual({ foo: "bar" }); + expect(result2).toEqual({ biz: "baz" }); + }); + + it("should return undefined for removed item", () => { + // Arrange + const slot_map = SlotMap(); + const key = slot_map.add({ foo: "bar" }); + + // Act + slot_map.remove(key); + const result = slot_map.get(key); + + // Assert + expect(result).toBeUndefined(); + }); + + it("should reuse indices", () => { + // Arrange + const slot_map = SlotMap(); + const keys = []; + for (let idx = 0; idx < 8; idx++) { + keys[idx] = slot_map.add(idx); + } + + // Act + slot_map.remove(keys[1]); + keys[8] = slot_map.add(8); + const results = []; + for (let idx = 0; idx < 9; ++idx) { + results[idx] = slot_map.get(keys[idx]); + } + + // Assert + expect(keys).toEqual([ + { index: 0, generation: 1 }, + { index: 1, generation: 1 }, + { index: 2, generation: 1 }, + { index: 3, generation: 1 }, + { index: 4, generation: 1 }, + { index: 5, generation: 1 }, + { index: 6, generation: 1 }, + { index: 7, generation: 1 }, + { index: 1, generation: 2 }, + ]); + expect(results).toEqual([ + 0, + undefined, + 2, + 3, + 4, + 5, + 6, + 7, + 8, + ]); + }); +}); diff --git a/src/slot_map.ts b/src/slot_map.ts new file mode 100644 index 0000000..fbbec38 --- /dev/null +++ b/src/slot_map.ts @@ -0,0 +1,75 @@ +export interface IKey { + index: number; + generation: number; +} + +// TODO - implement iterator +export interface ISlotMap { + get(key: IKey): T | undefined; + add(item: T): IKey; + remove(key: IKey): boolean; +} + +export function SlotMap(): ISlotMap { + const indices: IKey[] = []; + const data: (T | undefined)[] = []; + const erase: number[] = []; + const free: number[] = []; + let capacity = 0; + let size = 0; + + function grow_capacity(n: number) { + for (let index = capacity; index < n; ++index) { + indices[index] = { index: 0, generation: 1 }; + data[index] = undefined; + free.push(index); + erase[index] = 0; + } + capacity = n; + } + + grow_capacity(8); + + return { + get(key: IKey): T | undefined { + const internal_key = indices[key.index]; + if (internal_key === undefined || key.generation !== internal_key.generation) { + return undefined; + } + return data[internal_key.index]; + }, + + add(item: T) { + if (free.length === 0) { + grow_capacity(2 * capacity); + } + + const slot = free.shift()!; + const internal_key = indices[slot]; + internal_key.index = size; + data[size] = item; + erase[size] = slot; + size += 1; + + return { index: slot, generation: internal_key.generation }; + }, + + remove(key: IKey): boolean { + const internal_key = indices[key.index]; + if (internal_key === undefined || key.generation !== internal_key.generation) { + return false; + } + + internal_key.generation += 1; + const del_idx = internal_key.index; + data[del_idx] = data[size - 1]; + data[size - 1] = undefined; + const idx = erase[del_idx] = erase[size - 1]; + indices[idx].index = key.index; + free.push(internal_key.index); + size -= 1; + + return true; + } + }; +} From 5c380efffc5b5051b9f73265667b30f72d4475ce Mon Sep 17 00:00:00 2001 From: Zach Dahl Date: Wed, 3 Nov 2021 22:25:38 -0500 Subject: [PATCH 2/8] SlotMap is iterable --- src/__tests__/slot_map.spec.ts | 15 +++++++++++++++ src/slot_map.ts | 15 +++++++++++---- 2 files changed, 26 insertions(+), 4 deletions(-) diff --git a/src/__tests__/slot_map.spec.ts b/src/__tests__/slot_map.spec.ts index a237563..f17f71a 100644 --- a/src/__tests__/slot_map.spec.ts +++ b/src/__tests__/slot_map.spec.ts @@ -73,4 +73,19 @@ describe("SlotMap", () => { 8, ]); }); + + it("should be iterable", () => { + // Arrange + const slot_map = SlotMap(); + slot_map.add("foo"); + slot_map.add("bar"); + slot_map.add("biz"); + slot_map.add("baz"); + + // Act + const result = [...slot_map]; + + // Assert + expect(result).toEqual([ "foo", "bar", "biz", "baz" ]); + }); }); diff --git a/src/slot_map.ts b/src/slot_map.ts index fbbec38..83d1d2b 100644 --- a/src/slot_map.ts +++ b/src/slot_map.ts @@ -1,10 +1,12 @@ +const INITIAL_SIZE = 8; + export interface IKey { index: number; generation: number; } // TODO - implement iterator -export interface ISlotMap { +export interface ISlotMap extends IterableIterator { get(key: IKey): T | undefined; add(item: T): IKey; remove(key: IKey): boolean; @@ -28,9 +30,14 @@ export function SlotMap(): ISlotMap { capacity = n; } - grow_capacity(8); + grow_capacity(INITIAL_SIZE); - return { + return Object.assign(function* () { + // TODO - should warn or prevent editing while being accessed? + for (let index = 0; index < size; ++index) { + yield data[index]!; + } + }(), { get(key: IKey): T | undefined { const internal_key = indices[key.index]; if (internal_key === undefined || key.generation !== internal_key.generation) { @@ -71,5 +78,5 @@ export function SlotMap(): ISlotMap { return true; } - }; + }); } From 38c412c094fc997adb7664fb24a3a71e2223834e Mon Sep 17 00:00:00 2001 From: Zach Dahl Date: Wed, 3 Nov 2021 22:36:57 -0500 Subject: [PATCH 3/8] implement set --- src/__tests__/slot_map.spec.ts | 33 +++++++++++++++++++++++++++++++++ src/slot_map.ts | 13 ++++++++++++- 2 files changed, 45 insertions(+), 1 deletion(-) diff --git a/src/__tests__/slot_map.spec.ts b/src/__tests__/slot_map.spec.ts index f17f71a..2269b3a 100644 --- a/src/__tests__/slot_map.spec.ts +++ b/src/__tests__/slot_map.spec.ts @@ -88,4 +88,37 @@ describe("SlotMap", () => { // Assert expect(result).toEqual([ "foo", "bar", "biz", "baz" ]); }); + + it('should allow items to be overwritten', () => { + // Arrange + const slot_map = SlotMap(); + slot_map.add("foo"); + const key = slot_map.add("bar"); + slot_map.add("biz"); + + // Act + const success = slot_map.set(key, "baz"); + const result = [...slot_map]; + + // ASsert + expect(success).toBe(true); + expect(result).toEqual(["foo", "baz", "biz"]); + }); + + it('should fail to set removed key', () => { + // Arrange + const slot_map = SlotMap(); + slot_map.add("foo"); + const key = slot_map.add("bar"); + slot_map.add("biz"); + slot_map.remove(key); + + // Act + const success = slot_map.set(key, "baz"); + const result = [...slot_map]; + + // ASsert + expect(success).toBe(false); + expect(result).toEqual(["foo", "biz"]); + }); }); diff --git a/src/slot_map.ts b/src/slot_map.ts index 83d1d2b..4ef2f0a 100644 --- a/src/slot_map.ts +++ b/src/slot_map.ts @@ -7,8 +7,9 @@ export interface IKey { // TODO - implement iterator export interface ISlotMap extends IterableIterator { - get(key: IKey): T | undefined; add(item: T): IKey; + get(key: IKey): T | undefined; + set(key: IKey, item: T): boolean; remove(key: IKey): boolean; } @@ -46,6 +47,16 @@ export function SlotMap(): ISlotMap { return data[internal_key.index]; }, + set(key: IKey, item: T): boolean { + const internal_key = indices[key.index]; + if (internal_key === undefined || key.generation !== internal_key.generation) { + return false; + } + + data[internal_key.index] = item; + return true; + }, + add(item: T) { if (free.length === 0) { grow_capacity(2 * capacity); From f932c2f2233e9477ea95a7f9c16b0929bb351ff6 Mon Sep 17 00:00:00 2001 From: Zach Dahl Date: Wed, 3 Nov 2021 22:47:42 -0500 Subject: [PATCH 4/8] aggregate not calls --- src/__tests__/index.spec.ts | 14 ++++++++++++-- src/index.ts | 2 +- 2 files changed, 13 insertions(+), 3 deletions(-) diff --git a/src/__tests__/index.spec.ts b/src/__tests__/index.spec.ts index 702dc5a..a71dc6d 100644 --- a/src/__tests__/index.spec.ts +++ b/src/__tests__/index.spec.ts @@ -1,5 +1,5 @@ /* eslint-disable @typescript-eslint/no-non-null-assertion */ -import { Entity, World, IWorld } from "../index"; +import { Entity, World, IWorld, ComponentFactory } from "../index"; describe("Entity", () => { it("should throw error", () => { @@ -189,15 +189,24 @@ describe("World", () => { }); describe("query_iter", () => { + let A: ComponentFactory; + let B: ComponentFactory; + beforeEach(() => { + A = () => ({}); + B = () => ({}); + world.register(Name); world.register(Position); world.register(Velocity); + world.register(A); + world.register(B); world .entity() .with(Name)("Bob") .with(Position)(1, 2) .with(Velocity)(0, 2) + .with(A)() .build(); world.entity().with(Name)("Roger").with(Position)(0, -1).build(); world @@ -205,6 +214,7 @@ describe("World", () => { .with(Name)("Tom") .with(Position)(-9, 3) .with(Velocity)(1, 1) + .with(B)() .build(); }); @@ -218,7 +228,7 @@ describe("World", () => { }); it("should allow negative searches", () => { - const result = world.query_iter(Name).not(Velocity).collect(); + const result = world.query_iter(Name).not(A).not(B).collect(); expect(result).toEqual([[{ name: "Roger" }]]); }); diff --git a/src/index.ts b/src/index.ts index 4c0077e..b33b865 100644 --- a/src/index.ts +++ b/src/index.ts @@ -200,7 +200,7 @@ export function World>(resources: R): IWorld { `#not() only expected to be called on pristine query.`, ); } - hasnt = toBitset(without); + hasnt = toBitset(without).or(hasnt); return iterator; }; From f11aa97e9ddc3a92052f4cae57fd810cd1d85723 Mon Sep 17 00:00:00 2001 From: Zach Dahl Date: Thu, 6 Mar 2025 20:04:05 -0600 Subject: [PATCH 5/8] check in previous work --- src/__tests__/slot_map.spec.ts | 29 +++------ src/index.ts | 17 ++--- src/slot_map.ts | 110 ++++++++++++++++++--------------- 3 files changed, 76 insertions(+), 80 deletions(-) diff --git a/src/__tests__/slot_map.spec.ts b/src/__tests__/slot_map.spec.ts index 2269b3a..322c391 100644 --- a/src/__tests__/slot_map.spec.ts +++ b/src/__tests__/slot_map.spec.ts @@ -1,4 +1,4 @@ -import { SlotMap } from "../slot_map"; +import { IKey, SlotMap } from "../slot_map"; describe("SlotMap", () => { it("should store items", () => { @@ -35,8 +35,8 @@ describe("SlotMap", () => { it("should reuse indices", () => { // Arrange - const slot_map = SlotMap(); - const keys = []; + const slot_map = SlotMap(); + const keys: IKey[] = []; for (let idx = 0; idx < 8; idx++) { keys[idx] = slot_map.add(idx); } @@ -44,9 +44,10 @@ describe("SlotMap", () => { // Act slot_map.remove(keys[1]); keys[8] = slot_map.add(8); - const results = []; + const results: number[] = []; for (let idx = 0; idx < 9; ++idx) { - results[idx] = slot_map.get(keys[idx]); + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + results[idx] = slot_map.get(keys[idx])!; } // Assert @@ -61,17 +62,7 @@ describe("SlotMap", () => { { index: 7, generation: 1 }, { index: 1, generation: 2 }, ]); - expect(results).toEqual([ - 0, - undefined, - 2, - 3, - 4, - 5, - 6, - 7, - 8, - ]); + expect(results).toEqual([0, undefined, 2, 3, 4, 5, 6, 7, 8]); }); it("should be iterable", () => { @@ -86,10 +77,10 @@ describe("SlotMap", () => { const result = [...slot_map]; // Assert - expect(result).toEqual([ "foo", "bar", "biz", "baz" ]); + expect(result).toEqual(["foo", "bar", "biz", "baz"]); }); - it('should allow items to be overwritten', () => { + it("should allow items to be overwritten", () => { // Arrange const slot_map = SlotMap(); slot_map.add("foo"); @@ -105,7 +96,7 @@ describe("SlotMap", () => { expect(result).toEqual(["foo", "baz", "biz"]); }); - it('should fail to set removed key', () => { + it("should fail to set removed key", () => { // Arrange const slot_map = SlotMap(); slot_map.add("foo"); diff --git a/src/index.ts b/src/index.ts index b33b865..7f7acec 100644 --- a/src/index.ts +++ b/src/index.ts @@ -99,10 +99,10 @@ export interface IQueryBuilder { const ZERO_BITSET = new BitSet(); export function World>(resources: R): IWorld { const componentIds = IdentityPool(); - const entityIds = IdentityPool(); - const componentsMap: Map = new Map(); + const entityIds = IdentityPool(); // TODO - remove upon switch to SlotMap + const componentsMap: Map = new Map(); // TODO - ComponentFactory -> (number, Component[]) Map const componentsBit: Map = new Map(); - const entities: Map = new Map(); + const entities: Map = new Map(); // TODO - switch to SlotMap const systems: System[] = []; function toBitset(factories: ComponentFactory[]): BitSet { @@ -178,20 +178,13 @@ export function World>(resources: R): IWorld { } })(); - // TODO - is there a native method for this? iterator.collect = () => { if (!pristine) { throw new Error( `#collect() only expected to be called on pristine query.`, ); } - const result = []; - - for (const r of iterator) { - result.push(r); - } - - return result as unknown as ReturnTypes[]; + return [...iterator]; }; iterator.not = (...without: ComponentFactory[]) => { @@ -216,7 +209,7 @@ export function World>(resources: R): IWorld { let query: IQueryBuilder[]>; return (query = { not(...factories) { - hasnt = toBitset(factories); + hasnt = toBitset(factories).or(hasnt); return query; }, diff --git a/src/slot_map.ts b/src/slot_map.ts index 4ef2f0a..d36fa50 100644 --- a/src/slot_map.ts +++ b/src/slot_map.ts @@ -1,3 +1,4 @@ +/* eslint-disable @typescript-eslint/no-non-null-assertion */ const INITIAL_SIZE = 8; export interface IKey { @@ -5,7 +6,6 @@ export interface IKey { generation: number; } -// TODO - implement iterator export interface ISlotMap extends IterableIterator { add(item: T): IKey; get(key: IKey): T | undefined; @@ -33,61 +33,73 @@ export function SlotMap(): ISlotMap { grow_capacity(INITIAL_SIZE); - return Object.assign(function* () { - // TODO - should warn or prevent editing while being accessed? - for (let index = 0; index < size; ++index) { - yield data[index]!; - } - }(), { - get(key: IKey): T | undefined { - const internal_key = indices[key.index]; - if (internal_key === undefined || key.generation !== internal_key.generation) { - return undefined; + return Object.assign( + (function* () { + // TODO - should warn or prevent editing while being accessed? + for (let index = 0; index < size; ++index) { + yield data[index]!; } - return data[internal_key.index]; - }, + })(), + { + get(key: IKey): T | undefined { + const internal_key = indices[key.index]; + if ( + internal_key === undefined || + key.generation !== internal_key.generation + ) { + return undefined; + } + return data[internal_key.index]; + }, - set(key: IKey, item: T): boolean { - const internal_key = indices[key.index]; - if (internal_key === undefined || key.generation !== internal_key.generation) { - return false; - } + set(key: IKey, item: T): boolean { + const internal_key = indices[key.index]; + if ( + internal_key === undefined || + key.generation !== internal_key.generation + ) { + return false; + } - data[internal_key.index] = item; - return true; - }, + data[internal_key.index] = item; + return true; + }, - add(item: T) { - if (free.length === 0) { - grow_capacity(2 * capacity); - } + add(item: T) { + if (free.length === 0) { + grow_capacity(2 * capacity); + } - const slot = free.shift()!; - const internal_key = indices[slot]; - internal_key.index = size; - data[size] = item; - erase[size] = slot; - size += 1; + const slot = free.shift()!; + const internal_key = indices[slot]; + internal_key.index = size; + data[size] = item; + erase[size] = slot; + size += 1; - return { index: slot, generation: internal_key.generation }; - }, + return { index: slot, generation: internal_key.generation }; + }, - remove(key: IKey): boolean { - const internal_key = indices[key.index]; - if (internal_key === undefined || key.generation !== internal_key.generation) { - return false; - } + remove(key: IKey): boolean { + const internal_key = indices[key.index]; + if ( + internal_key === undefined || + key.generation !== internal_key.generation + ) { + return false; + } - internal_key.generation += 1; - const del_idx = internal_key.index; - data[del_idx] = data[size - 1]; - data[size - 1] = undefined; - const idx = erase[del_idx] = erase[size - 1]; - indices[idx].index = key.index; - free.push(internal_key.index); - size -= 1; + internal_key.generation += 1; + const del_idx = internal_key.index; + data[del_idx] = data[size - 1]; + data[size - 1] = undefined; + const idx = (erase[del_idx] = erase[size - 1]); + indices[idx].index = key.index; + free.push(internal_key.index); + size -= 1; - return true; - } - }); + return true; + }, + }, + ); } From c47ae65c3ab673836686772c2f2a984cecd824ba Mon Sep 17 00:00:00 2001 From: Zach Dahl Date: Thu, 6 Mar 2025 20:08:58 -0600 Subject: [PATCH 6/8] fix ts error --- src/__tests__/index.spec.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/__tests__/index.spec.ts b/src/__tests__/index.spec.ts index a71dc6d..cb4a22e 100644 --- a/src/__tests__/index.spec.ts +++ b/src/__tests__/index.spec.ts @@ -251,7 +251,7 @@ describe("World", () => { }); it("should be iterable", () => { - const result = []; + const result: string[] = []; for (const [name] of world.query_iter(Name)) { result.push(name.name); From b249638dafb2d5c1934a72f866180a6a7e41b614 Mon Sep 17 00:00:00 2001 From: Zach Dahl Date: Fri, 7 Mar 2025 21:04:01 -0600 Subject: [PATCH 7/8] Add SlotMap, SecondaryMap and SparseSecondaryMap --- .npmrc | 1 + src/__tests__/index.spec.ts | 43 +++++-- src/index.ts | 100 +++++++++------- src/slot_map/__tests__/secondary_map.spec.ts | 73 ++++++++++++ src/{ => slot_map}/__tests__/slot_map.spec.ts | 42 +++++++ .../__tests__/sparse_secondary_map.spec.ts | 73 ++++++++++++ src/slot_map/secondary_map.ts | 111 ++++++++++++++++++ src/{ => slot_map}/slot_map.ts | 57 +++++++-- src/slot_map/sparse_secondary_map.ts | 72 ++++++++++++ tsconfig.json | 4 +- 10 files changed, 514 insertions(+), 62 deletions(-) create mode 100644 .npmrc create mode 100644 src/slot_map/__tests__/secondary_map.spec.ts rename src/{ => slot_map}/__tests__/slot_map.spec.ts (73%) create mode 100644 src/slot_map/__tests__/sparse_secondary_map.spec.ts create mode 100644 src/slot_map/secondary_map.ts rename src/{ => slot_map}/slot_map.ts (60%) create mode 100644 src/slot_map/sparse_secondary_map.ts diff --git a/.npmrc b/.npmrc new file mode 100644 index 0000000..449691b --- /dev/null +++ b/.npmrc @@ -0,0 +1 @@ +save-exact=true \ No newline at end of file diff --git a/src/__tests__/index.spec.ts b/src/__tests__/index.spec.ts index cb4a22e..cb7a3fc 100644 --- a/src/__tests__/index.spec.ts +++ b/src/__tests__/index.spec.ts @@ -21,9 +21,9 @@ describe("World", () => { const entity0 = world.entity().build(); const entity1 = world.entity().build(); + const result = world.query(Entity).result(); - expect(entity0).toBe(0); - expect(entity1).toBe(1); + expect(result).toEqual([[entity0], [entity1]]); }); it("should throw when given unregistered component", () => { @@ -49,7 +49,6 @@ describe("World", () => { world.entity().with(Name)("Tom").build(); world.delete(entity1); - expect(world.entity().build()).toEqual(entity1); expect(world.query(Name).result()).toEqual([ [{ name: "Bob" }], [{ name: "Tom" }], @@ -60,7 +59,7 @@ describe("World", () => { const world = World({}); expect(() => { - world.delete(0); + world.delete({ generation: 99, index: 0 }); }).not.toThrow(); }); }); @@ -141,7 +140,7 @@ describe("World", () => { it("should throw on unknown entity", () => { expect(() => { - world.add(Name, 77)("Bob"); + world.add(Name, { generation: 99, index: 9 })("Bob"); }).toThrowError("unknown entity"); }); }); @@ -176,14 +175,14 @@ describe("World", () => { it("should throw on unregistered component", () => { world.entity().build(); expect(() => { - world.remove(Position, 0); + world.remove(Position, { generation: 1, index: 1 }); }).toThrowError("unknown Component"); }); it("should throw on unknown entity", () => { world.register(Position); expect(() => { - world.remove(Position, 0); + world.remove(Position, { generation: 1, index: 1 }); }).toThrowError("unknown entity"); }); }); @@ -237,9 +236,18 @@ describe("World", () => { const result = world.query_iter(Position, Entity).collect(); expect(result).toEqual([ - [{ x: 1, y: 2 }, 0], - [{ x: 0, y: -1 }, 1], - [{ x: -9, y: 3 }, 2], + [ + { x: 1, y: 2 }, + { generation: 1, index: 0 }, + ], + [ + { x: 0, y: -1 }, + { generation: 1, index: 1 }, + ], + [ + { x: -9, y: 3 }, + { generation: 1, index: 2 }, + ], ]); }); @@ -320,9 +328,18 @@ describe("World", () => { const result = world.query(Position, Entity).result(); expect(result).toEqual([ - [{ x: 1, y: 2 }, 0], - [{ x: 0, y: -1 }, 1], - [{ x: -9, y: 3 }, 2], + [ + { x: 1, y: 2 }, + { generation: 1, index: 0 }, + ], + [ + { x: 0, y: -1 }, + { generation: 1, index: 1 }, + ], + [ + { x: -9, y: 3 }, + { generation: 1, index: 2 }, + ], ]); }); diff --git a/src/index.ts b/src/index.ts index 7f7acec..523b747 100644 --- a/src/index.ts +++ b/src/index.ts @@ -2,6 +2,13 @@ import BitSet from "bitset"; import type { Cast, Prepend, Pos, Reverse, Length, Next } from "./type-utils"; import { IdentityPool } from "./identity"; +import { IKey, SlotMap, ISecondaryMap } from "./slot_map/slot_map"; +import { SecondaryMap } from "./slot_map/secondary_map"; + +type ISecondaryMapConstructor = { + new (): ISecondaryMap; + with_capacity?(capacity: number): ISecondaryMap; +}; type QueryResult = T extends typeof Entity ? number @@ -46,23 +53,26 @@ export type System = (world: IWorld) => void; // Resources ✓ // Entities ✓ // Components ✓ -// Systems // TODO - evaluate parrallel execution in the future +// Systems // TODO - evaluate parrallel execution, scheduling in the future export interface IWorld> { // Entities entity(): IEntityBuilder; - delete(entity: number): void; + delete(entity: IKey): void; // Components - register(factory: ComponentFactory): IWorld; + register( + factory: T, + secondary?: ISecondaryMapConstructor, + ): IWorld; get( factory: T, - entity: number, + entity: IKey, ): ReturnType | null; add( factory: T, - entity: number, + entity: IKey, ): BoundFactory; - remove(factory: ComponentFactory, entity: number): void; + remove(factory: ComponentFactory, entity: IKey): void; query_iter( ...factories: T ): IQuery>; @@ -83,7 +93,7 @@ export type BoundFactory = ( export interface IEntityBuilder { with(factory: T): BoundFactory; - build(): number; + build(): IKey; } export interface IQuery extends IterableIterator { @@ -99,10 +109,12 @@ export interface IQueryBuilder { const ZERO_BITSET = new BitSet(); export function World>(resources: R): IWorld { const componentIds = IdentityPool(); - const entityIds = IdentityPool(); // TODO - remove upon switch to SlotMap - const componentsMap: Map = new Map(); // TODO - ComponentFactory -> (number, Component[]) Map + const componentsMap: Map< + ComponentFactory, + ISecondaryMap + > = new Map(); const componentsBit: Map = new Map(); - const entities: Map = new Map(); // TODO - switch to SlotMap + const entities = SlotMap(); const systems: System[] = []; function toBitset(factories: ComponentFactory[]): BitSet { @@ -130,13 +142,22 @@ export function World>(resources: R): IWorld { return (world = { resources, - register(factory: ComponentFactory) { - componentsMap.set(factory, []); + register( + factory: T, + secondaryType: ISecondaryMapConstructor = SecondaryMap, + ) { + let secondary: ISecondaryMap; + if (secondaryType.with_capacity !== undefined) { + secondary = secondaryType.with_capacity(entities.size()); + } else { + secondary = new SecondaryMap(); + } + componentsMap.set(factory, secondary); componentsBit.set(factory, componentIds.get()); return world as unknown as IWorld; }, - get(factory: T, entity: number) { + get(factory: T, entity: IKey) { const components = componentsMap.get(factory); if (components === undefined) { throw new TypeError( @@ -144,7 +165,7 @@ export function World>(resources: R): IWorld { ); } - const component = components[entity]; + const component = components.get(entity); return typeof component === "undefined" ? null : (component as ReturnType); @@ -171,7 +192,7 @@ export function World>(resources: R): IWorld { factory === Entity ? entity : // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - componentsMap.get(factory)![entity]; + componentsMap.get(factory)!.get(entity); } yield result; } @@ -217,6 +238,7 @@ export function World>(resources: R): IWorld { // TODO - might not need #result/lazy() call if we implement Iterator for QueryBuilder? result() { const results: any[] = []; + for (const [entity, bitset] of entities.entries()) { if ( has.and(bitset).equals(has) && @@ -228,7 +250,7 @@ export function World>(resources: R): IWorld { factory === Entity ? entity : // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - componentsMap.get(factory)![entity]; + componentsMap.get(factory)!.get(entity); } results.push(result); } @@ -252,10 +274,9 @@ export function World>(resources: R): IWorld { }, build() { - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - const entity = entityIds.get(); - const entityMask = new BitSet(); + const entity = entities.add(entityMask); + for (const [factory, args] of parts) { const bit = componentsBit.get(factory); const components = componentsMap.get(factory); @@ -265,10 +286,10 @@ export function World>(resources: R): IWorld { ); } - components[entity] = factory(...args); + components.set(entity, factory(...args)); entityMask.set(bit, 1); } - entities.set(entity, entityMask); + return entity; }, }); @@ -276,7 +297,7 @@ export function World>(resources: R): IWorld { add( factory: T, - entity: number, + entity: IKey, ): BoundFactory { return (...args: Parameters) => { const entityMask = entities.get(entity); @@ -292,12 +313,12 @@ export function World>(resources: R): IWorld { ); } - components[entity] = factory(...(args as any)); // TODO - why is this cast to any needed? + components.set(entity, factory(...(args as any))); entityMask.set(bit, 1); }; }, - remove(factory: ComponentFactory, entity: number) { + remove(factory: ComponentFactory, entity: IKey) { const bit = componentsBit.get(factory); const components = componentsMap.get(factory); if (bit === undefined || components === undefined) { @@ -313,27 +334,26 @@ export function World>(resources: R): IWorld { ); } - delete components[entity]; + components.remove(entity); entityMask.set(bit, 0); }, - delete(entity: number) { - const entityMask = entities.get(entity); - if (!entityMask) { - return; - } + delete(entity: IKey) { + // Can just be overwritten lazily for now with SlotMaps + // const entityMask = entities.get(entity); + // if (!entityMask) { + // return; + // } - // TODO - would get cleaned up by transition to number id'd components // TODO - delay and batch? - const toDelete = entityMask.toArray(); - for (const [factory, bit] of componentsBit.entries()) { - if (toDelete.indexOf(bit) !== -1) { - world.remove(factory, entity); - } - } - - entities.delete(entity); - entityIds.retire(entity); + // const toDelete = entityMask.toArray(); + // for (const [factory, bit] of componentsBit.entries()) { + // if (toDelete.indexOf(bit) !== -1) { + // world.remove(factory, entity); + // } + // } + + entities.remove(entity); }, // TODO - scheduling? disabling? diff --git a/src/slot_map/__tests__/secondary_map.spec.ts b/src/slot_map/__tests__/secondary_map.spec.ts new file mode 100644 index 0000000..e0d9379 --- /dev/null +++ b/src/slot_map/__tests__/secondary_map.spec.ts @@ -0,0 +1,73 @@ +import { SlotMap } from "../slot_map"; +import { SecondaryMap } from "../secondary_map"; + +describe("SecondaryMap", () => { + it("should allow setting and getting of key, value pairs", () => { + // Arrange + const slot_map = SlotMap(); + const key_1 = slot_map.add(1); + const key_2 = slot_map.add(2); + const secondary = new SecondaryMap(); + + // Act + secondary.set(key_1, "one"); + const results = [secondary.get(key_1), secondary.get(key_2)]; + + // Assert + expect(results).toEqual(["one", undefined]); + }); + + it("SecondaryMap#has should return if key is occupied", () => { + // Arrange + const slot_map = SlotMap(); + const key_1 = slot_map.add(1); + const key_2 = slot_map.add(2); + const secondary = new SecondaryMap(); + secondary.set(key_1, "one"); + + // Act + const results = [secondary.has(key_1), secondary.has(key_2)]; + + // Assert + expect(results).toEqual([true, false]); + }); + + it("should allow for removals", () => { + // Arrange + const slot_map = SlotMap(); + const key = slot_map.add(1); + const secondary = new SecondaryMap(); + secondary.set(key, "foo"); + + // Act + secondary.remove(key); + const results = [secondary.has(key), secondary.get(key)]; + + // Assert + expect(results).toEqual([false, undefined]); + }); + + it("should be iterable", () => { + // Arrange + const slot_map = SlotMap(); + const keys = [ + slot_map.add("foo"), + slot_map.add("bar"), + slot_map.add("biz"), + slot_map.add("baz"), + slot_map.add("riz"), + ]; + const secondary = new SecondaryMap(); + keys.forEach((k) => { + secondary.set(k, slot_map.get(k)?.toUpperCase() || "null"); + }); + secondary.remove(keys[1]); + secondary.remove(keys[3]); + + // Act + const results = [secondary.size(), Array.from(secondary)]; + + // Assert + expect(results).toEqual([3, ["FOO", "BIZ", "RIZ"]]); + }); +}); diff --git a/src/__tests__/slot_map.spec.ts b/src/slot_map/__tests__/slot_map.spec.ts similarity index 73% rename from src/__tests__/slot_map.spec.ts rename to src/slot_map/__tests__/slot_map.spec.ts index 322c391..93c539a 100644 --- a/src/__tests__/slot_map.spec.ts +++ b/src/slot_map/__tests__/slot_map.spec.ts @@ -80,6 +80,48 @@ describe("SlotMap", () => { expect(result).toEqual(["foo", "bar", "biz", "baz"]); }); + describe("SlotMap#entries", () => { + it("should be iterable", () => { + // Arrange + const slot_map = SlotMap(); + const keys = [ + slot_map.add("foo"), + slot_map.add("bar"), + slot_map.add("biz"), + slot_map.add("baz"), + ]; + slot_map.remove(keys[1]); + slot_map.add("riz"); + slot_map.remove(keys[3]); + + // Act + const result = Array.from(slot_map.entries()); + + // Assert + expect(result).toEqual([ + [{ generation: 1, index: 0 }, "foo"], + [{ generation: 1, index: 2 }, "biz"], + [{ generation: 1, index: 4 }, "riz"], + ]); + }); + }); + + it("should not iterate over removed items", () => { + // Arrange + const slot_map = SlotMap(); + slot_map.add("foo"); + const to_remove = slot_map.add("bar"); + slot_map.add("biz"); + slot_map.add("baz"); + slot_map.remove(to_remove); + + // Act + const result = [...slot_map]; + + // Assert + expect(result).toEqual(["foo", "baz", "biz"]); + }); + it("should allow items to be overwritten", () => { // Arrange const slot_map = SlotMap(); diff --git a/src/slot_map/__tests__/sparse_secondary_map.spec.ts b/src/slot_map/__tests__/sparse_secondary_map.spec.ts new file mode 100644 index 0000000..c127330 --- /dev/null +++ b/src/slot_map/__tests__/sparse_secondary_map.spec.ts @@ -0,0 +1,73 @@ +import { SlotMap } from "../slot_map"; +import { SparseSecondaryMap } from "../sparse_secondary_map"; + +describe("SparseSecondaryMap", () => { + it("should allow setting and getting of key, value pairs", () => { + // Arrange + const slot_map = SlotMap(); + const key_1 = slot_map.add(1); + const key_2 = slot_map.add(2); + const secondary = new SparseSecondaryMap(); + + // Act + secondary.set(key_1, "one"); + const results = [secondary.get(key_1), secondary.get(key_2)]; + + // Assert + expect(results).toEqual(["one", undefined]); + }); + + it("SparseSecondaryMap#has should return if key is occupied", () => { + // Arrange + const slot_map = SlotMap(); + const key_1 = slot_map.add(1); + const key_2 = slot_map.add(2); + const secondary = new SparseSecondaryMap(); + secondary.set(key_1, "one"); + + // Act + const results = [secondary.has(key_1), secondary.has(key_2)]; + + // Assert + expect(results).toEqual([true, false]); + }); + + it("should allow for removals", () => { + // Arrange + const slot_map = SlotMap(); + const key = slot_map.add(1); + const secondary = new SparseSecondaryMap(); + secondary.set(key, "foo"); + + // Act + secondary.remove(key); + const results = [secondary.has(key), secondary.get(key)]; + + // Assert + expect(results).toEqual([false, undefined]); + }); + + it("should be iterable", () => { + // Arrange + const slot_map = SlotMap(); + const keys = [ + slot_map.add("foo"), + slot_map.add("bar"), + slot_map.add("biz"), + slot_map.add("baz"), + slot_map.add("riz"), + ]; + const secondary = new SparseSecondaryMap(); + keys.forEach((k) => { + secondary.set(k, slot_map.get(k)?.toUpperCase() || "null"); + }); + secondary.remove(keys[1]); + secondary.remove(keys[3]); + + // Act + const results = [secondary.size(), Array.from(secondary)]; + + // Assert + expect(results).toEqual([3, ["FOO", "BIZ", "RIZ"]]); + }); +}); diff --git a/src/slot_map/secondary_map.ts b/src/slot_map/secondary_map.ts new file mode 100644 index 0000000..0385743 --- /dev/null +++ b/src/slot_map/secondary_map.ts @@ -0,0 +1,111 @@ +import type { IKey, ISecondaryMap } from "./slot_map"; + +type ISlot = { + generation: number; + occupied: boolean; + value: T; +}; + +export class SecondaryMap implements ISecondaryMap { + _slots: ISlot[] = []; + _capacity = 0; + _size = 0; + + static with_capacity(capacity: number) { + const sm = new SecondaryMap(); + sm.grow_capacity(capacity); + return sm; + } + + *[Symbol.iterator]() { + for (const slot of this._slots) { + if (slot.occupied) { + yield slot.value; + } + } + } + + public set(key: IKey, value: T): T | undefined { + if (key.index >= this._capacity) { + this.grow_capacity(key.index + 1); + } + + const slot = this._slots[key.index]; + if (key.generation < slot.generation) { + return undefined; + } + + if (slot.occupied === false) { + this._size += 1; + slot.occupied = true; + } else if (slot.generation === key.generation) { + const replaced = slot.value; + slot.value = value; + return replaced; + } + + slot.generation = key.generation; + slot.value = value; + return undefined; + } + + public has(key: IKey): boolean { + const slot = this._slots[key.index]; + return ( + slot !== undefined && slot.generation === key.generation && slot.occupied + ); + } + + public get(key: IKey): T | undefined { + const slot = this._slots[key.index]; + if ( + slot === undefined || + slot.generation !== key.generation || + slot.occupied === false + ) { + return undefined; + } + + return slot.value; + } + + public remove(key: IKey): T | undefined { + const slot = this._slots[key.index]; + if ( + slot === undefined || + slot.generation !== key.generation || + slot.occupied === false + ) { + return undefined; + } + + this._size -= 1; + const removed = slot.value; + slot.value = undefined as unknown as T; + slot.occupied = false; + return removed; + } + + public clear() { + for (const slot of this._slots) { + slot.value = undefined as unknown as T; + slot.occupied = false; + } + this._size = 0; + } + + public size(): number { + return this._size; + } + + protected grow_capacity(capacity: number) { + for (let index = this._capacity; index < capacity; ++index) { + this._slots[index] = { + generation: -1, + value: undefined as unknown as T, + occupied: false, + }; + } + this._capacity = capacity; + } +} diff --git a/src/slot_map.ts b/src/slot_map/slot_map.ts similarity index 60% rename from src/slot_map.ts rename to src/slot_map/slot_map.ts index d36fa50..e7b9772 100644 --- a/src/slot_map.ts +++ b/src/slot_map/slot_map.ts @@ -1,4 +1,5 @@ /* eslint-disable @typescript-eslint/no-non-null-assertion */ +// Also: https://github.com/orlp/slotmap/blob/master/src - TODO: hop vs sparse vs basic const INITIAL_SIZE = 8; export interface IKey { @@ -6,15 +7,29 @@ export interface IKey { generation: number; } -export interface ISlotMap extends IterableIterator { +interface ISlot extends IKey { + occupied: boolean; +} + +export type ISecondaryMap = Iterable & { + get(key: IKey): T | undefined; + set(key: IKey, value: T): T | undefined; + has(key: IKey): boolean; + remove(key: IKey): T | undefined; + size(): number; +}; + +export interface ISlotMap extends Iterable { add(item: T): IKey; get(key: IKey): T | undefined; set(key: IKey, item: T): boolean; remove(key: IKey): boolean; + size(): number; + entries(): IterableIterator<[IKey, T]>; } export function SlotMap(): ISlotMap { - const indices: IKey[] = []; + const indices: ISlot[] = []; const data: (T | undefined)[] = []; const erase: number[] = []; const free: number[] = []; @@ -23,7 +38,7 @@ export function SlotMap(): ISlotMap { function grow_capacity(n: number) { for (let index = capacity; index < n; ++index) { - indices[index] = { index: 0, generation: 1 }; + indices[index] = { index: 0, generation: 1, occupied: false }; data[index] = undefined; free.push(index); erase[index] = 0; @@ -41,6 +56,26 @@ export function SlotMap(): ISlotMap { } })(), { + *entries(): IterableIterator<[IKey, T]> { + let returned = 0; + + // TODO - should warn or prevent editing while being accessed? + let index = 0; + while (returned < size) { + const key_index = index++; + const key = indices[key_index]; + if (key.occupied === false) { + continue; + } + + ++returned; + yield [ + { index: key_index, generation: key.generation }, + data[key.index]!, + ]; + } + }, + get(key: IKey): T | undefined { const internal_key = indices[key.index]; if ( @@ -56,7 +91,8 @@ export function SlotMap(): ISlotMap { const internal_key = indices[key.index]; if ( internal_key === undefined || - key.generation !== internal_key.generation + key.generation !== internal_key.generation || + internal_key.occupied === false ) { return false; } @@ -73,6 +109,7 @@ export function SlotMap(): ISlotMap { const slot = free.shift()!; const internal_key = indices[slot]; internal_key.index = size; + internal_key.occupied = true; data[size] = item; erase[size] = slot; size += 1; @@ -80,22 +117,28 @@ export function SlotMap(): ISlotMap { return { index: slot, generation: internal_key.generation }; }, + size(): number { + return size; + }, + remove(key: IKey): boolean { const internal_key = indices[key.index]; if ( internal_key === undefined || - key.generation !== internal_key.generation + key.generation !== internal_key.generation || + internal_key.occupied === false ) { return false; } internal_key.generation += 1; + internal_key.occupied = false; const del_idx = internal_key.index; data[del_idx] = data[size - 1]; data[size - 1] = undefined; const idx = (erase[del_idx] = erase[size - 1]); - indices[idx].index = key.index; - free.push(internal_key.index); + indices[idx].index = del_idx; + free.push(key.index); size -= 1; return true; diff --git a/src/slot_map/sparse_secondary_map.ts b/src/slot_map/sparse_secondary_map.ts new file mode 100644 index 0000000..5f96e52 --- /dev/null +++ b/src/slot_map/sparse_secondary_map.ts @@ -0,0 +1,72 @@ +import type { IKey, ISecondaryMap } from "./slot_map"; + +type ISlot = { + generation: number; + value: T; +}; + +export class SparseSecondaryMap implements ISecondaryMap { + _slots = new Map>(); + + *[Symbol.iterator]() { + for (const slot of this._slots.values()) { + yield slot.value; + } + } + + public set(key: IKey, value: T): T | undefined { + let slot = this._slots.get(key.index); + if (slot !== undefined && key.generation < slot.generation) { + return undefined; + } + + if (slot === undefined) { + slot = { generation: key.generation, value }; + this._slots.set(key.index, slot); + return undefined; + } + + if (slot.generation === key.generation) { + const replaced = slot.value; + slot.value = value; + return replaced; + } + + slot.generation = key.generation; + slot.value = value; + return undefined; + } + + public has(key: IKey): boolean { + const slot = this._slots.get(key.index); + return slot !== undefined && slot.generation === key.generation; + } + + public get(key: IKey): T | undefined { + const slot = this._slots.get(key.index); + if (slot === undefined || slot.generation !== key.generation) { + return undefined; + } + + return slot.value; + } + + public remove(key: IKey): T | undefined { + const slot = this._slots.get(key.index); + if (slot === undefined || slot.generation !== key.generation) { + return undefined; + } + + const removed = slot.value; + this._slots.delete(key.index); + return removed; + } + + public clear() { + this._slots.clear(); + } + + public size(): number { + return this._slots.size; + } +} diff --git a/tsconfig.json b/tsconfig.json index 11324da..81dd23d 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -1,7 +1,7 @@ { "compilerOptions": { - "target": "ES2015", /* Specify ECMAScript target version: 'ES3' (default), 'ES5', 'ES2015', 'ES2016', 'ES2017', 'ES2018', 'ES2019', 'ES2020', or 'ESNEXT'. */ - "module": "ES2015", /* Specify module code generation: 'none', 'commonjs', 'amd', 'system', 'umd', 'es2015', 'es2020', or 'ESNext'. */ + "target": "ESNext", /* Specify ECMAScript target version: 'ES3' (default), 'ES5', 'ES2015', 'ES2016', 'ES2017', 'ES2018', 'ES2019', 'ES2020', or 'ESNEXT'. */ + "module": "ESNext", /* Specify module code generation: 'none', 'commonjs', 'amd', 'system', 'umd', 'es2015', 'es2020', or 'ESNext'. */ // "lib": [], /* Specify library files to be included in the compilation. */ // "allowJs": true, /* Allow javascript files to be compiled. */ // "checkJs": true, /* Report errors in .js files. */ From c587732f645a2b281674cfb97231a3f43a9ac2e5 Mon Sep 17 00:00:00 2001 From: Zach Dahl Date: Fri, 7 Mar 2025 21:04:14 -0600 Subject: [PATCH 8/8] BinaryNumberHeap --- .../__tests__/binary_number_heap.spec.ts | 21 ++++ src/binary_number_heap/binary_number_heap.ts | 96 +++++++++++++++++++ 2 files changed, 117 insertions(+) create mode 100644 src/binary_number_heap/__tests__/binary_number_heap.spec.ts create mode 100644 src/binary_number_heap/binary_number_heap.ts diff --git a/src/binary_number_heap/__tests__/binary_number_heap.spec.ts b/src/binary_number_heap/__tests__/binary_number_heap.spec.ts new file mode 100644 index 0000000..b1d56ec --- /dev/null +++ b/src/binary_number_heap/__tests__/binary_number_heap.spec.ts @@ -0,0 +1,21 @@ +/* eslint-disable @typescript-eslint/no-non-null-assertion */ +import { BinaryNumberHeap } from "../binary_number_heap"; + +describe("BinaryNumberHeap", () => { + it("should store numbers in order", () => { + const heap = new BinaryNumberHeap(); + [10, 3, 4, 8, 2, 9, 7, 1, 2, 6, 3, 5].forEach((x) => { + heap.push(x); + }); + + heap.remove(2); + + const result: number[] = []; + + while (heap.size() > 0) { + result.push(heap.pop()!); + } + + expect(result).toEqual([1, 2, 3, 3, 4, 5, 6, 7, 8, 9, 10]); + }); +}); diff --git a/src/binary_number_heap/binary_number_heap.ts b/src/binary_number_heap/binary_number_heap.ts new file mode 100644 index 0000000..092cf94 --- /dev/null +++ b/src/binary_number_heap/binary_number_heap.ts @@ -0,0 +1,96 @@ +/* eslint-disable @typescript-eslint/no-non-null-assertion */ +// https://eloquentjavascript.net/1st_edition/appendix2.html +// We aren't making it generic in order to get the most performance +export class BinaryNumberHeap { + content: number[] = []; + + push(value: number) { + this.content.push(value); + this.bubbleUp(this.content.length - 1); + } + + pop(): number | undefined { + const result = this.content[0]; + const end = this.content.pop()!; + + if (this.content.length > 0) { + this.content[0] = end; + this.sinkDown(0); + } + + return result; + } + + remove(value: number) { + const length = this.content.length; + + for (let i = 0; i < length; ++i) { + if (this.content[i] !== value) { + continue; + } + + const end = this.content.pop()!; + if (i === length - 1) { + break; + } + + this.content[i] = end; + this.bubbleUp(i); + this.sinkDown(i); + break; + } + } + + size(): number { + return this.content.length; + } + + private bubbleUp(index: number) { + const element = this.content[index]; + + while (index > 0) { + const parentN = (((index + 1) / 2) | 0) - 1; + const parent = this.content[parentN]; + if (element >= parent) { + break; + } + + this.content[parentN] = element; + this.content[index] = parent; + index = parentN; + } + } + + private sinkDown(index: number) { + const length = this.content.length; + const element = this.content[index]; + + while (true) { + const child2N = (index + 1) * 2; + const child1N = child2N - 1; + let swap: number | null = null; + + if (child1N < length) { + const child1 = this.content[child1N]; + if (child1 < element) { + swap = child1N; + } + } + + if (child2N < length) { + const child2 = this.content[child2N]; + if (child2 < (swap === null ? element : this.content[child1N])) { + swap = child2N; + } + } + + if (swap === null) { + break; + } + + this.content[index] = this.content[swap]; + this.content[swap] = element; + index = swap; + } + } +}