diff --git a/packages-user/data-base/src/map/dynamicTile.ts b/packages-user/data-base/src/map/dynamicTile.ts index 2253a81..1b5791f 100644 --- a/packages-user/data-base/src/map/dynamicTile.ts +++ b/packages-user/data-base/src/map/dynamicTile.ts @@ -42,6 +42,21 @@ export class DynamicTile } else { this.tileRaw = data; } + this.restoreDefaultEvents(); + } + + /** + * 根据当前图块原始数据恢复默认事件并建立干净基准 + */ + private restoreDefaultEvents(): void { + this.tileEvent().clear(); + const data = this.raw(); + if (data) { + for (const [priority, id] of Object.entries(data.events)) { + this.tileEvent().set(Number(priority), id); + } + } + this.tileEvent().markPure(); } num(): number { @@ -61,6 +76,7 @@ export class DynamicTile } else { this.tileRaw = data; } + this.restoreDefaultEvents(); } setPos(x: number, y: number): void { @@ -98,26 +114,27 @@ export class DynamicTile } saveState(): Readonly { - const save: IDynamicBlockSave = { - num: this.num() - }; - - if (this.triggers) { - save.triggers = this.triggers; + let save: IDynamicBlockSave; + if (this.tileEvent().dirty()) { + save = { + num: this.num(), + events: new Map(this.tileEvent().get()) + }; + } else { + save = { + num: this.num() + }; } - return save; } loadState(save: Readonly): void { - if (save.triggers) { - if (save.triggers.size === 0) { - this.useEmptyTrigger(); - } else { - save.triggers.forEach(v => this.addTrigger(v)); + this.restoreDefaultEvents(); + if (save.events) { + this.tileEvent().clear(); + for (const [priority, id] of save.events) { + this.tileEvent().set(priority, id); } - } else { - this.clearTrigger(); } } } diff --git a/packages-user/data-base/src/map/mapLayer.ts b/packages-user/data-base/src/map/mapLayer.ts index 1311952..6fa0d62 100644 --- a/packages-user/data-base/src/map/mapLayer.ts +++ b/packages-user/data-base/src/map/mapLayer.ts @@ -50,6 +50,11 @@ export class MapLayer /** 点事件视图,外层 key = y,内层 key = x */ private readonly pointEvents: Map> = new Map(); + /** 点事件的原始纯基准,外层 key = y,内层 key = x */ + private readonly pointEventBaselines: Map< + number, + Map> + > = new Map(); /** 动态图块到其当前坐标的映射 */ private readonly posTileMap: Map = new Map(); /** 图层脏标记 */ @@ -131,14 +136,120 @@ export class MapLayer private syncStaticEvent(tile: IDynamicTile, keepEvent: boolean): void { const staticTile = this.getTile(tile.x, tile.y); if (!staticTile) return; - staticTile.tileEvent().clear(); + staticTile.set(staticTile.num()); if (keepEvent) { + staticTile.tileEvent().clear(); for (const [priority, id] of tile.tileEvent().get()) { staticTile.tileEvent().set(priority, id); } } } + /** + * 记录尚未保存过的点事件纯基准 + * @param x 点事件横坐标 + * @param y 点事件纵坐标 + * @param eventView 点事件视图 + */ + private capturePointEventBaseline( + x: number, + y: number, + eventView: ILayerEventView + ): void { + const yMap = this.pointEventBaselines.get(y); + if (yMap?.has(x)) return; + if (eventView.dirty()) return; + const target = yMap ?? new Map>(); + target.set(x, new Map(eventView.get())); + if (!yMap) this.pointEventBaselines.set(y, target); + } + + /** + * 将所有已创建的点事件视图恢复到原始纯基准 + */ + private resetPointEvents(): void { + for (const [y, xMap] of this.pointEvents) { + for (const [x, eventView] of xMap) { + this.capturePointEventBaseline(x, y, eventView); + } + } + for (const [y, xMap] of this.pointEvents) { + for (const [x, eventView] of xMap) { + eventView.clear(); + const baseline = this.pointEventBaselines.get(y)?.get(x); + if (baseline) { + for (const [priority, id] of baseline) { + eventView.set(priority, id); + } + } + eventView.markPure(); + } + } + } + + /** + * 裁剪超出新图层范围的点事件及其纯基准 + * @param width 新图层宽度 + * @param height 新图层高度 + */ + private cropPointEvents(width: number, height: number): void { + for (const [y, xMap] of this.pointEvents) { + if (y >= height) { + this.pointEvents.delete(y); + continue; + } + for (const x of xMap.keys()) { + if (x >= width) xMap.delete(x); + } + if (xMap.size === 0) this.pointEvents.delete(y); + } + for (const [y, xMap] of this.pointEventBaselines) { + if (y >= height) { + this.pointEventBaselines.delete(y); + continue; + } + for (const x of xMap.keys()) { + if (x >= width) xMap.delete(x); + } + if (xMap.size === 0) this.pointEventBaselines.delete(y); + } + } + + /** + * 收集需要保存的点事件并复制其内部 Map + */ + private savePointEvents(): Map> { + const pointEvents = new Map>(); + for (const [y, xMap] of this.pointEvents) { + for (const [x, eventView] of xMap) { + if (!eventView.dirty()) continue; + const index = this.map.indexer.locToIndex(x, y); + pointEvents.set(index, new Map(eventView.get())); + } + } + return pointEvents; + } + + /** + * 在图层基准上叠加点事件存档 + * @param save 点事件存档,可省略 + */ + private loadPointEvents( + save?: ReadonlyMap> + ): void { + this.resetPointEvents(); + if (!save) return; + for (const [index, events] of save) { + const { x, y } = this.map.indexer.indexToLocator(index); + const eventView = this.event(x, y); + if (!eventView) continue; + eventView.clear(); + for (const [priority, id] of events) { + eventView.set(priority, id); + } + } + } + //#region 静态图层操作 inMap(x: number, y: number): boolean { @@ -321,6 +432,7 @@ export class MapLayer const tile = new DynamicTile(num, x, y, this); const location = this.getLocationData(x, y); if (location) { + tile.tileEvent().clear(); for (const [priority, id] of location.static.tileEvent().get()) { tile.tileEvent().set(priority, id); } @@ -479,6 +591,8 @@ export class MapLayer if (!eventView) { eventView = new LayerEventView(); xMap.set(x, eventView); + } else { + this.capturePointEventBaseline(x, y, eventView); } return eventView; } @@ -501,7 +615,13 @@ export class MapLayer } dirty(): boolean { - return this.layerDirty; + if (this.layerDirty) return true; + for (const xMap of this.pointEvents.values()) { + for (const eventView of xMap.values()) { + if (eventView.dirty()) return true; + } + } + return false; } markDirty(dirty: boolean): void { @@ -535,6 +655,7 @@ export class MapLayer return; } this.layerDirty = true; + this.cropPointEvents(width, height); this.mapData.expired = true; const before = this.mapArray; const beforeWidth = this.width; @@ -571,6 +692,8 @@ export class MapLayer resize2(width: number, height: number): void { this.layerDirty = true; + this.pointEvents.clear(); + this.pointEventBaselines.clear(); if (this.width === width && this.height === height) { this.empty = true; this.mapArray.fill(0); @@ -655,7 +778,8 @@ export class MapLayer height: this.height, fullMap: new Uint32Array(this.mapArray), staticBlocks: this.saveStatics(), - dynamicBlocks: this.saveDynamics() + dynamicBlocks: this.saveDynamics(), + pointEvents: this.savePointEvents() }; } @@ -671,14 +795,16 @@ export class MapLayer height: this.height, fullMap: new Uint32Array(this.mapArray), staticBlocks, - dynamicBlocks + dynamicBlocks, + pointEvents: this.savePointEvents() }; } else { return { width: this.width, height: this.height, staticBlocks, - dynamicBlocks + dynamicBlocks, + pointEvents: this.savePointEvents() }; } } @@ -696,7 +822,8 @@ export class MapLayer height: this.height, rows: this.diffRows(this.refArray), staticBlocks, - dynamicBlocks + dynamicBlocks, + pointEvents: this.savePointEvents() }; } else { return { @@ -704,7 +831,8 @@ export class MapLayer height: this.height, fullMap: new Uint32Array(this.mapArray), staticBlocks, - dynamicBlocks + dynamicBlocks, + pointEvents: this.savePointEvents() }; } } else { @@ -712,7 +840,8 @@ export class MapLayer width: this.width, height: this.height, staticBlocks, - dynamicBlocks + dynamicBlocks, + pointEvents: this.savePointEvents() }; } } @@ -833,6 +962,7 @@ export class MapLayer } else { this.loadNoCompression(save); } + this.loadPointEvents(save.pointEvents); } //#endregion diff --git a/packages-user/data-base/src/map/mapLifecycle.test.ts b/packages-user/data-base/src/map/mapLifecycle.test.ts new file mode 100644 index 0000000..d7019cc --- /dev/null +++ b/packages-user/data-base/src/map/mapLifecycle.test.ts @@ -0,0 +1,228 @@ +import { beforeAll, describe, expect, it, vi } from 'vitest'; + +interface TestModules { + MapState: typeof import('./mapState').MapState; + TileStore: typeof import('@user/data-common').TileStore; + RoleFaceBinder: typeof import('@user/data-common').RoleFaceBinder; + FaceManager: typeof import('@user/data-common').FaceManager; + Dir8FaceHandler: typeof import('@user/data-common').Dir8FaceHandler; + SaveCompression: typeof import('@user/data-common').SaveCompression; +} + +let modules: TestModules; + +beforeAll(async () => { + vi.stubGlobal('main', { replayChecking: true }); + vi.stubGlobal('location', { origin: 'http://localhost' }); + const mapModule = await import('./mapState'); + const commonModule = await import('@user/data-common'); + modules = { + MapState: mapModule.MapState, + TileStore: commonModule.TileStore, + RoleFaceBinder: commonModule.RoleFaceBinder, + FaceManager: commonModule.FaceManager, + Dir8FaceHandler: commonModule.Dir8FaceHandler, + SaveCompression: commonModule.SaveCompression + }; +}); + +function createMapState( + pointEvents: Record> = {} +) { + const tileStore = new modules.TileStore(); + tileStore.addTile({ + num: 1, + id: 'base', + events: { 10: 'base-event' }, + type: 0, + pass: { onlyEvents: false, outPass: 15, inPass: 15 }, + eventPass: true + }); + tileStore.addTile({ + num: 2, + id: 'alternate', + events: { 20: 'alternate-event' }, + type: 0, + pass: { onlyEvents: false, outPass: 15, inPass: 15 }, + eventPass: true + }); + const faceManager = new modules.FaceManager(); + faceManager.register(1, new modules.Dir8FaceHandler()); + const state = { + tileStore, + itemStore: {}, + mapStore: {}, + eventStore: {}, + roleFace: new modules.RoleFaceBinder(), + faceManager, + saveSystem: {} + }; + const mapState = new modules.MapState(tileStore, state); + const map = mapState.fromRaw({ + floorId: 'F1', + width: 2, + map: { 0: [1, 1, 1, 1] }, + layerAlias: { 0: 'event' }, + events: { 0: pointEvents } + }); + return { map: map!, layer: map!.getLayerByAlias('event')! }; +} + +describe('MapLayer tile defaults snapshots conversion and movement', () => { + it('tile defaults snapshots conversion and movement', () => { + const { layer } = createMapState(); + const staticTile = layer.getTile(0, 0)!; + expect(staticTile.tileEvent().get()).toEqual( + new Map([[10, 'base-event']]) + ); + expect(staticTile.tileEvent().dirty()).toBe(false); + + staticTile.set(2); + expect(staticTile.tileEvent().get()).toEqual( + new Map([[20, 'alternate-event']]) + ); + expect(staticTile.tileEvent().dirty()).toBe(false); + + const dynamicTile = layer.createDynamic(1, 1, 0); + expect(dynamicTile.tileEvent().get()).toEqual( + new Map([[10, 'base-event']]) + ); + dynamicTile.set(2); + expect(dynamicTile.tileEvent().get()).toEqual( + new Map([[20, 'alternate-event']]) + ); + expect(dynamicTile.tileEvent().dirty()).toBe(false); + + const cleanSave = dynamicTile.saveState(); + expect(cleanSave.events).toBeUndefined(); + dynamicTile.loadState(cleanSave); + expect(dynamicTile.tileEvent().get()).toEqual( + new Map([[20, 'alternate-event']]) + ); + expect(dynamicTile.tileEvent().dirty()).toBe(false); + + dynamicTile.tileEvent().set(30, 'override-event'); + const overrideSave = dynamicTile.saveState(); + const savedEvents = new Map(overrideSave.events!); + dynamicTile.tileEvent().set(30, 'changed-after-save'); + expect(overrideSave.events).toEqual(savedEvents); + dynamicTile.loadState(overrideSave); + expect(dynamicTile.tileEvent().get()).toEqual( + new Map([ + [20, 'alternate-event'], + [30, 'override-event'] + ]) + ); + expect(dynamicTile.tileEvent().dirty()).toBe(true); + + staticTile.set(1); + const discardDynamic = layer.transferToDynamic(0, 0)!; + discardDynamic.tileEvent().set(30, 'discarded-event'); + const restoredStatic = layer.transferToStatic(discardDynamic, false)!; + expect(restoredStatic.tileEvent().get()).toEqual( + new Map([[10, 'base-event']]) + ); + expect(restoredStatic.tileEvent().dirty()).toBe(false); + + const keptDynamic = layer.transferToDynamic(1, 0)!; + keptDynamic.tileEvent().set(30, 'kept-event'); + const keptStatic = layer.transferToStatic(keptDynamic, true)!; + expect(keptStatic.tileEvent().get()).toEqual( + new Map([ + [10, 'base-event'], + [30, 'kept-event'] + ]) + ); + expect(keptStatic.tileEvent().dirty()).toBe(true); + + const point = layer.event(0, 1)!; + point.set(5, 'point-event'); + point.markPure(); + layer.getPointEvent(0, 1); + const moving = layer.transferToDynamic(0, 1)!; + moving.setPos(1, 1); + expect(layer.getPointEvent(0, 1)).toEqual( + new Map([[5, 'point-event']]) + ); + expect(layer.getPointEvent(1, 1)).toEqual(new Map()); + }); +}); + +describe('MapLayer point event lifecycle', () => { + it('point event lifecycle', () => { + const { layer } = createMapState({ 1: { 5: 'raw-point' } }); + const point = layer.event(1, 0)!; + expect(point.dirty()).toBe(false); + + point.set(7, 'runtime-point'); + expect(layer.dirty()).toBe(true); + point.delete(7); + expect(point.dirty()).toBe(false); + point.set(5, 'changed-point'); + expect(point.dirty()).toBe(true); + point.set(5, 'raw-point'); + expect(point.dirty()).toBe(false); + point.clear(); + expect(point.dirty()).toBe(true); + point.set(5, 'raw-point'); + expect(point.dirty()).toBe(false); + + const compressionLevels = [ + modules.SaveCompression.NoCompression, + modules.SaveCompression.LowCompression, + modules.SaveCompression.HighCompression + ]; + for (const compression of compressionLevels) { + point.set(7, 'saved-point'); + const save = layer.saveState(compression); + expect(save.pointEvents?.get(1)).toEqual( + new Map([ + [5, 'raw-point'], + [7, 'saved-point'] + ]) + ); + point.set(7, 'changed-after-save'); + layer.loadState(save, compression); + expect(point.get()).toEqual( + new Map([ + [5, 'raw-point'], + [7, 'saved-point'] + ]) + ); + expect(point.dirty()).toBe(true); + point.set(8, 'after-load'); + expect(save.pointEvents?.get(1)).toEqual( + new Map([ + [5, 'raw-point'], + [7, 'saved-point'] + ]) + ); + point.delete(8); + } + + point.set(8, 'runtime-only'); + layer.loadState( + { + width: 2, + height: 2, + fullMap: new Uint32Array([1, 1, 1, 1]) + }, + modules.SaveCompression.NoCompression + ); + expect(point.get()).toEqual(new Map([[5, 'raw-point']])); + expect(point.dirty()).toBe(false); + }); + + it('resize preserves in-range point events and resize2 clears them', () => { + const { layer } = createMapState({ + 0: { 1: 'top-left' }, + 3: { 1: 'bottom-right' } + }); + layer.resize(1, 1); + expect(layer.getPointEvent(0, 0)).toEqual(new Map([[1, 'top-left']])); + expect(layer.getPointEvent(1, 1)).toBeNull(); + + layer.resize2(1, 1); + expect(layer.getPointEvent(0, 0)).toEqual(new Map()); + }); +}); diff --git a/packages-user/data-base/src/map/staticTile.ts b/packages-user/data-base/src/map/staticTile.ts index 94695be..5cc82e2 100644 --- a/packages-user/data-base/src/map/staticTile.ts +++ b/packages-user/data-base/src/map/staticTile.ts @@ -13,6 +13,21 @@ export class StaticTile { constructor(x: number, y: number, layer: IMapLayer) { super(x, y, layer); + this.restoreDefaultEvents(); + } + + /** + * 根据当前图块原始数据恢复默认事件并建立干净基准 + */ + private restoreDefaultEvents(): void { + this.tileEvent().clear(); + const data = this.raw(); + if (data) { + for (const [priority, id] of Object.entries(data.events)) { + this.tileEvent().set(Number(priority), id); + } + } + this.tileEvent().markPure(); } num(): number { @@ -25,6 +40,7 @@ export class StaticTile set(num: number): void { this.layer.setBlock(num, this.locator.x, this.locator.y); + this.restoreDefaultEvents(); } toDynamic(): IDynamicTile { @@ -32,28 +48,28 @@ export class StaticTile } shouldSave(): boolean { - return !!this.triggers; + return this.tileEvent().dirty(); } saveState(): Readonly { - const save: IStaticBlockSave = {}; - - if (this.triggers) { - save.triggers = this.triggers; + let save: IStaticBlockSave; + if (this.tileEvent().dirty()) { + save = { + events: new Map(this.tileEvent().get()) + }; + } else { + save = {}; } - return save; } loadState(save: Readonly): void { - if (save.triggers) { - if (save.triggers.size === 0) { - this.useEmptyTrigger(); - } else { - save.triggers.forEach(v => this.addTrigger(v)); + this.restoreDefaultEvents(); + if (save.events) { + this.tileEvent().clear(); + for (const [priority, id] of save.events) { + this.tileEvent().set(priority, id); } - } else { - this.clearTrigger(); } } }