diff --git a/packages/game/.gitignore b/packages/game/.gitignore new file mode 100644 index 0000000..5271e71 --- /dev/null +++ b/packages/game/.gitignore @@ -0,0 +1 @@ +/proto/**/*.ts diff --git a/packages/game/README.md b/packages/game/README.md new file mode 100644 index 0000000..e69de29 diff --git a/packages/game/package.json b/packages/game/package.json new file mode 100644 index 0000000..19cbbde --- /dev/null +++ b/packages/game/package.json @@ -0,0 +1,36 @@ +{ + "name": "game", + "version": "0.0.0", + "scripts": { + "install": "yarn run proto", + "test": "vitest run", + "proto": "run-script-os", + "proto:default": "protoc --plugin=./node_modules/.bin/protoc-gen-ts_proto --ts_proto_out=./proto --ts_proto_opt=unrecognizedEnum=false --ts_proto_opt=oneof=unions -I=./proto ./proto/*.proto", + "proto:win32": "protoc --plugin=protoc-gen-ts_proto=\".\\node_modules\\.bin\\protoc-gen-ts_proto.cmd\" --ts_proto_out=./proto --ts_proto_opt=unrecognizedEnum=false --ts_proto_opt=oneof=unions -I=./proto ./proto/*.proto", + "lint": "eslint \"src/**/*.{tsx,ts}\"" + }, + "dependencies": { + "@dimforge/rapier2d": "^0.11.2", + "@petamoriken/float16": "^3.8.3", + "@stdlib/math": "^0.0.11", + "lz-string": "^1.5.0", + "mnemonist": "^0.39.5", + "protobufjs": "^7.2.4", + "runtime-framework": "*", + "ts-proto": "^1.156.6", + "typescript": "latest" + }, + "devDependencies": { + "@protobuf-ts/protoc": "^2.9.1", + "eslint-config-custom": "*", + "run-script-os": "^1.1.6", + "tsconfig": "*", + "vitest": "^0.31.4" + }, + "eslintConfig": { + "root": true, + "extends": [ + "custom" + ] + } +} diff --git a/packages/game/proto/replay.proto b/packages/game/proto/replay.proto new file mode 100644 index 0000000..a01addc --- /dev/null +++ b/packages/game/proto/replay.proto @@ -0,0 +1,5 @@ +syntax = "proto3"; + +message ReplayModel { + bytes frames = 1; +} diff --git a/packages/game/proto/world.proto b/packages/game/proto/world.proto new file mode 100644 index 0000000..c02c322 --- /dev/null +++ b/packages/game/proto/world.proto @@ -0,0 +1,58 @@ +syntax = "proto3"; + +enum EntityType { + ROCKET = 0; + LEVEL = 1; + SHAPE = 2; +} + +message RocketBehaviorConfig { + float thrust_distance = 1; + float thrust_value = 2; + float thrust_ground_multiplier = 3; + float explosion_angle = 4; +} + +message RocketConfig { + float position_x = 1; + float position_y = 2; + float rotation = 3; + + RocketConfig default_config = 4; +} + +message LevelConfig { + float position_x = 1; + float position_y = 2; + float rotation = 3; + + float camera_top_left_x = 4; + float camera_top_left_y = 5; + + float camera_bottom_right_x = 6; + float camera_bottom_right_y = 7; + + float capture_area_left = 8; + float capture_area_right = 9; + + optional RocketConfig rocket_config = 10; +} + +message ShapeConfig { + bytes vertices = 1; +} + +message GroupConfig { + repeated RocketConfig rockets = 1; + repeated LevelConfig levels = 2; + repeated ShapeConfig shapes = 3; +} + +message GamemodeConfig { + repeated string groups = 1; +} + +message WorldConfig { + map groups = 1; + map gamemodes = 2; +} diff --git a/packages/game/src/framework/entity.base.test.ts b/packages/game/src/framework/entity.base.test.ts new file mode 100644 index 0000000..ceff0f3 --- /dev/null +++ b/packages/game/src/framework/entity.base.test.ts @@ -0,0 +1,948 @@ +import { beforeEach, describe, expect, it } from "vitest" +import { Entity, EntityStore, EntityWith, newEntityStore } from "./entity" + +interface Components { + position: { x: number; y: number } + velocity: { dx: number; dy: number } + health: { hp: number } +} + +let store: EntityStore + +beforeEach(() => { + store = newEntityStore() +}) + +describe("EntityStore", () => { + it("should create and retrieve a single entity with specified components", () => { + const entity = store.create({ position: { x: 0, y: 0 } }) + const retrieved = store.single("position") + expect(retrieved().get("position")).toEqual(entity.get("position")) + expect(retrieved().get("position")).toEqual({ x: 0, y: 0 }) + }) + + it("should create and retrieve a single entity with specified not components", () => { + const entity = store.create({ position: { x: 0, y: 0 } }) + const retrieved = store.single("not-velocity")() + expect(retrieved.has("position") && retrieved.get("position")).toEqual( + entity.get("position"), + ) + expect(retrieved.has("position") && retrieved.get("position")).toEqual({ x: 0, y: 0 }) + }) + + it("should throw an error when single is accessed with no entities", () => { + const single = store.single("position") + expect(() => single().get("position")).toThrow() + }) + + it("should throw an error when single is accessed with multiple entities", () => { + store.create({ position: { x: 0, y: 0 } }) + store.create({ position: { x: 1, y: 1 } }) + + const single = store.single("position") + expect(() => single().get("position")).toThrow() + }) + + it("should create and retrieve multiple entities with specified components", () => { + const entity1 = store.create({ position: { x: 0, y: 0 } }) + const entity2 = store.create({ position: { x: 1, y: 1 } }) + const retrieved = store.multiple("position") + // console.log(retrieved[0]) + expect(retrieved.length).toBe(2) + expect(retrieved[0].get("position")).toEqual(entity1.get("position")) + expect(retrieved[1].get("position")).toEqual(entity2.get("position")) + }) + + it("should update multiple entities list when new entity is created", () => { + const entity1 = store.create({ position: { x: 0, y: 0 } }) + const retrieved = store.multiple("position") + expect(retrieved.length).toBe(1) + expect(retrieved[0].get("position")).toEqual(entity1.get("position")) + + const entity2 = store.create({ position: { x: 1, y: 1 } }) + const newRetrieved = store.multiple("position") + + expect(newRetrieved).toBe(retrieved) + + expect(retrieved.length).toBe(2) + expect([...newRetrieved][0].get("position")).toEqual(entity1.get("position")) + expect([...newRetrieved][1].get("position")).toEqual(entity2.get("position")) + + expect(retrieved.length).toBe(2) + expect(retrieved[0].get("position")).toEqual(entity1.get("position")) + expect(retrieved[1].get("position")).toEqual(entity2.get("position")) + + expect(retrieved).toBe(newRetrieved) + }) + + it("should update multiple entities list when an entity is removed", () => { + const entity1 = store.create({ position: { x: 0, y: 0 } }) + const entity2 = store.create({ position: { x: 1, y: 1 } }) + const retrieved = store.multiple("position") + + expect(retrieved.length).toBe(2) + expect(retrieved[0].get("position")).toEqual(entity1.get("position")) + expect(retrieved[1].get("position")).toEqual(entity2.get("position")) + + store.remove(entity1) + + const newRetrieved = store.multiple("position") + expect(newRetrieved).toBe(retrieved) + + expect(retrieved.length).toBe(1) + expect(retrieved[0].get("position")).toEqual(entity2.get("position")) + }) + + it("should allow setting components for an entity", () => { + const entity = store.create({ position: { x: 0, y: 0 } }) + store.create({ velocity: { dx: 1, dy: 1 } }) + + const newEntity = entity.set("velocity", { dx: 2, dy: 2 }) + const retrieved = store.single("position", "velocity") + expect(retrieved().get("position")).toEqual(entity.get("position")) + expect(retrieved().get("velocity")).toEqual(newEntity.get("velocity")) + expect(retrieved().get("velocity")).toEqual( + entity.has("velocity") && entity.get("velocity"), + ) + }) + + it("should update single entity reference after creation or removal", () => { + const single = store.single("position") + expect(() => single().get("position")).toThrow() + + let entity = store.create({ position: { x: 0, y: 0 } }) + + expect(() => single().get("position")).not.toThrow() + expect(single().get("position")).toEqual({ x: 0, y: 0 }) + + store.remove(entity) + + expect(() => single().get("position")).toThrow() + + entity = store.create({ position: { x: 1, y: 1 } }) + + expect(() => single().get("position")).not.toThrow() + expect(single().get("position")).toEqual({ x: 1, y: 1 }) + + store.remove(single()) + + expect(() => single().get("position")).toThrow() + }) + + it("should handle multiple components correctly on a single entity", () => { + store.create({ position: { x: 0, y: 0 }, velocity: { dx: 1, dy: 1 } }) + + const multiple = [ + store.multiple("position", "velocity"), + store.multiple("position"), + store.multiple("velocity"), + store.multiple("position", "velocity", "not-health"), + ] + + for (const retrieved of multiple) { + const [one]: readonly Entity[] = retrieved + + expect(retrieved.length).toBe(1) + expect(one.get("position")).toBeDefined() + expect(one.get("velocity")).toBeDefined() + } + + store.create({ position: { x: 0, y: 0 }, velocity: { dx: 1, dy: 1 } }) + + for (const retrieved of multiple) { + const [one]: readonly Entity[] = retrieved + + expect(retrieved.length).toBe(2) + expect(one.get("position")).toBeDefined() + expect(one.get("velocity")).toBeDefined() + } + }) + + it("should handle creation and removal of hundreds of entities with random interactions", () => { + const entities: EntityWith[] = [] + + // Create 500 entities with random components + for (let i = 0; i < 500; i++) { + const entity = store.create({ + position: { x: Math.random() * 100, y: Math.random() * 100 }, + velocity: { dx: Math.random() * 10, dy: Math.random() * 10 }, + health: { hp: Math.floor(Math.random() * 100) }, + }) + entities.push(entity) + } + + // Randomly remove 250 entities + for (let i = 0; i < 250; i++) { + const index = Math.floor(Math.random() * entities.length) + const entity = entities.splice(index, 1)[0] + store.remove(entity) + } + + // Verify the remaining entities + const remainingEntities = store.multiple("position", "velocity", "health") + expect([...remainingEntities].length).toBe(250) + + // Perform random updates on remaining entities + ;[...remainingEntities].forEach(entity => { + if (Math.random() > 0.5) { + entity.set("position", { x: Math.random() * 100, y: Math.random() * 100 }) + } + if (Math.random() > 0.5) { + entity.set("velocity", { dx: Math.random() * 10, dy: Math.random() * 10 }) + } + if (Math.random() > 0.5) { + entity.set("health", { hp: Math.floor(Math.random() * 100) }) + } + }) + + // Verify updates + ;[...remainingEntities].forEach(entity => { + expect(entity.get("position")).toBeDefined() + expect(entity.get("velocity")).toBeDefined() + expect(entity.get("health")).toBeDefined() + }) + }) + + it("should retrieve entities that do not have a specified component", () => { + const _entity1 = store.create({ position: { x: 0, y: 0 }, velocity: { dx: 1, dy: 1 } }) + const entity2 = store.create({ position: { x: 1, y: 1 }, health: { hp: 100 } }) + const _entity3 = store.create({ velocity: { dx: 2, dy: 2 }, health: { hp: 50 } }) + + const retrieved = store.multiple("not-velocity") + const [one] = retrieved + + expect(retrieved.length).toBe(1) + expect(one.has("position") && one.get("position")).toEqual(entity2.get("position")) + expect(one.has("health") && one.get("health")).toEqual(entity2.get("health")) + }) + + it("should retrieve entities with multiple specified components and absence of others", () => { + const _entity1 = store.create({ position: { x: 0, y: 0 }, velocity: { dx: 1, dy: 1 } }) + const entity2 = store.create({ position: { x: 1, y: 1 }, health: { hp: 100 } }) + const _entity3 = store.create({ velocity: { dx: 2, dy: 2 }, health: { hp: 50 } }) + + const retrieved = store.multiple("position", "not-velocity") + const [one] = retrieved + + expect(retrieved.length).toBe(1) + expect(one.get("position")).toEqual(entity2.get("position")) + expect(one.has("health") && one.get("health")).toEqual(entity2.get("health")) + }) + + it("should retrieve a single entity with a specified component and absence of another", () => { + const _entity1 = store.create({ position: { x: 0, y: 0 }, velocity: { dx: 1, dy: 1 } }) + const entity2 = store.create({ position: { x: 1, y: 1 }, health: { hp: 100 } }) + + const retrieved = store.single("position", "not-velocity") + + expect(retrieved().get("position")).toEqual(entity2.get("position")) + + const i1 = retrieved() + expect(i1.has("health") && i1.get("health")).toEqual(entity2.get("health")) + }) + + it("should throw an error when multiple entities match single with a specified component and absence of another", () => { + store.create({ position: { x: 0, y: 0 }, health: { hp: 50 } }) + store.create({ position: { x: 1, y: 1 }, health: { hp: 100 } }) + + const single = store.single("position", "not-velocity") + expect(() => single().get("position")).toThrow() + }) + + it("should throw an error when no entities match single with a specified component and absence of another", () => { + store.create({ velocity: { dx: 1, dy: 1 }, health: { hp: 50 } }) + + const single = store.single("position", "not-velocity") + expect(() => single().get("position")).toThrow() + }) + + it("should allow singles with only negative", () => { + const entity = store.create({ position: { x: 0, y: 0 } }) + + const single = store.single("not-velocity") + + const i1 = single() + expect(i1.has("position") && i1.get("position")).toEqual(entity.get("position")) + + const single2 = store.single("not-health") + const entity2 = store.create({ velocity: { dx: 1, dy: 1 } }) + + expect(() => single2().has("position") && single2().get("position" as never)).toThrow() + expect(() => single2().has("velocity") && single().get("velocity" as never)).toThrow() + + const i3 = single() + expect(i3.has("position") && i3.get("position")).toEqual(entity.get("position")) + + entity.set("health", { hp: 100 }) + + const i4 = single() + expect(i4.has("position") && i4.get("position")).toEqual(entity.get("position")) + + const i5 = single2() + expect(i5.has("velocity") && i5.get("velocity")).toEqual(entity2.get("velocity")) + }) + + it("should handle entity removal and addition within the same operation", () => { + const entity1 = store.create({ position: { x: 0, y: 0 } }) + const _entity2 = store.create({ position: { x: 1, y: 1 } }) + + store.remove(entity1) + const _entity3 = store.create({ position: { x: 2, y: 2 } }) + + const retrieved = store.multiple("position") + expect(retrieved.length).toBe(2) + expect(retrieved[0].get("position")).toEqual({ x: 1, y: 1 }) + expect(retrieved[1].get("position")).toEqual({ x: 2, y: 2 }) + }) + + it("should maintain correct entity order after multiple removals and additions", () => { + const entity1 = store.create({ position: { x: 0, y: 0 } }) + const entity2 = store.create({ position: { x: 1, y: 1 } }) + const _entity3 = store.create({ position: { x: 2, y: 2 } }) + + store.remove(entity2) + const _entity4 = store.create({ position: { x: 3, y: 3 } }) + store.remove(entity1) + const _entity5 = store.create({ position: { x: 4, y: 4 } }) + + const retrieved = store.multiple("position") + expect(retrieved.length).toBe(3) + expect(retrieved[0].get("position")).toEqual({ x: 2, y: 2 }) + expect(retrieved[1].get("position")).toEqual({ x: 3, y: 3 }) + expect(retrieved[2].get("position")).toEqual({ x: 4, y: 4 }) + }) + + it("should handle component removal correctly", () => { + const entity = store.create({ position: { x: 0, y: 0 }, velocity: { dx: 1, dy: 1 } }) + entity.delete("velocity") + + expect(entity.has("position")).toBe(true) + expect(entity.has("velocity")).toBe(false) + expect(() => entity.get("velocity")).toThrow() + }) + + it("should update multiple queries when a component is removed from an entity", () => { + store.create({ position: { x: 0, y: 0 }, velocity: { dx: 1, dy: 1 } }) + const entity2 = store.create({ position: { x: 1, y: 1 }, velocity: { dx: 2, dy: 2 } }) + + const withBoth = store.multiple("position", "velocity") + const withPosition = store.multiple("position") + + expect(withBoth.length).toBe(2) + expect(withPosition.length).toBe(2) + + entity2.delete("velocity") + + expect(withBoth.length).toBe(1) + expect(withPosition.length).toBe(2) + }) + + it("should handle component addition correctly", () => { + const entity = store.create({ position: { x: 0, y: 0 } }) + const entityWithVelocity = entity.set("velocity", { dx: 1, dy: 1 }) + + expect(entity.has("position")).toBe(true) + expect(entity.has("velocity")).toBe(true) + expect(entityWithVelocity.get("velocity")).toEqual({ dx: 1, dy: 1 }) + }) + + it("should update multiple queries when a new component is added to an entity", () => { + store.create({ position: { x: 0, y: 0 }, velocity: { dx: 1, dy: 1 } }) + const entity2 = store.create({ position: { x: 1, y: 1 } }) + + const withBoth = store.multiple("position", "velocity") + const withPosition = store.multiple("position") + + expect(withBoth.length).toBe(1) + expect(withPosition.length).toBe(2) + + entity2.set("velocity", { dx: 2, dy: 2 }) + + expect(withBoth.length).toBe(2) + expect(withPosition.length).toBe(2) + }) + + it("should handle overwriting existing components", () => { + const entity = store.create({ position: { x: 0, y: 0 } }) + entity.set("position", { x: 1, y: 1 }) + + expect(entity.get("position")).toEqual({ x: 1, y: 1 }) + }) + + it("should maintain correct single entity reference after component overwrite", () => { + store.create({ position: { x: 0, y: 0 } }) + const single = store.single("position") + + single().set("position", { x: 1, y: 1 }) + + expect(single().get("position")).toEqual({ x: 1, y: 1 }) + }) + + it("should handle entities with multiple 'not' components correctly", () => { + store.create({ position: { x: 0, y: 0 }, velocity: { dx: 1, dy: 1 } }) + store.create({ position: { x: 1, y: 1 } }) + store.create({ velocity: { dx: 2, dy: 2 } }) + + const retrieved = store.multiple("not-position", "not-velocity") + expect(retrieved.length).toBe(0) + }) + + it("should correctly update queries with 'not' components when components are added or removed", () => { + const entity1 = store.create({ position: { x: 0, y: 0 } }) + store.create({ velocity: { dx: 1, dy: 1 } }) + + const notVelocity = store.multiple("not-velocity") + expect(notVelocity.length).toBe(1) + + entity1.set("velocity", { dx: 2, dy: 2 }) + expect(notVelocity.length).toBe(0) + + entity1.delete("velocity") + expect(notVelocity.length).toBe(1) + }) + + it("should handle complex queries with multiple 'not' and regular components", () => { + store.create({ position: { x: 0, y: 0 }, velocity: { dx: 1, dy: 1 }, health: { hp: 100 } }) + store.create({ position: { x: 1, y: 1 }, health: { hp: 50 } }) + store.create({ velocity: { dx: 2, dy: 2 }, health: { hp: 75 } }) + + const retrieved = store.multiple("position", "health", "not-velocity") + expect(retrieved.length).toBe(1) + expect(retrieved[0].get("position")).toEqual({ x: 1, y: 1 }) + expect(retrieved[0].get("health")).toEqual({ hp: 50 }) + }) + + it("should maintain consistency between single and multiple queries", () => { + store.create({ position: { x: 0, y: 0 } }) + + const single = store.single("position") + const multiple = store.multiple("position") + + expect(single().get("position")).toEqual(multiple[0].get("position")) + + single().set("position", { x: 1, y: 1 }) + + expect(single().get("position")).toEqual(multiple[0].get("position")) + }) + + it("should handle removal of all entities", () => { + const entity1 = store.create({ position: { x: 0, y: 0 } }) + const entity2 = store.create({ position: { x: 1, y: 1 } }) + + store.remove(entity1) + store.remove(entity2) + + const retrieved = store.multiple("position") + expect(retrieved.length).toBe(0) + + const single = store.single("position") + expect(() => single().get("position")).toThrow() + }) + + it("should handle queries with multiple 'not' components and one positive component", () => { + store.create({ position: { x: 0, y: 0 }, velocity: { dx: 1, dy: 1 }, health: { hp: 100 } }) + store.create({ position: { x: 1, y: 1 }, health: { hp: 50 } }) + store.create({ velocity: { dx: 2, dy: 2 }, health: { hp: 75 } }) + + const retrieved = store.multiple("health", "not-position", "not-velocity") + expect(retrieved.length).toBe(0) + }) + + it("should correctly update queries when components are added and removed in rapid succession", () => { + const entity = store.create({ position: { x: 0, y: 0 } }) + const query = store.multiple("position", "velocity") + + expect(query.length).toBe(0) + + entity.set("velocity", { dx: 1, dy: 1 }) + expect(query.length).toBe(1) + + entity.delete("velocity") + expect(query.length).toBe(0) + + entity.set("velocity", { dx: 2, dy: 2 }) + expect(query.length).toBe(1) + }) + + it("should handle queries with all 'not' components", () => { + store.create({ position: { x: 0, y: 0 }, velocity: { dx: 1, dy: 1 } }) + store.create({ health: { hp: 100 } }) + + const retrieved = store.multiple("not-position", "not-velocity", "not-health") + expect(retrieved.length).toBe(0) + }) + + it("should maintain consistency when rapidly creating and removing entities", () => { + const entities = [] + for (let i = 0; i < 1000; i++) { + entities.push(store.create({ position: { x: i, y: i } })) + } + + const query = store.multiple("position") + expect(query.length).toBe(1000) + + for (const entity of entities) { + store.remove(entity) + } + + expect(query.length).toBe(0) + }) + + it("should correctly handle queries with duplicate component specifications", () => { + store.create({ position: { x: 0, y: 0 }, velocity: { dx: 1, dy: 1 } }) + + const retrieved = store.multiple("position", "velocity", "position") + expect(retrieved.length).toBe(1) + }) + + it("should maintain correct entity references when components are added and removed", () => { + const entity = store.create({ position: { x: 0, y: 0 } }) + const query = store.multiple("position", "velocity") + + entity.set("velocity", { dx: 1, dy: 1 }) + expect(query[0]).toBe(entity) + + entity.delete("velocity") + expect(query.length).toBe(0) + }) + + it("should correctly update queries when components are modified", () => { + const entity = store.create({ position: { x: 0, y: 0 } }) + const query = store.multiple("position") + + expect(query.length).toBe(1) + + entity.set("position", { x: 1, y: 1 }) + expect(query.length).toBe(1) + expect(query[0].get("position")).toEqual({ x: 1, y: 1 }) + }) + + it("should handle very large numbers of entities efficiently", () => { + const start = Date.now() + for (let i = 0; i < 100000; i++) { + store.create({ position: { x: i, y: i } }) + } + const end = Date.now() + + const query = store.multiple("position") + expect(query.length).toBe(100000) + expect(end - start).toBeLessThan(1000) // Adjust this threshold as needed + }) + + it("should correctly handle queries with all entities removed and then new ones added", () => { + const entity1 = store.create({ position: { x: 0, y: 0 } }) + const entity2 = store.create({ position: { x: 1, y: 1 } }) + + const query = store.multiple("position") + expect(query.length).toBe(2) + + store.remove(entity1) + store.remove(entity2) + expect(query.length).toBe(0) + + store.create({ position: { x: 2, y: 2 } }) + expect(query.length).toBe(1) + }) + + it("should maintain correct single entity reference when components are modified", () => { + const entity = store.create({ position: { x: 0, y: 0 }, velocity: { dx: 1, dy: 1 } }) + const single = store.single("position", "velocity") + + entity.set("position", { x: 1, y: 1 }) + expect(single().get("position")).toEqual({ x: 1, y: 1 }) + + entity.delete("velocity") + expect(() => single().get("velocity")).toThrow() + }) + + it("should correctly handle removal and immediate re-creation of entities with same components", () => { + const entity = store.create({ position: { x: 0, y: 0 } }) + const query = store.multiple("position") + + expect(query.length).toBe(1) + + store.remove(entity) + expect(query.length).toBe(0) + + store.create({ position: { x: 0, y: 0 } }) + expect(query.length).toBe(1) + }) + + it("should handle edge case of creating an entity with no components", () => { + const entity = store.create({}) + expect(entity).toBeDefined() + + const allQueries = store.multiple() + expect(allQueries).toContain(entity) + }) + + it("should record non- component entities", () => { + store.create({ position: { x: 0, y: 0 } }) + const changing = store.changing("not-velocity") + store.create({ position: { x: 1, y: 1 } }) + + expect(changing().added.length).toBe(2) + }) + + it("should record entities changing correctly", () => { + store.create({ position: { x: 0, y: 0 } }) + const a = store.create({ position: { x: 1, y: 1 }, velocity: { dx: 1, dy: 1 } }) + + const changing = store.changing("position") + const changes = changing() + + const changing2 = store.changing("not-velocity") + + console.log(changes) + + expect(changes.added.length).toBe(2) + expect(changes.removed.length).toBe(0) + expect(changes.added[0].get("position")).toEqual({ x: 0, y: 0 }) + expect(changes.added[1].get("position")).toEqual({ x: 1, y: 1 }) + + const b = store.create({ position: { x: 2, y: 2 } }) + store.create({ position: { x: 3, y: 3 }, velocity: { dx: 3, dy: 3 } }) + + const changes2 = changing2() + + expect(changes2.added.length).toBe(2) + expect(changes2.removed.length).toBe(0) + + const newChanges = changing() + + expect(newChanges.added.length).toBe(2) + expect(newChanges.removed.length).toBe(0) + expect(newChanges.added[0].get("position")).toEqual({ x: 2, y: 2 }) + expect(newChanges.added[1].get("position")).toEqual({ x: 3, y: 3 }) + + store.remove(a) + store.remove(b) + + const removedChanges = changing() + + expect(removedChanges.added.length).toBe(0) + expect(removedChanges.removed.length).toBe(2) + expect(removedChanges.removed[0].get("position")).toEqual({ x: 1, y: 1 }) + expect(removedChanges.removed[1].get("position")).toEqual({ x: 2, y: 2 }) + + const changes22 = changing2() + + expect(changes22.added.length).toBe(0) + expect(changes22.removed.length).toBe(1) + }) + + it("should notify when entities are added that match the requirements", () => { + const addedEntities: Entity[] = [] + const removedEntities: Entity[] = [] + + const unlisten = store.listen( + ["position", "velocity"], + entity => addedEntities.push(entity), + entity => removedEntities.push(entity), + ) + + const entity1 = store.create({ position: { x: 0, y: 0 }, velocity: { dx: 1, dy: 1 } }) + const _entity2 = store.create({ position: { x: 1, y: 1 } }) + store.create({ health: { hp: 100 } }) + + expect(addedEntities.length).toBe(1) + expect(addedEntities[0]).toBe(entity1) + expect(removedEntities.length).toBe(0) + + unlisten() + }) + + it("should notify when entities are removed that match the requirements", () => { + const entity1 = store.create({ position: { x: 0, y: 0 }, velocity: { dx: 1, dy: 1 } }) + const _entity2 = store.create({ position: { x: 1, y: 1 }, velocity: { dx: 2, dy: 2 } }) + + const addedEntities: Entity[] = [] + const removedEntities: Entity[] = [] + + const unlisten = store.listen( + ["position", "velocity"], + entity => addedEntities.push(entity), + entity => removedEntities.push(entity), + ) + + store.remove(entity1) + + expect(addedEntities.length).toBe(2) + expect(removedEntities.length).toBe(1) + expect(removedEntities[0]).toBe(entity1) + + unlisten() + }) + + it("should notify when entities gain or lose components to match the requirements", () => { + const entity = store.create({ position: { x: 0, y: 0 } }) + + const addedEntities: Entity[] = [] + const removedEntities: Entity[] = [] + + const unlisten = store.listen( + ["position", "velocity"], + entity => addedEntities.push(entity), + entity => removedEntities.push(entity), + ) + + entity.set("velocity", { dx: 1, dy: 1 }) + + expect(addedEntities.length).toBe(1) + expect(addedEntities[0]).toBe(entity) + + entity.delete("velocity") + + expect(removedEntities.length).toBe(1) + expect(removedEntities[0]).toBe(entity) + + unlisten() + }) + + it("should handle 'not' requirements correctly", () => { + const addedEntities: Entity[] = [] + const removedEntities: Entity[] = [] + + const unlisten = store.listen( + ["position", "not-velocity"], + entity => addedEntities.push(entity), + entity => removedEntities.push(entity), + ) + + const entity1 = store.create({ position: { x: 0, y: 0 } }) + const _entity2 = store.create({ position: { x: 1, y: 1 }, velocity: { dx: 1, dy: 1 } }) + + expect(addedEntities.length).toBe(1) + expect(addedEntities[0]).toBe(entity1) + + entity1.set("velocity", { dx: 2, dy: 2 }) + + expect(removedEntities.length).toBe(1) + expect(removedEntities[0]).toBe(entity1) + + unlisten() + }) + + it("should stop notifying after unlisten is called", () => { + const addedEntities: Entity[] = [] + const removedEntities: Entity[] = [] + + const unlisten = store.listen( + ["position"], + entity => addedEntities.push(entity), + entity => removedEntities.push(entity), + ) + + store.create({ position: { x: 0, y: 0 } }) + expect(addedEntities.length).toBe(1) + + unlisten() + + store.create({ position: { x: 1, y: 1 } }) + expect(addedEntities.length).toBe(1) // Should not have increased + + const entity = store.create({ position: { x: 2, y: 2 } }) + store.remove(entity) + expect(removedEntities.length).toBe(0) // Should not have increased + }) + + it("should handle multiple listeners on the same requirements", () => { + const added1: Entity[] = [] + const added2: Entity[] = [] + const removed1: Entity[] = [] + const removed2: Entity[] = [] + + const unlisten1 = store.listen( + ["position"], + entity => added1.push(entity), + entity => removed1.push(entity), + ) + + const unlisten2 = store.listen( + ["position"], + entity => added2.push(entity), + entity => removed2.push(entity), + ) + + const entity = store.create({ position: { x: 0, y: 0 } }) + + expect(added1.length).toBe(1) + expect(added2.length).toBe(1) + + store.remove(entity) + + expect(removed1.length).toBe(1) + expect(removed2.length).toBe(1) + + unlisten1() + unlisten2() + }) + + it("should notify for existing entities and new additions", () => { + const entity1 = store.create({ position: { x: 0, y: 0 }, velocity: { dx: 1, dy: 1 } }) + + const addedEntities: Entity[] = [] + const removedEntities: Entity[] = [] + + const unlisten = store.listen( + ["position", "velocity"], + entity => addedEntities.push(entity), + entity => removedEntities.push(entity), + ) + + expect(addedEntities.length).toBe(1) // Notified for existing entity + expect(addedEntities[0]).toBe(entity1) + + const entity2 = store.create({ position: { x: 1, y: 1 }, velocity: { dx: 2, dy: 2 } }) + + expect(addedEntities.length).toBe(2) + expect(addedEntities[1]).toBe(entity2) + + unlisten() + }) + + it("should notify when entities are removed that match the requirements", () => { + const entity1 = store.create({ position: { x: 0, y: 0 }, velocity: { dx: 1, dy: 1 } }) + const _entity2 = store.create({ position: { x: 1, y: 1 }, velocity: { dx: 2, dy: 2 } }) + + const addedEntities: Entity[] = [] + const removedEntities: Entity[] = [] + + const unlisten = store.listen( + ["position", "velocity"], + entity => addedEntities.push(entity), + entity => removedEntities.push(entity), + ) + + addedEntities.length = 0 // Clear initial notifications + + store.remove(entity1) + + expect(addedEntities.length).toBe(0) + expect(removedEntities.length).toBe(1) + expect(removedEntities[0]).toBe(entity1) + + unlisten() + }) + + it("should handle edge case of empty store", () => { + const addedEntities: Entity[] = [] + const removedEntities: Entity[] = [] + + const unlisten = store.listen( + ["position", "velocity"], + entity => addedEntities.push(entity), + entity => removedEntities.push(entity), + ) + + expect(addedEntities.length).toBe(0) + expect(removedEntities.length).toBe(0) + + const entity = store.create({ position: { x: 0, y: 0 }, velocity: { dx: 1, dy: 1 } }) + + expect(addedEntities.length).toBe(1) + expect(addedEntities[0]).toBe(entity) + + unlisten() + }) + + it("should handle entities that partially match requirements", () => { + store.create({ position: { x: 0, y: 0 } }) + + const addedEntities: Entity[] = [] + const removedEntities: Entity[] = [] + + const unlisten = store.listen( + ["position", "velocity"], + entity => addedEntities.push(entity), + entity => removedEntities.push(entity), + ) + + expect(addedEntities.length).toBe(0) + + const entity = store.create({ position: { x: 1, y: 1 }, velocity: { dx: 1, dy: 1 } }) + + expect(addedEntities.length).toBe(1) + expect(addedEntities[0]).toBe(entity) + + unlisten() + }) + + it("should handle component removal making entity match requirements", () => { + const entity = store.create({ + position: { x: 0, y: 0 }, + velocity: { dx: 1, dy: 1 }, + health: { hp: 100 }, + }) + + const addedEntities: Entity[] = [] + const removedEntities: Entity[] = [] + + const unlisten = store.listen( + ["position", "velocity", "not-health"], + entity => addedEntities.push(entity), + entity => removedEntities.push(entity), + ) + + expect(addedEntities.length).toBe(0) + + entity.delete("health") + + expect(addedEntities.length).toBe(1) + expect(addedEntities[0]).toBe(entity) + + unlisten() + }) + + it("should handle component addition making entity no longer match requirements", () => { + const entity = store.create({ position: { x: 0, y: 0 }, velocity: { dx: 1, dy: 1 } }) + + const addedEntities: Entity[] = [] + const removedEntities: Entity[] = [] + + const unlisten = store.listen( + ["position", "velocity", "not-health"], + entity => addedEntities.push(entity), + entity => removedEntities.push(entity), + ) + + expect(addedEntities.length).toBe(1) + addedEntities.length = 0 // Clear initial notification + + entity.set("health", { hp: 100 }) + + expect(removedEntities.length).toBe(1) + expect(removedEntities[0]).toBe(entity) + + unlisten() + }) + + it("should handle multiple listeners with different requirements", () => { + const entity = store.create({ position: { x: 0, y: 0 } }) + + const added1: Entity[] = [] + const added2: Entity[] = [] + + const unlisten1 = store.listen( + ["position"], + entity => added1.push(entity), + () => {}, + ) + const unlisten2 = store.listen( + ["position", "velocity"], + entity => added2.push(entity), + () => {}, + ) + + expect(added1.length).toBe(1) + expect(added2.length).toBe(0) + + entity.set("velocity", { dx: 1, dy: 1 }) + + expect(added1.length).toBe(1) + expect(added2.length).toBe(1) + + unlisten1() + unlisten2() + }) +}) diff --git a/packages/game/src/framework/entity.change.test.ts b/packages/game/src/framework/entity.change.test.ts new file mode 100644 index 0000000..cc8c218 --- /dev/null +++ b/packages/game/src/framework/entity.change.test.ts @@ -0,0 +1,254 @@ +import { beforeEach, describe, expect, it } from "vitest" +import { EntityStore, newEntityStore } from "./entity" + +interface TestComponents { + position: { x: number; y: number } + velocity: { dx: number; dy: number } + health: { current: number; max: number } + tag: string + render: { model: string; texture: string } +} + +let store: EntityStore + +beforeEach(() => { + store = newEntityStore() +}) + +describe("EntityStore changing feature", () => { + it("should track additions correctly", () => { + const changeTracker = store.changing("position") + + store.create({ position: { x: 0, y: 0 } }) + store.create({ position: { x: 1, y: 1 } }) + + const changes = changeTracker() + expect(changes.added.length).toBe(2) + expect(changes.removed.length).toBe(0) + expect(changes.added[0].get("position")).toEqual({ x: 0, y: 0 }) + expect(changes.added[1].get("position")).toEqual({ x: 1, y: 1 }) + + // Subsequent call should return no changes + const secondChanges = changeTracker() + expect(secondChanges.added.length).toBe(0) + expect(secondChanges.removed.length).toBe(0) + }) + + it("should track removals correctly", () => { + const entity1 = store.create({ position: { x: 0, y: 0 } }) + const entity2 = store.create({ position: { x: 1, y: 1 } }) + + const changeTracker = store.changing("position") + changeTracker() // Clear initial additions + + store.remove(entity1) + store.remove(entity2) + + const changes = changeTracker() + expect(changes.added.length).toBe(0) + expect(changes.removed.length).toBe(2) + expect(changes.removed[0].get("position")).toEqual({ x: 0, y: 0 }) + expect(changes.removed[1].get("position")).toEqual({ x: 1, y: 1 }) + }) + + it("should handle component removal correctly", () => { + const entity = store.create({ position: { x: 0, y: 0 }, velocity: { dx: 1, dy: 1 } }) + const changeTracker = store.changing("position", "velocity") + changeTracker() // Clear initial addition + + entity.delete("velocity") + const changes = changeTracker() + expect(changes.added.length).toBe(0) + expect(changes.removed.length).toBe(1) + expect(changes.removed[0].get("position")).toEqual({ x: 0, y: 0 }) + }) + + it("should handle component addition correctly", () => { + const entity = store.create({ position: { x: 0, y: 0 } }) + const changeTracker = store.changing("position", "velocity") + changeTracker() // Clear initial addition + + entity.set("velocity", { dx: 1, dy: 1 }) + const changes = changeTracker() + expect(changes.added.length).toBe(1) + expect(changes.removed.length).toBe(0) + expect(changes.added[0].get("position")).toEqual({ x: 0, y: 0 }) + expect(changes.added[0].get("velocity")).toEqual({ dx: 1, dy: 1 }) + }) + + it("should track changes with multiple components", () => { + const changeTracker = store.changing("position", "velocity", "health") + + store.create({ position: { x: 0, y: 0 } }) + store.create({ velocity: { dx: 1, dy: 1 } }) + store.create({ + position: { x: 1, y: 1 }, + velocity: { dx: 2, dy: 2 }, + health: { current: 100, max: 100 }, + }) + + const changes = changeTracker() + expect(changes.added.length).toBe(1) + expect(changes.removed.length).toBe(0) + expect(changes.added[0].get("position")).toEqual({ x: 1, y: 1 }) + expect(changes.added[0].get("velocity")).toEqual({ dx: 2, dy: 2 }) + expect(changes.added[0].get("health")).toEqual({ current: 100, max: 100 }) + }) + + it("should track changes with 'not' components", () => { + const changeTracker = store.changing("position", "not-velocity") + + const entity1 = store.create({ position: { x: 0, y: 0 } }) + store.create({ position: { x: 1, y: 1 }, velocity: { dx: 1, dy: 1 } }) + + let changes = changeTracker() + expect(changes.added.length).toBe(1) + expect(changes.added[0].get("position")).toEqual({ x: 0, y: 0 }) + + const entity2 = store.create({ position: { x: 2, y: 2 } }) + changes = changeTracker() + expect(changes.added.length).toBe(1) + expect(changes.added[0].get("position")).toEqual({ x: 2, y: 2 }) + + entity1.set("velocity", { dx: 0, dy: 0 }) + entity2.set("velocity", { dx: 0, dy: 0 }) + changes = changeTracker() + expect(changes.removed.length).toBe(2) + expect(changes.removed[0].get("position")).toEqual({ x: 0, y: 0 }) + expect(changes.removed[1].get("position")).toEqual({ x: 2, y: 2 }) + }) + + it("should handle multiple change trackers independently", () => { + const positionTracker = store.changing("position") + const velocityTracker = store.changing("velocity") + + store.create({ position: { x: 0, y: 0 } }) + store.create({ velocity: { dx: 1, dy: 1 } }) + + const positionChanges = positionTracker() + const velocityChanges = velocityTracker() + + expect(positionChanges.added.length).toBe(1) + expect(velocityChanges.added.length).toBe(1) + expect(positionChanges.added[0].get("position")).toEqual({ x: 0, y: 0 }) + expect(velocityChanges.added[0].get("velocity")).toEqual({ dx: 1, dy: 1 }) + + // Subsequent calls should return no changes + expect(positionTracker().added.length).toBe(0) + expect(velocityTracker().added.length).toBe(0) + }) + + it("should handle rapid additions and removals", () => { + const changeTracker = store.changing("position") + + const entity1 = store.create({ position: { x: 0, y: 0 } }) + store.remove(entity1) + const entity2 = store.create({ position: { x: 1, y: 1 } }) + store.remove(entity2) + store.create({ position: { x: 2, y: 2 } }) + + const changes = changeTracker() + expect(changes.added.length).toBe(1) + expect(changes.removed.length).toBe(0) + expect(changes.added[0].get("position")).toEqual({ x: 2, y: 2 }) + }) + + it("should track changes in primitive component types", () => { + const changeTracker = store.changing("tag") + + const entity = store.create({ tag: "player" }) + let changes = changeTracker() + expect(changes.added.length).toBe(1) + expect(changes.added[0].get("tag")).toBe("player") + + entity.set("tag", "enemy") + changes = changeTracker() + expect(changes.added.length).toBe(0) + expect(changes.removed.length).toBe(0) + + entity.delete("tag") + changes = changeTracker() + expect(changes.added.length).toBe(0) + expect(changes.removed.length).toBe(1) + }) + + it("should handle changes in nested component properties", () => { + const changeTracker = store.changing("health") + + const entity = store.create({ health: { current: 100, max: 100 } }) + let changes = changeTracker() + expect(changes.added.length).toBe(1) + + entity.set("health", { current: 80, max: 100 }) + changes = changeTracker() + expect(changes.added.length).toBe(0) + expect(changes.removed.length).toBe(0) + + entity.delete("health") + changes = changeTracker() + expect(changes.added.length).toBe(0) + expect(changes.removed.length).toBe(1) + }) + + it("should handle complex scenarios with multiple trackers", () => { + const positionVelocityTracker = store.changing("position", "velocity") + const healthTracker = store.changing("health") + const renderTracker = store.changing("render") + + const entity1 = store.create({ + position: { x: 0, y: 0 }, + health: { current: 100, max: 100 }, + }) + const entity2 = store.create({ position: { x: 1, y: 1 }, velocity: { dx: 1, dy: 1 } }) + + let pvChanges = positionVelocityTracker() + let hChanges = healthTracker() + expect(pvChanges.added.length).toBe(1) + expect(hChanges.added.length).toBe(1) + + entity1.set("velocity", { dx: 0, dy: 0 }) + entity2.set("health", { current: 80, max: 100 }) + + pvChanges = positionVelocityTracker() + hChanges = healthTracker() + expect(pvChanges.added.length).toBe(1) + expect(hChanges.added.length).toBe(1) + + entity1.delete("health") + entity2.delete("velocity") + + pvChanges = positionVelocityTracker() + hChanges = healthTracker() + expect(pvChanges.removed.length).toBe(1) + expect(hChanges.removed.length).toBe(1) + + const entity3 = store.create({ render: { model: "player", texture: "default" } }) + let rChanges = renderTracker() + expect(rChanges.added.length).toBe(1) + + entity3.set("render", { model: "player_damaged", texture: "damaged" }) + rChanges = renderTracker() + expect(rChanges.added.length).toBe(0) + expect(rChanges.removed.length).toBe(0) + }) + + it("should handle entities that temporarily match and then unmatch", () => { + const tracker = store.changing("position", "velocity") + + const entity = store.create({ position: { x: 0, y: 0 } }) + let changes = tracker() + expect(changes.added.length).toBe(0) + + entity.set("velocity", { dx: 1, dy: 1 }) + changes = tracker() + expect(changes.added.length).toBe(1) + + entity.delete("velocity") + changes = tracker() + expect(changes.removed.length).toBe(1) + + entity.set("velocity", { dx: 2, dy: 2 }) + changes = tracker() + expect(changes.added.length).toBe(1) + }) +}) diff --git a/packages/game/src/framework/entity.complex.test.ts b/packages/game/src/framework/entity.complex.test.ts new file mode 100644 index 0000000..8c7fcfb --- /dev/null +++ b/packages/game/src/framework/entity.complex.test.ts @@ -0,0 +1,230 @@ +import { beforeEach, describe, expect, it } from "vitest" +import { EntityStore, newEntityStore } from "./entity" + +interface AdvancedComponents { + position: { x: number; y: number; z: number } + velocity: { dx: number; dy: number; dz: number } + health: { current: number; max: number } + inventory: { items: string[] } + ai: { state: "idle" | "attack" | "flee" } + render: { model: string; texture: string } +} + +let store: EntityStore + +beforeEach(() => { + store = newEntityStore() +}) + +describe("AdvancedEntityStore", () => { + it("should handle complex entity creation and component interactions", () => { + const entity = store.create({ + position: { x: 0, y: 0, z: 0 }, + health: { current: 100, max: 100 }, + inventory: { items: ["sword", "shield"] }, + }) + + expect(entity.get("position")).toEqual({ x: 0, y: 0, z: 0 }) + expect(entity.get("health")).toEqual({ current: 100, max: 100 }) + expect(entity.get("inventory")).toEqual({ items: ["sword", "shield"] }) + + const updatedEntity = entity + .set("velocity", { dx: 1, dy: 0, dz: 0 }) + .set("ai", { state: "idle" }) + + expect(updatedEntity.get("velocity")).toEqual({ dx: 1, dy: 0, dz: 0 }) + expect(updatedEntity.get("ai")).toEqual({ state: "idle" }) + + const finalEntity = updatedEntity.set("health", { current: 80, max: 100 }) + expect(finalEntity.get("health")).toEqual({ current: 80, max: 100 }) + }) + + it("should handle complex queries with multiple components and negations", () => { + store.create({ + position: { x: 0, y: 0, z: 0 }, + velocity: { dx: 1, dy: 0, dz: 0 }, + health: { current: 100, max: 100 }, + ai: { state: "idle" }, + }) + store.create({ + position: { x: 10, y: 10, z: 10 }, + health: { current: 50, max: 100 }, + inventory: { items: ["potion"] }, + }) + store.create({ + position: { x: 20, y: 20, z: 20 }, + velocity: { dx: 0, dy: 1, dz: 0 }, + render: { model: "player", texture: "default" }, + }) + + const result = store.multiple("position", "health", "not-velocity", "not-ai") + expect(result.length).toBe(1) + expect(result[0].get("position")).toEqual({ x: 10, y: 10, z: 10 }) + expect(result[0].get("health")).toEqual({ current: 50, max: 100 }) + }) + + it("should correctly track changes in complex scenarios", () => { + const changeTracker = store.changing("position", "health", "not-ai") + + const entity1 = store.create({ + position: { x: 0, y: 0, z: 0 }, + health: { current: 100, max: 100 }, + }) + const entity2 = store.create({ + position: { x: 10, y: 10, z: 10 }, + health: { current: 80, max: 100 }, + ai: { state: "idle" }, + }) + + let changes = changeTracker() + expect(changes.added.length).toBe(1) + expect(changes.added[0].get("position")).toEqual({ x: 0, y: 0, z: 0 }) + + entity2.delete("ai") + changes = changeTracker() + expect(changes.added.length).toBe(1) + expect(changes.added[0].get("position")).toEqual({ x: 10, y: 10, z: 10 }) + + entity1.set("ai", { state: "attack" }) + changes = changeTracker() + expect(changes.removed.length).toBe(1) + expect(changes.removed[0].get("position")).toEqual({ x: 0, y: 0, z: 0 }) + }) + + it("should handle rapid component additions and removals", () => { + const entity = store.create({ position: { x: 0, y: 0, z: 0 } }) + const query = store.multiple("position", "velocity", "health") + + expect(query.length).toBe(0) + + entity.set("velocity", { dx: 1, dy: 0, dz: 0 }) + entity.set("health", { current: 100, max: 100 }) + expect(query.length).toBe(1) + + entity.delete("velocity") + expect(query.length).toBe(0) + + entity.set("velocity", { dx: 2, dy: 0, dz: 0 }) + expect(query.length).toBe(1) + + entity.delete("health") + entity.delete("velocity") + expect(query.length).toBe(0) + }) + + it("should maintain consistency with single queries in edge cases", () => { + store.create({ + position: { x: 0, y: 0, z: 0 }, + health: { current: 100, max: 100 }, + }) + + const singleQuery = store.single("position", "health", "not-velocity") + expect(singleQuery().get("position")).toEqual({ x: 0, y: 0, z: 0 }) + + const b = store.create({ + position: { x: 10, y: 10, z: 10 }, + health: { current: 80, max: 100 }, + }) + + expect(() => singleQuery().get("position")).toThrow() + + store.create({ + position: { x: 20, y: 20, z: 20 }, + health: { current: 90, max: 100 }, + velocity: { dx: 1, dy: 1, dz: 1 }, + }) + + store.remove(b) + + expect(singleQuery().get("position")).toEqual({ x: 0, y: 0, z: 0 }) + }) + + it("should handle complex inventory management", () => { + const entity = store.create({ + inventory: { items: ["sword", "shield"] }, + }) + + const inventoryQuery = store.multiple("inventory") + expect(inventoryQuery.length).toBe(1) + expect(inventoryQuery[0].get("inventory").items).toEqual(["sword", "shield"]) + + entity.set("inventory", { items: [...entity.get("inventory").items, "potion"] }) + expect(inventoryQuery[0].get("inventory").items).toEqual(["sword", "shield", "potion"]) + + entity.set("inventory", { + items: entity.get("inventory").items.filter(item => item !== "shield"), + }) + expect(inventoryQuery[0].get("inventory").items).toEqual(["sword", "potion"]) + + entity.delete("inventory") + expect(inventoryQuery.length).toBe(0) + }) + + it("should correctly handle AI state transitions", () => { + const entity = store.create({ + ai: { state: "idle" }, + health: { current: 100, max: 100 }, + }) + + const aiQuery = store.multiple("ai", "health") + expect(aiQuery.length).toBe(1) + expect(aiQuery[0].get("ai").state).toBe("idle") + + entity.set("ai", { state: "attack" }) + expect(aiQuery[0].get("ai").state).toBe("attack") + + entity.set("health", { current: 20, max: 100 }) + entity.set("ai", { state: "flee" }) + expect(aiQuery[0].get("ai").state).toBe("flee") + expect(aiQuery[0].get("health").current).toBe(20) + + entity.delete("ai") + expect(aiQuery.length).toBe(0) + }) + + it("should handle complex render component updates", () => { + const entity = store.create({ + render: { model: "player", texture: "default" }, + }) + + const renderQuery = store.multiple("render") + expect(renderQuery.length).toBe(1) + expect(renderQuery[0].get("render")).toEqual({ model: "player", texture: "default" }) + + entity.set("render", { ...entity.get("render"), texture: "damaged" }) + expect(renderQuery[0].get("render")).toEqual({ model: "player", texture: "damaged" }) + + entity.set("render", { model: "player_low_health", texture: "damaged" }) + expect(renderQuery[0].get("render")).toEqual({ + model: "player_low_health", + texture: "damaged", + }) + + entity.delete("render") + expect(renderQuery.length).toBe(0) + }) + + it("should handle complex component interactions and dependencies", () => { + const entity = store.create({ + position: { x: 0, y: 0, z: 0 }, + velocity: { dx: 1, dy: 0, dz: 0 }, + health: { current: 100, max: 100 }, + ai: { state: "idle" }, + }) + + const complexQuery = store.multiple("position", "velocity", "health", "ai") + expect(complexQuery.length).toBe(1) + + entity.set("health", { current: 50, max: 100 }) + entity.set("ai", { state: "flee" }) + + expect(complexQuery[0].get("health").current).toBe(50) + expect(complexQuery[0].get("ai").state).toBe("flee") + + entity.delete("velocity") + expect(complexQuery.length).toBe(0) + + const newQuery = store.multiple("position", "health", "ai", "not-velocity") + expect(newQuery.length).toBe(1) + }) +}) diff --git a/packages/game/src/framework/entity.ts b/packages/game/src/framework/entity.ts new file mode 100644 index 0000000..3f7100f --- /dev/null +++ b/packages/game/src/framework/entity.ts @@ -0,0 +1,589 @@ +export interface Entity { + get id(): number + + get(component: K): Type[K] + + set( + component: K, + value: Components[K], + ): Entity & Type, Components> + + has( + component: K, + ): this is Entity & Type, Components> + + delete(component: K): Entity, Components> +} + +export type EntityWith = Entity< + Pick, + Components +> + +const ModifierNot = "not-" + +// => keyof Components & "not-" + keyof Components +type ComponentsWithModifier = + | keyof Components + | `${typeof ModifierNot}${keyof Components & string}` + +// filter the result of ComponentsWithModifier so that it is keyof Components +type FilterModifier< + C extends string | number | symbol, + Components extends object, +> = C extends keyof Components ? C : never + +export interface EntityChange { + added: EntityWith[] + removed: EntityWith[] +} + +type Listener[], Components extends object> = ( + entity: EntityWith>, +) => void + +export interface EntityStore { + create(components: Pick): EntityWith + + remove(entity: number | EntityWith): void + + listen[]>( + requirements: [...K], + notifyAdded: Listener, + notifyRemoved: Listener, + ): () => void + + multiple[]>( + ...requirements: K + ): readonly EntityWith>[] + + single[]>( + ...requirements: K + ): () => EntityWith> + + changing[]>( + ...requirements: K + ): () => EntityChange> +} + +export interface EntityStoreScoped extends EntityStore { + clear(): void +} + +class WeakLookup { + private map = new Map>() + private inUse = false + + set(key: K, value: any) { + this.map.set(key, new WeakRef(value)) + } + + get(key: K) { + const value = this.map.get(key) + + if (value === undefined) { + return undefined + } + + const result = value.deref() + + if (result === undefined) { + this.map.delete(key) + } + + return result + } +} + +export function newEntityStore(): EntityStore { + function componentsToKey(components: string[]) { + return components.sort().join(",") + } + + function requirementsSatisfyComponents( + requirements: readonly string[], + components: readonly string[], + ) { + for (const requirement of requirements) { + if (requirement.startsWith(ModifierNot)) { + const forbidden = requirement.slice(ModifierNot.length) + + if (components.includes(forbidden)) { + return false + } + + continue + } + + if (components.includes(requirement) === false) { + return false + } + } + + return true + } + + function populateArcheTypeListener(archeType: EntityArcheType, listener: ListenerBatch) { + archeType.listenerBatches.push(listener) + + for (const requirement of listener.requirements) { + let componentToListener = archeType.componentToListenerBatches.get(requirement) + + if (componentToListener === undefined) { + componentToListener = [] + archeType.componentToListenerBatches.set(requirement, componentToListener) + } + + componentToListener.push(listener) + } + } + + function emplaceEntityArcheType(key: string, components: string[]) { + let archeType = entityArcheTypes.get(key) + + if (archeType === undefined) { + archeType = { + components, + entities: new Map(), + + componentToListenerBatches: new Map(), + listenerBatches: [], + } + + for (const [, listener] of listenerBatches) { + if (requirementsSatisfyComponents(listener.requirements, components)) { + populateArcheTypeListener(archeType, listener) + } + } + + entityArcheTypes.set(key, archeType) + } + + return archeType + } + + // signals that a property changed from existing to not existing or vice versa + function entityPropertyChanged(entity: EntityInternal, from: string, to: string) { + const listenersToRemove = entity.archeType.componentToListenerBatches.get(from) + + if (listenersToRemove) { + for (const listener of listenersToRemove) { + listener.notifyRemoved(entity.facade) + } + } + + entity.archeType.entities.delete(entity.id) + + entity.key = componentsToKey(Object.getOwnPropertyNames(entity.components)) + + entity.archeType = emplaceEntityArcheType( + entity.key, + Object.getOwnPropertyNames(entity.components), + ) + + entity.archeType.entities.set(entity.id, entity) + + const listenersToAdd = entity.archeType.componentToListenerBatches.get(to) + + if (listenersToAdd) { + for (const listener of listenersToAdd) { + listener.notifyAdded(entity.facade) + } + } + } + + interface EntityInternal { + id: number + key: string + + components: Record + archeType: EntityArcheType + + facade: Entity + } + + class EntityList { + private _entities: Entity[] + private _entityToIndex: Map + private _requirements: string[] + + constructor(requirements: string[]) { + this._requirements = requirements + this._entities = [] + this._entityToIndex = new Map() + } + + get entities(): readonly Entity[] { + return this._entities + } + + get requirements(): readonly string[] { + return this._requirements + } + + push(entity: Entity) { + this._entities.push(entity) + this._entityToIndex.set(entity.id, this._entities.length - 1) + } + + remove(entity: Entity) { + const index = this._entityToIndex.get(entity.id) + + if (index === undefined) { + throw new Error("Entity not found in list") + } + + const last = this._entities.pop()! + + if (index !== this._entities.length) { + this._entities[index] = last + this._entityToIndex.set(last.id, index) + } + + this._entityToIndex.delete(entity.id) + } + + has(entity: Entity) { + return this._entityToIndex.has(entity.id) + } + + pop() { + const temp = this._entities + this._entities = [] + this._entityToIndex.clear() + return temp + } + } + + class ChangeListener { + private _added: EntityList + private _removed: EntityList + + constructor(requirements: string[]) { + this._added = new EntityList(requirements) + this._removed = new EntityList(requirements) + } + + get added() { + return this._added.entities + } + + get removed() { + return this._removed.entities + } + + get requirements() { + return this._added.requirements + } + + notifyAdded(entity: Entity) { + if (this._removed.has(entity)) { + this._removed.remove(entity) + } else { + this._added.push(entity) + } + } + + notifyRemoved(entity: Entity) { + if (this._added.has(entity)) { + this._added.remove(entity) + } else { + this._removed.push(entity) + } + } + + pop() { + return { + added: this._added.pop(), + removed: this._removed.pop(), + } + } + } + + class ListenerBatch { + private added: Map[], Components>> + private removed: Map[], Components>> + + constructor(private _requirements: string[]) { + this.added = new Map() + this.removed = new Map() + } + + get requirements(): readonly string[] { + return this._requirements + } + + add( + notifyAdded: Listener[], Components>, + notifyRemoved: Listener[], Components>, + ): symbol { + const symbol = Symbol() + + this.added.set(symbol, notifyAdded) + this.removed.set(symbol, notifyRemoved) + + return symbol + } + + remove(symbol: symbol) { + this.added.delete(symbol) + this.removed.delete(symbol) + } + + notifyAdded(entity: Entity) { + for (const [, listener] of this.added) { + listener(entity) + } + } + + notifyRemoved(entity: Entity) { + for (const [, listener] of this.removed) { + listener(entity) + } + } + } + + interface EntityArcheType { + components: string[] + entities: Map + + componentToListenerBatches: Map + listenerBatches: ListenerBatch[] + } + + let nextEntityId = 0 + const entities = new Map() + + const entityArcheTypes = new Map() + const listenerBatches = new Map() + + function create(base: unknown): EntityWith { + const components = Object.getOwnPropertyNames(base) + const key = componentsToKey(components) + + const entity: EntityInternal = { + id: nextEntityId++, + key, + + components: base as any, + archeType: emplaceEntityArcheType(key, components), + + facade: { + get id() { + return entity.id + }, + get(component: string) { + if (component in entity.components === false) { + throw new Error("Component not found") + } + + return entity.components[component] + }, + set(component: string, value: any) { + const exists = component in entity.components + entity.components[component] = value + + if (exists === false) { + entityPropertyChanged(entity, ModifierNot + component, component) + } + + return entity.facade + }, + has(component: string) { + return component in entity.components + }, + delete(component: string) { + const result = delete entity.components[component] + + if (result) { + entityPropertyChanged(entity, component, ModifierNot + component) + } + + return result + }, + + // very ugly that we have to cast here. but has method + // wont work without it and complexity is manageable + } as any, + } + + for (const listener of entity.archeType.listenerBatches) { + listener.notifyAdded(entity.facade) + } + + entity.archeType.entities.set(entity.id, entity) + entities.set(entity.id, entity) + + return entity.facade + } + + function remove(entity: number | Entity): void { + entity = typeof entity === "object" ? entity.id : entity + + const internalEntity = entities.get(entity) + + if (internalEntity === undefined) { + throw new Error("Entity not found") + } + + for (const listener of internalEntity.archeType.listenerBatches) { + listener.notifyRemoved(internalEntity.facade) + } + + internalEntity.archeType.entities.delete(internalEntity.id) + entities.delete(internalEntity.id) + } + + function listen[]>( + requirements: string[], + notifyAdded: Listener, + notifyRemoved: Listener, + ): () => void { + const key = componentsToKey(requirements) + const existing = listenerBatches.get(key) + + if (existing) { + const symbol = existing.add(notifyAdded, notifyRemoved) + return () => void existing.remove(symbol) + } + + const newListener = new ListenerBatch(requirements) + listenerBatches.set(key, newListener) + const symbol = newListener.add(notifyAdded, notifyRemoved) + + for (const archeType of entityArcheTypes.values()) { + if (requirementsSatisfyComponents(requirements, archeType.components)) { + for (const [, entity] of archeType.entities) { + newListener.notifyAdded(entity.facade) + } + + populateArcheTypeListener(archeType, newListener) + } + } + + return () => void newListener.remove(symbol) + } + + const multiples = new WeakLookup[]>() + + function multiple[]>( + ...requirements: string[] + ): readonly EntityWith>[] { + const key = componentsToKey(requirements) + const existing = multiples.get(key) + + if (existing) { + return existing + } + + const entityList = new EntityList(requirements) + const entityListRef = new WeakRef(entityList) + + const unlisten = listen( + requirements, + entity => { + const entityList = entityListRef.deref() + + if (entityList === undefined) { + unlisten() + return + } + + entityList.push(entity) + }, + entity => { + const result = entityListRef.deref() + + if (result === undefined) { + unlisten() + return + } + + result.remove(entity) + }, + ) + + multiples.set(key, entityList.entities) + + return entityList.entities + } + + const keyToSingle = new WeakLookup() + + function single[]>( + ...requirements: string[] + ): () => EntityWith> { + const key = componentsToKey(requirements) + const existing = keyToSingle.get(key) + + if (existing) { + return existing + } + + multiple(...requirements) + const list = multiples.get(key)! + + function ensureSingleEntity() { + if (list.length === 0) { + throw new Error( + `Single entity with components "${requirements.join(", ")}" does not exist`, + ) + } + + if (list.length > 1) { + throw new Error( + `Single entity with components "${requirements.join(", ")}" is not unique`, + ) + } + + const [x] = list + return x + } + + const newSingle = () => ensureSingleEntity() + + keyToSingle.set(key, newSingle) + + return newSingle + } + + function changing[]>( + ...requirements: string[] + ): () => EntityChange> { + const changeListener = new ChangeListener(requirements) + + const result = () => changeListener.pop() + const resultRef = new WeakRef(result) + + const unlisten = listen( + requirements, + entity => { + if (resultRef.deref() === undefined) { + unlisten() + return + } + + changeListener.notifyAdded(entity) + }, + entity => { + if (resultRef.deref() === undefined) { + unlisten() + return + } + + changeListener.notifyRemoved(entity) + }, + ) + + return result + } + + return { + create, + remove, + listen: listen as any, + multiple: multiple as any, + single: single as any, + changing: changing as any, + } +} diff --git a/packages/game/src/framework/event.ts b/packages/game/src/framework/event.ts new file mode 100644 index 0000000..30b8b0b --- /dev/null +++ b/packages/game/src/framework/event.ts @@ -0,0 +1,41 @@ +export class EventStore { + private listeners: { + [K in keyof Events]?: Events[K][] + } = {} + + constructor() { + this.invoke = {} + } + + listen(events: Partial): () => void { + const keys = Object.keys(events) as (keyof Events)[] + + for (const key of keys) { + if (!this.listeners[key]) { + this.listeners[key] = [] + + const invoker = (...args: any[]) => { + for (const listener of this.listeners[key] as any[]) { + listener(...args) + } + } + + this.invoke[key] = invoker as Events[keyof Events] + } + + if (events[key]) { + this.listeners[key]?.push(events[key]) + } + } + + return () => { + for (const key of keys) { + this.listeners[key] = this.listeners[key]?.filter( + listener => listener !== events[key], + ) + } + } + } + + public invoke: Partial = {} +} diff --git a/packages/game/src/framework/resource.ts b/packages/game/src/framework/resource.ts new file mode 100644 index 0000000..721b291 --- /dev/null +++ b/packages/game/src/framework/resource.ts @@ -0,0 +1,15 @@ +export class ResourceStore { + private resources: Partial = {} + + get(key: K): Resources[K] { + if (!this.resources[key]) { + throw new Error(`Resource ${key.toString()} not loaded`) + } + + return this.resources[key] + } + + set(key: K, value: Resources[K]) { + this.resources[key] = value + } +} diff --git a/packages/game/src/game.ts b/packages/game/src/game.ts new file mode 100644 index 0000000..e820848 --- /dev/null +++ b/packages/game/src/game.ts @@ -0,0 +1,68 @@ +import RAPIER from "@dimforge/rapier2d" +import { WorldConfig } from "../proto/world" +import { GameStore } from "./model/store" +import { ModuleLevel } from "./modules/module-level" +import { ModuleRocket } from "./modules/module-rocket" +import { ModuleShape } from "./modules/module-shape" + +export interface GameConfig { + gamemode: string + world: WorldConfig +} + +export interface GameDependencies { + rapier: typeof RAPIER +} + +export interface GameInput { + rotation: number + thrust: boolean +} + +export class Game { + public store: GameStore + + private moduleLevel: ModuleLevel + private moduleRocket: ModuleRocket + private moduleShape: ModuleShape + + constructor(config: GameConfig, deps: GameDependencies) { + this.store = new GameStore() + + this.insertResources(config, deps) + + this.moduleLevel = new ModuleLevel(this.store) + this.moduleRocket = new ModuleRocket(this.store) + this.moduleShape = new ModuleShape(this.store) + } + + public onUpdate(input: GameInput) { + this.moduleLevel.onUpdate(input) + this.moduleRocket.onUpdate(input) + } + + public onReset() { + this.moduleLevel.onReset() + this.moduleRocket.onReset() + this.moduleShape.onReset() + } + + private insertResources(config: GameConfig, deps: GameDependencies) { + const groups = config.world.gamemodes[config.gamemode].groups.map( + groupName => config.world.groups[groupName], + ) + + const levels = groups.flatMap(group => group.levels) + const rockets = groups.flatMap(group => group.rockets) + const shapes = groups.flatMap(group => group.shapes) + + this.store.resources.set("config", { + world: config.world, + levels, + rocket: rockets[0], + shapes, + }) + + this.store.resources.set("rapier", deps.rapier) + } +} diff --git a/packages/game/src/model/point.ts b/packages/game/src/model/point.ts new file mode 100644 index 0000000..521347d --- /dev/null +++ b/packages/game/src/model/point.ts @@ -0,0 +1,4 @@ +export interface Point { + x: number + y: number +} diff --git a/packages/game/src/model/store.ts b/packages/game/src/model/store.ts new file mode 100644 index 0000000..7d6ab90 --- /dev/null +++ b/packages/game/src/model/store.ts @@ -0,0 +1,48 @@ +import RAPIER from "@dimforge/rapier2d" +import { LevelConfig, RocketConfig, ShapeConfig, WorldConfig } from "../../proto/world" +import { EntityStore, newEntityStore } from "../framework/entity" +import { EventStore } from "../framework/event" +import { ResourceStore } from "../framework/resource" +import { LevelComponent } from "../modules/module-level" +import { RocketComponent } from "../modules/module-rocket" +import { Point } from "./point" + +export class GameStore { + public resources: ResourceStore + public events: EventStore + public entities: EntityStore + + constructor() { + this.resources = new ResourceStore() + this.events = new EventStore() + this.entities = newEntityStore() + } +} + +export interface ConfigsResource { + levels: LevelConfig[] + rocket: RocketConfig + shapes: ShapeConfig[] + world: WorldConfig +} + +export interface GameResources { + config: ConfigsResource + rapier: typeof RAPIER + world: RAPIER.World +} + +export interface GameComponents { + level: LevelComponent + rigid: RAPIER.RigidBody + rocket: RocketComponent +} + +export interface GameEvents { + collision(props: { + started: boolean + + normal: Point + contact: Point + }): void +} diff --git a/packages/game/src/modules/module-level.ts b/packages/game/src/modules/module-level.ts new file mode 100644 index 0000000..da6122a --- /dev/null +++ b/packages/game/src/modules/module-level.ts @@ -0,0 +1,16 @@ +import { EntityWith } from "../framework/entity" +import { GameInput } from "../game" +import { GameComponents, GameStore } from "../model/store" + +export interface LevelComponent {} + +export const levelComponents = ["level", "rigid"] satisfies (keyof GameComponents)[] +export type LevelEntity = EntityWith + +export class ModuleLevel { + constructor(private store: GameStore) {} + + onReset() {} + + onUpdate(_input: GameInput) {} +} diff --git a/packages/game/src/modules/module-rocket.ts b/packages/game/src/modules/module-rocket.ts new file mode 100644 index 0000000..381ca84 --- /dev/null +++ b/packages/game/src/modules/module-rocket.ts @@ -0,0 +1,26 @@ +import { EntityWith } from "../framework/entity" +import { GameInput } from "../game" +import { GameComponents, GameStore } from "../model/store" + +export interface RocketComponent { + thrust: boolean +} + +export const rocketComponents = ["rocket", "rigid"] satisfies (keyof GameComponents)[] +export type RocketEntity = EntityWith + +export class ModuleRocket { + private getRocket: () => RocketEntity + + constructor(private store: GameStore) { + this.getRocket = store.entities.single(...rocketComponents) + } + + onReset() {} + + onUpdate({ thrust }: GameInput) { + const rocket = this.getRocket() + + rocket.get("rocket").thrust = thrust + } +} diff --git a/packages/game/src/modules/module-shape.ts b/packages/game/src/modules/module-shape.ts new file mode 100644 index 0000000..05799d3 --- /dev/null +++ b/packages/game/src/modules/module-shape.ts @@ -0,0 +1,7 @@ +import { GameStore } from "../model/store" + +export class ModuleShape { + constructor(private store: GameStore) {} + + onReset() {} +} diff --git a/packages/game/src/modules/module-world.ts b/packages/game/src/modules/module-world.ts new file mode 100644 index 0000000..e69de29 diff --git a/packages/game/tsconfig.json b/packages/game/tsconfig.json new file mode 100644 index 0000000..6b0d306 --- /dev/null +++ b/packages/game/tsconfig.json @@ -0,0 +1,4 @@ +{ + "extends": "tsconfig/base.json", + "include": ["src", "proto"] +}