From 78f67969157b27c5eaaa2663918a0b9948b1d041 Mon Sep 17 00:00:00 2001 From: unanmed <1319491857@qq.com> Date: Thu, 17 Sep 2026 13:53:46 +0800 Subject: [PATCH] fix(07-11): #06-17-6 preserve follower instances across loadState MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - HeroFollowersController 新增 restoreFollowers 保留式恢复入口 - 同索引同图块数字原地 loadState 并保留实例;否则替换槽位并按既有语义触发增删钩子 - 存档中不存在的末尾跟随者按存档为准删除 - HeroState.loadState 改用 restoreFollowers,不再 removeAllFollowers 全量重建(保持同步) - IHeroFollowersController 补声明与 jsDoc;新增同实例/替换/删除/补齐回归用例 --- .../data-base/src/hero/follower.test.ts | 101 ++++++++++++++++++ packages-user/data-base/src/hero/follower.ts | 47 +++++++- .../data-base/src/hero/saveLoad.test.ts | 49 +++++++++ packages-user/data-base/src/hero/state.ts | 7 +- packages-user/data-base/src/hero/types.ts | 13 +++ 5 files changed, 210 insertions(+), 7 deletions(-) diff --git a/packages-user/data-base/src/hero/follower.test.ts b/packages-user/data-base/src/hero/follower.test.ts index 329c0e0..1c8e15d 100644 --- a/packages-user/data-base/src/hero/follower.test.ts +++ b/packages-user/data-base/src/hero/follower.test.ts @@ -5,6 +5,7 @@ import { Dir8FaceHandler, FaceDirection, ItemStore, + SaveCompression, TileStore, TileType } from '@user/data-common'; @@ -266,3 +267,103 @@ describe('HeroFollowersController gathering', () => { expect(result.ret.num).toBe(0); }); }); + +describe('HeroFollowersController same-reference restore (#06-17-6)', () => { + // 验证同索引同图块数字的跟随者在原地读档,且不触发增删钩子 + it('keeps the follower instance when the index and number match', () => { + const env = createController(); + const added: number[] = []; + const removed: number[] = []; + env.controller + .addHook({ + onAddFollower: (_follower, index) => added.push(index), + onRemoveFollower: (_follower, index) => removed.push(index) + }) + .load(); + const follower = env.controller.addFollower(100); + follower.location.setPos(2, 3); + follower.rendering.setAlpha(0.5); + + const saved = [follower.saveState(SaveCompression.NoCompression)]; + follower.location.setPos(9, 9); + follower.rendering.setAlpha(1); + + env.controller.restoreFollowers(saved, SaveCompression.NoCompression); + + expect(env.controller.getFollower(0)).toBe(follower); + expect(follower.location.x).toBe(2); + expect(follower.location.y).toBe(3); + expect(follower.rendering.alpha).toBe(0.5); + expect(added).toEqual([0]); + expect(removed).toEqual([]); + }); + + // 验证同索引但图块数字不同的槽位替换实例并通知移除与新增钩子 + it('replaces a slot with a different number and notifies both hooks', () => { + const env = createController(); + const added: number[] = []; + const removed: number[] = []; + env.controller + .addHook({ + onAddFollower: (_follower, index) => added.push(index), + onRemoveFollower: (_follower, index) => removed.push(index) + }) + .load(); + const follower = env.controller.addFollower(100); + const save = follower.saveState(SaveCompression.NoCompression); + + env.controller.restoreFollowers( + [{ ...save, num: 101 }], + SaveCompression.NoCompression + ); + + expect(env.controller.getFollower(0)).not.toBe(follower); + expect(env.controller.getFollower(0)!.num).toBe(101); + expect(added).toEqual([0, 0]); + expect(removed).toEqual([0]); + }); + + // 验证存档短于现有列表时末尾跟随者被删除并通知移除钩子 + it('deletes trailing followers absent from the save', () => { + const env = createController(); + const removed: number[] = []; + env.controller + .addHook({ + onRemoveFollower: (_follower, index) => removed.push(index) + }) + .load(); + const first = env.controller.addFollower(100); + env.controller.addFollower(100); + + env.controller.restoreFollowers( + [first.saveState(SaveCompression.NoCompression)], + SaveCompression.NoCompression + ); + + expect(env.controller.getAllFollowers()).toEqual([first]); + expect(removed).toEqual([1]); + }); + + // 验证存档长于现有列表时按索引补齐新跟随者并通知新增钩子 + it('appends followers missing from the live list', () => { + const env = createController(); + const added: number[] = []; + env.controller + .addHook({ + onAddFollower: (_follower, index) => added.push(index) + }) + .load(); + const first = env.controller.addFollower(100); + const save = first.saveState(SaveCompression.NoCompression); + + env.controller.restoreFollowers( + [save, { ...save }], + SaveCompression.NoCompression + ); + + const all = env.controller.getAllFollowers(); + expect(all[0]).toBe(first); + expect(all.length).toBe(2); + expect(added).toEqual([0, 1]); + }); +}); diff --git a/packages-user/data-base/src/hero/follower.ts b/packages-user/data-base/src/hero/follower.ts index ad553f2..16e0fe9 100644 --- a/packages-user/data-base/src/hero/follower.ts +++ b/packages-user/data-base/src/hero/follower.ts @@ -113,13 +113,21 @@ export class HeroFollowersController return new HookController(this, hook); } - addFollower(num: number | string): IHeroFollower { + /** + * 在勇士当前位置创建一个跟随者对象,此时不加入跟随者列表 + * @param num 跟随者的图块数字或图块 id + */ + private createFollower(num: number | string): HeroFollower { const loc: IFacedTileLocator = { x: this.heroLocation.x, y: this.heroLocation.y, direction: this.heroLocation.mover.faceDirection }; - const follower = new HeroFollower(num, loc, this.faceHandler, this); + return new HeroFollower(num, loc, this.faceHandler, this); + } + + addFollower(num: number | string): IHeroFollower { + const follower = this.createFollower(num); this.followers.push(follower); const index = this.followers.length - 1; this.forEachHook(hook => { @@ -128,6 +136,41 @@ export class HeroFollowersController return follower; } + restoreFollowers( + saves: readonly IHeroFollowerSave[], + compression: SaveCompression + ): void { + // 以存档为准:先删除末尾多出的跟随者,并按原索引通知移除 + const extra = this.followers.splice(saves.length); + for (let i = 0; i < extra.length; i++) { + const index = saves.length + i; + this.forEachHook(hook => { + hook.onRemoveFollower?.(extra[i], index); + }); + } + for (let i = 0; i < saves.length; i++) { + const save = saves[i]; + const current = this.followers[i]; + // 索引相同且图块数字相同:在保留的实例上原地读档,此时不触发增删钩子 + if (current && current.num === save.num) { + current.loadState(save, compression); + continue; + } + // 索引不同或图块数字不同:该槽位替换为新实例,并按既有钩子语义通知增删 + if (current) { + this.forEachHook(hook => { + hook.onRemoveFollower?.(current, i); + }); + } + const follower = this.createFollower(save.num); + follower.loadState(save, compression); + this.followers[i] = follower; + this.forEachHook(hook => { + hook.onAddFollower?.(follower, i); + }); + } + } + getFollower(index: number): IHeroFollower | null { return this.followers[index] ?? null; } diff --git a/packages-user/data-base/src/hero/saveLoad.test.ts b/packages-user/data-base/src/hero/saveLoad.test.ts index c1f8995..a435719 100644 --- a/packages-user/data-base/src/hero/saveLoad.test.ts +++ b/packages-user/data-base/src/hero/saveLoad.test.ts @@ -204,6 +204,15 @@ function createHeroState(): IHeroState { ); } +/** 构造一个已注册跟随者图块、可直接添加跟随者的勇士状态对象 */ +function createFollowerHero(): IHeroState { + return new HeroState( + createFollowerState(), + new Dir8FaceHandler(), + new HeroAttribute(createBaseAttr()) + ); +} + /** 构造一个已注册装备定义并设置好装备槽的勇士状态对象(基础 atk 为 10) */ function createEquipHero(): IHeroState { const env = createEquipEnv(); @@ -741,3 +750,43 @@ describe('HeroState same-reference equipment load (#06-17-4)', () => { expect(hero.items.equipment.get(extra)).toBeNull(); }); }); + +describe('HeroState same-reference follower load (#06-17-6)', () => { + // 验证经勇士容器三档往返后同索引同图块数字的跟随者为同一实例,位置与渲染恢复到存档点 + it('keeps the follower instance and restores its state across all compressions', () => { + for (const compression of SAVE_COMPRESSIONS) { + const hero = createFollowerHero(); + const follower = hero.followers.addFollower(100); + follower.location.setPos(2, 3); + follower.location.mover.setFaceDir(FaceDirection.Up); + follower.rendering.setAlpha(0.5); + + const saved = hero.saveState(compression); + follower.location.setPos(9, 9); + follower.location.mover.setFaceDir(FaceDirection.Down); + follower.rendering.setAlpha(1); + + hero.loadState(saved, compression); + + expect(hero.followers.getFollower(0)).toBe(follower); + expect(follower.location.x).toBe(2); + expect(follower.location.y).toBe(3); + expect(follower.rendering.alpha).toBe(0.5); + } + }); + + // 验证跟随者数量以存档为准:存档中不存在的跟随者读档后被删除 + it('deletes followers absent from the save', () => { + const hero = createFollowerHero(); + const kept = hero.followers.addFollower(100); + + const saved = hero.saveState(SaveCompression.NoCompression); + hero.followers.addFollower(100); + + hero.loadState(saved, SaveCompression.NoCompression); + + const all = hero.followers.getAllFollowers(); + expect(all).toHaveLength(1); + expect(all[0]).toBe(kept); + }); +}); diff --git a/packages-user/data-base/src/hero/state.ts b/packages-user/data-base/src/hero/state.ts index ffec225..b48c8d8 100644 --- a/packages-user/data-base/src/hero/state.ts +++ b/packages-user/data-base/src/hero/state.ts @@ -145,10 +145,7 @@ export class HeroState this.rendering.loadState(state.rendering, compression); this.items.loadState(state.items, compression); this.equip.loadState(state.equip, compression); - void this.followers.removeAllFollowers(); - for (const save of state.followers) { - const follower = this.followers.addFollower(save.num); - follower.loadState(save, compression); - } + // 跟随者保留式恢复:同索引同图块数字的实例原地读档,其余按存档替换或删除 + this.followers.restoreFollowers(state.followers, compression); } } diff --git a/packages-user/data-base/src/hero/types.ts b/packages-user/data-base/src/hero/types.ts index 9ec8f1b..77a2b75 100644 --- a/packages-user/data-base/src/hero/types.ts +++ b/packages-user/data-base/src/hero/types.ts @@ -506,6 +506,19 @@ export interface IHeroFollowersController */ addFollower(num: number | string): IHeroFollower; + /** + * 按存档恢复跟随者列表:索引相同且图块数字相同的跟随者在其原实例上原地读档, + * 索引相同但图块数字不同的槽位替换为新实例,存档中不存在的末尾跟随者按存档为准删除。 + * 仅发生替换或删除时触发 `onRemoveFollower`,仅新建实例时触发 `onAddFollower`, + * 原地读档的跟随者不触发增删钩子。 + * @param saves 跟随者存档列表 + * @param compression 存档压缩级别 + */ + restoreFollowers( + saves: readonly IHeroFollowerSave[], + compression: SaveCompression + ): void; + /** * 根据跟随者的索引数字获取跟随者对象 * @param index 跟随者的索引数字