420 lines
19 KiB
C#
420 lines
19 KiB
C#
using UnityEngine;
|
|
using WL.Combat;
|
|
|
|
namespace WL.Player
|
|
{
|
|
/// <summary>
|
|
/// 검 궤적 슬래시 VFX 재생기 (PD #712 ②).
|
|
///
|
|
/// 콤보 타격 순간마다 슬래시 프리팹을 **검 소켓 위치·방향**으로 1회 재생한다.
|
|
/// 매번 Instantiate 하지 않고 미리 만든 것을 돌려 쓴다(오브젝트 풀 · 기본 3개).
|
|
///
|
|
/// 소켓 규약 — 캐릭터가 바뀌어도 같은 방식으로 찾는다:
|
|
/// ① CombatSettings.slashSocketNames 의 이름을 계층에서 위에서부터 찾는다 (Kazuko = `Kazuko_claymore`)
|
|
/// ② 못 찾으면 Humanoid 오른손 본 (`HumanBodyBones.RightHand`) — 두 캐릭터 모두 Humanoid 라 항상 잡힌다
|
|
///
|
|
/// 모든 수치는 CombatSettings(ScriptableObject)에서 읽는다(C45).
|
|
/// </summary>
|
|
public class SlashVfxPlayer : MonoBehaviour
|
|
{
|
|
[Header("설정 (비우면 PlayerCombat 것을 쓴다)")]
|
|
[SerializeField] private CombatSettings settings;
|
|
[Tooltip("검 소켓을 직접 지정하고 싶을 때. 비우면 이름 후보 → 오른손 본 순으로 찾는다")]
|
|
[SerializeField] private Transform socketOverride;
|
|
|
|
private Transform _socket;
|
|
private Vector3 _bladeLocalDir = Vector3.forward; // 소켓 기준 검날 방향 (실측)
|
|
private float _bladeLength = 1f; // 검날 길이(m) (실측)
|
|
|
|
// ── PD #716 ④ 실제 궤적 추적
|
|
private Vector3 _tipPrev; // 직전 프레임 검끝 월드 위치
|
|
private bool _tipInit;
|
|
private Vector3 _tipVelocity; // 검끝 속도(월드 · m/s)
|
|
private Vector3 _lastNormal = Vector3.right; // 마지막 스윙 평면 법선
|
|
private float _meshTopExtent = 1f; // 슬래시 메시가 +Y 로 뻗은 길이(파티클 크기 반영)
|
|
|
|
private GameObject[] _pool;
|
|
private float[] _returnAt;
|
|
private int _next;
|
|
private Animator _animator;
|
|
|
|
/// <summary>실제로 쓰고 있는 소켓. 검증용.</summary>
|
|
public Transform Socket { get { return _socket; } }
|
|
/// <summary>마지막으로 재생한 콤보 단계. 검증용.</summary>
|
|
public int LastPlayedStep { get; private set; }
|
|
/// <summary>지금까지 재생한 횟수. 검증용.</summary>
|
|
public int PlayCount { get; private set; }
|
|
/// <summary>검끝 월드 위치(현재 프레임). 검증용.</summary>
|
|
public Vector3 TipPosition { get { return _socket != null ? _socket.TransformPoint(_bladeLocalDir * _bladeLength) : Vector3.zero; } }
|
|
/// <summary>검 손잡이(소켓 원점) 월드 위치. 검증용.</summary>
|
|
public Vector3 HiltPosition { get { return _socket != null ? _socket.position : Vector3.zero; } }
|
|
/// <summary>검끝 속도(m/s). 검증용.</summary>
|
|
public Vector3 TipVelocity { get { return _tipVelocity; } }
|
|
/// <summary>마지막 재생에서 쓴 스윙 평면 법선. 검증용.</summary>
|
|
public Vector3 LastSwingNormal { get { return _lastNormal; } }
|
|
/// <summary>마지막으로 재생한 슬래시 오브젝트. 검증용.</summary>
|
|
public Transform LastSpawned { get; private set; }
|
|
|
|
private CombatSettings Config
|
|
{
|
|
get
|
|
{
|
|
if (settings != null) return settings;
|
|
var pc = GetComponent<PlayerCombat>();
|
|
return pc != null ? pc.Settings : null;
|
|
}
|
|
}
|
|
|
|
private void Awake()
|
|
{
|
|
_animator = GetComponentInChildren<Animator>();
|
|
ResolveSocket();
|
|
}
|
|
|
|
private void OnEnable() { ResolveSocket(); }
|
|
|
|
/// <summary>소켓을 찾는다. 이름 후보 → Humanoid 오른손 본 순.</summary>
|
|
public void ResolveSocket()
|
|
{
|
|
if (socketOverride != null) { _socket = socketOverride; MeasureBlade(); return; }
|
|
|
|
var s = Config;
|
|
if (s != null && s.slashSocketNames != null)
|
|
{
|
|
for (int i = 0; i < s.slashSocketNames.Length; i++)
|
|
{
|
|
var found = FindDeep(transform, s.slashSocketNames[i]);
|
|
if (found != null) { _socket = found; MeasureBlade(); return; } // PD #715 ⑤ — 이름으로 찾은 경우에도 검날을 실측한다
|
|
}
|
|
}
|
|
|
|
if (_animator == null) _animator = GetComponentInChildren<Animator>();
|
|
if (_animator != null && _animator.avatar != null && _animator.avatar.isHuman)
|
|
_socket = _animator.GetBoneTransform(HumanBodyBones.RightHand);
|
|
|
|
if (_socket == null) _socket = transform;
|
|
MeasureBlade();
|
|
}
|
|
|
|
/// <summary>
|
|
/// 소켓(검) 메시의 로컬 바운즈에서 **검날 방향과 길이를 실측**한다 (PD #715 ⑤).
|
|
/// 캐릭터마다 검의 로컬 축이 다르다 — Kazuko 클레이모어는 +Z(1.27m), P09 한손검은 -Y(0.73m).
|
|
/// 고정 오프셋을 쓰면 한쪽에서 이펙트가 허공에 뜬다. 그래서 축을 재서 프레임을 만든다.
|
|
/// </summary>
|
|
private void MeasureBlade()
|
|
{
|
|
_bladeLocalDir = Vector3.forward;
|
|
_bladeLength = 1f;
|
|
if (_socket == null) return;
|
|
|
|
var r = _socket.GetComponent<Renderer>();
|
|
if (r == null) r = _socket.GetComponentInChildren<Renderer>();
|
|
Mesh mesh = null;
|
|
var smr = r as SkinnedMeshRenderer;
|
|
if (smr != null) mesh = smr.sharedMesh;
|
|
else { var mf = r != null ? r.GetComponent<MeshFilter>() : null; if (mf != null) mesh = mf.sharedMesh; }
|
|
if (mesh == null) return;
|
|
|
|
var b = mesh.bounds;
|
|
int axis = (b.size.x >= b.size.y && b.size.x >= b.size.z) ? 0 : (b.size.y >= b.size.z ? 1 : 2);
|
|
float sign = b.center[axis] < 0f ? -1f : 1f; // 바운즈 중심이 있는 쪽이 칼날 쪽
|
|
Vector3 dir = Vector3.zero; dir[axis] = sign;
|
|
_bladeLength = b.size[axis];
|
|
|
|
// 검 메시가 소켓의 자식이면 그 로컬 회전까지 반영해 소켓 기준으로 바꾼다
|
|
if (r != null && r.transform != _socket)
|
|
dir = _socket.InverseTransformDirection(r.transform.TransformDirection(dir));
|
|
_bladeLocalDir = dir.normalized;
|
|
}
|
|
|
|
/// <summary>소켓 기준 검날 방향(로컬). 검증용.</summary>
|
|
public Vector3 BladeLocalDir { get { return _bladeLocalDir; } }
|
|
/// <summary>실측한 검날 길이(m). 검증용.</summary>
|
|
public float BladeLength { get { return _bladeLength; } }
|
|
|
|
private static Transform FindDeep(Transform root, string name)
|
|
{
|
|
if (string.IsNullOrEmpty(name)) return null;
|
|
if (root.name == name) return root;
|
|
for (int i = 0; i < root.childCount; i++)
|
|
{
|
|
var r = FindDeep(root.GetChild(i), name);
|
|
if (r != null) return r;
|
|
}
|
|
return null;
|
|
}
|
|
|
|
private void EnsurePool()
|
|
{
|
|
var s = Config;
|
|
if (s == null || s.slashVfxPrefab == null) return;
|
|
int size = Mathf.Max(s.slashVfxPoolSize, 1);
|
|
if (_pool != null && _pool.Length == size) return;
|
|
|
|
_pool = new GameObject[size];
|
|
_returnAt = new float[size];
|
|
for (int i = 0; i < size; i++)
|
|
{
|
|
var go = Instantiate(s.slashVfxPrefab);
|
|
go.name = s.slashVfxPrefab.name + "_pool" + i;
|
|
// PD #718 ② — 파티클을 월드 공간으로 돌린다. Local 이면 파티클까지 검을 따라 끌려다닌다.
|
|
foreach (var ps in go.GetComponentsInChildren<ParticleSystem>(true))
|
|
{
|
|
var main = ps.main;
|
|
main.simulationSpace = ParticleSystemSimulationSpace.World;
|
|
}
|
|
go.transform.SetParent(null, false);
|
|
go.SetActive(false);
|
|
_pool[i] = go;
|
|
_returnAt[i] = 0f;
|
|
}
|
|
MeasureMeshExtent(_pool[0]);
|
|
}
|
|
|
|
/// <summary>
|
|
/// 슬래시 메시가 로컬 +Y 로 뻗은 길이를 잰다(파티클 시작 크기 반영).
|
|
/// 이 값으로 검날 길이에 맞춰 스케일을 잡는다 — 캐릭터마다 검 길이가 달라도 호가 칼끝을 덮는다.
|
|
/// </summary>
|
|
private void MeasureMeshExtent(GameObject sample)
|
|
{
|
|
_meshTopExtent = 1f;
|
|
if (sample == null) return;
|
|
float best = 0f;
|
|
foreach (var ps in sample.GetComponentsInChildren<ParticleSystem>(true))
|
|
{
|
|
var psr = ps.GetComponent<ParticleSystemRenderer>();
|
|
if (psr == null || psr.renderMode != ParticleSystemRenderMode.Mesh || psr.mesh == null) continue;
|
|
float top = psr.mesh.bounds.max.y * ps.main.startSize.constantMax;
|
|
if (top > best) best = top;
|
|
}
|
|
if (best > 0.0001f) _meshTopExtent = best;
|
|
}
|
|
|
|
/// <summary>
|
|
/// 애니메이션이 적용된 뒤(LateUpdate) 검끝 위치를 샘플링해 **실제 궤적 속도**를 얻는다 (PD #716 ④).
|
|
/// 이 속도가 스윙 평면을 정한다 — 검날 방향과 검끝 진행 방향이 만드는 평면이 곧 궤적의 평면이다.
|
|
/// </summary>
|
|
private void LateUpdate()
|
|
{
|
|
if (_socket == null) return;
|
|
Vector3 tip = _socket.TransformPoint(_bladeLocalDir * _bladeLength);
|
|
float dt = Time.deltaTime;
|
|
if (_tipInit && dt > 0.00001f) _tipVelocity = (tip - _tipPrev) / dt;
|
|
_tipPrev = tip;
|
|
_tipInit = true;
|
|
|
|
// PD #718 — 스윙 창 동안 검 두 점을 모은다(트레일 · 궤적 실측 공용)
|
|
if (_swingActive)
|
|
{
|
|
_swingTimer -= dt;
|
|
if (_trail != null) _trail.Sample(_socket.position, tip);
|
|
if (_arcCount < _arcTip.Length) { _arcTip[_arcCount] = tip; _arcHilt[_arcCount] = _socket.position; _arcCount++; }
|
|
if (_swingTimer <= 0f) EndSwing();
|
|
}
|
|
}
|
|
|
|
// ── PD #718 스윙 창 관리
|
|
private BladeTrail _trail;
|
|
private bool _swingActive;
|
|
private float _swingTimer;
|
|
private int _swingStep;
|
|
private float _pendingWindow = 0.3f;
|
|
private readonly Vector3[] _arcTip = new Vector3[64];
|
|
private readonly Vector3[] _arcHilt = new Vector3[64];
|
|
private int _arcCount;
|
|
|
|
/// <summary>이번 스윙에서 모은 검끝 궤적 점 수. 검증용.</summary>
|
|
public int ArcSampleCount { get { return _arcCount; } }
|
|
/// <summary>스윙 창이 열려 있는가. 검증용.</summary>
|
|
public bool IsSwinging { get { return _swingActive; } }
|
|
/// <summary>트레일 컴포넌트. 검증용.</summary>
|
|
public BladeTrail Trail { get { return _trail; } }
|
|
/// <summary>이번 스윙의 검끝 궤적 점(읽기용 · 개수는 ArcSampleCount).</summary>
|
|
public Vector3 GetArcTip(int i) { return (i >= 0 && i < _arcCount) ? _arcTip[i] : Vector3.zero; }
|
|
|
|
/// <summary>
|
|
/// 공격 클립 재생 시작 시 호출 (PD #718 (2)) — 이펙트를 **스윙 시작 시점**에 연다.
|
|
/// 히트 시점에 한 프레임 속도로 평면을 추정하던 방식은 검끝이 33ms 에 2.56m 까지 움직여
|
|
/// 실제 호와 어긋났다(PM 실측). 이제는 창이 열린 동안 궤적을 모아서 쓴다.
|
|
/// </summary>
|
|
public void OnAttackStarted(int comboStep, float clipLength)
|
|
{
|
|
var s = Config;
|
|
if (s == null) return;
|
|
if (_socket == null) ResolveSocket();
|
|
|
|
float startN = Mathf.Clamp01(s.slashSwingStartNormalized);
|
|
float endN = Mathf.Clamp01(s.slashSwingEndNormalized);
|
|
if (endN <= startN) endN = Mathf.Min(1f, startN + 0.3f);
|
|
_swingStep = comboStep;
|
|
_pendingWindow = Mathf.Max(clipLength * (endN - startN), 0.05f);
|
|
CancelInvoke("BeginSwing");
|
|
Invoke("BeginSwing", Mathf.Max(clipLength * startN, 0f));
|
|
}
|
|
|
|
private void BeginSwing()
|
|
{
|
|
var s = Config;
|
|
if (s == null) return;
|
|
_swingActive = true;
|
|
_swingTimer = _pendingWindow;
|
|
_arcCount = 0;
|
|
|
|
if (s.slashMode == SlashMode.BladeTrail)
|
|
{
|
|
if (_trail == null) _trail = GetComponent<BladeTrail>();
|
|
if (_trail == null) _trail = gameObject.AddComponent<BladeTrail>();
|
|
_trail.Begin();
|
|
}
|
|
}
|
|
|
|
private void EndSwing()
|
|
{
|
|
_swingActive = false;
|
|
var s = Config;
|
|
if (_trail != null) _trail.End();
|
|
// 프리팹 방식은 **모아 둔 실제 궤적**으로 호를 잡아 스윙이 끝나는 순간 1회 배치한다.
|
|
if (s != null && s.slashMode == SlashMode.PrefabArc && _arcCount >= 3) PlayArcFromSamples(_swingStep);
|
|
// 트레일 방식은 원본의 fx·dust 만 검끝 자리에 터뜨려 "튀는" 느낌을 유지한다 (PD #718 ④)
|
|
else if (s != null && s.slashMode == SlashMode.BladeTrail && s.trailSpawnBurst && _arcCount >= 1)
|
|
PlayBurstAtTip(_arcTip[_arcCount - 1]);
|
|
}
|
|
|
|
/// <summary>
|
|
/// 원본 프리팹의 **fx·dust 서브시스템만** 검끝 위치에 월드 스폰한다 (PD #718 ④).
|
|
/// 크레센트(slash_add·slash_alp)는 트레일이 대신 그리므로 꺼 둔다.
|
|
/// </summary>
|
|
private void PlayBurstAtTip(Vector3 tipPos)
|
|
{
|
|
var s = Config;
|
|
if (s == null || s.slashVfxPrefab == null) return;
|
|
EnsurePool();
|
|
if (_pool == null || _pool.Length == 0) return;
|
|
|
|
var go = _pool[_next];
|
|
_next = (_next + 1) % _pool.Length;
|
|
if (go == null) return;
|
|
|
|
StopAndClear(go);
|
|
var t = go.transform;
|
|
t.SetParent(null, false);
|
|
t.position = tipPos;
|
|
t.rotation = Quaternion.identity;
|
|
t.localScale = Vector3.one * Mathf.Max(s.slashScale, 0.0001f);
|
|
|
|
go.SetActive(true);
|
|
foreach (var ps in go.GetComponentsInChildren<ParticleSystem>(true))
|
|
{
|
|
bool crescent = ps.gameObject.name == "slash_add" || ps.gameObject.name == "slash_alp";
|
|
var psr = ps.GetComponent<ParticleSystemRenderer>();
|
|
if (psr != null) psr.enabled = !crescent; // 크레센트는 트레일이 그린다
|
|
if (!crescent) ps.Play(true);
|
|
}
|
|
|
|
_returnAt[(_next - 1 + _pool.Length) % _pool.Length] = Time.time + Mathf.Max(s.slashAutoReturnSeconds, 0.1f);
|
|
LastSpawned = t;
|
|
PlayCount++;
|
|
}
|
|
|
|
/// <summary>스윙을 즉시 접는다(콤보 취소·피격 등).</summary>
|
|
public void CancelSwing()
|
|
{
|
|
CancelInvoke("BeginSwing");
|
|
_swingActive = false;
|
|
_arcCount = 0;
|
|
if (_trail != null) _trail.Clear();
|
|
}
|
|
|
|
/// <summary>
|
|
/// 모아 둔 검끝 궤적으로 호의 중심·평면 법선·시작→끝 방향을 구해 프리팹을 월드에 1회 배치한다.
|
|
/// (부모 없음 · 파티클도 월드 공간 — 검을 따라 끌려다니지 않는다)
|
|
/// </summary>
|
|
private void PlayArcFromSamples(int comboStep)
|
|
{
|
|
var s = Config;
|
|
if (s == null || s.slashVfxPrefab == null) return;
|
|
EnsurePool();
|
|
if (_pool == null || _pool.Length == 0) return;
|
|
|
|
Vector3 first = _arcTip[0], last = _arcTip[_arcCount - 1];
|
|
Vector3 center = Vector3.zero;
|
|
for (int i = 0; i < _arcCount; i++) center += _arcTip[i];
|
|
center /= _arcCount;
|
|
|
|
// 평면 법선 — 궤적 점 **전체**로 최적 평면을 구한다(Newell 법).
|
|
// 세 점(시작·중간·끝)만 쓰면 스윙이 조금만 휘어도 평면이 크게 틀어진다(실측 0.65~0.86m 이탈).
|
|
Vector3 mid = _arcTip[_arcCount / 2];
|
|
Vector3 n = Vector3.zero;
|
|
for (int i = 0; i < _arcCount; i++)
|
|
{
|
|
Vector3 a = _arcTip[i] - center;
|
|
Vector3 b = _arcTip[(i + 1) % _arcCount] - center;
|
|
n += Vector3.Cross(a, b);
|
|
}
|
|
if (n.sqrMagnitude < 0.000001f) n = Vector3.Cross(mid - first, last - mid);
|
|
if (n.sqrMagnitude < 0.000001f) n = Vector3.Cross(last - first, Vector3.up);
|
|
if (n.sqrMagnitude < 0.000001f) n = transform.right;
|
|
n.Normalize();
|
|
|
|
Vector3 hiltMid = _arcHilt[_arcCount / 2];
|
|
Vector3 outward = (mid - hiltMid);
|
|
if (outward.sqrMagnitude < 0.000001f) outward = transform.forward;
|
|
outward.Normalize();
|
|
|
|
var go = _pool[_next];
|
|
_next = (_next + 1) % _pool.Length;
|
|
if (go == null) return;
|
|
|
|
StopAndClear(go);
|
|
var t = go.transform;
|
|
t.SetParent(null, false); // 월드 고정
|
|
t.rotation = Quaternion.LookRotation(n, outward); // Z=평면 법선 · Y=칼끝 방향
|
|
// 손 근처를 기준으로 삼되 **최적 평면 위로 투영**한다 — 그래야 호가 궤적 평면에 정확히 눕는다
|
|
hiltMid -= n * Vector3.Dot(hiltMid - center, n);
|
|
t.position = hiltMid;
|
|
float radius = Vector3.Distance(hiltMid, mid);
|
|
float scale = Mathf.Max(s.slashScale, 0.0001f);
|
|
if (s.slashFitToBlade && _meshTopExtent > 0.0001f) scale *= radius / _meshTopExtent;
|
|
t.localScale = Vector3.one * scale;
|
|
|
|
go.SetActive(true);
|
|
var systems = go.GetComponentsInChildren<ParticleSystem>(true);
|
|
for (int i = 0; i < systems.Length; i++) systems[i].Play(true);
|
|
|
|
_returnAt[(_next - 1 + _pool.Length) % _pool.Length] = Time.time + Mathf.Max(s.slashAutoReturnSeconds, 0.1f);
|
|
LastPlayedStep = comboStep;
|
|
LastSpawned = t;
|
|
_lastNormal = n;
|
|
PlayCount++;
|
|
}
|
|
|
|
/// <summary>풀 인스턴스를 재사용하기 전에 반드시 멈추고 비운다 (PD #718 (3) — 잔상 방지).</summary>
|
|
private static void StopAndClear(GameObject go)
|
|
{
|
|
var systems = go.GetComponentsInChildren<ParticleSystem>(true);
|
|
for (int i = 0; i < systems.Length; i++)
|
|
{
|
|
systems[i].Stop(true, ParticleSystemStopBehavior.StopEmittingAndClear);
|
|
systems[i].Clear(true);
|
|
}
|
|
go.SetActive(false);
|
|
}
|
|
|
|
private void Update()
|
|
{
|
|
if (_pool == null) return;
|
|
float now = Time.time;
|
|
for (int i = 0; i < _pool.Length; i++)
|
|
{
|
|
if (_pool[i] == null || !_pool[i].activeSelf) continue;
|
|
if (_returnAt[i] > 0f && now >= _returnAt[i])
|
|
{
|
|
StopAndClear(_pool[i]); // PD #718 (3) — 멈추고 비운 뒤 비활성 (잔상 방지)
|
|
_returnAt[i] = 0f;
|
|
}
|
|
}
|
|
}
|
|
|
|
}
|
|
}
|