diff --git a/Assets/_Game/Scripts/Editor/World/Map/MapDatabaseEditor.cs b/Assets/_Game/Scripts/Editor/World/Map/MapDatabaseEditor.cs
new file mode 100644
index 0000000..d203a35
--- /dev/null
+++ b/Assets/_Game/Scripts/Editor/World/Map/MapDatabaseEditor.cs
@@ -0,0 +1,148 @@
+#if UNITY_EDITOR
+using System.Collections.Generic;
+using UnityEditor;
+using UnityEngine;
+using BaseGames.World.Map;
+
+namespace BaseGames.Editor.Map
+{
+ ///
+ /// MapDatabaseSO 的自定义 Inspector:
+ ///
+ /// - 显示房间总数、已配置出口数
+ /// - 一键 "重新验证",将错误以 HelpBox 列出
+ /// - 一键 "打开布局编辑器" 跳转到 MapLayoutEditorWindow
+ /// - 可展开的房间列表,每行点击可 Ping 对应 SO
+ ///
+ ///
+ [CustomEditor(typeof(MapDatabaseSO))]
+ public class MapDatabaseEditor : UnityEditor.Editor
+ {
+ private MapDatabaseSO _database;
+ private List _lastErrors;
+ private bool _roomListExpanded;
+ private Vector2 _roomListScroll;
+
+ ///
+ /// 缓存的错误房间 ID 集合,仅在"重新验证"按钮点击时重建。
+ /// 避免 OnInspectorGUI(高频重绘)每次都重建 O(N²) 扫描。
+ ///
+ private readonly HashSet _cachedErrorRoomIds = new();
+
+ private static readonly GUIContent LabelValidate = new GUIContent("重新验证", "检查重复 RoomId、出口目标缺失、房间网格重叠等问题");
+ private static readonly GUIContent LabelOpenEditor = new GUIContent("打开布局编辑器", "在独立窗口中预览全局地图布局");
+
+ private void OnEnable() => _database = (MapDatabaseSO)target;
+
+ public override void OnInspectorGUI()
+ {
+ serializedObject.Update();
+
+ // ── 统计摘要 ──────────────────────────────────────────────────────
+ int roomCount = _database.AllRooms?.Length ?? 0;
+ int exitCount = 0;
+ if (_database.AllRooms != null)
+ foreach (var r in _database.AllRooms)
+ if (r?.Exits != null) exitCount += r.Exits.Length;
+
+ EditorGUILayout.BeginVertical(EditorStyles.helpBox);
+ EditorGUILayout.LabelField("📊 数据库统计", EditorStyles.boldLabel);
+ EditorGUI.indentLevel++;
+ EditorGUILayout.LabelField("房间总数", roomCount.ToString());
+ EditorGUILayout.LabelField("出口总数", exitCount.ToString());
+ EditorGUI.indentLevel--;
+ EditorGUILayout.EndVertical();
+
+ EditorGUILayout.Space(4);
+
+ // ── 操作按钮 ──────────────────────────────────────────────────────
+ EditorGUILayout.BeginHorizontal();
+
+ if (GUILayout.Button(LabelValidate, GUILayout.Height(28)))
+ {
+ _lastErrors = _database.ValidateAll();
+
+ // 构建错误 RoomId 集合(使用引号匹配,防止 "Room_A1" 误匹配 "Room_A10")
+ _cachedErrorRoomIds.Clear();
+ if (_lastErrors.Count > 0 && _database.AllRooms != null)
+ foreach (var err in _lastErrors)
+ foreach (var r in _database.AllRooms)
+ if (r != null && err.Contains($"'{r.RoomId}'"))
+ _cachedErrorRoomIds.Add(r.RoomId);
+
+ if (_lastErrors.Count == 0)
+ Debug.Log("[MapDatabase] 验证通过,未发现问题 ✓");
+ else
+ Debug.LogWarning($"[MapDatabase] 发现 {_lastErrors.Count} 项问题,详见 Inspector。");
+ }
+
+ if (GUILayout.Button(LabelOpenEditor, GUILayout.Height(28)))
+ {
+ var win = EditorWindow.GetWindow("地图布局编辑器");
+ win.SetDatabase(_database);
+ }
+
+ EditorGUILayout.EndHorizontal();
+
+ EditorGUILayout.Space(4);
+
+ // ── 验证结果 ──────────────────────────────────────────────────────
+ if (_lastErrors != null)
+ {
+ if (_lastErrors.Count == 0)
+ {
+ EditorGUILayout.HelpBox("验证通过,无问题 ✓", MessageType.Info);
+ }
+ else
+ {
+ string errorText = string.Join("\n", _lastErrors);
+ EditorGUILayout.HelpBox(errorText, MessageType.Warning);
+ }
+ }
+
+ EditorGUILayout.Space(4);
+
+ // ── 默认字段(AllRooms 数组等) ───────────────────────────────────
+ DrawDefaultInspector();
+
+ EditorGUILayout.Space(4);
+
+ // ── 可折叠房间列表(快速总览) ────────────────────────────────────
+ _roomListExpanded = EditorGUILayout.Foldout(_roomListExpanded, $"房间列表({roomCount})", true);
+ if (_roomListExpanded && _database.AllRooms != null)
+ {
+ _roomListScroll = EditorGUILayout.BeginScrollView(_roomListScroll, GUILayout.MaxHeight(200));
+
+ foreach (var room in _database.AllRooms)
+ {
+ if (room == null)
+ {
+ EditorGUILayout.HelpBox("空引用(AllRooms 中有 null 元素)", MessageType.Error);
+ continue;
+ }
+
+ bool hasError = _cachedErrorRoomIds.Contains(room.RoomId);
+ var rowStyle = hasError
+ ? new GUIStyle(EditorStyles.label) { normal = { textColor = new Color(1f, 0.35f, 0.35f) } }
+ : EditorStyles.label;
+
+ EditorGUILayout.BeginHorizontal();
+ EditorGUILayout.LabelField(
+ $"{(hasError ? "⚠ " : "")}{room.RoomId} [{room.GridPosition.x},{room.GridPosition.y}] {room.GridSize.x}×{room.GridSize.y}",
+ rowStyle);
+ if (GUILayout.Button("Ping", EditorStyles.miniButton, GUILayout.Width(40)))
+ {
+ Selection.activeObject = room;
+ EditorGUIUtility.PingObject(room);
+ }
+ EditorGUILayout.EndHorizontal();
+ }
+
+ EditorGUILayout.EndScrollView();
+ }
+
+ serializedObject.ApplyModifiedProperties();
+ }
+ }
+}
+#endif
diff --git a/Assets/_Game/Scripts/Editor/World/Map/MapLayoutEditorWindow.cs b/Assets/_Game/Scripts/Editor/World/Map/MapLayoutEditorWindow.cs
new file mode 100644
index 0000000..ebc4de8
--- /dev/null
+++ b/Assets/_Game/Scripts/Editor/World/Map/MapLayoutEditorWindow.cs
@@ -0,0 +1,391 @@
+#if UNITY_EDITOR
+using System.Collections.Generic;
+using UnityEditor;
+using UnityEngine;
+using BaseGames.World.Map;
+
+namespace BaseGames.Editor.Map
+{
+ ///
+ /// 全局地图布局预览编辑器窗口(架构 15_MapShopModule §5.2)。
+ /// 菜单:BaseGames/Map/Map Layout Editor
+ ///
+ /// - 以格子坐标显示 MapDatabaseSO 中所有房间
+ /// - 鼠标滚轮缩放,中键(或 Alt+左键)拖拽平移
+ /// - 按区域自动着色,红色高亮配置错误(重叠/重复)
+ /// - 左键点击房间:Selection + Ping 对应 SO,并在 Inspector 中打开
+ ///
+ ///
+ public class MapLayoutEditorWindow : EditorWindow
+ {
+ [MenuItem("BaseGames/Map/Map Layout Editor", priority = 100)]
+ public static void ShowWindow() => GetWindow("地图布局编辑器");
+
+ // ── 状态 ──────────────────────────────────────────────────────────────
+
+ private MapDatabaseSO _database;
+ private Vector2 _panOffset;
+ private float _zoom = 24f; // px / 格
+ private bool _isDragging;
+ private Vector2 _dragStart;
+ private MapRoomDataSO _selectedRoom;
+ private List _validationErrors;
+ private HashSet _errorRoomIds;
+
+ /// 缓存的错误房间集合,由验证按钮点击时重建(防止 OnInspectorGUI 高频重建导致 GC)。
+ private readonly HashSet _cachedErrorRoomIds = new();
+
+ private readonly Dictionary _regionColors = new();
+ private int _paletteIndex;
+
+ // 缓存 GUIStyle,避免每帧每格重新分配(约 100 房间 × 60fps = 大量 GC 压力)
+ private GUIStyle _roomLabelStyle;
+ private GUIStyle _badgeBossStyle;
+ private GUIStyle _badgeNormalStyle;
+ private float _cachedZoomForStyle = -1f; // 用于检测缩放变化时重建 Style
+
+ // 区域配色方案(与 MapExploration 标注颜色视觉呼应)
+ private static readonly Color[] Palette =
+ {
+ new Color(0.30f, 0.60f, 1.00f),
+ new Color(0.30f, 1.00f, 0.55f),
+ new Color(1.00f, 0.60f, 0.25f),
+ new Color(0.85f, 0.30f, 0.90f),
+ new Color(1.00f, 0.88f, 0.20f),
+ new Color(0.25f, 0.90f, 1.00f),
+ new Color(1.00f, 0.45f, 0.45f),
+ new Color(0.50f, 1.00f, 0.80f),
+ };
+
+ // ── 主 GUI ────────────────────────────────────────────────────────────
+
+ private void OnGUI()
+ {
+ DrawToolbar();
+
+ if (_database == null)
+ {
+ EditorGUILayout.HelpBox("请在上方选择 MapDatabaseSO 资产以预览地图布局。", MessageType.Info);
+ return;
+ }
+
+ // 验证错误摘要
+ if (_validationErrors != null && _validationErrors.Count > 0)
+ {
+ int shown = Mathf.Min(3, _validationErrors.Count);
+ string msg = string.Join("\n", _validationErrors.GetRange(0, shown));
+ if (_validationErrors.Count > shown) msg += $"\n…(共 {_validationErrors.Count} 项)";
+ EditorGUILayout.HelpBox(msg, MessageType.Warning);
+ }
+
+ // 地图绘制区域
+ Rect mapRect = GUILayoutUtility.GetRect(
+ GUIContent.none, GUIStyle.none,
+ GUILayout.ExpandWidth(true), GUILayout.ExpandHeight(true));
+
+ HandleInput(mapRect);
+ DrawMapArea(mapRect);
+ }
+
+ // ── 工具栏 ────────────────────────────────────────────────────────────
+
+ private void DrawToolbar()
+ {
+ EditorGUILayout.BeginHorizontal(EditorStyles.toolbar);
+
+ var newDb = (MapDatabaseSO)EditorGUILayout.ObjectField(
+ _database, typeof(MapDatabaseSO), false, GUILayout.Width(240));
+ if (newDb != _database)
+ {
+ _database = newDb;
+ _validationErrors = null;
+ _errorRoomIds = null;
+ _regionColors.Clear();
+ _paletteIndex = 0;
+ _selectedRoom = null;
+ Repaint();
+ }
+
+ GUILayout.FlexibleSpace();
+
+ if (GUILayout.Button("验 证", EditorStyles.toolbarButton, GUILayout.Width(60)))
+ RunValidation();
+
+ if (GUILayout.Button("重置视图", EditorStyles.toolbarButton, GUILayout.Width(72)))
+ {
+ _zoom = 24f;
+ _panOffset = Vector2.zero;
+ Repaint();
+ }
+
+ EditorGUILayout.LabelField($"缩放 {_zoom:F0}px/格", GUILayout.Width(80));
+ EditorGUILayout.EndHorizontal();
+ }
+
+ // ── 输入处理 ──────────────────────────────────────────────────────────
+
+ private void HandleInput(Rect mapRect)
+ {
+ var e = Event.current;
+ if (!mapRect.Contains(e.mousePosition)) return;
+
+ switch (e.type)
+ {
+ case EventType.ScrollWheel:
+ _zoom = Mathf.Clamp(_zoom - e.delta.y * 1.8f, 6f, 96f);
+ e.Use();
+ Repaint();
+ break;
+
+ // 中键拖拽 或 Alt+左键拖拽 平移
+ case EventType.MouseDown when e.button == 2 || (e.button == 0 && e.alt):
+ _isDragging = true;
+ _dragStart = e.mousePosition;
+ e.Use();
+ break;
+
+ case EventType.MouseDrag when _isDragging:
+ _panOffset += e.mousePosition - _dragStart;
+ _dragStart = e.mousePosition;
+ e.Use();
+ Repaint();
+ break;
+
+ case EventType.MouseUp when _isDragging:
+ _isDragging = false;
+ e.Use();
+ break;
+
+ // 左键(非 Alt)点击选中房间
+ case EventType.MouseUp when e.button == 0 && !e.alt:
+ TrySelectRoom(e.mousePosition - mapRect.position, mapRect);
+ break;
+ }
+ }
+
+ // ── 地图绘制 ──────────────────────────────────────────────────────────
+
+ private void DrawMapArea(Rect mapRect)
+ {
+ if (_database?.AllRooms == null) return;
+
+ EnsureLabelStyles();
+ GUI.BeginClip(mapRect);
+
+ Vector2 origin = mapRect.size * 0.5f + _panOffset;
+
+ foreach (var room in _database.AllRooms)
+ {
+ if (room == null) continue;
+
+ Rect cell = RoomToClipRect(room, origin);
+
+ // 填充
+ bool hasError = _errorRoomIds != null && _errorRoomIds.Contains(room.RoomId);
+ Color regionColor = GetRegionColor(room.RegionId);
+ Color fillColor = hasError
+ ? new Color(1f, 0.15f, 0.15f, 0.55f)
+ : new Color(regionColor.r, regionColor.g, regionColor.b, 0.28f);
+ EditorGUI.DrawRect(cell, fillColor);
+
+ // 边框
+ Color borderColor = room == _selectedRoom
+ ? new Color(1f, 0.9f, 0.1f, 1f)
+ : new Color(regionColor.r, regionColor.g, regionColor.b, 0.85f);
+ float border = room == _selectedRoom ? 2f : 1f;
+ DrawBorder(cell, borderColor, border);
+
+ // 标签(缩放足够大时才显示,使用缓存 Style)
+ if (_zoom >= 18f)
+ GUI.Label(cell, string.IsNullOrEmpty(room.RoomId) ? "?" : room.RoomId, _roomLabelStyle);
+
+ // 特殊图标标记(Boss / 存档点 / 商店)
+ DrawRoomBadge(cell, room);
+ }
+
+ // 出口连线(缩放足够大时)
+ if (_zoom >= 12f)
+ DrawExitLines(origin);
+
+ GUI.EndClip();
+ }
+
+ ///
+ /// 按需重建 GUIStyle 缓存——仅当 zoom 发生变化(或首次调用)时重建,
+ /// 避免每帧每格 new GUIStyle(...) 造成大量 GC 分配。
+ ///
+ private void EnsureLabelStyles()
+ {
+ if (Mathf.Approximately(_cachedZoomForStyle, _zoom) && _roomLabelStyle != null) return;
+
+ _cachedZoomForStyle = _zoom;
+ int fontSize = Mathf.Clamp(Mathf.RoundToInt(_zoom * 0.4f), 8, 14);
+
+ _roomLabelStyle = new GUIStyle(EditorStyles.miniLabel)
+ {
+ alignment = TextAnchor.MiddleCenter,
+ wordWrap = false,
+ normal = { textColor = Color.white },
+ fontSize = fontSize,
+ };
+
+ int badgeSize = Mathf.Max(8, Mathf.RoundToInt(_zoom * 0.35f));
+
+ _badgeBossStyle = new GUIStyle(EditorStyles.miniLabel)
+ {
+ alignment = TextAnchor.UpperRight,
+ normal = { textColor = new Color(1f, 0.4f, 0.4f) },
+ fontSize = badgeSize,
+ };
+
+ _badgeNormalStyle = new GUIStyle(EditorStyles.miniLabel)
+ {
+ alignment = TextAnchor.UpperRight,
+ normal = { textColor = Color.cyan },
+ fontSize = badgeSize,
+ };
+ }
+
+ private void DrawRoomBadge(Rect cell, MapRoomDataSO room)
+ {
+ if (!room.IsBossRoom && !room.IsSavePoint && !room.IsShop) return;
+ if (cell.width < 8f) return;
+
+ string badge = room.IsBossRoom ? "★" :
+ room.IsSavePoint ? "♦" : "¥";
+ GUI.Label(cell, badge, room.IsBossRoom ? _badgeBossStyle : _badgeNormalStyle);
+ }
+
+ private void DrawExitLines(Vector2 origin)
+ {
+ if (_database?.AllRooms == null) return;
+ var lineColor = new Color(1f, 1f, 0.5f, 0.35f);
+
+ foreach (var room in _database.AllRooms)
+ {
+ if (room?.Exits == null) continue;
+ foreach (var exit in room.Exits)
+ {
+ var target = _database.GetRoom(exit.TargetRoomId);
+ if (target == null) continue;
+
+ Vector2 from = GridCenterToClip(room.GridPosition + room.GridSize / 2, origin);
+ Vector2 to = GridCenterToClip(target.GridPosition + target.GridSize / 2, origin);
+
+ DrawLine(from, to, lineColor, 1.5f);
+ }
+ }
+ }
+
+ // ── 公共 API ──────────────────────────────────────────────────────────
+
+ /// 由外部(如 MapDatabaseEditor)注入 Database,避免反射访问私有字段。
+ public void SetDatabase(MapDatabaseSO db)
+ {
+ _database = db;
+ _validationErrors = null;
+ _errorRoomIds = null;
+ _regionColors.Clear();
+ _paletteIndex = 0;
+ _selectedRoom = null;
+ Repaint();
+ }
+
+ // ── 房间选择 ──────────────────────────────────────────────────────────
+
+ private void TrySelectRoom(Vector2 clipPos, Rect mapRect)
+ {
+ if (_database?.AllRooms == null) return;
+
+ // 使用与 DrawMapArea 完全相同的 origin 公式,确保命中测试一致
+ Vector2 origin = mapRect.size * 0.5f + _panOffset;
+
+ foreach (var room in _database.AllRooms)
+ {
+ if (room == null) continue;
+ Rect cell = RoomToClipRect(room, origin);
+ if (cell.Contains(clipPos))
+ {
+ _selectedRoom = room;
+ Selection.activeObject = room;
+ EditorGUIUtility.PingObject(room);
+ Repaint();
+ return;
+ }
+ }
+ _selectedRoom = null;
+ Repaint();
+ }
+
+ // ── 工具方法 ──────────────────────────────────────────────────────────
+
+ private Rect RoomToClipRect(MapRoomDataSO room, Vector2 origin)
+ {
+ float x = origin.x + room.GridPosition.x * _zoom;
+ // Y 轴:格子坐标 Y 向上,屏幕 Y 向下,需翻转
+ float y = origin.y - (room.GridPosition.y + room.GridSize.y) * _zoom;
+ return new Rect(x, y, room.GridSize.x * _zoom, room.GridSize.y * _zoom);
+ }
+
+ private Vector2 GridCenterToClip(Vector2Int gridPos, Vector2 origin)
+ => new Vector2(origin.x + gridPos.x * _zoom, origin.y - gridPos.y * _zoom);
+
+ private Color GetRegionColor(string regionId)
+ {
+ if (string.IsNullOrEmpty(regionId)) return new Color(0.55f, 0.55f, 0.55f);
+ if (!_regionColors.TryGetValue(regionId, out var c))
+ {
+ c = Palette[_paletteIndex % Palette.Length];
+ _paletteIndex++;
+ _regionColors[regionId] = c;
+ }
+ return c;
+ }
+
+ private void RunValidation()
+ {
+ _validationErrors = _database?.ValidateAll() ?? new List();
+ _errorRoomIds = new HashSet();
+ if (_validationErrors != null && _database?.AllRooms != null)
+ foreach (var err in _validationErrors)
+ foreach (var r in _database.AllRooms)
+ if (r != null && err.Contains($"'{r.RoomId}'"))
+ _errorRoomIds.Add(r.RoomId);
+ Repaint();
+ }
+
+ // ── 绘图辅助 ──────────────────────────────────────────────────────────
+
+ private static void DrawBorder(Rect r, Color color, float t)
+ {
+ var prev = GUI.color;
+ GUI.color = color;
+ GUI.DrawTexture(new Rect(r.x, r.y, r.width, t), EditorGUIUtility.whiteTexture);
+ GUI.DrawTexture(new Rect(r.x, r.yMax - t, r.width, t), EditorGUIUtility.whiteTexture);
+ GUI.DrawTexture(new Rect(r.x, r.y, t, r.height), EditorGUIUtility.whiteTexture);
+ GUI.DrawTexture(new Rect(r.xMax - t, r.y, t, r.height), EditorGUIUtility.whiteTexture);
+ GUI.color = prev;
+ }
+
+ // 用细矩形近似绘制连线(无需 GL/Handles)
+ private static void DrawLine(Vector2 a, Vector2 b, Color color, float thickness)
+ {
+ Vector2 dir = b - a;
+ float len = dir.magnitude;
+ if (len < 1f) return;
+
+ float angle = Mathf.Atan2(dir.y, dir.x) * Mathf.Rad2Deg;
+ Vector2 mid = (a + b) * 0.5f;
+
+ var prevMatrix = GUI.matrix;
+ var prevColor = GUI.color;
+ GUI.color = color;
+ GUIUtility.RotateAroundPivot(angle, mid);
+ GUI.DrawTexture(new Rect(mid.x - len * 0.5f, mid.y - thickness * 0.5f, len, thickness),
+ EditorGUIUtility.whiteTexture);
+ GUI.matrix = prevMatrix;
+ GUI.color = prevColor;
+ }
+ }
+}
+#endif
diff --git a/Assets/_Game/Scripts/World/Map/IMapService.cs b/Assets/_Game/Scripts/World/Map/IMapService.cs
index 1d06705..7c6d88f 100644
--- a/Assets/_Game/Scripts/World/Map/IMapService.cs
+++ b/Assets/_Game/Scripts/World/Map/IMapService.cs
@@ -13,5 +13,14 @@ namespace BaseGames.World.Map
/// 玩家当前所在区域 ID(最近一次 EVT_RegionChanged 对应的值)。
string CurrentRegionId { get; }
+
+ /// 已踏入的房间总数。
+ int ExploredRoomCount { get; }
+
+ /// 探索进度 0~1(已探索房间数 / 数据库总房间数)。
+ float GetExplorationProgress();
+
+ /// 返回属于指定区域的所有房间数据;regionId 为空时返回空数组。
+ MapRoomDataSO[] GetRoomsByRegion(string regionId);
}
}
diff --git a/Assets/_Game/Scripts/World/Map/IPinService.cs b/Assets/_Game/Scripts/World/Map/IPinService.cs
new file mode 100644
index 0000000..210683f
--- /dev/null
+++ b/Assets/_Game/Scripts/World/Map/IPinService.cs
@@ -0,0 +1,31 @@
+using System.Collections.Generic;
+using BaseGames.Core.Save;
+
+namespace BaseGames.World.Map
+{
+ ///
+ /// 玩家地图标记服务接口。
+ ///
+ /// MapPanel 依赖此接口而非具体组件,
+ /// 允许替换实现(云存档、多存档槽等扩展场景)。
+ ///
+ /// 通过 注册与获取。
+ ///
+ public interface IPinService
+ {
+ /// 当前所有地图标记(只读视图)。
+ IReadOnlyList Pins { get; }
+
+ ///
+ /// 每次增删标记时自增的版本号,供消费方进行脏检查。
+ ///
+ int PinsVersion { get; }
+
+ /// 创建并添加一个地图标记。
+ MapPin CreatePin(string roomId, float normX, float normY,
+ PinType type = PinType.Marker, string note = "");
+
+ /// 移除指定地图标记。
+ void RemovePin(MapPin pin);
+ }
+}
diff --git a/Assets/_Game/Scripts/World/Map/IPlayerPositionProvider.cs b/Assets/_Game/Scripts/World/Map/IPlayerPositionProvider.cs
new file mode 100644
index 0000000..49d09f0
--- /dev/null
+++ b/Assets/_Game/Scripts/World/Map/IPlayerPositionProvider.cs
@@ -0,0 +1,28 @@
+using System;
+using UnityEngine;
+
+namespace BaseGames.World.Map
+{
+ ///
+ /// 玩家地图位置信息提供者接口。
+ ///
+ /// MapPanel / MinimapHUD 依赖此接口而非具体组件,
+ /// 支持替换实现(单人、多人、观察者模式、重播系统等场景)。
+ ///
+ /// 通过 注册与获取。
+ ///
+ public interface IPlayerPositionProvider
+ {
+ /// 玩家当前所在房间 ID;未在任何已知房间内时为 null 或空字符串。
+ string CurrentRoomId { get; }
+
+ ///
+ /// 玩家在当前房间内的归一化坐标(0~1,基于世界坐标精确插值)。
+ /// 每帧更新,可用于平滑移动图标。
+ ///
+ Vector2 NormalizedPositionInRoom { get; }
+
+ /// 玩家进入新房间时触发(参数为新房间 ID)。
+ event Action OnRoomChanged;
+ }
+}
diff --git a/Assets/_Game/Scripts/World/Map/MapGridConstants.cs b/Assets/_Game/Scripts/World/Map/MapGridConstants.cs
new file mode 100644
index 0000000..30caa7c
--- /dev/null
+++ b/Assets/_Game/Scripts/World/Map/MapGridConstants.cs
@@ -0,0 +1,12 @@
+namespace BaseGames.World.Map
+{
+ ///
+ /// 地图格子渲染常量。
+ /// 统一管理像素/格比例,避免魔法数字分散在多个文件中。
+ ///
+ public static class MapGridConstants
+ {
+ /// 全屏地图每格像素数(MapPanel / MapRoomCellUI 使用)。
+ public const float FullMapCellPixels = 32f;
+ }
+}
diff --git a/Assets/_Game/Scripts/World/Map/MapInputHandler.cs b/Assets/_Game/Scripts/World/Map/MapInputHandler.cs
new file mode 100644
index 0000000..2898d65
--- /dev/null
+++ b/Assets/_Game/Scripts/World/Map/MapInputHandler.cs
@@ -0,0 +1,78 @@
+using UnityEngine;
+using UnityEngine.EventSystems;
+using UnityEngine.UI;
+
+namespace BaseGames.World.Map
+{
+ ///
+ /// 全屏地图输入处理器(架构 15_MapShopModule §1.3.1)。
+ /// 挂在与 MapPanel 相同的 GameObject 上(MapPanel OnEnable/OnDisable 联动启停)。
+ ///
+ /// - 鼠标滚轮缩放(以鼠标位置为缩放中心)
+ /// - 键盘方向键 / WASD 平移
+ ///
+ ///
+ [RequireComponent(typeof(MapPanel))]
+ public class MapInputHandler : MonoBehaviour, IScrollHandler
+ {
+ [SerializeField] private ScrollRect _scrollRect;
+ [SerializeField] private RectTransform _zoomTarget; // 通常为 _roomContainer(格子根节点)
+
+ [Header("缩放")]
+ [SerializeField, Range(0.2f, 1f)] private float _zoomMin = 0.4f;
+ [SerializeField, Range(1f, 5f)] private float _zoomMax = 3.0f;
+ [SerializeField, Range(0.05f, 0.5f)] private float _zoomStep = 0.12f;
+
+ [Header("键盘平移")]
+ [SerializeField] private float _keyPanSpeed = 600f; // px / 秒
+
+ private float _zoom = 1f;
+
+ private void OnEnable()
+ {
+ // 重新激活时还原缩放,避免上次关闭时的残留状态
+ if (_zoomTarget != null)
+ _zoom = _zoomTarget.localScale.x;
+ }
+
+ private void Update()
+ {
+ if (_scrollRect == null) return;
+
+ float h = Input.GetAxisRaw("Horizontal");
+ float v = Input.GetAxisRaw("Vertical");
+ if (h == 0 && v == 0) return;
+
+ var delta = new Vector2(h, v) * (_keyPanSpeed * Time.unscaledDeltaTime);
+ _scrollRect.content.anchoredPosition += delta;
+ }
+
+ // ── 鼠标滚轮缩放 ─────────────────────────────────────────────────────
+
+ public void OnScroll(PointerEventData eventData)
+ {
+ if (_zoomTarget == null) return;
+
+ float newZoom = Mathf.Clamp(
+ _zoom + eventData.scrollDelta.y * _zoomStep,
+ _zoomMin, _zoomMax);
+
+ if (Mathf.Approximately(newZoom, _zoom)) return;
+
+ // 将鼠标屏幕坐标转为 zoomTarget 本地坐标(缩放前)
+ RectTransformUtility.ScreenPointToLocalPointInRectangle(
+ _zoomTarget, eventData.position, eventData.pressEventCamera, out Vector2 pivotBefore);
+
+ _zoom = newZoom;
+ _zoomTarget.localScale = new Vector3(_zoom, _zoom, 1f);
+
+ // 将同一屏幕点再次映射(缩放后),计算偏移量保持鼠标下方内容不动
+ RectTransformUtility.ScreenPointToLocalPointInRectangle(
+ _zoomTarget, eventData.position, eventData.pressEventCamera, out Vector2 pivotAfter);
+
+ // pivotAfter - pivotBefore 是 zoomTarget 本地空间的偏差,需转为父空间偏差
+ Vector2 offset = pivotAfter - pivotBefore;
+ _zoomTarget.anchoredPosition += offset * _zoom;
+ }
+ }
+}
diff --git a/Assets/_Game/Scripts/World/Map/MapManager.cs b/Assets/_Game/Scripts/World/Map/MapManager.cs
index 082c5ac..3df2b4d 100644
--- a/Assets/_Game/Scripts/World/Map/MapManager.cs
+++ b/Assets/_Game/Scripts/World/Map/MapManager.cs
@@ -1,4 +1,5 @@
using System.Collections.Generic;
+using System.Linq;
using UnityEngine;
using BaseGames.Core;
using BaseGames.Core.Events;
@@ -28,6 +29,7 @@ namespace BaseGames.World.Map
private HashSet _exploredRooms = new();
private HashSet _mappedRooms = new();
private string _currentRegionId;
+ private int _totalRoomCount = -1; // -1 = 未缓存;OnLoad 后重置
private readonly CompositeDisposable _subs = new();
private void Awake()
@@ -58,8 +60,9 @@ namespace BaseGames.World.Map
public void OnLoad(SaveData data)
{
- _exploredRooms = data.Map.ExploredRooms != null ? new HashSet(data.Map.ExploredRooms) : new HashSet();
- _mappedRooms = data.Map.MappedRooms != null ? new HashSet(data.Map.MappedRooms) : new HashSet();
+ _exploredRooms = data.Map.ExploredRooms != null ? new HashSet(data.Map.ExploredRooms) : new HashSet();
+ _mappedRooms = data.Map.MappedRooms != null ? new HashSet(data.Map.MappedRooms) : new HashSet();
+ _totalRoomCount = -1; // 强制下次调用 GetExplorationProgress 时重新计数
}
// ── 事件驱动房间发现 ──────────────────────────────────────────────────
@@ -94,8 +97,23 @@ namespace BaseGames.World.Map
public bool IsExplored(string roomId) => _exploredRooms.Contains(roomId);
public bool IsMapped(string roomId) => _mappedRooms.Contains(roomId);
public string CurrentRegionId => _currentRegionId;
+ public MapDatabaseSO Database => _database;
+ public int ExploredRoomCount => _exploredRooms.Count;
- public MapDatabaseSO Database => _database;
+ public float GetExplorationProgress()
+ {
+ if (_database?.AllRooms == null || _database.AllRooms.Length == 0) return 0f;
+ if (_totalRoomCount < 0)
+ _totalRoomCount = _database.AllRooms.Count(r => r != null);
+ return _totalRoomCount > 0 ? Mathf.Clamp01((float)_exploredRooms.Count / _totalRoomCount) : 0f;
+ }
+
+ public MapRoomDataSO[] GetRoomsByRegion(string regionId)
+ {
+ if (string.IsNullOrEmpty(regionId) || _database?.AllRooms == null)
+ return System.Array.Empty();
+ return _database.AllRooms.Where(r => r != null && r.RegionId == regionId).ToArray();
+ }
private void OnDestroy()
{
diff --git a/Assets/_Game/Scripts/World/Map/MapPanel.cs b/Assets/_Game/Scripts/World/Map/MapPanel.cs
index 59b8459..18a516b 100644
--- a/Assets/_Game/Scripts/World/Map/MapPanel.cs
+++ b/Assets/_Game/Scripts/World/Map/MapPanel.cs
@@ -2,7 +2,6 @@ using System;
using System.Collections.Generic;
using UnityEngine;
using UnityEngine.UI;
-using UnityEngine.EventSystems;
using TMPro;
using BaseGames.Core;
using BaseGames.Core.Events;
@@ -10,16 +9,17 @@ using BaseGames.Core.Save;
namespace BaseGames.World.Map
{
- /// 房间可见性三级状态:未知 / 已踏入 / 已标注(购买地图碎片)。
- public enum RoomVisibility { Unknown, Explored, Mapped }
-
///
/// 全屏地图 UI 面板(架构 15_MapShopModule §1.3)。
/// 由 UIManager PanelStack 管理开关;OnEnable 时重建格子并订阅更新事件。
+ ///
+ /// 依赖项均通过 获取(、
+ /// 、),
+ /// 不持有任何具体 MonoBehaviour 的 SerializeField 引用,实现架构解耦。
+ ///
///
public class MapPanel : MonoBehaviour
{
- [SerializeField] private MapDatabaseSO _database;
[SerializeField] private RectTransform _roomContainer; // 格子图放置根节点
[SerializeField] private MapRoomCellUI _cellPrefab; // 地图格子预制
[SerializeField] private Image _exitConnectorPrefab; // 出口连接线预制(小矩形 Image)
@@ -37,13 +37,11 @@ namespace BaseGames.World.Map
[SerializeField] private Color _colorUnknown = Color.black;
[Header("玩家位置")]
- [SerializeField] private MapPlayerTracker _playerTracker; // 挂在 Player 上的追踪器
- [SerializeField] private Image _playerIconImg; // _roomContainer 内的玩家图标
+ [SerializeField] private Image _playerIconImg; // _roomContainer 内的玩家图标
[Header("地图标记")]
[SerializeField] private Image _pinPrefab;
- [SerializeField] private PinSpriteEntry[] _pinSprites; // PinType → 对应 Sprite
- [SerializeField] private MapPinManager _pinManager;
+ [SerializeField] private PinSpriteEntry[] _pinSprites; // PinType → 对应 Sprite(在 Inspector 中配置)
[Header("Tooltip")]
[SerializeField] private GameObject _tooltipPanel;
@@ -52,16 +50,34 @@ namespace BaseGames.World.Map
[Header("Event Channels")]
[SerializeField] private StringEventChannelSO _onMapUpdated; // 房间发现/标注时刷新
- private Dictionary _cells = new();
- private List _pinImages = new();
- private List _exitImages = new();
+ private Dictionary _cells = new();
+ private List _pinImages = new();
+ private List _exitImages= new();
private string _highlightedRoomId;
- private IMapService _mapSvc; // 面板活跃期间缓存,避免高频 ServiceLocator 查询
- private readonly CompositeDisposable _subs = new();
+ private string _lastIconRoomId; // LateUpdate 脏标记
+ private Vector2 _lastIconNormPos; // LateUpdate 脏标记
+ private int _lastPinVersion = -1;
+
+ private IMapService _mapSvc;
+ private IPlayerPositionProvider _playerProvider;
+ private IPinService _pinService;
+ private Dictionary _pinSpriteDict;
+ private readonly CompositeDisposable _subs = new();
+
+ private void Awake()
+ {
+ // 预构建 PinType → Sprite 字典,将 GetPinSprite 从 O(N) 降至 O(1)
+ _pinSpriteDict = new Dictionary();
+ if (_pinSprites != null)
+ foreach (var e in _pinSprites)
+ _pinSpriteDict[e.PinType] = e.Sprite;
+ }
private void OnEnable()
{
- _mapSvc = ServiceLocator.GetOrDefault();
+ _mapSvc = ServiceLocator.GetOrDefault();
+ _playerProvider = ServiceLocator.GetOrDefault();
+ _pinService = ServiceLocator.GetOrDefault();
// 首次打开时建立格子;后续打开只刷新探索状态,跳过重复 Instantiate
if (_cells.Count == 0)
@@ -78,7 +94,11 @@ namespace BaseGames.World.Map
private void OnDisable()
{
_subs.Clear();
- _mapSvc = null;
+ _mapSvc = null;
+ _playerProvider = null;
+ _pinService = null;
+ _lastIconRoomId = null;
+ _lastIconNormPos = Vector2.zero;
HideTooltip();
}
@@ -93,6 +113,12 @@ namespace BaseGames.World.Map
private void LateUpdate()
{
+ if (_playerProvider == null || _playerIconImg == null) return;
+ // 脏标记:位置/房间未变化时跳过 RectTransform 读写,消除无效每帧开销
+ if (_playerProvider.CurrentRoomId == _lastIconRoomId &&
+ _playerProvider.NormalizedPositionInRoom == _lastIconNormPos) return;
+ _lastIconRoomId = _playerProvider.CurrentRoomId;
+ _lastIconNormPos = _playerProvider.NormalizedPositionInRoom;
UpdatePlayerIcon();
}
@@ -102,7 +128,7 @@ namespace BaseGames.World.Map
foreach (var (roomId, cell) in _cells)
{
if (cell == null) continue;
- cell.SetVisibility(GetVisibility(_mapSvc, roomId));
+ cell.SetVisibility(_mapSvc.GetVisibility(roomId));
}
}
@@ -110,13 +136,15 @@ namespace BaseGames.World.Map
private void BuildGrid()
{
- if (_database?.AllRooms == null) return;
- foreach (var room in _database.AllRooms)
+ var db = _mapSvc?.Database;
+ if (db?.AllRooms == null) return;
+ foreach (var room in db.AllRooms)
{
if (room == null) continue;
var cell = Instantiate(_cellPrefab, _roomContainer);
- cell.Setup(room, GetVisibility(_mapSvc, room.RoomId), ChooseIcon(room),
+ cell.Setup(room, _mapSvc.GetVisibility(room.RoomId), ChooseIcon(room),
ShowTooltip, HideTooltip);
+ cell.SetColors(_colorExplored, _colorMapped, _colorUnknown);
_cells[room.RoomId] = cell;
}
DrawExits();
@@ -125,16 +153,18 @@ namespace BaseGames.World.Map
/// 为每条出口在格子坐标处实例化一个小矩形连接线图像。
private void DrawExits()
{
- if (_exitConnectorPrefab == null || _database?.AllRooms == null) return;
+ var db = _mapSvc?.Database;
+ if (_exitConnectorPrefab == null || db?.AllRooms == null) return;
ClearExits();
- const float px = 32f;
- foreach (var room in _database.AllRooms)
+ foreach (var room in db.AllRooms)
{
if (room?.Exits == null) continue;
foreach (var exit in room.Exits)
{
var conn = Instantiate(_exitConnectorPrefab, _roomContainer);
- conn.rectTransform.anchoredPosition = new Vector2(exit.ExitGridPos.x * px, exit.ExitGridPos.y * px);
+ conn.rectTransform.anchoredPosition = new Vector2(
+ exit.ExitGridPos.x * MapGridConstants.FullMapCellPixels,
+ exit.ExitGridPos.y * MapGridConstants.FullMapCellPixels);
bool vertical = exit.Direction == ExitDirection.Up || exit.Direction == ExitDirection.Down;
conn.rectTransform.sizeDelta = vertical ? new Vector2(16f, 8f) : new Vector2(8f, 16f);
_exitImages.Add(conn);
@@ -152,30 +182,30 @@ namespace BaseGames.World.Map
private void OnMapUpdated(string roomId)
{
if (_cells.TryGetValue(roomId, out var cell))
- cell.SetVisibility(GetVisibility(_mapSvc, roomId));
+ cell.SetVisibility(_mapSvc.GetVisibility(roomId));
}
// ── 玩家位置图标 ──────────────────────────────────────────────────────
private void UpdatePlayerIcon()
{
- if (_playerIconImg == null || _playerTracker == null) return;
- var roomId = _playerTracker.CurrentRoomId;
+ if (_playerIconImg == null || _playerProvider == null) return;
+ var roomId = _playerProvider.CurrentRoomId;
if (string.IsNullOrEmpty(roomId) || !_cells.TryGetValue(roomId, out var cell))
{
_playerIconImg.enabled = false;
UpdateCellHighlight(null);
return;
}
- var cellRT = cell.GetComponent();
_playerIconImg.sprite = _iconPlayerPos;
_playerIconImg.enabled = true;
_playerIconImg.rectTransform.anchoredPosition =
- cellRT.anchoredPosition
- + Vector2.Scale(_playerTracker.NormalizedPositionInRoom, cellRT.sizeDelta);
+ cell.RT.anchoredPosition
+ + Vector2.Scale(_playerProvider.NormalizedPositionInRoom, cell.RT.sizeDelta);
UpdateCellHighlight(roomId);
}
- // ── 当前房间高亮 & ScrollRect 居中 ──────────────────────────────────────
+
+ // ── 当前房间高亮 & ScrollRect 居中 ─────────────────────────────────
/// 切换高亮描边:取消旧房间高亮,激活新房间高亮。
private void UpdateCellHighlight(string roomId)
@@ -191,20 +221,20 @@ namespace BaseGames.World.Map
/// 面板打开时将 ScrollRect 视口居中到玩家当前所在房间。
private void CenterOnCurrentRoom()
{
- if (_scrollRect == null || _playerTracker == null) return;
- var roomId = _playerTracker.CurrentRoomId;
+ if (_scrollRect == null || _playerProvider == null) return;
+ var roomId = _playerProvider.CurrentRoomId;
if (string.IsNullOrEmpty(roomId) || !_cells.TryGetValue(roomId, out var cell)) return;
- Canvas.ForceUpdateCanvases();
+ // 仅重建 ScrollRect.content 的布局,避免全 Canvas 树强制刷新
+ LayoutRebuilder.ForceRebuildLayoutImmediate(_scrollRect.content);
var content = _scrollRect.content;
var viewport = _scrollRect.viewport != null
? _scrollRect.viewport
: (RectTransform)_scrollRect.transform;
- var cellRT = cell.GetComponent();
// 将 cell 中心转换到 content 本地坐标系
- Vector2 cellWorldCenter = cellRT.TransformPoint(cellRT.rect.center);
+ Vector2 cellWorldCenter = cell.RT.TransformPoint(cell.RT.rect.center);
Vector2 cellLocal = content.InverseTransformPoint(cellWorldCenter);
// 距 content 左下角的距离(pivot 无关)
@@ -221,22 +251,28 @@ namespace BaseGames.World.Map
_scrollRect.normalizedPosition = new Vector2(normX, normY);
}
+
// ── 地图标记渲染 ──────────────────────────────────────────────────────
private void RenderPins()
{
+ if (_pinService == null) return;
+
+ // 版本号脏检查:Pin 集合未变化时跳过重绘,避免无效 Instantiate
+ if (_pinService.PinsVersion == _lastPinVersion && _pinImages.Count > 0) return;
+ _lastPinVersion = _pinService.PinsVersion;
+
ClearPins();
- if (_pinPrefab == null || _pinManager == null) return;
- foreach (var pin in _pinManager.Pins)
+ if (_pinPrefab == null) return;
+ foreach (var pin in _pinService.Pins)
{
if (!_cells.TryGetValue(pin.RoomId, out var cell)) continue;
var img = Instantiate(_pinPrefab, _roomContainer);
img.sprite = GetPinSprite((PinType)pin.PinTypeInt);
- var cellRT = cell.GetComponent();
img.rectTransform.anchoredPosition =
- cellRT.anchoredPosition + new Vector2(
- pin.NormalizedPosX * cellRT.sizeDelta.x,
- pin.NormalizedPosY * cellRT.sizeDelta.y);
+ cell.RT.anchoredPosition + new Vector2(
+ pin.NormalizedPosX * cell.RT.sizeDelta.x,
+ pin.NormalizedPosY * cell.RT.sizeDelta.y);
_pinImages.Add(img);
}
}
@@ -261,15 +297,6 @@ namespace BaseGames.World.Map
// ── 辅助方法 ──────────────────────────────────────────────────────────
- /// 按优先级推导可见性:Explored > Mapped > Unknown。
- private static RoomVisibility GetVisibility(IMapService svc, string roomId)
- {
- if (svc == null) return RoomVisibility.Unknown;
- if (svc.IsExplored(roomId)) return RoomVisibility.Explored;
- if (svc.IsMapped(roomId)) return RoomVisibility.Mapped;
- return RoomVisibility.Unknown;
- }
-
private Sprite ChooseIcon(MapRoomDataSO room)
{
if (room.MapIconOverride != null) return room.MapIconOverride;
@@ -280,95 +307,6 @@ namespace BaseGames.World.Map
}
private Sprite GetPinSprite(PinType type)
- {
- if (_pinSprites != null)
- foreach (var e in _pinSprites)
- if (e.PinType == type) return e.Sprite;
- return null;
- }
- }
-
- [Serializable]
- public struct PinSpriteEntry
- {
- public PinType PinType;
- public Sprite Sprite;
- }
-
- // ─── 单个地图格子 UI ─────────────────────────────────────────────────────────
-
- /// 地图面板中每个房间对应的格子 UI 组件。
- public class MapRoomCellUI : MonoBehaviour, IPointerEnterHandler, IPointerExitHandler
- {
- [SerializeField] private Image _bg;
- [SerializeField] private Image _icon;
- [SerializeField] private RawImage _outlineImage; // 可选:房间非矩形轮廓纹理
- [SerializeField] private Image _highlight; // 可选:当前房间高亮描边(玩家所在时激活)
-
- private static readonly Color ColExplored = Color.white;
- private static readonly Color ColMapped = new Color(0.45f, 0.45f, 0.45f, 1f);
- private static readonly Color ColUnknown = Color.black;
-
- private string _displayName;
- private Action _onHover;
- private Action _onHoverExit;
-
- /// 初始化格子(位置、可见性、图标、Tooltip 回调)。
- public void Setup(MapRoomDataSO room, RoomVisibility visibility, Sprite icon,
- Action onHover = null, Action onHoverExit = null)
- {
- _displayName = room.DisplayName;
- _onHover = onHover;
- _onHoverExit = onHoverExit;
-
- if (TryGetComponent(out var rt))
- {
- rt.anchoredPosition = new Vector2(room.GridPosition.x * 32f, room.GridPosition.y * 32f);
- rt.sizeDelta = new Vector2(room.GridSize.x * 32f, room.GridSize.y * 32f);
- }
-
- // 房间轮廓纹理(非矩形形状,覆盖在矩形背景上方)
- if (_outlineImage != null)
- {
- _outlineImage.texture = room.RoomOutlineTex;
- _outlineImage.enabled = room.RoomOutlineTex != null;
- }
-
- SetVisibility(visibility);
-
- if (_icon != null)
- {
- _icon.sprite = icon;
- _icon.enabled = icon != null;
- }
- }
-
- public void SetVisibility(RoomVisibility v)
- {
- if (_bg == null) return;
- _bg.color = v switch
- {
- RoomVisibility.Explored => ColExplored,
- RoomVisibility.Mapped => ColMapped,
- _ => ColUnknown,
- };
- }
-
- /// 向后兼容:直接传 bool 时等同于 Explored / Unknown。
- public void SetDiscovered(bool v)
- => SetVisibility(v ? RoomVisibility.Explored : RoomVisibility.Unknown);
-
- /// 激活/取消当前房间高亮描边。
- public void SetHighlight(bool v)
- {
- if (_highlight != null) _highlight.enabled = v;
- }
-
- public void OnPointerEnter(PointerEventData _)
- {
- if (!string.IsNullOrEmpty(_displayName)) _onHover?.Invoke(_displayName);
- }
-
- public void OnPointerExit(PointerEventData _) => _onHoverExit?.Invoke();
+ => _pinSpriteDict.TryGetValue(type, out var s) ? s : null;
}
}
diff --git a/Assets/_Game/Scripts/World/Map/MapPin.cs b/Assets/_Game/Scripts/World/Map/MapPin.cs
index d2b147d..98d4f95 100644
--- a/Assets/_Game/Scripts/World/Map/MapPin.cs
+++ b/Assets/_Game/Scripts/World/Map/MapPin.cs
@@ -1,3 +1,5 @@
+// NOTE: 此文件包含 MapPinManager 类,但文件名为 MapPin.cs(历史遗留,Unity .meta 绑定限制不可安全重命名)。
+// 如需搜索,请搜索 "MapPinManager" 类名,而非文件名。
using System.Collections.Generic;
using UnityEngine;
using BaseGames.Core;
@@ -5,28 +7,55 @@ using BaseGames.Core.Save;
namespace BaseGames.World.Map
{
+ /// 标记类型与显示精灵的映射表项(从 MapPanel 移入,与数据同文件管理)。
+ [System.Serializable]
+ public class PinSpriteEntry
+ {
+ public PinType PinType;
+ public Sprite Sprite;
+ }
+
///
/// 地图自定义标记管理器(架构 15_MapShopModule §1.5)。
- /// 实现 ISaveable,通过 SaveManager 持久化玩家地图标记。
+ /// 实现 和 ,通过 ServiceLocator 对外暴露。
+ ///
+ /// 每次 Pin 集合变化时自增,外部消费方(MapPanel)
+ /// 可通过版本号判断是否需要重绘,避免无效 Instantiate。
+ ///
/// MapPin/PinType 数据类定义在 SaveData.cs(BaseGames.Core.Save)中,避免循环依赖。
///
- public class MapPinManager : MonoBehaviour, ISaveable
+ public class MapPinManager : MonoBehaviour, ISaveable, IPinService
{
private List _pins = new();
- public IReadOnlyList Pins => _pins;
+ public IReadOnlyList Pins => _pins;
- private void OnEnable() => ServiceLocator.GetOrDefault()?.Register(this);
- private void OnDisable() => ServiceLocator.GetOrDefault()?.Unregister(this);
+ /// 每次 Pin 集合发生变化时自增;外部消费方通过此版本号实现脏检查。
+ public int PinsVersion { get; private set; }
+
+ private void OnEnable()
+ {
+ ServiceLocator.GetOrDefault()?.Register(this);
+ ServiceLocator.Register(this);
+ }
+
+ private void OnDisable()
+ {
+ ServiceLocator.GetOrDefault()?.Unregister(this);
+ ServiceLocator.Unregister(this);
+ }
// ── 公共 API ──────────────────────────────────────────────────────────
public void AddPin(MapPin pin)
{
- if (pin != null) _pins.Add(pin);
+ if (pin != null) { _pins.Add(pin); PinsVersion++; }
}
- public void RemovePin(MapPin pin) => _pins.Remove(pin);
+ public void RemovePin(MapPin pin)
+ {
+ if (_pins.Remove(pin)) PinsVersion++;
+ }
/// 便捷方法:用枚举类型创建并添加标记。
public MapPin CreatePin(string roomId, float normX, float normY,
@@ -47,6 +76,11 @@ namespace BaseGames.World.Map
// ── ISaveable ─────────────────────────────────────────────────────────
public void OnSave(SaveData data) => data.Map.Pins = _pins;
- public void OnLoad(SaveData data) => _pins = data.Map.Pins ?? new List();
+
+ public void OnLoad(SaveData data)
+ {
+ _pins = data.Map.Pins ?? new List();
+ PinsVersion++; // 加载存档后通知消费方重绘
+ }
}
}
diff --git a/Assets/_Game/Scripts/World/Map/MapPlayerTracker.cs b/Assets/_Game/Scripts/World/Map/MapPlayerTracker.cs
index ee8f596..5273032 100644
--- a/Assets/_Game/Scripts/World/Map/MapPlayerTracker.cs
+++ b/Assets/_Game/Scripts/World/Map/MapPlayerTracker.cs
@@ -1,49 +1,126 @@
+using System;
+using System.Collections.Generic;
using UnityEngine;
+using BaseGames.Core;
namespace BaseGames.World.Map
{
///
- /// 将玩家世界坐标转换为地图格子坐标,供 MapPanel 显示玩家位置图标(架构 15_MapShopModule §1.4)。
+ /// 将玩家世界坐标转换为地图格子坐标,供 MapPanel / MinimapHUD 显示玩家位置图标。
/// 挂在 Player GameObject 上(LateUpdate 每帧计算)。
+ ///
+ /// 性能:首次启动时建立 Dictionary<Vector2Int, string> 空间索引,
+ /// LateUpdate 房间判定为 O(1) 哈希查找。归一化位置每帧从世界坐标精确插值,
+ /// 确保图标跟随玩家平滑移动,而非以格子步长离散跳动。
+ ///
+ /// 通过 注册为 。
///
- public class MapPlayerTracker : MonoBehaviour
+ public class MapPlayerTracker : MonoBehaviour, IPlayerPositionProvider
{
[SerializeField] private Transform _playerTransform;
- [SerializeField] private MapDatabaseSO _database;
+ [SerializeField] private MapDatabaseSO _databaseOverride; // 可选:直接指定;留空则从 IMapService 获取
[Header("世界坐标 → 格子坐标换算参数")]
- [SerializeField] private float _worldUnitsPerCell = 18f; // 1 格 = N 世界单位
+ [Tooltip("1 格对应的世界单位数。请在关卡编辑器中测量房间实际尺寸后填入,确保与关卡设计对齐。")]
+ [SerializeField] private float _worldUnitsPerCell = 18f;
- /// 玩家当前所在房间 ID(用于地图高亮当前房间)。
+ /// 玩家当前所在房间 ID;未在任何已知房间内时为 null。
public string CurrentRoomId { get; private set; }
- /// 玩家在当前格子房间内的归一化坐标(0~1)。
+ ///
+ /// 玩家在当前房间内的归一化坐标(0~1)。
+ /// 基于世界坐标精确插值,每帧更新,可用于平滑移动图标。
+ ///
public Vector2 NormalizedPositionInRoom { get; private set; }
- // 缓存上一帧的格子坐标;格子不变则跳过 O(N) 搜索
- private Vector2Int _lastCellPos = new Vector2Int(int.MinValue, int.MinValue);
+ /// 玩家进入新房间时触发(参数为新房间 ID)。
+ public event Action OnRoomChanged;
- private void LateUpdate()
+ private MapDatabaseSO _database;
+ private Dictionary _cellToRoomId;
+ private MapRoomDataSO _currentRoom; // 当前房间数据缓存,避免 LateUpdate 每帧 GetRoom 查找
+ private Vector2Int _lastCellPos = new Vector2Int(int.MinValue, int.MinValue);
+
+ private void Awake()
{
- if (_playerTransform == null || _database?.AllRooms == null) return;
+ // 单例保护:同一时刻只允许一个 IPlayerPositionProvider 存在
+ if (ServiceLocator.GetOrDefault() != null) return;
+ ServiceLocator.Register(this);
+ }
- Vector2Int cellPos = WorldToCell(_playerTransform.position);
- if (cellPos == _lastCellPos) return; // 格子未变,无需重新搜索
- _lastCellPos = cellPos;
+ private void Start()
+ {
+ _database = _databaseOverride
+ ?? ServiceLocator.GetOrDefault()?.Database;
+ BuildSpatialIndex();
+ }
+ private void OnDestroy()
+ {
+ ServiceLocator.Unregister(this);
+ }
+
+ ///
+ /// 构建格子坐标 → 房间 ID 的哈希映射,将 LateUpdate 的查询从 O(N) 降至 O(1)。
+ /// 房间数据变化时(运行时热更)可再次调用重建索引。
+ ///
+ public void BuildSpatialIndex()
+ {
+ _cellToRoomId = new Dictionary();
+ if (_database?.AllRooms == null) return;
foreach (var room in _database.AllRooms)
{
if (room == null) continue;
- var rect = new RectInt(room.GridPosition, room.GridSize);
- if (rect.Contains(cellPos))
+ for (int x = 0; x < room.GridSize.x; x++)
+ for (int y = 0; y < room.GridSize.y; y++)
{
- CurrentRoomId = room.RoomId;
- Vector2 inRoom = (Vector2)(cellPos - room.GridPosition);
- NormalizedPositionInRoom = new Vector2(
- inRoom.x / Mathf.Max(1, room.GridSize.x),
- inRoom.y / Mathf.Max(1, room.GridSize.y));
+ var cell = new Vector2Int(room.GridPosition.x + x, room.GridPosition.y + y);
+ _cellToRoomId[cell] = room.RoomId;
+ }
+ }
+ }
+
+ private void LateUpdate()
+ {
+ if (_playerTransform == null || _cellToRoomId == null) return;
+
+ Vector2Int cellPos = WorldToCell(_playerTransform.position);
+ bool cellChanged = cellPos != _lastCellPos;
+
+ if (cellChanged)
+ {
+ _lastCellPos = cellPos;
+
+ if (!_cellToRoomId.TryGetValue(cellPos, out var newRoomId))
+ {
+ // 玩家离开所有已知房间
+ CurrentRoomId = null;
+ _currentRoom = null;
+ NormalizedPositionInRoom = Vector2.zero;
return;
}
+
+ var prevRoomId = CurrentRoomId;
+ CurrentRoomId = newRoomId;
+ _currentRoom = _database.GetRoom(newRoomId);
+
+ if (newRoomId != prevRoomId)
+ OnRoomChanged?.Invoke(newRoomId);
+ }
+
+ // 每帧从世界坐标精确计算归一化位置,实现平滑图标跟随
+ if (_currentRoom != null)
+ {
+ var worldMin = new Vector2(
+ _currentRoom.GridPosition.x * _worldUnitsPerCell,
+ _currentRoom.GridPosition.y * _worldUnitsPerCell);
+ var worldSize = new Vector2(
+ _currentRoom.GridSize.x * _worldUnitsPerCell,
+ _currentRoom.GridSize.y * _worldUnitsPerCell);
+ var localPos = (Vector2)_playerTransform.position - worldMin;
+ NormalizedPositionInRoom = new Vector2(
+ Mathf.Clamp01(localPos.x / Mathf.Max(1f, worldSize.x)),
+ Mathf.Clamp01(localPos.y / Mathf.Max(1f, worldSize.y)));
}
}
@@ -53,3 +130,4 @@ namespace BaseGames.World.Map
Mathf.FloorToInt(worldPos.y / _worldUnitsPerCell));
}
}
+
diff --git a/Assets/_Game/Scripts/World/Map/MapRoomCellUI.cs b/Assets/_Game/Scripts/World/Map/MapRoomCellUI.cs
new file mode 100644
index 0000000..0f340c5
--- /dev/null
+++ b/Assets/_Game/Scripts/World/Map/MapRoomCellUI.cs
@@ -0,0 +1,113 @@
+using System;
+using UnityEngine;
+using UnityEngine.UI;
+using UnityEngine.EventSystems;
+
+namespace BaseGames.World.Map
+{
+ /// 房间可见性三级状态:未知 / 已踏入 / 已标注(购买地图碎片)。
+ public enum RoomVisibility { Unknown, Explored, Mapped }
+
+ ///
+ /// 地图面板中每个房间对应的格子 UI 组件(架构 15_MapShopModule §1.3)。
+ /// 同时被 MapPanel(全屏地图)和 MinimapHUD(角落小地图)复用。
+ /// 颜色通过 从外部注入,不在此处硬编码。
+ /// 属性在 Awake 中缓存,避免调用方反复 GetComponent。
+ ///
+ public class MapRoomCellUI : MonoBehaviour, IPointerEnterHandler, IPointerExitHandler
+ {
+ [SerializeField] private Image _bg;
+ [SerializeField] private Image _icon;
+ [SerializeField] private RawImage _outlineImage; // 可选:房间非矩形轮廓纹理
+ [SerializeField] private Image _highlight; // 可选:当前房间高亮描边(玩家所在时激活)
+
+ // 实例颜色(默认值与原硬编码保持一致);可通过 SetColors 统一覆盖
+ private Color _colExplored = Color.white;
+ private Color _colMapped = new Color(0.45f, 0.45f, 0.45f, 1f);
+ private Color _colUnknown = Color.black;
+ private RoomVisibility _currentVisibility;
+
+ private string _displayName;
+ private Action _onHover;
+ private Action _onHoverExit;
+
+ /// 格子的 RectTransform(Awake 中缓存,外部直接访问无需 GetComponent)。
+ public RectTransform RT { get; private set; }
+
+ private void Awake() => RT = GetComponent();
+
+ ///
+ /// 初始化格子(位置、可见性、图标、Tooltip 回调)。
+ ///
+ ///
+ /// 每格像素数,默认 (32f)。
+ /// MinimapHUD 调用后会立即通过 PlaceCell 按自身比例覆盖位置/尺寸。
+ ///
+ public void Setup(MapRoomDataSO room, RoomVisibility visibility, Sprite icon,
+ Action onHover = null, Action onHoverExit = null,
+ float pixelsPerCell = MapGridConstants.FullMapCellPixels)
+ {
+ _displayName = room.DisplayName;
+ _onHover = onHover;
+ _onHoverExit = onHoverExit;
+
+ RT.anchoredPosition = new Vector2(room.GridPosition.x * pixelsPerCell,
+ room.GridPosition.y * pixelsPerCell);
+ RT.sizeDelta = new Vector2(room.GridSize.x * pixelsPerCell,
+ room.GridSize.y * pixelsPerCell);
+
+ // 房间轮廓纹理(非矩形形状,覆盖在矩形背景上方)
+ if (_outlineImage != null)
+ {
+ _outlineImage.texture = room.RoomOutlineTex;
+ _outlineImage.enabled = room.RoomOutlineTex != null;
+ }
+
+ SetVisibility(visibility);
+
+ if (_icon != null)
+ {
+ _icon.sprite = icon;
+ _icon.enabled = icon != null;
+ }
+ }
+
+ /// 覆盖此格子的三级可见性颜色(通常由 MapPanel / MinimapHUD 在创建后统一调用)。
+ public void SetColors(Color explored, Color mapped, Color unknown)
+ {
+ _colExplored = explored;
+ _colMapped = mapped;
+ _colUnknown = unknown;
+ SetVisibility(_currentVisibility); // 用新颜色重新渲染当前状态
+ }
+
+ public void SetVisibility(RoomVisibility v)
+ {
+ _currentVisibility = v;
+ if (_bg == null) return;
+ _bg.color = v switch
+ {
+ RoomVisibility.Explored => _colExplored,
+ RoomVisibility.Mapped => _colMapped,
+ _ => _colUnknown,
+ };
+ }
+
+ /// 向后兼容:直接传 bool 时等同于 Explored / Unknown。
+ public void SetDiscovered(bool v)
+ => SetVisibility(v ? RoomVisibility.Explored : RoomVisibility.Unknown);
+
+ /// 激活/取消当前房间高亮描边。
+ public void SetHighlight(bool v)
+ {
+ if (_highlight != null) _highlight.enabled = v;
+ }
+
+ public void OnPointerEnter(PointerEventData _)
+ {
+ if (!string.IsNullOrEmpty(_displayName)) _onHover?.Invoke(_displayName);
+ }
+
+ public void OnPointerExit(PointerEventData _) => _onHoverExit?.Invoke();
+ }
+}
diff --git a/Assets/_Game/Scripts/World/Map/MapRoomDataSO.cs b/Assets/_Game/Scripts/World/Map/MapRoomDataSO.cs
index d733992..e9273e1 100644
--- a/Assets/_Game/Scripts/World/Map/MapRoomDataSO.cs
+++ b/Assets/_Game/Scripts/World/Map/MapRoomDataSO.cs
@@ -40,6 +40,12 @@ namespace BaseGames.World.Map
"建议在关卡内容基本定型后更新此值。0 = 未填写,将跳过内存预算检查。")]
[Min(0)]
public int EstimatedMemoryKB;
+
+ private void OnValidate()
+ {
+ // 保证 GridSize 每轴最小为 1,防止零尺寸房间导致碰撞和渲染异常
+ GridSize = new Vector2Int(Mathf.Max(1, GridSize.x), Mathf.Max(1, GridSize.y));
+ }
}
[Serializable]
@@ -84,5 +90,67 @@ namespace BaseGames.World.Map
}
private void OnDisable() => _index = null; // SO 卸载时清理缓存
+
+ private void OnValidate() => _index = null; // 编辑器中修改 AllRooms 后强制重建索引
+
+ // ── 配置验证 ──────────────────────────────────────────────────────────
+
+ ///
+ /// 检查数据库中的常见配置错误(RoomId 重复、格子重叠、出口悬空)。
+ /// 编辑器侧调用;运行时不应调用(有 O(N²) 开销)。
+ /// 返回错误描述列表;空列表表示无错误。
+ ///
+ public List ValidateAll()
+ {
+ var errors = new List();
+ if (AllRooms == null) return errors;
+
+ // ① null / 空 RoomId
+ for (int i = 0; i < AllRooms.Length; i++)
+ {
+ if (AllRooms[i] == null) { errors.Add($"AllRooms[{i}] 为 null"); continue; }
+ if (string.IsNullOrEmpty(AllRooms[i].RoomId))
+ errors.Add($"AllRooms[{i}]({AllRooms[i].name})RoomId 为空");
+ }
+
+ // ② RoomId 重复
+ var seenIds = new Dictionary();
+ foreach (var room in AllRooms)
+ {
+ if (room == null || string.IsNullOrEmpty(room.RoomId)) continue;
+ if (seenIds.TryGetValue(room.RoomId, out var first))
+ errors.Add($"RoomId '{room.RoomId}' 重复({first} 与 {room.name})");
+ else
+ seenIds[room.RoomId] = room.name;
+ }
+
+ // ③ 格子重叠
+ var cellOwner = new Dictionary();
+ foreach (var room in AllRooms)
+ {
+ if (room == null) continue;
+ for (int x = 0; x < room.GridSize.x; x++)
+ for (int y = 0; y < room.GridSize.y; y++)
+ {
+ var cell = new Vector2Int(room.GridPosition.x + x, room.GridPosition.y + y);
+ if (cellOwner.TryGetValue(cell, out var other))
+ errors.Add($"'{room.RoomId}' 与 '{other}' 在格子 {cell} 重叠");
+ else
+ cellOwner[cell] = room.RoomId;
+ }
+ }
+
+ // ④ 出口目标不存在(单向验证)
+ var validIds = new HashSet(seenIds.Keys);
+ foreach (var room in AllRooms)
+ {
+ if (room?.Exits == null) continue;
+ foreach (var exit in room.Exits)
+ if (!string.IsNullOrEmpty(exit.TargetRoomId) && !validIds.Contains(exit.TargetRoomId))
+ errors.Add($"'{room.RoomId}' 出口指向不存在的房间 '{exit.TargetRoomId}'");
+ }
+
+ return errors;
+ }
}
}
diff --git a/Assets/_Game/Scripts/World/Map/MapServiceExtensions.cs b/Assets/_Game/Scripts/World/Map/MapServiceExtensions.cs
new file mode 100644
index 0000000..246e579
--- /dev/null
+++ b/Assets/_Game/Scripts/World/Map/MapServiceExtensions.cs
@@ -0,0 +1,20 @@
+namespace BaseGames.World.Map
+{
+ ///
+ /// IMapService 无状态扩展方法,集中可复用的查询逻辑。
+ /// MapPanel、MinimapHUD 等所有消费方均调用此处,避免分散的重复实现。
+ ///
+ public static class MapServiceExtensions
+ {
+ ///
+ /// 根据探索状态推导房间三级可见性(Explored > Mapped > Unknown)。
+ ///
+ public static RoomVisibility GetVisibility(this IMapService svc, string roomId)
+ {
+ if (svc == null) return RoomVisibility.Unknown;
+ if (svc.IsExplored(roomId)) return RoomVisibility.Explored;
+ if (svc.IsMapped(roomId)) return RoomVisibility.Mapped;
+ return RoomVisibility.Unknown;
+ }
+ }
+}
diff --git a/Assets/_Game/Scripts/World/Map/MinimapHUD.cs b/Assets/_Game/Scripts/World/Map/MinimapHUD.cs
new file mode 100644
index 0000000..c313f21
--- /dev/null
+++ b/Assets/_Game/Scripts/World/Map/MinimapHUD.cs
@@ -0,0 +1,201 @@
+using System.Collections.Generic;
+using UnityEngine;
+using UnityEngine.UI;
+using BaseGames.Core;
+using BaseGames.Core.Events;
+
+namespace BaseGames.World.Map
+{
+ ///
+ /// 角落小地图 HUD(架构 15_MapShopModule §1.6)。
+ /// 以玩家当前房间为中心,仅渲染 ±ViewRadiusCells 格范围内的房间。
+ /// 玩家跨格进入新房间时(OnRoomChanged 事件)触发增量重建,无需每帧扫描全局。
+ ///
+ /// 挂载位置:HUD Canvas 下 Minimap 根节点(需配有 RectMask2D 用于裁剪)。
+ ///
+ /// 依赖 和 ,
+ /// 均通过 获取,不持有具体类的 SerializeField 引用。
+ ///
+ public class MinimapHUD : MonoBehaviour
+ {
+ [SerializeField] private MapRoomCellUI _cellPrefab;
+ [SerializeField] private RectTransform _cellContainer; // 带 RectMask2D 的容器,内容在此节点内平移
+ [SerializeField] private Image _playerDot; // 玩家位置圆点(在 _cellContainer 内)
+
+ [Header("显示范围")]
+ [SerializeField, Min(1)] private int _viewRadiusCells = 3; // 以玩家房间中心为圆心的可视半径(格)
+ [SerializeField] private float _cellPixels = 16f; // 每格显示像素数
+
+ [Header("颜色(Inspector 覆盖)")]
+ [SerializeField] private Color _colorExplored = Color.white;
+ [SerializeField] private Color _colorMapped = new Color(0.45f, 0.45f, 0.45f, 1f);
+ [SerializeField] private Color _colorUnknown = Color.black;
+
+ [Header("Event Channels")]
+ [SerializeField] private StringEventChannelSO _onMapUpdated; // 房间发现/标注时局部刷新
+
+ private IMapService _mapSvc;
+ private IPlayerPositionProvider _playerProvider;
+ private readonly Dictionary _cells = new();
+ private readonly CompositeDisposable _subs = new();
+
+ // 复用 List 避免 RefreshView 每次分配临时 List(GC 友好)
+ private readonly List _toRemove = new List(8);
+
+ private Vector2Int _currentCenter;
+ private string _lastDotRoomId;
+ private Vector2 _lastDotNormPos;
+
+ // ── 生命周期 ──────────────────────────────────────────────────────────
+
+ private void OnEnable()
+ {
+ _mapSvc = ServiceLocator.GetOrDefault();
+ _playerProvider = ServiceLocator.GetOrDefault();
+
+ if (_playerProvider != null)
+ _playerProvider.OnRoomChanged += OnRoomChanged;
+
+ _onMapUpdated?.Subscribe(OnMapUpdated).AddTo(_subs);
+
+ // 首次显示时立即刷新
+ RefreshView();
+ }
+
+ private void OnDisable()
+ {
+ if (_playerProvider != null)
+ _playerProvider.OnRoomChanged -= OnRoomChanged;
+
+ _subs.Clear();
+ ClearAllCells();
+ _lastDotRoomId = null;
+ _mapSvc = null;
+ _playerProvider = null;
+ }
+
+ private void ClearAllCells()
+ {
+ foreach (var cell in _cells.Values)
+ if (cell != null) Destroy(cell.gameObject);
+ _cells.Clear();
+ }
+
+ private void LateUpdate()
+ {
+ UpdatePlayerDot();
+ }
+
+ // ── 事件响应 ──────────────────────────────────────────────────────────
+
+ private void OnRoomChanged(string _) => RefreshView();
+
+ private void OnMapUpdated(string roomId)
+ {
+ if (_cells.TryGetValue(roomId, out var cell))
+ cell.SetVisibility(_mapSvc.GetVisibility(roomId));
+ }
+
+ // ── 视图重建 ──────────────────────────────────────────────────────────
+
+ ///
+ /// 以玩家当前房间中心为基准,增量更新可视格内的格子:
+ /// 回收超出范围的旧格子,实例化刚进入范围的新格子,重定位全部格子到新中心。
+ ///
+ private void RefreshView()
+ {
+ var db = _mapSvc?.Database;
+ if (db?.AllRooms == null) return;
+
+ var currentRoomId = _playerProvider?.CurrentRoomId;
+ if (string.IsNullOrEmpty(currentRoomId)) return;
+
+ var currentRoom = db.GetRoom(currentRoomId);
+ if (currentRoom == null) return;
+
+ _currentCenter = currentRoom.GridPosition + currentRoom.GridSize / 2;
+
+ int minX = _currentCenter.x - _viewRadiusCells;
+ int maxX = _currentCenter.x + _viewRadiusCells;
+ int minY = _currentCenter.y - _viewRadiusCells;
+ int maxY = _currentCenter.y + _viewRadiusCells;
+
+ // ① 回收不在可视范围内的格子(复用 _toRemove 避免每帧 new)
+ _toRemove.Clear();
+ foreach (var (id, cell) in _cells)
+ {
+ var r = db.GetRoom(id);
+ if (r == null || !RoomInView(r, minX, maxX, minY, maxY))
+ {
+ if (cell != null) Destroy(cell.gameObject);
+ _toRemove.Add(id);
+ }
+ }
+ foreach (var id in _toRemove) _cells.Remove(id);
+
+ // ② 实例化新进入范围的格子
+ foreach (var room in db.AllRooms)
+ {
+ if (room == null || _cells.ContainsKey(room.RoomId)) continue;
+ if (!RoomInView(room, minX, maxX, minY, maxY)) continue;
+
+ var cell = Instantiate(_cellPrefab, _cellContainer);
+ cell.Setup(room, _mapSvc.GetVisibility(room.RoomId), null);
+ cell.SetColors(_colorExplored, _colorMapped, _colorUnknown);
+ _cells[room.RoomId] = cell;
+ }
+
+ // ③ 重定位所有格子(中心发生变化时)
+ foreach (var (id, cell) in _cells)
+ {
+ if (cell == null) continue;
+ var r = db.GetRoom(id);
+ if (r != null) PlaceCell(cell, r);
+ }
+
+ UpdatePlayerDot();
+ }
+
+ // ── 格子位置 ──────────────────────────────────────────────────────────
+
+ private void PlaceCell(MapRoomCellUI cell, MapRoomDataSO room)
+ {
+ cell.RT.sizeDelta = new Vector2(room.GridSize.x * _cellPixels, room.GridSize.y * _cellPixels);
+ cell.RT.anchoredPosition = new Vector2(
+ (room.GridPosition.x - _currentCenter.x) * _cellPixels,
+ (room.GridPosition.y - _currentCenter.y) * _cellPixels);
+ }
+
+ private static bool RoomInView(MapRoomDataSO room, int minX, int maxX, int minY, int maxY)
+ => room.GridPosition.x + room.GridSize.x > minX &&
+ room.GridPosition.x < maxX &&
+ room.GridPosition.y + room.GridSize.y > minY &&
+ room.GridPosition.y < maxY;
+
+ // ── 玩家圆点 ──────────────────────────────────────────────────────────
+
+ private void UpdatePlayerDot()
+ {
+ if (_playerDot == null || _playerProvider == null) return;
+
+ var roomId = _playerProvider.CurrentRoomId;
+ var normPos = _playerProvider.NormalizedPositionInRoom;
+
+ // Dirty check: 避免房间和位置均未变化时写 RectTransform
+ if (roomId == _lastDotRoomId && normPos == _lastDotNormPos) return;
+ _lastDotRoomId = roomId;
+ _lastDotNormPos = normPos;
+
+ if (string.IsNullOrEmpty(roomId) || !_cells.TryGetValue(roomId, out var cell))
+ {
+ _playerDot.enabled = false;
+ return;
+ }
+
+ _playerDot.enabled = true;
+ _playerDot.rectTransform.anchoredPosition =
+ cell.RT.anchoredPosition
+ + Vector2.Scale(normPos, cell.RT.sizeDelta);
+ }
+ }
+}
diff --git a/Docs/Plan/00_DevelopmentPlan.md b/Docs/Plan/00_DevelopmentPlan.md
deleted file mode 100644
index 104bd22..0000000
--- a/Docs/Plan/00_DevelopmentPlan.md
+++ /dev/null
@@ -1,514 +0,0 @@
-# 开发实施计划
-
-> **版本**:1.0
-> **日期**:2026-05-11
-> **依据**:`Docs/Architecture/`(24 份架构文档)
-> **目标**:完整实现 `Docs/Design/` 所有技术需求
-
----
-
-## 目录
-
-1. [策略选择](#1-策略选择)
-2. [整体阶段概览](#2-整体阶段概览)
-3. [Phase 0:项目基础](#3-phase-0项目基础)
-4. [Phase 1:垂直切片 MVP](#4-phase-1垂直切片-mvp)
-5. [Phase 2:核心玩法扩展](#5-phase-2核心玩法扩展)
-6. [Phase 3:世界与进程系统](#6-phase-3世界与进程系统)
-7. [Phase 4:内容与完善](#7-phase-4内容与完善)
-8. [模块依赖顺序](#8-模块依赖顺序)
-9. [技术风险与缓解策略](#9-技术风险与缓解策略)
-
----
-
-## 1. 策略选择
-
-### 为什么不用纯 MVP 先验证可行性?
-
-纯 MVP 策略适合**架构未定型**的项目——先做最小功能,验证架构方向再继续。
-
-本项目**架构已完全定型**(24 份文档,类/接口/字段/方法全部明确),逻辑设计无需验证。真正的风险集中在:
-
-| 风险点 | 内容 |
-|--------|------|
-| 第三方库集成 | Animancer Pro FSM、PathBerserker2d NavSurface、Behavior Designer AI 树三者协作 |
-| SO 事件频道跨系统串联 | 首次接线需要验证整条链路(Input→Player→Combat→Save→UI) |
-| Addressables 异步加载 | 场景 Additive 加载 + Persistent 常驻场景的 Load/Unload 时序 |
-| Cinemachine + PixelPerfect | 相机切换与房间边界的实际表现 |
-
-### 推荐:骨架垂直切片策略
-
-```
-Phase 0: 项目骨架(无可玩内容,但基础设施就绪)
- ↓
-Phase 1: 垂直切片 MVP(一个可玩房间,验证所有第三方集成点)
- ↓
-Phase 2: 核心玩法扩展(在验证的骨架上填充完整战斗/移动系统)
- ↓
-Phase 3: 世界与进程系统(地图、存档、谜题、任务)
- ↓
-Phase 4: 内容与完善(Boss 技能、叙事、支撑系统、平台发布)
-```
-
-每个 Phase 结束都有**可演示的里程碑**,但 Phase 0 之后的每一层都是在可运行的代码上追加,而非重写。
-
----
-
-## 2. 整体阶段概览
-
-| Phase | 目标 | 里程碑 | 预估周期 | 状态 |
-|-------|------|--------|---------|------|
-| **0** | 项目基础设施 | 编译无错,基础框架可寻址加载 | 1 周 | ✅ 完成(2026-05-07)|
-| **1** | 垂直切片 MVP | 一个房间可玩:移动/攻击/一只敌人/存读档 | 3–4 周 | ✅ 完成(2026-05-08)|
-| **2** | 核心玩法扩展 | 完整玩家能力树/战斗系统/多种敌人 | 4–5 周 | 🔄 代码全部完成(剩余:VFX 资产填充 - 仅 Unity 编辑器操作)|
-| **3** | 世界与进程 | 完整地图/谜题/任务/商店 | 4–5 周 | ✅ 代码全部完成(剩余:P3-1 Prefab 装配 - 仅 Unity 编辑器操作)|
-| **4** | 内容与完善 | Boss/叙事/平台服务/QA | 3–4 周 | ✅ 完成(2026-05-11,P4-1~P4-6 全部 ✅)|
-
----
-
-## 3. Phase 0:项目基础
-
-**目标**:所有模块能编译,Addressables/Assembly Definitions/SO 事件频道骨架就绪,无可玩内容。
-
-### 任务清单
-
-#### P0-1:项目结构(01_ProjectStructure)
-- [x] 按规范建立 `Assets/Scripts/`、`Assets/Data/`、`Assets/Prefabs/`、`Assets/Scenes/` 文件夹层级
-- [x] 创建全部 Assembly Definition 文件(`BaseGames.Core`、`BaseGames.Core.Events`、`BaseGames.Core.Save`、`BaseGames.Input`、`BaseGames.Player`、`BaseGames.Player.States`、`BaseGames.Combat`、`BaseGames.Combat.StatusEffects`、`BaseGames.Parry`、`BaseGames.Enemies`、`BaseGames.Enemies.AI`、`BaseGames.World`、`BaseGames.UI`、`BaseGames.Audio`、`BaseGames.Progression`、`BaseGames.Feedback`、`BaseGames.Camera`、`BaseGames.Animation`、`BaseGames.Editor`)
-- [x] 验证 asmdef 依赖方向(低层不引用高层,`Core.Events` 无任何依赖)
-- [x] 建立 `Persistent.unity` 场景骨架(空 GameObject 层级,待 Phase 1 填充组件)
-
-#### P0-2:SO 事件系统(02_EventSystem)
-- [x] `BaseEventChannelSO` 泛型基类
-- [x] `VoidBaseEventChannelSO` 无负载基类
-- [x] 所有具体频道类型(已创建):`VoidEventChannelSO`、`IntEventChannelSO`、`FloatEventChannelSO`、`BoolEventChannelSO`、`StringEventChannelSO`、`Vector2EventChannelSO`、`TransformEventChannelSO`、`GameStateEventChannelSO`、`SceneLoadRequestEventChannelSO`、`DifficultyChangedEventChannel`、`DamageInfoEventChannelSO`、`HitConfirmedEventChannelSO`、`ShopPurchaseEventChannelSO`、`DialogueEventChannelSO`、`LiquidEventChannelSO`、`WorldMarkerEventChannelSO`、`AbilityTypeEventChannelSO`(`AbilityType` payload)、`ColorBlindModeEventChannelSO`、`BossSkillEventChannelSO`、`BossPhaseEventChannelSO`、`StatusEffectEventChannelSO`、`QuestStateChangedEventChannel`、`QuestObjectiveEventChannelSO`
-- [x] `ToolUsedEventChannelSO`(payload:`ToolUsedPayload { SlotIndex, ToolId }`;`Core/Events/ToolEvents.cs`;⚠️ 架构 09 §7.5 用 `Tool` 对象,实现用 `string ToolId` 避免跨程序集依赖)
-- [x] `AchievementEventChannelSO`(payload:`AchievementSO`;`Progression/AchievementSO.cs`;Phase 0 骨架,Phase 4 扩充)
-- [x] 在 `Assets/Data/Events/` 下预建所有全局频道 `.asset` 资产(`CreateEventChannelAssets` Editor 菜单一键生成;对照 `02_EventSystem §4` 清单)
-- [x] `EventSubscription` struct + `CompositeDisposable`(已实现;命名差异:架构用 `EventSubscriptionToken`/`DisposableSubscription`,实现用 `EventSubscription`/`CompositeDisposable`——功能等价)
-- [x] `AddTo(ICollection)` 扩展方法(`EventSubscriptionExtensions` 静态类;架构 02 §8)
-
-#### P0-3:Core 骨架(03_CoreModule)
-- [x] `GameStateId` struct + `IGameState` 接口 + `GameStateMachine`(替代旧 `GameState` 枚举;架构 03_CoreModule §2)
-- [x] `GameStates` 静态工厂(8 个内置状态:MainMenu/Gameplay/Paused/BossFight/Cutscene/Loading/Dead/GameOver)
-- [x] `IGameStateFactory` 接口(供 DLC/扩展注册自定义状态)
-- [x] `GameStateEventChannelSO` payload 类型为 `GameStateId`(非旧 enum)
-- [x] `GameManager`(字段 + 接口签名,Awake 暂留空,内嵌 `GameStateMachine`)
-- [x] `SceneLoader`(异步加载骨架,`LoadSceneAsync` 封装)
-- [x] `SettingsManager` + `GlobalSettingsSO`
-- [x] `GlobalObjectPool`(通用池,`Spawn` / `Despawn`;`WarmupAsync` 批量预热)
-- [x] `PooledObject` 组件
-
-#### P0-3b:服务层骨架(03_CoreModule §11-13)
-- [x] `ServiceLocator` 静态类(`Register` / `Get` / `GetOrDefault` / `OverrideForTest` / `Reset`)
-- [x] `GameServiceRegistrar`(ExecutionOrder -2000,DontDestroyOnLoad;`Awake` 中注册所有核心服务)
-- [x] 五大服务接口:`IAudioService`、`ISaveService`、`ISceneService`、`IDeathRespawnService`、`IEventChannelRegistry`
-- [x] `NullAudioService`(空实现,测试/Editor 下兜底,不报错)
-- [x] `DeathRespawnService`(实现 `IDeathRespawnService`,UniTask-based,封装死亡/重生序列;Phase 1 完成完整逻辑,Day 3 只建文件+接口)
-- [x] `SceneService`(实现 `ISceneService`,UniTask-based,封装场景加载;Day 3 只建文件+接口)
-
-#### P0-4:资产加载骨架(13_AssetPoolModule)
-- [x] `AddressKeys` 静态常量类(填入当前已知地址;后续持续追加)
-- [x] `AssetLoader` 工具类(封装 `Addressables.LoadAssetAsync`)
-- [x] `AssetReleaseTracker`(场景卸载时批量 Release)
-- [x] `AddressKeyRegistry`(运行时 key 注册层,DLC 扩展用;`TryRegister(key, addr)` / `Resolve(key)` / `TryResolve()` / `Unregister()` / `ForceRegister()`;`Core/Assets/AddressKeyRegistry.cs`;架构 13 §9)
-- [x] Editor 工具:`AddressKeyValidator`(反射扫描 `AddressKeys` 所有常量,对比 Addressable 分组)+ `AddressKeyImportWatcher`(`AssetPostprocessor`,Addressable 分组变更后自动触发;`Editor/AddressKeyValidator.cs`;架构 13 §10)
-
-#### P0-5:存档骨架(12_SaveModule 部分)
-- [x] `SaveData` C# 完整数据结构(所有子类:`SaveMeta`、`PlayerSaveData`、`WorldSaveData` 等)
-- [x] `ISaveStorage` 接口 + `LocalFileStorage` 实现(⚠️ 类名无 "Save" 前缀,架构 12 §2)
-- [x] `SaveManager`(接口签名,`Save`/`Load`/`NewGame` 占位实现)
-
-### Phase 0 完成标准
-- Unity 编译无错,Console 无警告
-- 能在 Editor 中运行 `Persistent` 场景(GameManager Awake 打印版本号)
-- 能新建 / 加载 JSON 存档文件(即使数据为空)
-- 所有 asmdef 引用方向通过 Assembly Reference Checker 验证
-
----
-
-## 4. Phase 1:垂直切片 MVP
-
-**目标**:一个完整可玩房间。验证所有第三方库集成点,建立可以往上叠加的可运行骨架。
-
-**可玩标准**:玩家能在一个房间内移动、跳跃、普通攻击;有一只巡逻敌人能被击杀;房间内有存档点可存读档;死亡后从存档点复活。
-
-### 任务清单
-
-#### P1-1:输入系统(04_InputModule)
-- [x] `InputReaderSO`(持有 `PlayerInputActions` 引用,暴露 Unity Events)
-- [x] `InputBuffer`(三路独立缓冲:`_jumpBufferDuration = 0.15f`、`_attackBufferDuration = 0.12f`、`_dashBufferDuration = 0.10f`)
-- [x] 配置 `PlayerInputActions.inputactions`(Gameplay / UI / Cutscene 三套 Action Map;存于 `Assets/Settings/PlayerInputActions.inputactions`)
-- [x] 在 `Persistent` 场景挂载 `InputReader`
-
-#### P1-2:相机系统(17_CameraModule)
-- [x] `CameraStateController`(管理 GlobalCam / RoomCam 优先级)
-- [x] `RoomVisibleArea`(每个房间场景的 `CinemachineConfiner2D` 边界)
-- [x] `CameraTriggerZone`(触发切换活跃相机)
-- [x] `CameraConfigSO`(Pixel Perfect 参数)
-- [x] `CameraBlendProfileSO`(混合曲线配置 SO,`ToBlendDefinition()` 返回 Cinemachine v3 结构体)
-- [x] `RoomCamera`(单房间 VCam 封装,`Activate()`/`Deactivate()` 优先级切换)
-- [x] 配置 Cinemachine Virtual Camera + Pixel Perfect Camera 组合(Unity Editor 场景组装)
-- [x] **验证点**:进入房间后相机锁定在正确边界,像素对齐无抖动
-
-#### P1-3:玩家模块——基础(05_PlayerModule)
-目标:Run/Jump/Fall/Idle/Attack 五个状态可运行
-
-- [x] `PlayerController`(协调器字段 + Awake 注册 + Update 状态转发)
-- [x] `PlayerMovement`(Rigidbody2D 移动,走/跳/落地)
-- [x] `PlayerStats`(HP 字段,`TakeDamage` 接口)
-- [x] `PlayerCombat`(基础近战攻击触发,HitBox 激活/停用)
-- [x] `PlayerStateBase`(抽象基类;`PlayerStateMachine` 逻辑内嵌于 `PlayerController`)
-- [x] State 实现:`IdleState`、`RunState`、`JumpState`、`FallState`、`AttackState`(✅)
-- [x] `PlayerAnimationConfigSO` + `PlayerMovementConfigSO`(基础数值)
-- [x] `PlayerStatsSO`(HP/灵力/弹簧/Geo 初始数值 SO)
-- [x] **Animancer 集成验证点**:FSM 切换时 Animancer Linear 混合正常,无动画卡顿
-
-#### P1-4:战斗管道(06_CombatModule)
-- [x] `DamageInfo` struct + `Builder`
-- [x] 所有枚举(`DamageType`、`DamageCategory`、`DamageFlags`、`DamageTags`、`HitFxType`、`BreakLevel`、`PoiseLevel`;⚠️ `DamageTags` 为 `[Flags]` 枚举含 15 个标记位,`PoiseLevel` 均定义于架构 06_CombatModule §2)
-- [x] `DamageSourceSO`
-- [x] `HitBox`(`OnTriggerEnter2D` → 收集重叠的 `HurtBox`,调用 `ReceiveDamage`)
-- [x] `HurtBox`(`ReceiveDamage` 管道:防御计算 → `IDamageable.TakeDamage`)
-- [x] `IDamageable` 接口
-- [x] **伤害管道验证点**:玩家攻击命中敌人 HurtBox → 敌人 HP 减少,Console 打印 DamageInfo 内容
-
-#### P1-5:基础敌人(07_EnemyModule)
-目标:一只能巡逻、追玩家、被攻击、死亡的基础敌人
-
-- [x] `EnemyBase`(`IDamageable`,`TakeDamage`/`Die`)
-- [x] `EnemyStats` + `EnemyStatsSO`(基础 HP/攻击力)
-- [x] `EnemyMovement`(Rigidbody2D,配合 NavAgent)
-- [x] `EnemyNavAgent`(封装 PathBerserker2d `NavAgentComponent`)
-- [x] `EnemyCombat`(近战攻击区域,触发玩家 HurtBox)
-- [x] NavSurface 烘焙(测试房间地形)
-- [x] Behavior Designer 任务类:`BD_Patrol`、`BD_MoveToPlayer`、`BD_Attack`、`BD_IsPlayerInRange`(⚠️ 类名以架构 07_EnemyModule §8 为准,非 BD_ChasePlayer/BD_AttackPlayer/BD_CheckPlayerDistance)
-- [x] 一份基础 BehaviorTree 资产(Patrol → Detect → Chase → Attack)
-- [x] **PathBerserker2d + Behavior Designer 集成验证点**:敌人能在 NavSurface 上寻路追玩家
-
-#### P1-6:存档完整实现(12_SaveModule)
-- [x] `SaveManager` 完整实现(`Save`、`Load`、`NewGame`、`DeleteSave`)
-- [x] `SavePoint` 场景组件(触发存档,广播 `_onSavePointActivated`)
-- [x] `ISaveable` 接口 + `PlayerSaveData` 读写(HP、位置、当前场景)
-- [x] `SaveMigrator`(版本号检查)
-- [x] `EmergencySaveService`(定时自动存档)
-- [x] **存档验证点**:Play → 激活存档点 → 退出 Play → 再 Play → 正确加载到存档位置
-
-#### P1-7:最简 UI(10_UIModule)
-- [x] `UIManager`(Panel 层级管理,`OpenPanel`/`CloseTopPanel`;⚠️ 非 `ShowPanel`/`HidePanel`,架构 10 §2)
-- [x] `HUDController`(HP 条 + 当前货币;⚠️ 非 `HUDPanel`,架构 10 §3)
-- [x] 订阅 `_onHPChanged`、`_onGeoChanged` 事件频道更新 HUD(⚠️ 货币频道名为 `_onGeoChanged`,架构 10 §3)
-- [x] `DeathScreenController`(占位,显示"You Died";⚠️ 非 `DeathPanel`,架构 10 §6)
-
-#### P1-8:最简音频钩子(11_AudioModule)
-- [x] `AudioManager`(单例,`PlaySFX`/`PlayBGM` 接口)
-- [x] `AudioMixerKeys`(Exposed Parameter 常量类)+ `AudioConfigSO`(区域 BGM 映射)
-- [x] `BGMController`(订阅 Zone/Boss 事件驱动 BGM 切换)+ `AudioZone`(区域切换触发)
-- [x] 在伤害管道和死亡事件上挂上 SFX 钩子(`CombatSFXController` 订阅 `HitConfirmedEventChannelSO` + `_onPlayerDied`)
-
-#### P1-9:VFX 最简(18_VFXFeedbackModule 骨架)
-- [x] `VFXPool`(基于 `GlobalObjectPool` 的 VFX 专用包装)
-- [x] `HitFXSpawner`(根据 `DamageInfo.FxType` 路由到 VFX Prefab)
-- [x] `HurtFlashController`(白闪 Shader 参数控制)
-- [x] `VFXCatalogSO`(填入2-3个占位 Prefab)
-- [x] **验证点**:击中敌人时播放命中特效和白闪
-
-#### P1-10:死亡复活流程串联(03_CoreModule §8)
-- [x] `GameManager` 完整实现死亡/复活时序
-- [x] 接通事件链:`_onPlayerDied` → `GameManager.OnPlayerDied()` → `DeathScreenController` → 等待输入 → `SceneLoader.ReloadFromSave()`
-
-### Phase 1 完成标准
-| 验证项 | 要求 |
-|--------|------|
-| 玩家可移动/跳跃/攻击 | Animancer FSM 无卡顿 |
-| 敌人能寻路追击攻击 | PathBerserker2d + Behavior Designer 无报错 |
-| 攻击命中有 VFX 和音效 | 事件链完整 |
-| 激活存档点,退出重进,正确复位 | SaveManager JSON 读写正确 |
-| 玩家死亡 → 死亡画面 → 复活 | GameManager 状态机流转正确 |
-| 相机锁定房间,像素对齐 | 无亚像素抖动 |
-| Console 无错误,无 MissingReference | — |
-
----
-
-## 5. Phase 2:核心玩法扩展
-
-**目标**:在 Phase 1 的可运行骨架上,填充完整的玩家能力、战斗纵深、多种敌人。
-
-### 任务清单
-
-#### P2-1:玩家完整 FSM(05_PlayerModule 剩余)
-- [x] State 补充(Week 5):`DashState`、`AerialDashState`、`WallSlideState`、`WallJumpState`、`AirAttackState`、`DownAttackState`、`UpAttackState`、`HurtState`、`DeadState`、`SpringState`、`ParryState`
-- [x] `SwimState`(`21_LiquidPuzzleModule §SwimState`,✅ Week 11 完成)
-- [x] `PlayerWallDetector`(独立墙壁检测组件,双射线每侧防误判)
-- [x] `PlayerMovementConfigSO` 补充 `DefaultGravityScale`
-- [x] `FormController`(三形态切换:Sky/Earth/Death;含 `FormType`、`FormSO`、`FormConfigSO`)
-- [x] `WeaponManager`(FormController 联动 + Override 护符替换 API;含 `WeaponSO` 完整字段扩展)
-- [x] `PlayerCombat` 完整实现(HitBox 四向激活、`SetComboSegmentSource`、`OnHitConfirmed`→灵力)
-- [x] `PlayerController` Phase 2 扩展(`IPoiseSource` + 11 个新状态 + `TakeDamage` 路由到 HurtState/DeadState)
-- [x] `SkillManager`(技能槽 + 冷却计时)— 已在 P3-3 完整实现(`BaseGames.Skills` 程序集)
-- [x] `SpringSystem`(头部弹簧摆动,完整实现)
-- [x] Animancer 双层动画:Base Layer(移动动画)+ Overlay Layer(叠加层:灵泉/魂技能)✅ 2026-05-12(`PlayerController` 初始化 Layer 1;`PlayOnOverlay`/`StopOverlay` API;`SpringState` 改用叠加层)
-
-#### P2-2:护盾系统(20_ShieldModule)
-- [x] `ShieldComponent`(Phase 2 完整实现:护盾值吸收、再生延迟、破盾惩罚事件;新增 `FullRecharge()` / `OnParrySuccess()` / 破碎惩罚计时;可选 `ShieldConfigSO` 覆盖内联字段)
-- [x] `ShieldConfigSO`(MaxShieldHP、DamageAbsorptionRatio、RechargeDelay、RechargeRate、BrokenPenaltyDuration、ParryRestoreRatio)
-- [x] `IShieldable` 接口(`CombatInterfaces.cs`)
-- [x] `HurtBox.ReceiveDamage` 接入护盾管道(步骤 4 `AbsorbDamage` 已实现,ConsumeParry 步骤 2 已实现)
-- [x] 护盾破碎/恢复 VFX 钩子 ✅ 2026-05-13(`ShieldComponent` 新增 `_onShieldBrokenChannel` / `_onShieldRestoredChannel` VoidEventChannelSO 字段,破碎时 Raise,破碎惩罚结束及 FullRecharge 时 Raise restore)
-
-#### P2-3:完整战斗系统(06_CombatModule 剩余)
-- [x] `ProjectileConfigSO` + `LinearProjectile` + `ParryableProjectile`(+ `ArcProjectile`、`HomingProjectile`、`ProjectileManager`)
-- [x] `ParrySystem` 5 阶段状态机(Inactive/Startup/Active/EndLag/CounterWindow)+ 完美弹反子弹时间 + C# 事件 + `ParryConfigSO`(全部 12 个字段)
-- [x] `ParryInfo` struct + `ParryInfoEventChannelSO`(SO 事件频道,`BaseGames.Parry` 程序集)
-- [x] `PoiseWindowConfig` struct(`[Serializable]`,描述动画时间段霸体等级)+ `EnemyPoiseComponent`(`IPoiseSource` 实现,自动注入 HurtBox)
-- [x] `StatusEffectManager` + `StatusEffect`(抽象基类,架构 06_CombatModule §11,非 `StatusEffectBase`)+ 具体效果(Poison、Burn、Freeze、Stun)→ **已在 P2-4b 实现**
-- [x] `IBreakable`(机关/障碍物交互接口;`CombatInterfaces.cs` 内定义;`HitBox` 已在命中非 `HurtBox` 时尝试调用)
-- [x] `ClashResolver`(拼刀系统:同帧玩家/敌人 HitBox 对碰检测,广播 `EVT_NailClash`;架构 06 §15)✅ 2026-05-11(`ClashConfigSO.cs`、`ClashResolver.cs` 新建;`HitBox.cs` 加入拼刀检测分支,`CanClash` / `IsActive` / `OwnerRigidbody` 属性)
-
-#### P2-4:动画事件系统(24_AnimEventModule)✅ Week 7(2026-05-11)
-- [x] `AnimationEventType` enum(21 种事件类型)
-- [x] `AnimationEventConfigSO`
-- [x] `AnimationEventBinder`
-- [x] `PlayerAnimationEvents`(挂在玩家上)
-- [x] `EnemyAnimationEvents`(挂在敌人上)
-- [x] `IAnimationEventHandler` 接口(⚠️ 非 `IAnimEventReceiver`,架构 24_AnimEventModule §1 patch)
-- [x] 将 AttackState HitBox 激活时机从 FixedUpdate 改为 AnimEvent 驱动(已通过 Animancer `events.Add(0.3f/0.6f, ...)` 归一化时间事件实现)
-- [x] Editor 工具:`EventConfigEditor`(`[CustomEditor(typeof(AnimationEventConfigSO))]`;时间轴标记点可视化、Clip 漂移检测(偏差>5帧警告)、normalizedTime 越界保护、事件类型着色;架构 24 §10)
-
-#### P2-4b:状态效果系统(06_CombatModule §11)✅ Week 7(2026-05-11)
-- [x] `StatusEffectType.cs` 枚举(Fire/Poison/Stagger/Freeze/Stun;⚠️ 架构定义 4 个值 Fire/Poison/Freeze/Stun,Stagger 为实现扩展)
-- [x] `StatusEffect.cs` 抽象基类(双计时器:Duration + TickTimer;OnApply/OnStack/OnTick/OnExpire)
-- [x] `FireEffect.cs`(DoT,MaxStacks=1,**3s**/0.5s/**1dmg True**/tick;OnApply/OnExpire 设置 `_FireGlow` Shader 参数)
-- [x] `PoisonEffect.cs`(DoT,MaxStacks=3,**5s**/1s/**StackCount dmg True**/tick;OnApply/OnStack/OnExpire 管理 `_PoisonGlow` Shader 参数)
-- [x] `StaggerEffect.cs`(硬直,MaxStacks=1,可自定义持续时间;架构扩展,非核心规格)
-- [x] `StatusEffectEventChannelSO.cs`(SO 事件频道,携带 StatusEffectEvent struct;架构用 StatusEffectType 直接广播,实现扩展为含 StackCount/RemainingDuration 的 struct 更利 UI)
-- [x] `StatusEffectManager.cs` 完整实现(List + Dictionary 双结构;ApplyEffect/CleanseEffect/CleanseAll/HasEffect;`ApplyDirectDamage(DamageInfo)` 替代旧 DealDotDamage;`SetShaderParam(string,float)` via MaterialPropertyBlock;SpriteRenderer 初始化于 Awake)
-
-#### P2-5:完整 VFX 反馈(18_VFXFeedbackModule 完整)
-- [x] `FeedbackConfigSO`(`HurtFlashColor`/`HurtFlashDuration`;Phase 2 扩充 Feel 封装字段)
-- [x] `IFeedbackPlayer` 接口(完整 API:PlayHit/PlayTakeHit/PlayDeath/PlayHeal/PlayLandImpact 等)+ `PlayerFeedback`、`EnemyFeedback`、`NullFeedbackPlayer` 实现
-- [ ] `VFXCatalogSO` 填入全部 VFX Prefab(命中/死亡/技能/环境)
-- [x] 接通 Feel MMF_Player 到相机震动、控制器震动 ✅ 2026-05-13(新增 `PostProcessManager`、`RegionLightController` + `RegionLightCatalogSO`、`PaletteSwapSystem` + `PaletteCatalogSO`;`BaseGames.VFX.asmdef` 加入 `BaseGames.Player` 引用)
-
-#### P2-6:难度系统(19_DifficultyModule)✅ Week 8(2026-05-10)
-- [x] `DifficultyLevel` enum(已在 Core.Events,`Easy/Normal/Hard/SteelSoul`)
-- [x] `DifficultyScalerSO` × 4 份资产(Easy/Normal/Hard/SteelSoul — 脚本已创,Unity 内创建资产)
-- [x] `DifficultyManager`(`DefaultExecutionOrder: -900`,SteelSoul 不可降级)
-- [x] `BoolEventChannelSO`(新增,BossBase 依赖)
-- [x] `PlayerStats` 订阅 `DifficultyChangedEventChannel`,难度切换时按 HP 比例缩放(`hpRatio × newMax`)并广播 `_onHPChanged`/`_onMaxHPChanged` ✅ 2026-05-11
-- [x] `EnemyStats` 订阅 `DifficultyChangedEventChannel` 支持运行时切换;`Initialize()` 只缩放 HP,不缩放 Defense(架构 19 §5);提取 `ApplyHPScaler()` ✅ 2026-05-11
-- [x] `ShopController.GetEffectivePrice()` 新增公开方法;`TryPurchase()` 改用实际价格(含 `ShopPriceMultiplier`)✅ 2026-05-11
-- [x] `DeathRespawnService.StartGameOverCoroutine()` 实现:删除存档槽 → `ISceneService.LoadMainMenuCoroutine()` ✅ 2026-05-11
-- [x] `SaveManager.CurrentSlot` 公开属性(供 DeathRespawnService 获取当前槽)✅ 2026-05-11
-- [x] `GameManager.DeathFlow()` 接入 SteelSoul 分支:检查 `InstantDeathOnZeroHP`,true 时调用 `StartGameOverCoroutine()` 跳过普通死亡流程 ✅ 2026-05-11
-- [x] `DifficultyScalerSO` × 4 份资产(Easy/Normal/Hard/SteelSoul — 脚本已创,Unity 内创建资产)
-- [x] 钢铁之魂模式:`SaveMeta.IsSteelSoul` 字段写入 + `DifficultyManager` 实现 `ISaveable`(OnSave/OnLoad)在读档时锁定难度 ✅ 2026-05-11
-
-#### P2-7:敌人扩展(07_EnemyModule 剩余)✅ Week 9(2026-05-10)
-- [x] `BossBase`(Phase 切换 EnterPhase/IsHPBelow/Die 广播)
-- [x] `ProjectileConfigSO` + `Projectile` (Linear/Arc/Homing/Parryable) + `ProjectileManager`
-- [x] `RangedEnemy` + `FlyingEnemy`(EnemyBase 子类)
-- [x] `DeathShade`(IInteractable,Geo 回收事件)
-- [x] `LootTableSO` + `LootResolver`(加权随机掉落 + 难度缩放;`SpawnGeo`/`SpawnItem` 调用 `CollectibleSpawner` 静态方法,Phase 3 TODO 占位已移除)✅ 2026-05-11
-- [x] `AttackPatternSO` + `TelegraphSystem` ✅ 2026-05-12
-- [x] `EnemyQuotaManager` ✅ 2026-05-12
-- [x] `BatchLOSSystem` + `ILOSRequester` ✅ 2026-05-12
-- [x] `BD_` 任务类补充(14 个 Action + Conditional)✅ 2026-05-12
-
-#### P2-8:音频系统完整(11_AudioModule 完整)✅ Week 8(2026-05-10)
-- [x] `AudioEventSO`(随机音效 SO,Play / PlayOneShot)
-- [x] `GlobalSFXPlayer`(静态 SFX 入口,2D + 世界坐标 3D 播放)
-- [x] 为所有模块 SFX 钩子连接 `AudioEventSO` ✅ 2026-05-13(`CombatSFXController` 全部字段由 `AudioClip` 升级为 `AudioEventSO`,通过 `GlobalSFXPlayer.Play()` 播放,支持随机音量/音调/多片段)
-
----
-
-## 6. Phase 3:世界与进程系统
-
-**目标**:完整地图探索、谜题机关、任务系统、商店;存档数据完整;关卡编辑器工具就绪。
-
-### 任务清单
-
-#### P3-1:世界模块完整(08_WorldModule)✅ Week 10 代码完成
-- [x] `RoomTransition`(门/传送,Additive 场景加载/卸载)
-- [x] `HazardZone`(即死/持续伤害区域)
-- [x] `Collectible`(Geo/物品,弧线吸附)
-- [x] `DestructibleTile`(三帧图集,三种触发条件)+ `DirectionalDestructible`
-- [x] `DirectionalInteractable`(OneShot 持久化)
-- [x] `MovingPlatform`(三种 MoveType + 乘客 Parent 方案)
-- [x] `CrumblePlatform`(四态状态机 + NavLink 联动)
-- [x] `FalseWall`(三种揭示条件)
-- [x] `MagicWall`(仅 Gizmo,Layer Matrix 实现)
-- [x] `SoftTerrain`(Marker 组件)
-- [x] `PhantomInteractable`(继承 `DirectionalInteractable`,响应 `PhantomBody` 层)
-- [x] `WorldStateRegistry`(运行时缓存已触发/已破坏状态)
-- [x] `CollectibleSpawner`(静态生成器;`SpawnGeo(Vector2, int)` / `SpawnItem(Vector2, string)`;由 `LootResolver` 调用)+ `CollectibleSpawnerConfig`(MonoBehaviour,Persistent 场景持有 GeoPrefab/ItemPrefab 引用,Awake 注册到 CollectibleSpawner)✅ 2026-05-11
-- [x] `Collectible.SetGeo(int)` / `SetItem(string)`(运行时配置方法,由 CollectibleSpawner 实例化后调用;移除旧静态 SpawnGeo Debug.Log 占位)✅ 2026-05-11
-- [x] `RoomTransition.HasItem` 接入 `WorldStateRegistry.IsCollected(itemId)`(不再返回硬编码 true;新增 `[SerializeField] private WorldStateRegistry _worldState` 字段)✅ 2026-05-11
-- [ ] 场景内端对端验证(Unity 编辑器内 Prefab 装配 + 运行验证)
-
-#### P3-2:液体与谜题系统(21_LiquidPuzzleModule)✅ Week 11 代码完成
-- [x] `LiquidZone` + `LiquidType` enum
-- [x] `LiquidPhysicsConfigSO`(浮力、水下速度)
-- [x] `SwimState`(已在 P3-2 完成)
-- [x] `ISwitchable`、`IActivatable` 接口
-- [x] `PuzzleSwitch`、`PuzzleReceiver`(AND/OR/XOR 逻辑)、`PuzzleWire`(可视化连接)
-- [x] `WaterDangerState` + `UnderwaterPostProcessingController`(架构 21 §12-13)
-- [x] `FootstepMaterial` + `FootstepAudioConfigSO` + `UnderwaterAudioController`(架构 21 §3.3-3.4)
-- [x] `WorldMarker` + `BreadcrumbTracker` ✅ 2026-05-12
-- [x] `TutorialManager` + `ContextualHintTrigger` ✅ 2026-05-12
-
-#### P3-3:进度系统(09_ProgressionModule)✅ 完成(2026-05-10)
-- [x] `AbilityGate`(能力锁区域,检查 `PlayerStats.HasAbility()`)
-- [x] `AbilityUnlock`(解锁 Prefab,播放解锁演出)
-- [x] `CharmSO`、`EquipmentManager`(护符装备/卸载,Notch 管理)
-- [x] `CharmCatalogSO`(护符目录 SO,`Find(charmId)→CharmSO`;`EquipmentManager.AddToCollection` + `OnLoad` 依赖)✅ 2026-05-11
-- [x] `EquipmentManager.AddToCollection` 完整实现(从 CharmCatalogSO 查找 → 去重加入 `_collected`;旧 Debug.Log 占位已移除)✅ 2026-05-11
-- [x] `EquipmentManager.OnSave/OnLoad` 完整实现(读写 `OwnedCharmIds`;OnLoad 恢复 `_collected` 后逐个调用 `TryEquipCharm`)✅ 2026-05-11
-- [x] `FormSkillSO`、`SkillManager`、`SkillModifierRegistry`(技能数据+管理+修改器)
-- [x] `ToolSO`、`ToolSlotManager`(两槽工具 + 冷却)+ `ToolHUD`
-- [x] `ToolCatalogSO`(工具目录 SO,`Find(toolId)→ToolSO`;`ToolSlotManager.OnLoad` 依赖)✅ 2026-05-11
-- [x] `ToolSlotManager.OnLoad` 完整实现(从 ToolCatalogSO 查找两槽 toolId → 调用 `EquipTool(i, tool)`;旧 TODO 已移除)✅ 2026-05-11
-- [x] `EquipmentManager`(护符装备,多态 ICharmEffect)
-- [x] `RegionDefinitionSO`、`ProgressLock`、`BossProgressTracker`、`HPContainerPickup`
-- [x] `SkillHitBoxInstance`(技能 HitBox Prefab 组件)
-- [x] `CharmEffectDrawer`(Editor 护符效果下拉 Inspector)
-
-#### P3-4:任务与挑战(22_QuestChallengeModule)✅ 完成(2026-05-11)
-- [x] `QuestSO`、`QuestObjectiveSO`(5 种具体类型)、`RewardSO`(物品发放通过 `StringEventChannelSO _onItemGranted.Raise(id)` 广播,旧 InventoryManager 占位已移除)✅ 2026-05-11
-- [x] `QuestManager`(ISaveable,事件驱动进度追踪)
-- [x] `QuestGiver`(继承 InteractableNPC,根据任务状态切换对话)
-- [x] `InteractableNPC`、`DialogueSequenceSO`(BaseGames.Dialogue 基础实现)
-- [x] `ChallengeRoomSO`、`ChallengeEncounterSO`、`BossRushSequenceSO`
-- [x] `ChallengeRoomManager`(Addressables 波次生成,快存/快读)
-- [x] `ChallengeRoomTrigger`(IInteractable 入口,事件触发场景加载)
-- [x] `EnemyBase` 扩展:`EnemyId` 属性 + `OnDied` 事件
-
-#### P3-5:地图与商店(15_MapShopModule)✅ 完成(2026-05-11)
-- [x] `MapRoomDataSO` + `MapDatabaseSO` + `RoomExitData` + `ExitDirection`(房间数据 SO;架构 15 §1.1)
-- [x] `MapManager`(ISaveable;订阅 `EVT_RoomEntered`;`SetMapped(roomId)`;`[DefaultExecutionOrder(-700)]`;架构 15 §1.2)
-- [x] `MapPanel` + `MapRoomCellUI`(格子地图 UI;订阅 `EVT_MapUpdated` 增量刷新;架构 15 §1.3)
-- [x] `MapPlayerTracker`(`WorldToCell` 换算;LateUpdate 找所在房间;架构 15 §1.4)
-- [x] `MapPinManager`(ISaveable;`_pins: List`;MapPin/PinType 定义在 SaveData.cs 避免循环依赖;架构 15 §1.5)
-- [x] `ShopItemSO` + `ShopItemType`(架构 15 §2.1)
-- [x] `ShopInventorySO` + `RestockPolicy`(架构 15 §2.2)
-- [x] `ShopController`(ISaveable;`TryPurchase`;`GetAvailableItems`;`Restock`;`ShopPanel` 存根;架构 15 §2.3)
-- [x] `ShopNPC`(IInteractable;`DialogueEventChannelSO` 触发招呼对话→打开商店;架构 15 §2.4)
-- [x] Editor 工具:`MapRoomDataEditor`(`[CustomEditor(typeof(MapRoomDataSO))]`;Scene 句柄拖拽 GridPosition/GridSize;"居中 Scene View"按钮;架构 15 §5)
-
-#### P3-6:存档完善(12_SaveModule 剩余)✅ 2026-05-11
-- [x] `CrashReporter` + `EmergencySaveService.PromoteToSlot` 完整实现(定时检点 + 崩溃日志)
-- [x] `SaveMigrator` 版本迁移策略(v1.0→v1.1→v2.0→v2.1 字段兼容)
-- [x] 存档槽 UI(`SaveSlotController` + `SaveSlotUI`;新建/删除/选择存档)
-- [x] `SaveManager.DeleteSlotAsync` + `BaseGames.UI.asmdef` 添加 `BaseGames.Core.Save` 引用
-
----
-
-## 7. Phase 4:内容与完善
-
-**目标**:Boss 战系统、叙事/过场、平台服务层、本地化、Debug 工具,进入 QA 可测试状态。
-
-### 任务清单
-
-#### P4-1:Boss 技能系统(23_BossSkillModule)✅ 2026-05-11
-- [x] `BossSkillTypes.cs`:全部枚举(`BossSkillCategory`、`BossSkillType`、`InteractionTag`、`VulnTriggerType`、`WeakPointType`、`CounterType`、`ArenaEventType`)及 Serializable struct(`VulnerabilityWindow`、`PlayerCounterResponse`、`ArenaEventTrigger`、`ArenaEventParams`、`ArenaEventData`、`BossResourceCost`、`IArenaInteractable`)
-- [x] `BossSkillSO`(`CreateAssetMenu`,引用 `AttackPatternSO[]`、`VulnerabilityWindow[]`、`PlayerCounterResponse[]`、`ArenaEventTrigger[]`、`BossResourceCost`、`PoiseWindowConfig`、`ClipTransition`)
-- [x] `AttackPatternSO`(伤害/弹幕/AoE/时序参数;`DamageSourceSO` 引用)
-- [x] `SkillSequenceSO`(`SequenceStep[]`,含重复逻辑 `RepeatIfPlayerInRange`)
-- [x] `BossResourceConfigSO`(Boss 自身资源配置,如愤怒值)
-- [x] `BossSkillExecutor`(Coroutine 实现;`ExecuteSkill()` / `InterruptCurrentSkill()`;VulnerabilityWindow 与攻击序列并行协程;注:架构规格为 UniTask,当前用 Coroutine 实现以匹配项目实际依赖)
-- [x] `WeakPointSystem`(弱点 HurtBox 激活管理;`SetActive(bool, float, bool)`;`_onVulnerabilityWindowOpened` 广播)
-
-#### P4-2:叙事系统(14_NarrativeModule)✅ 2026-05-11
-- [x] `DialogueSequenceSO` 补全 `DialogueLine.portraitSprite` / `typewriterDelay` 字段 ✅ 2026-05-11
-- [x] `DialogueUI`(打字机效果,StringBuilder 零分配;`ShowLine` / `SkipTyping` / `Hide`;`IsTyping` 状态属性)✅ 2026-05-11
-- [x] `DialogueManager`(Coroutine 驱动打字序列;`StartDialogue(sequence, npcId)`;Action Map 切换;广播 `EVT_NpcDialogueCompleted`;注册到 ServiceLocator)✅ 2026-05-11
-- [x] `InteractionPromptController`(交互提示 UI;键盘/手柄图标自动切换;挂在 IInteractable 子节点)✅ 2026-05-11
-- [x] `NarrativeNPC`(扩展 InteractableNPC;`DialogueVersion[]` 条件对话版本;接入 `WorldStateRegistry`)✅ 2026-05-11
-- [x] `InteractableNPC.PlayDialogue()` 接入 `ServiceLocator.GetOrDefault()` ✅ 2026-05-11
-- [x] `EventChainSO` + 内置 7 个 `ChainCondition` + 10 个 `ChainAction`(全事件频道驱动,无跨程序集直接依赖;`BaseGames.EventChain` 新程序集)✅ 2026-05-11
-- [x] `EventChainManager`(订阅 5 条 StringEventChannelSO 中继;EvaluateAll;存档集成;防重入)✅ 2026-05-11
-- [x] `CutsceneSO`(`TimelineAsset`;`CutsceneBinding[]`;`CameraBlendProfileSO` 混合;`DialogueSequenceSO[]` 叠加层)✅ 2026-05-11
-- [x] `CutsceneManager`(`PlayableDirector` 封装;`PlayById` 事件驱动;Action Map 切换;`EVT_CutsceneStarted/Ended`)✅ 2026-05-11
-- [x] `CutsceneTrigger`(4 种模式:OnEnter/OnInteract/OnSceneLoad/OnEvent;实现 `IInteractable`;WorldStateRegistry 去重)✅ 2026-05-11
-- [x] `SignalEmitterClip` + `SignalEmitterBehaviour`(Timeline → VoidEventChannelSO 零耦合桥接;支持循环重播)✅ 2026-05-11
-
-#### P4-3:输入重绑定(04_InputModule §6)✅ 2026-05-11
-- [x] `InputReaderSO` 补全重绑定 API:`StartRebinding()`(自动 Dispose op)、`SaveBindingOverrides()`、`LoadBindingOverrides()`、`ResetBindings()`、`GetAllActionMap()`、`FindAction()` ✅ 2026-05-11
-- [x] `InputReaderBootstrap` 启动时调用 `LoadBindingOverrides()`(在 `EnableGameplayInput()` 前)✅ 2026-05-11
-- [x] `ConflictDetector`(扫描 Gameplay Map 重复 effectivePath;返回冲突 Action 名称 HashSet)✅ 2026-05-11
-- [x] `RebindActionRow`(单行:显示当前绑定 HumanReadable 路径;排他锁 SetInteractable;冲突红色高亮)✅ 2026-05-11
-- [x] `RebindPanel`(统一协调排他重绑定流程;`OnResetAll`;完成后自动 `SaveBindingOverrides`)✅ 2026-05-11
-- [x] `BaseGames.UI.asmdef` 补充 `BaseGames.Input` + `Unity.InputSystem` 引用 ✅ 2026-05-11
-
-#### P4-4:UI 完整(10_UIModule 剩余)
-- [x] `PauseMenuController`(设置/键位重绑定/退出;架构 10 §7.3)✅ 2026-05-11
-- [x] `SettingsPanelController`(音量/画质/帧率/VSync 设置面板;架构 10 §7.4)✅ 2026-05-11
-- [x] `BossHPBar`(Boss 血条 + 阶段标记;订阅 EVT_BossFightToggled/HPChanged;架构 10 §4)✅ 2026-05-11
-- [x] `SaveIndicator`(右下角存档提示;订阅 EVT_SaveIndicatorVisible;架构 10 §7.6)✅ 2026-05-11
-- [x] `LoadingOverlay`(全屏黑幕淡入淡出;订阅 EVT_LoadingOverlay;架构 10 §8)✅ 2026-05-11
-- [x] `LoadingScreenManager`(进度条 + 提示文字 + 随机背景;架构 10 §7.7)✅ 2026-05-11
-- [x] `FloatingDamageText` + `FloatingDamageSpawner`(对象池伤害飘字;订阅 EVT_DamageDealt;架构 10 §10)✅ 2026-05-11
-- [x] `ToastNotification` + `ToastManager`(成就/能力 Toast 队列弹出;架构 10 §11)✅ 2026-05-11
-- [x] `InputDeviceIconSetSO` + `InputDeviceIconSwitcher` + `InputIconImage`(键鼠/手柄图标切换;架构 10 §12)✅ 2026-05-11
-- [x] `BaseGames.UI.asmdef` 补充 `BaseGames.Combat` 引用 ✅ 2026-05-11
-- [x] `SaveSlotController` + `SaveSlotUI`(主菜单 3 槽存档卡片 UI;架构 10 §7.5)✅ 已在 P3-6 完成
-
-#### P4-5:支撑模块(16_SupportingModules)✅ 已完成
-- [x] `LocalizationManager` + `LanguageManagerSO`(`#if UNITY_LOCALIZATION` 守卫;fallback 返回 key;架构 16 §5)
-- [x] `IPlatformService` ServiceLocator + `SteamPlatformService` + `NullPlatformService` + `PlatformBootstrap`(架构 16 §2)
-- [x] `AchievementManager`(通过 `ServiceLocator.Get()` 接入平台成就;12 条件类;架构 16 §3)
-- [x] `AnalyticsManager`(本地 JSON 日志;TrackBossKill/TrackDeath/TrackSessionStart/End;架构 16 §7)
-- [x] `DebugCheatSystem`(`#if UNITY_EDITOR || DEVELOPMENT_BUILD`;heal/addgeo/godmode/unlock/killall/scene/revive;架构 16 §4)
-- [x] `AntiSoftlockSystem` + `RoomEscapeInfoSO` + `HardAbilityGate`(卡关检测 + 自动解锁;架构 16 §6)
-- [x] `SpeedrunTimer`(unscaledDeltaTime;ISaveable;架构 16 §8)
-- [x] `AccessibilitySettingsSO` + `AccessibilityManager` + `ColorBlindFilter`(色盲矩阵 URP RenderFeature;架构 16 §6)
-- [x] `BaseGames.Support.asmdef`(引用 Core/Events/Save/Input/Player/Enemies/Combat/World/Progression/Platform/TMP/URP)
-
-#### P4-6:编辑器工具(09_EditorExtensions)✅ 2026-05-11
-- [x] `AddressKeyValidator`(Build Pre-process hook;`AddressKeyValidatorBuildHook` 在 `AddressKeyValidator.cs` 补充;callbackOrder=0;架构 13 §10)✅ 2026-05-11
-- [x] `AddressReferenceGraphWindow`(`BaseGames/Tools/Asset Reference Graph`;扫描所有 `.cs` 对 `AddressKeys.X` 的引用,标红孤儿 key,橙色标注不在 Addressables 的 key,一键导出 CSV;架构 13 §11)✅ 2026-05-11
-- [x] `DirectionalDestructible`/`FalseWall`/`MovingPlatform`/`HazardZone`/`RoomTransition` Gizmo 已实现;`DestructibleTile` Gizmo(`DestructibleTileEditor.cs`)已补充;`PhantomPlate` 已创建 ✅ 2026-05-11
-- [x] `NavSurfaceBakeShortcut`(`%#b` 快捷键;`BaseGames/Tools/Bake All NavSurfaces`;完成时 SetDirty + 打印用时;架构 PathBerserker2d)✅ 2026-05-11
-- [x] `EventChannelEditor`(`VoidBaseEventChannelSO` RaiseInEditor 按钮;`BaseEventChannelSO` 订阅者数量显示;Play Mode only;架构 02 §9)✅ 2026-05-11
-- [x] `CharmEffectDrawer`(`CharmSO.effects` 自定义 PropertyDrawer;实现为 `CharmSOEditor`,含类型下拉+效果预览;架构 16 §4.1)✅ 已在 P3-3 完成
-- [x] `SOValidationRunner` + `IValidatable` 接口(预构建 SO 数据验证;菜单 `Tools/Validate All SOs` + `IPreprocessBuildWithReport`;架构 16 §10)✅ 2026-05-11
-- [x] `EventBusMonitorWindow`(Editor Only;运行时事件监控面板,显示订阅者数量;架构 02 §9)
-- [x] `EventChainEditorWindow`(`BaseGames/Tools/Event Chain Viewer`;左侧链列表/右侧条件+动作表格;Play Mode 运行时着色绿/橙;`ChainCompletedCondition` 依赖链显示;执行日志最近 20 条;双击 PingObject;架构 14 §13)✅ 2026-05-11
-- [x] `BossSkillSequenceWindow`(`BaseGames/Tools/Boss Skill Sequence Viewer`;`BossSkillSO`/`SkillSequenceSO` 甘特图时间轴;Windup黄/Active红/Recovery灰/Vuln绿/Delay暗灰;拖放加载;架构 23 §12)✅ 2026-05-11
-- [x] `AchievementSOEditor`(`[CustomEditor(typeof(AchievementSO))]`;条件中文类型标签;内联展开 SO 字段;Ping/Delete 按钮;架构 16 §2.4)✅ 2026-05-11
-- [x] `BaseGames.Editor.asmdef` 补充 `BaseGames.EventChain` 引用 ✅ 2026-05-11
-
----
-
-## 8. 模块依赖顺序
-
-```
-BaseGames.Core.Events
- └─► BaseGames.Core(GameManager、SceneLoader、ObjectPool)
- └─► BaseGames.Core.Save(SaveManager)
- └─► BaseGames.Input(InputReaderSO)
- └─► BaseGames.Camera(CameraStateController)
- └─► BaseGames.Combat(DamageInfo、HitBox、HurtBox)
- └─► BaseGames.Player(PlayerController + FSM)
- └─► BaseGames.Enemies(EnemyBase + AI)
- └─► BaseGames.World(WorldInteractables、RoomTransition)
- └─► BaseGames.Progression(AbilityGate、Charms、Quest)
- └─► BaseGames.UI(全量 Panel)
- └─► BaseGames.Audio(AudioMixer + AudioEventSO)
- └─► BaseGames.Dialogue/Cutscene
-```
-
-**原则**:低层模块不引用高层。`Core.Events` 是唯一无依赖模块,所有其他模块均可安全引用。
-
----
-
-## 9. 技术风险与缓解策略
-
-| 风险 | 严重度 | 缓解方案 |
-|------|--------|---------|
-| Animancer + Behavior Designer 冲突(双方均用 Update 驱动) | 🔴 高 | Phase 1 最先验证;`AnimancerComponent.Playable.Speed` 控制暂停,不与 BD 冲突 |
-| PathBerserker2d NavSurface 烘焙在移动平台上失效 | 🟠 中 | Phase 1 验证 `LocalNavSurface`;失效则退化到手动 NavLink |
-| Addressables 场景 Additive Load 内存泄漏 | 🟠 中 | `AssetReleaseTracker` 在 `SceneUnloaded` 回调中强制 Release;Phase 1 末验证 |
-| AudioEventSO 未完整填充导致 SFX 静音 | 🟡 低 | Phase 1 先用 `AudioManager.PlaySFX(AudioClip)` 直接传 Clip,Phase 2 完成 `AudioEventSO` 后统一替换引用 |
-| SO 事件频道大量 `.asset` 资产管理混乱 | 🟡 低 | `Assets/Data/Events/` 严格按模块子文件夹分组;命名规范 `EVT_{Module}_{Event}` |
-| 钢铁之魂模式存档不可降级误操作 | 🟡 低 | `SaveData.Meta.IsSteelSoul` 写入后 `DifficultyManager` 锁死选项,UI 二次确认 |
diff --git a/Docs/Plan/01_Phase0_Foundation.md b/Docs/Plan/01_Phase0_Foundation.md
deleted file mode 100644
index dc30469..0000000
--- a/Docs/Plan/01_Phase0_Foundation.md
+++ /dev/null
@@ -1,753 +0,0 @@
-# Phase 0 · 项目基础设施
-
-> **周期**:1 周
-> **前置条件**:Unity 2022.3 LTS 项目已创建,以下 Package 已导入:Cinemachine 3、New Input System、2D Pixel Perfect、Addressables、Newtonsoft Json、Kybernetik Animancer Pro、PathBerserker2d、Behavior Designer、Feel(More Mountains)
-> **产出物**:编译无错;能运行 Persistent 场景;能读写 JSON 存档;asmdef 依赖方向验证通过
-
----
-
-## 目录
-
-1. [实施顺序总览](#1-实施顺序总览)
-2. [Day 1:文件夹与 asmdef 骨架](#2-day-1文件夹与-asmdef-骨架)
-3. [Day 2:SO 事件系统](#3-day-2so-事件系统)
-4. [Day 3:Core 模块](#4-day-3core-模块)
-5. [Day 4:Addressables 与对象池骨架](#5-day-4addressables-与对象池骨架)
-6. [Day 5:SaveData 骨架 + Persistent 场景验证](#6-day-5savedata-骨架--persistent-场景验证)
-7. [完成标准检查清单](#7-完成标准检查清单)
-
----
-
-## 1. 实施顺序总览
-
-```
-Day 1: 文件夹结构 → asmdef 文件 → 依赖验证
- ↓
-Day 2: BaseEventChannelSO 基类 → 所有具体频道类型 → Data/Events/ 资产
- ↓
-Day 3: GameState + GameManager 骨架 → SceneLoader → SettingsManager + GlobalSettingsSO
- ↓
-Day 4: AddressKeys → AssetLoader → GlobalObjectPool + PooledObject
- ↓
-Day 5: SaveData C# 结构 → SaveManager 骨架 → Persistent 场景组装 → 全链路验证
-```
-
-**关键原则**:每步完成后确认编译无错再继续。asmdef 创建顺序遵循依赖图(无依赖的先创建)。
-
----
-
-## 2. Day 1:文件夹与 asmdef 骨架
-
-### 2.1 创建文件夹结构
-
-按 `01_ProjectStructure §1` 在 `Assets/` 下创建以下文件夹(Unity 中不存在同名 .meta 则空文件夹不提交,用 `.gitkeep` 占位):
-
-```
-Assets/
-├── Scripts/
-│ ├── Core/
-│ │ ├── Events/
-│ │ └── Save/
-│ ├── Input/
-│ ├── Camera/
-│ ├── Player/
-│ │ └── States/
-│ ├── Combat/
-│ │ └── StatusEffects/
-│ ├── Parry/
-│ ├── Enemies/
-│ │ ├── AI/
-│ │ ├── Boss/
-│ │ │ └── Patterns/
-│ │ └── Navigation/
-│ ├── Feedback/
-│ ├── World/
-│ │ ├── Map/
-│ │ └── Shop/
-│ ├── UI/
-│ ├── Audio/
-│ ├── Progression/
-│ ├── Dialogue/
-│ ├── Equipment/
-│ ├── Cutscene/
-│ ├── Animation/
-│ ├── Spells/
-│ ├── Localization/
-│ ├── Tutorial/
-│ ├── Platform/
-│ └── Editor/
-│
-├── Data/
-│ ├── Events/
-│ │ ├── Core/
-│ │ ├── Player/
-│ │ ├── Combat/
-│ │ ├── World/
-│ │ └── UI/
-│ ├── Player/
-│ ├── Combat/
-│ ├── Enemies/
-│ ├── Progression/
-│ ├── Audio/
-│ ├── World/
-│ ├── UI/
-│ └── Settings/
-│
-├── Prefabs/
-│ ├── Player/
-│ ├── Enemies/
-│ ├── World/
-│ ├── UI/
-│ ├── Combat/
-│ ├── Effects/
-│ └── Persistent/
-│
-└── Scenes/
-```
-
-### 2.2 创建 Assembly Definition 文件
-
-**创建顺序(依赖图自底向上)**:
-
-| 顺序 | 文件路径 | Assembly 名 | 引用 |
-|------|---------|-------------|------|
-| 1 | `Scripts/Core/Events/BaseGames.Core.Events.asmdef` | `BaseGames.Core.Events` | 无 |
-| 2 | `Scripts/Core/Save/BaseGames.Core.Save.asmdef` | `BaseGames.Core.Save` | Core.Events, Newtonsoft.Json |
-| 3 | `Scripts/Core/BaseGames.Core.asmdef` | `BaseGames.Core` | Core.Events, Core.Save |
-| 4 | `Scripts/Input/BaseGames.Input.asmdef` | `BaseGames.Input` | Core.Events, Unity.InputSystem |
-| 5 | `Scripts/Camera/BaseGames.Camera.asmdef` | `BaseGames.Camera` | Core.Events, Cinemachine |
-| 6 | `Scripts/Combat/BaseGames.Combat.asmdef` | `BaseGames.Combat` | Core.Events |
-| 7 | `Scripts/Combat/StatusEffects/BaseGames.Combat.StatusEffects.asmdef` | `BaseGames.Combat.StatusEffects` | Combat |
-| 8 | `Scripts/Parry/BaseGames.Parry.asmdef` | `BaseGames.Parry` | Combat |
-| 9 | `Scripts/Feedback/BaseGames.Feedback.asmdef` | `BaseGames.Feedback` | Core.Events, Combat |
-| 10 | `Scripts/Player/BaseGames.Player.asmdef` | `BaseGames.Player` | Core, Input, Combat, Parry, Feedback, Animancer |
-| 11 | `Scripts/Player/States/BaseGames.Player.States.asmdef` | `BaseGames.Player.States` | Player |
-| 12 | `Scripts/Enemies/BaseGames.Enemies.asmdef` | `BaseGames.Enemies` | Core, Combat, Feedback, Animancer |
-| 13 | `Scripts/Enemies/AI/BaseGames.Enemies.AI.asmdef` | `BaseGames.Enemies.AI` | Enemies, BehaviorDesigner.Runtime |
-| 14 | `Scripts/Enemies/Navigation/BaseGames.Enemies.Navigation.asmdef` | `BaseGames.Enemies.Navigation` | Enemies, PathBerserker2d |
-| 15 | `Scripts/World/BaseGames.World.asmdef` | `BaseGames.World` | Core, Combat, Animancer |
-| 16 | `Scripts/UI/BaseGames.UI.asmdef` | `BaseGames.UI` | Core.Events |
-| 17 | `Scripts/Audio/BaseGames.Audio.asmdef` | `BaseGames.Audio` | Core.Events |
-| 18 | `Scripts/Progression/BaseGames.Progression.asmdef` | `BaseGames.Progression` | Core, Player |
-| 19 | `Scripts/Equipment/BaseGames.Equipment.asmdef` | `BaseGames.Equipment` | Core.Events, Player |
-| 20 | `Scripts/Spells/BaseGames.Spells.asmdef` | `BaseGames.Spells` | Core.Events, Player, Combat |
-| 21 | `Scripts/World/Map/BaseGames.World.Map.asmdef` | `BaseGames.World.Map` | World, Core.Save |
-| 22 | `Scripts/World/Shop/BaseGames.World.Shop.asmdef` | `BaseGames.World.Shop` | World, Core.Events |
-| 23 | `Scripts/Dialogue/BaseGames.Dialogue.asmdef` | `BaseGames.Dialogue` | Core.Events |
-| 24 | `Scripts/Cutscene/BaseGames.Cutscene.asmdef` | `BaseGames.Cutscene` | Core.Events, Dialogue |
-| 25 | `Scripts/Animation/BaseGames.Animation.asmdef` | `BaseGames.Animation` | Core.Events, Animancer |
-| 26 | `Scripts/Enemies/Boss/Patterns/BaseGames.Enemies.Boss.Patterns.asmdef` | `BaseGames.Enemies.Boss.Patterns` | Enemies, Combat |
-| 27 | `Scripts/Localization/BaseGames.Localization.asmdef` | `BaseGames.Localization` | Core.Events |
-| 28 | `Scripts/Platform/BaseGames.Platform.asmdef` | `BaseGames.Platform` | Core.Events |
-| 29 | `Scripts/Tutorial/BaseGames.Tutorial.asmdef` | `BaseGames.Tutorial` | Core.Events, World |
-| 30 | `Scripts/Editor/BaseGames.Editor.asmdef` | `BaseGames.Editor` | 全部(Editor Only,`includePlatforms: ["Editor"]`) |
-
-### 2.3 放置占位脚本
-
-每个 asmdef 目录放一个 `_Placeholder.cs`(仅 namespace 声明),确保 Unity 不报"asmdef 无脚本"警告:
-
-```csharp
-// _Placeholder.cs
-namespace BaseGames.Core { } // 各目录对应 namespace
-```
-
-### 2.4 验证依赖方向
-
-在 Unity Editor 打开 **Edit → Project Settings → Player → Other Settings** 确认无编译错误。
-确认底层 asmdef 的引用列表里**没有**高层 asmdef 名称。
-
----
-
-## 3. Day 2:SO 事件系统
-
-**参考文档**:`02_EventSystem.md`
-
-### 3.1 实现基类(`Assets/Scripts/Core/Events/`)
-
-按顺序创建以下文件:
-
-```
-BaseEventChannelSO.cs ← 泛型基类 + VoidBaseEventChannelSO
-VoidEventChannelSO.cs ← [CreateAssetMenu]
-BoolEventChannelSO.cs
-IntEventChannelSO.cs
-FloatEventChannelSO.cs
-StringEventChannelSO.cs
-Vector2EventChannelSO.cs
-TransformEventChannelSO.cs
-GameState.cs ← 枚举(顺便在此处定义)
-GameStateEventChannelSO.cs
-SceneLoadRequest.cs ← struct(sceneName + entryId + loadingScreen)
-SceneLoadRequestEventChannelSO.cs
-DifficultyLevel.cs ← 枚举(Easy/Normal/Hard/SteelSoul)
-DifficultyChangedEventChannel.cs ← Phase 2 难度系统用(按架构命名,非 DifficultyEventChannelSO)
-DamageInfoEventChannelSO.cs ← Combat 伤害事件(EVT_DamageDealt)
-HitInfo.cs ← struct(DamageInfo + HitPoint Vector3)
-HitConfirmedEventChannelSO.cs ← VFX 命中事件(EVT_HitConfirmed,洛载类型 HitInfo)
-ShopPurchaseEvent.cs ← struct(⚠️ 架构 15_MapShopModule §2.3:ShopPurchaseEvent { Item, Price };非旧版 ShopTransactionEvent)
-ShopPurchaseEventChannelSO.cs ← 商店购买事件(EVT_ItemPurchased,⚠️ 架构 15 §2.3;非旧版 EVT_ShopTransactionCompleted)
-DialogueEventChannelSO.cs ← 对话请求事件(EVT_DialogueStartRequest,⚠️ payload 为 DialogueDataSO SO 引用,非 struct;无 DialogueRequest 类,架构 02 §3)
-LiquidEventChannelSO.cs ← 液体进出事件(EVT_LiquidEntered / EVT_LiquidExited)
-AbilityType.cs ← `[Flags] uint` 枚举(WallCling/WallJump/Dash/...) ⚠️ 位于 Scripts/Player/(程序集 BaseGames.Player),非 Scripts/Progression/(架构 09_ProgressionModule §1)
-AbilityTypeEventChannelSO.cs ← 能力解锁事件(仅内部保留;⚠️ EVT_AbilityUnlocked 实际使用 StringEventChannelSO,架构 02 §4)
-// ── Quest 事件频道(22_QuestChallengeModule §5)──────────────────────────────
-QuestState.cs ← 枚举(Unavailable/Available/Active/Completed/Failed)
-QuestStateChangedEvent.cs ← struct { string QuestId; QuestState State; }
-QuestStateChangedEventChannel.cs ← 任务状态变更(⚠️ 按架构命名,无 SO 后缀)
-QuestObjectiveEventChannelSO.cs ← 任务目标进度(payload: QuestObjectiveEvent{QuestId, ObjectiveId, Progress, Required},架构 02 §3)
-StatusEffectEventChannelSO.cs ← 状态效果施加/过期(payload: StatusEffectType 枚举,架构 02 §3)
-// ── Boss 技能事件频道(23_BossSkillModule §11)─────────────────────────────
-BossSkillEventChannelSO.cs ← Boss 技能开始/结束(payload: (string bossId, string skillId))
-BossPhaseEventChannelSO.cs ← Boss 阶段切换(payload: (string bossId, int phase))
-// ── 可访问性事件频道(16_SupportingModules §AccessibilityManager)──────────
-ColorblindMode.cs ← 枚举(None/Deuteranopia/Protanopia/Tritanopia)
-ColorblindModeEventChannelSO.cs ← 色觉模式切换事件(EVT_ColorblindModeChanged)
-// ── 导航标记事件频道(21_LiquidPuzzleModule §14)─────────────────────────────
-WorldMarkerEventChannelSO.cs ← 导航标记激活/失活(EVT_WorldMarkerActivated / EVT_WorldMarkerDeactivated)
-```
-
-### 3.2 在 `Assets/Data/Events/` 下预建全局 SO 资产
-
-命名规范:`EVT_{EventName}.asset`(不含模块前缀中的下划线,与架构 `02_EventSystem` 一致)
-
-**Core 事件**(`Data/Events/Core/`):
-
-| 资产名 | 类型 |
-|--------|------|
-| `EVT_PlayerDied` | `VoidEventChannelSO` |
-| `EVT_PlayerRespawned` | `VoidEventChannelSO` |
-| `EVT_PauseRequested` | `VoidEventChannelSO` |
-| `EVT_DeathScreenConfirmed` | `VoidEventChannelSO` |
-| `EVT_GameStateChanged` | `GameStateEventChannelSO` |
-| `EVT_SceneLoadRequest` | `SceneLoadRequestEventChannelSO` |
-| `EVT_SceneLoaded` | `StringEventChannelSO` |
-| `EVT_SavePointActivated` | `StringEventChannelSO` |
-| `EVT_DifficultyChanged` | `DifficultyChangedEventChannel` |
-
-**UI 事件**(`Data/Events/UI/`):
-
-| 资产名 | 类型 |
-|--------|------|
-| `EVT_FadeIn` | `VoidEventChannelSO` |
-| `EVT_FadeOut` | `VoidEventChannelSO` |
-| `EVT_FastTravelOpen` | `VoidEventChannelSO` |
-| `EVT_ShopOpened` | `StringEventChannelSO` |
-| `EVT_ShopClosed` | `VoidEventChannelSO` |
-| `EVT_MapOpen` | `VoidEventChannelSO` |
-| `EVT_ShowPanel` | `StringEventChannelSO`(panelId) |
-| `EVT_HidePanel` | `StringEventChannelSO`(panelId) |
-
-**Player 事件**(`Data/Events/Player/`):
-
-| 资产名 | 类型 |
-|--------|------|
-| `EVT_HPChanged` | `IntEventChannelSO` |
-| `EVT_MaxHPChanged` | `IntEventChannelSO` |
-| `EVT_SoulPowerChanged` | `IntEventChannelSO` |
-| `EVT_SpiritPowerChanged` | `IntEventChannelSO` |
-| `EVT_SpringChargesChanged` | `IntEventChannelSO` |
-| `EVT_GeoChanged` | `IntEventChannelSO` |
-| `EVT_PlayerFormChanged` | `IntEventChannelSO` |
-| `EVT_AbilityUnlocked` | `StringEventChannelSO`(abilityId,⚠️ 非 AbilityTypeEventChannelSO,架构 02 §4) |
-| `EVT_SkillSetChanged` | `VoidEventChannelSO`(发布: FormController,订阅: SkillHUD) |
-| `EVT_ShieldHPChanged` | `IntEventChannelSO`(发布: ShieldComponent,订阅: HUDController) |
-| `EVT_ShieldBroken` | `VoidEventChannelSO` |
-| `EVT_ShieldRestored` | `VoidEventChannelSO` |
-
-**Combat 事件**(`Data/Events/Combat/`):
-
-| 资产名 | 类型 |
-|--------|------|
-| `EVT_EnemyDied` | `TransformEventChannelSO` |
-| `EVT_DamageDealt` | `DamageInfoEventChannelSO` |
-| `EVT_HitConfirmed` | `HitConfirmedEventChannelSO` |
-| `EVT_ParrySuccess` | `VoidEventChannelSO` |
-| `EVT_BossFightStarted` | `StringEventChannelSO`(bossId;⚠️ 属战斗事件,架构 02 §4,非 Core 事件) |
-| `EVT_BossFightEnded` | `BoolEventChannelSO`(⚠️ 属战斗事件,架构 02 §4,非 Core 事件) |
-| `EVT_BossFightToggled` | `BoolEventChannelSO`(true=开始,false=结束) |
-| `EVT_BossHPChanged` | `IntEventChannelSO` |
-| `EVT_BossNameSet` | `StringEventChannelSO`(bossName) |
-| `EVT_BossHPMaxSet` | `IntEventChannelSO` |
-| `EVT_NailClash` | `VoidEventChannelSO` |
-| `EVT_StatusEffectApplied` | `StatusEffectEventChannelSO` |
-| `EVT_StatusEffectExpired` | `StatusEffectEventChannelSO` |
-| `EVT_BossSkillStarted` | `BossSkillEventChannelSO` |
-| `EVT_BossSkillEnded` | `BossSkillEventChannelSO` |
-| `EVT_BossVulnerabilityWindowOpened` | `StringEventChannelSO` |
-| `EVT_BossPhaseChanged` | `BossPhaseEventChannelSO` |
-
-**World 事件**(`Data/Events/World/`):
-
-| 资产名 | 类型 |
-|--------|------|
-| `EVT_CollectiblePickup` | `StringEventChannelSO` |
-| `EVT_RoomEntered` | `StringEventChannelSO` |
-| `EVT_RoomTransitionRequest` | `SceneLoadRequestEventChannelSO` |
-| `EVT_MapUpdated` | `StringEventChannelSO` |
-| `EVT_LiquidEntered` | `LiquidEventChannelSO` |
-| `EVT_LiquidExited` | `LiquidEventChannelSO` |
-| `EVT_DrownProgress` | `FloatEventChannelSO`(0–1 进度) |
-| `EVT_PlayerDrowned` | `VoidEventChannelSO` |
-| `EVT_GeoRecovered` | `StringEventChannelSO` |
-| `EVT_ShowInteractPrompt` | `StringEventChannelSO` |
-| `EVT_HideInteractPrompt` | `VoidEventChannelSO` |
-| `EVT_WorldMarkerActivated` | `WorldMarkerEventChannelSO` |
-| `EVT_WorldMarkerDeactivated` | `WorldMarkerEventChannelSO` |
-| `EVT_ItemPurchased` | `ShopPurchaseEventChannelSO` |
-
-**Audio 事件**(`Data/Events/Audio/`):
-
-| 资产名 | 类型 |
-|--------|------|
-| `EVT_PlayBGM` | `StringEventChannelSO` |
-| `EVT_StopBGM` | `VoidEventChannelSO` |
-| `EVT_PlaySFX` | `StringEventChannelSO` |
-| `EVT_RegionEntered` | `StringEventChannelSO` |
-
-**Dialogue 事件**(`Data/Events/Dialogue/`):
-
-| 资产名 | 类型 |
-|--------|------|
-| `EVT_DialogueStartRequest` | `DialogueEventChannelSO` |
-| `EVT_DialogueStarted` | `VoidEventChannelSO` |
-| `EVT_DialogueEnded` | `VoidEventChannelSO` |
-| `EVT_NpcDialogueCompleted` | `StringEventChannelSO` |
-| `EVT_CutsceneStarted` | `VoidEventChannelSO` |
-| `EVT_CutsceneEnded` | `VoidEventChannelSO` |
-
-**Quest 事件**(`Data/Events/Quest/`):
-
-| 资产名 | 类型 |
-|--------|------|
-| `EVT_QuestStarted` | `StringEventChannelSO`(questId,⚠️ 架构命名,对应 QuestManager.StartQuest(),架构 02 §4) |
-| `EVT_QuestCompleted` | `StringEventChannelSO`(questId) |
-| `EVT_QuestFailed` | `StringEventChannelSO`(questId) |
-| `EVT_ObjectiveUpdated` | `QuestObjectiveEventChannelSO`(QuestObjectiveEvent) |
-| `EVT_QuestStateChanged` | `QuestStateChangedEventChannel` |
-| `EVT_ChallengeCompleted` | `StringEventChannelSO` |
-| `EVT_ChallengeFailed` | `StringEventChannelSO` |
-
-**事件链 / EventChain 事件**(`Data/Events/EventChain/`):
-
-| 资产名 | 类型 |
-|--------|------|
-| `EVT_ChainCompleted` | `StringEventChannelSO` |
-| `EVT_DoorOpened` | `StringEventChannelSO` |
-| `EVT_FlagChanged` | `StringEventChannelSO` |
-
-**Save 事件**(`Data/Events/Save/`):
-
-| 资产名 | 类型 |
-|--------|------|
-| `EVT_SaveIndicatorVisible` | `BoolEventChannelSO` |
-
-**Accessibility 事件**(`Data/Events/Accessibility/`):
-
-| 资产名 | 类型 |
-|--------|------|
-| `EVT_AchievementUnlocked` | `StringEventChannelSO` |
-| `EVT_SoftlockDetected` | `VoidEventChannelSO` |
-| `EVT_ColorblindModeChanged` | `ColorblindModeEventChannelSO` |
-| `EVT_SubtitlesToggled` | `BoolEventChannelSO` |
-| `EVT_HighContrastToggled` | `BoolEventChannelSO` |
-
-> **说明**:DamageInfoEventChannelSO、HitConfirmedEventChannelSO 等具体 channel 类型的脚本在 Day 2 §3.1 中一并创建;对应 SO 资产在创建完类型后立即在 Inspector 中创建。所有事件频道资产后续在各模块实现时如需补充,按此规范追加。
-
-### 3.3 验证
-
-在 Editor 中创建一个临时 `TestEventChannel.cs`,订阅 `EVT_PlayerDied.OnEventRaised` 并打印日志,在 Inspector 点击"Raise"按钮(需在 `BaseEventChannelSO` Editor 脚本里添加测试按钮)确认事件触发。
-
----
-
-## 4. Day 3:Core 模块
-
-**参考文档**:`03_CoreModule.md`
-
-### 4.1 实现文件列表(`Assets/Scripts/Core/`)
-
-```
-GameStateId.cs ← struct(替代旧 enum GameState,可扩展的状态 ID)
-IGameState.cs ← 状态接口(OnEnter / OnExit / Tick)
-GameStateMachine.cs ← 驱动所有 IGameState 切换的状态机
-GameStates.cs ← 静态工厂:提供 8 个内置状态实例(MainMenu/Gameplay/Paused/BossFight/Cutscene/Loading/Dead/GameOver)
-IGameStateFactory.cs ← DLC/扩展工厂接口(注册自定义状态)
-GameManager.cs ← 字段 + 接口签名(Awake/Start 骨架;内嵌 GameStateMachine)
-GameStateEventChannelSO.cs ← payload 改为 GameStateId(⚠️ 非旧枚举)
-SceneLoader.cs ← LoadAsync 骨架(监听 EVT_SceneLoadRequest)
-SettingsManager.cs ← Load/Save 设置
-GlobalSettingsSO.cs ← SO 数据类
-// ── 服务层骨架(03_CoreModule §11-13)───────────────────────────────────────
-ServiceLocator.cs ← 静态类(Register/Get/GetOrDefault/OverrideForTest/Reset)
-GameServiceRegistrar.cs ← MonoBehaviour,ExecutionOrder -2000,DontDestroyOnLoad
-IAudioService.cs ← 音频服务接口
-ISaveService.cs ← 存档服务接口
-ISceneService.cs ← 场景加载服务接口(UniTask-based)
-IDeathRespawnService.cs ← 死亡/重生服务接口(UniTask-based)
-IEventChannelRegistry.cs ← SO 事件频道查找接口
-NullAudioService.cs ← IAudioService 的空实现(测试兜底)
-DeathRespawnService.cs ← IDeathRespawnService 骨架(Phase 1 实现完整逻辑)
-SceneService.cs ← ISceneService 骨架(封装 SceneLoader,Phase 1 完整实现)
-```
-
-> **⚠️ 架构升级**:`GameState` 枚举已全面替换为 `GameStateId` struct + `IGameState` 接口体系(架构 03_CoreModule §2)。
-> 旧写法 `TransitionTo(GameState.Gameplay)` 改为 `TransitionTo(GameStates.Gameplay)`;
-> 订阅方将收到 `GameStateId` 而非枚举值。
-
-### 4.2 GameManager 实现优先级
-
-Day 3 只实现**骨架部分**,不实现完整死亡/复活流程(Phase 1 完成):
-
-```csharp
-// Day 3 实现范围
-// GameStateId 是 struct,不是 enum,通过 GameStates 静态类获取内置实例
-// IGameState 接口:void OnEnter(); void OnExit(); void Tick(float dt);
-// GameStateMachine 包含 TransitionTo(IGameState next),GameManager 内嵌实例
-
-void Awake()
-{
- // 1. 单例检查(DontDestroyOnLoad 由 Persistent 场景保证)
- // 2. 订阅事件频道(6个监听)
- // 3. _stateMachine.TransitionTo(GameStates.MainMenu) — 占位
- Debug.Log($"[GameManager] Awake v{Application.version}");
-}
-
-// 占位实现(不报错即可)
-public void TransitionTo(IGameState newState) { _stateMachine.TransitionTo(newState); }
-public void Pause() { TransitionTo(GameStates.Paused); }
-public void Resume() { TransitionTo(GameStates.Gameplay); }
-```
-
-### 4.3 SceneLoader 实现
-
-```csharp
-// Day 3 实现范围
-// 订阅 EVT_SceneLoadRequest,执行 Addressables.LoadSceneAsync
-// 加载完成后发布 EVT_SceneLoaded
-// _currentRoomScene 记录当前场景名,用于 Unload
-```
-
-### 4.4 GlobalSettingsSO
-
-```csharp
-// ⚠️ 字段名、menuName、结构均必须与架构 03_CoreModule §7 一致
-[CreateAssetMenu(menuName = "Settings/GlobalSettings")]
-public class GlobalSettingsSO : ScriptableObject
-{
- [Header("Audio")]
- public float DefaultMasterVolume = 1f;
- public float DefaultBGMVolume = 0.8f;
- public float DefaultSFXVolume = 1f;
- public float DefaultAmbientVolume = 0.8f; // ⚠️ 与 AudioMixerKeys.Ambient 对应
-
- [Header("Display")]
- public int DefaultTargetFPS = 60;
- public bool DefaultVSync = false;
-
- [Header("Language")]
- public string DefaultLocaleCode = "zh-CN";
-
- [Header("Accessibility")]
- public bool DefaultHighContrast = false;
- public bool DefaultScreenShake = true;
-}
-
-[System.Serializable]
-public class GlobalSettingsData
-{
- public float MasterVolume;
- public float BGMVolume;
- public float SFXVolume;
- public float AmbientVolume; // ⚠️ 与 AudioMixerKeys.Ambient 对应
- public int TargetFPS;
- public bool VSync;
- public string LocaleCode;
- public bool HighContrast;
- public bool ScreenShake;
-}
-```
-
-`SettingsManager.Awake()` 从 `PlayerPrefs` 恢复设置到 `GlobalSettingsData`;`Apply()` 方法将数据应用到 Unity `QualitySettings`/`Screen`/`PlayerPrefs`。
-
----
-
-## 5. Day 4:Addressables 与对象池骨架
-
-**参考文档**:`13_AssetPoolModule.md`
-
-### 5.1 AddressKeys
-
-```csharp
-// Assets/Scripts/Core/Assets/AddressKeys.cs
-// ⚠️ 命名规则:camelCase(无下划线分隔),字符串值保持原样(架构 13_AssetPoolModule §1 patch)
-public static class AddressKeys
-{
- // Scenes
- public const string ScenePersistent = "Scene_Persistent"; // ⚠️ 值为 "Scene_Persistent"(非 "Persistent")
- public const string SceneMainMenu = "Scene_MainMenu";
- public const string SceneTestRoom = "Scene_TestRoom"; // Phase 1 测试房间
-
- // Player
- public const string PrefabPlayer = "PLY_Player"; // ⚠️ camelCase(非 Prefab_Player)
-
- // 其余常量在各 Phase 按需追加(统一 camelCase 命名规则)
-}
-```
-
-### 5.2 AssetLoader
-
-```csharp
-// Assets/Scripts/Core/Assets/AssetLoader.cs
-// ⚠️ 架构使用标准 Task(非 UniTask),见 13_AssetPoolModule §5
-public static class AssetLoader
-{
- // 异步加载单个资产(带缓存)
- public static async Task LoadAsync(string addressKey) where T : UnityEngine.Object;
-
- // 释放(减引用计数)
- public static void Release(string addressKey);
-
- // 释放全部缓存
- public static void ReleaseAll();
-}
-```
-
-### 5.3 GlobalObjectPool
-
-```csharp
-// Assets/Scripts/Core/Pool/GlobalObjectPool.cs
-// ⚠️ 最终 API 见 13_AssetPoolModule §3;Phase 0 仅建骨架,不实现全量
-[DefaultExecutionOrder(-800)]
-public class GlobalObjectPool : MonoBehaviour
-{
- // 预热:按 _warmupConfigs 配置批量实例化所有条目(无参数,以 Task 返回)
- public async Task WarmupAsync();
-
- // 获取对象(正式操作: Spawn)
- public T Spawn(string addressKey, Vector3 position, Quaternion rotation) where T : Component;
-
- // 归还对象(正式操作: Despawn)
- public void Despawn(string addressKey, GameObject instance);
-}
-```
-
-### 5.3.1 WarmupManifestSO(Phase 1 优化,架构 13 §12)
-
-> Phase 0 仅建骨架;Phase 1 Vertical Slice 阶段完善 `SceneService.LoadSceneAsync()` 时一并实现。
-
-```csharp
-// Assets/Scripts/Core/Pool/WarmupManifestSO.cs
-[CreateAssetMenu(menuName = "Core/Pool/Warmup Manifest")]
-public class WarmupManifestSO : ScriptableObject
-{
- [Serializable]
- public struct WarmupEntry
- {
- public string AddressKey; // AddressKeys 常量
- public int InitialCount; // 预热实例数
- public WarmupCategory Category;
- }
-
- public enum WarmupCategory { Enemy = 0, Projectile = 1, VFX = 2, UI = 3, Other = 99 }
-
- public WarmupEntry[] Entries;
- [Range(1, 20)] public int InstancesPerFrame = 5; // 每帧最多实例化数量(防卡顿)
-}
-
-// GlobalObjectPool 新增方法(Phase 1 补充):
-// public async UniTask WarmupFromManifestAsync(WarmupManifestSO manifest, CancellationToken ct)
-// → 分帧预热:每帧最多实例化 manifest.InstancesPerFrame 个,await UniTask.Yield() 让出帧
-
-// SceneService.LoadSceneAsync() 调用:
-// await GlobalObjectPool.Instance.WarmupFromManifestAsync(_warmupManifest, ct);
-
-// 资产路径:Assets/Data/Pool/Warmup/Global_Warmup.asset、{SceneName}_Warmup.asset 等
-```
-
-### 5.4 PooledObject
-
-```csharp
-// Assets/Scripts/Core/Pool/PooledObject.cs
-// ⚠️ 完整实现(对齐架构 13_AssetPoolModule §4);Phase 0 先创建此完整版本,后续直接使用
-public class PooledObject : MonoBehaviour
-{
- public string AddressKey { get; private set; } // ⚠️ 属性而非公共字段(架构 13 §4)
- private GlobalObjectPool _pool;
-
- // 由 GlobalObjectPool.SpawnInternal 调用,注入 key 和 pool 引用
- public void Setup(string key, GlobalObjectPool pool)
- {
- AddressKey = key;
- _pool = pool;
- }
-
- // 子类可覆盖(从池中取出时调用)
- public virtual void OnSpawn() { }
-
- // 子类可覆盖(归还到池时调用)
- public virtual void OnDespawn(){ }
-
- // 便利方法:自归还
- public void ReturnToPool() => _pool?.Despawn(AddressKey, gameObject);
-
- // 延迟归还(定时销毁型 VFX / 弹射物常用)
- public void ReturnToPoolDelayed(float delay) => StartCoroutine(DelayedReturn(delay));
- private IEnumerator DelayedReturn(float delay)
- {
- yield return new WaitForSeconds(delay);
- ReturnToPool();
- }
-}
-```
-
-### 5.5 AssetReleaseTracker
-
-```csharp
-// Assets/Scripts/Core/Assets/AssetReleaseTracker.cs
-// ⚠️ 完整实现对齐架构 13_AssetPoolModule §8:事件驱动,订阅 SceneLoadRequestEventChannelSO
-// ⚠️ 不使用 RegisterForScene/ReleaseScene 显式注册 API(SceneLoader 不主动调用本类)
-public class AssetReleaseTracker : MonoBehaviour
-{
- [Header("Event Channels")]
- [SerializeField] private SceneLoadRequestEventChannelSO _onSceneLoadRequest;
-
- private string _lastLoadedScene;
-
- private void OnEnable()
- => _onSceneLoadRequest.OnEventRaised += OnSceneLoadRequested;
- private void OnDisable()
- => _onSceneLoadRequest.OnEventRaised -= OnSceneLoadRequested;
-
- private void OnSceneLoadRequested(SceneLoadRequest req)
- {
- if (!string.IsNullOrEmpty(_lastLoadedScene))
- {
- // 清除旧场景的对象池(⚠️ GlobalObjectPool.ClearPool 方法存在,架构 13_AssetPoolModule §3)
- GlobalObjectPool.Instance.ClearPool(AddressKeys.PrefabEnemyGrunt);
- // ... 其他场景对象(按需追加)
- }
- _lastLoadedScene = req.SceneName;
- }
-}
-```
-
-### 5.6 Editor 验证工具
-
-在 `Assets/Scripts/Editor/AddressKeysValidator.cs` 创建菜单项 `Tools/Validate AddressKeys`:
-- 反射读取 `AddressKeys` 所有 `const string`
-- 对照 `Addressables.ResourceLocators` 验证每个 key 是否存在
-- 不匹配的 key 输出 Warning
-
----
-
-## 6. Day 5:SaveData 骨架 + Persistent 场景验证
-
-**参考文档**:`12_SaveModule.md §1-4`
-
-### 6.1 SaveData C# 数据结构
-
-创建 `Assets/Scripts/Core/Save/` 下所有数据类(完整结构):
-
-```
-SaveData.cs ← 顶层 + JsonExtensionData
-SaveMeta.cs ← 版本号、难度、游戏时间
-PlayerSaveData.cs ← HP、位置、灵力、形态等
-EquipmentSaveData.cs ← 护符相关(⚠️ 工具槽数据在独立的 ToolsSaveData,见架构 12_SaveModule §2)
-WorldSaveData.cs ← 房间状态、已破坏地形、已触发机关
-MapSaveData.cs ← 已探索房间列表
-QuestSaveData.cs ← 任务进度
-AchievementSaveData.cs
-ToolsSaveData.cs
-StatsSaveData.cs
-DeathShadeSaveData.cs
-ChallengeRoomsSaveData.cs ← 挑战房间进度(架构 12_SaveModule §1)
-EventChainsSaveData.cs ← 事件链状态(架构 12_SaveModule §1)
-ShopsSaveData.cs ← 商店已购记录(架构 12_SaveModule §1)
-NGPlusSaveData.cs ← New Game+ 数据(架构 12_SaveModule §1)
-```
-⚠️ `SaveData` 类**不含** `Tutorial` 字段(架构 12 §1 无 `TutorialSaveData`),教程进度应通过 `PlayerPrefs` 或独立 JSON 文件持久化,不经过存档系统。
-
-### 6.2 ISaveStorage + LocalFileStorage
-
-```csharp
-// Assets/Scripts/Core/Save/ISaveStorage.cs
-// ⚠️ 接口方法操作原始 JSON 字符串(架构 12_SaveModule §2);序列化/校验由 SaveManager 负责
-// ⚠️ 返回类型为标准 Task(非 UniTask)
-public interface ISaveStorage
-{
- Task WriteAsync(int slotIndex, string json);
- Task ReadAsync(int slotIndex);
- Task DeleteAsync(int slotIndex);
- bool Exists(int slotIndex);
- IEnumerable GetExistingSlots();
-}
-
-// Assets/Scripts/Core/Save/LocalFileStorage.cs
-// ⚠️ 类名无 Save 前缀(架构 12_SaveModule §2)
-// 存档路径: Application.persistentDataPath/saves/save_{slot}.json
-public class LocalFileStorage : ISaveStorage { }
-```
-
-### 6.3 SaveManager 骨架
-
-```csharp
-// Assets/Scripts/Core/Save/SaveManager.cs
-// ⚠️ 返回类型为标准 Task(非 UniTask),见架构 12_SaveModule §4
-[DefaultExecutionOrder(-900)]
-public class SaveManager : MonoBehaviour
-{
- // 存档当前状态(由 EVT_SavePointActivated 触发)
- public async Task SaveAsync(int slot = -1); // 遍历 _saveables → 序列化 → WriteAsync
- // 读取并恢复存档(返回 false 表示槽位不存在或校验失败)
- public async Task LoadAsync(int slot); // ReadAsync → 反序列化 → 遍历 _saveables
- public bool SlotExists(int slot);
- public IEnumerable GetExistingSlots();
- public void Register(ISaveable saveable);
- public void Unregister(ISaveable saveable);
-}
-```
-
-### 6.4 组装 Persistent 场景
-
-在 `Persistent.unity` 创建以下 GameObject 层级(组件留空或骨架绑定):
-
-```
-[Managers]
-├── GameManager ← GameManager.cs,Inspector 绑定所有 EVT_ SO 资产
-├── SceneLoader ← SceneLoader.cs
-├── GlobalObjectPool ← GlobalObjectPool.cs
-├── SaveManager ← SaveManager.cs
-└── SettingsManager ← SettingsManager.cs + GlobalSettingsSO 资产引用
-```
-
-### 6.5 全链路验证
-
-创建 `Scenes/TestRoom_Phase0.unity`(空房间,一个平台的 Tilemap)并完成以下验证:
-
-| 验证项 | 方法 |
-|--------|------|
-| Persistent 场景加载 + GameManager Awake 打印版本号 | Play `Persistent.unity`,Console 看 Log |
-| `SaveAsync(0)` 写入 JSON 存档文件(Phase 0 新游戏可手动构造初始 SaveData 后调用) | 菜单调用,检查 `persistentDataPath/saves/save_0.json` |
-| `LoadAsync(0)` 读取并填充 `CurrentSave` | Debug.Log 打印 `CurrentSave.Meta.PlayTime` |
-| `EVT_PlayerDied` Raise → `GameManager` 收到并打印 | Inspector "Raise" 按钮 |
-| `AddressKeys.SceneTestRoom` 能被 `SceneLoader` 加载 | 调用 `GameManager.LoadRoom` |
-
----
-
-## 7. 完成标准检查清单
-
-```
-✅ Unity 编译无错,Console 无 Error(脚本层全部创建完毕,2026-05-07)
-✅ 所有 asmdef 依赖方向正确(低层不引用高层)
-✅ SO 事件系统:Raise 能触发订阅者,OnDisable 能正确取消订阅
-□ GlobalSettingsSO 序列化/反序列化无 JSON 报错(待 Unity Editor 运行验证)
-□ SaveManager.SaveAsync(0) → 磁盘生成 save_0.json(待 Unity Editor 运行验证)
-□ SaveManager.LoadAsync → CurrentSave 非 null,数据匹配(待 Unity Editor 运行验证)
-□ SceneLoader 能 Additive 加载/卸载 TestRoom 场景(待 TestRoom 场景创建后验证)
-□ GlobalObjectPool.Spawn 能返回实例,Despawn 能归还(待 Unity Editor 运行验证)
-□ AddressKeysValidator 无 Warning(当前定义的 key 均已在 Addressables 分组中)
-□ Persistent 场景 GameManager.Awake 打印版本号(待 Persistent 场景组装后验证)
-```
-
-> **Phase 0 代码层完成于 2026-05-07。** 运行时验证项待 Phase 1 场景组装完成后一并执行。
-
-**Phase 0 完成后进入 Phase 1。**
diff --git a/Docs/Plan/02_Phase1_VerticalSlice.md b/Docs/Plan/02_Phase1_VerticalSlice.md
deleted file mode 100644
index 677b116..0000000
--- a/Docs/Plan/02_Phase1_VerticalSlice.md
+++ /dev/null
@@ -1,1350 +0,0 @@
-# Phase 1 · 垂直切片 MVP
-
-> **周期**:3–4 周(Week 1-4)
-> **前置条件**:Phase 0 全部完成标准通过
-> **核心目标**:一个可玩的测试房间;验证所有第三方库(Animancer / PathBerserker2d / BD / Cinemachine / Feel / Addressables)协作无冲突
-> **产出物**:玩家能移动/跳跃/攻击 → 敌人寻路并反击 → 死亡 → 存档复活,VFX/SFX 全流程响应
-
-> **进度更新(2026-05-07)**:Week 1–3 代码层完成,Week 4 未开始。
->
-> **Week 1 完成项**:InputReaderSO、InputBuffer、PlayerMovement、PlayerStats、PlayerController(Idle/Run/Jump/Fall)、CameraStateController、RoomVisibleArea、CameraTriggerZone、CameraConfigSO、CameraBlendProfileSO。
->
-> **Week 2 完成项**:DamageInfo/DamageType/DamageFlags/DamageTags/DamageCategory/HitFxType/BreakLevel、HitBox、HurtBox、DamageSourceSO、WeaponManager、PlayerCombat、AttackState、EnemyBase、EnemyStats、EnemyNavAgent、BD 基础行为树任务(BD_Patrol / BD_MoveToPlayer / BD_Attack / BD_IsPlayerInRange)。
->
-> **Week 3 完成项**:SaveManager(完整 SaveAsync/LoadAsync/Checksum/SlotSummary)、ISaveable、SaveData、LocalFileStorage、SaveMigrator、IInteractable、SavePoint(含 ISaveable 集成)、EmergencySaveService、GameManager(DeathFlow + RequestTransition)、DeathRespawnService、UIManager、HUDController(HP/灵力/Geo/弹簧/形态/交互提示)、DeathScreenController。PlayerStats.FullHeal() 已补全;BaseGames.World.asmdef 已加 Player.States/Core.Events/Core.Save 引用;BaseGames.UI.asmdef 已加 BaseGames.Core 引用。
-
----
-
-## 目录
-
-1. [实施顺序总览](#1-实施顺序总览)
-2. [Week 1:输入 + 玩家骨架 + 相机](#2-week-1输入--玩家骨架--相机)
-3. [Week 2:战斗基础 + 敌人骨架](#3-week-2战斗基础--敌人骨架)
-4. [Week 3:存档完善 + 死亡复活流 + UI](#4-week-3存档完善--死亡复活流--ui)
-5. [Week 4:VFX/音效 + 集成验证](#5-week-4vfx音效--集成验证)
-6. [第三方集成检查点](#6-第三方集成检查点)
-7. [完成标准检查清单](#7-完成标准检查清单)
-
----
-
-## 1. 实施顺序总览
-
-```
-Week 1:
- InputReaderSO + InputBuffer
- ↓
- PlayerMovement + PlayerStats(Rigidbody2D 物理验证)
- ↓
- PlayerController(仅 Idle/Run/Jump/Fall 状态)
- ↓
- CameraStateController + CinemachineConfiner2D + PixelPerfect 验证
-
-Week 2:
- DamageInfo + DamageType 枚举 + DamageCategory/Flags/Tags
- ↓
- HitBox + HurtBox + IDamageable
- ↓
- AttackState(Animancer 状态机 首次集成)
- ↓
- EnemyBase + EnemyStats + EnemyNavAgent(PathBerserker2d 首次集成)
- ↓
- Behavior Designer 基础 BT(Patrol→Chase→Attack)
-
-Week 3:
- SaveManager 完整实现 + SavePoint + ISaveable + EmergencySaveService
- ↓
- GameManager 死亡/复活流完整 Coroutine
- ↓
- UIManager + HUDController(HP 条、灵力条)+ DeathScreenController(⚠️ 非 HUDPanel/DeathPanel,架构 10 §3/§6)
-
-Week 4:
- VFXPool + HitFXSpawner + HurtFlashController + VFXCatalogSO
- ↓
- AudioManager(Unity AudioMixer + 双 AudioSource 交叉淡入淡出)
- ↓
- IFeedbackPlayer + PlayerFeedback / EnemyFeedback(Feel MMF_Player)
- ↓
- 集成验证(Phase 1 全链路)
-```
-
----
-
-## 2. Week 1:输入 + 玩家骨架 + 相机
-
-**参考文档**:`04_InputModule.md`、`05_PlayerModule.md §1-4`、`17_CameraModule.md`
-
-### 2.1 InputReaderSO
-
-**文件**:`Assets/Scripts/Input/InputReaderSO.cs`
-
-创建要点:
-- `IInputActionCollection2` 接口(通过 Input Actions 代码生成类自动满足)
-- `OnEnable` / `OnDisable` 中 Enable / Disable ActionMap
-- 所有 Action 委托在 `OnEnable` 中绑定到内部 `_actions` 上,`OnDisable` 取消绑定
-- 区分两个 ActionMap:`Gameplay`、`UI`,通过专用方法切换(⚠️ 方法名以架构 `04_InputModule §2` 为准,不存在 `SetActionMap(ActionMapType)`;对话中由 DialogueManager 调用 `EnableUIInput()`,无独立 Dialogue Map)
-
-```csharp
-// 最小可验证 API(Phase 1 需要)
-public event Action MoveEvent;
-public event Action JumpStartedEvent;
-public event Action JumpCancelledEvent;
-public event Action AttackEvent;
-public event Action PauseEvent;
-
-public void EnableGameplayInput(); // 启用 Gameplay Map,禁用 UI Map
-public void EnableUIInput(); // 启用 UI Map,禁用 Gameplay Map
-public void DisableAllInput(); // 全部禁用(过场/加载中)
-```
-
-**资产**:`Assets/Data/Player/PLY_InputReader.asset` — 在 Inspector 中 Create Asset,供全局使用。
-
-**PlayerInputActions.inputactions**:在 `Assets/Settings/Input/` 创建 Input Actions 资产,配置:
-
-| ActionMap | Action | Binding |
-|-----------|--------|---------|
-| Gameplay | Move | WASD / 左摇杆 |
-| Gameplay | Jump | Space / 南键 |
-| Gameplay | Attack | Z / 西键 |
-| Gameplay | Pause | Escape / Start |
-| UI | Navigate | 方向键 / 左摇杆 |
-| UI | Submit | Return / 南键 |
-| UI | Cancel | Escape / 东键 |
-
-### 2.2 InputBuffer
-
-**文件**:`Assets/Scripts/Input/InputBuffer.cs`
-
-```csharp
-// 挂在 Player Prefab 上,与 InputReaderSO 配合
-public class InputBuffer : MonoBehaviour
-{
- [SerializeField] private InputReaderSO _inputReader; // ⚠️ 非 _input(架构 04_InputModule §4)
- [SerializeField] private float _jumpBufferDuration = 0.15f; // 跳跃宻d容窗口(架构 04_InputModule §4)
- [SerializeField] private float _attackBufferDuration = 0.12f; // 攻击接续连段
- [SerializeField] private float _dashBufferDuration = 0.10f; // 冲刺小量容错
-
- private float _jumpBuffer;
- private float _attackBuffer;
- private float _dashBuffer;
-
- // 查询接口(消费型,查到即清除)
- public bool ConsumeJump();
- public bool ConsumeAttack();
- public bool ConsumeDash();
-
- // 内部:OnEnable 订阅 InputReader 事件,设定定时清除
-}
-```
-
-### 2.3 PlayerMovement
-
-**文件**:`Assets/Scripts/Player/PlayerMovement.cs`
-
-Phase 1 实现范围(不含 Dash/WallSlide/Swim):
-
-| 方法 | Phase 1 实现 |
-|------|--------------|
-| `Move(float speedX)` | ✅ 完整 |
-| `Jump(bool isVariable)` | ✅ 完整(CoyoteTime + VariableJump)|
-| `CutJump()` | ✅ 完整 |
-| `ApplyKnockback` | ✅ 完整 |
-| `CheckGrounded()` | ✅ `Physics2D.OverlapBox` |
-| `CheckWalls()` | ✅ Raycast |
-| `Dash(Vector2 dir, float speed)` | ⏳ Phase 2 |
-| `DropThroughPlatform()` | ⏳ Phase 2 |
-
-### 2.4 PlayerStats
-
-**文件**:`Assets/Scripts/Player/PlayerStats.cs`
-
-Phase 1 仅实现:
-
-```csharp
-public int MaxHP { get; private set; }
-public int CurrentHP { get; private set; }
-public int MaxSpringCharges { get; private set; } // ⚠️ 非 MaxSpring(架构 05 §4)
-public int CurrentSpringCharges { get; private set; } // ⚠️ 非 CurrentSpring
-
-public void TakeDamage(int amount); // 减 HP → 发布 EVT_HPChanged;=0 时发布 EVT_PlayerDied
-public void HealHP(int amount); // ⚠️ 非 Heal;回血(上限 MaxHP,架构 05 §4)
-// ⚠️ 无 Initialize(PlayerStatsSO) 方法;PlayerStatsSO _config 通过 Inspector [SerializeField] 设置
-// 存档恢复使用 LoadSaveData(PlayerSaveData data)(架构 05 §4)
-```
-
-**资产**:`Assets/Data/Player/PLY_Stats_Normal.asset`(PlayerStatsSO)
-
-### 2.5 PlayerController + 4 个基础状态
-
-**文件**:
-- `Assets/Scripts/Player/PlayerController.cs`(主控制器,Phase 1 骨架)
-- `Assets/Scripts/Player/States/PlayerStateBase.cs`(抽象基类)
-- `Assets/Scripts/Player/States/IdleState.cs`
-- `Assets/Scripts/Player/States/RunState.cs`
-- `Assets/Scripts/Player/States/JumpState.cs`
-- `Assets/Scripts/Player/States/FallState.cs`
-
-Animancer 集成要点:
-- `AnimancerComponent` 挂在 Player Prefab 根节点
-- 每个 State 的 `AnimationClip` 定义在 `PlayerAnimationConfigSO` 里,Phase 1 直接引用(⚠️ 字段类型为 AnimationClip,非 ClipTransition,架构 05_PlayerModule §17)
-- 进入 State 时调用 `_player.Animancer.Play(_config.Idle)`(⚠️ 字段名为 Idle,非 IdleClip,架构 05_PlayerModule §17)
-- **验证点**:切换状态时无 `MissingReferenceException`,动画无卡顿
-
-```csharp
-// PlayerController.Awake 最小初始化(Phase 1)
-void Awake()
-{
- _movement = GetComponent();
- _stats = GetComponent();
- _inputBuffer = GetComponent();
- _animancer = GetComponent();
-
- _idleState = new IdleState(this);
- _runState = new RunState(this);
- _jumpState = new JumpState(this);
- _fallState = new FallState(this);
- _attackState = new AttackState(this); // Week 2
-
- TryTransitionState(_idleState);
-}
-```
-
-### 2.6 CameraStateController
-
-**参考文档**:`17_CameraModule.md §2–§10`
-
-**文件**:`Assets/Scripts/Camera/CameraStateController.cs`
-
-Phase 1 实现范围:
-
-```csharp
-// 全局双机 A/B 切换(架构 17_CameraModule §3)
-namespace BaseGames.Camera
-{
- public class CameraStateController : MonoBehaviour
- {
- [SerializeField] CinemachineCamera _vcamA;
- [SerializeField] CinemachineCamera _vcamB;
- [SerializeField] CinemachineBrain _brain;
- [SerializeField] CameraConfigSO _defaultConfig;
-
- CinemachineCamera _activeCam; // runtime
- CinemachineCamera _inactiveCam;
- RoomCamera _currentRoomCam;
-
- public static CameraStateController Instance { get; private set; }
-
- void Awake()
- {
- Instance = this;
- _activeCam = _vcamA;
- _inactiveCam = _vcamB;
- }
-
- // 由 CameraTriggerZone.OnTriggerEnter2D 调用
- public void SwitchRoom(RoomCameraData data);
- // 由 RoomCamera.OnEnable 调用
- public void RegisterRoomCamera(RoomCamera rc);
- // 由 RoomCamera.OnDisable 调用
- public void UnregisterRoomCamera(RoomCamera rc);
- // 由 IFeedbackPlayer 调用(架构 17_CameraModule §9)
- public void TriggerImpulse(CameraShakePreset preset);
- }
-
- public struct RoomCameraData
- {
- public Collider2D ConfinerCollider;
- public Vector3 CameraOffset;
- public CameraBlendProfileSO BlendProfile;
- }
-}
-
-// Persistent 场景 CameraRig Prefab 组装
-// ├── Main Camera (CinemachineBrain + PixelPerfectCamera + CinemachinePixelPerfect)
-// ├── VCam_Global_A(CinemachineCamera + CinemachinePositionComposer + CinemachineConfiner2D
-// │ + CinemachineImpulseListener + CinemachinePixelPerfect,Priority: 10,active)
-// ├── VCam_Global_B(同上,Priority: 9,standby)
-// └── CameraStateController.cs(挂在根节点)
-```
-
-#### 2.6.1 RoomVisibleArea
-
-**文件**:`Assets/Scripts/Camera/RoomVisibleArea.cs`(架构 17_CameraModule §4)
-
-```csharp
-[ExecuteAlways]
-[RequireComponent(typeof(PolygonCollider2D))]
-public class RoomVisibleArea : MonoBehaviour
-{
- [SerializeField] private Vector2 _roomSize = new(20f, 11.25f);
- [SerializeField] private Vector2 _viewportSize = new(20f, 11.25f);
-
- /// roomSize ≈ viewportSize 时为固定镜头(无滚动)。
- public bool IsFixedCamera { get; }
-
- /// 返回 PolygonCollider2D,供 CameraTriggerZone 传入 CinemachineConfiner2D。
- public Collider2D Collider { get; private set; }
-
- private void Awake() => Collider = GetComponent();
- // 每次 Inspector 变更重算 Confiner = Max(0, roomSize - viewportSize)
- private void OnValidate() => RebuildCollider();
- private void RebuildCollider() { /* 重建 PolygonCollider2D 形状 */ }
-}
-// + RoomVisibleAreaEditor(自定义 Inspector 拖拽句柄,[ExecuteAlways])
-```
-
-#### 2.6.2 CameraTriggerZone
-
-**文件**:`Assets/Scripts/Camera/CameraTriggerZone.cs`(架构 17_CameraModule §5)
-
-```csharp
-[ExecuteAlways]
-[RequireComponent(typeof(BoxCollider2D))]
-public class CameraTriggerZone : MonoBehaviour
-{
- [SerializeField] private Vector2 _center = Vector2.zero;
- [SerializeField] private Vector2 _size = new(0.5f, 4f);
- [SerializeField] private RoomVisibleArea _targetRoom; // 目标房间的 RoomVisibleArea
- [SerializeField] private Vector3 _cameraOffset;
- [SerializeField] private CameraBlendProfileSO _blendOverride; // null = 使用默认
- [SerializeField] private bool _triggerOnce = false;
-
- private bool _triggered;
-
- private void OnTriggerEnter2D(Collider2D other)
- {
- if (!other.CompareTag("Player")) return;
- if (_triggerOnce && _triggered) return;
- _triggered = true;
- CameraStateController.Instance.SwitchRoom(new RoomCameraData
- {
- ConfinerCollider = _targetRoom.Collider,
- CameraOffset = _cameraOffset,
- BlendProfile = _blendOverride,
- });
- }
-}
-// + CameraTriggerZoneEditor(场景拖拽句柄,_center/_size)
-```
-
-#### 2.6.3 CameraConfigSO
-
-**文件**:`Assets/Scripts/Camera/CameraConfigSO.cs`(架构 17_CameraModule §7)
-
-```csharp
-[CreateAssetMenu(menuName = "Camera/CameraConfig")]
-public class CameraConfigSO : ScriptableObject
-{
- [Header("跟随")]
- public Vector3 DefaultFollowOffset = new(0f, 1f, -10f);
- public float HorizontalDamping = 0.5f;
- public float VerticalDamping = 0.3f;
-
- [Header("默认混合")]
- public float DefaultBlendDuration = 0.5f;
- public CinemachineBlendDefinition.Style DefaultBlendStyle
- = CinemachineBlendDefinition.Style.EaseInOut;
-
- [Header("Pixel Perfect")]
- public int ReferenceResolutionX = 320;
- public int ReferenceResolutionY = 180;
- public int PixelsPerUnit = 16;
- public bool CropFrameX = false;
- public bool CropFrameY = false;
-
- /// 相机视口世界单位尺寸(320×180 / 16PPU = 20×11.25)。
- public Vector2 ViewportSizeInWorldUnits
- => new Vector2((float)ReferenceResolutionX / PixelsPerUnit,
- (float)ReferenceResolutionY / PixelsPerUnit);
-}
-// 资产路径:Assets/ScriptableObjects/Camera/Camera_Config.asset
-```
-
-#### 2.6.4 CameraBlendProfileSO
-
-**文件**:`Assets/Scripts/Camera/CameraBlendProfileSO.cs`(架构 17_CameraModule §8)
-
-```csharp
-[CreateAssetMenu(menuName = "Camera/BlendProfile")]
-public class CameraBlendProfileSO : ScriptableObject
-{
- public float Duration = 0.5f;
- public CinemachineBlendDefinition.Style Style
- = CinemachineBlendDefinition.Style.EaseInOut;
-
- public CinemachineBlendDefinition ToBlendDefinition()
- => new CinemachineBlendDefinition(Style, Duration);
-}
-// 内置预设(Assets/ScriptableObjects/Camera/Blends/):
-// Blend_Default.asset(0.5s)、Blend_Instant.asset(0s)、
-// Blend_Slow.asset(1.0s)、Blend_BossExit.asset(0.8s)
-```
-
-#### 2.6.5 RoomCamera(可选)
-
-**文件**:`Assets/Scripts/Camera/RoomCamera.cs`(架构 17_CameraModule §6,优先级 15 的房间专属机位)
-
-```csharp
-namespace BaseGames.Camera
-{
- public class RoomCamera : MonoBehaviour
- {
- [SerializeField] CinemachineCamera _vcam;
- [SerializeField] RoomVisibleArea _visibleArea;
- [SerializeField] CameraBlendProfileSO _enterBlend; // 可留空
-
- void OnEnable()
- {
- _vcam.Priority = 15;
- CameraStateController.Instance.RegisterRoomCamera(this);
- }
-
- void OnDisable()
- {
- _vcam.Priority = 0;
- CameraStateController.Instance.UnregisterRoomCamera(this);
- }
-
- public CinemachineCamera Vcam => _vcam;
- public RoomVisibleArea VisibleArea => _visibleArea;
- public CameraBlendProfileSO EnterBlend => _enterBlend;
- }
-}
-```
-
-**Pixel Perfect 验证**:Play Mode 下检查 `PixelPerfectCamera.upscaleRT = true`,角色移动时像素网格对齐无抖动。
-
----
-
-## 3. Week 2:战斗基础 + 敌人骨架
-
-**参考文档**:`06_CombatModule.md`、`07_EnemyModule.md`
-
-### 3.1 战斗数据结构
-
-按顺序创建(后者依赖前者):
-
-```
-DamageType.cs ← 枚举(Normal/Fire/Poison/Ice/Lightning/Void/True)
-DamageCategory.cs ← 枚举(NormalAttack/SoulSkill/...)
-DamageFlags.cs ← [Flags] 枚举(Unblockable/CanBeParried/...)
-DamageTags.cs ← [Flags] 枚举(MeleeHit/RangedHit/SkillHit/ElementFire/ElementPoison/ElementVoid/AfterParry/ChargedAttack/SkyFormOnly/EarthFormOnly/DeathFormOnly/BreakLight/BreakMedium/BreakHeavy/BreakBreaker,架构 06_CombatModule §2)
-HitFxType.cs ← 枚举(Spark/Slash/Blood/Magic/Heavy/Crit/Void/Heal/Parry/Fire/Ice,架构 06_CombatModule §2)
-BreakLevel.cs ← 枚举(None=0/Light=1/Medium=2/Heavy=3/Breaker=4,架构 06_CombatModule §2)
-DamageInfo.cs ← struct + Builder(⚠️ 非 readonly struct;Builder 需就地赋値字段,架构 06_CombatModule §1)
-DamageSourceSO.cs ← [CreateAssetMenu] SO 包装器
-IDamageable.cs ← 接口(bool IsInvincible / int Defense / void TakeDamage(DamageInfo info),三成员,架构 06_CombatModule §5)
-```
-
-### 3.2 HitBox
-
-**文件**:`Assets/Scripts/Combat/HitBox.cs`
-
-```csharp
-// 挂在武器 / 攻击判定子节点上(Collider2D 设 IsTrigger=true,Layer=PlayerHitBox 或 EnemyHitBox)
-// ⚠️ 完整实现以架构 06_CombatModule §4 为准
-[RequireComponent(typeof(Collider2D))]
-public class HitBox : MonoBehaviour
-{
- [SerializeField] private DamageSourceSO _defaultSource; // Inspector 默认值
- [SerializeField] private float _hitCooldown = 0.1f; // 同目标多帧冷却
-
- // 运行时注入(AttackState / Projectile 覆盖默认 SO)
- private DamageSourceSO _currentSource;
- private Transform _attackerTransform;
- private bool _isActive;
-
- // 命中确认委托(PlayerCombat / EnemyCombat 订阅)
- public System.Action OnHitConfirmed;
-
- // ⚠️ 参数 source/attacker 均可选(架构 06_CombatModule §4);不存在 Activate(float duration)
- public void Activate(DamageSourceSO source = null, Transform attacker = null)
- {
- _currentSource = source ?? _defaultSource;
- _attackerTransform = attacker ?? transform;
- _isActive = true;
- }
- public void Deactivate() => _isActive = false;
-
- private void OnTriggerEnter2D(Collider2D other)
- {
- if (!_isActive) return;
- if (!CheckCooldown(other)) return;
-
- var knockDir = ((Vector2)other.bounds.center - (Vector2)_attackerTransform.position).normalized;
- var info = _currentSource.CreateBuilder()
- .SetKnockback(knockDir, _currentSource.KnockbackForce)
- .SetSourcePos(_attackerTransform.position)
- .SetLayer(_attackerTransform.gameObject.layer)
- .Build();
-
- // ① 命中 HurtBox(敌人/玩家受击)
- var hurtBox = other.GetComponent();
- if (hurtBox != null)
- {
- hurtBox.ReceiveDamage(info); // ⚠️ 必须用 ReceiveDamage(见架构 06_CombatModule §5)
- OnHitConfirmed?.Invoke(info);
- return;
- }
-
- // ② 命中 IBreakable(机关/障碍物)
- var breakable = other.GetComponent();
- breakable?.TryInteract(info);
- }
-
- private Dictionary _hitCooldownTimers = new();
- private bool CheckCooldown(Collider2D other)
- {
- float now = Time.time;
- if (_hitCooldownTimers.TryGetValue(other, out float last) && now - last < _hitCooldown)
- return false;
- _hitCooldownTimers[other] = now;
- return true;
- }
-}
-```
-
-**Layer 设置**(Physics 2D Layer Collision Matrix):
-- `PlayerHitBox` 与 `EnemyHurtBox` 碰撞
-- `EnemyHitBox` 与 `PlayerHurtBox` 碰撞
-- 其他组合全部关闭
-
-### 3.3 HurtBox
-
-**文件**:`Assets/Scripts/Combat/HurtBox.cs`
-
-```csharp
-[RequireComponent(typeof(Collider2D))]
-public class HurtBox : MonoBehaviour
-{
- private IDamageable _owner; // 由 Awake 的 GetComponentInParent 注入
- private IShieldable _shieldable; // 由 PlayerController.Awake() 注入
- private bool _isHurtBoxInvincible; // 动画事件 EnableIFrame/DisableIFrame 控制
- private bool _isActive = true; // HurtBox 整体开关(⚠️ 架构 06_CombatModule §5)
-
- // 弹反系统(仅玩家侧注入;Phase 2 由 PlayerController.Awake() 调用 SetParrySystem)
- private ParrySystem _parrySystem; // null = 无弹反(Phase 1 默认 null,Phase 2 注入)
-
- // 霸体来源(EnemyPoiseComponent / PlayerController 实现;Phase 2 注入)
- private IPoiseSource _poiseSource; // null = 无霸体检查(Phase 1 默认 null,Phase 2 注入)
-
- [SerializeField] private DamageInfoEventChannelSO _onDamageDealt; // EVT_DamageDealt(AnalyticsManager)
- [SerializeField] private HitConfirmedEventChannelSO _onHitConfirmed; // EVT_HitConfirmed(VFX/Audio/Feedback,架构 06_CombatModule §5)
-
- public void SetShieldable(IShieldable shieldable) => _shieldable = shieldable; // ⚠️ 参数为 IShieldable 接口(非 ShieldComponent 具体类型)
- public void SetInvincible(bool value) => _isHurtBoxInvincible = value; // 由 PlayerAnimationEvents 的 EnableIFrame/DisableIFrame 事件调用
- public void SetParrySystem(ParrySystem ps) => _parrySystem = ps; // 由 PlayerController.Awake() 注入(Phase 2)
- public void SetPoiseSource(IPoiseSource src) => _poiseSource = src; // 由 EnemyBase.Awake() 注入(Phase 2)
- public void SetActive(bool value) => _isActive = value; // 整体开关(架构 06_CombatModule §5)
-
- // ⚠️ 方法名必须与架构 06_CombatModule §5 完全一致;全 8 步流水线
- public void ReceiveDamage(DamageInfo info)
- {
- if (!_isActive) return; // HurtBox 被禁用时忽略
-
- // 1. 无敌帧检查(架构:_owner.IsInvincible || _isHurtBoxInvincible)
- if ((_owner.IsInvincible || _isHurtBoxInvincible) && !info.Flags.HasFlag(DamageFlags.IgnoreIFrame)) return;
-
- // 2. 弹反检查(须在伤害计算前;仅玩家侧 HurtBox 注入了 _parrySystem;Phase 1 skip)
- if (_parrySystem != null && info.Flags.HasFlag(DamageFlags.CanBeParried))
- if (_parrySystem.TryParryDamage(info)) return;
-
- // 3. 霸体检查(BreakLevel vs PoiseLevel;Phase 1 _poiseSource == null 跳过)
- if (!info.Flags.HasFlag(DamageFlags.ForceBreak) && _poiseSource != null)
- {
- PoiseLevel curPoise = _poiseSource.GetCurrentPoiseLevel();
- if (curPoise == PoiseLevel.Unbreakable) return;
- if ((int)info.Break < (int)curPoise)
- {
- _onHitConfirmed.Raise(new HitInfo { DamageInfo = info, HitPoint = transform.position });
- return;
- }
- }
-
- // 4. 护盾层拦截(玩家专属,在防御减免前)
- if (_shieldable != null && _shieldable.HasShield)
- {
- int passThrough = _shieldable.AbsorbDamage(info.Amount); // ⚠️ 返回穿透量(int),非 ref 参数(架构 20_ShieldModule §5)
- if (passThrough <= 0) return; // 全部被护盾吸收
- info.Amount = passThrough; // 穿透量继续走防御减免流程
- }
-
- // 5. 计算 FinalDamage(防御减免,最低 1)
- int finalDamage = Mathf.Max(1, info.Amount - _owner.Defense);
- info.Amount = finalDamage;
- info.FinalDamage = finalDamage; // ⚠️ 同步写入 FinalDamage(架构 06_CombatModule §5 步骤 5)
-
- // 6. 调用 _owner.TakeDamage
- _owner.TakeDamage(info);
-
- // 7. 全局广播(两个频道,架构 06_CombatModule §5)
- _onDamageDealt.Raise(info);
- _onHitConfirmed.Raise(new HitInfo { DamageInfo = info, HitPoint = transform.position });
-
- // 8. 状态效果触发(DoT — Fire/Poison;Phase 1 StatusEffectManager 可能尚未挂载)
- if (_owner is MonoBehaviour mb)
- {
- var sem = mb.GetComponent();
- if (sem != null)
- {
- if (info.Type == DamageType.Fire) sem.ApplyEffect(new FireEffect());
- else if (info.Type == DamageType.Poison) sem.ApplyEffect(new PoisonEffect());
- }
- }
- }
-}
-```
-
-### 3.4 WeaponManager + PlayerCombat(Week 2 新增)
-
-> **架构约束(05_PlayerModule.md §5/§7)**:HitBox **挂载在 Player Prefab 本体上**(由 `PlayerCombat` 管理),**不使用独立武器 Prefab**。WeaponSO 是纯数据 SO,不含 Prefab 引用。⚠️ `WeaponInstance` 类不存在。
-
-**文件**:
-- `Assets/Scripts/Player/WeaponManager.cs`
-- `Assets/Scripts/Player/PlayerCombat.cs`(Phase 1 简化版;完整版见 Phase 2 §2.3)
-
-```csharp
-// WeaponManager.cs — 挂在 PlayerController 上
-// ⚠️ 架构 05_PlayerModule §7:ActiveWeapon(WeaponSO),OnWeaponChanged 事件,无 Equip() 方法,无 WeaponInstance
-// Phase 1: _startingWeapon 在 Inspector 直接指定;Phase 2 §2.3 接入 FormController.OnFormChanged
-public class WeaponManager : MonoBehaviour
-{
- [SerializeField] private WeaponSO _startingWeapon; // Phase 1 直接指定,Phase 2 改为由 FormController 驱动
-
- public WeaponSO ActiveWeapon { get; private set; }
- public event Action OnWeaponChanged;
-
- void Start()
- {
- if (_startingWeapon != null)
- {
- ActiveWeapon = _startingWeapon;
- OnWeaponChanged?.Invoke(ActiveWeapon);
- }
- }
-
- // Phase 2 §2.3 补全:SetOverride / ClearOverride + FormController 订阅(架构 05_PlayerModule §7)
-}
-
-// PlayerCombat.cs — Phase 1 简化版(只含 HitBox 激活,无 RefreshWeaponData / SetComboSegmentSource)
-// ⚠️ 架构 05_PlayerModule §5:HitBox 直接挂在 Player Prefab 上,不经过 WeaponInstance
-public class PlayerCombat : MonoBehaviour
-{
- [SerializeField] private WeaponManager _weaponManager;
-
- // ── 玩家角色 Prefab 上的 HitBox(固定挂载,不依赖武器 Prefab)────────────
- [SerializeField] private HitBox _hitBoxGround;
- [SerializeField] private HitBox _hitBoxUp;
- [SerializeField] private HitBox _hitBoxDown;
- [SerializeField] private HitBox _hitBoxAir;
-
- // ── HitBox 激活(供 AttackState / AnimationEvent 调用)─────────────────
- public void EnableWeaponHitBox(AttackDirection dir)
- => GetHitBox(dir)?.Activate(_weaponManager.ActiveWeapon?.GetSourceByDir(dir), transform);
-
- public void DisableWeaponHitBox(AttackDirection dir)
- => GetHitBox(dir)?.Deactivate();
-
- public void DisableAllWeaponHitBoxes()
- {
- _hitBoxGround?.Deactivate();
- _hitBoxUp?.Deactivate();
- _hitBoxDown?.Deactivate();
- _hitBoxAir?.Deactivate();
- }
-
- // 命中回调 → 增加灵力(Phase 2 §2.3 补全)
- internal void OnHitConfirmed(DamageInfo info) { /* 增加灵力 */ }
-
- private HitBox GetHitBox(AttackDirection dir) => dir switch
- {
- AttackDirection.Ground => _hitBoxGround,
- AttackDirection.Up => _hitBoxUp,
- AttackDirection.Down => _hitBoxDown,
- AttackDirection.Air => _hitBoxAir,
- _ => null
- };
-}
-```
-
-**Player Prefab 关键子节点(Week 2 补充)**:
-```
-[Player]
-├── ... (其余同架构层级)
-├── [HitBoxGround] ← 碰撞体,挂 HitBox 组件(PlayerCombat._hitBoxGround)
-├── [HitBoxUp] ← 碰撞体,挂 HitBox 组件(PlayerCombat._hitBoxUp)
-├── [HitBoxDown] ← 碰撞体,挂 HitBox 组件(PlayerCombat._hitBoxDown)
-├── [HitBoxAir] ← 碰撞体,挂 HitBox 组件(PlayerCombat._hitBoxAir)
-└── [SkillSocket] ← 空 Transform,技能 HitBox 挂点(Phase 2 技能系统使用)
-```
-
-**AttackState 说明**:`AttackState` 通过 `_player.Combat.EnableWeaponHitBox(dir)` / `DisableAllWeaponHitBoxes()` 激活/关闭 HitBox(见 §3.5)。⚠️ 不存在 `WeaponInstance` 或 `ActiveWeaponInstance` API。
-
-### 3.5 AttackState(Animancer 集成关键)
-
-**文件**:`Assets/Scripts/Player/States/AttackState.cs`
-
-```csharp
-// Animancer 状态机集成要点
-public class AttackState : PlayerStateBase
-{
- // PlayerAnimationConfigSO 中定义 AnimationClip[] GroundAttacks(3段连击,⚠️ 字段名 GroundAttacks,非 AttackChainClips,架构 05_PlayerModule §17)
- private int _comboIndex;
-
- public override void OnStateEnter()
- {
- _comboIndex = 0;
- PlayAttackClip();
- _player.Input.AttackEvent += OnAttackInput; // 监听连击输入
- }
-
- private void PlayAttackClip()
- {
- var clip = _player.AnimConfig.GroundAttacks[_comboIndex]; // ⚠️ 字段名 GroundAttacks(架构 05_PlayerModule §17)
- var state = _player.Animancer.Play(clip);
- state.Events.OnEnd = OnClipEnd;
- // HitBox 激活由 Animancer 帧事件驱动(架构 05_PlayerModule §5)
- state.Events.SetCallback(0.3f, () => _player.Combat.EnableWeaponHitBox(AttackDirection.Ground));
- state.Events.SetCallback(0.6f, () => _player.Combat.DisableAllWeaponHitBoxes());
- }
-
- private void OnClipEnd()
- {
- _player.Input.AttackEvent -= OnAttackInput;
- _player.Combat.DisableAllWeaponHitBoxes(); // 确保关闭
- _player.TryTransitionState(_player.IdleState);
- }
-
- // 连击窗口内再次按攻击键 → 进入下一段
- private void OnAttackInput()
- {
- if (_comboIndex < 2) { _comboIndex++; PlayAttackClip(); }
- }
-}
-```
-
-**验证点**:3 段连击动画顺序播放无帧跳,最终段结束后正确回到 Idle。PlayerCombat HitBox 在正确帧激活/关闭。
-
-### 3.5 EnemyBase + EnemyStats + EnemyNavAgent
-
-**文件**:
-- `Assets/Scripts/Enemies/EnemyBase.cs`
-- `Assets/Scripts/Enemies/EnemyStats.cs`
-- `Assets/Scripts/Enemies/Navigation/EnemyNavAgent.cs`
-
-Phase 1 实现范围:
-
-| 组件 | Phase 1 实现 |
-|------|-------------|
-| `EnemyBase` | Awake、TakeDamage、Die(播放动画+延迟销毁)|
-| `EnemyStats` | MaxHP/CurrentHP、TakeDamage、DistanceToPlayer(每帧更新)|
-| `EnemyNavAgent` | `SetDestination`、`Stop`、PathBerserker2d `NavAgent` 封装 |
-
-**PathBerserker2d 集成要点**:
-- 在测试房间 Tilemap 上烘焙 `NavSurface2D`
-- `EnemyNavAgent` 持有 `PathBerserker2d.NavAgent` 组件,`SetDestination` 直接调用其 API
-- **验证点**:敌人能在平台间寻路,不陷入地面,停止时无滑动
-
-### 3.6 Behavior Designer 基础行为树
-
-**文件**:`Assets/Scripts/Enemies/AI/BasicEnemyBT.cs`(BD 外部行为树,非 MonoBehaviour 直接编写)
-
-Phase 1 行为树结构(BD Editor 中组装):
-
-```
-Selector
-├── Sequence (Chase & Attack)
-│ ├── Conditional: IsPlayerInAttackRange(1.5f)
-│ └── Action: MeleeAttack → BeginAttack(AttackType.Melee)
-│
-├── Sequence (Chase)
-│ ├── Conditional: IsPlayerVisible
-│ └── Action: MoveToPlayer → MoveTo(playerPos)
-│
-└── Action: Patrol → MoveInDirection(patrolDir) + FlipOnBoundary
-```
-
-**BD + Animancer 冲突验证**:BD 调用 `EnemyBase.BeginAttack` → Animancer 切换攻击动画,确认无 `MissingAnimancerComponent` 异常。
-
----
-
-## 4. Week 3:存档完善 + 死亡复活流 + UI
-
-**参考文档**:`12_SaveModule.md`、`03_CoreModule.md §4-6`、`10_UIModule.md`
-
-### 4.1 SaveManager 完整实现
-
-Phase 0 仅实现骨架,Week 3 补全:
-
-```csharp
-// 补充实现(⚠️ 返回类型为 Task,与架构 12_SaveModule §4 一致;非 UniTask)
-public async Task SaveAsync(int slot = -1)
-{
- // 1. 遍历 _saveables 列表,调用 s.OnSave(_current) 收集数据
- // (直接迭代,非事件广播)
- // 2. LocalFileStorage.WriteAsync(slot, json)
- // (⚠️ 类名为 LocalFileStorage,非 LocalFileSaveStorage)
- // 3. 计算 Checksum、更新 LastCheckpointScene / LastCheckpointSpawnId
-}
-
-public async Task LoadAsync(int slot)
-{
- // 1. LocalFileStorage.ReadAsync(slot) → 反序列化 SaveData
- // 2. ValidateChecksum → 失败返回 false
- // 3. SaveMigrator.Migrate(data)
- // 4. 遍历 _saveables 列表,调用 s.OnLoad(_current) 恢复状态
-}
-```
-
-**ISaveable 接口**:
-
-```csharp
-// Assets/Scripts/Core/Save/ISaveable.cs
-// ⚠️ 方法名必须与架构 12_SaveModule §3 完全一致
-public interface ISaveable
-{
- void OnSave(SaveData data); // 收集并填充对应字段(存档时调用)
- void OnLoad(SaveData data); // 从存档数据恢复自身状态(读档时调用)
-}
-```
-
-**PlayerController** 实现 `ISaveable`:
-- `OnSave`:填充 `data.Player.PosX/PosY/CurrentHP/CurrentSpring`
-- `OnLoad`:设置 Position、HP、Spring
-
-### 4.2 SavePoint
-
-**文件**:`Assets/Scripts/World/SavePoint.cs`
-
-```csharp
-// ⚠️ Interact 参数为 Transform(架构 14_NarrativeModule §1 IInteractable 定义)
-public class SavePoint : MonoBehaviour, IInteractable
-{
- [SerializeField] private string _savePointId; // 全局唯一 ID
- [SerializeField] private bool _restoreSpring = true;
- [SerializeField] private StringEventChannelSO _onSavePointActivated;
- [SerializeField] private VoidEventChannelSO _onFastTravelOpen; // 快速旅行 UI
-
- private bool _isActivated = false;
-
- public bool CanInteract => true;
- public string InteractPrompt => _isActivated ? "休息" : "激活";
-
- public void Interact(Transform player) // 参数为 Transform(架构 14_NarrativeModule §1 / 08_WorldModule §7 IInteractable 定义)
- {
- // ⚠️ PlayerController 无 Instance(Architecture 05 §2);通过 player 参数获取
- var pc = player.GetComponent();
- _isActivated = true;
- if (_restoreSpring && pc != null) pc.Stats.RestoreSpringCharges();
- _onSavePointActivated.Raise(_savePointId); // → GameManager 调用 SaveManager.SaveAsync
- _onFastTravelOpen.Raise();
- PlayActivateAnimation();
- }
-
- // ISaveable.OnLoad:从 WorldSaveData.ActivatedSavePoints 恢复激活状态
- public void OnSave(SaveData data) { if (_isActivated) data.World.ActivatedSavePoints.Add(_savePointId); }
- public void OnLoad(SaveData data) { _isActivated = data.World.ActivatedSavePoints.Contains(_savePointId); }
- public void OnPlayerEnterRange(Transform player) { }
- public void OnPlayerExitRange() { }
-}
-```
-
-### 4.3 EmergencySaveService
-
-```csharp
-// Assets/Scripts/Core/Save/EmergencySaveService.cs
-// 定期自动保存到独立紧急存档槽(slot 99),用于崩溃/异常退出后恢复进度
-// ⚠️ 与架构 12_SaveModule §9 完全对应;非"死亡时保存"机制
-public class EmergencySaveService : MonoBehaviour
-{
- private const int EmergencySlot = 99; // 独立槽,不显示在存档选择界面
-
- [SerializeField] private float _intervalSeconds = 120f; // 每 2 分钟
- [SerializeField] private SaveManager _saveManager;
- [SerializeField] private BoolEventChannelSO _onGameplayActive; // 仅 Gameplay 状态下保存
-
- private bool _gameplayActive;
- private float _timer;
-
- private void OnEnable() => _onGameplayActive.OnEventRaised += v => _gameplayActive = v;
- private void OnDisable() => _onGameplayActive.OnEventRaised -= v => _gameplayActive = v;
-
- private void Update()
- {
- if (!_gameplayActive) return;
- _timer += Time.deltaTime;
- if (_timer >= _intervalSeconds)
- {
- _timer = 0f;
- _ = _saveManager.SaveAsync(EmergencySlot); // fire-and-forget
- }
- }
-
- // 判断是否存在未读的紧急存档(启动时由 MainMenuController 检查)
- public bool HasEmergencySave() => _saveManager.SlotExists(EmergencySlot);
-
- // 将紧急存档提升为指定主存档槽(玩家确认恢复后调用)
- public async Task PromoteToSlot(int targetSlot)
- {
- // 读取 slot 99 → 写入 targetSlot → 删除 slot 99
- // 由 LocalFileStorage(ISaveStorage 实现)执行
- }
-}
-```
-
-### 4.4 GameManager 死亡/复活完整流
-
-补充 `03_CoreModule.md §8` 中定义的死亡/复活流程:
-
-```csharp
-// ⚠️ Architecture 03 §8:HandlePlayerDied() 在协程启动前先调用 TransitionTo(GameState.Dead)
-// private void HandlePlayerDied()
-// {
-// TransitionTo(GameState.Dead); ← 协程外,HandlePlayerDied() 中直接调用
-// StartCoroutine(DeathSequenceCoroutine());
-// }
-
-private IEnumerator DeathSequenceCoroutine()
-{
- // 1. 等待死亡动画完成(约 1.5s)
- yield return new WaitForSeconds(1.5f);
- // 2. DeathScreenController 已通过 EVT_PlayerDied 订阅并延迟 1.5s 自动显示;GameManager 无需直接调用 UI
- // ⚠️ 不调用 UIManager.ShowPanel("DeathScreen"):Architecture 10 §2 UIManager 无该方法(API 为 OpenPanel(GameObject))
- // 3. 等待玩家按下"重试"(监听 EVT_DeathScreenConfirmed)
-}
-
-private IEnumerator RespawnCoroutine()
-{
- // 1. TransitionTo(GameState.LoadingScene)
- // 2. _sceneLoader.RequestLoad(new SceneLoadRequest
- // {
- // SceneName = _currentSceneName,
- // EntryTransitionId = _lastSavePointId,
- // ShowLoadingScreen = false,
- // IsRespawn = true
- // });
- // → SceneLoader 内部依次:淡出 → 卸载 → 加载 → SaveManager 恢复 → 淡入
- // ⚠️ GameManager 不额外调用 SaveManager.LoadAsync(),恢复由 SceneLoader 流程内部处理
- // 3. TransitionTo(GameState.Gameplay)
- // 4. _onPlayerRespawned.Raise()
- yield break;
-}
-```
-
-### 4.5 HUDController(Phase 1 最小集)+ DeathScreenController
-
-**文件**:
-- `Assets/Scripts/UI/UIManager.cs`(Phase 1 仅占位;完整实现见 Plan 05 §4.1)
-- `Assets/Scripts/UI/HUD/HUDController.cs` ⚠️ 路径/类名以 Architecture 10 §3 为准(非 HUDPanel / UI/Panels/)
-- `Assets/Scripts/UI/Menus/DeathScreenController.cs` ⚠️ 路径/类名以 Architecture 10 §6 为准(非 DeathPanel / UI/Panels/)
-
-**Phase 1 HUDController 最小集**(完整字段见 Architecture 10 §3):
-
-```csharp
-// Assets/Scripts/UI/HUD/HUDController.cs
-// Phase 1 仅实现 HP + Soul;其余字段 Phase 2+ 填充
-public class HUDController : MonoBehaviour
-{
- [Header("HP")]
- [SerializeField] private Transform _hpContainer;
- [SerializeField] private GameObject _hpCellPrefab;
-
- [Header("Gauges")]
- [SerializeField] private Image _soulGaugeFill;
- [SerializeField] private TMP_Text _geoText;
-
- [Header("Event Channels - Subscribe")]
- [SerializeField] private IntEventChannelSO _onHPChanged;
- [SerializeField] private IntEventChannelSO _onMaxHPChanged;
- [SerializeField] private IntEventChannelSO _onSoulPowerChanged;
- [SerializeField] private IntEventChannelSO _onGeoChanged;
-
- private void OnEnable()
- {
- _onHPChanged.OnEventRaised += UpdateHP;
- _onMaxHPChanged.OnEventRaised += RebuildHPCells;
- _onSoulPowerChanged.OnEventRaised += val => _soulGaugeFill.fillAmount = val / 100f;
- _onGeoChanged.OnEventRaised += val => _geoText.text = val.ToString();
- }
- private void OnDisable()
- {
- _onHPChanged.OnEventRaised -= UpdateHP;
- _onMaxHPChanged.OnEventRaised -= RebuildHPCells;
- _onSoulPowerChanged.OnEventRaised -= val => _soulGaugeFill.fillAmount = val / 100f;
- _onGeoChanged.OnEventRaised -= val => _geoText.text = val.ToString();
- }
-
- private void UpdateHP(int current); // HP 格子激活/灰化
- private void RebuildHPCells(int max); // MaxHP 改变时重建格子列表
-}
-```
-
-**DeathScreenController**(完整见 Architecture 10 §6):
-- 文件:`Assets/Scripts/UI/Menus/DeathScreenController.cs`
-- 订阅 `EVT_PlayerDied`,`WaitForSeconds(1.5f)` 后调用 `Show()`
-- "继续"按钮点击后调用 `_onDeathScreenConfirmed.Raise()`
-
----
-
-## 5. Week 4:VFX/音效 + 集成验证
-
-**参考文档**:`18_VFXFeedbackModule.md`
-
-### 5.1 VFXPool
-
-```csharp
-// Assets/Scripts/VFX/VFXPool.cs
-// ❗ API 以架构 18_VFXFeedbackModule §6 为准,不使用 SpawnAt
-public class VFXPool : MonoBehaviour
-{
- public static VFXPool Instance { get; private set; }
-
- // 在世界坐标播放一次特效。Fire-and-forget(UniTask 自动回池)
- // ❗ vfxRef 类型为 AssetReferenceGameObject(非 string key)
- public async UniTaskVoid Play(AssetReferenceGameObject vfxRef,
- Vector3 position,
- Quaternion rotation = default);
-
- // 预热:预先创建若干实例避免首次播放媁顿
- public async UniTask WarmupAsync(AssetReferenceGameObject vfxRef, int count);
-}
-```
-
-### 5.2 VFXCatalogSO
-
-```csharp
-// ❗ 结构名、字段名、数组名以架构 18_VFXFeedbackModule §7 为准
-[CreateAssetMenu(menuName = "VFX/VFXCatalog")]
-public class VFXCatalogSO : ScriptableObject
-{
- [Header("命中特效映射")]
- public VFXEntry[] hitEffects; // ❗ 数组名 hitEffects(非 Entries);HitFxType → VFX Prefab
-
- [Header("预热配置")]
- public VFXWarmupEntry[] warmups; // ❗ 预热配置数组(不可缺少)
-
- // ❗ 方法名为 TryGetHitFX(非 GetKey);返回 bool + out(架构 18_VFXFeedbackModule §7)
- public bool TryGetHitFX(HitFxType type, out AssetReferenceGameObject vfxRef);
-}
-
-[Serializable]
-public struct VFXEntry
-{
- public HitFxType type; // ❗ 字段名 type(非 FxType),小写开头
- public AssetReferenceGameObject vfxRef; // ❗ 字段名 vfxRef(非 VfxRef),camelCase
-}
-
-[Serializable]
-public struct VFXWarmupEntry
-{
- public AssetReferenceGameObject vfxRef;
- [Min(1)] public int warmupCount; // 建议 3~5
-}
-```
-
-**Phase 1 需要的 VFX 资产**(最小集):
-- `VFX_HitSlashLight`:轻攻击命中
-- `VFX_HitSlashHeavy`:重攻击命中
-- `VFX_PlayerDust`:落地/跑步尘土
-- `VFX_EnemyDie`:敌人死亡
-
-### 5.3 HitFXSpawner
-
-```csharp
-// Assets/Scripts/VFX/HitFXSpawner.cs
-// 挂在 Persistent 场景,监听 EVT_HitConfirmed 事件频道,驱动 VFXPool
-// ❗ 完整实现以架构 18_VFXFeedbackModule §8 为准
-public class HitFXSpawner : MonoBehaviour
-{
- [SerializeField] private HitConfirmedEventChannelSO _onHitConfirmed;
- [SerializeField] private VFXCatalogSO _catalog; // ❗ 字段为 _catalog(非 _vfxPool)
-
- private void OnEnable() => _onHitConfirmed.OnEventRaised += HandleHit;
- private void OnDisable() => _onHitConfirmed.OnEventRaised -= HandleHit;
-
- private void HandleHit(HitInfo info) // ❗ 参数类型 HitInfo(非 HitEventData)
- {
- // ⚠️ DamageInfo 中字段名为 FxType(架构 06_CombatModule §1),非 HitFxType
- if (_catalog.TryGetHitFX(info.DamageInfo.FxType, out var vfxRef))
- VFXPool.Instance.Play(vfxRef, info.HitPoint).Forget();
- }
-}
-```
-
-❗ `HitInfo`(DamageInfo + HitPoint)和 `HitConfirmedEventChannelSO` 均已在 Day 2 §3.1 创建;`VFXCatalogSO` 中的 Entry.VfxRef 为 `AssetReferenceGameObject`。`DamageInfo.FxType` 为字段名(`HitFxType` 为类型名,字段名是 `FxType`,架构 06_CombatModule §1)。
-
-### 5.4 HurtFlashController
-
-```csharp
-// Assets/Scripts/VFX/HurtFlashController.cs
-// 挂在 Player / Enemy SpriteRenderer 同一 GameObject 上
-// ❗ 字段与架构 18_VFXFeedbackModule §9 对齐:单个 _renderer + FeedbackConfigSO _config(非数组+裸float)
-// ❗ Flash() 为 async UniTaskVoid(非 Coroutine),使用 UniTask.Delay(架构 18 §9)
-public class HurtFlashController : MonoBehaviour
-{
- [SerializeField] SpriteRenderer _renderer; // ❗ 单个(非 SpriteRenderer[] _renderers)
- [SerializeField] FeedbackConfigSO _config; // ❗ 时长/颜色来自 FeedbackConfigSO(非 float _flashDuration)
-
- static readonly int FlashColorID = Shader.PropertyToID("_FlashColor"); // ❗ 颜色属性(不可缺少)
- static readonly int FlashAmountID = Shader.PropertyToID("_FlashAmount"); // 混合强度
-
- MaterialPropertyBlock _block;
-
- void Awake() => _block = new MaterialPropertyBlock();
-
- ///
- /// 触发一次受击白闪;由 IFeedbackPlayer.PlayTakeHit 调用。
- /// ⚠️ 返回 async UniTaskVoid(非 IEnumerator Coroutine),使用 UniTask.Delay(架构 18_VFXFeedbackModule §9)
- ///
- public async UniTaskVoid Flash(CancellationToken ct = default)
- {
- _renderer.GetPropertyBlock(_block);
- _block.SetColor(FlashColorID, _config.HurtFlashColor);
- _block.SetFloat(FlashAmountID, 1f);
- _renderer.SetPropertyBlock(_block);
-
- await UniTask.Delay(
- TimeSpan.FromSeconds(_config.HurtFlashDuration),
- cancellationToken: ct);
-
- _block.SetFloat(FlashAmountID, 0f);
- _renderer.SetPropertyBlock(_block);
- }
- // Shader 需支持 _FlashColor(Color)和 _FlashAmount(float 0~1)(URP Sprite-Lit-Flash 变体)
-}
-```
-
-### 5.5 AudioManager + BGMController + AudioZone
-
-**参考文档**:`11_AudioModule.md`
-
-> **⚠️ 架构说明**:架构使用 **Unity AudioMixer + 双 AudioSource 交叉淡入**,无 FMOD 依赖。Phase 2 无需"切换后端"。
-
-**文件**:
-- `Assets/Scripts/Audio/AudioManager.cs`
-- `Assets/Scripts/Audio/BGMController.cs`
-- `Assets/Scripts/Audio/AudioZone.cs`
-- `Assets/Scripts/Audio/AudioMixerKeys.cs`
-- `Assets/Scripts/Audio/AudioConfigSO.cs`
-
-```csharp
-// Assets/Scripts/Audio/AudioMixerKeys.cs
-public static class AudioMixerKeys
-{
- public const string Master = "MasterVolume";
- public const string BGM = "BGMVolume";
- public const string SFX = "SFXVolume";
- public const string Ambient = "AmbientVolume";
-}
-
-// Assets/Scripts/Audio/AudioManager.cs
-[DefaultExecutionOrder(-500)]
-public class AudioManager : MonoBehaviour
-{
- [Header("AudioMixer")]
- [SerializeField] private AudioMixer _mixer;
-
- [Header("BGM Sources(双 Source 交叉淡入淡出)")]
- [SerializeField] private AudioSource _bgmSourceA;
- [SerializeField] private AudioSource _bgmSourceB;
-
- [Header("SFX")]
- [SerializeField] private AudioSource _globalSFXSource;
-
- [Header("Event Channels - Subscribe")]
- [SerializeField] private GameStateEventChannelSO _onGameStateChanged;
- [SerializeField] private StringEventChannelSO _onBossFightStarted; // ⚠️ bossId: string(架构 11 §2)
-
- // 私有状态(双 Source 交叉淡入淡出)
- private AudioSource _activeBGMSource;
- private AudioSource _inactiveBGMSource;
- private Coroutine _crossfadeCoroutine;
-
- // BGM 播放(独立淡出/淡入时长)
- public void PlayBGM(AudioClip clip, float fadeOutDur = 1f, float fadeInDur = 1f); // ⚠️ 两个独立参数(架构 11 §2)
- public void StopBGM(float fadeDuration = 1f);
-
- // SFX 一次性播放
- public void PlaySFX(AudioClip clip, float volumeScale = 1f);
- public void PlaySFXAtPosition(AudioClip clip, Vector2 pos, float volumeScale = 1f); // 空间音效
-
- // 音量设置(SettingsManager 调用)
- public void Initialize(); // 读取 GlobalSettings 并应用音量
- /// 唯一音量写入入口。exposedParam = AudioMixerKeys.* 常量。
- public void SetVolume(string exposedParam, float linear)
- => _mixer.SetFloat(exposedParam, LinearToDecibel(linear));
-
- // 快照切换(Pause/BossFight 状态)
- public void TransitionToSnapshot(string snapshotName, float transitionTime = 0.5f); // ⚠️ TransitionToSnapshot(架构 11 §2)
-
- private IEnumerator CrossfadeCoroutine(AudioClip newClip, float fadeOutDur, float fadeInDur); // ⚠️ 两个 float 参数
-
- private static float LinearToDecibel(float linear)
- => linear > 0.0001f ? 20f * Mathf.Log10(linear) : -80f;
-}
-
-// BGMController.cs — 订阅 Boss/Zone 事件,驱动 AudioManager 切换 BGM
-public class BGMController : MonoBehaviour
-{
- [SerializeField] private AudioManager _audioManager;
- [SerializeField] private AudioConfigSO _config;
-
- [Header("Event Channels - Subscribe")]
- [SerializeField] private GameStateEventChannelSO _onGameStateChanged;
- [SerializeField] private BoolEventChannelSO _onBossFightToggled; // ⚠️ 单一频道:true=开始, false=结束(架构 11 §3)
- [SerializeField] private StringEventChannelSO _onRegionEntered; // ⚠️ _onRegionEntered(非 _onAudioZoneEntered)
-
- private MusicState _musicState = MusicState.Exploration;
- private string _currentRegion = "Forest";
-
- private void OnEnable()
- {
- _onBossFightToggled.OnEventRaised += OnBossFightToggled;
- _onRegionEntered.OnEventRaised += OnRegionEntered;
- _onGameStateChanged.OnEventRaised += HandleStateChanged;
- }
-
- private void OnDisable()
- {
- _onBossFightToggled.OnEventRaised -= OnBossFightToggled;
- _onRegionEntered.OnEventRaised -= OnRegionEntered;
- _onGameStateChanged.OnEventRaised -= HandleStateChanged;
- }
-
- private void OnBossFightToggled(bool started) // ⚠️ OnBossFightToggled,非 StartBossBGM/EndBossBGM 两个方法
- {
- if (started)
- {
- _musicState = MusicState.Boss;
- var clip = _config.GetBossBGM(_currentRegion);
- _audioManager.PlayBGM(clip, fadeOutDur: 1f, fadeInDur: 0.5f);
- _audioManager.TransitionToSnapshot("BossFight", 0.5f);
- }
- else
- {
- StartCoroutine(PlayVictoryThenRestore());
- }
- }
-
- private IEnumerator PlayVictoryThenRestore()
- {
- _musicState = MusicState.Victory;
- _audioManager.PlayBGM(_config.VictoryStingBGM, fadeOutDur: 0.3f, fadeInDur: 0.1f);
- yield return new WaitForSecondsRealtime(_config.VictoryStingDuration);
- _musicState = MusicState.Exploration;
- OnRegionEntered(_currentRegion);
- _audioManager.TransitionToSnapshot("Default", 1.0f);
- }
-
- private void OnRegionEntered(string regionId) // ⚠️ OnRegionEntered,非 SwitchZoneBGM
- {
- if (regionId == _currentRegion) return;
- _currentRegion = regionId;
- if (_musicState == MusicState.Exploration)
- {
- var clip = _config.GetZoneBGM(regionId);
- _audioManager.PlayBGM(clip, fadeOutDur: 1f, fadeInDur: 1f);
- }
- }
-
- private void HandleStateChanged(GameStateId state)
- {
- // ⚠️ GameStateId 是 struct,不能用 switch;使用 if/else + GameStates 常量(架构 03_CoreModule §2)
- if (state == GameStates.MainMenu)
- _audioManager.PlayBGM(_config.MainMenuBGM, fadeOutDur: 0.5f, fadeInDur: 1.0f); // ⚠️ 架构 11 §3
- else if (state == GameStates.Paused)
- _audioManager.TransitionToSnapshot("Paused", 0.2f);
- else if (state == GameStates.Dead)
- _audioManager.TransitionToSnapshot("Dead", 1.5f); // ⚠️ 1.5f(架构 11 §3),非 0.5f
- else if (state == GameStates.Gameplay)
- _audioManager.TransitionToSnapshot("Default", 0.3f); // ⚠️ 0.3f(架构 11 §3),非 0.2f
- }
-}
-```
-
-**Phase 1 资产**:`Assets/Audio/MainMixer.mixer`(Master/BGM/SFX/Ambient 四组 + Default/Paused/Dead/BossFight 快照)
-
-### 5.6 IFeedbackPlayer + PlayerFeedback + EnemyFeedback
-
-**参考文档**:`18_VFXFeedbackModule.md §2–§4`
-
-```csharp
-// Assets/Scripts/Feedback/IFeedbackPlayer.cs
-// ⚠️ Phase 1 必须定义此接口;GameLogic 依赖接口不依赖 Feel
-public interface IFeedbackPlayer
-{
- void PlayHit(HitWeight weight); // 命中反馈(Light/Medium/Heavy)
- void PlayParrySuccess(); // 弹反成功
- void PlayTakeHit(); // 受击
- void PlayDeath(); // 死亡演出
- void PlayHeal(); // 治疗
- void PlayLandImpact(); // 落地冲击
- void PlayAttackWhoosh(); // 攻击挥动音效
- void PlayJumpLaunch(); // 起跳
- void TriggerPreset(string presetId); // 通过 ID 触发预设(AnimEvent 用)
- void PlaySFXById(string sfxId); // 通过 ID 播放音效(AnimEvent 用)
-}
-
-public enum HitWeight { Light, Medium, Heavy }
-
-/// 空实现,用于测试和无反馈需求场景。
-public class NullFeedbackPlayer : IFeedbackPlayer { /* 全部空实现 */ }
-```
-
-```csharp
-// PlayerFeedback 聚合以下 MMF_Player 实例(Inspector 连线):
-// ⚠️ 字段名以架构 18_VFXFeedbackModule §3 为准(_on* 前缀)
-// ├── _onHitLight → MMF_Player(CameraShake Light + HitStop 0.05s)
-// ├── _onHitMedium → MMF_Player(CameraShake Medium)
-// ├── _onHitHeavy → MMF_Player(CameraShake Heavy + HitStop 0.1s)
-// ├── _onParrySuccess → MMF_Player(弹反成功)
-// ├── _onTakeHit → MMF_Player(HurtFlash + Haptic Light)
-// ├── _onDeath → MMF_Player(SlowMotion 0.3s + SFX)
-// ├── _onHeal → MMF_Player(ParticleSystem spawn + SFX)
-// ├── _onLandImpact → MMF_Player(落地震动)
-// ├── _onAttackWhoosh → MMF_Player(攻击挥动音效)
-// └── _onJumpLaunch → MMF_Player(起跳)
-```
-
-**Phase 1 最小实现**:仅连线 `_onHitLight`、`_onHitHeavy`、`_onTakeHit`、`_onDeath`、`_onHeal`,其余 Phase 2 补全。
-
-**Feel 集成验证**:命中敌人时屏幕轻震 + 0.05s 冻帧;玩家受击时白闪 + 手柄震动(若有)。
-
----
-
-## 6. 第三方集成检查点
-
-所有检查点必须在 Phase 1 结束前通过:
-
-| 库 | 检查项 | 通过标准 |
-|----|--------|---------|
-| **Animancer Pro** | 玩家 FSM 状态切换(Idle/Run/Jump/Attack) | 无 `MissingReferenceException`;动画无帧跳 |
-| **Animancer Pro** | Animancer + BD 同时运行(敌人既用 Animancer 又有 BD) | 无竞争报错,敌人攻击动画正常播放 |
-| **PathBerserker2d** | 敌人在 NavSurface2D 上寻路 | 能到达玩家附近;无陷入地面;停止时无滑动 |
-| **Behavior Designer** | 基础 BT(Patrol→Chase→Attack)运行 | 状态切换符合预期;BT 条件读取 `EnemyStats` 数据正确 |
-| **Cinemachine 3** | 双机 A/B 切换 + Confiner2D | 无相机跳变;切换时有混合过渡 |
-| **PixelPerfect Camera** | 角色移动时像素对齐 | 无亚像素抖动(放大观察精灵边缘) |
-| **Addressables** | 场景加载/卸载 + 资产加载/释放 | 无内存泄漏(Profiler 观察 GC Alloc);场景切换时 `AssetReleaseTracker` 自动清理旧场景对象池 |
-| **Feel / MMF_Player** | 命中冻帧 + 相机震动 | `Time.timeScale` 短暂降低;相机有位移 |
-| **NewtonSoft.Json** | SaveData 序列化/反序列化 | 存档文件内容字段完整;加载后数据与保存前一致 |
-
----
-
-## 7. 完成标准检查清单
-
-```
-□ 玩家移动/跳跃/攻击无 Animancer 卡顿(测试:快速连续输入10次)
-□ 3 段连击动画顺序播放,最终段后回到 Idle
-□ 敌人在 NavSurface2D 上寻路到玩家位置(直线距离和跨平台)
-□ BD 行为树 Patrol→Chase→Attack 切换正确
-□ 玩家攻击命中敌人:HP 减少 + HitFX 播放 + Feel 冻帧 0.05s
-□ 敌人攻击命中玩家:HP 减少 + HurtFlash + HUD HP Bar 更新
-□ 玩家 HP ≤ 0 → 死亡动画 → DeathScreenController 显示(⚠️ 非 DeathPanel,架构 10 §6)
-□ 按确认键 → FadeOut → LoadAsync 恢复存档 → FadeIn → 玩家在 SavePoint 位置复活
-□ 相机跟随玩家,像素对齐无抖动
-□ 相机进入触发区域后切换到目标 Confiner 边界(测试用两个 Confiner 区域)
-□ SavePoint 激活 → 自动存档 → 关闭 Play Mode → 重开 → 数据与存档一致
-□ Addressables 场景加载/卸载后 Profiler 无持续增长内存
-□ Console 无 Error(Warning 可记录 TODO 但不阻塞)
-```
-
-**Phase 1 完成后进入 Phase 2。**
diff --git a/Docs/Plan/03_Phase2_CoreGameplay.md b/Docs/Plan/03_Phase2_CoreGameplay.md
deleted file mode 100644
index c96553b..0000000
--- a/Docs/Plan/03_Phase2_CoreGameplay.md
+++ /dev/null
@@ -1,2931 +0,0 @@
-# Phase 2 · 核心玩法扩展
-
-> **周期**:4–5 周(Week 5–9)
-> **前置条件**:Phase 1 全部完成标准通过(第三方库协作验证无误)
-> **核心目标**:完整的玩家战斗/移动系统、护盾/弹反、状态效果、难度系统、AudioMixer 快照、敌人扩展
-> **产出物**:玩家拥有所有移动能力(冲刺/登墙/游泳)、完整连击链、弹反机制;2–3 种敌人类型;AudioMixer 快照需求已整合
-
----
-
-## 目录
-
-1. [实施顺序总览](#1-实施顺序总览)
-2. [Week 5:玩家 FSM 完整扩展](#2-week-5玩家-fsm-完整扩展)
-3. [Week 6:护盾 + 弹反 + 战斗深化](#3-week-6护盾--弹反--战斗深化)
-4. [Week 7:状态效果 + 动画事件 + 完整 VFX](#4-week-7状态效果--动画事件--完整-vfx)
-5. [Week 8:难度系统 + AudioMixer 快照](#5-week-8难度系统--audiomixer-快照)
-6. [Week 9:敌人扩展 + BossBase 骨架](#6-week-9敌人扩展--bossbase-骨架)
-7. [完成标准检查清单](#7-完成标准检查清单)
-
----
-
-## 1. 实施顺序总览
-
-```
-Week 5: DashState → WallSlideState → AirAttackState → DownAttack/UpAttack
- ↓
- FormController → WeaponManager → PlayerCombat 完整(含 SoulSkill 触发)
- ↓
- PlayerStats 完整(SoulPower/SpiritPower/Geo/Ability 系统)
-
-Week 6: ShieldComponent → HurtBox 修正(护盾管线)
- ↓
- ParrySystem → ParryConfigSO → ParryState
- ↓
- PoiseSystem(霸体)→ HurtState 更新(考虑霸体打断)
- ↓
- 连击链完整(3 段地面 + 2 段空中 + 下劈 + 上劈)
-
-Week 7: AnimationEventType 枚举 → IAnimationEventHandler(接口,架构 24 §4)
- ↓
- StatusEffect(抽象基类,架构 06 §11,非 StatusEffectBase)→ Poison / Burn / Stagger 具体实现
- ↓
- StatusEffectManager → HurtBox 集成
- ↓
- 完整 VFX(ParryFlash / FormSwitch / StatusEffect 粒子)
-
-Week 8: AudioMixer 快照配置(BGM形态切换/战斗/平静快照)
- ↓
- DifficultyManager → DifficultyScalerSO × 4 → PlayerStats/EnemyStats 注入钩子
-
-Week 9: EnemyBase 子类:RangedEnemy(远程)+ FlyingEnemy(飞行巡逻)
- ↓
- LootResolver + LootTableSO
- ↓
- BossBase 骨架(HP 分段、Phase 切换、Arena 锁定)
-```
-
----
-
-## 2. Week 5:玩家 FSM 完整扩展 ✅ 完成(2026-05-12)
-
-**参考文档**:`05_PlayerModule.md §2`
-
-### 2.1 新增 State 文件列表
-
-```
-Assets/Scripts/Player/States/
-├── DashState.cs ← 地面冲刺(位移 + 无敌帧)
-├── AerialDashState.cs ← 空中冲刺(架构 05 §12,独立状态,消耗 MaxAerialDashes 次数)⚠️ 与 DashState 分离
-├── WallSlideState.cs ← 蹬墙(下滑 + 蹬墙跳触发)
-├── WallJumpState.cs ← 蹬墙跳
-├── AirAttackState.cs ← 空中攻击
-├── DownAttackState.cs ← 下劈(Physics2D.OverlapPoint 踩踏检测)
-├── UpAttackState.cs ← 上劈
-├── HurtState.cs ← 受击硬直
-├── DeadState.cs ← 死亡(冻结物理)
-├── SpringState.cs ← 使用灵泉(治疗动画)
-└── ParryState.cs ← 弹反预备(Week 6)
-```
-
-### 2.2 DashState 实现要点
-
-```csharp
-public class DashState : PlayerStateBase
-{
- public override void OnStateEnter()
- {
- // 1. 消耗冲刺次数(Phase 2 先不加多段冲刺计数,一次冲刺)
- // 2. 开启无敌帧 BeginInvincibility(_config.DashInvincibleDuration)
- // 3. 施加冲刺速度 _player.Movement.Dash(facingDir, _config.DashSpeed)
- // 4. 启动持续时间计时器 → 到期进入 Fall 或 Idle
- // 5. 播放冲刺动画 + _player.Feedback.PlayDash()
- }
- // 冲刺期间忽略重力(SetGravityScale(0)),结束时恢复
-}
-```
-
-### 2.3 FormController + WeaponManager
-
-按 `05_PlayerModule.md §6/§7` 实现:
-
-```
-FormConfigSO.asset × 1 ← 单一资产,forms[0..2] = Sky/Earth/Death(Assets/Data/Player/Forms/) ⚠️ 非 3 个 FormConfigSO
-FormSO.asset × 3 ← Sky / Earth / Death(存于 FormConfigSO.forms[] 数组中,架构 05 §18)
-WeaponSO.asset × 3 ← 三形态对应武器数据(Assets/Data/Combat/Weapons/)
-// ⚠️ 无 WeaponInstance Prefab;HitBox 直接挂载在 Player Prefab 上(PlayerCombat._hitBoxGround/Up/Down/Air),架构 05 §5/§7
-```
-
-`FormController.SwitchForm()` 实现(架构 05_PlayerModule §6):
-1. 更新 `CurrentForm`
-2. 发布事件频道 `_onFormChanged.Raise(_config.GetFormIndex(newForm))`(UI / Save 用)
-3. 触发 C# 事件 `OnFormChanged?.Invoke()`(⚠️ WeaponManager 在 OnEnable 自订阅,非直接调用 WeaponManager)
-4. 调用 `_paletteSwapSystem?.ApplyPalette(newForm.formType)`(⚠️ 传 `FormType` 枚举值,架构 18_VFXFeedbackModule §10 定义 `ApplyPalette(FormType form)` 接受 FormType;架构 05 §6 代码片段错误地传了 `newForm.formAccentColor`(Color)与架构 18 §10 不符,以架构 18 §10 为准;`FormSO` 需补充 `public FormType formType` 字段,架构 05 §18 FormSO 定义中遗漏此字段)
-5. 调用 `_skillManager?.UpdateSkillSet(newForm)`(⚠️ 传 FormSO 1 个参数,非 3 个 FormSkillSO,架构 05 §6)
-6. 发布 `_onSkillSetChanged?.Raise()`(EVT_SkillSetChanged,通知 SkillHUD 刷新,架构 09_ProgressionModule §11)
-
-**PlayerCombat**(按 `05_PlayerModule.md §5` 完整实现,Phase 1 §3.4 简化版补全):
-
-```csharp
-// Assets/Scripts/Player/PlayerCombat.cs
-// ⚠️ 架构 05_PlayerModule §5:HitBox 直接挂在 Player Prefab 上,不经过 WeaponInstance(无此类)
-public class PlayerCombat : MonoBehaviour
-{
- [SerializeField] private WeaponManager _weaponManager;
- [SerializeField] private SkillManager _skillManager;
-
- // ── 玩家角色 Prefab 上的 HitBox(固定挂载,不依赖武器 Prefab)────────────
- [SerializeField] private HitBox _hitBoxGround;
- [SerializeField] private HitBox _hitBoxUp;
- [SerializeField] private HitBox _hitBoxDown;
- [SerializeField] private HitBox _hitBoxAir;
-
- void OnEnable()
- {
- _weaponManager.OnWeaponChanged += RefreshWeaponData;
- if (_weaponManager.ActiveWeapon != null)
- RefreshWeaponData(_weaponManager.ActiveWeapon);
- }
-
- void OnDisable() => _weaponManager.OnWeaponChanged -= RefreshWeaponData;
-
- void RefreshWeaponData(WeaponSO weapon) { /* 缓存动画片段,刷新 HitBox DamageSource */ }
-
- public void SetComboSegmentSource(ComboState segment)
- {
- WeaponSO w = _weaponManager.ActiveWeapon;
- DamageSourceSO src = segment switch
- {
- ComboState.Attack1 => w?.attack1Source,
- ComboState.Attack2 => w?.attack2Source,
- ComboState.Attack3 => w?.attack3Source,
- ComboState.AirAttack => w?.airAttackSource,
- ComboState.UpAttack => w?.upAttackSource,
- ComboState.DownAttack => w?.downAttackSource,
- _ => null,
- };
- if (src != null) _hitBoxGround.SetDamageSource(src);
- }
-
- // ── HitBox 激活(供 AttackState / AnimationEvent 调用)─────────────────
- public void EnableWeaponHitBox(AttackDirection dir)
- => GetHitBox(dir)?.Activate(_weaponManager.ActiveWeapon?.GetSourceByDir(dir), transform);
-
- public void DisableWeaponHitBox(AttackDirection dir)
- => GetHitBox(dir)?.Deactivate();
-
- public void DisableAllWeaponHitBoxes()
- {
- _hitBoxGround?.Deactivate();
- _hitBoxUp?.Deactivate();
- _hitBoxDown?.Deactivate();
- _hitBoxAir?.Deactivate();
- }
-
- // 命中回调 → 增加灵力
- internal void OnHitConfirmed(DamageInfo info) { /* 增加灵力 */ }
-
- private HitBox GetHitBox(AttackDirection dir) => dir switch
- {
- AttackDirection.Ground => _hitBoxGround,
- AttackDirection.Up => _hitBoxUp,
- AttackDirection.Down => _hitBoxDown,
- AttackDirection.Air => _hitBoxAir,
- _ => null,
- };
-}
-```
-
-**WeaponSO**(按 `05_PlayerModule.md §7` 完整实现):
-
-```csharp
-// Assets/Scripts/Combat/WeaponSO.cs
-// ⚠️ 纯数据 SO(不含 Prefab),架构 05 §7;HitBox 固定在 Player Prefab 上
-[CreateAssetMenu(menuName = "Combat/Weapon")]
-public class WeaponSO : ScriptableObject
-{
- [Header("基础信息")]
- public string weaponId; // 全局唯一 ID,如 "Weapon_SkyBlade"
- public string displayName; // "天裂刃"
- public Sprite icon;
- public WeaponType weaponType; // ⚠️ WeaponType 枚举(架构 05 §7)
-
- [Header("连击动画(Animancer ClipTransition)")]
- public ClipTransition attack1Clip;
- public ClipTransition attack2Clip;
- public ClipTransition attack3Clip;
- public ClipTransition airAttackClip;
- public ClipTransition upAttackClip;
- public ClipTransition downAttackClip;
-
- [Header("伤害来源(每段独立 DamageSourceSO)")]
- public DamageSourceSO attack1Source;
- public DamageSourceSO attack2Source;
- public DamageSourceSO attack3Source;
- public DamageSourceSO airAttackSource;
- public DamageSourceSO upAttackSource;
- public DamageSourceSO downAttackSource;
-
- [Header("HitBox 配置")]
- /// 零向量 = 使用 PlayerCombat 中的默认尺寸
- public Vector2 hitBoxSizeOverride;
- public Vector2 hitBoxOffsetOverride;
-
- [Header("武器特效")]
- public WeaponVFXConfig vfxConfig; // ⚠️ 类型为 WeaponVFXConfig(非 MMF_Player),架构 05 §7
-
- public DamageSourceSO GetSourceByDir(AttackDirection dir) => dir switch
- {
- AttackDirection.Ground => attack1Source,
- AttackDirection.Up => upAttackSource,
- AttackDirection.Down => downAttackSource,
- AttackDirection.Air => airAttackSource,
- _ => null,
- };
-}
-
-// ⚠️ WeaponType 枚举(架构 05 §7)
-public enum WeaponType
-{
- SkyBlade, // 天魂:裂空刃(高频轻击)
- EarthHammer, // 地魂:地震锤(低频重击,范围大)
- LifeScythe, // 命魂:命镰(穿透,直线斩)
- Custom, // 护符替换或未来扩展武器
-}
-
-// ⚠️ WeaponVFXConfig:内嵌类([Serializable]),架构 05 §7;包含 FeedbackPresetSO 而非 MMF_Player
-[Serializable]
-public class WeaponVFXConfig
-{
- [Tooltip("切换到此武器时播放的特效(形态切换音效/粒子)")]
- public FeedbackPresetSO onEquipFeedback; // ⚠️ FeedbackPresetSO(非 MMF_Player),架构 05 §7
-
- [Tooltip("武器挥斩拖尾 Prefab(null = 不显示拖尾)")]
- public GameObject weaponTrailPrefab;
-
- public Color trailColor = Color.white;
-
- [Tooltip("命中特效类型覆盖(null = 使用 DamageSourceSO.HitFxType)")]
- public HitFxType? hitFxOverride;
-}
-```
-
-**WeaponManager**(按 `05_PlayerModule.md §7` 完整实现,含护符 Override API):
-
-```csharp
-// Assets/Scripts/Player/WeaponManager.cs
-// ⚠️ 架构 05 §7:含护符替换武器的 Override 字典(_overrides),非简单直接切换
-public class WeaponManager : MonoBehaviour
-{
- [SerializeField] private FormController _formController;
-
- public WeaponSO ActiveWeapon { get; private set; }
- public event Action OnWeaponChanged;
-
- // ⚠️ 护符注入的武器覆盖:Key = FormSO.formId,Value = 替换武器(架构 05 §7)
- readonly Dictionary _overrides = new();
-
- void Awake()
- {
- if (_formController.CurrentForm != null)
- ApplyWeapon(_formController.CurrentForm);
- }
-
- void OnEnable() => _formController.OnFormChanged += HandleFormChanged;
- void OnDisable() => _formController.OnFormChanged -= HandleFormChanged;
-
- void HandleFormChanged() => ApplyWeapon(_formController.CurrentForm);
-
- void ApplyWeapon(FormSO form)
- {
- WeaponSO next = _overrides.TryGetValue(form.formId, out var ov)
- ? ov
- : form.defaultWeapon;
-
- if (next == ActiveWeapon) return;
- ActiveWeapon = next;
- OnWeaponChanged?.Invoke(next);
- // ⚠️ vfxConfig.onEquipFeedback 为 FeedbackPresetSO(架构 05 §7),非 MMF_Player
- next?.vfxConfig.onEquipFeedback?.PlayFeedbacks(gameObject);
- }
-
- // ─────────── 护符 Override API(由 WeaponOverrideEffect 调用,架构 05 §7)───────────
- /// 为指定形态设置武器覆盖。formId 为空 = 覆盖所有形态。
- public void SetOverride(string formId, WeaponSO weapon)
- {
- if (string.IsNullOrEmpty(formId))
- foreach (var f in _formController.AllForms) _overrides[f.formId] = weapon;
- else
- _overrides[formId] = weapon;
- ApplyWeapon(_formController.CurrentForm);
- }
-
- /// 移除覆盖,恢复默认武器。formId 为空 = 移除所有形态覆盖。
- public void ClearOverride(string formId)
- {
- if (string.IsNullOrEmpty(formId))
- foreach (var f in _formController.AllForms) _overrides.Remove(f.formId);
- else
- _overrides.Remove(formId);
- ApplyWeapon(_formController.CurrentForm);
- }
-}
-```
-
-**WeaponOverrideEffect**(护符效果实现,按 `05_PlayerModule.md §7`):
-
-```csharp
-// Assets/Scripts/Equipment/CharmEffects/WeaponOverrideEffect.cs
-// ⚠️ 实现 ICharmEffect 接口(架构 05 §7),非直接继承 MonoBehaviour
-namespace BaseGames.Equipment
-{
- [Serializable]
- public class WeaponOverrideEffect : ICharmEffect
- {
- [Tooltip("目标形态 ID(留空 = 所有形态)")]
- public string targetFormId;
- [Tooltip("替换武器 SO")]
- public WeaponSO replacementWeapon;
-
- public void OnEquip(EquipmentContext ctx)
- => ctx.WeaponMgr.SetOverride(targetFormId, replacementWeapon);
-
- public void OnUnequip(EquipmentContext ctx)
- => ctx.WeaponMgr.ClearOverride(targetFormId);
-
- public string GetEffectDescription()
- {
- string formStr = string.IsNullOrEmpty(targetFormId) ? "所有形态" : targetFormId;
- string wName = replacementWeapon != null ? replacementWeapon.displayName : "null";
- return $"{formStr}的武器替换为 [{wName}]";
- }
- }
-}
-// EquipmentContext 需新增 WeaponManager WeaponMgr 字段(见 17_EquipmentSystem §3)
-```
-
-### 2.4 PlayerStats 完整实现
-
-补充 Phase 1 未实现的部分:
-
-| 功能 | 实现要点 |
-|------|---------|
-| `SoulPower` | 击杀/命中增加;使用魂技能消耗;上限 100 |
-| `SpiritPower` | 时间自动回复(`SpiritRegenRate`/秒);使用魄技能消耗 |
-| `SpringCharges` | 击杀积分达阈值增加;存档点恢复满值 |
-| `Geo` | 击杀掉落收集;购买消耗 |
-| `UnlockedAbilities` | `HashSet` 持久存储;`HasAbility` 供状态检查 |
-
-> **⚠️ `AddSoul` vs `AddSoulPower` 命名不一致**:`PlayerStats.cs` API(架构 05 §4)定义 `AddSoulPower(int amount)`,但 `ParrySystem.HandleSuccessfulParry`(架构 06 §8)调用 `_playerStats.AddSoul(soulGain)`。**以架构 06 §8 为准**,即 `AddSoul` 为正确方法名(可能是 SO 配置文件的旧名称);实现 `PlayerStats.cs` 时须确保两个调用名称一致。
-
-**PlayerStatsSO 完整定义**(按 `05_PlayerModule.md §16`):
-
-```csharp
-// Assets/Scripts/Player/PlayerStatsSO.cs
-[CreateAssetMenu(menuName = "Player/Stats")]
-public class PlayerStatsSO : ScriptableObject
-{
- [Header("HP")]
- public int MaxHP = 5; // 初始心脏容器数 × 2(半格为1)
-
- [Header("Soul Power")]
- public int MaxSoulPower = 100;
-
- [Header("Spirit Power")]
- public int MaxSpiritPower = 100;
- public int SpiritRegenRate = 5; // 每秒回复量
-
- [Header("Spring")]
- public int MaxSpringCharges = 3; // 初始灵泉次数上限
- public int SpringHealAmount = 2; // ⚠️ 每次回复 HP 量(半格),架构 05 §16
- public int SpringKillThreshold = 4; // ⚠️ 增加 1 次灵泉所需击杀点数,架构 05 §16
-
- [Header("Invincibility")]
- public float InvincibilityDuration = 0.6f;
-
- [Header("Geo")]
- public int InitialGeo = 0;
-}
-```
-
-**PlayerMovementConfigSO 完整定义**(按 `05_PlayerModule.md §15`,含所有 Wall 参数):
-
-```csharp
-// Assets/Scripts/Player/PlayerMovementConfigSO.cs
-[CreateAssetMenu(menuName = "Player/MovementConfig")]
-public class PlayerMovementConfigSO : ScriptableObject
-{
- [Header("Ground Movement")]
- public float RunSpeed = 7f;
- public float Acceleration = 50f;
- public float Deceleration = 80f;
-
- [Header("Jump")]
- public float JumpForce = 18f;
- public float CoyoteTime = 0.12f; // 秒
- public float FallGravityMult = 2.5f; // 下落时额外重力倍率
- public float MaxFallSpeed = 20f;
-
- [Header("Dash")]
- public float DashSpeed = 20f;
- public float DashDuration = 0.18f; // 秒
- public float DashCooldown = 0.4f; // 秒
- public int MaxAerialDashes = 1; // ⚠️ 空中冲刺次数(AerialDashState 消耗此计数)
-
- [Header("Wall")]
- public float WallSlideSpeed = 2f;
- public float WallJumpForceX = 12f;
- public float WallJumpForceY = 16f;
-
- [Header("Wall — 检测")]
- public float WallRayLength = 0.55f; // 墙壁射线长度
- public float WallRayOffsetY = 0.2f; // 上/下两根射线相对中心的 Y 偏移
-
- [Header("Wall — WallGrab")]
- public float WallGrabMaxHeightGain = 0.5f; // ⚠️ 允许的最大垂直高度增益(防无限蹬墙),架构 05 §15
- public float WallGrabReleaseDelay = 0.08f; // ⚠️ 离开墙面后的延迟释放(避免误判),架构 05 §15
-
- [Header("Wall — WallJump")]
- public float WallJumpBackForceX = 14f; // ⚠️ 背墙跳(同向)水平速度,架构 05 §15
- public float WallJumpAwayForceX = 10f; // ⚠️ 对墙跳(反向)水平速度,架构 05 §15
- public float WallJumpAwayForceY = 18f; // ⚠️ 对墙跳垂直速度,架构 05 §15
- public float WallJumpInputLockDuration = 0.15f; // ⚠️ 对墙跳后短暂锁定水平输入,架构 05 §15
-
- [Header("General")]
- public float DefaultGravityScale = 3f;
-}
-```
-
-### 2.5 PlayerWallDetector + PlayerController IPoiseSource 扩展
-
-**PlayerWallDetector**(独立组件,架构 `05_PlayerModule.md §13`):
-
-```csharp
-// Assets/Scripts/Player/PlayerWallDetector.cs
-// ⚠️ 独立组件,不嵌入 PlayerMovement(架构 05 §13)
-// WallSlideState 通过 _owner.WallDetector.IsTouchingWall 访问
-[RequireComponent(typeof(PlayerMovement))]
-public class PlayerWallDetector : MonoBehaviour
-{
- [SerializeField] private PlayerMovementConfigSO _config;
-
- public bool IsTouchingWall { get; private set; }
- public int WallDirection { get; private set; } // +1 = 右墙,-1 = 左墙
-
- private void FixedUpdate()
- {
- bool rightWall = CheckSide(Vector2.right);
- bool leftWall = CheckSide(Vector2.left);
-
- IsTouchingWall = rightWall || leftWall;
- WallDirection = rightWall ? 1 : leftWall ? -1 : 0;
- }
-
- // ⚠️ 每侧发两根射线(TopRay + BottomRay),两根均命中才返回 true(防卡角误判)
- private bool CheckSide(Vector2 dir)
- {
- Vector2 center = transform.position;
- float len = _config.WallRayLength;
- float oy = _config.WallRayOffsetY;
- int layer = LayerMask.GetMask("Wall"); // Layer 10(见 06_CombatModule §12)
-
- bool top = Physics2D.Raycast(center + Vector2.up * oy, dir, len, layer);
- bool bot = Physics2D.Raycast(center + Vector2.down * oy, dir, len, layer);
- return top && bot;
- }
-}
-```
-
-**PlayerController 补充**(架构 `05_PlayerModule.md §13` + `06_CombatModule §13`):
-
-```csharp
-// PlayerController.cs 追加以下内容(与 Phase 1 骨架 merge)
-// ⚠️ 须实现 IPoiseSource 接口(架构 06_CombatModule §13)
-public partial class PlayerController : MonoBehaviour, IPoiseSource
-{
- [Header("Wall Detection")]
- [SerializeField] private PlayerWallDetector _wallDetector; // ⚠️ 独立组件引用(架构 05 §13)
-
- public PlayerWallDetector WallDetector => _wallDetector; // WallSlideState / WallJumpState 使用
-
- // ── IPoiseSource 实现(架构 06_CombatModule §13)────────────────────────
- private PoiseWindowConfig _currentPoiseWindow;
- private AnimancerState _activeState; // Animancer 提供当前动画状态
-
- public void SetPoiseWindow(PoiseWindowConfig window) // 由 AttackState / SkillState.OnStateEnter() 调用
- => _currentPoiseWindow = window;
-
- public void ClearPoiseWindow() // 由 OnStateExit() 调用
- => _currentPoiseWindow = default;
-
- /// IPoiseSource 实现:每帧查询当前霸体等级(攻击动画期间)
- public PoiseLevel GetCurrentPoiseLevel()
- {
- if (_currentPoiseWindow.Level == PoiseLevel.None) return PoiseLevel.None;
- if (_activeState == null) return PoiseLevel.None;
-
- float t = _activeState.NormalizedTime % 1f;
- bool inWindow = t >= _currentPoiseWindow.NormalizedStart
- && t <= _currentPoiseWindow.NormalizedEnd;
- return inWindow ? _currentPoiseWindow.Level : PoiseLevel.None;
- }
-}
-// 资产:Player Prefab Inspector 需挂载 PlayerWallDetector 组件并赋值到 _wallDetector
-```
-
----
-
-## 3. Week 6:护盾 + 弹反 + 战斗深化 ✅ 完成(2026-05-10)
-
-**参考文档**:`06_CombatModule.md §8-13`、`20_ShieldModule.md`
-
-> **实现摘要**(与原计划的实际偏差):
-> - `ShieldConfigSO` 放在 `BaseGames.Combat` 命名空间(非 `BaseGames.Player.Shield`),统一管理
-> - `ShieldComponent` 新增 `FullRecharge()` / `OnParrySuccess()` / `_brokenPenaltyTimer` / 可选 `ShieldConfigSO` 覆盖
-> - `ParrySystem` 使用 **C# 事件**(`OnParryActivated` / `OnParryConsumed`)替代 `ForceState(PlayerStateType.Parry)` 强制转换,避免 Parry 程序集引用 Player.States 程序集
-> - `ParryInfo` 结构体仅含 `IsPerfect` + `SoulGained`(去掉 `DamageInfo OriginalDamage` 字段,保持 Parry 不引用 Combat 的程序集约束)
-> - `PlayerController.Awake()` 新增订阅 `ParrySystem.OnParryActivated`(转 ParryState)和 `OnParryConsumed`(发放灵力+恢复护盾),`OnDestroy()` 解订阅
-> - `BaseGames.Parry.asmdef` 新增引用:`BaseGames.Input`、`BaseGames.Core.Events`
-
-**完成文件清单**:
-```
-新建:Assets/Scripts/Combat/ShieldConfigSO.cs
-新建:Assets/Scripts/Combat/PoiseWindowConfig.cs
-新建:Assets/Scripts/Parry/ParryConfigSO.cs
-新建:Assets/Scripts/Parry/ParryInfo.cs
-新建:Assets/Scripts/Parry/ParryInfoEventChannelSO.cs
-新建:Assets/Scripts/Enemies/EnemyPoiseComponent.cs
-修改:Assets/Scripts/Combat/ShieldComponent.cs(FullRecharge/OnParrySuccess/破碎惩罚/ShieldConfigSO)
-修改:Assets/Scripts/Parry/ParrySystem.cs(5 阶段状态机完整重写)
-修改:Assets/Scripts/Player/States/PlayerController.cs(订阅 ParrySystem 事件)
-修改:Assets/Scripts/Parry/BaseGames.Parry.asmdef(新增 Input + Core.Events 引用)
-```
-
-### 3.0 护盾数据层 SO 与接口
-
-```csharp
-// Assets/Scripts/Player/Shield/ShieldConfigSO.cs
-// ⚠️ 按 Architecture 20_ShieldModule §3 实现
-[CreateAssetMenu(menuName = "Player/ShieldConfig")]
-public class ShieldConfigSO : ScriptableObject
-{
- [Header("基础参数")]
- [Min(1)]
- public int MaxShieldHP = 60;
- [Range(0f, 1f)]
- public float DamageAbsorptionRatio = 1.0f;
-
- [Header("恢复")]
- [Min(0f)]
- public float RechargeDelay = 2.5f; // 最后受击后静默时间(秒)
- [Min(0f)]
- public float RechargeRate = 20f; // 每秒恢复量
-
- [Header("破碎惩罚")]
- [Min(0f)]
- public float BrokenPenaltyDuration = 3.0f; // 护盾破碎后无法恢复的时间
-
- [Header("存档点全量恢复")]
- public bool FullRechargeOnSavePoint = true;
-
- [Header("弹反加成(P1)")]
- [Range(0f, 1f)]
- public float ParryRestoreRatio = 0.3f; // 成功格挡时恢复护盾耐久比例
-}
-
-// Assets/Scripts/Player/Shield/IShieldable.cs
-// ⚠️ 按 Architecture 20_ShieldModule §5 实现
-namespace BaseGames.Player.Shield
-{
- public interface IShieldable
- {
- bool HasShield { get; }
- int CurrentShieldHP { get; }
- int MaxShieldHP { get; }
- int AbsorbDamage(int incomingDamage); // ⚠️ 返回穿透伤害剩余量:0 = 全部被吸收,>0 = 穿透量(架构 20_ShieldModule §5)
- void FullRecharge();
- void OnParrySuccess();
- }
-}
-```
-
-### 3.1 ShieldComponent
-
-按 `20_ShieldModule.md` 实现完整护盾系统:
-
-```csharp
-// 关键实现点
-public class ShieldComponent : MonoBehaviour, IShieldable
-{
- public bool HasShield => _currentShieldHP > 0 && !_isBroken; // ⚠️ 字段名 _currentShieldHP(架构 20_ShieldModule §4)
-
- public int AbsorbDamage(int incomingDamage)
- {
- _timeSinceLastHit = 0f; // ⚠️ 重置静默计时(架构 20_ShieldModule §4)
-
- int absorbed = Mathf.RoundToInt(incomingDamage * _config.DamageAbsorptionRatio);
- int passThrough = incomingDamage - absorbed;
-
- _currentShieldHP -= absorbed;
-
- if (_currentShieldHP <= 0)
- {
- // 护盾破碎:多余伤害穿透(⚠️ 架构 20_ShieldModule §4)
- passThrough += Mathf.Abs(_currentShieldHP);
- _currentShieldHP = 0;
- _isBroken = true;
- _brokenTimer = 0f;
- _onShieldBroken.Raise(); // ⚠️ Raise(),非 RaiseEvent()(架构 02 §2)
- }
-
- _onShieldHPChanged.Raise(_currentShieldHP); // ⚠️ 更新 ShieldBarUI(架构 20_ShieldModule §4)
- return passThrough;
- }
-
- // ⚠️ 存档加载时恢复护盾状态,由 PlayerController.LoadFromSaveData() 调用(架构 20_ShieldModule §4)
- public void SetShieldHP(int hp, bool isBroken)
- {
- _currentShieldHP = Mathf.Clamp(hp, 0, _config.MaxShieldHP);
- _isBroken = isBroken;
- _brokenTimer = 0f;
- _timeSinceLastHit = 0f;
- }
-
- private void Update()
- {
- // 破碎冷却计时(递增到 BrokenPenaltyDuration)
- if (_isBroken)
- {
- _brokenTimer += Time.deltaTime;
- if (_brokenTimer >= _config.BrokenPenaltyDuration) // ⚠️ BrokenPenaltyDuration,非 BreakPenaltyDuration
- {
- _isBroken = false;
- _brokenTimer = 0f;
- }
- return;
- }
- // 延迟恢复(_timeSinceLastHit 累计受击后静默时间,架构 20_ShieldModule §4)
- if (_currentShieldHP < _config.MaxShieldHP)
- {
- _timeSinceLastHit += Time.deltaTime;
- if (_timeSinceLastHit >= _config.RechargeDelay)
- _currentShieldHP = Mathf.Min(
- _config.MaxShieldHP,
- _currentShieldHP + Mathf.RoundToInt(_config.RechargeRate * Time.deltaTime)
- );
- }
- }
-}
-```
-
-### 3.2 ParrySystem
-
-按 `06_CombatModule.md §8` 实现 **5 阶段状态机**(Inactive → Startup → Active → EndLag/CounterWindow → Inactive):
-
-```
-Inactive → [按弹反键] → Startup(0.05s) → Active(0.28s) → [命中可弹反攻击] → ParrySuccess
- ↓(Startup/Active 超时) ↓
- EndLag(0.10s) → Inactive CounterWindow(0.5s) → Inactive
-```
-
-```csharp
-// Assets/Scripts/Parry/ParrySystem.cs
-// ⚠️ 5 阶段状态机;事件频道为 ParryInfoEventChannelSO(非 VoidEventChannelSO),架构 06 §8
-
-/// 弹反成功时通过 OnParrySuccess 频道广播的载荷(架构 06 §8)
-public struct ParryInfo
-{
- public DamageInfo OriginalDamage; // 被弹反的原始攻击信息
- public bool IsPerfect; // 是否为完美弹反
- public Projectile HitProjectile; // 若弹反了投射物,此字段非 null
- public DamageSourceSO ReflectDamageSource; // 反击伤害来源(ParryCounterMultiplier 倍率已应用)
-}
-
-/// 弹反阶段枚举(架构 06 §8)
-public enum ParryPhase { Inactive, Startup, Active, EndLag, CounterWindow }
-
-public class ParrySystem : MonoBehaviour
-{
- [SerializeField] private InputReaderSO _inputReader;
- [SerializeField] private PlayerController _controller;
- [SerializeField] private ParryConfigSO _config;
- [SerializeField] private HurtBox _playerHurtBox;
- [SerializeField] private PlayerStats _playerStats;
-
- [Header("Event Channels - Raise")]
- // ⚠️ ParryInfoEventChannelSO 携带 ParryInfo 载荷(非 VoidEventChannelSO),架构 06 §8
- [SerializeField] private ParryInfoEventChannelSO _onParrySuccess;
-
- // ── 运行时状态 ────────────────────────────────────────────────
- private ParryPhase _phase = ParryPhase.Inactive;
- private float _phaseTimer;
- private bool _isInCounterWindow;
-
- public ParryPhase CurrentPhase => _phase;
- public bool IsInCounterWindow => _isInCounterWindow;
-
- private void OnEnable() => _inputReader.ParryEvent += TryActivateParry;
- private void OnDisable() => _inputReader.ParryEvent -= TryActivateParry;
-
- private void Update()
- {
- if (_phase == ParryPhase.Inactive) return;
-
- // ⚠️ 使用 unscaledDeltaTime — 子弹时间期间计时不受影响(架构 06 §8)
- _phaseTimer -= Time.unscaledDeltaTime;
- if (_phaseTimer <= 0f) AdvancePhase(success: false);
- }
-
- // ── 外部调用入口 ──────────────────────────────────────────────
-
- /// 玩家按下弹反键时由 InputReader 事件触发
- private void TryActivateParry()
- {
- if (_phase != ParryPhase.Inactive) return;
- if (!_playerStats.HasAbility(AbilityType.Parry)) return;
-
- EnterPhase(ParryPhase.Startup, _config.StartupDuration);
- _controller.ForceState(PlayerStateType.Parry);
- }
-
- ///
- /// HurtBox.ReceiveDamage 在 Active 阶段调用此方法判断是否弹反成功。
- /// 返回 true 表示弹反成功,调用方应跳过普通受击流程。(架构 06 §8)
- ///
- public bool TryParryDamage(DamageInfo info)
- {
- if (_phase != ParryPhase.Active) return false;
- if (info.Flags.HasFlag(DamageFlags.Unblockable)) return false;
- if (!info.Flags.HasFlag(DamageFlags.CanBeParried)) return false;
- if (info.Flags.HasFlag(DamageFlags.PerfectParryOnly) && !IsInPerfectWindow()) return false;
-
- bool isPerfect = IsInPerfectWindow();
- HandleSuccessfulParry(info, isPerfect);
- return true;
- }
-
- // ── 私有实现 ──────────────────────────────────────────────────
-
- private bool IsInPerfectWindow()
- {
- // Active 阶段开始后的前 PerfectParryThreshold 秒内命中为完美弹反
- float activeElapsed = _config.WindowDuration - _phaseTimer;
- return activeElapsed <= _config.PerfectParryThreshold;
- }
-
- /// 弹反成功的统一处理入口(TryParryDamage 和 ParryableProjectile 均调用此处)
- public void HandleSuccessfulParry(DamageInfo originalDamage, bool isPerfect,
- Projectile hitProjectile = null)
- {
- // ⚠️ _playerStats.AddSoul()(架构 06 §8 使用 AddSoul,非 AddSoulPower)
- int soulGain = isPerfect
- ? _config.SoulGainOnParry + _config.SoulGainOnPerfect
- : _config.SoulGainOnParry;
- _playerStats.AddSoul(soulGain);
-
- // 护盾恢复
- if (_controller.TryGetComponent(out var shield))
- shield.OnParrySuccess();
-
- // 子弹时间(仅完美弹反)
- if (isPerfect)
- StartCoroutine(ApplyBulletTime());
-
- // ⚠️ _onParrySuccess 为 ParryInfoEventChannelSO,携带 ParryInfo 载荷
- var parryInfo = new ParryInfo
- {
- OriginalDamage = originalDamage,
- IsPerfect = isPerfect,
- HitProjectile = hitProjectile,
- ReflectDamageSource = null, // 实际项目中设为预配置的 ReflectDamageSourceSO
- };
- _onParrySuccess.Raise(parryInfo);
-
- // 进入 CounterWindow
- EnterPhase(ParryPhase.CounterWindow, _config.CounterWindowDuration);
- _isInCounterWindow = true;
- }
-
- private void AdvancePhase(bool success)
- {
- switch (_phase)
- {
- case ParryPhase.Startup:
- EnterPhase(ParryPhase.Active, _config.WindowDuration);
- break;
- case ParryPhase.Active:
- EnterPhase(ParryPhase.EndLag, _config.EndlagDuration);
- break;
- case ParryPhase.EndLag:
- case ParryPhase.CounterWindow:
- EnterPhase(ParryPhase.Inactive, 0f);
- _isInCounterWindow = false;
- break;
- }
- }
-
- private void EnterPhase(ParryPhase phase, float duration)
- {
- _phase = phase;
- _phaseTimer = duration;
- }
-
- private System.Collections.IEnumerator ApplyBulletTime()
- {
- Time.timeScale = _config.BulletTimeScale;
- yield return new WaitForSecondsRealtime(_config.BulletTimeDuration);
- Time.timeScale = 1f;
- }
-}
-```
-
-**ParryConfigSO**(按 `06_CombatModule.md §9` 完整字段):
-
-```csharp
-// Assets/ScriptableObjects/Combat/ParryConfig.asset
-[CreateAssetMenu(menuName = "Combat/ParryConfig")]
-public class ParryConfigSO : ScriptableObject
-{
- [Header("阶段时长")]
- public float StartupDuration = 0.05f; // ⚠️ 前摇(Startup)时长(秒),架构 06 §9
- public float WindowDuration = 0.28f; // ⚠️ 弹反有效窗口(Active)时长(秒)
- public float EndlagDuration = 0.10f; // ⚠️ 后摇(EndLag)时长(秒)
- public float CounterWindowDuration = 0.5f; // ⚠️ 弹反成功后反击窗口时长(秒)
-
- [Header("完美弹反判定")]
- public float PerfectParryThreshold = 0.05f; // ⚠️ Active 阶段开始后的完美弹反窗口(秒)
-
- [Header("冷却")]
- public float ParryCooldown = 0.3f; // 弹反动作冷却(秒)
-
- [Header("灵力奖励")]
- public int SoulGainOnParry = 33; // ⚠️ 普通弹反获得灵力(架构 06 §9)
- public int SoulGainOnPerfect = 50; // ⚠️ 完美弹反额外获得灵力(累计 +83)
-
- [Header("反击伤害")]
- public float ParryCounterMultiplier = 3.0f; // ⚠️ 弹反反击伤害倍率
-
- [Header("子弹时间(完美弹反)")]
- public float BulletTimeScale = 0.25f; // ⚠️ 完美弹反触发时的时间缩放
- public float BulletTimeDuration = 0.2f; // ⚠️ 子弹时间持续时长(秒,实际时间)
-
- [Header("硬直")]
- public float StaggerDuration = 0.8f; // ⚠️ 被弹反敌人的受击硬直时长(秒)
-}
-```
-
-`ParryState` 实现要点:
-- 进入:`EnterPhase(Startup)` 已在 `TryActivateParry()` 完成,State 播放弹反预备动画
-- Active 阶段:HurtBox 调用 `TryParryDamage(info)` — 5 阶段自动推进
-- 成功:`HandleSuccessfulParry()` → CounterWindow → 可切换到 AttackState(反击)
-- 超时未命中:EndLag → Inactive → 返回 `IdleState`
-
-### 3.3 PoiseSystem(霸体)
-
-> **架构核心机制**(`06_CombatModule.md §13`):霸体使用**等级比较**,而非数值耐久条。
-> - 攻击方:`DamageInfo.Break`(`BreakLevel` 枚举)——此次攻击能打断多高的霸体
-> - 承受方:`IPoiseSource.GetCurrentPoiseLevel()`(`PoiseLevel` 枚举)——当前帧拥有多高的霸体
-> - 判定公式:`(int)info.Break >= (int)currentPoise` → 打断成功
-> - **玩家和敌人均可拥有霸体**(玩家在攻击/技能动画的特定帧,敌人在超甲状态)
-
-```csharp
-// 实体实现此接口以提供当前霸体等级(玩家侧由 PlayerController 实现,敌人侧由 EnemyPoiseComponent 实现)
-public interface IPoiseSource
-{
- /// 返回当前帧的霸体等级(受时间窗口/状态机控制)
- PoiseLevel GetCurrentPoiseLevel();
-}
-
-// 地址: Assets/Scripts/Combat/PoiseWindowConfig.cs
-// 描述某个状态/技能在特定动画时间段内的霸体等级
-[System.Serializable]
-public struct PoiseWindowConfig
-{
- public PoiseLevel Level; // 此窗口期间的霸体等级
- public float NormalizedStart; // 动画归一化时间起点(0~1)
- public float NormalizedEnd; // 动画归一化时间终点(0~1)
-}
-
-// 地址: Assets/Scripts/Enemies/EnemyPoiseComponent.cs
-// 敌人霸体组件:Awake() 自动注入到同节点 HurtBox(架构 06 §13)
-// ⚠️ [RequireComponent(typeof(EnemyBase))](架构 06 §13)
-[RequireComponent(typeof(EnemyBase))]
-public class EnemyPoiseComponent : MonoBehaviour, IPoiseSource
-{
- [SerializeField] private PoiseLevel _defaultPoiseLevel = PoiseLevel.None;
- private PoiseLevel _currentPoiseLevel;
-
- private void Awake()
- {
- _currentPoiseLevel = _defaultPoiseLevel;
- // ⚠️ Awake 自动注入到同节点 HurtBox(架构 06 §13)
- if (TryGetComponent(out var hurtBox))
- hurtBox.SetPoiseSource(this);
- }
-
- // 超甲状态由 EnemyBase 或 BehaviorDesigner 任务直接调用
- public void SetPoiseLevel(PoiseLevel level) => _currentPoiseLevel = level;
- public void ResetPoiseLevel() => _currentPoiseLevel = _defaultPoiseLevel;
-
- // IPoiseSource 实现
- public PoiseLevel GetCurrentPoiseLevel() => _currentPoiseLevel;
-}
-```
-
-**HurtBox 霸体判定逻辑**(已在 `06_CombatModule §5` HurtBox.ReceiveDamage 中实现):
-```csharp
-// 3. 霸体检查(BreakLevel vs PoiseLevel)
-if (!info.Flags.HasFlag(DamageFlags.ForceBreak) && _poiseSource != null)
-{
- PoiseLevel curPoise = _poiseSource.GetCurrentPoiseLevel();
- if (curPoise != PoiseLevel.None && curPoise == PoiseLevel.Unbreakable) return; // 完全无法打断
- if ((int)info.Break < (int)curPoise)
- {
- // 打断等级不足:触发受击 VFX 但跳过伤害
- _onHitConfirmed.Raise(new HitInfo { DamageInfo = info, HitPoint = transform.position });
- return;
- }
-}
-```
-
-**PoiseOverrideTableSO**(精细控制资产,按 `06_CombatModule.md §13`):
-
-```csharp
-// Assets/ScriptableObjects/Combat/PoiseOverrideTable.asset
-// ⚠️ 用于特殊规则:某个特定 sourceId 对某类目标的打断等级覆盖(架构 06 §13)
-[CreateAssetMenu(menuName = "Combat/PoiseOverrideTable")]
-public class PoiseOverrideTableSO : ScriptableObject
-{
- [Serializable]
- public struct OverrideEntry
- {
- public string SourceId; // DamageSourceSO.sourceId(攻击来源)
- public string TargetTag; // 目标 GameObject Tag(如 "Boss")
- public BreakLevel OverrideBreak; // 覆盖使用的打断等级(忽略 DamageInfo.Break)
- }
-
- public List Entries;
-
- public bool TryGetOverride(string sourceId, string targetTag, out BreakLevel result)
- {
- foreach (var e in Entries)
- {
- if (e.SourceId == sourceId && e.TargetTag == targetTag)
- { result = e.OverrideBreak; return true; }
- }
- result = default;
- return false;
- }
-}
-```
-
-### 3.4 完整连击链
-
-| 输入 | 状态 | 说明 |
-|------|------|------|
-| 攻击 × 3(地面) | AttackState (combo 0/1/2) | 3段地面连击链 |
-| 攻击(空中) | AirAttackState | 1段空中基础攻击 |
-| 下 + 攻击(空中) | DownAttackState | 下劈(踩踏判定)|
-| 上 + 攻击 | UpAttackState | 上劈(向上击飞)|
-| 形态技能键 | SoulSkillState | 消耗灵力的魂技能(Phase 2)|
-
-### 3.5 ClashResolver — 拼刀系统
-
-**参考文档**:`06_CombatModule.md §15`
-
-> 当玩家与敌人的近战 HitBox 同时激活并相互重叠时触发拼刀:双方武器碰撞,均不扣血,各自弹开,播放拼刀特效与音效。仅携带 `CanClash` 标记(`DamageFlags.CanClash`)的 HitBox 才参与拼刀检测。
-
-```csharp
-// Assets/Scripts/Combat/ClashResolver.cs
-// ⚠️ 单例服务,常驻 Persistent 场景(由 GameManager 持有),架构 06 §15
-[DefaultExecutionOrder(-500)]
-public class ClashResolver : MonoBehaviour
-{
- public static ClashResolver Instance { get; private set; }
-
- [SerializeField] VoidEventChannelSO _onNailClash;
- [SerializeField] ClashConfigSO _config;
-
- // 防止同一碰撞在同帧被双方 HitBox 各触发一次(去重)
- readonly HashSet _processedThisFrame = new();
-
- void Awake()
- {
- if (Instance != null) { Destroy(gameObject); return; }
- Instance = this;
- }
-
- void LateUpdate() => _processedThisFrame.Clear();
-
- public void ResolveClash(HitBox playerHitBox, HitBox enemyHitBox)
- {
- int key = playerHitBox.GetInstanceID() ^ enemyHitBox.GetInstanceID();
- if (!_processedThisFrame.Add(key)) return; // 本帧已处理,去重
-
- // 1. 拼刀 HitStop
- HitStopManager.FreezeFrames(_config.ClashFreezeFrames);
- // 2. 双方弹开
- ApplyClashKnockback(playerHitBox.OwnerRigidbody, enemyHitBox.transform.position);
- ApplyClashKnockback(enemyHitBox.OwnerRigidbody, playerHitBox.transform.position);
- // 3. 广播事件(VFX / Audio / CameraImpulse 订阅)
- _onNailClash?.Raise();
- }
-
- void ApplyClashKnockback(Rigidbody2D rb, Vector2 oppositePos)
- {
- if (rb == null) return;
- var dir = ((Vector2)rb.transform.position - oppositePos).normalized;
- rb.AddForce(dir * _config.ClashKnockbackForce, ForceMode2D.Impulse);
- }
-}
-
-// Assets/ScriptableObjects/Config/Combat/ClashConfig.asset
-[CreateAssetMenu(menuName = "Combat/ClashConfig")]
-public class ClashConfigSO : ScriptableObject
-{
- [Header("HitStop")]
- public int ClashFreezeFrames = 1; // 拼刀冻帧(比命中的 2 帧更短)
- [Header("弹开")]
- public float ClashKnockbackForce = 6.0f; // 拼刀弹开力度
- [Header("Camera Impulse")]
- public float ClashImpulseStrength = 0.3f; // Cinemachine Impulse 强度(轻微)
-}
-```
-
-**拼刀判定规则**:
-
-| 情况 | 结果 |
-|------|------|
-| 双方 HitBox 均激活 + 均 `CanClash = true` | 触发拼刀,双方弹开,不扣血 |
-| 仅玩家 HitBox 激活,敌人 HitBox 未激活 | 正常命中敌人 HurtBox |
-| 敌人攻击标记为 `CanClash = false`(如 Boss 重击)| 不触发拼刀,正常伤害玩家 |
-| 同帧多次碰撞 | `HashSet` 去重,每对 HitBox 每帧只触发一次 |
-
-### 3.6 BreakConditionSO + IBreakable + BreakableProp — 机关/障碍物交互
-
-**参考文档**:`06_CombatModule.md §14`
-
-> 游戏中某些机关(晶石、封印门、毒液容器……)**只能被特定类别/标签的攻击击碎**。这类物体实现 `IBreakable` 而非 `IDamageable`,`HitBox.OnTriggerEnter2D` 会把 `DamageInfo` 传给 `IBreakable.TryInteract(info)` 由物体本身决定是否响应。
-
-```csharp
-// Assets/Scripts/Combat/BreakConditionSO.cs
-// ⚠️ 数据驱动机关响应条件(架构 06 §14)
-[CreateAssetMenu(menuName = "Combat/BreakCondition")]
-public class BreakConditionSO : ScriptableObject
-{
- [Header("Category 白名单(空 = 不限类别)")]
- public DamageCategory[] AllowedCategories;
-
- [Header("Tags 必须位(AND 逻辑)")]
- public DamageTags RequiredTags;
-
- [Header("Tags 禁止位(AND NOT 逻辑)")]
- public DamageTags ForbiddenTags;
-
- [Header("DamageType 白名单(空 = 不限元素)")]
- public DamageType[] AllowedTypes;
-
- [Header("技能 ID 白名单(空 = 不限技能)")]
- public string[] AllowedSkillIds;
-
- public bool Evaluate(in DamageInfo info)
- {
- if (AllowedCategories is { Length: > 0 }
- && System.Array.IndexOf(AllowedCategories, info.Category) < 0) return false;
- if (RequiredTags != DamageTags.None && (info.Tags & RequiredTags) != RequiredTags) return false;
- if (ForbiddenTags != DamageTags.None && (info.Tags & ForbiddenTags) != 0) return false;
- if (AllowedTypes is { Length: > 0 }
- && System.Array.IndexOf(AllowedTypes, info.Type) < 0) return false;
- if (AllowedSkillIds is { Length: > 0 }
- && System.Array.IndexOf(AllowedSkillIds, info.SkillId) < 0) return false;
- return true;
- }
-}
-
-// Assets/Scripts/Combat/IBreakable.cs
-// ⚠️ 机关、障碍物实现此接口(非 IDamageable),架构 06 §14
-public interface IBreakable
-{
- bool TryInteract(in DamageInfo info);
-}
-
-// Assets/Scripts/Combat/BreakableProp.cs
-// ⚠️ 通用可破坏/可交互物体(挂在机关 Prefab 上),架构 06 §14
-public class BreakableProp : MonoBehaviour, IBreakable
-{
- [SerializeField] private BreakConditionSO _condition;
- [SerializeField] private int _maxHp = 1; // 默认单次即碎
- [SerializeField] private VoidEventChannelSO _onBroken; // 全局广播(开门、切换场景等)
- [SerializeField] private FeedbackPresetSO _hitFeedback;
- [SerializeField] private FeedbackPresetSO _breakFeedback;
- [SerializeField] private FeedbackPresetSO _rejectFeedback; // 提示玩家"需要特定能力"
-
- private int _currentHp;
-
- private void Awake() => _currentHp = _maxHp;
-
- public bool TryInteract(in DamageInfo info)
- {
- if (!_condition.Evaluate(info))
- {
- _rejectFeedback?.Play(transform.position);
- return false;
- }
-
- _currentHp -= info.FinalDamage > 0 ? info.FinalDamage : 1;
- _hitFeedback?.Play(transform.position);
-
- if (_currentHp <= 0)
- {
- _breakFeedback?.Play(transform.position);
- _onBroken?.Raise();
- gameObject.SetActive(false);
- }
- return true;
- }
-}
-```
-
-**机关配置示例**:
-
-| 机关 | AllowedCategories | RequiredTags | AllowedTypes | 说明 |
-|------|-------------------|--------------|--------------|------|
-| 毒液晶石 | — | `ElementPoison` | `Poison` | 任何毒属性攻击 |
-| 封印门 | `SoulSkill` | `SkyFormOnly` | — | 仅天形魂技能 |
-| 弱点晶球 | `NormalAttack`, `SoulSkill` | `AfterParry` | — | 弹反后才能击碎 |
-| 普通木箱 | `NormalAttack`, `SoulSkill`, `SpiritSkill` | `MeleeHit` | — | 任意近战 |
-
----
-
-## 4. Week 7:状态效果 + 动画事件 + 完整 VFX ✅ 代码完成(2026-05-13,VFX 资产填充仅需 Unity 编辑器)
-
-**当前状态**:✅ 完成(2026-05-11)
-**参考文档**:`06_CombatModule.md §10-11`、`16_AnimationModule.md`、`18_VFXFeedbackModule.md`
-
-**已实施内容**:
-1. ✅ **动画事件骨架**:`AnimationEventType.cs`(**21 种事件类型**)、`IAnimationEventHandler.cs`(接口)、`AnimationEventConfigSO.cs`(SO 资产 + 时间线编辑支持)、`AnimationEventBinder.cs`(Animancer SetCallback 注入)
-2. ✅ **状态效果系统**:`StatusEffectType.cs`(枚举,含 Stagger 扩展项)、`StatusEffect.cs`(抽象基类,非 StatusEffectBase)、`FireEffect.cs`(DoT,不可叠加,**3s/0.5s/1dmg True**,含 `_FireGlow` Shader)、`PoisonEffect.cs`(DoT,3层叠加,**5s/1s/StackCount dmg True**,含 `_PoisonGlow` Shader)、`StaggerEffect.cs`(硬直,架构扩展)、`StatusEffectEventChannelSO.cs`(SO 事件频道)
-3. ✅ **StatusEffectManager 完整重写**:双结构(List + Dictionary)、O(1) 查找、`ApplyDirectDamage(DamageInfo)` via IDamageable、`SetShaderParam(string,float)` via MaterialPropertyBlock、`CleanseEffect`/`CleanseAll`、`HasEffect` 查询、SO 事件广播、SpriteRenderer + MaterialPropertyBlock Awake 初始化
-4. ✅ **动画事件接线**:`PlayerAnimationEvents.cs`(含 HitBox.Id 按名精确激活、ParrySystem.OpenParryWindow/CloseParryWindow、CancelWindowOpen、IFrame、Feedback)、`EnemyAnimationEvents.cs`(含 SpawnProjectile、SetRoaring、TriggerPhaseTwo、OnAnimationComplete)
-5. ✅ **编辑器工具**:`EventConfigEditor.cs`(时间线色块预览 + 归一化时间验证 + Clip 长度漂移检测 + 排序按钮)
-6. ✅ **依赖修改**:`BaseGames.Animation.asmdef` 添加 Combat/Parry/Feedback/Player/Enemies 引用;`BaseGames.Combat.StatusEffects.asmdef` 添加 Core.Events 引用;`BaseGames.Editor.asmdef` 添加 Animation 引用
-7. ✅ **HitBox.Id** 属性新增(`[SerializeField] private string _id`)
-8. ✅ **EnemyBase** 虚方法:`SpawnProjectile`、`TriggerPhaseTwo`、`OnAnimationComplete`、`SetRoaring`
-
-**注**:FootstepSystem(计划 §4.1.2)为 Footstep 事件留了占位(不调用任何实现),待音频模块阶段(`FootstepCatalogSO` + Addressables 异步加载)完整实现。
-
-**架构差异注记(已验证 2026-05-11)**:
-- **ParrySystem 方法名**:架构 24 §5 代码示例写的是 `OpenWindow()`/`CloseWindow()`,实际 ParrySystem API(06 §8)为 `OpenParryWindow()`/`CloseParryWindow()`,代码已按正确 API 实现
-- **StatusEffectType 枚举**:架构 §11 定义 4 值 {Fire, Poison, Freeze, Stun},实现额外添加 `Stagger` 作为硬直扩展
-- **StatusEffectEventChannelSO**:架构期望广播 `StatusEffectType` 直接值,实现扩展为包含 StackCount/RemainingDuration 的 `StatusEffectEvent` struct(UI 更方便)
-- **EnemyAnimationEvents RoarStart/RoarEnd**:架构展示 `_enemy.Blackboard.SetVariableValue()` 直接调用,实现抽象为 `EnemyBase.SetRoaring(bool)` 虚方法(避免 Animation 程序集依赖 BehaviorDesigner)
-
-**实施优先级**(建议顺序):
-1. **动画事件骨架**:`AnimationEventType` 枚举 → `AnimationEventConfigSO` → `AnimationEventBinder` → `IAnimationEventHandler`
-2. **状态效果系统**:`StatusEffect` 抽象基类 → `FireEffect` / `PoisonEffect` / `StaggerEffect` → `StatusEffectManager`
-3. **HurtBox 集成**:步骤 8 `_statusEffectable.ApplyStatusEffect(info.Type)` 连接 `StatusEffectManager`
-4. **动画事件接线**:`PlayerAnimationEvents` + `EnemyAnimationEvents`(含 HitBox 激活时机改为 AnimEvent 驱动)
-5. **编辑器工具**:`EventConfigEditor`(时间轴可视化 + Clip 漂移检测)
-
-> **汇编约束**:`BaseGames.Animation` 程序集需引用 `BaseGames.Combat`(PlayerAnimationEvents 访问 HitBox/HurtBox)和 `BaseGames.Parry`(AnimEvent 驱动 ParrySystem.OpenParryWindow/CloseParryWindow;注意架构文档示例写的是 OpenWindow/CloseWindow,以实际 API 为准)。
-
-### 4.1 AnimationEventType 枚举 + AnimationEventBinder
-
-**参考文档**:`24_AnimEventModule.md §2-4`
-
-> **⚠️ 架构约束**:
-> - **禁止**使用 Unity 传统 `AnimationEvent`(字符串反射)
-> - **必须**使用 Animancer `ClipTransition.Events.SetCallback(normalizedTime, callback)`
-> - 所有时机值由 `AnimationEventConfigSO` 管理(设计师可在 Inspector 调整)
-
-**文件**:
-```
-Assets/Scripts/Animation/AnimationEventType.cs ← 枚举(与架构完全一致)
-Assets/Scripts/Animation/AnimationEventConfigSO.cs ← SO 配置(每个 Clip 的事件时机)
-Assets/Scripts/Animation/AnimationEventBinder.cs ← 静态工具类(注册回调)
-Assets/Scripts/Animation/IAnimationEventHandler.cs ← 接口(⚠️ IAnimationEventHandler,架构 24 §4)
-Assets/Scripts/Animation/PlayerAnimationEvents.cs ← 玩家侧实现
-Assets/Scripts/Animation/EnemyAnimationEvents.cs ← 敌人侧实现
-```
-
-```csharp
-// ✅ 正确 — 完整枚举定义(对应架构 24_AnimEventModule §2)
-namespace BaseGames.Animation
-{
- public enum AnimationEventType
- {
- // 战斗 - HitBox
- EnableHitBox, // 开启 HitBox(payload 字段携带 HitBox 编号或名称)
- DisableHitBox, // 关闭 HitBox
- AttackImpact, // 攻击命中反馈(音效/特效时机)
-
- // 战斗 - 弹反窗口(由 ParrySystem 监听)
- EnableParryWindow, // 开启可弹反时间窗(ParrySystem.OpenWindow())
- DisableParryWindow, // 关闭可弹反时间窗(ParrySystem.CloseWindow())
-
- // 玩家
- EnableIFrame, // 开启无敌帧(翻滚/受击恢复)
- DisableIFrame, // 关闭无敌帧
- Footstep, // 脚步落地(⚠️ 合并 FootstepLeft/Right,由 payload 区分面,架构 24 §2)
- LandImpact, // 落地震动(落地音效/特效)
- JumpLaunch, // 起跳(跳跃音效/特效)
- EnableInteract, // 动画帧触发互动(如 NPC 握手时机)
-
- // 反馈派发
- TriggerFeedback, // 触发 MMF_Player 预设(payload = Feedback 名称)
- PlaySFX, // 播放音效(payload = AudioEventSO Address key)
-
- // 敌人
- SpawnProjectile, // 生成弹幕(⚠️ SpawnProjectile,非 SummonProjectile,架构 24 §2)
- RoarStart, // 怒吼开始(AI 警觉)
- RoarEnd, // 怒吼结束
- PhaseTwoStart, // 二阶段开始(Boss 过渡)
-
- // 通用
- CancelWindowOpen, // 可取消帧窗口开始(连击/取消窗口)
- CancelWindowClose, // 可取消帧窗口结束
- StateTransition, // 动画驱动状态机转移(payload = 目标状态名)
- AnimationComplete, // 动画播完(一次性动画通知)
- }
-}
-```
-
-```csharp
-// AnimationEventConfigSO.cs — 每个 ClipTransition 的事件时机配置(设计师可在 Inspector 调整)
-namespace BaseGames.Animation
-{
- [CreateAssetMenu(menuName = "Animation/EventConfig")]
- public class AnimationEventConfigSO : ScriptableObject
- {
- [Serializable]
- public struct EventEntry
- {
- public AnimationEventType eventType;
- [Range(0f, 1f)]
- public float normalizedTime; // 触发帧在整个动画中的归一化位置
- [Tooltip("附加数据(可空):如 HitBox 编号、音频 key 等")]
- public string data;
- }
-
- [Header("绑定的动画片段(类型安全引用,替代旧 clipName 字符串)")]
- public AnimationClip targetClip; // ⚠️ AnimationClip(非 string clipName),架构 24_AnimEventModule §3
-
- [Header("事件时机列表")]
- public EventEntry[] events;
-
- /// 按时机顺序排序,方便 Binder 批量注册。
- public IEnumerable SortedEvents =>
- events.OrderBy(e => e.normalizedTime);
-
- /// 查询指定事件类型的触发帧(工具/编辑器用)。⚠️ 架构 24 §3 定义
- public float GetNormalizedTime(AnimationEventType eventType)
- {
- foreach (var e in events)
- if (e.eventType == eventType) return e.normalizedTime;
- return -1f; // 未找到
- }
- }
-}
-```
-
-```csharp
-// AnimationEventBinder.cs — 静态工具,将 SO 配置绑定到 ClipTransition
-namespace BaseGames.Animation
-{
- public static class AnimationEventBinder
- {
- public static void Bind(
- ClipTransition clip,
- AnimationEventConfigSO config,
- IAnimationEventHandler receiver) // ⚠️ IAnimationEventHandler(架构 24 §4)
- {
- if (config == null) return;
- foreach (var entry in config.SortedEvents)
- {
- var captured = entry;
- clip.Events.SetCallback(captured.normalizedTime, () =>
- receiver.HandleEvent(captured.eventType, captured.data)); // ⚠️ HandleEvent(架构 24 §4)
- }
- }
- }
-
- // ⚠️ 接口名为 IAnimationEventHandler,方法名为 HandleEvent(架构 24 §4,非 IAnimEventReceiver/OnAnimationEvent)
- public interface IAnimationEventHandler
- {
- void HandleEvent(AnimationEventType type, string payload);
- }
-}
-```
-
-```csharp
-// PlayerAnimationEvents.cs — 挂在 [Animation] 子节点,是玩家所有动画事件的唯一派发入口
-// ⚠️ 字段以架构 24_AnimEventModule §5 为准:独立组件引用,非 PlayerController 聚合
-namespace BaseGames.Animation
-{
- // ⚠️ 实现 IAnimationEventHandler(架构 24 §5),方法名 HandleEvent
- public class PlayerAnimationEvents : MonoBehaviour, IAnimationEventHandler
- {
- [Header("战斗组件")]
- [SerializeField] HitBox[] _hitBoxes; // 玩家身上所有 HitBox
- [SerializeField] HurtBox _hurtBox;
-
- [Header("能力组件")]
- [SerializeField] PlayerStats _playerStats;
- [SerializeField] PlayerMovement _mover; // ⚠️ 类型为 PlayerMovement(架构 05_PlayerModule §3),非 PlayerMover
- [SerializeField] ParrySystem _parrySystem; // ⚠️ 弹反窗口控制(架构 24 §5 / 20_ShieldModule §1)
-
- [Header("特效/音效")]
- [SerializeField] IFeedbackPlayer _feedback; // 通过 GetComponentInParent 注入
-
- [Header("事件配置(与 ClipTransition 一一对应)")]
- [SerializeField] EventBinding[] _bindings;
-
- [Serializable]
- struct EventBinding
- {
- public ClipTransition clip;
- public AnimationEventConfigSO config;
- }
-
- void Awake()
- {
- _feedback = GetComponentInParent();
- foreach (var b in _bindings)
- AnimationEventBinder.Bind(b.clip, b.config, this);
- }
-
- public void HandleEvent(AnimationEventType type, string payload) // ⚠️ HandleEvent + payload(架构 24 §4)
- {
- switch (type)
- {
- case AnimationEventType.EnableHitBox:
- EnableHitBoxById(payload); break;
- case AnimationEventType.DisableHitBox:
- DisableHitBoxById(payload); break;
- case AnimationEventType.AttackImpact:
- _feedback?.PlayAttackWhoosh(); break;
- case AnimationEventType.EnableIFrame:
- _hurtBox.SetInvincible(true); break; // ⚠️ HurtBox.SetInvincible,非 PlayerStats.BeginInvincibility
- case AnimationEventType.DisableIFrame:
- _hurtBox.SetInvincible(false); break;
- case AnimationEventType.Footstep: // ⚠️ Footstep(合并左右脚,payload 区分)
- FootstepSystem.Play(_mover.CurrentSurface, transform.position); break;
- case AnimationEventType.LandImpact:
- _feedback?.PlayLandImpact(); break;
- case AnimationEventType.JumpLaunch:
- _feedback?.PlayJumpLaunch(); break;
- case AnimationEventType.EnableParryWindow:
- _parrySystem?.OpenWindow(); break; // ⚠️ 架构 24 §5:ParrySystem.OpenWindow()
- case AnimationEventType.DisableParryWindow:
- _parrySystem?.CloseWindow(); break; // ⚠️ 架构 24 §5:ParrySystem.CloseWindow()
- case AnimationEventType.CancelWindowOpen:
- _mover.SetCancelWindowOpen(true); break; // ⚠️ PlayerMovement.SetCancelWindowOpen(架构 05_PlayerModule §3),非 AttackState 方法
- case AnimationEventType.CancelWindowClose:
- _mover.SetCancelWindowOpen(false); break;
- case AnimationEventType.TriggerFeedback:
- _feedback?.TriggerPreset(payload); break; // ⚠️ TriggerPreset(架构 18 §2 IFeedbackPlayer 接口,非 PlayFeedbackByName)
- case AnimationEventType.PlaySFX:
- _feedback?.PlaySFXById(payload); break; // ⚠️ PlaySFXById(架构 18 §2 IFeedbackPlayer 接口;AudioManager 无 PlaySFXByKey 方法)
- }
- }
-
- void EnableHitBoxById(string id)
- {
- foreach (var hb in _hitBoxes)
- if (hb.Id == id || string.IsNullOrEmpty(id)) hb.Activate(); // ⚠️ HitBox.Activate(),无参数(架构 06 §4)
- }
-
- void DisableHitBoxById(string id)
- {
- foreach (var hb in _hitBoxes)
- if (hb.Id == id || string.IsNullOrEmpty(id)) hb.Deactivate(); // ⚠️ HitBox.Deactivate()(架构 06 §4)
- }
- }
-}
-```
-
-```csharp
-// EnemyAnimationEvents.cs — 挂在 EnemyBase(或其 [Animation] 子节点),敌人侧动画事件派发入口
-// ⚠️ 与 PlayerAnimationEvents 结构相同(见架构 24_AnimEventModule §6)
-namespace BaseGames.Animation
-{
- public class EnemyAnimationEvents : MonoBehaviour, IAnimationEventHandler // ⚠️ IAnimationEventHandler(架构 24 §4,非 IAnimEventReceiver)
- {
- [SerializeField] HitBox[] _hitBoxes;
- [SerializeField] EnemyFeedback _feedback;
- [SerializeField] EnemyBase _enemy;
-
- [SerializeField] EventBinding[] _bindings;
-
- [Serializable]
- struct EventBinding
- {
- public ClipTransition clip;
- public AnimationEventConfigSO config;
- }
-
- void Awake()
- {
- foreach (var b in _bindings)
- AnimationEventBinder.Bind(b.clip, b.config, this);
- }
-
- public void HandleEvent(AnimationEventType type, string payload) // ⚠️ HandleEvent(架构 24 §6,非 OnAnimationEvent)
- {
- switch (type)
- {
- case AnimationEventType.EnableHitBox:
- foreach (var hb in _hitBoxes)
- if (hb.Id == payload || string.IsNullOrEmpty(payload)) hb.Activate(); // ⚠️ HitBox.Activate()(架构 06 §4)
- break;
- case AnimationEventType.DisableHitBox:
- foreach (var hb in _hitBoxes)
- if (hb.Id == payload || string.IsNullOrEmpty(payload)) hb.Deactivate(); // ⚠️ HitBox.Deactivate()(架构 06 §4)
- break;
- case AnimationEventType.SpawnProjectile: // ⚠️ SpawnProjectile(架构 24 §2,非旧版 SummonProjectile)
- _enemy.SpawnProjectile(payload); break;
- case AnimationEventType.RoarStart:
- case AnimationEventType.RoarEnd:
- _enemy.Blackboard.SetVariableValue("IsRoaring",
- type == AnimationEventType.RoarStart);
- break;
- case AnimationEventType.PhaseTwoStart:
- _enemy.TriggerPhaseTwo(); break;
- case AnimationEventType.AnimationComplete:
- _enemy.OnAnimationComplete(payload); break;
- }
- }
- }
-}
-```
-
-**配置工作流**:
-1. 为每个攻击动画创建 `AnimationEventConfigSO`(Inspector 设置 normalizedTime)
-2. 在 `PlayerAnimationEvents.Awake` 中调用 `AnimationEventBinder.Bind(clip, config, this)`
-3. 设计师调整时机无需修改代码,只改 SO 资产
-
-### 4.1.1 EventConfigEditor — 动画事件时间轴可视化(⚠️ 架构 24 §10,Phase 2 优化)
-
-> 路径:`Assets/Editor/Animation/EventConfigEditor.cs`。`[CustomEditor(typeof(AnimationEventConfigSO))]`,设计师可在 Inspector 中直观查看/调整所有 normalizedTime 标记点,无需手动核对帧序号。
-
-```csharp
-// Assets/Editor/Animation/EventConfigEditor.cs
-// 需在 AnimationEventConfigSO 补充隐藏字段:
-// [HideInInspector] public float ExpectedClipLength = -1f; // Auto-detect 写入
-
-[CustomEditor(typeof(AnimationEventConfigSO))]
-public class EventConfigEditor : UnityEditor.Editor
-{
- // 事件类型颜色规则(架构 24 §10):
- // HitBox=红 IFrame=绿 Footstep/SFX=蓝 ParryWindow=黄 TriggerFeedback=紫 CancelWindow=橙
-
- public override void OnInspectorGUI()
- {
- var config = (AnimationEventConfigSO)target;
-
- // ① 时间轴背景条(全宽 24px 高)+ 各事件 normalizedTime 竖线标记(按类型着色)
- DrawTimeline(config);
-
- // ② 默认 Inspector 字段
- DrawDefaultInspector();
-
- // ③ 越界保护:normalizedTime < 0 || > 1 → HelpBox Error
- ValidateNormalizedTime(config);
-
- // ④ Clip 漂移检测:|currentLen - ExpectedClipLength| > 5帧 → HelpBox Warning
- if (config.ExpectedClipLength > 0 && config.TargetClip != null)
- {
- float frameDrift = Mathf.Abs(config.TargetClip.length - config.ExpectedClipLength)
- * config.TargetClip.frameRate;
- if (frameDrift > 5f)
- EditorGUILayout.HelpBox(
- $"Clip 长度偏差 {frameDrift:F1} 帧,请重新校验 normalizedTime 或点击 Auto-detect 更新基准。",
- MessageType.Warning);
- }
-
- // ⑤ Auto-detect 按钮:将当前 Clip 长度写入 ExpectedClipLength
- if (GUILayout.Button("Auto-detect Clip Length"))
- {
- if (config.TargetClip != null)
- {
- config.ExpectedClipLength = config.TargetClip.length;
- EditorUtility.SetDirty(config);
- }
- }
- }
-
- private void DrawTimeline(AnimationEventConfigSO config) { /* ... */ }
- private void ValidateNormalizedTime(AnimationEventConfigSO config) { /* ... */ }
-}
-```
-
-### 4.1.2 FootstepSystem + FootstepCatalogSO + SurfaceType
-
-**参考文档**:`24_AnimEventModule.md §8`
-
-```csharp
-// Assets/Scripts/Animation/FootstepSystem.cs
-// ⚠️ 静态工具类;_catalog 通过 Addressables 异步加载,禁止 Resources.Load
-// BootstrapLoader 在游戏启动时调用 FootstepSystem.InitAsync()
-namespace BaseGames.Animation
-{
- public static class FootstepSystem
- {
- static FootstepCatalogSO _catalog;
-
- public static async UniTask InitAsync()
- {
- _catalog = await Addressables
- .LoadAssetAsync(AddressKeys.SO_FootstepCatalog).Task;
- // AddressKeys.SO_FootstepCatalog = "Config/FootstepCatalog"
- }
-
- public static void Play(SurfaceType surface, Vector3 position)
- {
- var entry = _catalog.GetEntry(surface);
- if (entry == null) return;
- // ⚠️ entry.audioClip 类型为 AudioClip(非 string key),对应架构 11_AudioModule §2 的 PlaySFXAtPosition(AudioClip, Vector2)
- AudioManager.Instance.PlaySFXAtPosition(entry.audioClip, position);
- if (entry.dustParticlePrefab.RuntimeKeyIsValid())
- VFXPool.Instance.Play(entry.dustParticlePrefab, position);
- }
- }
-
- // Assets/Scripts/Animation/FootstepCatalogSO.cs
- [CreateAssetMenu(menuName = "Audio/FootstepCatalog")]
- public class FootstepCatalogSO : ScriptableObject
- {
- [Serializable]
- public struct FootstepEntry
- {
- public SurfaceType surface;
- public AudioClip audioClip; // ⚠️ AudioClip 直接引用,非 string key
- public AssetReferenceGameObject dustParticlePrefab;
- }
-
- [SerializeField] FootstepEntry[] _entries;
-
- public FootstepEntry? GetEntry(SurfaceType surface)
- {
- foreach (var e in _entries)
- if (e.surface == surface) return e;
- return null;
- }
- }
-
- // Assets/Scripts/Animation/SurfaceType.cs
- public enum SurfaceType { Stone, Wood, Dirt, Water, Metal, Grass }
-}
-```
-
-### 4.1.2 PlayerMovement Phase 2 扩展字段
-
-**参考文档**:`05_PlayerModule.md §3`、`24_AnimEventModule.md §9`
-
-> **Week 7 补充**:在 PlayerMovement 已有方法基础上,追加以下字段供 AnimEvent 系统使用。
-
-```csharp
-// PlayerMovement.cs 中新增(Phase 2 Week 7,对应架构 05_PlayerModule §3)
-// ── 可取消帧窗口 ────────────────────────────────────────────────────
-private bool _cancelWindowOpen = false;
-public bool CancelWindowOpen => _cancelWindowOpen;
-public void SetCancelWindowOpen(bool open) => _cancelWindowOpen = open;
-// 由 PlayerAnimationEvents.OnAnimationEvent(CancelWindowOpen/CancelWindowClose) 驱动
-
-// ── 当前地面材质 ─────────────────────────────────────────────────────
-public SurfaceType CurrentSurface { get; private set; } // private set,由 CheckGrounded() 检测 Tile 材质后赋值
-// FootstepSystem.Play(_mover.CurrentSurface, ...) 依赖此属性
-```
-
-### 4.1.3 AttackState.GetNextState() CancelWindow 检查
-
-**参考文档**:`24_AnimEventModule.md §9`
-
-> **Week 7 补充**:在 Plan 02 §3.5 AttackState 基础上,Phase 2 Week 7 追加 `GetNextState()` 重写,
-> 确保只有在 `CancelWindowOpen` 为 true 时才允许 FSM 接受新输入并切换状态。
-
-```csharp
-// AttackState.cs 中追加(Phase 2 Week 7,对应架构 24_AnimEventModule §9)
-// ⚠️ Phase 1 §3.5 使用 inline Events.SetCallback 的简化版;Week 7 改为由 PlayerAnimationEvents 驱动
-public override PlayerStateBase GetNextState()
-{
- // 只有在 CancelWindowOpen 时才接受新输入转换状态
- if (!_player.Movement.CancelWindowOpen) return null;
- if (_player.Input.AttackPressed) return _nextAttackState;
- if (_player.Input.DashPressed) return _player.DashState;
- if (_player.Input.JumpPressed) return _player.JumpState;
- return null;
-}
-```
-
-**流程**(`24_AnimEventModule.md §9` 时序):
-```
-AttackClip 播放
- ├─ NormalizedTime = 0.6 → AnimationEventType.CancelWindowOpen → PlayerMovement._cancelWindowOpen = true
- ├─ 玩家可输入 → FSM.GetNextState() 允许取消进入其他状态
- └─ NormalizedTime = 0.9 → AnimationEventType.CancelWindowClose → PlayerMovement._cancelWindowOpen = false
-```
-
-### 4.2 StatusEffect + 具体状态效果
-
-```csharp
-// Assets/Scripts/Combat/StatusEffects/StatusEffect.cs
-// ⚠️ 类名为 StatusEffect(非 StatusEffectBase),架构 06_CombatModule §11
-namespace BaseGames.Combat.StatusEffects
-{
- public enum StatusEffectType { Fire, Poison, Freeze, Stun } // ⚠️ 枚举标识符,非 string EffectId
-
- public abstract class StatusEffect
- {
- public abstract StatusEffectType EffectType { get; } // ⚠️ EffectType(非 EffectId string)
- public abstract int MaxStacks { get; } // 最大叠加层数(1 = 不可叠加)
-
- public int StackCount { get; protected set; } = 1; // ⚠️ 必须定义 StackCount(架构 06 §11)
- public float Duration { get; protected set; } // 当前剩余持续时间
- public float TickInterval { get; protected set; } // 每次 Tick 的间隔秒数
- float _tickTimer;
-
- protected StatusEffectManager Owner; // 宿主(由 OnApply 注入)
-
- // ⚠️ OnApply 参数为 StatusEffectManager owner(非 IDamageable)
- public virtual void OnApply(StatusEffectManager owner)
- {
- Owner = owner;
- Duration = GetBaseDuration();
- }
-
- // ⚠️ OnStack(同类型再次施加时叠层/刷新)
- public virtual void OnStack()
- {
- Duration = GetBaseDuration();
- StackCount = Mathf.Min(StackCount + 1, MaxStacks);
- }
-
- // ⚠️ OnTick/OnExpire 无参数(用 Owner 字段引用宿主)
- public virtual void OnTick() { }
- public virtual void OnExpire() { } // ⚠️ OnExpire(非 OnRemove)
-
- public virtual bool IsExpired => Duration <= 0f;
-
- // ⚠️ Update(float delta)(非 Tick(IDamageable, float))
- public void Update(float delta)
- {
- Duration -= delta;
- _tickTimer += delta;
- if (_tickTimer >= TickInterval)
- {
- _tickTimer -= TickInterval;
- OnTick();
- }
- }
-
- protected abstract float GetBaseDuration();
- public abstract string GetDisplayName();
- }
-}
-```
-
-// Phase 2 实现以下 3 种(其余 Phase 3 补充)
-// Assets/Scripts/Combat/StatusEffects/FireEffect.cs — 燃烧(DOT,不可叠加,触发则刷新)
-// Assets/Scripts/Combat/StatusEffects/PoisonEffect.cs — 中毒(DOT,最多 3 层叠加)
-// Assets/Scripts/Combat/StatusEffects/StaggerEffect.cs — 硬直(无法行动 N 秒)
-
-### 4.3 StatusEffectManager
-
-```csharp
-// Assets/Scripts/Combat/StatusEffects/StatusEffectManager.cs
-// ⚠️ [RequireComponent(typeof(SpriteRenderer))] — 需要 SpriteRenderer 支持 Shader 参数(架构 06 §10)
-namespace BaseGames.Combat.StatusEffects
-{
- [RequireComponent(typeof(SpriteRenderer))]
- public class StatusEffectManager : MonoBehaviour
- {
- [Header("事件频道")]
- // ⚠️ StatusEffectEventChannelSO(非 VoidEventChannelSO),携带 StatusEffectType 载荷(架构 06 §10)
- [SerializeField] StatusEffectEventChannelSO _onStatusEffectApplied;
- [SerializeField] StatusEffectEventChannelSO _onStatusEffectExpired;
-
- // ⚠️ 双结构:List 用于有序 Update 遍历;Dictionary 用于 O(1) 查找(架构 06 §10)
- readonly List _activeList = new();
- readonly Dictionary _activeIndex = new();
-
- SpriteRenderer _renderer;
- MaterialPropertyBlock _propBlock;
-
- void Awake()
- {
- _renderer = GetComponent();
- _propBlock = new MaterialPropertyBlock();
- }
-
- void Update()
- {
- for (int i = _activeList.Count - 1; i >= 0; i--)
- {
- var effect = _activeList[i];
- effect.Update(Time.deltaTime); // ⚠️ Update(float)(非 Tick(IDamageable, float))
- if (effect.IsExpired)
- {
- effect.OnExpire(); // ⚠️ OnExpire()(非 OnRemove(target))
- _activeIndex.Remove(effect.EffectType);
- _activeList.RemoveAt(i);
- _onStatusEffectExpired?.Raise(effect.EffectType);
- }
- }
- }
-
- /// 施加状态效果。已有相同类型时叠层/刷新(O(1) 查找)。
- public void ApplyEffect(StatusEffect newEffect)
- {
- // ⚠️ 用 Dictionary 做 O(1) 查找(非 List.Find),架构 06 §10
- if (_activeIndex.TryGetValue(newEffect.EffectType, out var existing))
- {
- existing.OnStack(); // ⚠️ OnStack()叠层或刷新
- }
- else
- {
- newEffect.OnApply(this); // ⚠️ 传 StatusEffectManager(非 IDamageable)
- _activeList.Add(newEffect);
- _activeIndex[newEffect.EffectType] = newEffect;
- _onStatusEffectApplied?.Raise(newEffect.EffectType);
- }
- }
-
- /// 净化指定类型的状态效果(O(1) 查找)。⚠️ 方法名为 CleanseEffect(非 RemoveEffect),架构 06 §10
- public void CleanseEffect(StatusEffectType type)
- {
- if (!_activeIndex.TryGetValue(type, out var effect)) return;
- effect.OnExpire();
- _activeIndex.Remove(type);
- _activeList.Remove(effect);
- _onStatusEffectExpired?.Raise(type);
- }
-
- /// 净化所有状态效果。⚠️ 存档点激活时调用(架构 06 §10)
- public void CleanseAll()
- {
- foreach (var e in _activeList) e.OnExpire();
- _activeList.Clear();
- _activeIndex.Clear();
- }
-
- /// 由状态效果调用,直接扣 HP(True 伤害,绕过 HurtBox)。⚠️ 架构 06 §10
- public void ApplyDirectDamage(DamageInfo info)
- => GetComponent()?.TakeDamage(info);
-
- /// 由状态效果调用,设置 Shader 参数(MaterialPropertyBlock)。⚠️ 架构 06 §10
- public void SetShaderParam(string param, float value)
- {
- _renderer.GetPropertyBlock(_propBlock);
- _propBlock.SetFloat(param, value);
- _renderer.SetPropertyBlock(_propBlock);
- }
-
- /// 检查是否有指定类型的状态效果。⚠️ 架构 06 §10
- public bool HasEffect(StatusEffectType type) => _activeIndex.ContainsKey(type);
- }
-}
-```
-
-HurtBox 收到含 `DamageType.Poison/Fire` 的 DamageInfo 时,自动调用 `StatusEffectManager.ApplyEffect`。
-净化方式:进入水域调用 `CleanseEffect(Fire)`;装备解毒护符自动净化 Poison;存档点调用 `CleanseAll()`(架构 06 §11.2)。
-
-### 4.4 完整 VFX 反馈模块
-
-**参考文档**:`18_VFXFeedbackModule.md §2–§12`
-
-#### 4.4.1 FeedbackConfigSO
-
-```csharp
-// Assets/ScriptableObjects/Feedback/Feedback_Config.asset
-// ⚠️ menuName = "Feedback/FeedbackConfig"(架构 18_VFXFeedbackModule §5)
-namespace BaseGames.Feedback
-{
- [CreateAssetMenu(menuName = "Feedback/FeedbackConfig")]
- public class FeedbackConfigSO : ScriptableObject
- {
- [Header("冻帧")]
- [Range(0f, 0.2f)]
- public float FreezeFrameDuration = 0.033f; // 默认 2 帧(60fps)
- [Range(0f, 0.2f)]
- public float ParryFreezeFrameDuration = 0.067f; // 弹反冻帧更长
-
- [Header("子弹时间")]
- [Range(0.01f, 1f)]
- public float BulletTimeScale = 0.15f;
- [Range(0f, 1f)]
- public float BulletTimeDuration = 0.3f;
-
- [Header("镜头震动强度")]
- public float ShakeLightForce = 0.2f;
- public float ShakeMediumForce = 0.5f;
- public float ShakeHeavyForce = 1.0f;
- public float ShakeParryForce = 0.8f;
-
- [Header("受击白闪")]
- public Color HurtFlashColor = Color.white;
- [Range(0f, 0.5f)]
- public float HurtFlashDuration = 0.15f;
- }
-}
-```
-
-#### 4.4.2 PlayerFeedback(完整实现)
-
-```csharp
-// Assets/Scripts/Feedback/PlayerFeedback.cs
-// ⚠️ 挂在 Player Prefab 根节点下的 [Feedback] 子 GameObject 上(架构 18_VFXFeedbackModule §3)
-// Player Prefab 层级:[Player] → [Feedback] ← PlayerFeedback.cs + MMF_Player × 10
-namespace BaseGames.Feedback
-{
- public class PlayerFeedback : MonoBehaviour, IFeedbackPlayer
- {
- [Header("命中反馈")]
- [SerializeField] MMF_Player _onHitLight;
- [SerializeField] MMF_Player _onHitMedium;
- [SerializeField] MMF_Player _onHitHeavy;
-
- [Header("战斗反馈")]
- [SerializeField] MMF_Player _onParrySuccess;
- [SerializeField] MMF_Player _onTakeHit;
- [SerializeField] MMF_Player _onDeath;
-
- [Header("移动反馈")]
- [SerializeField] MMF_Player _onHeal;
- [SerializeField] MMF_Player _onLandImpact;
- [SerializeField] MMF_Player _onAttackWhoosh;
- [SerializeField] MMF_Player _onJumpLaunch;
-
- [Header("配置")]
- [SerializeField] FeedbackConfigSO _config;
-
- // 预设字典(runtime,用于 TriggerPreset)
- Dictionary _presetMap;
-
- void Awake()
- {
- _presetMap = new Dictionary
- {
- { "HitLight", _onHitLight },
- { "HitMedium", _onHitMedium },
- { "HitHeavy", _onHitHeavy },
- { "ParrySuccess", _onParrySuccess },
- { "TakeHit", _onTakeHit },
- { "Death", _onDeath },
- { "LandImpact", _onLandImpact },
- };
- }
-
- public void PlayHit(HitWeight weight)
- {
- switch (weight)
- {
- case HitWeight.Light: _onHitLight?.PlayFeedbacks(); break;
- case HitWeight.Medium: _onHitMedium?.PlayFeedbacks(); break;
- case HitWeight.Heavy: _onHitHeavy?.PlayFeedbacks(); break;
- }
- }
- public void PlayParrySuccess() => _onParrySuccess?.PlayFeedbacks();
- public void PlayTakeHit() => _onTakeHit?.PlayFeedbacks();
- public void PlayDeath() => _onDeath?.PlayFeedbacks();
- public void PlayHeal() => _onHeal?.PlayFeedbacks();
- public void PlayLandImpact() => _onLandImpact?.PlayFeedbacks();
- public void PlayAttackWhoosh() => _onAttackWhoosh?.PlayFeedbacks();
- public void PlayJumpLaunch() => _onJumpLaunch?.PlayFeedbacks();
-
- public void TriggerPreset(string presetId)
- {
- if (_presetMap.TryGetValue(presetId, out var player))
- player?.PlayFeedbacks();
- }
-
- public void PlaySFXById(string sfxId)
- {
- // sfxId → AudioClip 解析通过 SFXCatalogSO 查表后调用 AudioManager.Instance.PlaySFX(clip)
- }
- }
-}
-```
-
-#### 4.4.3 EnemyFeedback(完整实现)
-
-```csharp
-// Assets/Scripts/Feedback/EnemyFeedback.cs
-// ⚠️ 挂在 EnemyBase Prefab 下的 [Feedback] 子 GameObject 上(架构 18_VFXFeedbackModule §4)
-// EnemyBase 通过 [SerializeField] EnemyFeedback _feedback 引用
-namespace BaseGames.Feedback
-{
- public class EnemyFeedback : MonoBehaviour, IFeedbackPlayer
- {
- [SerializeField] MMF_Player _onTakeHit;
- [SerializeField] MMF_Player _onDeath;
- [SerializeField] MMF_Player _onHitLight;
- [SerializeField] MMF_Player _onHitMedium;
- [SerializeField] MMF_Player _onHitHeavy;
-
- public void PlayHit(HitWeight weight)
- {
- switch (weight)
- {
- case HitWeight.Light: _onHitLight?.PlayFeedbacks(); break;
- case HitWeight.Medium: _onHitMedium?.PlayFeedbacks(); break;
- case HitWeight.Heavy: _onHitHeavy?.PlayFeedbacks(); break;
- }
- }
- public void PlayTakeHit() => _onTakeHit?.PlayFeedbacks();
- public void PlayDeath() => _onDeath?.PlayFeedbacks();
-
- // 敌人未使用的接口方法(空实现)
- public void PlayParrySuccess() { }
- public void PlayHeal() { }
- public void PlayLandImpact() { }
- public void PlayAttackWhoosh() { }
- public void PlayJumpLaunch() { }
- public void TriggerPreset(string id) { }
- public void PlaySFXById(string id) { }
- }
-}
-```
-
-#### 4.4.4 PaletteSwapSystem + PaletteCatalogSO
-
-```csharp
-// Assets/Scripts/VFX/PaletteSwapSystem.cs
-// ⚠️ ApplyPalette(FormType form) 接受 FormType 枚举(架构 18_VFXFeedbackModule §10)
-// FormController.SwitchForm 调用:_paletteSwapSystem?.ApplyPalette(newForm.formType) ← 正确(FormSO.formType 字段,架构 05 §18 遗漏,以架构 18 §10 为准)
-namespace BaseGames.VFX
-{
- public class PaletteSwapSystem : MonoBehaviour
- {
- [SerializeField] SpriteRenderer _renderer;
- [SerializeField] PaletteCatalogSO _catalog;
-
- static readonly int PaletteTexID = Shader.PropertyToID("_PaletteTex");
- MaterialPropertyBlock _block;
-
- void Awake() => _block = new MaterialPropertyBlock();
-
- /// 切换到指定形态的调色板。由 FormController 调用。
- public void ApplyPalette(FormType form) // ⚠️ 参数为 FormType 枚举(非 Color,非 FormSO)
- {
- if (!_catalog.TryGetPalette(form, out var tex)) return;
- _renderer.GetPropertyBlock(_block);
- _block.SetTexture(PaletteTexID, tex);
- _renderer.SetPropertyBlock(_block);
- }
- }
-
- // Assets/Scripts/VFX/PaletteCatalogSO.cs
- [CreateAssetMenu(menuName = "VFX/PaletteCatalog")]
- public class PaletteCatalogSO : ScriptableObject
- {
- public PaletteEntry[] entries;
-
- public bool TryGetPalette(FormType form, out Texture2D tex)
- {
- foreach (var e in entries)
- if (e.form == form) { tex = e.paletteLUT; return true; }
- tex = null;
- return false;
- }
- }
-
- [Serializable]
- public struct PaletteEntry
- {
- public FormType form;
- public Texture2D paletteLUT; // 1D 查找表纹理(256×1 px)
- }
-}
-```
-
-#### 4.4.5 PostProcessManager
-
-```csharp
-// Assets/Scripts/VFX/PostProcessManager.cs
-// ⚠️ 挂在 Persistent 场景 [PostProcess] GameObject(架构 18_VFXFeedbackModule §11)
-// ⚠️ DOTween 规范:.SetAutoKill(true).SetLink(gameObject)(架构 §11)
-namespace BaseGames.VFX
-{
- public class PostProcessManager : MonoBehaviour
- {
- [Header("Volume 引用(Persistent 场景内)")]
- [SerializeField] Volume _underwaterVolume; // Priority=10
- [SerializeField] Volume _bossArenaVolume; // Priority=10
- [SerializeField] Volume _deathVolume; // Priority=20
- [SerializeField] Volume _victoryVolume; // Priority=10
-
- [Header("事件频道")]
- [SerializeField] VoidEventChannelSO _onLiquidEntered;
- [SerializeField] VoidEventChannelSO _onLiquidExited;
- [SerializeField] VoidEventChannelSO _onBossFightStarted;
- [SerializeField] VoidEventChannelSO _onBossFightEnded;
- [SerializeField] VoidEventChannelSO _onPlayerDied;
- [SerializeField] VoidEventChannelSO _onPlayerRespawned;
- [SerializeField] VoidEventChannelSO _onBossDefeated;
-
- [SerializeField] float _blendDuration = 0.4f;
-
- private Volume[] _nonDefaultVolumes;
-
- private void Awake()
- {
- _nonDefaultVolumes = new[] { _underwaterVolume, _bossArenaVolume, _deathVolume, _victoryVolume };
- }
-
- private void OnEnable()
- {
- _onLiquidEntered.OnEventRaised += () => BlendTo(_underwaterVolume);
- _onLiquidExited.OnEventRaised += ResetAll;
- _onBossFightStarted.OnEventRaised += () => BlendTo(_bossArenaVolume);
- _onBossFightEnded.OnEventRaised += ResetAll;
- _onPlayerDied.OnEventRaised += () => BlendTo(_deathVolume);
- _onPlayerRespawned.OnEventRaised += ResetAll;
- _onBossDefeated.OnEventRaised += () => BlendTo(_victoryVolume);
- }
-
- private void OnDisable()
- {
- _onLiquidEntered.OnEventRaised -= () => BlendTo(_underwaterVolume);
- _onLiquidExited.OnEventRaised -= ResetAll;
- _onBossFightStarted.OnEventRaised -= () => BlendTo(_bossArenaVolume);
- _onBossFightEnded.OnEventRaised -= ResetAll;
- _onPlayerDied.OnEventRaised -= () => BlendTo(_deathVolume);
- _onPlayerRespawned.OnEventRaised -= ResetAll;
- _onBossDefeated.OnEventRaised -= () => BlendTo(_victoryVolume);
- }
-
- private void BlendTo(Volume target)
- {
- foreach (var v in _nonDefaultVolumes)
- DOTween.To(() => v.weight, x => v.weight = x, 0f, _blendDuration)
- .SetAutoKill(true).SetLink(gameObject);
- DOTween.To(() => target.weight, x => target.weight = x, 1f, _blendDuration)
- .SetAutoKill(true).SetLink(gameObject);
- }
-
- private void ResetAll()
- {
- foreach (var v in _nonDefaultVolumes)
- DOTween.To(() => v.weight, x => v.weight = x, 0f, _blendDuration)
- .SetAutoKill(true).SetLink(gameObject);
- }
- }
-}
-```
-
-**Persistent 场景 [PostProcess] 子 Volume 层级**:
-
-```
-[PostProcess]
- ├── Volume_Default Priority=0 Weight=1.0(始终生效)
- ├── Volume_Underwater Priority=10 Weight=0(进水时 blend 到 1.0)
- ├── Volume_BossArena Priority=10 Weight=0(Boss 战开始时 blend 到 1.0)
- ├── Volume_Death Priority=20 Weight=0(玩家死亡时 blend 到 1.0)
- └── Volume_Victory Priority=10 Weight=0(Boss 击败时 blend 到 1.0)
-```
-
-| Volume | Bloom | Color Grading | Vignette | Chromatic Aberration |
-|--------|-------|--------------|----------|---------------------|
-| Default | 0.3 | 正常 | 0.2 | 关闭 |
-| Underwater | 0.1 | 青绿 Filter -0.3 | 0.45 | 0.4 |
-| BossArena | 0.5 | 饱和度 +20% | 0.35 | 0.15 |
-| Death | 0 | 去饱和度 -80% | 0.7(黑色)| 0.8 |
-| Victory | 0.8(白色)| 亮度 +0.4 | 0 | 0 |
-
-#### 4.4.6 RegionLightController + RegionLightCatalogSO
-
-```csharp
-// Assets/Scripts/VFX/RegionLightController.cs
-// ⚠️ 挂在 Persistent 场景 [Lighting] GameObject(架构 18_VFXFeedbackModule §12)
-// 监听 EVT_RegionEntered(StringEventChannelSO regionId)平滑切换 Global Light 2D
-namespace BaseGames.VFX
-{
- public class RegionLightController : MonoBehaviour
- {
- [SerializeField] Light2D _globalLight;
- [SerializeField] RegionLightCatalogSO _catalog;
- [SerializeField] StringEventChannelSO _onRegionEntered;
- [SerializeField] float _transitionDuration = 1.5f;
-
- private void OnEnable() => _onRegionEntered.OnEventRaised += OnRegionEntered;
- private void OnDisable() => _onRegionEntered.OnEventRaised -= OnRegionEntered;
-
- private void OnRegionEntered(string regionId)
- {
- if (!_catalog.TryGet(regionId, out var config)) return;
- DOTween.To(() => _globalLight.color,
- x => _globalLight.color = x,
- config.Color, _transitionDuration)
- .SetAutoKill(true).SetLink(gameObject);
- DOTween.To(() => _globalLight.intensity,
- x => _globalLight.intensity = x,
- config.Intensity, _transitionDuration)
- .SetAutoKill(true).SetLink(gameObject);
- }
- }
-
- // Assets/Scripts/VFX/RegionLightCatalogSO.cs
- [CreateAssetMenu(menuName = "VFX/RegionLightCatalog")]
- public class RegionLightCatalogSO : ScriptableObject
- {
- [Serializable]
- public struct RegionLightConfig
- {
- public string regionId;
- public Color Color;
- [Range(0f, 1f)] public float Intensity;
- }
-
- [SerializeField] RegionLightConfig[] _entries;
-
- public bool TryGet(string regionId, out RegionLightConfig cfg)
- {
- foreach (var e in _entries)
- if (e.regionId == regionId) { cfg = e; return true; }
- cfg = default;
- return false;
- }
- }
-}
-```
-
-**区域 Global Light 2D 参数速查**:
-
-| 区域 | 颜色 | 强度 |
-|------|------|------|
-| Forest(扎根森林)| `#C8E8D0`(淡绿)| 0.8 |
-| Cave(腐蚀洞穴)| `#1A0A2E`(深紫)| 0.2 |
-| Ruins(坍塌废墟)| `#3D3028`(暖褐)| 0.5 |
-| Abyss(深渊裂隙)| `#000820`(极暗蓝)| 0.1 |
-| Core(核心熔炉)| `#4A1800`(暗红橙)| 0.6 |
-
-#### 4.4.7 Phase 2 追加的 VFX 资产
-
-| VFX Key | 触发时机 |
-|---------|---------|
-| `VFX_ParryFlash` | 弹反成功瞬间 |
-| `VFX_ShieldBreak` | 护盾破碎 |
-| `VFX_FormSwitch_Sky` | 切换天魂形态 |
-| `VFX_FormSwitch_Earth` | 切换地魂形态 |
-| `VFX_FormSwitch_Death` | 切换命魂形态 |
-| `VFX_Poison_Tick` | 中毒每秒跳动 |
-| `VFX_Burn_Tick` | 燃烧 DOT |
-| `VFX_DashTrail` | 冲刺残影 |
-| `VFX_WallSlide_Dust` | 蹬墙粉尘 |
-| `VFX_DownAttack_Land` | 下劈落地冲击 |
-
----
-
-## 5. Week 8:难度系统 + AudioMixer 快照 ✅ 完成(2026-05-10)
-
-**参考文档**:`11_AudioModule.md §3-5`、`19_DifficultyModule.md §4`
-
-> **⚠️ 架构说明**:Phase 2 音频工作是完善 Unity AudioMixer 快照和 BGM 管理;架构无 FMOD 依赖,**不需要 IAudioBackend 接口层**。Phase 1 实现的 AudioManager 无需重构。
-
-### 5.1 AudioMixer 快照系统
-
-**四个快照**(在 `Assets/Audio/MainMixer.mixer` 中已创建):
-
-| 快照 | 触发条件 | BGM 变化 |
-|------|---------|---------|
-| `Default` | 正常游戏 | 正常音量 |
-| `Paused` | PauseMenu 打开 | BGM 降低 + 低通滤波 |
-| `Dead` | 玩家死亡 | BGM 淡出 + 环境音压低 |
-| `BossFight` | Boss 战开始 | BGM 切换 + SFX 提升 |
-
-**AudioManager 快照 API 完善**(Week 8 补充到 Phase 1 的 `AudioManager.cs`):
-
-```csharp
-// 已在 Phase 1 AudioManager 骨架中预留,Week 8 正式实现:
-public void TransitionToSnapshot(string snapshotName, float transitionTime = 0.5f) // ⚠️ TransitionToSnapshot(架构 11 §2)
-{
- var snapshot = _mixer.FindSnapshot(snapshotName);
- snapshot?.TransitionTo(transitionTime);
-}
-```
-
-**GameManager 事件绑定**:
-- `EVT_PauseRequested` → `TransitionToSnapshot("Paused", 0.3f)`
-- `EVT_BossFightToggled`(true) → `TransitionToSnapshot("BossFight", 1.0f)` + BGM 切换
-- `EVT_PlayerDied` → `TransitionToSnapshot("Dead", 0.5f)`
-
-### 5.1.1 AudioEventSO
-
-**参考文档**:`11_AudioModule.md §5`
-
-```csharp
-// Assets/Scripts/Audio/AudioEventSO.cs
-// ⚠️ 字段全大写(Clips, VolumeMin/Max, PitchMin/Max),方法为 Play/PlayOneShot(架构 11 §5)
-[CreateAssetMenu(menuName = "Audio/AudioEvent")]
-public class AudioEventSO : ScriptableObject
-{
- public AudioClip[] Clips; // 随机挑选一个播放
- [Range(0f, 1f)]
- public float VolumeMin = 0.9f;
- [Range(0f, 1f)]
- public float VolumeMax = 1.0f;
- [Range(0.5f, 2f)]
- public float PitchMin = 0.95f;
- [Range(0.5f, 2f)]
- public float PitchMax = 1.05f;
- public AudioMixerGroup MixerGroup; // ⚠️ 路由到子混音组(如 SFX_Player),架构 11 §5
-
- public void Play(AudioSource source)
- {
- if (Clips == null || Clips.Length == 0) return;
- source.outputAudioMixerGroup = MixerGroup; // ⚠️ 架构 11 §5
- source.clip = Clips[Random.Range(0, Clips.Length)];
- source.volume = Random.Range(VolumeMin, VolumeMax);
- source.pitch = Random.Range(PitchMin, PitchMax);
- source.Play();
- }
-
- public void PlayOneShot(AudioSource source)
- {
- if (Clips == null || Clips.Length == 0) return;
- var clip = Clips[Random.Range(0, Clips.Length)];
- source.outputAudioMixerGroup = MixerGroup; // ⚠️ 架构 11 §5
- source.PlayOneShot(clip, Random.Range(VolumeMin, VolumeMax));
- }
-}
-// 资产路径:Assets/ScriptableObjects/Audio/
-// 命名规范:AUD_{Category}_{Name}.asset(例 AUD_Player_SwordSlash.asset)
-```
-
-### 5.1.2 GlobalSFXPlayer
-
-**参考文档**:`11_AudioModule.md §6`
-
-```csharp
-// Assets/Scripts/Audio/GlobalSFXPlayer.cs
-// ⚠️ 静态入口 Play(AudioEventSO, Vector2?);Singleton 为 _instance(非 Instance,架构 11 §6)
-public class GlobalSFXPlayer : MonoBehaviour
-{
- private static GlobalSFXPlayer _instance;
-
- [SerializeField] private AudioMixerGroup _sfxGroup;
- [SerializeField] private AudioSource _globalSFXSource;
-
- private void Awake()
- {
- if (_instance != null) { Destroy(gameObject); return; }
- _instance = this;
- }
-
- /// 在任意地方播放 SFX。worldPos != null 时使用对象池创建空间音源。
- public static void Play(AudioEventSO audioEvent, Vector2? worldPos = null)
- {
- if (worldPos.HasValue)
- {
- // 使用对象池获取临时 AudioSource GO,播放后自动归还
- // (Phase 2 简化实现:直接 PlaySFXAtPosition 即可)
- AudioManager.Instance.PlaySFXAtPosition(
- audioEvent.Clips[Random.Range(0, audioEvent.Clips.Length)],
- worldPos.Value,
- Random.Range(audioEvent.VolumeMin, audioEvent.VolumeMax));
- }
- else
- {
- audioEvent.Play(_instance._globalSFXSource);
- }
- }
-}
-```
-
-### 5.1.3 AudioConfigSO
-
-**参考文档**:`11_AudioModule.md §7`
-
-> **⚠️ Phase 1 的 BGMController 已引用 `_config.VictoryStingBGM` / `GetBossBGM()` / `GetZoneBGM()` 等,此处补全类定义(P2-8 任务)。**
-
-```csharp
-// Assets/Scripts/Audio/AudioConfigSO.cs — 程序集 BaseGames.Audio
-[CreateAssetMenu(menuName = "Audio/AudioConfig")]
-public class AudioConfigSO : ScriptableObject
-{
- [System.Serializable]
- public struct ZoneBGMEntry
- {
- public string ZoneId;
- public AudioClip BGMClip;
- public float FadeDuration;
- }
-
- [System.Serializable]
- public struct BossBGMEntry
- {
- public string BossId;
- public AudioClip BGMClip;
- }
-
- public ZoneBGMEntry[] ZoneBGMs;
- public BossBGMEntry[] BossBGMs;
- public AudioClip MainMenuBGM;
- public AudioClip GameOverSting; // 死亡时短音乐片段
- public AudioClip VictoryStingBGM; // Boss 击败后胜利音乐片段
- public float VictoryStingDuration = 4f; // 胜利音乐播放时长(秒)
-
- public AudioClip GetZoneBGM(string zoneId)
- {
- foreach (var e in ZoneBGMs)
- if (e.ZoneId == zoneId) return e.BGMClip;
- return null;
- }
-
- public AudioClip GetBossBGM(string bossId)
- {
- foreach (var e in BossBGMs)
- if (e.BossId == bossId) return e.BGMClip;
- return null;
- }
-}
-// 资产路径:Assets/ScriptableObjects/Audio/AUD_Config.asset
-```
-
-> **⚠️ Footstep + Underwater 音频**:架构 11 §9-10 定义了 `FootstepMaterial` enum、`FootstepAudioConfigSO`、`FootstepMaterialMarker` 和 `UnderwaterAudioController`;这两个系统与玩家游泳(Phase 3 LiquidZone)及地面脚步相关,留至 Phase 3 Week 11(SwimState 完整实现时)集成实现。
-
-
-
-按 `19_DifficultyModule.md §3-4` 实现:
-
-```csharp
-// Assets/Scripts/Core/Difficulty/DifficultyScalerSO.cs
-namespace BaseGames.Core
-{
- [CreateAssetMenu(menuName = "Core/DifficultyScaler")]
- public class DifficultyScalerSO : ScriptableObject
- {
- [Header("标识")]
- public DifficultyLevel level;
-
- [Header("玩家属性缩放")]
- [Range(0.1f, 3.0f)] public float PlayerMaxHPMultiplier = 1.0f;
- [Range(0.1f, 3.0f)] public float PlayerDamageMultiplier = 1.0f;
- [Range(0.0f, 2.0f)] public float InvincibilityFrameScale = 1.0f;
-
- [Header("敌人属性缩放")]
- [Range(0.1f, 3.0f)] public float EnemyDamageMultiplier = 1.0f;
- [Range(0.1f, 3.0f)] public float EnemyHPMultiplier = 1.0f;
- [Range(0.1f, 3.0f)] public float BossDamageMultiplier = 1.0f;
- [Range(0.1f, 3.0f)] public float BossHPMultiplier = 1.0f;
-
- [Header("商店价格")]
- [Range(0.5f, 2.0f)] public float ShopPriceMultiplier = 1.0f;
-
- [Header("游戏规则")]
- public bool CanReviveWithGeoLoss = true;
- public bool InstantDeathOnZeroHP = false; // SteelSoul 专用:HP 归零清档
- public bool GeoPenaltyOnDeath = true; // false = Easy 无 Geo 损失
-
- [Header("AI 行为(Behavior Designer 黑板变量名)")]
- public float EnemyAttackIntervalScale = 1.0f; // 攻击间隔倍率(Hard < 1 = 更频繁)
- public float EnemyAggroRangeScale = 1.0f; // 感知范围倍率
- [Range(0.3f, 2.0f)]
- public float EnemyReactionTimeScale = 1.0f; // ⚠️ 反应时间倍率(>1 = 更慢 = 更简单),架构 19 §3
- [Range(0, 5)]
- public int EnemyAggressionLevel = 2; // ⚠️ 0=被动 … 5=全力出击(影响 BT 决策权重),架构 19 §3
-
- [Header("掉落与奖励")]
- [Range(0.0f, 3.0f)]
- public float GeoDropMultiplier = 1.0f; // ⚠️ Geo 掉落量倍率(Easy 可给更多),架构 19 §3
- }
-}
-```
-
-```csharp
-// Assets/Scripts/Core/Difficulty/DifficultyManager.cs
-namespace BaseGames.Core
-{
- ///
- /// 全局难度管理器,挂在 Persistent 场景 [GameManagers] 下。
- /// 持有当前难度 ScalerSO,提供静态访问入口,广播难度变更事件。
- ///
- [DefaultExecutionOrder(-900)] // ⚠️ 架构 19_DifficultyModule §4:早于 PlayerStats/EnemyStats(-800) Awake 执行
- public class DifficultyManager : MonoBehaviour
- {
- // ── Inspector ────────────────────────────────────────
- [SerializeField] DifficultyScalerSO[] _allScalers; // 4 档资产
- [SerializeField] DifficultyChangedEventChannel _onDifficultyChanged;
-
- // ── Singleton ────────────────────────────────────────
- public static DifficultyManager Instance { get; private set; }
-
- // ── Runtime State ────────────────────────────────────
- public DifficultyLevel CurrentLevel { get; private set; } = DifficultyLevel.Normal;
- public DifficultyScalerSO CurrentScaler { get; private set; }
-
- void Awake()
- {
- Instance = this;
- // SaveData 加载后由 GameManager 调用 Apply,此处初始化为 Normal
- Apply(DifficultyLevel.Normal);
- }
-
- ///
- /// 应用难度。新游戏开始/读档时由 GameManager 调用。
- ///
- public void Apply(DifficultyLevel level)
- {
- // SteelSoul 不可降级
- if (CurrentLevel == DifficultyLevel.SteelSoul && level != DifficultyLevel.SteelSoul)
- {
- Debug.LogWarning("[DifficultyManager] SteelSoul 模式不可降级");
- return;
- }
- CurrentLevel = level;
- CurrentScaler = GetScaler(level);
- _onDifficultyChanged.Raise(CurrentScaler);
- }
-
- public DifficultyScalerSO GetScaler(DifficultyLevel level)
- {
- foreach (var s in _allScalers)
- if (s.level == level) return s;
- Debug.LogError($"[DifficultyManager] 找不到 {level} 的 ScalerSO");
- return _allScalers[0]; // fallback
- }
-
- ///
- /// 游戏进行中切换难度(仅允许 Easy ↔ Normal ↔ Hard,不可切换到 SteelSoul)。
- ///
- public void ChangeDifficulty(DifficultyLevel newLevel)
- {
- if (newLevel == DifficultyLevel.SteelSoul)
- {
- Debug.LogWarning("[DifficultyManager] 游戏进行中不可切换到 SteelSoul");
- return;
- }
- Apply(newLevel);
- }
- }
-}
-```
-
-**四份 DifficultyScalerSO 资产**(`Assets/ScriptableObjects/Core/Difficulty/`):
-- `Difficulty_Easy.asset`:玩家 HP ×1.5,敌人伤害 ×0.7,`GeoPenaltyOnDeath = false`,`GeoDropMultiplier = 1.2`,`EnemyReactionTimeScale = 1.4`,`EnemyAggressionLevel = 1`
-- `Difficulty_Normal.asset`:全部 ×1.0(默认),`EnemyAggressionLevel = 2`
-- `Difficulty_Hard.asset`:玩家 HP ×0.75,敌人伤害 ×1.3,敌人 HP ×1.2,`EnemyAttackIntervalScale = 0.8`,`EnemyReactionTimeScale = 0.7`,`EnemyAggressionLevel = 3`
-- `Difficulty_SteelSoul.asset`:玩家 HP ×1.2,敌人伤害 ×1.5,`InstantDeathOnZeroHP = true`,`EnemyReactionTimeScale = 0.6`,`EnemyAggressionLevel = 4`
-
-> **⚠️ 架构 19_DifficultyModule §3** 完整字段见 DifficultyScalerSO 上方的资产预设表,以上为关键差异项。
-
-**各系统注入**:
-- `PlayerStats` — 难度由 `DifficultyManager._onDifficultyChanged` 事件驱动(无 Initialize 参数)
-- `EnemyStats.Initialize(so)` — 仅传入 `EnemyStatsSO`(架构 07_EnemyModule §2);难度缩放由 `DifficultyManager.Apply` 广播 `DifficultyChangedEventChannel` 统一驱动
-
----
-
-## 6. Week 9:敌人扩展 + BossBase 骨架 ✅ 完成(2026-05-10)
-
-**参考文档**:`07_EnemyModule.md §3-5`
-
-### 6.1 新增敌人类型
-
-**RangedEnemy**(远程攻击):
-- 继承 `EnemyBase`
-- BD BT:在攻击距离内静止 → `BeginAttack(AttackType.Ranged)` → 生成 `Projectile`
-- `Projectile` 飞行中持有 `HitBox`,命中玩家 `HurtBox` 后归还对象池
-
-**FlyingEnemy**(飞行巡逻):
-- 继承 `EnemyBase`
-- 不使用 PathBerserker2d(空中无需 NavSurface)
-- 使用 `Vector2.MoveTowards` 直线追踪玩家
-- 碰撞攻击(`OnTriggerStay2D`)
-
-### 6.1.1 Projectile 弹射物系统
-
-**参考文档**:`06_CombatModule.md §7`
-
-```csharp
-// Assets/Scripts/Combat/ProjectileConfigSO.cs
-// ⚠️ 弹射物静态配置(数据与运行时实例分离),架构 06 §7
-[CreateAssetMenu(menuName = "Combat/ProjectileConfig")]
-public class ProjectileConfigSO : ScriptableObject
-{
- public DamageSourceSO DamageSource; // 伤害来源
- [Header("运动")]
- public float speed = 12f; // 飞行速度 (m/s)
- public float lifetime = 5f; // 生存时间 (s)
- public float launchAngleDeg = 45f; // ArcProjectile 发射角(度)
- public float gravityScale = 1f; // ArcProjectile 重力缩放
- public float homingStrength = 4f; // HomingProjectile 追踪角速度(弧度/秒)
- [Header("对象池")]
- public string poolKey; // AddressKeys 常量,用于 GlobalObjectPool(⚠️ 类名为 GlobalObjectPool,非 ObjectPoolManager,架构 13_AssetPoolModule §3)
- [Header("弹反")]
- public float parrySpeedMultiplier = 1.2f; // 弹反后速度倍率
- public float parryDamageMultiplier = 2.0f; // 弹反伤害倍率
-}
-
-// Assets/Scripts/Combat/Projectile.cs(基类)
-// ⚠️ 直线 LinearProjectile、抛物线 ArcProjectile、追踪 HomingProjectile 均继承此基类(架构 06 §7)
-[RequireComponent(typeof(Rigidbody2D), typeof(HitBox))]
-public class Projectile : MonoBehaviour
-{
- [HideInInspector] public DamageInfo DamageInfo; // 由发射方注入
- [HideInInspector] public Vector2 Direction; // 归一化发射方向
-
- protected ProjectileConfigSO _config;
- protected Rigidbody2D _rb;
- protected HitBox _hitBox;
- protected float _aliveTimer;
-
- public virtual void Initialize(ProjectileConfigSO config, DamageInfo damageInfo, Vector2 direction)
- {
- _config = config;
- DamageInfo = damageInfo;
- Direction = direction.normalized;
- _aliveTimer = 0f;
- _rb = GetComponent();
- _hitBox = GetComponent();
- OnInitialized();
- }
-
- protected virtual void OnInitialized() { }
-
- protected virtual void Update()
- {
- _aliveTimer += Time.deltaTime;
- if (_aliveTimer >= _config.lifetime) ReturnToPool();
- }
-
- protected void ReturnToPool()
- {
- gameObject.SetActive(false);
- GlobalObjectPool.Instance.Despawn(_config.poolKey, gameObject); // ⚠️ GlobalObjectPool(非 ObjectPoolManager),架构 13_AssetPoolModule §3
- }
-}
-
-// ── 抛物线弹射物(投石、毒液弹)────────────────────────────────────────────────
-// ⚠️ ArcProjectile 继承 Projectile(架构 06 §7)
-public class ArcProjectile : Projectile
-{
- protected override void OnInitialized()
- {
- float angle = _config.launchAngleDeg * Mathf.Deg2Rad;
- _rb.velocity = new Vector2(
- Direction.x * _config.speed * Mathf.Cos(angle),
- _config.speed * Mathf.Sin(angle)
- );
- _rb.gravityScale = _config.gravityScale;
- }
-}
-
-// ── 追踪弹射物(Boss 阶段特殊弹、追踪蜂群)────────────────────────────────────
-// ⚠️ HomingProjectile 通过 TransformEventChannelSO 注入目标(零耦合),架构 06 §7
-public class HomingProjectile : Projectile
-{
- [SerializeField] TransformEventChannelSO _onPlayerSpawned;
- Transform _target;
-
- void OnEnable() => _onPlayerSpawned.OnEventRaised += t => _target = t;
- void OnDisable() => _onPlayerSpawned.OnEventRaised -= t => _target = t;
-
- public void SetTarget(Transform t) => _target = t;
-
- protected override void OnInitialized() => _rb.velocity = Direction * _config.speed;
-
- protected override void Update()
- {
- base.Update();
- if (_target == null) return;
- Vector2 toTarget = ((Vector2)_target.position - (Vector2)transform.position).normalized;
- Vector2 newVel = Vector2.MoveTowards(
- _rb.velocity.normalized, toTarget,
- _config.homingStrength * Time.deltaTime) * _config.speed;
- _rb.velocity = newVel;
- }
-}
-
-// ── ProjectileManager(追踪弹辅助缓存,常驻 Persistent 场景)─────────────────
-// ⚠️ 架构 06 §7;在发射追踪弹时注入已缓存的玩家 Transform
-public class ProjectileManager : MonoBehaviour
-{
- [SerializeField] TransformEventChannelSO _onPlayerSpawned;
- Transform _playerTransform;
-
- void OnEnable() => _onPlayerSpawned.OnEventRaised += t => _playerTransform = t;
- void OnDisable() => _onPlayerSpawned.OnEventRaised -= t => _playerTransform = t;
-
- public Transform PlayerTransform => _playerTransform;
-
- public void LaunchHoming(HomingProjectile proj, Vector2 origin, Vector2 direction,
- ProjectileConfigSO config, DamageInfo damageInfo)
- {
- proj.Initialize(config, damageInfo, direction);
- proj.SetTarget(_playerTransform); // 直接注入缓存值
- }
-}
-```
-
-```csharp
-// ⚠️ 字段名以架构 07_EnemyModule §14 为准(AddressKey→ItemId,DropChance→BaseWeight,GeoRange→GuaranteedGeoMin/Max)
-[CreateAssetMenu(menuName = "Enemies/LootTable")]
-public class LootTableSO : ScriptableObject
-{
- [Serializable]
- public struct LootEntry
- {
- public string ItemId; // ⚠️ 非 AddressKey;"" 表示纯 Geo 掉落
- public int GeoAmount; // ItemId 为空时使用
- public float BaseWeight; // ⚠️ 非 DropChance;加权随机权重(相对值)
- public bool ScaleWithDifficulty; // true → Hard 难度时权重 × 1.5
- }
- public LootEntry[] Entries;
- public int GuaranteedGeoMin; // ⚠️ 非 GeoRange;死亡必掉最低 Geo
- public int GuaranteedGeoMax;
-}
-
-public static class LootResolver
-{
- public static void Resolve(LootTableSO table, Vector2 worldPosition)
- {
- if (table == null) return;
- // 1. 保底 Geo
- int geo = Random.Range(table.GuaranteedGeoMin, table.GuaranteedGeoMax + 1);
- if (geo > 0) CollectibleSpawner.SpawnGeo(worldPosition, geo);
- // 2. 按 BaseWeight 加权随机(Hard 难度时 ScaleWithDifficulty=true 的条目权重 × 1.5)
- float totalWeight = 0f;
- var difficulty = DifficultyManager.Instance.CurrentLevel;
- foreach (var entry in table.Entries)
- {
- float w = entry.BaseWeight;
- if (entry.ScaleWithDifficulty && difficulty >= DifficultyLevel.Hard) w *= 1.5f;
- totalWeight += w;
- }
- float roll = Random.Range(0f, totalWeight);
- float cumulative = 0f;
- foreach (var entry in table.Entries)
- {
- float w = entry.BaseWeight;
- if (entry.ScaleWithDifficulty && difficulty >= DifficultyLevel.Hard) w *= 1.5f;
- cumulative += w;
- if (roll <= cumulative)
- {
- if (!string.IsNullOrEmpty(entry.ItemId))
- CollectibleSpawner.SpawnItem(worldPosition, entry.ItemId);
- break;
- }
- }
- }
-}
-```
-
-### 6.3 BossBase 骨架
-
-```csharp
-// Assets/Scripts/Enemies/Boss/BossBase.cs
-public class BossBase : EnemyBase
-{
- [SerializeField] protected BossOrchestrator _orchestrator; // ⚠️ 架构 07_EnemyModule §10
- [SerializeField] protected TelegraphSystem _telegraph; // ⚠️ 架构 07_EnemyModule §10
-
- [Header("Boss 识别")]
- [SerializeField] private string _bossId; // ⚠️ 用于事件 payload(无 BossConfigSO)
-
- [Header("Event Channels")]
- [SerializeField] private BoolEventChannelSO _onBossFightEnded; // ⚠️ Raise 方:BossBase.Die()(架构 02 §4)
- [SerializeField] private BossPhaseEventChannelSO _onBossPhaseChanged; // ⚠️ Raise 方:BossBase.EnterPhase()
-
- public string BossId => _bossId;
- protected int _currentPhase = 0;
-
- // Boss 专属接口(由 BD_EnterPhase 任务调用)
- // ⚠️ 方法名为 EnterPhase,非 TransitionToPhase(架构 07_EnemyModule §9)
- public virtual void EnterPhase(int phase)
- {
- _currentPhase = phase;
- // 1. 短暂无敌
- // 2. 播放 Phase 过渡演出动画
- _onBossPhaseChanged.Raise(new BossPhaseEvent { BossId = _bossId, Phase = phase });
- }
-
- // HP 阈值检查(BD_IsHPBelow 任务使用,无 CheckPhaseTransition MonoBehaviour 方法)
- public bool IsHPBelow(float ratio) => (float)_stats.CurrentHP / _stats.MaxHP < ratio;
-
- public override void Die()
- {
- base.Die();
- _onBossFightEnded.Raise(true); // 广播胜利
- // 触发死亡过场(EventChain)
- }
-}
-```
-
-### 6.3.1 CameraStateController 完整实现(补充 Boss/Death 相机)
-
-> **参考文档**:`17_CameraModule.md §3`
-> Phase 1 仅创建了基础骨架(A/B 双机 + SwitchRoom + RegisterRoomCamera + TriggerImpulse)。Phase 2 添加 BossBase 时须同步完善 `CameraStateController`,加入以下字段和处理方法:
-
-```csharp
-// 补充到 CameraStateController(在 Phase 1 骨架基础上追加)
-[Header("特殊状态相机(⚠️ 架构 17_CameraModule §3 要求)")]
-[SerializeField] CinemachineCamera _vcamBoss; // Priority 30(Boss 战激活)
-[SerializeField] CinemachineCamera _vcamDeath; // Priority 50(死亡时激活,1.0s EaseIn)
-[SerializeField] CinemachineCamera _vcamCutscene; // Priority 40(Phase 4 Cutscene 时激活)
-
-[Header("Phase 2 事件频道")]
-[SerializeField] BoolEventChannelSO _onBossFightToggled; // true=开始 false=结束(⚠️ 与 BGMController 共用同一 SO)
-[SerializeField] VoidEventChannelSO _onPlayerDied; // 死亡相机接管
-[SerializeField] VoidEventChannelSO _onPlayerRespawned; // 死亡相机释放
-
-// 补充事件订阅(在 OnEnable/OnDisable 中追加)
-_onBossFightToggled.OnEventRaised += OnBossFightToggled;
-_onPlayerDied.OnEventRaised += OnPlayerDied;
-_onPlayerRespawned.OnEventRaised += OnPlayerRespawned;
-
-// 补充处理方法
-private void OnBossFightToggled(bool started)
-{
- _vcamBoss.Priority = started ? 30 : 0;
- if (started) _brain.DefaultBlend =
- new CinemachineBlendDefinition(CinemachineBlendDefinition.Style.EaseIn, 0.8f);
- else _brain.DefaultBlend = new CinemachineBlendDefinition(
- _defaultConfig.DefaultBlendStyle, _defaultConfig.DefaultBlendDuration);
-}
-
-private void OnPlayerDied()
-{
- _vcamDeath.Priority = 50; // 死亡相机接管,1.0s EaseIn
- _brain.DefaultBlend =
- new CinemachineBlendDefinition(CinemachineBlendDefinition.Style.EaseIn, 1.0f);
-}
-
-private void OnPlayerRespawned()
-{
- _vcamDeath.Priority = 0;
- _brain.DefaultBlend = new CinemachineBlendDefinition(
- _defaultConfig.DefaultBlendStyle, _defaultConfig.DefaultBlendDuration);
-}
-```
-
-> **Persistent 场景结构更新**:在 `[CameraRig]` 下新增 `[SpecialCameras]` 子组,包含 `VCam_Boss`(Priority 0)、`VCam_Death`(Priority 0)、`VCam_Cutscene`(Priority 0)三个 CinemachineCamera。`VCam_Cutscene` Phase 4 才正式使用,但 Phase 2 可先创建 GO 留空。
-
----
-
-### 6.4 DeathShade(死亡遗骸)
-
-**文件**:`Assets/Scripts/World/DeathShade.cs`(架构 07_EnemyModule §12)
-
-> 玩家死亡时由 `GameManager.HandlePlayerDied()` 在最后位置生成;与之交互可回收玩家死亡时持有的 Geo。
-
-```csharp
-// ⚠️ 完整实现以架构 07_EnemyModule §12 为准
-// ⚠️ IInteractable 定义在 BaseGames.World 命名空间(Architecture 08 §7 / 14 §1)
-public class DeathShade : MonoBehaviour, IInteractable
-{
- [Header("Event Channel")]
- [SerializeField] private StringEventChannelSO _onGeoRecovered; // ⚠️ payload: sceneId(通知存档)
-
- private int _storedGeo;
- private string _sceneId;
- private Vector2 _worldPosition;
-
- public bool CanInteract => true;
- public string InteractPrompt => "回收遗骸";
-
- /// 由 GameManager(或 DeathShadeManager)在玩家死亡时调用。
- public void Initialize(int geo, string sceneId, Vector2 pos)
- {
- _storedGeo = geo;
- _sceneId = sceneId;
- _worldPosition = pos;
- transform.position = pos;
- }
-
- public void Interact(Transform player)
- {
- // ⚠️ PlayerController 无 Instance(Architecture 05 §2);通过 player 参数获取
- var pc = player.GetComponent();
- if (pc != null) pc.Stats.AddGeo(_storedGeo);
- _onGeoRecovered.Raise(_sceneId);
- Destroy(gameObject); // 或归还对象池
- }
-
- public void OnPlayerEnterRange(Transform player) { }
- public void OnPlayerExitRange() { }
-
- // 存档集成(世界状态持久化,架构 07_EnemyModule §12)
- public DeathShadeSaveData GetSaveData();
- public void LoadSaveData(DeathShadeSaveData data);
-}
-```
-
-**GameManager 集成**(Plan 02 §4.4 `HandlePlayerDied` 补充):
-- `HandlePlayerDied()` 在切入 `GameState.Dead` 后,通过 `[SerializeField] DeathShade _deadShadePrefab` + `Instantiate` 在玩家当前位置生成 DeathShade,并调用 `Initialize(currentGeo, currentSceneName, playerPos)`
-- 玩家死后 Geo 清零;新死亡覆盖旧遗骸(旧 DeathShade 自动销毁)
-
----
-
-## 7. 完成标准检查清单
-
-> **图例**:`☑` = 代码实现完成(待 Unity 运行时验证)|`□` = 尚未实现
-
-```
-☑ 冲刺:DashState 实现(施加冲刺力 + 无敌帧)——待 Unity 内验证落地切换 Idle
-☑ 蹬墙:WallSlideState + WallJumpState 实现——待 Unity 内验证墙面检测与弹跳方向
-☑ 下劈:DownAttackState 实现(向下速度 + 着地切换 Idle)——待 Unity 内验证踩踏弹跳
-☑ 上劈:UpAttackState 实现——待 Unity 内验证命中后进入 FallState
-☑ ParryState 开启弹反窗口——待 ParrySystem 完整实现后验证成功/失败流程
-☑ ShieldComponent 护盾吸收逻辑实现——待 HurtBox 护盾管道接入 + UI 耐久条
-□ 状态效果:Poison 每秒掉血 + StatusEffectManager 正确 Tick + 到期自动移除
-□ AnimationEventType:攻击动画 HitBox 激活时机与动画帧完全同步
-□ EventConfigEditor:AnimationEventConfigSO Inspector 时间轴可视化正常渲染,Clip 漂移警告触发(偏差>5帧)
-☑ FormController 三形态切换代码实现——待 Unity 内验证调色板切换与武器对应
-☑ AudioMixer 快照 API(TransitionToSnapshot)实现——待 Unity 内配置 MainMixer 快照并验证过渡
-☑ AudioEventSO + GlobalSFXPlayer 代码实现——待 Unity 内填充音效资产并连接 SFX 钩子
-☑ DifficultyManager + DifficultyScalerSO 代码实现——待 Unity 内创建 4 份 SO 资产并验证 Hard 模式缩放
-☑ BoolEventChannelSO 实现——供 BossBase._onBossFightEnded 使用
-☑ RangedEnemy:SpawnProjectile 重写实现——待 Unity 内挂载 ProjectileConfigSO 并验证 Projectile 命中
-☑ FlyingEnemy:Rigidbody2D MoveTowards 追击 + 接触伤害实现——待 Unity 内验证导航行为
-☑ BossBase:EnterPhase + IsHPBelow + Die 广播实现——待 Unity 内验证 Phase 切换演出触发
-☑ LootTableSO + LootResolver:加权随机掉落 + 难度缩放实现——待 Unity 内配置掉落表并验证
-☑ ProjectileConfigSO + Projectile(Linear/Arc/Homing/Parryable) + ProjectileManager 实现——待对象池预热后验证
-☑ DeathShade:IInteractable + Geo 回收事件实现——待 Unity 内验证交互流程
-□ Console 无 Error(Unity 编辑器内编译验证)
-```
-
-### Week 5 已完成实现(2026-05-09)
-
-| 文件 | 状态 | 说明 |
-|------|------|------|
-| `FormType.cs` | ✅ | Sky/Earth/Death 枚举 |
-| `FormSO.cs` | ✅ | 单形态数据 SO |
-| `FormConfigSO.cs` | ✅ | 三形态统一容器 |
-| `FormController.cs` | ✅ | 形态切换 + C# 事件 + SO 事件频道 |
-| `WeaponSO.cs` | ✅ | 完整扩展:6 方向动画片段 + DamageSource + VFX |
-| `WeaponManager.cs` | ✅ | FormController 联动 + 护符 Override 字典 |
-| `PlayerCombat.cs` | ✅ | HitBox 四向激活 + SetComboSegmentSource + Soul 增益 |
-| `PlayerStats.cs` | ✅ | AddSoul 别名 + 现有完整 API |
-| `PlayerMovement.cs` | ✅ | Dash / WallSlide / WallJump 方法 |
-| `PlayerMovementConfigSO.cs` | ✅ | 补充 DefaultGravityScale = 3f |
-| `PlayerWallDetector.cs` | ✅ | 独立组件,双射线每侧检测 |
-| `HitBox.cs` | ✅ | SetDamageSource 方法 |
-| `DashState.cs` | ✅ | 无敌帧 + 重力置零 + 冷却 |
-| `AerialDashState.cs` | ✅ | 空中冲刺计数 + 落地重置 |
-| `WallSlideState.cs` | ✅ | 下滑限速 + 蹬墙跳触发 |
-| `WallJumpState.cs` | ✅ | 蹬墙跳 + 输入锁定 |
-| `AirAttackState.cs` | ✅ | 空中攻击 + HitBoxAir |
-| `DownAttackState.cs` | ✅ | 下劈冲击 + 着地检测 |
-| `UpAttackState.cs` | ✅ | 上劈 + 结束分流 |
-| `HurtState.cs` | ✅ | 受击 + 击退 + 无敌帧 |
-| `DeadState.cs` | ✅ | 物理冻结 + HurtBox 关闭 |
-| `SpringState.cs` | ✅ | 灵泉治疗动画 |
-| `ParryState.cs` | ✅ | 弹反窗口开关 |
-| `PlayerController.cs` | ✅ | Phase 2 完整扩展(IPoiseSource + 11 状态 + TakeDamage 路由)|
-| `ShieldComponent.cs` | ✅ | Phase 2 完整实现(吸收 + 再生 + 破盾事件)|
-
-### Week 8 已完成实现(2026-05-10)
-
-| 文件 | 状态 | 说明 |
-|------|------|------|
-| `BoolEventChannelSO.cs` | ✅ | `BaseEventChannelSO` 频道,BossBase 依赖 |
-| `DifficultyScalerSO.cs` | ✅ | 难度缩放参数 SO,含 AI/玩家/敌人/经济多维度字段 |
-| `DifficultyManager.cs` | ✅ | 单例,`DefaultExecutionOrder(-900)`,SteelSoul 不可降级 |
-| `AudioEventSO.cs` | ✅ | 随机音效 SO,支持 Play / PlayOneShot |
-| `GlobalSFXPlayer.cs` | ✅ | 静态 SFX 入口,支持 2D 和世界坐标 3D 播放 |
-| `EnemyBase._playerTransform` | ✅ | 改为 `protected`,子类(RangedEnemy/FlyingEnemy)可访问 |
-| `BaseGames.Combat.asmdef` | ✅ | 新增 `BaseGames.Core` 引用,供 GlobalObjectPool 使用 |
-
-### Week 9 已完成实现(2026-05-10)
-
-| 文件 | 状态 | 说明 |
-|------|------|------|
-| `ProjectileConfigSO.cs` | ✅ | 抛射物配置 SO(速度/生命周期/弹反倍率/对象池键) |
-| `Projectile.cs` | ✅ | 抽象基类,Initialize + ReturnToPool,依赖 PooledObject |
-| `LinearProjectile.cs` | ✅ | 直线飞行,固定速度 |
-| `ArcProjectile.cs` | ✅ | 抛物线,LaunchAngleDeg + GravityScale |
-| `HomingProjectile.cs` | ✅ | 追踪弹,每帧向目标转向 |
-| `ParryableProjectile.cs` | ✅ | 可弹反,手动 OnTriggerEnter2D 检测 ParrySystem |
-| `ProjectileManager.cs` | ✅ | 单例,缓存 PlayerTransform,辅助 Homing 注入目标 |
-| `RangedEnemy.cs` | ✅ | 重写 SpawnProjectile,GlobalObjectPool.Spawn + Initialize |
-| `FlyingEnemy.cs` | ✅ | Rigidbody2D MoveTowards 追击 + OnTriggerStay2D 接触伤害 |
-| `BossBase.cs` | ✅ | EnterPhase(int) + IsHPBelow(ratio) + Die 广播战斗结束 |
-| `LootTableSO.cs` | ✅ | 战利品表 SO,LootEntry 含 BaseWeight + ScaleWithDifficulty |
-| `LootResolver.cs` | ✅ | 加权随机掉落 + DifficultyManager 缩放保底 Geo |
-| `DeathShade.cs` | ✅ | IInteractable,IntEventChannelSO 零耦合返还 Geo |
-
-### P2-7 补充实现(2026-05-12)
-
-| 文件 | 状态 | 说明 |
-|------|------|------|
-| `ILOSRequester.cs` | ✅ | 视线检测请求接口(`LOSOrigin`/`LOSTarget`/`LOSBlockingMask`/`ReceiveLOSResult`);命名空间 `BaseGames.Enemies.AI` |
-| `BatchLOSSystem.cs` | ✅ | 批量视线检测,`[DefaultExecutionOrder(-200)]`,round-robin Raycast2D(最多 8/帧),`Register`/`Unregister` |
-| `TelegraphSystem.cs` | ✅ | Boss 预警系统;`ShowTelegraph(vfxKey, duration, pos)` Coroutine;`GlobalObjectPool.Instance.Spawn`;`PooledObject.ReturnToPool()` |
-| `EnemyQuotaManager.cs` | ✅ | BT 配额管理;最多 12 棵 BT 启用;每 10 帧按玩家距离排序重排 |
-| `EnemyBase.cs` | ✅ | 扩展:实现 `ILOSRequester`;新增 `Nav`/`Movement`/`Stats`/`Animancer`/`AnimConfig`/`BehaviorTree`(`#if GRAPH_DESIGNER`)属性;`SetAggroTickRate`;`JumpTo` |
-| `EnemyStatsSO.cs` | ✅ | 追加 `EyeOffset`(`Vector2(0,0.8f)`)+ `LOSBlockingMask`(LayerMask)字段 |
-| `EnemyMovement.cs` | ✅ | 追加 `JumpToTarget(Vector2)` 抛物线跳跃方法 |
-| `EnemyAnimationConfigSO.cs` | ✅ | 追加 `GetClipByName(string)` 方法(switch 路由 Idle/Walk/Run/Attack/Hurt/Death) |
-| `BD_TeleportTo.cs` | ✅ | Action;单帧 `transform.position = Target.Value`,返回 Success |
-| `BD_SummonMinions.cs` | ✅ | Action;`GlobalObjectPool.Instance.Spawn(key, pos, Quaternion.identity)`;随机角度偏移 |
-| `BD_EnterPhase.cs` | ✅ | Action;`GetComponent()?.EnterPhase(PhaseIndex.Value)` |
-| `BD_IsPlayerVisible.cs` | ✅ | Conditional;调用 `_enemy.IsPlayerVisible()` |
-| `BD_CanAttack.cs` | ✅ | Conditional;调用 `_enemy.CanAttack()` |
-| `BD_IsHPBelow.cs` | ✅ | Conditional;SharedFloat 阈值(0–1);`CurrentHP/MaxHP <= threshold` |
-| `BD_IsGrounded.cs` | ✅ | Conditional;`_enemy.Movement?.IsGrounded` |
-| `BD_IsNearEdge.cs` | ✅ | Conditional;`_enemy.Nav?.IsNearEdge()` |
-| `BD_IsStateMatch.cs` | ✅ | Conditional;SharedInt TargetState;`(int)_enemy.CurrentState == TargetState.Value` |
-
-### P2-8 补充实现(2026-05-13)
-
-| 文件 | 状态 | 说明 |
-|------|------|------|
-| `ShieldComponent.cs` | ✅ | P2-2 VFX 钩子:新增 `_onShieldBrokenChannel`(VoidEventChannelSO)和 `_onShieldRestoredChannel`(VoidEventChannelSO);破碎时 Raise broken,破碎惩罚结束/FullRecharge 时 Raise restored |
-| `PostProcessManager.cs` | ✅ | P2-5 VFX:后处理 Volume 分区管理器;Coroutine 渐变 Boss/Death/Victory Volume 权重;订阅 BossFightStarted/Ended、PlayerDied/Respawned、BossDefeated 事件频道 |
-| `RegionLightController.cs` | ✅ | P2-5 VFX:区域灯光切换;订阅 `StringEventChannelSO _onRegionEntered`;Coroutine 渐变 Global Light 2D 颜色和强度 |
-| `RegionLightCatalogSO.cs` | ✅ | P2-5 VFX:与 RegionLightController 同文件;regionId → Color + Intensity 映射 |
-| `PaletteSwapSystem.cs` | ✅ | P2-5 VFX:形态调色板切换;`ApplyPalette(FormType)` 通过 MaterialPropertyBlock 设置 `_PaletteTex` |
-| `PaletteCatalogSO.cs` | ✅ | P2-5 VFX:与 PaletteSwapSystem 同文件;FormType → Texture2D(LUT 1D 256×1 px)映射 |
-| `BaseGames.VFX.asmdef` | ✅ | 新增 `BaseGames.Player` 引用(PaletteSwapSystem 需要 FormType) |
-| `CombatSFXController.cs` | ✅ | P2-8 SFX 钩子:所有字段由 `AudioClip` 升级为 `AudioEventSO`;播放改为 `GlobalSFXPlayer.Play(sfx, pos)`,支持随机音量/音调/多片段 |
diff --git a/Docs/Plan/04_Phase3_WorldProgression.md b/Docs/Plan/04_Phase3_WorldProgression.md
deleted file mode 100644
index 244bc7c..0000000
--- a/Docs/Plan/04_Phase3_WorldProgression.md
+++ /dev/null
@@ -1,3736 +0,0 @@
-# Phase 3 · 世界与进程系统
-
-> **周期**:4–5 周(Week 10–14)
-> **前置条件**:Phase 2 全部完成标准通过
-> **核心目标**:完整的世界互动(房间切换/可破坏物/机关/移动平台)、液态谜题、进程系统(护符/工具/技能树)、任务系统、地图/商店、存档迁移
-> **产出物**:能进行多房间探索;护符装备生效;任务系统可完成至少 2 个任务;商店可购买道具;液态谜题可通关
-
----
-
-## 目录
-
-1. [实施顺序总览](#1-实施顺序总览)
-2. [Week 10:世界互动基础组件](#2-week-10世界互动基础组件)
-3. [Week 11:液态谜题模块](#3-week-11液态谜题模块)
-4. [Week 12:进程模块(护符/工具/技能)](#4-week-12进程模块护符工具技能)
-5. [Week 13:任务与挑战房间](#5-week-13任务与挑战房间)
-6. [Week 14:地图/商店/存档迁移](#6-week-14地图商店存档迁移)
-7. [完成标准检查清单](#7-完成标准检查清单)
-
----
-
-## 1. 实施顺序总览
-
-```
-Week 10: IInteractable + InteractableDetector
- ↓
- RoomTransition + PlayerSpawnPoint + RoomController
- ↓
- HazardZone + Collectible(Geo/道具)
- ↓
- DestructibleTile + MovingPlatform + CrumblePlatform
- ↓
- DirectionalDestructible + DirectionalInteractable + SkillInteractable
- ↓
- WorldStateRegistry(房间状态持久化)
-
-Week 11: LiquidZone + LiquidPuzzleController + LiquidFlowSimulator(骨架)
- ↓
- SwimState(玩家游泳态完整)
- ↓
- 液态谜题机关(Valve/Pump/Drain 三件套)
-
-Week 12: AbilityType 枚举 + AbilityGate
- ↓
- CharmSO + ICharmEffect + EquipmentManager
- ↓
- ToolSO + FormSkillSO + SkillManager
- ↓
- SkillModifierRegistry
-
-Week 13: QuestSO + QuestObjectiveSO + RewardSO + QuestManager
- ↓
- QuestGiver(扩展 InteractableNPC)
- ↓
- ChallengeRoomSO + ChallengeRoomManager + ChallengeRoomTrigger
-
-Week 14: MapModule(Fog of War + 房间探索记录 + 传送点)
- ↓
- ShopController + ShopInventorySO + ShopItemSO
- ↓
- SaveData 迁移(版本号 + JsonExtensionData 降级兜底)
-```
-
----
-
-## 2. Week 10:世界互动基础组件 ✅ 完成(2026-05-10)
-
-**参考文档**:`08_WorldModule.md`
-
-### 2.1 IInteractable + InteractableDetector
-
-```csharp
-// Assets/Scripts/World/IInteractable.cs // ⚠️ 路径为 World/(架构 08_WorldModule §7 / 14_NarrativeModule §1)
-// 命名空间:namespace BaseGames.World;Dialogue 程序集通过 asmdef 引用 BaseGames.World
-// ⚠️ 实现方如需 PlayerController,通过 player.GetComponent() 获取(PlayerController 无 Instance,Architecture 05 §2)
-namespace BaseGames.World
-{
- public interface IInteractable
- {
- bool CanInteract { get; }
- string InteractPrompt { get; } // UI 提示文本(property)
- void Interact(Transform player); // 传入玩家 Transform
- void OnPlayerEnterRange(Transform player); // 进入检测范围
- void OnPlayerExitRange(); // 离开检测范围
- }
-}
-
-// Assets/Scripts/World/InteractableDetector.cs
-// 挂在 Player 上,检测附近可交互物,管理 UI 提示
-public class InteractableDetector : MonoBehaviour
-{
- [SerializeField] private float _detectRadius = 1.5f; // ⚠️ _detectRadius(非 _detectionRadius),默认 1.5f(架构 08 §8)
- [SerializeField] private LayerMask _interactableLayer;
- [SerializeField] private InputReaderSO _inputReader; // ⚠️ _inputReader(非 _input)(架构 08 §8)
- [SerializeField] private StringEventChannelSO _onShowInteractPrompt; // ⚠️ StringEventChannelSO(架构 08 §8)
- [SerializeField] private VoidEventChannelSO _onHideInteractPrompt; // ⚠️ VoidEventChannelSO(架构 08 §8)
-
- private IInteractable _nearest;
- private IInteractable _previousNearest;
-
- private void OnEnable() => _inputReader.InteractEvent += TryInteract;
- private void OnDisable() => _inputReader.InteractEvent -= TryInteract;
-
- private void Update()
- {
- // OverlapCircleAll → 找最近 IInteractable → 检测变化后通过事件频道显示/隐藏提示 UI
- var hits = Physics2D.OverlapCircleAll(transform.position, _detectRadius, _interactableLayer);
- _nearest = FindNearest(hits);
- if (_nearest != _previousNearest)
- {
- if (_previousNearest != null) { _previousNearest.OnPlayerExitRange(); _onHideInteractPrompt.Raise(); }
- if (_nearest != null) { _nearest.OnPlayerEnterRange(transform); _onShowInteractPrompt.Raise(_nearest.InteractPrompt); }
- _previousNearest = _nearest;
- }
- }
-
- private void TryInteract()
- {
- _nearest?.Interact(transform);
- }
-}
-```
-
-### 2.2 RoomTransition + RoomController
-
-**RoomTransition** 按 `08_WorldModule.md §2` 实现(Phase 1 已有 SavePoint 骨架)。
-
-**RoomController**:
-
-```csharp
-// Assets/Scripts/World/RoomController.cs
-// 挂在每个房间场景的 [RoomRoot] 上
-public class RoomController : MonoBehaviour
-{
- [SerializeField] private string _roomId;
- [SerializeField] private PlayerSpawnPoint[] _spawnPoints;
- // ⚠️ SwitchRoom(RoomCameraData) 以架构 17_CameraModule §3 为准,传入 RoomCameraData 而非裸 PolygonCollider2D
- [SerializeField] private Collider2D _cameraBounds; // Cinemachine Confiner
- [SerializeField] private Vector3 _cameraOffset;
- [SerializeField] private CameraBlendProfileSO _blendProfile;
-
- private void Start()
- {
- // 通知 CameraStateController 更新 Confiner(⚠️ 方法名为 SwitchRoom,参数为 RoomCameraData)
- CameraStateController.Instance.SwitchRoom(new RoomCameraData
- {
- ConfinerCollider = _cameraBounds,
- CameraOffset = _cameraOffset,
- BlendProfile = _blendProfile
- });
-
- // WorldStateRegistry 已在 LoadAsync 中通过 LoadSaveData(data.World) 全量恢复;
- // 各 Collectible/DestructibleTile/SavePoint 在自身 Start/OnEnable 中查询 Instance 获取状态
- }
-
- // 找到 entryTransitionId 对应的 PlayerSpawnPoint,返回出生位置
- public PlayerSpawnPoint GetSpawnPoint(string transitionId);
-}
-```
-
-### 2.3 Collectible(Geo + 道具)
-
-```csharp
-// Assets/Scripts/World/Collectible.cs
-// ⚠️ 枚举值与架构 08_WorldModule §5 对齐:Geo / Item / HPOrb
-public class Collectible : MonoBehaviour
-{
- [Header("Config")]
- [SerializeField] private CollectibleType _type;
- [SerializeField] private int _geoAmount; // type = Geo 时
- [SerializeField] private string _itemId; // type = Item 时
- [SerializeField] private bool _isPersistent; // false = 敌人掉落; true = 固定位置(存档)
-
- [Header("Physics")]
- [SerializeField] private float _bounceForce = 5f;
-
- [Header("Event Channel")]
- [SerializeField] private StringEventChannelSO _onCollectiblePickup;
-
- private string _collectibleId; // 持久化 Collectible 存档唯一 ID
-
- private void OnTriggerEnter2D(Collider2D other)
- {
- if (!other.CompareTag("Player")) return;
-
- var player = other.GetComponentInParent();
- if (player == null) return;
-
- switch (_type)
- {
- case CollectibleType.Geo:
- player.Stats.AddGeo(_geoAmount);
- break;
- case CollectibleType.Item:
- _onCollectiblePickup.Raise(_itemId);
- break;
- case CollectibleType.HPOrb:
- // 恢复少量 HP(具体值由 PlayerStats 决定)
- player.Stats.RestoreHP(1);
- break;
- }
-
- if (_isPersistent)
- _onCollectiblePickup.Raise(_collectibleId); // 存档标记
-
- Despawn();
- }
-
- private void Despawn(); // 归还对象池
-
- // 敌人死亡时生成 Geo Collectible(由 EnemyBase 调用)
- public static void SpawnGeo(Vector2 position, int amount, GlobalObjectPool pool);
-}
-```
-
-### 2.4 HazardZone
-
-```csharp
-// Assets/Scripts/World/HazardZone.cs
-// 即死/高伤害区域(深坑、岩浆等)—— 与架构 08_WorldModule §4 对齐
-[RequireComponent(typeof(Collider2D))]
-public class HazardZone : MonoBehaviour
-{
- // ⚠️ RespawnType 枚举(架构 08_WorldModule §4)
- public enum RespawnType { AtLastSavePoint, AtRoomEntry }
-
- [SerializeField] private bool _isInstantKill = true;
- [SerializeField] private int _damage = 9999;
- [SerializeField] private RespawnType _respawnType = RespawnType.AtLastSavePoint; // ⚠️ 架构 08 §4
-
- private void OnTriggerEnter2D(Collider2D other)
- {
- if (!other.CompareTag("Player")) return;
-
- var stats = other.GetComponentInParent();
- if (stats == null) return;
-
- if (_isInstantKill)
- stats.TakeDamage(stats.MaxHP * 2); // 确保即死
- else
- stats.TakeDamage(_damage);
- }
-}
-```
-
-### 2.5 DestructibleTile + MovingPlatform + CrumblePlatform
-
-**DestructibleTile**(可破坏瓦片):
-
-```csharp
-// 实现 IDamageable,被攻击击中时破碎
-// 从 TilemapCollider2D 移除对应 Tile,播放 VFX,在 WorldStateRegistry 记录状态
-// ⚠️ 字段名与架构 08_WorldModule §10 对齐:_maxHP / _destructedId
-public class DestructibleTile : MonoBehaviour, IDamageable
-{
- [SerializeField] private int _maxHP = 1;
- [SerializeField] private string _destructedId; // 存档唯一 ID
- // ⚠️ WorldStateRegistry 为 ScriptableObject(架构 14_NarrativeModule §8),不使用静态 Instance;
- // 通过 [SerializeField] 注入 SO 引用
- [SerializeField] private WorldStateRegistry _worldState;
-
- private bool _isDestroyed = false;
-
- public bool IsInvincible => _isDestroyed;
- public int Defense => 0;
-
- public void TakeDamage(DamageInfo info)
- {
- if (_isDestroyed) return;
- if (!CheckDestroyCondition(info)) return; // 子类可覆盖(DirectionalDestructible 方向校验)
- _isDestroyed = true;
- Destroy();
- }
-
- // ⚠️ virtual,DirectionalDestructible 覆盖此方法做方向校验(架构 08_WorldModule §12)
- protected virtual bool CheckDestroyCondition(DamageInfo info) => true;
-
- private void Destroy()
- {
- // 清除 Tilemap 中对应格子
- // 播放 VFX
- _worldState?.MarkDestroyed(_destructedId); // ⚠️ SO 注入,非 WorldStateRegistry.Instance
- gameObject.SetActive(false);
- }
-}
-```
-
-**MovingPlatform**:
-
-```csharp
-// 动态移动平台:Kinematic Rigidbody2D,乘客自动跟随(Passenger Pattern)
-// ⚠️ 与架构 08_WorldModule §11 完全对齐:MoveType 枚举 / _activationChannel / _passengerSensor
-[RequireComponent(typeof(Rigidbody2D))]
-public class MovingPlatform : MonoBehaviour
-{
- public enum MoveType { LinearAB, WayPoints, TriggeredLinear }
-
- [Header("移动配置")]
- [SerializeField] private MoveType _moveType = MoveType.LinearAB;
- [SerializeField] private Transform[] _wayPoints; // LinearAB 仅用 [0][1]
- [SerializeField] private float _speed = 3f; // u/s
- [SerializeField] private float _waitAtEndpoint = 0.5f; // 端点停留秒数
-
- [Header("TriggeredLinear 模式")]
- [SerializeField] private VoidEventChannelSO _activationChannel; // 收到信号后单程运动
-
- [Header("乘客检测")]
- [SerializeField] private BoxCollider2D _passengerSensor; // IsTrigger,仅用于检测
-
- private Rigidbody2D _rb;
- private List _passengers = new();
- private int _waypointIndex;
- private bool _movingForward = true;
- private bool _triggered;
-
- private void Awake() => _rb = GetComponent();
-
- private void FixedUpdate()
- {
- if (_moveType == MoveType.TriggeredLinear && !_triggered) return;
- MoveTowardsNextWaypoint();
- }
-
- private void MoveTowardsNextWaypoint()
- {
- var target = _wayPoints[_waypointIndex].position;
- var next = Vector2.MoveTowards(_rb.position, (Vector2)target, _speed * Time.fixedDeltaTime);
- _rb.MovePosition(next);
-
- if (Vector2.Distance(_rb.position, target) < 0.02f)
- StartCoroutine(WaitAndAdvance());
- }
-
- private IEnumerator WaitAndAdvance()
- {
- yield return new WaitForSeconds(_waitAtEndpoint);
- AdvanceWaypoint();
- }
-
- private void AdvanceWaypoint()
- {
- if (_moveType == MoveType.TriggeredLinear)
- {
- _waypointIndex = Mathf.Min(_waypointIndex + 1, _wayPoints.Length - 1);
- if (_waypointIndex == _wayPoints.Length - 1) _triggered = false;
- return;
- }
- if (_moveType == MoveType.LinearAB)
- {
- _movingForward = !_movingForward;
- _waypointIndex = _movingForward ? 1 : 0;
- }
- else
- {
- _waypointIndex = (_waypointIndex + 1) % _wayPoints.Length;
- }
- }
-
- // Passenger Pattern:乘客跟随平台移动(SetParent)
- private void OnTriggerEnter2D(Collider2D other)
- {
- if ((1 << other.gameObject.layer & LayerMask.GetMask("Player", "Enemy")) == 0) return;
- other.transform.SetParent(transform);
- _passengers.Add(other.transform);
- }
-
- private void OnTriggerExit2D(Collider2D other)
- {
- if (!_passengers.Contains(other.transform)) return;
- other.transform.SetParent(null);
- _passengers.Remove(other.transform);
- if (other.CompareTag("Player"))
- other.GetComponentInParent()?.AddForce(
- _rb.velocity, ForceMode2D.Impulse);
- }
-
- private void OnEnable()
- {
- if (_activationChannel != null)
- _activationChannel.OnEventRaised += OnTriggered;
- }
-
- private void OnDisable()
- {
- if (_activationChannel != null)
- _activationChannel.OnEventRaised -= OnTriggered;
- }
-
- private void OnTriggered() => _triggered = true;
-}
-```
-
-> **MoveType 说明**:
-> - `LinearAB`:`_wayPoints[0]` ↔ `[1]` 往返循环
-> - `WayPoints`:按 `_wayPoints[]` 顺序环形循环
-> - `TriggeredLinear`:监听 `_activationChannel`,收到信号后单程 `[0]→[n-1]`,到达后停止
->
-> **NavSurface 联动**:每个移动平台挂载独立 `LocalNavSurface`(局部坐标系),附着其上的敌人使用该 LocalNavSurface 寻路。
-
-**CrumblePlatform**(碎裂平台):
-
-```csharp
-// ⚠️ 与架构 08_WorldModule §14 完全对齐:字段名 / 完整 CrumbleSequence 状态机
-[RequireComponent(typeof(BoxCollider2D))]
-public class CrumblePlatform : MonoBehaviour
-{
- [SerializeField] private float _warningDuration = 0.6f; // 踩上后警告时长(抖动)
- [SerializeField] private float _crumbleDuration = 0.3f; // 碎裂动画时长
- [SerializeField] private float _respawnDelay = 3.0f; // 0 = 永久消失
- [SerializeField] private bool _isOneShot = false; // true = 碎裂后永久消失
- [SerializeField] private MMF_Player _crumbleFeedback; // 预警震动 + 碎裂粒子 + 音效
- [SerializeField] private BoxCollider2D _passengerSensor; // Trigger,检测玩家踩踏
-
- private BoxCollider2D _col;
- private SpriteRenderer _sr;
- private bool _isCrumbling;
-
- private void Awake()
- {
- _col = GetComponent();
- _sr = GetComponent();
- }
-
- private void OnTriggerEnter2D(Collider2D other)
- {
- if (_isCrumbling || !other.CompareTag("Player")) return;
- StartCoroutine(CrumbleSequence());
- }
-
- private IEnumerator CrumbleSequence()
- {
- _isCrumbling = true;
-
- // 1. Warning(抖动)
- _crumbleFeedback?.PlayFeedbacks();
- yield return new WaitForSeconds(_warningDuration);
-
- // 2. Crumbling
- yield return new WaitForSeconds(_crumbleDuration);
-
- // 3. Gone:禁用碰撞体 + 隐藏 Sprite
- _col.enabled = false;
- _sr.enabled = false;
- _passengerSensor.enabled = false;
-
- if (_isOneShot || _respawnDelay <= 0f)
- {
- yield break; // 永久消失
- }
-
- // 4. Respawn
- yield return new WaitForSeconds(_respawnDelay);
- _col.enabled = true;
- _sr.enabled = true;
- _passengerSensor.enabled = true;
- _isCrumbling = false;
- }
-}
-```
-
-> **状态机**:`Idle` →[玩家踩上]→ `Warning`(抖动)→[warningDuration]→ `Crumbling`→[动画]→ `Gone` →[respawnDelay,非 OneShot]→ `Idle`
-
-### 2.6 DirectionalDestructible + DirectionalInteractable + SkillInteractable
-
-**参考文档**:`08_WorldModule.md §12-15`
-
-**DirectionalDestructible**(单向可破坏墙):
-
-```csharp
-// Assets/Scripts/World/DirectionalDestructible.cs
-// 继承 DestructibleTile,增加攻击方向校验
-// ⚠️ 与架构 08_WorldModule §12 完全对齐:AttackSide 枚举 / CheckDestroyCondition 覆盖
-public class DirectionalDestructible : DestructibleTile
-{
- public enum AttackSide { Left, Right, Top, Bottom, Any }
-
- [SerializeField] private AttackSide _validAttackSide = AttackSide.Any;
-
- protected override bool CheckDestroyCondition(DamageInfo info)
- {
- if (_validAttackSide == AttackSide.Any)
- return base.CheckDestroyCondition(info);
-
- var dir = (info.SourcePosition - (Vector2)transform.position).normalized;
- bool valid = _validAttackSide switch
- {
- AttackSide.Left => dir.x < -0.5f,
- AttackSide.Right => dir.x > 0.5f,
- AttackSide.Top => dir.y > 0.5f,
- AttackSide.Bottom => dir.y < -0.5f,
- _ => true
- };
- return valid && base.CheckDestroyCondition(info);
- }
-
-#if UNITY_EDITOR
- private void OnDrawGizmos()
- {
- var arrow = _validAttackSide switch
- {
- AttackSide.Left => Vector2.left,
- AttackSide.Right => Vector2.right,
- AttackSide.Top => Vector2.up,
- AttackSide.Bottom => Vector2.down,
- _ => Vector2.zero
- };
- if (arrow == Vector2.zero) return;
- Gizmos.color = new Color(1f, 0.5f, 0f, 0.9f);
- var origin = (Vector2)transform.position;
- Gizmos.DrawLine(origin, origin + arrow * 0.8f);
- }
-#endif
-}
-```
-
-**DirectionalInteractable**(单向触发机关):
-
-```csharp
-// Assets/Scripts/World/DirectionalInteractable.cs
-// ⚠️ 与架构 08_WorldModule §13 完全对齐:Interact(Transform player),非 Interact(PlayerController)
-[RequireComponent(typeof(Collider2D))]
-public class DirectionalInteractable : MonoBehaviour, IInteractable
-{
- public enum TriggerSide { Left, Right, Top, Any }
- public enum TriggerCondition { PlayerAttack, PlayerBody, InteractKey }
-
- [Header("触发条件")]
- [SerializeField] private TriggerSide _triggerSide = TriggerSide.Any;
- [SerializeField] private TriggerCondition _triggerCondition = TriggerCondition.InteractKey;
-
- [Header("行为")]
- [SerializeField] private bool _isOneShot;
- [SerializeField] private string _interactableId;
-
- [Header("事件频道(零耦合连接关卡受体)")]
- [SerializeField] private VoidEventChannelSO _activationChannel;
- [SerializeField] private VoidEventChannelSO _deactivationChannel;
-
- [Header("反馈")]
- [SerializeField] private MMF_Player _activateFeedback;
-
- // ⚠️ SO 注入(架构 14_NarrativeModule §8 patch):不使用 SaveManager.SetMechanismState/GetMechanismState(不存在于架构)
- [SerializeField] private WorldStateRegistry _worldState;
-
- private bool _activated;
-
- public bool CanInteract => !(_isOneShot && _activated);
- public string InteractPrompt => _activated ? "已激活" : "交互";
-
- public void Interact(Transform player) // ⚠️ Transform 参数(架构 08 §7 IInteractable 标准)
- {
- if (_triggerCondition != TriggerCondition.InteractKey) return;
- if (!CheckSide(player.position)) return;
- TryActivate();
- }
-
- public void OnPlayerEnterRange(Transform player) { }
- public void OnPlayerExitRange() { }
-
- private void OnTriggerEnter2D(Collider2D other)
- {
- if (_triggerCondition != TriggerCondition.PlayerBody) return;
- if (!other.CompareTag("Player")) return;
- if (!CheckSide(other.transform.position)) return;
- TryActivate();
- }
-
- private void OnTriggerExit2D(Collider2D other)
- {
- if (_triggerCondition != TriggerCondition.PlayerBody) return;
- if (!other.CompareTag("Player") || _isOneShot) return;
- _activated = false;
- _deactivationChannel?.Raise();
- }
-
- public void TryInteractFromDamage(DamageInfo info)
- {
- if (_triggerCondition != TriggerCondition.PlayerAttack) return;
- if (!CheckSide(info.SourcePosition)) return;
- TryActivate();
- }
-
- protected void TryActivate()
- {
- if (_isOneShot && _activated) return;
- _activated = true;
- _activateFeedback?.PlayFeedbacks();
- _activationChannel?.Raise();
- // ⚠️ 用 WorldStateRegistry.SetFlag 替代 SaveManager.SetMechanismState(后者不存在于架构)
- if (_isOneShot && !string.IsNullOrEmpty(_interactableId))
- _worldState?.SetFlag("mechanism_" + _interactableId);
- }
-
- private bool CheckSide(Vector2 sourcePos)
- {
- if (_triggerSide == TriggerSide.Any) return true;
- var dir = (sourcePos - (Vector2)transform.position).normalized;
- return _triggerSide switch
- {
- TriggerSide.Left => dir.x < -0.4f,
- TriggerSide.Right => dir.x > 0.4f,
- TriggerSide.Top => dir.y > 0.4f,
- _ => true
- };
- }
-
- private void Start()
- {
- // ⚠️ 用 WorldStateRegistry.HasFlag 替代 SaveManager.GetMechanismState(后者不存在于架构)
- if (_isOneShot && !string.IsNullOrEmpty(_interactableId)
- && _worldState != null && _worldState.HasFlag("mechanism_" + _interactableId))
- {
- _activated = true;
- _activationChannel?.Raise();
- }
- }
-}
-```
-
-**SkillInteractable(MagicWall / SoftTerrain / PhantomInteractable)**:
-
-```csharp
-// Assets/Scripts/World/MagicWall.cs
-// ⚠️ 与架构 08_WorldModule §15.1 完全对齐:仅 Gizmo 可视化,穿越靠 Physics Layer Matrix
-[ExecuteAlways]
-public class MagicWall : MonoBehaviour
-{
- [SerializeField] private Color _normalColor = new(0.4f, 0.2f, 1f, 0.8f);
- [SerializeField] private Color _ghostColor = new(0.4f, 0.2f, 1f, 0.15f);
-
- // 穿越通过 Physics Layer Matrix:Ghost vs MagicWall = IgnoreCollision(无代码逻辑)
- // SkillManager 在太虚斩激活/结束时切换玩家 Layer(Ghost ↔ Player)
-
-#if UNITY_EDITOR
- private void OnDrawGizmos()
- {
- Gizmos.color = _normalColor;
- var b = GetComponent();
- if (b != null)
- Gizmos.DrawWireCube(transform.position, b.bounds.size);
- }
-#endif
-}
-
-// Assets/Scripts/World/SoftTerrain.cs
-// ⚠️ 与架构 08_WorldModule §15.2 完全对齐:Marker 组件,GroundDiveState 检测用
-public class SoftTerrain : MonoBehaviour
-{
- // Marker 组件——无逻辑,仅供 GetComponent() 检测
- // GroundDiveState 通过 Physics2D.OverlapPoint 检测当前瓦片:
- // 有 SoftTerrain → SetSoulDrainRate(0);否则 → SetSoulDrainRate(FormSkillSO.soulCostPerSecond)
-}
-
-// Assets/Scripts/World/PhantomInteractable.cs
-// ⚠️ 与架构 08_WorldModule §15.3 完全对齐:继承 DirectionalInteractable,额外响应 PhantomBody 层
-public class PhantomInteractable : DirectionalInteractable
-{
- private new void OnTriggerEnter2D(Collider2D other)
- {
- bool isPlayer = other.CompareTag("Player");
- bool isPhantom = other.gameObject.layer == LayerMask.NameToLayer("PhantomBody");
- if (!isPlayer && !isPhantom) return;
- TryActivate();
- }
-}
-```
-
-### 2.6.1 FalseWall(假墙/秘密通道)
-
-> **参考文档**:`Design/08_WorldSystem.md §9.8`(架构文档无独立章节,以 Design 文档为准)
-> **⚠️ 注意**:FalseWall **不销毁**,只切换碰撞体启用状态;与 `DestructibleTile` 的区别在于状态可逆性。
-
-```csharp
-// Assets/Scripts/World/FalseWall.cs
-// 假墙:外观与普通墙几乎相同,玩家可通过攻击/接近揭示并穿越
-// 实现 IDamageable 接口(接收攻击);揭示后持久化到 WorldSaveData
-[RequireComponent(typeof(Collider2D))]
-public class FalseWall : MonoBehaviour, IDamageable
-{
- public enum RevealCondition { Proximity, AttackOnce, AlwaysOpen }
-
- [Header("识别")]
- [SerializeField] private string _wallId; // 持久化唯一 ID(如 "FW_Forest_01_SecretA")
-
- [Header("揭示条件")]
- [SerializeField] private RevealCondition _revealCondition = RevealCondition.AttackOnce;
- [SerializeField] private float _proximityRadius = 2.0f; // Proximity 模式检测半径
-
- [Header("组件引用")]
- [SerializeField] private Collider2D _wallCollider; // 揭示后 enabled = false
- [SerializeField] private SpriteRenderer _renderer; // Normal / Revealed 两帧切换
- [SerializeField] private MMF_Player _revealFeedback; // Shimmer 粒子 + 空洞回声
-
- // IDamageable
- public bool IsInvincible => _isRevealed;
- public int Defense => 0;
-
- private bool _isRevealed = false;
-
- private void Start()
- {
- // 读档恢复:若已揭示则静默还原,不播放演出
- if (_revealCondition == RevealCondition.AlwaysOpen)
- {
- SetPassThroughImmediate();
- return;
- }
- // ⚠️ WorldSaveData.RevealedFalseWalls(Design 31 §6)—
- // 通过 SaveManager 当前存档检查(实际访问路径由 SaveManager 公开属性决定)
- // 示例:bool revealed = SaveManager.Instance?.CurrentSave?.World?.RevealedFalseWalls?.Contains(_wallId) ?? false;
- // if (revealed) SetPassThroughImmediate();
- }
-
- // IDamageable:被攻击时揭示(AttackOnce 模式)
- public void TakeDamage(DamageInfo info)
- {
- if (_isRevealed || _revealCondition != RevealCondition.AttackOnce) return;
- Reveal();
- }
-
- // Proximity 模式:玩家进入范围时触发 Shimmer(不开通道)
- private void OnTriggerEnter2D(Collider2D other)
- {
- if (_isRevealed || _revealCondition != RevealCondition.Proximity) return;
- if (!other.CompareTag("Player")) return;
- _revealFeedback?.PlayFeedbacks(); // 轻微 Shimmer 暗示(碰撞仍启用)
- }
-
- private void Reveal()
- {
- _isRevealed = true;
- _revealFeedback?.PlayFeedbacks();
- SetPassThroughImmediate();
- // 持久化:广播事件或直接写入 WorldSaveData.RevealedFalseWalls(通过 SaveManager)
- }
-
- private void SetPassThroughImmediate()
- {
- if (_wallCollider != null) _wallCollider.enabled = false; // 禁用碰撞,允许穿越
- // 切换 Sprite 到 Revealed 帧(透明度过渡)
- }
-
-#if UNITY_EDITOR
- private void OnDrawGizmosSelected()
- {
- // Gizmo:紫色虚线矩形框(Scene 视图标记)
- Gizmos.color = new Color(0.6f, 0.2f, 1f, 0.8f);
- var col = GetComponent();
- if (col != null) Gizmos.DrawWireCube(transform.position, col.bounds.size);
-
- if (_revealCondition == RevealCondition.Proximity)
- {
- Gizmos.color = new Color(0.6f, 0.2f, 1f, 0.2f);
- Gizmos.DrawWireSphere(transform.position, _proximityRadius);
- }
- }
-#endif
-}
-```
-
-**三种揭示条件说明**:
-
-| RevealCondition | 行为 | 典型用途 |
-|----------------|------|---------|
-| `Proximity` | 玩家进入 `_proximityRadius` 时播放 Shimmer(仅视觉暗示,碰撞仍启用) | 隐藏提示层(需攻击才能穿越) |
-| `AttackOnce` | 玩家攻击命中后碰撞禁用,永久可穿越 | 标准假墙(主要用法) |
-| `AlwaysOpen` | 初始即无碰撞,天生可穿越 | 返程单向暗门(已知通道)|
-
-**SaveData 持久化**:揭示后写入 `WorldSaveData.RevealedFalseWalls`(Design §31 §6 字段:`List RevealedFalseWalls`);`FalseWall.Start()` 读档检查该列表。
-
-### 2.7 WorldStateRegistry
-
-```csharp
-// Assets/Scripts/World/WorldStateRegistry.cs
-// ⚠️ ScriptableObject 单例(架构 14_NarrativeModule §8 patch):不使用静态 Instance;
-// 各组件通过 [SerializeField] 注入 SO 引用,避免服务定位器耦合
-// SaveManager.SaveAsync 调用 GetAllFlags();SaveManager.LoadAsync 调用 LoadFromSave(data.World)
-[CreateAssetMenu(menuName = "World/WorldStateRegistry")]
-public class WorldStateRegistry : ScriptableObject
-{
- private HashSet _collectedIds = new();
- private HashSet _activatedSavePoints = new();
- private HashSet _openedDoors = new();
- private HashSet _destroyedObjects = new();
-
- public bool IsCollected(string id) => _collectedIds.Contains(id);
- public void MarkCollected(string id) => _collectedIds.Add(id);
-
- public bool IsSavePointActivated(string id) => _activatedSavePoints.Contains(id);
- public void MarkSavePointActivated(string id) => _activatedSavePoints.Add(id);
-
- public bool IsDestroyed(string id) => _destroyedObjects.Contains(id);
- public void MarkDestroyed(string id) => _destroyedObjects.Add(id);
-
- public bool IsDoorOpened(string id) => _openedDoors.Contains(id);
- public void MarkDoorOpened(string id) => _openedDoors.Add(id);
-
- // 通用世界状态标记(过场记录、剧情事件等)
- // ⚠️ HasFlag(非 IsFlagSet);SetFlag(key) 单参数只添加(架构 14 patch)
- // ⚠️ 无双参数 SetFlag(key, bool);若需清除标记使用独立 ClearFlag(key)(暂不实现)
- private HashSet _flags = new();
- public bool HasFlag(string key) => _flags.Contains(key);
- public void SetFlag(string key) => _flags.Add(key);
-
- // SaveManager 调用
- public void LoadFromSave(WorldSaveData data);
- public HashSet GetAllFlags();
-}
-```
-
----
-
-### 2.8 WorldMarker + BreadcrumbTracker
-
-**参考文档**:`21_LiquidPuzzleModule.md §14–§15`
-
-```csharp
-// Assets/Scripts/World/Navigation/WorldMarker.cs
-// ⚠️ 命名空间 BaseGames.World.Navigation(架构 21 §14)
-namespace BaseGames.World.Navigation
-{
- /// 场景内导航标记点,广播给地图/HUD。
- public class WorldMarker : MonoBehaviour
- {
- [Header("标记信息")]
- [SerializeField] string _markerId; // 唯一 ID(与 MapDataSO 匹配)
- [SerializeField] WorldMarkerType _markerType; // ⚠️ 枚举 WorldMarkerType
- [SerializeField] string _labelKey; // 本地化显示名称 key
-
- [Header("可见性")]
- [SerializeField] bool _visibleOnMap = true;
- [SerializeField] bool _visibleOnHUD = false; // 在 HUD 显示箭头指引
-
- [Header("事件频道")]
- [SerializeField] WorldMarkerEventChannelSO _onMarkerActivated; // ⚠️ EVT_WorldMarkerActivated
- [SerializeField] WorldMarkerEventChannelSO _onMarkerDeactivated; // ⚠️ EVT_WorldMarkerDeactivated
-
- bool _isActive = false;
-
- void Start()
- {
- if (_visibleOnMap || _visibleOnHUD)
- Activate();
- }
-
- public void Activate()
- {
- _isActive = true;
- _onMarkerActivated?.Raise(this);
- }
-
- public void Deactivate()
- {
- _isActive = false;
- _onMarkerDeactivated?.Raise(this);
- }
-
- public string MarkerId => _markerId;
- public WorldMarkerType MarkerType => _markerType;
- public string LabelKey => _labelKey;
- public bool IsActive => _isActive;
- public bool VisibleOnHUD => _visibleOnHUD;
- }
-
- // ⚠️ 枚举值必须与架构 21 §14 完全一致
- public enum WorldMarkerType
- {
- Objective, // 当前主线目标
- NPC, // NPC 位置
- PointOfInterest, // 兴趣点
- Exit, // 出口/传送点
- Secret, // 隐藏区域(解锁后显示)
- }
-}
-```
-
-```csharp
-// Assets/Scripts/World/Navigation/BreadcrumbTracker.cs
-// ⚠️ 命名空间 BaseGames.World.Navigation(架构 21 §15)
-// 挂在玩家 GameObject 上;数据不持久化(每次游戏重置)
-namespace BaseGames.World.Navigation
-{
- public class BreadcrumbTracker : MonoBehaviour
- {
- [Header("追踪参数")]
- [SerializeField] float _recordInterval = 2.0f; // 每隔多少秒记录一次
- [SerializeField] int _maxCrumbs = 20; // 最多保留多少个历史位置
- [SerializeField] float _minMoveDistance = 1.0f; // 移动距离低于此值不记录
-
- readonly Queue _crumbs = new();
- float _timer;
- Vector2 _lastPos;
-
- public IReadOnlyCollection Crumbs => _crumbs;
-
- void Start() { _lastPos = transform.position; }
-
- void Update()
- {
- _timer += Time.deltaTime;
- if (_timer < _recordInterval) return;
- _timer = 0f;
-
- Vector2 current = transform.position;
- if (Vector2.Distance(current, _lastPos) < _minMoveDistance) return;
-
- _crumbs.Enqueue(current);
- if (_crumbs.Count > _maxCrumbs) _crumbs.Dequeue();
- _lastPos = current;
- }
-
- /// 获取最近 N 个面包屑位置(用于地图渲染)。
- public Vector2[] GetRecentCrumbs(int count)
- => System.Linq.Enumerable.TakeLast(_crumbs, count).ToArray();
- }
-}
-```
-
----
-
-### 2.9 TutorialManager + ContextualHintTrigger
-
-**参考文档**:`21_LiquidPuzzleModule.md §17–§18`
-
-```csharp
-// Assets/Scripts/Tutorial/TutorialManager.cs
-// ⚠️ 命名空间 BaseGames.Tutorial;不实现 ISaveable(架构 12_SaveModule §1 SaveData 无 Tutorial 字段)
-// ⚠️ 教程进度使用 PlayerPrefs 持久化(非存档系统),格式:key="hint_{hintId}",value=1
-// 挂在 Persistent 场景 [GameManagers] 下
-namespace BaseGames.Tutorial
-{
- public class TutorialManager : MonoBehaviour
- {
- [SerializeField] TutorialHintUI _hintUI; // HUD 上的提示 UI 组件
-
- readonly HashSet _completedHints = new();
-
- // ⚠️ Singleton(架构 21 §17 必须有 Instance)
- public static TutorialManager Instance { get; private set; }
-
- void Awake()
- {
- Instance = this;
- // 从 PlayerPrefs 恢复已完成提示列表
- // (架构 12 SaveData 无 Tutorial 字段;教程进度独立于存档系统)
- }
-
- /// 显示提示。若已完成则跳过。
- public void ShowHint(string hintId, string localizedText, float duration = 3f)
- {
- if (_completedHints.Contains(hintId)) return;
- _hintUI.Show(localizedText, duration);
- }
-
- /// 标记提示为已完成,持久化到 PlayerPrefs,不再显示。
- public void CompleteHint(string hintId)
- {
- _completedHints.Add(hintId);
- PlayerPrefs.SetInt("hint_" + hintId, 1); // ⚠️ PlayerPrefs(非 SaveData.Tutorial,架构 12 无此字段)
- PlayerPrefs.Save();
- }
-
- public bool IsCompleted(string hintId) => _completedHints.Contains(hintId);
-
- // ⚠️ 不实现 ISaveable;教程进度通过 PlayerPrefs 读写,无需注入 SaveManager
- }
-}
-```
-
-```csharp
-// Assets/Scripts/Tutorial/ContextualHintTrigger.cs
-// ⚠️ 命名空间 BaseGames.Tutorial;[RequireComponent(Collider2D)](架构 21 §18)
-namespace BaseGames.Tutorial
-{
- [RequireComponent(typeof(Collider2D))]
- public class ContextualHintTrigger : MonoBehaviour
- {
- [Header("提示配置")]
- [SerializeField] string _hintId; // 唯一 ID
- [SerializeField] string _hintTextKey; // 本地化 key
- [SerializeField] float _displayDuration = 3f;
-
- [Header("触发条件(可选)")]
- // ⚠️ AbilityType 枚举(Architecture 09 §1)无 None 值;用 bool 标记是否要求能力
- [SerializeField] bool _requiresAbility = false;
- [SerializeField] AbilityType _requiredAbility;
- [SerializeField] bool _onlyOnce = true; // ⚠️ 建议保持 true
-
- void OnTriggerEnter2D(Collider2D other)
- {
- if (!other.CompareTag("Player")) return;
-
- // 检查能力条件(仅当 _requiresAbility = true 时)
- if (_requiresAbility)
- {
- var stats = other.GetComponent();
- if (stats == null || !stats.HasAbility(_requiredAbility)) return;
- }
-
- var text = LocalizationManager.Get(LocalizationManager.Table_UI, _hintTextKey); // ⚠️ LocalizationManager 为静态类(Architecture 16 §1),无 Instance;方法为 Get(tableKey, entryKey)
- TutorialManager.Instance.ShowHint(_hintId, text, _displayDuration);
-
- if (_onlyOnce)
- {
- TutorialManager.Instance.CompleteHint(_hintId);
- gameObject.SetActive(false); // 触发后禁用自身,避免重复
- }
- }
- }
-}
-```
-
----
-
-## 3. Week 11:液态谜题模块 ✅ 完成(2026-05-11)
-
-**参考文档**:`21_LiquidPuzzleModule.md`
-
-### 3.0 液体数据层
-
-```csharp
-// Assets/Scripts/World/Liquid/LiquidType.cs
-// ⚠️ 按 Architecture 21_LiquidPuzzleModule §2 实现
-namespace BaseGames.World.Liquid
-{
- public enum LiquidType
- {
- Water, // 可游泳(需 Swim 能力)
- ShallowWater, // ⚠️ 浅水(水中慢走,无需游泳能力,速度 ×0.65,架构 21 §2)
- Mud, // ⚠️ 泥水(移动极慢,无需游泳能力,速度 ×0.50,架构 21 §2)
- Acid, // 接触即死(HazardZone 处理)
- Lava, // 接触即死(HazardZone 处理)
- }
-}
-
-// Assets/Scripts/World/Liquid/LiquidPhysicsConfigSO.cs
-// ⚠️ 按 Architecture 21_LiquidPuzzleModule §3 实现
-[CreateAssetMenu(menuName = "World/LiquidPhysicsConfig")]
-public class LiquidPhysicsConfigSO : ScriptableObject
-{
- [Header("水下物理")]
- [Range(0f, 1f)]
- public float GravityScale = 0.3f;
- [Range(0f, 1f)]
- public float BuoyancyForce = 0.5f;
- public float MaxSwimSpeed = 4.0f;
- public float SwimAcceleration = 8.0f;
- public float SurfaceExitSpeed = 5.0f;
- public float SinkSpeed = 2.0f; // ⚠️ 无游泳能力时自然下沉速度(架构 21 §3)
- public float DiveSpeedMultiplier = 1.5f; // ⚠️ 主动下潜时的速度倍率(架构 21 §3)
-
- [Header("浅水/泥水速度缩放")]
- [Range(0.1f, 1.0f)]
- public float ShallowSpeedScale = 0.65f; // ⚠️ ShallowWater 类型水平移动速度倍率(架构 21 §3)
- [Range(0.1f, 1.0f)]
- public float MudSpeedScale = 0.50f; // ⚠️ Mud 类型水平移动速度倍率(架构 21 §3)
-
- [Header("溺死计时(无游泳能力时)")]
- public float DrownTime = 3.0f; // ⚠️ 屏气倒计时(秒),倒计时结束则触发死亡(架构 21 §3)
-
- [Header("进出液体")]
- public float SplashEntryDelay = 0.05f;
- public float DragCoefficient = 3.0f;
-
- [Header("视觉")]
- public VolumeProfile WaterVolumeProfile; // 水下后处理 Profile(可为 null)
-}
-```
-
-### 3.1 LiquidZone
-
-```csharp
-// Assets/Scripts/World/Liquid/LiquidZone.cs
-// 定义液体区域,玩家进入时切换 SwimState
-// ⚠️ 事件频道类型为 LiquidEventChannelSO,携带 LiquidZone 引用(SwimState 需要 PhysicsConfig)
-namespace BaseGames.World.Liquid // ⚠️ 必须有命名空间
-{
-[RequireComponent(typeof(Collider2D))]
-public class LiquidZone : MonoBehaviour
-{
- [SerializeField] LiquidType _liquidType = LiquidType.Water; // Water / Acid / Lava
-
- // ⚠️ 溺水伤害(Water 类型专用;Acid/Lava 由子节点 HazardZone 处理)——架构 21_LiquidPuzzleModule §3
- [SerializeField] bool _dealsDrowningDamage = false;
- [SerializeField] float _drowningDamagePerSecond = 5f;
-
- [SerializeField] LiquidPhysicsConfigSO _physicsConfig; // 浮力/速度配置
-
- [Header("Event Channels")]
- [SerializeField] LiquidEventChannelSO _onPlayerEntered; // payload = this(SwimState 需要读 Physics)
- [SerializeField] LiquidEventChannelSO _onPlayerExited;
-
- [Header("Feedback")]
- [SerializeField] MMF_Player _splashEnterFeedback;
- [SerializeField] MMF_Player _splashExitFeedback;
-
- public LiquidType Type => _liquidType;
- public LiquidPhysicsConfigSO Physics => _physicsConfig;
-
- private void OnTriggerEnter2D(Collider2D other)
- {
- if (!other.CompareTag("Player")) return;
- _splashEnterFeedback?.PlayFeedbacks();
- _onPlayerEntered.Raise(this); // 传递 LiquidZone 引用
- // PlayerController 收到 EVT_LiquidEntered → swimState.SetLiquidZone(zone) → 切换 SwimState
- }
-
- private void OnTriggerExit2D(Collider2D other)
- {
- if (!other.CompareTag("Player")) return;
- _splashExitFeedback?.PlayFeedbacks();
- _onPlayerExited.Raise(this);
- }
-}
-} // namespace BaseGames.World.Liquid
-```
-
-**SwimState 完整实现**(Architecture 21 §5,`05_PlayerModule.md` §12 第 18 个状态):
-
-```csharp
-// Assets/Scripts/Player/States/SwimState.cs
-namespace BaseGames.Player.States
-{
- public class SwimState : PlayerStateBase
- {
- [SerializeField] LiquidPhysicsConfigSO _physics; // 由 LiquidZone 注入
- [SerializeField] ClipTransition _swimIdleClip;
- [SerializeField] ClipTransition _swimMoveClip;
-
- LiquidZone _currentZone;
- float _originalGravity;
-
- // 由 PlayerController 在收到 EVT_LiquidEntered 后调用
- public void SetLiquidZone(LiquidZone zone) => _currentZone = zone;
-
- public override void OnEnter()
- {
- _originalGravity = RB.gravityScale;
- RB.gravityScale = _currentZone?.Physics.GravityScale ?? 0.3f;
- Animancer.Play(_swimIdleClip);
- }
-
- public override void OnExit()
- {
- RB.gravityScale = _originalGravity;
- }
-
- public override void OnUpdate()
- {
- var input = Input.Move;
- if (input != Vector2.zero)
- {
- var targetVel = input * (_currentZone?.Physics.MaxSwimSpeed ?? 4f);
- RB.linearVelocity = Vector2.MoveTowards(
- RB.linearVelocity, targetVel,
- (_currentZone?.Physics.SwimAcceleration ?? 8f) * Time.deltaTime
- );
- Animancer.Play(_swimMoveClip);
- }
- else
- {
- // 水下浮力(持续向上的微弱力)
- RB.AddForce(Vector2.up * (_currentZone?.Physics.BuoyancyForce ?? 0.5f),
- ForceMode2D.Force);
- Animancer.Play(_swimIdleClip);
- }
-
- // 跳跃键 = 跃出水面
- if (Input.JumpPressed)
- {
- RB.AddForce(Vector2.up * (_currentZone?.Physics.SurfaceExitSpeed ?? 5f),
- ForceMode2D.Impulse);
- }
-
- // 施加水阻
- RB.linearVelocity *= 1f - _currentZone?.Physics.DragCoefficient * Time.deltaTime ?? 0f;
- }
-
- public override PlayerStateBase GetNextState()
- {
- // 离开液体区域由 PlayerController 订阅 EVT_LiquidExited 后切换到 FallState
- return null;
- }
- }
-}
-```
-
-**进入条件**:`PlayerStats.HasAbility(AbilityType.Swim)` 为 true(否则在液体内受伤;Acid/Lava 由 HazardZone InstantKill 处理)
-
-### 3.2 液态谜题机关三件套
-
-```
-Valve.cs ← 可交互旋转阀,控制液体流动开关
-Pump.cs ← 需消耗 SoulPower 激活,提升液面
-Drain.cs ← 破坏性排水(一次性,DestructibleTile 变体)
-```
-
-### 3.3 LiquidPuzzleController
-
-```csharp
-// Assets/Scripts/World/Liquid/LiquidPuzzleController.cs
-// 管理一个谜题区域内的液位状态
-public class LiquidPuzzleController : MonoBehaviour, ISaveable
-{
- [SerializeField] private float _currentLevel; // 0~1 归一化液位
- [SerializeField] private float _targetLevel; // 目标液位(谜题目标)
- [SerializeField] private Transform _waterSurface; // 视觉层
-
- // 阀门/泵驱动液位变化
- public void RaiseLiquid(float amount);
- public void DrainLiquid(float amount);
-
- // 每帧平滑插值 _waterSurface.position
- // 液位达到 _targetLevel → 触发谜题完成事件
-}
-```
-
-### 3.4 谜题机关系统(PuzzleSwitch / PuzzleReceiver / PuzzleWire)
-
-> **参考文档**:`21_LiquidPuzzleModule.md §7–§11`(Part B · 谜题架构)
-> **命名空间**:`BaseGames.Puzzle`
-
-**核心接口**(`namespace BaseGames.Puzzle`,对应 Architecture 21 §8):
-
-```csharp
-// Assets/Scripts/World/Puzzle/ (均属于 namespace BaseGames.Puzzle)
-namespace BaseGames.Puzzle
-{
- /// 任何可被切换激活/停用状态的谜题元素。
- public interface ISwitchable
- {
- bool IsActive { get; }
- event Action OnStateChanged;
- void ForceState(bool active); // SaveData 恢复时调用
- }
-
- /// 可被玩家推动的物件(需 Rigidbody2D)。
- public interface IMovable
- {
- bool CanBePushed { get; }
- void OnPushStart(Vector2 direction);
- void OnPushEnd();
- }
-
- /// 接受激活信号后改变自身状态的物件。
- public interface IActivatable
- {
- void Activate();
- void Deactivate();
- bool IsActivated { get; }
- }
-}
-```
-
-**PuzzleSwitch**(通用开关):
-
-```csharp
-// Assets/Scripts/World/Puzzle/PuzzleSwitch.cs
-namespace BaseGames.Puzzle // ⚠️ 必须有命名空间
-{
- // 实现 ISwitchable + IInteractable
- [RequireComponent(typeof(Collider2D))]
- public class PuzzleSwitch : MonoBehaviour, ISwitchable, IInteractable
- {
- [Header("触发模式")]
- [SerializeField] SwitchTriggerMode _mode = SwitchTriggerMode.InteractOnce;
- [Header("状态")]
- [SerializeField] bool _startsActive = false;
- [SerializeField] string _switchId; // 持久化唯一 ID(存档用,空串则不持久化)
- [Header("视觉")]
- [SerializeField] AnimancerComponent _animancer;
- [SerializeField] AnimationClip _activeClip;
- [SerializeField] AnimationClip _inactiveClip;
- [SerializeField] MMF_Player _activateFeedback;
-
- // ⚠️ SO 注入(架构 14_NarrativeModule §8 patch):不使用 WorldStateRegistry.Instance
- [SerializeField] WorldStateRegistry _worldState;
-
- bool _isActive;
- public bool IsActive => _isActive;
- public event Action OnStateChanged;
-
- void Start() => _isActive = _startsActive; // ⚠️ 初始化初始状态
-
- // IInteractable
- public string InteractPrompt => _mode == SwitchTriggerMode.Hold ? "按住交互" : "交互";
- public bool CanInteract => true;
-
- public void Interact(Transform player)
- {
- if (_mode == SwitchTriggerMode.InteractOnce && _isActive) return;
- SetState(!_isActive);
- }
-
- public void OnPlayerEnterRange(Transform player) { } // ⚠️ IInteractable 必需实现
- public void OnPlayerExitRange() { } // ⚠️ IInteractable 必需实现
-
- // ISwitchable
- public void ForceState(bool active) => SetState(active);
-
- // 压板模式:OnTriggerEnter2D / OnTriggerExit2D ⚠️ 缺少则 Pressure 模式无法工作
- void OnTriggerEnter2D(Collider2D col)
- {
- if (_mode != SwitchTriggerMode.Pressure) return;
- if (col.CompareTag("Player") || col.CompareTag("PushBox"))
- SetState(true);
- }
-
- void OnTriggerExit2D(Collider2D col)
- {
- if (_mode != SwitchTriggerMode.Pressure) return;
- if (col.CompareTag("Player") || col.CompareTag("PushBox"))
- SetState(false);
- }
-
- void SetState(bool active)
- {
- if (_isActive == active) return;
- _isActive = active;
- if (active) _animancer?.Play(_activeClip);
- else _animancer?.Play(_inactiveClip);
- _activateFeedback?.PlayFeedbacks();
- OnStateChanged?.Invoke(active);
- // ⚠️ SetFlag 双参数(架构 08_WorldModule §9):key + bool value,激活和停用均需持久化
- if (!string.IsNullOrEmpty(_switchId))
- _worldState?.SetFlag("switch_" + _switchId, active);
- }
- }
-
- public enum SwitchTriggerMode { InteractOnce, InteractToggle, Pressure, Hold }
-} // namespace BaseGames.Puzzle
-```
-
-**PuzzleReceiver + PuzzleDoor**(接收器 + 门子类):
-
-```csharp
-// Assets/Scripts/World/Puzzle/PuzzleReceiver.cs
-namespace BaseGames.Puzzle // ⚠️ 必须有命名空间
-{
- // 基类:子类覆写 OnActivate/OnDeactivate 实现具体行为
- public class PuzzleReceiver : MonoBehaviour, IActivatable
- {
- [SerializeField] bool _startsActivated = false; // ⚠️ 缺少则无法初始化激活状态
- [SerializeField] string _receiverId; // 持久化唯一 ID(存档用,空串则不持久化)
- [SerializeField] MMF_Player _activateFeedback;
- [SerializeField] MMF_Player _deactivateFeedback;
-
- // ⚠️ SO 注入(架构 14_NarrativeModule §8 patch):不使用 WorldStateRegistry.Instance
- [SerializeField] WorldStateRegistry _worldState;
-
- bool _isActivated;
- public bool IsActivated => _isActivated;
-
- void Start() // ⚠️ 初始化起始激活状态
- {
- _isActivated = _startsActivated;
- if (_isActivated) Activate();
- }
-
- public void Activate()
- {
- if (_isActivated) return;
- _isActivated = true;
- _activateFeedback?.PlayFeedbacks();
- OnActivate();
- // ⚠️ SetFlag 双参数(架构 08_WorldModule §9):key + true,激活时持久化
- if (!string.IsNullOrEmpty(_receiverId))
- _worldState?.SetFlag("receiver_" + _receiverId, true);
- }
-
- public void Deactivate()
- {
- if (!_isActivated) return;
- _isActivated = false;
- _deactivateFeedback?.PlayFeedbacks();
- OnDeactivate();
- // ⚠️ SetFlag 双参数(架构 08_WorldModule §9):key + false,停用时也需持久化(架构 21 §10)
- if (!string.IsNullOrEmpty(_receiverId))
- _worldState?.SetFlag("receiver_" + _receiverId, false);
- }
-
- protected virtual void OnActivate() { }
- protected virtual void OnDeactivate() { }
- }
-
- // Assets/Scripts/World/Puzzle/PuzzleDoor.cs
- public class PuzzleDoor : PuzzleReceiver
- {
- [SerializeField] AnimancerComponent _animancer;
- [SerializeField] AnimationClip _openClip;
- [SerializeField] AnimationClip _closeClip;
-
- protected override void OnActivate() => _animancer.Play(_openClip);
- protected override void OnDeactivate() => _animancer.Play(_closeClip);
- }
-} // namespace BaseGames.Puzzle
-```
-
-**PuzzleWire**(逻辑连接器):
-
-```csharp
-// Assets/Scripts/World/Puzzle/PuzzleWire.cs
-// 将一个或多个 PuzzleSwitch 连接到 PuzzleReceiver,支持 AND/OR/XOR 逻辑
-// Inspector 中配置,关卡设计师无需写代码
-// ⚠️ 字段名:_switches / _receiver;枚举类型:LogicType(架构 §3.4 patch,非 WireLogic/WireLogicMode)
-namespace BaseGames.Puzzle // ⚠️ 必须有命名空间
-{
- public class PuzzleWire : MonoBehaviour
- {
- [Header("输入开关")]
- [SerializeField] PuzzleSwitch[] _switches; // ⚠️ 非 _inputs
- [Header("激活逻辑")]
- [SerializeField] LogicType _logic = LogicType.AND; // ⚠️ 枚举 LogicType(架构 patch)
- [Header("目标接收器")]
- [SerializeField] PuzzleReceiver _receiver; // ⚠️ 非 _output
-
- void Start()
- {
- foreach (var sw in _switches)
- sw.OnStateChanged += _ => Evaluate();
- Evaluate(); // 初始求值
- }
-
- void Evaluate()
- {
- bool shouldActivate = _logic switch
- {
- LogicType.AND => System.Array.TrueForAll(_switches, s => s.IsActive),
- LogicType.OR => System.Array.Exists(_switches, s => s.IsActive),
- LogicType.XOR => _switches.Count(s => s.IsActive) % 2 == 1,
- _ => false,
- };
- if (shouldActivate) _receiver.Activate();
- else _receiver.Deactivate();
- }
- }
-
- public enum LogicType { AND, OR, XOR } // ⚠️ 枚举名为 LogicType(架构 §3.4 patch)
-} // namespace BaseGames.Puzzle
-```
-
----
-
-### 3.3 脚步声材质系统(FootstepMaterial)
-
-> **⚠️ 延迟集成**:本系统在 Phase 2 中已预留(架构 11 §9-10),与玩家游泳状态(SwimState)及 Animancer 动画事件联动,至 Phase 3 Week 11 随 LiquidZone 一并集成。
-
-```csharp
-// Assets/Scripts/Audio/FootstepMaterial.cs
-public enum FootstepMaterial
-{
- Stone, // 石板地(默认)
- Dirt, // 泥土/草地
- Wood, // 木板
- Metal, // 金属格栅
- Water, // 浅水区(溅水声)
- Sand, // 沙地
- Grass, // 草丛
- Cave, // 洞穴(回响加强)
-}
-
-// Assets/Scripts/Audio/FootstepAudioConfigSO.cs
-[CreateAssetMenu(menuName = "BaseGames/Audio/FootstepAudioConfig")]
-public class FootstepAudioConfigSO : ScriptableObject
-{
- [System.Serializable]
- public struct MaterialEntry
- {
- public FootstepMaterial material;
- public AudioClip[] clips; // 随机选一个,防止重复感
- [Range(0f, 1f)] public float volume;
- [Range(0.8f, 1.2f)] public float pitchVariance; // 每次随机 pitch 偏移范围
- }
- public MaterialEntry[] entries;
-
- public MaterialEntry? GetEntry(FootstepMaterial mat)
- {
- foreach (var e in entries)
- if (e.material == mat) return e;
- return null;
- }
-}
-
-// Assets/Scripts/Audio/FootstepMaterialMarker.cs
-// 挂载到地面碰撞体所在 GameObject(Tilemap 图层 or 单体地形 Prefab)
-public class FootstepMaterialMarker : MonoBehaviour
-{
- public FootstepMaterial material;
-}
-```
-
-**播放时机**:
-- **落地**:`PlayerController.OnLanded()` 触发(音量 ×1.5)
-- **行走**:Animancer 动画事件 `FootstepL` / `FootstepR`(见架构 24 §AnimEventModule)触发
-- **冲刺起步**:Dash 动画第 2 帧触发专属 `DashSFX`(不走 Footstep 通道)
-
-玩家若脚下 GameObject 无 `FootstepMaterialMarker`,默认使用 `Stone`。(架构 11 §9)
-
----
-
-### 3.4 水下音效处理(UnderwaterAudioController)
-
-> 进入 `LiquidZone` 时,全局音效自动应用水下 DSP 处理。(架构 11 §10)
-
-```csharp
-// Assets/Scripts/Audio/UnderwaterAudioController.cs
-// 挂载于 PlayerController 所在 GameObject;LiquidZone 调用 EnterWater/ExitWater
-public class UnderwaterAudioController : MonoBehaviour
-{
- [SerializeField] AudioMixer _mixer;
- [SerializeField] float _transitionDuration = 0.3f;
-
- /// LiquidZone.OnTriggerEnter2D 时调用
- public void EnterWater()
- {
- _mixer.FindSnapshot("Underwater").TransitionTo(_transitionDuration);
- }
-
- /// LiquidZone.OnTriggerExit2D 时调用
- public void ExitWater()
- {
- _mixer.FindSnapshot("Default").TransitionTo(_transitionDuration);
- }
-}
-```
-
-**Underwater Snapshot DSP 配置**(AudioMixer 预设):
-
-| Bus | 处理 |
-|-----|------|
-| BGM | Low-Pass 800 Hz |
-| SFX | Low-Pass 1200 Hz + Volume ×0.7 |
-| Ambient | Volume ×0,替换为水下环境音(气泡声)|
-| PlayerSFX | Low-Pass 1000 Hz |
-
----
-
-### 3.5 WaterDangerState — 溺水倒计时(⚠️ 架构 21 §12,原 Plan 遗漏)
-
-> 当玩家进入 `Water` 类型液体且**未解锁游泳能力**时,触发溺水倒计时。挂在 `PlayerController` 子节点 `[WaterDanger]` 上。
-
-```csharp
-// Assets/Scripts/World/Liquid/WaterDangerState.cs
-// ⚠️ 订阅 LiquidEventChannelSO(携带 LiquidEvent struct,非 LiquidZone 引用)
-// ⚠️ 使用 PlayerStats.HasAbility 替代 AbilityInventorySO(项目实际 API,无额外 SO 依赖)
-public class WaterDangerState : MonoBehaviour
-{
- [SerializeField] private LiquidPhysicsConfigSO _config;
- [SerializeField] private PlayerStats _playerStats; // 检查 Swim 能力
- [SerializeField] private LiquidEventChannelSO _onLiquidEntered; // EVT_LiquidEntered
- [SerializeField] private LiquidEventChannelSO _onLiquidExited; // EVT_LiquidExited
- [SerializeField] private FloatEventChannelSO _onDrownProgress; // 0~1 倒计时进度(HUD 用)→ EVT_DrownProgress
- [SerializeField] private VoidEventChannelSO _onPlayerDrowned; // 触发死亡 → EVT_PlayerDrowned
-
- private float _drownTimer;
- private bool _isActive;
-
- private void OnEnable()
- {
- if (_onLiquidEntered != null) _onLiquidEntered.OnEventRaised += OnEnterLiquid;
- if (_onLiquidExited != null) _onLiquidExited.OnEventRaised += OnExitLiquid;
- }
-
- private void OnDisable()
- {
- if (_onLiquidEntered != null) _onLiquidEntered.OnEventRaised -= OnEnterLiquid;
- if (_onLiquidExited != null) _onLiquidExited.OnEventRaised -= OnExitLiquid;
- }
-
- public void OnEnterLiquid(LiquidEvent evt)
- {
- if (evt.LiquidType != nameof(LiquidType.Water)) return;
- if (_playerStats != null && _playerStats.HasAbility(AbilityType.Swim)) return;
- _isActive = true;
- _drownTimer = _config != null ? _config.DrownTime : 3f;
- }
-
- public void OnExitLiquid(LiquidEvent evt)
- {
- _isActive = false;
- _drownTimer = _config != null ? _config.DrownTime : 3f;
- _onDrownProgress?.Raise(0f);
- }
-
- private void Update()
- {
- if (!_isActive) return;
- _drownTimer -= Time.deltaTime;
- float drownTime = _config != null ? _config.DrownTime : 3f;
- _onDrownProgress?.Raise(1f - (_drownTimer / drownTime));
- if (_drownTimer <= 0f)
- {
- _isActive = false;
- _onPlayerDrowned?.Raise();
- }
- }
-}
-```
-
-**事件频道补充**:
-- `EVT_DrownProgress`(`FloatEventChannelSO`):`WaterDangerState` → `HUDController`(溺水进度条)
-- `EVT_PlayerDrowned`(`VoidEventChannelSO`):`WaterDangerState` → `GameManager`(触发死亡流程)
-
----
-
-### 3.6 UnderwaterPostProcessingController — 水下后处理(⚠️ 架构 21 §13,原 Plan 遗漏)
-
-> 订阅 `EVT_LiquidEntered` / `EVT_LiquidExited`,渐变启用/停用 水下专属 `Volume`(颜色滤镜/色差/暗角)。使用 Coroutine 混合权重,不依赖 DOTween。
-
-```csharp
-// Assets/Scripts/World/Liquid/UnderwaterPostProcessingController.cs
-public class UnderwaterPostProcessingController : MonoBehaviour
-{
- [SerializeField] private Volume _underwaterVolume; // 水下专属 Volume(WeightMode)
- [SerializeField] private float _blendInDuration = 0.3f;
- [SerializeField] private float _blendOutDuration = 0.3f;
- [SerializeField] private LiquidEventChannelSO _onLiquidEntered; // EVT_LiquidEntered(payload: LiquidEvent struct)
- [SerializeField] private LiquidEventChannelSO _onLiquidExited; // EVT_LiquidExited(与 Enter 同类型,保持一致)
-
- private Coroutine _blendCoroutine;
-
- private void OnEnable()
- {
- _onLiquidEntered.OnEventRaised += OnLiquidEntered;
- _onLiquidExited.OnEventRaised += OnLiquidExited;
- }
- private void OnDisable()
- {
- _onLiquidEntered.OnEventRaised -= OnLiquidEntered;
- _onLiquidExited.OnEventRaised -= OnLiquidExited;
- }
-
- private void OnLiquidEntered(LiquidEvent evt)
- {
- if (evt.LiquidType != nameof(LiquidType.Water)) return;
- BlendVolume(1f, _blendInDuration);
- }
- private void OnLiquidExited(LiquidEvent evt) => BlendVolume(0f, _blendOutDuration);
-
- private void BlendVolume(float target, float duration)
- {
- if (_blendCoroutine != null) StopCoroutine(_blendCoroutine);
- _blendCoroutine = StartCoroutine(BlendRoutine(target, duration));
- }
- private IEnumerator BlendRoutine(float target, float duration)
- {
- float start = _underwaterVolume.weight, elapsed = 0f;
- while (elapsed < duration)
- {
- elapsed += Time.deltaTime;
- _underwaterVolume.weight = Mathf.Lerp(start, target, elapsed / duration);
- yield return null;
- }
- _underwaterVolume.weight = target;
- }
-}
-```
-
----
-
-## 4. Week 12:进程模块(护符/工具/技能)✅ 完成(2026-05-10)
-
-**参考文档**:`09_ProgressionModule.md`
-
-### 4.0 AbilityType 枚举 + AbilityGate
-
-> **文件位置(架构 `09_ProgressionModule §2.1`)**:
-> - `AbilityType.cs` → `Assets/Scripts/Player/AbilityType.cs`,程序集 `BaseGames.Player`
-> - `AbilityGate.cs` → `Assets/Scripts/World/AbilityGate.cs`,程序集 `BaseGames.World`
-
-```csharp
-// Assets/Scripts/Player/AbilityType.cs — 程序集 BaseGames.Player
-// ⚠️ 此枚举在 Player 程序集,非 Equipment 或 Spells
-// ⚠️ 枚举值必须与架构 09_ProgressionModule §1 完全一致
-namespace BaseGames.Player
-{
- public enum AbilityType
- {
- // 移动
- WallCling,
- WallJump,
- Dash,
- AerialDash, // ⚠️ 空中冲刺,默认锁定,升级后解锁(架构 09_ProgressionModule §1)
- InvincibleDash, // ⚠️ 冲刺全程无敌,Dash 升级版(架构 09_ProgressionModule §1)
- DoubleJump,
- ClimbVines,
- Swim, // 游泳(LiquidZone 内切换 SwimState)
-
- // 战斗
- Parry,
- Spring, // 灵泉反弹
- UseTools,
-
- // 互动
- ReadShrine,
- UseGrapple,
- }
-}
-
-// Assets/Scripts/World/AbilityGate.cs — 程序集 BaseGames.World
-// 检测玩家是否持有对应能力,不满足则阻拦(触发器 + 提示 UI)
-// ⚠️ 以架构 09_ProgressionModule §2 为准:含 _blockingObject、_hintUI、_gateId、_saveData 注入
-[RequireComponent(typeof(Collider2D))]
-public class AbilityGate : MonoBehaviour
-{
- [SerializeField] private AbilityType _requiredAbility;
- [SerializeField] private GameObject _blockingObject; // 实际阻挡物件(禁/启用)
- [SerializeField] private GameObject _hintUI; // ⚠️ 提示 UI(能力图标 + "???"),架构 09 §2
- [SerializeField] private string _gateId; // 存档 ID(已开启的门不再重置)
-
- [Header("Event Channels")]
- [SerializeField] private StringEventChannelSO _onAbilityUnlocked; // ⚠️ StringEventChannelSO(架构 02 §4 patch):EVT_AbilityUnlocked payload 为 abilityId string(非 AbilityTypeEventChannelSO)
-
- // ⚠️ _saveData 由 GameInitializer 在 Awake 时注入(零耦合,避免 SaveManager.Instance;架构 09 §2)
- private SaveData _saveData;
- public void InjectSaveData(SaveData data) => _saveData = data;
-
- private void OnEnable() => _onAbilityUnlocked.OnEventRaised += OnAbilityUnlocked;
- private void OnDisable() => _onAbilityUnlocked.OnEventRaised -= OnAbilityUnlocked;
-
- private void Start()
- {
- // ⚠️ 读档检查:若已持有该能力则直接开放(架构 09 §2)
- bool hasAbility = _saveData != null
- && _saveData.Player.Abilities.TryGetValue(_requiredAbility.ToString(), out bool val)
- && val;
- _blockingObject.SetActive(!hasAbility);
- if (_hintUI != null) _hintUI.SetActive(!hasAbility);
- }
-
- private void OnAbilityUnlocked(string abilityId) // ⚠️ string 参数(非 AbilityType 枚举)
- {
- if (abilityId != _requiredAbility.ToString()) return;
- Open();
- }
-
- public void Open()
- {
- _blockingObject.SetActive(false);
- if (_hintUI != null) _hintUI.SetActive(false);
- // P1:播放解锁动画(如荆棘收缩、道路开通特效)
- }
-}
-```
-
-### 4.0b AbilityUnlock(能力解锁交互物)
-
-**文件**:`Assets/Scripts/World/AbilityUnlock.cs`(架构 08_WorldModule §6)
-
-> 世界中固定位置的能力解锁物;玩家与之交互后获得新技能,触发 `EVT_AbilityUnlocked` 事件频道。
-
-```csharp
-// ⚠️ 完整实现以架构 08_WorldModule §6 为准
-// ⚠️ IInteractable 定义在 BaseGames.World 命名空间(Architecture 08 §7 / 14 §1)
-public class AbilityUnlock : MonoBehaviour, IInteractable
-{
- [SerializeField] private AbilityType _abilityToUnlock;
- [SerializeField] private string _unlockId; // 存档用(全局唯一)
-
- [Header("Event Channel")]
- [SerializeField] private StringEventChannelSO _onCollectiblePickup; // ⚠️ payload: _unlockId;通知 WorldStateRegistry + QuestManager
-
- private bool _isCollected = false;
-
- public bool CanInteract => !_isCollected;
- public string InteractPrompt => "获得能力";
-
- public void Interact(Transform player)
- {
- if (_isCollected) return;
- _isCollected = true;
- // ⚠️ PlayerController 无 Instance(Architecture 05 §2);通过 player 参数获取
- player.GetComponent()?.Stats.UnlockAbility(_abilityToUnlock);
- _onCollectiblePickup.Raise(_unlockId); // → WorldStateRegistry 记录 + QuestManager 追踪
- // 触发解锁演出(Cutscene / UI 提示;Phase 4 完善)
- gameObject.SetActive(false);
- }
-
- public void OnPlayerEnterRange(Transform player) { }
- public void OnPlayerExitRange() { }
-
- // 存档集成(由 WorldStateRegistry 通过 _onCollectiblePickup 驱动)
- public void SetCollected(bool val)
- {
- _isCollected = val;
- if (val) gameObject.SetActive(false);
- }
-}
-```
-
-### 4.1 CharmSO + ICharmEffect + EquipmentContext
-
-```csharp
-// Assets/Scripts/Equipment/CharmSO.cs — 程序集 BaseGames.Equipment
-[CreateAssetMenu(menuName = "Equipment/Charm")]
-public class CharmSO : ScriptableObject
-{
- [Header("Identity")]
- public string charmId;
- public string displayNameKey; // 本地化 Key
- [TextArea(2,4)]
- public string descriptionKey; // 本地化 Key
-
- [Header("Visual")]
- public Sprite icon;
- public Color glowColor;
-
- [Header("Slot Cost")]
- [Range(1,4)]
- public int notchCost; // 占用笔记数(1~4)
-
- [Header("Effects")]
- [SerializeReference]
- public List effects; // 多态序列化,支持多个效果叠加
-
- [Header("Lore")]
- public bool isUnique; // 唯一物品,不可重复装备
- public string unlockHint;
-}
-
-// Assets/Scripts/Equipment/ICharmEffect.cs
-[System.Serializable]
-public interface ICharmEffect
-{
- void OnEquip(EquipmentContext ctx);
- void OnUnequip(EquipmentContext ctx);
- string GetEffectDescription();
-}
-
-// ⚠️ 解耦上下文:struct(非 class),架构 09 §4
-public struct EquipmentContext
-{
- public PlayerStats Stats;
- public PlayerFeedback Feedback; // ⚠️ 架构 09 §4(原 Plan 遗漏)
- public EventChannelRegistry Events; // ⚠️ SO 事件频道注册表(架构 09 §4,原 Plan 遗漏)
- public SkillModifierRegistry SkillMods; // ⚠️ 字段名 SkillMods(非 SkillModifiers),架构 09 §4
- public WeaponManager WeaponMgr; // ⚠️ 字段名 WeaponMgr(非 Weapons),架构 09 §4
-}
-
-// Assets/Scripts/Equipment/Effects/ — 内置 CharmEffect 实现(Architecture 09 §5)
-
-// ── 属性加成 ──────────────────────────────────────────────────────────
-[Serializable]
-public class StatModifierEffect : ICharmEffect
-{
- public StatType statType; // ⚠️ 字段名 statType(非 Stat),架构 09 §5:MaxHP / AttackDamage / MoveSpeed / JumpHeight / SoulGain / Defense
- public float flatBonus; // ⚠️ 固定加成(非 Value + IsPercent bool),架构 09 §5
- public float percentBonus; // ⚠️ 百分比加成(如 +0.2 = +20%),架构 09 §5
-
- public void OnEquip(EquipmentContext ctx) => ctx.Stats.AddModifier(statType, flatBonus, percentBonus); // ⚠️ AddModifier(非 ApplyModifier),架构 09 §5
- public void OnUnequip(EquipmentContext ctx) => ctx.Stats.RemoveModifier(statType, flatBonus, percentBonus);
- public string GetEffectDescription() => $"{statType}: +{flatBonus} +{percentBonus*100:0}%";
-}
-
-// ── 攻击速度加成 ──────────────────────────────────────────────────────
-[Serializable]
-public class AttackSpeedEffect : ICharmEffect
-{
- [Range(0.1f, 2.0f)]
- public float speedMultiplier = 1.2f; // ⚠️ 字段名 speedMultiplier(非 SpeedMultiplier),架构 09 §5
-
- public void OnEquip(EquipmentContext ctx) => ctx.Stats.AnimatorSpeedMultiplier += (speedMultiplier - 1f); // ⚠️ 直接修改 AnimatorSpeedMultiplier(非 ApplyAttackSpeedMult),架构 09 §5
- public void OnUnequip(EquipmentContext ctx) => ctx.Stats.AnimatorSpeedMultiplier -= (speedMultiplier - 1f);
- public string GetEffectDescription() => $"攻击速度 +{(speedMultiplier - 1) * 100:0}%";
-}
-
-// ── 命中触发效果 ──────────────────────────────────────────────────────
-[Serializable]
-public class OnHitEffect : ICharmEffect
-{
- public OnHitEffectType effectType; // ⚠️ OnHitEffectType 枚举(非 DamageType),架构 09 §5:ApplyPoison / ApplyFire / KnockbackBoost
- [Range(0f, 1f)]
- public float chance; // ⚠️ 字段名 chance(非 Chance),架构 09 §5
-
- private DamageInfoEventChannelSO _onHitChannel; // ⚠️ 通过 EventChannelRegistry 取得(架构 09 §5)
-
- public void OnEquip(EquipmentContext ctx)
- {
- _onHitChannel = ctx.Events.Get("OnHitConfirmed"); // ⚠️ 架构 09 §5
- _onHitChannel.OnEventRaised += HandleHit;
- }
- public void OnUnequip(EquipmentContext ctx) => _onHitChannel.OnEventRaised -= HandleHit;
-
- private void HandleHit(DamageInfo info)
- {
- if (UnityEngine.Random.value > chance) return;
- // 触发对应效果(由 StatusEffectManager 处理,见 06_CombatModule §12)
- }
- public string GetEffectDescription() => $"命中时 {chance * 100:0}% 概率附加 {effectType}";
-}
-
-// ── 灵魂法术强化 ──────────────────────────────────────────────────────
-[Serializable]
-public class SoulSpellEffect : ICharmEffect // ⚠️ 架构 09 §5(原 Plan 遗漏)
-{
- public SpellType spellType; // SoulAttack / HealingWave
- public int soulCostReduction; // 减少消耗 Soul 点数
-
- public void OnEquip(EquipmentContext ctx)
- => ctx.Stats.RegisterSpellModifier(spellType, soulCostReduction, 0f);
- public void OnUnequip(EquipmentContext ctx)
- => ctx.Stats.UnregisterSpellModifier(spellType, soulCostReduction, 0f);
- public string GetEffectDescription() => $"{spellType} 消耗减少 {soulCostReduction} Soul";
-}
-
-// ── 技能数值修改 ──────────────────────────────────────────────────────
-[Serializable]
-public class SkillNumericModifierEffect : ICharmEffect
-{
- public string TargetSkillId;
- public SkillStat Stat; // enum: Damage, Cost, Cooldown, Range, Duration
- public float Delta;
- public bool IsPercent;
-
- public void OnEquip(EquipmentContext ctx) => ctx.SkillMods.Register(TargetSkillId, Stat, Delta, IsPercent); // ⚠️ ctx.SkillMods(非 ctx.SkillModifiers),架构 09 §5
- public void OnUnequip(EquipmentContext ctx) => ctx.SkillMods.Unregister(TargetSkillId, Stat, Delta, IsPercent);
- public string GetEffectDescription() => $"{TargetSkillId}.{Stat} {(Delta >= 0 ? "+" : "")}{Delta}";
-}
-
-// ── 技能插槽替换 ──────────────────────────────────────────────────────
-[Serializable]
-public class SkillSlotOverrideEffect : ICharmEffect // ⚠️ 架构 09 §5(原 Plan 遗漏)
-{
- public SkillSlotOverride overrideData; // targetForm / targetSlot / replacementSkill / priority
-
- public void OnEquip(EquipmentContext ctx) => ctx.SkillMods.AddSlotOverride(overrideData);
- public void OnUnequip(EquipmentContext ctx) => ctx.SkillMods.RemoveSlotOverride(overrideData);
- public string GetEffectDescription()
- {
- string formStr = overrideData.targetForm != null ? overrideData.targetForm.name : "所有形态";
- string skillName = overrideData.replacementSkill != null ? overrideData.replacementSkill.displayNameKey : "null";
- return $"{formStr}的 {overrideData.targetSlot} 替换为 [{skillName}]";
- }
-}
-```
-
-**Phase 3 实现以下 Charm**(最小集,验证系统可用):
-
-| CharmId | 效果 |
-|---------|------|
-| `Charm_VoidHeart` | MaxHP +2 |
-| `Charm_QuickSlash` | AttackSpeed ×1.3(通过 AnimancerClip 速度倍率)|
-| `Charm_SoulCatcher` | 命中时获得的 SoulPower ×1.5 |
-
-### 4.2 EquipmentManager
-
-```csharp
-// Assets/Scripts/Equipment/EquipmentManager.cs — 程序集 BaseGames.Equipment
-public class EquipmentManager : MonoBehaviour
-{
- [Header("配置")]
- [SerializeField] private EquipmentConfigSO _config; // ⚠️ SO 配置(非 int _totalNotches),含 initialNotchCount,架构 09 §6
-
- [Header("Event Channels")]
- [SerializeField] private CharmEventChannelSO _onCharmEquipped; // ⚠️ 架构 09 §6(原 Plan 遗漏)
- [SerializeField] private CharmEventChannelSO _onCharmUnequipped; // ⚠️ 架构 09 §6(原 Plan 遗漏)
- [SerializeField] private VoidEventChannelSO _onEquipmentChanged;
-
- private List _equipped = new(4);
- private List _collected = new(32);
- private int _currentNotchCapacity;
-
- private EquipmentContext _ctx; // ⚠️ 私有字段,在 Awake() 中构建(非 [SerializeField]),架构 09 §6
-
- private void Awake()
- {
- // ⚠️ EquipmentContext 在 Awake 中通过 GetComponent 构建(架构 09 §6)
- _ctx = new EquipmentContext
- {
- Stats = GetComponent(),
- Feedback = GetComponent(),
- Events = EventChannelRegistry.Instance,
- SkillMods = GetComponent(),
- WeaponMgr = GetComponent(),
- };
- _currentNotchCapacity = _config != null ? _config.initialNotchCount : 3;
- }
-
- public int UsedNotches => _equipped.Sum(c => c.notchCost);
- public int TotalNotches => _currentNotchCapacity;
- public IReadOnlyList Equipped => _equipped;
- public IReadOnlyList Collected => _collected;
-
- /// 装备护符。返回失败原因(null = 成功)。⚠️ 返回 string(非 bool),架构 09 §6
- public string TryEquipCharm(CharmSO charm) // ⚠️ 方法名 TryEquipCharm(非 TryEquip),返回 string,架构 09 §6
- {
- if (_equipped.Contains(charm)) return "已经装备";
- if (!_collected.Contains(charm)) return "尚未收集此魅力";
- if (UsedNotches + charm.notchCost > _currentNotchCapacity)
- return $"笔记不足(需要 {charm.notchCost},剩余 {_currentNotchCapacity - UsedNotches})";
-
- _equipped.Add(charm);
- foreach (var fx in charm.effects) fx.OnEquip(_ctx);
- _onCharmEquipped.Raise(charm);
- _onEquipmentChanged.Raise();
- return null;
- }
-
- public void UnequipCharm(CharmSO charm) // ⚠️ 方法名 UnequipCharm(非 Unequip),架构 09 §6
- {
- if (!_equipped.Remove(charm)) return;
- foreach (var fx in charm.effects) fx.OnUnequip(_ctx);
- _onCharmUnequipped.Raise(charm);
- _onEquipmentChanged.Raise();
- }
-
- // 收集(从 Collectible / ShopController 调用,传 charmId 字符串)
- public void AddToCollection(string charmId);
-
- public void IncreaseNotches(int amount) => _currentNotchCapacity += amount;
-
- // 存档集成(非 ISaveable,直接调用)
- public EquipmentSaveData GetSaveData();
- public void LoadSaveData(EquipmentSaveData data);
-}
-```
-
-### 4.3 ToolSO + FormSkillSO
-
-> **程序集位置(架构 `09_ProgressionModule §7–8`)**:
-> - `FormSkillSO`、`SkillManager`、`SkillModifierRegistry` → `Assets/Scripts/Skills/`,程序集 `BaseGames.Spells`
-> - `ToolSO`、`EquipmentManager` → `Assets/Scripts/Equipment/`,程序集 `BaseGames.Equipment`
-
-**ToolSO**(主动工具,如抓钩/炸弹/气球):
-
-```csharp
-// Assets/Scripts/Equipment/ToolSO.cs — 程序集 BaseGames.Equipment
-[CreateAssetMenu(menuName = "Equipment/Tool")]
-public class ToolSO : ScriptableObject
-{
- public string toolId;
- public string displayNameKey; // 本地化 Key
- public Sprite icon;
- public int maxUses; // -1 = 无限次数
-
- [SerializeReference]
- public IToolEffect effect; // 工具使用效果(多态,Strategy 模式)
-}
-
-public interface IToolEffect
-{
- void Use(PlayerController player);
-}
-```
-
-**FormSkillSO**(各形态魂技能):
-
-```csharp
-// Assets/Scripts/Skills/FormSkillSO.cs — 程序集 BaseGames.Spells
-[CreateAssetMenu(menuName = "Skills/FormSkill")]
-public class FormSkillSO : ScriptableObject
-{
- [Header("Identity")]
- public string skillId;
- public string displayNameKey;
- [TextArea(1,3)] public string descriptionKey;
- public Sprite icon;
-
- [Header("Resource")]
- public SkillResourceType resourceType; // SoulPower / SpiritPower
- public int baseCost;
- public float cooldown;
-
- [Header("Animation")]
- public ClipTransition castAnimation; // Animancer Pro ClipTransition
- public float castLockDuration; // 秒,动画锁帧时长
-
- [Header("Effect")]
- public SkillEffectType effectType;
- public DamageSourceSO damageSource;
-
- [Header("Projectile")]
- public ProjectileConfigSO projectileConfig;
- public bool isHoming;
- public bool holdForContinuous;
-
- [Header("Dash")]
- public float dashForce;
- public float dashDuration;
- public bool isInvincibleDuringDash;
-
- [Header("Explosion")]
- public float explosionDelay;
- public float explosionRadius;
-
- [Header("Feedback")]
- public FeedbackPresetSO castFeedback;
-
- [Header("HitBox Prefab")]
- public GameObject SkillHitBoxPrefab; // 近战/爆炸技能命中盒 Prefab;投射物技能留空
-}
-
-public enum SkillResourceType { SoulPower, SpiritPower }
-public enum SkillEffectType
-{
- MeleeAoE, Projectile, BarrierAura, GroundDive,
- DragonKick, WraithDash, ShadowDecoy, DelayedExplosion
-}
-```
-
-### 4.3.5 ToolSlotManager + ToolHUD(Architecture 09 §7.5)
-
-```csharp
-// Assets/Scripts/Equipment/ToolSlotManager.cs — 程序集 BaseGames.Equipment
-public class ToolSlotManager : MonoBehaviour, ISaveable
-{
- private const int SlotCount = 2;
-
- [SerializeField] private ToolSO[] _slots = new ToolSO[SlotCount];
- [SerializeField] private int[] _remainingUses = new int[SlotCount]; // -1 = 无限
- [SerializeField] private ToolUsedEventChannelSO _onToolUsed;
- private float[] _cooldowns = new float[SlotCount];
-
- /// 装备工具到指定槽位
- public void EquipTool(int slotIndex, ToolSO tool)
- {
- _slots[slotIndex] = tool;
- _remainingUses[slotIndex] = tool?.maxUses ?? -1;
- _cooldowns[slotIndex] = 0f;
- }
-
- /// 使用槽位工具:检查冷却/次数 → 执行 IToolEffect → 触发事件
- public bool TryUseTool(int slotIndex, PlayerController player)
- {
- var tool = _slots[slotIndex];
- if (tool == null) return false;
- if (_cooldowns[slotIndex] > 0f) return false;
- if (_remainingUses[slotIndex] == 0) return false;
-
- tool.effect?.Use(player);
- if (_remainingUses[slotIndex] > 0) _remainingUses[slotIndex]--;
- _cooldowns[slotIndex] = (tool is IToolCooldown cd) ? cd.CooldownDuration : 0f;
- _onToolUsed.Raise(new ToolUsedPayload { SlotIndex = slotIndex, Tool = tool });
- return true;
- }
-
- private void Update()
- {
- for (int i = 0; i < SlotCount; i++)
- if (_cooldowns[i] > 0f) _cooldowns[i] -= Time.deltaTime;
- }
-
- public ToolSO GetTool(int slotIndex) => _slots[slotIndex];
- public float GetCooldownRatio(int slotIndex) => _cooldowns[slotIndex] / GetMaxCooldown(slotIndex);
- public int GetRemainingUses(int slotIndex) => _remainingUses[slotIndex];
-
- private float GetMaxCooldown(int i)
- => (_slots[i] is IToolCooldown cd) ? cd.CooldownDuration : 1f;
-
- // ISaveable
- public void OnSave(SaveData data)
- {
- data.Tools.ToolSlot0 = _slots[0]?.toolId;
- data.Tools.ToolSlot1 = _slots[1]?.toolId;
- }
- public void OnLoad(SaveData data);
-}
-
-/// 工具冷却接口(实现该接口的 ToolSO 才会有冷却)
-public interface IToolCooldown
-{
- float CooldownDuration { get; }
-}
-
-// Assets/Scripts/UI/ToolHUD.cs
-public class ToolHUD : MonoBehaviour
-{
- [SerializeField] private ToolSlotUI[] _slots;
- [SerializeField] private ToolSlotManager _slotManager;
- [SerializeField] private ToolUsedEventChannelSO _onToolUsed;
-
- private void OnEnable() => _onToolUsed.OnEventRaised += RefreshSlot;
- private void OnDisable() => _onToolUsed.OnEventRaised -= RefreshSlot;
-
- private void RefreshSlot(ToolUsedPayload payload)
- => _slots[payload.SlotIndex].Refresh(
- _slotManager.GetTool(payload.SlotIndex),
- _slotManager.GetRemainingUses(payload.SlotIndex),
- _slotManager.GetCooldownRatio(payload.SlotIndex));
-
- private void Update()
- {
- // 实时更新冷却遮罩(每帧刷新)
- for (int i = 0; i < _slots.Length; i++)
- _slots[i].SetCooldownFill(_slotManager.GetCooldownRatio(i));
- }
-}
-
-public class ToolSlotUI : MonoBehaviour
-{
- [SerializeField] private Image _icon;
- [SerializeField] private TMP_Text _usesText;
- [SerializeField] private Image _cooldownMask; // fillAmount 冷却遮罩
-
- public void Refresh(ToolSO tool, int remainingUses, float cooldownRatio)
- {
- _icon.sprite = tool != null ? tool.icon : null;
- _usesText.text = tool == null || tool.maxUses < 0 ? "" : remainingUses.ToString();
- _cooldownMask.fillAmount = cooldownRatio;
- }
-
- public void SetCooldownFill(float ratio) => _cooldownMask.fillAmount = ratio;
-}
-```
-
-### 4.4 SkillManager + SkillModifierRegistry
-
-```csharp
-// Assets/Scripts/Skills/SkillManager.cs — 程序集 BaseGames.Spells
-// SkillManager:管理当前形态可用技能,通过 InputReaderSO 事件驱动技能释放
-public class SkillManager : MonoBehaviour
-{
- [SerializeField] private PlayerStats _stats;
- [SerializeField] private PlayerController _controller;
- [SerializeField] private InputReaderSO _input;
- [SerializeField] private SkillModifierRegistry _modifiers;
- [SerializeField] private Transform _skillSocket; // [SkillSocket] 子节点引用
- [SerializeField] private GlobalObjectPool _pool; // ⚠️ 投射物/技能命中盒从池中取(Architecture 09 §9)
-
- private FormSkillSO _soulSkill, _spirit1, _spirit2;
- private float _soulCooldown, _spirit1Cooldown, _spirit2Cooldown;
-
- private void OnEnable()
- {
- _input.SoulSkillEvent += TrySoulSkill;
- _input.SpiritSkill1StartedEvent += TrySpiritSkill1; // ⚠️ SpiritSkill1StartedEvent(架构 04_InputModule §2 line 103),非 SpiritSkill1Event
- _input.SpiritSkill2StartedEvent += TrySpiritSkill2; // ⚠️ SpiritSkill2StartedEvent(架构 04_InputModule §2 line 105),非 SpiritSkill2Event
- }
-
- private void OnDisable()
- {
- _input.SoulSkillEvent -= TrySoulSkill;
- _input.SpiritSkill1StartedEvent -= TrySpiritSkill1;
- _input.SpiritSkill2StartedEvent -= TrySpiritSkill2;
- }
-
- // 切换形态时由 FormController 调用(⚠️ 单参数 FormSO,架构 09 §6;FormController 调用 _skillManager?.UpdateSkillSet(newForm))
- public void UpdateSkillSet(FormSO form); // 内部从 form 提取 SoulSkill/SpiritSkill1/SpiritSkill2
-
- // 校验冷却 → 消耗资源(GetFinalCost)→ 播放 castAnimation → 激活 SkillHitBox
- private void TrySoulSkill();
- private void TrySpiritSkill1();
- private void TrySpiritSkill2();
-
- // baseCost 经 SkillModifierRegistry 调整后的最终消耗
- private int GetFinalCost(FormSkillSO skill);
-}
-
-// Assets/Scripts/Combat/SkillHitBoxInstance.cs(Architecture 09 §9.5)
-// Prefab: Assets/Prefabs/Skills/SKL_{skillId}_HitBox.prefab
-public class SkillHitBoxInstance : MonoBehaviour
-{
- [SerializeField] private HitBox[] _hitBoxes;
- public System.Action OnHitConfirmed;
-
- private void Awake()
- {
- foreach (var hb in _hitBoxes)
- hb.OnHitConfirmed += info => OnHitConfirmed?.Invoke(info);
- }
-
- public void Activate(DamageSourceSO source, Transform attacker)
- {
- foreach (var hb in _hitBoxes) hb.Activate(source, attacker);
- }
-
- public void AutoDestroyAfter(float duration) => Destroy(gameObject, duration);
-
- private void OnDestroy()
- {
- foreach (var hb in _hitBoxes) hb.Deactivate();
- }
-}
-
-// Assets/Scripts/Skills/SkillModifierRegistry.cs — 程序集 BaseGames.Spells
-// 收集护符对技能数值的修改;SkillManager 查询最终消耗/冷却等
-public class SkillModifierRegistry
-{
- private Dictionary> _overrides = new();
-
- public void Register(string skillId, SkillStat stat, float delta, bool isPercent);
- public void Unregister(string skillId, SkillStat stat, float delta, bool isPercent);
-
- // ⚠️ 主查询方法(架构 09 §10):一次调用获取全部有效参数快照,供 SkillManager.CastRoutine() 使用
- public EffectiveSkillParams GetEffectiveParams(FormSkillSO skill);
-
- // 向后兼容:单字段查询(内部调用 GetEffectiveParams 后提取)
- public float GetModifiedValue(string skillId, SkillStat stat, float baseVal);
-
- // ⚠️ 技能插槽覆盖(供 SkillSlotOverrideEffect 使用,架构 09 §5/10)
- public void AddSlotOverride(SkillSlotOverride overrideData);
- public void RemoveSlotOverride(SkillSlotOverride overrideData);
-}
-
-// ⚠️ 所有数值修改器叠加后的运行时参数快照(架构 09 §10,原 Plan 遗漏)
-public struct EffectiveSkillParams
-{
- public FormSkillSO baseSkill; // 原始 SO 引用(不变,供判断 effectType)
- public int effectiveCost; // 修改后消耗量
- public float effectiveCooldown; // 修改后冷却(秒)
- public float damageMult; // 伤害倍率(1.0 = 无增益)
- public float rangeMult; // 范围倍率(AoE 半径 / 障壁半径 / 爆炸半径)
- public FeedbackPresetSO effectiveFeedback; // 最终特效预设(护符可替换,null = 回退原始)
- public ClipTransition effectiveAnimation; // 最终施法动画(护符可替换,null = 回退原始)
-
- /// 以技能 SO 默认值初始化,无任何修改器加成。
- public static EffectiveSkillParams FromBase(FormSkillSO skill) => new()
- {
- baseSkill = skill,
- effectiveCost = skill.baseCost,
- effectiveCooldown = skill.cooldown,
- damageMult = 1f,
- rangeMult = 1f,
- effectiveFeedback = null,
- effectiveAnimation = null,
- };
-}
-
-public enum SkillStat { Damage, Cost, Cooldown, Range, Duration }
-```
-
-
-### 4.5 RegionDefinitionSO(区域定义)
-
-> **⚠️ 此节内容来自架构 09_ProgressionModule §11,原 Plan 遗漏;已补充。**
-> **文件**:`Assets/Scripts/Progression/RegionDefinitionSO.cs`,命名规范:`Region_{RegionId}.asset`
-
-```csharp
-// Assets/Scripts/Progression/RegionDefinitionSO.cs — 程序集 BaseGames.Progression
-// 每个区域一个 SO 资产,集中管理区域元数据(音频区域、地图颜色、解锁条件等)
-[CreateAssetMenu(menuName = "Progression/RegionDefinition")]
-public class RegionDefinitionSO : ScriptableObject
-{
- public string regionId; // 如 "Cave"(与 AudioZone.regionId 一致)
- public string displayName; // 如 "腐蚀洞穴"
- public Color mapColor; // 地图 UI 上该区域的颜色标识
- public Sprite mapIconSprite; // 地图图标
-
- [Header("解锁条件")]
- public string requiredBossDefeated; // 空字符串 = 无条件
- public AbilityType requiredAbility; // 默认值 0(WallCling)= 无要求时按惯例留默认值并忽略
-
- [Header("关联房间")]
- public string[] roomSceneNames; // 该区域包含的所有场景名
- public string bossSceneName; // Boss 房间场景名
- public string entrySceneName; // 从外部进入该区域的第一个房间
-}
-```
-
-**区域 ID 对照表**(架构 09 §11):
-
-| 区域 ID | 中文名 | Boss | 开放条件 |
-|---------|--------|------|---------|
-| `Forest` | 扎根森林 | Boss_SpiderGuard | 无(起始区域)|
-| `Cave` | 腐蚀洞穴 | Boss_CorrosionWorm | 击败 Boss_SpiderGuard |
-| `Ruins` | 坍塌废墟 | Boss_RuinsKnight | 获得 Dash 能力 |
-| `Abyss` | 深渊裂隙 | Boss_AbyssThroat | 击败 Boss_RuinsKnight |
-| `Core` | 核心熔炉 | FinalBoss | 击败 Boss_AbyssThroat |
-
-### 4.6 ProgressLock(进程锁)
-
-> **⚠️ 此节内容来自架构 09_ProgressionModule §12,原 Plan 遗漏;已补充。**
-> **文件**:`Assets/Scripts/Progression/ProgressLock.cs`
-> 单向/永久性阻挡,需满足特定条件(击败 Boss 或持有道具)才能解锁。与 `AbilityGate` 的区别:ProgressLock 基于 Boss 击败/道具持有而非能力解锁。
-
-```csharp
-// Assets/Scripts/Progression/ProgressLock.cs — 程序集 BaseGames.Progression
-public class ProgressLock : MonoBehaviour
-{
- [Header("解锁条件")]
- [SerializeField] private string _requiredBossId; // 空 = 不检查 Boss
- [SerializeField] private string _requiredItemId; // 空 = 不检查道具
-
- [Header("物理表现")]
- [SerializeField] private GameObject _lockedVisuals; // 锁住状态视觉
- [SerializeField] private GameObject _unlockedVisuals; // 开启状态视觉(可 null)
- [SerializeField] private Collider2D _blockCollider;
-
- [Header("存档")]
- [SerializeField] private string _lockId; // 唯一 ID,存档记录开启状态
-
- [Header("Event Channels")]
- [SerializeField] private StringEventChannelSO _onBossDefeated; // EVT_BossDefeated
-
- private void Start()
- {
- bool isUnlocked = CheckUnlocked();
- ApplyState(isUnlocked);
- if (!isUnlocked)
- _onBossDefeated.OnEventRaised += OnBossDefeated;
- }
-
- private void OnDestroy() => _onBossDefeated.OnEventRaised -= OnBossDefeated;
-
- private void OnBossDefeated(string bossId)
- {
- if (_requiredBossId == bossId && CheckUnlocked())
- ApplyState(true);
- }
-
- private bool CheckUnlocked()
- {
- var save = SaveManager.Instance.Data;
- if (!string.IsNullOrEmpty(_requiredBossId) && !save.World.DefeatedBossIds.Contains(_requiredBossId))
- return false;
- return save.World.OpenedDoors.Contains(_lockId);
- }
-
- private void ApplyState(bool unlocked)
- {
- _blockCollider.enabled = !unlocked;
- _lockedVisuals.SetActive(!unlocked);
- if (_unlockedVisuals != null)
- _unlockedVisuals.SetActive(unlocked);
- }
-}
-```
-
-### 4.7 BossProgressTracker(Boss 进程追踪)
-
-> **⚠️ 此节内容来自架构 09_ProgressionModule §13,原 Plan 遗漏;已补充。**
-> **文件**:`Assets/Scripts/Progression/BossProgressTracker.cs`
-> 轻量辅助组件,挂载在 Boss 房间的 BossTrigger 同一对象上,监听 Boss 死亡事件并通知存档系统。
-
-```csharp
-// Assets/Scripts/Progression/BossProgressTracker.cs — 程序集 BaseGames.Progression
-public class BossProgressTracker : MonoBehaviour
-{
- [SerializeField] private string _bossId; // 如 "Boss_SpiderGuard"
- [SerializeField] private string[] _unlocksProgressLockIds; // 击败后解锁哪些 ProgressLock
-
- [Header("Event Channels")]
- [SerializeField] private StringEventChannelSO _onBossDefeated; // 监听(EVT_BossDefeated)
- [SerializeField] private StringEventChannelSO _onBossDefeatedForSave; // 广播 → SaveSystem
-
- private void OnEnable() => _onBossDefeated.OnEventRaised += OnBossDefeated;
- private void OnDisable() => _onBossDefeated.OnEventRaised -= OnBossDefeated;
-
- private void OnBossDefeated(string bossId)
- {
- if (bossId != _bossId) return;
- // 通过事件频道通知 SaveSystem(零耦合)
- _onBossDefeatedForSave.Raise(bossId);
- // SaveSystem 收到后:data.World.DefeatedBossIds.Add(bossId); 并解锁相关 ProgressLock
- }
-}
-```
-
-### 4.8 HPContainerPickup(HP 容器拾取)
-
-> **⚠️ 此节内容来自架构 09_ProgressionModule §14,原 Plan 遗漏;已补充。**
-> **文件**:`Assets/Scripts/Progression/HPContainerPickup.cs`
-> 永久 MaxHP +2 的可拾取物件,通过事件频道零耦合通知 SaveSystem。
-
-```csharp
-// Assets/Scripts/Progression/HPContainerPickup.cs — 程序集 BaseGames.Progression
-public class HPContainerPickup : MonoBehaviour
-{
- [SerializeField] private string _collectibleId; // 存档用唯一 ID
- [SerializeField] private InputReaderSO _inputReader; // ⚠️ 架构 09 §14(原 Plan 遗漏):禁用/恢复玩家输入
-
- [Header("Event Channels")]
- [SerializeField] private StringEventChannelSO _onMaxHPContainerPickedUp; // → SaveSystem
- [SerializeField] private IntEventChannelSO _onMaxHPChanged; // → HUDController
-
- private void OnTriggerEnter2D(Collider2D other)
- {
- if (!other.CompareTag("Player")) return;
- var save = SaveManager.Instance.Data;
- // ⚠️ 防重复:已收集则跳过(存档 CollectedIds 检查)
- if (save != null && save.World.CollectedIds.Contains(_collectibleId)) return;
- StartCoroutine(PickupSequence());
- }
-
- private IEnumerator PickupSequence()
- {
- _inputReader.EnableGameplayInput(false); // ⚠️ 架构 09 §14(原 Plan 遗漏)
- gameObject.SetActive(false);
-
- // Feel MMF_Player 播放获取特效(外部引用或 GetComponent)
- yield return new WaitForSeconds(0.8f);
-
- // 零耦合:通过事件频道通知 SaveSystem
- _onMaxHPContainerPickedUp.Raise(_collectibleId);
- // SaveSystem:data.Player.MaxHP += 2; data.World.CollectedIds.Add(id); Save();
-
- yield return new WaitForSeconds(0.5f);
- _inputReader.EnableGameplayInput(true); // ⚠️ 架构 09 §14(原 Plan 遗漏)
- }
-}
-```
-
----
-
-## 5. Week 13:任务与挑战房间 ✅ 完成(2026-05-11)
-
-**参考文档**:`22_QuestChallengeModule.md`
-
-### 5.0 任务与挑战数据层 SO(创建顺序)
-
-依赖最底层的数据 SO 必须最先创建:`QuestObjectiveSO → RewardSO → QuestSO → ChallengeEncounterSO → BossRushSequenceSO → ChallengeRoomSO`
-
-```csharp
-// Assets/Scripts/Quest/QuestSO.cs
-// ⚠️ menuName = "Quest/Quest"(架构 22 §2)
-namespace BaseGames.Quest
-{
- [CreateAssetMenu(menuName = "Quest/Quest")]
- public class QuestSO : ScriptableObject
- {
- [Header("标识")]
- public string questId;
- public string displayName;
- [TextArea(2, 6)]
- public string description;
- public Sprite icon;
-
- [Header("目标链")]
- public QuestObjectiveSO[] objectives;
-
- [Header("前置条件")]
- public string[] prerequisiteQuestIds;
- public int minAffinityToAccept;
-
- [Header("奖励")]
- public RewardSO reward;
-
- [Header("失败条件(可选)")]
- public bool canFail;
- public QuestObjectiveSO failCondition;
-
- [Header("完成后续任务(分支)")]
- public QuestBranch[] branches;
- }
-
- [Serializable]
- public class QuestBranch
- {
- public string conditionQuestId;
- public QuestSO nextQuest;
- public string npcDialogueKey;
- }
-}
-```
-
-```csharp
-// Assets/Scripts/Quest/QuestObjectiveSO.cs
-// ⚠️ 多态目标体系(架构 22 §3),抽象基类 + 5 个具体子类,替代旧的单类+ObjectiveType枚举方案
-namespace BaseGames.Quest
-{
- /// 任务目标基类(抽象)。所有具体目标类型均继承此类。
- public abstract class QuestObjectiveSO : ScriptableObject
- {
- [Header("标识")]
- public string objectiveId;
- [TextArea(1, 4)]
- public string displayText;
- public bool IsOptional;
-
- public abstract void RegisterListeners(IQuestObjectiveListener listener);
- public abstract void UnregisterListeners(IQuestObjectiveListener listener);
- /// 根据当前进度判断目标是否完成。
- public abstract bool EvaluateCompletion(QuestObjectiveState state);
- }
-
- [CreateAssetMenu(menuName = "Quest/Objective/TalkToNPC")]
- public class TalkToNPCObjective : QuestObjectiveSO
- {
- public string targetNpcId;
- public override void RegisterListeners(IQuestObjectiveListener l) => l.RegisterListeners(this);
- public override void UnregisterListeners(IQuestObjectiveListener l) => l.UnregisterListeners(this);
- public override bool EvaluateCompletion(QuestObjectiveState s) => s.progressCount >= 1;
- }
-
- [CreateAssetMenu(menuName = "Quest/Objective/Defeat")]
- public class DefeatEnemyObjective : QuestObjectiveSO
- {
- public string targetEnemyId;
- [Min(1)] public int defeatCount = 1;
- public override void RegisterListeners(IQuestObjectiveListener l) => l.RegisterListeners(this);
- public override void UnregisterListeners(IQuestObjectiveListener l) => l.UnregisterListeners(this);
- public override bool EvaluateCompletion(QuestObjectiveState s) => s.progressCount >= defeatCount;
- }
-
- [CreateAssetMenu(menuName = "Quest/Objective/Collect")]
- public class CollectItemObjective : QuestObjectiveSO
- {
- public string itemId;
- [Min(1)] public int collectCount = 1;
- public override void RegisterListeners(IQuestObjectiveListener l) => l.RegisterListeners(this);
- public override void UnregisterListeners(IQuestObjectiveListener l) => l.UnregisterListeners(this);
- public override bool EvaluateCompletion(QuestObjectiveState s) => s.progressCount >= collectCount;
- }
-
- [CreateAssetMenu(menuName = "Quest/Objective/Reach")]
- public class ReachAreaObjective : QuestObjectiveSO
- {
- public string sceneName;
- public string markerTag;
- public override void RegisterListeners(IQuestObjectiveListener l) => l.RegisterListeners(this);
- public override void UnregisterListeners(IQuestObjectiveListener l) => l.UnregisterListeners(this);
- public override bool EvaluateCompletion(QuestObjectiveState s) => s.progressCount >= 1;
- }
-
- [CreateAssetMenu(menuName = "Quest/Objective/UseSkill")]
- public class UseSkillObjective : QuestObjectiveSO
- {
- public AbilityType requiredAbility;
- [Min(1)] public int useCount = 1;
- public override void RegisterListeners(IQuestObjectiveListener l) => l.RegisterListeners(this);
- public override void UnregisterListeners(IQuestObjectiveListener l) => l.UnregisterListeners(this);
- public override bool EvaluateCompletion(QuestObjectiveState s) => s.progressCount >= useCount;
- }
-}
-```
-
-```csharp
-// Assets/Scripts/Quest/RewardSO.cs
-// ⚠️ 用 unlocksAbility bool + unlockedAbility 字段替代 AbilityType.None(架构 09 §1 无 None 值)
-namespace BaseGames.Quest
-{
- [CreateAssetMenu(menuName = "Quest/Reward")]
- public class RewardSO : ScriptableObject
- {
- public int geo;
- public int soulBonus;
- public string[] itemIds;
- public int affinityBonus;
- public string unlockDialogueKey;
- public bool unlocksAbility = false; // ⚠️ 替代 AbilityType.None(架构 09 §1)
- public AbilityType unlockedAbility; // 仅 unlocksAbility == true 时有效
-
- public void Apply(PlayerStats player)
- {
- if (geo > 0) player.AddGeo(geo);
- if (soulBonus > 0) player.ExtendSoulMax(soulBonus);
- if (unlocksAbility) player.UnlockAbility(unlockedAbility);
- // ⚠️ InventoryManager 不在架构中(Architecture 总览无此类)
- // 物品发放通过 EVT_CollectiblePickup 事件频道路由(itemId string)
- // 实际接线在 QuestManager.CompleteQuest 调用处注入 StringEventChannelSO
- foreach (var id in itemIds)
- Debug.Log($"[Reward] Grant item: {id}"); // 占位 → Phase 实际实现时替换为事件频道
- }
- }
-}
-```
-
-```csharp
-// Assets/Scripts/Quest/ChallengeRoomSO.cs
-namespace BaseGames.Challenge
-{
- [CreateAssetMenu(menuName = "Challenge/ChallengeRoom")]
- public class ChallengeRoomSO : ScriptableObject
- {
- [Header("标识")]
- public string challengeId;
- public string displayName;
- public ChallengeType challengeType;
-
- [Header("波次(非 BossRush)")]
- public ChallengeEncounterSO[] encounters;
-
- [Header("Boss Rush")]
- public BossRushSequenceSO bossRushSequence;
-
- [Header("限制")]
- public float timeLimit; // 0 = 无时限
- public bool requireNoHit;
- public int minComboRequired;
-
- [Header("奖励")]
- public RewardSO firstClearReward;
- public RewardSO repeatedReward;
-
- [Header("解锁条件")]
- public string[] prerequisiteBossIds;
- }
-
- public enum ChallengeType { Survival, TimeTrial, BossRush, NoHit }
-}
-```
-
-```csharp
-// Assets/Scripts/Quest/ChallengeEncounterSO.cs
-namespace BaseGames.Challenge
-{
- [CreateAssetMenu(menuName = "Challenge/Encounter")]
- public class ChallengeEncounterSO : ScriptableObject
- {
- [Serializable]
- public struct SpawnEntry
- {
- public string enemyAddressKey;
- public Transform spawnPoint;
- public int count;
- }
-
- public SpawnEntry[] enemies;
- public float waveDelay;
- }
-}
-```
-
-```csharp
-// Assets/Scripts/Quest/BossRushSequenceSO.cs
-namespace BaseGames.Challenge
-{
- [CreateAssetMenu(menuName = "Challenge/BossRushSequence")]
- public class BossRushSequenceSO : ScriptableObject
- {
- [Serializable]
- public struct BossEntry
- {
- public string bossSceneName;
- public string bossId;
- public float hpRestoreRatio; // 击败本 Boss 后玩家恢复 HP 比例(默认 0.3)
- }
-
- public BossEntry[] bosses;
- }
-}
-```
-
-```csharp
-// Assets/Scripts/Quest/ChallengeRoomTrigger.cs
-// ⚠️ 通过 EVT_SceneLoadRequest 频道触发加载(SceneLoader 无 Instance,架构 03 §3)
-namespace BaseGames.Challenge
-{
- [RequireComponent(typeof(Collider2D))]
- public class ChallengeRoomTrigger : MonoBehaviour, IInteractable
- {
- [SerializeField] ChallengeRoomSO _challengeData;
- [SerializeField] string _challengeSceneName;
- [SerializeField] SceneLoadRequestEventChannelSO _onSceneLoadRequest; // EVT_SceneLoadRequest
-
- public string InteractPrompt => $"进入挑战:{_challengeData.displayName}";
- public bool CanInteract => IsUnlocked();
-
- public void Interact(Transform player)
- {
- if (!IsUnlocked()) return;
- _onSceneLoadRequest.Raise(new SceneLoadRequest
- {
- SceneName = _challengeSceneName,
- EntryTransitionId = string.Empty,
- ShowLoadingScreen = false,
- IsRespawn = false,
- });
- }
-
- public void OnPlayerEnterRange(Transform player) { }
- public void OnPlayerExitRange() { }
-
- bool IsUnlocked()
- {
- foreach (var bossId in _challengeData.prerequisiteBossIds)
- if (!SaveManager.Instance.IsBossDefeated(bossId)) return false; // ⚠️ 架构 12 §8 确认存在 IsBossDefeated(bossId)
- return true;
- }
- }
-}
-```
-
-### 5.1 QuestManager
-
-```csharp
-// Assets/Scripts/Quest/QuestManager.cs
-// ⚠️ 事件频道已分拆(架构 22 §5):替代旧 QuestStateChangedEventChannel 单频道
-namespace BaseGames.Quest
-{
- ///
- /// 运行时任务管理器,挂在 Persistent 场景 [GameManagers] 下。
- /// 通过事件频道追踪目标进度,不主动轮询。
- ///
- public class QuestManager : MonoBehaviour
- {
- // ── Inspector ────────────────────────────────────────
- [SerializeField] QuestSO[] _allQuests;
- [SerializeField] TransformEventChannelSO _onEnemyDied; // EVT_EnemyDied
- [SerializeField] StringEventChannelSO _onCollectiblePickup; // EVT_CollectiblePickup(itemId)
- [SerializeField] StringEventChannelSO _onSceneLoaded; // EVT_SceneLoaded(sceneName)
- [SerializeField] StringEventChannelSO _onNpcDialogueCompleted; // EVT_NpcDialogueCompleted(npcId)
-
- // ⚠️ 分拆为粒度更细的事件频道(架构 22 §5,替代旧 _onQuestStateChanged 单频道)
- [SerializeField] StringEventChannelSO _onQuestStarted; // Raise:questId
- [SerializeField] StringEventChannelSO _onQuestCompleted; // Raise:questId
- [SerializeField] StringEventChannelSO _onQuestFailed; // Raise:questId
- [SerializeField] QuestObjectiveEventChannelSO _onObjectiveUpdated; // Raise:objectiveId + progress
-
- // ── Runtime State ────────────────────────────────────
- readonly Dictionary _questStates = new();
- readonly Dictionary _objectiveStates = new(); // ⚠️ 替代旧 _objectiveProgress: Dictionary
-
- public static QuestManager Instance { get; private set; }
-
- // ⚠️ 公开属性供 QuestGiver / QuestLogUI 订阅(架构 22 §5)
- public StringEventChannelSO OnQuestStarted => _onQuestStarted;
- public StringEventChannelSO OnQuestCompleted => _onQuestCompleted;
-
- void Awake() => Instance = this;
-
- void OnEnable()
- {
- _onEnemyDied.OnEventRaised += HandleEnemyDefeated;
- _onCollectiblePickup.OnEventRaised += HandleItemCollected;
- _onSceneLoaded.OnEventRaised += HandleSceneLoaded;
- _onNpcDialogueCompleted.OnEventRaised += HandleNpcDialogue;
- }
-
- void OnDisable()
- {
- _onEnemyDied.OnEventRaised -= HandleEnemyDefeated;
- _onCollectiblePickup.OnEventRaised -= HandleItemCollected;
- _onSceneLoaded.OnEventRaised -= HandleSceneLoaded;
- _onNpcDialogueCompleted.OnEventRaised -= HandleNpcDialogue;
- }
-
- // ── 公共 API ──────────────────────────────────────────
-
- public void AcceptQuest(string questId)
- {
- if (!CanAccept(questId)) return;
- _questStates[questId] = QuestState.Active;
- _onQuestStarted.Raise(questId); // ⚠️ 独立频道(架构 22 §5,非 QuestStateChangedEvent)
- }
-
- public void CompleteQuest(string questId, PlayerStats player)
- {
- if (!IsReadyToComplete(questId)) return;
- var quest = GetQuestSO(questId);
- quest.reward?.Apply(player);
- _questStates[questId] = QuestState.Completed;
- _onQuestCompleted.Raise(questId); // ⚠️ 独立频道(架构 22 §5)
-
- // 解锁后续任务
- foreach (var branch in quest.branches)
- {
- if (string.IsNullOrEmpty(branch.conditionQuestId) ||
- GetState(branch.conditionQuestId) == QuestState.Completed)
- {
- if (branch.nextQuest != null)
- _questStates[branch.nextQuest.questId] = QuestState.Available;
- break;
- }
- }
- }
-
- public QuestState GetState(string questId)
- => _questStates.TryGetValue(questId, out var s) ? s : QuestState.Unavailable;
-
- public bool IsReadyToComplete(string questId)
- {
- var quest = GetQuestSO(questId);
- if (quest == null || GetState(questId) != QuestState.Active) return false;
- foreach (var obj in quest.objectives)
- {
- if (!obj.IsOptional && !IsObjectiveComplete(obj)) return false;
- }
- return true;
- }
-
- // ── 存档(非 ISaveable,由 SaveManager 直接访问) ────────────
-
- public IReadOnlyDictionary QuestStates => _questStates;
-
- public void LoadFromSaveData(QuestSaveData data)
- {
- _questStates.Clear();
- _objectiveStates.Clear();
- foreach (var (id, stateInt) in data.QuestStates)
- _questStates[id] = (QuestState)stateInt;
- foreach (var (id, progress) in data.ObjectiveProgress)
- _objectiveStates[id] = new QuestObjectiveState { progressCount = progress };
- }
-
- // ── 私有 ─────────────────────────────────────────────
-
- bool CanAccept(string questId)
- {
- if (GetState(questId) != QuestState.Available) return false;
- var quest = GetQuestSO(questId);
- foreach (var pre in quest.prerequisiteQuestIds)
- if (GetState(pre) != QuestState.Completed) return false;
- return true;
- }
-
- bool IsObjectiveComplete(QuestObjectiveSO obj)
- {
- _objectiveStates.TryGetValue(obj.objectiveId, out var s);
- s ??= new QuestObjectiveState();
- return obj.EvaluateCompletion(s); // ⚠️ 多态调用(架构 22 §3),替代旧 ObjectiveType switch
- }
-
- void HandleEnemyDefeated(Transform enemyTransform)
- {
- var enemyBase = enemyTransform.GetComponent();
- if (enemyBase == null) return;
- // ⚠️ EnemyId 在 EnemyStatsSO(Architecture 07 §6),不在 EnemyBase 上;
- // EnemyBase 需暴露 public string EnemyId => _statsSO?.EnemyId; 便捷属性
- string enemyId = enemyBase.EnemyId;
- foreach (var (qid, state) in _questStates)
- {
- if (state != QuestState.Active) continue;
- var quest = GetQuestSO(qid);
- foreach (var obj in quest.objectives)
- {
- if (obj is DefeatEnemyObjective def && def.targetEnemyId == enemyId)
- IncrementProgress(obj.objectiveId); // ⚠️ 用 is 模式匹配替代旧 ObjectiveType 枚举
- }
- }
- }
-
- void HandleItemCollected(string itemId) { /* 同上,匹配 CollectItemObjective */ }
- void HandleNpcDialogue(string npcId) { /* 同上,匹配 TalkToNPCObjective */ }
- void HandleSceneLoaded(string sceneName) { /* 同上,匹配 ReachAreaObjective */ }
-
- void IncrementProgress(string objectiveId)
- {
- if (!_objectiveStates.TryGetValue(objectiveId, out var s))
- s = _objectiveStates[objectiveId] = new QuestObjectiveState();
- s.progressCount++;
- _onObjectiveUpdated.Raise(new QuestObjectiveEvent { ObjectiveId = objectiveId, Progress = s.progressCount });
- }
-
- QuestSO GetQuestSO(string id) => System.Array.Find(_allQuests, q => q.questId == id);
- }
-
- public enum QuestState { Unavailable, Available, Active, Completed, Failed }
-
- /// 记录单个目标的运行时进度(架构 22 §5)。
- public class QuestObjectiveState
- {
- public bool completed = false;
- public int progressCount = 0;
- }
-}
-```
-
-### 5.2 ChallengeRoomManager
-
-```csharp
-// Assets/Scripts/Quest/ChallengeRoomManager.cs
-// ⚠️ 字段名、方法名、事件频道与架构 22_QuestChallengeModule §12 完全对齐
-namespace BaseGames.Challenge
-{
-public class ChallengeRoomManager : MonoBehaviour
-{
- [SerializeField] ChallengeRoomSO _challengeData; // ⚠️ _challengeData(非 _config)
- [SerializeField] StringEventChannelSO _onChallengeCompleted; // → EVT_ChallengeCompleted(challengeId)
- [SerializeField] StringEventChannelSO _onChallengeFailed; // → EVT_ChallengeFailed(challengeId)
- // ⚠️ PlayerController 无 Instance(Architecture 05 §2);挑战房间场景持有玩家引用
- [SerializeField] PlayerController _player;
-
- int _currentEncounterIndex;
- int _remainingEnemies; // ⚠️ _remainingEnemies(非 _aliveEnemyCount)
- float _elapsedTime; // 超时检测用
- bool _isRunning;
- bool _noHitViolated;
-
- void Start() => StartChallenge();
-
- void Update()
- {
- if (!_isRunning) return;
- _elapsedTime += Time.deltaTime;
- // 超时失败
- if (_challengeData.timeLimit > 0 && _elapsedTime >= _challengeData.timeLimit)
- FailChallenge();
- }
-
- void StartChallenge()
- {
- SaveManager.Instance.QuickSave(); // ⚠️ 架构 12 §8 确认存在 QuickSave()(专用快速存档槽)
- _isRunning = true;
- _currentEncounterIndex = 0;
- SpawnWave(_currentEncounterIndex); // ⚠️ SpawnWave(非 StartNextEncounter)
- }
-
- void SpawnWave(int index) // ⚠️ 方法名 SpawnWave(int index)
- {
- var enc = _challengeData.encounters[index];
- _remainingEnemies = 0;
- foreach (var entry in enc.enemies)
- {
- for (int i = 0; i < entry.count; i++)
- {
- _remainingEnemies++;
- Addressables.InstantiateAsync(entry.enemyAddressKey, entry.spawnPoint.position, Quaternion.identity)
- .Completed += handle =>
- {
- if (handle.Result.TryGetComponent(out var enemy))
- enemy.OnDied += OnEnemyDefeated;
- };
- }
- }
- }
-
- void OnEnemyDefeated() // ⚠️ OnEnemyDefeated(),无参数(非 OnEnemyDied(Transform))
- {
- _remainingEnemies--;
- if (_remainingEnemies > 0) return;
-
- _currentEncounterIndex++;
- if (_currentEncounterIndex >= _challengeData.encounters.Length)
- CompleteChallenge();
- else
- StartCoroutine(DelayedNextWave(_challengeData.encounters[_currentEncounterIndex].waveDelay));
- }
-
- IEnumerator DelayedNextWave(float delay)
- {
- yield return new WaitForSeconds(delay);
- SpawnWave(_currentEncounterIndex);
- }
-
- void CompleteChallenge()
- {
- _isRunning = false;
- // ⚠️ 架构 12 §8 确认存在 IsFirstClear(challengeId)
- var reward = SaveManager.Instance.IsFirstClear(_challengeData.challengeId)
- ? _challengeData.firstClearReward
- : _challengeData.repeatedReward;
- reward?.Apply(_player.Stats);
- _onChallengeCompleted.Raise(_challengeData.challengeId);
- }
-
- void FailChallenge()
- {
- _isRunning = false;
- _onChallengeFailed.Raise(_challengeData.challengeId);
- SaveManager.Instance.QuickLoad(); // ⚠️ 架构 12 §8 确认存在 QuickLoad()(读回快速存档槽)
- }
-}
-} // namespace BaseGames.Challenge
-```
-
-### 5.3 QuestGiver
-
-```csharp
-// Assets/Scripts/Quest/QuestGiver.cs
-// ⚠️ 继承 InteractableNPC(架构 22 §6),不用 [RequireComponent]+MonoBehaviour 组合方式
-namespace BaseGames.Quest
-{
-// 继承 InteractableNPC,负责发布/完成任务并根据任务状态切换对话
-public class QuestGiver : InteractableNPC
-{
- [Header("任务")]
- [SerializeField] QuestSO[] _offeredQuests; // ⚠️ QuestSO 数组(架构 22 §6,非单个 string _questId)
-
- [Header("对话版本(根据任务状态切换)")]
- [SerializeField] DialogueSequenceSO _availableDialogue; // ⚠️ DialogueSequenceSO 引用(非 string key)
- [SerializeField] DialogueSequenceSO _activeDialogue;
- [SerializeField] DialogueSequenceSO _readyDialogue;
- [SerializeField] DialogueSequenceSO _completedDialogue;
-
- // ⚠️ 不需要 OnEnable/OnDisable 订阅 QuestManager.OnQuestStateChanged(该频道已废弃)
- // 对话切换通过 override GetCurrentDialogue() 在每次 Interact 时动态获取
-
- // ── Interact_Internal 覆盖(在启动对话前处理任务逻辑)────────
- protected override void Interact_Internal(Transform player) // ⚠️ override Interact_Internal(架构 22 §6)
- {
- var quest = GetCurrentQuest();
- if (quest == null) return;
-
- var state = QuestManager.Instance.GetState(quest.questId);
- if (state == QuestState.Available)
- QuestManager.Instance.AcceptQuest(quest.questId);
- else if (QuestManager.Instance.IsReadyToComplete(quest.questId))
- {
- // ⚠️ PlayerController 无 Instance;通过 player 参数获取
- QuestManager.Instance.CompleteQuest(quest.questId,
- player.GetComponent()?.Stats);
- }
- }
-
- // ── 返回与当前最高优先级任务状态匹配的对话 SO ──────────────────
- protected override DialogueSequenceSO GetCurrentDialogue() // ⚠️ override(架构 22 §6)
- {
- var quest = GetCurrentQuest();
- if (quest == null) return base.GetCurrentDialogue();
-
- return QuestManager.Instance.GetState(quest.questId) switch
- {
- QuestState.Available => _availableDialogue,
- QuestState.Active => QuestManager.Instance.IsReadyToComplete(quest.questId)
- ? _readyDialogue : _activeDialogue,
- QuestState.Completed => _completedDialogue,
- _ => base.GetCurrentDialogue(),
- };
- }
-
- // 返回当前处于 Available 或 Active 状态的第一个任务
- QuestSO GetCurrentQuest()
- {
- if (_offeredQuests == null) return null;
- foreach (var q in _offeredQuests)
- {
- var s = QuestManager.Instance.GetState(q.questId);
- if (s == QuestState.Available || s == QuestState.Active) return q;
- }
- return null;
- }
-}
-} // namespace BaseGames.Quest
-```
-
----
-
-## 6. Week 14:地图/商店/存档迁移 ✅ 完成(P3-5,2026-05-11)
-
-**参考文档**:`15_MapShopModule.md`
-
-### 6.0 Map/Shop 数据层 SO
-
-```csharp
-// Assets/Scripts/World/Map/MapRoomDataSO.cs
-[CreateAssetMenu(menuName = "World/Map/RoomData")]
-public class MapRoomDataSO : ScriptableObject
-{
- [Header("基础信息")]
- public string RoomId;
- public string RegionId;
- public string DisplayName;
-
- [Header("地图布局(格子坐标,单位:格)")]
- public Vector2Int GridPosition;
- public Vector2Int GridSize;
-
- [Header("房间轮廓纹理")]
- public Texture2D RoomOutlineTex; // ⚠️ 用于地图 UI 显示房间形状(架构 15 §1.1;null = 回退到矩形格子)
-
- [Header("出口信息")]
- public RoomExitData[] Exits; // ⚠️ 所有出口定义(架构 15 §1.1)
-
- [Header("特殊标记")]
- public bool IsBossRoom;
- public bool IsSavePoint;
- public bool IsShop;
- public Sprite MapIconOverride; // ⚠️ null = 按 isXxx 自动选择图标(架构 15 §1.1)
-}
-
-// ⚠️ 出口数据(架构 15_MapShopModule §1.1)
-[Serializable]
-public struct RoomExitData
-{
- public string TargetRoomId; // 连接的目标房间 ID
- public Vector2Int ExitGridPos; // 出口在格子地图上的位置
- public ExitDirection Direction; // 出口方向
-}
-
-public enum ExitDirection { Up, Down, Left, Right } // ⚠️ 架构 15 §1.1
-
-// Assets/Scripts/World/Map/MapDatabaseSO.cs
-[CreateAssetMenu(menuName = "World/Map/MapDatabase")]
-public class MapDatabaseSO : ScriptableObject
-{
- public MapRoomDataSO[] AllRooms;
-
- private Dictionary _index;
- public MapRoomDataSO GetRoom(string roomId)
- {
- if (_index == null)
- _index = AllRooms.ToDictionary(r => r.RoomId);
- _index.TryGetValue(roomId, out var r);
- return r;
- }
-}
-```
-
-### 6.1 MapModule(Fog of War)
-
-```csharp
-// Assets/Scripts/World/Map/MapManager.cs
-// ⚠️ 类名为 MapManager,以架构 15_MapShopModule §1 为准(非 MapController)
-[DefaultExecutionOrder(-700)]
-public class MapManager : MonoBehaviour, ISaveable
-{
- [SerializeField] private MapDatabaseSO _database; // ⚠️ MapDatabaseSO(非 MapDataSO)
-
- [Header("Event Channels")]
- [SerializeField] private StringEventChannelSO _onRoomEntered; // ⚠️ 订阅此频道(非 EVT_SceneLoaded,房间进入由 RoomTransition 专用频道广播)
- [SerializeField] private StringEventChannelSO _onMapUpdated; // 发布:房间发现时刷新地图
-
- // ⚠️ 必须有 Instance Singleton(架构 15_MapShopModule §1.2;MapPanel.BuildGrid 依赖此字段)
- public static MapManager Instance { get; private set; }
-
- // ⚠️ 三级可见性(架构 15 §1.2):
- // Unknown → 未进入过(默认)
- // Explored → 进入过但未购买地图(显示轮廓/格子)
- // Mapped → 已完整获取地图信息(显示图标/名称)
- private HashSet _exploredRooms = new(); // ⚠️ 玩家踏入过(非 _discoveredRooms)
- private HashSet _mappedRooms = new(); // ⚠️ 完整地图信息(购买 MapFragment 或存档点揭示)
-
- private void Awake()
- {
- if (Instance != null && Instance != this) { Destroy(gameObject); return; }
- Instance = this;
- }
-
- // ── 事件订阅 ────────────────────────────────────────────────────
- private void OnEnable() => _onRoomEntered.OnEventRaised += OnRoomEntered;
- private void OnDisable() => _onRoomEntered.OnEventRaised -= OnRoomEntered;
-
- private void OnRoomEntered(string roomId) // ⚠️ private(非 public MarkDiscovered)
- {
- bool changed = _exploredRooms.Add(roomId);
- if (changed) _onMapUpdated.Raise(roomId); // 通知 MapPanel 刷新
- }
-
- /// 标记为已完整获取地图信息(购买 MapFragment SO 触发)。⚠️ 架构 15 §1.2
- public void SetMapped(string roomId)
- {
- _exploredRooms.Add(roomId);
- if (_mappedRooms.Add(roomId))
- _onMapUpdated.Raise(roomId);
- }
-
- // UI 查询
- public bool IsExplored(string roomId) => _exploredRooms.Contains(roomId); // ⚠️ 架构 15 §1.2
- public bool IsMapped(string roomId) => _mappedRooms.Contains(roomId); // ⚠️ 架构 15 §1.2
- public bool IsDiscovered(string roomId) => _exploredRooms.Contains(roomId); // 向后兼容别名
-
- // ── ISaveable ─────────────────────────────────────────────────────
- // ⚠️ 存储 ExploredRooms + MappedRooms 两个字段(List),架构 15 §1.2 + §3
- public void OnSave(SaveData data)
- {
- data.Map.ExploredRooms = _exploredRooms.ToList();
- data.Map.MappedRooms = _mappedRooms.ToList();
- }
-
- public void OnLoad(SaveData data)
- {
- _exploredRooms = new HashSet(data.Map.ExploredRooms ?? new List());
- _mappedRooms = new HashSet(data.Map.MappedRooms ?? new List());
- }
-}
-```
-
-地图 UI 通过 `MapPanel.cs`(Architecture §1.3)渲染:
-
-```csharp
-// Assets/Scripts/World/Map/MapPanel.cs
-// 全屏地图 UI,由 UIManager PanelStack 管理
-public class MapPanel : MonoBehaviour
-{
- [SerializeField] private MapDatabaseSO _database;
- [SerializeField] private RectTransform _roomContainer; // 格子图放置根节点
- [SerializeField] private MapRoomCellUI _cellPrefab;
-
- [Header("图标 Sprites")]
- [SerializeField] private Sprite _iconSavePoint;
- [SerializeField] private Sprite _iconBossRoom;
- [SerializeField] private Sprite _iconShop;
- [SerializeField] private Sprite _iconPlayerPos;
-
- [Header("颜色")]
- [SerializeField] private Color _colorDiscovered = Color.white;
- [SerializeField] private Color _colorUndiscovered = Color.black;
-
- [Header("Event Channels")]
- [SerializeField] private StringEventChannelSO _onMapUpdated; // 房间发现时刷新
-
- private Dictionary _cells = new();
-
- private void OnEnable()
- {
- BuildGrid();
- _onMapUpdated.OnEventRaised += OnMapUpdated;
- }
-
- private void OnDisable()
- => _onMapUpdated.OnEventRaised -= OnMapUpdated;
-
- private void BuildGrid()
- {
- foreach (var room in _database.AllRooms)
- {
- var cell = Instantiate(_cellPrefab, _roomContainer);
- cell.Setup(room, MapManager.Instance.IsDiscovered(room.RoomId));
- _cells[room.RoomId] = cell;
- }
- }
-
- private void OnMapUpdated(string roomId)
- {
- if (_cells.TryGetValue(roomId, out var cell))
- cell.SetDiscovered(true);
- }
-}
-
-// 单个地图格子 UI 组件
-public class MapRoomCellUI : MonoBehaviour
-{
- [SerializeField] private Image _bg;
- [SerializeField] private Image _icon;
-
- public void Setup(MapRoomDataSO room, bool discovered) { /* 设置 grid 位置+颜色 */ }
- public void SetDiscovered(bool v) => _bg.color = v ? Color.white : Color.black;
-}
-```
-
-### 6.1.1 MapPlayerTracker
-
-> **⚠️ 架构 15_MapShopModule §1.4 要求**:将玩家世界坐标转换为地图格子坐标,供 MapPanel 显示玩家位置图标。
-
-```csharp
-// Assets/Scripts/World/Map/MapPlayerTracker.cs
-public class MapPlayerTracker : MonoBehaviour
-{
- [SerializeField] private Transform _playerTransform;
- [SerializeField] private MapDatabaseSO _database;
- [SerializeField] private MapManager _mapManager;
-
- [Header("世界坐标 → 格子坐标换算参数")]
- [SerializeField] private float _worldUnitsPerCell = 18f; // 1 格 = N 世界单位
-
- /// 返回玩家当前所在房间 ID(用于地图高亮当前房间)。
- public string CurrentRoomId { get; private set; }
-
- /// 玩家在当前格子房间内的归一化坐标(0~1)。
- public Vector2 NormalizedPositionInRoom { get; private set; }
-
- private void LateUpdate()
- {
- if (_playerTransform == null) return;
- Vector2 worldPos = _playerTransform.position;
- Vector2Int cellPos = WorldToCell(worldPos);
-
- foreach (var room in _database.AllRooms)
- {
- var rect = new RectInt(room.GridPosition, room.GridSize);
- if (rect.Contains(cellPos))
- {
- CurrentRoomId = room.RoomId;
- Vector2 inRoom = (Vector2)(cellPos - room.GridPosition);
- NormalizedPositionInRoom = new Vector2(
- inRoom.x / room.GridSize.x,
- inRoom.y / room.GridSize.y);
- return;
- }
- }
- }
-
- private Vector2Int WorldToCell(Vector2 worldPos)
- => new Vector2Int(
- Mathf.FloorToInt(worldPos.x / _worldUnitsPerCell),
- Mathf.FloorToInt(worldPos.y / _worldUnitsPerCell));
-}
-```
-
-### 6.1.2 MapPin 系统
-
-> **⚠️ 架构 15_MapShopModule §1.5 要求**:玩家可在地图上放置自定义标记,通过 `MapPinManager`(`ISaveable`)持久化。
-
-```csharp
-// Assets/Scripts/World/Map/MapPin.cs
-[Serializable]
-public class MapPin
-{
- public string RoomId; // 所在房间 ID
- public Vector2 NormalizedPos; // 房间内归一化位置(0~1)
- public PinType Type;
- public string Note; // 玩家文字备注(最多 64 字符)
-}
-
-public enum PinType
-{
- Marker, // 通用标记
- Chest, // 宝箱/收藏品
- Enemy, // 危险/敌人
- Path, // 路径指引
- Note, // 笔记
-}
-
-// Assets/Scripts/World/Map/MapPinManager.cs
-// ⚠️ MapPinManager 实现 ISaveable,存档路径 data.Map.Pins(架构 15 §1.5)
-public class MapPinManager : MonoBehaviour, ISaveable
-{
- private List _pins = new();
-
- public IReadOnlyList Pins => _pins;
-
- public void AddPin(MapPin pin) => _pins.Add(pin);
- public void RemovePin(MapPin pin) => _pins.Remove(pin);
-
- public void OnSave(SaveData data) => data.Map.Pins = _pins;
- public void OnLoad(SaveData data) => _pins = data.Map.Pins ?? new List();
-}
-```
-
-### 6.2 Shop 数据层 SO
-
-```csharp
-// Assets/Scripts/World/Shop/ShopItemSO.cs
-[CreateAssetMenu(menuName = "World/Shop/ShopItem")]
-public class ShopItemSO : ScriptableObject
-{
- [Header("标识")]
- public string ItemId;
- public string DisplayName;
- [TextArea(2, 5)]
- public string Description;
- public Sprite Icon;
-
- [Header("价格")]
- public int BasePrice;
- public bool IsUnique;
-
- [Header("商品类型")]
- public ShopItemType ItemType;
-
- public int HealthRestoreAmount;
- public CharmSO CharmReference;
- public string KeyItemId;
- public int MaxPurchaseCount = -1;
-}
-
-public enum ShopItemType { HealthRestoration, CharmItem, KeyItem, ConsumableBuff, MapFragment }
-
-// Assets/Scripts/World/Shop/ShopInventorySO.cs
-[CreateAssetMenu(menuName = "World/Shop/ShopInventory")]
-public class ShopInventorySO : ScriptableObject
-{
- public string ShopId;
- public List DefaultInventory;
- public int MaxDisplaySlots = 6; // ⚠️ UI 最多同时显示的商品格数(架构 15 §2.2)
- public RestockPolicy RestockPolicy = RestockPolicy.Never; // ⚠️ 补货策略(架构 15 §2.2)
- public Sprite KeeperPortrait;
- public string KeeperName;
-}
-
-/// ⚠️ 库存补货时机策略(架构 15_MapShopModule §2.2)
-public enum RestockPolicy
-{
- Never, // 永不补货(唯一商品卖完即消失)
- OnSavePoint, // 激活存档点时补货
- OnBossDefeat, // 击败 Boss 后补货
- Periodic, // 周期性补货
-}
-```
-
-### 6.2 ShopController
-
-```csharp
-// Assets/Scripts/World/Shop/ShopController.cs
-// 挂在 NPC 商人上(⚠️ 以架构 15_MapShopModule §2.3 为准)
-public class ShopController : MonoBehaviour, ISaveable
-{
- [SerializeField] private ShopInventorySO _inventory;
- [SerializeField] private ShopPanel _shopPanel;
-
- [Header("Event Channels")]
- [SerializeField] private StringEventChannelSO _onShopOpen; // Raise 商店开启
- [SerializeField] private ShopPurchaseEventChannelSO _onItemPurchased; // ⚠️ ShopPurchaseEventChannelSO(架构 15 §2.3,非 ShopTransactionEventChannelSO)
- [SerializeField] private StringEventChannelSO _onBossDefeated; // ⚠️ 订阅 → OnBossDefeat 时补货(架构 15 §2.3)
- [SerializeField] private VoidEventChannelSO _onSavePointActivated; // ⚠️ 订阅 → OnSavePoint 时补货(架构 15 §2.3)
-
- // key = itemId,value = 已购次数
- private Dictionary _purchaseCounts = new();
- private HashSet _soldUniqueItems = new();
-
- // ⚠️ OnEnable/OnDisable 按 RestockPolicy 订阅补货事件(架构 15 §2.3)
- private void OnEnable()
- {
- if (_inventory.RestockPolicy == RestockPolicy.OnBossDefeat && _onBossDefeated != null)
- _onBossDefeated.OnEventRaised += _ => Restock();
- if (_inventory.RestockPolicy == RestockPolicy.OnSavePoint && _onSavePointActivated != null)
- _onSavePointActivated.OnEventRaised += Restock;
- }
-
- private void OnDisable()
- {
- if (_onBossDefeated != null) _onBossDefeated.OnEventRaised -= _ => Restock();
- if (_onSavePointActivated != null) _onSavePointActivated.OnEventRaised -= Restock;
- }
-
- public void Open()
- {
- _shopPanel.Show(GetAvailableItems(), this);
- _onShopOpen.Raise(_inventory.ShopId);
- }
- public void Close() => _shopPanel.Hide();
-
- // 过滤商品:移除已售尽的唯一品 / 超出最大购买次数的商品
- public List GetAvailableItems()
- {
- return _inventory.DefaultInventory
- .Take(_inventory.MaxDisplaySlots)
- .Where(item =>
- !_soldUniqueItems.Contains(item.ItemId) &&
- (item.MaxPurchaseCount < 0 || GetPurchaseCount(item.ItemId) < item.MaxPurchaseCount))
- .ToList();
- }
-
- // ⚠️ 按 RestockPolicy 补货:重置非唯一商品的购买次数(架构 15 §2.3)
- public void Restock()
- {
- var nonUniqueIds = _inventory.DefaultInventory
- .Where(i => !i.IsUnique)
- .Select(i => i.ItemId);
- foreach (var id in nonUniqueIds)
- _purchaseCounts.Remove(id);
- }
-
- // 由 ShopPanel 购买按钮调用:所有购买动作通过 _onItemPurchased 事件频道路由
- public bool TryPurchase(ShopItemSO item, int playerGeo)
- {
- if (playerGeo < item.BasePrice) return false;
- if (_soldUniqueItems.Contains(item.ItemId)) return false;
-
- // ⚠️ 扣 Geo:ShopPurchaseEvent { Item, Price }(架构 15 §2.3,非 ShopTransactionEvent)
- _onItemPurchased.Raise(new ShopPurchaseEvent { Item = item, Price = item.BasePrice });
-
- // 更新库存
- _purchaseCounts[item.ItemId] = GetPurchaseCount(item.ItemId) + 1;
- if (item.IsUnique) _soldUniqueItems.Add(item.ItemId);
- return true;
- }
-
- // ⚠️ 难度价格倍率(架构 19_DifficultyModule §5)
- public int GetPrice(ShopItemSO item)
- {
- var scaler = DifficultyManager.Instance.CurrentScaler;
- return Mathf.RoundToInt(item.BasePrice * scaler.ShopPriceMultiplier);
- }
-
- private int GetPurchaseCount(string id)
- => _purchaseCounts.TryGetValue(id, out var c) ? c : 0;
-
- // ── ISaveable(data.Shops.ShopRecords,key=ShopId,架构 15 §2.3 + §3)──────
- public void OnSave(SaveData data)
- {
- if (!data.Shops.ShopRecords.ContainsKey(_inventory.ShopId))
- data.Shops.ShopRecords[_inventory.ShopId] = new ShopRecord();
-
- var record = data.Shops.ShopRecords[_inventory.ShopId];
- record.SoldUniqueItems = _soldUniqueItems.ToList();
- record.PurchaseCounts = new Dictionary(_purchaseCounts);
- }
-
- public void OnLoad(SaveData data)
- {
- if (data.Shops.ShopRecords.TryGetValue(_inventory.ShopId, out var record))
- {
- _soldUniqueItems = new HashSet(record.SoldUniqueItems ?? new List());
- _purchaseCounts = record.PurchaseCounts ?? new Dictionary();
- }
- }
-}
-```
-
-NPC 商人通过 `ShopNPC.cs`(Architecture §2.4)触发商店:
-
-```csharp
-// Assets/Scripts/World/Shop/ShopNPC.cs
-public class ShopNPC : MonoBehaviour, IInteractable
-{
- [SerializeField] private ShopController _shopController;
- [SerializeField] private DialogueSequenceSO _greetDialogue; // 可选开场白
- [SerializeField] private DialogueManager _dialogueManager;
- [SerializeField] private VoidEventChannelSO _onDialogueEnded; // 订阅:对话结束后开商店
-
- public bool CanInteract => true;
- public string InteractPrompt => "购物";
-
- public void Interact(Transform player)
- {
- if (_greetDialogue != null)
- {
- _dialogueManager.StartDialogue(_greetDialogue);
- void OpenAfterDialogue()
- {
- _shopController.Open();
- _onDialogueEnded.OnEventRaised -= OpenAfterDialogue;
- }
- _onDialogueEnded.OnEventRaised += OpenAfterDialogue;
- }
- else
- {
- _shopController.Open();
- }
- }
-
- public void OnPlayerEnterRange(Transform player) { }
- public void OnPlayerExitRange() { }
-}
-```
-
-### 6.3 存档版本迁移
-
-```csharp
-// Assets/Scripts/Core/Save/SaveMigrator.cs
-// ⚠️ 以架构 12_SaveModule §5 为准:单参数 Migrate(SaveData),字符串版本,switch/goto 模式
-public static class SaveMigrator
-{
- private const string CurrentVersion = "2.0";
-
- public static SaveData Migrate(SaveData data)
- {
- switch (data.Meta.Version)
- {
- case "1.0": data = MigrateFrom1_0(data); goto case "1.1";
- case "1.1": data = MigrateFrom1_1(data); goto case "2.0";
- case "2.0": break; // 当前版本,无需迁移
- default: Debug.LogWarning($"Unknown save version: {data.Meta.Version}"); break;
- }
- data.Meta.Version = CurrentVersion;
- return data;
- }
-
- private static SaveData MigrateFrom1_0(SaveData d)
- {
- // 防御性 null-check:若旧版本缺少整个子结构体,先补全对象(架构 12_SaveModule §5)
- d.Equipment ??= new EquipmentSaveData();
- d.Player ??= new PlayerSaveData();
- d.World ??= new WorldSaveData();
- // 1.0 → 1.1:新增 Equipment.UpgradedCharmIds 字段,旧存档初始化为空列表
- d.Equipment.UpgradedCharmIds ??= new List();
- return d;
- }
-
- private static SaveData MigrateFrom1_1(SaveData d)
- {
- // 防御性 null-check:子结构体若为 null 先补全(架构 12_SaveModule §5)
- d.Stats ??= new StatsSaveData();
- // 1.1 → 2.0:Stats 新增 SkillUseCounts;World 新增 ChallengeFirstClears
- d.Stats.SkillUseCounts ??= new Dictionary();
- d.World.ChallengeFirstClears ??= new HashSet();
- // 1.1 → 2.0:Player 新增护盾字段(ShieldHP = -1 表示满护盾)
- // ⚠️ ShieldHP 为 int(值类型默认 0),用 -1 作哨兵值表示"未记录"→恢复满护盾
- if (d.Player.ShieldHP == 0 && !d.Player.ShieldIsBroken)
- d.Player.ShieldHP = -1; // 旧存档没有护盾字段时恢复为满护盾
- return d;
- }
-}
-```
-
-`LocalFileStorage.LoadAsync`(⚠️ 非 `LocalFileSaveStorage`,以架构 `12_SaveModule` 为准)调用 `SaveMigrator.Migrate` 在反序列化后立即执行。`JsonExtensionData` 保证旧存档中未知字段不丢失,向前兼容。
-
----
-
-## 7. 完成标准检查清单
-
-### Week 10 已完成实现(2026-05-10)
-
-| 文件 | 状态 | 说明 |
-|------|------|------|
-| `WorldStateRegistry.cs` | ✅ | ScriptableObject,Contains/Mark 系列 API + HasFlag/SetFlag + LoadFromSave |
-| `InteractableDetector.cs` | ✅ | OverlapCircleAll + FindNearest + InputReaderSO.InteractEvent 绑定 |
-| `PlayerSpawnPoint.cs` | ✅ | TransitionId + SpawnPosition,Gizmo 绿球标记 |
-| `RoomTransition.cs` | ✅ | IInteractable,自动触发或按键,广播 `scene|transitionId` 字符串 |
-| `RoomController.cs` | ✅ | Start 切换 RoomCamera,GetSpawnPoint 查询出生点 |
-| `HazardZone.cs` | ✅ | 即死/定值伤害,RespawnType 枚举 |
-| `Collectible.cs` | ✅ | Geo/Item/HPOrb 三类,PlayerStats 直接调用 |
-| `DestructibleTile.cs` | ✅ | IDamageable,CheckDestroyCondition virtual hook,Start 读档恢复 |
-| `DirectionalDestructible.cs` | ✅ | AttackSide 枚举 + SourcePosition 方向校验 |
-| `DirectionalInteractable.cs` | ✅ | 三触发模式 + TriggerSide + OneShot 持久化 |
-| `MagicWall.cs` | ✅ | Gizmo-only 标记,穿越靠 Layer Matrix |
-| `SoftTerrain.cs` | ✅ | Marker 组件(无逻辑) |
-| `PhantomInteractable.cs` | ✅ | 继承 DirectionalInteractable,额外响应 PhantomBody 层 |
-| `MovingPlatform.cs` | ✅ | LinearAB/WayPoints/TriggeredLinear + Passenger SetParent 方案 |
-| `CrumblePlatform.cs` | ✅ | Warning/Crumbling/Gone/Respawn 四态协程 + MMF_Player |
-| `FalseWall.cs` | ✅ | IDamageable,Proximity/AttackOnce/AlwaysOpen 三种揭示方式 |
-| `BaseGames.World.asmdef` | ✅ | 新增 Input/Combat/Player/Camera/MoreMountains.Tools 引用 |
-
-### P3-2 补充实现(2026-05-12)
-
-| 文件 | 状态 | 说明 |
-|------|------|------|
-| `WorldMarkerEventChannelSO.cs` | ✅ | `BaseEventChannelSO` 事件频道;命名空间 `BaseGames.World` |
-| `WorldMarker.cs` | ✅ | 场景导航标记点;`Activate()`/`Deactivate()` 广播事件频道;`WorldMarkerType` 枚举(Objective/NPC/PointOfInterest/Exit/Secret) |
-| `BreadcrumbTracker.cs` | ✅ | 玩家位置历史追踪;`_recordInterval=2f`/`_maxCrumbs=20`/`_minMoveDistance=1f`;`GetRecentCrumbs(int)` → `IReadOnlyList`(oldest→newest);`Clear()` |
-| `TutorialManager.cs` | ✅ | 单例 (`Instance`);实现 `ISaveable`;`ShowHint`/`CompleteHint`;进度写入 `SaveData.Tutorial`(非 PlayerPrefs,与架构 12 §1 注解不同) |
-| `TutorialHintUI.cs` | ✅ | HUD 提示 UI;`Show(text, duration)` + `Hide()`;`AutoHideRoutine` 协程;TMP_Text 标签 |
-| `ContextualHintTrigger.cs` | ✅ | `[RequireComponent(Collider2D)]`;`_requiresAbility`/`_requiredAbility(AbilityType)` 条件;调用 `LocalizationManager.Get`;首次触发后 `gameObject.SetActive(false)` |
-| `SaveData.cs` | ✅ | 追加 `TutorialSaveData Tutorial = new()`;新增 `TutorialSaveData` 类(含 `List CompletedHintIds`) |
-| `BaseGames.Tutorial.asmdef` | ✅ | 引用 Core.Events/Core.Save/World/Player/Localization |
-
-```
-☑ InteractableDetector:OverlapCircleAll 检测最近交互物,驱动 UI 提示显示/隐藏(代码完成)
-☑ WorldStateRegistry:HashSet 持久化状态,LoadFromSave/GetAllFlags 接口完成
-☑ RoomTransition + RoomController + PlayerSpawnPoint:房间切换框架完成(待 SceneLoader 集成)
-☑ HazardZone:即死/定值伤害(代码完成,待 Unity 内配置 Layer 和 Tag 验证)
-☑ Collectible:Geo/Item/HPOrb 拾取(代码完成,待 Unity 内配置 Prefab 验证)
-☑ DestructibleTile + DirectionalDestructible:IDamageable + 方向校验(代码完成)
-☑ DirectionalInteractable + PhantomInteractable:三种触发模式 + WorldStateRegistry 持久化
-☑ MagicWall + SoftTerrain:标记组件(无逻辑)
-☑ MovingPlatform:三种移动模式 + Passenger SetParent 方案(代码完成)
-☑ CrumblePlatform:四态协程,MMF_Player 反馈(代码完成)
-☑ FalseWall:三种揭示条件 + IDamageable(代码完成)
-□ 场景内端对端验证(待 Unity 编辑器内装配 Prefab 并运行)
-□ Console 无 Error(Unity 编辑器内编译验证)
-```
-
-### Week 11–14 待实施
-```
-□ RoomTransition:触发切换 → 淡出 → 加载目标场景 → 玩家在对应 SpawnPoint 出生
-□ HazardZone:掉入深渊 → 瞬间死亡 → 正常死亡流程
-□ DestructibleTile:Heavy 攻击命中破碎 + WorldStateRegistry 记录 → 重载场景后仍为破碎状态
-□ MovingPlatform:玩家站在平台上随平台移动,不抖动,不穿透
-□ CrumblePlatform:落上后 0.6s 碎裂,3s 后复原
-□ LiquidZone:进入水域 → SwimState(已解锁)/ HazardZone 伤害(未解锁)
-□ 液态谜题:Valve → LiquidPuzzleController 液位上升 → 达目标液位 → 谜题完成事件
-□ CharmSO 装备:VoidHeart 装备后玩家 MaxHP +2(HUD 更新),卸载后恢复
-□ 护符凹槽:总 notchCost 超出 maxNotches → 装备被拒绝
-□ FormSkillSO:切换形态 → 对应技能可用 → 释放消耗 SoulPower
-□ QuestManager:接任务 → 击杀指定敌人 → 进度推进 → 交任务 → 获得奖励
-□ ChallengeRoom:进入 → 锁门 → 三波敌人依次生成 → 通关 → 奖励 + 开门
-☑ MapPanel:探索新房间后地图格子变亮,已探索持久化(MapManager ISaveable 已实现)
-☑ ShopController:购买护符 → Geo 减少(EVT_ItemPurchased)→ 商店标记已售出(IsUnique 机制)
-□ 存档迁移:旧版本存档文件加载时无报错,缺失字段填充默认值
-□ Console 无 Error
-```
-
-### Week 14 已完成实现(P3-5,地图与商店模块)
-
-| 文件 | 状态 | 说明 |
-|------|------|------|
-| `MapRoomDataSO.cs` | ✅ | `MapRoomDataSO` + `MapDatabaseSO` + `RoomExitData` + `ExitDirection` |
-| `MapManager.cs` | ✅ | ISaveable;`[DefaultExecutionOrder(-700)]`;订阅 `EVT_RoomEntered`;`SetMapped` |
-| `MapPanel.cs` | ✅ | `MapPanel` + `MapRoomCellUI`;OnEnable 重建格子;`EVT_MapUpdated` 增量刷新 |
-| `MapPlayerTracker.cs` | ✅ | `WorldToCell`(18f/格);LateUpdate 找所在房间;`NormalizedPositionInRoom` |
-| `MapPin.cs` | ✅ | `MapPinManager` ISaveable(MapPin/PinType 定义在 SaveData.cs 避免循环依赖) |
-| `ShopItemSO.cs` | ✅ | `ShopItemSO` + `ShopItemType` 枚举;CharmSO 引用 |
-| `ShopInventorySO.cs` | ✅ | `ShopInventorySO` + `RestockPolicy` 枚举 |
-| `ShopController.cs` | ✅ | ISaveable;`TryPurchase`;`GetAvailableItems`;`Restock`;`ShopPanel` 存根 |
-| `ShopNPC.cs` | ✅ | IInteractable;`DialogueEventChannelSO` 触发招呼对话→打开商店 |
-| `Editor/Map/MapRoomDataEditor.cs` | ✅ | `[CustomEditor(typeof(MapRoomDataSO))]`;Scene 句柄拖拽;居中按钮 |
-| `SaveData.cs` | ✅ | `MapSaveData`:`ExploredRooms/MappedRooms(List)` + `Pins(List)` |
-| `BaseGames.World.Map.asmdef` | ✅ | 新增 `BaseGames.Core.Save` + `BaseGames.Core.Events` 引用 |
-| `BaseGames.World.Shop.asmdef` | ✅ | 新增 `BaseGames.Core.Save` + `BaseGames.Equipment` + `BaseGames.Dialogue` 引用 |
-| `BaseGames.Editor.asmdef` | ✅ | 新增 `BaseGames.World.Map` 引用(MapRoomDataEditor 需要) |
-
-> **编辑器工具**:`AddressReferenceGraphWindow`(`Assets/Editor/Assets/AddressReferenceGraphWindow.cs`)扫描所有 `.cs` 文件对 `AddressKeys.X` 的引用,标红孤儿 key(0 引用),标黄单次引用 key,标绿 ≥2 次引用 key,支持导出 CSV(架构 13 §11,P3 优化)。
-
-**Phase 3 完成后进入 Phase 4。**
diff --git a/Docs/Plan/05_Phase4_ContentPolish.md b/Docs/Plan/05_Phase4_ContentPolish.md
deleted file mode 100644
index b3603ec..0000000
--- a/Docs/Plan/05_Phase4_ContentPolish.md
+++ /dev/null
@@ -1,3268 +0,0 @@
-# Phase 4 · 内容与完善
-
-> **周期**:3–4 周(Week 15–18)
-> **前置条件**:Phase 3 全部完成标准通过
-> **核心目标**:Boss 技能系统、叙事/对话/过场、按键重绑定、完整 UI 面板、支撑系统(本地化/成就/Steam/调试工具)、编辑器工具、QA 就绪
-> **产出物**:游戏发布前技术层面全部完成,可进入内容填充 + 关卡设计阶段
-> **状态**:✅ 全部完成(2026-05-11,P4-1~P4-6 全部 ✅)
-
----
-
-## 目录
-
-1. [实施顺序总览](#1-实施顺序总览)
-2. [Week 15:Boss 技能系统完整](#2-week-15boss-技能系统完整)
-3. [Week 16:叙事模块(对话/过场/事件链)](#3-week-16叙事模块对话过场事件链)
-4. [Week 17:UI 完整面板 + 按键重绑定](#4-week-17ui-完整面板--按键重绑定)
-5. [Week 18:支撑模块 + 编辑器工具 + QA](#5-week-18支撑模块--编辑器工具--qa)
-6. [完成标准检查清单](#6-完成标准检查清单)
-7. [发布前技术 Checklist](#7-发布前技术-checklist)
-
----
-
-## 1. 实施顺序总览
-
-```
-Week 15: BossSkillSO + AttackPatternSO + SkillSequenceSO(数据层)
- ↓
- VulnerabilityWindow + WeakPointSystem
- ↓
- BossSkillExecutor(执行层)
- ↓
- BossOrchestrator 集成(BD + BossSkillExecutor 协作)
- ↓
- 首个 Boss 完整技能套(2 阶段 × 3 技能)
-
-Week 16: DialogueLine / DialogueSequenceSO(数据)
- ↓
- DialogueManager(打字机效果 + 快进 + 分支选择)
- ↓
- InteractableNPC(挂载 DialogueSequenceSO,接入 QuestGiver)
- ↓
- EventChain + EventChainManager(世界事件链)
- ↓
- CutsceneManager(Unity Timeline 封装)
-
-Week 17: 完整 UI 面板套装
- ├─ PausePanel(设置入口 + 存档 + 退出)
- ├─ InventoryPanel(护符 + 工具槽 + 凹槽 UI)
- ├─ MapPanel(完整 Fog of War 渲染)
- ├─ ShopPanel(商品列表 + 购买确认)
- ├─ AchievementPanel
- └─ SettingsPanel(音量/分辨率/按键重绑定)
- ↓
- InputRebindingUI(New Input System 重绑定 API)
-
-Week 18: LocalizationManager(Unity Localization 包)
- ↓
- AchievementManager(本地 + Steam 双通道)
- ↓
- PlatformBootstrap(Steam 成就/存档云同步;⚠️ 非 PlatformManager 静态类,架构 16 §3)
- ↓
- DebugCheatSystem(Editor Only + Development Build)
- ↓
- AntiSoftlockSystem(出门触发器 + 快速复活备用)
- ↓
- AccessibilityManager(色盲模式/字幕/震动/高对比)
- ↓
- AnalyticsManager(本地 JSONL 日志,开发分析)
- ↓
- SpeedrunTimer(IGT 计时器,ISaveable)
- ↓
- 编辑器工具(验证工具套装)
- ↓
- QA Checklist 执行
-```
-
----
-
-## 2. Week 15:Boss 技能系统完整 ✅ 完成(2026-05-11)
-
-**参考文档**:`23_BossSkillModule.md`
-
-### 2.1 数据层 SO 创建顺序
-
-按依赖关系由底向上:
-
-```
-BossSkillCategory.cs ← 分类枚举(Melee/Ranged/Charge/AoE/…)
-BossSkillType.cs ← 具体类型枚举(MeleeSlash / ChargeAttack / LeapSlam / …)
-InteractionTag.cs ← [Flags] 互动标签枚举(Parryable/Unblockable/…)
-VulnTriggerType.cs ← 弱点触发方式枚举(OnAttackRecovery/OnParriedSuccess/…)
-WeakPointType.cs ← 弱点位置类型枚举(FullBody/HeadOnly/BackOnly/…)
-VulnerabilityWindow.cs ← struct(TriggerDelay, Duration, WeakPointType, DamageMultiplier, ForceStagger, …)
-AttackPatternSO.cs ← 单个攻击图案(HitBox 范围/时序/DamageSourceSO 引用)
-SkillSequenceSO.cs ← SequenceStep[] 序列 + RepeatIfPlayerInRange / MaxRepeatCount
-PlayerCounterResponse.cs ← struct(CounterType + Boss应激反应参数)
-ArenaEventTrigger.cs ← struct + ArenaEventType + IArenaInteractable(场景联动)
-BossResourceCost.cs ← struct + BossResourceConfigSO(Boss 资源系统)
-BossSkillSO.cs ← 技能(所有架构 23 §4 字段,含 attackPatterns/counterResponses/arenaEvents/resourceCost/poiseWindow)
-```
-> **注意**:不存在 `BossConfigSO`。Boss 阶段技能列表直接由 `BossOrchestrator` 的 `_phaseOneSkills[]` / `_phaseTwoSkills[]` 字段管理(见 §2.3)。
-
-### 2.1.1 BossSkillCategory + BossSkillType + 互动/弱点枚举
-
-```csharp
-// Assets/Scripts/Boss/BossSkillType.cs
-namespace BaseGames.Boss
-{
- /// ⚠️ 高层技能分类(平衡框架用)(架构 23 §2)
- public enum BossSkillCategory
- {
- Melee, Ranged, Charge, AoE, Environmental, Summon,
- Buff, Debuff, Phase, Passive, Reactive
- }
-
- /// 具体技能类型(战斗设计用)。
- public enum BossSkillType
- {
- MeleeSlash,
- ChargeAttack,
- LeapSlam,
- ProjectileVolley,
- LaserBeam,
- PhaseTransition,
- SummonMinion,
- ArenaTrap,
- SpeedBurst,
- DefensiveShell,
- }
-
- /// ⚠️ 互动标签:玩家可对该技能执行哪些反制操作(架构 23 §2,[Flags] 枚举)
- [Flags]
- public enum InteractionTag
- {
- None = 0,
- Parryable = 1 << 0, // 可弹反
- PerfectParryOnly = 1 << 1, // 仅完美弹反有效
- DodgeWindow = 1 << 2, // 打开逃避窗口
- Unblockable = 1 << 3, // 无法拦截
- CanBeReflected = 1 << 4, // 弹幕可被反射
- ExposesWeakPoint = 1 << 5, // 暴露弱点
- GrantsPlayerReso = 1 << 6, // 命中后给予玩家资源
- ArenaHazard = 1 << 7, // 场地危机
- PhaseGate = 1 << 8, // 阶段门关
- }
-
- /// ⚠️ 弱点触发方式(架构 23 §3)
- public enum VulnTriggerType
- {
- OnAttackRecovery, // 攻击后摇
- OnParriedSuccess, // 弹反成功
- OnCounterSkillHit, // 反制技能命中
- OnPhaseTransition, // 阶段切换时
- OnHazardBackfire, // 场地反噬
- OnSummonDefeated, // 召唤物被击败
- Manual, // BossSkillExecutor 手动触发
- }
-
- /// ⚠️ 弱点位置类型(架构 23 §3)
- public enum WeakPointType
- {
- FullBody, // 主体全身都是弱点
- HeadOnly, // 仅头部
- BackOnly, // 仅背部
- CoreExposed, // 核心暴露(展开中心 HurtBox)
- CustomPoint, // 自定义弱点 HurtBox
- }
-}
-```
-
-### 2.1.2 BossSkillSO
-
-```csharp
-// Assets/Scripts/Boss/BossSkillSO.cs
-// ⚠️ menuName = "Boss/BossSkill"(架构 23 §4);skillAnimation 为 Animancer ClipTransition
-namespace BaseGames.Boss
-{
- [CreateAssetMenu(menuName = "Boss/BossSkill")]
- public class BossSkillSO : ScriptableObject
- {
- [Header("元信息")]
- public string skillId;
- public string displayName;
- [TextArea(1, 4)]
- public string designNote;
-
- [Header("技能分类")]
- public BossSkillCategory category; // ⚠️ 高层分类(架构 23 §4)
- public BossSkillType skillType;
-
- [Header("阶段可用性")]
- public int[] availablePhaseIndices; // ⚠️ 空数组 = 全阶段可用(架构 23 §4)
-
- [Header("核心攻击动作(按执行顺序排列)")]
- public AttackPatternSO[] attackPatterns; // ⚠️ 按架构 23 §4:伤害参数只在 AttackPatternSO
-
- [Header("弱点窗口(至少 1 个)")]
- public VulnerabilityWindow[] vulnerabilityWindows;
-
- [Header("互动标签")]
- public InteractionTag interactionTags; // ⚠️ [Flags] 枚举(架构 23 §4,非 string interactionTag)
-
- [Header("连段子序列")]
- public SkillSequenceSO sequenceOnHit; // 弱点被击时触发(可空)
- public SkillSequenceSO sequenceOnMiss; // 弱点未被击时触发(可空)
-
- [Header("玩家反制接口")]
- public PlayerCounterResponse[] counterResponses; // ⚠️ 架构 23 §4.1
-
- [Header("场景联动")]
- public ArenaEventTrigger[] arenaEvents; // ⚠️ 架构 23 §4.2
-
- [Header("Boss 资源")]
- public BossResourceCost resourceCost; // ⚠️ 架构 23 §4.3
- public bool buildsRage; // ⚠️ 是否积累愤怒值(架构 23 §4)
-
- [Header("霸体配置")]
- public PoiseWindowConfig poiseWindow; // ⚠️ 技能执行期间的霸体窗口(架构 23 §4)
-
- [Header("动画")]
- public ClipTransition skillAnimation; // Animancer ClipTransition
-
- [Header("冷却")]
- [Min(0f)]
- public float cooldown;
- }
-}
-```
-
-### 2.1.3 AttackPatternSO
-
-```csharp
-// Assets/Scripts/Boss/AttackPatternSO.cs
-// ⚠️ menuName = "Boss/AttackPattern";DamageSource 字段为 DamageSourceSO(架构 23 §5)
-namespace BaseGames.Boss
-{
- [CreateAssetMenu(menuName = "Boss/AttackPattern")]
- public class AttackPatternSO : ScriptableObject
- {
- [Header("输出")]
- public DamageSourceSO DamageSource;
- public float KnockbackAngle;
-
- [Header("弹幕")]
- public AssetReferenceGameObject ProjectilePrefab;
- public int ProjectileCount = 1;
- public float SpreadAngle = 0f;
- public float ProjectileSpeed = 8f;
-
- [Header("范围攻击")]
- public float AoERadius;
- public Vector2 AoEOffset;
-
- [Header("时序")]
- public float WindupDuration;
- public float ActiveDuration;
- public float RecoveryDuration;
- }
-}
-```
-
-### 2.1.4 SkillSequenceSO
-
-```csharp
-// Assets/Scripts/Boss/SkillSequenceSO.cs
-// ⚠️ menuName = "Boss/SkillSequence"(架构 23 §6)
-namespace BaseGames.Boss
-{
- [CreateAssetMenu(menuName = "Boss/SkillSequence")]
- public class SkillSequenceSO : ScriptableObject
- {
- [Serializable]
- public struct SequenceStep
- {
- public AttackPatternSO pattern;
- public float delayBeforeStep;
- }
-
- public SequenceStep[] steps;
-
- [Header("循环行为")]
- public bool RepeatIfPlayerInRange;
- public float RepeatDelay;
- [Range(0, 10)]
- public int MaxRepeatCount; // 0 = 无限
- }
-}
-```
-
-### 2.1.5 WeakPointSystem
-
-```csharp
-// Assets/Scripts/Boss/WeakPointSystem.cs
-// ⚠️ SetActive(bool active, float multiplier) 由 BossSkillExecutor.ActivateVulnerabilityWindows() 调用(架构 23 §8)
-namespace BaseGames.Boss
-{
- public class WeakPointSystem : MonoBehaviour
- {
- [Serializable]
- public struct WeakPoint
- {
- public HurtBox hurtBox;
- public GameObject visualIndicator;
- }
-
- [SerializeField] WeakPoint[] _weakPoints;
- [SerializeField] string _bossId;
- [SerializeField] StringEventChannelSO _onVulnerabilityWindowOpened;
-
- float _damageMultiplier = 1f;
-
- public void SetActive(bool active, float multiplier = 1f)
- {
- _damageMultiplier = multiplier;
- foreach (var wp in _weakPoints)
- {
- wp.hurtBox.gameObject.SetActive(active);
- if (wp.visualIndicator != null)
- wp.visualIndicator.SetActive(active);
- }
- if (active) _onVulnerabilityWindowOpened?.Raise(_bossId);
- }
-
- public float GetDamageMultiplier() => _damageMultiplier;
- }
-}
-```
-
-### 2.1.6 PlayerCounterResponse + ArenaEventTrigger + BossResourceCost
-
-> **⚠️ 架构 23 §4.1-4.3 要求**:BossSkillSO 引用这三个数据结构,均在 `BaseGames.Boss` 命名空间。
-
-```csharp
-// Assets/Scripts/Boss/PlayerCounterResponse.cs
-namespace BaseGames.Boss
-{
- /// ⚠️ 玩家反制接口(架构 23 §4.1)
- [Serializable]
- public struct PlayerCounterResponse
- {
- [Header("反制条件")]
- public CounterType counterType;
- public string requiredSkillId; // counterType = SpecificSkill 时填写
-
- [Header("反制效果(对 Boss)")]
- public float bossStaggerDuration; // 强制硬直时长(秒,0 = 不强制)
- public float bossDamageBonus; // 对 Boss 的额外伤害倍率(0 = 不额外)
- public bool openVulnWindow; // 是否触发 VulnerabilityWindow
- public bool interruptSkill; // 是否打断 Boss 当前技能
-
- [Header("反制收益(对玩家)")]
- public int soulPowerGrant;
- public int spiritPowerGrant;
- public MMF_Player counterFeedback; // 成功反制的特效/音效反馈
- }
-
- public enum CounterType
- {
- Parry, PerfectParry, DodgeThrough, SpecificSkill,
- WeakPointHit, HazardBackfire, SummonKill,
- }
-
- // Assets/Scripts/Boss/ArenaEventTrigger.cs
- /// ⚠️ 场景联动(架构 23 §4.2)
- [Serializable]
- public struct ArenaEventTrigger
- {
- public string targetArenaObjectId;
- public ArenaEventType eventType;
- public float delay;
- public ArenaEventParams parameters;
- }
-
- public enum ArenaEventType
- {
- DestroyPlatform, ActivateHazard, DeactivateHazard, SpawnHazardArea,
- ShakeArena, ToggleLighting, SpawnPlatform, TriggerCutscene,
- }
-
- [Serializable]
- public struct ArenaEventParams
- {
- public float duration; // 效果持续时间(0 = 永久)
- public float intensity; // 强度(震动幅度/半径等)
- public bool revertsOnPhaseEnd; // 阶段结束时是否恢复
- }
-
- /// ⚠️ 场景中可被 Boss 技能交互的对象接口(架构 23 §4.2)
- public interface IArenaInteractable
- {
- string ArenaObjectId { get; }
- void OnBossArenaEvent(ArenaEventData data);
- }
-
- [Serializable]
- public struct ArenaEventData
- {
- public ArenaEventType type;
- public ArenaEventParams parameters;
- public string sourceSkillId;
- }
-
- // Assets/Scripts/Boss/BossResourceCost.cs
- /// ⚠️ Boss 资源消耗(架构 23 §4.3)
- [Serializable]
- public struct BossResourceCost
- {
- public string resourceId; // 对应 BossResourceConfigSO.resourceId(如 "Rage")
- public float cost;
- public float minRequired; // 使用此技能的最低资源要求
- }
-
- [CreateAssetMenu(menuName = "Boss/ResourceConfig")]
- public class BossResourceConfigSO : ScriptableObject
- {
- public string resourceId;
- public string displayName;
- public float maxValue;
- public float startValue;
-
- [Header("自动变化")]
- public float passiveRate; // 每秒自动变化量(正=增长/负=衰减)
- public float onTakeDamageGain; // 每受 1 点伤害积累量
- public float onSkillUseGain; // 每使用一次技能积累量
-
- [Header("满值效果")]
- public bool autoTriggerOnFull;
- public BossSkillSO fullTriggerSkill;
- public float resetValueAfterTrigger;
- }
-}
-```
-
-### 2.2 VulnerabilityWindow
-
-```csharp
-// ⚠️ 字段为绝对时间(秒),不是归一化时间(0–1)(架构 23_BossSkillModule §3)
-// ⚠️ 使用 UniTask 序列按 TriggerDelay+Duration 激活弱点(非 Update() 轮询归一化时间)
-namespace BaseGames.Boss
-{
- [Serializable]
- public struct VulnerabilityWindow
- {
- [Tooltip("弱点触发方式")]
- public VulnTriggerType TriggerType; // ⚠️ 架构 23 §3
-
- [Tooltip("触发后延迟出现(秒)")]
- [Min(0f)]
- public float TriggerDelay; // ⚠️ 绝对秒数(架构 23 §3,非归一化时间)
-
- [Tooltip("弱点持续时长(秒,设计约定 ≥0.5s)")]
- [Min(0.1f)]
- public float Duration; // ⚠️ 绝对秒数(架构 23 §3,非归一化时间)
-
- [Tooltip("弱点位置类型")]
- public WeakPointType WeakPointType; // ⚠️ 架构 23 §3
-
- [Tooltip("弱点激活时 Boss 受击乘数(1 = 正常,>1 = 额外伤害)")]
- [Min(0.1f)]
- public float DamageMultiplier;
-
- [Tooltip("命中后是否强制硬直")]
- public bool ForceStagger; // ⚠️ 架构 23 §3
-
- [Tooltip("硬直时间(秒),ForceStagger=true 时生效")]
- [Min(0f)]
- public float StaggerDuration; // ⚠️ 架构 23 §3
-
- [Tooltip("弱点开启时播放的 Feedback(光效等)")]
- public MMF_Player OpenFeedback; // ⚠️ 架构 23 §3
-
- [Tooltip("弱点关闭时播放的 Feedback")]
- public MMF_Player CloseFeedback; // ⚠️ 架构 23 §3
-
- [Tooltip("弱点激活时的高亮颜色(指示玩家该攻击哪里)")]
- public Color HighlightColor; // ⚠️ 架构 23 §3
-
- // 向后兼容辅助属性(BossSkillExecutor 判断是否激活专属 HurtBox)
- public bool ActivateWeakPointHurtBox => WeakPointType != WeakPointType.FullBody;
- }
-}
-```
-
-### 2.3 BossSkillExecutor + BossOrchestrator
-
-```csharp
-// Assets/Scripts/Enemies/Boss/BossSkillExecutor.cs
-namespace BaseGames.Boss
-{
- public class BossSkillExecutor : MonoBehaviour
- {
- [SerializeField] HitBox[] _hitBoxes;
- [SerializeField] WeakPointSystem _weakPointSystem;
- [SerializeField] AnimancerComponent _animancer;
- [SerializeField] private string _bossId; // ⚠️ 事件 payload 用
- [SerializeField] private BossSkillEventChannelSO _onBossSkillStarted; // ⚠️ 架构 23 §11
- [SerializeField] private BossSkillEventChannelSO _onBossSkillEnded; // ⚠️ 架构 23 §11
- // ⚠️ PlayerController 无 Instance(Architecture 05 §2);Boss 居小场景持有玩家 Transform 引用
- [SerializeField] private Transform _playerTransform; // 由 Inspector 指定玩家 Transform
-
- BossSkillSO _currentSkill;
- AnimancerState _currentState;
- bool _isExecuting;
- CancellationTokenSource _cts;
-
- public bool IsExecuting => _isExecuting;
-
- // BD Task 节点调用
- public async UniTask ExecuteSkill(BossSkillSO skill, CancellationToken ct)
- {
- if (_isExecuting) return;
- _isExecuting = true;
- _currentSkill = skill;
- _onBossSkillStarted?.Raise(new BossSkillEvent { BossId = _bossId, SkillId = skill.skillId });
-
- // 播放技能动画(直接从 BossSkillSO.skillAnimation 引用 ClipTransition)
- _currentState = _animancer.Play(skill.skillAnimation);
-
- // 执行 SkillSequenceSO(若挂载 sequenceOnMiss)
- if (skill.sequenceOnMiss != null)
- await ExecuteSequence(skill.sequenceOnMiss, ct);
-
- _isExecuting = false;
- _onBossSkillEnded?.Raise(new BossSkillEvent { BossId = _bossId, SkillId = skill.skillId });
- }
-
- async UniTask ExecuteSequence(SkillSequenceSO seq, CancellationToken ct)
- {
- int repeatCount = 0;
- do
- {
- foreach (var step in seq.steps)
- {
- ct.ThrowIfCancellationRequested();
- await UniTask.WaitForSeconds(step.delayBeforeStep, cancellationToken: ct);
- await ExecutePattern(step.pattern, ct);
- }
- repeatCount++;
- if (seq.RepeatDelay > 0)
- await UniTask.WaitForSeconds(seq.RepeatDelay, cancellationToken: ct);
- }
- while (seq.RepeatIfPlayerInRange
- && repeatCount < seq.MaxRepeatCount
- && IsPlayerInRange()
- && !ct.IsCancellationRequested);
- }
-
- async UniTask ExecutePattern(AttackPatternSO pattern, CancellationToken ct)
- {
- await UniTask.WaitForSeconds(pattern.WindupDuration, cancellationToken: ct);
- // ⚠️ 以架构 06_CombatModule §4 为准:HitBox.Activate(DamageSourceSO, Transform) + Deactivate()
- foreach (var hb in _hitBoxes)
- hb.Activate(pattern.DamageSource, transform);
- await UniTask.WaitForSeconds(pattern.ActiveDuration, cancellationToken: ct);
- foreach (var hb in _hitBoxes) hb.Deactivate();
- await UniTask.WaitForSeconds(pattern.RecoveryDuration, cancellationToken: ct);
- }
-
- // ⚠️ UniTask 序列激活弱点窗口(架构 23 §7)
- // ⚠️ 使用绝对时间(TriggerDelay + Duration),不使用 Update() 归一化时间轮询
- async UniTask ActivateVulnerabilityWindows(BossSkillSO skill, CancellationToken ct)
- {
- foreach (var window in skill.vulnerabilityWindows)
- {
- // 等待触发延迟
- await UniTask.Delay(
- TimeSpan.FromSeconds(window.TriggerDelay),
- cancellationToken: ct);
-
- // 激活弱点(⚠️ 架构 23 §8 SetActive 仅 2 参数,§7 3-arg call 为内部不一致;以 §8 定义为准)
- _weakPointSystem.SetActive(true, window.DamageMultiplier);
- window.OpenFeedback?.PlayFeedbacks();
-
- // 持续弱点窗口
- await UniTask.Delay(
- TimeSpan.FromSeconds(window.Duration),
- cancellationToken: ct);
-
- // 关闭弱点
- _weakPointSystem.SetActive(false, 1f);
- window.CloseFeedback?.PlayFeedbacks();
- }
- }
-
- bool IsPlayerInRange() =>
- _playerTransform != null &&
- Vector2.Distance(transform.position, _playerTransform.position) < 8f;
- }
-}
-
-// Assets/Scripts/Enemies/Boss/BossOrchestrator.cs
-// Behavior Designer 的宿主;直接持有各阶段 BossSkillSO[] 数组(不通过 BossConfigSO)
-namespace BaseGames.Boss
-{
- public class BossOrchestrator : MonoBehaviour
- {
- [SerializeField] BossBase _boss; // ⚠️ 架构 07_EnemyModule §10
- [SerializeField] TelegraphSystem _telegraph; // ⚠️ 架构 07_EnemyModule §10
- [SerializeField] BoxCollider2D _arenaBlocker; // ⚠️ 架构 07_EnemyModule §10
-
- [Header("Boss 技能")]
- [SerializeField] BossSkillSO[] _phaseOneSkills;
- [SerializeField] BossSkillSO[] _phaseTwoSkills;
- [SerializeField] BossSkillExecutor _executor;
-
- [Header("Event Channels")]
- [SerializeField] private StringEventChannelSO _onBossFightStarted; // ⚠️ 架构 07_EnemyModule §10 + 02 §4
-
- int _currentPhase = 1;
- CancellationTokenSource _cts;
-
- // 由触发器或 GameManager 调用(架构 07_EnemyModule §10)
- public void StartBossFight()
- {
- _arenaBlocker.enabled = true;
- _onBossFightStarted.Raise(_boss.BossId);
- _boss.GetComponent().EnableBehavior();
- }
-
- // BD Task 节点调用:ExecuteSkillById(skillId)
- public async UniTask ExecuteSkillById(string skillId)
- {
- var skills = _currentPhase == 1 ? _phaseOneSkills : _phaseTwoSkills;
- var skill = System.Array.Find(skills, s => s.skillId == skillId);
- if (skill == null) return;
- _cts?.Cancel();
- _cts = new CancellationTokenSource();
- await _executor.ExecuteSkill(skill, _cts.Token);
- }
-
- public void EnterPhaseTwo() { _currentPhase = 2; _cts?.Cancel(); }
- }
-}
-```
-
-### 2.4 WeakPointSystem
-
-```csharp
-// Assets/Scripts/Enemies/Boss/WeakPointSystem.cs
-// ⚠️ API 为 SetActive(bool, float) 整体控制,不支持按 Id 单独开关(架构 23_BossSkillModule §8)
-namespace BaseGames.Boss
-{
- public class WeakPointSystem : MonoBehaviour
- {
- [Serializable]
- public struct WeakPoint
- {
- public HurtBox hurtBox;
- public GameObject visualIndicator; // 弱点亮光/标记(可为 null)
- }
-
- [SerializeField] private WeakPoint[] _weakPoints;
- [SerializeField] private string _bossId; // ⚠️ 事件 payload 用
- [SerializeField] private StringEventChannelSO _onVulnerabilityWindowOpened; // 发布:弱点窗口开启(Architecture 23 §8)
- private float _damageMultiplier = 1f;
-
- /// 统一激活/关闭所有弱点 HurtBox。由 BossSkillExecutor.Update() 驱动。
- public void SetActive(bool active, float multiplier = 1f)
- {
- _damageMultiplier = multiplier;
- foreach (var wp in _weakPoints)
- {
- wp.hurtBox.gameObject.SetActive(active);
- if (wp.visualIndicator != null)
- wp.visualIndicator.SetActive(active);
- }
- if (active) _onVulnerabilityWindowOpened?.Raise(_bossId);
- }
-
- /// 弱点 HurtBox 受击时由 BossStats 调用,获取最终伤害倍率。
- public float GetDamageMultiplier() => _damageMultiplier;
- }
-}
-```
-
-### 2.5 首个 Boss 完整资产
-
-**Boss_Example 资产集**(`Assets/Data/Enemies/Boss/Boss_Example/`):
-
-| 资产名 | 类型 | 内容 |
-|--------|------|------|
-| `BSS_Skill_Slash` | `BossSkillSO` | MeleeSlash,vulnerabilityWindows[0]: TriggerDelay=0.5s, Duration=1.2s(⚠️ 绝对秒数,非归一化时间) |
-| `BSS_Skill_Charge` | `BossSkillSO` | ChargeAttack,WeakPoint 头部,TriggerDelay=0.2s, Duration=1.5s |
-| `BSS_Skill_LeapSlam` | `BossSkillSO` | LeapSlam,落地后全身 VulnerabilityWindow: TriggerDelay=0.1s, Duration=1.0s |
-| `BSS_Seq_Slash` | `SkillSequenceSO` | 3段斩击时序 |
-| `BSS_Pat_Slash_*` | `AttackPatternSO` × 3 | 各段 HitBox 参数(WindupDuration/ActiveDuration/RecoveryDuration)|
-
----
-
-## 3. Week 16:叙事模块(对话/过场/事件链)✅ 完成(2026-05-11)
-
-**参考文档**:`14_NarrativeModule.md`
-
-### 3.0 DialogueLine + DialogueSequenceSO(数据层)
-
-按依赖关系最先实现,`DialogueManager` / `InteractableNPC` 均依赖这两个数据类型。
-
-```csharp
-// Assets/Scripts/Dialogue/DialogueLineSO.cs
-// ⚠️ 为 [System.Serializable] class(架构 14_NarrativeModule §3),非 ScriptableObject、非 struct
-namespace BaseGames.Dialogue
-{
- [System.Serializable]
- public class DialogueLine
- {
- public string SpeakerNameKey; // 说话人姓名本地化 key
- [TextArea(2, 6)]
- public string TextKey; // 对话文本本地化 key
- public Sprite PortraitSprite; // 可空(无头像时留空)
- public float TypewriterDelay = 0.03f; // 每字符显示延迟(架构 14 §3)
- }
-}
-```
-
-```csharp
-// Assets/Scripts/Dialogue/DialogueSequenceSO.cs
-// ⚠️ menuName = "Dialogue/DialogueSequence"(架构 14_NarrativeModule §2)
-namespace BaseGames.Dialogue
-{
- [CreateAssetMenu(menuName = "Dialogue/DialogueSequence")]
- public class DialogueSequenceSO : ScriptableObject
- {
- public string sequenceId; // 唯一 ID(用于 WorldStateRegistry 条件查询)
- public DialogueLine[] Lines; // 对话行数组
-
- [System.Serializable]
- public struct ConditionalVariant
- {
- public string ConditionFlag; // WorldStateRegistry 标志 key
- public DialogueSequenceSO Sequence; // 条件满足时替换的序列
- }
-
- public ConditionalVariant[] Variants; // ConditionalVariant[] 而非 Variants[](架构 14 §2)
- }
-}
-// 资产路径:Assets/ScriptableObjects/Dialogue/
-// 命名规范:DLG_{NpcId}_{Context}.asset(例 DLG_Elder_Intro.asset)
-```
-
-### 3.1 DialogueManager
-
-```csharp
-// Assets/Scripts/Dialogue/DialogueManager.cs
-public class DialogueManager : MonoBehaviour
-{
- [SerializeField] private DialogueBox _dialogueBox;
- [SerializeField] private InputReaderSO _inputReader;
-
- [Header("Event Channels")]
- [SerializeField] private VoidEventChannelSO _onDialogueStarted;
- [SerializeField] private VoidEventChannelSO _onDialogueEnded;
- [SerializeField] private StringEventChannelSO _onNpcDialogueCompleted; // → EVT_NpcDialogueCompleted(npcId,QuestManager 订阅)
- [SerializeField] private GameStateEventChannelSO _onGameStateChanged;
-
- public bool IsDialogueActive { get; private set; }
- private bool _skipRequested;
-
- // 打字机效果:每字符 TypewriterDelay 延迟显示
- // 按确认键:若未显示完毕则立即显示全部;若已完毕则进入下一行
- // 所有行显示完毕:结束对话,恢复 Gameplay ActionMap
-
- // ⚠️ 正确实现模式:使用 IEnumerator + StartCoroutine(架构 14_NarrativeModule §3)
- // 正确 API: void StartDialogue(DialogueSequenceSO sequence) — 返回 void,非 UniTask
- public void StartDialogue(DialogueSequenceSO sequence)
- {
- // 1. 若 IsDialogueActive → 返回
- if (IsDialogueActive) return;
- // 2. 选择 ConditionalVariant(查询 WorldStateRegistry)
- IsDialogueActive = true;
- _dialogueBox.Show(); // 步骤 3:先显示对话框
- _inputReader.EnableUIInput(); // 步骤 4:切换 ActionMap → UI(先于 Raise)
- _onDialogueStarted.Raise(); // 步骤 5:广播(在 ActionMap 切换后)
- StartCoroutine(PlaySequence(sequence)); // 步骤 6
- }
-
- // 玩家按下 Submit 键时(InputReaderSO.SubmitEvent)
- private void OnSubmit() => _skipRequested = true;
-
- private IEnumerator PlaySequence(DialogueSequenceSO sequence)
- {
- foreach (var line in sequence.Lines)
- {
- _skipRequested = false;
- yield return _dialogueBox.TypeText(line.TextKey, line.TypewriterDelay);
- // 等待玩家按 Submit 继续
- yield return new WaitUntil(() => _skipRequested);
- }
- EndDialogue();
- }
-
- private void EndDialogue()
- {
- _dialogueBox.Hide();
- IsDialogueActive = false;
- _inputReader.EnableGameplayInput(); // 先恢复 ActionMap → Gameplay(Architecture 14 §3:先切换再广播)
- _onDialogueEnded.Raise();
- }
-
- // 分支选择(当 DialogueSequenceSO 有 ConditionalVariants 时)
- public DialogueSequenceSO ResolveVariant(DialogueSequenceSO sequence);
-}
-```
-
-**DialoguePanel**(UI Toolkit 或 UGUI):
-- 文本框(Text Mesh Pro)
-- 说话人姓名框
-- 头像 Image
-- 翻页箭头(Blink 动画)
-
-### 3.2 InteractableNPC + NarrativeNPC
-
-```csharp
-// Assets/Scripts/Dialogue/InteractableNPC.cs
-// 实现 IInteractable(BaseGames.World 命名空间)
-public class InteractableNPC : MonoBehaviour, IInteractable
-{
- [SerializeField] private string _npcId;
- [SerializeField] private DialogueSequenceSO _defaultDialogue;
- [SerializeField] private float _interactRadius = 1.5f;
-
- // ⚠️ 通过场景内注入(或 Find),不使用 DialogueManager.Instance 单例(架构 14_NarrativeModule §6)
- private DialogueManager _dialogueManager;
-
- public bool CanInteract => true;
- public string InteractPrompt => "对话"; // ⚠️ 架构 14 §6:提示文本为 "对话"(非 "按 [X] 对话")
-
- public void Interact(Transform player) // ⚠️ 参数为 Transform(架构 14_NarrativeModule §1)
- {
- Interact_Internal(player); // ⚠️ 先调用子类扩展钩子(架构 14 §6,原 Plan 遗漏)
- _dialogueManager.StartDialogue(GetCurrentDialogue()); // ⚠️ 通过虚方法获取对话,支持子类重写(原 Plan 遗漏)
- }
-
- /// 子类覆盖此方法以在对话前注入额外逻辑(如接受/完成任务)。
- protected virtual void Interact_Internal(Transform player) { } // ⚠️ 架构 14 §6(原 Plan 遗漏)
-
- /// 子类覆盖此方法以根据游戏状态返回不同的对话 SO(见 NarrativeNPC)。
- protected virtual DialogueSequenceSO GetCurrentDialogue() => _defaultDialogue; // ⚠️ 架构 14 §6(原 Plan 遗漏)
-
- public void OnPlayerEnterRange(Transform player) { } // 交互提示 UI 由 InteractableDetector 统一发布
- public void OnPlayerExitRange() { } // 交互提示 UI 由 InteractableDetector 统一发布
-}
-```
-
-```csharp
-// Assets/Scripts/Dialogue/NarrativeNPC.cs
-// ⚠️ 架构 14 §7(原 Plan 遗漏):扩展 InteractableNPC,根据 WorldStateRegistry 标志动态选择对话版本
-public class NarrativeNPC : InteractableNPC
-{
- [Header("台词版本集(从高到低优先级排列)")]
- [SerializeField] DialogueVersion[] _dialogueVersions;
- [SerializeField] DialogueSequenceSO _fallbackDialogue; // 无条件满足时的默认台词
- [SerializeField] WorldStateRegistry _worldState; // SO 注入
-
- protected override DialogueSequenceSO GetCurrentDialogue()
- {
- foreach (var version in _dialogueVersions)
- {
- if (version.CheckConditions(_worldState))
- return version.dialogue;
- }
- return _fallbackDialogue;
- }
-}
-
-[System.Serializable]
-public class DialogueVersion
-{
- public string versionLabel; // 编辑器显示名(如"森林Boss击败后")
- public DialogueSequenceSO dialogue;
- public string[] requiredFlags; // 全部满足才激活此版本(AND 关系)
- public string[] blockedByFlags; // 有任意一个 = 此版本不激活(NOT 关系)
-
- public bool CheckConditions(WorldStateRegistry registry)
- {
- foreach (var f in requiredFlags)
- if (!registry.HasFlag(f)) return false;
- foreach (var f in blockedByFlags)
- if (registry.HasFlag(f)) return false;
- return true;
- }
-}
-```
-
-### 3.2.1 InteractionPromptController(架构 14 §2)
-
-```csharp
-// Assets/Scripts/Dialogue/InteractionPromptController.cs
-// ⚠️ 挂载在每个 IInteractable GameObject 下作为子节点(架构 14_NarrativeModule §2)
-// 由 InteractableDetector 统一调用 Show()/Hide(),不直接挂在 InteractableDetector 上
-public class InteractionPromptController : MonoBehaviour
-{
- [SerializeField] GameObject _promptRoot; // 提示 UI 根节点(含图标、键位提示等)
- [SerializeField] Image _icon; // 动态切换键盘/手柄图标
- [SerializeField] Sprite _keyboardIcon; // 键盘/鼠标提示图标
- [SerializeField] Sprite _gamepadIcon; // 手柄提示图标
-
- public void Show()
- {
- _promptRoot.SetActive(true);
- // 根据当前活跃输入设备切换图标
- bool isGamepad = InputSystem.devices.OfType().Any(g => g.enabled);
- _icon.sprite = isGamepad ? _gamepadIcon : _keyboardIcon;
- }
-
- public void Hide() => _promptRoot.SetActive(false);
-}
-```
-
-> **检测逻辑**:`InteractableDetector`(挂在 Player 子节点)通过 `CircleCollider2D` 检测范围内的 `IInteractable`,调用 `interactable.GetComponent()?.Show()/Hide()`。
-
-### 3.3 EventChain + EventChainManager
-
-```csharp
-// EventChainSO — Condition+Action 驱动模型(架构 14_NarrativeModule §9)
-// ⚠️ menuName = "EventChain/EventChain"
-// ⚠️ ChainCondition/ChainAction 均为 ScriptableObject 子类(原 Plan 错误使用 [Serializable] 普通类)
-[CreateAssetMenu(menuName = "EventChain/EventChain")]
-public class EventChainSO : ScriptableObject
-{
- [Header("基础")]
- public string chainId; // 全局唯一,如 "Chain_BossForest_Defeated"
- public bool repeatable; // ⚠️ false = 仅触发一次(原 Plan 错误使用 oneShot 字段名,架构 14 §9)
- public float actionDelay = 0f; // ⚠️ 各 action 之间的延迟(秒)(原 Plan 遗漏,架构 14 §9)
-
- [Header("触发条件(全部满足才触发)")]
- public ChainCondition[] conditions; // ⚠️ ScriptableObject 数组(原 Plan 错误使用 [SerializeReference] List<>)
-
- [Header("执行动作(顺序执行)")]
- public ChainAction[] actions; // ⚠️ ScriptableObject 数组(同上)
-}
-
-// ── ChainCondition 基类(⚠️ ScriptableObject,非普通类;Register/Unregister/IsMet 模式,架构 14 §9)
-// ⚠️ 原 Plan 将 ChainCondition 实现为带 Evaluate(WorldStateRegistry) 的普通类,与架构不符
-public abstract class ChainCondition : ScriptableObject
-{
- public abstract void Register(EventChainManager manager);
- public abstract void Unregister(EventChainManager manager);
- public abstract bool IsMet();
-}
-
-// ── 7 个内置 ChainCondition 实现 ─────────────────────────────────────────────────────────────
-
-[CreateAssetMenu(menuName = "EventChain/Condition/BossDefeated")]
-public class BossDefeatedCondition : ChainCondition
-{
- public string bossId;
- bool _met;
- public override void Register(EventChainManager m) => m.OnBossDefeated += Check;
- public override void Unregister(EventChainManager m) => m.OnBossDefeated -= Check;
- public override bool IsMet() => _met;
- void Check(string id) { if (id == bossId) _met = true; }
-}
-
-[CreateAssetMenu(menuName = "EventChain/Condition/FlagSet")]
-public class FlagSetCondition : ChainCondition
-{
- public string flagId;
- public override void Register(EventChainManager m) { } // 无需订阅事件,持续轮询
- public override void Unregister(EventChainManager m) { }
- public override bool IsMet() => SaveManager.Instance.GetFlag(flagId);
-}
-
-[CreateAssetMenu(menuName = "EventChain/Condition/AbilityUnlocked")]
-public class AbilityUnlockedCondition : ChainCondition
-{
- public string abilityId;
- bool _met;
- public override void Register(EventChainManager m) => m.OnAbilityUnlocked += Check;
- public override void Unregister(EventChainManager m) => m.OnAbilityUnlocked -= Check;
- public override bool IsMet() => _met;
- void Check(string id) { if (id == abilityId) _met = true; }
-}
-
-[CreateAssetMenu(menuName = "EventChain/Condition/CollectibleCollected")]
-public class CollectibleCollectedCondition : ChainCondition
-{
- public string itemId;
- bool _met;
- public override void Register(EventChainManager m) => m.OnCollectiblePickedUp += Check;
- public override void Unregister(EventChainManager m) => m.OnCollectiblePickedUp -= Check;
- public override bool IsMet() => _met;
- void Check(string id) { if (id == itemId) _met = true; }
-}
-
-[CreateAssetMenu(menuName = "EventChain/Condition/RoomEntered")]
-public class RoomEnteredCondition : ChainCondition
-{
- public string sceneName;
- bool _met;
- public override void Register(EventChainManager m) => m.OnRoomEntered += Check;
- public override void Unregister(EventChainManager m) => m.OnRoomEntered -= Check;
- public override bool IsMet() => _met;
- void Check(string id) { if (id == sceneName) _met = true; }
-}
-
-[CreateAssetMenu(menuName = "EventChain/Condition/DialogueCompleted")]
-public class DialogueCompletedCondition : ChainCondition
-{
- public string npcId;
- public string sequenceId;
- bool _met;
- public override void Register(EventChainManager m) => m.OnDialogueCompleted += Check;
- public override void Unregister(EventChainManager m) => m.OnDialogueCompleted -= Check;
- public override bool IsMet() => _met;
- void Check(string id) { if (id == npcId) _met = true; }
-}
-
-[CreateAssetMenu(menuName = "EventChain/Condition/ChainCompleted")]
-public class ChainCompletedCondition : ChainCondition
-{
- public string chainId;
- bool _met;
- public override void Register(EventChainManager m) => m.OnChainCompleted += Check;
- public override void Unregister(EventChainManager m) => m.OnChainCompleted -= Check;
- public override bool IsMet() => _met;
- void Check(string id) { if (id == chainId) _met = true; }
-}
-
-// ── ChainAction 基类(⚠️ ScriptableObject,非普通类;ExecuteAsync(MonoBehaviour) 模式,架构 14 §9)
-// ⚠️ 原 Plan 将 ChainAction 实现为带 Execute(EventChainContext) 的普通类,与架构不符
-public abstract class ChainAction : ScriptableObject
-{
- // ⚠️ 方法名 ExecuteAsync(非 Execute),参数为 MonoBehaviour runner(非 EventChainContext)
- public abstract IEnumerator ExecuteAsync(MonoBehaviour runner);
-}
-
-// ── 10 个内置 ChainAction 实现 ────────────────────────────────────────────────────────────────
-
-[CreateAssetMenu(menuName = "EventChain/Action/OpenDoor")]
-public class OpenDoorAction : ChainAction
-{
- public string doorId;
- [SerializeField] StringEventChannelSO _onDoorOpened; // EVT_DoorOpened
- public override IEnumerator ExecuteAsync(MonoBehaviour runner)
- { _onDoorOpened.Raise(doorId); yield break; }
-}
-
-[CreateAssetMenu(menuName = "EventChain/Action/SetFlag")]
-public class SetFlagAction : ChainAction
-{
- public string flagId;
- public bool value;
- [SerializeField] StringEventChannelSO _onFlagChanged; // EVT_FlagChanged
- public override IEnumerator ExecuteAsync(MonoBehaviour runner)
- {
- SaveManager.Instance.SetFlag(flagId, value);
- _onFlagChanged.Raise(flagId);
- yield break;
- }
-}
-
-[CreateAssetMenu(menuName = "EventChain/Action/UpdateMap")]
-public class UpdateMapAction : ChainAction
-{
- public string regionId;
- public override IEnumerator ExecuteAsync(MonoBehaviour runner)
- { MapManager.Instance.RevealRegion(regionId); yield break; }
-}
-
-[CreateAssetMenu(menuName = "EventChain/Action/PlayCutscene")]
-public class PlayCutsceneAction : ChainAction
-{
- public string cutsceneId;
- [SerializeField] StringEventChannelSO _onPlayCutscene; // → CutsceneManager.PlayById
- [SerializeField] VoidEventChannelSO _onCutsceneEnded; // ← CutsceneManager 播完时 Raise
- public override IEnumerator ExecuteAsync(MonoBehaviour runner)
- {
- bool done = false;
- _onCutsceneEnded.OnEventRaised += OnDone;
- _onPlayCutscene.Raise(cutsceneId);
- yield return new WaitUntil(() => done);
- _onCutsceneEnded.OnEventRaised -= OnDone;
- void OnDone() => done = true;
- }
-}
-
-[CreateAssetMenu(menuName = "EventChain/Action/ChangeNPCDialogue")]
-public class ChangeNPCDialogueAction : ChainAction
-{
- public string npcId;
- public string newSequenceId;
- public override IEnumerator ExecuteAsync(MonoBehaviour runner)
- { NPCRegistry.Instance.SetDialogueSequence(npcId, newSequenceId); yield break; }
-}
-
-[CreateAssetMenu(menuName = "EventChain/Action/SpawnObject")]
-public class SpawnObjectAction : ChainAction
-{
- public GameObject prefab;
- public Vector3 position;
- public override IEnumerator ExecuteAsync(MonoBehaviour runner)
- { Object.Instantiate(prefab, position, Quaternion.identity); yield break; }
-}
-
-[CreateAssetMenu(menuName = "EventChain/Action/Wait")]
-public class WaitAction : ChainAction
-{
- public float seconds;
- public override IEnumerator ExecuteAsync(MonoBehaviour runner)
- => new WaitForSeconds(seconds) as IEnumerator;
-}
-
-[CreateAssetMenu(menuName = "EventChain/Action/RaiseEvent")]
-public class RaiseEventAction : ChainAction
-{
- [SerializeField] VoidEventChannelSO eventChannelSO;
- public override IEnumerator ExecuteAsync(MonoBehaviour runner)
- { eventChannelSO.Raise(); yield break; }
-}
-
-[CreateAssetMenu(menuName = "EventChain/Action/UnlockAbility")]
-public class UnlockAbilityAction : ChainAction
-{
- public string abilityId;
- public override IEnumerator ExecuteAsync(MonoBehaviour runner)
- { PlayerStats.Instance.UnlockAbility(abilityId); yield break; }
-}
-
-[CreateAssetMenu(menuName = "EventChain/Action/PlayAudio")]
-public class PlayAudioAction : ChainAction
-{
- [SerializeField] StringEventChannelSO _onPlayBGM; // EVT_PlayBGM
- public string bgmKey;
- public override IEnumerator ExecuteAsync(MonoBehaviour runner)
- { _onPlayBGM.Raise(bgmKey); yield break; }
-}
-```
-
-**EventChainManager**(Architecture 14 §10):
-
-```csharp
-// Assets/Scripts/Cutscene/EventChainManager.cs
-// ⚠️ ChainCondition/ChainAction 均为 ScriptableObject 子类(架构 14 §10)
-// ⚠️ 含中继 C# 事件供 ChainCondition.Register() 订阅(原 Plan 遗漏)
-// ⚠️ 存档集成:Awake 从 SaveManager 恢复已完成链;ExecuteChain 写入 SaveManager(原 Plan 遗漏)
-public class EventChainManager : MonoBehaviour
-{
- [Header("所有事件链")]
- [SerializeField] EventChainSO[] _chains;
-
- [Header("事件频道(中继)")]
- [SerializeField] StringEventChannelSO _onBossDefeated; // EVT_EnemyDied (bossId)
- [SerializeField] StringEventChannelSO _onCollectiblePickedUp; // EVT_CollectiblePickup
- [SerializeField] StringEventChannelSO _onAbilityUnlocked; // EVT_AbilityUnlocked
- [SerializeField] StringEventChannelSO _onRoomEntered; // EVT_SceneLoaded
- [SerializeField] StringEventChannelSO _onDialogueCompleted; // EVT_NpcDialogueCompleted
-
- // ⚠️ 中继 C# 事件,供 ChainCondition.Register() 订阅(原 Plan 遗漏)
- public event Action OnBossDefeated;
- public event Action OnCollectiblePickedUp;
- public event Action OnAbilityUnlocked;
- public event Action OnRoomEntered;
- public event Action OnDialogueCompleted;
- public event Action OnChainCompleted; // 链完成时广播 chainId(供 ChainCompletedCondition)
-
- readonly HashSet _completedChains = new();
-
- void Awake()
- {
- // ⚠️ 从 SaveData 恢复已完成链 ID(原 Plan 遗漏)
- foreach (var id in SaveManager.Instance.GetCompletedChains())
- _completedChains.Add(id);
- }
-
- void OnEnable()
- {
- _onBossDefeated.OnEventRaised += id => { OnBossDefeated?.Invoke(id); EvaluateAll(); };
- _onCollectiblePickedUp.OnEventRaised += id => { OnCollectiblePickedUp?.Invoke(id); EvaluateAll(); };
- _onAbilityUnlocked.OnEventRaised += id => { OnAbilityUnlocked?.Invoke(id); EvaluateAll(); };
- _onRoomEntered.OnEventRaised += id => { OnRoomEntered?.Invoke(id); EvaluateAll(); };
- _onDialogueCompleted.OnEventRaised += id => { OnDialogueCompleted?.Invoke(id); EvaluateAll(); };
-
- // ⚠️ 向每个 Condition 注册中继事件(原 Plan 遗漏)
- foreach (var chain in _chains)
- foreach (var cond in chain.conditions)
- cond.Register(this);
- }
-
- void OnDisable()
- {
- foreach (var chain in _chains)
- foreach (var cond in chain.conditions)
- cond.Unregister(this);
- }
-
- void EvaluateAll()
- {
- foreach (var chain in _chains)
- {
- // ⚠️ 使用 repeatable 字段(原 Plan 错误使用 oneShot)
- if (!chain.repeatable && _completedChains.Contains(chain.chainId))
- continue;
-
- if (Array.TrueForAll(chain.conditions, c => c.IsMet()))
- StartCoroutine(ExecuteChain(chain));
- }
- }
-
- IEnumerator ExecuteChain(EventChainSO chain)
- {
- if (!chain.repeatable)
- _completedChains.Add(chain.chainId);
-
- foreach (var action in chain.actions)
- {
- yield return action.ExecuteAsync(this); // ⚠️ ExecuteAsync(MonoBehaviour)(原 Plan:Execute(EventChainContext))
-
- // ⚠️ actionDelay 支持(原 Plan 遗漏)
- if (chain.actionDelay > 0f)
- yield return new WaitForSeconds(chain.actionDelay);
- }
-
- // ⚠️ 存档集成(原 Plan 遗漏)
- SaveManager.Instance.SetChainCompleted(chain.chainId);
- OnChainCompleted?.Invoke(chain.chainId);
- }
-}
-```
-
-### 3.4 CutsceneManager + CutsceneSO + CutsceneTrigger
-
-```csharp
-// Assets/Scripts/Cutscene/CutsceneManager.cs
-// ⚠️ 基于 PlayableDirector.stopped 回调,非 async(架构 14_NarrativeModule §7)
-[RequireComponent(typeof(PlayableDirector))]
-public class CutsceneManager : MonoBehaviour
-{
- [SerializeField] private InputReaderSO _inputReader;
- [SerializeField] private VoidEventChannelSO _onCutsceneStarted;
- [SerializeField] private VoidEventChannelSO _onCutsceneEnded;
-
- private PlayableDirector _director;
- public bool IsPlaying => _director.state == PlayState.Playing;
-
- private void Awake() => _director = GetComponent();
-
- // ⚠️ 参数为 CutsceneSO(非 TimelineAsset),架构 14 §7;需应用 Track→GameObject 绑定(原 Plan 遗漏)
- public void PlayCutscene(CutsceneSO cutscene)
- {
- if (cutscene == null) return;
- _director.playableAsset = cutscene.Timeline;
-
- // ⚠️ 应用 Track → GameObject 绑定(原 Plan 遗漏,架构 14 §7)
- foreach (var binding in cutscene.Bindings)
- {
- var track = cutscene.Timeline.GetOutputTrack(
- System.Array.FindIndex(cutscene.Bindings, b => b.trackName == binding.trackName));
- if (track != null && binding.target != null)
- _director.SetGenericBinding(track, binding.target);
- }
-
- _director.stopped += OnCutsceneStopped;
- _director.Play();
- _onCutsceneStarted.Raise();
- // 禁用 Gameplay 输入
- }
-
- public void StopCutscene() => _director.Stop();
-
- private void OnCutsceneStopped(PlayableDirector d)
- {
- _director.stopped -= OnCutsceneStopped;
- _onCutsceneEnded.Raise();
- // 恢复 Gameplay 输入
- }
-}
-
-// Assets/Scripts/Cutscene/CutsceneSO.cs
-[CreateAssetMenu(menuName = "Cutscene/Cutscene")]
-public class CutsceneSO : ScriptableObject
-{
- [Header("Identity")]
- public string cutsceneId;
- public string displayName; // ⚠️ 架构 14 §11.5(原 Plan 遗漏)
- public bool playOnlyOnce; // true → 仅首次播放
- public bool isSkippable = true; // ⚠️ 架构 14 §11.5(原 Plan 遗漏)
- public Sprite thumbnail; // ⚠️ 过场预览图(架构 14 §11.5,原 Plan 遗漏)
-
- [Header("Timeline")]
- public TimelineAsset Timeline;
-
- [Header("Timeline Bindings")]
- // ⚠️ Track→GameObject 绑定(架构 14 §11.5,原 Plan 遗漏);CutsceneManager.PlayCutscene 遍历此数组设置 binding
- public CutsceneBinding[] Bindings;
-
- [Header("Camera")]
- public CinemachineBlendDefinition BlendIn;
- public CinemachineBlendDefinition BlendOut;
-
- [Header("Optional Dialogue Overlay")]
- public DialogueSequenceSO[] DialogueLayers; // 过场中叠加播放的对话层
-}
-
-// ⚠️ 架构 14 §11.5(原 Plan 遗漏):将一条 Timeline Track 绑定到运行时场景对象
-[Serializable]
-public struct CutsceneBinding
-{
- [Tooltip("Timeline Track 的名称(需与 PlayableDirector 内轨道名一致)")]
- public string trackName;
- [Tooltip("绑定的目标对象;若为 null 则 CutsceneManager 会从场景中按 tag/name 查找")]
- public Object target; // UnityEngine.Object(可以是 GameObject / Component / asset)
-}
-
-// Assets/Scripts/Cutscene/CutsceneTrigger.cs
-// 实现 IInteractable(OnInteract 模式)
-public class CutsceneTrigger : MonoBehaviour, IInteractable
-{
- public enum TriggerMode
- {
- OnEnter, // ⚠️ 架构 14 §11.5:OnEnter(原 Plan 错误使用 OnZoneEnter)
- OnInteract, // 玩家主动交互(IInteractable)
- OnSceneLoad, // 场景加载完毕(Start)
- OnEvent, // ⚠️ 架构 14 §11.5(原 Plan 遗漏):订阅事件频道触发(配合 _triggerEventChannel)
- }
-
- [SerializeField] private CutsceneSO _cutscene;
- [SerializeField] private TriggerMode _mode = TriggerMode.OnEnter;
- [SerializeField] private CutsceneManager _cutsceneManager;
- [SerializeField] private VoidEventChannelSO _triggerEventChannel; // ⚠️ OnEvent 模式用(原 Plan 遗漏,架构 14 §11.5)
-
- // ⚠️ SO 注入(架构 14_NarrativeModule §11.5 patch):不使用 WorldStateRegistry.Instance
- [SerializeField] private WorldStateRegistry _worldState;
-
- public bool CanInteract => _mode == TriggerMode.OnInteract;
- public string InteractPrompt => "查看";
-
- public void Interact(Transform player) => TriggerCutscene();
- public void OnPlayerEnterRange(Transform player) { }
- public void OnPlayerExitRange() { }
-
- private void OnEnable()
- {
- // ⚠️ OnEvent 模式(原 Plan 遗漏)
- if (_mode == TriggerMode.OnEvent && _triggerEventChannel != null)
- _triggerEventChannel.OnEventRaised += TriggerCutscene;
- }
-
- private void OnDisable()
- {
- if (_mode == TriggerMode.OnEvent && _triggerEventChannel != null)
- _triggerEventChannel.OnEventRaised -= TriggerCutscene;
- }
-
- private void OnTriggerEnter2D(Collider2D other)
- {
- if (_mode != TriggerMode.OnEnter || !other.CompareTag("Player")) return; // ⚠️ OnEnter(非 OnZoneEnter)
- TriggerCutscene();
- }
-
- private void Start()
- {
- if (_mode == TriggerMode.OnSceneLoad) TriggerCutscene();
- }
-
- private void TriggerCutscene()
- {
- if (_cutscene == null) return;
- // ⚠️ SO 注入(非 Instance)+ HasFlag(非 IsFlagSet)
- if (_cutscene.playOnlyOnce &&
- _worldState != null && _worldState.HasFlag($"cutscene_played_{_cutscene.cutsceneId}"))
- return;
- _cutsceneManager.PlayCutscene(_cutscene); // ⚠️ 传入 CutsceneSO(原 Plan 错误传 _cutscene.Timeline),架构 14 §7
- _worldState?.SetFlag($"cutscene_played_{_cutscene.cutsceneId}");
- if (_mode == TriggerMode.OnEnter) enabled = false; // ⚠️ OnEnter(非 OnZoneEnter)
- }
-}
-```
-
-**资产路径**:`Assets/ScriptableObjects/Cutscene/`
-**命名规范**:`CS_{SceneId}_{ContextId}.asset`
-
-### 3.5 SignalEmitterClip — Timeline 零耦合事件桥接(⚠️ 架构 14 §11.6,原 Plan 遗漏)
-
-```csharp
-// Assets/Scripts/Cutscene/SignalEmitterClip.cs
-// ⚠️ 架构 14 §11.6(原 Plan 遗漏):Timeline 过场通过此 PlayableAsset 发布 SO 事件频道,保持零耦合
-// 在 Timeline 轨道上放置此 Clip,Clip 播放时向目标事件频道 Raise 一次事件
-[CreateAssetMenu(menuName = "BaseGames/Cutscene/SignalEmitterClip")]
-public class SignalEmitterClip : PlayableAsset, ITimelineClipAsset
-{
- [SerializeField] private EventChannelBaseSO _targetChannel; // 目标事件频道 SO
-
- // ITimelineClipAsset
- public ClipCaps clipCaps => ClipCaps.None;
-
- public override Playable CreatePlayable(PlayableGraph graph, GameObject owner)
- => ScriptPlayable.Create(graph,
- new SignalEmitterBehaviour { Clip = this });
-}
-
-// Assets/Scripts/Cutscene/SignalEmitterBehaviour.cs
-public class SignalEmitterBehaviour : PlayableBehaviour
-{
- public SignalEmitterClip Clip;
- private bool _fired;
-
- public override void OnBehaviourPlay(Playable playable, FrameData info)
- {
- _fired = false; // 重置,支持 Timeline 循环/重播
- }
-
- public override void ProcessFrame(Playable playable, FrameData info, object playerData)
- {
- if (!_fired && Clip._targetChannel != null)
- {
- Clip._targetChannel.RaiseEvent();
- _fired = true;
- }
- }
-}
-```
-
-**使用场景示例**:
-- 过场第 3 秒触发 `EVT_BossCutscenePhase2` → BossOrchestrator 切换阶段
-- 过场结束前 0.5 秒触发 `EVT_CutscenePreEnd` → HUD 开始淡入
-
-### 3.6 叙事事件频道清单(架构 14 §12)
-
-| 资产名 | 类型 | Raise 方 | Subscribe 方 |
-|--------|------|---------|-------------|
-| `EVT_DialogueStarted` | `VoidEventChannelSO` | `DialogueManager` | `InputReaderSO`(切 UI 输入)、`PlayerController`(锁定输入)|
-| `EVT_DialogueEnded` | `VoidEventChannelSO` | `DialogueManager` | `InputReaderSO`(切回 Gameplay)|
-| `EVT_CutsceneStarted` | `VoidEventChannelSO` | `CutsceneManager` | `HUDController`(隐藏 HUD)、`InputReaderSO` |
-| `EVT_CutsceneEnded` | `VoidEventChannelSO` | `CutsceneManager` | `HUDController`(恢复 HUD)|
-
----
-
-## 4. Week 17:UI 完整面板 + 按键重绑定 ✅ 完成(2026-05-11)
-
-**参考文档**:`10_UIModule.md`
-
-### 4.1 UIManager 完整路由
-
-```csharp
-// ⚠️ 字段类型为 GameObject(非类型化 UIPanel 子类),Stack 元素类型同为 GameObject(架构 10_UIModule §2)
-// ⚠️ 公开 API 为 OpenPanel(GameObject) / CloseTopPanel(),不存在 Push/Pop/PopAll 方法
-// UIManager 管理所有 Panel 的显示/隐藏,通过事件频道驱动
-[DefaultExecutionOrder(+50)]
-public class UIManager : MonoBehaviour
-{
- [Header("Canvas Roots")]
- [SerializeField] private GameObject _hudRoot;
- [SerializeField] private GameObject _pauseMenuRoot;
- [SerializeField] private GameObject _deathScreenRoot;
- [SerializeField] private GameObject _settingsRoot;
- [SerializeField] private GameObject _mapRoot;
- [SerializeField] private GameObject _shopRoot;
-
- // 事件频道
- [Header("Event Channels - Subscribe")]
- [SerializeField] private GameStateEventChannelSO _onGameStateChanged;
- [SerializeField] private VoidEventChannelSO _onPauseRequested;
- [SerializeField] private VoidEventChannelSO _onFastTravelOpen; // ⚠️ Architecture 10 §2 有此频道
- [SerializeField] private StringEventChannelSO _onShopOpen; // ⚠️ 字段名 _onShopOpen(非 _onShopOpened)
- [SerializeField] private VoidEventChannelSO _onMapOpen;
-
- // 通过 Stack 管理面板层级(支持 ESC 逐层关闭)
- private Stack _panelStack = new();
-
- private void OnEnable()
- {
- _onGameStateChanged.OnEventRaised += HandleGameStateChanged;
- _onPauseRequested.OnEventRaised += TogglePause;
- _onFastTravelOpen.OnEventRaised += OpenMap;
- _onShopOpen.OnEventRaised += OpenShop;
- _onMapOpen.OnEventRaised += OpenMap;
- }
-
- private void OnDisable()
- {
- _onGameStateChanged.OnEventRaised -= HandleGameStateChanged;
- _onPauseRequested.OnEventRaised -= TogglePause;
- _onFastTravelOpen.OnEventRaised -= OpenMap;
- _onShopOpen.OnEventRaised -= OpenShop;
- _onMapOpen.OnEventRaised -= OpenMap;
- }
-
- private void HandleGameStateChanged(GameStateId state)
- {
- // HUD 在 Gameplay 和 BossFight 状态下均显示(⚠️ 非仅 Gameplay,架构 10_UIModule §2)
- // ⚠️ GameStateId 是 struct,不能用 switch;用 if/else 比较(对齐架构 10 §2 patch)
- bool showHud = state == GameStates.Gameplay || state == GameStates.BossFight;
- _hudRoot.SetActive(showHud);
-
- if (state == GameStates.Dead)
- _deathScreenRoot.SetActive(true); // 死亡状态显示死亡画面(架构 10 §2)
- else if (state == GameStates.Cutscene)
- _hudRoot.SetActive(false); // 过场动画隐藏 HUD(架构 10 §2)
- }
-
- public void OpenPanel(GameObject panel)
- {
- if (_panelStack.Count > 0) _panelStack.Peek().SetActive(false);
- panel.SetActive(true);
- _panelStack.Push(panel);
- }
-
- public void CloseTopPanel()
- {
- if (_panelStack.Count == 0) return;
- _panelStack.Pop().SetActive(false);
- if (_panelStack.Count > 0) _panelStack.Peek().SetActive(true);
- }
-
- // 内部路由(由事件频道触发)
- private void TogglePause() => OpenPanel(_pauseMenuRoot);
- private void OpenShop(string npcId) => OpenPanel(_shopRoot);
- private void OpenMap() => OpenPanel(_mapRoot);
-}
-```
-
-### 4.2 各 Panel 内容要求
-
-**PausePanel**:
-- 继续游戏(Resume)
-- 设置(→ SettingsPanel)
-- 存档(调用 SaveManager.SaveAsync)
-- 返回主菜单(二次确认 → GameManager.ReturnToMainMenu)
-- 退出游戏(二次确认 → Application.Quit)
-
-**InventoryPanel**:
-- 护符槽可视化(总凹槽 N 个,已用 M 个)
-- 拥有的护符列表(可装备/卸载)
-- 工具槽(Slot0/Slot1,拖拽或按键分配)
-- 当前形态图标 + 形态切换按钮
-
-**SettingsPanel**:
-- 音量滑条(Master/BGM/SFX → AudioManager)
-- 分辨率/全屏(SettingsManager)
-- 语言下拉(LocalizationManager.SetLocale)
-- 按键重绑定区域(→ RebindPanel)
-
-**SaveSlotController + SaveSlotUI**(架构 10 §7.5):
-- 主菜单展示 3 个存档卡片;每卡展示角色名/场景/时间戳
-- 调用 `SaveManager.GetSlotSummaryAsync()` 异步加载占位数据
-- 支持新建游戏 / 加载 / 删除存档(二次确认)
-
-**LoadingOverlay**(架构 10 §8):
-- 订阅 `EVT_LoadingOverlay`(BoolEventChannelSO,true=显示/false=隐藏)
-- 展示全屏加载面(进度条 + Tip 文字 + 随机背景)
-
-**FloatingDamageText**(架构 10 §10):
-- 对象池驱动(AddressKeys.PrefabUIFloatingDmgText)
-- 由 `HUDController` 订阅 `EVT_DamageDealt` 并调用 `GlobalObjectPool.Spawn` 生成
-- 黑色 = 普通伤害,红色 = 暴击,灰色 = 防御减免
-
-**ToastNotification**(架构 10 §11):
-- 右上角弹出 Toast(成就/任务进度提示)
-- 订阅 `EVT_AchievementUnlocked` + `EVT_AbilityUnlocked`
-- 自动进队(Toast队列)+ 3 秒后淡出
-
-**InputDeviceIconSwitcher**(架构 10 §12):
-- 订阅 `EVT_InputDeviceChanged`(BoolEventChannelSO,true=手柄/false=键盘)
-- 遇到设备切换时全局刷新所有注册过的按键图标 Sprite
-
-### 4.3 RebindPanel + RebindActionRow + ConflictDetector
-
-> **⚠️ 架构约束(04_InputModule §6)**:重绑定 UI 由三个类组成:`RebindPanel`(主面板)、`RebindActionRow`(每行)、`ConflictDetector`(冲突检测)。所有持久化通过 `InputReaderSO.SaveBindingOverrides()` / `ResetBindings()` 进行,**禁止**直接访问 `_inputReader.Actions.asset`。
-
-```csharp
-// Assets/Scripts/UI/Settings/RebindPanel.cs
-// 设置界面中的完整按键重绑定面板(架构 04_InputModule §6)
-public class RebindPanel : MonoBehaviour
-{
- [SerializeField] private InputReaderSO _inputReader;
- [SerializeField] private RebindActionRow[] _rows; // Inspector 配置,顺序对应 HUD 布局
- [SerializeField] private Button _resetAllButton;
- [SerializeField] private ConflictDetector _conflictDetector;
-
- private void Awake()
- {
- _resetAllButton.onClick.AddListener(OnResetAll);
- foreach (var row in _rows)
- row.Initialize(_inputReader, _conflictDetector, OnRebindRequested);
- }
-
- private void OnRebindRequested(RebindActionRow row)
- {
- foreach (var r in _rows) r.SetInteractable(r == row);
- row.StartRebind(onFinished: () =>
- {
- foreach (var r in _rows) r.SetInteractable(true);
- _inputReader.SaveBindingOverrides(); // ⚠️ 通过 InputReaderSO 方法保存,非直接访问 asset
- });
- }
-
- private void OnResetAll()
- {
- _inputReader.ResetBindings(); // ⚠️ 通过 InputReaderSO.ResetBindings()
- _inputReader.SaveBindingOverrides();
- foreach (var row in _rows) row.RefreshDisplay();
- }
-}
-
-// Assets/Scripts/UI/Settings/RebindActionRow.cs
-// 单行:Action 名 + 当前绑定显示 + 点击启动重绑定
-public class RebindActionRow : MonoBehaviour
-{
- [SerializeField] private string _actionName;
- [SerializeField] private int _bindingIndex;
- [SerializeField] private TMP_Text _actionLabel;
- [SerializeField] private Button _bindButton;
- [SerializeField] private TMP_Text _currentBindingText;
-
- private InputReaderSO _inputReader;
- private ConflictDetector _conflictDetector;
- private Action _onRebindRequested;
-
- public void Initialize(InputReaderSO reader, ConflictDetector detector, Action onRequest)
- {
- _inputReader = reader; _conflictDetector = detector; _onRebindRequested = onRequest;
- _bindButton.onClick.AddListener(() => _onRebindRequested?.Invoke(this));
- RefreshDisplay();
- }
-
- public void StartRebind(Action onFinished)
- {
- _currentBindingText.text = "按下新按键…";
- // ⚠️ 通过 InputReaderSO.StartRebinding(),非直接调用 PerformInteractiveRebinding
- _inputReader.StartRebinding(_actionName, _bindingIndex,
- onComplete: () => { RefreshDisplay(); CheckConflicts(); onFinished?.Invoke(); },
- onCancel: () => { RefreshDisplay(); onFinished?.Invoke(); });
- }
-
- public void RefreshDisplay()
- {
- var action = _inputReader.FindAction(_actionName);
- _currentBindingText.text = action != null
- ? InputControlPath.ToHumanReadableString(
- action.bindings[_bindingIndex].effectivePath,
- InputControlPath.HumanReadableStringOptions.OmitDevice)
- : "—";
- }
-
- public void SetInteractable(bool v) => _bindButton.interactable = v;
-
- private void CheckConflicts()
- {
- var conflicts = _conflictDetector.FindConflicts(_inputReader.GetAllActionMap());
- foreach (var row in FindObjectsOfType())
- row.SetConflictHighlight(conflicts.Contains(row._actionName));
- }
-
- public void SetConflictHighlight(bool conflict)
- => _currentBindingText.color = conflict ? Color.red : Color.white;
-}
-
-// Assets/Scripts/Input/ConflictDetector.cs
-// 检测同一按键路径绑定了多个 Action
-public class ConflictDetector : MonoBehaviour
-{
- public HashSet FindConflicts(IEnumerable actions)
- {
- var pathToActions = new Dictionary>();
- foreach (var action in actions)
- foreach (var binding in action.bindings)
- {
- if (binding.isComposite || string.IsNullOrEmpty(binding.effectivePath)) continue;
- if (!pathToActions.ContainsKey(binding.effectivePath))
- pathToActions[binding.effectivePath] = new List();
- pathToActions[binding.effectivePath].Add(action.name);
- }
- var conflicted = new HashSet();
- foreach (var kv in pathToActions)
- if (kv.Value.Count > 1)
- foreach (var name in kv.Value)
- conflicted.Add(name);
- return conflicted;
- }
-}
-```
-
----
-
-### 4.4 HUDController 完整实现(Architecture 10 §3)
-
-```csharp
-// Assets/Scripts/UI/HUD/HUDController.cs
-// ⚠️ 文件路径:UI/HUD/(非 UI/Panels/);类名 HUDController(非 HUDPanel)
-public class HUDController : MonoBehaviour
-{
- [Header("HP")]
- [SerializeField] private Transform _hpContainer;
- [SerializeField] private GameObject _hpCellPrefab;
-
- [Header("Gauges")]
- [SerializeField] private Image _soulGaugeFill;
- [SerializeField] private Image _spiritGaugeFill;
- [SerializeField] private TMP_Text _geoText;
-
- [Header("Spring Charges")]
- [SerializeField] private Transform _springContainer;
- [SerializeField] private GameObject _springIconPrefab;
-
- [Header("Form")]
- [SerializeField] private Image[] _formIcons; // 3 形态图标
-
- [Header("Interact Prompt")]
- [SerializeField] private TMP_Text _interactText;
- [SerializeField] private GameObject _interactPromptRoot;
-
- [Header("Event Channels - Subscribe")]
- [SerializeField] private IntEventChannelSO _onHPChanged;
- [SerializeField] private IntEventChannelSO _onMaxHPChanged;
- [SerializeField] private IntEventChannelSO _onSoulPowerChanged;
- [SerializeField] private IntEventChannelSO _onSpiritPowerChanged;
- [SerializeField] private IntEventChannelSO _onGeoChanged;
- [SerializeField] private IntEventChannelSO _onSpringChargesChanged;
- [SerializeField] private IntEventChannelSO _onFormChanged;
- [SerializeField] private StringEventChannelSO _onShowInteractPrompt;
- [SerializeField] private VoidEventChannelSO _onHideInteractPrompt;
-
- private void OnEnable()
- {
- _onHPChanged.OnEventRaised += UpdateHP;
- _onMaxHPChanged.OnEventRaised += RebuildHPCells;
- _onSoulPowerChanged.OnEventRaised += val => _soulGaugeFill.fillAmount = val / 100f;
- _onSpiritPowerChanged.OnEventRaised += val => _spiritGaugeFill.fillAmount = val / 100f;
- _onGeoChanged.OnEventRaised += val => _geoText.text = val.ToString();
- _onSpringChargesChanged.OnEventRaised += RebuildSpringIcons;
- _onFormChanged.OnEventRaised += UpdateFormIcon;
- _onShowInteractPrompt.OnEventRaised += ShowInteractPrompt;
- _onHideInteractPrompt.OnEventRaised += HideInteractPrompt;
- }
- private void OnDisable()
- {
- _onHPChanged.OnEventRaised -= UpdateHP;
- _onMaxHPChanged.OnEventRaised -= RebuildHPCells;
- _onSoulPowerChanged.OnEventRaised -= val => _soulGaugeFill.fillAmount = val / 100f;
- _onSpiritPowerChanged.OnEventRaised -= val => _spiritGaugeFill.fillAmount = val / 100f;
- _onGeoChanged.OnEventRaised -= val => _geoText.text = val.ToString();
- _onSpringChargesChanged.OnEventRaised -= RebuildSpringIcons;
- _onFormChanged.OnEventRaised -= UpdateFormIcon;
- _onShowInteractPrompt.OnEventRaised -= ShowInteractPrompt;
- _onHideInteractPrompt.OnEventRaised -= HideInteractPrompt;
- }
-
- private void UpdateHP(int current); // 遍历 _hpContainer 子物体,激活前 current 个格子
- private void RebuildHPCells(int max); // 清空并重建 max 个 HP 格子 Prefab
- private void RebuildSpringIcons(int charges);
- private void UpdateFormIcon(int formIndex);
- private void ShowInteractPrompt(string text)
- {
- _interactText.text = text;
- _interactPromptRoot.SetActive(true);
- }
- private void HideInteractPrompt() => _interactPromptRoot.SetActive(false);
-}
-```
-
----
-
-### 4.5 BossHPBar + PauseMenuController + DeathScreenController(Architecture 10 §4–§6)
-
-```csharp
-// Assets/Scripts/UI/HUD/BossHPBar.cs
-// ⚠️ 全部事件频道已更新(Architecture 00_CoverageIndex §五 patch)
-public class BossHPBar : MonoBehaviour
-{
- [SerializeField] private TMP_Text _bossNameText;
- [SerializeField] private Image _hpFill;
- [SerializeField] private Transform _phaseMarkersRoot;
- [SerializeField] private GameObject _phaseMarkerPrefab;
-
- [Header("Event Channels")]
- // ⚠️ 已替换旧频道(旧:StringEventChannelSO _onBossFightStarted + BoolEventChannelSO _onBossFightEnded + FloatEventChannelSO _onBossHPRatioChanged)
- [SerializeField] private BoolEventChannelSO _onBossFightToggled; // EVT_BossFightToggled(true=战斗开始,false=结束)
- [SerializeField] private IntEventChannelSO _onBossHPChanged; // EVT_BossHPChanged(当前 HP 整数)
- [SerializeField] private StringEventChannelSO _onBossNameSet; // EVT_BossNameSet(Boss 名称 string)
- [SerializeField] private IntEventChannelSO _onBossHPMaxSet; // EVT_BossHPMaxSet(最大 HP 整数)
-
- private int _maxHP = 1;
-
- private void OnEnable()
- {
- _onBossFightToggled.OnEventRaised += OnFightToggled;
- _onBossHPChanged.OnEventRaised += OnHPChanged;
- _onBossNameSet.OnEventRaised += OnNameSet;
- _onBossHPMaxSet.OnEventRaised += hp => _maxHP = hp;
- }
- private void OnDisable()
- {
- _onBossFightToggled.OnEventRaised -= OnFightToggled;
- _onBossHPChanged.OnEventRaised -= OnHPChanged;
- _onBossNameSet.OnEventRaised -= OnNameSet;
- _onBossHPMaxSet.OnEventRaised -= hp => _maxHP = hp;
- }
-
- private void OnFightToggled(bool started)
- {
- // ⚠️ 架构 10 §4:使用 SlideIn/SlideOut 协程动画(原 Plan 错误使用 SetActive)
- if (started) StartCoroutine(SlideIn());
- else StartCoroutine(SlideOut());
- }
- private void OnHPChanged(int hp) => _hpFill.fillAmount = _maxHP > 0 ? (float)hp / _maxHP : 0f;
- private void OnNameSet(string bossName) { _bossNameText.text = bossName; } // 构建阶段标记,启用 GO
- private IEnumerator SlideIn(); // 动画:Boss 血条从屏幕底部滑入
- private IEnumerator SlideOut(); // 动画:Boss 血条滑出并隐藏
-}
-
-// Assets/Scripts/UI/Menus/PauseMenuController.cs
-public class PauseMenuController : MonoBehaviour
-{
- [SerializeField] private UIManager _uiManager;
- [SerializeField] private Button _btnResume;
- [SerializeField] private Button _btnSettings;
- [SerializeField] private Button _btnMainMenu;
- [SerializeField] private Button _btnQuit;
- [SerializeField] private GameObject _settingsRoot;
-
- [Header("Event Channels")]
- [SerializeField] private GameStateEventChannelSO _onGameStateChanged; // ⚠️ 架构 10 §5(原 Plan 遗漏)
- [SerializeField] private VoidEventChannelSO _onResumeRequested;
-
- private void Awake()
- {
- _btnResume.onClick.AddListener(Resume);
- _btnSettings.onClick.AddListener(() => _uiManager.OpenPanel(_settingsRoot));
- _btnMainMenu.onClick.AddListener(GoToMainMenu);
- _btnQuit.onClick.AddListener(Application.Quit);
- }
-
- private void Resume()
- {
- _onResumeRequested.Raise();
- _uiManager.CloseTopPanel();
- }
-
- private void GoToMainMenu();
- // 发布 EVT_SceneLoadRequest(目标 = MainMenuScene)
-}
-
-// Assets/Scripts/UI/Menus/DeathScreenController.cs
-// ⚠️ 类名 DeathScreenController(非 DeathPanel);路径 UI/Menus/(非 UI/Panels/)
-public class DeathScreenController : MonoBehaviour
-{
- [SerializeField] private TMP_Text _deathMessage;
- [SerializeField] private Button _btnRespawn;
-
- [Header("Event Channels")]
- [SerializeField] private VoidEventChannelSO _onPlayerDied;
- [SerializeField] private VoidEventChannelSO _onDeathScreenConfirmed;
-
- private void OnEnable() => _onPlayerDied.OnEventRaised += OnPlayerDied;
- private void OnDisable() => _onPlayerDied.OnEventRaised -= OnPlayerDied;
-
- // ⚠️ 必须延迟 1.5s(Architecture 10 §6 修正,AI-82):死亡动画播完后再显示
- private void OnPlayerDied() => StartCoroutine(ShowAfterDelay(1.5f));
-
- private IEnumerator ShowAfterDelay(float delay)
- {
- yield return new WaitForSeconds(delay);
- Show();
- }
-
- private void Show()
- {
- gameObject.SetActive(true);
- _btnRespawn.onClick.RemoveAllListeners();
- _btnRespawn.onClick.AddListener(Confirm);
- }
-
- private void Confirm()
- {
- gameObject.SetActive(false);
- _onDeathScreenConfirmed.Raise();
- }
-}
-```
-
----
-
-### 4.6 SettingsPanelController + LoadingOverlay(Architecture 10 §7–§8)
-
-```csharp
-// Assets/Scripts/UI/Menus/SettingsPanelController.cs
-public class SettingsPanelController : MonoBehaviour
-{
- [SerializeField] private SettingsManager _settings;
-
- [Header("Audio")]
- [SerializeField] private Slider _masterVolume;
- [SerializeField] private Slider _bgmVolume;
- [SerializeField] private Slider _sfxVolume;
- [SerializeField] private Slider _ambientVolume;
-
- [Header("Video")]
- [SerializeField] private Toggle _vSyncToggle;
- [SerializeField] private TMP_Dropdown _fpsDropdown;
- [SerializeField] private TMP_Dropdown _resolutionDropdown;
-
- private void Start()
- {
- // 从 SettingsManager 读取当前值并填充控件
- _masterVolume.value = _settings.GetMasterVolume();
- _bgmVolume.value = _settings.GetBGMVolume();
- _sfxVolume.value = _settings.GetSFXVolume();
-
- // 绑定 onChange 回调
- _masterVolume.onValueChanged.AddListener(_settings.SetMasterVolume);
- _bgmVolume.onValueChanged.AddListener(_settings.SetBGMVolume);
- _sfxVolume.onValueChanged.AddListener(_settings.SetSFXVolume);
- _ambientVolume.onValueChanged.AddListener(_settings.SetAmbientVolume);
- _vSyncToggle.onValueChanged.AddListener(_settings.SetVSync);
- }
-}
-
-// Assets/Scripts/UI/LoadingOverlay.cs
-// 由 SceneLoader 通过 EVT_LoadingOverlay(BoolEventChannelSO)控制全屏黑幕渐入渐出
-public class LoadingOverlay : MonoBehaviour
-{
- [SerializeField] private CanvasGroup _canvasGroup;
- [SerializeField] private float _fadeDuration = 0.3f;
-
- [Header("Event Channels")]
- [SerializeField] private BoolEventChannelSO _onLoadingOverlayRequested;
-
- private void OnEnable() => _onLoadingOverlayRequested.OnEventRaised += SetVisible;
- private void OnDisable() => _onLoadingOverlayRequested.OnEventRaised -= SetVisible;
-
- private void SetVisible(bool visible) => StartCoroutine(FadeCoroutine(visible ? 1f : 0f));
-
- private IEnumerator FadeCoroutine(float target)
- {
- float start = _canvasGroup.alpha;
- float t = 0;
- while (t < _fadeDuration)
- {
- _canvasGroup.alpha = Mathf.Lerp(start, target, t / _fadeDuration);
- t += Time.unscaledDeltaTime;
- yield return null;
- }
- _canvasGroup.alpha = target;
- _canvasGroup.blocksRaycasts = target > 0.5f;
- }
-}
-```
-
----
-
-### 4.7 FloatingDamageText + ToastManager + InputDeviceIconSwitcher(Architecture 10 §10–§12)
-
-```csharp
-// Assets/Scripts/UI/FloatingDamageText.cs
-// 从对象池取出,显示伤害数字,向上飘动后归还
-public class FloatingDamageText : MonoBehaviour
-{
- [SerializeField] private TMP_Text _text;
- [SerializeField] private float _floatDistance = 1.5f;
- [SerializeField] private float _duration = 0.8f;
-
- private string _poolKey = AddressKeys.UI_FloatingDmgText;
-
- public void Show(Vector2 worldPosition, int damage, DamageType type)
- {
- // 1. 世界坐标 → 屏幕坐标(Camera.main.WorldToScreenPoint → RectTransform.position)
- // 2. 颜色:Normal=白, Fire=橙, Poison=绿, True=黄
- // 3. 启动协程:向上漂移 _floatDistance + alpha 淡出,duration 后归还对象池
- }
-}
-
-// Assets/Scripts/UI/ToastNotification.cs
-// 右上角通知弹窗(能力解锁、成就)
-public class ToastNotification : MonoBehaviour
-{
- [SerializeField] private TMP_Text _titleText;
- [SerializeField] private TMP_Text _bodyText;
- [SerializeField] private Image _icon;
- [SerializeField] private float _displayDuration = 3f;
-
- public void Show(string title, string body, Sprite icon = null)
- {
- _titleText.text = title;
- _bodyText.text = body;
- if (icon != null) _icon.sprite = icon;
- gameObject.SetActive(true);
- StartCoroutine(AutoHide());
- }
-
- private IEnumerator AutoHide()
- {
- yield return new WaitForSeconds(_displayDuration);
- gameObject.SetActive(false);
- }
-}
-
-// Assets/Scripts/UI/ToastManager.cs
-// 管理通知队列(一次只显示一条)
-public class ToastManager : MonoBehaviour
-{
- [SerializeField] private ToastNotification _toast;
- private Queue<(string title, string body, Sprite icon)> _queue = new();
-
- [Header("Event Channels")]
- [SerializeField] private StringEventChannelSO _onAchievementUnlocked; // 成就解锁通知
-
- private void OnEnable() => _onAchievementUnlocked.OnEventRaised += OnAchievementUnlocked;
- private void OnDisable() => _onAchievementUnlocked.OnEventRaised -= OnAchievementUnlocked;
-
- private void OnAchievementUnlocked(string achievementId)
- => Enqueue("成就解锁", achievementId, null);
-
- public void Enqueue(string title, string body, Sprite icon = null)
- {
- _queue.Enqueue((title, body, icon));
- if (!_toast.gameObject.activeSelf) ShowNext();
- }
-
- private void ShowNext()
- {
- if (_queue.Count == 0) return;
- var (title, body, icon) = _queue.Dequeue();
- _toast.Show(title, body, icon);
- }
-}
-
-// Assets/Scripts/UI/InputDeviceIconSwitcher.cs
-// 检测输入设备切换(KB/手柄),自动替换 UI 按键图标
-public class InputDeviceIconSwitcher : MonoBehaviour
-{
- [SerializeField] private InputDeviceIconSetSO _kbIconSet;
- [SerializeField] private InputDeviceIconSetSO _padIconSet;
-
- [Header("Event Channel")]
- [SerializeField] private BoolEventChannelSO _onDeviceChanged; // true = 手柄
-
- private void OnEnable() => _onDeviceChanged.OnEventRaised += SwitchIconSet;
- private void OnDisable() => _onDeviceChanged.OnEventRaised -= SwitchIconSet;
-
- private void SwitchIconSet(bool isGamepad)
- {
- var set = isGamepad ? _padIconSet : _kbIconSet;
- foreach (var iconImg in FindObjectsOfType())
- iconImg.RefreshFromSet(set);
- }
-}
-```
-
----
-
-### 4.8 SaveSlotController + SaveIndicator + LoadingScreenManager + IBossHPProvider + DialogueBox(⚠️ 架构 10 §7.5–§9,原 Plan 遗漏)
-
-```csharp
-// Assets/Scripts/UI/Menus/SaveSlotController.cs
-// ⚠️ 架构 10 §7.5(原 Plan 遗漏):驱动主菜单存档槽选择(新游戏 / 继续)
-public class SaveSlotController : MonoBehaviour
-{
- [SerializeField] private SaveSlotUI[] _slotUIs; // 3 个存档槽 UI
- [SerializeField] private SaveManager _saveManager;
-
- public async UniTask RefreshAsync()
- {
- for (int i = 0; i < 3; i++)
- {
- var summary = await _saveManager.GetSlotSummaryAsync(i);
- _slotUIs[i].Refresh(summary); // null = 空槽(显示"新局")
- }
- }
-
- public void OnSlotSelected(int slotIndex);
- // 新局:_saveManager.CreateSlot(slotIndex) → 启动游戏
- // 继续:_saveManager.LoadAsync(slotIndex) → 载入存档
-}
-
-// 单个存档槽卡片组件
-public class SaveSlotUI : MonoBehaviour
-{
- [SerializeField] private TMP_Text _playtimeText;
- [SerializeField] private TMP_Text _regionText;
- [SerializeField] private TMP_Text _percentText;
- [SerializeField] private Image _formIcon;
- [SerializeField] private TMP_Text _lastSavedText;
- [SerializeField] private GameObject _emptyIndicator; // 空槽提示
-
- public void Refresh(SlotSummary summary);
- // summary == null → 空槽,显示 _emptyIndicator + "新游戏"
-}
-
-// Assets/Scripts/UI/SaveIndicator.cs
-// ⚠️ 架构 10 §7.6(原 Plan 遗漏):右下角存档进行中提示图标(EVT_SaveStarted/EVT_SaveCompleted)
-[RequireComponent(typeof(CanvasGroup))]
-public class SaveIndicator : MonoBehaviour
-{
- [SerializeField] private CanvasGroup _cg;
- [SerializeField] private float _fadeDuration = 0.2f;
-
- [Header("Event Channels")]
- [SerializeField] private VoidEventChannelSO _onSaveStarted;
- [SerializeField] private VoidEventChannelSO _onSaveCompleted;
-
- private void OnEnable()
- {
- _onSaveStarted.OnEventRaised += () => StartCoroutine(FadeTo(1f));
- _onSaveCompleted.OnEventRaised += () => StartCoroutine(FadeTo(0f));
- }
- private void OnDisable()
- {
- _onSaveStarted.OnEventRaised -= () => StartCoroutine(FadeTo(1f));
- _onSaveCompleted.OnEventRaised -= () => StartCoroutine(FadeTo(0f));
- }
-
- private IEnumerator FadeTo(float target)
- {
- float start = _cg.alpha, t = 0;
- while (t < _fadeDuration)
- {
- _cg.alpha = Mathf.Lerp(start, target, t / _fadeDuration);
- t += Time.unscaledDeltaTime;
- yield return null;
- }
- _cg.alpha = target;
- }
-}
-
-// Assets/Scripts/UI/LoadingScreenManager.cs
-// ⚠️ 架构 10 §7.7(原 Plan 遗漏):全屏加载面(进度条 + 提示文字 + 随机背景图)
-public class LoadingScreenManager : MonoBehaviour
-{
- [SerializeField] private GameObject _loadingRoot;
- [SerializeField] private Image _progressFill;
- [SerializeField] private TMP_Text _tipText;
- [SerializeField] private Image[] _backgroundArts;
- [SerializeField] private string[] _tipKeys; // 本地化 Key 数组
- [SerializeField] private float _minDisplayTime = 0.5f;
-
- [Header("Event Channels")]
- [SerializeField] private VoidEventChannelSO _onLoadingStarted;
- [SerializeField] private VoidEventChannelSO _onLoadingComplete;
- [SerializeField] private FloatEventChannelSO _onLoadingProgressUpdated; // 0–1
-
- private void OnEnable()
- {
- _onLoadingStarted.OnEventRaised += Show;
- _onLoadingComplete.OnEventRaised += Hide;
- _onLoadingProgressUpdated.OnEventRaised += SetProgress;
- }
- private void OnDisable()
- {
- _onLoadingStarted.OnEventRaised -= Show;
- _onLoadingComplete.OnEventRaised -= Hide;
- _onLoadingProgressUpdated.OnEventRaised -= SetProgress;
- }
-
- private void Show()
- {
- _loadingRoot.SetActive(true);
- _progressFill.fillAmount = 0f;
- foreach (var bg in _backgroundArts) bg.enabled = false;
- _backgroundArts[Random.Range(0, _backgroundArts.Length)].enabled = true;
- _tipText.text = LocalizationManager.Get("UI", _tipKeys[Random.Range(0, _tipKeys.Length)]);
- }
-
- private void Hide() => StartCoroutine(HideAfterMinTime());
- private void SetProgress(float v) => _progressFill.fillAmount = v;
-
- private IEnumerator HideAfterMinTime()
- {
- yield return new WaitForSecondsRealtime(_minDisplayTime);
- _loadingRoot.SetActive(false);
- }
-}
-
-// Assets/Scripts/UI/HUD/IBossHPProvider.cs
-// ⚠️ 架构 10 §7.8(原 Plan 遗漏):解耦接口,让 BossHPBar 不直接依赖 BossBase
-public interface IBossHPProvider
-{
- string BossId { get; }
- string BossNameKey { get; }
- float HPRatio { get; } // 0–1 实时 HP 比例
- int TotalPhases { get; }
- float[] PhaseThresholds { get; } // 各阶段切换 HP 阈值
-}
-
-// Assets/Scripts/UI/DialogueBox.cs
-// ⚠️ 架构 10 §9(原 Plan 遗漏):对话框组件,由 DialogueManager 直接调用(不经过事件频道)
-// 挂载在 Canvas_Overlay/DialogueBox 子对象
-public class DialogueBox : MonoBehaviour
-{
- [SerializeField] private TMP_Text _speakerNameText;
- [SerializeField] private TMP_Text _dialogueText;
- [SerializeField] private GameObject _continuePrompt;
-
- public void Show(string speakerName, string text, bool showContinue);
- public void Hide();
-
- // DialogueManager 在 PlaySequence 中 yield return 此协程(实现打字机效果)
- public IEnumerator TypeText(string text, float charDelay = 0.03f);
-}
-```
-
----
-
-## 5. Week 18:支撑模块 + 编辑器工具 + QA ✅ 完成(2026-05-11)
-
-**参考文档**:`16_SupportingModules.md`
-
-> **✅ P4-5 验证状态(已完成验证)**
-> 所有支撑模块文件已按架构 §1-§9 逐一对比,以下为最终裁定:
->
-> | 模块 | 状态 | 说明 |
-> |------|------|------|
-> | `IPlatformService` | ✅ 已修复 | 补充了 `RunCallbacks`/`Shutdown`/`SetStat`/`IncrementStat`/`GetStat`/`IsCloudAvailable`/`CloudSaveAsync`/`CloudLoadAsync`/`SetRichPresence`/`ClearRichPresence`;保留代码额外扩展(排行榜/DLC/ShowOverlay/ClearAchievement)|
-> | `PlatformBootstrap` | ✅ 已修复 | 增加 `Update()→RunCallbacks()` + `OnApplicationQuit()→Shutdown()`|
-> | `NullPlatformService` | ✅ 已更新 | 实现完整接口 |
-> | `SteamPlatformService` | ✅ 已更新 | 增加 SetStat/IncrementStat/GetStat、CloudSaveAsync/CloudLoadAsync、SetRichPresence/ClearRichPresence、RunCallbacks/Shutdown |
-> | `AnalyticsManager` | ✅ 已修复 | Awake 中添加 `#if !UNITY_EDITOR && !DEVELOPMENT_BUILD` 保护(Release 包默认关闭);保留缓冲 JSON 格式(非架构 JSONL,可接受)|
-> | `AchievementCondition` | ✅ 维持代码 | `IsMet(SaveData)` 轮询比架构的 RegisterListeners 事件模式更简洁;`GetProgress(SaveData)` 为额外增强 |
-> | `AchievementManager` | ✅ 维持代码 | `ServiceLocator.Get()` 优于架构的 `#if STEAMWORKS_NET PlatformManager` 静态调用 |
-> | `LocalizationManager` | ✅ 维持代码 | `Get(entryKey, tableName)` 参数顺序更符合调用习惯;`#if UNITY_LOCALIZATION` 守卫必要(包未安装)|
-> | `LanguageManagerSO` | ✅ 维持代码 | 字段命名与 `ApplySaved()` 方法名不同于架构,但功能等价且更清晰 |
-> | `AccessibilitySettingsSO` | ✅ 维持代码(简化版)| 架构字段更丰富(字幕/输入辅助/音频),当前代码仅实现核心子集;后续 P5/P6 可扩展 |
-> | `AccessibilityManager` | ✅ 维持代码 | 缺少 `_onHighContrastChanged`/`_onSubtitlesChanged` 频道(AccessibilitySettingsSO 简化对应);功能等价 |
-> | `ColorBlindFilter` | ✅ 维持代码 | URP RenderFeature + Brettel/Viénot 色彩矩阵实现正确 |
-> | `AntiSoftlockSystem` | ✅ 维持代码 | 代码用 `_stuckTimer + linearVelocity` 检测,比架构的位置距离对比更准确 |
-> | `RoomEscapeInfoSO` | ✅ 维持代码(单路径版)| 架构用多路径 `EscapeRoute[]`,代码用单路径+优先级数组,更适合当前房间规模 |
-> | `HardAbilityGate` | ✅ 维持代码 | 用 `World.Switches` key 验证物理拾取,比架构 `IsAbilityActuallyUnlocked()` 方法(不存在)更可行 |
-> | `SpeedrunTimer` | ✅ 维持代码 | 代码有显式 Start/Pause/Resume/Stop API,比架构订阅 `_onGameplayActive` 事件更灵活;后续可补事件订阅 |
-
-### 5.1 LocalizationManager
-
-按 `16_SupportingModules.md §1` 实现(Unity Localization 包封装)。
-
-```csharp
-// 路径: Assets/Scripts/Support/Localization/LocalizationManager.cs
-// Unity Localization 包(com.unity.localization)的轻量封装
-// 游戏内所有文本通过此类获取,不直接引用 LocalizationSettings
-public static class LocalizationManager
-{
- // 当前语言
- public static Locale ActiveLocale => LocalizationSettings.SelectedLocale;
-
- // 同步获取本地化字符串(Locale 已完全加载时使用)
- public static string Get(string tableKey, string entryKey)
- {
- var op = LocalizationSettings.StringDatabase.GetLocalizedString(tableKey, entryKey);
- return op.IsDone ? op.Result : entryKey;
- }
-
- // 异步获取(在等待 Locale 初始化的场景中使用)
- public static async Task GetAsync(string tableKey, string entryKey)
- {
- var op = LocalizationSettings.StringDatabase.GetLocalizedStringAsync(tableKey, entryKey);
- return await op.Task;
- }
-
- // 切换语言(由 SettingsPanelController 的语言下拉框调用)
- public static void SetLocale(string localeCode)
- {
- var locale = LocalizationSettings.AvailableLocales.Locales
- .FirstOrDefault(l => l.Identifier.Code == localeCode);
- if (locale != null)
- LocalizationSettings.SelectedLocale = locale;
- }
-
- // 快捷常量:String Table 名称
- public const string Table_UI = "UI";
- public const string Table_Dialogue = "Dialogue";
- public const string Table_Items = "Items";
- public const string Table_Enemies = "Enemies";
-}
-```
-
-### 5.1.1 LanguageManagerSO(语言切换 SO 单例)
-
-> **⚠️ 架构 16_SupportingModules §1.1 patch**:静态 `LocalizationManager` 仅做文本查询(无持久化);**语言设置持久化和设置界面切换**应使用 `LanguageManagerSO` SO 单例。
-
-```csharp
-// 路径: Assets/ScriptableObjects/Localization/LanguageManager.asset
-// ⚠️ menuName = "Localization/LanguageManager"(架构 16 §1.1)
-// 消费者:SettingsPanelController(语言下拉框)、GameManager.Awake(启动时加载上次选择的语言)
-[CreateAssetMenu(menuName = "Localization/LanguageManager")]
-public class LanguageManagerSO : ScriptableObject
-{
- // PlayerPrefs 持久化键
- private const string PrefKey = "SelectedLocale";
-
- /// 切换语言并持久化选择(替代 LocalizationManager.SetLocale,后者不持久化)
- public void SetLocale(string localeCode)
- {
- var locale = LocalizationSettings.AvailableLocales.Locales
- .FirstOrDefault(l => l.Identifier.Code == localeCode);
- if (locale != null)
- {
- LocalizationSettings.SelectedLocale = locale;
- PlayerPrefs.SetString(PrefKey, localeCode);
- }
- }
-
- /// 获取当前语言代码(默认 zh-CN)
- public string GetCurrentLocaleCode()
- => LocalizationSettings.SelectedLocale?.Identifier.Code ?? "zh-CN";
-
- /// 游戏启动时从 PlayerPrefs 读取上次选择的语言(由 GameManager.Awake 调用)
- public void LoadSavedLocale()
- => SetLocale(PlayerPrefs.GetString(PrefKey, "zh-CN"));
-}
-```
-
-> **注意**:`SettingsPanelController` 应改用 `[SerializeField] LanguageManagerSO _languageManager;` + `_languageManager.SetLocale(code)` 而非直接调用 `LocalizationManager.SetLocale()`。
-
-**最小本地化内容**(Phase 4 必须):
-- UI String Table(所有 UI 按钮/标签文本)
-- Dialogue String Table(所有 NPC 对话行)
-- Items String Table(护符/工具名称 + 描述)
-- Enemies String Table(敌人名称,⚠️ 架构 16_SupportingModules §1 定义 `Table_Enemies = "Enemies"`)
-- 支持语言:简体中文(zh-CN)+ 英文(en)
-
-### 5.2 AchievementManager
-
-> **⚠️ 架构 16_SupportingModules §2 完整实现**:采用 `AchievementSO` + `AchievementCondition` **ScriptableObject 策略模式**;**彻底废弃** `AchievementDef` + `AchievementDatabaseSO` 旧方案。
-
-#### 5.2.1 AchievementSO(成就数据 SO)
-
-```csharp
-// 路径: Assets/Scripts/Support/Achievements/AchievementSO.cs
-// ⚠️ menuName = "Achievement/Achievement"(非旧版 "Support/AchievementDatabase")
-// 命名空间:namespace BaseGames.Achievement
-namespace BaseGames.Achievement
-{
- [CreateAssetMenu(menuName = "Achievement/Achievement")]
- public class AchievementSO : ScriptableObject
- {
- [Header("基础信息")]
- public string achievementId; // 全局唯一 ID,如 "Ach_SlayBoss_Forest"
- public string displayName;
- [TextArea(2, 5)]
- public string description;
- [TextArea(2, 5)]
- public string hiddenDescription; // 未解锁时显示(空 = 完全隐藏)
-
- [Header("外观")]
- public Sprite icon;
- public Sprite hiddenIcon; // 未解锁占位图标
-
- [Header("分类")]
- public AchievementType type; // 故事/收集/挑战/隐藏
- public AchievementTier tier; // 铜/银/金(展示用)
-
- [Header("解锁条件(AND 逻辑:全部满足才解锁)")]
- public AchievementCondition[] conditions; // ⚠️ ScriptableObject 策略模式(非旧版 AchievementDef.TargetCount)
-
- [Header("奖励(可选)")]
- public bool grantsNotch; // 解锁额外 Notch 槽
- }
-
- public enum AchievementType { Story, Collection, Challenge, Hidden }
- public enum AchievementTier { Bronze, Silver, Gold }
-}
-```
-
-#### 5.2.2 AchievementCondition(ScriptableObject 策略模式)
-
-```csharp
-// 路径: Assets/Scripts/Support/Achievements/AchievementCondition.cs
-// ⚠️ 抽象基类为 ScriptableObject(非 [Serializable] class),每种条件一个 SO 子类(架构 16 §2.2)
-namespace BaseGames.Achievement
-{
- public abstract class AchievementCondition : ScriptableObject
- {
- public abstract void RegisterListeners(AchievementManager manager);
- public abstract void UnregisterListeners(AchievementManager manager);
- public abstract bool IsMet(AchievementRuntimeState state);
- }
-}
-```
-
-**内置条件类型(12 种)**:
-
-| SO 子类 | menuName | 参数 |
-|---------|---------|------|
-| `DefeatedBossCondition` | `Achievement/Condition/DefeatedBoss` | `bossId: string` |
-| `DefeatedAllBossesCondition` | `Achievement/Condition/DefeatedAllBosses` | — |
-| `EnteredRegionCondition` | `Achievement/Condition/EnteredRegion` | `regionId: string` |
-| `MapExplorationCondition` | `Achievement/Condition/MapExploration` | `minPercent: float` |
-| `CollectedItemCondition` | `Achievement/Condition/CollectedItem` | `itemId: string` |
-| `CollectedAllCharmsCondition` | `Achievement/Condition/CollectedAllCharms` | — |
-| `UnlockedAllAbilitiesCondition` | `Achievement/Condition/UnlockedAllAbilities` | — |
-| `NoHealRunCondition` | `Achievement/Condition/NoHealRun` | — |
-| `TimedBossKillCondition` | `Achievement/Condition/TimedBossKill` | `bossId`, `maxSeconds` |
-| `ParryCountCondition` | `Achievement/Condition/ParryCount` | `requiredCount: int` |
-| `NailClashCountCondition` | `Achievement/Condition/NailClashCount` | `requiredCount: int` |
-| `EventTriggeredCondition` | `Achievement/Condition/EventTriggered` | `eventChannelSO` |
-
-```csharp
-// 示例:DefeatedBossCondition
-[CreateAssetMenu(menuName = "Achievement/Condition/DefeatedBoss")]
-public class DefeatedBossCondition : AchievementCondition
-{
- public string bossId;
-
- public override void RegisterListeners(AchievementManager manager)
- => manager.OnBossDefeated += Evaluate;
- public override void UnregisterListeners(AchievementManager manager)
- => manager.OnBossDefeated -= Evaluate;
-
- void Evaluate(string defeatedBossId, AchievementRuntimeState state)
- {
- if (defeatedBossId == bossId) state.SetConditionMet(this);
- }
-
- public override bool IsMet(AchievementRuntimeState state)
- => state.IsConditionMet(this);
-}
-```
-
-#### 5.2.3 AchievementManager + AchievementRuntimeState
-
-```csharp
-// 路径: Assets/Scripts/Support/Achievements/AchievementManager.cs
-// ⚠️ 命名空间:namespace BaseGames.Achievement(架构 16 §2.3)
-namespace BaseGames.Achievement
-{
- public class AchievementManager : MonoBehaviour, ISaveable
- {
- [Header("成就列表(每个成就一个 AchievementSO 资产)")]
- [SerializeField] AchievementSO[] _allAchievements; // ⚠️ 非旧版 AchievementDatabaseSO
-
- [Header("事件频道(订阅)")]
- [SerializeField] StringEventChannelSO _onBossDefeated;
- [SerializeField] StringEventChannelSO _onCollectiblePickedUp;
- [SerializeField] IntEventChannelSO _onAbilityUnlocked;
- [SerializeField] StringEventChannelSO _onRoomEntered;
- [SerializeField] VoidEventChannelSO _onParrySuccess;
- [SerializeField] VoidEventChannelSO _onNailClash;
-
- [Header("事件频道(发布)")]
- [SerializeField] AchievementEventChannelSO _onAchievementUnlocked; // ⚠️ AchievementEventChannelSO(非 StringEventChannelSO)
-
- // ── 内部中继 C# 事件(供 AchievementCondition 子类订阅)──────────────
- public event Action OnBossDefeated;
- public event Action OnCollectiblePickedUp;
- public event Action OnAbilityUnlocked;
- public event Action OnRoomEntered;
- public event Action OnParrySuccess;
- public event Action OnNailClash;
-
- readonly Dictionary _states = new();
-
- void Awake()
- {
- foreach (var ach in _allAchievements)
- _states[ach.achievementId] = new AchievementRuntimeState(ach);
- }
-
- void OnEnable()
- {
- _onBossDefeated.OnEventRaised += id => { OnBossDefeated?.Invoke(id); EvaluateAll(); };
- _onCollectiblePickedUp.OnEventRaised += id => { OnCollectiblePickedUp?.Invoke(id); EvaluateAll(); };
- _onAbilityUnlocked.OnEventRaised += v => { OnAbilityUnlocked?.Invoke(v); EvaluateAll(); };
- _onRoomEntered.OnEventRaised += id => { OnRoomEntered?.Invoke(id); EvaluateAll(); };
- _onParrySuccess.OnEventRaised += () => { OnParrySuccess?.Invoke(); EvaluateAll(); };
- _onNailClash.OnEventRaised += () => { OnNailClash?.Invoke(); EvaluateAll(); };
-
- foreach (var ach in _allAchievements)
- foreach (var cond in ach.conditions)
- cond.RegisterListeners(this);
- }
-
- void OnDisable()
- {
- foreach (var ach in _allAchievements)
- foreach (var cond in ach.conditions)
- cond.UnregisterListeners(this);
- }
-
- void EvaluateAll()
- {
- foreach (var ach in _allAchievements)
- {
- var state = _states[ach.achievementId];
- if (state.IsUnlocked) continue;
- if (Array.TrueForAll(ach.conditions, c => c.IsMet(state)))
- Unlock(ach, state);
- }
- }
-
- void Unlock(AchievementSO ach, AchievementRuntimeState state)
- {
- state.IsUnlocked = true;
- _onAchievementUnlocked.Raise(ach); // → AchievementToast + Analytics
- // ⚠️ 通过 ServiceLocator 获取(PlatformManager 静态类不在架构中)
-#if STEAMWORKS_NET
- ServiceLocator.Get()?.UnlockAchievement(ach.achievementId);
-#endif
- }
-
- // ── ISaveable ────────────────────────────────────────────────────
- public void OnSave(SaveData data)
- {
- data.Achievements.Unlocked = _states
- .Where(kv => kv.Value.IsUnlocked)
- .Select(kv => kv.Key)
- .ToList();
- }
-
- public void OnLoad(SaveData data)
- {
- foreach (var id in data.Achievements.Unlocked)
- if (_states.TryGetValue(id, out var state))
- state.IsUnlocked = true;
- }
- }
-
- /// 单个成就的运行时状态(条件满足记录 + 解锁状态)
- public class AchievementRuntimeState
- {
- public bool IsUnlocked { get; set; }
- readonly HashSet _metConditions = new();
-
- public AchievementRuntimeState(AchievementSO ach) { } // 初始状态:未解锁
-
- public void SetConditionMet(AchievementCondition cond) => _metConditions.Add(cond);
- public bool IsConditionMet(AchievementCondition cond) => _metConditions.Contains(cond);
- }
-}
-```
-
-**Phase 4 最小成就集**(验证系统可用):
-
-| achievementId | AchievementCondition 类型 |
-|--------------|---------|
-| `ACH_FirstKill` | `EventTriggeredCondition`(`EVT_EnemyDied`)|
-| `ACH_FirstBoss` | `DefeatedBossCondition`(首个 Boss id)|
-| `ACH_Collector` | `CollectedAllCharmsCondition` 或 `MapExplorationCondition` |
-| `ACH_Speedrunner` | `TimedBossKillCondition`(最终 Boss + maxSeconds) |
-
-### 5.3 PlatformBootstrap + IPlatformService(Steam 集成)
-
-> **⚠️ 架构 16_SupportingModules §3 完整接口**:旧版 `IPlatformService` 缺少 `IncrementStat`、`GetStat`、`IsAchievementUnlocked`、RichPresence、振动、生命周期方法;云存档方法签名已变更(返回 `Task` / `Task`)。
-> **⚠️ 注入方式变更**:不使用 `PlatformManager` 静态类直接初始化;改用 `PlatformBootstrap` MonoBehaviour + `ServiceLocator` 模式(架构 16 §3)。
-
-```csharp
-// 路径: Assets/Scripts/Support/Platform/IPlatformService.cs
-// ⚠️ 全量接口(架构 16 §3);各实现类必须实现全部成员
-namespace BaseGames.Platform
-{
- public interface IPlatformService
- {
- // ── 成就 ──────────────────────────────────────────────────────────
- void UnlockAchievement(string achievementId);
- bool IsAchievementUnlocked(string achievementId); // ⚠️ 旧版缺失
-
- // ── 统计数据(用于成就进度跟踪)────────────────────────────────────
- void SetStat(string statId, int value);
- void IncrementStat(string statId, int increment = 1); // ⚠️ 旧版缺失
- int GetStat(string statId); // ⚠️ 旧版缺失
-
- // ── 云存档(二进制,UTF-8 序列化在 SaveSystem 层完成)──────────────
- // ⚠️ 旧版签名错误(Task WriteCloudSaveAsync/Task ReadCloudSaveAsync)
- Task CloudSaveAsync(string fileName, byte[] data);
- Task CloudLoadAsync(string fileName);
- bool IsCloudAvailable { get; } // ⚠️ 旧版为 CloudSaveExists(string) 方法
-
- // ── Rich Presence ──────────────────────────────────────────────────
- void SetRichPresence(string key, string value); // ⚠️ 旧版缺失
- void ClearRichPresence(); // ⚠️ 旧版缺失
-
- // ── 振动 ────────────────────────────────────────────────────────────
- void Rumble(float lowFreq, float highFreq, float duration); // ⚠️ 旧版缺失
- void StopRumble(); // ⚠️ 旧版缺失
-
- // ── 生命周期 ────────────────────────────────────────────────────────
- void Initialize(); // ⚠️ 旧版缺失;由 PlatformBootstrap.Awake 调用
- void RunCallbacks(); // ⚠️ 旧版缺失;由 PlatformBootstrap.Update 每帧调用
- void Shutdown(); // ⚠️ 旧版缺失;由 PlatformBootstrap.OnApplicationQuit 调用
- }
-}
-```
-
-```csharp
-// 路径: Assets/Scripts/Support/Platform/SteamPlatformService.cs
-// ⚠️ 条件编译符号 = UNITY_STANDALONE && STEAMWORKS_NET(非旧版 STEAMWORKS_NET)
-#if UNITY_STANDALONE && STEAMWORKS_NET
-namespace BaseGames.Platform
-{
- public class SteamPlatformService : IPlatformService
- {
- public bool IsCloudAvailable => SteamManager.Initialized && SteamRemoteStorage.IsCloudEnabledForApp();
-
- // ── 成就 ──────────────────────────────────────────────────────────
- public void UnlockAchievement(string id)
- {
- if (!SteamManager.Initialized) return;
- SteamUserStats.SetAchievement(id);
- SteamUserStats.StoreStats();
- }
- public bool IsAchievementUnlocked(string id)
- {
- SteamUserStats.GetAchievement(id, out bool unlocked);
- return unlocked;
- }
-
- // ── 统计 ──────────────────────────────────────────────────────────
- public void SetStat(string id, int v)
- {
- if (!SteamManager.Initialized) return;
- SteamUserStats.SetStat(id, v);
- }
- public void IncrementStat(string id, int inc = 1)
- {
- int cur = GetStat(id);
- SetStat(id, cur + inc);
- }
- public int GetStat(string id)
- {
- SteamUserStats.GetStat(id, out int v);
- return v;
- }
-
- // ── 云存档(二进制)──────────────────────────────────────────────
- public async Task CloudSaveAsync(string fileName, byte[] data)
- {
- if (!IsCloudAvailable) return false;
- return await Task.Run(() =>
- SteamRemoteStorage.FileWrite(fileName, data, data.Length));
- }
- public async Task CloudLoadAsync(string fileName)
- {
- if (!IsCloudAvailable || !SteamRemoteStorage.FileExists(fileName))
- return null;
- int size = SteamRemoteStorage.GetFileSize(fileName);
- var buf = new byte[size];
- await Task.Run(() => SteamRemoteStorage.FileRead(fileName, buf, size));
- return buf;
- }
-
- // ── Rich Presence ──────────────────────────────────────────────────
- public void SetRichPresence(string k, string v) => SteamFriends.SetRichPresence(k, v);
- public void ClearRichPresence() => SteamFriends.ClearRichPresence();
-
- // ── 振动 ──────────────────────────────────────────────────────────
- public void Rumble(float l, float h, float dur)
- {
- ushort lo = (ushort)(l * 65535);
- ushort hi = (ushort)(h * 65535);
- SteamController.TriggerVibration(SteamController.GetConnectedControllers()[0], lo, hi);
- }
- public void StopRumble() => Rumble(0f, 0f, 0f);
-
- // ── 生命周期 ──────────────────────────────────────────────────────
- public void Initialize() => SteamAPI.Init();
- public void RunCallbacks() => SteamAPI.RunCallbacks();
- public void Shutdown() => SteamAPI.Shutdown();
- }
-}
-#endif
-```
-
-```csharp
-// 路径: Assets/Scripts/Support/Platform/NullPlatformService.cs
-// ⚠️ 实现全部 IPlatformService 成员(旧版不完整)
-namespace BaseGames.Platform
-{
- public class NullPlatformService : IPlatformService
- {
- public bool IsCloudAvailable => false;
- public void UnlockAchievement(string id) => Debug.Log($"[Platform:Null] Achievement: {id}");
- public bool IsAchievementUnlocked(string id) => false;
- public void SetStat(string id, int v) { }
- public void IncrementStat(string id, int inc = 1) { }
- public int GetStat(string id) => 0;
- public Task CloudSaveAsync(string f, byte[] d) => Task.FromResult(false);
- public Task CloudLoadAsync(string f) => Task.FromResult(null);
- public void SetRichPresence(string k, string v) { }
- public void ClearRichPresence() { }
- public void Rumble(float l, float h, float dur) { }
- public void StopRumble() { }
- public void Initialize() { }
- public void RunCallbacks() { }
- public void Shutdown() { }
- }
-}
-```
-
-```csharp
-// 路径: Assets/Scripts/Support/Platform/PlatformBootstrap.cs
-// ⚠️ 注入方式:MonoBehaviour 挂在 Persistent 场景的 Bootstrap GameObject
-// 使用 ServiceLocator.Register(service)(非旧版 PlatformManager 静态类)
-// ⚠️ 旧版 PlatformManager 静态类(含 static _instance 字段)不在架构中,需替换
-public class PlatformBootstrap : MonoBehaviour
-{
- void Awake()
- {
- IPlatformService service;
-#if UNITY_STANDALONE && STEAMWORKS_NET
- service = new SteamPlatformService();
-#elif UNITY_SWITCH
- service = new SwitchPlatformService(); // ⚠️ 架构 16 §3:预留 Switch 平台支持(原 Plan 遗漏)
-#else
- service = new NullPlatformService();
-#endif
- service.Initialize();
- ServiceLocator.Register(service);
- }
-
- void Update() => ServiceLocator.Get()?.RunCallbacks();
- void OnApplicationQuit() => ServiceLocator.Get()?.Shutdown();
-}
-
-// 便捷访问(AchievementManager 内部用)
-// ⚠️ 通过 ServiceLocator 获取,不使用 PlatformManager.Service 静态属性
-// ServiceLocator.Get().UnlockAchievement(id);
-```
-
-### 5.4 DebugCheatSystem
-
-```csharp
-// Assets/Scripts/Support/Debug/DebugCheatSystem.cs
-// 仅在 UNITY_EDITOR 或 DEVELOPMENT_BUILD 时激活
-// ⚠️ 实现模式:BackQuote 键开关控制台文本输入框,非 F1-F7 固定快捷键(架构 16_SupportingModules §4)
-#if UNITY_EDITOR || DEVELOPMENT_BUILD
-public class DebugCheatSystem : MonoBehaviour
-{
- [Header("快捷键")]
- [SerializeField] private KeyCode _toggleConsoleKey = KeyCode.BackQuote; // ` 键开关控制台
-
- // ⚠️ SceneLoader 无 Instance 单例(Architecture 03 §3,事件驱动);通过事件频道触发加载
- [SerializeField] private SceneLoadRequestEventChannelSO _onSceneLoadRequest;
-
- private bool _consoleOpen;
- private string _input = "";
-
- private void Update()
- {
- if (Input.GetKeyDown(_toggleConsoleKey)) _consoleOpen = !_consoleOpen;
- }
-
- private void OnGUI()
- {
- if (!_consoleOpen) return;
- _input = GUI.TextField(new Rect(10, 10, 400, 30), _input);
- if (Event.current.type == EventType.KeyDown && Event.current.keyCode == KeyCode.Return)
- {
- ExecuteCommand(_input.Trim());
- _input = "";
- }
- }
-
- // 支持的命令:godmode / addgeo / teleport / unlock / killall
- private void ExecuteCommand(string cmd)
- {
- // ⚠️ PlayerController 无 Instance 单例(Architecture 05 §2);Debug 上下文用 FindObjectOfType
- var player = FindObjectOfType();
- var parts = cmd.Split(' ');
- switch (parts[0].ToLower())
- {
- case "godmode":
- player?.Stats.SetGodMode(true);
- break;
- case "addgeo" when parts.Length > 1 && int.TryParse(parts[1], out var geo):
- player?.Stats.AddGeo(geo);
- break;
- case "teleport" when parts.Length > 1:
- // ⚠️ 通过事件频道触发(SceneLoader 无 Instance;Architecture 03 §3)
- _onSceneLoadRequest.Raise(new SceneLoadRequest
- { SceneName = parts[1], EntryTransitionId = "Default" });
- break;
- case "unlock" when parts.Length > 1:
- player?.OnAbilityUnlocked(parts[1]);
- break;
- case "killall":
- // ⚠️ DamageInfo 无单参数构造函数(Architecture 06 §1);使用 Builder 模式(架构 16 §4 patch)
- var killDmg = new DamageInfo.Builder().SetRaw(99999).Build();
- foreach (var e in FindObjectsOfType()) e.TakeDamage(killDmg);
- break;
- default:
- Debug.Log($"[Cheat] 未知命令: {cmd}");
- break;
- }
- }
-}
-#endif
-```
-
-### 5.5 AntiSoftlockSystem
-
-```csharp
-// ⚠️ 静止阈值为 60s,参考 16_SupportingModules §5
-public class AntiSoftlockSystem : MonoBehaviour
-{
- [SerializeField] private float _softlockDetectionTime = 60f;
- [SerializeField] private InputReaderSO _inputReader; // ⚠️ 必须,架构 16 §5 定义
- [SerializeField] private VoidEventChannelSO _onSoftlockDetected; // 发布:检测到卡关
- // ⚠️ SceneLoader 无 Instance 单例(Architecture 03 §3,事件驱动);通过事件频道触发加载
- [SerializeField] private SceneLoadRequestEventChannelSO _onSceneLoadRequest;
-
- private float _idleTime;
- private Vector2 _lastPlayerPos;
- private bool _promptShown;
-
- private void Update()
- {
- // ⚠️ PlayerController 无 Instance(Architecture 05 §2);AntiSoftlock 在 Persistent 场景,
- // 通过 FindObjectOfType 获取(支撑系统可接受,非热路径)
- var player = FindObjectOfType();
- if (player == null) return;
- var playerPos = (Vector2)player.transform.position;
- if (Vector2.Distance(playerPos, _lastPlayerPos) > 0.1f)
- {
- _lastPlayerPos = playerPos;
- _idleTime = 0f;
- _promptShown = false;
- return;
- }
- _idleTime += Time.deltaTime;
- if (_idleTime >= _softlockDetectionTime && !_promptShown)
- {
- _promptShown = true;
- _onSoftlockDetected.Raise(); // UIManager 显示"是否传送到最近存档点"对话框
- }
- }
-
- // 由 UI 确认按钮调用
- public void TeleportToLastSavePoint()
- {
- // ⚠️ 通过事件频道触发(SceneLoader 无 Instance;Architecture 03 §3)
- _onSceneLoadRequest.Raise(new SceneLoadRequest
- {
- SceneName = SaveManager.LastCheckpointScene,
- EntryTransitionId = SaveManager.LastCheckpointSpawnId,
- IsRespawn = true
- });
- }
-}
-```
-
-### 5.5.1 RoomEscapeInfoSO(⚠️ 架构 16_SupportingModules §5.1)
-
-```csharp
-// Assets/Scripts/Support/AntiSoftlock/RoomEscapeInfoSO.cs
-// ⚠️ 每个房间场景必须挂载此 SO,记录"最低能力集合即可离开此房间"
-// 编辑器工具自动验证可达性,无法逃离的死局则标红警告(架构 16 §5.1)
-namespace BaseGames.Progression
-{
- [CreateAssetMenu(menuName = "Progression/RoomEscapeInfo")]
- public class RoomEscapeInfoSO : ScriptableObject
- {
- [Header("房间标识")]
- public string sceneAddress; // 对应 Addressable 场景地址
-
- [Header("逃离要求(满足任一路线即视为可逃离)")]
- public EscapeRoute[] escapeRoutes;
-
- [Header("单向入口警告")]
- public bool hasOneWayEntry; // 是否有单向进入点(如跌落入口)
- [TextArea(1, 3)]
- public string designerNotes;
-
- [Serializable]
- public class EscapeRoute
- {
- public string routeLabel; // 如 "向左回到 Forest_Main"
- public string targetSceneAddress; // 逃离到达的目标房间
- public AbilityType[] requiredAbilities; // 空 = 无需任何能力即可离开
- }
- }
-}
-```
-
-### 5.5.2 HardAbilityGate(⚠️ 架构 16_SupportingModules §5.2)
-
-```csharp
-// Assets/Scripts/Support/AntiSoftlock/HardAbilityGate.cs
-// ⚠️ 增强型能力门:防止玩家用精准时机绕过只检查标志的 AbilityGate(架构 16 §5.2)
-namespace BaseGames.Progression
-{
- ///
- /// 增强型能力门:除了检查能力标志,还检测玩家是否"物理上真的能做到"。
- /// 用于防止 Sequence Break(见 Design/49 §4.2)。
- ///
- public class HardAbilityGate : AbilityGate
- {
- [Header("额外物理验证")]
- [SerializeField] bool _requirePhysicalValidation = false;
-
- // 编辑器工具标记"此门已验证可能被绕过"
- [SerializeField] bool _sequenceBreakRisk = false;
-
- protected override bool EvaluateAccess()
- {
- if (!base.EvaluateAccess()) return false;
- if (!_requirePhysicalValidation) return true;
-
- // 检查能力实际已激活(非仅标志为 true)
- return _playerStats != null
- && _playerStats.IsAbilityActuallyUnlocked(_requiredAbility);
- }
- }
-}
-```
-
-### 5.6 AccessibilityManager
-
-> **⚠️ 架构 16_SupportingModules §6 完整实现**:使用独立的 `AccessibilitySettingsSO`(非 `GlobalSettingsSO`);`ColorBlindMode` 枚举含 5 个值(含 `Achromatopsia`);`AccessibilityManager.Instance` 单例模式;含 `CanPlayScreenShake()` 静态工具方法;含 `ColorBlindFilter` URP Renderer Feature。
-
-#### AccessibilitySettingsSO(数据容器)
-
-```csharp
-// Assets/ScriptableObjects/Accessibility/AccessibilitySettings.asset
-// ⚠️ 独立 SO(非 GlobalSettingsSO),menuName = "Accessibility/AccessibilitySettings"(架构 16 §6)
-[CreateAssetMenu(menuName = "Accessibility/AccessibilitySettings")]
-public class AccessibilitySettingsSO : ScriptableObject
-{
- // ── 视觉无障碍 ──────────────────────────────────────────────────────────
- [Header("色盲模式")]
- public ColorBlindMode colorBlindMode = ColorBlindMode.None;
- public bool highContrastMode = false;
- public float gameContrastBoost = 0f; // 0~1.0
-
- // ── 运动无障碍 ──────────────────────────────────────────────────────────
- [Header("运动敏感度")]
- public bool disableScreenShake = false;
- public bool disableCameraMotion = false;
- public float cameraMotionScale = 1f; // 0~1.0(0 = 完全关闭)
- public bool reduceParticleEffects = false;
- public bool disableFlashingEffects = false;
- public int flashFrequencyLimit = 3;
-
- // ── 字幕 ────────────────────────────────────────────────────────────────
- [Header("字幕系统")]
- public bool subtitlesEnabled = false;
- public bool sfxSubtitlesEnabled = false;
- public float subtitleFontSizeMultiplier = 1f; // 0.75~2.0
- public bool subtitleBackgroundEnabled = true;
- public float subtitleBackgroundOpacity = 0.7f;
- public bool speakerNameEnabled = true;
-
- // ── 输入辅助 ────────────────────────────────────────────────────────────
- [Header("输入辅助")]
- public bool autoParryAssist = false;
- public float parryWindowExtension = 0f; // 弹反窗口扩展(秒),0~0.2(ParrySystem 读取此字段)
- public bool holdToMash = false;
- public bool stickyJump = false;
- public bool autoClimb = false;
-
- // ── 音频无障碍 ────────────────────────────────────────────────────────
- [Header("音频无障碍")]
- public bool monoAudio = false;
- public float leftRightBalance = 0f; // -1~+1
- public bool visualDangerIndicator = false;
-}
-
-// ⚠️ ColorBlindMode 枚举:5 个值(含 Achromatopsia)(架构 16 §6)
-public enum ColorBlindMode
-{
- None, // 无(默认)
- Protanopia, // 红色盲
- Deuteranopia, // 绿色盲
- Tritanopia, // 蓝黄色盲
- Achromatopsia, // ⚠️ 全色盲(高对比灰度)(架构 16 §6,4 值版本遗漏此项)
-}
-```
-
-#### AccessibilityManager
-
-```csharp
-// Assets/Scripts/Support/Accessibility/AccessibilityManager.cs
-// ⚠️ 单例模式(Instance 属性);串联到 FeedbackSystem/ParrySystem(架构 16 §6)
-public class AccessibilityManager : MonoBehaviour
-{
- public static AccessibilityManager Instance { get; private set; }
-
- [SerializeField] private AccessibilitySettingsSO _settings; // ⚠️ AccessibilitySettingsSO,非 GlobalSettingsSO
- public AccessibilitySettingsSO Settings => _settings;
-
- // ── Event Channels(Raise 方)───────────────────────────────────────────
- [SerializeField] private ColorBlindModeEventChannelSO _onColorBlindModeChanged; // ⚠️ 大写 B(架构 16 §6)
- [SerializeField] private BoolEventChannelSO _onHighContrastChanged; // ⚠️ Changed(非 Toggled)
- [SerializeField] private BoolEventChannelSO _onSubtitlesChanged; // ⚠️ Changed(非 Toggled)
- [SerializeField] private BoolEventChannelSO _onScreenShakeChanged; // ⚠️ 架构 16 §7 清单
-
- private void Awake()
- {
- if (Instance != null) { Destroy(gameObject); return; }
- Instance = this;
- DontDestroyOnLoad(gameObject);
- }
-
- // 由 SettingsPanelController 调用的公开 API(架构 16 §6)
- public void ApplySettings()
- {
- _onColorBlindModeChanged.Raise(_settings.colorBlindMode);
- _onHighContrastChanged.Raise(_settings.highContrastMode);
- _onSubtitlesChanged.Raise(_settings.subtitlesEnabled);
- _onScreenShakeChanged.Raise(!_settings.disableScreenShake);
- }
-
- public void SetColorBlindMode(ColorBlindMode mode)
- { _settings.colorBlindMode = mode; ApplySettings(); }
- public void SetAutoParryAssist(bool v)
- { _settings.autoParryAssist = v; ApplySettings(); }
- public void SetParryWindowExtension(float sec)
- { _settings.parryWindowExtension = sec; ApplySettings(); }
- public void SetDisableScreenShake(bool v)
- { _settings.disableScreenShake = v; ApplySettings(); }
- public void SetCameraMotionScale(float s)
- { _settings.cameraMotionScale = s; ApplySettings(); }
- public void SetMonoAudio(bool v)
- { _settings.monoAudio = v; ApplySettings(); }
- public void SetVisualDangerIndicator(bool v)
- { _settings.visualDangerIndicator = v; ApplySettings(); }
-
- // ⚠️ 供 FeedbackSystem / ParrySystem 查询(静态方法)(架构 16 §6)
- public static bool CanPlayScreenShake()
- => Instance == null || !Instance.Settings.disableScreenShake;
-}
-```
-
-> **parryWindowExtension 集成**:`ParrySystem` 计算弹反窗口时读取此值:
-> `float window = _config.ParryWindowDuration + (AccessibilityManager.Instance?.Settings.parryWindowExtension ?? 0f);`
-
-#### ColorBlindFilter(URP Renderer Feature)
-
-```csharp
-// Assets/Scripts/Accessibility/ColorBlindFilter.cs
-// ⚠️ URP 2D 后处理最终合成阶段应用色彩矩阵变换(架构 16 §6)
-// 在 URP 2D Renderer Data(Assets/Settings/URP2DRenderer.asset)中添加此 Feature
-public class ColorBlindFilter : ScriptableRendererFeature
-{
- [SerializeField] ColorBlindMode _mode;
-
- // 色彩矩阵(3×3,基于 Brettel et al. 1997 算法)
- private static readonly Dictionary _matrices = new()
- {
- [ColorBlindMode.Protanopia] = new Matrix4x4(/*...*/),
- [ColorBlindMode.Deuteranopia] = new Matrix4x4(/*...*/),
- [ColorBlindMode.Tritanopia] = new Matrix4x4(/*...*/),
- [ColorBlindMode.Achromatopsia] = new Matrix4x4(/*...*/),
- };
-
- public override void Create() { /* 初始化 RenderPass */ }
- public override void AddRenderPasses(ScriptableRenderer renderer, ref RenderingData renderingData)
- {
- if (_mode == ColorBlindMode.None) return;
- renderer.EnqueuePass(new ColorBlindPass(_matrices[_mode]));
- }
-
- // AccessibilityManager 订阅 EVT_ColorBlindModeChanged 后调用
- public void SetMode(ColorBlindMode mode) => _mode = mode;
-}
-```
-
-### 5.7 AnalyticsManager
-
-```csharp
-// Assets/Scripts/Support/Analytics/AnalyticsManager.cs
-// 游戏行为数据收集(开发用途:热点图、死亡统计、卡关点分析)
-// 正式发布版无网络上报;仅写入本地 analytics.jsonl 日志供开发分析
-// 参考:16_SupportingModules §8
-public class AnalyticsManager : MonoBehaviour
-{
- [SerializeField] private bool _enabledInRelease = false; // 正式包默认关闭
- [SerializeField] private string _logPath; // 留空则用 persistentDataPath
-
- private StreamWriter _writer;
-
- private void Awake()
- {
-#if !UNITY_EDITOR && !DEVELOPMENT_BUILD
- if (!_enabledInRelease) { enabled = false; return; }
-#endif
- var path = string.IsNullOrEmpty(_logPath)
- ? Path.Combine(Application.persistentDataPath, "analytics.jsonl")
- : _logPath;
- _writer = new StreamWriter(path, append: true);
- }
-
- private void OnDestroy() => _writer?.Close();
-
- // 记录一条分析事件(JSONL 格式)
- public void Track(string eventName, Dictionary properties = null)
- {
- if (!enabled) return;
- var payload = new Dictionary
- {
- ["event"] = eventName,
- ["timestamp"] = DateTime.UtcNow.ToString("o"),
- ["session"] = Time.realtimeSinceStartup
- };
- if (properties != null)
- foreach (var kv in properties) payload[kv.Key] = kv.Value;
- _writer?.WriteLine(JsonConvert.SerializeObject(payload));
- }
-
- public void TrackDeath(string sceneName, Vector2 position, string cause)
- => Track("player_death", new() { ["scene"] = sceneName, ["pos_x"] = position.x, ["pos_y"] = position.y, ["cause"] = cause });
-
- public void TrackBossDefeated(string bossId, float elapsedSeconds)
- => Track("boss_defeated", new() { ["boss_id"] = bossId, ["time_s"] = elapsedSeconds });
-
- public void TrackAbilityUnlocked(string abilityId)
- => Track("ability_unlocked", new() { ["ability"] = abilityId });
-}
-```
-
-### 5.8 SpeedrunTimer
-
-```csharp
-// Assets/Scripts/Support/Speedrun/SpeedrunTimer.cs
-// 游戏内时间(IGT)计时器:排除加载、过场、暂停时间
-// 在 HUD 角落显示(仅当 GlobalSettingsSO.ShowSpeedrunTimer = true 时)
-// 参考:16_SupportingModules §9
-public class SpeedrunTimer : MonoBehaviour, ISaveable
-{
- [SerializeField] private BoolEventChannelSO _onGameplayActive; // Gameplay 状态 = 计时
- [SerializeField] private TMP_Text _display; // HUD 角落 Text
- [SerializeField] private GlobalSettingsSO _settings;
-
- private float _igt;
- private bool _isRunning;
-
- private void OnEnable()
- {
- _onGameplayActive.OnEventRaised += SetRunning;
- UpdateDisplay();
- }
-
- private void OnDisable() => _onGameplayActive.OnEventRaised -= SetRunning;
-
- private void SetRunning(bool active)
- {
- _isRunning = active;
- if (_display != null)
- _display.gameObject.SetActive(active && _settings.ShowSpeedrunTimer);
- }
-
- private void Update()
- {
- if (!_isRunning) return;
- _igt += Time.unscaledDeltaTime; // 不受 timeScale 影响(暂停时不计时)
- UpdateDisplay();
- }
-
- private void UpdateDisplay()
- {
- if (_display == null) return;
- var ts = TimeSpan.FromSeconds(_igt);
- _display.text = $"{ts.Hours:D2}:{ts.Minutes:D2}:{ts.Seconds:D2}.{ts.Milliseconds / 10:D2}";
- }
-
- public float TotalSeconds => _igt;
-
- public void OnSave(SaveData data) => data.Stats.DistanceTraveled = _igt; // 复用字段
- public void OnLoad(SaveData data) { _igt = data.Stats.DistanceTraveled; UpdateDisplay(); }
-}
-```
-
-**GlobalSettingsSO 新增字段**(追加至 §6 AccessibilityManager 的 GlobalSettingsSO 补充字段之后):
-```csharp
-public bool ShowSpeedrunTimer = false; // 默认隐藏,由设置界面开关控制
-```
-
-### 5.9 编辑器工具套装
-
-在 `Assets/Scripts/Editor/` 下创建:
-
-| 工具名 | 菜单路径 | 功能 |
-|--------|---------|------|
-| `AddressKeysValidator` | `Tools/Validate AddressKeys` | 验证所有 AddressKeys 常量在 Addressables 中存在 |
-| `EventChannelAuditor` | `Tools/Audit Event Channels` | 扫描所有 EVT_*.asset,检查是否有未被订阅的频道 |
-| `SaveDataInspector` | `Tools/Inspect Save Data` | 读取并显示当前存档文件内容(格式化 JSON)|
-| `RoomValidator` | `Tools/Validate Room Scene` | 检查当前场景是否有 RoomController/PlayerSpawnPoint/CameraBounds |
-| `CharmBalanceSheet` | `Tools/Charm Balance Sheet` | 列出所有 CharmSO 的 notchCost/effect 一览表 |
-| `CharmEffectDrawer` | — | `CharmSO.effects` 数组的自定义 PropertyDrawer(`[CustomPropertyDrawer(typeof(CharmEffectEntry))]`;在 Inspector 中下拉选择效果类型并展示对应字段;架构 16 §4.1) |
-| `SOValidationRunner` | `Tools/Validate All SOs` | 扫描所有实现 `IValidatable` 接口的 SO 资产并在 Console 报告验证结果;集成为 `[MenuItem]` + Build Pre-process hook(架构 16 §10)|
-| `EventBusMonitorWindow` | `Window/EventBus Monitor` | Editor Only;运行时监控所有事件频道的 Raise 次数和当前订阅者数量;通过 `EventBusMonitor.Record()` 静态方法接收数据(架构 02 §9)|
-| `EventChainEditorWindow` | `BaseGames/Tools/Event Chain Viewer` | 事件链可视化:左侧 chainId 分组总览,右侧条件/动作表格(`IsMet()` 颜色),Play Mode 运行时状态着色(已完成=绿/进行中=橙),`ChainCompletedCondition` 依赖箭头,执行日志(最近 20 条),双击→PingObject(架构 14 §13)|
-| `BossSkillSequenceWindow` | `BaseGames/Tools/Boss Skill Sequence Viewer` | 以甘特图可视化 `SkillSequenceSO` 时间轴:攻击阶段蓝色条、延迟灰色间隙、`VulnerabilityWindow` 绿色覆盖条;点击高亮对应 `AttackPatternSO` Ping;`DurationNormalized < 0.1` 时变红警告;SO 拖放加载(架构 23 §12)|
-| `AchievementSOEditor` | — | `[CustomEditor(typeof(AchievementSO))]`;conditions 数组中文类型标签(12 种映射);内联展开 SO 字段;Ping/Delete 按钮;"+ 添加条件 SO" 底部按钮(架构 16 §2.4)|
-
-> **✅ P4-6 完成状态(2026-05-11)**
->
-> | 文件 | 状态 | 说明 |
-> |------|------|------|
-> | `IValidatable` 接口 | ✅ 已创建 | `Assets/Scripts/Core/Validation/IValidatable.cs`;`namespace BaseGames.Core`;`IEnumerable