379 lines
20 KiB
C#
379 lines
20 KiB
C#
// ─────────────────────────────────────────────────────────────────────────────
|
||
// 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
|