Project_WL/Assets/WL/Tools/WL813_ShotOverlay.cs

379 lines
20 KiB
C#
Raw Normal View History

// ─────────────────────────────────────────────────────────────────────────────
// WL813_ShotOverlay.cs — #813m 캡처 세트 + 엄지 반경 오버레이 (에디터/개발빌드 전용)
//
// 무엇인가:
// ① 세로 캡처 5장 세트(타이틀 · HUD · 전투 · 보스 · 팝업)를 이름 규약으로 자동 저장한다.
// 파일명 `<번호>_<슬롯>_<W>x<H>_<HHmmss>.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
{
/// <summary>#813m 캡처 세트 · 엄지 반경 오버레이.</summary>
public static class WL813_ShotOverlay
{
public const string OutDir = "Screenshots_WL/Shots";
/// <summary>기준서 §D-1 813m 의 5장 세트 순서.</summary>
public static readonly string[] SlotOrder = { "title", "hud", "combat", "boss", "popup" };
/// <summary>오버레이를 같이 뜨는 슬롯(패드가 보이는 인게임 화면).</summary>
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<string> Saved = new List<string>(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; } }
// ── 진입점 ────────────────────────────────────────────────────────────
/// <summary>5장 세트. `interval` 초마다 한 장씩(그 사이에 QA 가 화면을 만든다).</summary>
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";
}
/// <summary>한 장만. slot = title|hud|combat|boss|popup(그 밖의 이름도 그대로 파일명에 쓴다).</summary>
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);
}
/// <summary>세로 규격이 아니면 경고 문구를 돌려준다(전환은 WL_GameViewSize 몫).</summary>
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<Runner>();
if (s_runner == null) s_runner = go.AddComponent<Runner>();
}
// ── 오버레이 ──────────────────────────────────────────────────────────
/// <summary>엄지 반경 오버레이를 띄운다. 이미 있으면 다시 그린다.</summary>
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>();
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";
}
/// <summary>버튼 하나를 실제 지름 원으로 표시하고 반경 안/밖을 색으로 구분한다.</summary>
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"));
}
/// <summary>모서리 기준 (dx, dy) px 자리에 지름 diameterPx 인 링을 놓는다.</summary>
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<RawImage>();
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<int, Texture2D> s_ringCache = new Dictionary<int, Texture2D>();
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; }
}
/// <summary>캡처 판정에 같이 붙일 레이아웃 수치(설정 + 실제 배치).</summary>
public static string LayoutSummary()
{
var hud = WLHudLayoutSettings.Instance;
if (hud == null) return "WLHudLayoutSettings 없음";
var pad = UnityEngine.Object.FindFirstObjectByType<WLBattlePadLayout>(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