// WL774_Water.cs — PD 지시 #774 : WL_Nature 강 구간에 구 WL 방식 물(ToonWaterU) 배치 // unity command run_script --file AgentScripts/WL774_Water.cs --entry WL774_Water.Probe // unity command run_script --file AgentScripts/WL774_Water.cs --entry WL774_Water.Apply // unity command run_script --file AgentScripts/WL774_Water.cs --entry WL774_Water.Revert // unity command run_script --file AgentScripts/WL774_Water.cs --entry WL774_Water.WarpTo --args '[79.9, 22.0, 270.0]' using System; using System.Collections.Generic; using System.Linq; using System.Text; using UnityEngine; #if UNITY_EDITOR using UnityEditor; #endif public static class WL774_Water { const string PrefabPath = "Assets/Res_Addr/Map/WL_Nature.prefab"; const string MatRiver = "Assets/WL/Materials/WL_Water_River.mat"; const string MatOcean = "Assets/WL/Materials/WL_Water_Ocean.mat"; const string OutDir = "AgentScripts/staging/WL_Maps/out"; const string WaterRoot = "WL_Water"; // 신규 루트 (되돌리기 = 이 오브젝트 삭제) static void W(string file, string body) { System.IO.Directory.CreateDirectory(OutDir); System.IO.File.WriteAllText(System.IO.Path.Combine(OutDir, file), body); } #if UNITY_EDITOR // ────────────────────────────────────────────────────────────── Probe public static object Probe() { var sb = new StringBuilder(); var root = PrefabUtility.LoadPrefabContents(PrefabPath); try { sb.AppendLine("# WL_Nature.prefab 루트 자식"); foreach (Transform c in root.transform) sb.AppendLine(string.Format(" {0} pos={1} scale={2} active={3} children={4}", c.name, V(c.position), V(c.localScale), c.gameObject.activeSelf, c.childCount)); sb.AppendLine(); sb.AppendLine("# 이름에 Water 가 든 오브젝트 (전체 계층)"); var all = root.GetComponentsInChildren(true); foreach (var t in all) { if (t.name.IndexOf("water", StringComparison.OrdinalIgnoreCase) < 0) continue; var mr = t.GetComponent(); var mats = mr != null ? string.Join("|", mr.sharedMaterials.Select(m => m != null ? m.name : "(null)")) : "-"; var b = mr != null ? mr.bounds : new Bounds(t.position, Vector3.zero); sb.AppendLine(string.Format(" {0}\n path={1}\n pos={2} scale={3} active={4} renderer={5} mat=[{6}]\n bounds c={7} size={8} y_top={9:F3}", t.name, Path(t, root.transform), V(t.position), V(t.localScale), t.gameObject.activeInHierarchy, mr != null ? (mr.enabled ? "on" : "OFF") : "none", mats, V(b.center), V(b.size), b.max.y)); } // 지형 콜라이더 bounds (물 평면 크기 결정용) sb.AppendLine(); sb.AppendLine("# MeshCollider 총 bounds (지형 범위)"); var cols = root.GetComponentsInChildren(true); if (cols.Length > 0) { var tb = cols[0].bounds; foreach (var c in cols) tb.Encapsulate(c.bounds); sb.AppendLine(string.Format(" colliders={0} bounds min={1} max={2}", cols.Length, V(tb.min), V(tb.max))); } sb.AppendLine(string.Format("# 이미 {0} 루트 존재 = {1}", WaterRoot, root.transform.Find(WaterRoot) != null)); } finally { PrefabUtility.UnloadPrefabContents(root); } // 머티리얼 · 셰이더 해석 확인 sb.AppendLine(); sb.AppendLine("# 머티리얼 해석"); foreach (var p in new[] { MatRiver, MatOcean }) { var m = AssetDatabase.LoadAssetAtPath(p); if (m == null) { sb.AppendLine(" " + p + " = LOAD FAIL"); continue; } sb.AppendLine(string.Format(" {0} shader={1} queue={2}", m.name, m.shader != null ? m.shader.name : "(null)", m.renderQueue)); var sh = m.shader; if (sh != null) for (int i = 0; i < sh.GetPropertyCount(); i++) if (sh.GetPropertyType(i) == UnityEngine.Rendering.ShaderPropertyType.Texture) { var pn = sh.GetPropertyName(i); var tex = m.GetTexture(pn); sb.AppendLine(string.Format(" tex {0} = {1}", pn, tex != null ? tex.name : "(NULL)")); } } var urp = UnityEngine.Rendering.GraphicsSettings.currentRenderPipeline as UnityEngine.Rendering.Universal.UniversalRenderPipelineAsset; sb.AppendLine(string.Format("# URP asset={0} depthTex={1} opaqueTex={2}", urp != null ? urp.name : "(null)", urp != null ? urp.supportsCameraDepthTexture.ToString() : "?", urp != null ? urp.supportsCameraOpaqueTexture.ToString() : "?")); var s = sb.ToString(); W("W0_probe.txt", s); return s; } static string V(Vector3 v) { return string.Format("({0:F2}, {1:F2}, {2:F2})", v.x, v.y, v.z); } static string Path(Transform t, Transform stop) { var p = t.name; while (t.parent != null && t.parent != stop) { t = t.parent; p = t.name + "/" + p; } return p; } // ────────────────────────────────────────────────────────────── Apply // args: [minX, maxX, minZ, maxZ, y] (기본 0,100,-100,120, -1.26) public static object ApplyDefault() { return Apply(0f, 100f, -100f, 120f, -1.26f); } public static object Apply(float minX, float maxX, float minZ, float maxZ, float y) { var sb = new StringBuilder(); var root = PrefabUtility.LoadPrefabContents(PrefabPath); try { var mat = AssetDatabase.LoadAssetAtPath(MatRiver); if (mat == null) { PrefabUtility.UnloadPrefabContents(root); return "material load fail: " + MatRiver; } // 기존 WL_Water 루트 제거(재실행 안전) var old = root.transform.Find(WaterRoot); if (old != null) UnityEngine.Object.DestroyImmediate(old.gameObject); var holder = new GameObject(WaterRoot); holder.transform.SetParent(root.transform, false); holder.transform.localPosition = Vector3.zero; // 평면 1장 = 구 WL ToonWaterU (버텍스 변위 없음 → 서브디비전 불필요) var go = new GameObject("Water_River_WL"); go.transform.SetParent(holder.transform, false); go.transform.position = new Vector3((minX + maxX) * 0.5f, y, (minZ + maxZ) * 0.5f); var mf = go.AddComponent(); mf.sharedMesh = BuildQuad(maxX - minX, maxZ - minZ); var mr = go.AddComponent(); mr.sharedMaterial = mat; mr.shadowCastingMode = UnityEngine.Rendering.ShadowCastingMode.Off; mr.receiveShadows = false; mr.lightProbeUsage = UnityEngine.Rendering.LightProbeUsage.Off; mr.reflectionProbeUsage = UnityEngine.Rendering.ReflectionProbeUsage.Off; GameObjectUtility.SetStaticEditorFlags(go, StaticEditorFlags.BatchingStatic); sb.AppendLine(string.Format("water plane center={0} size=({1:F1} x {2:F1}) y={3:F3} mat={4}", V(go.transform.position), maxX - minX, maxZ - minZ, y, mat.name)); // 겹치는 팩 수면 렌더러 비활성 (z-fight 방지 · 되돌리기 = Revert) int off = 0; var rect = new Rect(minX, minZ, maxX - minX, maxZ - minZ); foreach (var t in root.GetComponentsInChildren(true)) { if (t.name.IndexOf("water", StringComparison.OrdinalIgnoreCase) < 0) continue; if (t.IsChildOf(holder.transform)) continue; var r = t.GetComponent(); if (r == null || !r.enabled) continue; var b = r.bounds; bool overlap = b.max.x > rect.xMin && b.min.x < rect.xMax && b.max.z > rect.yMin && b.min.z < rect.yMax; if (!overlap) { sb.AppendLine(" keep " + t.name + " (영역 밖) bounds c=" + V(b.center)); continue; } r.enabled = false; off++; sb.AppendLine(string.Format(" OFF {0} bounds c={1} size={2}", t.name, V(b.center), V(b.size))); } sb.AppendLine("팩 수면 렌더러 비활성 = " + off); PrefabUtility.SaveAsPrefabAsset(root, PrefabPath); } finally { PrefabUtility.UnloadPrefabContents(root); } AssetDatabase.SaveAssets(); var s = sb.ToString(); W("W1_apply.txt", s); return s; } public static object Revert() { var sb = new StringBuilder(); var root = PrefabUtility.LoadPrefabContents(PrefabPath); try { var old = root.transform.Find(WaterRoot); if (old != null) { UnityEngine.Object.DestroyImmediate(old.gameObject); sb.AppendLine("removed " + WaterRoot); } int on = 0; foreach (var t in root.GetComponentsInChildren(true)) { if (t.name.IndexOf("water", StringComparison.OrdinalIgnoreCase) < 0) continue; var r = t.GetComponent(); if (r != null && !r.enabled) { r.enabled = true; on++; } } sb.AppendLine("팩 수면 렌더러 복구 = " + on); PrefabUtility.SaveAsPrefabAsset(root, PrefabPath); } finally { PrefabUtility.UnloadPrefabContents(root); } AssetDatabase.SaveAssets(); return sb.ToString(); } static Mesh BuildQuad(float sx, float sz) { var m = new Mesh { name = "WL_WaterPlane" }; float hx = sx * 0.5f, hz = sz * 0.5f; m.vertices = new[] { new Vector3(-hx, 0, -hz), new Vector3(-hx, 0, hz), new Vector3(hx, 0, hz), new Vector3(hx, 0, -hz) }; // UV 는 1 m = 1 유닛 (팩 데모와 동일 스케일 감 · 셰이더 타일링은 머티리얼 파라미터가 담당) m.uv = new[] { new Vector2(0, 0), new Vector2(0, sz), new Vector2(sx, sz), new Vector2(sx, 0) }; m.normals = new[] { Vector3.up, Vector3.up, Vector3.up, Vector3.up }; m.tangents = new[] { new Vector4(1, 0, 0, -1), new Vector4(1, 0, 0, -1), new Vector4(1, 0, 0, -1), new Vector4(1, 0, 0, -1) }; m.triangles = new[] { 0, 1, 2, 0, 2, 3 }; m.RecalculateBounds(); var dir = "Assets/WL/Meshes"; System.IO.Directory.CreateDirectory(dir); var p = dir + "/WL_WaterPlane.asset"; var exist = AssetDatabase.LoadAssetAtPath(p); if (exist != null) { AssetDatabase.DeleteAsset(p); } AssetDatabase.CreateAsset(m, p); AssetDatabase.SaveAssets(); return AssetDatabase.LoadAssetAtPath(p); } #endif // ────────────────────────────────────────────────────────────── Play 검증 public static object WarpTo(float x, float z, float yaw) { if (!Application.isPlaying) return "not playing"; var me = UnityEngine.Object.FindObjectsByType(FindObjectsInactive.Exclude, FindObjectsSortMode.None).FirstOrDefault(); if (me == null) return "MyActor none"; float y = 5f; RaycastHit hit; if (Physics.Raycast(new Vector3(x, 60f, z), Vector3.down, out hit, 200f)) y = hit.point.y + 0.1f; var pos = new Vector3(x, y, z); var na = me.GetComponent(); if (na != null && na.enabled) na.Warp(pos); else me.transform.position = pos; me.transform.rotation = Quaternion.Euler(0, yaw, 0); return string.Format("warped to {0} (ground y={1:F3}) yaw={2}", V(pos), y, yaw); } // 재현 가능한 A/B 캡처용 포즈: PC 워프 + RealCamera.rotateAround 고정 public static object Pose(float x, float z, float rotateAround) { if (!Application.isPlaying) return "not playing"; var me = UnityEngine.Object.FindObjectsByType(FindObjectsInactive.Exclude, FindObjectsSortMode.None).FirstOrDefault(); if (me == null) return "MyActor none"; float y = 5f; RaycastHit hit; if (Physics.Raycast(new Vector3(x, 80f, z), Vector3.down, out hit, 250f)) y = hit.point.y + 0.1f; var pos = new Vector3(x, y, z); var na = me.GetComponent(); if (na != null && na.enabled) na.Warp(pos); else me.transform.position = pos; var cam = Camera.main; string camInfo = "(no RealCamera)"; if (cam != null) { var rc = cam.GetComponents().FirstOrDefault(c => c != null && c.GetType().Name == "RealCamera"); if (rc != null) { var f = rc.GetType().GetField("rotateAround", System.Reflection.BindingFlags.Public | System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance); if (f != null) { f.SetValue(rc, rotateAround); camInfo = "rotateAround=" + rotateAround; } } } return string.Format("posed PC={0} ground={1:F3} {2}", V(pos), y, camInfo); } // 지형 높이 스캔 — 물 평면이 강 밖에 고이는지 확인 public static object HeightScan(float minX, float maxX, float minZ, float maxZ, float waterY, float step) { if (!Application.isPlaying) return "not playing"; int total = 0, below = 0, noHit = 0; float lo = 999f, hi = -999f; var cells = new List(); for (float x = minX; x <= maxX; x += step) for (float z = minZ; z <= maxZ; z += step) { total++; RaycastHit h; if (!Physics.Raycast(new Vector3(x, 80f, z), Vector3.down, out h, 250f)) { noHit++; continue; } lo = Mathf.Min(lo, h.point.y); hi = Mathf.Max(hi, h.point.y); if (h.point.y < waterY) { below++; if (cells.Count < 400) cells.Add(string.Format("{0:F0},{1:F0},{2:F2}", x, z, h.point.y)); } } var sb = new StringBuilder(); sb.AppendLine(string.Format("scan {0}~{1} x {2}~{3} step {4} waterY {5:F2}", minX, maxX, minZ, maxZ, step, waterY)); sb.AppendLine(string.Format("total={0} below(=물에 잠김)={1} ({2:F1}%) noHit={3} terrainY {4:F2}~{5:F2}", total, below, 100f * below / Mathf.Max(1, total), noHit, lo, hi)); // 잠긴 셀의 x 범위 히스토그램 var byX = new Dictionary(); foreach (var c in cells) { var p = c.Split(','); int xi = (int)(float.Parse(p[0]) / 10) * 10; byX[xi] = byX.TryGetValue(xi, out var v) ? v + 1 : 1; } sb.AppendLine("잠긴 셀 x 구간(10 m): " + string.Join(" ", byX.OrderBy(k => k.Key).Select(k => k.Key + ":" + k.Value))); var s = sb.ToString(); W("W2_heightscan.txt", s + "\n" + string.Join("\n", cells)); return s; } public static object HeightScanDefault() { return HeightScan(0f, 130f, -100f, 120f, -1.26f, 2f); } }