using System.Collections.Generic; using System.IO; using System.Linq; using System.Text; using UnityEditor; using UnityEditor.U2D; using UnityEngine; using UnityEngine.U2D; namespace TKS.EditorTools { /// /// UI 스프라이트 아틀라스(PD 2026-11-10 「드로우콜 최소화 구조」) — 구성 SOT = Assets/Data/UI/ui_atlas.csv → Assets/UI/Atlases/<이름>.spriteatlasv2. /// theme 줄 = UI 테마(UITheme.asset)가 쓰는 스프라이트를 match(경로 포함 · | = 또는 · * = 나머지 전부)로 나눔 · folder 줄 = 폴더 전체 · /// option single_area = 그보다 넓은 그림은 묶지 않음 · padding. /// UI 테마 임포트가 끝날 때마다 자동으로 다시 맞춘다(새 납품 스프라이트 = 코드 변경 없이 아틀라스에 들어감) · 구성이 같으면 파일을 건드리지 않는다. /// 패커 = Sprite Atlas V2(에디터 Play 에서도 묶음 · 프로젝트 설정 EditorSettings). /// public static class UIAtlasBuilder { public const string CsvPath = "Assets/Data/UI/ui_atlas.csv"; public const string OutDir = "Assets/UI/Atlases"; class Group { public string kind; public string name; public string[] match; public int maxSize; public readonly List paths = new List(); } [MenuItem("TKS/Build UI Atlases (UI 아틀라스 갱신)")] public static void Menu() { Build(AssetDatabase.LoadAssetAtPath(UIThemeImporter.AssetPath), true); } /// 표대로 아틀라스를 맞춘다 · 반환 = 오류 없이 끝났는가(오류면 아틀라스 미변경). public static bool Build(UITheme theme, bool log = false) { if (!File.Exists(CsvPath)) { Debug.LogError("[TKS] UI 아틀라스 표 없음: " + CsvPath); return false; } var groups = new List(); long singleArea = long.MaxValue; int padding = 4; var errors = new List(); var lines = File.ReadAllText(CsvPath, Encoding.UTF8).TrimStart('').Replace("\r", "").Split('\n'); for (int n = 1; n < lines.Length; n++) { if (string.IsNullOrWhiteSpace(lines[n])) continue; var f = lines[n].Split(','); if (f.Length < 4) { errors.Add((n + 1) + "행: 열 부족"); continue; } string kind = f[0].Trim(), name = f[1].Trim(), match = f[2].Trim(), value = f[3].Trim(); int v; switch (kind) { case "theme": case "folder": if (!int.TryParse(value, out v) || v < 128) errors.Add((n + 1) + "행 " + name + ": 최대 크기 " + value); groups.Add(new Group { kind = kind, name = name, match = match.Split('|'), maxSize = v }); break; case "option": if (!int.TryParse(value, out v)) errors.Add((n + 1) + "행 " + name + ": 값 " + value); else if (name == "single_area") singleArea = v; else if (name == "padding") padding = v; else errors.Add((n + 1) + "행: 모르는 옵션 " + name); break; default: errors.Add((n + 1) + "행: 모르는 종류 " + kind); break; } } // theme 스프라이트 → 첫 번째로 맞는 theme 줄(* = 나머지) var singles = new List(); foreach (var path in ThemeTextures(theme)) { var tex = AssetDatabase.LoadAssetAtPath(path); if (tex == null) continue; if ((long)tex.width * tex.height > singleArea) { singles.Add(path); continue; } Group hit = null; foreach (var g in groups) if (g.kind == "theme" && g.match.Any(m => m != "*" && path.Contains(m))) { hit = g; break; } if (hit == null) hit = groups.FirstOrDefault(g => g.kind == "theme" && g.match.Contains("*")); if (hit != null) hit.paths.Add(path); } foreach (var g in groups) if (g.kind == "folder") { if (!AssetDatabase.IsValidFolder(g.match[0])) errors.Add(g.name + ": 폴더 없음 " + g.match[0]); else g.paths.Add(g.match[0]); } if (errors.Count > 0) { Debug.LogError("[TKS] UI 아틀라스 표 오류 " + errors.Count + "건(아틀라스 미변경):\n" + string.Join("\n", errors)); return false; } // 패커 = Sprite Atlas V2(에디터 Play 에서도 묶음) if (EditorSettings.spritePackerMode != SpritePackerMode.SpriteAtlasV2) { EditorSettings.spritePackerMode = SpritePackerMode.SpriteAtlasV2; AssetDatabase.SaveAssets(); } if (!AssetDatabase.IsValidFolder(OutDir)) AssetDatabase.CreateFolder(Path.GetDirectoryName(OutDir).Replace('\\', '/'), Path.GetFileName(OutDir)); int changed = 0; var report = new StringBuilder(); foreach (var g in groups) { g.paths.Sort(System.StringComparer.Ordinal); if (Write(g, padding)) changed++; report.Append(" · ").Append(g.name).Append(' ').Append(g.paths.Count); } if (changed > 0) SpriteAtlasUtility.PackAllAtlases(EditorUserBuildSettings.activeBuildTarget); if (log || changed > 0) Debug.Log("[TKS] UI 아틀라스" + report + " · 큰 그림(묶지 않음) " + singles.Count + " · 갱신 " + changed + "장"); return true; } /// 아틀라스 1장을 표 구성대로 · 구성(목록·크기·간격) 서명이 같으면 건드리지 않는다. static bool Write(Group g, int padding) { string path = OutDir + "/" + g.name + ".spriteatlasv2"; string sig = Hash128.Compute(g.maxSize + ";" + padding + ";" + string.Join("|", g.paths.Select(AssetDatabase.AssetPathToGUID))).ToString(); var imp = AssetImporter.GetAtPath(path) as SpriteAtlasImporter; if (imp != null && imp.userData == sig) return false; var asset = new SpriteAtlasAsset(); var objs = new List(); foreach (var p in g.paths) { var o = AssetDatabase.LoadMainAssetAtPath(p); if (o != null) objs.Add(o); } asset.Add(objs.ToArray()); SpriteAtlasAsset.Save(asset, path); AssetDatabase.ImportAsset(path, ImportAssetOptions.ForceSynchronousImport); imp = (SpriteAtlasImporter)AssetImporter.GetAtPath(path); imp.includeInBuild = true; // UI = 사각 묶음(9-slice · Filled 가 그대로) · 회전 없음 · 밉맵 없음 imp.packingSettings = new SpriteAtlasPackingSettings { blockOffset = 1, enableRotation = false, enableTightPacking = false, enableAlphaDilation = true, padding = padding }; imp.textureSettings = new SpriteAtlasTextureSettings { readable = false, generateMipMaps = false, sRGB = true, filterMode = FilterMode.Bilinear, anisoLevel = 0 }; var ps = imp.GetPlatformSettings("DefaultTexturePlatform"); ps.maxTextureSize = g.maxSize; ps.format = TextureImporterFormat.Automatic; ps.textureCompression = TextureImporterCompression.Compressed; // 원본 UI 텍스처와 같은 압축(Normal) imp.SetPlatformSettings(ps); imp.userData = sig; imp.SaveAndReimport(); return true; } /// 테마가 쓰는 스프라이트의 텍스처 경로(팩 · 신규 제작 · 덮개) — 중복 없음. public static List ThemeTextures(UITheme theme) { var set = new HashSet(); if (theme != null) { foreach (var s in theme.skins) { Add(set, s.pack); Add(set, s.samguk); Add(set, s.overlay); } foreach (var i in theme.icons) { Add(set, i.pack); Add(set, i.samguk); } } var list = set.ToList(); list.Sort(System.StringComparer.Ordinal); return list; } static void Add(HashSet set, Sprite sp) { if (sp == null) return; var p = AssetDatabase.GetAssetPath(sp); // 팩 일부 = 확장자 대문자(.Png) → 확장자 대신 본 자산이 텍스처인지로 판정 if (!string.IsNullOrEmpty(p) && AssetDatabase.GetMainAssetTypeAtPath(p) == typeof(Texture2D)) set.Add(p); } } }