[P0][CR-B2] 地面物品两份存储且按 32px 距离删除:捡 A 删 B、存档复制、读档不重建 #522

Closed
opened 2026-09-29 06:43:55 +00:00 by troytt · 1 comment
Owner

来源:#516|优先级 P0|评审编号 B2|基线 e475c2e

问题描述

地面物品同时存在两份:

  • engine.ground[]:用于模拟和存档;
  • engine.groundItems:用于渲染和交互。

捡起物品后,要从 ground[] 里删掉对应的那一项,但匹配规则是"32px 以内或对象相同的第一件"。而同一次爆装的物品最小间距只有 16px,所以经常删掉旁边的另一件。

被捡的那件还留在 ground[] 里,存档时就多复制了一份。读档时又只替换 ground[]、不重建 groundItems,渲染与状态就对不上了。

证据

  • 两份存储:engine.ts:270-271。
  • 按距离删除:findIndex(g => Math.hypot(...) <= 32 || g.item === entity.item)(engine.ts:1656)。
    • findIndex 按数组顺序返回第一个满足任一条件的元素。只要排在前面的某件物品落在 32px 内,就会先命中它,而不是后面那件真正被捡起的物品。
  • 同样的写法还有 4 处:
  • 间距只有 16px:同一次爆装的最小间距 minSpacing = 16(ground-items.ts:1020)。
  • id 不确定:id 用 Date.now() 拼接(ground-items.ts:1372),也不进快照。
  • 读档不重建:loadSnapshot 只替换 ground(engine.ts:1202)。

根因

渲染层需要的额外字段(弹跳动画、标签)没有挂在模拟层的物品上,而是另建了一份集合。两份集合之间没有稳定的主键,只能用坐标去"猜"谁对应谁。

