fix(01-10): restore tile defaults and stable event snapshots

- Rebuild raw tile event baselines on construction, set, load, and conversion.
- Preserve coordinate-owned point events through dynamic movement and copy save Maps.
This commit is contained in:
unanmed 2026-09-09 00:08:38 +08:00
parent a35e1e116d
commit bcfa1f9467
4 changed files with 426 additions and 35 deletions

View File

@ -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<IDynamicBlockSave> {
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<IDynamicBlockSave>): 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();
}
}
}

View File

@ -50,6 +50,11 @@ export class MapLayer
/** 点事件视图,外层 key = y内层 key = x */
private readonly pointEvents: Map<number, Map<number, ILayerEventView>> =
new Map();
/** 点事件的原始纯基准,外层 key = y内层 key = x */
private readonly pointEventBaselines: Map<
number,
Map<number, ReadonlyMap<number, string>>
> = new Map();
/** 动态图块到其当前坐标的映射 */
private readonly posTileMap: Map<IDynamicTile, ITileLocator> = 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<number, ReadonlyMap<number, string>>();
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<number, ReadonlyMap<number, string>> {
const pointEvents = new Map<number, ReadonlyMap<number, string>>();
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<number, ReadonlyMap<number, string>>
): 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

View File

@ -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<number, Record<number, string>> = {}
) {
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());
});
});

View File

@ -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<IStaticBlockSave> {
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<IStaticBlockSave>): 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();
}
}
}