Project_WL/Assets/WL/Combat/SlashTrailSettings.cs

976 lines
60 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.

// ─────────────────────────────────────────────────────────────────────────────
// SlashTrailSettings.cs — 무기 궤적(검날 트레일) 설정 단일 출처(SOT)
//
// PD 지시 #747 (2026-09-05):
// "공격 이펙트가 플레이어 무기 궤적에 맞게 보이도록 수정해줘.
// (WL 프로젝트에 구현한 방식 참고) 현재 힘민지에서는 이펙트가 공격 중인 무기에
// 맞춰진게 아니라 캐릭터 기준으로 보이도록 되어있어."
//
// 목적:
// 조직 규칙 C45(하드 코딩 절대 금지)에 따라 궤적 이펙트가 쓰는 **모든 수치·이름·색**을
// 코드 상수가 아닌 이 ScriptableObject 에 둔다. 코드에는 어떤 게임 데이터 값도 넣지 않는다.
//
// 근거:
// 구 WL 프로토타입 `Backup/WL_Prototype_2026-09-04/Assets_WL/Scripts/Combat/CombatSettings.cs`
// 가 같은 목적의 SO 였고, 그 필드 중 **궤적에 실제로 쓰인 것만** 추려 이식했다.
// (전투 수치·몬스터 AI·데미지 팝업 등 힘민지에 이미 테이블로 존재하는 항목은 제외 — C45)
//
// 🔴 어셈블리 주의: 힘민지 `Assets/Script/` 에는 .asmdef 가 없다(전부 Assembly-CSharp).
// 이 파일이 놓일 `Assets/WL/Combat/` 에도 **.asmdef 를 만들지 말 것** — 만들면
// Actor/PCActor/InGameInfo(Assembly-CSharp)를 참조하지 못해 컴파일이 깨진다.
//
// 1단계 산출물(스테이징). 2단계에 PM 지시로 `Assets/WL/Combat/` 로 이동한다.
// ─────────────────────────────────────────────────────────────────────────────
using System;
using System.Collections.Generic;
using UnityEngine;
namespace WL.Combat
{
/// <summary>클래스별로 궤적 트레일을 켤지 정하는 방식.</summary>
public enum TrailClassMode
{
/// <summary>실측값으로 자동 판정한다(공격 사거리 + 무기 메시 형상).</summary>
Auto = 0,
/// <summary>무조건 켠다.</summary>
ForceOn = 1,
/// <summary>무조건 끈다(원거리 클래스 등).</summary>
ForceOff = 2
}
/// <summary>스윙이 끝난 검끝 자리에 터뜨릴 버스트 처리 방식.</summary>
public enum BurstMode
{
/// <summary>버스트를 쓰지 않는다(트레일만).</summary>
None = 0,
/// <summary>
/// 기존 <c>InGameInfo.Show_Effect(prefab, pos, euler)</c> 를 **검끝 위치**로 호출한다.
/// 힘민지 코드를 한 줄도 고치지 않고 동작하는 경로 — 다만 프리팹의 크레센트 이미터가
/// 함께 나오므로, 크레센트를 끄려면 2단계에서 프리팹 일괄 편집이 필요하다.
/// </summary>
ReuseShowEffect = 1,
/// <summary>
/// PD 지시 #761 (2026-09-06) — 구 WL <c>SlashVfxPlayer.PlayArcFromSamples</c> 방식.
///
/// **힘민지 원본 슬래시 프리팹을 억제 없이 그대로** 쓰되, 스윙 창에서 모은 검끝 궤적으로
/// 평면(법선 n)·중심·칼끝 방향(outward)을 구해 그 평면 위에 눕혀 배치한다.
/// 크기는 검날 길이에 맞춘다.
///
/// 🔴 이 모드에서는 <see cref="SlashCrescentMask"/> 억제를 적용하지 않는다 —
/// #760 2-A 가 만든 "이미터 전부 억제 → 원본 색이 사라짐" 증상(PD #761 원문)의
/// 직접 원인이 억제였기 때문이다. 프리팹은 원본 색·원본 이미터 그대로 재생된다.
/// </summary>
PrefabArc = 2
}
/// <summary>
/// <see cref="BurstMode.PrefabArc"/> 에서 프리팹을 궤적의 어느 지점에 놓을지.
/// (크레센트 이미터의 **시각 중심**이 그 지점에 오도록 루트를 옮긴다)
/// </summary>
public enum ArcAnchor
{
/// <summary>스윙 중간 프레임의 자루(손) 위치. 구 WL 기본값.</summary>
HiltMid = 0,
/// <summary>자루와 칼끝의 중간 = 쓸고 간 면의 한가운데.</summary>
Center = 1,
/// <summary>스윙 중간 프레임의 칼끝 위치.</summary>
Tip = 2
}
/// <summary>버스트를 스윙의 어느 시점에 터뜨릴지.</summary>
public enum BurstTiming
{
/// <summary>
/// 스윙이 끝난 검끝 자리. 구 WL <c>SlashVfxPlayer.EndSwing</c> 가 실제로 구현한 방식이고
/// 타격 위치에 스파크가 남아 임팩트감이 좋다. 다만 ShowEffect 이벤트가 한 클립에
/// 2번 뜨는 쌍검 클립(10201·10202 DualBlades_Attack1)에서는 버스트가 1회로 줄어든다.
/// </summary>
OnSwingEnd = 0,
/// <summary>
/// ShowEffect 이벤트가 뜬 그 프레임의 검끝 자리. 이벤트 1개당 버스트 1개라
/// **기존 동작의 스폰 횟수가 그대로 보존된다**(쌍검 2연타 = 2회).
/// </summary>
OnEvent = 1
}
/// <summary>
/// 원본 슬래시 프리팹의 파티클 이미터를 어디까지 억제할지 (PD 지시 #760 2-A · TRAIL §2-4).
///
/// 구 WL 룩은 "텍스처 리본 + 검끝 스파크" 두 가지뿐이다. 힘민지 원본 프리팹은 그 위에
/// 글로우·그림자·먼지·연기를 얹고 있어 리본이 묻힌다. 크레센트만 끄는 기존 방식
/// (<see cref="CrescentOnly"/>)으로는 나머지가 남는다 — 그래서 "남길 것을 지정하고
/// 나머지를 끄는" 반전 모드를 둔다.
///
/// 어느 모드든 <see cref="SlashCrescentMask"/> 가 캐시한 **원본 enabled 상태**를 기준으로
/// 계산하므로, 원래 꺼져 있던 이미터를 켜는 일은 없고 모드를 되돌리면 100% 원복된다(C8).
/// </summary>
public enum SuppressMode
{
/// <summary>기존 동작 — <c>crescentRules</c> 에 지정한 이미터만 끈다. 롤백 경로.</summary>
CrescentOnly = 0,
/// <summary>루트 이하 모든 파티클 렌더러를 끈다(트레일 리본만 남는다).</summary>
All = 1,
/// <summary>
/// 전면 억제하되 <c>keepEmitterNames</c>(전역) 또는 프리팹 규칙의
/// <c>keepEmitterNames</c> 에 든 이름만 남긴다. 구 WL 룩의 기본값 — 스파크만 남긴다.
/// </summary>
AllExceptListed = 2
}
/// <summary>
/// 슬래시 프리팹 1종의 크레센트(호 본체) 이미터 억제 규칙.
///
/// 실측(2026-09-05) — Effect_Slash_* 20종은 **원본 프리팹 5종을 공유**한다:
/// Blue_Slash_1 (10101_1·2) 크레센트 = slash_L01 ×2 · slash01_blue · flare02_blue
/// Blue_Sword (10101_3·10102_3·4·10501_3) 크레센트 = Center_flash · "Center_flash (1)"
/// ※ 이 계열엔 이름에 slash 가 든 이미터가 하나도 없다
/// Orange_Slash_1 (10102_1·2) 크레센트 = "slash_a (1)" · "slash_a (2)" · "slash_b (1)"
/// ※ 전부 Billboard — "Mesh = 크레센트" 휴리스틱이 통하지 않는다
/// BlueSlash (10201_1·2·3) 크레센트 = BlueSlash
/// 인라인 10202_1~4 크레센트 = OrangeSlash (·"OrangeSlash (1)")
/// Lana 10501_1·2 / 10502_1·2 크레센트 후보 = glow · shadow (텍스처 없음 — 아래 주의)
/// HCFX_Dust_04 (10502_3) 크레센트 **0개** (순수 먼지 버스트)
///
/// 🔴 미확인 / PM·PD 결정 필요:
/// Lana 계열 4종이 쓰는 1AB_mat·1Add_mat 은 **텍스처가 한 장도 물려 있지 않다**(전 슬롯 fileID 0).
/// 호 형상을 렌더할 수 없는 상태이며 이는 Lana 팩 원본에서 유래한 기존 결함이다.
/// </summary>
[Serializable]
public sealed class SlashPrefabEmitterRule
{
[Tooltip("대상 슬래시 프리팹 이름 (예: Effect_Slash_10502_1). ShowEffect 이벤트의 stringParameter 와 같다")]
public string prefabName;
[Tooltip("이 프리팹에서 렌더러를 끌 이미터 GameObject 이름. 정확히 일치해야 한다(공백·괄호 포함)")]
public string[] crescentEmitterNames = new string[0];
[Tooltip("SuppressMode.AllExceptListed 일 때 이 프리팹에서만 남길 이미터 이름. " +
"비우면 전역 keepEmitterNames 를 쓴다. 프리팹마다 스파크 이미터 이름이 다르면 여기에 적는다(C45)")]
public string[] keepEmitterNames = new string[0];
// ── PD #761 PrefabArc 전용 (프리팹별 배치 보정) ─────────────────────────
[Tooltip("체크하면 아래 arc* 값을 이 프리팹에만 적용한다. 끄면 설정 에셋의 전역 arc* 값을 쓴다")]
public bool overrideArc;
[Tooltip("궤적 평면 프레임(Z=평면 법선 · Y=칼끝 방향) 기준의 추가 회전(도). " +
"프리팹마다 크레센트가 그려지는 로컬 축 규약이 달라 자동 정렬이 뒤집히거나 " +
"90도 돌아가는 경우를 캡처 실측으로 보정한다. 0 = 자동 정렬 그대로")]
public Vector3 arcEulerOffset = Vector3.zero;
[Tooltip("체크하면 크레센트의 시각 지름이 검날 길이 × 2 가 되도록 스케일을 맞춘다(구 WL slashFitToBlade). " +
"끄면 arcScale 을 그대로 로컬 스케일로 쓴다")]
public bool arcFitToBlade = true;
[Tooltip("최종 스케일 배수. arcFitToBlade 가 켜져 있으면 맞춘 크기에 이 값을 곱한다")]
public float arcScale = 1f;
[Tooltip("크레센트의 시각 중심을 궤적의 어느 지점에 놓을지")]
public ArcAnchor arcAnchor = ArcAnchor.HiltMid;
}
// ═════════════════════════════════════════════════════════════════════════
// PD 지시 #792 (2026-09-07) — 무기 스윙에 "딱 맞게" 배치
//
// ① "무기 궤적을 그대로 따라가야 한다"가 아니라 **무기를 휘두르는 방향에 맞게
// 이펙트의 각도·위치·크기를 무기에 딱 맞게** 배치해 자연스럽게 보이게 할 것.
// ② 휘두르기 = NamuFX `Slash_B_recolor_1` · 찌르기 = EricWang `FX_Blue Stab`.
//
// 아래 타입들은 그 배치에 필요한 **데이터**를 담는다 — 코드에는 값을 넣지 않는다(C45).
// ═════════════════════════════════════════════════════════════════════════
/// <summary>공격 클립이 휘두르기인지 찌르기인지.</summary>
public enum SwingKind
{
/// <summary>칼끝 궤적으로 자동 분류한다(기본).</summary>
Auto = 0,
/// <summary>휘두르기 — 호 이펙트를 스윙 평면에 눕힌다.</summary>
Swing = 1,
/// <summary>찌르기 — 직선 이펙트를 찌르기 축에 맞춘다.</summary>
Stab = 2
}
/// <summary>호의 반지름을 무엇에 맞출지. 균등 스케일이라 하나만 정확히 맞출 수 있다.</summary>
public enum ArcRadiusFit
{
/// <summary>바깥 가장자리 = 칼끝 반경. 눈이 먼저 읽는 쪽이라 기본값.</summary>
Outer = 0,
/// <summary>안쪽 가장자리 = 손잡이 반경.</summary>
Inner = 1,
/// <summary>두 반경의 최소자승 절충.</summary>
Both = 2
}
/// <summary>찌르기 이펙트를 칼끝 경로의 어디에 놓을지.</summary>
public enum StabAnchor
{
/// <summary>이펙트 기준점을 칼끝 출발점에 맞춘다(기본 — 이펙트가 경로를 그대로 덮는다).</summary>
Start = 0,
/// <summary>이펙트 중앙을 경로 중앙에 맞춘다.</summary>
Center = 1,
/// <summary>이펙트 앞끝을 최대 전진점에 맞춘다.</summary>
End = 2
}
/// <summary>
/// 원본 ShowEffect 프리팹 이름 → 새 이펙트 프리팹 이름 치환 1행 (PD #792 매핑 테이블).
///
/// 🔴 원본 애니메이션 이벤트와 원본 프리팹은 **한 글자도 건드리지 않는다**.
/// 이벤트가 넘겨 준 이름(Effect_Slash_{CID}_{N})을 런타임에 여기서 갈아 끼운다.
/// 이 목록을 비우면 전역 두 개(swingArcEffect / stabEffect)만 쓰고,
/// useMappedEffects 를 끄면 그 즉시 원본 이펙트로 돌아간다(C8 롤백 경로).
/// </summary>
[Serializable]
public sealed class SlashEffectRemap
{
[Tooltip("원본 이벤트의 stringParameter. 예: Effect_Slash_10501_1. 비우면 이 행은 무시된다")]
public string sourcePrefab;
[Tooltip("이 행을 적용할 클래스 ID. 0 이면 클래스와 무관하게 적용")]
public int classId;
[Tooltip("휘두르기/찌르기 강제 지정. Auto 면 칼끝 궤적으로 자동 분류한다")]
public SwingKind kind = SwingKind.Auto;
[Tooltip("휘두르기로 판정됐을 때 쓸 이펙트 프리팹 이름(Assets/Res_Addr/Effect/ 기준). " +
"비우면 전역 swingArcEffect 를 쓴다. 클래스별 색 변형은 여기에 지정한다")]
public string swingEffect = "";
[Tooltip("찌르기로 판정됐을 때 쓸 이펙트 프리팹 이름. 비우면 전역 stabEffect 를 쓴다")]
public string stabEffect = "";
}
/// <summary>
/// 이펙트 프리팹 1종의 기하 실측 결과(에디터 베이크 산출물 · <see cref="SlashArcMeasure"/>).
///
/// 왜 굽는가 — 임포트된 FBX 는 Read/Write 가 꺼져 있는 것이 기본이라
/// **빌드된 플레이어에서 Mesh.vertices 가 실패**한다(에디터에서만 우연히 통한다).
/// 그래서 정점 기반 정밀 실측은 에디터에서 한 번만 하고 결과를 여기에 데이터로 남긴다
/// (C45 — 값의 SOT 는 SO 하나). 런타임은 읽기만 하고, 없으면 바운딩 박스 폴백으로 떨어진다.
///
/// 실측값(2026-09-07 · Effect_WLSwingArc ← NamuFX Slash_B_recolor_1):
/// normal (0,1,0) · center (0,0,0) · bisector (0,0,-1) · r_out 1.2200 · r_in 0.5733 · sweep 226.7°
/// 안/바깥 비 0.470 은 구 WL 이 독립적으로 실측한 0.47/1.0 과 일치한다(교차 검증).
/// </summary>
[Serializable]
public sealed class SlashEffectGeometry
{
[Tooltip("대상 이펙트 프리팹 이름(Assets/Res_Addr/Effect/ 기준)")]
public string prefabName;
[Tooltip("이 프리팹을 호(휘두르기)로 쓸지 직선(찌르기)으로 쓸지")]
public SwingKind kind = SwingKind.Swing;
[Tooltip("호 실측이 유효한가(베이크 완료 여부)")]
public bool arcValid;
[Tooltip("실측에 쓴 이미터 이름 / 메모(추적성)")]
public string emitter = "";
[Tooltip("호가 놓인 평면의 법선(루트 로컬 · 시작→끝이 이 축에서 CCW)")]
public Vector3 arcNormal = Vector3.forward;
[Tooltip("호가 그리는 원의 중심(루트 로컬 · 스케일 1)")]
public Vector3 arcCenter = Vector3.zero;
[Tooltip("원 중심 → 호 중앙(볼록 방향 · 단위)")]
public Vector3 arcBisector = Vector3.up;
[Tooltip("원 중심 → 호 시작(단위 · 검증용)")]
public Vector3 arcStartDir = Vector3.right;
[Tooltip("호 바깥 반지름(m · 루트 스케일 1)")]
public float arcRadiusOuter = 1f;
[Tooltip("호 안쪽 반지름(m · 루트 스케일 1)")]
public float arcRadiusInner = 0.47f;
[Tooltip("호가 덮는 각도(도)")]
public float arcSweepDeg = 180f;
[Tooltip("베이크 결과 호의 진행 방향이 뒤집혀 보이면 켠다(캡처로 확정). 재베이크 시 반영된다")]
public bool flipSweep;
[Tooltip("찌르기 실측이 유효한가")]
public bool stabValid;
[Tooltip("찌르기 진행 축(루트 로컬 · 꼬리 → 칼끝)")]
public Vector3 stabAxis = Vector3.forward;
[Tooltip("부착 기준점 = 프리팹 루트를 축에 투영한 지점(루트 로컬 · 스케일 1)")]
public Vector3 stabStart = Vector3.zero;
[Tooltip("기준점에서 앞끝까지의 도달 거리(m · 루트 스케일 1)")]
public float stabLength = 1f;
[Tooltip("이펙트 면의 법선(루트 로컬). 카메라를 향하게 롤을 맞출 때 쓴다")]
public Vector3 stabPlaneNormal = Vector3.up;
[Tooltip("베이크 결과 축이 반대로 잡히면 켠다(캡처로 확정)")]
public bool flipAxis;
[Tooltip("베이크 시각(추적성)")]
public string bakedAt = "";
}
/// <summary>
/// (클래스, 클립)별 스윙 프레임 캘리브레이션 1행 — **캐릭터 로컬 좌표**.
///
/// 왜 필요한가 (PD #792 타이밍) — 이펙트는 원본처럼 ShowEffect 이벤트 시점에 떠야 하는데,
/// 그 시점에는 아직 스윙이 진행 중이라 평면·반지름·피벗을 알 수 없다. 그래서 조합마다
/// 스윙이 끝난 뒤 프레임을 재서 캐릭터 로컬로 저장해 두고, 다음부터는 이벤트가 뜬 그 프레임에
/// 즉시 배치한다(스폰 지연 0 프레임). 에디터 베이크가 채우면 **첫 스윙부터** 정확하다.
///
/// 캐릭터 로컬로 저장하는 이유 — #789 로 공격 중 액터가 루트모션만큼(0.1~0.3 m) 움직이고
/// 방향도 바뀐다. 월드 좌표로 저장하면 다음 공격에서 전혀 다른 자리에 뜬다.
/// 회전+이동만 쓰고 스케일은 쓰지 않는다 — 반지름·길이는 미터 단위로 따로 담는다.
/// </summary>
[Serializable]
public sealed class SwingCalibration
{
[Tooltip("table_classconfig 의 n_ClassID")]
public int classId;
[Tooltip("ShowEffect 이벤트의 stringParameter. 예: Effect_Slash_10501_1.\n" +
"실측(2026-09-07) — 서브클립 Attack{N}_S_{CID} 는 Effect_Slash_{CID}_{N} 을 1:1 로 갖는다. " +
"즉 이 문자열 하나가 (클래스, 클립) 조합을 유일하게 식별하고, 이벤트가 뜬 그 순간에 " +
"바로 손에 들어온다 — 애니메이터 상태를 뒤져 클립 이름을 알아낼 필요가 없다.\n" +
"🔴 한 클립에 ShowEffect 가 2번 있는 쌍검 클립(10201·10202)은 두 이벤트가 같은 키를 쓴다.")]
public string clipName;
[Tooltip("이 클립의 분류 결과")]
public SwingKind kind = SwingKind.Swing;
[Tooltip("스윙 평면 법선(캐릭터 로컬 · 시작→끝이 이 축에서 CCW)")]
public Vector3 planeNormal = Vector3.right;
[Tooltip("스윙 피벗 = 칼끝 궤적의 원 중심(캐릭터 로컬)")]
public Vector3 pivot = Vector3.zero;
[Tooltip("피벗 → 스윙 중앙(볼록 방향 · 캐릭터 로컬 · 단위)")]
public Vector3 bisector = Vector3.forward;
[Tooltip("피벗 → 스윙 시작(캐릭터 로컬 · 단위 · 검증용)")]
public Vector3 startDir = Vector3.forward;
[Tooltip("칼끝 반경(m)")]
public float tipRadius = 1f;
[Tooltip("손잡이 반경(m)")]
public float hiltRadius = 0.3f;
[Tooltip("스윙이 덮은 각도(도)")]
public float sweepDeg;
[Tooltip("찌르기 축(캐릭터 로컬 · 단위)")]
public Vector3 stabAxis = Vector3.forward;
[Tooltip("칼끝 출발점(캐릭터 로컬)")]
public Vector3 stabStart = Vector3.zero;
[Tooltip("찌르기 거리(m)")]
public float stabLength = 0.5f;
[Tooltip("실측 품질 = 이 캘리브레이션을 만든 스윙에서 칼끝이 지나간 경로 길이(m).\n" +
"🔴 왜 필요한가(실측 2026-09-07) — 콤보가 중간에 끊기거나 다음 타로 넘어가면 스윙 창이 " +
"일찍 닫혀 **잘린 스윙**이 잡힌다(칼끝 이동 0.09 m · 9 샘플). 그 값이 캐시에 한 번 들어가면 " +
"영원히 그 잘린 프레임으로 배치된다 — 실제로 Attack2/Attack3 의 휘두르기/찌르기 분류가 " +
"서로 뒤바뀌었다. 그래서 매 스윙마다 다시 재고 **더 긴 궤적이 나오면 갈아 끼운다**.")]
public float quality;
[Tooltip("이 행을 만든 근거(런타임 자동 실측 / 에디터 베이크 · 추적성)")]
public string source = "";
}
/// <summary>PrefabArc 배치에 쓰는 값 묶음. 프리팹 규칙 → 전역 순으로 해석된다.</summary>
public struct ArcPlacement
{
public Vector3 eulerOffset;
public bool fitToBlade;
public float scale;
public ArcAnchor anchor;
}
/// <summary>
/// 클래스(직업)별 오버라이드 1행. 클래스 ID·색·폭 등을 개별 지정한다.
/// 비워 두면(<see cref="TrailClassMode.Auto"/>) 실측 자동 판정을 쓴다.
/// </summary>
[Serializable]
public sealed class SlashTrailClassOverride
{
[Tooltip("table_classconfig 의 n_ClassID (예: 10101 · 10502). 0 이면 이 행은 무시된다")]
public int classId;
[Tooltip("이 클래스에서 궤적 트레일을 켤지")]
public TrailClassMode mode = TrailClassMode.Auto;
[Tooltip("체크하면 아래 색/폭 값으로 전역 기본값을 덮어쓴다")]
public bool overrideLook;
[Tooltip("가산 겹 색(HDR) — overrideLook 일 때만 쓴다")]
[ColorUsage(true, true)] public Color colorAdditive = Color.white;
[Tooltip("알파 겹 색(HDR) — overrideLook 일 때만 쓴다")]
[ColorUsage(true, true)] public Color colorAlpha = Color.white;
[Tooltip("리본 폭 비율 — overrideLook 일 때만 쓴다")]
[Range(0.05f, 1f)] public float widthScale = 0.53f;
[Tooltip("체크하면 아래 무기 슬롯만 궤적을 그린다(양손 단검 = 양쪽 · 메이스+방패 = 오른손만)")]
public bool overrideWeaponSlots;
[Tooltip("궤적을 그릴 PCActor.tfs_weapon 인덱스 목록. overrideWeaponSlots 일 때만 쓴다")]
public int[] weaponSlotIndices = new int[0];
}
/// <summary>
/// 무기 궤적 트레일의 단일 출처(SOT). 코드 상수 금지(C45) — 모든 값을 여기서 읽는다.
/// 에셋 경로(2단계 생성 예정): <c>Assets/Resources/WL/SlashTrailSettings.asset</c>
///
/// 아래 기본값은 **구 WL 프로토타입에서 PM 이 실측해 확정한 값**을 옮긴 것이다
/// (출처는 각 Tooltip 에 표기). 힘민지 무기·이펙트 기준 재실측은 2단계 과제다.
/// </summary>
[CreateAssetMenu(fileName = "SlashTrailSettings", menuName = "WL/Slash Trail Settings")]
public sealed class SlashTrailSettings : ScriptableObject
{
// ── 로드 경로 ────────────────────────────────────────────────────────────
// C45 주의: 아래는 "게임 데이터 값"이 아니라 **에셋 로드 경로**다.
// 힘민지도 같은 방식으로 경로를 코드에 둔다(PCActor.Change_Weapon 의
// "Assets/Res_Addr/Weapon/{0}.prefab", InGameInfo.Load_Effect 의
// "Assets/Res_Addr/Effect/{0}.prefab"). 동일 규약을 따른다.
public const string ResourcesPath = "WL/SlashTrailSettings";
private static SlashTrailSettings s_cached;
private static bool s_lookupDone;
/// <summary>
/// 설정 에셋을 가져온다(1회 로드 후 캐시). 에셋이 없으면 null 을 돌려주고,
/// 호출부는 조용히 기존(캐릭터 기준) 동작으로 되돌아간다 — 즉 에셋이 없어도
/// 게임은 지금과 똑같이 굴러간다(C8 프로덕션 보호 · 롤백 경로).
/// </summary>
public static SlashTrailSettings Instance
{
get
{
if (s_lookupDone) return s_cached;
s_lookupDone = true;
s_cached = Resources.Load<SlashTrailSettings>(ResourcesPath);
return s_cached;
}
}
/// <summary>캐시를 비운다(에디터에서 에셋을 새로 만들었을 때 · 테스트용).</summary>
public static void ClearCache()
{
s_cached = null;
s_lookupDone = false;
}
// ── 마스터 스위치 ────────────────────────────────────────────────────────
[Header("■ 마스터 스위치")]
[Tooltip("끄면 무기 궤적 트레일을 전부 비활성하고 기존(캐릭터 기준) 이펙트 경로를 그대로 쓴다. " +
"라이브 롤백 스위치 — 코드 되돌리기 없이 원복할 수 있다(C8)")]
public bool enableWeaponTrail = true;
// ── 스윙 창 ──────────────────────────────────────────────────────────────
[Header("■ 스윙 창 (언제부터 언제까지 궤적을 모으는가)")]
[Tooltip("ShowEffect 애니메이션 이벤트가 뜬 시점부터 클립 정규화 시간으로 이만큼 동안 궤적을 모은다. " +
"정규화 시간이라 공격 속도(m_animation.speed)·클립 길이와 무관하게 같은 구간을 잡는다 · 임시 0.35")]
[Range(0.02f, 1f)] public float swingWindowNormalized = 0.35f;
[Tooltip("안전 상한(초). 애니메이터 상태를 못 읽거나 클립이 멈춰도 이 시간이 지나면 창을 닫는다 · 임시 0.60")]
public float swingMaxSeconds = 0.60f;
[Tooltip("스윙 창이 열린 직후 이 시간(초) 안에는 상태 전환으로 창을 닫지 않는다. " +
"콤보 전환 프레임에 창이 즉시 닫히는 것을 막는 유예 · 임시 0.03")]
public float swingStateGraceSeconds = 0.03f;
[Tooltip("체크하면 애니메이터 상태가 바뀌는 순간 스윙 창을 닫는다(콤보 다음 타로 넘어갈 때 꼬리를 끊는다)")]
public bool endSwingOnStateChange = true;
// ── 리본 형상 ────────────────────────────────────────────────────────────
[Header("■ 리본 형상")]
[Tooltip("트레일이 기억하는 최대 샘플(프레임) 수 · 구 WL 실측 기본 48")]
[Range(4, 256)] public int maxSamples = 48;
[Tooltip("직전 샘플과 검끝이 이 거리(m) 미만이면 샘플을 버린다. 정지 중 정점이 겹쳐 " +
"면이 뒤집히는 것을 막는다 · 임시 0.004")]
public float minSampleDistance = 0.004f;
[Tooltip("리본 폭 비율. 1 = 손잡이~칼끝 전체. " +
"구 WL 실측: 원본 SlashMesh_00 안쪽/바깥 반지름 = 0.47/1.0 이므로 0.53 이 원본과 같은 두께")]
[Range(0.05f, 1f)] public float widthScale = 0.53f;
[Tooltip("꼬리(오래된 쪽) 폭 비율. 1 = 균일, 작을수록 꼬리가 얇아진다(원본 크레센트의 테이퍼) · 구 WL 실측 0.25")]
[Range(0f, 1f)] public float tailWidthRatio = 0.25f;
[Tooltip("검끝 바깥으로 리본을 더 뻗는 비율(검날 길이 대비). 0 = 칼끝에서 끝난다 · 임시 0")]
[Range(0f, 0.5f)] public float tipExtendRatio = 0f;
// ── #785 샘플 보간 ───────────────────────────────────────────────────────
[Header("■ #785 샘플 보간 (한 프레임에 검이 크게 움직일 때)")]
[Tooltip("직전 샘플과 검끝 거리가 이 값(m)을 넘으면 사이에 중간 샘플을 끼워 각진 리본을 막는다. " +
"0 = 보간하지 않는다. 손잡이는 선형, 칼날 벡터는 Slerp 라 실제 호가 복원된다")]
public float maxSegmentDistance = 0.22f;
[Tooltip("한 프레임에 끼울 수 있는 중간 샘플의 최대 개수. 모바일 예산상 3 이면 충분하다")]
[Range(0, 4)] public int maxSubSteps = 3;
// ── 페이드 ───────────────────────────────────────────────────────────────
[Header("■ 페이드")]
[Tooltip("스윙이 끝난 뒤 트레일이 사라지는 시간(초) · 구 WL 실측 기본 0.18")]
public float fadeSeconds = 0.18f;
[Tooltip("페이드 중 알파를 그대로 유지하는 구간 비율(0~1). " +
"구 WL 실측: 원본 Custom2 그라디언트가 0.51 까지 알파 1 유지 후 0 까지 감소")]
[Range(0f, 0.95f)] public float fadeHoldRatio = 0.51f;
// ── 머티리얼·색 ──────────────────────────────────────────────────────────
[Header("■ 머티리얼 (URP · 2단계에 생성해 연결)")]
[Tooltip("가산 겹 머티리얼. URP 셰이더 'Universal Render Pipeline/Particles/Unlit' + Blending Mode = Additive 를 전제한다. " +
"비우면 그 겹을 그리지 않는다")]
public Material materialAdditive;
[Tooltip("알파 겹 머티리얼. 같은 셰이더 + Blending Mode = Alpha. 비우면 1겹(가산)만 그린다")]
public Material materialAlpha;
[Header("■ 색 (정점색으로 넣는다 · URP Particles/Unlit 는 정점색을 곱한다)")]
[Tooltip("가산 겹 색(HDR). 구 WL NamuFX 원본 실측 피크 = (0.086, 1.553, 2.024)")]
[ColorUsage(true, true)] public Color colorAdditive = new Color(0.086f, 1.553f, 2.024f, 1f);
[Tooltip("알파 겹 색(HDR). 구 WL NamuFX 원본 Custom Data 2 그라디언트 시작값 실측 = (0, 0.762, 1.582)")]
[ColorUsage(true, true)] public Color colorAlpha = new Color(0f, 0.762f, 1.582f, 1f);
[Tooltip("정점색이 먹지 않는 셰이더를 쓸 때 켠다. MaterialPropertyBlock 으로 _BaseColor 에도 같은 색을 넣는다. " +
"2단계 Play 확인 후 필요하면 켠다")]
public bool alsoApplyColorViaPropertyBlock;
// ── #785 그라디언트 (참고 이미지의 색 배치) ─────────────────────────────
[Header("■ #785 그라디언트 (안쪽 주황/노랑 → 바깥·꼬리 파랑/보라)")]
[Tooltip("켜면 아래 두 Gradient 로 정점색을 굽는다. 끄면 예전 단색(colorAdditive/colorAlpha) 경로로 돌아간다 — C8 롤백 스위치")]
public bool useGradients = true;
[Tooltip("잔상축 색. t=1 이 검의 현재 위치(머리), t=0 이 소멸 직전 꼬리. " +
"PD 참고 이미지 기준 기본값 = 머리 주황/노랑 → 꼬리 파랑/보라")]
public Gradient gradientAlongSwing = new Gradient();
[Tooltip("칼날축 색. t=0 이 손잡이(안쪽), t=1 이 칼끝(바깥). " +
"알파 채널은 안/바깥 세기 조절에 쓴다(가장자리 페이드 자체는 셰이더가 한다)")]
public Gradient gradientAlongBlade = new Gradient();
[Tooltip("두 그라디언트를 섞는 비율. 0 = 잔상축 색만, 1 = 칼날축 색만, 0.5 = 반반. " +
"곱이 아니라 Lerp 라 채도가 죽지 않는다")]
[Range(0f, 1f)] public float bladeGradientWeight = 0.5f;
[Tooltip("그라디언트 색에 곱하는 밝기 배수. 가산 블렌드라 1 이상이면 글로우가 강해진다")]
[Range(0f, 8f)] public float colorIntensity = 1f;
// ── UV ───────────────────────────────────────────────────────────────────
[Header("■ UV")]
[Tooltip("가산 겹 u 범위. 텍스처가 시트(3x3 등)면 한 칸만 쓰도록 좁힌다. " +
"구 WL 실측: T_SlashSheet01 프레임 2 = 0.667~1.0. 힘민지 텍스처는 2단계 재실측 대상")]
[Range(0f, 1f)] public float uvUMin = 0f;
[Range(0f, 1f)] public float uvUMax = 1f;
[Tooltip("가산 겹 v 범위(0 = 손잡이 쪽, 1 = 칼끝 쪽)")]
[Range(0f, 1f)] public float uvVMin = 0f;
[Range(0f, 1f)] public float uvVMax = 1f;
[Tooltip("켜면 알파 겹은 UV 0~1 전체를 쓴다(마스크가 시트가 아니라 한 장일 때)")]
public bool alphaLayerUvFull = true;
// ── NamuFX 호환 채널 (힘민지에서는 기본 OFF) ────────────────────────────
[Header("■ NamuFX 호환 채널 (힘민지에는 NamuFX 가 없다 · 기본 OFF)")]
[Tooltip("구 WL 의 NamuFX 셰이더(SH_Master_improved)는 색을 TEXCOORD2, 디졸브를 TEXCOORD1 에서 읽었다. " +
"그런 셰이더를 쓸 때만 켠다. URP Particles/Unlit 은 정점색을 쓰므로 힘민지에서는 꺼 둔다")]
public bool writeCustomDataChannels = false;
[Tooltip("알파 겹 디졸브 진행량 상한(구 WL NamuFX Custom Data 1 실측 = 수명 동안 0 → 0.98). " +
"writeCustomDataChannels 일 때만 의미가 있다")]
[Range(0f, 1f)] public float dissolveMax = 0.98f;
// ── 무기·소켓 실측 ───────────────────────────────────────────────────────
[Header("■ 무기 소켓 · 검날 실측")]
[Tooltip("PCActor.tfs_weapon 에서 무기를 못 찾을 때 계층에서 이름으로 찾을 후보(위에서부터). " +
"비워 두면 이름 탐색을 하지 않는다")]
public string[] socketNameCandidates = new string[0];
[Tooltip("무기 메시 최장축이 이 길이(m) 미만이면 '검날 아님'으로 보고 궤적을 그리지 않는다. " +
"오브(구체)·소형 장식 배제용 · 임시 0.25")]
public float minBladeLength = 0.25f;
[Tooltip("무기 메시의 (최장축 / 두 번째 축) 비율이 이 값 미만이면 '검날 아님'으로 본다. " +
"방패(납작·넓음 ≈ 1.2)를 배제하고 검·단검·메이스(≈ 4~9)만 통과시킨다 · 임시 2.2. " +
"🔴 이 기본값은 구 WL 무기 형상에서 추정한 값이다 — 힘민지 무기 실측은 2단계 과제")]
public float minBladeAspect = 2.2f;
[Tooltip("검날 길이를 못 재면 쓰는 대체 길이(m) · 임시 0.8")]
public float bladeLengthFallback = 0.8f;
[Tooltip("한 소켓에 렌더러가 여럿이면(검+장식) 메시 최장축이 가장 긴 것을 검날로 본다. " +
"끄면 첫 번째 렌더러를 쓴다")]
public bool pickLongestRenderer = true;
// ── 근접 클래스 판정 ─────────────────────────────────────────────────────
[Header("■ 근접 클래스 판정 (C45 — 코드에 클래스 ID 를 넣지 않는다)")]
[Tooltip("근접으로 볼 table_classconfig 의 e_BattleType 목록. 1순위 판정 기준이다. " +
"비우면 아래 f_AttackRange 임계값으로만 판정한다.\n" +
"기본값 근거 — ClassConfig.json 실측(전 12행): " +
"OneHand=10101·10102 / DualWeapon=10201·10202 / Shield=10501·10502 (모두 슬래시 프리팹 보유), " +
"Staff=10301~10303 · Bow=10401·10402 (슬래시 프리팹 없음 → 제외), " +
"TwoHand=10601 (무기 프리팹 자체가 빈칸이라 어차피 궤적을 못 그린다)")]
public eBattleType[] meleeBattleTypes = new eBattleType[]
{
eBattleType.OneHand,
eBattleType.DualWeapon,
eBattleType.Shield,
eBattleType.TwoHand
};
[Tooltip("meleeBattleTypes 가 비었을 때만 쓰는 2순위 기준. " +
"table_classconfig 의 f_AttackRange 가 이 값(m) 이하이면 근접으로 본다.\n" +
"기본값 근거 — ClassConfig.json 실측: 근접 = 1.0(쌍검) / 1.5(한손·방패) · " +
"원거리 = 5.0(스태프) / 6.0(활) · 양손 = 3.0. 2.0 이면 근접만 통과한다")]
public float meleeMaxAttackRange = 2.0f;
[Tooltip("클래스별 개별 지정. 여기 있는 클래스는 위 자동 판정보다 우선한다. " +
"자동 판정이 틀리는 클래스만 넣으면 된다")]
public List<SlashTrailClassOverride> classOverrides = new List<SlashTrailClassOverride>();
[Tooltip("체크하면 table_classconfig 의 n_WeaponLHIndex / n_WeaponRHIndex 가 가리키는 소켓만 본다. " +
"끄면 PCActor.tfs_weapon 에서 자식이 있는 소켓을 전부 훑는다.\n" +
"근거 — 실측상 tfs_weapon = [0]w_shield [1]w_righthand [2]w_onehand " +
"[3]w_bladedancer_l [4]w_bladedancer_r [5]w_orb [6]w_archer_l 이고, " +
"클래스 전환 시 이전 무기가 남아 있을 수 있어 테이블 인덱스를 쓰는 편이 정확하다")]
public bool useTableWeaponIndices = true;
// ── 검끝 버스트 ──────────────────────────────────────────────────────────
[Header("■ 검끝 버스트 (스윙 끝 지점에 원본 이펙트를 1회)")]
[Tooltip("None = 트레일만 그린다 / ReuseShowEffect = 기존 슬래시 프리팹을 **검끝 위치**로 1회 재생한다. " +
"ReuseShowEffect 는 힘민지 코드를 고치지 않고 쓸 수 있는 경로다")]
public BurstMode burstMode = BurstMode.ReuseShowEffect;
[Tooltip("버스트를 스윙 끝에서 터뜨릴지(구 WL 구현), 이벤트마다 터뜨릴지(기존 스폰 횟수 보존).\n" +
"🔴 PM/PD 결정 대기 항목 — 발주서 본문은 '스윙 시작 시 1회'라 했고 구 WL 코드는 " +
"EndSwing 에서 터뜨린다. 서로 다르므로 옵션으로 두고 2단계 캡처로 고른다.\n" +
"실측 근거 — 10201·10202 의 DualBlades_Attack1 클립은 ShowEffect 이벤트가 " +
"2개(0.0435 / 0.1292)라, OnSwingEnd 면 버스트가 2회 → 1회로 줄어든다")]
public BurstTiming burstTiming = BurstTiming.OnSwingEnd;
[Tooltip("버스트를 검끝에서 스윙 진행 방향으로 이만큼(m) 밀어 낸다 · 임시 0")]
public float burstForwardOffset = 0f;
[Tooltip("프리팹별 크레센트(호 본체) 이미터 억제 규칙. 비워 두면 아무것도 끄지 않는다(기본 · 가장 안전).\n" +
"🔴 전역 이름 목록을 쓰지 않는 이유(실측): 20개 슬래시 프리팹은 원본 5종을 공유하는데 " +
"이미터 이름 규칙이 원본마다 다르고 **충돌한다**. 예컨대 'glow' 는 Blue_Slash_1·Blue_Sword " +
"계열에서는 버스트(글로우)지만 Lana 계열(10501_1/2·10502_1/2)에서는 크레센트 후보다. " +
"전역 목록으로 끄면 멀쩡한 버스트까지 사라진다 — 그래서 프리팹 단위로 지정한다.\n" +
"이 규칙은 런타임에만 적용되며 프리팹 에셋을 건드리지 않는다(즉시 원복 가능 · C8).")]
public List<SlashPrefabEmitterRule> crescentRules = new List<SlashPrefabEmitterRule>();
[Tooltip("원본 슬래시 프리팹의 파티클을 어디까지 끌지. " +
"CrescentOnly = 기존(규칙에 적은 것만) · All = 전부 · AllExceptListed = keepEmitterNames 만 남김.\n" +
"구 WL 룩(리본 + 스파크)은 AllExceptListed + keep=['sparks'] . CrescentOnly (C8)")]
public SuppressMode suppressMode = SuppressMode.CrescentOnly;
[Tooltip("SuppressMode.AllExceptListed 에서 남길 이미터 GameObject 이름(전역 기본). " +
"프리팹별로 다르면 crescentRules 의 keepEmitterNames 로 덮어쓴다. 정확히 일치해야 한다")]
public string[] keepEmitterNames = new string[] { "sparks" };
// ── PD #761 PrefabArc (전역 기본값) ─────────────────────────────────────
[Header("■ PrefabArc — 원본 프리팹을 스윙 평면에 눕혀 배치 (PD #761)")]
[Tooltip("리본(BladeTrail 메시)을 그릴지. PrefabArc 로 원본 크레센트를 되살리면 리본은 " +
"겹쳐 보이므로 기본 OFF. 끄더라도 궤적 샘플링은 계속하므로(평면 계산에 필요) " +
"enableWeaponTrail 은 켜 둔 채로 둔다. 켜면 즉시 리본이 돌아온다(C8 롤백 경로)")]
public bool drawRibbon = false;
[Tooltip("켜면 프리팹의 크레센트 이미터를 실측해 그 로컬 축 규약을 자동으로 궤적 평면에 맞춘다. " +
"끄면 프리팹 루트를 그대로 (Z=평면 법선 · Y=칼끝) 프레임에 놓는다(구 WL 원안).\n" +
"자동 판정 = 카메라 정렬(View) 이 아닌 렌더러 중 시각 지름이 가장 큰 것을 크레센트로 본다")]
public bool arcAutoOrient = true;
[Tooltip("전역 추가 회전(도) — 프리팹 규칙에 overrideArc 가 없을 때 쓴다")]
public Vector3 arcEulerOffset = Vector3.zero;
[Tooltip("전역 — 크레센트 지름을 검날 길이 × 2 에 맞춘다(구 WL slashFitToBlade)")]
public bool arcFitToBlade = true;
[Tooltip("전역 — 최종 스케일 배수")]
public float arcScale = 1f;
[Tooltip("전역 — 크레센트 시각 중심을 놓을 지점")]
public ArcAnchor arcAnchor = ArcAnchor.HiltMid;
[Tooltip("PrefabArc 로 호를 잡는 데 필요한 최소 궤적 샘플 수. 이보다 적으면 " +
"평면을 못 구하므로 검끝 위치 버스트로 떨어진다(구 WL 도 3)")]
[Range(3, 32)] public int arcMinSamples = 3;
// ── PD #777 — 이펙트가 플레이어를 따라간다 ───────────────────────────────
[Header("■ 이펙트 추종 (PD #777)")]
[Tooltip("켜면 스폰된 슬래시 이펙트가 스폰 시점의 PC 상대 오프셋을 유지하며 따라온다.\n" +
"끄면 지금처럼 월드에 고정된다(기존 동작 · C8 롤백 경로).\n" +
"필요한 이유(실측) — PrefabArc 는 스윙이 끝난 자리에 이펙트를 월드 고정으로 두는데, " +
"#762 전진(공격당 0.30/0.70/0.65 m · 0.25 s)이 캐릭터만 앞으로 보내므로 " +
"이펙트 수명 동안 무기와 이펙트가 벌어진다. 부모를 바꾸지 않고 좌표만 따라가므로 " +
"InGameInfo 풀 구조는 그대로다")]
public bool arcFollowPlayer = true;
[Tooltip("PC 의 회전까지 따라갈지. 켜면 이펙트가 PC 로컬 좌표계에 고정돼 캐릭터가 돌면 같이 돈다. " +
"끄면 위치만 따라가고 스윙 평면(방향)은 그대로 유지된다 — 스윙은 이미 끝난 동작이라 " +
"방향이 유지되는 편이 자연스럽다고 보고 기본은 끔")]
public bool arcFollowRotation = false;
[Tooltip("추종을 유지할 시간(초). 0 = 이펙트 수명 전체(풀에 반환될 때까지). " +
"값을 주면 그 시간 뒤 이펙트가 그 자리에 남는다")]
public float arcFollowSeconds = 0f;
// ── PD #792 — NamuFX 호 + 찌르기 스프라이트를 무기에 딱 맞게 ──────────
[Header("■ #792 무기 정합 배치 (NamuFX Slash_B / FX_Blue Stab)")]
[Tooltip("끄면 #792 경로를 통째로 비활성하고 이전(#761 PrefabArc / #785 리본) 동작으로 돌아간다. " +
"라이브 롤백 스위치 — 코드를 되돌리지 않고 원복한다(C8)")]
public bool useMappedEffects = true;
[Tooltip("휘두르기 클립에 쓸 이펙트 프리팹 이름(Assets/Res_Addr/Effect/ 기준). " +
"PD #792 지정 = NamuFX Slash_B_recolor_1 을 감싼 Effect_WLSwingArc")]
public string swingArcEffect = "Effect_WLSwingArc";
[Tooltip("찌르기 클립에 쓸 이펙트 프리팹 이름. PD #792 지정 = EricWang FX_Blue Stab 을 감싼 Effect_WLStab")]
public string stabEffect = "Effect_WLStab";
[Tooltip("원본 프리팹 이름/클래스별 치환 규칙. 비우면 위 전역 두 개만 쓴다. " +
"클래스별 색 변형을 쓰고 싶을 때 여기에 클래스별 프리팹을 지정한다")]
public List<SlashEffectRemap> effectRemaps = new List<SlashEffectRemap>();
[Tooltip("이펙트 프리팹의 기하 실측 결과(에디터 베이크 산출물). " +
"비어 있으면 런타임이 바운딩 박스 폴백으로 배치한다 — 정확도가 크게 떨어진다")]
public List<SlashEffectGeometry> effectGeometry = new List<SlashEffectGeometry>();
[Tooltip("(클래스, 클립)별 스윙 프레임 캐시. 비어 있으면 첫 스윙은 스윙이 끝난 뒤 배치되고, " +
"그때 잰 값이 런타임 캐시에 들어가 두 번째 스윙부터 이벤트 시점에 즉시 배치된다. " +
"에디터 베이크로 채우면 첫 스윙부터 정확하다")]
public List<SwingCalibration> swingCalibrations = new List<SwingCalibration>();
[Header("□ #792 휘두르기/찌르기 자동 분류 (C45 — 코드에 숫자를 넣지 않는다)")]
[Tooltip("스윙 창 동안 **칼날 벡터(칼끝−손잡이)** 가 누적으로 이 각도(도) 이상 돌면 휘두르기로 본다.\n" +
"🔴 칼끝 궤적의 스윕 각이나 캐릭터 전방 내적으로 가르지 않는다 — 둘 다 흔들린다(실측 2026-09-07):\n" +
" · 스윕 각은 창이 잘리면 같은 클립에서 118°~485° 로 요동친다.\n" +
" · 전방 내적은 스윙 도중 캐릭터가 돌면 부호가 뒤집혀 같은 클립의 분류가 뒤바뀐다.\n" +
" 칼날 회전량은 캐릭터의 위치·방향과 무관한 순수 무기 회전이라 안정적이다.\n" +
"기본값 근거 — 10501 실측: Attack1 434° · Attack2 424° · Attack3 67°. 150 은 그 간격 한가운데다")]
public float swingMinSweepDegrees = 150f;
[Tooltip("칼끝 경로 / 손잡이 경로 비. 이 값 이상이면 손은 거의 제자리인데 칼끝만 크게 돈 것이므로 " +
"칼날 회전량이 작아도 휘두르기로 본다(손목 스냅 스윙 보호).\n" +
"반대로 이 값 미만이면 손과 칼끝이 함께 이동한 것 — 찌르기(런지)의 특징이다")]
public float swingMinTipHiltRatio = 3f;
[Tooltip("칼끝 궤적의 직진도(끝점 거리 / 경로 길이)가 이 값 이상이어야 찌르기 후보다. 1 = 완전 직선")]
[Range(0.5f, 1f)] public float stabMinStraightness = 0.86f;
[Tooltip("칼끝 진행 방향과 캐릭터 전방의 내적이 이 값 이상이어야 찌르기로 본다")]
[Range(0f, 1f)] public float stabMinForwardDot = 0.55f;
[Tooltip("칼끝 전진 거리가 이 값(m) 미만이면 찌르기로 보지 않는다(제자리 동작 배제)")]
public float stabMinDistance = 0.18f;
[Tooltip("스윙 창 안에서 **실제로 휘두른 구간만** 남기는 속도 임계 비율. " +
"한 프레임 칼끝 이동이 그 스윙 최대 이동의 이 배수 미만이면 앞뒤 꼬리로 보고 잘라 낸다.\n" +
"근거(실측 2026-09-07) — Attack3_S_10501 은 타격이 2 프레임(칼끝 1.0 m)만에 끝나고 그 뒤 " +
"18 프레임을 거의 정지한 채 버틴다. 창 전체를 그대로 피팅하면 정지 구간이 원피팅을 지배해 " +
"반지름이 0.04 m 로 붕괴한다. 0 이면 자르지 않는다")]
[Range(0f, 0.6f)] public float swingTrimSpeedRatio = 0.2f;
[Tooltip("■ #816v — 속도 트림 뒤에도 칼끝이 **한 바퀴 넘게 감기는** 구간(감아올리기)이 남으면 " +
"원 피팅이 깨진다. 창 끝을 한 프레임씩 앞으로 당기며 반경 편차 비율이 " +
"arcFitMaxDeviation 이하가 되는 가장 긴 창을 고른다.\n" +
"근거(실측 2026-09-15 · 에디트 모드 60 Hz) — Attack1_S_10101 은 ShowEffect 직후 4~5 프레임이 " +
"실제 타격 호(sweep 69~106° · 편차비 0.02~0.03)이고 그 뒤 프레임부터 칼끝이 한 바퀴 이상 " +
"감긴다. 창 전체(24 프레임)를 피팅하면 sweep 398.8° · 편차비 0.27 로 칼끝 원 피팅이 실패해 " +
"**손잡이 중심 폴백**으로 떨어지고 피벗이 머리 높이·몸 왼쪽으로 올라간다. " +
"2·3타는 편차비 0.14/0.17 로 원래 성립하므로 이 트림에 걸리지 않는다(무변경).\n" +
"끄면 즉시 #816t 동작으로 돌아간다(롤백)")]
public bool arcTrimWindupWrap = true;
[Tooltip("칼끝 원 피팅 합격 기준 = 반경 편차 / 평균 반경. 이 값을 넘으면 피팅 실패로 본다")]
[Range(0.05f, 0.6f)] public float arcFitMaxDeviation = 0.25f;
[Header("□ #792 호(휘두르기) 정합")]
[Tooltip("호의 반지름을 무엇에 맞출지. 균등 스케일이라 하나만 정확히 맞출 수 있다")]
public ArcRadiusFit arcRadiusFit = ArcRadiusFit.Outer;
[Tooltip("정합한 크기에 곱하는 배수. 1 = 칼끝 반경과 정확히 일치(기본 · C3-5)")]
[Range(0.4f, 2.5f)] public float arcSizeScale = 1f;
[Tooltip("■ #816t — 호 바깥 반지름을 칼끝 반경 대신 **플레이어 공격 범위**(사거리)에 맞춘다. " +
"출처 = NavMeshAgent.stoppingDistance 양수 최솟값(= ClassConfig f_AttackRange · PCActor.cs:381) " +
"+ eStat.AttackRange_Up + WLTargetingSettings.immediateAttackMargin — WLRangeRing 과 같은 SOT(상수 0개 · C45). " +
"끄면 즉시 이전(칼끝 반경 정합) 동작으로 돌아간다(C8 롤백)")]
public bool arcFitAttackRange = false;
[Tooltip("공격 범위에 곱하는 배수. 1 = 사거리와 정확히 일치")]
[Range(0.3f, 2f)] public float arcRangeScale = 1f;
[Tooltip("호를 스윙 평면에서 법선 방향으로 이만큼(m) 띄운다. 0 = 평면 위. " +
"무기 메시와 Z-파이팅이 보이면 아주 작은 값을 준다")]
public float arcPlaneOffset = 0f;
[Tooltip("호를 볼록 방향으로 이만큼(m) 밀어 낸다. 0 = 원 중심이 스윙 피벗")]
public float arcRadialOffset = 0f;
[Tooltip("이펙트 수명(초). 0 이면 프리팹의 파티클 duration 을 그대로 쓴다")]
public float arcLifetimeSeconds = 0f;
[Header("□ #792 찌르기 정합")]
[Tooltip("이펙트를 칼끝 경로의 어디에 놓을지")]
public StabAnchor stabAnchor = StabAnchor.Start;
[Tooltip("길이 정합 배수. 1 = 찌르기 거리와 정확히 일치")]
[Range(0.4f, 3f)] public float stabSizeScale = 1f;
[Tooltip("켜면 이펙트의 납작한 면이 메인 카메라를 향하도록 축 둘레 롤을 맞춘다. " +
"FX_Blue Stab 계열은 이미터가 전부 Local 정렬이라 스스로 카메라를 보지 않는다 — " +
"끄면 캐릭터의 위쪽(transform.up)을 기준으로 롤을 잡는다")]
public bool stabFaceCamera = true;
[Tooltip("이펙트 수명(초). 0 이면 프리팹의 파티클 duration 을 쓴다. " +
"FX_Blue Stab 루트는 duration 5 초 · looping 이라 반드시 값을 준다")]
public float stabLifetimeSeconds = 0.9f;
[Tooltip("찌르기 이펙트에서 남길 이미터 이름. 비우면 전부 남긴다.\n" +
"실측(2026-09-07) — FX_Blue Stab 은 렌더 이미터 10개 · 고유 머티리얼 9개라 " +
"모바일 예산(스윙당 추가 드로우 ≤ 3)을 크게 넘는다. 여기서 줄인다.\n" +
"남길 이미터가 곧 시각 길이를 정하므로 이 목록을 바꾸면 **기하를 다시 베이크**해야 한다")]
public string[] stabKeepEmitterNames = new string[0];
[Header("□ #792 추종")]
[Tooltip("#792 경로에서 이펙트가 캐릭터 회전까지 따라갈지. " +
"이벤트 시점(스윙 도중)에 배치하므로 캐릭터가 아직 돌고 있다 — 기본 ON. " +
"레거시 경로의 arcFollowRotation 과 분리해 두어 서로 영향이 없다(C8)")]
public bool mappedFollowRotation = true;
/// <summary>이 프리팹의 실측 기하. 없으면 null.</summary>
public SlashEffectGeometry FindGeometry(string prefabName)
{
if (effectGeometry == null || string.IsNullOrEmpty(prefabName)) return null;
for (int i = 0; i < effectGeometry.Count; i++)
{
var g = effectGeometry[i];
if (g != null && g.prefabName == prefabName) return g;
}
return null;
}
/// <summary>(클래스, 클립) 캘리브레이션. 없으면 null.</summary>
public SwingCalibration FindCalibration(int classId, string clipName)
{
if (swingCalibrations == null || string.IsNullOrEmpty(clipName)) return null;
for (int i = 0; i < swingCalibrations.Count; i++)
{
var c = swingCalibrations[i];
if (c != null && c.classId == classId && c.clipName == clipName) return c;
}
return null;
}
/// <summary>
/// 원본 이벤트 프리팹 이름 + 클래스 + 분류 결과 → 실제로 띄울 이펙트 이름.
/// 규칙이 하나도 없으면 전역 두 개(swingArcEffect / stabEffect)를 쓴다.
/// 클래스가 지정된 행이 클래스 무관 행보다 우선한다(더 좁은 규칙이 이긴다).
/// </summary>
public string ResolveEffectName(string sourcePrefab, int classId, SwingKind kind, out SwingKind forcedKind)
{
forcedKind = SwingKind.Auto;
string swing = swingArcEffect;
string stab = stabEffect;
if (effectRemaps != null)
{
bool hit = false;
for (int pass = 0; pass < 2 && !hit; pass++)
{
for (int i = 0; i < effectRemaps.Count; i++)
{
var r = effectRemaps[i];
if (r == null || string.IsNullOrEmpty(r.sourcePrefab)) continue;
if (r.sourcePrefab != sourcePrefab) continue;
if (pass == 0 && r.classId != classId) continue;
if (pass == 1 && r.classId != 0) continue;
if (r.kind != SwingKind.Auto) forcedKind = r.kind;
if (!string.IsNullOrEmpty(r.swingEffect)) swing = r.swingEffect;
if (!string.IsNullOrEmpty(r.stabEffect)) stab = r.stabEffect;
hit = true;
break;
}
}
}
var k = forcedKind != SwingKind.Auto ? forcedKind : kind;
return k == SwingKind.Stab ? stab : swing;
}
/// <summary>이 프리팹의 PrefabArc 배치 값. 규칙에 overrideArc 가 없으면 전역값을 쓴다.</summary>
public ArcPlacement ResolveArc(SlashPrefabEmitterRule rule)
{
var p = new ArcPlacement();
if (rule != null && rule.overrideArc)
{
p.eulerOffset = rule.arcEulerOffset;
p.fitToBlade = rule.arcFitToBlade;
p.scale = rule.arcScale;
p.anchor = rule.arcAnchor;
}
else
{
p.eulerOffset = arcEulerOffset;
p.fitToBlade = arcFitToBlade;
p.scale = arcScale;
p.anchor = arcAnchor;
}
if (p.scale <= 0.0001f) p.scale = 1f;
return p;
}
/// <summary>이 프리팹에서 남길 이미터 이름. 프리팹 규칙이 비어 있으면 전역 목록을 쓴다.</summary>
public string[] ResolveKeepNames(SlashPrefabEmitterRule rule)
{
if (rule != null && rule.keepEmitterNames != null && rule.keepEmitterNames.Length > 0)
return rule.keepEmitterNames;
return keepEmitterNames;
}
/// <summary>프리팹 이름에 해당하는 크레센트 억제 규칙. 없으면 null.</summary>
public SlashPrefabEmitterRule FindCrescentRule(string prefabName)
{
if (crescentRules == null || string.IsNullOrEmpty(prefabName)) return null;
for (int i = 0; i < crescentRules.Count; i++)
{
var r = crescentRules[i];
if (r != null && r.prefabName == prefabName) return r;
}
return null;
}
// ── 검증 ─────────────────────────────────────────────────────────────────
[Header("■ 검증 (에디터 전용)")]
[Tooltip("켜면 스윙 창 개폐·소켓 해석 결과를 콘솔에 남긴다. 에디터에서만 동작한다")]
public bool verboseLog = false;
[Tooltip("켜면 검끝 궤적 샘플을 씬 뷰 기즈모로 그린다. 에디터에서만 동작한다")]
public bool drawGizmos = false;
/// <summary>클래스 ID 에 해당하는 오버라이드 행. 없으면 null.</summary>
public SlashTrailClassOverride FindOverride(int classId)
{
if (classOverrides == null) return null;
for (int i = 0; i < classOverrides.Count; i++)
{
var o = classOverrides[i];
if (o != null && o.classId != 0 && o.classId == classId) return o;
}
return null;
}
}
}