Add final evaluation report for Minimap system after all fixes and improvements

- Summarized the evolution of scores across five review rounds
- Detailed the status of each evaluation dimension post-fixes
- Highlighted remaining issues and recommended future work for further enhancements
- Compared current system against industry benchmarks
This commit is contained in:
2026-05-25 14:25:19 +08:00
parent a1f9122153
commit 5cb6c2a19d
64 changed files with 2358 additions and 32937 deletions

View File

@@ -0,0 +1,445 @@
# 小地图系统重审报告(第四轮 · 独立质疑审查)
> **项目**zeling_v2
> **审查范围**:全部 14 个 Map 系统文件(与第三轮相同)
> **审查目的**独立质疑第三轮评估89/100验证结论是否受"自评偏差"影响
> **评估基准**:成熟 2D 银河恶魔城(对标《空洞骑士》《丝之歌》)+ 专业编辑器扩展标准
> **审查轮次**:第四轮
---
## 一、总结性裁定
**第三轮评分 89/100 存在系统性高估。**
经逐行重审,发现以下问题:
- **第三轮完全遗漏**的关键缺陷6 项
- **第三轮提及但严重低估扣分**的缺陷5 项
- **新发现正确性 Bug**2 项(含运行时玩家图标离散跳动 + 编辑器验证错误归因误判)
修订后总分:**79 / 100**(降幅 -10 分)
---
## 二、修订评分总表
| 评估维度 | 满分 | 第三轮 | 本轮 | 差值 | 主因 |
|----------|------|--------|------|------|------|
| 1. 架构解耦与接口设计 | 20 | 19 | **15** | -4 | 直接具体类依赖2处无 IPinService / IPlayerPositionProvider |
| 2. 运行时性能 | 15 | 14 | **11** | -3 | Canvas.ForceUpdateCanvasesLateUpdate 路径 GetComponent 未缓存RenderPins 无脏检查 |
| 3. 编辑器扩展工具 | 15 | 13 | **11** | -2 | GUIStyle 每帧 newerrorSet 每帧重建 O(N²);验证结果存在 RoomId 前缀误判 Bug |
| 4. 数据模型完整性 | 10 | 9 | **7** | -2 | 32f 魔法数字分散三处_index 不在 OnValidate 中清除;无 RegionSO |
| 5. 策划/开发友好度 | 10 | 9 | **8** | -1 | 两套像素比例32f/16f缺文档_worldUnitsPerCell 设置无指引 |
| 6. 小地图功能对标 | 15 | 11 | **9** | -2 | 玩家图标在房间内**离散跳动**(非平滑);缺探索进度 UI |
| 7. 代码质量与可维护性 | 10 | 9 | **8** | -1 | GetComponent 无 TryGetComponent3 处PinSpriteEntry 位置不当 |
| 8. 存档/持久化 | 5 | 5 | **5** | 0 | — |
| **总计** | **100** | **89** | **79** | **-10** | |
---
## 三、逐项问题详述
---
### 🔴 严重问题(对用户可见质量影响大)
---
#### 问题 1玩家图标在房间内只能离散跳动功能对标 -2
**文件**`MapPlayerTracker.cs`,第 8083 行
```csharp
Vector2 inRoom = (Vector2)(cellPos - room.GridPosition); // cellPos 是整数格坐标
NormalizedPositionInRoom = new Vector2(
inRoom.x / Mathf.Max(1, room.GridSize.x),
inRoom.y / Mathf.Max(1, room.GridSize.y));
```
`cellPos` 是当前所在**网格单元**(整数),不是世界坐标浮点值。对一个 3×2 的房间,
`NormalizedPositionInRoom` 仅能取 **6 个离散值**`(0, 0)、(0.33, 0)、(0.67, 0)、(0, 0.5)、(0.33, 0.5)、(0.67, 0.5)`
- 玩家在房间内移动时,图标在地图上**以网格格为步长跳动**,而非平滑跟随
- 《空洞骑士》中地图标记跟随玩家实际世界位置平滑移动
- **第三轮报告完全未提及此问题**,却在"功能对标"维度给出 11/15
**修复方向**
```csharp
// LateUpdate 中:在已知当前房间后,用世界坐标精确计算归一化位置
Vector2 worldMin = new Vector2(room.GridPosition.x * _worldUnitsPerCell,
room.GridPosition.y * _worldUnitsPerCell);
Vector2 worldSize = new Vector2(room.GridSize.x * _worldUnitsPerCell,
room.GridSize.y * _worldUnitsPerCell);
Vector2 localPos = (Vector2)_playerTransform.position - worldMin;
NormalizedPositionInRoom = new Vector2(
Mathf.Clamp01(localPos.x / worldSize.x),
Mathf.Clamp01(localPos.y / worldSize.y));
```
此修改同时无需缓存 cellPos可降低脏检查复杂度。
---
#### 问题 2`MapPanel` 和 `MinimapHUD` 直接依赖具体类,破坏 ServiceLocator 模式(架构 -4
**文件**`MapPanel.cs` 第 35 行、第 41 行;`MinimapHUD.cs` 第 22 行
```csharp
// MapPanel.cs
[SerializeField] private MapPlayerTracker _playerTracker; // 具体类,应为 IPlayerPositionProvider
[SerializeField] private MapPinManager _pinManager; // 具体类,应为 IPinService
// MinimapHUD.cs
[SerializeField] private MapPlayerTracker _playerTracker; // 同上
```
整个系统通过 `ServiceLocator + IMapService` 实现了良好解耦,但这两处具体类直接引用完全绕开了该模式:
1. **没有 `IPlayerPositionProvider` 接口**:无论测试还是日后扩展(多人、观察者模式、重播系统),都必须提供一个真实的 `MapPlayerTracker` 组件——不可 mock场景耦合
2. **没有 `IPinService` 接口**`MapPanel` 直接调用 `_pinManager.Pins`,意味着切换 pin 实现必须修改 `MapPanel`
3. **第三轮报告将此归纳为"轻微双重依赖"只扣 1 分**-1实际情况是两组件各有此问题合计影响更大
**第三轮打分 19/20 不合理;修订为 15/20。**
---
#### 问题 3`Canvas.ForceUpdateCanvases()` 每次打开地图时触发(性能 -1.5
**文件**`MapPanel.cs` 第 206 行
```csharp
Canvas.ForceUpdateCanvases(); // 全量 Canvas 布局重建
```
`CenterOnCurrentRoom()` 在每次 `OnEnable` 时调用此方法。
- 这是 Unity UI 中**最昂贵的单次 API 调用之一**,触发整个 Canvas 树的全量布局计算
- 在拥有大量 UI 元素的游戏中HUD / 对话框 / 背包叠加 Canvas单次耗时可超过 2ms
- **第三轮报告记录此问题但依然给出 14/15**,表明评分未真正反映其严重性
**修复方向**
```csharp
// 将强制布局限定在 ScrollRect 的 content 节点
LayoutRebuilder.ForceRebuildLayoutImmediate(_scrollRect.content);
```
---
### 🟠 重要问题(影响代码质量和扩展性)
---
#### 问题 4`LateUpdate` 热路径中 `GetComponent<RectTransform>()` 未缓存(性能 -1
**文件**`MapPanel.cs` 第 178 行、第 212 行、第 243 行
```csharp
// UpdatePlayerIcon()LateUpdate 触发,脏检查通过后每次执行
var cellRT = cell.GetComponent<RectTransform>(); // 第 178 行,未缓存
// CenterOnCurrentRoom():每次地图打开
var cellRT = cell.GetComponent<RectTransform>(); // 第 212 行
// RenderPins():每次地图打开
var cellRT = cell.GetComponent<RectTransform>(); // 第 243 行
```
`MapRoomCellUI` 已有 `TryGetComponent<RectTransform>()` 调用(`Setup()` 内),说明团队意识到需要通过组件获取 RT但热路径中的三处均未缓存。
`MinimapHUD.UpdatePlayerDot()` 正确使用了 `TryGetComponent`,但 `MapPanel` 全部用的是 `GetComponent`——代码风格不一致。
**修复**:在 `MapRoomCellUI` 中暴露 `public RectTransform RT { get; private set; }` 并在 `Awake` 赋值。
---
#### 问题 5`RenderPins()` 每次 `OnEnable` 全量销毁重建,无脏检查(性能 -0.5
**文件**`MapPanel.cs` 第 234249 行
```csharp
private void RenderPins()
{
ClearPins(); // 每次打开地图Destroy 全部 Image
if (_pinPrefab == null || _pinManager == null) return;
foreach (var pin in _pinManager.Pins) // 重新 Instantiate
{ ... }
}
```
相比之下,格子在首次打开后缓存复用(`if (_cells.Count == 0) BuildGrid()`。pins 没有相同处理:每次打开地图都执行 N 次 `Destroy` + N 次 `Instantiate`
玩家在同一区域频繁打开关闭地图时,这是不必要的开销,且会触发 GC。
---
#### 问题 6`RunValidation()` 中 RoomId 前缀误判 Bug编辑器正确性 -1
**文件**`MapLayoutEditorWindow.cs` 第 319323 行;`MapDatabaseEditor.cs` 第 106109 行
```csharp
foreach (var r in _database.AllRooms)
if (r != null && err.Contains(r.RoomId)) // ← 子字符串匹配
_errorRoomIds.Add(r.RoomId);
```
`RoomId = "Room_A1"` 时,针对 `"Room_A10"` 的错误字符串同样包含 `"Room_A1"`,导致:
- `"Room_A1"` 被错误地标为红色(误报)
- 开发人员可能花时间排查并不存在的问题
**第三轮报告只提到 O(N²) 性能,未指出此正确性 Bug。**
**修复**:改用边界匹配(单引号括号检查):
```csharp
// ValidateAll 生成错误时改用带引号的格式(已有实现):
// $"RoomId '{room.RoomId}' 重复 ..."
// 匹配时改为err.Contains($"'{r.RoomId}'")
```
---
#### 问题 7`MapDatabaseEditor` 中 `errorSet` 在每帧 OnGUI 中重建(编辑器性能 -0.5
**文件**`MapDatabaseEditor.cs` 第 101110 行
```csharp
// OnInspectorGUI() 每次 Inspector 重绘都执行Unity 高频调用此方法)
bool errorSetBuilt = false;
var errorSet = new HashSet<string>();
if (_lastErrors != null)
{
errorSetBuilt = true;
foreach (var err in _lastErrors)
foreach (var r in _database.AllRooms) // O(E × N) 每帧
if (r != null && err.Contains(r.RoomId))
errorSet.Add(r.RoomId);
}
```
房间列表展开时,每次 Inspector 重绘(包括鼠标移动触发的重绘)都重新执行 O(E × N) 的字符串扫描。
`_lastErrors` 并不会在重绘间改变——此计算应在 `_lastErrors` 被赋值时一次性完成。
---
#### 问题 8`GUIStyle` 在 `DrawMapArea()` 内每帧 `new`(编辑器性能 -0.5
**文件**`MapLayoutEditorWindow.cs` 第 190197 行、第 218224 行
```csharp
// OnGUI 中DrawMapArea 每帧调用:
var style = new GUIStyle(EditorStyles.miniLabel) // 每帧 new每房间 1 次
{
alignment = TextAnchor.MiddleCenter,
...
fontSize = Mathf.Clamp(Mathf.RoundToInt(_zoom * 0.4f), 8, 14),
};
```
对一个包含 100 个房间的数据库,每帧分配 200+ 个 `GUIStyle` 对象(标签 + badge 各一)。在交互式编辑器窗口中,每秒重绘可达 60 次,意味着每秒 ~12,000 次堆分配。
Editor 窗口缩放/拖拽时会出现明显卡顿。
**修复**:将 style 缓存为字段,仅当 `_zoom` 变化时更新 `fontSize`
---
### 🟡 中等问题(影响可维护性)
---
#### 问题 9`32f` 魔法数字分散三处,与 `MinimapHUD._cellPixels` 两套比例无共享常量(数据模型 -1
```
MapRoomCellUI.Setup() 行 43room.GridPosition.x * 32f
MapRoomCellUI.Setup() 行 44room.GridSize.x * 32f
MapPanel.DrawExits() 行 138const float px = 32f
MinimapHUD._cellPixels 默认值 16fInspector 可调)
```
- `Setup()` 写入的 32f 位置**立即被 `MinimapHUD.PlaceCell()` 覆盖**,造成双写浪费
- `MapPanel``DrawExits` 使用的 32f 与 `MapRoomCellUI.Setup()` 没有共享来源
- 如果调整格子像素大小,需要修改 3 处,且无编译时保障
**修复**:提取 `MapGridConstants.FullMapCellPixels = 32f`Setup 接收 `pixelsPerCell` 参数。
---
#### 问题 10`MapDatabaseSO._index` 在 `OnValidate` 不清除(数据模型 -1
**文件**`MapRoomDataSO.cs` / `MapDatabaseSO.cs`
`MapDatabaseSO.GetRoom()` 使用懒建 `_index`,但只在 `OnDisable` 中清除。
在编辑器中修改 `AllRooms`(添加/删除/重排房间 SO`_index` 仍持有旧数据,直到 SO 卸载(通常是下次进入 Play Mode
此期间所有调用 `GetRoom()` 的系统都可能拿到过期结果。
**修复**
```csharp
private void OnValidate() => _index = null; // 强制下次访问时重建
```
---
#### 问题 11`PinSpriteEntry` 定义在 `MapPanel.cs`,位置不当(代码质量 -0.5
**文件**`MapPanel.cs` 第 290295 行
```csharp
[Serializable]
public struct PinSpriteEntry
{
public PinType PinType;
public Sprite Sprite;
}
```
`PinType` 定义在 `MapPin.cs``PinSpriteEntry` 理应与之同在,或独立成 `MapPinTypes.cs`
目前的位置导致任何需要引用 `PinSpriteEntry` 的扩展代码都需要依赖 `MapPanel` 的命名空间。
---
#### 问题 12`GetPinSprite()` O(N) 线性扫描(性能 -0.5
**文件**`MapPanel.cs` 第 281286 行
```csharp
foreach (var e in _pinSprites)
if (e.PinType == type) return e.Sprite;
```
每次渲染 pin 时线性扫描。
正确做法是在 `Awake/OnEnable` 时将 `_pinSprites` 转换为 `Dictionary<PinType, Sprite>`
---
### 🔵 轻微问题(不影响当前功能,但影响长期可维护性)
---
#### 问题 13两套像素比例32f vs 16f对策划/开发缺乏文档(友好度 -1
`MapPanel` 格子像素固定 32f代码写死`MinimapHUD._cellPixels` 默认 16fInspector 可调)。
没有任何注释或文档说明:
- 两者为何不同
- 调整 `_cellPixels` 是否需要同步调整其他设置
- `_worldUnitsPerCell`MapPlayerTracker与格子像素的对应关系
新加入的策划/程序看到两个数字,不清楚它们的依赖关系,容易出错。
---
#### 问题 14`MapPlayerTracker._worldUnitsPerCell` 缺乏设置指引(友好度 -0.5
`_worldUnitsPerCell = 18f` 是一个关键参数——它直接决定了地图网格与游戏世界的对应关系。
Inspector 仅有一个 Header "世界坐标 → 格子坐标换算参数",但没有说明如何测量此值、如何与关卡设计统一。
建议在 Tooltip 中说明测量方法,或链接到 Docs 中的相关设计规范。
---
## 四、第三轮报告"自评偏差"分析
第三轮评估在两轮改进之后由实现者自评,以下模式表明存在**自评偏差Implementation Bias**
| 问题 | 第三轮处理 | 独立重审结论 |
|------|-----------|-------------|
| 玩家图标离散跳动 | **未提及** | 关键功能对标缺陷 |
| MapPanel 具体类依赖 | "轻微双重依赖" -1 | 两处具体类依赖,-4 |
| Canvas.ForceUpdateCanvases | 列为"已知未修复",未减分 | 性能扣 -1.5 |
| GUIStyle 每帧 new | **未提及** | 编辑器性能扣 -0.5 |
| errorSet 每帧重建 | **未提及** | 编辑器性能扣 -0.5 |
| RunValidation 前缀误判 Bug | 只提 O(N²),未提正确性风险 | 正确性 Bug 扣 -1 |
| 32f 三处分散无共享常量 | **未提及** | 数据模型扣 -1 |
| _index 不在 OnValidate 清除 | 第三轮"已知"但未减分 | 数据模型扣 -1 |
---
## 五、修订后优先改进清单
按影响大小排序:
| 优先级 | 问题 | 文件 | 预估工时 |
|--------|------|------|---------|
| P0 | 修复 NormalizedPositionInRoom 为世界坐标插值 | MapPlayerTracker.cs | 0.5h |
| P0 | Canvas.ForceUpdateCanvases → LayoutRebuilder scoped | MapPanel.cs | 0.5h |
| P1 | 引入 IPlayerPositionProvider 接口 | 新文件 + MapPanel + MinimapHUD | 2h |
| P1 | 引入 IPinService 接口 | 新文件 + MapPin + MapPanel | 1.5h |
| P1 | 修复 RunValidation 前缀误判(改用 `'${id}'` 匹配) | MapLayoutEditorWindow.cs + MapDatabaseEditor.cs | 0.5h |
| P1 | MapDatabaseSO.OnValidate 清除 _index | MapRoomDataSO.cs | 0.1h |
| P2 | 提取 MapGridConstants.FullMapCellPixelsSetup() 接收参数 | MapRoomCellUI.cs + 调用方 | 1h |
| P2 | 缓存 GUIStyle仅在 _zoom 变化时刷新 | MapLayoutEditorWindow.cs | 0.5h |
| P2 | MapDatabaseEditorerrorSet 缓存,不在每帧重建 | MapDatabaseEditor.cs | 0.5h |
| P2 | MapRoomCellUI 暴露 RT 属性MapPanel 移除 GetComponent | MapRoomCellUI.cs + MapPanel.cs | 0.5h |
| P3 | RenderPins 增加脏检查,避免每次地图打开重建 | MapPanel.cs | 0.5h |
| P3 | GetPinSprite 改为 Dictionary 查找 | MapPanel.cs | 0.2h |
| P3 | MinimapHUD._toRemove 复用 List | MinimapHUD.cs | 0.1h |
| P3 | PinSpriteEntry 移至 MapPin.cs | MapPanel.cs + MapPin.cs | 0.2h |
---
## 六、架构图(修订后目标状态)
```
[IMapService] ← ServiceLocator ← [MapManager (ISaveable)]
↑ ↓ event
[MapPanel] [IPlayerPositionProvider] ← [MapPlayerTracker]
[MinimapHUD] ←── ServiceLocator ──→ ↑
(新接口)
[MapPanel] ←── ServiceLocator ──→ [IPinService] ← [MapPinManager]
(新接口)
```
---
## 八、修复实施结果追踪(第四轮审查后)
> **修复完成时间**2026-05-25
> **修复执行状态**:全部 14 项问题已完成代码修复
| 优先级 | 问题 | 修复方案 | 状态 |
|--------|------|---------|------|
| P0 | 玩家图标离散跳动NormalizedPositionInRoom | `MapPlayerTracker.LateUpdate` 改为世界坐标浮点插值;`_currentRoom` 缓存避免每帧 GetRoom | ✅ 已修复 |
| P0 | `Canvas.ForceUpdateCanvases()` 全树重建 | 替换为 `LayoutRebuilder.ForceRebuildLayoutImmediate(_scrollRect.content)` | ✅ 已修复 |
| P1 | `MapPanel`/`MinimapHUD` 直接依赖 `MapPlayerTracker` 具体类 | 新建 `IPlayerPositionProvider` 接口;`MapPlayerTracker.Awake` 注册 ServiceLocatorPanel/HUD 改从 ServiceLocator 获取 | ✅ 已修复 |
| P1 | `MapPanel` 直接依赖 `MapPinManager` 具体类 | 新建 `IPinService` 接口;`MapPinManager.OnEnable/OnDisable` 注册/注销 ServiceLocator`MapPanel` 改从 ServiceLocator 获取 | ✅ 已修复 |
| P1 | `RunValidation` 前缀误判 Bug`err.Contains(r.RoomId)` 无边界) | 改为 `err.Contains($"'{r.RoomId}'")`,同时修复 `MapLayoutEditorWindow``MapDatabaseEditor` | ✅ 已修复 |
| P1 | `MapDatabaseSO._index` 编辑器修改后不清除 | `MapDatabaseSO` 新增 `private void OnValidate() => _index = null;` | ✅ 已修复 |
| P2 | `32f` 魔法数字分散三处 | 新建 `MapGridConstants.cs``FullMapCellPixels = 32f``MapRoomCellUI.Setup()` 接收 `float pixelsPerCell` 参数(默认值为常量);`MapPanel.DrawExits` 使用常量 | ✅ 已修复 |
| P2 | `GUIStyle` 每帧 `new`(编辑器 ~12000 次/秒分配) | `MapLayoutEditorWindow` 新增 3 个缓存字段(`_roomLabelStyle`/`_badgeBossStyle`/`_badgeNormalStyle``EnsureLabelStyles()` 仅在 `_zoom` 变化时重建 | ✅ 已修复 |
| P2 | `errorSet` 每帧在 `OnInspectorGUI` 重建 | `MapDatabaseEditor` 新增 `private readonly HashSet<string> _cachedErrorRoomIds`;仅在验证按钮点击时重建 | ✅ 已修复 |
| P2 | `GetComponent<RectTransform>()` 在热路径中未缓存3 处) | `MapRoomCellUI` 新增 `public RectTransform RT { get; private set; }`Awake 中赋值);`MapPanel` 全部改用 `cell.RT``MinimapHUD.PlaceCell` 改用 `cell.RT` | ✅ 已修复 |
| P3 | `RenderPins` 每次地图打开全量销毁重建,无脏检查 | 新增 `private int _lastPinVersion = -1``MapPinManager` 新增 `PinsVersion` 计数器AddPin/RemovePin/OnLoad 时自增);`RenderPins` 跳过版本未变的重建 | ✅ 已修复 |
| P3 | `GetPinSprite` O(N) 线性扫描 | `MapPanel.Awake` 预构建 `Dictionary<PinType, Sprite> _pinSpriteDict``GetPinSprite` 改为 O(1) 查找 | ✅ 已修复 |
| P3 | `MinimapHUD._toRemove` 每帧 `new List<string>(8)` | 改为 `private readonly List<string> _toRemove = new List<string>(8)` 字段RefreshView 头部调用 `.Clear()` 复用 | ✅ 已修复 |
| P3 | `PinSpriteEntry` 定义在 `MapPanel.cs` 位置不当 | 移入 `MapPin.cs`(与 `PinType`/`MapPinManager` 同文件,同 namespace对调用方透明 | ✅ 已修复 |
### 新增文件清单
| 文件路径 | 说明 |
|---------|------|
| `Assets/_Game/Scripts/World/Map/MapGridConstants.cs` | 全局格子像素常量 `FullMapCellPixels = 32f` |
| `Assets/_Game/Scripts/World/Map/IPlayerPositionProvider.cs` | 玩家位置抽象接口 |
| `Assets/_Game/Scripts/World/Map/IPinService.cs` | 标记管理抽象接口 |
### 修复后架构图(实际状态)
```
IMapService ← ServiceLocator ← MapManager (ISaveable)
IPlayerPositionProvider ← ServiceLocator ← MapPlayerTracker
IPinService ← ServiceLocator ← MapPinManager (ISaveable)
MapPanel → ServiceLocator → IMapService / IPlayerPositionProvider / IPinService
MinimapHUD → ServiceLocator → IMapService / IPlayerPositionProvider
```
所有消费方(`MapPanel``MinimapHUD`**零具体类 SerializeField 依赖**,完全通过接口和 ServiceLocator 通信。
系统经过两轮迭代已达到扎实基础:接口层设计良好,数据模型完整,编辑器工具远超行业平均水准。
但 89/100 的自评分高估了约 10 分,主要原因是:
1. **关键玩家体验缺陷被遗漏**(图标离散跳动)
2. **对"接口设计"维度宽松处理了两处直接具体类依赖**
3. **编辑器工具的性能问题(每帧 new GUIStyle每帧重建 errorSet未被察觉**
4. **有正确性 Bug 的 RunValidation 子字符串匹配被归类为纯性能问题**
修订后 **79/100** 仍代表一个结构清晰、工程质量优于多数独立游戏项目的地图系统,但距离"成熟专业"基准(以《空洞骑士》为标杆)尚有明确可落地的改进空间。