摄像机区域的优化

This commit is contained in:
2026-05-17 07:56:12 +08:00
parent f264329751
commit d25f237e76
62 changed files with 25774 additions and 5450 deletions

View File

@@ -9,14 +9,15 @@ namespace BaseGames.Editor
/// <summary>
/// CameraArea 自定义 Inspector + Scene GUI。
///
/// 功能与 <see cref="RoomCameraEditor"/> 一致
/// 功能:
/// 1. Scene 视图中直接拖拽黄色矩形四条边,编辑「可视区域」(_visibleBounds)。
/// 2. Inspector 按钮「从可视区域更新限位区域(透视)」:
/// 根据 FOV 和相机深度计算 PolygonCollider2D 限位范围并写入。
///
/// FOV 优先级(降序):
/// 专有 DedicatedCamera.Lens.FieldOfView
/// → CameraStateController._vcamAPersistent 场景
/// → CameraLensConfigSO.fieldOfView单一来源无跨场景依赖
/// → CameraStateController._vcamAPersistent 场景已加载时)
/// → Camera.main.fieldOfView
/// → 60f默认
/// </summary>
@@ -24,51 +25,270 @@ namespace BaseGames.Editor
public class CameraAreaEditor : UnityEditor.Editor
{
// ── 颜色常量 ──────────────────────────────────────────────────────────
private static readonly Color kVisibleFill = new Color(1f, 0.85f, 0.15f, 0.08f);
private static readonly Color kVisibleOutline = new Color(1f, 0.85f, 0.15f, 0.90f);
private static readonly Color kConfinerColor = new Color(0.2f, 0.8f, 1.0f, 0.80f);
private static readonly Color kVisibleFill = new Color(1.00f, 0.85f, 0.15f, 0.06f);
private static readonly Color kVisibleOutline = new Color(1.00f, 0.85f, 0.15f, 1.00f);
private static readonly Color kConfinerFill = new Color(0.20f, 0.75f, 1.00f, 0.08f);
private static readonly Color kConfinerLine = new Color(0.20f, 0.75f, 1.00f, 0.85f);
private static readonly Color kTriggerFill = new Color(0.30f, 1.00f, 0.50f, 0.07f);
private static readonly Color kTriggerLine = new Color(0.30f, 1.00f, 0.50f, 0.85f);
private static readonly Color kHeaderBg = new Color(0.18f, 0.18f, 0.23f, 1f);
private static readonly Color kOk = new Color(0.30f, 0.82f, 0.30f, 1f);
private static readonly Color kError = new Color(0.90f, 0.28f, 0.28f, 1f);
private static readonly Color kMuted = new Color(0.55f, 0.55f, 0.60f, 1f);
// ── 折叠状态(每个 CameraArea 实例独立) ─────────────────────────────
private bool _foldBase = true;
private bool _foldFollow = true;
private bool _foldLens = false;
private bool _foldCamera = false;
private bool _foldTools = false;
// ── 折叠标题样式缓存(深色背景 + 白色文字)────────────────────────────
private static GUIStyle _foldoutHeaderStyle;
// ── Scene 视图叠加面板样式缓存 ────────────────────────────────────────
private static GUIStyle _sceneOverlayBoldStyle;
private static GUIStyle _sceneLabelStyle;
private static GUIStyle _gizmoTagStyle;
// ── 常显标签样式([DrawGizmo])─────────────────────────────────────
private static GUIStyle _alwaysLabelShadowStyle;
private static GUIStyle _alwaysLabelMainStyle;
// ══ Inspector ═════════════════════════════════════════════════════════
public override void OnInspectorGUI()
{
DrawDefaultInspector();
EditorGUILayout.Space(8f);
EditorGUILayout.LabelField("── 可视区域工具 ──", EditorStyles.boldLabel);
serializedObject.Update();
CameraArea area = (CameraArea)target;
float vFOV = GetFOV(area);
float aspect = GetAspect();
float depth = area.CameraDepth;
float halfH = depth * Mathf.Tan(vFOV * 0.5f * Mathf.Deg2Rad);
float halfW = halfH * aspect;
using (new EditorGUI.DisabledScope(true))
// ── 基础设置 ──────────────────────────────────────────────────────
_foldBase = DrawFoldoutHeader("基础设置", _foldBase);
if (_foldBase)
{
EditorGUILayout.FloatField("垂直 FOV来源见工具提示", vFOV);
EditorGUILayout.FloatField("有效深度", depth);
EditorGUILayout.FloatField("视口半高(世界单位)", halfH);
EditorGUILayout.FloatField("视口半宽(世界单位)", halfW);
using (new EditorGUI.IndentLevelScope())
{
var confinerProp = serializedObject.FindProperty("_confinerCollider");
bool confinerOk = confinerProp.objectReferenceValue != null;
using (new EditorGUILayout.HorizontalScope())
{
Color prev = GUI.color;
GUI.color = confinerOk ? kOk : kError;
GUILayout.Label(confinerOk ? "●" : "✗", GUILayout.Width(14f));
GUI.color = prev;
EditorGUILayout.PropertyField(confinerProp, new GUIContent("Confiner Collider"));
}
if (!confinerOk)
EditorGUILayout.HelpBox("必须绑定子节点 PolygonCollider2DAreaBoundary否则 Cinemachine 无法限位。", MessageType.Error);
EditorGUILayout.PropertyField(serializedObject.FindProperty("_visibleBounds"), new GUIContent("Visible Bounds本地坐标"));
}
}
bool canSync = area.ConfinerCollider != null;
if (!canSync)
EditorGUILayout.HelpBox("ConfinerCollider 未绑定,无法同步限位区域。", MessageType.Warning);
EditorGUILayout.Space(2f);
using (new EditorGUI.DisabledScope(!canSync))
// ── 跟随参数覆盖 ─────────────────────────────────────────────────
var overrideProp = serializedObject.FindProperty("_overrideFollowBehaviour");
bool overrides = overrideProp.boolValue;
_foldFollow = DrawFoldoutHeader(
overrides ? "跟随参数覆盖 ●" : "跟随参数覆盖 ○ (使用全局默认)", _foldFollow);
if (_foldFollow)
{
if (GUILayout.Button("从可视区域更新限位区域(透视)", GUILayout.Height(28f)))
SyncConfinerFromVisibleBounds(area, vFOV, aspect);
using (new EditorGUI.IndentLevelScope())
{
EditorGUILayout.PropertyField(overrideProp, new GUIContent("Override Follow Behaviour"));
if (overrides)
{
EditorGUILayout.Space(2f);
EditorGUILayout.PropertyField(serializedObject.FindProperty("_screenPosition"), new GUIContent("Screen Position"));
EditorGUILayout.PropertyField(serializedObject.FindProperty("_damping"), new GUIContent("Damping"));
EditorGUILayout.PropertyField(serializedObject.FindProperty("_dampingDown"), new GUIContent("Damping Down"));
EditorGUILayout.PropertyField(serializedObject.FindProperty("_dampingUp"), new GUIContent("Damping Up"));
EditorGUILayout.PropertyField(serializedObject.FindProperty("_deadZoneSize"), new GUIContent("Dead Zone Size"));
EditorGUILayout.PropertyField(serializedObject.FindProperty("_lookaheadTime"), new GUIContent("Lookahead Time"));
EditorGUILayout.PropertyField(serializedObject.FindProperty("_lookaheadSmoothing"),new GUIContent("Lookahead Smoothing"));
EditorGUILayout.PropertyField(serializedObject.FindProperty("_lockHorizontal"), new GUIContent("Lock Horizontal"));
EditorGUILayout.PropertyField(serializedObject.FindProperty("_lockVertical"), new GUIContent("Lock Vertical"));
}
}
}
// ── 图例说明 ─────────────────────────────────────────────────────
EditorGUILayout.Space(4f);
DrawLegend("■ 黄色矩形Scene 视图)", kVisibleOutline, "可视区域 — 摄像机视口永不超出此范围");
DrawLegend("■ 蓝色多边形Scene 视图)", kConfinerColor, "限位区域 — CinemachineConfiner2D 的运动边界");
EditorGUILayout.Space(2f);
// ── 镜头 & 混合 ──────────────────────────────────────────────────
_foldLens = DrawFoldoutHeader("镜头 & 混合配置", _foldLens);
if (_foldLens)
{
using (new EditorGUI.IndentLevelScope())
{
EditorGUILayout.PropertyField(serializedObject.FindProperty("_lensSize"), new GUIContent("Lens Size"));
EditorGUILayout.PropertyField(serializedObject.FindProperty("_lensSizeDuration"), new GUIContent("Lens Size Duration"));
EditorGUILayout.PropertyField(serializedObject.FindProperty("_blendProfile"), new GUIContent("Blend Profile"));
}
}
EditorGUILayout.Space(2f);
// ── 专有相机(可选) ──────────────────────────────────────────────
_foldCamera = DrawFoldoutHeader("专有相机(可选)", _foldCamera);
if (_foldCamera)
{
using (new EditorGUI.IndentLevelScope())
{
EditorGUILayout.PropertyField(serializedObject.FindProperty("_dedicatedCamera"), new GUIContent("Dedicated Camera"));
EditorGUILayout.PropertyField(serializedObject.FindProperty("_dedicatedPriority"), new GUIContent("Dedicated Priority"));
}
}
EditorGUILayout.Space(2f);
// ── 可视区域工具 ──────────────────────────────────────────────────
_foldTools = DrawFoldoutHeader("可视区域工具", _foldTools);
if (_foldTools)
{
using (new EditorGUI.IndentLevelScope())
{
// ── 镜头配置 SO ─────────────────────────────────────────
EditorGUILayout.PropertyField(
serializedObject.FindProperty("_lensConfig"),
new GUIContent("镜头配置 (SO)",
"与 CameraStateController 引用同一个 CameraLensConfigSO\n" +
"保证限位计算 FOV 与运行时 VCam 一致。\n" +
"SO 中 FOV 修改同时自动重新同步限位多边形。"));
float vFOV = GetFOV(area);
float aspect = GetAspect();
// ── FOV 来源说明与过期警告 ──────────────────────────
string fovNote;
Color noteColor;
if (area.DedicatedCamera != null)
{
fovNote = $"来源:专有 VCam ({area.DedicatedCamera.name}) FOV = {vFOV:F1}°";
noteColor = kOk;
}
else if (area.LensConfig != null)
{
bool isStale = area.ConfinerCollider != null
&& area.LastSyncFOV > 0f
&& Mathf.Abs(area.LastSyncFOV - vFOV) > 0.05f;
if (isStale)
{
EditorGUILayout.HelpBox(
$"FOV 已从 {area.LastSyncFOV:F1}° 改为 {vFOV:F1}°,限位多边形需要重新同步。",
MessageType.Warning);
}
fovNote = isStale
? $"⚠ SO FOV 已从 {area.LastSyncFOV:F1}° 改为 {vFOV:F1}°,限位需重新同步"
: $"来源CameraLensConfigSO FOV = {vFOV:F1}°";
noteColor = isStale ? new Color(1f, 0.7f, 0.1f) : kOk;
}
else
{
fovNote = $"⚠ 未绑定 LensConfig SO使用备用来源 FOV = {vFOV:F1}°";
noteColor = new Color(1f, 0.7f, 0.1f);
EditorGUILayout.HelpBox("建议绑定 CameraLensConfigSO保证跨场景 FOV 一致。", MessageType.Warning);
}
Color prevC = GUI.color;
GUI.color = noteColor;
EditorGUILayout.LabelField(fovNote, EditorStyles.miniLabel);
GUI.color = prevC;
EditorGUILayout.Space(4f);
// ── 只读推算值 ──────────────────────────────────────────
float depth = area.CameraDepth;
// 深度来源说明
string depthNote;
Color depthColor;
var depthProp = serializedObject.FindProperty("_cameraDepth");
if (depthProp != null && depthProp.floatValue > 0f)
{
depthNote = $"来源:区域专有 _cameraDepth = {depth:F1}";
depthColor = kOk;
}
else if (area.LensConfig != null)
{
depthNote = $"来源CameraLensConfigSO.cameraDepth = {depth:F1}";
depthColor = kOk;
}
else
{
depthNote = "⚠ 未绑定 LensConfig SOCameraDepth = 0限位同步无效";
depthColor = new Color(1f, 0.3f, 0.3f);
}
{
Color prevC2 = GUI.color;
GUI.color = depthColor;
EditorGUILayout.LabelField(depthNote, EditorStyles.miniLabel);
GUI.color = prevC2;
}
if (area.LensConfig == null)
EditorGUILayout.HelpBox("请绑定 CameraLensConfigSO 以提供 cameraDepth否则限位多边形无法正确生成。", MessageType.Error);
float halfH = depth * Mathf.Tan(vFOV * 0.5f * Mathf.Deg2Rad);
float halfW = halfH * aspect;
using (new EditorGUI.DisabledScope(true))
{
EditorGUILayout.FloatField("有效深度", depth);
EditorGUILayout.FloatField("视口半高(世界单位)", halfH);
EditorGUILayout.FloatField("视口半宽(世界单位)", halfW);
}
bool canSync = area.ConfinerCollider != null;
if (!canSync)
EditorGUILayout.HelpBox("ConfinerCollider 未绑定,无法同步限位区域。", MessageType.Warning);
using (new EditorGUI.DisabledScope(!canSync))
{
if (GUILayout.Button("从可视区域更新限位区域(透视)", GUILayout.Height(26f)))
SyncConfinerFromVisibleBounds(area, vFOV, aspect);
}
EditorGUILayout.Space(4f);
DrawLegend("■ 黄色矩形Scene 视图)", kVisibleOutline, "可视区域 — 摄像机视口永不超出此范围");
DrawLegend("■ 蓝色多边形Scene 视图)", kConfinerLine, "限位区域 — CinemachineConfiner2D 的运动边界");
}
}
if (serializedObject.hasModifiedProperties)
serializedObject.ApplyModifiedProperties();
}
// ══ 全局 Gizmo非选中时也显示═══════════════════════════════════════════
/// <summary>
/// 在 Scene 视图中始终显示 CameraArea 名称标签(可视区域内部居中)。
/// 利用 [DrawGizmo] 实现非选中状态下也常显。
/// </summary>
[DrawGizmo(GizmoType.NotInSelectionHierarchy | GizmoType.InSelectionHierarchy | GizmoType.Active | GizmoType.Pickable)]
private static void DrawAreaNameGizmo(CameraArea area, GizmoType gizmoType)
{
Rect worldR = area.VisibleBounds;
if (worldR.width <= 0f || worldR.height <= 0f) return;
Vector3 center = new Vector3(worldR.center.x, worldR.center.y, 0f);
string text = area.gameObject.name;
float sz = HandleUtility.GetHandleSize(center) * 0.028f;
if (_alwaysLabelShadowStyle == null)
_alwaysLabelShadowStyle = new GUIStyle(EditorStyles.boldLabel)
{
normal = { textColor = new Color(0f, 0f, 0f, 0.85f) },
fontSize = 14,
alignment = TextAnchor.MiddleCenter,
};
if (_alwaysLabelMainStyle == null)
_alwaysLabelMainStyle = new GUIStyle(EditorStyles.boldLabel)
{
normal = { textColor = Color.white },
fontSize = 14,
alignment = TextAnchor.MiddleCenter,
};
// 阴影偏移,增强居中标签对任意背景的对比度
Handles.Label(center + new Vector3(sz, -sz, 0f), text, _alwaysLabelShadowStyle);
Handles.Label(center, text, _alwaysLabelMainStyle);
}
// ══ Scene GUI ════════════════════════════════════════════════════════
private void OnSceneGUI()
@@ -77,23 +297,41 @@ namespace BaseGames.Editor
serializedObject.Update();
SerializedProperty boundsP = serializedObject.FindProperty("_visibleBounds");
Rect r = boundsP.rectValue;
Rect localR = boundsP.rectValue;
// 本地坐标 → 世界坐标,可视区域随 CameraArea GameObject 一同移动
Vector2 areaPos = area.transform.position;
Rect r = new Rect(localR.x + areaPos.x, localR.y + areaPos.y, localR.width, localR.height);
// ── 绘制限位多边形(蓝色,参考用) ──────────────────────────────
// ── 触发区域(绿色,只读) ─────────────────────────────────────────
DrawTriggerZoneGizmos(area);
// ── 限位多边形(蓝色,只读) ─────────────────────────────────────
DrawConfinerGizmo(area);
// ── 绘制可视区域填充 + 边框 ──────────────────────────────────────
// ── 可视区域(黄色) + 尺寸标注 ──────────────────────────────
DrawVisibleRect(r);
DrawDimensionLabels(r);
// ── 四条边的拖拽 Handle ──────────────────────────────────────────
// ── Handle 编辑(四角 + 四边中点 + 中心移动) ─────────────────────
EditorGUI.BeginChangeCheck();
EditRectEdges(ref r);
EditRectHandles(ref r);
if (EditorGUI.EndChangeCheck())
{
Undo.RecordObject(area, "Edit Visible Bounds");
boundsP.rectValue = r;
if (area.ConfinerCollider != null)
Undo.RecordObject(area.ConfinerCollider, "Sync Confiner");
// 世界坐标 → 本地坐标,存入序列化字段
boundsP.rectValue = new Rect(r.x - areaPos.x, r.y - areaPos.y, r.width, r.height);
serializedObject.ApplyModifiedProperties();
// 拖拽时自动同步限位多边形(不输出日志)
if (area.ConfinerCollider != null)
SyncConfinerQuiet(area, GetFOV(area), GetAspect());
}
// ── 叠加信息面板(屏幕空间) ───────────────────────────────────────
DrawSceneInfoOverlay(area, r);
}
// ══ 绘制辅助 ═════════════════════════════════════════════════════════
@@ -108,13 +346,36 @@ namespace BaseGames.Editor
new Vector3(r.xMax, r.yMin, 0f),
};
Handles.DrawSolidRectangleWithOutline(corners, kVisibleFill, kVisibleOutline);
// 半透明填充
Handles.DrawSolidRectangleWithOutline(corners, kVisibleFill, Color.clear);
// 2.5px 抗锯齿粗轮廓
Handles.color = kVisibleOutline;
Handles.DrawAAPolyLine(2.5f,
corners[0], corners[1], corners[2], corners[3], corners[0]);
// 四角小圆点(提示可交互的边界点)
float dot = HandleUtility.GetHandleSize(r.center) * 0.04f;
foreach (var c in corners)
Handles.DrawSolidDisc(c, Vector3.back, dot);
}
/// <summary>在 Scene 视图中标注可视区域的宽高。</summary>
private static void DrawDimensionLabels(Rect r)
{
if (_sceneLabelStyle == null)
_sceneLabelStyle = new GUIStyle(EditorStyles.miniLabel)
{ normal = { textColor = new Color(1f, 0.85f, 0.15f, 1f) } };
// 宽度:底边中心正下方
Handles.Label(
new Vector3(r.xMin + 0.15f, r.yMax - 0.15f, 0f),
"Visible Area",
EditorStyles.miniLabel);
new Vector3(r.center.x - 0.4f, r.yMin - 0.55f, 0f),
$"← {r.width:F1} →", _sceneLabelStyle);
// 高度:右边中心右侧
Handles.Label(
new Vector3(r.xMax + 0.2f, r.center.y - 0.1f, 0f),
$"{r.height:F1}", _sceneLabelStyle);
}
private static void DrawConfinerGizmo(CameraArea area)
@@ -123,66 +384,131 @@ namespace BaseGames.Editor
if (poly == null || poly.pathCount == 0) return;
int ptCount = poly.GetTotalPointCount();
if (ptCount < 2) return;
if (ptCount < 3) return;
var pts2 = new System.Collections.Generic.List<Vector2>(ptCount);
poly.GetPath(0, pts2);
var pts3 = new Vector3[ptCount + 1];
var pts3 = new Vector3[ptCount];
for (int i = 0; i < ptCount; i++)
pts3[i] = poly.transform.TransformPoint(pts2[i]);
pts3[ptCount] = pts3[0];
Handles.color = kConfinerColor;
Handles.DrawPolyLine(pts3);
Handles.Label(
(Vector3)poly.transform.TransformPoint(pts2[0]) + new Vector3(0.1f, 0.1f),
"Confiner",
EditorStyles.miniLabel);
DrawPolyGizmo(pts3, kConfinerFill, kConfinerLine, 2.0f);
if (_gizmoTagStyle == null)
_gizmoTagStyle = new GUIStyle(EditorStyles.miniLabel)
{ normal = { textColor = Color.white }, fontStyle = FontStyle.Bold };
Handles.color = kConfinerLine;
Handles.Label(pts3[0] + new Vector3(0.15f, 0.15f, 0f), "限位", _gizmoTagStyle);
}
/// <summary>绘制四条边的滑动 Handle允许用户直接拖拽修改可视区域。</summary>
private static void EditRectEdges(ref Rect r)
private static void DrawTriggerZoneGizmos(CameraArea area)
{
float hs = HandleUtility.GetHandleSize(r.center) * 0.10f;
var zones = area.GetComponentsInChildren<CameraTriggerZone>(true);
foreach (var zone in zones)
{
var poly = zone.GetComponent<PolygonCollider2D>();
if (poly == null || poly.pathCount == 0) continue;
int ptCount = poly.GetTotalPointCount();
if (ptCount < 3) continue;
var pts2 = new System.Collections.Generic.List<Vector2>(ptCount);
poly.GetPath(0, pts2);
var pts3 = new Vector3[ptCount];
for (int i = 0; i < ptCount; i++)
pts3[i] = poly.transform.TransformPoint(pts2[i]);
DrawPolyGizmo(pts3, kTriggerFill, kTriggerLine, 1.5f);
if (_gizmoTagStyle == null)
_gizmoTagStyle = new GUIStyle(EditorStyles.miniLabel)
{ normal = { textColor = Color.white }, fontStyle = FontStyle.Bold };
Handles.color = kTriggerLine;
Handles.Label(pts3[0] + new Vector3(0.15f, 0.15f, 0f), "触发", _gizmoTagStyle);
}
}
/// <summary>绘制闭合多边形 Gizmo半透明填充凸多边形+ 抗锯齿粗轮廓。</summary>
private static void DrawPolyGizmo(Vector3[] pts, Color fill, Color line, float lineWidth)
{
// 凸多边形填充
Handles.color = fill;
Handles.DrawAAConvexPolygon(pts);
// 闭合轮廓线
var closed = new Vector3[pts.Length + 1];
System.Array.Copy(pts, closed, pts.Length);
closed[pts.Length] = pts[0];
Handles.color = line;
Handles.DrawAAPolyLine(lineWidth, closed);
}
/// <summary>
/// 绘制可视区域的交互 Handle4 个角点(对角缩放)+ 4 个边中点(单轴缩放)+ 中心点(整体移动)。
/// </summary>
private static void EditRectHandles(ref Rect r)
{
float hs = HandleUtility.GetHandleSize(r.center) * 0.09f;
float hsDot = hs * 0.60f;
Handles.color = kVisibleOutline;
// 左边 —— 沿 X 轴滑动
// ── 中心:整体移动 ─────────────────────────────────────────────────
EditorGUI.BeginChangeCheck();
Vector3 lp = Handles.Slider(
new Vector3(r.xMin, r.center.y, 0f),
Vector3.right, hs, Handles.RectangleHandleCap, EditorSnapSettings.move.x);
Vector3 oldC = new Vector3(r.center.x, r.center.y, 0f);
Vector3 newC = Handles.FreeMoveHandle(oldC, hs * 1.3f, Vector3.zero, Handles.CircleHandleCap);
if (EditorGUI.EndChangeCheck())
r.xMin = Mathf.Min(lp.x, r.xMax - 0.1f);
r.position += new Vector2(newC.x - oldC.x, newC.y - oldC.y);
// 右边 —— 沿 X 轴滑动
// ── 四角:对角缩放(方形 cap易抓取 ────────────────────────────
// 左下
EditorGUI.BeginChangeCheck();
Vector3 rp = Handles.Slider(
new Vector3(r.xMax, r.center.y, 0f),
Vector3.right, hs, Handles.RectangleHandleCap, EditorSnapSettings.move.x);
if (EditorGUI.EndChangeCheck())
r.xMax = Mathf.Max(rp.x, r.xMin + 0.1f);
Vector3 bl = Handles.FreeMoveHandle(new Vector3(r.xMin, r.yMin, 0f), hs, Vector3.zero, Handles.RectangleHandleCap);
if (EditorGUI.EndChangeCheck()) { r.xMin = Mathf.Min(bl.x, r.xMax - 0.1f); r.yMin = Mathf.Min(bl.y, r.yMax - 0.1f); }
// 下边 —— 沿 Y 轴滑动
//
EditorGUI.BeginChangeCheck();
Vector3 bp = Handles.Slider(
new Vector3(r.center.x, r.yMin, 0f),
Vector3.up, hs, Handles.RectangleHandleCap, EditorSnapSettings.move.y);
if (EditorGUI.EndChangeCheck())
r.yMin = Mathf.Min(bp.y, r.yMax - 0.1f);
Vector3 br = Handles.FreeMoveHandle(new Vector3(r.xMax, r.yMin, 0f), hs, Vector3.zero, Handles.RectangleHandleCap);
if (EditorGUI.EndChangeCheck()) { r.xMax = Mathf.Max(br.x, r.xMin + 0.1f); r.yMin = Mathf.Min(br.y, r.yMax - 0.1f); }
// 上边 —— 沿 Y 轴滑动
//
EditorGUI.BeginChangeCheck();
Vector3 tp = Handles.Slider(
new Vector3(r.center.x, r.yMax, 0f),
Vector3.up, hs, Handles.RectangleHandleCap, EditorSnapSettings.move.y);
if (EditorGUI.EndChangeCheck())
r.yMax = Mathf.Max(tp.y, r.yMin + 0.1f);
Vector3 tl = Handles.FreeMoveHandle(new Vector3(r.xMin, r.yMax, 0f), hs, Vector3.zero, Handles.RectangleHandleCap);
if (EditorGUI.EndChangeCheck()) { r.xMin = Mathf.Min(tl.x, r.xMax - 0.1f); r.yMax = Mathf.Max(tl.y, r.yMin + 0.1f); }
// 右上
EditorGUI.BeginChangeCheck();
Vector3 tr = Handles.FreeMoveHandle(new Vector3(r.xMax, r.yMax, 0f), hs, Vector3.zero, Handles.RectangleHandleCap);
if (EditorGUI.EndChangeCheck()) { r.xMax = Mathf.Max(tr.x, r.xMin + 0.1f); r.yMax = Mathf.Max(tr.y, r.yMin + 0.1f); }
// ── 四边中点:单轴缩放(点 cap视觉上比角小 ────────────────────
// 左边
EditorGUI.BeginChangeCheck();
Vector3 lp = Handles.Slider(new Vector3(r.xMin, r.center.y, 0f), Vector3.right, hsDot, Handles.DotHandleCap, EditorSnapSettings.move.x);
if (EditorGUI.EndChangeCheck()) r.xMin = Mathf.Min(lp.x, r.xMax - 0.1f);
// 右边
EditorGUI.BeginChangeCheck();
Vector3 rp = Handles.Slider(new Vector3(r.xMax, r.center.y, 0f), Vector3.right, hsDot, Handles.DotHandleCap, EditorSnapSettings.move.x);
if (EditorGUI.EndChangeCheck()) r.xMax = Mathf.Max(rp.x, r.xMin + 0.1f);
// 下边
EditorGUI.BeginChangeCheck();
Vector3 bp = Handles.Slider(new Vector3(r.center.x, r.yMin, 0f), Vector3.up, hsDot, Handles.DotHandleCap, EditorSnapSettings.move.y);
if (EditorGUI.EndChangeCheck()) r.yMin = Mathf.Min(bp.y, r.yMax - 0.1f);
// 上边
EditorGUI.BeginChangeCheck();
Vector3 tp = Handles.Slider(new Vector3(r.center.x, r.yMax, 0f), Vector3.up, hsDot, Handles.DotHandleCap, EditorSnapSettings.move.y);
if (EditorGUI.EndChangeCheck()) r.yMax = Mathf.Max(tp.y, r.yMin + 0.1f);
}
// ══ 透视同步逻辑 ══════════════════════════════════════════════════════
private static void SyncConfinerFromVisibleBounds(CameraArea area, float vFOV, float aspect)
internal static void SyncConfinerFromVisibleBounds(CameraArea area, float vFOV, float aspect)
{
var poly = area.ConfinerCollider;
if (poly == null)
@@ -191,6 +517,75 @@ namespace BaseGames.Editor
return;
}
// VisibleBounds 已含 transform.position为世界坐标。
// 限位多边形 = 相机中心运动范围 = VisibleBounds 向内收缩视口半尺寸。
// 运行时 ConfigureSlot 设置 OversizeWindow.MaxWindowSize ≈ 0
// 阻止 Cinemachine 再次收缩此多边形,确保边界精确匹配可视区域。
Rect visible = area.VisibleBounds; // 世界坐标
float depth = area.CameraDepth;
float halfH = depth * Mathf.Tan(vFOV * 0.5f * Mathf.Deg2Rad);
float halfW = halfH * aspect;
float xMin = visible.xMin + halfW;
float xMax = visible.xMax - halfW;
float yMin = visible.yMin + halfH;
float yMax = visible.yMax - halfH;
// 小房间:视口大于可视区域时收缩至中心点,相机固定在可视区域中心
const float kMinSize = 0.001f;
if (xMin > xMax) { float cx = visible.center.x; xMin = cx - kMinSize * 0.5f; xMax = cx + kMinSize * 0.5f; }
if (yMin > yMax) { float cy = visible.center.y; yMin = cy - kMinSize * 0.5f; yMax = cy + kMinSize * 0.5f; }
Transform polyT = poly.transform;
Vector2 Local(Vector3 w) => polyT.InverseTransformPoint(w);
Undo.RecordObject(poly, "Sync Confiner from Visible Bounds");
// 顶点必须 CCW逆时针Clipper 对 CW 多边形area<0会取反 delta
// 导致 Confiner 向外膨胀而非向内收缩,相机完全不受限。
// CCW 顺序BL → BR → TR → TL
poly.SetPath(0, new[]
{
Local(new Vector3(xMin, yMin, 0f)), // BL
Local(new Vector3(xMax, yMin, 0f)), // BR
Local(new Vector3(xMax, yMax, 0f)), // TR
Local(new Vector3(xMin, yMax, 0f)), // TL
});
EditorUtility.SetDirty(poly);
// 记录本次同步所用的 FOV供编辑器过期检测使用
var areaSO = new SerializedObject(area);
var lastFovProp = areaSO.FindProperty("_lastSyncFOV");
if (lastFovProp != null)
{
lastFovProp.floatValue = vFOV;
areaSO.ApplyModifiedPropertiesWithoutUndo();
}
Debug.Log(
$"[CameraAreaEditor] {area.name}:限位区域已同步。\n" +
$" 可视区域:({visible.xMin:F2}, {visible.yMin:F2}) ~ ({visible.xMax:F2}, {visible.yMax:F2})\n" +
$" FOV={vFOV:F1}° Depth={depth:F1} HalfView=({halfW:F2}, {halfH:F2})\n" +
$" 限位区域(相机中心运动范围):({xMin:F2}, {yMin:F2}) ~ ({xMax:F2}, {yMax:F2})");
}
/// <summary>
/// 自动检测 FOV 和宽高比,将 <paramref name="area"/> 的限位多边形同步到可视区域。
/// 可从其他编辑器工具(如 CameraAreaSetupTool一键批量调用。
/// </summary>
internal static void SyncConfinerAuto(CameraArea area) =>
SyncConfinerFromVisibleBounds(area, GetFOV(area), GetAspect());
/// <summary>
/// 与 <see cref="SyncConfinerFromVisibleBounds"/> 逻辑相同,但不记录 Undo、不输出日志。
/// 供拖拽 Handle 时每帧调用,避免 Undo 堆积和 Console 刷屏。
/// 调用方须在调用前自行执行 Undo.RecordObject(poly)。
/// </summary>
private static void SyncConfinerQuiet(CameraArea area, float vFOV, float aspect)
{
var poly = area.ConfinerCollider;
if (poly == null) return;
// VisibleBounds 已含 transform.position为世界坐标。
Rect visible = area.VisibleBounds;
float depth = area.CameraDepth;
float halfH = depth * Mathf.Tan(vFOV * 0.5f * Mathf.Deg2Rad);
@@ -201,28 +596,68 @@ namespace BaseGames.Editor
float yMin = visible.yMin + halfH;
float yMax = visible.yMax - halfH;
// 房间小于单屏 → 相机锁定在可视区域中心
if (xMin > xMax) { float cx = visible.center.x; xMin = xMax = cx; }
if (yMin > yMax) { float cy = visible.center.y; yMin = yMax = cy; }
const float kMinSize = 0.001f;
if (xMin > xMax) { float cx = visible.center.x; xMin = cx - kMinSize * 0.5f; xMax = cx + kMinSize * 0.5f; }
if (yMin > yMax) { float cy = visible.center.y; yMin = cy - kMinSize * 0.5f; yMax = cy + kMinSize * 0.5f; }
Transform polyT = poly.transform;
Vector2 Local(Vector3 w) => polyT.InverseTransformPoint(w);
Undo.RecordObject(poly, "Sync Confiner from Visible Bounds");
// CCW 顺序BL → BR → TR → TLSyncConfinerFromVisibleBounds
poly.SetPath(0, new[]
{
Local(new Vector3(xMin, yMin, 0f)),
Local(new Vector3(xMin, yMax, 0f)),
Local(new Vector3(xMax, yMax, 0f)),
Local(new Vector3(xMax, yMin, 0f)),
Local(new Vector3(xMin, yMin, 0f)), // BL
Local(new Vector3(xMax, yMin, 0f)), // BR
Local(new Vector3(xMax, yMax, 0f)), // TR
Local(new Vector3(xMin, yMax, 0f)), // TL
});
EditorUtility.SetDirty(poly);
}
Debug.Log(
$"[CameraAreaEditor] {area.name}:限位区域已同步。\n" +
$" 可视区域:{visible}\n" +
$" FOV={vFOV:F1}° Depth={depth:F1} HalfView=({halfW:F2}, {halfH:F2})\n" +
$" 限位区域:({xMin:F2}, {yMin:F2}) ~ ({xMax:F2}, {yMax:F2})");
/// <summary>在 Scene 视图左上角绘制叠加信息面板(屏幕空间)。</summary>
private static void DrawSceneInfoOverlay(CameraArea area, Rect bounds)
{
Handles.BeginGUI();
bool hasConfiner = area.ConfinerCollider != null;
float panelH = hasConfiner ? 82f : 62f;
var panel = new Rect(8f, 8f, 192f, panelH);
// 半透明深色背景
GUI.color = new Color(0f, 0f, 0f, 0.65f);
GUI.DrawTexture(panel, Texture2D.whiteTexture);
GUI.color = Color.white;
if (_sceneOverlayBoldStyle == null)
_sceneOverlayBoldStyle = new GUIStyle(EditorStyles.boldLabel)
{ normal = { textColor = new Color(1f, 0.85f, 0.15f) }, fontSize = 11 };
if (_sceneLabelStyle == null)
_sceneLabelStyle = new GUIStyle(EditorStyles.miniLabel)
{ normal = { textColor = new Color(0.85f, 0.85f, 0.85f) } };
// 区域名称(白色加粗,顶部第一行)
var nameStyle = new GUIStyle(EditorStyles.boldLabel)
{ normal = { textColor = Color.white }, fontSize = 12 };
GUI.Label(new Rect(panel.x + 6, panel.y + 4, panel.width - 12, 18),
area.name, nameStyle);
GUI.Label(new Rect(panel.x + 6, panel.y + 25, panel.width - 12, 17),
$"可视 {bounds.width:F1} × {bounds.height:F1}", _sceneOverlayBoldStyle);
GUI.Label(new Rect(panel.x + 6, panel.y + 43, panel.width - 12, 14),
$"中心 ({bounds.center.x:F1}, {bounds.center.y:F1})", _sceneLabelStyle);
if (hasConfiner)
{
if (GUI.Button(new Rect(panel.x + 6, panel.y + 61, panel.width - 12, 16),
"↺ 同步限位区域", EditorStyles.miniButton))
{
Undo.RecordObject(area.ConfinerCollider, "Sync Confiner");
SyncConfinerFromVisibleBounds(area, GetFOV(area), GetAspect());
}
}
Handles.EndGUI();
}
// ══ 工具方法 ══════════════════════════════════════════════════════════
@@ -232,11 +667,15 @@ namespace BaseGames.Editor
/// </summary>
private static float GetFOV(CameraArea area)
{
// 1. 区域专有 VCam
// 1. 专有 VCam(同一场景,优先级最高)
if (area.DedicatedCamera != null)
return area.DedicatedCamera.Lens.FieldOfView;
// 2. Persistent 场景中的 CameraStateController._vcamA通过反射读取私有字段
// 2. CameraLensConfigSO单一来源无跨场景依赖
if (area.LensConfig != null)
return area.LensConfig.fieldOfView;
// 3. Persistent 场景已加载时,实时读取全局 VCamA兆底
#pragma warning disable UNT0023 // FindObjectOfType 在编辑器工具中可接受
var ctrl = Object.FindObjectOfType<CameraStateController>();
#pragma warning restore UNT0023
@@ -248,11 +687,11 @@ namespace BaseGames.Editor
return vcamA.Lens.FieldOfView;
}
// 3. Camera.main
// 4. Camera.main
if (UnityEngine.Camera.main != null)
return UnityEngine.Camera.main.fieldOfView;
// 4. 默认
// 5. 默认
return 60f;
}
@@ -273,5 +712,29 @@ namespace BaseGames.Editor
EditorGUILayout.LabelField(new GUIContent(text, tooltip));
}
}
private static bool DrawFoldoutHeader(string title, bool expanded)
{
if (_foldoutHeaderStyle == null)
_foldoutHeaderStyle = new GUIStyle(EditorStyles.foldout)
{
fontStyle = FontStyle.Bold,
normal = { textColor = Color.white },
hover = { textColor = Color.white },
active = { textColor = Color.white },
focused = { textColor = Color.white },
onNormal = { textColor = Color.white },
onHover = { textColor = Color.white },
onActive = { textColor = Color.white },
onFocused = { textColor = Color.white },
};
Rect r = EditorGUILayout.GetControlRect(false, 22f);
EditorGUI.DrawRect(r, new Color(0.22f, 0.22f, 0.28f, 1f));
bool newExpanded = EditorGUI.Foldout(
new Rect(r.x + 4f, r.y + 3f, r.width - 4f, 18f),
expanded, title, true, _foldoutHeaderStyle);
return newExpanded;
}
}
}

