452 lines
16 KiB
C#
452 lines
16 KiB
C#
using UnityEngine;
|
||
using UnityEditor;
|
||
using System.Collections.Generic;
|
||
|
||
/// <summary>
|
||
/// Sprite 预制体批量生成工具 —— EditorWindow 版本。
|
||
/// 从选中的图片自动创建包含主 Sprite 和 EchoOutline 子物体的预制体。
|
||
/// 菜单路径:Tools > Sprite预制体生成器
|
||
/// </summary>
|
||
public class SpritePrefabGeneratorWindow : EditorWindow
|
||
{
|
||
// ======================== 序列化字段 ========================
|
||
[SerializeField] private List<Sprite> selectedSprites = new List<Sprite>();
|
||
[SerializeField] private string outputPath = "Assets/Prefabs/Generated";
|
||
|
||
// 材质路径常量
|
||
private const string GroundClipMatPath = "Assets/Materials/cloud1_GroundClipMat.mat";
|
||
private const string EchoOutlineMatPath = "Assets/Light/shaders/IndianOcean_SpriteEchoOutline.mat";
|
||
|
||
// UI 状态
|
||
private Vector2 _scrollPos;
|
||
private List<string> _logMessages = new List<string>();
|
||
|
||
// 缓存的居中灰色标签样式
|
||
private GUIStyle _centeredGreyStyle;
|
||
private GUIStyle CenteredGreyStyle
|
||
{
|
||
get
|
||
{
|
||
if (_centeredGreyStyle == null)
|
||
{
|
||
_centeredGreyStyle = new GUIStyle(EditorStyles.miniLabel);
|
||
_centeredGreyStyle.alignment = TextAnchor.MiddleCenter;
|
||
_centeredGreyStyle.normal.textColor = new Color(0.5f, 0.5f, 0.5f);
|
||
}
|
||
return _centeredGreyStyle;
|
||
}
|
||
}
|
||
|
||
// ======================== 打开窗口 ========================
|
||
[MenuItem("Tools/Sprite预制体生成器")]
|
||
public static void ShowWindow()
|
||
{
|
||
var win = GetWindow<SpritePrefabGeneratorWindow>("Sprite预制体生成器");
|
||
win.minSize = new Vector2(420, 520);
|
||
}
|
||
|
||
// ======================== GUI ========================
|
||
private void OnGUI()
|
||
{
|
||
_scrollPos = EditorGUILayout.BeginScrollView(_scrollPos);
|
||
|
||
// ---- 标题 ----
|
||
GUILayout.Label("Sprite 预制体批量生成器", EditorStyles.boldLabel);
|
||
EditorGUILayout.HelpBox(
|
||
"将图片拖入下方区域,自动生成预制体。\n" +
|
||
"预制体结构:\n" +
|
||
" 父物体(图片名)→ SpriteRenderer + GroundClip材质\n" +
|
||
" 子物体(EchoOutline)→ SpriteRenderer + EchoOutline材质 + Collider",
|
||
MessageType.Info);
|
||
|
||
EditorGUILayout.Space(8);
|
||
|
||
// ---- Sprite 选择区 ----
|
||
GUILayout.Label("① 选择 Sprite", EditorStyles.boldLabel);
|
||
DrawDropArea();
|
||
DrawSpriteList();
|
||
|
||
EditorGUILayout.Space(8);
|
||
|
||
// ---- 输出路径 ----
|
||
GUILayout.Label("② 输出路径", EditorStyles.boldLabel);
|
||
EditorGUILayout.BeginHorizontal();
|
||
outputPath = EditorGUILayout.TextField(outputPath);
|
||
if (GUILayout.Button("浏览", GUILayout.Width(50)))
|
||
{
|
||
string folder = EditorUtility.OpenFolderPanel("选择预制体输出文件夹", "Assets", "");
|
||
if (!string.IsNullOrEmpty(folder))
|
||
{
|
||
int idx = folder.IndexOf("Assets");
|
||
if (idx >= 0)
|
||
outputPath = folder.Substring(idx);
|
||
else
|
||
EditorUtility.DisplayDialog("错误", "请选择项目 Assets 目录下的文件夹", "确定");
|
||
}
|
||
}
|
||
EditorGUILayout.EndHorizontal();
|
||
|
||
EditorGUILayout.Space(8);
|
||
|
||
// ---- 材质预览 ----
|
||
GUILayout.Label("③ 材质引用(自动加载)", EditorStyles.boldLabel);
|
||
DrawMaterialPreview(GroundClipMatPath, "GroundClip 材质");
|
||
DrawMaterialPreview(EchoOutlineMatPath, "EchoOutline 材质");
|
||
|
||
EditorGUILayout.Space(12);
|
||
|
||
// ---- 生成按钮 ----
|
||
GUI.enabled = selectedSprites.Count > 0;
|
||
if (GUILayout.Button($"批量生成预制体 ({selectedSprites.Count})", GUILayout.Height(36)))
|
||
{
|
||
GenerateAll();
|
||
}
|
||
GUI.enabled = true;
|
||
|
||
EditorGUILayout.Space(8);
|
||
|
||
// ---- 日志 ----
|
||
if (_logMessages.Count > 0)
|
||
{
|
||
GUILayout.Label("日志", EditorStyles.boldLabel);
|
||
foreach (var msg in _logMessages)
|
||
EditorGUILayout.LabelField(msg, EditorStyles.miniLabel);
|
||
}
|
||
|
||
EditorGUILayout.EndScrollView();
|
||
}
|
||
|
||
// ======================== 拖放区域 ========================
|
||
private void DrawDropArea()
|
||
{
|
||
Rect dropRect = GUILayoutUtility.GetRect(0, 70, GUILayout.ExpandWidth(true));
|
||
GUI.Box(dropRect, GUIContent.none, EditorStyles.helpBox);
|
||
|
||
// 绘制提示文字
|
||
EditorGUI.LabelField(dropRect,
|
||
selectedSprites.Count == 0
|
||
? "将 Sprite / Texture 从 Project 拖入此处"
|
||
: $"已选择 {selectedSprites.Count} 个 Sprite(可继续拖入追加)",
|
||
CenteredGreyStyle);
|
||
|
||
// 处理拖放事件
|
||
EventType evtType = Event.current.type;
|
||
if (evtType == EventType.DragUpdated || evtType == EventType.DragPerform)
|
||
{
|
||
bool hasValid = false;
|
||
foreach (var obj in DragAndDrop.objectReferences)
|
||
{
|
||
if (obj is Sprite || obj is Texture2D)
|
||
{
|
||
hasValid = true;
|
||
break;
|
||
}
|
||
}
|
||
if (hasValid) DragAndDrop.visualMode = DragAndDropVisualMode.Copy;
|
||
if (evtType == EventType.DragPerform)
|
||
{
|
||
DragAndDrop.AcceptDrag();
|
||
AddFromDragObjects();
|
||
}
|
||
Event.current.Use();
|
||
}
|
||
}
|
||
|
||
private void AddFromDragObjects()
|
||
{
|
||
foreach (var obj in DragAndDrop.objectReferences)
|
||
{
|
||
if (obj is Sprite sp)
|
||
{
|
||
if (!selectedSprites.Contains(sp))
|
||
selectedSprites.Add(sp);
|
||
}
|
||
else if (obj is Texture2D tex)
|
||
{
|
||
string path = AssetDatabase.GetAssetPath(tex);
|
||
// 加载该纹理下的所有 Sprite(支持 Multiple 模式)
|
||
var subs = AssetDatabase.LoadAllAssetsAtPath(path);
|
||
bool added = false;
|
||
foreach (var sub in subs)
|
||
{
|
||
if (sub is Sprite s && !selectedSprites.Contains(s))
|
||
{
|
||
selectedSprites.Add(s);
|
||
added = true;
|
||
}
|
||
}
|
||
// Single 模式:直接加载
|
||
if (!added)
|
||
{
|
||
var single = AssetDatabase.LoadAssetAtPath<Sprite>(path);
|
||
if (single != null && !selectedSprites.Contains(single))
|
||
selectedSprites.Add(single);
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
// ======================== Sprite 列表 ========================
|
||
private void DrawSpriteList()
|
||
{
|
||
if (selectedSprites.Count == 0) return;
|
||
|
||
EditorGUILayout.BeginHorizontal();
|
||
GUILayout.FlexibleSpace();
|
||
if (GUILayout.Button("清空列表", GUILayout.Width(70)))
|
||
selectedSprites.Clear();
|
||
EditorGUILayout.EndHorizontal();
|
||
|
||
for (int i = selectedSprites.Count - 1; i >= 0; i--)
|
||
{
|
||
EditorGUILayout.BeginHorizontal(EditorStyles.helpBox);
|
||
|
||
// Sprite 缩略图
|
||
var sp = selectedSprites[i];
|
||
if (sp != null)
|
||
{
|
||
var preview = AssetPreview.GetMiniThumbnail(sp);
|
||
GUILayout.Label(preview, GUILayout.Width(20), GUILayout.Height(20));
|
||
GUILayout.Label(sp.name, GUILayout.ExpandWidth(true));
|
||
}
|
||
else
|
||
{
|
||
GUILayout.Label("(null)", GUILayout.ExpandWidth(true));
|
||
}
|
||
|
||
if (GUILayout.Button("×", GUILayout.Width(22)))
|
||
selectedSprites.RemoveAt(i);
|
||
|
||
EditorGUILayout.EndHorizontal();
|
||
}
|
||
}
|
||
|
||
// ======================== 材质预览 ========================
|
||
private void DrawMaterialPreview(string assetPath, string label)
|
||
{
|
||
var mat = AssetDatabase.LoadAssetAtPath<Material>(assetPath);
|
||
if (mat != null)
|
||
{
|
||
EditorGUILayout.ObjectField(label, mat, typeof(Material), false);
|
||
}
|
||
else
|
||
{
|
||
EditorGUILayout.HelpBox($"未找到材质:{assetPath}", MessageType.Warning);
|
||
}
|
||
}
|
||
|
||
// ======================== 批量生成 ========================
|
||
private void GenerateAll()
|
||
{
|
||
// 移除空项
|
||
selectedSprites.RemoveAll(s => s == null);
|
||
|
||
if (selectedSprites.Count == 0)
|
||
{
|
||
EditorUtility.DisplayDialog("提示", "请先选择至少一个 Sprite", "确定");
|
||
return;
|
||
}
|
||
|
||
// 加载材质
|
||
Material groundClipMat = AssetDatabase.LoadAssetAtPath<Material>(GroundClipMatPath);
|
||
Material echoOutlineMat = AssetDatabase.LoadAssetAtPath<Material>(EchoOutlineMatPath);
|
||
|
||
if (groundClipMat == null)
|
||
{
|
||
EditorUtility.DisplayDialog("错误", $"找不到材质:{GroundClipMatPath}", "确定");
|
||
return;
|
||
}
|
||
if (echoOutlineMat == null)
|
||
{
|
||
EditorUtility.DisplayDialog("错误", $"找不到材质:{EchoOutlineMatPath}", "确定");
|
||
return;
|
||
}
|
||
|
||
// 确保输出目录存在
|
||
EnsureFolderExists(outputPath);
|
||
|
||
_logMessages.Clear();
|
||
int count = 0;
|
||
GameObject lastPrefab = null;
|
||
|
||
int total = selectedSprites.Count;
|
||
for (int idx = 0; idx < total; idx++)
|
||
{
|
||
var sprite = selectedSprites[idx];
|
||
if (EditorUtility.DisplayCancelableProgressBar("生成预制体",
|
||
$"正在生成 {sprite.name}... ({idx + 1}/{total})", (float)idx / total))
|
||
break;
|
||
|
||
var prefab = GenerateSingle(sprite, groundClipMat, echoOutlineMat);
|
||
if (prefab != null)
|
||
{
|
||
count++;
|
||
lastPrefab = prefab;
|
||
}
|
||
}
|
||
EditorUtility.ClearProgressBar();
|
||
|
||
// 保存并刷新
|
||
AssetDatabase.SaveAssets();
|
||
AssetDatabase.Refresh();
|
||
|
||
_logMessages.Add($"✓ 共生成 {count} 个预制体 → {outputPath}");
|
||
Debug.Log($"[SpritePrefabGenerator] 共生成 {count} 个预制体");
|
||
|
||
if (lastPrefab != null)
|
||
{
|
||
Selection.activeObject = lastPrefab;
|
||
EditorGUIUtility.PingObject(lastPrefab);
|
||
}
|
||
|
||
Repaint();
|
||
|
||
if (count > 0)
|
||
EditorUtility.DisplayDialog("完成", $"已生成 {count} 个预制体\n路径:{outputPath}", "确定");
|
||
}
|
||
|
||
// ======================== 生成单个预制体 ========================
|
||
private GameObject GenerateSingle(Sprite sprite, Material groundClipMat, Material echoOutlineMat)
|
||
{
|
||
// 清理文件名中的非法字符
|
||
string spriteName = SanitizeFileName(sprite.name);
|
||
string prefabPath = $"{outputPath}/{spriteName}.prefab";
|
||
|
||
// ---- 父物体 ----
|
||
GameObject root = new GameObject(sprite.name); // GameObject 名用原始名
|
||
root.transform.localPosition = Vector3.zero;
|
||
root.transform.localRotation = Quaternion.identity;
|
||
root.transform.localScale = Vector3.one;
|
||
|
||
// 父物体 SpriteRenderer
|
||
var parentSR = root.AddComponent<SpriteRenderer>();
|
||
parentSR.sprite = sprite;
|
||
parentSR.sharedMaterial = groundClipMat;
|
||
parentSR.sortingOrder = 1;
|
||
|
||
// ---- 子物体 EchoOutline ----
|
||
GameObject child = new GameObject("EchoOutline");
|
||
child.transform.SetParent(root.transform, false);
|
||
child.transform.localPosition = Vector3.zero;
|
||
child.transform.localRotation = Quaternion.identity;
|
||
child.transform.localScale = Vector3.one;
|
||
|
||
// 子物体 SpriteRenderer
|
||
var childSR = child.AddComponent<SpriteRenderer>();
|
||
childSR.sprite = sprite;
|
||
childSR.sharedMaterial = echoOutlineMat;
|
||
childSR.sortingOrder = 10001;
|
||
|
||
// 子物体 Collider(根据 Sprite Pivot 自动设置)
|
||
SetupCollider2D(child, sprite);
|
||
|
||
// 保存为预制体并验证结果
|
||
bool success;
|
||
PrefabUtility.SaveAsPrefabAsset(root, prefabPath, out success);
|
||
Object.DestroyImmediate(root);
|
||
|
||
if (success)
|
||
{
|
||
var asset = AssetDatabase.LoadAssetAtPath<GameObject>(prefabPath);
|
||
_logMessages.Add($"✓ {spriteName}.prefab");
|
||
Debug.Log($"[SpritePrefabGenerator] ✓ 已创建预制体:{prefabPath}");
|
||
return asset;
|
||
}
|
||
else
|
||
{
|
||
_logMessages.Add($"✗ {spriteName}.prefab 保存失败");
|
||
Debug.LogWarning($"[SpritePrefabGenerator] ✗ 保存失败:{prefabPath}");
|
||
return null;
|
||
}
|
||
}
|
||
|
||
// ======================== Collider 设置 ========================
|
||
|
||
/// <summary>
|
||
/// 根据 Sprite 的 Pivot 自动设置 2D Collider。
|
||
/// 优先使用 Sprite Physics Shape(精灵物理轮廓),
|
||
/// 若无则使用 Sprite 边界矩形(考虑 Pivot 偏移)。
|
||
/// </summary>
|
||
private void SetupCollider2D(GameObject go, Sprite sprite)
|
||
{
|
||
float ppu = sprite.pixelsPerUnit;
|
||
Vector2 pivot = sprite.pivot; // 像素坐标(用于 BoxCollider fallback)
|
||
|
||
// 尝试使用 Sprite Physics Shape
|
||
// 注意:GetPhysicsShape 返回的坐标已经是像素单位且相对于 Pivot,
|
||
// 因此只需除以 PPU 即可转为世界单位
|
||
List<Vector2[]> paths = GetSpritePhysicsShape(sprite);
|
||
|
||
if (paths != null && paths.Count > 0)
|
||
{
|
||
var poly = go.AddComponent<PolygonCollider2D>();
|
||
poly.pathCount = paths.Count;
|
||
|
||
for (int i = 0; i < paths.Count; i++)
|
||
{
|
||
var worldPoints = new Vector2[paths[i].Length];
|
||
for (int j = 0; j < paths[i].Length; j++)
|
||
{
|
||
// Physics shape 坐标 = 像素、相对 pivot → 除以 PPU 得到世界单位
|
||
worldPoints[j] = paths[i][j] / ppu;
|
||
}
|
||
poly.SetPath(i, worldPoints);
|
||
}
|
||
}
|
||
else
|
||
{
|
||
// Fallback:基于 Sprite 边界矩形 + Pivot 偏移
|
||
var box = go.AddComponent<BoxCollider2D>();
|
||
Vector2 sizePx = new Vector2(sprite.rect.width, sprite.rect.height);
|
||
box.size = sizePx / ppu;
|
||
box.offset = (sizePx * 0.5f - pivot) / ppu;
|
||
}
|
||
}
|
||
|
||
/// <summary>
|
||
/// 读取 Sprite 的物理形状(Physics Shape),返回路径列表。
|
||
/// </summary>
|
||
private List<Vector2[]> GetSpritePhysicsShape(Sprite sprite)
|
||
{
|
||
var result = new List<Vector2[]>();
|
||
int pathCount = sprite.GetPhysicsShapeCount();
|
||
|
||
for (int i = 0; i < pathCount; i++)
|
||
{
|
||
var points = new List<Vector2>();
|
||
sprite.GetPhysicsShape(i, points);
|
||
if (points.Count >= 3)
|
||
result.Add(points.ToArray());
|
||
}
|
||
|
||
return result;
|
||
}
|
||
|
||
/// <summary>
|
||
/// 清理文件名中的非法字符(/\:*?"<>|)。
|
||
/// </summary>
|
||
private static string SanitizeFileName(string name)
|
||
{
|
||
foreach (char c in System.IO.Path.GetInvalidFileNameChars())
|
||
name = name.Replace(c, '_');
|
||
return name;
|
||
}
|
||
|
||
// ======================== 工具方法 ========================
|
||
|
||
/// <summary>
|
||
/// 确保 Asset 目录链存在(递归创建)。
|
||
/// </summary>
|
||
private void EnsureFolderExists(string folderPath)
|
||
{
|
||
if (AssetDatabase.IsValidFolder(folderPath)) return;
|
||
|
||
string parent = System.IO.Path.GetDirectoryName(folderPath).Replace('\\', '/');
|
||
if (!string.IsNullOrEmpty(parent) && !AssetDatabase.IsValidFolder(parent))
|
||
EnsureFolderExists(parent);
|
||
|
||
string folderName = System.IO.Path.GetFileName(folderPath);
|
||
AssetDatabase.CreateFolder(parent, folderName);
|
||
}
|
||
}
|