// WL792_Apply.cs — #792 이펙트 프리팹 생성 · 기하 베이크 · 설정 적용 (에디터 · Edit 모드) // // unity command run_script --file AgentScripts/WL792_Apply.cs --entry WL792_Apply.BuildPrefabs // unity command run_script --file AgentScripts/WL792_Apply.cs --entry WL792_Apply.BakeGeometry // unity command run_script --file AgentScripts/WL792_Apply.cs --entry WL792_Apply.Configure // unity command run_script --file AgentScripts/WL792_Apply.cs --entry WL792_Apply.DumpSettings // unity command run_script --file AgentScripts/WL792_Apply.cs --entry WL792_Apply.BakeCalibrations (Play 뒤) // unity command run_script --file AgentScripts/WL792_Apply.cs --entry WL792_Apply.Rollback (C8 원복) // // 설계 메모 // · 새 이펙트는 `Assets/Res_Addr/Effect/` 아래에 **프리팹 배리언트**로 만든다. // 그 폴더는 Addressables `Effect` 그룹의 **폴더 엔트리**라 프리팹을 넣기만 하면 // `Assets/Res_Addr/Effect/{name}.prefab` 주소로 자동 등록된다 — 그룹 에셋을 손대지 않는다. // 따라서 기존 InGameInfo 풀(dic_str_Effect + TurnOff_GO + Show_EffectEx)을 그대로 쓴다(C11). // · 배리언트라 PD 가 NamuFX 원본(recolor)을 고치면 그대로 따라온다. // · 루트를 **비활성**으로 저장한다 — Get_Effect 는 `!activeInHierarchy` 인 인스턴스를 재사용하므로 // 활성 상태로 저장하면 풀이 재사용되지 않고 스폰마다 새로 로드된다(누수). // · 씬을 더럽히지 않는다 — 프리뷰 씬(EditorSceneManager.NewPreviewScene)에서만 작업한다. using System.Collections.Generic; using System.Text; using UnityEditor; using UnityEditor.SceneManagement; using UnityEngine; using WL.Combat; public static class WL792_Apply { const string Out = "Screenshots_WL/combat6/wl792_apply.txt"; const string SrcSwing = "Assets/NamuFX/Simple Stylized Slash vol2/Prefabs/Slash_B_recolor_1 Variant.prefab"; const string SrcStab = "Assets/ResWork/EricWang/Game VFX - Cartoon Skill Collection Vol2/Prefabs/FX_Blue Stab.prefab"; const string DstSwing = "Assets/Res_Addr/Effect/Effect_WLSwingArc.prefab"; const string DstStab = "Assets/Res_Addr/Effect/Effect_WLStab.prefab"; const string SettingsPath = "Assets/WL/Settings/Resources/WL/SlashTrailSettings.asset"; // 찌르기에서 남길 이미터 — 실측(2026-09-07) 전체 10개 · 고유 머티리얼 9개는 // 모바일 예산(스윙당 추가 드로우 ≤ 3)을 크게 넘는다. 형태를 읽는 데 필요한 셋만 남긴다. // lb(뒤로 흐르는 스피드 라인) · blue_flash(찌르기 스트리크) · head(선단 플래시) static readonly string[] StabKeep = { "lb", "blue_flash", "head" }; // ───────────────────────────────────────────────────────────────────────── public static void BuildPrefabs() { var sb = new StringBuilder(); sb.AppendLine("# WL #792 BuildPrefabs " + System.DateTime.Now.ToString("HH:mm:ss")); Build(sb, SrcSwing, DstSwing, "Effect_WLSwingArc", false, 1.0f); Build(sb, SrcStab, DstStab, "Effect_WLStab", true, 0.9f); AssetDatabase.SaveAssets(); AssetDatabase.Refresh(); Write(sb); } static void Build(StringBuilder sb, string srcPath, string dstPath, string name, bool forceOffTime, float offTime) { var src = AssetDatabase.LoadAssetAtPath(srcPath); if (src == null) { sb.AppendLine("!! 원본 없음 " + srcPath); return; } var scene = EditorSceneManager.NewPreviewScene(); try { var inst = (GameObject)PrefabUtility.InstantiatePrefab(src, scene); inst.name = name; inst.transform.localPosition = Vector3.zero; inst.transform.localRotation = Quaternion.identity; inst.transform.localScale = Vector3.one; var off = inst.GetComponent(); if (off == null) off = inst.AddComponent(); off.NoUseParticleTime = forceOffTime; off.OffTime = offTime; inst.SetActive(false); var saved = PrefabUtility.SaveAsPrefabAsset(inst, dstPath); sb.AppendLine(string.Format("생성 {0} ← {1} · TurnOff_GO(NoUseParticleTime={2} OffTime={3}) · 루트 비활성 · 배리언트={4}", dstPath, System.IO.Path.GetFileName(srcPath), forceOffTime, offTime, saved != null && PrefabUtility.GetPrefabAssetType(saved) == PrefabAssetType.Variant)); Object.DestroyImmediate(inst); } catch (System.Exception e) { sb.AppendLine("!! " + name + " 실패: " + e); } finally { EditorSceneManager.ClosePreviewScene(scene); } } // ───────────────────────────────────────────────────────────────────────── public static void BakeGeometry() { var sb = new StringBuilder(); sb.AppendLine("# WL #792 BakeGeometry " + System.DateTime.Now.ToString("HH:mm:ss")); var so = AssetDatabase.LoadAssetAtPath(SettingsPath); if (so == null) { sb.AppendLine("!! 설정 에셋 없음 " + SettingsPath); Write(sb); return; } BakeOne(sb, so, DstSwing, "Effect_WLSwingArc", SwingKind.Swing, null); BakeOne(sb, so, DstStab, "Effect_WLStab", SwingKind.Stab, StabKeep); EditorUtility.SetDirty(so); AssetDatabase.SaveAssets(); Write(sb); } static void BakeOne(StringBuilder sb, SlashTrailSettings so, string path, string name, SwingKind kind, string[] keep) { var asset = AssetDatabase.LoadAssetAtPath(path); if (asset == null) { sb.AppendLine("!! 없음 " + path); return; } GameObject root = null; try { root = PrefabUtility.LoadPrefabContents(path); var g = so.FindGeometry(name); if (g == null) { g = new SlashEffectGeometry { prefabName = name }; so.effectGeometry.Add(g); } g.kind = kind; g.bakedAt = System.DateTime.Now.ToString("yyyy-MM-dd HH:mm:ss"); if (kind == SwingKind.Swing) { var f = SlashArcMeasure.MeasureCrescent(root, null, g.flipSweep); g.arcValid = f.valid; if (f.valid) { g.emitter = f.emitter; g.arcNormal = f.normal; g.arcCenter = f.center; g.arcBisector = f.bisector; g.arcStartDir = f.startDir; g.arcRadiusOuter = f.radiusOuter; g.arcRadiusInner = f.radiusInner; g.arcSweepDeg = f.sweepDeg; } sb.AppendLine(string.Format("[호] {0} valid={1} emitter='{2}' n={3} c={4} bis={5} rOut={6:F4} rIn={7:F4} sweep={8:F2}°", name, f.valid, f.emitter, V(f.normal), V(f.center), V(f.bisector), f.radiusOuter, f.radiusInner, f.sweepDeg)); } else { var s = SlashArcMeasure.MeasureStab(root, g.flipAxis, keep); g.stabValid = s.valid; if (s.valid) { g.emitter = s.note; g.stabAxis = s.axis; g.stabStart = s.start; g.stabLength = s.length; g.stabPlaneNormal = s.planeNormal; } sb.AppendLine(string.Format("[찌르기] {0} valid={1} note='{2}' axis={3} start={4} len={5:F4} planeN={6}", name, s.valid, s.note, V(s.axis), V(s.start), s.length, V(s.planeNormal))); } } catch (System.Exception e) { sb.AppendLine("!! " + name + " 베이크 실패: " + e); } finally { if (root != null) PrefabUtility.UnloadPrefabContents(root); } } // ───────────────────────────────────────────────────────────────────────── public static void Configure() { var sb = new StringBuilder(); sb.AppendLine("# WL #792 Configure " + System.DateTime.Now.ToString("HH:mm:ss")); var so = AssetDatabase.LoadAssetAtPath(SettingsPath); if (so == null) { sb.AppendLine("!! 설정 에셋 없음"); Write(sb); return; } sb.AppendLine(string.Format("변경 전 — useMappedEffects={0} burstMode={1} drawRibbon={2} arcFollowRotation={3}", so.useMappedEffects, so.burstMode, so.drawRibbon, so.arcFollowRotation)); so.useMappedEffects = true; so.swingArcEffect = "Effect_WLSwingArc"; so.stabEffect = "Effect_WLStab"; so.stabKeepEmitterNames = StabKeep; // 리본 OFF — PD #792 는 NamuFX 호를 쓰므로 리본이 겹치면 안 된다. // 궤적 샘플링은 계속된다(평면·반지름 계산에 필요) · BladeTrail/램프 텍스처 자산은 보존한다(C8). so.drawRibbon = false; // 레거시 버스트 경로는 꺼 둔다(#792 경로가 대신한다). so.burstMode = BurstMode.None; so.arcFollowPlayer = true; so.mappedFollowRotation = true; so.arcFollowSeconds = 0f; so.arcRadiusFit = ArcRadiusFit.Outer; so.arcSizeScale = 1f; so.arcPlaneOffset = 0f; so.arcRadialOffset = 0f; so.arcLifetimeSeconds = 0f; // 프리팹 파티클 duration(1.0 s) 을 그대로 쓴다 so.stabAnchor = StabAnchor.Start; so.stabSizeScale = 1f; so.stabFaceCamera = true; so.stabLifetimeSeconds = 0.9f; // 분류 임계값 — 10101·10501 실측(2026-09-07) 기준. // 창 안 칼날 누적 회전량: Attack1 366~434° · Attack2 385~424° · Attack3 164°(짧은 창일 땐 67°) // 150 은 그 간격 한가운데다. 칼끝/손잡이 경로 비 3.0 은 손목 스냅 스윙을 지켜 주는 보조 조건. so.swingMinSweepDegrees = 150f; so.swingMinTipHiltRatio = 3f; so.swingTrimSpeedRatio = 0.2f; so.stabMinStraightness = 0.86f; so.stabMinForwardDot = 0.55f; so.stabMinDistance = 0.18f; so.verboseLog = true; EditorUtility.SetDirty(so); AssetDatabase.SaveAssets(); sb.AppendLine(string.Format("변경 후 — useMappedEffects={0} swing='{1}' stab='{2}' drawRibbon={3} burstMode={4} keep=[{5}]", so.useMappedEffects, so.swingArcEffect, so.stabEffect, so.drawRibbon, so.burstMode, string.Join(",", StabKeep))); Write(sb); } // ───────────────────────────────────────────────────────────────────────── /// Play 중 런타임이 실측해 모아 둔 캘리브레이션을 SO 에 굽는다(첫 스윙부터 정확해진다). public static void BakeCalibrations() { var sb = new StringBuilder(); sb.AppendLine("# WL #792 BakeCalibrations " + System.DateTime.Now.ToString("HH:mm:ss")); var so = AssetDatabase.LoadAssetAtPath(SettingsPath); if (so == null) { sb.AppendLine("!! 설정 에셋 없음"); Write(sb); return; } var rt = WeaponTrailDriver.RuntimeCalibrations; sb.AppendLine("런타임 캐시 " + rt.Count + "행"); int added = 0, updated = 0; foreach (var kv in rt) { var c = kv.Value; if (c == null) continue; var exist = so.FindCalibration(c.classId, c.clipName); // 이미 구워 둔 행보다 품질(궤적 길이 × 원피팅 정합도)이 높을 때만 갈아 끼운다. if (exist == null) { so.swingCalibrations.Add(Clone(c)); added++; } else if (c.quality > exist.quality) { Copy(c, exist); updated++; } else { sb.AppendLine(string.Format(" (유지) {0} {1} — 기존 q={2:F2} ≥ 새 q={3:F2}", c.classId, c.clipName, exist.quality, c.quality)); continue; } sb.AppendLine(string.Format(" {0,-6} {1,-24} {2,-5} sweep={3,6:F1}° tipR={4:F3} hiltR={5:F3} pivot={6} n={7} bis={8} | {9}", c.classId, c.clipName, c.kind, c.sweepDeg, c.tipRadius, c.hiltRadius, V(c.pivot), V(c.planeNormal), V(c.bisector), c.source)); } sb.AppendLine(string.Format("추가 {0} · 갱신 {1} · 총 {2}행", added, updated, so.swingCalibrations.Count)); EditorUtility.SetDirty(so); AssetDatabase.SaveAssets(); Write(sb); } static SwingCalibration Clone(SwingCalibration s) { var d = new SwingCalibration(); Copy(s, d); d.classId = s.classId; d.clipName = s.clipName; return d; } static void Copy(SwingCalibration s, SwingCalibration d) { d.kind = s.kind; d.planeNormal = s.planeNormal; d.pivot = s.pivot; d.bisector = s.bisector; d.startDir = s.startDir; d.tipRadius = s.tipRadius; d.hiltRadius = s.hiltRadius; d.sweepDeg = s.sweepDeg; d.stabAxis = s.stabAxis; d.stabStart = s.stabStart; d.stabLength = s.stabLength; d.quality = s.quality; d.source = s.source; } // ───────────────────────────────────────────────────────────────────────── public static void DumpSettings() { var sb = new StringBuilder(); sb.AppendLine("# WL #792 DumpSettings (디스크 대조) " + System.DateTime.Now.ToString("HH:mm:ss")); var so = AssetDatabase.LoadAssetAtPath(SettingsPath); if (so == null) { sb.AppendLine("!! 없음"); Write(sb); return; } sb.AppendLine(string.Format("useMappedEffects={0} swing='{1}' stab='{2}' drawRibbon={3} burstMode={4} burstTiming={5}", so.useMappedEffects, so.swingArcEffect, so.stabEffect, so.drawRibbon, so.burstMode, so.burstTiming)); sb.AppendLine(string.Format("arcRadiusFit={0} arcSizeScale={1} arcPlaneOffset={2} arcRadialOffset={3} arcLifetime={4}", so.arcRadiusFit, so.arcSizeScale, so.arcPlaneOffset, so.arcRadialOffset, so.arcLifetimeSeconds)); sb.AppendLine(string.Format("stabAnchor={0} stabSizeScale={1} stabFaceCamera={2} stabLifetime={3} keep=[{4}]", so.stabAnchor, so.stabSizeScale, so.stabFaceCamera, so.stabLifetimeSeconds, so.stabKeepEmitterNames != null ? string.Join(",", so.stabKeepEmitterNames) : "")); sb.AppendLine(string.Format("분류 — 칼날회전≥{0}° 또는 칼끝/손잡이≥{1} → 휘두르기 · straight≥{2} minDist={3}m → 찌르기 · 속도트림 {4}", so.swingMinSweepDegrees, so.swingMinTipHiltRatio, so.stabMinStraightness, so.stabMinDistance, so.swingTrimSpeedRatio)); sb.AppendLine(string.Format("추종 — arcFollowPlayer={0} mappedFollowRotation={1} seconds={2}", so.arcFollowPlayer, so.mappedFollowRotation, so.arcFollowSeconds)); sb.AppendLine("── effectGeometry " + (so.effectGeometry != null ? so.effectGeometry.Count : 0) + "행"); if (so.effectGeometry != null) foreach (var g in so.effectGeometry) sb.AppendLine(string.Format(" {0,-20} kind={1,-5} arcValid={2} n={3} c={4} bis={5} rOut={6:F4} rIn={7:F4} sweep={8:F1}° | stabValid={9} axis={10} start={11} len={12:F4} planeN={13} | {14}", g.prefabName, g.kind, g.arcValid, V(g.arcNormal), V(g.arcCenter), V(g.arcBisector), g.arcRadiusOuter, g.arcRadiusInner, g.arcSweepDeg, g.stabValid, V(g.stabAxis), V(g.stabStart), g.stabLength, V(g.stabPlaneNormal), g.bakedAt)); sb.AppendLine("── swingCalibrations " + (so.swingCalibrations != null ? so.swingCalibrations.Count : 0) + "행"); if (so.swingCalibrations != null) foreach (var c in so.swingCalibrations) sb.AppendLine(string.Format(" {0,-6} {1,-24} {2,-5} sweep={3,6:F1}° tipR={4:F3} hiltR={5:F3} n={6} pivot={7} bis={8} stab(axis={9} len={10:F3})", c.classId, c.clipName, c.kind, c.sweepDeg, c.tipRadius, c.hiltRadius, V(c.planeNormal), V(c.pivot), V(c.bisector), V(c.stabAxis), c.stabLength)); sb.AppendLine("── 프리팹 존재 여부"); foreach (var p in new[] { DstSwing, DstStab }) { var a = AssetDatabase.LoadAssetAtPath(p); sb.AppendLine(string.Format(" {0} = {1}{2}", p, a != null ? "OK" : "없음", a != null ? " (activeSelf=" + a.activeSelf + ", TurnOff_GO=" + (a.GetComponent() != null) + ")" : "")); } Write(sb); } // ───────────────────────────────────────────────────────────────────────── /// /// 3타(Effect_Slash_{CID}_3)를 찌르기로 강제한다 — 찌르기 경로 검증 + PD 선택지 B. /// 실측상 10101·10501 의 세 클립은 모두 칼날 회전량 164~434° 로 휘두르기로 분류된다. /// PD 가 3타를 찌르기 연출로 원하면 이 SO 행 하나로 바꾼다 — 코드 변경 없음. /// public static void ForceStab() { SetStabRemap(true); } public static void UnforceStab() { SetStabRemap(false); } static void SetStabRemap(bool on) { var sb = new StringBuilder(); var so = AssetDatabase.LoadAssetAtPath(SettingsPath); if (so == null) { sb.AppendLine("!! 없음"); Write(sb); return; } so.effectRemaps.RemoveAll(r => r != null && r.sourcePrefab != null && r.sourcePrefab.EndsWith("_3")); if (on) { foreach (var cid in new[] { 10101, 10102, 10501, 10502 }) so.effectRemaps.Add(new SlashEffectRemap { sourcePrefab = "Effect_Slash_" + cid + "_3", classId = cid, kind = SwingKind.Stab }); } EditorUtility.SetDirty(so); AssetDatabase.SaveAssets(); sb.AppendLine("#792 3타 찌르기 강제 = " + on + " · remap 행 " + so.effectRemaps.Count); Write(sb); } /// C8 롤백 — #792 경로만 끈다. 프리팹·베이크 데이터는 남겨 둔다(다시 켜면 그대로 동작). public static void Rollback() { var sb = new StringBuilder(); var so = AssetDatabase.LoadAssetAtPath(SettingsPath); if (so == null) { sb.AppendLine("!! 없음"); Write(sb); return; } so.useMappedEffects = false; so.drawRibbon = true; so.burstMode = BurstMode.None; EditorUtility.SetDirty(so); AssetDatabase.SaveAssets(); sb.AppendLine("롤백 — useMappedEffects=false · drawRibbon=true · burstMode=None (#785 리본 상태로 복귀)"); Write(sb); } // ───────────────────────────────────────────────────────────────────────── static string V(Vector3 v) { return string.Format("({0:F4},{1:F4},{2:F4})", v.x, v.y, v.z); } static void Write(StringBuilder sb) { System.IO.Directory.CreateDirectory(System.IO.Path.GetDirectoryName(Out)); System.IO.File.AppendAllText(Out, sb.ToString() + "\n", new System.Text.UTF8Encoding(false)); Debug.Log(sb.ToString()); } }