View File

@@ -0,0 +1,81 @@
using UnityEditor;
using UnityEngine;
using UnityEngine.SceneManagement;
using BaseGames.Camera;
namespace BaseGames.Editor
{
/// <summary>
/// CameraLensConfigSO 自定义 Inspector。
///
/// 功能:
/// - 修改 <see cref="CameraLensConfigSO.fieldOfView"/> 后,
/// 自动遍历所有已加载场景中引用该 SO 的 <see cref="CameraArea"/>
/// 重新同步限位多边形,避免 FOV 改动后各场景出现错位。
/// - 提供手动批量同步按钮,用于已打开但尚未触发自动同步的情形。
///
/// 未打开的场景无法自动同步。建议工作流:
/// 1. 修改 SO 中的 FOV。
/// 2. 依次打开各 Room 场景并加载 — 每次打开场景后点击「同步所有已加载场景」,
/// 或直接在 CameraArea Inspector「可视区域工具」区域点击同步按钮。
/// </summary>
[CustomEditor(typeof(CameraLensConfigSO))]
internal sealed class CameraLensConfigSOEditor : UnityEditor.Editor
{
public override void OnInspectorGUI()
{
EditorGUI.BeginChangeCheck();
DrawDefaultInspector();
if (EditorGUI.EndChangeCheck())
{
serializedObject.ApplyModifiedProperties();
// FOV 发生变化时,立即重新同步所有已加载场景中的 CameraArea
SyncAllLoadedCameraAreas((CameraLensConfigSO)target);
}
EditorGUILayout.Space(8f);
EditorGUILayout.HelpBox(
"修改 FOV 后,编辑器会自动同步当前已打开场景中的 CameraArea 限位区域。\n" +
"未打开的场景请手动打开后重新同步。",
MessageType.Info);
if (GUILayout.Button("同步所有已加载场景的 CameraArea 限位区域", GUILayout.Height(26f)))
SyncAllLoadedCameraAreas((CameraLensConfigSO)target);
}
/// <summary>
/// 遍历所有已加载场景,对引用了指定 SO 且拥有 ConfinerCollider 的 CameraArea 重新同步限位多边形。
/// 由 <see cref="CameraLensConfigSOEditor"/> 的 OnInspectorGUI 以及外部批量工具调用。
/// </summary>
internal static void SyncAllLoadedCameraAreas(CameraLensConfigSO so)
{
float fov = so.fieldOfView;
float aspect = UnityEngine.Camera.main != null
? UnityEngine.Camera.main.aspect
: 16f / 9f;
int count = 0;
for (int i = 0; i < SceneManager.sceneCount; i++)
{
Scene scene = SceneManager.GetSceneAt(i);
if (!scene.isLoaded) continue;
foreach (GameObject root in scene.GetRootGameObjects())
{
foreach (CameraArea area in root.GetComponentsInChildren<CameraArea>(true))
{
if (area.LensConfig != so) continue;
if (area.ConfinerCollider == null) continue;
CameraAreaEditor.SyncConfinerFromVisibleBounds(area, fov, aspect);
count++;
}
}
}
if (count > 0)
Debug.Log($"[CameraLensConfigSO] 已同步 {count} 个 CameraArea 的限位区域FOV = {fov:F1}°)。");
}
}
}

