角色能力,存档
This commit is contained in:
243
Assets/_Game/Scripts/Editor/AbilityTypeDrawer.cs
Normal file
243
Assets/_Game/Scripts/Editor/AbilityTypeDrawer.cs
Normal file
@@ -0,0 +1,243 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using UnityEditor;
|
||||
using UnityEngine;
|
||||
using BaseGames.Player;
|
||||
|
||||
namespace BaseGames.Editor
|
||||
{
|
||||
/// <summary>
|
||||
/// AbilityType [Flags] uint 的 PropertyDrawer。
|
||||
/// 将枚举按能力类别分组,以可读的复选框网格呈现,替代默认的 MaskField。
|
||||
///
|
||||
/// 分组:
|
||||
/// 移动能力 — WallCling / WallJump / Dash / AirDash / DoubleJump / SuperJump / Swim / Dive
|
||||
/// 法术能力 — Spell1 / Spell2 / Spell3
|
||||
/// 形态能力 — SpiritForm / SpiritDash
|
||||
/// 战斗能力 — Parry / ChargeAttack / DownSlash
|
||||
/// 互动能力 — Interact / FastTravel
|
||||
/// 能力强化 — InvincibleDash
|
||||
/// </summary>
|
||||
[CustomPropertyDrawer(typeof(AbilityType))]
|
||||
public sealed class AbilityTypeDrawer : PropertyDrawer
|
||||
{
|
||||
// ── 分组定义 ──────────────────────────────────────────────────────────
|
||||
|
||||
private static readonly (string groupLabel, (AbilityType flag, string label)[] members)[] Groups =
|
||||
{
|
||||
("移动能力", new[]
|
||||
{
|
||||
(AbilityType.WallCling, "贴墙悬挂"),
|
||||
(AbilityType.WallJump, "墙跳"),
|
||||
(AbilityType.Dash, "冲刺"),
|
||||
(AbilityType.DoubleJump, "二段跳"),
|
||||
(AbilityType.SuperJump, "超级跳"),
|
||||
(AbilityType.Swim, "游泳"),
|
||||
(AbilityType.Dive, "下劈"),
|
||||
}),
|
||||
("法术能力", new[]
|
||||
{
|
||||
(AbilityType.Spell1, "法术槽 1"),
|
||||
(AbilityType.Spell2, "法术槽 2"),
|
||||
(AbilityType.Spell3, "法术槽 3"),
|
||||
}),
|
||||
("灵魄形态", new[]
|
||||
{
|
||||
(AbilityType.SpiritForm, "灵魄形态"),
|
||||
(AbilityType.SpiritDash, "灵魄冲刺"),
|
||||
}),
|
||||
("战斗能力", new[]
|
||||
{
|
||||
(AbilityType.Parry, "弹反"),
|
||||
(AbilityType.ChargeAttack, "蓄力攻击"),
|
||||
(AbilityType.DownSlash, "下斩"),
|
||||
}),
|
||||
("互动能力", new[]
|
||||
{
|
||||
(AbilityType.Interact, "互动"),
|
||||
(AbilityType.FastTravel, "快速旅行"),
|
||||
}),
|
||||
("能力强化", new[]
|
||||
{
|
||||
(AbilityType.InvincibleDash, "无敌冲刺"),
|
||||
}),
|
||||
};
|
||||
|
||||
// ── 布局常量 ──────────────────────────────────────────────────────────
|
||||
|
||||
private static readonly float RowH = EditorGUIUtility.singleLineHeight;
|
||||
private static readonly float GroupHeaderH = EditorGUIUtility.singleLineHeight + 4f;
|
||||
private static readonly float BtnRowH = EditorGUIUtility.singleLineHeight + 4f;
|
||||
private const float Spacing = 2f;
|
||||
private const float MinToggleW = 100f; // 每列最小宽度,用于动态计算列数
|
||||
private const int MaxColCount = 3; // 列数上限
|
||||
|
||||
// 缓存每个属性路径上次渲染时的列数,供 GetPropertyHeight 使用
|
||||
private static readonly Dictionary<string, int> _colsCache = new();
|
||||
|
||||
private static int ComputeCols(float availableWidth)
|
||||
=> Mathf.Clamp(Mathf.FloorToInt(availableWidth / MinToggleW), 1, MaxColCount);
|
||||
|
||||
// ── 高度计算 ──────────────────────────────────────────────────────────
|
||||
|
||||
public override float GetPropertyHeight(SerializedProperty property, GUIContent label)
|
||||
{
|
||||
float h = RowH + Spacing; // 属性标签行(含 None / All 按钮)
|
||||
|
||||
// 使用上次 OnGUI 缓存的列数;首次绘制前按视图宽度估算
|
||||
float viewW = EditorGUIUtility.currentViewWidth - EditorGUI.indentLevel * 15f - 18f;
|
||||
int cols = _colsCache.TryGetValue(property.propertyPath, out var cached)
|
||||
? cached
|
||||
: ComputeCols(viewW);
|
||||
|
||||
foreach (var (_, members) in Groups)
|
||||
{
|
||||
h += GroupHeaderH + Spacing;
|
||||
int rows = Mathf.CeilToInt((float)members.Length / cols);
|
||||
h += rows * (RowH + Spacing);
|
||||
}
|
||||
|
||||
return h + 4f;
|
||||
}
|
||||
|
||||
// ── 绘制 ──────────────────────────────────────────────────────────────
|
||||
|
||||
public override void OnGUI(Rect position, SerializedProperty property, GUIContent label)
|
||||
{
|
||||
EditorGUI.BeginProperty(position, label, property);
|
||||
|
||||
float y = position.y;
|
||||
float x = position.x;
|
||||
float w = position.width;
|
||||
int cols = ComputeCols(w);
|
||||
_colsCache[property.propertyPath] = cols;
|
||||
|
||||
uint current = (uint)property.longValue;
|
||||
bool changed = false;
|
||||
|
||||
// ── 统计已启用数量 ────────────────────────────────────────────────
|
||||
int enabledCount = 0, totalCount = 0;
|
||||
foreach (var (_, mems) in Groups)
|
||||
foreach (var (flag, _) in mems)
|
||||
{
|
||||
totalCount++;
|
||||
if ((current & (uint)flag) != 0) enabledCount++;
|
||||
}
|
||||
|
||||
// ── 标签行 + None / All 快捷按钮 + 数量提示 ──────────────────────
|
||||
Rect labelRect = new Rect(x, y, EditorGUIUtility.labelWidth, RowH);
|
||||
Rect btnNoneRect = new Rect(x + EditorGUIUtility.labelWidth, y, 50f, RowH);
|
||||
Rect btnAllRect = new Rect(btnNoneRect.xMax + 4f, y, 50f, RowH);
|
||||
Rect countRect = new Rect(btnAllRect.xMax + 8f, y, w - (btnAllRect.xMax + 8f - x), RowH);
|
||||
|
||||
EditorGUI.LabelField(labelRect, label);
|
||||
|
||||
if (GUI.Button(btnNoneRect, "None"))
|
||||
{
|
||||
current = 0;
|
||||
changed = true;
|
||||
}
|
||||
if (GUI.Button(btnAllRect, "All"))
|
||||
{
|
||||
uint all = 0;
|
||||
foreach (var (_, mems) in Groups)
|
||||
foreach (var (flag, _) in mems)
|
||||
all |= (uint)flag;
|
||||
current = all;
|
||||
changed = true;
|
||||
}
|
||||
|
||||
var countStyle = new GUIStyle(EditorStyles.miniLabel)
|
||||
{
|
||||
normal = { textColor = enabledCount > 0
|
||||
? new Color(0.55f, 0.85f, 0.55f)
|
||||
: new Color(0.6f, 0.6f, 0.6f) }
|
||||
};
|
||||
EditorGUI.LabelField(countRect, $"({enabledCount} / {totalCount} 项已解锁)", countStyle);
|
||||
y += RowH + Spacing;
|
||||
|
||||
// ── 各分组 ────────────────────────────────────────────────────────
|
||||
foreach (var (groupLabel, members) in Groups)
|
||||
{
|
||||
// 分组标题(含组级全选/清空按钮)
|
||||
Rect groupRect = new Rect(x, y, w, GroupHeaderH);
|
||||
uint newCurrent = DrawGroupHeader(groupRect, groupLabel, members, current);
|
||||
if (newCurrent != current) { current = newCurrent; changed = true; }
|
||||
y += GroupHeaderH + Spacing;
|
||||
|
||||
// 复选框网格(列宽均分可用宽度,列数随窗口大小自动调整)
|
||||
float toggleW = w / cols;
|
||||
for (int i = 0; i < members.Length; i++)
|
||||
{
|
||||
int col = i % cols;
|
||||
|
||||
if (col == 0 && i > 0)
|
||||
y += RowH + Spacing; // 换行
|
||||
|
||||
Rect togRect = new Rect(x + col * toggleW, y, toggleW, RowH);
|
||||
|
||||
var (flag, toggleLabel) = members[i];
|
||||
bool isOn = (current & (uint)flag) != 0;
|
||||
bool newOn = GUI.Toggle(togRect, isOn, toggleLabel, EditorStyles.toggle);
|
||||
|
||||
if (newOn != isOn)
|
||||
{
|
||||
if (newOn) current |= (uint)flag;
|
||||
else current &= ~(uint)flag;
|
||||
changed = true;
|
||||
}
|
||||
|
||||
// 若是最后一个且在本行
|
||||
if (i == members.Length - 1)
|
||||
y += RowH + Spacing;
|
||||
}
|
||||
}
|
||||
|
||||
if (changed)
|
||||
{
|
||||
property.longValue = (long)(uint)current;
|
||||
property.serializedObject.ApplyModifiedProperties();
|
||||
}
|
||||
|
||||
EditorGUI.EndProperty();
|
||||
}
|
||||
|
||||
// ── 辅助:分组标题绘制(含组级全选/清空按钮与已选数量)────────────────
|
||||
|
||||
private static uint DrawGroupHeader(Rect rect, string text,
|
||||
(AbilityType flag, string label)[] members, uint current)
|
||||
{
|
||||
// 计算本组已选数量
|
||||
int groupEnabled = 0;
|
||||
foreach (var (flag, _) in members)
|
||||
if ((current & (uint)flag) != 0) groupEnabled++;
|
||||
|
||||
Color old = GUI.backgroundColor;
|
||||
GUI.backgroundColor = new Color(0.25f, 0.25f, 0.28f, 1f);
|
||||
GUI.Box(new Rect(rect.x, rect.y, rect.width, rect.height - 2f), GUIContent.none, EditorStyles.helpBox);
|
||||
GUI.backgroundColor = old;
|
||||
|
||||
// 分组标签(含已选/总数)
|
||||
Rect labelRect = new Rect(rect.x + 4f, rect.y + 1f, rect.width - 86f, RowH);
|
||||
EditorGUI.LabelField(labelRect,
|
||||
$"{text} {groupEnabled}/{members.Length}",
|
||||
EditorStyles.boldLabel);
|
||||
|
||||
// 组级按钮:全选 / 清空
|
||||
const float BtnW = 36f;
|
||||
Rect btnAll = new Rect(rect.xMax - BtnW * 2 - 6f, rect.y + 1f, BtnW, RowH);
|
||||
Rect btnNone = new Rect(rect.xMax - BtnW - 4f, rect.y + 1f, BtnW, RowH);
|
||||
|
||||
if (GUI.Button(btnAll, "全选", EditorStyles.miniButton))
|
||||
foreach (var (flag, _) in members) current |= (uint)flag;
|
||||
if (GUI.Button(btnNone, "清空", EditorStyles.miniButton))
|
||||
foreach (var (flag, _) in members) current &= ~(uint)flag;
|
||||
|
||||
// 底部分割线
|
||||
EditorGUI.DrawRect(new Rect(rect.x, rect.yMax - 2f, rect.width, 1f),
|
||||
new Color(0.45f, 0.45f, 0.50f, 0.8f));
|
||||
|
||||
return current;
|
||||
}
|
||||
}
|
||||
}
|
||||
11
Assets/_Game/Scripts/Editor/AbilityTypeDrawer.cs.meta
Normal file
11
Assets/_Game/Scripts/Editor/AbilityTypeDrawer.cs.meta
Normal file
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 60df999cbd27df94eb8ffd215c336b27
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -27,6 +27,7 @@
|
||||
"Kybernetik.Animancer",
|
||||
"BaseGames.Animation",
|
||||
"BaseGames.Equipment",
|
||||
"BaseGames.Parry",
|
||||
"BaseGames.Skills",
|
||||
"BaseGames.World.Map",
|
||||
"BaseGames.EventChain",
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
using System.Reflection;
|
||||
using UnityEditor;
|
||||
using UnityEditor.SceneManagement;
|
||||
using UnityEngine;
|
||||
using Unity.Cinemachine;
|
||||
using BaseGames.Camera;
|
||||
@@ -17,7 +18,7 @@ namespace BaseGames.Editor
|
||||
/// FOV 优先级(降序):
|
||||
/// 专有 DedicatedCamera.Lens.FieldOfView
|
||||
/// → CameraLensConfigSO.fieldOfView(单一来源,无跨场景依赖)
|
||||
/// → CameraStateController._vcamA(Persistent 场景已加载时)
|
||||
/// → CameraStateController 活动 VCam 的 FieldOfView(编辑器备用)
|
||||
/// → Camera.main.fieldOfView
|
||||
/// → 60f(默认)
|
||||
/// </summary>
|
||||
@@ -41,6 +42,7 @@ namespace BaseGames.Editor
|
||||
private bool _foldFollow = true;
|
||||
private bool _foldLens = false;
|
||||
private bool _foldCamera = false;
|
||||
private bool _foldNoise = false;
|
||||
private bool _foldTools = false;
|
||||
|
||||
// ── 折叠标题样式缓存(深色背景 + 白色文字)────────────────────────────
|
||||
@@ -78,7 +80,7 @@ namespace BaseGames.Editor
|
||||
EditorGUILayout.PropertyField(confinerProp, new GUIContent("Confiner Collider"));
|
||||
}
|
||||
if (!confinerOk)
|
||||
EditorGUILayout.HelpBox("必须绑定子节点 PolygonCollider2D(AreaBoundary),否则 Cinemachine 无法限位。", MessageType.Error);
|
||||
EditorGUILayout.HelpBox("必须绑定子节点 BoxCollider(AreaBoundary),否则 CinemachineConfiner3D 无法限位。", MessageType.Error);
|
||||
|
||||
EditorGUILayout.PropertyField(serializedObject.FindProperty("_visibleBounds"), new GUIContent("Visible Bounds(本地坐标)"));
|
||||
}
|
||||
@@ -86,11 +88,11 @@ namespace BaseGames.Editor
|
||||
|
||||
EditorGUILayout.Space(2f);
|
||||
|
||||
// ── 跟随参数覆盖 ─────────────────────────────────────────────────
|
||||
// ── 跟随行为参数 ─────────────────────────────────────────────────
|
||||
var overrideProp = serializedObject.FindProperty("_overrideFollowBehaviour");
|
||||
bool overrides = overrideProp.boolValue;
|
||||
_foldFollow = DrawFoldoutHeader(
|
||||
overrides ? "跟随参数覆盖 ●" : "跟随参数覆盖 ○ (使用全局默认)", _foldFollow);
|
||||
overrides ? "相机行为参数 ●" : "相机行为参数 ○ (使用 VCam 默认参数)", _foldFollow);
|
||||
if (_foldFollow)
|
||||
{
|
||||
using (new EditorGUI.IndentLevelScope())
|
||||
@@ -108,6 +110,25 @@ namespace BaseGames.Editor
|
||||
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);
|
||||
EditorGUILayout.LabelField("方向感知水平偏置", EditorStyles.miniLabel);
|
||||
var overrideFacingProp = serializedObject.FindProperty("_overrideFacingBias");
|
||||
EditorGUILayout.PropertyField(overrideFacingProp, new GUIContent("Override Facing Bias",
|
||||
"开启后可为此区域单独设置偏置量;关闭则使用 VCam 扩展组件的默认值。"));
|
||||
if (overrideFacingProp.boolValue)
|
||||
{
|
||||
using (new EditorGUI.IndentLevelScope())
|
||||
{
|
||||
var biasProp = serializedObject.FindProperty("_facingBiasOverride");
|
||||
EditorGUILayout.PropertyField(biasProp, new GUIContent("Facing Bias (units)",
|
||||
"方向感知偏置量(世界单位)。0 = 禁用此区域的方向偏置。\n" +
|
||||
"较窄走廊建议设 0,防止相机超出 Confiner 边界。"));
|
||||
if (biasProp.floatValue < 0.01f)
|
||||
EditorGUILayout.HelpBox("设为 0 将禁用此区域的方向感知偏置。", MessageType.Info);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -128,8 +149,8 @@ namespace BaseGames.Editor
|
||||
|
||||
EditorGUILayout.Space(2f);
|
||||
|
||||
// ── 专有相机(可选) ──────────────────────────────────────────────
|
||||
_foldCamera = DrawFoldoutHeader("专有相机(可选)", _foldCamera);
|
||||
// ── 虚拟相机 ──────────────────────────────────────────────
|
||||
_foldCamera = DrawFoldoutHeader("虚拟相机", _foldCamera);
|
||||
if (_foldCamera)
|
||||
{
|
||||
using (new EditorGUI.IndentLevelScope())
|
||||
@@ -140,7 +161,29 @@ namespace BaseGames.Editor
|
||||
}
|
||||
|
||||
EditorGUILayout.Space(2f);
|
||||
// ── 相机噪音(氛围震动) ────────────────────────────────────────
|
||||
_foldNoise = DrawFoldoutHeader("相机噪音(氛围震动)", _foldNoise);
|
||||
if (_foldNoise)
|
||||
{
|
||||
using (new EditorGUI.IndentLevelScope())
|
||||
{
|
||||
var noiseProp = serializedObject.FindProperty("_noiseProfile");
|
||||
EditorGUILayout.PropertyField(noiseProp, new GUIContent("Noise Profile",
|
||||
"氛围震动配置(Noise Settings 资产),留空则禁用噪音。"));
|
||||
if (noiseProp.objectReferenceValue != null)
|
||||
{
|
||||
EditorGUILayout.PropertyField(serializedObject.FindProperty("_noiseAmplitude"),
|
||||
new GUIContent("Amplitude Gain", "振幅增益(0 = 无震动,推荐 0.2 ~ 0.8)。"));
|
||||
EditorGUILayout.PropertyField(serializedObject.FindProperty("_noiseFrequency"),
|
||||
new GUIContent("Frequency Gain", "频率增益(1 = 资产原始频率)。"));
|
||||
}
|
||||
EditorGUILayout.HelpBox(
|
||||
"专属 VCam 已包含 CinemachineBasicMultiChannelPerlin 组件(使用工具创建时自动附加)。",
|
||||
MessageType.Info);
|
||||
}
|
||||
}
|
||||
|
||||
EditorGUILayout.Space(2f);
|
||||
// ── 可视区域工具 ──────────────────────────────────────────────────
|
||||
_foldTools = DrawFoldoutHeader("可视区域工具", _foldTools);
|
||||
if (_foldTools)
|
||||
@@ -247,7 +290,7 @@ namespace BaseGames.Editor
|
||||
|
||||
EditorGUILayout.Space(4f);
|
||||
DrawLegend("■ 黄色矩形(Scene 视图)", kVisibleOutline, "可视区域 — 摄像机视口永不超出此范围");
|
||||
DrawLegend("■ 蓝色多边形(Scene 视图)", kConfinerLine, "限位区域 — CinemachineConfiner2D 的运动边界");
|
||||
DrawLegend("■ 蓝色矩形(Scene 视图)", kConfinerLine, "限位区域 — CinemachineConfiner3D 的运动边界");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -319,7 +362,7 @@ namespace BaseGames.Editor
|
||||
{
|
||||
Undo.RecordObject(area, "Edit Visible Bounds");
|
||||
if (area.ConfinerCollider != null)
|
||||
Undo.RecordObject(area.ConfinerCollider, "Sync Confiner");
|
||||
Undo.RecordObject(area.ConfinerCollider, "Sync Confiner"); // BoxCollider
|
||||
|
||||
// 世界坐标 → 本地坐标,存入序列化字段
|
||||
boundsP.rectValue = new Rect(r.x - areaPos.x, r.y - areaPos.y, r.width, r.height);
|
||||
@@ -380,17 +423,21 @@ namespace BaseGames.Editor
|
||||
|
||||
private static void DrawConfinerGizmo(CameraArea area)
|
||||
{
|
||||
var poly = area.ConfinerCollider;
|
||||
if (poly == null || poly.pathCount == 0) return;
|
||||
var box = area.ConfinerCollider;
|
||||
if (box == null) return;
|
||||
|
||||
int ptCount = poly.GetTotalPointCount();
|
||||
if (ptCount < 3) return;
|
||||
// 将 BoxCollider 的 XY 范围投影到 Scene 视图(忽略 Z,2D 俯视)
|
||||
Vector3 centerWorld = box.transform.TransformPoint(box.center);
|
||||
float hw = box.size.x * 0.5f;
|
||||
float hh = box.size.y * 0.5f;
|
||||
|
||||
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]);
|
||||
var pts3 = new Vector3[]
|
||||
{
|
||||
new Vector3(centerWorld.x - hw, centerWorld.y - hh, 0f), // BL
|
||||
new Vector3(centerWorld.x + hw, centerWorld.y - hh, 0f), // BR
|
||||
new Vector3(centerWorld.x + hw, centerWorld.y + hh, 0f), // TR
|
||||
new Vector3(centerWorld.x - hw, centerWorld.y + hh, 0f), // TL
|
||||
};
|
||||
|
||||
DrawPolyGizmo(pts3, kConfinerFill, kConfinerLine, 2.0f);
|
||||
|
||||
@@ -510,17 +557,16 @@ namespace BaseGames.Editor
|
||||
|
||||
internal static void SyncConfinerFromVisibleBounds(CameraArea area, float vFOV, float aspect)
|
||||
{
|
||||
var poly = area.ConfinerCollider;
|
||||
if (poly == null)
|
||||
var box = area.ConfinerCollider;
|
||||
if (box == null)
|
||||
{
|
||||
Debug.LogWarning($"[CameraAreaEditor] {area.name}:ConfinerCollider 未绑定,无法同步。");
|
||||
return;
|
||||
}
|
||||
|
||||
// VisibleBounds 已含 transform.position,为世界坐标。
|
||||
// 限位多边形 = 相机中心运动范围 = VisibleBounds 向内收缩视口半尺寸。
|
||||
// 运行时 ConfigureSlot 设置 OversizeWindow.MaxWindowSize ≈ 0,
|
||||
// 阻止 Cinemachine 再次收缩此多边形,确保边界精确匹配可视区域。
|
||||
// 限位体积 = 相机中心运动范围 = VisibleBounds 向内收缩视口半尺寸。
|
||||
// Confiner3D 以 BoxCollider 直接约束相机 3D 位置,无需 OversizeWindow 补偿。
|
||||
Rect visible = area.VisibleBounds; // 世界坐标
|
||||
float depth = area.CameraDepth;
|
||||
float halfH = depth * Mathf.Tan(vFOV * 0.5f * Mathf.Deg2Rad);
|
||||
@@ -532,25 +578,21 @@ namespace BaseGames.Editor
|
||||
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; }
|
||||
// BoxCollider 允许 size = 0,Confiner3D 不需要最小尺寸
|
||||
if (xMin > xMax) { float cx = visible.center.x; xMin = cx; xMax = cx; }
|
||||
if (yMin > yMax) { float cy = visible.center.y; yMin = cy; yMax = cy; }
|
||||
|
||||
Transform polyT = poly.transform;
|
||||
Vector2 Local(Vector3 w) => polyT.InverseTransformPoint(w);
|
||||
// BoxCollider center 以其 Transform 本地坐标表示
|
||||
// 相机在世界 Z = -depth,AreaBoundary 节点在 Z = 0,所以 center.z = -depth
|
||||
Transform boxT = box.transform;
|
||||
Vector3 centerWorld = new Vector3((xMin + xMax) * 0.5f, (yMin + yMax) * 0.5f, 0f);
|
||||
Vector3 centerLocal = boxT.InverseTransformPoint(centerWorld);
|
||||
centerLocal.z = -depth;
|
||||
|
||||
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);
|
||||
Undo.RecordObject(box, "Sync Confiner from Visible Bounds");
|
||||
box.center = centerLocal;
|
||||
box.size = new Vector3(xMax - xMin, yMax - yMin, 1f);
|
||||
EditorUtility.SetDirty(box);
|
||||
|
||||
// 记录本次同步所用的 FOV,供编辑器过期检测使用
|
||||
var areaSO = new SerializedObject(area);
|
||||
@@ -575,6 +617,180 @@ namespace BaseGames.Editor
|
||||
internal static void SyncConfinerAuto(CameraArea area) =>
|
||||
SyncConfinerFromVisibleBounds(area, GetFOV(area), GetAspect());
|
||||
|
||||
// ── VCam 组件顺序检测 / 修复 ─────────────────────────────────────────
|
||||
|
||||
// 必须在 CinemachineConfiner3D 之前运行的扩展类型(修改 RawPosition,需被 Confiner 裁剪)
|
||||
private static readonly System.Type[] s_MustBeforeConfiner =
|
||||
{
|
||||
typeof(CameraAsymmetricDampingExtension),
|
||||
typeof(CameraFallBiasExtension),
|
||||
typeof(CameraFacingBiasExtension),
|
||||
typeof(CameraAdaptiveLookaheadExtension),
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// 检查 <paramref name="vcam"/> 的扩展组件顺序是否正确。
|
||||
/// 返回问题描述字符串;若无问题则返回 <c>null</c>。
|
||||
/// </summary>
|
||||
internal static string CheckVCamExtensionOrderIssue(CinemachineCamera vcam)
|
||||
{
|
||||
if (vcam == null) return null;
|
||||
|
||||
var confiner = vcam.GetComponent<CinemachineConfiner3D>();
|
||||
if (confiner == null) return "缺少 CinemachineConfiner3D 组件";
|
||||
|
||||
Component[] comps = vcam.GetComponents<Component>();
|
||||
int confinerIdx = System.Array.IndexOf(comps, confiner);
|
||||
var sb = new System.Text.StringBuilder();
|
||||
|
||||
foreach (var t in s_MustBeforeConfiner)
|
||||
{
|
||||
var comp = vcam.GetComponent(t);
|
||||
if (comp == null) continue;
|
||||
if (System.Array.IndexOf(comps, comp) > confinerIdx)
|
||||
sb.AppendLine($" · {t.Name} 在 Confiner 之后(偏置绕过限位 → 相机逃出区域)");
|
||||
}
|
||||
|
||||
var axisLock = vcam.GetComponent<CameraAxisLockExtension>();
|
||||
if (axisLock != null && System.Array.IndexOf(comps, axisLock) < confinerIdx)
|
||||
sb.AppendLine(" · CameraAxisLockExtension 在 Confiner 之前(轴向锁定会被 Confiner 覆盖失效)");
|
||||
|
||||
return sb.Length > 0 ? sb.ToString().TrimEnd() : null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 自动修正 <paramref name="vcam"/> 上扩展组件的挂载顺序:
|
||||
/// 将偏置扩展移到 <see cref="CinemachineConfiner3D"/> 之前,
|
||||
/// 将 <see cref="CameraAxisLockExtension"/> 移到之后。
|
||||
/// </summary>
|
||||
internal static void FixVCamExtensionOrder(CinemachineCamera vcam)
|
||||
{
|
||||
if (vcam == null) return;
|
||||
|
||||
var confiner = vcam.GetComponent<CinemachineConfiner3D>();
|
||||
if (confiner == null)
|
||||
{
|
||||
Debug.LogWarning($"[CameraAreaEditor] {vcam.name} 缺少 CinemachineConfiner3D,无法修正顺序。");
|
||||
return;
|
||||
}
|
||||
|
||||
Undo.RegisterCompleteObjectUndo(vcam.gameObject, "Fix VCam Extension Order");
|
||||
|
||||
// 将偏置扩展逐个移到 Confiner 之前
|
||||
foreach (var t in s_MustBeforeConfiner)
|
||||
{
|
||||
var comp = vcam.GetComponent(t);
|
||||
if (comp == null) continue;
|
||||
|
||||
// 反复上移,直到位于 Confiner 之前(最多 30 步防死循环)
|
||||
for (int guard = 0; guard < 30; guard++)
|
||||
{
|
||||
Component[] comps = vcam.GetComponents<Component>();
|
||||
int compIdx = System.Array.IndexOf(comps, comp);
|
||||
int confinerIdx = System.Array.IndexOf(comps, confiner);
|
||||
if (compIdx < confinerIdx) break;
|
||||
UnityEditorInternal.ComponentUtility.MoveComponentUp(comp);
|
||||
}
|
||||
}
|
||||
|
||||
// 将 AxisLock 移到 Confiner 之后
|
||||
var axisLock = vcam.GetComponent<CameraAxisLockExtension>();
|
||||
if (axisLock != null)
|
||||
{
|
||||
for (int guard = 0; guard < 30; guard++)
|
||||
{
|
||||
Component[] comps = vcam.GetComponents<Component>();
|
||||
int axisIdx = System.Array.IndexOf(comps, axisLock);
|
||||
int confinerIdx = System.Array.IndexOf(comps, confiner);
|
||||
if (axisIdx > confinerIdx) break;
|
||||
UnityEditorInternal.ComponentUtility.MoveComponentDown(axisLock);
|
||||
}
|
||||
}
|
||||
|
||||
EditorUtility.SetDirty(vcam.gameObject);
|
||||
Debug.Log($"[CameraAreaEditor] 已修正 {vcam.name} 的扩展组件顺序。");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 为指定 <paramref name="area"/> 创建子节点专有 VCam,附加所有必要组件并绑定到
|
||||
/// <c>_dedicatedCamera</c>。若已有专有 VCam 则直接返回现有实例,不重复创建。
|
||||
/// </summary>
|
||||
internal static CinemachineCamera CreateDedicatedVCamForArea(CameraArea area)
|
||||
{
|
||||
if (area == null) return null;
|
||||
|
||||
if (area.DedicatedCamera != null)
|
||||
{
|
||||
Debug.LogWarning(
|
||||
$"[CameraAreaEditor] {area.name} 已有专有 VCam:{area.DedicatedCamera.name},跳过创建。");
|
||||
return area.DedicatedCamera;
|
||||
}
|
||||
|
||||
string vcamName = $"VCam_{area.gameObject.name}";
|
||||
var vcamGO = new GameObject(vcamName);
|
||||
Undo.RegisterCreatedObjectUndo(vcamGO, "Create Dedicated VCam");
|
||||
vcamGO.transform.SetParent(area.transform);
|
||||
vcamGO.transform.localPosition = Vector3.zero;
|
||||
|
||||
// ── CinemachineCamera ─────────────────────────────────────────────
|
||||
var vcam = vcamGO.AddComponent<CinemachineCamera>();
|
||||
vcam.Priority = 0; // 非激活;由 CameraStateController.ActivateDedicated 提升优先级
|
||||
|
||||
// ── Body:位置合成器 ───────────────────────────────────────────────
|
||||
var composer = vcamGO.AddComponent<CinemachinePositionComposer>();
|
||||
|
||||
// ── 扩展组件(管线顺序:Body 阶段偏置扩展 → PostBody Confiner → 独立 AxisLock → Noise)
|
||||
// AsymmetricDamping → FallBias → FacingBias → AdaptiveLookahead
|
||||
// vcamGO.AddComponent<CameraAsymmetricDampingExtension>();
|
||||
// vcamGO.AddComponent<CameraFallBiasExtension>();
|
||||
// vcamGO.AddComponent<CameraFacingBiasExtension>();
|
||||
// vcamGO.AddComponent<CameraAdaptiveLookaheadExtension>();
|
||||
vcamGO.AddComponent<CinemachinePixelPerfect>();
|
||||
// ── 限位 Confiner(PostBody 阶段,须在位置偏置扩展之后)────────────
|
||||
var confiner3d = vcamGO.AddComponent<CinemachineConfiner3D>();
|
||||
if (area.ConfinerCollider != null)
|
||||
confiner3d.BoundingVolume = area.ConfinerCollider;
|
||||
|
||||
// ── 轴向约束(独立,PostBody)────────────────────────────────────
|
||||
vcamGO.AddComponent<CameraAxisLockExtension>();
|
||||
|
||||
// ── 噪音(Noise 阶段,须排在 Confiner / AxisLock 之后)───────────
|
||||
vcamGO.AddComponent<CinemachineBasicMultiChannelPerlin>();
|
||||
|
||||
// ── 应用镜头参数:FOV + CameraDistance + Transform Z ─────────────
|
||||
// 与 CameraStateController.ApplyLensToVcam 逻辑保持一致:
|
||||
// 1. Lens.FieldOfView
|
||||
// 2. composer.CameraDistance(控制运行时真实 Z 距离,否则管线会覆盖)
|
||||
// 3. transform.localPosition.z(编辑器预览与运行时保持一致)
|
||||
float fov = area.LensConfig != null ? area.LensConfig.fieldOfView : 60f;
|
||||
float depth = area.CameraDepth > 0f ? area.CameraDepth : 10f;
|
||||
|
||||
var lens = vcam.Lens;
|
||||
lens.FieldOfView = fov;
|
||||
vcam.Lens = lens;
|
||||
|
||||
composer.CameraDistance = depth;
|
||||
|
||||
var localPos = vcamGO.transform.localPosition;
|
||||
localPos.z = -depth;
|
||||
vcamGO.transform.localPosition = localPos;
|
||||
|
||||
// ── 写入 CameraArea._dedicatedCamera(及默认优先级)───────────────
|
||||
var so = new SerializedObject(area);
|
||||
so.Update();
|
||||
so.FindProperty("_dedicatedCamera").objectReferenceValue = vcam;
|
||||
var priProp = so.FindProperty("_dedicatedPriority");
|
||||
if (priProp.intValue == 0) priProp.intValue = 20;
|
||||
so.ApplyModifiedProperties();
|
||||
EditorUtility.SetDirty(area);
|
||||
EditorSceneManager.MarkSceneDirty(area.gameObject.scene);
|
||||
|
||||
EditorGUIUtility.PingObject(vcamGO);
|
||||
Debug.Log($"[CameraAreaEditor] 已为 {area.name} 创建专有 VCam:{vcamName}" +
|
||||
$" FOV={fov:F1}° Depth={depth:F1}");
|
||||
return vcam;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 与 <see cref="SyncConfinerFromVisibleBounds"/> 逻辑相同,但不记录 Undo、不输出日志。
|
||||
/// 供拖拽 Handle 时每帧调用,避免 Undo 堆积和 Console 刷屏。
|
||||
@@ -582,8 +798,8 @@ namespace BaseGames.Editor
|
||||
/// </summary>
|
||||
private static void SyncConfinerQuiet(CameraArea area, float vFOV, float aspect)
|
||||
{
|
||||
var poly = area.ConfinerCollider;
|
||||
if (poly == null) return;
|
||||
var box = area.ConfinerCollider;
|
||||
if (box == null) return;
|
||||
|
||||
// VisibleBounds 已含 transform.position,为世界坐标。
|
||||
Rect visible = area.VisibleBounds;
|
||||
@@ -596,22 +812,17 @@ namespace BaseGames.Editor
|
||||
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; }
|
||||
if (xMin > xMax) { float cx = visible.center.x; xMin = cx; xMax = cx; }
|
||||
if (yMin > yMax) { float cy = visible.center.y; yMin = cy; yMax = cy; }
|
||||
|
||||
Transform polyT = poly.transform;
|
||||
Vector2 Local(Vector3 w) => polyT.InverseTransformPoint(w);
|
||||
Transform boxT = box.transform;
|
||||
Vector3 centerWorld = new Vector3((xMin + xMax) * 0.5f, (yMin + yMax) * 0.5f, 0f);
|
||||
Vector3 centerLocal = boxT.InverseTransformPoint(centerWorld);
|
||||
centerLocal.z = -depth;
|
||||
|
||||
// CCW 顺序:BL → BR → TR → TL(同 SyncConfinerFromVisibleBounds)
|
||||
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);
|
||||
box.center = centerLocal;
|
||||
box.size = new Vector3(xMax - xMin, yMax - yMin, 1f);
|
||||
EditorUtility.SetDirty(box);
|
||||
}
|
||||
|
||||
/// <summary>在 Scene 视图左上角绘制叠加信息面板(屏幕空间)。</summary>
|
||||
@@ -663,7 +874,7 @@ namespace BaseGames.Editor
|
||||
// ══ 工具方法 ══════════════════════════════════════════════════════════
|
||||
|
||||
/// <summary>
|
||||
/// 获取用于透视计算的 FOV(优先级:专有 VCam → 全局 VCamA → Camera.main → 60f)。
|
||||
/// 获取用于透视计算的 FOV(优先级:专属 VCam → CameraLensConfigSO → Camera.main → 60f)。
|
||||
/// </summary>
|
||||
private static float GetFOV(CameraArea area)
|
||||
{
|
||||
@@ -675,7 +886,7 @@ namespace BaseGames.Editor
|
||||
if (area.LensConfig != null)
|
||||
return area.LensConfig.fieldOfView;
|
||||
|
||||
// 3. Persistent 场景已加载时,实时读取全局 VCamA(兆底)
|
||||
// 3. CameraStateController 存在时,通过 LensConfig 读取 FOV(备用底线)
|
||||
#pragma warning disable UNT0023 // FindObjectOfType 在编辑器工具中可接受
|
||||
var ctrl = Object.FindObjectOfType<CameraStateController>();
|
||||
#pragma warning restore UNT0023
|
||||
|
||||
@@ -17,8 +17,9 @@ namespace BaseGames.Editor
|
||||
///
|
||||
/// 新格式:
|
||||
/// [新 CameraArea GO](CameraArea 组件,_visibleBounds = 本地 Rect)
|
||||
/// ├─ AreaBoundary(PolygonCollider2D,isTrigger=true,对应旧 Confiner)
|
||||
/// └─ TriggerZone(CameraTriggerZone + PolygonCollider2D,对应旧 TriggerRegion)
|
||||
/// ├─ AreaBoundary(BoxCollider,对应旧 Confiner)
|
||||
/// ├─ TriggerZone(CameraTriggerZone + PolygonCollider2D,对应旧 TriggerRegion)
|
||||
/// └─ VCam_xxx(CinemachineCamera + 所有扩展组件,专属虚拟相机)
|
||||
///
|
||||
/// 菜单:BaseGames → Camera → 相机区域迁移工具
|
||||
/// </summary>
|
||||
@@ -32,9 +33,10 @@ namespace BaseGames.Editor
|
||||
}
|
||||
|
||||
// ── 设置字段 ──────────────────────────────────────────────────────────
|
||||
private Transform _sourcesParent; // 旧 Zone_xxx 的父节点(通常名为 Zones)
|
||||
private Transform _targetParent; // 新对象放置位置(留空 = 与旧区域同级)
|
||||
private CameraLensConfigSO _lensConfig; // 绑定到新 CameraArea._lensConfig
|
||||
private Transform _sourcesParent; // 旧 Zone_xxx 的父节点(通常名为 Zones)
|
||||
private Transform _targetParent; // 新对象放置位置(留空 = 与旧区域同级)
|
||||
private CameraLensConfigSO _lensConfig; // 绑定到新 CameraArea._lensConfig
|
||||
private bool _createDedicatedVCam = true; // 为每个区域创建专属 CinemachineCamera
|
||||
|
||||
|
||||
// ── 运行时状态 ────────────────────────────────────────────────────────
|
||||
@@ -86,6 +88,12 @@ namespace BaseGames.Editor
|
||||
new GUIContent("镜头配置 SO", "赋给所有新 CameraArea._lensConfig;留空则不赋值"),
|
||||
_lensConfig, typeof(CameraLensConfigSO), false);
|
||||
|
||||
_createDedicatedVCam = EditorGUILayout.Toggle(
|
||||
new GUIContent("创建专属 VCam",
|
||||
"为每个迁移区域创建子节点 CinemachineCamera(含所有扩展组件),\n" +
|
||||
"并绑定到 CameraArea._dedicatedCamera。"),
|
||||
_createDedicatedVCam);
|
||||
|
||||
|
||||
|
||||
EditorGUILayout.Space(8);
|
||||
@@ -220,17 +228,24 @@ namespace BaseGames.Editor
|
||||
|
||||
foreach (Transform child in _sourcesParent)
|
||||
{
|
||||
Debug.Log($"[root]:{child.name}");
|
||||
// 旧 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)
|
||||
// 收集触发多边形顶点——xxx_TriggerRegion 下每个子节点的世界坐标即一个顶点,
|
||||
// 按子节点顺序依次连线围成多边形触发区域。
|
||||
Transform triggerRoot = FindChildContaining(child, "Trigger");
|
||||
|
||||
if (triggerRoot != null){
|
||||
Debug.Log($"[trigger]:{triggerRoot.name}");
|
||||
foreach (Transform pt in triggerRoot){
|
||||
Debug.Log($"{pt.name}");
|
||||
entry.TriggerWorldPts.Add((Vector2)pt.position);
|
||||
}
|
||||
}
|
||||
|
||||
// 读取限位碰撞体(Zone_xxx_Confiner 上的 Collider2D)
|
||||
Transform confinerT = FindChildContaining(child, "Confiner");
|
||||
@@ -298,20 +313,20 @@ namespace BaseGames.Editor
|
||||
soArea.FindProperty("_lensConfig").objectReferenceValue = _lensConfig;
|
||||
soArea.ApplyModifiedProperties();
|
||||
|
||||
// ── 3. 创建 AreaBoundary(限位多边形,isTrigger = true)──────────
|
||||
// ── 3. 创建 AreaBoundary(限位体积 BoxCollider)─────────────────────────────
|
||||
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));
|
||||
BoxCollider confinerBox = boundaryGO.AddComponent<BoxCollider>();
|
||||
confinerBox.isTrigger = true;
|
||||
confinerBox.center = new Vector3(0f, 0f, -10f); // Z 占位符,SyncConfiner 会立即重算
|
||||
confinerBox.size = new Vector3(localBounds.width, localBounds.height, 1f);
|
||||
|
||||
// 绑定 _confinerCollider
|
||||
soArea.Update();
|
||||
soArea.FindProperty("_confinerCollider").objectReferenceValue = confinerPoly;
|
||||
soArea.FindProperty("_confinerCollider").objectReferenceValue = confinerBox;
|
||||
soArea.ApplyModifiedProperties();
|
||||
EditorUtility.SetDirty(area);
|
||||
|
||||
@@ -330,9 +345,10 @@ namespace BaseGames.Editor
|
||||
// AddComponent 会因 [RequireComponent] 自动添加 PolygonCollider2D
|
||||
CameraTriggerZone triggerComp = triggerGO.AddComponent<CameraTriggerZone>();
|
||||
PolygonCollider2D triggerPoly = triggerGO.GetComponent<PolygonCollider2D>();
|
||||
// 将旧触发多边形路径(本地坐标,相对于新 CameraArea 世界位置)直接赋给 PolygonCollider2D
|
||||
Vector2[] triggerPath = BuildTriggerPath(entry, worldPos, localBounds);
|
||||
triggerPoly.isTrigger = true;
|
||||
triggerPoly.pathCount = 1;
|
||||
triggerPoly.SetPath(0, BuildTriggerPath(entry, worldPos, localBounds));
|
||||
triggerPoly.SetPath(0, triggerPath);
|
||||
|
||||
// _targetArea → 指向刚创建的 CameraArea
|
||||
var soTrigger = new SerializedObject(triggerComp);
|
||||
@@ -340,7 +356,11 @@ namespace BaseGames.Editor
|
||||
soTrigger.ApplyModifiedProperties();
|
||||
EditorUtility.SetDirty(triggerComp);
|
||||
|
||||
// ── 5. 处理旧对象 ──────────────────────────────────────────────
|
||||
// ── 5. 创建专属 VCam(每区域独立相机)─────────────────────────────
|
||||
if (_createDedicatedVCam)
|
||||
CameraAreaEditor.CreateDedicatedVCamForArea(area);
|
||||
|
||||
// ── 6. 处理旧对象 ──────────────────────────────────────────────
|
||||
// 先记录原始激活状态,再对旧对象做处理,避免 SetActive(false) 后误读
|
||||
bool wasActive = zoneGO.activeSelf;
|
||||
|
||||
@@ -356,8 +376,8 @@ namespace BaseGames.Editor
|
||||
|
||||
Debug.Log($"[迁移工具] {zoneGO.name} → {areaGO.name} " +
|
||||
$"可视 {localBounds.width:F0}×{localBounds.height:F0} " +
|
||||
$"触发 {triggerPoly.GetTotalPointCount()} pt " +
|
||||
$"限位 {confinerPoly.GetTotalPointCount()} pt");
|
||||
$"触发 PolygonCollider2D ({triggerPath.Length} 点) " +
|
||||
$"限位 BoxCollider ({confinerBox.size.x:F1}×{confinerBox.size.y:F1})");
|
||||
}
|
||||
|
||||
// ── 限位多边形路径 ────────────────────────────────────────────────────
|
||||
@@ -405,14 +425,40 @@ namespace BaseGames.Editor
|
||||
{
|
||||
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;
|
||||
// 将世界坐标转换为 areaGO 本地坐标
|
||||
var localPts = new Vector2[entry.TriggerWorldPts.Count];
|
||||
for (int i = 0; i < localPts.Length; i++)
|
||||
localPts[i] = entry.TriggerWorldPts[i] - (Vector2)areaWorldPos;
|
||||
|
||||
// 按照质心角度排序,确保顶点顺序能够围成合法多边形
|
||||
return SortPointsByAngle(localPts);
|
||||
}
|
||||
return RectToPolygon(fallback);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 将一组点按照围绕质心的角度(逆时针)排序,使其能够围成合法的简单多边形。
|
||||
/// 适用于凸多边形及质心在多边形内部的凹多边形。
|
||||
/// </summary>
|
||||
private static Vector2[] SortPointsByAngle(Vector2[] points)
|
||||
{
|
||||
// 计算质心
|
||||
Vector2 centroid = Vector2.zero;
|
||||
foreach (var p in points)
|
||||
centroid += p;
|
||||
centroid /= points.Length;
|
||||
|
||||
// 按照相对质心的极角升序排列(逆时针)
|
||||
var sorted = new System.Collections.Generic.List<Vector2>(points);
|
||||
sorted.Sort((a, b) =>
|
||||
{
|
||||
float angleA = Mathf.Atan2(a.y - centroid.y, a.x - centroid.x);
|
||||
float angleB = Mathf.Atan2(b.y - centroid.y, b.x - centroid.x);
|
||||
return angleA.CompareTo(angleB);
|
||||
});
|
||||
return sorted.ToArray();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 手动计算 BoxCollider2D 的世界 AABB,不依赖 .bounds(inactive 对象上 .bounds 无效)。
|
||||
/// </summary>
|
||||
|
||||
@@ -98,6 +98,9 @@ namespace BaseGames.Editor
|
||||
|
||||
if (GUILayout.Button("↺ 全部同步限位区域", EditorStyles.toolbarButton))
|
||||
SyncAllConfiners();
|
||||
|
||||
if (GUILayout.Button("✔ 批量创建专属 VCam", EditorStyles.toolbarButton))
|
||||
BatchCreateDedicatedVCams();
|
||||
}
|
||||
|
||||
// ── 创建 CameraArea 面板 ───────────────────────────────────────
|
||||
@@ -217,6 +220,40 @@ namespace BaseGames.Editor
|
||||
|
||||
Debug.Log($"[CameraAreaSetupTool] 已同步 {count} 个 CameraArea 的限位区域。");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 为所有尚未配置专有 VCam 的 CameraArea 批量创建专有 CinemachineCamera。
|
||||
/// 已有 _dedicatedCamera 的区域将跳过。
|
||||
/// </summary>
|
||||
private void BatchCreateDedicatedVCams()
|
||||
{
|
||||
if (_cameraAreas.Count == 0)
|
||||
{
|
||||
Debug.LogWarning("[CameraAreaSetupTool] 场景中无 CameraArea,跳过批量创建。");
|
||||
return;
|
||||
}
|
||||
|
||||
int count = 0;
|
||||
foreach (var area in _cameraAreas)
|
||||
{
|
||||
if (area == null) continue;
|
||||
if (area.DedicatedCamera != null) continue; // 已有专有 VCam,跳过
|
||||
|
||||
CameraAreaEditor.CreateDedicatedVCamForArea(area);
|
||||
count++;
|
||||
}
|
||||
|
||||
if (count > 0)
|
||||
{
|
||||
RescanScene();
|
||||
Debug.Log($"[CameraAreaSetupTool] 已为 {count} 个 CameraArea 创建专有 VCam。");
|
||||
}
|
||||
else
|
||||
{
|
||||
Debug.Log("[CameraAreaSetupTool] 所有 CameraArea 均已有专有 VCam,无需创建。");
|
||||
}
|
||||
}
|
||||
|
||||
// ── CameraStateController ──────────────────────────────────────────
|
||||
|
||||
private void DrawControllerSection()
|
||||
@@ -240,8 +277,6 @@ namespace BaseGames.Editor
|
||||
}
|
||||
|
||||
SerializedObject so = new SerializedObject(_controller);
|
||||
DrawFieldCheck(so, "_vcamA", "全局 VCam A (CinemachineCamera)");
|
||||
DrawFieldCheck(so, "_vcamB", "全局 VCam B (CinemachineCamera)");
|
||||
DrawFieldCheck(so, "_brain", "CinemachineBrain");
|
||||
DrawFieldCheck(so, "_onPlayerSpawned", "玩家生成事件 (EVT_PlayerSpawned) → VCam 自动绑 Follow");
|
||||
DrawFieldCheck(so, "_impulseSource", "CinemachineImpulseSource", optional: true);
|
||||
@@ -334,7 +369,8 @@ namespace BaseGames.Editor
|
||||
bool confinerOk = so.FindProperty("_confinerCollider").objectReferenceValue != null;
|
||||
var boundZones = FindTriggerZonesForArea(area);
|
||||
bool hasZone = boundZones.Count > 0;
|
||||
bool allOk = confinerOk && hasZone;
|
||||
bool hasVCam = so.FindProperty("_dedicatedCamera").objectReferenceValue != null;
|
||||
bool allOk = confinerOk && hasZone && hasVCam;
|
||||
|
||||
using (new EditorGUILayout.VerticalScope(_boxStyle))
|
||||
{
|
||||
@@ -353,6 +389,10 @@ namespace BaseGames.Editor
|
||||
GUI.color = hasZone ? kOk : kError;
|
||||
GUILayout.Label(hasZone ? $"[{boundZones.Count} 触发器]" : "[无触发器]",
|
||||
EditorStyles.miniLabel, GUILayout.Width(74f));
|
||||
|
||||
GUI.color = hasVCam ? kOk : kError;
|
||||
GUILayout.Label(hasVCam ? "[VCam ✔]" : "[VCam ✗]",
|
||||
EditorStyles.miniLabel, GUILayout.Width(54f));
|
||||
GUI.color = prevC;
|
||||
|
||||
if (GUILayout.Button("⊙", GUILayout.Width(24f)))
|
||||
@@ -366,12 +406,61 @@ namespace BaseGames.Editor
|
||||
EditorGUILayout.Space(3f);
|
||||
|
||||
// ── 绑定字段 ────────────────────────────────────────────────
|
||||
DrawCheckRow("_confinerCollider(可视边界 PolygonCollider2D)", confinerOk);
|
||||
DrawCheckRow("_dedicatedCamera(专有 VCam,可选)",
|
||||
so.FindProperty("_dedicatedCamera").objectReferenceValue != null, optional: true);
|
||||
DrawCheckRow("_confinerCollider(可视边界 BoxCollider)", confinerOk);
|
||||
|
||||
// ── 专有 VCam 状态行(创建 / Ping 按鈕)────────────────────────
|
||||
using (new EditorGUILayout.HorizontalScope())
|
||||
{
|
||||
Color prevC2 = GUI.color;
|
||||
GUI.color = hasVCam ? kOk : kError;
|
||||
GUILayout.Label(hasVCam ? "●" : "✗", GUILayout.Width(16f));
|
||||
GUI.color = prevC2;
|
||||
|
||||
if (hasVCam)
|
||||
{
|
||||
var vcamObj = so.FindProperty("_dedicatedCamera").objectReferenceValue;
|
||||
EditorGUILayout.LabelField($"_dedicatedCamera:{vcamObj.name}",
|
||||
GUILayout.ExpandWidth(true));
|
||||
if (GUILayout.Button("⊙", GUILayout.Width(24f)))
|
||||
EditorGUIUtility.PingObject(vcamObj);
|
||||
if (GUILayout.Button("选中", GUILayout.Width(36f)))
|
||||
Selection.activeObject = vcamObj;
|
||||
}
|
||||
else
|
||||
{
|
||||
EditorGUILayout.LabelField("_dedicatedCamera(专有 VCam)未创建",
|
||||
GUILayout.ExpandWidth(true));
|
||||
if (GUILayout.Button("创建专有 VCam", GUILayout.Width(90f), GUILayout.Height(18f)))
|
||||
{
|
||||
CameraAreaEditor.CreateDedicatedVCamForArea(area);
|
||||
RescanScene();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
DrawCheckRow("_blendProfile(可选,未设则用全局默认)",
|
||||
so.FindProperty("_blendProfile").objectReferenceValue != null, optional: true);
|
||||
|
||||
// ── VCam 扩展组件顺序检查 ────────────────────────────────────
|
||||
// AsymmetricDamping/FallBias/FacingBias 必须在 CinemachineConfiner3D 之前;
|
||||
// AxisLock 必须在之后。顺序错误会使相机逃出限位区域。
|
||||
if (hasVCam)
|
||||
{
|
||||
var vcam = so.FindProperty("_dedicatedCamera").objectReferenceValue as CinemachineCamera;
|
||||
string issue = CameraAreaEditor.CheckVCamExtensionOrderIssue(vcam);
|
||||
if (issue != null)
|
||||
{
|
||||
EditorGUILayout.HelpBox(
|
||||
$"VCam 扩展组件顺序错误!相机会逃出限位区域:\n{issue}",
|
||||
MessageType.Error);
|
||||
if (GUILayout.Button("⚙ 自动修正组件顺序", GUILayout.Height(22f)))
|
||||
{
|
||||
CameraAreaEditor.FixVCamExtensionOrder(vcam);
|
||||
RescanScene();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── 触发区域列表 ─────────────────────────────────────────────
|
||||
EditorGUILayout.Space(3f);
|
||||
using (new EditorGUILayout.HorizontalScope())
|
||||
@@ -412,16 +501,16 @@ namespace BaseGames.Editor
|
||||
EditorGUILayout.Space(3f);
|
||||
if (!confinerOk)
|
||||
{
|
||||
// 区分:有非 Trigger 的 PolygonCollider2D 可直接绑定 vs 完全没有 AreaBoundary
|
||||
var existingBoundary = FindBoundaryPoly(area);
|
||||
// 区分:有 BoxCollider 可直接绑定 vs 完全没有 AreaBoundary
|
||||
var existingBoundary = FindBoundaryBox(area);
|
||||
if (existingBoundary != null)
|
||||
{
|
||||
if (GUILayout.Button("修复:绑定子节点 PolygonCollider2D", GUILayout.Height(22f)))
|
||||
if (GUILayout.Button("修复:绑定子节点 BoxCollider", GUILayout.Height(22f)))
|
||||
FixConfinerBinding(area);
|
||||
}
|
||||
else
|
||||
{
|
||||
if (GUILayout.Button("创建 AreaBoundary(限位多边形,默认 24 × 12)", GUILayout.Height(22f)))
|
||||
if (GUILayout.Button("创建 AreaBoundary(限位体积,默认 24 × 12)", GUILayout.Height(22f)))
|
||||
{
|
||||
CreateAreaBoundary(area);
|
||||
RescanScene();
|
||||
@@ -433,7 +522,7 @@ namespace BaseGames.Editor
|
||||
Color helpC = GUI.color;
|
||||
GUI.color = kMuted;
|
||||
EditorGUILayout.LabelField(
|
||||
"★ 可视边界:选中子节点的 PolygonCollider2D,在 Scene 视图中编辑顶点。",
|
||||
"★ 限位体积:选中子节点的 BoxCollider,在 Inspector 中编辑 Center / Size。",
|
||||
EditorStyles.miniLabel);
|
||||
GUI.color = helpC;
|
||||
}
|
||||
@@ -557,34 +646,34 @@ namespace BaseGames.Editor
|
||||
Debug.LogWarning("[CameraAreaSetupTool] _vcamA/_vcamB 均未绑定,无法赋值 Follow。请先在 Inspector 中绑定。");
|
||||
}
|
||||
|
||||
/// <summary>将子节点中找到的第一个不含 CameraTriggerZone 的 PolygonCollider2D 绑定到 CameraArea._confinerCollider。</summary>
|
||||
/// <summary>将子节点中找到的第一个不含 CameraTriggerZone 的 BoxCollider 绑定到 CameraArea._confinerCollider。</summary>
|
||||
private static void FixConfinerBinding(CameraArea area)
|
||||
{
|
||||
PolygonCollider2D poly = FindBoundaryPoly(area)
|
||||
?? area.GetComponentInChildren<PolygonCollider2D>(true);
|
||||
if (poly == null)
|
||||
BoxCollider box = FindBoundaryBox(area)
|
||||
?? area.GetComponentInChildren<BoxCollider>(true);
|
||||
if (box == null)
|
||||
{
|
||||
Debug.LogWarning($"[CameraAreaSetupTool] {area.name}:子节点中未找到 PolygonCollider2D。");
|
||||
Debug.LogWarning($"[CameraAreaSetupTool] {area.name}:子节点中未找到 BoxCollider。");
|
||||
return;
|
||||
}
|
||||
|
||||
SerializedObject so = new SerializedObject(area);
|
||||
so.FindProperty("_confinerCollider").objectReferenceValue = poly;
|
||||
so.FindProperty("_confinerCollider").objectReferenceValue = box;
|
||||
so.ApplyModifiedProperties();
|
||||
|
||||
Debug.Log($"[CameraAreaSetupTool] {area.name}:_confinerCollider → {poly.gameObject.name}");
|
||||
Debug.Log($"[CameraAreaSetupTool] {area.name}:_confinerCollider → {box.gameObject.name}");
|
||||
}
|
||||
|
||||
/// <summary>返回 area 子节点中第一个不含 CameraTriggerZone 的 PolygonCollider2D(即 AreaBoundary 限位体)。</summary>
|
||||
private static PolygonCollider2D FindBoundaryPoly(CameraArea area)
|
||||
/// <summary>返回 area 子节点中第一个不含 CameraTriggerZone 的 BoxCollider(即 AreaBoundary 限位体)。</summary>
|
||||
private static BoxCollider FindBoundaryBox(CameraArea area)
|
||||
{
|
||||
foreach (var p in area.GetComponentsInChildren<PolygonCollider2D>(true))
|
||||
if (p.GetComponent<CameraTriggerZone>() == null) return p;
|
||||
foreach (var b in area.GetComponentsInChildren<BoxCollider>(true))
|
||||
if (b.GetComponent<CameraTriggerZone>() == null) return b;
|
||||
return null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 为指定 CameraArea 创建 AreaBoundary 子节点(默认矩形限位多边形,isTrigger = false)并绑定到 _confinerCollider。
|
||||
/// 为指定 CameraArea 创建 AreaBoundary 子节点(默认 BoxCollider 限位体)并绑定到 _confinerCollider。
|
||||
/// </summary>
|
||||
private static void CreateAreaBoundary(CameraArea area)
|
||||
{
|
||||
@@ -602,27 +691,20 @@ namespace BaseGames.Editor
|
||||
childGo.transform.localPosition = Vector3.zero;
|
||||
}
|
||||
|
||||
PolygonCollider2D poly = childGo.GetComponent<PolygonCollider2D>()
|
||||
?? childGo.AddComponent<PolygonCollider2D>();
|
||||
poly.isTrigger = true; // 限位多边形,仅作为相机约束边界,不产生物理碰撞
|
||||
poly.pathCount = 1;
|
||||
poly.SetPath(0, new Vector2[]
|
||||
{
|
||||
new Vector2(-12f, -6f),
|
||||
new Vector2(-12f, 6f),
|
||||
new Vector2( 12f, 6f),
|
||||
new Vector2( 12f, -6f),
|
||||
});
|
||||
BoxCollider box = childGo.GetComponent<BoxCollider>()
|
||||
?? childGo.AddComponent<BoxCollider>();
|
||||
box.center = new Vector3(0f, 0f, -10f); // Z 占位符,绑定 LensConfig 后点击「同步限位区域」更新
|
||||
box.size = new Vector3(24f, 12f, 1f); // 默认 24 × 12 占位符
|
||||
EditorUtility.SetDirty(childGo);
|
||||
|
||||
SerializedObject so = new SerializedObject(area);
|
||||
so.Update();
|
||||
so.FindProperty("_confinerCollider").objectReferenceValue = poly;
|
||||
so.FindProperty("_confinerCollider").objectReferenceValue = box;
|
||||
so.ApplyModifiedProperties();
|
||||
EditorUtility.SetDirty(area);
|
||||
|
||||
EditorGUIUtility.PingObject(childGo);
|
||||
Debug.Log($"[CameraAreaSetupTool] 已为 {area.name} 创建 AreaBoundary(矩形 24 × 12)。");
|
||||
Debug.Log($"[CameraAreaSetupTool] 已为 {area.name} 创建 AreaBoundary(BoxCollider 默认 24 × 12)。");
|
||||
}
|
||||
|
||||
/// <summary>返回所有以此 area 为激活目标的 CameraTriggerZone。</summary>
|
||||
@@ -642,18 +724,10 @@ namespace BaseGames.Editor
|
||||
/// <summary>为指定 CameraArea 创建配对的 CameraTriggerZone,自动匹配 Confiner 范围。</summary>
|
||||
private static void CreateTriggerZoneForArea(CameraArea area)
|
||||
{
|
||||
// 用 PolygonCollider2D 包围盒作为放置中心和尺寸;没有则退回到 area 自身位置
|
||||
Vector3 center = area.transform.position;
|
||||
Vector2 size = new Vector2(4f, 4f);
|
||||
|
||||
var poly = area.GetComponentInChildren<PolygonCollider2D>(true);
|
||||
if (poly != null)
|
||||
{
|
||||
Bounds b = poly.bounds;
|
||||
center = b.center;
|
||||
center.z = area.transform.position.z;
|
||||
size = new Vector2(b.size.x, b.size.y);
|
||||
}
|
||||
// 用 VisibleBounds 作为放置中心和初始多边形范围
|
||||
Rect visible = area.VisibleBounds;
|
||||
Vector3 center = new Vector3(visible.center.x, visible.center.y, area.transform.position.z);
|
||||
Vector2 half = visible.size * 0.5f;
|
||||
|
||||
var go = new GameObject($"{area.gameObject.name}_TriggerZone");
|
||||
Undo.RegisterCreatedObjectUndo(go, "Create CameraTriggerZone");
|
||||
@@ -661,20 +735,21 @@ namespace BaseGames.Editor
|
||||
go.transform.SetParent(area.transform);
|
||||
go.transform.position = center;
|
||||
|
||||
var col = go.AddComponent<PolygonCollider2D>();
|
||||
// [RequireComponent] 会自动附加 PolygonCollider2D;先 AddComponent<CameraTriggerZone>
|
||||
// 再通过 GetComponent 引用,避免顺序依赖问题
|
||||
var zone = go.AddComponent<CameraTriggerZone>();
|
||||
var col = go.GetComponent<PolygonCollider2D>();
|
||||
col.isTrigger = true;
|
||||
float hw = size.x * 0.5f;
|
||||
float hh = size.y * 0.5f;
|
||||
// 以 VisibleBounds 矩形四角为默认路径(可在 Inspector 中进一步编辑顶点)
|
||||
col.SetPath(0, new Vector2[]
|
||||
{
|
||||
new Vector2(-hw, -hh),
|
||||
new Vector2(-hw, hh),
|
||||
new Vector2( hw, hh),
|
||||
new Vector2( hw, -hh),
|
||||
new Vector2(-half.x, -half.y),
|
||||
new Vector2(-half.x, half.y),
|
||||
new Vector2( half.x, half.y),
|
||||
new Vector2( half.x, -half.y),
|
||||
});
|
||||
|
||||
var zone = go.AddComponent<CameraTriggerZone>();
|
||||
var so = new SerializedObject(zone);
|
||||
var so = new SerializedObject(zone);
|
||||
so.Update();
|
||||
so.FindProperty("_targetArea").objectReferenceValue = area;
|
||||
so.ApplyModifiedProperties();
|
||||
|
||||
835
Assets/_Game/Scripts/Editor/CharacterWizardWindow.cs
Normal file
835
Assets/_Game/Scripts/Editor/CharacterWizardWindow.cs
Normal file
@@ -0,0 +1,835 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using UnityEditor;
|
||||
using UnityEditor.UIElements;
|
||||
using UnityEngine;
|
||||
using UnityEngine.UIElements;
|
||||
using BaseGames.Boss;
|
||||
using BaseGames.Combat;
|
||||
using BaseGames.Enemies;
|
||||
using BaseGames.Equipment;
|
||||
using BaseGames.Input;
|
||||
using BaseGames.Parry;
|
||||
using BaseGames.Player;
|
||||
using BaseGames.Player.States;
|
||||
using BaseGames.Skills;
|
||||
|
||||
namespace BaseGames.Editor
|
||||
{
|
||||
/// <summary>
|
||||
/// 角色创建向导(W-01)— 统一入口窗口。
|
||||
/// 技术:UI Toolkit,三标签页(玩家 / 小怪 / Boss)。
|
||||
/// 菜单:BaseGames / Tools / Character Wizard
|
||||
///
|
||||
/// 各标签页均提供:
|
||||
/// ① 当前 SO 资产状态速览(绿色=已存在 / 橙色=缺失)
|
||||
/// ② SO 资产工厂(一键创建所需的所有 ScriptableObject)
|
||||
/// ③ 场景搭建快捷按钮(调用 SceneObjectPlacerTool.PlaceXxx)
|
||||
/// ④ 跳转到对应专项编辑器窗口
|
||||
/// </summary>
|
||||
public class CharacterWizardWindow : EditorWindow
|
||||
{
|
||||
// ── 常量 ──────────────────────────────────────────────────────────────
|
||||
private const string UssPath = "Assets/_Game/Scripts/Editor/UIToolkit/Editor.uss";
|
||||
private const string DataRoot = "Assets/_Game/Data";
|
||||
|
||||
private static StyleSheet _uss;
|
||||
private static StyleSheet Uss =>
|
||||
_uss != null ? _uss : (_uss = AssetDatabase.LoadAssetAtPath<StyleSheet>(UssPath));
|
||||
|
||||
[MenuItem("BaseGames/Tools/Character Wizard", priority = 1)]
|
||||
public static void Open()
|
||||
{
|
||||
var wnd = GetWindow<CharacterWizardWindow>();
|
||||
wnd.titleContent = new GUIContent("Character Wizard", EditorGUIUtility.IconContent("d_Prefab Icon").image);
|
||||
wnd.minSize = new Vector2(520, 600);
|
||||
}
|
||||
|
||||
// ── 状态 ──────────────────────────────────────────────────────────────
|
||||
private int _activeTab = 0;
|
||||
private Button _btnPlayer, _btnEnemy, _btnBoss;
|
||||
|
||||
// SO 状态缓存(避免每帧重查)
|
||||
private List<(string label, bool exists)> _playerSOStatus = new();
|
||||
private List<(string label, bool exists)> _enemySOStatus = new();
|
||||
private List<(string label, bool exists)> _bossSOStatus = new();
|
||||
private double _lastRefreshTime;
|
||||
|
||||
// 小怪类型选择
|
||||
private int _enemyTypeIndex = 0;
|
||||
private static readonly string[] EnemyTypeLabels = { "普通(近战)", "远程", "飞行" };
|
||||
|
||||
// Boss 命名字段
|
||||
private string _bossId = "NewBoss";
|
||||
private string _enemyId = "NewEnemy";
|
||||
private string _playerId = "Player";
|
||||
|
||||
// SO 状态面板(按标签页缓存)
|
||||
private VisualElement _playerStatusPanel;
|
||||
private VisualElement _enemyStatusPanel;
|
||||
private VisualElement _bossStatusPanel;
|
||||
|
||||
// ── 生命周期 ──────────────────────────────────────────────────────────
|
||||
|
||||
public void CreateGUI()
|
||||
{
|
||||
if (Uss != null)
|
||||
rootVisualElement.styleSheets.Add(Uss);
|
||||
|
||||
rootVisualElement.style.flexDirection = FlexDirection.Column;
|
||||
|
||||
BuildTabBar();
|
||||
BuildTabContents();
|
||||
RefreshSOStatus();
|
||||
SwitchTab(0);
|
||||
}
|
||||
|
||||
private void OnFocus() => RefreshSOStatus();
|
||||
|
||||
// ── 标签栏 ────────────────────────────────────────────────────────────
|
||||
|
||||
private void BuildTabBar()
|
||||
{
|
||||
var bar = new VisualElement();
|
||||
bar.AddToClassList("tab-bar");
|
||||
|
||||
_btnPlayer = MakeTabButton("玩家", () => SwitchTab(0));
|
||||
_btnEnemy = MakeTabButton("小怪", () => SwitchTab(1));
|
||||
_btnBoss = MakeTabButton("Boss", () => SwitchTab(2));
|
||||
|
||||
bar.Add(_btnPlayer);
|
||||
bar.Add(_btnEnemy);
|
||||
bar.Add(_btnBoss);
|
||||
rootVisualElement.Add(bar);
|
||||
}
|
||||
|
||||
private Button MakeTabButton(string label, Action onClick)
|
||||
{
|
||||
var btn = new Button(onClick) { text = label };
|
||||
btn.AddToClassList("tab-btn");
|
||||
return btn;
|
||||
}
|
||||
|
||||
private void SwitchTab(int idx)
|
||||
{
|
||||
_activeTab = idx;
|
||||
var tabs = rootVisualElement.Query<VisualElement>(className: "tab-content").ToList();
|
||||
for (int i = 0; i < tabs.Count; i++)
|
||||
tabs[i].style.display = (i == idx) ? DisplayStyle.Flex : DisplayStyle.None;
|
||||
|
||||
_btnPlayer.EnableInClassList("tab-btn--active", idx == 0);
|
||||
_btnEnemy .EnableInClassList("tab-btn--active", idx == 1);
|
||||
_btnBoss .EnableInClassList("tab-btn--active", idx == 2);
|
||||
}
|
||||
|
||||
// ── 标签页内容 ────────────────────────────────────────────────────────
|
||||
|
||||
private void BuildTabContents()
|
||||
{
|
||||
rootVisualElement.Add(BuildPlayerTab());
|
||||
rootVisualElement.Add(BuildEnemyTab());
|
||||
rootVisualElement.Add(BuildBossTab());
|
||||
}
|
||||
|
||||
// ════════════════════════════════════════════════════════════════════════
|
||||
// 玩家标签页
|
||||
// ════════════════════════════════════════════════════════════════════════
|
||||
|
||||
private VisualElement BuildPlayerTab()
|
||||
{
|
||||
var root = MakeTabContent();
|
||||
|
||||
root.Add(MakeSectionHeader("▶ SO 资产状态"));
|
||||
_playerStatusPanel = new VisualElement();
|
||||
root.Add(_playerStatusPanel);
|
||||
|
||||
root.Add(MakeSectionHeader("▶ SO 资产工厂"));
|
||||
root.Add(MakeHelpBox("在 Project 中创建下列 ScriptableObject 资产。若已存在则跳过。"));
|
||||
|
||||
var idRow = MakeLabeledTextField("资产名称前缀", _playerId, v => _playerId = v);
|
||||
root.Add(idRow);
|
||||
|
||||
var factory = MakeActionGroup();
|
||||
factory.Add(MakeFactoryButton("PlayerStatsSO", () => CreatePlayerStat()));
|
||||
factory.Add(MakeFactoryButton("PlayerMovementConfigSO", () => CreateMovementConfig()));
|
||||
factory.Add(MakeFactoryButton("PlayerAnimationConfigSO", () => CreateAnimConfig()));
|
||||
factory.Add(MakeFactoryButton("FormConfigSO + 3 FormSO", () => CreateFormConfig()));
|
||||
factory.Add(MakeFactoryButton("WeaponSO × 3(形态)", () => CreateFormWeapons()));
|
||||
factory.Add(MakeFactoryButton("DamageSourceSO(连击×3)", () => CreatePlayerDamageSources()));
|
||||
factory.Add(MakeFactoryButton("ParryConfigSO", () => CreateParryConfig()));
|
||||
factory.Add(MakeFactoryButton("ShieldConfigSO", () => CreateShieldConfig()));
|
||||
factory.Add(MakeFactoryButton("EquipmentConfigSO", () => CreateEquipmentConfig()));
|
||||
factory.Add(MakeFactoryButton("CharmCatalogSO", () => CreateCharmCatalog()));
|
||||
root.Add(factory);
|
||||
|
||||
var createAllBtn = new Button(CreateAllPlayerSOs) { text = "★ 一键创建全部 Player SO" };
|
||||
createAllBtn.style.marginTop = 6;
|
||||
createAllBtn.style.height = 26;
|
||||
root.Add(createAllBtn);
|
||||
|
||||
root.Add(MakeSeparator());
|
||||
root.Add(MakeSectionHeader("▶ 场景搭建"));
|
||||
root.Add(MakeHelpBox("在当前活动场景中放置玩家 GameObject(带完整组件树)。"));
|
||||
|
||||
var sceneGroup = MakeActionGroup();
|
||||
sceneGroup.Add(MakeSceneButton("放置玩家到场景", SceneObjectPlacerTool.PlacePlayer));
|
||||
sceneGroup.Add(MakeSceneButton("指定所有 SO 到场景角色", AssignAllPlayerSOsToScene));
|
||||
sceneGroup.Add(MakeSceneButton("放置地面平台", SceneObjectPlacerTool.PlaceGroundPlatform));
|
||||
sceneGroup.Add(MakeSceneButton("放置存档点", SceneObjectPlacerTool.PlaceSavePoint));
|
||||
root.Add(sceneGroup);
|
||||
|
||||
root.Add(MakeSeparator());
|
||||
root.Add(MakeSectionHeader("▶ 专项编辑器"));
|
||||
|
||||
var jumpGroup = MakeActionGroup();
|
||||
jumpGroup.Add(MakeJumpButton("武器编辑器", () => Combat.WeaponEditorWindow.Open()));
|
||||
jumpGroup.Add(MakeJumpButton("技能编辑器", () => Skills.SkillEditorWindow.Open()));
|
||||
jumpGroup.Add(MakeJumpButton("形态编辑器", () => FormEditorWindow.Open()));
|
||||
jumpGroup.Add(MakeJumpButton("SO 全局校验", SOValidationRunner.ValidateMenu));
|
||||
root.Add(jumpGroup);
|
||||
|
||||
return root;
|
||||
}
|
||||
|
||||
// ════════════════════════════════════════════════════════════════════════
|
||||
// 小怪标签页
|
||||
// ════════════════════════════════════════════════════════════════════════
|
||||
|
||||
private VisualElement BuildEnemyTab()
|
||||
{
|
||||
var root = MakeTabContent();
|
||||
|
||||
root.Add(MakeSectionHeader("▶ SO 资产状态"));
|
||||
_enemySOStatus.Clear();
|
||||
_enemyStatusPanel = new VisualElement();
|
||||
root.Add(_enemyStatusPanel);
|
||||
|
||||
root.Add(MakeSectionHeader("▶ 敌人类型选择"));
|
||||
|
||||
var typeRow = new VisualElement { style = { flexDirection = FlexDirection.Row, marginBottom = 4 } };
|
||||
for (int i = 0; i < EnemyTypeLabels.Length; i++)
|
||||
{
|
||||
int captured = i;
|
||||
var btn = new Button(() =>
|
||||
{
|
||||
_enemyTypeIndex = captured;
|
||||
// 高亮激活按钮(简单刷新所有同类按钮样式)
|
||||
RefreshEnemyTypeButtons(root);
|
||||
})
|
||||
{ text = EnemyTypeLabels[i] };
|
||||
btn.name = $"enemy-type-{i}";
|
||||
btn.EnableInClassList("type-btn--active", i == _enemyTypeIndex);
|
||||
typeRow.Add(btn);
|
||||
}
|
||||
root.Add(typeRow);
|
||||
|
||||
root.Add(MakeSectionHeader("▶ SO 资产工厂"));
|
||||
root.Add(MakeHelpBox("每个敌人建议独立命名,便于 Loot / BD 资产管理。"));
|
||||
|
||||
var idRow = MakeLabeledTextField("敌人 ID", _enemyId, v => _enemyId = v);
|
||||
root.Add(idRow);
|
||||
|
||||
var factory = MakeActionGroup();
|
||||
factory.Add(MakeFactoryButton("EnemyStatsSO", () => CreateEnemyStat()));
|
||||
factory.Add(MakeFactoryButton("LootTableSO", () => CreateLootTable()));
|
||||
factory.Add(MakeFactoryButton("AttackPatternSO × 2", () => CreateEnemyAttackPatterns()));
|
||||
factory.Add(MakeFactoryButton("DamageSourceSO", () => CreateEnemyDamageSource()));
|
||||
root.Add(factory);
|
||||
|
||||
root.Add(MakeSeparator());
|
||||
root.Add(MakeSectionHeader("▶ 场景搭建"));
|
||||
root.Add(MakeHelpBox("根据选中的类型在场景中生成对应的敌人 GameObject。"));
|
||||
|
||||
var sceneGroup = MakeActionGroup();
|
||||
sceneGroup.Add(MakeSceneButton("放置敌人到场景", PlaceSelectedEnemyType));
|
||||
root.Add(sceneGroup);
|
||||
|
||||
root.Add(MakeSeparator());
|
||||
root.Add(MakeSectionHeader("▶ 专项编辑器"));
|
||||
|
||||
var jumpGroup = MakeActionGroup();
|
||||
jumpGroup.Add(MakeJumpButton("敌人数据管理", () => Enemies.EnemyDataWindow.Open()));
|
||||
jumpGroup.Add(MakeJumpButton("SO 全局校验", SOValidationRunner.ValidateMenu));
|
||||
root.Add(jumpGroup);
|
||||
|
||||
return root;
|
||||
}
|
||||
|
||||
// ════════════════════════════════════════════════════════════════════════
|
||||
// Boss 标签页
|
||||
// ════════════════════════════════════════════════════════════════════════
|
||||
|
||||
private VisualElement BuildBossTab()
|
||||
{
|
||||
var root = MakeTabContent();
|
||||
|
||||
root.Add(MakeSectionHeader("▶ SO 资产状态"));
|
||||
_bossStatusPanel = new VisualElement();
|
||||
root.Add(_bossStatusPanel);
|
||||
|
||||
root.Add(MakeSectionHeader("▶ SO 资产工厂"));
|
||||
root.Add(MakeHelpBox("每个 Boss 独立目录:Assets/_Game/Data/Boss/<BossId>/"));
|
||||
|
||||
var idRow = MakeLabeledTextField("Boss ID", _bossId, v => _bossId = v);
|
||||
root.Add(idRow);
|
||||
|
||||
var factory = MakeActionGroup();
|
||||
factory.Add(MakeFactoryButton("EnemyStatsSO(Boss)", () => CreateBossStat()));
|
||||
factory.Add(MakeFactoryButton("LootTableSO(Boss)", () => CreateBossLoot()));
|
||||
factory.Add(MakeFactoryButton("AttackPatternSO × 3(阶段)", () => CreateBossAttackPatterns()));
|
||||
factory.Add(MakeFactoryButton("BossSkillSO × 3", () => CreateBossSkills()));
|
||||
factory.Add(MakeFactoryButton("SkillSequenceSO(Phase 1)", () => CreateBossSkillSequence(1)));
|
||||
factory.Add(MakeFactoryButton("SkillSequenceSO(Phase 2)", () => CreateBossSkillSequence(2)));
|
||||
factory.Add(MakeFactoryButton("DamageSourceSO × 3", () => CreateBossDamageSources()));
|
||||
root.Add(factory);
|
||||
|
||||
root.Add(MakeSeparator());
|
||||
root.Add(MakeSectionHeader("▶ 场景搭建"));
|
||||
|
||||
var sceneGroup = MakeActionGroup();
|
||||
sceneGroup.Add(MakeSceneButton("放置 Boss 到场景", SceneObjectPlacerTool.PlaceBossEnemy));
|
||||
root.Add(sceneGroup);
|
||||
|
||||
root.Add(MakeSeparator());
|
||||
root.Add(MakeSectionHeader("▶ 专项编辑器"));
|
||||
|
||||
var jumpGroup = MakeActionGroup();
|
||||
jumpGroup.Add(MakeJumpButton("Boss 技能序列查看器", BossSkillSequenceWindow.OpenWindow));
|
||||
jumpGroup.Add(MakeJumpButton("敌人数据管理", () => Enemies.EnemyDataWindow.Open()));
|
||||
jumpGroup.Add(MakeJumpButton("SO 全局校验", SOValidationRunner.ValidateMenu));
|
||||
root.Add(jumpGroup);
|
||||
|
||||
return root;
|
||||
}
|
||||
|
||||
// ── SO 资产工厂:玩家 ────────────────────────────────────────────────
|
||||
|
||||
private void CreatePlayerStat()
|
||||
{
|
||||
var asset = EditorScaffoldUtils.CreateSOAsset<PlayerStatsSO>(
|
||||
$"{DataRoot}/Player", "PLY_PlayerStats");
|
||||
if (asset != null) RefreshSOStatus();
|
||||
}
|
||||
|
||||
private void CreateMovementConfig()
|
||||
{
|
||||
var asset = EditorScaffoldUtils.CreateSOAsset<PlayerMovementConfigSO>(
|
||||
$"{DataRoot}/Player", "PLY_PlayerMovementConfig");
|
||||
if (asset != null) RefreshSOStatus();
|
||||
}
|
||||
|
||||
private void CreateAnimConfig()
|
||||
{
|
||||
var asset = EditorScaffoldUtils.CreateSOAsset<PlayerAnimationConfigSO>(
|
||||
$"{DataRoot}/Player", "PLY_PlayerAnimationConfig");
|
||||
if (asset != null) RefreshSOStatus();
|
||||
}
|
||||
|
||||
private void CreateFormConfig()
|
||||
{
|
||||
string configDir = $"{DataRoot}/Player";
|
||||
string formsDir = $"{DataRoot}/Player/Forms";
|
||||
EditorScaffoldUtils.EnsureFolder(formsDir);
|
||||
|
||||
var cfg = EditorScaffoldUtils.CreateSOAsset<FormConfigSO>(configDir, "PLY_FormConfig");
|
||||
if (cfg == null) cfg = AssetDatabase.LoadAssetAtPath<FormConfigSO>($"{configDir}/PLY_FormConfig.asset");
|
||||
|
||||
var formTypes = new[] { ("TianHun", FormType.TianHun, "天魂"), ("DiHun", FormType.DiHun, "地魂"), ("MingHun", FormType.MingHun, "命魂") };
|
||||
var forms = new List<FormSO>();
|
||||
foreach (var (id, ftype, dname) in formTypes)
|
||||
{
|
||||
string path = $"{formsDir}/PLY_Form_{id}.asset";
|
||||
var form = AssetDatabase.LoadAssetAtPath<FormSO>(path);
|
||||
if (form == null)
|
||||
{
|
||||
form = ScriptableObject.CreateInstance<FormSO>();
|
||||
form.formId = $"Form_{id}";
|
||||
form.displayName = dname;
|
||||
form.formType = ftype;
|
||||
AssetDatabase.CreateAsset(form, path);
|
||||
}
|
||||
forms.Add(form);
|
||||
}
|
||||
|
||||
if (cfg != null && (cfg.forms == null || cfg.forms.Length == 0))
|
||||
{
|
||||
cfg.forms = forms.ToArray();
|
||||
EditorUtility.SetDirty(cfg);
|
||||
}
|
||||
|
||||
AssetDatabase.SaveAssets();
|
||||
EditorGUIUtility.PingObject(cfg);
|
||||
RefreshSOStatus();
|
||||
}
|
||||
|
||||
private void CreateFormWeapons()
|
||||
{
|
||||
string dir = $"{DataRoot}/Combat/Weapons";
|
||||
foreach (var id in new[] { "TianHun", "DiHun", "MingHun" })
|
||||
EditorScaffoldUtils.CreateSOAsset<WeaponSO>(dir, $"WPN_{id}");
|
||||
RefreshSOStatus();
|
||||
}
|
||||
|
||||
private void CreatePlayerDamageSources()
|
||||
{
|
||||
string dir = $"{DataRoot}/Combat/DamageSources";
|
||||
foreach (var label in new[] { "Attack1", "Attack2", "Attack3" })
|
||||
EditorScaffoldUtils.CreateSOAsset<DamageSourceSO>(dir, $"CMB_Player_{label}");
|
||||
RefreshSOStatus();
|
||||
}
|
||||
|
||||
// ── SO 资产工厂:玩家(Config 类)────────────────────────────────────────
|
||||
|
||||
private void CreateParryConfig()
|
||||
{
|
||||
var asset = EditorScaffoldUtils.CreateSOAsset<ParryConfigSO>(
|
||||
$"{DataRoot}/Player", "PLY_ParryConfig");
|
||||
if (asset != null) RefreshSOStatus();
|
||||
}
|
||||
|
||||
private void CreateShieldConfig()
|
||||
{
|
||||
var asset = EditorScaffoldUtils.CreateSOAsset<ShieldConfigSO>(
|
||||
$"{DataRoot}/Player", "PLY_ShieldConfig");
|
||||
if (asset != null) RefreshSOStatus();
|
||||
}
|
||||
|
||||
private void CreateEquipmentConfig()
|
||||
{
|
||||
var asset = EditorScaffoldUtils.CreateSOAsset<EquipmentConfigSO>(
|
||||
$"{DataRoot}/Player", "PLY_EquipmentConfig");
|
||||
if (asset != null) RefreshSOStatus();
|
||||
}
|
||||
|
||||
private void CreateCharmCatalog()
|
||||
{
|
||||
var asset = EditorScaffoldUtils.CreateSOAsset<CharmCatalogSO>(
|
||||
$"{DataRoot}/Progression/Charms", "PLY_CharmCatalog");
|
||||
if (asset != null) RefreshSOStatus();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 一键创建全部 Player 所需 SO(已存在则跳过)。
|
||||
/// 完成后提示用户点击"指定所有 SO 到场景角色"完成绑定。
|
||||
/// </summary>
|
||||
private void CreateAllPlayerSOs()
|
||||
{
|
||||
CreatePlayerStat();
|
||||
CreateMovementConfig();
|
||||
CreateAnimConfig();
|
||||
CreateFormConfig();
|
||||
CreateFormWeapons();
|
||||
CreatePlayerDamageSources();
|
||||
CreateParryConfig();
|
||||
CreateShieldConfig();
|
||||
CreateEquipmentConfig();
|
||||
CreateCharmCatalog();
|
||||
AssetDatabase.SaveAssets();
|
||||
EditorUtility.DisplayDialog("创建完成",
|
||||
"全部 Player SO 已创建(已存在的跳过)。\n" +
|
||||
"请在场景中放置角色后,点击「▣ 指定所有 SO 到场景角色」完成绑定。",
|
||||
"确定");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 查找场景中的 PlayerController,将项目中已存在的配置 SO 全部指定给对应组件字段。
|
||||
/// 使用 SerializedObject 赋值,自动标记 dirty 并保存。
|
||||
/// </summary>
|
||||
private void AssignAllPlayerSOsToScene()
|
||||
{
|
||||
var pc = UnityEngine.Object.FindObjectOfType<PlayerController>();
|
||||
if (pc == null)
|
||||
{
|
||||
EditorUtility.DisplayDialog("未找到角色",
|
||||
"场景中没有 PlayerController。\n请先使用「▣ 放置玩家到场景」。", "确定");
|
||||
return;
|
||||
}
|
||||
|
||||
var plyGo = pc.gameObject;
|
||||
int count = 0;
|
||||
|
||||
// 通过 SerializedObject 写入 SerializeField(支持撤销)
|
||||
var missing = new System.Collections.Generic.List<string>();
|
||||
|
||||
void TryAssign<T>(Component comp, string field) where T : ScriptableObject
|
||||
{
|
||||
if (comp == null) return;
|
||||
var so = EditorScaffoldUtils.FindAllAssetsOfType<T>().FirstOrDefault();
|
||||
if (so == null)
|
||||
{
|
||||
missing.Add($"{typeof(T).Name}({comp.GetType().Name}.{field})");
|
||||
return;
|
||||
}
|
||||
var sObj = new SerializedObject(comp);
|
||||
var prop = sObj.FindProperty(field);
|
||||
if (prop == null) return;
|
||||
prop.objectReferenceValue = so;
|
||||
sObj.ApplyModifiedProperties();
|
||||
count++;
|
||||
}
|
||||
|
||||
var stats = plyGo.GetComponent<PlayerStats>();
|
||||
var movement = plyGo.GetComponent<PlayerMovement>();
|
||||
var form = plyGo.GetComponent<FormController>();
|
||||
var parry = plyGo.GetComponent<ParrySystem>();
|
||||
var shield = plyGo.GetComponent<ShieldComponent>();
|
||||
var equip = plyGo.GetComponent<EquipmentManager>();
|
||||
var wall = plyGo.GetComponent<PlayerWallDetector>();
|
||||
|
||||
// PlayerStats
|
||||
TryAssign<PlayerStatsSO> (stats, "_config");
|
||||
// PlayerMovement
|
||||
TryAssign<PlayerMovementConfigSO> (movement, "_config");
|
||||
// PlayerController(多个字段)
|
||||
TryAssign<PlayerMovementConfigSO> (pc, "_movementConfig");
|
||||
TryAssign<PlayerAnimationConfigSO> (pc, "_animConfig");
|
||||
TryAssign<InputReaderSO> (pc, "_inputReader");
|
||||
TryAssign<FormConfigSO> (pc, "_formConfig");
|
||||
// FormController
|
||||
TryAssign<FormConfigSO> (form, "_config");
|
||||
// ParrySystem
|
||||
TryAssign<ParryConfigSO> (parry, "_config");
|
||||
// ShieldComponent
|
||||
TryAssign<ShieldConfigSO> (shield, "_config");
|
||||
// EquipmentManager
|
||||
TryAssign<EquipmentConfigSO> (equip, "_config");
|
||||
TryAssign<CharmCatalogSO> (equip, "_charmCatalog");
|
||||
// PlayerWallDetector(复用移动配置)
|
||||
TryAssign<PlayerMovementConfigSO> (wall, "_config");
|
||||
|
||||
EditorUtility.SetDirty(plyGo);
|
||||
RefreshSOStatus();
|
||||
|
||||
string msg = $"已将 {count} 个 SO 引用指定到场景角色 [{plyGo.name}]。";
|
||||
if (missing.Count > 0)
|
||||
msg += $"\n\n★ 以下 SO 尚未创建,请先点击工厂按钮创建后再次指定:\n • " +
|
||||
string.Join("\n • ", missing);
|
||||
else
|
||||
msg += "\n全部 SO 绑定完成,可在 Inspector 中确认各组件字段。";
|
||||
EditorUtility.DisplayDialog("指定完成", msg, "确定");
|
||||
}
|
||||
|
||||
// ── SO 资产工厂:小怪 ────────────────────────────────────────────────
|
||||
|
||||
private void CreateEnemyStat()
|
||||
{
|
||||
string dir = $"{DataRoot}/Enemies/{_enemyId}";
|
||||
var asset = EditorScaffoldUtils.CreateSOAsset<EnemyStatsSO>(dir, $"ENM_{_enemyId}_Stats");
|
||||
if (asset != null) RefreshSOStatus();
|
||||
}
|
||||
|
||||
private void CreateLootTable()
|
||||
{
|
||||
string dir = $"{DataRoot}/Enemies/{_enemyId}";
|
||||
var asset = EditorScaffoldUtils.CreateSOAsset<LootTableSO>(dir, $"ENM_{_enemyId}_Loot");
|
||||
if (asset != null) RefreshSOStatus();
|
||||
}
|
||||
|
||||
private void CreateEnemyAttackPatterns()
|
||||
{
|
||||
string dir = $"{DataRoot}/Enemies/{_enemyId}";
|
||||
foreach (var label in new[] { "Melee", "Ranged" })
|
||||
EditorScaffoldUtils.CreateSOAsset<AttackPatternSO>(dir, $"ENM_{_enemyId}_Pattern_{label}");
|
||||
RefreshSOStatus();
|
||||
}
|
||||
|
||||
private void CreateEnemyDamageSource()
|
||||
{
|
||||
string dir = $"{DataRoot}/Enemies/{_enemyId}";
|
||||
EditorScaffoldUtils.CreateSOAsset<DamageSourceSO>(dir, $"ENM_{_enemyId}_DS");
|
||||
RefreshSOStatus();
|
||||
}
|
||||
|
||||
// ── SO 资产工厂:Boss ─────────────────────────────────────────────────
|
||||
|
||||
private void CreateBossStat()
|
||||
{
|
||||
string dir = $"{DataRoot}/Boss/{_bossId}";
|
||||
EditorScaffoldUtils.CreateSOAsset<EnemyStatsSO>(dir, $"ENM_{_bossId}_Stats");
|
||||
RefreshSOStatus();
|
||||
}
|
||||
|
||||
private void CreateBossLoot()
|
||||
{
|
||||
string dir = $"{DataRoot}/Boss/{_bossId}";
|
||||
EditorScaffoldUtils.CreateSOAsset<LootTableSO>(dir, $"ENM_{_bossId}_Loot");
|
||||
RefreshSOStatus();
|
||||
}
|
||||
|
||||
private void CreateBossAttackPatterns()
|
||||
{
|
||||
string dir = $"{DataRoot}/Boss/{_bossId}/Patterns";
|
||||
foreach (var label in new[] { "Phase1", "Phase2_A", "Phase2_B" })
|
||||
EditorScaffoldUtils.CreateSOAsset<AttackPatternSO>(dir, $"ENM_{_bossId}_Pattern_{label}");
|
||||
RefreshSOStatus();
|
||||
}
|
||||
|
||||
private void CreateBossSkills()
|
||||
{
|
||||
string dir = $"{DataRoot}/Boss/{_bossId}/Skills";
|
||||
foreach (var label in new[] { "Skill_Slam", "Skill_Sweep", "Skill_Summon" })
|
||||
EditorScaffoldUtils.CreateSOAsset<BossSkillSO>(dir, $"SKL_{_bossId}_{label}");
|
||||
RefreshSOStatus();
|
||||
}
|
||||
|
||||
private void CreateBossSkillSequence(int phase)
|
||||
{
|
||||
string dir = $"{DataRoot}/Boss/{_bossId}/Skills";
|
||||
EditorScaffoldUtils.CreateSOAsset<SkillSequenceSO>(dir, $"SKL_{_bossId}_Phase{phase}_Sequence");
|
||||
RefreshSOStatus();
|
||||
}
|
||||
|
||||
private void CreateBossDamageSources()
|
||||
{
|
||||
string dir = $"{DataRoot}/Boss/{_bossId}/DamageSources";
|
||||
foreach (var label in new[] { "Slam", "Sweep", "Projectile" })
|
||||
EditorScaffoldUtils.CreateSOAsset<DamageSourceSO>(dir, $"ENM_{_bossId}_DS_{label}");
|
||||
RefreshSOStatus();
|
||||
}
|
||||
|
||||
// ── 场景搭建 ──────────────────────────────────────────────────────────
|
||||
|
||||
private void PlaceSelectedEnemyType()
|
||||
{
|
||||
switch (_enemyTypeIndex)
|
||||
{
|
||||
case 0: SceneObjectPlacerTool.PlaceEnemy(); break;
|
||||
case 1: SceneObjectPlacerTool.PlaceEnemy(); break; // 复用,类型通过 SO 区分
|
||||
case 2: SceneObjectPlacerTool.PlaceEnemy(); break;
|
||||
}
|
||||
}
|
||||
|
||||
// ── SO 状态面板刷新 ───────────────────────────────────────────────────
|
||||
|
||||
private void RefreshSOStatus()
|
||||
{
|
||||
_lastRefreshTime = EditorApplication.timeSinceStartup;
|
||||
BuildPlayerStatus();
|
||||
BuildEnemyStatus();
|
||||
BuildBossStatus();
|
||||
}
|
||||
|
||||
private void BuildPlayerStatus()
|
||||
{
|
||||
if (_playerStatusPanel == null) return;
|
||||
_playerStatusPanel.Clear();
|
||||
|
||||
var checks = new (string label, UnityEngine.Object asset)[]
|
||||
{
|
||||
("PlayerStatsSO", FindFirst<PlayerStatsSO>()),
|
||||
("PlayerMovementConfigSO", FindFirst<PlayerMovementConfigSO>()),
|
||||
("PlayerAnimationConfigSO", FindFirst<PlayerAnimationConfigSO>()),
|
||||
("FormConfigSO", FindFirst<FormConfigSO>()),
|
||||
("FormSO(天魂)", FindFormOfType(FormType.TianHun)),
|
||||
("FormSO(地魂)", FindFormOfType(FormType.DiHun)),
|
||||
("FormSO(命魂)", FindFormOfType(FormType.MingHun)),
|
||||
("WeaponSO(≥3)", FindFirstIfCount<WeaponSO>(3)),
|
||||
("ParryConfigSO", FindFirst<ParryConfigSO>()),
|
||||
("ShieldConfigSO", FindFirst<ShieldConfigSO>()),
|
||||
("EquipmentConfigSO", FindFirst<EquipmentConfigSO>()),
|
||||
("CharmCatalogSO", FindFirst<CharmCatalogSO>()),
|
||||
};
|
||||
|
||||
_playerStatusPanel.Add(MakeStatusGrid(checks));
|
||||
}
|
||||
|
||||
private void BuildEnemyStatus()
|
||||
{
|
||||
if (_enemyStatusPanel == null) return;
|
||||
_enemyStatusPanel.Clear();
|
||||
|
||||
string dir = $"{DataRoot}/Enemies/{_enemyId}";
|
||||
var checks = new (string label, UnityEngine.Object asset)[]
|
||||
{
|
||||
("EnemyStatsSO", FindAtPath<EnemyStatsSO>($"{dir}/ENM_{_enemyId}_Stats.asset")),
|
||||
("LootTableSO", FindAtPath<LootTableSO>($"{dir}/ENM_{_enemyId}_Loot.asset")),
|
||||
("AttackPatternSO×2", FindAtPath<AttackPatternSO>($"{dir}/ENM_{_enemyId}_Pattern_Melee.asset")),
|
||||
("DamageSourceSO", FindAtPath<DamageSourceSO>($"{dir}/ENM_{_enemyId}_DS.asset")),
|
||||
};
|
||||
|
||||
_enemyStatusPanel.Add(MakeStatusGrid(checks));
|
||||
}
|
||||
|
||||
private void BuildBossStatus()
|
||||
{
|
||||
if (_bossStatusPanel == null) return;
|
||||
_bossStatusPanel.Clear();
|
||||
|
||||
string dir = $"{DataRoot}/Boss/{_bossId}";
|
||||
var checks = new (string label, UnityEngine.Object asset)[]
|
||||
{
|
||||
("EnemyStatsSO(Boss)", FindAtPath<EnemyStatsSO>($"{dir}/ENM_{_bossId}_Stats.asset")),
|
||||
("LootTableSO", FindAtPath<LootTableSO>($"{dir}/ENM_{_bossId}_Loot.asset")),
|
||||
("AttackPatternSO(Phase1)", FindAtPath<AttackPatternSO>($"{dir}/Patterns/ENM_{_bossId}_Pattern_Phase1.asset")),
|
||||
("BossSkillSO(≥1)", EditorScaffoldUtils.FindAllAssetsOfType<BossSkillSO>()
|
||||
.FirstOrDefault(s => s.name.StartsWith("SKL_" + _bossId, StringComparison.OrdinalIgnoreCase))),
|
||||
("SkillSequenceSO(Phase1)", FindAtPath<SkillSequenceSO>($"{dir}/Skills/SKL_{_bossId}_Phase1_Sequence.asset")),
|
||||
};
|
||||
|
||||
_bossStatusPanel.Add(MakeStatusGrid(checks));
|
||||
}
|
||||
|
||||
// ── 辅助:状态格 ─────────────────────────────────────────────────────
|
||||
|
||||
private static VisualElement MakeStatusGrid((string label, UnityEngine.Object asset)[] items)
|
||||
{
|
||||
var grid = new VisualElement();
|
||||
grid.style.flexDirection = FlexDirection.Row;
|
||||
grid.style.flexWrap = Wrap.Wrap;
|
||||
grid.style.marginBottom = 6;
|
||||
|
||||
foreach (var (label, asset) in items)
|
||||
{
|
||||
bool exists = asset != null;
|
||||
VisualElement chip;
|
||||
if (exists)
|
||||
{
|
||||
var captured = asset;
|
||||
var btn = new Button(() =>
|
||||
{
|
||||
EditorGUIUtility.PingObject(captured);
|
||||
Selection.activeObject = captured;
|
||||
}) { text = $"✔ {label}" };
|
||||
chip = btn;
|
||||
}
|
||||
else
|
||||
{
|
||||
chip = new Label($"✘ {label}");
|
||||
}
|
||||
|
||||
chip.style.marginRight = 6;
|
||||
chip.style.marginBottom = 4;
|
||||
chip.style.paddingLeft = 6;
|
||||
chip.style.paddingRight = 6;
|
||||
chip.style.paddingTop = 2;
|
||||
chip.style.paddingBottom = 2;
|
||||
chip.style.borderTopLeftRadius = 4;
|
||||
chip.style.borderTopRightRadius = 4;
|
||||
chip.style.borderBottomLeftRadius = 4;
|
||||
chip.style.borderBottomRightRadius = 4;
|
||||
|
||||
if (exists)
|
||||
{
|
||||
chip.style.backgroundColor = new Color(0.18f, 0.55f, 0.22f, 0.85f);
|
||||
chip.style.color = new Color(0.85f, 1.00f, 0.85f);
|
||||
}
|
||||
else
|
||||
{
|
||||
chip.style.backgroundColor = new Color(0.65f, 0.35f, 0.05f, 0.85f);
|
||||
chip.style.color = new Color(1.00f, 0.90f, 0.70f);
|
||||
}
|
||||
|
||||
grid.Add(chip);
|
||||
}
|
||||
return grid;
|
||||
}
|
||||
|
||||
// ── 辅助:UI 组件构建 ─────────────────────────────────────────────────
|
||||
|
||||
private static VisualElement MakeTabContent()
|
||||
{
|
||||
var root = new ScrollView(ScrollViewMode.Vertical);
|
||||
root.AddToClassList("tab-content");
|
||||
root.style.flexGrow = 1;
|
||||
root.contentContainer.style.paddingLeft = 8;
|
||||
root.contentContainer.style.paddingRight = 8;
|
||||
root.contentContainer.style.paddingTop = 8;
|
||||
root.contentContainer.style.paddingBottom = 8;
|
||||
return root;
|
||||
}
|
||||
|
||||
private static Label MakeSectionHeader(string text)
|
||||
{
|
||||
var lbl = new Label(text);
|
||||
lbl.AddToClassList("section-header");
|
||||
return lbl;
|
||||
}
|
||||
|
||||
private static VisualElement MakeActionGroup()
|
||||
{
|
||||
var group = new VisualElement();
|
||||
group.AddToClassList("action-buttons");
|
||||
return group;
|
||||
}
|
||||
|
||||
private static Button MakeFactoryButton(string label, Action onClick)
|
||||
{
|
||||
var btn = new Button(onClick) { text = $"+ {label}" };
|
||||
btn.AddToClassList("wizard-factory-btn");
|
||||
return btn;
|
||||
}
|
||||
|
||||
private static Button MakeSceneButton(string label, Action onClick)
|
||||
{
|
||||
var btn = new Button(onClick) { text = $"▣ {label}" };
|
||||
btn.AddToClassList("wizard-scene-btn");
|
||||
return btn;
|
||||
}
|
||||
|
||||
private static Button MakeJumpButton(string label, Action onClick)
|
||||
{
|
||||
var btn = new Button(onClick) { text = $"⇒ {label}" };
|
||||
btn.AddToClassList("wizard-jump-btn");
|
||||
return btn;
|
||||
}
|
||||
|
||||
private static HelpBox MakeHelpBox(string text)
|
||||
{
|
||||
return new HelpBox(text, HelpBoxMessageType.Info);
|
||||
}
|
||||
|
||||
private static VisualElement MakeSeparator()
|
||||
{
|
||||
var sep = new VisualElement();
|
||||
sep.style.height = 1;
|
||||
sep.style.backgroundColor = new Color(0.3f, 0.3f, 0.3f, 0.6f);
|
||||
sep.style.marginTop = 8;
|
||||
sep.style.marginBottom = 8;
|
||||
return sep;
|
||||
}
|
||||
|
||||
private static VisualElement MakeLabeledTextField(string label, string value, Action<string> onChange)
|
||||
{
|
||||
var row = new VisualElement();
|
||||
row.style.flexDirection = FlexDirection.Row;
|
||||
row.style.marginBottom = 4;
|
||||
row.style.alignItems = Align.Center;
|
||||
|
||||
var lbl = new Label(label) { style = { minWidth = 110, marginRight = 6 } };
|
||||
var field = new TextField { value = value, style = { flexGrow = 1 } };
|
||||
field.RegisterValueChangedCallback(e => onChange(e.newValue));
|
||||
row.Add(lbl);
|
||||
row.Add(field);
|
||||
return row;
|
||||
}
|
||||
|
||||
private void RefreshEnemyTypeButtons(VisualElement tabRoot)
|
||||
{
|
||||
for (int i = 0; i < EnemyTypeLabels.Length; i++)
|
||||
{
|
||||
var btn = tabRoot.Q<Button>($"enemy-type-{i}");
|
||||
btn?.EnableInClassList("type-btn--active", i == _enemyTypeIndex);
|
||||
}
|
||||
}
|
||||
|
||||
// ── 资产查找辅助 ──────────────────────────────────────────
|
||||
|
||||
private static T FindFirst<T>() where T : ScriptableObject
|
||||
=> EditorScaffoldUtils.FindAllAssetsOfType<T>().FirstOrDefault();
|
||||
|
||||
/// <summary>返回第一个,仅当总数达到 minCount 时才认为满足。</summary>
|
||||
private static T FindFirstIfCount<T>(int minCount) where T : ScriptableObject
|
||||
{
|
||||
var list = EditorScaffoldUtils.FindAllAssetsOfType<T>();
|
||||
return list.Count >= minCount ? list[0] : null;
|
||||
}
|
||||
|
||||
private static T FindAtPath<T>(string path) where T : UnityEngine.Object
|
||||
=> AssetDatabase.LoadAssetAtPath<T>(path);
|
||||
|
||||
private static FormSO FindFormOfType(FormType type)
|
||||
=> EditorScaffoldUtils.FindAllAssetsOfType<FormSO>()
|
||||
.FirstOrDefault(f => f.formType == type);
|
||||
}
|
||||
}
|
||||
11
Assets/_Game/Scripts/Editor/CharacterWizardWindow.cs.meta
Normal file
11
Assets/_Game/Scripts/Editor/CharacterWizardWindow.cs.meta
Normal file
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 301eee333a6bf174bac93f44362e72bd
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -197,11 +197,10 @@ namespace BaseGames.Editor.Combat
|
||||
_detailRoot.Add(btnRow);
|
||||
}
|
||||
|
||||
/// <summary>attack1 → attack2 → attack3 连击链数值横排预览。</summary>
|
||||
/// <summary>连击序列数值横排预览。</summary>
|
||||
private void BuildComboPreview(WeaponSO weapon)
|
||||
{
|
||||
// 只在有至少一个连击数据时显示
|
||||
if (weapon.attack1Source == null && weapon.attack2Source == null && weapon.attack3Source == null)
|
||||
if (weapon.groundComboSteps == null || weapon.groundComboSteps.Length == 0)
|
||||
return;
|
||||
|
||||
var section = new Label("连击链预览") { style = { unityFontStyleAndWeight = FontStyle.Bold, marginBottom = 4 } };
|
||||
@@ -210,8 +209,11 @@ namespace BaseGames.Editor.Combat
|
||||
var chain = new VisualElement();
|
||||
chain.AddToClassList("stats-preview");
|
||||
|
||||
void AddSegment(string label, ClipTransition clip, DamageSourceSO src, bool addArrow)
|
||||
for (int i = 0; i < weapon.groundComboSteps.Length; i++)
|
||||
{
|
||||
var step = weapon.groundComboSteps[i];
|
||||
bool addArrow = i < weapon.groundComboSteps.Length - 1;
|
||||
|
||||
var cell = new VisualElement
|
||||
{
|
||||
style =
|
||||
@@ -230,24 +232,21 @@ namespace BaseGames.Editor.Combat
|
||||
}
|
||||
};
|
||||
|
||||
// 段名
|
||||
cell.Add(new Label(label)
|
||||
cell.Add(new Label($"攻击{i + 1}")
|
||||
{
|
||||
style = { fontSize = 10, color = new Color(0.65f, 0.65f, 0.65f) }
|
||||
});
|
||||
|
||||
// Clip 名称
|
||||
string clipName = clip?.Clip != null ? clip.Clip.name : "<无动画>";
|
||||
string clipName = step.clip?.Clip != null ? step.clip.Clip.name : "<无动画>";
|
||||
cell.Add(new Label(clipName)
|
||||
{
|
||||
style = { fontSize = 11, unityFontStyleAndWeight = FontStyle.Bold }
|
||||
});
|
||||
|
||||
// 伤害数值
|
||||
if (src != null)
|
||||
if (step.damageSource != null)
|
||||
{
|
||||
int dmg = Mathf.RoundToInt(src.BaseDamage * src.DamageMultiplier);
|
||||
cell.Add(new Label($"伤害 {dmg} [{src.BreakLevel}]")
|
||||
int dmg = Mathf.RoundToInt(step.damageSource.BaseDamage * step.damageSource.DamageMultiplier);
|
||||
cell.Add(new Label($"伤害 {dmg} [{step.damageSource.BreakLevel}]")
|
||||
{
|
||||
style = { fontSize = 10, color = new Color(1f, 0.7f, 0.3f) }
|
||||
});
|
||||
@@ -266,10 +265,6 @@ namespace BaseGames.Editor.Combat
|
||||
chain.Add(new Label("→") { style = { alignSelf = Align.Center, marginLeft = 2, marginRight = 2 } });
|
||||
}
|
||||
|
||||
AddSegment("攻击1", weapon.attack1Clip, weapon.attack1Source, true);
|
||||
AddSegment("攻击2", weapon.attack2Clip, weapon.attack2Source, true);
|
||||
AddSegment("攻击3", weapon.attack3Clip, weapon.attack3Source, false);
|
||||
|
||||
_detailRoot.Add(chain);
|
||||
|
||||
// 追加空中/上/下攻击的简要行
|
||||
@@ -288,9 +283,9 @@ namespace BaseGames.Editor.Combat
|
||||
});
|
||||
}
|
||||
|
||||
ExtraStat("空中", weapon.airAttackSource);
|
||||
ExtraStat("上挑", weapon.upAttackSource);
|
||||
ExtraStat("下砸", weapon.downAttackSource);
|
||||
ExtraStat("空中", weapon.airComboSteps?[0].damageSource);
|
||||
ExtraStat("上劈", weapon.upStep.damageSource);
|
||||
ExtraStat("下劈", weapon.downStep.damageSource);
|
||||
|
||||
if (extraRow.childCount > 0)
|
||||
_detailRoot.Add(extraRow);
|
||||
|
||||
118
Assets/_Game/Scripts/Editor/Combat/WeaponSOEditor.cs
Normal file
118
Assets/_Game/Scripts/Editor/Combat/WeaponSOEditor.cs
Normal file
@@ -0,0 +1,118 @@
|
||||
using UnityEditor;
|
||||
using UnityEngine;
|
||||
using BaseGames.Combat;
|
||||
using BaseGames.Player;
|
||||
|
||||
namespace BaseGames.Editor.Combat
|
||||
{
|
||||
/// <summary>
|
||||
/// WeaponSO 自定义 Inspector:在 hitBoxPrefab 下方提供一键生成按钮。
|
||||
/// 等同于菜单 BaseGames / Create / Weapon HitBox Prefab,但已预填 weaponId 并自动赋值。
|
||||
/// </summary>
|
||||
[CustomEditor(typeof(WeaponSO))]
|
||||
public class WeaponSOEditor : UnityEditor.Editor
|
||||
{
|
||||
private const string OutputFolder = "Assets/_Game/Prefabs/Weapons";
|
||||
|
||||
public override void OnInspectorGUI()
|
||||
{
|
||||
DrawDefaultInspector();
|
||||
|
||||
var weapon = (WeaponSO)target;
|
||||
|
||||
EditorGUILayout.Space(6);
|
||||
EditorGUILayout.LabelField("HitBox Prefab 工具", EditorStyles.boldLabel);
|
||||
|
||||
bool hasId = !string.IsNullOrWhiteSpace(weapon.weaponId);
|
||||
bool hasPrefab = weapon.hitBoxPrefab != null;
|
||||
|
||||
if (!hasId)
|
||||
{
|
||||
EditorGUILayout.HelpBox("请先填写 weaponId,再生成 HitBox Prefab。", MessageType.Info);
|
||||
return;
|
||||
}
|
||||
|
||||
string prefabPath = $"{OutputFolder}/WPN_{weapon.weaponId}_HitBox.prefab";
|
||||
EditorGUILayout.HelpBox($"输出路径:{prefabPath}", MessageType.None);
|
||||
|
||||
string btnLabel = hasPrefab ? "重新生成 HitBox Prefab" : "一键生成 HitBox Prefab";
|
||||
if (GUILayout.Button(btnLabel))
|
||||
{
|
||||
if (hasPrefab && !EditorUtility.DisplayDialog(
|
||||
"确认重新生成",
|
||||
$"hitBoxPrefab 已有引用,是否覆盖并重新赋值?\n\n{prefabPath}",
|
||||
"覆盖", "取消"))
|
||||
return;
|
||||
|
||||
var prefab = CreateHitBoxPrefab(weapon.weaponId, prefabPath);
|
||||
if (prefab != null)
|
||||
{
|
||||
Undo.RecordObject(weapon, "Assign HitBox Prefab");
|
||||
weapon.hitBoxPrefab = prefab;
|
||||
EditorUtility.SetDirty(weapon);
|
||||
AssetDatabase.SaveAssets();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── 创建逻辑 ──────────────────────────────────────────────────────────
|
||||
|
||||
private static GameObject CreateHitBoxPrefab(string weaponId, string assetPath)
|
||||
{
|
||||
EditorScaffoldUtils.EnsureFolder(OutputFolder);
|
||||
|
||||
int layer = LayerMask.NameToLayer("PlayerHitBox");
|
||||
if (layer < 0)
|
||||
{
|
||||
Debug.LogWarning("[WeaponSOEditor] 未找到 Physics Layer 'PlayerHitBox',子节点 Layer 将设为 Default。");
|
||||
layer = 0;
|
||||
}
|
||||
|
||||
string prefabName = $"WPN_{weaponId}_HitBox";
|
||||
var root = new GameObject(prefabName);
|
||||
var instance = root.AddComponent<WeaponHitBoxInstance>();
|
||||
var so = new SerializedObject(instance);
|
||||
|
||||
AddHitBoxChild(root, so, "HitBox_Ground", "_hitBoxGround", layer, new Vector2(1f, 0.5f));
|
||||
AddHitBoxChild(root, so, "HitBox_Up", "_hitBoxUp", layer, new Vector2(0.5f, 1f ));
|
||||
AddHitBoxChild(root, so, "HitBox_Down", "_hitBoxDown", layer, new Vector2(1f, 0.5f));
|
||||
AddHitBoxChild(root, so, "HitBox_Air", "_hitBoxAir", layer, new Vector2(0.5f, 1f ));
|
||||
|
||||
so.ApplyModifiedPropertiesWithoutUndo();
|
||||
|
||||
var prefab = PrefabUtility.SaveAsPrefabAsset(root, assetPath);
|
||||
Object.DestroyImmediate(root);
|
||||
AssetDatabase.Refresh();
|
||||
|
||||
if (prefab != null)
|
||||
{
|
||||
EditorScaffoldUtils.PingAndSelect(prefab);
|
||||
Debug.Log($"[WeaponSOEditor] 已创建:{assetPath}");
|
||||
}
|
||||
else
|
||||
{
|
||||
Debug.LogError($"[WeaponSOEditor] Prefab 保存失败:{assetPath}");
|
||||
}
|
||||
|
||||
return prefab;
|
||||
}
|
||||
|
||||
private static void AddHitBoxChild(GameObject root, SerializedObject so,
|
||||
string nodeName, string fieldName,
|
||||
int layer, Vector2 boxSize)
|
||||
{
|
||||
var child = new GameObject(nodeName);
|
||||
child.transform.SetParent(root.transform, false);
|
||||
child.layer = layer;
|
||||
|
||||
var col = child.AddComponent<BoxCollider2D>();
|
||||
col.isTrigger = true;
|
||||
col.size = boxSize;
|
||||
|
||||
var hb = child.AddComponent<HitBox>();
|
||||
var prop = so.FindProperty(fieldName);
|
||||
if (prop != null)
|
||||
prop.objectReferenceValue = hb;
|
||||
}
|
||||
}
|
||||
}
|
||||
11
Assets/_Game/Scripts/Editor/Combat/WeaponSOEditor.cs.meta
Normal file
11
Assets/_Game/Scripts/Editor/Combat/WeaponSOEditor.cs.meta
Normal file
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: df9abfb2b89aa244bbcc1f4e62694dd6
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -48,6 +48,7 @@ namespace BaseGames.Editor
|
||||
CreateAsset<VoidEventChannelSO> ("Combat", "EVT_PlayerRespawned");
|
||||
CreateAsset<VoidEventChannelSO> ("Combat", "EVT_RespawnStarted");
|
||||
CreateAsset<VoidEventChannelSO> ("Combat", "EVT_RespawnCompleted");
|
||||
CreateAsset<VoidEventChannelSO> ("Combat", "EVT_CheckpointRespawn");
|
||||
|
||||
// ── Boss ──────────────────────────────────────────────────────────
|
||||
CreateAsset<BossSkillEventChannelSO> ("Boss", "EVT_BossSkill");
|
||||
@@ -70,6 +71,8 @@ namespace BaseGames.Editor
|
||||
|
||||
// ── World ─────────────────────────────────────────────────────────
|
||||
CreateAsset<StringEventChannelSO> ("World", "EVT_SavePointActivated");
|
||||
CreateAsset<VoidEventChannelSO> ("World", "EVT_CheckpointReached");
|
||||
CreateAsset<StringEventChannelSO> ("World", "EVT_DoorOpened"); // 开门/交互机关(钥匙、机关等)触发自动存档
|
||||
|
||||
// ── 对话/商店 ─────────────────────────────────────────────────────
|
||||
CreateAsset<ShopPurchaseEventChannelSO> ("Dialogue", "EVT_ShopPurchase");
|
||||
@@ -92,6 +95,7 @@ namespace BaseGames.Editor
|
||||
// ── 进度/成就 ─────────────────────────────────────────────────────
|
||||
CreateAsset<ToolUsedEventChannelSO> ("Progression", "EVT_ToolUsed");
|
||||
CreateAsset<AchievementEventChannelSO> ("Progression", "EVT_AchievementUnlocked");
|
||||
CreateAsset<StringEventChannelSO> ("Progression", "EVT_MaxHPContainerPickedUp");
|
||||
|
||||
AssetDatabase.SaveAssets();
|
||||
AssetDatabase.Refresh();
|
||||
|
||||
413
Assets/_Game/Scripts/Editor/FormEditorWindow.cs
Normal file
413
Assets/_Game/Scripts/Editor/FormEditorWindow.cs
Normal file
@@ -0,0 +1,413 @@
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using UnityEditor;
|
||||
using UnityEditor.UIElements;
|
||||
using UnityEngine;
|
||||
using UnityEngine.UIElements;
|
||||
using BaseGames.Player;
|
||||
|
||||
namespace BaseGames.Editor
|
||||
{
|
||||
/// <summary>
|
||||
/// 形态系统可视化编辑器(W-06)。
|
||||
/// 技术:UI Toolkit TwoPaneSplitView + 三列形态网格。
|
||||
/// 菜单:BaseGames / Data / Form Editor
|
||||
///
|
||||
/// 左栏:FormConfigSO 列表 + [新建] 按钮。
|
||||
/// 右栏:
|
||||
/// · 三列网格(天魂 / 地魂 / 命魂),每列显示对应 FormSO 详情及武器引用。
|
||||
/// · 各列可独立新建/重新绑定 FormSO。
|
||||
/// · "一键自动填充" 按钮:按 formType 枚举在 Project 中搜索已有 FormSO 并赋值。
|
||||
/// · 底部:在 Project 中选中 FormConfigSO / 在 Inspector 中编辑 原始字段。
|
||||
/// </summary>
|
||||
public class FormEditorWindow : EditorWindow
|
||||
{
|
||||
// ── 常量 ──────────────────────────────────────────────────────────────
|
||||
private const string UssPath = "Assets/_Game/Scripts/Editor/UIToolkit/Editor.uss";
|
||||
private const string DataRoot = "Assets/_Game/Data/Player/Forms";
|
||||
|
||||
private static readonly StyleSheet _uss;
|
||||
private static readonly (FormType type, string label, Color accent)[] FormDefs =
|
||||
{
|
||||
(FormType.TianHun, "天魂", new Color(0.40f, 0.70f, 1.00f)),
|
||||
(FormType.DiHun, "地魂", new Color(0.55f, 0.85f, 0.40f)),
|
||||
(FormType.MingHun, "命魂", new Color(0.80f, 0.30f, 0.30f)),
|
||||
};
|
||||
|
||||
static FormEditorWindow()
|
||||
{
|
||||
_uss = AssetDatabase.LoadAssetAtPath<StyleSheet>(UssPath);
|
||||
}
|
||||
|
||||
[MenuItem("BaseGames/Data/Form Editor", priority = 103)]
|
||||
public static void Open()
|
||||
{
|
||||
var wnd = GetWindow<FormEditorWindow>();
|
||||
wnd.titleContent = new GUIContent("Form Editor");
|
||||
wnd.minSize = new Vector2(760, 420);
|
||||
}
|
||||
|
||||
// ── 状态 ──────────────────────────────────────────────────────────────
|
||||
private List<FormConfigSO> _configs = new();
|
||||
private List<FormConfigSO> _filtered = new();
|
||||
private FormConfigSO _selected;
|
||||
private string _searchText = "";
|
||||
|
||||
private ListView _configList;
|
||||
private VisualElement _detailRoot;
|
||||
private InspectorElement _rawInspector;
|
||||
|
||||
// 三列形态格(每列一个 FormSO ObjectField)
|
||||
private readonly ObjectField[] _formFields = new ObjectField[3];
|
||||
private readonly ObjectField[] _weaponFields = new ObjectField[3];
|
||||
private readonly VisualElement[]_columnRoots = new VisualElement[3];
|
||||
|
||||
// ── 生命周期 ──────────────────────────────────────────────────────────
|
||||
|
||||
public void CreateGUI()
|
||||
{
|
||||
if (_uss != null)
|
||||
rootVisualElement.styleSheets.Add(_uss);
|
||||
|
||||
// ── Toolbar ───────────────────────────────────────────────────────
|
||||
var toolbar = new Toolbar();
|
||||
var search = new ToolbarSearchField { style = { flexGrow = 1 } };
|
||||
search.RegisterValueChangedCallback(e => { _searchText = e.newValue; RefreshFilter(); });
|
||||
search.tooltip = "按名称过滤 FormConfigSO";
|
||||
toolbar.Add(search);
|
||||
|
||||
var btnNew = new ToolbarButton(CreateNewFormConfig) { text = "+ 新建 FormConfig" };
|
||||
var btnRefresh = new ToolbarButton(RefreshAll) { text = "↺" };
|
||||
btnRefresh.tooltip = "重新扫描 Project 中的 FormConfigSO 资产";
|
||||
toolbar.Add(btnNew);
|
||||
toolbar.Add(btnRefresh);
|
||||
rootVisualElement.Add(toolbar);
|
||||
|
||||
// ── 分栏 ──────────────────────────────────────────────────────────
|
||||
var split = new TwoPaneSplitView(0, 200, TwoPaneSplitViewOrientation.Horizontal);
|
||||
|
||||
// 左栏
|
||||
var leftPane = new VisualElement { style = { minWidth = 140 } };
|
||||
|
||||
_configList = new ListView
|
||||
{
|
||||
selectionType = SelectionType.Single,
|
||||
makeItem = MakeListItem,
|
||||
bindItem = BindListItem,
|
||||
style = { flexGrow = 1 },
|
||||
showAlternatingRowBackgrounds = AlternatingRowBackground.ContentOnly,
|
||||
};
|
||||
_configList.selectionChanged += OnConfigSelected;
|
||||
leftPane.Add(_configList);
|
||||
split.Add(leftPane);
|
||||
|
||||
// 右栏(ScrollView)
|
||||
_detailRoot = new ScrollView(ScrollViewMode.Vertical) { style = { flexGrow = 1 } };
|
||||
_detailRoot.contentContainer.style.paddingLeft = 10;
|
||||
_detailRoot.contentContainer.style.paddingRight = 10;
|
||||
_detailRoot.contentContainer.style.paddingTop = 10;
|
||||
_detailRoot.contentContainer.style.paddingBottom = 10;
|
||||
_detailRoot.Add(new HelpBox("← 在左侧选择一个 FormConfigSO 开始编辑", HelpBoxMessageType.Info));
|
||||
split.Add(_detailRoot);
|
||||
|
||||
rootVisualElement.Add(split);
|
||||
|
||||
RefreshAll();
|
||||
}
|
||||
|
||||
// ── 列表 ──────────────────────────────────────────────────────────────
|
||||
|
||||
private VisualElement MakeListItem()
|
||||
{
|
||||
var label = new Label();
|
||||
label.AddToClassList("list-item");
|
||||
return label;
|
||||
}
|
||||
|
||||
private void BindListItem(VisualElement ve, int idx)
|
||||
{
|
||||
if (ve is Label lbl && idx < _filtered.Count)
|
||||
lbl.text = _filtered[idx].name;
|
||||
}
|
||||
|
||||
private void OnConfigSelected(IEnumerable<object> objs)
|
||||
{
|
||||
_selected = objs.FirstOrDefault() as FormConfigSO;
|
||||
RebuildDetail();
|
||||
}
|
||||
|
||||
private void RefreshFilter()
|
||||
{
|
||||
string s = _searchText.ToLowerInvariant();
|
||||
_filtered = string.IsNullOrEmpty(s)
|
||||
? new List<FormConfigSO>(_configs)
|
||||
: _configs.Where(c => c.name.ToLowerInvariant().Contains(s)).ToList();
|
||||
_configList.itemsSource = _filtered;
|
||||
_configList.Rebuild();
|
||||
}
|
||||
|
||||
private void RefreshAll()
|
||||
{
|
||||
_configs = EditorScaffoldUtils.FindAllAssetsOfType<FormConfigSO>();
|
||||
_configs.Sort((a, b) => string.Compare(a.name, b.name, System.StringComparison.Ordinal));
|
||||
RefreshFilter();
|
||||
}
|
||||
|
||||
// ── 右栏详情 ──────────────────────────────────────────────────────────
|
||||
|
||||
private void RebuildDetail()
|
||||
{
|
||||
_detailRoot.Clear();
|
||||
if (_selected == null) return;
|
||||
|
||||
// 标题 + 快捷操作
|
||||
var header = new VisualElement { style = { flexDirection = FlexDirection.Row, alignItems = Align.Center, marginBottom = 8 } };
|
||||
header.Add(new Label(_selected.name) { style = { fontSize = 14, unityFontStyleAndWeight = FontStyle.Bold, flexGrow = 1 } });
|
||||
|
||||
var btnPing = new Button(() => EditorGUIUtility.PingObject(_selected)) { text = "⌖ Ping" };
|
||||
var btnAuto = new Button(AutoFillForms) { text = "自动填充形态", tooltip = "按 formType 枚举在 Project 中搜索已有 FormSO 并赋值到 forms[] 数组" };
|
||||
header.Add(btnAuto);
|
||||
header.Add(btnPing);
|
||||
_detailRoot.Add(header);
|
||||
|
||||
// 三列形态网格
|
||||
var formGrid = new VisualElement();
|
||||
formGrid.style.flexDirection = FlexDirection.Row;
|
||||
formGrid.style.marginBottom = 10;
|
||||
_detailRoot.Add(formGrid);
|
||||
|
||||
for (int i = 0; i < 3; i++)
|
||||
formGrid.Add(BuildFormColumn(i));
|
||||
|
||||
// 分割线
|
||||
_detailRoot.Add(MakeSeparator());
|
||||
|
||||
// 原始 Inspector(完整字段编辑)
|
||||
var inspHeader = new Label("原始 Inspector 编辑") { style = { unityFontStyleAndWeight = FontStyle.Bold, marginBottom = 4 } };
|
||||
_detailRoot.Add(inspHeader);
|
||||
|
||||
_rawInspector = new InspectorElement(_selected);
|
||||
_detailRoot.Add(_rawInspector);
|
||||
}
|
||||
|
||||
private VisualElement BuildFormColumn(int colIdx)
|
||||
{
|
||||
var (formType, label, accent) = FormDefs[colIdx];
|
||||
|
||||
var col = new VisualElement();
|
||||
col.style.flexGrow = 1;
|
||||
col.style.marginRight = colIdx < 2 ? 8 : 0;
|
||||
col.style.borderTopWidth = 2;
|
||||
col.style.borderTopColor = accent;
|
||||
col.style.borderLeftWidth = 1;
|
||||
col.style.borderRightWidth = 1;
|
||||
col.style.borderBottomWidth = 1;
|
||||
col.style.borderLeftColor = new Color(accent.r, accent.g, accent.b, 0.35f);
|
||||
col.style.borderRightColor = new Color(accent.r, accent.g, accent.b, 0.35f);
|
||||
col.style.borderBottomColor = new Color(accent.r, accent.g, accent.b, 0.35f);
|
||||
col.style.borderTopLeftRadius = 4;
|
||||
col.style.borderTopRightRadius = 4;
|
||||
col.style.borderBottomLeftRadius = 4;
|
||||
col.style.borderBottomRightRadius = 4;
|
||||
col.style.paddingLeft = 8;
|
||||
col.style.paddingRight = 8;
|
||||
col.style.paddingTop = 8;
|
||||
col.style.paddingBottom = 8;
|
||||
_columnRoots[colIdx] = col;
|
||||
|
||||
// 列标题 + 色块
|
||||
var titleRow = new VisualElement { style = { flexDirection = FlexDirection.Row, alignItems = Align.Center, marginBottom = 6 } };
|
||||
var colorDot = new VisualElement();
|
||||
colorDot.style.width = 12;
|
||||
colorDot.style.height = 12;
|
||||
colorDot.style.borderTopLeftRadius = 6;
|
||||
colorDot.style.borderTopRightRadius = 6;
|
||||
colorDot.style.borderBottomLeftRadius = 6;
|
||||
colorDot.style.borderBottomRightRadius = 6;
|
||||
colorDot.style.backgroundColor = accent;
|
||||
colorDot.style.marginRight = 6;
|
||||
titleRow.Add(colorDot);
|
||||
titleRow.Add(new Label(label) { style = { unityFontStyleAndWeight = FontStyle.Bold } });
|
||||
col.Add(titleRow);
|
||||
|
||||
// 当前 FormSO 显示
|
||||
FormSO current = GetFormByType(formType);
|
||||
|
||||
var formField = new ObjectField("FormSO") { objectType = typeof(FormSO), value = current };
|
||||
formField.RegisterValueChangedCallback(e =>
|
||||
{
|
||||
SetFormByType(formType, e.newValue as FormSO);
|
||||
// 联动刷新武器字段
|
||||
if (_weaponFields[colIdx] != null)
|
||||
_weaponFields[colIdx].value = (e.newValue as FormSO)?.defaultWeapon;
|
||||
});
|
||||
_formFields[colIdx] = formField;
|
||||
col.Add(formField);
|
||||
|
||||
// 武器预览(只读,跟随 FormSO.defaultWeapon)
|
||||
var weaponField = new ObjectField("默认武器") { objectType = typeof(WeaponSO), value = current?.defaultWeapon };
|
||||
weaponField.SetEnabled(false);
|
||||
_weaponFields[colIdx] = weaponField;
|
||||
col.Add(weaponField);
|
||||
|
||||
// 新建该形态 FormSO
|
||||
var btnCreate = new Button(() => CreateFormForType(formType, colIdx))
|
||||
{
|
||||
text = current == null ? $"+ 新建 {label} FormSO" : "重新新建(覆盖)",
|
||||
tooltip = $"在 {DataRoot} 创建 Form_{formType}.asset",
|
||||
style = { marginTop = 8 }
|
||||
};
|
||||
col.Add(btnCreate);
|
||||
|
||||
// 新建该形态 WeaponSO
|
||||
var btnWeapon = new Button(() => CreateWeaponForType(formType, colIdx))
|
||||
{
|
||||
text = $"+ 新建 {label} WeaponSO",
|
||||
tooltip = $"在 Assets/_Game/Data/Player/Weapons 创建 Weapon_{formType}.asset",
|
||||
style = { marginTop = 2 }
|
||||
};
|
||||
col.Add(btnWeapon);
|
||||
|
||||
return col;
|
||||
}
|
||||
|
||||
// ── 操作:新建 FormConfigSO ───────────────────────────────────────────
|
||||
|
||||
private void CreateNewFormConfig()
|
||||
{
|
||||
var cfg = EditorScaffoldUtils.CreateSOAsset<FormConfigSO>(DataRoot, "FormConfig");
|
||||
if (cfg != null)
|
||||
{
|
||||
RefreshAll();
|
||||
// 选中新建项
|
||||
int idx = _filtered.IndexOf(cfg);
|
||||
if (idx >= 0) _configList.SetSelection(idx);
|
||||
}
|
||||
}
|
||||
|
||||
// ── 操作:新建 FormSO ──────────────────────────────────────────────────
|
||||
|
||||
private void CreateFormForType(FormType formType, int colIdx)
|
||||
{
|
||||
if (_selected == null) return;
|
||||
|
||||
EditorScaffoldUtils.EnsureFolder(DataRoot);
|
||||
string path = $"{DataRoot}/Form_{formType}.asset";
|
||||
var form = AssetDatabase.LoadAssetAtPath<FormSO>(path);
|
||||
if (form == null)
|
||||
{
|
||||
form = ScriptableObject.CreateInstance<FormSO>();
|
||||
form.formId = $"Form_{formType}";
|
||||
form.displayName = FormDefs[colIdx].label;
|
||||
form.formType = formType;
|
||||
AssetDatabase.CreateAsset(form, path);
|
||||
AssetDatabase.SaveAssets();
|
||||
}
|
||||
|
||||
SetFormByType(formType, form);
|
||||
_formFields[colIdx].value = form;
|
||||
EditorGUIUtility.PingObject(form);
|
||||
}
|
||||
|
||||
// ── 操作:新建 WeaponSO ───────────────────────────────────────────────
|
||||
|
||||
private void CreateWeaponForType(FormType formType, int colIdx)
|
||||
{
|
||||
string dir = "Assets/_Game/Data/Player/Weapons";
|
||||
var weapon = EditorScaffoldUtils.CreateSOAsset<WeaponSO>(dir, $"Weapon_{formType}");
|
||||
if (weapon == null) weapon = AssetDatabase.LoadAssetAtPath<WeaponSO>($"{dir}/Weapon_{formType}.asset");
|
||||
if (weapon == null) return;
|
||||
|
||||
// 赋值到对应 FormSO.defaultWeapon
|
||||
var form = GetFormByType(formType);
|
||||
if (form != null)
|
||||
{
|
||||
form.defaultWeapon = weapon;
|
||||
EditorUtility.SetDirty(form);
|
||||
AssetDatabase.SaveAssets();
|
||||
_weaponFields[colIdx].value = weapon;
|
||||
}
|
||||
}
|
||||
|
||||
// ── 操作:自动填充形态 ────────────────────────────────────────────────
|
||||
|
||||
private void AutoFillForms()
|
||||
{
|
||||
if (_selected == null) return;
|
||||
|
||||
var allForms = EditorScaffoldUtils.FindAllAssetsOfType<FormSO>();
|
||||
bool changed = false;
|
||||
|
||||
for (int i = 0; i < 3; i++)
|
||||
{
|
||||
var (ftype, _, _) = FormDefs[i];
|
||||
if (GetFormByType(ftype) != null) continue;
|
||||
|
||||
var match = allForms.FirstOrDefault(f => f.formType == ftype);
|
||||
if (match != null)
|
||||
{
|
||||
SetFormByType(ftype, match);
|
||||
_formFields[i].value = match;
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (changed)
|
||||
{
|
||||
EditorUtility.SetDirty(_selected);
|
||||
AssetDatabase.SaveAssets();
|
||||
Debug.Log("[FormEditorWindow] 自动填充完成。");
|
||||
}
|
||||
else
|
||||
{
|
||||
Debug.Log("[FormEditorWindow] 未发现需要填充的空槽,或 Project 中无匹配 FormSO。");
|
||||
}
|
||||
}
|
||||
|
||||
// ── 数据访问(操作 FormConfigSO.forms[])────────────────────────────
|
||||
|
||||
private FormSO GetFormByType(FormType type)
|
||||
{
|
||||
if (_selected == null || _selected.forms == null) return null;
|
||||
return _selected.forms.FirstOrDefault(f => f != null && f.formType == type);
|
||||
}
|
||||
|
||||
private void SetFormByType(FormType type, FormSO form)
|
||||
{
|
||||
if (_selected == null) return;
|
||||
|
||||
if (_selected.forms == null || _selected.forms.Length < 3)
|
||||
{
|
||||
var arr = new FormSO[3];
|
||||
if (_selected.forms != null)
|
||||
for (int i = 0; i < _selected.forms.Length && i < 3; i++)
|
||||
arr[i] = _selected.forms[i];
|
||||
_selected.forms = arr;
|
||||
}
|
||||
|
||||
// 按 FormDefs 顺序:TianHun=0, DiHun=1, MingHun=2
|
||||
for (int i = 0; i < FormDefs.Length; i++)
|
||||
{
|
||||
if (FormDefs[i].type == type)
|
||||
{
|
||||
_selected.forms[i] = form;
|
||||
EditorUtility.SetDirty(_selected);
|
||||
AssetDatabase.SaveAssets();
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── UI 辅助 ───────────────────────────────────────────────────────────
|
||||
|
||||
private static VisualElement MakeSeparator()
|
||||
{
|
||||
var sep = new VisualElement();
|
||||
sep.style.height = 1;
|
||||
sep.style.backgroundColor = new Color(0.3f, 0.3f, 0.3f, 0.6f);
|
||||
sep.style.marginTop = 10;
|
||||
sep.style.marginBottom = 10;
|
||||
return sep;
|
||||
}
|
||||
}
|
||||
}
|
||||
11
Assets/_Game/Scripts/Editor/FormEditorWindow.cs.meta
Normal file
11
Assets/_Game/Scripts/Editor/FormEditorWindow.cs.meta
Normal file
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 15618e4fc32a98346a68e945428fcb47
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
493
Assets/_Game/Scripts/Editor/GMToolWindow.cs
Normal file
493
Assets/_Game/Scripts/Editor/GMToolWindow.cs
Normal file
@@ -0,0 +1,493 @@
|
||||
using UnityEditor;
|
||||
using UnityEngine;
|
||||
using BaseGames.Player;
|
||||
using BaseGames.Player.States;
|
||||
|
||||
namespace BaseGames.Editor
|
||||
{
|
||||
/// <summary>
|
||||
/// 开发阶段 GM 调试工具窗口(仅 Play Mode 有效)。
|
||||
/// 功能:资源快速填充(灵铢/灵力/魄元)、能力解锁/锁定、形态切换、调试辅助。
|
||||
/// 菜单:BaseGames / Tools / GM Debug Tool
|
||||
/// </summary>
|
||||
public class GMToolWindow : EditorWindow
|
||||
{
|
||||
// ── 菜单 ──────────────────────────────────────────────────────────────
|
||||
[MenuItem("BaseGames/Tools/GM Debug Tool", priority = 2)]
|
||||
public static void Open()
|
||||
{
|
||||
var wnd = GetWindow<GMToolWindow>();
|
||||
wnd.titleContent = new GUIContent("GM Debug Tool");
|
||||
wnd.minSize = new Vector2(320, 500);
|
||||
}
|
||||
|
||||
// ── 资源输入字段 ──────────────────────────────────────────────────────
|
||||
private int _lingZhuAmount = 9999;
|
||||
private int _soulPowerAmount = 100;
|
||||
private int _spiritAmount = 100;
|
||||
|
||||
// ── 折叠状态 ──────────────────────────────────────────────────────────
|
||||
private bool _foldResources = true;
|
||||
private bool _foldJump = true;
|
||||
private bool _foldForms = true;
|
||||
private bool _foldAbilities = false;
|
||||
private bool _foldDebug = true;
|
||||
|
||||
// ── 缓存(避免每帧 FindObjectOfType)─────────────────────────────────
|
||||
private PlayerStats _stats;
|
||||
private FormController _formCtrl;
|
||||
private PlayerController _playerCtrl;
|
||||
private double _lastCacheTime = -10;
|
||||
|
||||
// ── 能力分组定义(与 AbilityTypeDrawer 保持一致)────────────────────
|
||||
private static readonly (string label, AbilityType[] flags)[] AbilityGroups =
|
||||
{
|
||||
("移动能力", new[]
|
||||
{
|
||||
AbilityType.WallCling, AbilityType.WallJump,
|
||||
AbilityType.Dash, AbilityType.Dash,
|
||||
AbilityType.DoubleJump, AbilityType.SuperJump,
|
||||
AbilityType.Swim, AbilityType.Dive,
|
||||
}),
|
||||
("法术能力", new[] { AbilityType.Spell1, AbilityType.Spell2, AbilityType.Spell3 }),
|
||||
("灵魄形态", new[] { AbilityType.SpiritForm, AbilityType.SpiritDash }),
|
||||
("战斗能力", new[] { AbilityType.Parry, AbilityType.ChargeAttack, AbilityType.DownSlash }),
|
||||
("互动能力", new[] { AbilityType.Interact, AbilityType.FastTravel }),
|
||||
("能力强化", new[] { AbilityType.InvincibleDash }),
|
||||
};
|
||||
|
||||
private static readonly string[] AbilityFlagNames =
|
||||
{
|
||||
"贴墙悬挂", "墙跳", "地面冲刺", "空中冲刺", "二段跳", "超级跳", "游泳", "下劈",
|
||||
"法术槽 1", "法术槽 2", "法术槽 3",
|
||||
"灵魄形态", "灵魄冲刺",
|
||||
"弹反", "蓄力攻击", "下斩",
|
||||
"互动", "快速旅行",
|
||||
"无敌冲刺",
|
||||
};
|
||||
|
||||
// ── 样式(懒初始化)──────────────────────────────────────────────────
|
||||
private GUIStyle _headerStyle;
|
||||
private GUIStyle _boxStyle;
|
||||
|
||||
// ── 滚动 ──────────────────────────────────────────────────────────────
|
||||
private Vector2 _scroll;
|
||||
|
||||
// ── EditorWindow 回调 ─────────────────────────────────────────────────
|
||||
|
||||
private void OnEnable() => EditorApplication.playModeStateChanged += OnPlayModeChanged;
|
||||
private void OnDisable() => EditorApplication.playModeStateChanged -= OnPlayModeChanged;
|
||||
|
||||
private void OnPlayModeChanged(PlayModeStateChange state)
|
||||
{
|
||||
_stats = null;
|
||||
_formCtrl = null;
|
||||
_playerCtrl = null;
|
||||
Repaint();
|
||||
}
|
||||
|
||||
private void OnGUI()
|
||||
{
|
||||
EnsureStyles();
|
||||
|
||||
if (!Application.isPlaying)
|
||||
{
|
||||
EditorGUILayout.HelpBox("GM 工具仅在 Play Mode 下有效。\n请先运行游戏。", MessageType.Info);
|
||||
return;
|
||||
}
|
||||
|
||||
RefreshCache();
|
||||
|
||||
if (_stats == null)
|
||||
{
|
||||
EditorGUILayout.HelpBox("场景中未找到 PlayerStats 组件。\n请确认玩家已生成。", MessageType.Warning);
|
||||
if (GUILayout.Button("重新扫描")) _lastCacheTime = -10;
|
||||
return;
|
||||
}
|
||||
|
||||
_scroll = EditorGUILayout.BeginScrollView(_scroll);
|
||||
|
||||
DrawResourceSection();
|
||||
DrawJumpSection();
|
||||
DrawFormSection();
|
||||
DrawAbilitySection();
|
||||
DrawDebugSection();
|
||||
|
||||
EditorGUILayout.EndScrollView();
|
||||
}
|
||||
|
||||
// ── 分区:资源 ────────────────────────────────────────────────────────
|
||||
|
||||
private void DrawResourceSection()
|
||||
{
|
||||
_foldResources = DrawFoldout(_foldResources, "资源快速填充");
|
||||
if (!_foldResources) return;
|
||||
|
||||
EditorGUILayout.BeginVertical(_boxStyle);
|
||||
|
||||
// 灵铢
|
||||
EditorGUILayout.LabelField("灵铢", EditorStyles.boldLabel);
|
||||
EditorGUILayout.LabelField($"当前:{_stats.CurrentLingZhu}", EditorStyles.miniLabel);
|
||||
EditorGUILayout.BeginHorizontal();
|
||||
_lingZhuAmount = EditorGUILayout.IntField(_lingZhuAmount, GUILayout.Width(80));
|
||||
if (GUILayout.Button("增加")) _stats.AddLingZhu(_lingZhuAmount);
|
||||
if (GUILayout.Button("设为 9999")) _stats.AddLingZhu(Mathf.Max(0, 9999 - _stats.CurrentLingZhu));
|
||||
EditorGUILayout.EndHorizontal();
|
||||
|
||||
EditorGUILayout.Space(6);
|
||||
|
||||
// 灵力(SoulPower)
|
||||
EditorGUILayout.LabelField("灵力(技能用)", EditorStyles.boldLabel);
|
||||
EditorGUILayout.LabelField($"当前:{_stats.CurrentSoulPower} / {_stats.MaxSoulPower}", EditorStyles.miniLabel);
|
||||
EditorGUILayout.BeginHorizontal();
|
||||
_soulPowerAmount = EditorGUILayout.IntField(_soulPowerAmount, GUILayout.Width(80));
|
||||
if (GUILayout.Button("增加")) _stats.AddSoulPower(_soulPowerAmount);
|
||||
if (GUILayout.Button("填满")) _stats.AddSoulPower(_stats.MaxSoulPower);
|
||||
EditorGUILayout.EndHorizontal();
|
||||
|
||||
EditorGUILayout.Space(6);
|
||||
|
||||
// 魄元(SpiritPower)
|
||||
EditorGUILayout.LabelField("魄元(魄技能用)", EditorStyles.boldLabel);
|
||||
EditorGUILayout.LabelField($"当前:{_stats.CurrentSpiritPower} / {_stats.MaxSpiritPower}", EditorStyles.miniLabel);
|
||||
EditorGUILayout.BeginHorizontal();
|
||||
_spiritAmount = EditorGUILayout.IntField(_spiritAmount, GUILayout.Width(80));
|
||||
if (GUILayout.Button("增加")) _stats.AddSpiritPower(_spiritAmount);
|
||||
if (GUILayout.Button("填满")) _stats.AddSpiritPower(_stats.MaxSpiritPower);
|
||||
EditorGUILayout.EndHorizontal();
|
||||
|
||||
EditorGUILayout.Space(4);
|
||||
|
||||
if (GUILayout.Button("▶ 全部资源填满"))
|
||||
{
|
||||
_stats.AddLingZhu(Mathf.Max(0, 9999 - _stats.CurrentLingZhu));
|
||||
_stats.AddSoulPower(_stats.MaxSoulPower);
|
||||
_stats.AddSpiritPower(_stats.MaxSpiritPower);
|
||||
}
|
||||
|
||||
EditorGUILayout.EndVertical();
|
||||
}
|
||||
|
||||
// ── 分区:跳跃快捷 ────────────────────────────────────────────────────
|
||||
|
||||
private void DrawJumpSection()
|
||||
{
|
||||
_foldJump = DrawFoldout(_foldJump, "跳跃能力快捷");
|
||||
if (!_foldJump) return;
|
||||
|
||||
EditorGUILayout.BeginVertical(_boxStyle);
|
||||
|
||||
// ── 当前状态 ──
|
||||
bool hasDoubleJump = _stats.HasAbility(AbilityType.DoubleJump);
|
||||
bool hasDash = _stats.HasAbility(AbilityType.Dash);
|
||||
bool hasWallJump = _stats.HasAbility(AbilityType.WallJump);
|
||||
bool hasWallCling = _stats.HasAbility(AbilityType.WallCling);
|
||||
|
||||
int airJumpsLeft = _playerCtrl != null ? _playerCtrl.AirJumpsLeft : -1;
|
||||
int maxAirJumps = _playerCtrl != null && _playerCtrl.MovConfig != null
|
||||
? _playerCtrl.MovConfig.MaxAirJumps : -1;
|
||||
string airStr = airJumpsLeft >= 0
|
||||
? $"{airJumpsLeft} / {maxAirJumps}"
|
||||
: "N/A";
|
||||
|
||||
EditorGUILayout.LabelField(
|
||||
$"二段跳:{(hasDoubleJump ? "✔ 已解锁" : "✘ 未解锁")} | 腾空剩余:{airStr}",
|
||||
EditorStyles.miniLabel);
|
||||
|
||||
// ── MaxAirJumps 控制 ──
|
||||
if (_playerCtrl != null && _playerCtrl.MovConfig != null)
|
||||
{
|
||||
EditorGUILayout.BeginHorizontal();
|
||||
EditorGUILayout.LabelField("最大空中跳跃次数", GUILayout.Width(120));
|
||||
int newMax = EditorGUILayout.IntSlider(
|
||||
_playerCtrl.MovConfig.MaxAirJumps, 1, 5, GUILayout.ExpandWidth(true));
|
||||
if (newMax != _playerCtrl.MovConfig.MaxAirJumps)
|
||||
{
|
||||
_playerCtrl.MovConfig.MaxAirJumps = newMax;
|
||||
EditorUtility.SetDirty(_playerCtrl.MovConfig);
|
||||
}
|
||||
EditorGUILayout.EndHorizontal();
|
||||
EditorGUILayout.LabelField(
|
||||
$" 1=二段跳 2=三段跳 3=四段跳…(需先解锁 DoubleJump 能力)",
|
||||
EditorStyles.miniLabel);
|
||||
}
|
||||
|
||||
EditorGUILayout.Space(4);
|
||||
|
||||
// ── 跳跃系列快捷按钮(每行 2 个)──
|
||||
EditorGUILayout.BeginHorizontal();
|
||||
DrawToggleAbilityBtn(hasDoubleJump, AbilityType.DoubleJump, "二段跳");
|
||||
DrawToggleAbilityBtn(hasDash, AbilityType.Dash, "冲刺(地面+空中)");
|
||||
EditorGUILayout.EndHorizontal();
|
||||
|
||||
EditorGUILayout.BeginHorizontal();
|
||||
DrawToggleAbilityBtn(hasWallJump, AbilityType.WallJump, "墙跳");
|
||||
DrawToggleAbilityBtn(hasWallCling, AbilityType.WallCling, "贴墙悬挂");
|
||||
EditorGUILayout.EndHorizontal();
|
||||
|
||||
EditorGUILayout.BeginHorizontal();
|
||||
DrawToggleAbilityBtn(_stats.HasAbility(AbilityType.SuperJump), AbilityType.SuperJump, "超级跳");
|
||||
EditorGUILayout.EndHorizontal();
|
||||
|
||||
EditorGUILayout.Space(4);
|
||||
|
||||
// ── 批量快捷 ──
|
||||
EditorGUILayout.BeginHorizontal();
|
||||
if (GUILayout.Button("解锁全部移动能力"))
|
||||
{
|
||||
foreach (var f in new[]
|
||||
{
|
||||
AbilityType.Dash,
|
||||
AbilityType.DoubleJump, AbilityType.SuperJump,
|
||||
AbilityType.WallCling, AbilityType.WallJump,
|
||||
})
|
||||
_stats.UnlockAbility(f);
|
||||
}
|
||||
if (GUILayout.Button("锁定全部移动能力"))
|
||||
{
|
||||
foreach (var f in new[]
|
||||
{
|
||||
AbilityType.Dash,
|
||||
AbilityType.DoubleJump, AbilityType.SuperJump,
|
||||
AbilityType.WallCling, AbilityType.WallJump,
|
||||
})
|
||||
_stats.LockAbility(f);
|
||||
}
|
||||
EditorGUILayout.EndHorizontal();
|
||||
|
||||
EditorGUILayout.HelpBox(
|
||||
"MaxAirJumps 修改立即写入 ScriptableObject(持久化)。\n" +
|
||||
"AirJumpsLeft 在角色下次落地时按新值重置。",
|
||||
MessageType.None);
|
||||
|
||||
EditorGUILayout.EndVertical();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 绘制一个「已解锁 → 锁定 / 未解锁 → 解锁」的切换按钮。
|
||||
/// </summary>
|
||||
private void DrawToggleAbilityBtn(bool hasIt, AbilityType flag, string label)
|
||||
{
|
||||
GUI.backgroundColor = hasIt ? new Color(0.6f, 1.0f, 0.6f) : new Color(1.0f, 0.85f, 0.6f);
|
||||
string btnText = hasIt ? $"✔ {label}" : $"✘ {label}";
|
||||
if (GUILayout.Button(btnText))
|
||||
{
|
||||
if (hasIt) _stats.LockAbility(flag);
|
||||
else _stats.UnlockAbility(flag);
|
||||
}
|
||||
GUI.backgroundColor = Color.white;
|
||||
}
|
||||
|
||||
// ── 分区:形态 ────────────────────────────────────────────────────────
|
||||
|
||||
private void DrawFormSection()
|
||||
{
|
||||
_foldForms = DrawFoldout(_foldForms, "形态快速切换");
|
||||
if (!_foldForms) return;
|
||||
|
||||
EditorGUILayout.BeginVertical(_boxStyle);
|
||||
|
||||
if (_formCtrl == null)
|
||||
{
|
||||
EditorGUILayout.HelpBox("场景中未找到 FormController 组件。", MessageType.Warning);
|
||||
}
|
||||
else
|
||||
{
|
||||
string cur = _formCtrl.CurrentForm != null ? _formCtrl.CurrentForm.displayName : "未知";
|
||||
EditorGUILayout.LabelField($"当前形态:{cur}", EditorStyles.boldLabel);
|
||||
EditorGUILayout.Space(4);
|
||||
|
||||
EditorGUILayout.BeginHorizontal();
|
||||
if (GUILayout.Button("天魂")) SwitchForm(FormType.TianHun);
|
||||
if (GUILayout.Button("地魂")) SwitchForm(FormType.DiHun);
|
||||
if (GUILayout.Button("命魂")) SwitchForm(FormType.MingHun);
|
||||
EditorGUILayout.EndHorizontal();
|
||||
|
||||
EditorGUILayout.HelpBox("提示:切换形态前请确保已解锁灵魄形态(SpiritForm)能力。", MessageType.None);
|
||||
}
|
||||
|
||||
EditorGUILayout.EndVertical();
|
||||
}
|
||||
|
||||
private void SwitchForm(FormType type)
|
||||
{
|
||||
// 确保 SpiritForm 能力已解锁(否则 FSM 可能拒绝形态切换)
|
||||
_stats.UnlockAbility(AbilityType.SpiritForm);
|
||||
_formCtrl.SwitchForm(type);
|
||||
}
|
||||
|
||||
// ── 分区:能力 ────────────────────────────────────────────────────────
|
||||
|
||||
private void DrawAbilitySection()
|
||||
{
|
||||
_foldAbilities = DrawFoldout(_foldAbilities, "能力解锁 / 锁定");
|
||||
if (!_foldAbilities) return;
|
||||
|
||||
EditorGUILayout.BeginVertical(_boxStyle);
|
||||
|
||||
// 快捷全选/全清
|
||||
EditorGUILayout.BeginHorizontal();
|
||||
if (GUILayout.Button("全部解锁"))
|
||||
{
|
||||
foreach (var (_, flags) in AbilityGroups)
|
||||
foreach (var f in flags)
|
||||
_stats.UnlockAbility(f);
|
||||
}
|
||||
if (GUILayout.Button("全部锁定"))
|
||||
{
|
||||
foreach (var (_, flags) in AbilityGroups)
|
||||
foreach (var f in flags)
|
||||
_stats.LockAbility(f);
|
||||
}
|
||||
EditorGUILayout.EndHorizontal();
|
||||
|
||||
EditorGUILayout.Space(6);
|
||||
|
||||
// 各分组
|
||||
foreach (var (groupLabel, flags) in AbilityGroups)
|
||||
{
|
||||
EditorGUILayout.LabelField(groupLabel, EditorStyles.boldLabel);
|
||||
|
||||
EditorGUILayout.BeginHorizontal();
|
||||
bool allOn = true;
|
||||
foreach (var f in flags) if (!_stats.HasAbility(f)) { allOn = false; break; }
|
||||
if (GUILayout.Button(allOn ? "全锁" : "全解", GUILayout.Width(42)))
|
||||
{
|
||||
foreach (var f in flags)
|
||||
{
|
||||
if (allOn) _stats.LockAbility(f);
|
||||
else _stats.UnlockAbility(f);
|
||||
}
|
||||
}
|
||||
EditorGUILayout.EndHorizontal();
|
||||
|
||||
EditorGUILayout.BeginHorizontal();
|
||||
int col = 0;
|
||||
foreach (var flag in flags)
|
||||
{
|
||||
bool has = _stats.HasAbility(flag);
|
||||
bool toggled = GUILayout.Toggle(has, FlagDisplayName(flag), GUILayout.Width(128));
|
||||
if (toggled != has)
|
||||
{
|
||||
if (toggled) _stats.UnlockAbility(flag);
|
||||
else _stats.LockAbility(flag);
|
||||
}
|
||||
col++;
|
||||
if (col == 2) { col = 0; EditorGUILayout.EndHorizontal(); EditorGUILayout.BeginHorizontal(); }
|
||||
}
|
||||
EditorGUILayout.EndHorizontal();
|
||||
EditorGUILayout.Space(4);
|
||||
}
|
||||
|
||||
EditorGUILayout.EndVertical();
|
||||
}
|
||||
|
||||
// ── 分区:调试辅助 ────────────────────────────────────────────────────
|
||||
|
||||
private void DrawDebugSection()
|
||||
{
|
||||
_foldDebug = DrawFoldout(_foldDebug, "调试辅助");
|
||||
if (!_foldDebug) return;
|
||||
|
||||
EditorGUILayout.BeginVertical(_boxStyle);
|
||||
|
||||
// HP
|
||||
EditorGUILayout.LabelField($"HP:{_stats.CurrentHP} / {_stats.MaxHP}", EditorStyles.miniLabel);
|
||||
EditorGUILayout.BeginHorizontal();
|
||||
if (GUILayout.Button("满血")) _stats.FullHeal();
|
||||
EditorGUILayout.EndHorizontal();
|
||||
|
||||
EditorGUILayout.Space(4);
|
||||
|
||||
// 弹簧充能
|
||||
EditorGUILayout.LabelField($"弹力充能:{_stats.CurrentSpringCharges} / {_stats.MaxSpringCharges}", EditorStyles.miniLabel);
|
||||
if (GUILayout.Button("恢复全部弹力充能")) _stats.RestoreSpringCharges();
|
||||
|
||||
EditorGUILayout.Space(4);
|
||||
|
||||
// 无敌模式(God Mode)
|
||||
bool godNow = _stats.IsInvincible; // 仅作参考,GodMode 内部字段
|
||||
EditorGUILayout.BeginHorizontal();
|
||||
if (GUILayout.Button("开启无敌模式")) _stats.SetGodMode(true);
|
||||
if (GUILayout.Button("关闭无敌模式")) _stats.SetGodMode(false);
|
||||
EditorGUILayout.EndHorizontal();
|
||||
|
||||
EditorGUILayout.Space(4);
|
||||
|
||||
// 一键全开(开发快速进入测试状态)
|
||||
GUI.backgroundColor = new Color(0.7f, 1.0f, 0.7f);
|
||||
if (GUILayout.Button("▶ 一键满状态(资源 + 全能力 + 满血)", GUILayout.Height(32)))
|
||||
{
|
||||
_stats.FullHeal();
|
||||
_stats.RestoreSpringCharges();
|
||||
_stats.AddLingZhu(Mathf.Max(0, 9999 - _stats.CurrentLingZhu));
|
||||
_stats.AddSoulPower(_stats.MaxSoulPower);
|
||||
_stats.AddSpiritPower(_stats.MaxSpiritPower);
|
||||
foreach (var (_, flags) in AbilityGroups)
|
||||
foreach (var f in flags)
|
||||
_stats.UnlockAbility(f);
|
||||
}
|
||||
GUI.backgroundColor = Color.white;
|
||||
|
||||
EditorGUILayout.EndVertical();
|
||||
}
|
||||
|
||||
// ── 工具方法 ──────────────────────────────────────────────────────────
|
||||
|
||||
private void RefreshCache()
|
||||
{
|
||||
if (EditorApplication.timeSinceStartup - _lastCacheTime < 2.0) return;
|
||||
_lastCacheTime = EditorApplication.timeSinceStartup;
|
||||
_stats = FindObjectOfType<PlayerStats>();
|
||||
_formCtrl = FindObjectOfType<FormController>();
|
||||
_playerCtrl = FindObjectOfType<PlayerController>();
|
||||
}
|
||||
|
||||
private bool DrawFoldout(bool state, string label)
|
||||
{
|
||||
EditorGUILayout.Space(4);
|
||||
bool next = EditorGUILayout.Foldout(state, label, true, _headerStyle);
|
||||
return next;
|
||||
}
|
||||
|
||||
private static string FlagDisplayName(AbilityType flag) => flag switch
|
||||
{
|
||||
AbilityType.WallCling => "贴墙悬挂",
|
||||
AbilityType.WallJump => "墙跳",
|
||||
AbilityType.Dash => "冲刺",
|
||||
AbilityType.DoubleJump => "二段跳",
|
||||
AbilityType.SuperJump => "超级跳",
|
||||
AbilityType.Swim => "游泳",
|
||||
AbilityType.Dive => "下劈",
|
||||
AbilityType.Spell1 => "法术槽 1",
|
||||
AbilityType.Spell2 => "法术槽 2",
|
||||
AbilityType.Spell3 => "法术槽 3",
|
||||
AbilityType.SpiritForm => "灵魄形态",
|
||||
AbilityType.SpiritDash => "灵魄冲刺",
|
||||
AbilityType.Parry => "弹反",
|
||||
AbilityType.ChargeAttack => "蓄力攻击",
|
||||
AbilityType.DownSlash => "下斩",
|
||||
AbilityType.Interact => "互动",
|
||||
AbilityType.FastTravel => "快速旅行",
|
||||
AbilityType.InvincibleDash => "无敌冲刺",
|
||||
_ => flag.ToString(),
|
||||
};
|
||||
|
||||
private void EnsureStyles()
|
||||
{
|
||||
if (_headerStyle != null) return;
|
||||
_headerStyle = new GUIStyle(EditorStyles.foldout)
|
||||
{
|
||||
fontStyle = FontStyle.Bold,
|
||||
fontSize = 12,
|
||||
};
|
||||
_boxStyle = new GUIStyle(EditorStyles.helpBox)
|
||||
{
|
||||
padding = new RectOffset(8, 8, 6, 6),
|
||||
};
|
||||
}
|
||||
|
||||
// ── 自动刷新(每秒重绘以显示最新数值)──────────────────────────────
|
||||
private void OnInspectorUpdate() => Repaint();
|
||||
}
|
||||
}
|
||||
11
Assets/_Game/Scripts/Editor/GMToolWindow.cs.meta
Normal file
11
Assets/_Game/Scripts/Editor/GMToolWindow.cs.meta
Normal file
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: fe104ad18cf3df743a6edd48b173115f
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
393
Assets/_Game/Scripts/Editor/SOManagerWindow.cs
Normal file
393
Assets/_Game/Scripts/Editor/SOManagerWindow.cs
Normal file
@@ -0,0 +1,393 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using UnityEditor;
|
||||
using UnityEngine;
|
||||
using UnityEngine.UIElements;
|
||||
|
||||
namespace BaseGames.Editor
|
||||
{
|
||||
/// <summary>
|
||||
/// SO 资产总管理窗口 —— 浏览、搜索并在 Project 窗口定位项目中所有 ScriptableObject 资产。
|
||||
///
|
||||
/// 布局:顶部搜索栏 | 左侧分类列表 | 右侧资产列表(名称 / 类型 / 路径)
|
||||
/// 功能:单击资产行 → Project 窗口 Ping 并选中;双击 → 同上并聚焦 Project 窗口。
|
||||
/// 菜单:BaseGames / Tools / SO Manager (Priority 2)
|
||||
/// </summary>
|
||||
public class SOManagerWindow : EditorWindow
|
||||
{
|
||||
private const string DataRoot = "Assets/_Game/Data";
|
||||
private const string UssPath = "Assets/_Game/Scripts/Editor/UIToolkit/Editor.uss";
|
||||
|
||||
// ── 数据模型 ──────────────────────────────────────────────────────────
|
||||
|
||||
private sealed class CategoryEntry
|
||||
{
|
||||
public string Label;
|
||||
public string Folder; // null = 全部
|
||||
public int Count;
|
||||
}
|
||||
|
||||
private sealed class AssetEntry
|
||||
{
|
||||
public string Name;
|
||||
public string TypeName;
|
||||
public string AssetPath;
|
||||
public ScriptableObject Asset;
|
||||
}
|
||||
|
||||
private readonly List<CategoryEntry> _categories = new();
|
||||
private readonly List<AssetEntry> _allAssets = new();
|
||||
private readonly List<AssetEntry> _filtered = new();
|
||||
|
||||
private int _selectedCatIdx = 0;
|
||||
private string _search = "";
|
||||
|
||||
// ── UI 引用 ───────────────────────────────────────────────────────────
|
||||
|
||||
private ListView _catList;
|
||||
private ListView _assetList;
|
||||
private TextField _searchField;
|
||||
private Label _statusLabel;
|
||||
|
||||
// ── 菜单入口 ──────────────────────────────────────────────────────────
|
||||
|
||||
[MenuItem("BaseGames/Tools/SO Manager", priority = 2)]
|
||||
public static void Open()
|
||||
{
|
||||
var wnd = GetWindow<SOManagerWindow>();
|
||||
wnd.titleContent = new GUIContent("SO Manager",
|
||||
EditorGUIUtility.IconContent("d_ScriptableObject Icon").image);
|
||||
wnd.minSize = new Vector2(680, 420);
|
||||
}
|
||||
|
||||
// ── 生命周期 ──────────────────────────────────────────────────────────
|
||||
|
||||
public void CreateGUI()
|
||||
{
|
||||
var uss = AssetDatabase.LoadAssetAtPath<StyleSheet>(UssPath);
|
||||
if (uss != null) rootVisualElement.styleSheets.Add(uss);
|
||||
|
||||
BuildUI();
|
||||
Refresh();
|
||||
}
|
||||
|
||||
private void OnFocus() => Refresh();
|
||||
|
||||
// ── UI 构建 ───────────────────────────────────────────────────────────
|
||||
|
||||
private void BuildUI()
|
||||
{
|
||||
rootVisualElement.style.flexDirection = FlexDirection.Column;
|
||||
|
||||
// ─ 顶部工具栏 ──────────────────────────────────────────────────────
|
||||
var toolbar = new VisualElement();
|
||||
toolbar.style.flexDirection = FlexDirection.Row;
|
||||
toolbar.style.paddingLeft = 8;
|
||||
toolbar.style.paddingRight = 8;
|
||||
toolbar.style.paddingTop = 5;
|
||||
toolbar.style.paddingBottom = 5;
|
||||
toolbar.style.borderBottomWidth = 1;
|
||||
toolbar.style.borderBottomColor = new Color(0.15f, 0.15f, 0.15f);
|
||||
toolbar.style.backgroundColor = new Color(0.22f, 0.22f, 0.22f, 0.6f);
|
||||
|
||||
var searchLbl = new Label("搜索:");
|
||||
searchLbl.style.unityTextAlign = TextAnchor.MiddleLeft;
|
||||
searchLbl.style.marginRight = 4;
|
||||
|
||||
_searchField = new TextField();
|
||||
_searchField.style.flexGrow = 1;
|
||||
_searchField.RegisterValueChangedCallback(e =>
|
||||
{
|
||||
_search = e.newValue;
|
||||
ApplyFilter();
|
||||
});
|
||||
|
||||
var refreshBtn = new Button(Refresh) { text = "↻ 刷新" };
|
||||
refreshBtn.style.marginLeft = 8;
|
||||
refreshBtn.style.width = 58;
|
||||
|
||||
toolbar.Add(searchLbl);
|
||||
toolbar.Add(_searchField);
|
||||
toolbar.Add(refreshBtn);
|
||||
rootVisualElement.Add(toolbar);
|
||||
|
||||
// ─ 主体:两栏分割 ──────────────────────────────────────────────────
|
||||
var split = new TwoPaneSplitView(0, 164, TwoPaneSplitViewOrientation.Horizontal);
|
||||
split.style.flexGrow = 1;
|
||||
|
||||
// 左栏:分类列表 ────────────────────────────────────────────────────
|
||||
var leftPane = new VisualElement();
|
||||
leftPane.style.flexDirection = FlexDirection.Column;
|
||||
leftPane.style.minWidth = 100;
|
||||
|
||||
var catHeader = new Label("分类");
|
||||
catHeader.style.paddingLeft = 8;
|
||||
catHeader.style.paddingTop = 5;
|
||||
catHeader.style.paddingBottom = 5;
|
||||
catHeader.style.unityFontStyleAndWeight = FontStyle.Bold;
|
||||
catHeader.style.borderBottomWidth = 1;
|
||||
catHeader.style.borderBottomColor = new Color(0.22f, 0.22f, 0.22f);
|
||||
catHeader.style.backgroundColor = new Color(0.22f, 0.22f, 0.22f, 0.4f);
|
||||
leftPane.Add(catHeader);
|
||||
|
||||
_catList = new ListView
|
||||
{
|
||||
makeItem = MakeCatItem,
|
||||
bindItem = BindCatItem,
|
||||
selectionType = SelectionType.Single,
|
||||
fixedItemHeight = 26,
|
||||
};
|
||||
_catList.style.flexGrow = 1;
|
||||
_catList.selectionChanged += _ =>
|
||||
{
|
||||
if (_catList.selectedIndex >= 0)
|
||||
{
|
||||
_selectedCatIdx = _catList.selectedIndex;
|
||||
ApplyFilter();
|
||||
}
|
||||
};
|
||||
leftPane.Add(_catList);
|
||||
|
||||
// 右栏:资产列表 ────────────────────────────────────────────────────
|
||||
var rightPane = new VisualElement();
|
||||
rightPane.style.flexDirection = FlexDirection.Column;
|
||||
|
||||
// 列标题行
|
||||
var colHeader = new VisualElement();
|
||||
colHeader.style.flexDirection = FlexDirection.Row;
|
||||
colHeader.style.paddingLeft = 8;
|
||||
colHeader.style.paddingRight = 8;
|
||||
colHeader.style.paddingTop = 4;
|
||||
colHeader.style.paddingBottom = 4;
|
||||
colHeader.style.borderBottomWidth = 1;
|
||||
colHeader.style.borderBottomColor = new Color(0.22f, 0.22f, 0.22f);
|
||||
colHeader.style.backgroundColor = new Color(0.22f, 0.22f, 0.22f, 0.4f);
|
||||
colHeader.Add(MakeHeaderLabel("资产名", true, 0));
|
||||
colHeader.Add(MakeHeaderLabel("类型", false, 170));
|
||||
colHeader.Add(MakeHeaderLabel("路径", true, 0));
|
||||
rightPane.Add(colHeader);
|
||||
|
||||
_assetList = new ListView
|
||||
{
|
||||
makeItem = MakeAssetRow,
|
||||
bindItem = BindAssetRow,
|
||||
selectionType = SelectionType.Single,
|
||||
fixedItemHeight = 22,
|
||||
};
|
||||
_assetList.style.flexGrow = 1;
|
||||
_assetList.selectionChanged += _ => OnAssetPicked();
|
||||
_assetList.itemsChosen += _ => FocusProjectWindow();
|
||||
rightPane.Add(_assetList);
|
||||
|
||||
// 状态栏
|
||||
_statusLabel = new Label("—");
|
||||
_statusLabel.style.paddingLeft = 8;
|
||||
_statusLabel.style.paddingTop = 3;
|
||||
_statusLabel.style.paddingBottom = 3;
|
||||
_statusLabel.style.borderTopWidth = 1;
|
||||
_statusLabel.style.borderTopColor = new Color(0.15f, 0.15f, 0.15f);
|
||||
_statusLabel.style.color = new Color(0.58f, 0.58f, 0.58f);
|
||||
_statusLabel.style.fontSize = 11;
|
||||
rightPane.Add(_statusLabel);
|
||||
|
||||
split.Add(leftPane);
|
||||
split.Add(rightPane);
|
||||
rootVisualElement.Add(split);
|
||||
}
|
||||
|
||||
private static Label MakeHeaderLabel(string text, bool grow, int fixedWidth)
|
||||
{
|
||||
var lbl = new Label(text);
|
||||
lbl.style.unityFontStyleAndWeight = FontStyle.Bold;
|
||||
lbl.style.overflow = Overflow.Hidden;
|
||||
if (grow) lbl.style.flexGrow = 1;
|
||||
if (fixedWidth > 0) lbl.style.width = fixedWidth;
|
||||
return lbl;
|
||||
}
|
||||
|
||||
// ── 分类列表项 ────────────────────────────────────────────────────────
|
||||
|
||||
private static VisualElement MakeCatItem()
|
||||
{
|
||||
var lbl = new Label();
|
||||
lbl.style.paddingLeft = 10;
|
||||
lbl.style.paddingRight = 6;
|
||||
lbl.style.overflow = Overflow.Hidden;
|
||||
lbl.style.unityTextAlign = TextAnchor.MiddleLeft;
|
||||
return lbl;
|
||||
}
|
||||
|
||||
private void BindCatItem(VisualElement el, int i)
|
||||
{
|
||||
if (i >= _categories.Count) return;
|
||||
var cat = _categories[i];
|
||||
((Label)el).text = $"{cat.Label} ({cat.Count})";
|
||||
}
|
||||
|
||||
// ── 资产列表项 ────────────────────────────────────────────────────────
|
||||
|
||||
private static VisualElement MakeAssetRow()
|
||||
{
|
||||
var row = new VisualElement();
|
||||
row.style.flexDirection = FlexDirection.Row;
|
||||
row.style.alignItems = Align.Center;
|
||||
row.style.paddingLeft = 8;
|
||||
row.style.paddingRight = 8;
|
||||
|
||||
var nameEl = new Label { name = "n" };
|
||||
nameEl.style.flexGrow = 1;
|
||||
nameEl.style.overflow = Overflow.Hidden;
|
||||
nameEl.style.textOverflow = TextOverflow.Ellipsis;
|
||||
|
||||
var typeEl = new Label { name = "t" };
|
||||
typeEl.style.width = 170;
|
||||
typeEl.style.overflow = Overflow.Hidden;
|
||||
typeEl.style.textOverflow = TextOverflow.Ellipsis;
|
||||
typeEl.style.color = new Color(0.52f, 0.80f, 1.00f);
|
||||
typeEl.style.fontSize = 11;
|
||||
|
||||
var pathEl = new Label { name = "p" };
|
||||
pathEl.style.flexGrow = 1;
|
||||
pathEl.style.overflow = Overflow.Hidden;
|
||||
pathEl.style.textOverflow = TextOverflow.Ellipsis;
|
||||
pathEl.style.color = new Color(0.48f, 0.48f, 0.48f);
|
||||
pathEl.style.fontSize = 10;
|
||||
|
||||
row.Add(nameEl);
|
||||
row.Add(typeEl);
|
||||
row.Add(pathEl);
|
||||
return row;
|
||||
}
|
||||
|
||||
private void BindAssetRow(VisualElement el, int i)
|
||||
{
|
||||
if (i >= _filtered.Count) return;
|
||||
var e = _filtered[i];
|
||||
el.Q<Label>("n").text = e.Name;
|
||||
el.Q<Label>("t").text = e.TypeName;
|
||||
// 显示相对于 DataRoot 的路径,去掉文件名本身只保留目录
|
||||
string rel = e.AssetPath.StartsWith(DataRoot + "/")
|
||||
? e.AssetPath.Substring(DataRoot.Length + 1)
|
||||
: e.AssetPath;
|
||||
// 去掉最后的文件名,只显示目录部分
|
||||
string dir = Path.GetDirectoryName(rel)?.Replace('\\', '/') ?? "";
|
||||
el.Q<Label>("p").text = string.IsNullOrEmpty(dir) ? "/" : dir;
|
||||
}
|
||||
|
||||
// ── 资产选中 ──────────────────────────────────────────────────────────
|
||||
|
||||
private void OnAssetPicked()
|
||||
{
|
||||
int idx = _assetList.selectedIndex;
|
||||
if (idx < 0 || idx >= _filtered.Count) return;
|
||||
var asset = _filtered[idx].Asset;
|
||||
if (asset == null) return;
|
||||
EditorGUIUtility.PingObject(asset);
|
||||
Selection.activeObject = asset;
|
||||
}
|
||||
|
||||
private static void FocusProjectWindow()
|
||||
{
|
||||
EditorApplication.ExecuteMenuItem("Window/General/Project");
|
||||
}
|
||||
|
||||
// ── 数据逻辑 ──────────────────────────────────────────────────────────
|
||||
|
||||
private void Refresh()
|
||||
{
|
||||
_allAssets.Clear();
|
||||
|
||||
// 扫描 DataRoot 下所有 ScriptableObject 资产
|
||||
var guids = AssetDatabase.FindAssets("t:ScriptableObject", new[] { DataRoot });
|
||||
foreach (var guid in guids)
|
||||
{
|
||||
string path = AssetDatabase.GUIDToAssetPath(guid);
|
||||
var asset = AssetDatabase.LoadAssetAtPath<ScriptableObject>(path);
|
||||
if (asset == null) continue;
|
||||
_allAssets.Add(new AssetEntry
|
||||
{
|
||||
Name = asset.name,
|
||||
TypeName = asset.GetType().Name,
|
||||
AssetPath = path,
|
||||
Asset = asset,
|
||||
});
|
||||
}
|
||||
|
||||
// 构建分类:首项为"全部",其余为 DataRoot 的直接子目录
|
||||
_categories.Clear();
|
||||
_categories.Add(new CategoryEntry
|
||||
{
|
||||
Label = "全部",
|
||||
Folder = null,
|
||||
Count = _allAssets.Count,
|
||||
});
|
||||
|
||||
if (AssetDatabase.IsValidFolder(DataRoot))
|
||||
{
|
||||
foreach (var sub in AssetDatabase.GetSubFolders(DataRoot).OrderBy(f => f))
|
||||
{
|
||||
string folderName = Path.GetFileName(sub);
|
||||
int count = _allAssets.Count(a =>
|
||||
a.AssetPath.StartsWith(sub + "/", StringComparison.Ordinal));
|
||||
if (count == 0) continue;
|
||||
_categories.Add(new CategoryEntry
|
||||
{
|
||||
Label = folderName,
|
||||
Folder = sub,
|
||||
Count = count,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
_catList.itemsSource = _categories;
|
||||
_catList.Rebuild();
|
||||
|
||||
int clampedIdx = Mathf.Clamp(_selectedCatIdx, 0, _categories.Count - 1);
|
||||
_catList.SetSelection(clampedIdx);
|
||||
_selectedCatIdx = clampedIdx;
|
||||
ApplyFilter();
|
||||
}
|
||||
|
||||
private void ApplyFilter()
|
||||
{
|
||||
_filtered.Clear();
|
||||
|
||||
string folder = (_selectedCatIdx >= 0 && _selectedCatIdx < _categories.Count)
|
||||
? _categories[_selectedCatIdx].Folder
|
||||
: null;
|
||||
|
||||
IEnumerable<AssetEntry> source = folder == null
|
||||
? _allAssets
|
||||
: _allAssets.Where(a => a.AssetPath.StartsWith(folder + "/", StringComparison.Ordinal));
|
||||
|
||||
foreach (var entry in source)
|
||||
{
|
||||
if (string.IsNullOrEmpty(_search)
|
||||
|| entry.Name.IndexOf(_search, StringComparison.OrdinalIgnoreCase) >= 0
|
||||
|| entry.TypeName.IndexOf(_search, StringComparison.OrdinalIgnoreCase) >= 0)
|
||||
{
|
||||
_filtered.Add(entry);
|
||||
}
|
||||
}
|
||||
|
||||
// 同分类内按类型分组,再按名称排序
|
||||
_filtered.Sort((a, b) =>
|
||||
{
|
||||
int cmp = string.Compare(a.TypeName, b.TypeName, StringComparison.OrdinalIgnoreCase);
|
||||
return cmp != 0 ? cmp : string.Compare(a.Name, b.Name, StringComparison.OrdinalIgnoreCase);
|
||||
});
|
||||
|
||||
_assetList.itemsSource = _filtered;
|
||||
_assetList.Rebuild();
|
||||
|
||||
int total = folder == null
|
||||
? _allAssets.Count
|
||||
: _allAssets.Count(a => a.AssetPath.StartsWith(folder + "/", StringComparison.Ordinal));
|
||||
_statusLabel.text = string.IsNullOrEmpty(_search)
|
||||
? $"共 {_filtered.Count} 个资产"
|
||||
: $"筛选 {_filtered.Count} / {total} 个资产(搜索:{_search})";
|
||||
}
|
||||
}
|
||||
}
|
||||
11
Assets/_Game/Scripts/Editor/SOManagerWindow.cs.meta
Normal file
11
Assets/_Game/Scripts/Editor/SOManagerWindow.cs.meta
Normal file
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 7dd063f0750f2c24cae7c29f40b24a8a
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -1,11 +1,16 @@
|
||||
using System.Collections.Generic;
|
||||
using System.Reflection;
|
||||
using Animancer;
|
||||
using BaseGames.Camera;
|
||||
using BaseGames.Combat;
|
||||
using BaseGames.Combat.StatusEffects;
|
||||
using BaseGames.Dialogue;
|
||||
using BaseGames.Enemies;
|
||||
using BaseGames.Equipment;
|
||||
using BaseGames.Parry;
|
||||
using BaseGames.Player;
|
||||
using BaseGames.Player.States;
|
||||
using BaseGames.Skills;
|
||||
using BaseGames.World;
|
||||
using PathBerserker2d;
|
||||
using Unity.Cinemachine;
|
||||
@@ -33,53 +38,83 @@ namespace BaseGames.Editor
|
||||
{
|
||||
var report = new List<string>();
|
||||
|
||||
GameObject go = new GameObject("Player");
|
||||
Undo.RegisterCreatedObjectUndo(go, "Place Player");
|
||||
go.transform.position = GetDropPosition();
|
||||
go.tag = "Player";
|
||||
SetLayer(go, "Player", report);
|
||||
// ── Player 根节点(行为+物理+标签三合一)──────────────────────────────
|
||||
// Rigidbody2D / 所有 MonoBehaviour 集中于此节点。
|
||||
// HurtBox 作为其子节点,GetComponentInParent<IDamageable>() 向上即可找到
|
||||
// 本节点上的 PlayerController(IDamageable 实现者)。
|
||||
GameObject root = new GameObject("Player");
|
||||
Undo.RegisterCreatedObjectUndo(root, "Place Player");
|
||||
root.transform.position = GetDropPosition();
|
||||
root.tag = "Player";
|
||||
SetLayer(root, "Player", report);
|
||||
|
||||
Rigidbody2D rb = GetOrAddComponent<Rigidbody2D>(go);
|
||||
rb.bodyType = RigidbodyType2D.Dynamic;
|
||||
rb.gravityScale = 2f;
|
||||
rb.constraints = RigidbodyConstraints2D.FreezeRotation;
|
||||
rb.collisionDetectionMode = CollisionDetectionMode2D.Continuous;
|
||||
// 物理组件(PlayerMovement RequireComponent(Rigidbody2D),必须同节点)
|
||||
Rigidbody2D rb = GetOrAddComponent<Rigidbody2D>(root);
|
||||
rb.bodyType = RigidbodyType2D.Dynamic;
|
||||
rb.gravityScale = 2f;
|
||||
rb.constraints = RigidbodyConstraints2D.FreezeRotation;
|
||||
rb.collisionDetectionMode = CollisionDetectionMode2D.Continuous;
|
||||
rb.interpolation = RigidbodyInterpolation2D.Interpolate;
|
||||
GetOrAddComponent<BoxCollider2D>(root);
|
||||
|
||||
GetOrAddComponent<CapsuleCollider2D>(go);
|
||||
GetOrAddComponent<Animator>(go);
|
||||
SetupSpriteRenderer(go);
|
||||
// 动画组件(AnimancerComponent 需要 Animator 存在;PlayerController
|
||||
// [RequireComponent(typeof(AnimancerComponent))] 保证其存在)
|
||||
GetOrAddComponent<Animator>(root);
|
||||
GetOrAddComponent<AnimancerComponent>(root);
|
||||
|
||||
SetupSpriteRenderer(root);
|
||||
|
||||
PlayerStats playerStats = GetOrAddComponent<PlayerStats>(go);
|
||||
PlayerMovement playerMovement = GetOrAddComponent<PlayerMovement>(go);
|
||||
PlayerController playerController = GetOrAddComponent<PlayerController>(go);
|
||||
PlayerCombat playerCombat = GetOrAddComponent<PlayerCombat>(go);
|
||||
// 核心行为组件
|
||||
PlayerStats playerStats = GetOrAddComponent<PlayerStats>(root);
|
||||
PlayerMovement playerMovement = GetOrAddComponent<PlayerMovement>(root);
|
||||
PlayerCombat playerCombat = GetOrAddComponent<PlayerCombat>(root);
|
||||
FormController formController = GetOrAddComponent<FormController>(root);
|
||||
WeaponManager weaponManager = GetOrAddComponent<WeaponManager>(root);
|
||||
SkillManager skillManager = GetOrAddComponent<SkillManager>(root);
|
||||
SpringSystem springSystem = GetOrAddComponent<SpringSystem>(root);
|
||||
ParrySystem parrySystem = GetOrAddComponent<ParrySystem>(root);
|
||||
ShieldComponent shield = GetOrAddComponent<ShieldComponent>(root);
|
||||
PlayerWallDetector wallDetector = GetOrAddComponent<PlayerWallDetector>(root);
|
||||
EquipmentManager equipmentManager = GetOrAddComponent<EquipmentManager>(root);
|
||||
GetOrAddComponent<SkillModifierRegistry>(root);
|
||||
GetOrAddComponent<StatusEffectManager>(root);
|
||||
// PlayerController 最后添加:RequireComponent 会拉取上方已加好的组件
|
||||
PlayerController playerController = GetOrAddComponent<PlayerController>(root);
|
||||
|
||||
// Ground check pivot
|
||||
Transform groundCheckGo = GetOrCreateChild(go.transform, "GroundCheck");
|
||||
groundCheckGo.localPosition = new Vector3(0f, -0.75f, 0f);
|
||||
AssignReference(playerMovement, "_groundCheck", groundCheckGo, report);
|
||||
AssignLayerMask(playerMovement, "_groundLayer", "Ground", report);
|
||||
|
||||
// Weapon socket (WeaponManager instantiates weapons here at runtime)
|
||||
GetOrCreateChild(go.transform, "WeaponSocket");
|
||||
|
||||
// Camera follow target — CinemachineCamera.Follow 使用此子节点而非 Player 根节点
|
||||
GetOrCreateChild(go.transform, "CameraFollowTarget");
|
||||
|
||||
// HurtBox child
|
||||
Transform hurtBoxT = GetOrCreateChild(go.transform, "HurtBox");
|
||||
// ── HurtBox 子节点 ───────────────────────────────────────────────────
|
||||
Transform hurtBoxT = GetOrCreateChild(root.transform, "HurtBox");
|
||||
SetLayer(hurtBoxT.gameObject, "PlayerHurtBox", report);
|
||||
CapsuleCollider2D hurtCollider = GetOrAddComponent<CapsuleCollider2D>(hurtBoxT.gameObject);
|
||||
BoxCollider2D hurtCollider = GetOrAddComponent<BoxCollider2D>(hurtBoxT.gameObject);
|
||||
hurtCollider.isTrigger = true;
|
||||
HurtBox hurtBox = GetOrAddComponent<HurtBox>(hurtBoxT.gameObject);
|
||||
|
||||
// Assign controller references
|
||||
AssignReference(playerController, "_stats", playerStats, report);
|
||||
AssignReference(playerController, "_hurtBox", hurtBox, report);
|
||||
AssignReference(playerController, "_movement", playerMovement, report);
|
||||
AssignReference(playerController, "_combat", playerCombat, report);
|
||||
// ── [WeaponSocket] 子节点(WeaponManager 动态实例化武器 HitBox 的挂点)
|
||||
GetOrCreateChild(root.transform, "[WeaponSocket]");
|
||||
|
||||
// Event channels (all optional — will be skipped silently if assets missing)
|
||||
// ── GroundCheck 子节点(地面检测 Transform)────────────────────────
|
||||
Transform groundCheckT = GetOrCreateChild(root.transform, "GroundCheck");
|
||||
groundCheckT.localPosition = new Vector3(0f, -0.75f, 0f);
|
||||
AssignReference(playerMovement, "_groundCheck", groundCheckT, report);
|
||||
AssignLayerMask(playerMovement, "_groundLayer", "Ground", report);
|
||||
|
||||
// ── SkillHitBox_Slot 子节点(技能 HitBox 实例化挂点)────────────────
|
||||
GetOrCreateChild(root.transform, "SkillHitBox_Slot");
|
||||
|
||||
// ── CameraFollowTarget 子节点(CinemachineCamera.Follow 目标)────────
|
||||
GetOrCreateChild(root.transform, "CameraFollowTarget");
|
||||
|
||||
// ── PlayerController SerializeField 引用赋值 ──────────────────────
|
||||
AssignReference(playerController, "_combat", playerCombat, report);
|
||||
AssignReference(playerController, "_formController", formController, report);
|
||||
AssignReference(playerController, "_weaponManager", weaponManager, report);
|
||||
AssignReference(playerController, "_skillManager", skillManager, report);
|
||||
AssignReference(playerController, "_springSystem", springSystem, report);
|
||||
AssignReference(playerController, "_parrySystem", parrySystem, report);
|
||||
AssignReference(playerController, "_hurtBox", hurtBox, report);
|
||||
AssignReference(playerController, "_shield", shield, report);
|
||||
AssignReference(playerController, "_wallDetector", wallDetector, report);
|
||||
|
||||
// ── 事件频道(可选,缺失时跳过) ───────────────────────────────────
|
||||
AssignAsset(playerStats, "_onHPChanged", report, false, "EVT_HPChanged");
|
||||
AssignAsset(playerStats, "_onMaxHPChanged", report, false, "EVT_MaxHPChanged");
|
||||
AssignAsset(playerStats, "_onSoulPowerChanged", report, false, "EVT_SoulPowerChanged");
|
||||
@@ -93,17 +128,43 @@ namespace BaseGames.Editor
|
||||
AssignAsset(hurtBox, "_onDamageDealt", report, false, "EVT_DamageDealt");
|
||||
AssignAsset(hurtBox, "_onHitConfirmed", report, false, "EVT_HitConfirmed");
|
||||
|
||||
// Config ScriptableObjects (optional — link manually after placing)
|
||||
Object statsConfig = FindFirstAsset("PLY_PlayerStats", "PlayerStats");
|
||||
Object movConfig = FindFirstAsset("PLY_PlayerMovementConfig", "PlayerMovementConfig");
|
||||
if (movConfig != null) AssignReference(playerController, "_movementConfig", movConfig, report);
|
||||
if (statsConfig != null) AssignReference(playerStats, "_config", statsConfig, report);
|
||||
if (movConfig != null) AssignReference(playerMovement, "_config", movConfig, report);
|
||||
// ── Config SO 自动查找(资产存在时自动绑定)──────────────────────
|
||||
Object statsConfig = FindFirstAsset("PLY_PlayerStats");
|
||||
Object movConfig = FindFirstAsset("PLY_PlayerMovementConfig");
|
||||
Object formConfig = FindFirstAsset("PLY_FormConfig");
|
||||
Object parryConfig = FindFirstAsset("PLY_ParryConfig");
|
||||
Object shieldConfig = FindFirstAsset("PLY_ShieldConfig");
|
||||
Object inputReader = FindFirstAsset("InputReader");
|
||||
Object equipmentConfig = FindFirstAsset("PLY_EquipmentConfig");
|
||||
Object charmCatalog = FindFirstAsset("PLY_CharmCatalog");
|
||||
|
||||
report.Add("PlayerMovement._config、PlayerController._animConfig、_inputReader 等需后续手动绑定。");
|
||||
if (statsConfig != null) AssignReference(playerStats, "_config", statsConfig, report);
|
||||
if (movConfig != null)
|
||||
{
|
||||
AssignReference(playerController, "_movementConfig", movConfig, report);
|
||||
AssignReference(playerMovement, "_config", movConfig, report);
|
||||
AssignReference(wallDetector, "_config", movConfig, report);
|
||||
}
|
||||
if (formConfig != null)
|
||||
{
|
||||
AssignReference(playerController, "_formConfig", formConfig, report);
|
||||
AssignReference(formController, "_config", formConfig, report);
|
||||
}
|
||||
if (parryConfig != null) AssignReference(parrySystem, "_config", parryConfig, report);
|
||||
if (shieldConfig != null) AssignReference(shield, "_config", shieldConfig, report);
|
||||
if (inputReader != null) AssignReference(playerController, "_inputReader", inputReader, report);
|
||||
if (equipmentConfig != null) AssignReference(equipmentManager, "_config", equipmentConfig, report);
|
||||
if (charmCatalog != null) AssignReference(equipmentManager, "_charmCatalog", charmCatalog, report);
|
||||
|
||||
Selection.activeGameObject = go;
|
||||
MarkDirtyAndLog("Player", go, report);
|
||||
report.Add("★ 需手动绑定:PlayerController._animConfig(PLY_PlayerAnimationConfig)");
|
||||
if (statsConfig == null) report.Add("★ 需创建并绑定:PlayerStats._config(PlayerStatsSO)");
|
||||
if (inputReader == null) report.Add("★ 需手动绑定:PlayerController._inputReader(InputReaderSO)");
|
||||
if (equipmentConfig == null) report.Add("★ 需创建并绑定:EquipmentManager._config(EquipmentConfigSO)");
|
||||
if (charmCatalog == null) report.Add("★ 需创建并绑定:EquipmentManager._charmCatalog(CharmCatalogSO)");
|
||||
report.Add("SkillManager 技能槽 SO 需手动填入。");
|
||||
|
||||
Selection.activeGameObject = root;
|
||||
MarkDirtyAndLog("Player", root, report);
|
||||
}
|
||||
|
||||
[MenuItem("BaseGames/Scene/Place/Player Spawn Point", priority = 105)]
|
||||
@@ -168,11 +229,11 @@ namespace BaseGames.Editor
|
||||
AssignReference(enemyBase, "_stats", enemyStats, report);
|
||||
|
||||
// DamageSourceSO for body contact (optional — create manually if missing)
|
||||
Object dmgSrc = FindFirstAsset("DS_EnemyBody", "DS_TestEnemyBody");
|
||||
Object dmgSrc = FindFirstAsset("CMB_DS_EnemyBody", "DS_EnemyBody");
|
||||
if (dmgSrc != null)
|
||||
AssignReference(hitBox, "_defaultSource", dmgSrc, report);
|
||||
else
|
||||
report.Add("未找到 DamageSourceSO (DS_EnemyBody),HitBox_Body._defaultSource 未绑定。请创建后手动指定。");
|
||||
report.Add("未找到 DamageSourceSO,HitBox_Body._defaultSource 未绑定。请按规范创建 CMB_DS_EnemyBody.asset。");
|
||||
|
||||
// Event channels
|
||||
AssignAsset(enemyBase, "_onEnemyDied", report, false, "EVT_EnemyDied");
|
||||
@@ -186,7 +247,7 @@ namespace BaseGames.Editor
|
||||
if (enemyStatsSO != null)
|
||||
AssignReference(enemyBase, "_statsSO", enemyStatsSO, report);
|
||||
else
|
||||
report.Add("未找到 EnemyStatsSO,EnemyBase._statsSO 未绑定。请在 Data/Enemies/ 创建后手动指定。");
|
||||
report.Add("未找到 EnemyStatsSO,EnemyBase._statsSO 未绑定。请在 Data/Enemies/ 创建 ENM_{id}_Stats.asset 后手动指定。");
|
||||
|
||||
report.Add("行为树、导航参数(NavAgent)、动画片段需后续手工挂载。");
|
||||
|
||||
@@ -239,11 +300,11 @@ namespace BaseGames.Editor
|
||||
AssignReference(bossBase, "_stats", bossStats, report);
|
||||
|
||||
// DamageSourceSO
|
||||
Object dmgSrc = FindFirstAsset("DS_BossBody", "DS_EnemyBody");
|
||||
Object dmgSrc = FindFirstAsset("CMB_DS_BossBody", "CMB_DS_EnemyBody", "DS_BossBody");
|
||||
if (dmgSrc != null)
|
||||
AssignReference(hitBox, "_defaultSource", dmgSrc, report);
|
||||
else
|
||||
report.Add("未找到 DamageSourceSO,HitBox_Body._defaultSource 未绑定。");
|
||||
report.Add("未找到 DamageSourceSO,HitBox_Body._defaultSource 未绑定。请按规范创建 CMB_DS_BossBody.asset。");
|
||||
|
||||
// Event channels
|
||||
AssignAsset(bossBase, "_onEnemyDied", report, false, "EVT_EnemyDied");
|
||||
@@ -290,7 +351,8 @@ namespace BaseGames.Editor
|
||||
hurtCol.size = new Vector2(2f, 0.3f);
|
||||
GetOrAddComponent<HurtBox>(hurtBoxT.gameObject);
|
||||
|
||||
AssignAsset(trap, "_onPlayerDied", report, false, "EVT_PlayerDied");
|
||||
AssignAsset(trap, "_onPlayerDied", report, false, "EVT_PlayerDied");
|
||||
AssignAsset(trap, "_onCheckpointRespawn", report, false, "EVT_CheckpointRespawn");
|
||||
|
||||
report.Add("_canPogo=true:子 HurtBox 供玩家下劈弹起;设为 false 可改为纯死亡区(无需子 HurtBox)。");
|
||||
|
||||
@@ -298,7 +360,32 @@ namespace BaseGames.Editor
|
||||
MarkDirtyAndLog("Hazard (LethalTrap)", go, report);
|
||||
}
|
||||
|
||||
[MenuItem("BaseGames/Scene/Place/Collectible (LingZhu)", priority = 125)]
|
||||
[MenuItem("BaseGames/Scene/Place/Checkpoint Marker", priority = 125)]
|
||||
public static void PlaceCheckpointMarker()
|
||||
{
|
||||
var report = new List<string>();
|
||||
|
||||
GameObject go = new GameObject("CheckpointMarker");
|
||||
Undo.RegisterCreatedObjectUndo(go, "Place CheckpointMarker");
|
||||
go.transform.position = GetDropPosition();
|
||||
SetLayer(go, "TriggerZone", report);
|
||||
|
||||
BoxCollider2D col = GetOrAddComponent<BoxCollider2D>(go);
|
||||
col.isTrigger = true;
|
||||
col.size = new Vector2(1f, 2f);
|
||||
|
||||
CheckpointMarker marker = GetOrAddComponent<CheckpointMarker>(go);
|
||||
AssignLayerMask(marker, "_playerLayers", "Player", report);
|
||||
AssignAsset(marker, "_onCheckpointReached", report, false, "EVT_CheckpointReached");
|
||||
|
||||
report.Add("放置于跳跳乐段落的关键节点处;玩家经过后成为该房间最近检查点。");
|
||||
report.Add("同一房间可放置多个,以最近经过的为准。");
|
||||
|
||||
Selection.activeGameObject = go;
|
||||
MarkDirtyAndLog("Checkpoint Marker", go, report);
|
||||
}
|
||||
|
||||
[MenuItem("BaseGames/Scene/Place/Collectible (LingZhu)", priority = 130)]
|
||||
public static void PlaceCollectible()
|
||||
{
|
||||
var report = new List<string>();
|
||||
@@ -349,14 +436,41 @@ namespace BaseGames.Editor
|
||||
|
||||
SavePoint savePoint = GetOrAddComponent<SavePoint>(go);
|
||||
|
||||
AssignAsset(savePoint, "_onSceneLoaded", report, false, "EVT_SceneLoaded");
|
||||
AssignAsset(savePoint, "_onSavePointActivated", report, false, "EVT_SavePointActivated");
|
||||
AssignAsset(savePoint, "_onFastTravelOpen", report, false, "EVT_FastTravelOpen");
|
||||
|
||||
report.Add("填写 _savePointId(全局唯一字符串,用于存档点激活记录与复活定位)。");
|
||||
|
||||
Selection.activeGameObject = go;
|
||||
MarkDirtyAndLog("Save Point", go, report);
|
||||
}
|
||||
|
||||
[MenuItem("BaseGames/Scene/Place/Room Transition", priority = 135)]
|
||||
[MenuItem("BaseGames/Scene/Place/Teleport Station", priority = 135)]
|
||||
public static void PlaceTeleportStation()
|
||||
{
|
||||
var report = new List<string>();
|
||||
|
||||
GameObject go = new GameObject("TeleportStation");
|
||||
Undo.RegisterCreatedObjectUndo(go, "Place TeleportStation");
|
||||
go.transform.position = GetDropPosition();
|
||||
SetLayer(go, "TriggerZone", report);
|
||||
|
||||
BoxCollider2D col = GetOrAddComponent<BoxCollider2D>(go);
|
||||
col.isTrigger = true;
|
||||
col.size = new Vector2(1.5f, 2f);
|
||||
SetupSpriteRenderer(go);
|
||||
|
||||
TeleportStation station = GetOrAddComponent<TeleportStation>(go);
|
||||
AssignAsset(station, "_onFastTravelOpen", report, false, "EVT_FastTravelOpen");
|
||||
|
||||
report.Add("填写 _stationId(传送站唯一 ID,用于地图 UI 标注)。");
|
||||
report.Add("传送站不存档、不复活、不恢复 HP;与存档点是独立对象。");
|
||||
|
||||
Selection.activeGameObject = go;
|
||||
MarkDirtyAndLog("Teleport Station", go, report);
|
||||
}
|
||||
|
||||
[MenuItem("BaseGames/Scene/Place/Room Transition", priority = 140)]
|
||||
public static void PlaceRoomTransition()
|
||||
{
|
||||
var report = new List<string>();
|
||||
@@ -410,20 +524,12 @@ namespace BaseGames.Editor
|
||||
|
||||
CameraArea cameraArea = GetOrAddComponent<CameraArea>(go);
|
||||
|
||||
// AreaBoundary child — 提供 CinemachineConfiner2D 所需的限位多边形(isTrigger = true,仅作为相机约束边界)
|
||||
// AreaBoundary child — 提供 CinemachineConfiner3D 所需的限位体积
|
||||
Transform boundaryT = GetOrCreateChild(go.transform, $"{areaName}_AreaBoundary");
|
||||
PolygonCollider2D boundaryCollider = GetOrAddComponent<PolygonCollider2D>(boundaryT.gameObject);
|
||||
BoxCollider boundaryCollider = GetOrAddComponent<BoxCollider>(boundaryT.gameObject);
|
||||
boundaryCollider.isTrigger = true;
|
||||
boundaryCollider.pathCount = 1;
|
||||
// 顶点必须逆时针(CCW)排列:Cinemachine 底层 Clipper 库对 CW 多边形(area<0)会取反 delta,
|
||||
// 导致向外膨胀而非向内收缩,相机将不受限制地跑出边界。
|
||||
boundaryCollider.SetPath(0, new Vector2[]
|
||||
{
|
||||
new Vector2(-12f, -6f), // BL
|
||||
new Vector2( 12f, -6f), // BR
|
||||
new Vector2( 12f, 6f), // TR
|
||||
new Vector2(-12f, 6f), // TL
|
||||
});
|
||||
boundaryCollider.center = new Vector3(0f, 0f, -10f); // Z 占位符,实际深度由 SyncConfiner 按 LensConfig 计算
|
||||
boundaryCollider.size = new Vector3(24f, 12f, 1f); // 默认房间尺寸占位符
|
||||
|
||||
AssignReference(cameraArea, "_confinerCollider", boundaryCollider, report);
|
||||
|
||||
@@ -433,26 +539,26 @@ namespace BaseGames.Editor
|
||||
zoneGo.transform.position = pos;
|
||||
SetLayer(zoneGo, "TriggerZone", report);
|
||||
|
||||
PolygonCollider2D col = GetOrAddComponent<PolygonCollider2D>(zoneGo);
|
||||
CameraTriggerZone zone = GetOrAddComponent<CameraTriggerZone>(zoneGo);
|
||||
PolygonCollider2D col = GetOrAddComponent<PolygonCollider2D>(zoneGo);
|
||||
col.isTrigger = true;
|
||||
// 默认矩形轮廓(CCW),与 AreaBoundary 默认尺寸一致(可在 Inspector 中编辑顶点调整为任意多边形)
|
||||
// 默认矩形多边形(24×12),可在 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
|
||||
new Vector2(-12f, -6f),
|
||||
new Vector2(-12f, 6f),
|
||||
new Vector2( 12f, 6f),
|
||||
new Vector2( 12f, -6f),
|
||||
});
|
||||
|
||||
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 顶点以匹配入口走廊(支持任意多边形)。");
|
||||
report.Add($"绑定 LensConfig SO 后单击 Inspector 中「从可视区域更新限位区域」计算 {areaName}_AreaBoundary BoxCollider。");
|
||||
report.Add($"编辑 {areaName}_TriggerZone PolygonCollider2D 的顶点以匹配入口多边形区域。");
|
||||
|
||||
// ── 自动关联到同场景 RoomController(若其 _cameraArea 为空)────────
|
||||
#if UNITY_6000_0_OR_NEWER
|
||||
|
||||
@@ -92,7 +92,7 @@ namespace BaseGames.Editor
|
||||
|
||||
GameObject vcamAGo = GetOrCreateChild(camera, "VCamA").gameObject;
|
||||
CinemachineCamera vcamA = GetOrAddComponent<CinemachineCamera>(vcamAGo);
|
||||
GetOrAddComponent<CinemachineConfiner2D>(vcamAGo);
|
||||
GetOrAddComponent<CinemachineConfiner3D>(vcamAGo);
|
||||
GetOrAddComponent<CameraAxisLockExtension>(vcamAGo);
|
||||
GetOrAddComponent<CameraAsymmetricDampingExtension>(vcamAGo);
|
||||
GetOrAddComponent<CameraAdaptiveLookaheadExtension>(vcamAGo);
|
||||
@@ -102,7 +102,7 @@ namespace BaseGames.Editor
|
||||
|
||||
GameObject vcamBGo = GetOrCreateChild(camera, "VCamB").gameObject;
|
||||
CinemachineCamera vcamB = GetOrAddComponent<CinemachineCamera>(vcamBGo);
|
||||
GetOrAddComponent<CinemachineConfiner2D>(vcamBGo);
|
||||
GetOrAddComponent<CinemachineConfiner3D>(vcamBGo);
|
||||
GetOrAddComponent<CameraAxisLockExtension>(vcamBGo);
|
||||
GetOrAddComponent<CameraAsymmetricDampingExtension>(vcamBGo);
|
||||
GetOrAddComponent<CameraAdaptiveLookaheadExtension>(vcamBGo);
|
||||
@@ -221,15 +221,11 @@ namespace BaseGames.Editor
|
||||
GameObject cameraAreaGo = GetOrCreateChild(cameraGroup, "CameraArea").gameObject;
|
||||
CameraArea cameraArea = GetOrAddComponent<CameraArea>(cameraAreaGo);
|
||||
|
||||
// AreaBoundary — 提供 CinemachineConfiner2D 所需的限位多边形
|
||||
// AreaBoundary — 提供 CinemachineConfiner3D 所需的限位体积
|
||||
Transform boundaryT = GetOrCreateChild(cameraAreaGo.transform, "AreaBoundary");
|
||||
PolygonCollider2D boundaryCollider = GetOrAddComponent<PolygonCollider2D>(boundaryT.gameObject);
|
||||
boundaryCollider.pathCount = 1;
|
||||
boundaryCollider.SetPath(0, new Vector2[]
|
||||
{
|
||||
new Vector2(-12f, -6f), new Vector2(-12f, 6f),
|
||||
new Vector2( 12f, 6f), new Vector2( 12f, -6f),
|
||||
});
|
||||
BoxCollider boundaryCollider = GetOrAddComponent<BoxCollider>(boundaryT.gameObject);
|
||||
boundaryCollider.center = new Vector3(0f, 0f, -10f); // Z 占位符,实际深度由 SyncConfiner 按 LensConfig 计算
|
||||
boundaryCollider.size = new Vector3(24f, 12f, 1f); // 默认房间尺寸占位符
|
||||
|
||||
AssignReference(cameraArea, "_confinerCollider", boundaryCollider);
|
||||
|
||||
@@ -282,7 +278,7 @@ namespace BaseGames.Editor
|
||||
|
||||
// ── Report ─────────────────────────────────────────────────────
|
||||
report.Add("在 RoomController._roomId 填写唯一房间 ID(如 \"Room_Forest_01\")。");
|
||||
report.Add("调整 AreaBoundary PolygonCollider2D 顶点以匹配实际房间大小。");
|
||||
report.Add("绑定 LensConfig SO 后单击 Inspector 中「从可视区域更新限位区域」计算正确的 BoxCollider。");
|
||||
report.Add("使用 Tile Palette 在 Ground Tilemap 上绘制地形,然后在 NavSurface Inspector 中点击 Bake。");
|
||||
report.Add("[Transitions] 子节点下使用 BaseGames/Scene/Place/Room Transition 添加过渡点。");
|
||||
|
||||
|
||||
@@ -76,3 +76,79 @@
|
||||
border-bottom-width: 2px;
|
||||
border-bottom-color: rgb(100, 160, 255);
|
||||
}
|
||||
|
||||
/* ── CharacterWizardWindow 专用类 ───────────────────────── */
|
||||
|
||||
/* 向导标签页按钮(复用 tab-bar 容器) */
|
||||
.tab-btn {
|
||||
flex-grow: 1;
|
||||
padding: 6px 0;
|
||||
border-radius: 0;
|
||||
border-width: 0;
|
||||
border-bottom-width: 2px;
|
||||
border-bottom-color: rgba(0,0,0,0);
|
||||
background-color: rgba(0,0,0,0);
|
||||
color: rgb(170, 170, 170);
|
||||
-unity-font-style: bold;
|
||||
font-size: 13px;
|
||||
}
|
||||
.tab-btn:hover {
|
||||
background-color: rgba(255,255,255,0.06);
|
||||
}
|
||||
.tab-btn--active {
|
||||
color: rgb(255, 255, 255);
|
||||
border-bottom-width: 2px;
|
||||
border-bottom-color: rgb(90, 160, 255);
|
||||
}
|
||||
|
||||
/* SO 工厂按钮(绿调) */
|
||||
.wizard-factory-btn {
|
||||
background-color: rgba(40, 100, 55, 0.75);
|
||||
color: rgb(200, 255, 210);
|
||||
border-radius: 4px;
|
||||
padding: 3px 10px;
|
||||
margin-right: 4px;
|
||||
margin-bottom: 4px;
|
||||
border-width: 1px;
|
||||
border-color: rgba(80, 180, 100, 0.60);
|
||||
}
|
||||
.wizard-factory-btn:hover {
|
||||
background-color: rgba(55, 130, 70, 0.90);
|
||||
}
|
||||
|
||||
/* 场景放置按钮(蓝调) */
|
||||
.wizard-scene-btn {
|
||||
background-color: rgba(30, 60, 120, 0.80);
|
||||
color: rgb(190, 220, 255);
|
||||
border-radius: 4px;
|
||||
padding: 3px 10px;
|
||||
margin-right: 4px;
|
||||
margin-bottom: 4px;
|
||||
border-width: 1px;
|
||||
border-color: rgba(80, 130, 220, 0.55);
|
||||
}
|
||||
.wizard-scene-btn:hover {
|
||||
background-color: rgba(40, 80, 155, 0.95);
|
||||
}
|
||||
|
||||
/* 跳转按钮(中性灰调) */
|
||||
.wizard-jump-btn {
|
||||
background-color: rgba(55, 55, 60, 0.80);
|
||||
color: rgb(210, 210, 220);
|
||||
border-radius: 4px;
|
||||
padding: 3px 10px;
|
||||
margin-right: 4px;
|
||||
margin-bottom: 4px;
|
||||
border-width: 1px;
|
||||
border-color: rgba(120, 120, 130, 0.45);
|
||||
}
|
||||
.wizard-jump-btn:hover {
|
||||
background-color: rgba(75, 75, 85, 0.95);
|
||||
}
|
||||
|
||||
/* 小怪类型选择按钮 */
|
||||
.type-btn--active {
|
||||
border-bottom-width: 2px;
|
||||
border-bottom-color: rgb(255, 200, 50);
|
||||
color: rgb(255, 220, 100);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user