Project_WL/Assets/WL/Tools/WL813_ShotOverlay.cs

379 lines
20 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// ─────────────────────────────────────────────────────────────────────────────
// 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