修复指南

  1. 单一存储:
    • GameEngine 只保留一份 ground: Map<GroundId, GroundItem>(或者按插入顺序的数组加 id 索引)。模拟、拾取和存档都用它。
    • 渲染需要的动画状态放进前端自己的 Map<GroundId, GroundItemView>,按 id 关联,不参与模拟。
  2. 确定性 id:用引擎内的单调计数器 nextGroundId++ 生成 id,并纳入快照。不要用 Date.now() 或随机数(与 #523 一致)。
  3. 只按 id 删除:
    • 提供 removeGround(id);删除不存在的 id 直接抛错,不要"找不到就删最近的"。
    • 拾取命令携带 id(pickup(groundId))。UI 先通过命中测试拿到 id,再下发命令。
  4. 读档:loadSnapshot 之后,由前端按引擎的 ground 重建 view(或者由引擎发一个 groundReset 事件)。不要在引擎里维护渲染集合。
  5. 距离判断只留在输入层:只用于"鼠标点到了哪一件"这种命中测试,而且要选最近的一件,不是第一件。

验收标准

  • 测试:两件物品间距 16px,捡起 A 之后,ground 里只少了 A,B 仍在;存档、读档后数量正确。
  • 测试:同一位置叠放两件相同的物品,按 id 分别拾取,互不影响。
  • 读档后,渲染集合与引擎的 ground 一一对应(id 集合相等)。
  • src/ 中不再有基于 Math.hypot(...) <= 32 的删除逻辑。
  • npm run typecheck 0 error;npx vitest run 全部通过。

相关

  • #520:玩家状态同样有两份。
  • #521:金币堆的删除也走这套匹配。
  • #523:确定性 id。
> 来源:#516|优先级 P0|评审编号 B2|基线 `e475c2e` ## 问题描述 地面物品同时存在两份: - `engine.ground[]`:用于模拟和存档; - `engine.groundItems`:用于渲染和交互。 捡起物品后,要从 `ground[]` 里删掉对应的那一项,但匹配规则是"32px 以内或对象相同的第一件"。而同一次爆装的物品最小间距只有 16px,所以经常**删掉旁边的另一件**。 被捡的那件还留在 `ground[]` 里,存档时就多复制了一份。读档时又只替换 `ground[]`、不重建 `groundItems`,渲染与状态就对不上了。 ## 证据 - **两份存储**:[engine.ts:270-271](https://git.projectdiablo2.cn/troytt/diablo2-web/src/commit/e475c2e6d8a6e85eabb607525d9d87beca9fbee3/src/game/engine.ts#L270-L271)。 - **按距离删除**:`findIndex(g => Math.hypot(...) <= 32 || g.item === entity.item)`([engine.ts:1656](https://git.projectdiablo2.cn/troytt/diablo2-web/src/commit/e475c2e6d8a6e85eabb607525d9d87beca9fbee3/src/game/engine.ts#L1656))。 - `findIndex` 按数组顺序返回第一个满足任一条件的元素。只要排在前面的某件物品落在 32px 内,就会先命中它,而不是后面那件真正被捡起的物品。 - **同样的写法还有 4 处**: - [skill-caster.ts:416](https://git.projectdiablo2.cn/troytt/diablo2-web/src/commit/e475c2e6d8a6e85eabb607525d9d87beca9fbee3/src/scene/backend/skill-caster.ts#L416)(心灵传动拾取) - [mouse-controller.ts:498](https://git.projectdiablo2.cn/troytt/diablo2-web/src/commit/e475c2e6d8a6e85eabb607525d9d87beca9fbee3/src/scene/frontend/mouse-controller.ts#L498)(点击拾取) - [engine.ts:1612](https://git.projectdiablo2.cn/troytt/diablo2-web/src/commit/e475c2e6d8a6e85eabb607525d9d87beca9fbee3/src/game/engine.ts#L1612)(金币:对象相同或 32px 内) - [engine.ts:1124](https://git.projectdiablo2.cn/troytt/diablo2-web/src/commit/e475c2e6d8a6e85eabb607525d9d87beca9fbee3/src/game/engine.ts#L1124)(`groundItems.find(item) ?? findAtPoint(x, y, 48)`,找不到就退到 48px 范围) - **间距只有 16px**:同一次爆装的最小间距 `minSpacing = 16`([ground-items.ts:1020](https://git.projectdiablo2.cn/troytt/diablo2-web/src/commit/e475c2e6d8a6e85eabb607525d9d87beca9fbee3/src/game/ground-items.ts#L1020))。 - **id 不确定**:id 用 `Date.now()` 拼接([ground-items.ts:1372](https://git.projectdiablo2.cn/troytt/diablo2-web/src/commit/e475c2e6d8a6e85eabb607525d9d87beca9fbee3/src/game/ground-items.ts#L1372)),也不进快照。 - **读档不重建**:`loadSnapshot` 只替换 `ground`([engine.ts:1202](https://git.projectdiablo2.cn/troytt/diablo2-web/src/commit/e475c2e6d8a6e85eabb607525d9d87beca9fbee3/src/game/engine.ts#L1202))。 ## 根因 渲染层需要的额外字段(弹跳动画、标签)没有挂在模拟层的物品上,而是另建了一份集合。两份集合之间没有稳定的主键,只能用坐标去"猜"谁对应谁。 ## 修复指南 1. **单一存储**: - `GameEngine` 只保留一份 `ground: Map<GroundId, GroundItem>`(或者按插入顺序的数组加 id 索引)。模拟、拾取和存档都用它。 - 渲染需要的动画状态放进前端自己的 `Map<GroundId, GroundItemView>`,按 id 关联,**不参与模拟**。 2. **确定性 id**:用引擎内的单调计数器 `nextGroundId++` 生成 id,并纳入快照。不要用 `Date.now()` 或随机数(与 #523 一致)。 3. **只按 id 删除**: - 提供 `removeGround(id)`;删除不存在的 id 直接抛错,不要"找不到就删最近的"。 - 拾取命令携带 id(`pickup(groundId)`)。UI 先通过命中测试拿到 id,再下发命令。 4. **读档**:`loadSnapshot` 之后,由前端按引擎的 `ground` 重建 view(或者由引擎发一个 `groundReset` 事件)。不要在引擎里维护渲染集合。 5. **距离判断只留在输入层**:只用于"鼠标点到了哪一件"这种命中测试,而且要选**最近**的一件,不是第一件。 ## 验收标准 - [ ] 测试:两件物品间距 16px,捡起 A 之后,`ground` 里只少了 A,B 仍在;存档、读档后数量正确。 - [ ] 测试:同一位置叠放两件相同的物品,按 id 分别拾取,互不影响。 - [ ] 读档后,渲染集合与引擎的 `ground` 一一对应(id 集合相等)。 - [ ] `src/` 中不再有基于 `Math.hypot(...) <= 32` 的删除逻辑。 - [ ] `npm run typecheck` 0 error;`npx vitest run` 全部通过。 ## 相关 - #520:玩家状态同样有两份。 - #521:金币堆的删除也走这套匹配。 - #523:确定性 id。
Author
Owner

已在提交 231d375(fix(ground): unify authoritative ground-item store with exact instance IDs (#522))中完成修复,并在 5de27f8 中补充端到端与 136 关全量验证套件。

修复与验证摘要

  • 修复内容:使用确定性递增实例 ID(ground_item_${seq})1:1 统一 GroundItemManager 与 engine.ground,移除全部 5 处 Math.hypot <= 32 距离删除逻辑,改为按精确 ID 删除,并修复存档重复序列化与读档地面物品重建。
  • 专项回归测试:tests/p0-522-ground-items.test.ts
  • 总体验收门禁:npm run build 0 错误、14 个 P0/E2E 测试套件(190/190 用例)100% 通过、全仓 Vitest 6,460/6,460 通过、136/136 关无头浏览器巡检通过。
已在提交 [`231d375`](https://git.projectdiablo2.cn/troytt/diablo2-web/commit/231d375e62bc68637436f72dd9e401fa340712a2)(`fix(ground): unify authoritative ground-item store with exact instance IDs (#522)`)中完成修复,并在 [`5de27f8`](https://git.projectdiablo2.cn/troytt/diablo2-web/commit/5de27f8177d43524444de7aa301cc470c4f0844e) 中补充端到端与 136 关全量验证套件。 ### 修复与验证摘要 - **修复内容**:使用确定性递增实例 ID(`ground_item_${seq}`)1:1 统一 `GroundItemManager` 与 `engine.ground`,移除全部 5 处 `Math.hypot <= 32` 距离删除逻辑,改为按精确 ID 删除,并修复存档重复序列化与读档地面物品重建。 - **专项回归测试**:[`tests/p0-522-ground-items.test.ts`](https://git.projectdiablo2.cn/troytt/diablo2-web/src/commit/5de27f8177d43524444de7aa301cc470c4f0844e/tests/p0-522-ground-items.test.ts) - **总体验收门禁**:`npm run build` 0 错误、14 个 P0/E2E 测试套件(190/190 用例)100% 通过、全仓 Vitest 6,460/6,460 通过、136/136 关无头浏览器巡检通过。
Sign in to join this conversation.
No Label
No Milestone
No project
No Assignees
1 Participants
Notifications
Due Date
The due date is invalid or out of range. Please use the format 'yyyy-mm-dd'.

No due date set.

Dependencies

No dependencies set.

Reference: troytt/diablo2-web#522
No description provided.