Project_WL/AgentScripts/WL800_Shot.cs

257 lines
14 KiB
C#
Raw Normal View History

WL 리뉴얼 세션 2026-09-06~07 (#760~#800) — 전투·배경·UI·타격감 (조직 PD 로그 #746~#800 · 대화로그 §14~§24) - 전투: Knight@Attack1~3_S 3콤보(4 컨트롤러·클래스별 서브클립) · 타겟팅 4규칙(정면 우선·재타겟·즉시 공격·어그로 금지 · WLTargetingSettings) · 대쉬 후 공격(Stander@Chase_Start · DashDriver · 5 m · 사정거리+0.8 · 근거리 적 우선) · 공격 이동 FrameTable(발 접지 실측 표 · AttackRootMotion) · 충돌 반경/투사체 0.3 배(WLCollisionTuning) · 무적(임시)·펫 금지(WLGameplaySettings) - 검기: NamuFX Slash_B 배리언트 Effect_WLSwingArc 원 피팅 정합 배치 + 캘리브레이션(SlashArcMeasure · SlashTrailSettings) · 찌르기 Effect_WLStab 대기 · 램프 리본(BladeTrail · WL_BladeRibbon.shader · T_WL_BladeRibbonRamp) 보존(drawRibbon 0) - 타격감: Assets/WL/Feel(WLHitFeel · 히트스톱 0.03 · 셰이크 0.10 m · 몹 펀치 1.12 · Actor.Get_Damage 훅 1줄 · 원본 RealCamera 셰이크 결함 대체) - 배경/맵: LMHPOLY Demo_01~10 → WL_Nature01~10(프리팹·씬·NavMesh·스포너·BattleMapConfig) · 물(ToonWaterU) · 포스트 블룸 0.9/0.3 · 잔디(BruteForce·드레싱) 제거 · 마젠타 머티리얼 URP 변환 - UI: 세로 HUD(WL_HUD · 하단 5메뉴 폭 전체 · 채팅/전투 패드 숨김 · WLIngameUiOverride) · Title/TitleInfo 1080×1920 Expand + 배경 높이 fit(WLBackgroundFit) · 로딩 SortOrder_5(WLRawImageAspectSync · 초점표 8장) · Loading1~8 ASTC 6×6 - 도구: AgentScripts/*(LightProbe · WL_MapSwitch · WL760~WL800 프로브/집행/검증 · 상단 사용법 주석) · 에디터 락 프로토콜 파일(staging) - 제외(별도 커밋 예정): Assets/LMHPOLY(703 MB) · Assets/Feel(422 MB) · Assets/Shinabro(300 MB) — 에셋 스토어/구 WL 팩 원본 Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-06 23:24:25 +00:00
// WL800_Shot.cs — #800 로딩 화면 해상도별 실측 + 에디트 모드 렌더 캡처 (읽기 전용: 디스크 에셋 미수정)
//
// 1) unity command run_script --file AgentScripts/WL_GameViewSize.cs --entry WL_GameViewSize.Set --args '[1080,1920]'
// 2) unity command run_script --file AgentScripts/WL800_Shot.cs --entry WL800_Shot.Shot --args '["before_1080x1920",0,1]'
// args = [라벨, mode, 로딩이미지 인덱스(1~8)]
// mode 0 = LoadingUI (배경/슬라이더/텍스트) 1 = PopupUI 2 = OptionInfo 3 = NotiInfo 4 = NetWait
// 3) unity command run_script --file AgentScripts/WL800_Shot.cs --entry WL800_Shot.ResetScene
//
// 왜 임시 카메라인가: SortOrder_5 캔버스는 ScreenSpaceOverlay 이고 Title.unity 에는 카메라가 없다.
// Overlay 는 런타임 백버퍼에만 합성되므로 에디트 모드에서 capture_game_view 로는 찍을 수 없다.
// => 프리팹을 씬에 임시 인스턴스화하고 캔버스를 ScreenSpaceCamera 로 잠깐 바꿔 RenderTexture 에 직접 렌더한다.
//
// 안전장치: 씬을 절대 저장하지 않는다. 프리팹도 인스턴스만 조작하고 DestroyImmediate 로 지운다.
// (프리팹 인스턴스에 가한 변경은 ApplyPrefabInstance 를 호출하지 않는 한 원본에 반영되지 않는다.)
using System.Collections.Generic;
using System.Text;
using UnityEditor;
using UnityEditor.SceneManagement;
using UnityEngine;
using UnityEngine.UI;
public static class WL800_Shot
{
const string ScenePath = "Assets/Scenes/Title.unity";
const string PrefabPath = "Assets/ResWork/UIPrefabs/Title/SortOrder_5.prefab";
const string TexDir = "Assets/Res_Addr/Loading";
const string OutDir = "Screenshots_WL/loading";
// run_script 는 파일 단위로 독립 컴파일되므로 WL800_Apply 의 상수를 참조할 수 없다. 값이 같아야 한다.
const string BackdropPath = "LoadingUI/child";
const string BgPath = "LoadingUI/child/RawImage";
/// Overlay/ScreenSpace 캔버스에서 RectTransform 의 월드 코너 = 화면 픽셀.
static Rect ScreenRect(RectTransform rt)
{
var c = new Vector3[4];
rt.GetWorldCorners(c);
float w = Vector3.Distance(c[0], c[3]);
float h = Vector3.Distance(c[0], c[1]);
var center = (c[0] + c[2]) * 0.5f;
return new Rect(center.x - w * 0.5f, center.y - h * 0.5f, w, h);
}
static void MeasureRect(Transform root, string path, StringBuilder sb)
{
var t = root.Find(path) as RectTransform;
if (t == null) { sb.AppendLine(" [" + path + "] 없음"); return; }
var r = ScreenRect(t);
bool outL = r.xMin < -0.5f, outR = r.xMax > Screen.width + 0.5f;
bool outB = r.yMin < -0.5f, outT = r.yMax > Screen.height + 0.5f;
string clip = (outL || outR || outB || outT)
? (" !! 화면밖" + (outL ? " 좌" + (-r.xMin).ToString("F0") : "") + (outR ? " 우" + (r.xMax - Screen.width).ToString("F0") : "")
+ (outB ? " 하" + (-r.yMin).ToString("F0") : "") + (outT ? " 상" + (r.yMax - Screen.height).ToString("F0") : ""))
: "";
var tmp = t.GetComponent<TMPro.TextMeshProUGUI>();
string font = tmp != null ? (" 글자 " + (tmp.fontSize * t.lossyScale.y).ToString("F1") + "px") : "";
sb.AppendLine(string.Format(" [{0,-22}] {1,7:F1} x {2,7:F1} px 좌하({3,7:F1},{4,7:F1}) 하단여백 {5,6:F1}px{6}{7}",
path, r.width, r.height, r.xMin, r.yMin, r.yMin, font, clip));
}
static void MeasureBg(Transform root, StringBuilder sb)
{
var t = root.Find(BgPath) as RectTransform;
if (t == null) { sb.AppendLine(" [배경] 경로 없음"); return; }
var raw = t.GetComponent<RawImage>();
var arf = t.GetComponent<AspectRatioFitter>();
var fit = t.GetComponent<WLBackgroundFit>();
var r = ScreenRect(t);
float srcRatio = raw != null && raw.texture != null ? raw.texture.width / (float)raw.texture.height : 0f;
float shownRatio = r.height > 0f ? r.width / r.height : 0f;
float distort = srcRatio > 0f ? (shownRatio / srcRatio - 1f) * 100f : 0f;
float marginL = Mathf.Max(0f, r.xMin), marginR = Mathf.Max(0f, Screen.width - r.xMax);
float marginB = Mathf.Max(0f, r.yMin), marginT = Mathf.Max(0f, Screen.height - r.yMax);
float cropL = Mathf.Max(0f, -r.xMin), cropR = Mathf.Max(0f, r.xMax - Screen.width);
sb.AppendLine(" [배경] AspectRatioFitter=" + (arf != null ? (arf.aspectMode + "/" + arf.aspectRatio.ToString("F4")) : "없음")
+ " WLBackgroundFit=" + (fit != null ? ("focusX " + fit.focusX.ToString("F2")) : "없음"));
sb.AppendLine(string.Format(" 원본 {0}x{1} (종횡비 {2:F4}) -> 화면 출력 {3:F1} x {4:F1} px (종횡비 {5:F4})",
raw != null && raw.texture != null ? raw.texture.width : 0,
raw != null && raw.texture != null ? raw.texture.height : 0,
srcRatio, r.width, r.height, shownRatio));
sb.AppendLine(string.Format(" 왜곡 {0:F3}% (0 = 종횡비 유지) 높이 채움 {1:F1}% 폭 채움 {2:F1}%",
distort, Screen.height > 0 ? r.height / Screen.height * 100f : 0f,
Screen.width > 0 ? Mathf.Min(r.width, Screen.width) / Screen.width * 100f : 0f));
sb.AppendLine(string.Format(" 검정 여백 좌 {0:F0} 우 {1:F0} 상 {2:F0} 하 {3:F0} px | 화면밖 크롭 좌 {4:F0} 우 {5:F0} px",
marginL, marginR, marginT, marginB, cropL, cropR));
if (cropL + cropR > 0f)
sb.AppendLine(string.Format(" -> 원본 가로의 {0:F1}% 만 보인다 (보이는 구간 x = {1:F3} ~ {2:F3})",
Screen.width / r.width * 100f, cropL / r.width, (cropL + Screen.width) / r.width));
}
static readonly string[] Modes = { "LoadingUI", "PopupUI", "OptionInfo", "NotiInfo", "NetWait" };
public static object Shot(string label, int mode, int imgIndex)
{
if (EditorApplication.isPlaying) return "ABORT: Play 중";
var scene = EditorSceneManager.GetActiveScene();
if (scene.path != ScenePath) return "ABORT: 활성 씬 " + scene.path + " (기대 " + ScenePath + ")";
if (mode < 0 || mode >= Modes.Length) return "ABORT: mode 범위 0~" + (Modes.Length - 1);
int w = Screen.width, h = Screen.height;
var sb = new StringBuilder();
sb.AppendLine("=== " + label + " (Screen " + w + "x" + h + ", " + Modes[mode] + ") ===");
var mainGo = GameObject.Find("MainToTitle");
GameObject inst = null;
var temps = new List<Object>();
var restore = new List<KeyValuePair<Canvas, Camera>>();
Camera cam = null; RenderTexture rt = null; Texture2D png = null;
try
{
if (mainGo != null) mainGo.SetActive(false); // 아래 깔린 타이틀 씬 캔버스를 잠시 끈다
var pf = AssetDatabase.LoadAssetAtPath<GameObject>(PrefabPath);
if (pf == null) return "ABORT: 프리팹 로드 실패 " + PrefabPath;
inst = Object.Instantiate(pf);
inst.name = "SortOrder_5_shot";
// 대상 서브트리만 켠다
var target = inst.transform.Find(Modes[mode]);
if (target == null) return "ABORT: " + Modes[mode] + " 없음";
target.gameObject.SetActive(true);
if (mode == 0)
{
var child = inst.transform.Find(BackdropPath);
if (child == null) return "ABORT: " + BackdropPath + " 없음";
child.gameObject.SetActive(true); // LoadingUI.Start_Loading() 과 동일 상태
// 런타임엔 Addressables 가 넣는 텍스처를 에디트 모드에서 수동 주입(인스턴스 한정)
int idx = Mathf.Clamp(imgIndex, 1, 8);
var raw = inst.transform.Find(BgPath)?.GetComponent<RawImage>();
var tex = AssetDatabase.LoadAssetAtPath<Texture2D>(TexDir + "/Loading" + idx + ".jpg");
if (raw != null && tex != null) raw.texture = tex;
sb.AppendLine(" 미리보기 텍스처 = Loading" + idx + ".jpg");
// 런타임에 LoadingUI 가 하는 일(장별 초점 적용)을 에디트 모드에서도 재현한다
var lui = inst.transform.Find("LoadingUI")?.GetComponent<LoadingUI>();
var fit = raw != null ? raw.GetComponent<WLBackgroundFit>() : null;
var sync = raw != null ? raw.GetComponent<WLRawImageAspectSync>() : null;
if (sync != null) sync.Refresh(); // 종횡비를 새 텍스처에서 다시 읽는다
if (lui != null && fit != null && lui.focusX_perImage != null && idx - 1 < lui.focusX_perImage.Length)
{
fit.focusX = lui.focusX_perImage[idx - 1];
sb.AppendLine(" 장별 초점 적용 = " + fit.focusX.ToString("F2"));
}
if (fit != null) fit.Apply();
}
else
{
// 팝업류는 내용이 비활성 "child" 아래에 있다
var body = target.Find("child");
if (body != null) body.gameObject.SetActive(true);
}
Canvas.ForceUpdateCanvases();
LayoutRebuilder.ForceRebuildLayoutImmediate((RectTransform)inst.transform);
Canvas.ForceUpdateCanvases();
// WLBackgroundFit 은 rect 가 확정된 뒤라야 오프셋을 제대로 clamp 한다 -> 레이아웃 후 한 번 더
if (mode == 0)
{
var fit2 = inst.transform.Find(BgPath)?.GetComponent<WLBackgroundFit>();
if (fit2 != null) { fit2.Apply(); Canvas.ForceUpdateCanvases(); }
}
// --- 실측 ---
var cv = inst.GetComponent<Canvas>();
var cs = inst.GetComponent<CanvasScaler>();
sb.AppendLine(string.Format(" [캔버스] scaleFactor {0:F4} ref {1} {2} 캔버스rect {3:F0}x{4:F0}",
cv.scaleFactor, cs != null ? cs.referenceResolution.ToString() : "-",
cs != null ? cs.screenMatchMode.ToString() : "-",
((RectTransform)inst.transform).rect.width, ((RectTransform)inst.transform).rect.height));
if (mode == 0)
{
MeasureBg(inst.transform, sb);
sb.AppendLine(" [로딩 UI 요소]");
MeasureRect(inst.transform, "LoadingUI/child/botimg", sb);
MeasureRect(inst.transform, "LoadingUI/child/mark", sb);
MeasureRect(inst.transform, "LoadingUI/child/Slider_", sb);
MeasureRect(inst.transform, "LoadingUI/child/Slider_/t_proc", sb);
MeasureRect(inst.transform, "LoadingUI/child/Slider_/handle", sb);
MeasureRect(inst.transform, "LoadingUI/child/tip", sb);
MeasureRect(inst.transform, "LoadingUI/child/tip/tip", sb);
}
else
{
sb.AppendLine(" [" + Modes[mode] + " 요소]");
var body = target.Find("child") ?? target;
string bp = Modes[mode] + (body == target ? "" : "/child");
if (body.childCount == 0) sb.AppendLine(" (자식 없음 — 런타임 생성 UI 로 보임)");
foreach (Transform c in body)
MeasureRect(inst.transform, bp + "/" + c.name, sb);
}
// --- 렌더 (Overlay 는 에디트 모드 캡처 불가 -> 임시 카메라) ---
var camGo = new GameObject("WL800_ShotCam");
temps.Add(camGo);
cam = camGo.AddComponent<Camera>();
cam.orthographic = true;
cam.clearFlags = CameraClearFlags.SolidColor;
cam.backgroundColor = new Color(1f, 0f, 1f, 1f); // 마젠타 = "아무것도 안 그린 영역" 진단색
cam.nearClipPlane = 0.1f; cam.farClipPlane = 1000f;
cam.transform.position = new Vector3(0f, 0f, -500f);
rt = new RenderTexture(w, h, 24, RenderTextureFormat.ARGB32, RenderTextureReadWrite.sRGB);
rt.Create();
cam.targetTexture = rt;
restore.Add(new KeyValuePair<Canvas, Camera>(cv, cv.worldCamera));
cv.renderMode = RenderMode.ScreenSpaceCamera;
cv.worldCamera = cam;
cv.planeDistance = 100f;
Canvas.ForceUpdateCanvases();
cam.Render();
var prev = RenderTexture.active;
RenderTexture.active = rt;
png = new Texture2D(w, h, TextureFormat.RGBA32, false);
png.ReadPixels(new Rect(0, 0, w, h), 0, 0);
png.Apply();
RenderTexture.active = prev;
System.IO.Directory.CreateDirectory(OutDir);
var outPath = OutDir + "/" + label + ".png";
System.IO.File.WriteAllBytes(outPath, png.EncodeToPNG());
sb.AppendLine(" 캡처 -> " + outPath);
}
finally
{
foreach (var kv in restore)
if (kv.Key != null) { kv.Key.renderMode = RenderMode.ScreenSpaceOverlay; kv.Key.worldCamera = kv.Value; }
if (cam != null) cam.targetTexture = null;
if (rt != null) { rt.Release(); Object.DestroyImmediate(rt); }
if (png != null) Object.DestroyImmediate(png);
if (inst != null) Object.DestroyImmediate(inst);
foreach (var o in temps) if (o != null) Object.DestroyImmediate(o);
if (mainGo != null) mainGo.SetActive(true);
}
return sb.ToString();
}
/// 캡처 패스가 끝난 뒤 씬을 디스크 상태로 되돌린다(저장하지 않았으므로 변경 폐기).
public static object ResetScene()
{
if (EditorApplication.isPlaying) return "ABORT: Play 중";
EditorSceneManager.OpenScene(ScenePath, OpenSceneMode.Single);
var s = EditorSceneManager.GetActiveScene();
return "reload " + s.path + " isDirty=" + s.isDirty + " rootCount=" + s.rootCount;
}
}