diff --git a/src/GridCharacter/GridCharacter.test.ts b/src/GridCharacter/GridCharacter.test.ts index 156ebf22..ce0690aa 100644 --- a/src/GridCharacter/GridCharacter.test.ts +++ b/src/GridCharacter/GridCharacter.test.ts @@ -169,6 +169,36 @@ describe("GridCharacter", () => { ); }); + it("should set movement progress", () => { + const { gridCharacter } = createDefaultTilemapMock(); + + expect(gridCharacter.getMovementProgress()).toBe(0); + gridCharacter.setMovementProgress(300); + expect(gridCharacter.getMovementProgress()).toBe(300); + + gridCharacter.setMovementProgress(-10); + expect(gridCharacter.getMovementProgress()).toBe(0); + + gridCharacter.setMovementProgress(10000); + expect(gridCharacter.getMovementProgress()).toBe(1000); + }); + + it("should set collidesWithTiles", () => { + const { gridCharacter } = createDefaultTilemapMock(); + + expect(gridCharacter.collidesWithTiles()).toBe(true); + gridCharacter.setCollidesWithTiles(false); + expect(gridCharacter.collidesWithTiles()).toBe(false); + }); + + it("should set ignoreMissingTiles", () => { + const { gridCharacter } = createDefaultTilemapMock(); + + expect(gridCharacter.getIgnoreMissingTiles()).toBe(false); + gridCharacter.setIgnoreMissingTiles(true); + expect(gridCharacter.getIgnoreMissingTiles()).toBe(true); + }); + it("should update vertically", () => { const { gridCharacter } = createDefaultTilemapMock(); diff --git a/src/GridCharacter/GridCharacter.ts b/src/GridCharacter/GridCharacter.ts index 43d04277..9c14cac6 100644 --- a/src/GridCharacter/GridCharacter.ts +++ b/src/GridCharacter/GridCharacter.ts @@ -119,10 +119,18 @@ export class GridCharacter { return this.collidesWithTilesInternal; } + setCollidesWithTiles(collidesWithTiles: boolean): void { + this.collidesWithTilesInternal = collidesWithTiles; + } + getIgnoreMissingTiles(): boolean { return this.ignoreMissingTiles; } + setIgnoreMissingTiles(ignoreMissingTiles: boolean): void { + this.ignoreMissingTiles = ignoreMissingTiles; + } + setTilePosition(tilePosition: LayerVecPos): void { if (this.isMoving()) { this.movementStopped$.next(this.movementDirection); @@ -362,6 +370,11 @@ export class GridCharacter { return this.movementProgress; } + setMovementProgress(progress: number): void { + const newProgress = Math.max(0, Math.min(MAX_MOVEMENT_PROGRESS, progress)); + this.movementProgress = newProgress; + } + hasWalkedHalfATile(): boolean { return this.movementProgress > MAX_MOVEMENT_PROGRESS / 2; } diff --git a/src/GridCharacter/GridCharacterState.ts b/src/GridCharacter/GridCharacterState.ts new file mode 100644 index 00000000..0fbd3084 --- /dev/null +++ b/src/GridCharacter/GridCharacterState.ts @@ -0,0 +1,13 @@ +import { Direction } from "../Direction/Direction"; +import { CollisionConfig } from "../GridEngineHeadless"; +import { LayerPosition } from "../IGridEngine"; +import { CharId } from "./GridCharacter"; + +export interface GridCharacterState { + id: CharId; + position: LayerPosition; + facingDirection: Direction; + speed: number; + movementProgress: number; + collisionConfig: CollisionConfig; +} diff --git a/src/GridEngine.test.ts b/src/GridEngine.test.ts index b6b0c3c4..8f7d4925 100644 --- a/src/GridEngine.test.ts +++ b/src/GridEngine.test.ts @@ -49,6 +49,7 @@ import { NoPathFoundStrategy } from "./Pathfinding/NoPathFoundStrategy.js"; import { PathBlockedStrategy } from "./Pathfinding/PathBlockedStrategy.js"; import { createSpriteMock } from "./Utils/MockFactory/MockFactory.js"; import { createPhaserTilemapStub } from "./Utils/MockFactory/MockPhaserTilemap.js"; +import { GridEngineState } from "./GridEngineState.js"; describe("GridEngine", () => { let gridEngine: GridEngine; @@ -2042,6 +2043,176 @@ describe("GridEngine", () => { ); }); + it("should get state", () => { + gridEngine.create( + createPhaserTilemapStub(new Map([["someLayer", ["...", "..."]]])), + { + characters: [ + { + id: "char1", + startPosition: { x: 1, y: 0 }, + charLayer: "someLayer", + collides: { + collisionGroups: ["cGroup1"], + collidesWithTiles: true, + ignoreMissingTiles: true, + }, + speed: 1, + }, + { + id: "char2", + startPosition: { x: 2, y: 0 }, + charLayer: "someOtherLayer", + collides: { + collisionGroups: ["cGroup2"], + collidesWithTiles: false, + }, + }, + ], + }, + ); + gridEngine.move("char1", Direction.LEFT); + gridEngine.update(0, 10); + + const want: GridEngineState = { + characters: [ + { + id: "char1", + position: { position: { x: 1, y: 0 }, charLayer: "someLayer" }, + collisionConfig: { + collisionGroups: ["cGroup1"], + collidesWithTiles: true, + ignoreMissingTiles: true, + }, + facingDirection: Direction.LEFT, + speed: 1, + movementProgress: 10, + }, + { + id: "char2", + position: { position: { x: 2, y: 0 }, charLayer: "someOtherLayer" }, + collisionConfig: { + collisionGroups: ["cGroup2"], + collidesWithTiles: false, + ignoreMissingTiles: false, + }, + speed: 4, + movementProgress: 0, + facingDirection: Direction.DOWN, + }, + ], + }; + + expect(gridEngine.getState()).toEqual(want); + }); + + it("should set state", () => { + gridEngine.create( + createPhaserTilemapStub(new Map([["someLayer", ["...", "..."]]])), + { + characters: [ + { + id: "char1", + startPosition: { x: 1, y: 0 }, + charLayer: "someLayer", + collides: { + collisionGroups: ["cGroup1"], + collidesWithTiles: true, + ignoreMissingTiles: true, + }, + speed: 1, + }, + { + id: "char2", + startPosition: { x: 2, y: 0 }, + charLayer: "someOtherLayer", + collides: { + collisionGroups: ["cGroup2"], + collidesWithTiles: false, + }, + }, + ], + }, + ); + + const want: GridEngineState = { + characters: [ + { + id: "char1", + position: { position: { x: 2, y: 3 }, charLayer: "someOtherLayer" }, + collisionConfig: { + collisionGroups: ["cGroup3"], + collidesWithTiles: false, + ignoreMissingTiles: false, + }, + facingDirection: Direction.UP, + speed: 2, + movementProgress: 20, + }, + { + id: "char2", + position: { position: { x: 2, y: 0 }, charLayer: "someOtherLayer" }, + collisionConfig: { + collisionGroups: ["cGroup2"], + collidesWithTiles: false, + ignoreMissingTiles: false, + }, + speed: 4, + movementProgress: 0, + facingDirection: Direction.DOWN, + }, + ], + }; + + gridEngine.setState({ characters: [want.characters[0]] }); + + expect(gridEngine.getState()).toEqual(want); + }); + + it("should not reset tile position if it did not change", () => { + gridEngine.create( + createPhaserTilemapStub(new Map([["someLayer", ["...", "..."]]])), + { + characters: [ + { + id: "char1", + startPosition: { x: 1, y: 0 }, + charLayer: "someLayer", + collides: { + collisionGroups: ["cGroup1"], + collidesWithTiles: true, + ignoreMissingTiles: true, + }, + speed: 1, + }, + ], + }, + ); + + const want: GridEngineState = { + characters: [ + { + id: "char1", + position: { position: { x: 1, y: 0 }, charLayer: "someLayer" }, + collisionConfig: { + collisionGroups: ["cGroup3"], + collidesWithTiles: false, + ignoreMissingTiles: false, + }, + facingDirection: Direction.UP, + speed: 2, + movementProgress: 20, + }, + ], + }; + + const mock = jest.fn(); + gridEngine.positionChangeFinished().subscribe(mock); + + gridEngine.setState({ characters: [want.characters[0]] }); + expect(mock).not.toHaveBeenCalled(); + }); + describe("Error Handling unknown char id", () => { const UNKNOWN_CHAR_ID = "unknownCharId"; diff --git a/src/GridEngine.ts b/src/GridEngine.ts index 460511b7..be82df6b 100644 --- a/src/GridEngine.ts +++ b/src/GridEngine.ts @@ -72,6 +72,7 @@ import { import { TiledTilemap } from "./GridTilemap/TiledTilemap/TiledTilemap.js"; import { TiledLayer } from "./GridTilemap/TiledTilemap/TiledLayer.js"; import { TiledTile } from "./GridTilemap/TiledTilemap/TiledTile.js"; +import { GridEngineState } from "./GridEngineState.js"; export { ArrayTilemap, @@ -930,6 +931,24 @@ export class GridEngine implements IGridEngine { return this.geHeadless.clearEnqueuedMovements(charId); } + /** + * {@inheritDoc IGridEngine.getState} + * + * @category GridEngine + */ + getState(): GridEngineState { + return this.geHeadless.getState(); + } + + /** + * {@inheritDoc IGridEngine.setState} + * + * @category GridEngine + */ + setState(state: GridEngineState): void { + this.geHeadless.setState(state); + } + /** * {@inheritDoc IGridEngine.getTileCost} * diff --git a/src/GridEngineHeadless.test.ts b/src/GridEngineHeadless.test.ts index e8a7c7c4..c3cf8ba0 100644 --- a/src/GridEngineHeadless.test.ts +++ b/src/GridEngineHeadless.test.ts @@ -41,6 +41,7 @@ import { updateLayer, } from "./Utils/MockFactory/MockFactory.js"; import { MockTilemap } from "./Utils/MockFactory/MockTilemap.js"; +import { GridEngineState } from "./GridEngineState.js"; describe("GridEngineHeadless", () => { let gridEngineHeadless: GridEngineHeadless; @@ -1880,6 +1881,200 @@ describe("GridEngineHeadless", () => { ).toBe(true); }); + it("should get state", () => { + gridEngineHeadless.create( + // prettier-ignore + mockBlockMap( + [ + "...", + "...", + ], + "someLayer", + false + ), + { + characters: [ + { + id: "char1", + startPosition: { x: 1, y: 0 }, + charLayer: "someLayer", + collides: { + collisionGroups: ["cGroup1"], + collidesWithTiles: true, + ignoreMissingTiles: true, + }, + speed: 1, + }, + { + id: "char2", + startPosition: { x: 2, y: 0 }, + charLayer: "someOtherLayer", + collides: { + collisionGroups: ["cGroup2"], + collidesWithTiles: false, + }, + }, + ], + }, + ); + gridEngineHeadless.move("char1", Direction.LEFT); + gridEngineHeadless.update(0, 10); + + const want: GridEngineState = { + characters: [ + { + id: "char1", + position: { position: { x: 1, y: 0 }, charLayer: "someLayer" }, + collisionConfig: { + collisionGroups: ["cGroup1"], + collidesWithTiles: true, + ignoreMissingTiles: true, + }, + facingDirection: Direction.LEFT, + speed: 1, + movementProgress: 10, + }, + { + id: "char2", + position: { position: { x: 2, y: 0 }, charLayer: "someOtherLayer" }, + collisionConfig: { + collisionGroups: ["cGroup2"], + collidesWithTiles: false, + ignoreMissingTiles: false, + }, + speed: 4, + movementProgress: 0, + facingDirection: Direction.DOWN, + }, + ], + }; + + expect(gridEngineHeadless.getState()).toEqual(want); + }); + + it("should set state", () => { + gridEngineHeadless.create( + // prettier-ignore + mockBlockMap( + [ + "...", + "...", + ], + "someLayer", + false + ), + { + characters: [ + { + id: "char1", + startPosition: { x: 1, y: 0 }, + charLayer: "someLayer", + collides: { + collisionGroups: ["cGroup1"], + collidesWithTiles: true, + ignoreMissingTiles: true, + }, + speed: 1, + }, + { + id: "char2", + startPosition: { x: 2, y: 0 }, + charLayer: "someOtherLayer", + collides: { + collisionGroups: ["cGroup2"], + collidesWithTiles: false, + }, + }, + ], + }, + ); + + const want: GridEngineState = { + characters: [ + { + id: "char1", + position: { position: { x: 2, y: 3 }, charLayer: "someOtherLayer" }, + collisionConfig: { + collisionGroups: ["cGroup3"], + collidesWithTiles: false, + ignoreMissingTiles: false, + }, + facingDirection: Direction.UP, + speed: 2, + movementProgress: 20, + }, + { + id: "char2", + position: { position: { x: 2, y: 0 }, charLayer: "someOtherLayer" }, + collisionConfig: { + collisionGroups: ["cGroup2"], + collidesWithTiles: false, + ignoreMissingTiles: false, + }, + speed: 4, + movementProgress: 0, + facingDirection: Direction.DOWN, + }, + ], + }; + + gridEngineHeadless.setState({ characters: [want.characters[0]] }); + + expect(gridEngineHeadless.getState()).toEqual(want); + }); + + it("should not reset tile position if it did not change", () => { + gridEngineHeadless.create( + // prettier-ignore + mockBlockMap( + [ + "...", + "...", + ], + "someLayer", + false + ), + { + characters: [ + { + id: "char1", + startPosition: { x: 1, y: 0 }, + charLayer: "someLayer", + collides: { + collisionGroups: ["cGroup3"], + collidesWithTiles: false, + ignoreMissingTiles: false, + }, + speed: 1, + }, + ], + }, + ); + + const want: GridEngineState = { + characters: [ + { + id: "char1", + position: { position: { x: 1, y: 0 }, charLayer: "someLayer" }, + collisionConfig: { + collisionGroups: ["cGroup3"], + collidesWithTiles: false, + ignoreMissingTiles: false, + }, + facingDirection: Direction.UP, + speed: 2, + movementProgress: 20, + }, + ], + }; + + const mock = jest.fn(); + gridEngineHeadless.positionChangeFinished().subscribe(mock); + + gridEngineHeadless.setState({ characters: [want.characters[0]] }); + expect(mock).not.toHaveBeenCalled(); + }); + describe("Error Handling unknown char id", () => { const UNKNOWN_CHAR_ID = "unknownCharId"; beforeEach(() => { diff --git a/src/GridEngineHeadless.ts b/src/GridEngineHeadless.ts index 2741e132..89a6d424 100644 --- a/src/GridEngineHeadless.ts +++ b/src/GridEngineHeadless.ts @@ -57,6 +57,8 @@ import { QueueMovementEntry, Finished as QueueMovementFinished, } from "./Movement/QueueMovement/QueueMovement.js"; +import { GridCharacterState } from "./GridCharacter/GridCharacterState.js"; +import { GridEngineState } from "./GridEngineState.js"; export { CollisionStrategy, @@ -1219,6 +1221,77 @@ export class GridEngineHeadless implements IGridEngine { ); } + /** + * {@inheritDoc IGridEngine.getState} + * + * @category GridEngine + */ + getState(): GridEngineState { + const chars: GridCharacterState[] = []; + if (this.gridCharacters) { + for (const [id, char] of this.gridCharacters.entries()) { + chars.push({ + id, + position: LayerPositionUtils.fromInternal(char.getTilePos()), + facingDirection: char.getFacingDirection(), + speed: char.getSpeed(), + movementProgress: char.getMovementProgress(), + collisionConfig: { + collisionGroups: char.getCollisionGroups(), + collidesWithTiles: char.collidesWithTiles(), + ignoreMissingTiles: char.getIgnoreMissingTiles(), + }, + }); + } + } + return { + characters: chars, + }; + } + + /** + * {@inheritDoc IGridEngine.setState} + * + * @category GridEngine + */ + setState(state: GridEngineState): void { + if (this.gridCharacters) { + for (const charState of state.characters) { + const char = this.gridCharacters.get(charState.id); + if (char) { + const currentTilePos = char.getTilePos(); + if ( + !LayerPositionUtils.equal( + currentTilePos, + LayerPositionUtils.toInternal(charState.position), + ) + ) { + char.setTilePosition( + LayerPositionUtils.toInternal(charState.position), + ); + } + char.setSpeed(charState.speed); + char.turnTowards(charState.facingDirection); + char.turnTowards(charState.facingDirection); + if (charState.collisionConfig.collisionGroups) { + char.setCollisionGroups(charState.collisionConfig.collisionGroups); + } + if (charState.collisionConfig.collidesWithTiles !== undefined) { + char.setCollidesWithTiles( + charState.collisionConfig.collidesWithTiles, + ); + } + if (charState.collisionConfig.ignoreMissingTiles !== undefined) { + char.setIgnoreMissingTiles( + charState.collisionConfig.ignoreMissingTiles, + ); + } + char.setMovementProgress(charState.movementProgress); + } + } + } + } + private charRemoved(charId: string): Observable { if (!this.charRemoved$) throw this.createUninitializedErr(); return this.charRemoved$?.pipe( diff --git a/src/GridEngineState.ts b/src/GridEngineState.ts new file mode 100644 index 00000000..f26b7713 --- /dev/null +++ b/src/GridEngineState.ts @@ -0,0 +1,5 @@ +import { GridCharacterState } from "./GridCharacter/GridCharacterState"; + +export interface GridEngineState { + characters: GridCharacterState[]; +} diff --git a/src/IGridEngine.ts b/src/IGridEngine.ts index 1ca55ae8..57557422 100644 --- a/src/IGridEngine.ts +++ b/src/IGridEngine.ts @@ -14,6 +14,7 @@ import { CharacterFilteringOptions } from "./GridCharacter/CharacterFilter/Chara import { PathfindingOptions } from "./Pathfinding/Pathfinding.js"; import { PositionChange } from "./GridCharacter/GridCharacter.js"; import { ShortestPathAlgorithmType } from "./Pathfinding/ShortestPathAlgorithm.js"; +import { GridEngineState } from "./GridEngineState.js"; export type CharLayer = string | undefined; @@ -636,4 +637,19 @@ export interface IGridEngine { charLayer?: string, srcDirection?: Direction, ): number; + + /** + * Returns the current state of Grid Engine. This is useful for persiting or + * sharing the state. + */ + getState(): GridEngineState; + + /** + * Sets the given state for Grid Engine. Be aware that it will **not** remove + * any characters from Grid Engine. If you want to completely reset the state, + * you should call {@link GridEngineHeadless.create}/{@link GridEngine.create} + * or remove all characters via + * {@link GridEngineHeadless.removeAllCharacters}. + */ + setState(state: GridEngineState): void; }