View File

@@ -1,5 +1,5 @@
fileFormatVersion: 2
guid: e733a7cb718909842b12f5994eb841c4
guid: 48768cad22a696a4582e9dbc2c100194
MonoImporter:
externalObjects: {}
serializedVersion: 2

View File

@@ -0,0 +1,439 @@
using System.Collections.Generic;
using BaseGames.Camera;
using UnityEditor;
using UnityEditor.SceneManagement;
using UnityEngine;
namespace BaseGames.Editor
{
/// <summary>
/// 将旧格式相机区域批量迁移到新架构。
///
/// 旧格式:
/// Zone_xxx挂 BoxCollider2D定义相机可视矩形
/// ├─ Zone_xxx_TriggerRegion
/// │ ├─ Zone_xxx_TriggerRegion_Point_0 … (多边形顶点)
/// └─ Zone_xxx_Confiner挂 BoxCollider2D / PolygonCollider2D定义限位边界
///
/// 新格式:
/// [新 CameraArea GO]CameraArea 组件_visibleBounds = 本地 Rect
/// ├─ AreaBoundaryPolygonCollider2DisTrigger=true对应旧 Confiner
/// └─ TriggerZoneCameraTriggerZone + PolygonCollider2D对应旧 TriggerRegion
///
/// 菜单BaseGames → Camera → 相机区域迁移工具
/// </summary>
public class CameraZoneMigrationTool : EditorWindow
{
[MenuItem("BaseGames/Camera/相机区域迁移工具", priority = 110)]
public static void Open()
{
var win = GetWindow<CameraZoneMigrationTool>("相机区域迁移工具");
win.minSize = new Vector2(500f, 440f);
}
// ── 设置字段 ──────────────────────────────────────────────────────────
private Transform _sourcesParent; // 旧 Zone_xxx 的父节点(通常名为 Zones
private Transform _targetParent; // 新对象放置位置(留空 = 与旧区域同级)
private CameraLensConfigSO _lensConfig; // 绑定到新 CameraArea._lensConfig
// ── 运行时状态 ────────────────────────────────────────────────────────
private readonly List<ZoneEntry> _entries = new List<ZoneEntry>();
private Vector2 _scroll;
private bool _scanned;
private int _lastMigratedCount;
// ── 单条目数据 ────────────────────────────────────────────────────────
private class ZoneEntry
{
public GameObject ZoneObj;
public BoxCollider2D VisibleBox; // Zone_xxx 上的 BoxCollider2D
public List<Vector2> TriggerWorldPts = new List<Vector2>(); // TriggerRegion 各点世界坐标
public Collider2D ConfinerCollider; // Zone_xxx_Confiner 上的碰撞体(可空)
public bool AlreadyMigrated;
public bool Selected = true;
}
// ── 颜色 ─────────────────────────────────────────────────────────────
private static readonly Color kOk = new Color(0.30f, 0.85f, 0.30f);
private static readonly Color kWarning = new Color(1.00f, 0.75f, 0.10f);
private static readonly Color kMuted = new Color(0.55f, 0.55f, 0.60f);
// ══ GUI ═══════════════════════════════════════════════════════════════
private void OnGUI()
{
EditorGUILayout.Space(6);
EditorGUILayout.LabelField("旧格式相机区域迁移工具", EditorStyles.boldLabel);
EditorGUILayout.HelpBox(
"将旧架构Zone_xxx + BoxCollider2D + TriggerRegion 点集 + Confiner" +
"批量转换为新架构CameraArea + AreaBoundary + CameraTriggerZone。\n" +
"迁移结果可在 Scene 视图直接预览,原旧对象可选择禁用。",
MessageType.Info);
EditorGUILayout.Space(6);
// ── 配置 ──────────────────────────────────────────────────────────
_sourcesParent = (Transform)EditorGUILayout.ObjectField(
new GUIContent("旧区域父节点 (Zones)", "包含所有 Zone_xxx 的父对象"),
_sourcesParent, typeof(Transform), true);
_targetParent = (Transform)EditorGUILayout.ObjectField(
new GUIContent("新区域父节点(留空 = 同级)", "生成的 CameraArea 放在此节点下;留空则与旧 Zone_xxx 同级"),
_targetParent, typeof(Transform), true);
_lensConfig = (CameraLensConfigSO)EditorGUILayout.ObjectField(
new GUIContent("镜头配置 SO", "赋给所有新 CameraArea._lensConfig留空则不赋值"),
_lensConfig, typeof(CameraLensConfigSO), false);
EditorGUILayout.Space(8);
using (new EditorGUI.DisabledScope(_sourcesParent == null))
{
if (GUILayout.Button("扫描区域", GUILayout.Height(30)))
ScanZones();
}
if (!_scanned) return;
EditorGUILayout.Space(4);
if (_entries.Count == 0)
{
EditorGUILayout.HelpBox(
"未检测到旧格式区域。\n条件BoxCollider2D + 含 \"TriggerRegion\" 字样的子节点。",
MessageType.Warning);
return;
}
// ── 统计行 ─────────────────────────────────────────────────────────
int migrated = 0, pending = 0;
foreach (var e in _entries) { if (e.AlreadyMigrated) migrated++; else if (e.Selected) pending++; }
EditorGUILayout.LabelField(
$"共 {_entries.Count} 个区域 | 已迁移 {migrated} | 已选待迁移 {pending}",
EditorStyles.miniBoldLabel);
EditorGUILayout.Space(2);
// ── 条目列表 ───────────────────────────────────────────────────────
_scroll = EditorGUILayout.BeginScrollView(_scroll, GUILayout.MaxHeight(240));
foreach (var entry in _entries)
DrawEntryRow(entry);
EditorGUILayout.EndScrollView();
EditorGUILayout.Space(8);
// ── 批量迁移 ───────────────────────────────────────────────────────
int selectedPending = _entries.FindAll(e => e.Selected && !e.AlreadyMigrated).Count;
using (new EditorGUI.DisabledScope(selectedPending == 0))
{
if (GUILayout.Button($"迁移已选中区域({selectedPending} 个)", GUILayout.Height(36)))
{
int count = 0;
foreach (var e in _entries)
if (e.Selected && !e.AlreadyMigrated) { MigrateZone(e); count++; }
_lastMigratedCount = count;
ScanZones();
Debug.Log($"[迁移工具] 完成迁移 {count} 个相机区域。");
}
}
if (_lastMigratedCount > 0)
EditorGUILayout.HelpBox(
$"上次迁移完成 {_lastMigratedCount} 个。请在 Scene 视图确认后保存场景Ctrl+S。",
MessageType.Info);
}
private void DrawEntryRow(ZoneEntry entry)
{
using (new EditorGUILayout.HorizontalScope(EditorStyles.helpBox))
{
// 选择框(已迁移的不可再选)
using (new EditorGUI.DisabledScope(entry.AlreadyMigrated))
entry.Selected = EditorGUILayout.Toggle(entry.Selected, GUILayout.Width(16));
// 名称
Color prev = GUI.color;
GUI.color = entry.AlreadyMigrated ? kMuted : kOk;
EditorGUILayout.LabelField(entry.ZoneObj.name, GUILayout.Width(170));
GUI.color = prev;
// 可视框尺寸
if (entry.VisibleBox != null)
{
Vector2 sz = entry.VisibleBox.size;
EditorGUILayout.LabelField($"Box {sz.x:F0}×{sz.y:F0}", EditorStyles.miniLabel, GUILayout.Width(72));
}
else
{
GUI.color = kWarning;
EditorGUILayout.LabelField("无 Box2D", EditorStyles.miniLabel, GUILayout.Width(72));
GUI.color = prev;
}
// 触发点数
Color ptColor = entry.TriggerWorldPts.Count >= 3 ? kOk : kWarning;
GUI.color = ptColor;
EditorGUILayout.LabelField($"触发 {entry.TriggerWorldPts.Count}pt", EditorStyles.miniLabel, GUILayout.Width(54));
GUI.color = prev;
// 限位来源
string confLabel = entry.ConfinerCollider is PolygonCollider2D ? "Poly限位"
: entry.ConfinerCollider is BoxCollider2D ? "Box限位"
: "默认矩形";
EditorGUILayout.LabelField(confLabel, EditorStyles.miniLabel, GUILayout.Width(54));
// 状态 / 单独迁移按钮
if (entry.AlreadyMigrated)
{
GUI.color = kMuted;
EditorGUILayout.LabelField("已迁移", EditorStyles.miniLabel, GUILayout.Width(46));
GUI.color = prev;
}
else
{
if (GUILayout.Button("迁移", GUILayout.Width(44), GUILayout.Height(16)))
{
MigrateZone(entry);
_lastMigratedCount = 1;
ScanZones();
}
}
// Ping 旧对象
if (GUILayout.Button("●", GUILayout.Width(20), GUILayout.Height(16)))
EditorGUIUtility.PingObject(entry.ZoneObj);
}
}
// ══ 扫描 ══════════════════════════════════════════════════════════════
private void ScanZones()
{
_entries.Clear();
_scanned = true;
if (_sourcesParent == null) return;
foreach (Transform child in _sourcesParent)
{
// 旧 Zone 的标识:子节点直属,且挂有 BoxCollider2D
var box = child.GetComponent<BoxCollider2D>();
if (box == null) continue;
var entry = new ZoneEntry { ZoneObj = child.gameObject, VisibleBox = box };
// 收集触发多边形顶点TriggerRegion 子节点的各个点对象)
Transform triggerRoot = FindChildContaining(child, "TriggerRegion");
if (triggerRoot != null)
foreach (Transform pt in triggerRoot)
entry.TriggerWorldPts.Add((Vector2)pt.position);
// 读取限位碰撞体Zone_xxx_Confiner 上的 Collider2D
Transform confinerT = FindChildContaining(child, "Confiner");
if (confinerT != null)
entry.ConfinerCollider = confinerT.GetComponent<Collider2D>();
// 是否已经完成迁移(自身或子节点含 CameraArea
entry.AlreadyMigrated =
child.GetComponent<CameraArea>() != null ||
child.GetComponentInChildren<CameraArea>(true) != null;
_entries.Add(entry);
}
Repaint();
}
private static Transform FindChildContaining(Transform parent, string keyword)
{
foreach (Transform child in parent)
if (child.name.IndexOf(keyword, System.StringComparison.OrdinalIgnoreCase) >= 0)
return child;
return null;
}
// ══ 迁移 ══════════════════════════════════════════════════════════════
private void MigrateZone(ZoneEntry entry)
{
GameObject zoneGO = entry.ZoneObj;
Transform parent = _targetParent != null ? _targetParent : zoneGO.transform.parent;
Vector3 worldPos = zoneGO.transform.position;
// ── 1. 计算本地可视 Rect相对于新 CameraArea 的世界位置)────────
Rect localBounds;
if (entry.VisibleBox != null)
{
// 注意BoxCollider2D.bounds 在 inactive 对象上无效,必须手动计算
Bounds wb = GetColliderWorldBounds(entry.VisibleBox);
localBounds = new Rect(
wb.min.x - worldPos.x,
wb.min.y - worldPos.y,
wb.size.x, wb.size.y);
}
else
{
localBounds = new Rect(-12f, -6f, 24f, 12f);
}
// ── 2. 创建 CameraArea 根节点 ─────────────────────────────────────
GameObject areaGO = new GameObject(zoneGO.name);
Undo.RegisterCreatedObjectUndo(areaGO, "Migrate Camera Zone");
areaGO.transform.SetParent(parent, worldPositionStays: false);
areaGO.transform.position = worldPos;
// 紧跟旧对象之后(同级排列时保持顺序直观)
if (parent == zoneGO.transform.parent)
areaGO.transform.SetSiblingIndex(zoneGO.transform.GetSiblingIndex() + 1);
CameraArea area = areaGO.AddComponent<CameraArea>();
var soArea = new SerializedObject(area);
soArea.FindProperty("_visibleBounds").rectValue = localBounds;
if (_lensConfig != null)
soArea.FindProperty("_lensConfig").objectReferenceValue = _lensConfig;
soArea.ApplyModifiedProperties();
// ── 3. 创建 AreaBoundary限位多边形isTrigger = true──────────
GameObject boundaryGO = new GameObject($"{zoneGO.name}_AreaBoundary");
Undo.RegisterCreatedObjectUndo(boundaryGO, "Migrate Camera Zone");
boundaryGO.transform.SetParent(areaGO.transform, worldPositionStays: false);
boundaryGO.transform.localPosition = Vector3.zero;
PolygonCollider2D confinerPoly = boundaryGO.AddComponent<PolygonCollider2D>();
confinerPoly.isTrigger = true;
confinerPoly.pathCount = 1;
confinerPoly.SetPath(0, BuildConfinerPath(entry, worldPos, localBounds));
// 绑定 _confinerCollider
soArea.Update();
soArea.FindProperty("_confinerCollider").objectReferenceValue = confinerPoly;
soArea.ApplyModifiedProperties();
EditorUtility.SetDirty(area);
// 绑定完成后立即按 FOV/Depth 公式同步限位多边形,
// 确保与 LensConfig 参数一致(创建时不会自动同步)
float syncFOV = _lensConfig != null ? _lensConfig.fieldOfView : 60f;
float syncAspect = UnityEngine.Camera.main != null ? UnityEngine.Camera.main.aspect : 16f / 9f;
CameraAreaEditor.SyncConfinerFromVisibleBounds(area, syncFOV, syncAspect);
// ── 4. 创建 TriggerZone相机激活触发器───────────────────────
GameObject triggerGO = new GameObject($"{zoneGO.name}_TriggerZone");
Undo.RegisterCreatedObjectUndo(triggerGO, "Migrate Camera Zone");
triggerGO.transform.SetParent(areaGO.transform, worldPositionStays: false);
triggerGO.transform.localPosition = Vector3.zero;
// AddComponent 会因 [RequireComponent] 自动添加 PolygonCollider2D
CameraTriggerZone triggerComp = triggerGO.AddComponent<CameraTriggerZone>();
PolygonCollider2D triggerPoly = triggerGO.GetComponent<PolygonCollider2D>();
triggerPoly.isTrigger = true;
triggerPoly.pathCount = 1;
triggerPoly.SetPath(0, BuildTriggerPath(entry, worldPos, localBounds));
// _targetArea → 指向刚创建的 CameraArea
var soTrigger = new SerializedObject(triggerComp);
soTrigger.FindProperty("_targetArea").objectReferenceValue = area;
soTrigger.ApplyModifiedProperties();
EditorUtility.SetDirty(triggerComp);
// ── 5. 处理旧对象 ──────────────────────────────────────────────
// 先记录原始激活状态,再对旧对象做处理,避免 SetActive(false) 后误读
bool wasActive = zoneGO.activeSelf;
// 同步旧区域的激活状态(旧 Zone_xxx 若为禁用,新对象同样禁用)
if (!wasActive)
areaGO.SetActive(false);
EditorUtility.SetDirty(areaGO);
EditorSceneManager.MarkSceneDirty(zoneGO.scene);
EditorGUIUtility.PingObject(areaGO);
Debug.Log($"[迁移工具] {zoneGO.name} → {areaGO.name} " +
$"可视 {localBounds.width:F0}×{localBounds.height:F0} " +
$"触发 {triggerPoly.GetTotalPointCount()} pt " +
$"限位 {confinerPoly.GetTotalPointCount()} pt");
}
// ── 限位多边形路径 ────────────────────────────────────────────────────
/// <summary>
/// 按优先级构建限位多边形路径(本地坐标,相对于新 CameraArea
/// 1. Zone_xxx_Confiner 上的 PolygonCollider2D → 直接转换
/// 2. Zone_xxx_Confiner 上的 BoxCollider2D → 取 AABB 四角
/// 3. 兜底 → 使用可视矩形
/// </summary>
private static Vector2[] BuildConfinerPath(ZoneEntry entry, Vector3 areaWorldPos, Rect fallback)
{
if (entry.ConfinerCollider is PolygonCollider2D poly && poly.pathCount > 0)
{
var pts = new List<Vector2>();
poly.GetPath(0, pts);
var result = new Vector2[pts.Count];
for (int i = 0; i < pts.Count; i++)
result[i] = (Vector2)poly.transform.TransformPoint(pts[i]) - (Vector2)areaWorldPos;
return result;
}
if (entry.ConfinerCollider is BoxCollider2D box)
{
Bounds b = GetColliderWorldBounds(box);
return new Vector2[]
{
new Vector2(b.min.x - areaWorldPos.x, b.min.y - areaWorldPos.y),
new Vector2(b.min.x - areaWorldPos.x, b.max.y - areaWorldPos.y),
new Vector2(b.max.x - areaWorldPos.x, b.max.y - areaWorldPos.y),
new Vector2(b.max.x - areaWorldPos.x, b.min.y - areaWorldPos.y),
};
}
// 兜底:可视矩形
return RectToPolygon(fallback);
}
/// <summary>
/// 构建触发多边形路径(本地坐标,相对于新 CameraArea
/// 取 TriggerRegion 各点的世界坐标减去 areaWorldPos
/// 若点数不足 3 则兜底使用可视矩形。
/// </summary>
private static Vector2[] BuildTriggerPath(ZoneEntry entry, Vector3 areaWorldPos, Rect fallback)
{
if (entry.TriggerWorldPts.Count >= 3)
{
var path = new Vector2[entry.TriggerWorldPts.Count];
for (int i = 0; i < path.Length; i++)
path[i] = entry.TriggerWorldPts[i] - (Vector2)areaWorldPos;
return path;
}
return RectToPolygon(fallback);
}
/// <summary>
/// 手动计算 BoxCollider2D 的世界 AABB不依赖 .boundsinactive 对象上 .bounds 无效)。
/// </summary>
private static Bounds GetColliderWorldBounds(BoxCollider2D box)
{
Vector2 worldCenter = (Vector2)box.transform.TransformPoint(box.offset);
Vector2 worldSize = new Vector2(
box.size.x * Mathf.Abs(box.transform.lossyScale.x),
box.size.y * Mathf.Abs(box.transform.lossyScale.y));
return new Bounds(worldCenter, worldSize);
}
private static Vector2[] RectToPolygon(Rect r)
{
return new Vector2[]
{
new Vector2(r.xMin, r.yMin),
new Vector2(r.xMin, r.yMax),
new Vector2(r.xMax, r.yMax),
new Vector2(r.xMax, r.yMin),
};
}
}
}

