// WL800_Apply.cs — PD 지시 #800 로딩 화면 비율 어긋남 집행 (에디트 모드 · Assets 수정) // // unity command run_script --file AgentScripts/WL800_Apply.cs --entry WL800_Apply.ApplyPrefab --args '["0.68,0.70,0.50,0.29,0.70,0.27,0.29,0.71"]' // unity command run_script --file AgentScripts/WL800_Apply.cs --entry WL800_Apply.ApplyTextures // unity command run_script --file AgentScripts/WL800_Apply.cs --entry WL800_Apply.TuneCluster --args '[28,1080]' // unity command run_script --file AgentScripts/WL800_Apply.cs --entry WL800_Apply.SetFocusX --args '[0.5]' // unity command run_script --file AgentScripts/WL800_Apply.cs --entry WL800_Apply.Dump // 읽기 전용 확인 // // 초점표(focusX_perImage): Loading1~8 은 주인공이 서 있는 가로 위치가 제각각이다(0.27~0.71). // 세로 화면에서는 원본 가로의 31.6% 만 보이므로, 단일 초점 0.5 로는 8장 중 6장에서 // 인물이 아예 화면 밖으로 밀려난다. 그래서 장별 초점을 LoadingUI 에 데이터로 들고 간다. // // 진단(실측 근거 · #800): // SortOrder_5.prefab / LoadingUI/child/RawImage 가 앵커 전체 스트레치다. // CanvasScaler = 참조 1920x1080 · Expand 이므로 1080x1920 단말에서 캔버스가 1920x3413 로 열리고, // 1920x1080 원본(Loading1~8, 8장 모두 1.7778)이 세로로 3.16배 늘어난다. = PD 가 본 "비율 어긋남". // 타이틀(#795)과 동일 원인·동일 해법. // // 해법(타이틀과 같은 방식): // LoadingUI/child : Image(검정) 추가 = 백드롭 겸 입력 차단 (현 RawImage 의 raycast 역할 승계) // LoadingUI/child/RawImage : 세로 스트레치 + AspectRatioFitter(HeightControlsWidth) // + WLBackgroundFit(초점 클램프) + WLRawImageAspectSync(런타임 텍스처 추종) // => 높이 = 화면 높이, 폭 = 높이 x 원본 종횡비. 넘치면 초점 크롭(대칭), 좁으면 좌우 대칭 검정. // // 캔버스 기준(참조 해상도)은 건드리지 않는다. 근거는 WL800_Shot.MeasureCanvas 실측 참조: // Expand + 참조 1920x1080 은 세로 단말에서 캔버스 폭이 정확히 1920 유닛 = 모든 자식이 작성된 폭 기준과 일치. // 1080x1920 로 바꾸면 캔버스 폭이 1080 유닛이 되어 Slider_(1510) · tip(1000) · 팝업류가 전부 화면 밖으로 넘친다. // // 값의 SOT 는 이 스크립트가 아니라 프리팹이다. 여기서는 "기본값 1회 주입"만 하고 이후는 인스펙터에서 튜닝한다(C45). using System.Text; using UnityEditor; using UnityEngine; using UnityEngine.UI; public static class WL800_Apply { public const string PrefabPath = "Assets/ResWork/UIPrefabs/Title/SortOrder_5.prefab"; public const string LoadingRoot = "LoadingUI"; public const string BackdropPath = "LoadingUI/child"; // LoadingUI.go (SetActive 대상) public const string BgPath = "LoadingUI/child/RawImage"; // LoadingUI.loadingimg const string TexDir = "Assets/Res_Addr/Loading"; const int TexCount = 8; // LoadingUI.MaxImage 와 동일 (Loading1~8) // ---------- 1) 프리팹 ---------- /// focusCsv = Loading1~8 각 장의 초점 x (예 "0.68,0.70,0.50,0.29,0.70,0.27,0.29,0.71"). /// 빈 문자열이면 전부 0.5(정중앙). 값은 이 스크립트가 아니라 프리팹이 SOT 다 — 여기서는 1회 주입만 한다. public static object ApplyPrefab(string focusCsv) { if (EditorApplication.isPlaying) return "ABORT: Play 중"; var sb = new StringBuilder(); sb.AppendLine("=== SortOrder_5.prefab / LoadingUI ==="); float[] focus = ParseFocus(focusCsv, sb); float focusX = focus != null && focus.Length > 0 ? focus[0] : 0.5f; var root = PrefabUtility.LoadPrefabContents(PrefabPath); if (root == null) return "ABORT: 프리팹 로드 실패 " + PrefabPath; try { // 캔버스 기준은 그대로 둔다 — 현재 값을 기록만 한다(변경 없음 증빙). var cs = root.GetComponent(); if (cs != null) sb.AppendLine(" CanvasScaler(변경 없음): ref " + cs.referenceResolution + " / " + cs.screenMatchMode + " / match " + cs.matchWidthOrHeight); var backdropTr = root.transform.Find(BackdropPath) as RectTransform; var bgTr = root.transform.Find(BgPath) as RectTransform; if (backdropTr == null) return "ABORT: 경로 없음 " + BackdropPath; if (bgTr == null) return "ABORT: 경로 없음 " + BgPath; var bgRaw = bgTr.GetComponent(); if (bgRaw == null) return "ABORT: RawImage 없음 " + BgPath; // --- 1) 부모 = 검정 백드롭 (여백을 확실히 채우고, 원래 RawImage 가 하던 입력 차단을 승계) --- bool hadRaycast = bgRaw.raycastTarget; var backdrop = backdropTr.GetComponent(); if (backdrop == null) { backdrop = backdropTr.gameObject.AddComponent(); sb.AppendLine(" child: Image 신규 추가 (검정 백드롭)"); } else sb.AppendLine(" child: 기존 Image 재사용"); backdrop.sprite = null; backdrop.color = Color.black; backdrop.raycastTarget = hadRaycast; // 로딩 중 하위 UI 입력 차단 유지 backdrop.maskable = false; // 백드롭은 전체 스트레치를 유지한다 backdropTr.anchorMin = Vector2.zero; backdropTr.anchorMax = Vector2.one; backdropTr.pivot = new Vector2(0.5f, 0.5f); backdropTr.anchoredPosition = Vector2.zero; backdropTr.sizeDelta = Vector2.zero; sb.AppendLine(" color=black raycastTarget=" + backdrop.raycastTarget + " (RawImage 에서 승계)"); // --- 2) 배경 = 세로 맞춤 · 종횡비 유지 · 초점 크롭 --- bgRaw.raycastTarget = false; // 입력은 부모가 받는다 (레이캐스트 대상 1개로 축소) bgRaw.color = Color.white; bgTr.anchorMin = new Vector2(0.5f, 0f); // 세로 스트레치 + 가로 자유 bgTr.anchorMax = new Vector2(0.5f, 1f); // => AspectRatioFitter 가 폭을 계산할 수 있는 유일한 구성 bgTr.pivot = new Vector2(0.5f, 0.5f); bgTr.anchoredPosition = Vector2.zero; bgTr.sizeDelta = Vector2.zero; bgTr.localScale = Vector3.one; // 원본 종횡비는 텍스처에서 유도한다(하드코딩 아님). 런타임 교체분은 Sync 컴포넌트가 추종. float ratio = SourceRatio(sb); var arf = bgTr.GetComponent() ?? bgTr.gameObject.AddComponent(); arf.aspectMode = AspectRatioFitter.AspectMode.HeightControlsWidth; arf.aspectRatio = ratio; var fit = bgTr.GetComponent() ?? bgTr.gameObject.AddComponent(); fit.focusX = focusX; fit.applyVertical = false; var sync = bgTr.GetComponent() ?? bgTr.gameObject.AddComponent(); sync.fallbackRatio = ratio; sb.AppendLine(" RawImage: 전체 스트레치 -> 세로 맞춤(HeightControlsWidth) ratio=" + ratio.ToString("F4") + " focusX=" + focusX.ToString("F2") + " raycast=false"); sb.AppendLine(" + AspectRatioFitter / WLBackgroundFit / WLRawImageAspectSync(런타임 텍스처 추종)"); // --- 3) 이미지 번호별 초점표를 LoadingUI 에 주입 --- // 그림마다 주인공 위치가 달라 단일 초점으로는 6/8 장에서 인물이 화면 밖으로 밀려난다. var lui = root.transform.Find(LoadingRoot)?.GetComponent(); if (lui == null) sb.AppendLine(" !! LoadingUI 컴포넌트를 못 찾음 — 초점표 미주입"); else { lui.bgFit = fit; if (focus != null && focus.Length > 0) { lui.focusX_perImage = focus; sb.AppendLine(" LoadingUI.focusX_perImage = [" + string.Join(", ", System.Array.ConvertAll(focus, v => v.ToString("F2"))) + "]" + " (MaxImage=" + lui.MaxImage + ")"); if (focus.Length != lui.MaxImage) sb.AppendLine(" !! 초점 개수 " + focus.Length + " != MaxImage " + lui.MaxImage + " — 남는 장은 0.5 로 처리된다"); } sb.AppendLine(" LoadingUI.bgFit = " + fit.name); } PrefabUtility.SaveAsPrefabAsset(root, PrefabPath); sb.AppendLine(" 저장 완료"); } finally { PrefabUtility.UnloadPrefabContents(root); } AssetDatabase.SaveAssets(); return sb.ToString(); } /// Loading1~8 이 모두 같은 종횡비인지 확인하고 그 값을 돌려준다. 다르면 첫 장 기준 + 경고. static float SourceRatio(StringBuilder sb) { float first = 0f; bool mixed = false; for (int i = 1; i <= TexCount; i++) { var t = AssetDatabase.LoadAssetAtPath(TexPath(i)); if (t == null) continue; float r = t.width / (float)t.height; if (first == 0f) first = r; else if (Mathf.Abs(r - first) > 0.001f) mixed = true; } if (first == 0f) { sb.AppendLine(" !! 로딩 텍스처를 하나도 못 읽음 -> 16:9 로 가정"); return 16f / 9f; } sb.AppendLine(" 원본 종횡비(Loading1~" + TexCount + "): " + first.ToString("F4") + (mixed ? " !! 장마다 다름 -> Sync 컴포넌트가 런타임에 보정" : " (8장 동일)")); return first; } static string TexPath(int i) { return TexDir + "/Loading" + i + ".jpg"; } static float[] ParseFocus(string csv, StringBuilder sb) { if (string.IsNullOrEmpty(csv)) { sb.AppendLine(" 초점표 미지정 -> 전부 0.5(정중앙)"); return null; } var parts = csv.Split(','); var v = new float[parts.Length]; for (int i = 0; i < parts.Length; i++) { float f; if (!float.TryParse(parts[i].Trim(), System.Globalization.NumberStyles.Float, System.Globalization.CultureInfo.InvariantCulture, out f)) { sb.AppendLine(" !! 초점 파싱 실패 '" + parts[i] + "' -> 0.5"); f = 0.5f; } v[i] = Mathf.Clamp01(f); } return v; } // ---------- 2) 텍스처 임포트 (Loading4 기준으로 8장 통일) ---------- public static object ApplyTextures() { if (EditorApplication.isPlaying) return "ABORT: Play 중"; var sb = new StringBuilder(); sb.AppendLine("=== Loading1~" + TexCount + ".jpg 임포트 통일 (기준 = Loading4 / #795) ==="); int changed = 0; AssetDatabase.StartAssetEditing(); try { for (int i = 1; i <= TexCount; i++) { var path = TexPath(i); var ti = AssetImporter.GetAtPath(path) as TextureImporter; if (ti == null) { sb.AppendLine(" !! TextureImporter 없음 " + path); continue; } bool dirty = false; var before = new StringBuilder(); var def = ti.GetDefaultPlatformTextureSettings(); if (def.maxTextureSize != 2048 || def.textureCompression != TextureImporterCompression.Compressed) { before.Append(" Default[" + def.maxTextureSize + "/" + def.textureCompression + "]"); def.maxTextureSize = 2048; def.textureCompression = TextureImporterCompression.Compressed; ti.SetPlatformTextureSettings(def); dirty = true; } var and = ti.GetPlatformTextureSettings("Android"); if (!and.overridden || and.format != TextureImporterFormat.ASTC_6x6 || and.compressionQuality != 100 || and.maxTextureSize != 2048 || and.crunchedCompression) { before.Append(" Android[ovr=" + and.overridden + "/" + and.format + "/q" + and.compressionQuality + "]"); and.overridden = true; and.maxTextureSize = 2048; and.format = TextureImporterFormat.ASTC_6x6; and.compressionQuality = 100; and.crunchedCompression = false; ti.SetPlatformTextureSettings(and); dirty = true; } if (ti.mipmapEnabled) { before.Append(" mipmap[on]"); ti.mipmapEnabled = false; dirty = true; } if (ti.isReadable) { before.Append(" isReadable[on]"); ti.isReadable = false; dirty = true; } if (ti.filterMode != FilterMode.Bilinear) { before.Append(" filter[" + ti.filterMode + "]"); ti.filterMode = FilterMode.Bilinear; dirty = true; } if (dirty) { EditorUtility.SetDirty(ti); ti.SaveAndReimport(); changed++; sb.AppendLine(" Loading" + i + ": 변경" + before); } else sb.AppendLine(" Loading" + i + ": 이미 기준 충족 (변경 없음)"); } } finally { AssetDatabase.StopAssetEditing(); } AssetDatabase.Refresh(); AssetDatabase.SaveAssets(); sb.AppendLine(" 변경 " + changed + " / " + TexCount + " 장"); for (int i = 1; i <= TexCount; i++) { var t = AssetDatabase.LoadAssetAtPath(TexPath(i)); if (t != null) sb.AppendLine(string.Format(" Loading{0}: {1}x{2} format={3} mip={4}", i, t.width, t.height, t.format, t.mipmapCount)); } return sb.ToString(); } // ---------- 3) 로딩 하단 클러스터 가독성 (세로 기준 재배치) ---------- // // 캔버스가 Expand + 참조 1920x1080 이므로 캔버스 폭은 어떤 단말에서도 정확히 1920 유닛이다. // => 1 유닛 = (화면폭 / 1920) px. 1080 폭 단말에서 1 유닛 = 0.5625 px. // 현재 퍼센트/팁 글자는 20 유닛 = 11.3 px 로, 1080 폭 단말에서 사실상 판독 불가다. // 목표 글자 px 하나만 받아 필요한 유닛을 역산한다(매직넘버 없음). // 구도(가로 폭·앵커·mark/botimg 배치)는 건드리지 않는다 — 아트 결정 영역. public static object TuneCluster(float targetTextPx, float refScreenWidth) { if (EditorApplication.isPlaying) return "ABORT: Play 중"; var sb = new StringBuilder(); var root = PrefabUtility.LoadPrefabContents(PrefabPath); if (root == null) return "ABORT: 프리팹 로드 실패"; try { var cs = root.GetComponent(); float unitPerPx = cs.referenceResolution.x / refScreenWidth; // 유닛/px float fontUnit = Mathf.Round(targetTextPx * unitPerPx); sb.AppendLine("=== 로딩 하단 클러스터 (목표 글자 " + targetTextPx.ToString("F0") + "px @ 화면폭 " + refScreenWidth.ToString("F0") + ") ==="); sb.AppendLine(" 캔버스 폭 " + cs.referenceResolution.x + " 유닛 / 화면폭 " + refScreenWidth + "px => 1 유닛 = " + (1f / unitPerPx).ToString("F4") + "px, 목표 글자 = " + fontUnit + " 유닛"); var t = root.transform; float lineH = Mathf.Round(fontUnit * 1.4f); // 글자 1줄이 들어갈 최소 높이 // 퍼센트 텍스트 SetFont(t, "LoadingUI/child/Slider_/t_proc", fontUnit, new Vector2(fontUnit * 6f, lineH), sb); // 슬라이더 바 = 글자 높이의 약 88% (바가 글자보다 두꺼워 보이지 않도록) SetSize(t, "LoadingUI/child/Slider_", null, Mathf.Round(fontUnit * 0.88f), sb); SetSize(t, "LoadingUI/child/Slider_/handle", Mathf.Round(fontUnit * 1.76f), Mathf.Round(fontUnit * 1.76f), sb); // 팁 바 + 팁 텍스트 (텍스트 폭은 바 안쪽으로 제한 = 잘림 방지) var tipBar = t.Find("LoadingUI/child/tip") as RectTransform; float tipW = tipBar != null ? tipBar.sizeDelta.x : 1000f; SetSize(t, "LoadingUI/child/tip", null, lineH, sb); SetFont(t, "LoadingUI/child/tip/tip", fontUnit, new Vector2(tipW - fontUnit, lineH), sb); // 커진 바끼리 겹치지 않도록 팁 바만 아래로 내린다 (슬라이더/마크/botimg 위치는 유지) var slider = t.Find("LoadingUI/child/Slider_") as RectTransform; if (tipBar != null && slider != null) { float sliderBottom = slider.anchoredPosition.y - slider.sizeDelta.y * 0.5f; float wantTop = sliderBottom - Mathf.Round(fontUnit * 0.3f); // 여백 = 글자의 30% float newY = wantTop - tipBar.sizeDelta.y * 0.5f; if (newY < tipBar.anchoredPosition.y) { sb.AppendLine(" tip 위치 y " + tipBar.anchoredPosition.y + " -> " + newY + " (슬라이더와 겹침 방지)"); tipBar.anchoredPosition = new Vector2(tipBar.anchoredPosition.x, newY); } } PrefabUtility.SaveAsPrefabAsset(root, PrefabPath); sb.AppendLine(" 저장 완료"); } finally { PrefabUtility.UnloadPrefabContents(root); } AssetDatabase.SaveAssets(); return sb.ToString(); } static void SetFont(Transform root, string path, float size, Vector2 rect, StringBuilder sb) { var rt = root.Find(path) as RectTransform; if (rt == null) { sb.AppendLine(" !! 경로 없음 " + path); return; } var tmp = rt.GetComponent(); if (tmp == null) { sb.AppendLine(" !! TMP 없음 " + path); return; } sb.AppendLine(" " + path + ": font " + tmp.fontSize + " -> " + size + ", rect " + rt.sizeDelta + " -> " + rect); tmp.fontSize = size; tmp.enableAutoSizing = false; rt.sizeDelta = rect; } static void SetSize(Transform root, string path, float? w, float? h, StringBuilder sb) { var rt = root.Find(path) as RectTransform; if (rt == null) { sb.AppendLine(" !! 경로 없음 " + path); return; } var before = rt.sizeDelta; rt.sizeDelta = new Vector2(w ?? before.x, h ?? before.y); sb.AppendLine(" " + path + ": size " + before + " -> " + rt.sizeDelta); } // ---------- 4) 초점만 재조정 (A/B 비교용) ---------- public static object SetFocusX(float focusX) { if (EditorApplication.isPlaying) return "ABORT: Play 중"; var root = PrefabUtility.LoadPrefabContents(PrefabPath); try { var f = root.transform.Find(BgPath)?.GetComponent(); if (f == null) return "ABORT: WLBackgroundFit 없음 (ApplyPrefab 먼저)"; f.focusX = focusX; PrefabUtility.SaveAsPrefabAsset(root, PrefabPath); } finally { PrefabUtility.UnloadPrefabContents(root); } AssetDatabase.SaveAssets(); return "SortOrder_5.prefab " + BgPath + " focusX = " + focusX.ToString("F2"); } // ---------- 4) 읽기 전용 확인 ---------- public static object Dump() { var sb = new StringBuilder(); var root = PrefabUtility.LoadPrefabContents(PrefabPath); try { var cs = root.GetComponent(); sb.AppendLine("CanvasScaler: mode=" + cs.uiScaleMode + " ref=" + cs.referenceResolution + " match=" + cs.screenMatchMode + "/" + cs.matchWidthOrHeight); var bd = root.transform.Find(BackdropPath) as RectTransform; var img = bd != null ? bd.GetComponent() : null; sb.AppendLine("child: active=" + (bd != null && bd.gameObject.activeSelf) + " Image=" + (img != null ? (img.color + " raycast=" + img.raycastTarget) : "없음")); var bg = root.transform.Find(BgPath) as RectTransform; if (bg != null) { var raw = bg.GetComponent(); var arf = bg.GetComponent(); var fit = bg.GetComponent(); var sync = bg.GetComponent(); sb.AppendLine("RawImage: anc " + bg.anchorMin + "~" + bg.anchorMax + " piv " + bg.pivot + " pos " + bg.anchoredPosition + " size " + bg.sizeDelta + " raycast=" + (raw != null && raw.raycastTarget)); sb.AppendLine(" AspectRatioFitter=" + (arf != null ? (arf.aspectMode + "/" + arf.aspectRatio.ToString("F4")) : "없음")); sb.AppendLine(" WLBackgroundFit=" + (fit != null ? ("focusX " + fit.focusX.ToString("F2")) : "없음")); sb.AppendLine(" WLRawImageAspectSync=" + (sync != null ? ("fallback " + sync.fallbackRatio.ToString("F4")) : "없음")); } var lui = root.transform.Find(LoadingRoot)?.GetComponent(); if (lui != null) sb.AppendLine("LoadingUI: MaxImage=" + lui.MaxImage + " bgFit=" + (lui.bgFit != null ? lui.bgFit.name : "null") + " focusX_perImage=" + (lui.focusX_perImage == null ? "null" : "[" + string.Join(", ", System.Array.ConvertAll(lui.focusX_perImage, v => v.ToString("F2"))) + "]")); } finally { PrefabUtility.UnloadPrefabContents(root); } return sb.ToString(); } }