Project_WL/Assets/WL/Combat/Weapon/WLShadowSlash.cs

218 lines
17 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.

// ─────────────────────────────────────────────────────────────────────────────
// WLShadowSlash.cs — 단검(비수 1043 · Dagger 10202) 주력 「그림자 베기」(Skill_ShadowSlash) — 시전 중 캐릭터 보이기 · 이펙트 크기 배율
//
// PD 원문(#943-⑧ · 2026-10-19): 「단검 주력 스킬 시전 시 캐릭터의 모습이 보이도록 수정하고, 이펙트의 크기를 절반으로 줄여줘.」
//
// ■ 안 보이던 원인(코드 실측) — 원본 Skill_ShadowSlash.Set 이 shooter.Set_Invisible(2.1 초)를 부른다 → Actor.Co_Invisible 이
// 애니메이터 배속 0 · 무적 · Model_OnOff(false)(파티클 외 렌더러 전부 끔) · Weapon_OnOff(false) · 이동 금지 → 2.1 초 뒤 되돌린 다음 대상 뒤 1.5 m 로 순간이동.
// 이펙트가 덮거나 카메라 문제가 아니라 원본 스킬이 모델을 끈다.
// ■ 처리 — 원본 Set 의 Set_Invisible 뒤 훅 1줄(OnSet): Co_Invisible 이 끈 렌더러 · 무기를 바로 다시 켠다(원본이 2.1 초 뒤 부르는 같은 함수).
// freezeAnim 0 이면 애니메이터 배속도 1 로 되돌려 원본 상태 사슬 DualBlades_Jump_Start → JumpAttack1·2·3 → Jump_End → idle 이 보인다
// (JumpAttack1~3 클립 이벤트 0 개 · Jump_Start 의 SkillEvent 는 시각 0 하나 · 종료 전이 0.7 이라 다시 안 돈다 — 2026-10-19 컨트롤러 · FBX 메타 실측).
// 무적 · 이동 금지 · 2.1 초 뒤 대상 뒤 순간이동 · 판정은 원본 그대로.
// ■ 이펙트 — 베기(go_effect = Slash · 파티클 6 층 · 크기 2.6 ~ 7.7)와 연기(go_invisibleEffect = FX_DarkSkill_SmokeExplosion)의 Transform 을
// 「처음 크기 × 배율」로(풀 재사용 때 누적 없음 · 처음 크기는 WLShadowSlashBase 에 기억) · 파티클 크기 모드 Hierarchy
// (베기 팩 원본이 Local 이라 Transform 배율이 안 먹는다). 베기 판정 상자(BoxCollider 5.25 × 9 × 5.9)는 keepHitBox 1 이면 배율의 역수로 키워 월드 크기 그대로.
// ■ 값 = Resources/WL/WLShadowSlash.json (C45) · 끄기 = enabled 0(원본 그대로)
// 🔴 Debug.LogError 금지(원본 ErrorLogHook 팝업) — 실패는 LastLog
// ─────────────────────────────────────────────────────────────────────────────
using System.Reflection;
using UnityEngine;
namespace WL.Combat.Weapon
{
[System.Serializable]
public class WLShadowSlashJson
{
public int enabled = 0;
public int keepVisible = 1; // 1 = 시전 중 PC 렌더러 · 무기를 켠다(원본 = 2.1 초 숨김)
public int freezeAnim = 0; // 1 = 원본처럼 애니메이터 배속 0 유지(보이면 2.1 초 정지 자세) · 0 = 원본 상태 사슬 재생
public float slashScale = 1f; // 베기(go_effect) 보이는 크기 배율
public float smokeScale = 1f; // 연기(go_invisibleEffect) 크기 배율
public int keepHitBox = 1; // 1 = 베기 판정 상자 월드 크기 유지(보이는 크기만 바꿈)
// #954 PD 「비수 주력 = Slash Effects FREE 의 녹색 난사(Multiple Slash 2)로」 — 원본 베기 렌더러를 끄고(판정 상자 그대로) WL 사본을 같은 자리·시각에 띄운다
public string fxPrefab = ""; // Resources 경로(비우면 원본 베기 그대로)
public float fxScale = 1f, fxDelay = 0.5f, fxForward = 1.5f, fxYOffset = 0f, fxLife = 2.5f; // 배율 · 시전 뒤 시각(원본 go_effect 0.5 s) · PC 정면 거리(원본 1.5) · 높이 · 수명
public int hideOriginalFx = 0; // 1 = 원본 베기(go_effect) 렌더러 끔
// #955 PD 「주력 쓸 때 왜 캐릭터가 공중에 뜬 채 행동하나 — 제자리에서 단검을 난사하도록」 — 원본 상태 사슬(DualBlades_Jump_Start → JumpAttack1·2·3 = 공중 자세)을 쓰지 않고 slashSeconds 동안 slashStates 를 slashInterval 마다 번갈아 재생(배속 slashSpeed · 애니메이션 이벤트 끔 = 추가 타격·궤적 없음)
public string[] slashStates = new string[0];
public float slashInterval = 0.28f, slashSpeed = 1.8f, slashSeconds = 2.0f;
// #956 PD 「주력 이펙트가 너무 먼 위치에 재생 → 플레이어가 휘두르는 무기 위치에 맞게(휘두르는 느낌)」 — fxMain 0 = 정면 1회 사본(fxForward)을 안 띄움 ·
// fxPerSlash 1 = 난사 루프(slashInterval)마다 fxPrefab 의 작은 사본(fxSlashScale)을 무기가 달린 소켓(PCActor.tfs_weapon · 비수 = 양손 · 번갈아)의 손잡이→날 끝 fxSlashFraction 자리 + 위 fxSlashYOffset 에
// 띄우고 fxSlashLife 동안 날 방향(수평 성분)·위치를 매 프레임 따라가게(fxSlashFollow 1 · 0 = 처음 자리 고정)
public int fxMain = 1, fxPerSlash = 0, fxSlashFollow = 1;
public float fxSlashScale = 0.35f, fxSlashLife = 0.6f, fxSlashFraction = 0.6f, fxSlashYOffset = 0f;
}
public static class WLShadowSlash
{
public const string ResourcesPath = "WL/WLShadowSlash";
public static int Casts, Shown, Scaled, Errors;
public static string LastLog = "";
static WLShadowSlashJson s_cfg;
static MethodInfo s_model, s_weapon;
static WLShadowSlashJson Cfg
{
get
{
if (s_cfg != null) return s_cfg;
s_cfg = new WLShadowSlashJson();
var ta = Resources.Load<TextAsset>(ResourcesPath);
if (ta == null) { LastLog = "설정 없음 " + ResourcesPath; return s_cfg; }
try { JsonUtility.FromJsonOverwrite(ta.text, s_cfg); } catch (System.Exception ex) { LastLog = "설정 파싱 실패 — " + ex.Message; }
return s_cfg;
}
}
[RuntimeInitializeOnLoadMethod(RuntimeInitializeLoadType.AfterSceneLoad)]
static void Boot() { s_cfg = null; Casts = Shown = Scaled = Errors = 0; LastLog = ""; }
public static string Describe() { return "casts " + Casts + " · shown " + Shown + " · scaled " + Scaled + " · err " + Errors + " · " + LastLog; }
/// <summary>원본 Skill_ShadowSlash.Set 훅(Set_Invisible 바로 뒤 1줄) — 캐릭터 보이기 · 베기 · 연기 크기 배율.</summary>
public static void OnSet(Skill_ShadowSlash skill)
{
try
{
var c = Cfg;
if (skill == null || c.enabled == 0) return;
Casts++;
var pd = skill.Get_ProjectTileData();
var shooter = pd != null ? pd.shooter : null;
if (c.keepVisible != 0 && !DSUtil.CheckNull(shooter))
{
bool model = Call(ref s_model, "Model_OnOff", shooter);
bool weapon = Call(ref s_weapon, "Weapon_OnOff", shooter);
if (model && weapon) Shown++;
if (c.freezeAnim == 0 && shooter.m_animation != null) shooter.m_animation.speed = 1f;
}
if (skill.go_effect != null) Scale(skill.go_effect.transform, c.slashScale, c.keepHitBox != 0);
if (skill.go_invisibleEffect != null) Scale(skill.go_invisibleEffect.transform, c.smokeScale, false);
if (!string.IsNullOrEmpty(c.fxPrefab) && !DSUtil.CheckNull(shooter)) // #954
{
if (c.hideOriginalFx != 0 && skill.go_effect != null) foreach (var rd in skill.go_effect.GetComponentsInChildren<Renderer>(true)) rd.enabled = false; // 풀 인스턴스라 한 번 끄면 유지
if (c.fxMain != 0) Runner.StartCoroutine(CoFx(shooter, c)); // #956 fxMain 0 = 정면 사본 없이 난사 사본(fxPerSlash)만
}
if (c.slashStates != null && c.slashStates.Length > 0 && !DSUtil.CheckNull(shooter) && shooter.m_animation != null) Runner.StartCoroutine(CoSlash(shooter, c)); // #955
LastLog = "cast " + Casts + " · shown " + Shown + " · scaled " + Scaled;
}
catch (System.Exception ex) { Errors++; LastLog = "실패 — " + ex.Message; }
}
public static int FxShown;
static WLShadowSlashRunner s_runner;
static WLShadowSlashRunner Runner { get { if (s_runner == null) { var go = new GameObject("[WL954] ShadowSlashFx"); go.hideFlags = HideFlags.HideAndDontSave; Object.DontDestroyOnLoad(go); s_runner = go.AddComponent<WLShadowSlashRunner>(); } return s_runner; } }
/// <summary>#954 원본 베기가 켜지는 시각(fxDelay)에 WL 사본(녹색 난사)을 PC 정면 fxForward 에 띄운다(PC 방향 · fxLife 뒤 파괴).</summary>
static System.Collections.IEnumerator CoFx(Actor shooter, WLShadowSlashJson c)
{
if (c.fxDelay > 0f) yield return new WaitForSeconds(c.fxDelay);
if (DSUtil.CheckNull(shooter) || shooter.IsDead()) yield break;
var p = Resources.Load<GameObject>(c.fxPrefab); if (p == null) { LastLog = "fx 프리팹 없음 " + c.fxPrefab; yield break; }
var fwd = shooter.transform.forward; fwd.y = 0f; if (fwd.sqrMagnitude < 1e-4f) fwd = Vector3.forward; fwd.Normalize();
var pos = shooter.Get_CenterPositionFoward(c.fxForward) + Vector3.up * c.fxYOffset;
var go = Object.Instantiate(p, pos, Quaternion.LookRotation(fwd, Vector3.up)); go.transform.localScale = p.transform.localScale * c.fxScale;
foreach (var ps in go.GetComponentsInChildren<ParticleSystem>(true)) { var m = ps.main; m.scalingMode = ParticleSystemScalingMode.Hierarchy; ps.Play(true); }
Object.Destroy(go, Mathf.Max(0.5f, c.fxLife)); FxShown++; LastLog = "fx " + c.fxPrefab + " @" + pos.ToString("F2") + " ×" + c.fxScale;
}
public static int SlashLoops; public static string SlashLog = "";
/// <summary>#955 제자리 난사 — slashSeconds 동안 slashStates 를 번갈아 CrossFade(이벤트 끔 · 배속) · 스킬 상태를 벗어나면 중단 · 끝에 이벤트·배속 원상.</summary>
static System.Collections.IEnumerator CoSlash(Actor shooter, WLShadowSlashJson c)
{
var an = shooter.m_animation; if (an == null) yield break;
bool prevEvents = an.fireEvents; an.fireEvents = false; float t0 = Time.time; int i = 0;
try
{
while (Time.time - t0 < c.slashSeconds)
{
if (DSUtil.CheckNull(shooter) || shooter.IsDead()) break; // CurAnim 조건은 뺀다(실측: 3회 만에 끊겨 원본 콤보 전이 Attack3→4(회전)·Jump 사슬이 이어졌다) · 0.28 s 마다 다시 시작하므로 상태 exit 전이에 안 걸린다
string st = c.slashStates[i % c.slashStates.Length]; int h = Animator.StringToHash(st);
if (an.HasState(0, h)) { an.CrossFadeInFixedTime(h, 0.05f, 0, 0f); an.speed = Mathf.Max(0.1f, c.slashSpeed); SlashLoops++; }
else SlashLog = "상태 없음 " + st;
if (c.fxPerSlash != 0 && !string.IsNullOrEmpty(c.fxPrefab)) SlashFx(shooter, c, i); // #956 휘두르는 손의 날 자리에 작은 사본
i++;
yield return new WaitForSeconds(Mathf.Max(0.05f, c.slashInterval));
}
}
finally { if (!DSUtil.CheckNull(shooter) && shooter.m_animation != null) { shooter.m_animation.fireEvents = prevEvents; shooter.m_animation.speed = 1f; } }
SlashLog = "난사 " + i + "회 · " + (Time.time - t0).ToString("F2") + " s";
}
public static int FxSlashes; public static string FxSlashLog = "";
static readonly System.Collections.Generic.List<Transform> s_sock = new System.Collections.Generic.List<Transform>(2);
static readonly System.Collections.Generic.List<Vector3> s_tip = new System.Collections.Generic.List<Vector3>(2);
/// <summary>#956 난사 1회마다 fxPrefab 의 작은 사본을 무기 날 자리에 — 소켓은 무기 메시가 달린 것만(비수 = w_bladedancer_l · _r · WLImpactRunner.SocketTipOf) · i 번째 난사는 소켓을 번갈아 · 추종은 WLShadowSlashFxFollow.</summary>
static void SlashFx(Actor shooter, WLShadowSlashJson c, int i)
{
var pc = shooter as PCActor; if (pc == null || pc.tfs_weapon == null) { FxSlashLog = "PC 아님"; return; }
if (i == 0 || s_sock.Count == 0)
{
s_sock.Clear(); s_tip.Clear();
foreach (var s in pc.tfs_weapon) { Vector3 tl; if (WL.Combat.Impact.WLImpactRunner.SocketTipOf(s, out tl) > 0.05f) { s_sock.Add(s); s_tip.Add(tl); } }
if (s_sock.Count == 0) { FxSlashLog = "무기 소켓 없음"; return; }
}
int k = i % s_sock.Count; var sock = s_sock[k]; if (sock == null) return;
var p = Resources.Load<GameObject>(c.fxPrefab); if (p == null) { FxSlashLog = "fx 프리팹 없음 " + c.fxPrefab; return; }
var go = Object.Instantiate(p); go.transform.localScale = p.transform.localScale * c.fxSlashScale;
var f = go.AddComponent<WLShadowSlashFxFollow>(); f.Set(pc, sock, s_tip[k], c.fxSlashFraction, c.fxSlashYOffset, c.fxSlashFollow != 0); f.Apply();
foreach (var ps in go.GetComponentsInChildren<ParticleSystem>(true)) { var m = ps.main; m.scalingMode = ParticleSystemScalingMode.Hierarchy; ps.Play(true); }
Object.Destroy(go, Mathf.Max(0.1f, c.fxSlashLife)); FxSlashes++;
FxSlashLog = "난사 fx " + FxSlashes + " · " + sock.name + " 날 " + s_tip[k].magnitude.ToString("F2") + " m · @" + go.transform.position.ToString("F2") + " ×" + c.fxSlashScale;
}
/// <summary>Actor 의 protected 켜기 함수(Model_OnOff · Weapon_OnOff(bool))를 true 로 부른다 — 원본 Co_Invisible 끝과 같은 호출(가상 함수는 PCActor 쪽이 불린다).</summary>
static bool Call(ref MethodInfo m, string name, Actor a)
{
if (m == null) m = typeof(Actor).GetMethod(name, BindingFlags.Instance | BindingFlags.NonPublic | BindingFlags.Public, null, new[] { typeof(bool) }, null);
if (m == null) { LastLog = "함수 없음 " + name; return false; }
m.Invoke(a, new object[] { true });
return true;
}
/// <summary>Transform 을 처음 크기 × k 로 · 파티클 크기 모드 Hierarchy · 판정 상자는 keepBox 면 1/k 로(월드 크기 유지).</summary>
static void Scale(Transform t, float k, bool keepBox)
{
if (t == null || k <= 0f) return;
var b = t.GetComponent<WLShadowSlashBase>();
if (b == null)
{
if (Mathf.Approximately(k, 1f)) return; // 손댄 적 없고 배율 1 = 원본 그대로
b = t.gameObject.AddComponent<WLShadowSlashBase>();
b.scale = t.localScale;
var bc0 = t.GetComponent<BoxCollider>();
if (bc0 != null) { b.hasBox = true; b.size = bc0.size; b.center = bc0.center; }
foreach (var ps in t.GetComponentsInChildren<ParticleSystem>(true)) { var m = ps.main; m.scalingMode = ParticleSystemScalingMode.Hierarchy; }
}
t.localScale = b.scale * k;
if (b.hasBox)
{
var bc = t.GetComponent<BoxCollider>();
if (bc != null) { float inv = keepBox ? 1f / k : 1f; bc.size = b.size * inv; bc.center = b.center * inv; }
}
Scaled++;
}
}
/// <summary>#943-⑧ 이 Transform(원본 스킬 풀 인스턴스)의 처음 크기 · 판정 상자 — 배율 누적 방지.</summary>
public sealed class WLShadowSlashBase : MonoBehaviour { public Vector3 scale, size, center; public bool hasBox; }
/// <summary>#954 코루틴 숙주(씬 밖 · 숨김).</summary>
public sealed class WLShadowSlashRunner : MonoBehaviour { }
/// <summary>#956 난사 사본 추종 — 자리 = 소켓(손잡이) → 날 끝 사이 fraction + 위 yOffset · 방향 = 날 방향의 수평 성분(없으면 PC 정면) · follow 면 매 프레임(LateUpdate · 애니메이션 평가 뒤) 다시 맞춘다.</summary>
public sealed class WLShadowSlashFxFollow : MonoBehaviour
{
Actor _pc; Transform _sock; Vector3 _tip; float _frac, _yOff; bool _follow;
public void Set(Actor pc, Transform sock, Vector3 tipLocal, float frac, float yOff, bool follow) { _pc = pc; _sock = sock; _tip = tipLocal; _frac = frac; _yOff = yOff; _follow = follow; }
public void Apply()
{
if (_sock == null) { _follow = false; return; }
Vector3 hilt = _sock.position, tip = _sock.TransformPoint(_tip), dir = tip - hilt; dir.y = 0f;
if (dir.sqrMagnitude < 1e-4f) { dir = _pc != null ? _pc.transform.forward : Vector3.forward; dir.y = 0f; if (dir.sqrMagnitude < 1e-4f) dir = Vector3.forward; }
transform.position = Vector3.Lerp(hilt, tip, _frac) + Vector3.up * _yOff;
transform.rotation = Quaternion.LookRotation(dir.normalized, Vector3.up);
}
void LateUpdate() { if (_follow) Apply(); }
}
}