View File

@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 3578efbe9a4a182448fa721fcbe75853
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:

View File

@@ -1,268 +0,0 @@
using UnityEditor;
using UnityEngine;
using Unity.Cinemachine;
using BaseGames.Camera;
namespace BaseGames.Editor
{
/// <summary>
/// RoomCamera 自定义 Inspector + Scene GUI。
///
/// 功能:
/// 1. Scene 视图中直接拖拽黄色矩形的四条边,编辑「可视区域」(_visibleBounds)。
/// 2. Inspector 按钮「从可视区域更新限位区域(透视)」:
/// 根据 CinemachineCamera.Lens.FieldOfView 和摄像机深度,计算出
/// CinemachineConfiner2D 所需的限位多边形并写入子节点 PolygonCollider2D。
///
/// 透视相机限位公式:
/// halfH = depth × tan(vFOV / 2)
/// halfW = halfH × aspectRatio
/// confiner = visibleBounds inset by (halfW, halfH)
/// → 相机视口边缘恰好与可视区域边框对齐。
/// → 若房间小于单屏,限位收缩为中心点(相机固定居中)。
/// </summary>
[CustomEditor(typeof(RoomCamera))]
public class RoomCameraEditor : UnityEditor.Editor
{
// ── 颜色常量 ──────────────────────────────────────────────────────────
private static readonly Color kVisibleFill = new Color(1f, 0.85f, 0.15f, 0.08f);
private static readonly Color kVisibleOutline = new Color(1f, 0.85f, 0.15f, 0.90f);
private static readonly Color kConfinerColor = new Color(0.2f, 0.8f, 1.0f, 0.80f);
// ══ Inspector ═════════════════════════════════════════════════════════
public override void OnInspectorGUI()
{
DrawDefaultInspector();
EditorGUILayout.Space(8f);
EditorGUILayout.LabelField("── 可视区域工具 ──", EditorStyles.boldLabel);
RoomCamera rc = (RoomCamera)target;
var vcam = rc.GetComponent<CinemachineCamera>();
var confiner = rc.GetComponent<CinemachineConfiner2D>();
// ── 透视参数预览 ─────────────────────────────────────────────────
float vFOV = vcam != null ? vcam.Lens.FieldOfView : 60f;
float aspect = GetAspect();
float depth = rc.CameraDepth;
float halfH = depth * Mathf.Tan(vFOV * 0.5f * Mathf.Deg2Rad);
float halfW = halfH * aspect;
using (new EditorGUI.DisabledScope(true))
{
EditorGUILayout.FloatField("垂直 FOV来自 Lens", vFOV);
EditorGUILayout.FloatField("有效深度", depth);
EditorGUILayout.FloatField("视口半高(世界单位)", halfH);
EditorGUILayout.FloatField("视口半宽(世界单位)", halfW);
}
bool canSync = rc.ConfinerCollider != null;
if (!canSync)
EditorGUILayout.HelpBox("ConfinerCollider 未绑定_visibleArea 为空),无法同步限位区域。", MessageType.Warning);
using (new EditorGUI.DisabledScope(!canSync))
{
if (GUILayout.Button("从可视区域更新限位区域(透视)", GUILayout.Height(28f)))
SyncConfinerFromVisibleBounds(rc, vFOV, aspect);
}
// ── 图例说明 ─────────────────────────────────────────────────────
EditorGUILayout.Space(4f);
DrawLegend("■ 黄色矩形Scene 视图)", kVisibleOutline, "可视区域 — 摄像机视口永不超出此范围");
DrawLegend("■ 蓝色多边形Scene 视图)", kConfinerColor, "限位区域 — CinemachineConfiner2D 的运动边界");
}
// ══ Scene GUI ════════════════════════════════════════════════════════
private void OnSceneGUI()
{
RoomCamera rc = (RoomCamera)target;
serializedObject.Update();
SerializedProperty boundsP = serializedObject.FindProperty("_visibleBounds");
Rect r = boundsP.rectValue;
// ── 绘制限位多边形(蓝色,参考用) ──────────────────────────────
DrawConfinerGizmo(rc);
// ── 绘制可视区域填充 + 边框 ──────────────────────────────────────
DrawVisibleRect(r);
// ── 四条边的拖拽 Handle ──────────────────────────────────────────
EditorGUI.BeginChangeCheck();
EditRectEdges(ref r);
if (EditorGUI.EndChangeCheck())
{
Undo.RecordObject(rc, "Edit Visible Bounds");
boundsP.rectValue = r;
serializedObject.ApplyModifiedProperties();
}
}
// ══ 绘制辅助 ═════════════════════════════════════════════════════════
private static void DrawVisibleRect(Rect r)
{
Vector3[] corners =
{
new Vector3(r.xMin, r.yMin, 0f),
new Vector3(r.xMin, r.yMax, 0f),
new Vector3(r.xMax, r.yMax, 0f),
new Vector3(r.xMax, r.yMin, 0f),
};
Handles.DrawSolidRectangleWithOutline(corners, kVisibleFill, kVisibleOutline);
// 标签
Handles.color = kVisibleOutline;
Handles.Label(
new Vector3(r.xMin + 0.15f, r.yMax - 0.15f, 0f),
"Visible Area",
EditorStyles.miniLabel);
}
private static void DrawConfinerGizmo(RoomCamera rc)
{
var poly = rc.ConfinerCollider;
if (poly == null || poly.pathCount == 0) return;
int ptCount = poly.GetTotalPointCount();
if (ptCount < 2) return;
var pts2 = new System.Collections.Generic.List<Vector2>(ptCount);
poly.GetPath(0, pts2);
var pts3 = new Vector3[ptCount + 1];
for (int i = 0; i < ptCount; i++)
pts3[i] = poly.transform.TransformPoint(pts2[i]);
pts3[ptCount] = pts3[0];
Handles.color = kConfinerColor;
Handles.DrawPolyLine(pts3);
Handles.Label(
(Vector3)poly.transform.TransformPoint(pts2[0]) + new Vector3(0.1f, 0.1f),
"Confiner",
EditorStyles.miniLabel);
}
/// <summary>绘制四条边的滑动 Handle允许用户直接拖拽修改可视区域。</summary>
private static void EditRectEdges(ref Rect r)
{
float hs = HandleUtility.GetHandleSize(r.center) * 0.10f;
Handles.color = kVisibleOutline;
// 左边 —— 沿 X 轴滑动
EditorGUI.BeginChangeCheck();
Vector3 lp = Handles.Slider(
new Vector3(r.xMin, r.center.y, 0f),
Vector3.right, hs, Handles.RectangleHandleCap, EditorSnapSettings.move.x);
if (EditorGUI.EndChangeCheck())
{
// 保持 xMax 不变xMin 向右最多到 xMax-0.1
r.xMin = Mathf.Min(lp.x, r.xMax - 0.1f);
}
// 右边 —— 沿 X 轴滑动
EditorGUI.BeginChangeCheck();
Vector3 rp = Handles.Slider(
new Vector3(r.xMax, r.center.y, 0f),
Vector3.right, hs, Handles.RectangleHandleCap, EditorSnapSettings.move.x);
if (EditorGUI.EndChangeCheck())
{
r.xMax = Mathf.Max(rp.x, r.xMin + 0.1f);
}
// 下边 —— 沿 Y 轴滑动
EditorGUI.BeginChangeCheck();
Vector3 bp = Handles.Slider(
new Vector3(r.center.x, r.yMin, 0f),
Vector3.up, hs, Handles.RectangleHandleCap, EditorSnapSettings.move.y);
if (EditorGUI.EndChangeCheck())
{
r.yMin = Mathf.Min(bp.y, r.yMax - 0.1f);
}
// 上边 —— 沿 Y 轴滑动
EditorGUI.BeginChangeCheck();
Vector3 tp = Handles.Slider(
new Vector3(r.center.x, r.yMax, 0f),
Vector3.up, hs, Handles.RectangleHandleCap, EditorSnapSettings.move.y);
if (EditorGUI.EndChangeCheck())
{
r.yMax = Mathf.Max(tp.y, r.yMin + 0.1f);
}
}
// ══ 透视同步逻辑 ══════════════════════════════════════════════════════
/// <summary>
/// 根据可视区域矩形与透视参数计算限位多边形,写入 PolygonCollider2D。
/// </summary>
private static void SyncConfinerFromVisibleBounds(RoomCamera rc, float vFOV, float aspect)
{
var poly = rc.ConfinerCollider;
if (poly == null)
{
Debug.LogWarning($"[RoomCameraEditor] {rc.name}ConfinerCollider 未绑定,无法同步。");
return;
}
Rect visible = rc.VisibleBounds;
float depth = rc.CameraDepth;
float halfH = depth * Mathf.Tan(vFOV * 0.5f * Mathf.Deg2Rad);
float halfW = halfH * aspect;
float xMin = visible.xMin + halfW;
float xMax = visible.xMax - halfW;
float yMin = visible.yMin + halfH;
float yMax = visible.yMax - halfH;
// 房间小于单屏 → 相机锁定在可视区域中心
if (xMin > xMax) { float cx = visible.center.x; xMin = xMax = cx; }
if (yMin > yMax) { float cy = visible.center.y; yMin = yMax = cy; }
Transform polyT = poly.transform;
Vector2 Local(Vector3 w) => polyT.InverseTransformPoint(w);
Undo.RecordObject(poly, "Sync Confiner from Visible Bounds");
poly.SetPath(0, new[]
{
Local(new Vector3(xMin, yMin, 0f)),
Local(new Vector3(xMin, yMax, 0f)),
Local(new Vector3(xMax, yMax, 0f)),
Local(new Vector3(xMax, yMin, 0f)),
});
EditorUtility.SetDirty(poly);
Debug.Log(
$"[RoomCameraEditor] {rc.name}:限位区域已同步。\n" +
$" 可视区域:{visible}\n" +
$" FOV={vFOV:F1}° Depth={depth:F1} HalfView=({halfW:F2}, {halfH:F2})\n" +
$" 限位区域:({xMin:F2}, {yMin:F2}) ~ ({xMax:F2}, {yMax:F2})");
}
// ══ 工具方法 ══════════════════════════════════════════════════════════
/// <summary>
/// 获取 Game 视图宽高比。编辑器中优先用 Camera.main否则回退到 16:9。
/// </summary>
private static float GetAspect()
{
if (UnityEngine.Camera.main != null) return UnityEngine.Camera.main.aspect;
return 16f / 9f;
}
private static void DrawLegend(string text, Color color, string tooltip)
{
using (new EditorGUILayout.HorizontalScope())
{
Color prev = GUI.color;
GUI.color = color;
GUILayout.Label("■", GUILayout.Width(14f));
GUI.color = prev;
EditorGUILayout.LabelField(new GUIContent(text, tooltip));
}
}
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,109 @@
using UnityEditor;
using UnityEditor.SceneManagement;
using UnityEngine;
using UnityEngine.SceneManagement;
namespace BaseGames.Editor
{
/// <summary>
/// 编辑器 Edit Mode 辅助:打开任意场景时自动将 Persistent 场景 Additive 加入 Hierarchy。
///
/// 职责范围(仅限 Edit Mode
/// 让设计师在编辑房间场景时Inspector 中可直接看到并配置 GameManager / SceneService
/// 等 Persistent 场景内的组件,无需手动 Open Additive。
///
/// 运行时Play Mode / 发行版 Build的保证由 GameBootstrapRuntime 程序集)负责,
/// 本脚本与 Play Mode 状态无关,不监听 playModeStateChanged。
///
/// 菜单BaseGames/Tools/Edit Mode: Auto-Open Persistent Scene
/// </summary>
[InitializeOnLoad]
public static class PersistentSceneAutoLoader
{
// ── 常量 ─────────────────────────────────────────────────────────────
private const string MenuPath = "BaseGames/Tools/Edit Mode: Auto-Open Persistent Scene";
private const string PrefKey = "BaseGames_EditAutoOpen_Persistent";
private const string PersistentSceneName = "Scene_Persistent";
// ── 构造Editor 启动时执行)──────────────────────────────────────────
static PersistentSceneAutoLoader()
{
EditorSceneManager.sceneOpened += OnSceneOpened;
// 启动时补一次检查Editor 已打开但 Persistent 不在 Hierarchy 的场景
EditorApplication.delayCall += EnsurePersistentInHierarchyEditMode;
}
// ── 菜单 ─────────────────────────────────────────────────────────────
[MenuItem(MenuPath, validate = false, priority = 301)]
private static void ToggleEnabled()
{
bool current = EditorPrefs.GetBool(PrefKey, true);
EditorPrefs.SetBool(PrefKey, !current);
}
[MenuItem(MenuPath, validate = true)]
private static bool ToggleEnabledValidate()
{
Menu.SetChecked(MenuPath, EditorPrefs.GetBool(PrefKey, true));
return true;
}
// ── 场景打开回调 ──────────────────────────────────────────────────────
private static void OnSceneOpened(Scene scene, OpenSceneMode mode)
{
// 若是 Persistent 本身被打开,无需额外处理
if (IsPersistentScene(scene.name)) return;
// Single 模式(替换当前场景)或 Additive 加入新场景时,确保 Persistent 也在 Hierarchy 中
// 使用 delayCall 避免在场景加载中途调用 OpenScene
EditorApplication.delayCall += EnsurePersistentInHierarchyEditMode;
}
// ── 核心逻辑 ──────────────────────────────────────────────────────────
private static void EnsurePersistentInHierarchyEditMode()
{
// 仅在 Edit Mode 执行Play Mode 由 GameBootstrap 负责)
if (Application.isPlaying) return;
if (!EditorPrefs.GetBool(PrefKey, true)) return;
// 若 Persistent 已在 Hierarchy无需操作
for (int i = 0; i < SceneManager.sceneCount; i++)
if (IsPersistentScene(SceneManager.GetSceneAt(i).name)) return;
// 查找并 Additive 打开 Persistent 场景
string path = FindPersistentScenePath();
if (string.IsNullOrEmpty(path))
{
Debug.LogWarning(
$"[PersistentAutoLoader] 未找到 '{PersistentSceneName}' 场景。" +
"请确认场景已添加到 Build Settings 或可在 Assets 中搜索到。");
return;
}
EditorSceneManager.OpenScene(path, OpenSceneMode.Additive);
}
// ── 工具函数 ──────────────────────────────────────────────────────────
private static bool IsPersistentScene(string sceneName)
=> sceneName == PersistentSceneName || sceneName == "Persistent";
private static string FindPersistentScenePath()
{
// 优先从 Build Settings 查找(保证与 GameBootstrap 使用同一文件)
foreach (var buildScene in EditorBuildSettings.scenes)
{
if (!buildScene.enabled) continue;
string name = System.IO.Path.GetFileNameWithoutExtension(buildScene.path);
if (IsPersistentScene(name)) return buildScene.path;
}
// 回退:在 AssetDatabase 中搜索
string[] guids = AssetDatabase.FindAssets($"t:Scene {PersistentSceneName}");
if (guids.Length > 0) return AssetDatabase.GUIDToAssetPath(guids[0]);
guids = AssetDatabase.FindAssets("t:Scene Persistent");
return guids.Length > 0 ? AssetDatabase.GUIDToAssetPath(guids[0]) : null;
}
}
}

View File

@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 1b5ab9e5f153fb148817239307245e00
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:

View File

@@ -385,46 +385,101 @@ namespace BaseGames.Editor
MarkDirtyAndLog("Room Transition", go, report);
}
[MenuItem("BaseGames/Scene/Place/Room Camera", priority = 140)]
public static void PlaceRoomCamera()
[MenuItem("BaseGames/Scene/Place/Camera Area", priority = 140)]
public static void PlaceCameraArea() => PlaceCameraArea("CameraArea");
/// <param name="areaName">
/// 生成的 CameraArea GameObject 名称。
/// 子节点 AreaBoundary 和 TriggerZone 将以此为前缀命名(如 MyZone_AreaBoundary
/// </param>
/// <param name="parent">生成的 GameObject 所挂载的父节点(为 null 时放置于场景根节点)。</param>
public static void PlaceCameraArea(string areaName, Transform parent = null)
{
var report = new List<string>();
int undoGroup = Undo.GetCurrentGroup();
Undo.SetCurrentGroupName("Place Camera Area (+ TriggerZone)");
GameObject go = new GameObject("RoomCamera");
Undo.RegisterCreatedObjectUndo(go, "Place Room Camera");
go.transform.position = GetDropPosition();
Vector3 pos = GetDropPosition();
CinemachineCamera cinemachine = GetOrAddComponent<CinemachineCamera>(go);
RoomCamera roomCamera = GetOrAddComponent<RoomCamera>(go);
CinemachineConfiner2D confiner = GetOrAddComponent<CinemachineConfiner2D>(go);
// ── CameraArea ─────────────────────────────────────────────────────
GameObject go = new GameObject(areaName);
Undo.RegisterCreatedObjectUndo(go, "Place Camera Area");
go.transform.position = pos;
if (parent != null)
Undo.SetTransformParent(go.transform, parent, "Parent Camera Area");
// RoomBoundary child — defines the camera confinement area
Transform boundaryT = GetOrCreateChild(go.transform, "RoomBoundary");
CameraArea cameraArea = GetOrAddComponent<CameraArea>(go);
// AreaBoundary child — 提供 CinemachineConfiner2D 所需的限位多边形isTrigger = true仅作为相机约束边界
Transform boundaryT = GetOrCreateChild(go.transform, $"{areaName}_AreaBoundary");
PolygonCollider2D boundaryCollider = GetOrAddComponent<PolygonCollider2D>(boundaryT.gameObject);
boundaryCollider.isTrigger = true;
boundaryCollider.pathCount = 1;
// 顶点必须逆时针CCW排列Cinemachine 底层 Clipper 库对 CW 多边形area<0会取反 delta
// 导致向外膨胀而非向内收缩,相机将不受限制地跑出边界。
boundaryCollider.SetPath(0, new Vector2[]
{
new Vector2(-12f, -6f),
new Vector2(-12f, 6f),
new Vector2( 12f, 6f),
new Vector2( 12f, -6f),
new Vector2(-12f, -6f), // BL
new Vector2( 12f, -6f), // BR
new Vector2( 12f, 6f), // TR
new Vector2(-12f, 6f), // TL
});
RoomVisibleArea visibleArea = GetOrAddComponent<RoomVisibleArea>(boundaryT.gameObject);
AssignReference(roomCamera, "_visibleArea", visibleArea, report);
AssignReference(confiner, "m_BoundingShape2D", boundaryCollider, report);
AssignReference(cameraArea, "_confinerCollider", boundaryCollider, report);
// Disable any Camera and AudioListener added by Cinemachine
UnityEngine.Camera cam = go.GetComponent<UnityEngine.Camera>();
if (cam != null) cam.enabled = false;
AudioListener al = go.GetComponent<AudioListener>();
if (al != null) { Undo.DestroyObjectImmediate(al); }
// ── CameraTriggerZone配对─────────────────────────────────────────
GameObject zoneGo = new GameObject($"{areaName}_TriggerZone");
Undo.RegisterCreatedObjectUndo(zoneGo, "Place Camera Trigger Zone");
zoneGo.transform.position = pos;
SetLayer(zoneGo, "TriggerZone", report);
report.Add("将 Player/CameraFollowTarget Transform 拖入 CinemachineCamera.Follow 字段以跟随玩家(或使用 Room Camera Setup 工具批量赋值)。");
report.Add("调整 RoomBoundary PolygonCollider2D 顶点以匹配房间边界。");
PolygonCollider2D col = GetOrAddComponent<PolygonCollider2D>(zoneGo);
col.isTrigger = true;
// 默认矩形轮廓CCW与 AreaBoundary 默认尺寸一致(可在 Inspector 中编辑顶点调整为任意多边形)
col.SetPath(0, new Vector2[]
{
new Vector2(-12f, -6f), // BL
new Vector2( 12f, -6f), // BR
new Vector2( 12f, 6f), // TR
new Vector2(-12f, 6f), // TL
});
CameraTriggerZone zone = GetOrAddComponent<CameraTriggerZone>(zoneGo);
AssignReference(zone, "_targetArea", cameraArea, report);
// TriggerZone 归入 CameraArea 节点,方便统一调整与查找
Undo.SetTransformParent(zoneGo.transform, go.transform, "Parent TriggerZone to CameraArea");
zoneGo.transform.localPosition = Vector3.zero;
Undo.CollapseUndoOperations(undoGroup);
report.Add($"调整 {areaName}_AreaBoundary PolygonCollider2D 顶点以匹配区域边界。");
report.Add($"调整 {areaName}_TriggerZone PolygonCollider2D 顶点以匹配入口走廊(支持任意多边形)。");
// ── 自动关联到同场景 RoomController若其 _cameraArea 为空)────────
#if UNITY_6000_0_OR_NEWER
var roomControllers = Object.FindObjectsByType<RoomController>(FindObjectsSortMode.None);
#else
var roomControllers = Object.FindObjectsOfType<RoomController>();
#endif
bool autoAssigned = false;
foreach (var rc in roomControllers)
{
// 仅使用反射检查,避免每次都覆盖已绑定的引用
var fi = typeof(RoomController).GetField("_cameraArea",
System.Reflection.BindingFlags.Instance | System.Reflection.BindingFlags.NonPublic);
if (fi == null) continue;
if (fi.GetValue(rc) != null) continue;
Undo.RecordObject(rc, "Auto-assign CameraArea to RoomController");
fi.SetValue(rc, cameraArea);
EditorUtility.SetDirty(rc);
report.Add($"✅ 已自动将 {areaName} 关联到 {rc.gameObject.name}.RoomController._cameraArea。");
autoAssigned = true;
}
if (!autoAssigned)
report.Add("将此 CameraArea 拖入 RoomController._cameraArea 字段(未找到空 _cameraArea 的 RoomController。");
Selection.activeGameObject = go;
MarkDirtyAndLog("Room Camera", go, report);
MarkDirtyAndLog($"Camera Area (+ TriggerZone): {areaName}", go, report);
}
[MenuItem("BaseGames/Scene/Place/Ground Platform", priority = 150)]
@@ -534,28 +589,6 @@ namespace BaseGames.Editor
MarkDirtyAndLog("Nav Surface", go, report);
}
[MenuItem("BaseGames/Scene/Place/Camera Trigger Zone", priority = 180)]
public static void PlaceCameraTriggerZone()
{
var report = new List<string>();
GameObject go = new GameObject("CameraTriggerZone");
Undo.RegisterCreatedObjectUndo(go, "Place Camera Trigger Zone");
go.transform.position = GetDropPosition();
SetLayer(go, "TriggerZone", report);
BoxCollider2D col = GetOrAddComponent<BoxCollider2D>(go);
col.isTrigger = true;
col.size = new Vector2(2f, 2f);
GetOrAddComponent<CameraTriggerZone>(go);
report.Add("将目标 RoomCamera 拖入 CameraTriggerZone._targetCamera 字段。");
Selection.activeGameObject = go;
MarkDirtyAndLog("Camera Trigger Zone", go, report);
}
[MenuItem("BaseGames/Scene/Place/Obstacle (Static)", priority = 190)]
public static void PlaceObstacle()
{

View File

@@ -86,6 +86,29 @@ namespace BaseGames.Editor
CameraStateController cameraStateController = GetOrAddComponent<CameraStateController>(cameraStateGo);
CinemachineImpulseSource impulseSource = GetOrAddComponent<CinemachineImpulseSource>(cameraStateGo);
// 垂直窥视系统独立节点CameraStateController 持引用
GameObject lookSystemGo = GetOrCreateChild(camera, "CameraLookSystem").gameObject;
CameraLookSystem lookSystem = GetOrAddComponent<CameraLookSystem>(lookSystemGo);
GameObject vcamAGo = GetOrCreateChild(camera, "VCamA").gameObject;
CinemachineCamera vcamA = GetOrAddComponent<CinemachineCamera>(vcamAGo);
GetOrAddComponent<CinemachineConfiner2D>(vcamAGo);
GetOrAddComponent<CameraAxisLockExtension>(vcamAGo);
GetOrAddComponent<CameraAsymmetricDampingExtension>(vcamAGo);
GetOrAddComponent<CameraAdaptiveLookaheadExtension>(vcamAGo);
// CinemachinePositionComposerBody 阶段组件必须存在ConfigureSlot 依赖它写入所有相机跟随参数
var composerA = GetOrAddComponent<CinemachinePositionComposer>(vcamAGo);
ApplyComposerDefaults(composerA);
GameObject vcamBGo = GetOrCreateChild(camera, "VCamB").gameObject;
CinemachineCamera vcamB = GetOrAddComponent<CinemachineCamera>(vcamBGo);
GetOrAddComponent<CinemachineConfiner2D>(vcamBGo);
GetOrAddComponent<CameraAxisLockExtension>(vcamBGo);
GetOrAddComponent<CameraAsymmetricDampingExtension>(vcamBGo);
GetOrAddComponent<CameraAdaptiveLookaheadExtension>(vcamBGo);
var composerB = GetOrAddComponent<CinemachinePositionComposer>(vcamBGo);
ApplyComposerDefaults(composerB);
GameObject uiRootGo = GetOrCreateChild(ui, "UIRoot").gameObject;
UIManager uiManager = GetOrAddComponent<UIManager>(uiRootGo);
@@ -146,6 +169,11 @@ namespace BaseGames.Editor
AssignReference(cameraStateController, "_brain", brain);
AssignReference(cameraStateController, "_impulseSource", impulseSource);
AssignReference(cameraStateController, "_lookSystem", lookSystem);
AssignReference(cameraStateController, "_vcamA", vcamA);
AssignReference(cameraStateController, "_vcamB", vcamB);
AssignAsset(cameraStateController, "_onPlayerSpawned", report, true, "EVT_PlayerSpawned");
AssignAsset(cameraStateController, "_lensConfig", report, false, "CAM_LensConfig", "LensConfig", "CameraLensConfig");
AssignReference(uiManager, "_hudRoot", hudRootGo);
AssignReference(uiManager, "_pauseMenuRoot", pauseRootGo);
@@ -189,13 +217,12 @@ namespace BaseGames.Editor
// ── [Camera] ───────────────────────────────────────────────────
Transform cameraGroup = GetOrCreateChild(root.transform, "[Camera]");
GameObject roomCameraGo = GetOrCreateChild(cameraGroup, "RoomCamera").gameObject;
CinemachineCamera cinemachineCamera = GetOrAddComponent<CinemachineCamera>(roomCameraGo);
RoomCamera roomCamera = GetOrAddComponent<RoomCamera>(roomCameraGo);
CinemachineConfiner2D confiner = GetOrAddComponent<CinemachineConfiner2D>(roomCameraGo);
// CameraArea — 定义相机区域(限位 + 混合配置 + 可选专有 VCam
GameObject cameraAreaGo = GetOrCreateChild(cameraGroup, "CameraArea").gameObject;
CameraArea cameraArea = GetOrAddComponent<CameraArea>(cameraAreaGo);
// RoomBoundary — defines visible area and confiner polygon
Transform boundaryT = GetOrCreateChild(roomCameraGo.transform, "RoomBoundary");
// AreaBoundary — 提供 CinemachineConfiner2D 所需的限位多边形
Transform boundaryT = GetOrCreateChild(cameraAreaGo.transform, "AreaBoundary");
PolygonCollider2D boundaryCollider = GetOrAddComponent<PolygonCollider2D>(boundaryT.gameObject);
boundaryCollider.pathCount = 1;
boundaryCollider.SetPath(0, new Vector2[]
@@ -203,16 +230,8 @@ namespace BaseGames.Editor
new Vector2(-12f, -6f), new Vector2(-12f, 6f),
new Vector2( 12f, 6f), new Vector2( 12f, -6f),
});
RoomVisibleArea visibleArea = GetOrAddComponent<RoomVisibleArea>(boundaryT.gameObject);
AssignReference(roomCamera, "_visibleArea", visibleArea);
AssignReference(confiner, "m_BoundingShape2D", boundaryCollider);
// Disable stray Camera / AudioListener components sometimes added by Cinemachine
UnityEngine.Camera staleCam = roomCameraGo.GetComponent<UnityEngine.Camera>();
if (staleCam != null) staleCam.enabled = false;
AudioListener staleAl = roomCameraGo.GetComponent<AudioListener>();
if (staleAl != null) { Undo.DestroyObjectImmediate(staleAl); }
AssignReference(cameraArea, "_confinerCollider", boundaryCollider);
// ── [SpawnPoints] ──────────────────────────────────────────────
Transform spawnGroup = GetOrCreateChild(root.transform, "[SpawnPoints]");
@@ -250,7 +269,7 @@ namespace BaseGames.Editor
GetOrCreateChild(root.transform, "[Transitions]");
// ── Wire RoomController ────────────────────────────────────────
AssignReference(roomController, "_roomCamera", roomCamera);
AssignReference(roomController, "_cameraArea", cameraArea);
SerializedObject roomSO = new SerializedObject(roomController);
SerializedProperty spawnArrayProp = roomSO.FindProperty("_spawnPoints");
@@ -263,8 +282,7 @@ namespace BaseGames.Editor
// ── Report ─────────────────────────────────────────────────────
report.Add("在 RoomController._roomId 填写唯一房间 ID如 \"Room_Forest_01\")。");
report.Add("将 Player/CameraFollowTarget Transform 拖入 CinemachineCamera.Follow 字段以跟随玩家(或使用 BaseGames → Camera → Room Camera Setup 工具批量赋值)。");
report.Add("调整 RoomBoundary PolygonCollider2D 顶点以匹配实际房间大小。");
report.Add("调整 AreaBoundary PolygonCollider2D 顶点以匹配实际房间大小。");
report.Add("使用 Tile Palette 在 Ground Tilemap 上绘制地形,然后在 NavSurface Inspector 中点击 Bake。");
report.Add("[Transitions] 子节点下使用 BaseGames/Scene/Place/Room Transition 添加过渡点。");
@@ -561,6 +579,34 @@ namespace BaseGames.Editor
Debug.LogWarning($"[SceneScaffoldTools] {scaffoldName} 完成,但仍有 {report.Count} 项需要手工确认:\n- {string.Join("\n- ", report)}", root);
}
/// <summary>
/// 为 VCam 上的 CinemachinePositionComposer 写入初始默认展示参数。
/// 这些値与 <see cref="CameraArea"/> 的默认値一致,确保脆架生成后 Scene 预览即有正确感觉。
/// 运行时 CameraStateController.ConfigureSlot 会在每次 SwitchArea 时用 per-area 配置覆写。
/// </summary>
private static void ApplyComposerDefaults(CinemachinePositionComposer composer)
{
if (composer == null) return;
// 屏幕位置:玩家稍低于中心,上方有更多视野
var comp = composer.Composition;
comp.ScreenPosition = new Vector2(0f, -0.15f);
comp.DeadZone.Enabled = true;
comp.DeadZone.Size = new Vector2(0.15f, 0.05f);
composer.Composition = comp;
// 阻尼X 轻度缓冲Y = 0由 CameraAsymmetricDampingExtension 接管非对称 Y 阻尼)
composer.Damping = new Vector3(0.5f, 0f, 0f);
// Lookahead水平引领预测开启IgnoreY = true平台游戏 Y 轴不预测,避免起跳时镜头猛拉)
var lah = composer.Lookahead;
lah.Enabled = true;
lah.Time = 0.28f;
lah.Smoothing = 5f;
lah.IgnoreY = true;
composer.Lookahead = lah;
}
}
}