// ───────────────────────────────────────────────────────────────────────────── // WL813_ShotOverlay.cs — #813m 캡처 세트 + 엄지 반경 오버레이 (에디터/개발빌드 전용) // // 무엇인가: // ① 세로 캡처 5장 세트(타이틀 · HUD · 전투 · 보스 · 팝업)를 이름 규약으로 자동 저장한다. // 파일명 `<번호>_<슬롯>_x_.png` · 폴더 `Screenshots_WL/Shots/`(Assets 밖 · 미추적). // ② **엄지 반경 오버레이** — 우하단(미러면 좌하단) 모서리 기준 반경 원 + 버튼 실제 지름 원을 // `WLHudLayoutSettings` 값 그대로 그린다. 반경 안 = 초록 · 밖 = 빨강 · 조이스틱 = 파랑. // 캡처 직후 반드시 제거한다(런타임 GameObject · `HideFlags.DontSave` · 씬 저장 대상 아님). // // 왜 `capture_game_view` 를 그대로 쓰지 않는가: `--source screen` 은 에디터 창 비율로 나와 // 세로 1080×1920 검증에 못 쓴다(WL796_Shot 주석과 같은 이유). 여기서는 `ScreenCapture` 로 직접 뜬다. // 게임뷰 해상도 전환은 기존 도구 `AgentScripts/WL_GameViewSize.cs` 를 쓴다(중복 구현 금지). // // 🔴 읽기 전용: 게임 상태·에셋·설정을 바꾸지 않는다. 오버레이는 Play 중 임시 오브젝트이고 캡처 후 파괴된다. // 🔴 CLI 명령을 만들지 않는다(802c 소관). 진입점은 `AgentScripts/WL813_Shots.cs`(얇은 래퍼). // 🔴 릴리즈 빌드 제외. // ───────────────────────────────────────────────────────────────────────────── #if UNITY_EDITOR || DEVELOPMENT_BUILD using System; using System.Collections; using System.Collections.Generic; using System.IO; using System.Text; using UnityEngine; using UnityEngine.UI; using WL.UI; namespace WL.Tools { /// #813m 캡처 세트 · 엄지 반경 오버레이. public static class WL813_ShotOverlay { public const string OutDir = "Screenshots_WL/Shots"; /// 기준서 §D-1 813m 의 5장 세트 순서. public static readonly string[] SlotOrder = { "title", "hud", "combat", "boss", "popup" }; /// 오버레이를 같이 뜨는 슬롯(패드가 보이는 인게임 화면). static readonly string[] ReachSlots = { "hud", "combat", "boss" }; public const int DesignWidth = 1080, DesignHeight = 1920; public static string LastResult = "(not run)"; public static string LastPath = ""; public static string LastManifest = ""; public static readonly List Saved = new List(16); static Runner s_runner; static GameObject s_overlay; public static bool OverlayActive { get { return s_overlay != null; } } public static bool Running { get { return s_runner != null; } } // ── 진입점 ──────────────────────────────────────────────────────────── /// 5장 세트. `interval` 초마다 한 장씩(그 사이에 QA 가 화면을 만든다). public static string RunSet(float intervalSeconds, bool withOverlay) { if (!Application.isPlaying) return "not playing — Title 에서 Play 후 실행한다"; if (s_runner != null) return "already running — " + Status(); float itv = intervalSeconds > 0f ? intervalSeconds : 6f; EnsureRunner(); Saved.Clear(); LastResult = "(running)"; s_runner.StartCoroutine(s_runner.Co_Set(itv, withOverlay)); return "started — 슬롯 " + string.Join(" → ", SlotOrder) + " · " + itv.ToString("F1") + " s 간격 · 오버레이 " + (withOverlay ? "on" : "off") + (SizeWarning() ?? "") + " · 진행은 WL813_Shots.Result"; } /// 한 장만. slot = title|hud|combat|boss|popup(그 밖의 이름도 그대로 파일명에 쓴다). public static string Shot(string slot, bool withOverlay) { if (!Application.isPlaying) return "not playing"; if (s_runner != null) return "busy — 세트 진행 중 (" + Status() + ")"; EnsureRunner(); string s = string.IsNullOrEmpty(slot) ? "shot" : slot.Trim(); s_runner.StartCoroutine(s_runner.Co_One(s, withOverlay)); return "capturing " + s + (withOverlay ? " (+엄지 반경 오버레이)" : "") + (SizeWarning() ?? "") + " — WL813_Shots.Result 로 확인"; } public static string Status() { if (s_runner != null) return "running · saved " + Saved.Count; return "idle · saved " + Saved.Count + " · last=" + (string.IsNullOrEmpty(LastPath) ? "(none)" : LastPath); } /// 세로 규격이 아니면 경고 문구를 돌려준다(전환은 WL_GameViewSize 몫). public static string SizeWarning() { if (Screen.width == DesignWidth && Screen.height == DesignHeight) return null; return " · 🔴 게임뷰 " + Screen.width + "x" + Screen.height + " (규격 " + DesignWidth + "x" + DesignHeight + " — WL_GameViewSize.Set [1080,1920] 로 맞춘 뒤 다시)"; } static void EnsureRunner() { if (s_runner != null) return; var go = GameObject.Find("__wl813_shots"); if (go == null) { go = new GameObject("__wl813_shots"); go.hideFlags = HideFlags.DontSave; } s_runner = go.GetComponent(); if (s_runner == null) s_runner = go.AddComponent(); } // ── 오버레이 ────────────────────────────────────────────────────────── /// 엄지 반경 오버레이를 띄운다. 이미 있으면 다시 그린다. public static string ShowOverlay() { HideOverlay(); var hud = WLHudLayoutSettings.Instance; if (hud == null) return "WLHudLayoutSettings 없음 — 오버레이 생략"; float k = hud.designWidthPx > 0f ? Screen.width / hud.designWidthPx : 1f; bool mirror = hud.mirrorLeftHanded; s_overlay = new GameObject("__wl813_reach_overlay", typeof(RectTransform), typeof(Canvas)); s_overlay.hideFlags = HideFlags.DontSave; var canvas = s_overlay.GetComponent(); canvas.renderMode = RenderMode.ScreenSpaceOverlay; canvas.sortingOrder = 32760; // 게임 UI 위 var sb = new StringBuilder(); sb.Append("반경 ").Append(hud.thumbReachRadiusPx.ToString("F0")).Append(" px · k=").Append(k.ToString("F3")); // 반경 원(모서리 기준) AddRing(canvas.transform, hud.fanOriginPx, hud.thumbReachRadiusPx * 2f, k, mirror, new Color(1f, 0.85f, 0.15f, 0.9f), 4f, "reach"); float r = hud.thumbReachRadiusPx; int outside = 0, inside = 0; float maxReach = 0f; for (int i = 0; i < Mathf.Max(0, hud.activeSkillSlots); i++) Mark(canvas.transform, hud.FanSlotPx(i), hud.slotDiameterPx, k, mirror, r, ref inside, ref outside, ref maxReach, "skill" + i, sb); for (int i = 0; i < 2; i++) Mark(canvas.transform, hud.ReserveSlotPx(i), hud.reserveDiameterPx, k, mirror, r, ref inside, ref outside, ref maxReach, "reserve" + i, sb); Mark(canvas.transform, hud.attackCenterPx, hud.attackDiameterPx, k, mirror, r, ref inside, ref outside, ref maxReach, "attack", sb); Mark(canvas.transform, hud.autoButtonCenterPx, hud.autoButtonDiameterPx, k, mirror, r, ref inside, ref outside, ref maxReach, "auto", sb); // 조이스틱(반대쪽 모서리 기준 · 반경 밖 판정 대상 아님) AddRing(canvas.transform, hud.joystickCenterPx, hud.joystickRadiusPx * 2f, k, !mirror, new Color(0.3f, 0.7f, 1f, 0.85f), 3f, "joystick"); LastResult = "overlay on · " + sb + " · 안 " + inside + " · 밖 " + outside + " · 최대 도달 " + maxReach.ToString("F1") + " px"; return LastResult; } public static string HideOverlay() { if (s_overlay == null) return "overlay off"; UnityEngine.Object.DestroyImmediate(s_overlay); s_overlay = null; return "overlay removed"; } /// 버튼 하나를 실제 지름 원으로 표시하고 반경 안/밖을 색으로 구분한다. static void Mark(Transform parent, Vector2 px, float diameterPx, float k, bool mirror, float radiusPx, ref int inside, ref int outside, ref float maxReach, string name, StringBuilder sb) { float reach = px.magnitude + diameterPx * 0.5f; if (reach > maxReach) maxReach = reach; bool ok = radiusPx <= 0f || reach <= radiusPx; if (ok) inside++; else outside++; var c = ok ? new Color(0.2f, 0.95f, 0.45f, 0.95f) : new Color(1f, 0.25f, 0.28f, 0.95f); AddRing(parent, px, diameterPx, k, mirror, c, 3f, name); sb.Append(" · ").Append(name).Append(' ').Append(reach.ToString("F0")); } /// 모서리 기준 (dx, dy) px 자리에 지름 diameterPx 인 링을 놓는다. static void AddRing(Transform parent, Vector2 px, float diameterPx, float k, bool mirror, Color color, float thicknessPx, string name) { var go = new GameObject("ring_" + name, typeof(RectTransform), typeof(RawImage)); go.hideFlags = HideFlags.DontSave; var rt = (RectTransform)go.transform; rt.SetParent(parent, false); var img = go.GetComponent(); img.raycastTarget = false; img.color = color; img.texture = RingTexture(Mathf.Max(2f, thicknessPx * 128f / Mathf.Max(8f, diameterPx * k))); Vector2 anchor = new Vector2(mirror ? 0f : 1f, 0f); rt.anchorMin = anchor; rt.anchorMax = anchor; rt.pivot = new Vector2(0.5f, 0.5f); float sx = mirror ? px.x : -px.x; rt.anchoredPosition = new Vector2(sx * k, px.y * k); rt.sizeDelta = new Vector2(diameterPx * k, diameterPx * k); rt.localScale = Vector3.one; } // 링 텍스처는 두께 비율별로 캐시한다(캡처마다 새로 만들지 않는다). static readonly Dictionary s_ringCache = new Dictionary(); static Texture2D RingTexture(float thicknessTexels) { const int N = 128; float th = Mathf.Clamp(thicknessTexels, 1.5f, 24f); int key = Mathf.RoundToInt(th * 4f); Texture2D tex; if (s_ringCache.TryGetValue(key, out tex) && tex != null) return tex; tex = new Texture2D(N, N, TextureFormat.RGBA32, false); tex.hideFlags = HideFlags.DontSave; tex.wrapMode = TextureWrapMode.Clamp; var px = new Color32[N * N]; float c = (N - 1) * 0.5f; float outer = c; // 바깥 반지름 float mid = outer - th * 0.5f; // 링 중심선 for (int y = 0; y < N; y++) { for (int x = 0; x < N; x++) { float dx = x - c, dy = y - c; float d = Mathf.Sqrt(dx * dx + dy * dy); float a = 1f - Mathf.Clamp01((Mathf.Abs(d - mid) - th * 0.5f + 1f) / 1.5f); byte b = (byte)Mathf.RoundToInt(Mathf.Clamp01(a) * 255f); px[y * N + x] = new Color32(255, 255, 255, b); } } tex.SetPixels32(px); tex.Apply(false, false); s_ringCache[key] = tex; return tex; } // ── 캡처 ────────────────────────────────────────────────────────────── static int SlotIndex(string slot) { for (int i = 0; i < SlotOrder.Length; i++) if (string.Equals(SlotOrder[i], slot, StringComparison.OrdinalIgnoreCase)) return i + 1; return 9; } static bool WantsReach(string slot) { for (int i = 0; i < ReachSlots.Length; i++) if (string.Equals(ReachSlots[i], slot, StringComparison.OrdinalIgnoreCase)) return true; return false; } internal sealed class Runner : MonoBehaviour { public IEnumerator Co_One(string slot, bool withOverlay) { yield return Capture(slot, false); if (withOverlay) { ShowOverlay(); yield return Capture(slot + "_reach", true); HideOverlay(); } Finish(); } public IEnumerator Co_Set(float interval, bool withOverlay) { var log = new StringBuilder(); log.AppendLine("# WL813 캡처 세트 (#813m) " + DateTime.Now.ToString("yyyy-MM-dd HH:mm:ss")); log.AppendLine("# 게임뷰 " + Screen.width + "x" + Screen.height + " · 간격 " + interval.ToString("F1") + " s · 오버레이 " + (withOverlay ? "on" : "off")); var w = SizeWarning(); if (w != null) log.AppendLine("#" + w); log.AppendLine("# 슬롯 사이 " + interval.ToString("F0") + " s 안에 해당 화면을 만든다(타이틀 → HUD → 전투 → 보스 → 팝업)."); log.AppendLine(); for (int i = 0; i < SlotOrder.Length; i++) { string slot = SlotOrder[i]; Debug.Log("[WL813m] 다음 캡처 = " + slot + " (" + interval.ToString("F0") + " s 뒤)"); float t0 = Time.unscaledTime; while (Time.unscaledTime - t0 < interval) yield return null; yield return Capture(slot, false); log.AppendLine("- " + slot + " → " + (Saved.Count > 0 ? Saved[Saved.Count - 1] : "(실패)")); if (withOverlay && WantsReach(slot)) { string ov = ShowOverlay(); yield return Capture(slot + "_reach", true); log.AppendLine(" · 오버레이 → " + (Saved.Count > 0 ? Saved[Saved.Count - 1] : "(실패)") + " [" + ov + "]"); HideOverlay(); } } log.AppendLine(); log.AppendLine(LayoutSummary()); LastManifest = log.ToString(); try { Directory.CreateDirectory(OutDir); var p = Path.Combine(OutDir, "shots_" + DateTime.Now.ToString("HHmmss") + ".txt"); File.WriteAllText(p, LastManifest, new UTF8Encoding(false)); LastManifest += "\n(매니페스트 " + p + ")"; } catch (Exception e) { LastManifest += "\n(매니페스트 저장 실패 " + e.Message + ")"; } LastResult = "세트 완료 · " + Saved.Count + "장 · " + OutDir; Debug.Log("[WL813m] " + LastResult + "\n" + LastManifest); Finish(); } IEnumerator Capture(string slot, bool overlayOn) { yield return new WaitForEndOfFrame(); Texture2D tex = null; try { tex = ScreenCapture.CaptureScreenshotAsTexture(); Directory.CreateDirectory(OutDir); string name = SlotIndex(slot.Replace("_reach", "")).ToString("00") + "_" + slot + "_" + tex.width + "x" + tex.height + "_" + DateTime.Now.ToString("HHmmss") + ".png"; string path = Path.Combine(OutDir, name); File.WriteAllBytes(path, tex.EncodeToPNG()); Saved.Add(path); LastPath = path; Debug.Log("[WL813m] 캡처 " + path + (overlayOn ? " (엄지 반경 오버레이)" : "")); } catch (Exception e) { Debug.LogWarning("[WL813m] 캡처 실패 " + slot + " · " + e.Message); } finally { if (tex != null) UnityEngine.Object.Destroy(tex); } } void Finish() { HideOverlay(); s_runner = null; if (gameObject != null) Destroy(gameObject); } void OnDestroy() { if (s_runner == this) s_runner = null; } } /// 캡처 판정에 같이 붙일 레이아웃 수치(설정 + 실제 배치). public static string LayoutSummary() { var hud = WLHudLayoutSettings.Instance; if (hud == null) return "WLHudLayoutSettings 없음"; var pad = UnityEngine.Object.FindFirstObjectByType(FindObjectsInactive.Include); var sb = new StringBuilder(); sb.AppendLine("## 엄지 반경 수치 (기준서 §E 3-1 · 7-2)"); sb.AppendLine("- 반경 " + hud.thumbReachRadiusPx.ToString("F0") + " px · 슬롯 " + hud.activeSkillSlots + "개 지름 " + hud.slotDiameterPx.ToString("F0") + " px · 최소 간격 기준 " + hud.slotMinGapPx.ToString("F0") + " px"); sb.AppendLine("- 부채꼴 원점 (" + hud.fanOriginPx.x.ToString("F0") + "," + hud.fanOriginPx.y.ToString("F0") + ") · 반지름 " + hud.fanRadiusPx.ToString("F0") + " px · 시작 " + hud.fanStartAngleDeg.ToString("F1") + "° 간격 " + hud.fanStepAngleDeg.ToString("F1") + "°"); float maxGeo = 0f; for (int i = 0; i < Mathf.Max(0, hud.activeSkillSlots); i++) maxGeo = Mathf.Max(maxGeo, hud.FanSlotPx(i).magnitude + hud.slotDiameterPx * 0.5f); for (int i = 0; i < 2; i++) maxGeo = Mathf.Max(maxGeo, hud.ReserveSlotPx(i).magnitude + hud.reserveDiameterPx * 0.5f); maxGeo = Mathf.Max(maxGeo, hud.attackCenterPx.magnitude + hud.attackDiameterPx * 0.5f); maxGeo = Mathf.Max(maxGeo, hud.autoButtonCenterPx.magnitude + hud.autoButtonDiameterPx * 0.5f); sb.AppendLine("- 설정 기하 최대 도달 " + maxGeo.ToString("F1") + " px → 반경 " + (maxGeo <= hud.thumbReachRadiusPx ? "안 ✅" : "밖 🔴")); sb.AppendLine("- 실제 배치 " + (pad != null ? "maxReach " + pad.LastMaxReachPx.ToString("F1") + " px · minGap " + pad.LastMinGapPx.ToString("F1") + " px · 슬롯 " + pad.LastActiveSlots + "+예약 " + pad.LastReserveSlots : "WLBattlePadLayout 없음(패드 미표시 · 「미확인」)")); sb.AppendLine("- 조이스틱 (" + hud.joystickCenterPx.x.ToString("F0") + "," + hud.joystickCenterPx.y.ToString("F0") + ") 반경 " + hud.joystickRadiusPx.ToString("F0") + " px · 미러=" + (hud.mirrorLeftHanded ? "좌손" : "우손")); return sb.ToString(); } } } #endif