feat: VFX 인프라 구축 및 Ground Target 시스템 구현

- VfxEffect 스킬 이펙트 클래스 추가 (일회성 VFX 스폰, 위치/스케일/파티클 제어)
- SkillEffect.IsVisualOnly 프로퍼티 추가로 서버 가드 없이 모든 클라이언트에서 VFX 로컬 실행
- SkillProjectile 트레일 VFX 지원 (OnNetworkSpawn에서 양쪽 생성, despawn 시 월드 분리)
- SkillProjectile HitEffectClientRpc 추가로 충돌 이펙트 클라이언트 동기화
- Ground Target 시스템: 타겟팅 모드 상태머신, 인디케이터, 지면 위치 RPC 전달
- 마법 오름 Ground Target 스킬 에셋 및 VfxEffect 에셋 추가
- 마법 오름 애니메이션 클립 추가
- Ground layer (Layer 7) 추가
- ProjectileBasic에 trailPrefab/hitEffect 필드 추가
- Prefabs/VFX/ 폴더 생성
This commit is contained in:
2026-04-02 22:25:19 +09:00
parent 57ab230c61
commit 188b134062
34 changed files with 42905 additions and 102 deletions

View File

@@ -97,6 +97,8 @@ namespace Colosseum.Player
[SerializeField] private LayerMask groundTargetLayers;
[Tooltip("지면 타겟팅 최대 사거리")]
[Min(1f)] [SerializeField] private float groundTargetMaxRange = 20f;
[Tooltip("지면 타겟 인디케이터 (없으면 자동 생성)")]
[SerializeField] private GroundTargetIndicator groundTargetIndicator;
private InputSystem_Actions inputActions;
private bool gameplayInputEnabled = true;
@@ -458,7 +460,7 @@ namespace Colosseum.Player
/// 서버에 스킬 실행 요청
/// </summary>
[Rpc(SendTo.Server)]
private void RequestSkillExecutionRpc(int slotIndex, ulong targetNetworkObjectId = 0, Vector3 groundTargetPosition = default)
private void RequestSkillExecutionRpc(int slotIndex, ulong targetNetworkObjectId = 0, Vector3 groundTargetPosition = default, bool hasGroundTarget = false)
{
if (slotIndex < 0 || slotIndex >= skillSlots.Length)
return;
@@ -491,7 +493,7 @@ namespace Colosseum.Player
}
// 지면 타겟 사거리 검증
if (groundTargetPosition != default)
if (hasGroundTarget)
{
float distance = Vector3.Distance(transform.position, groundTargetPosition);
if (distance > groundTargetMaxRange)
@@ -508,14 +510,14 @@ namespace Colosseum.Player
}
// 모든 클라이언트에 스킬 실행 전파
BroadcastSkillExecutionRpc(slotIndex, targetNetworkObjectId, groundTargetPosition);
BroadcastSkillExecutionRpc(slotIndex, targetNetworkObjectId, groundTargetPosition, hasGroundTarget);
}
/// <summary>
/// 모든 클라이언트에 스킬 실행 전파
/// </summary>
[Rpc(SendTo.ClientsAndHost)]
private void BroadcastSkillExecutionRpc(int slotIndex, ulong targetNetworkObjectId = 0, Vector3 groundTargetPosition = default)
private void BroadcastSkillExecutionRpc(int slotIndex, ulong targetNetworkObjectId = 0, Vector3 groundTargetPosition = default, bool hasGroundTarget = false)
{
if (slotIndex < 0 || slotIndex >= skillSlots.Length)
return;
@@ -528,12 +530,13 @@ namespace Colosseum.Player
GameObject targetOverride = ResolveTargetFromNetworkId(targetNetworkObjectId);
// 모든 클라이언트에서 스킬 실행 (애니메이션 포함)
if (groundTargetPosition != default)
if (hasGroundTarget)
{
skillController.ExecuteSkill(loadoutEntry, targetOverride, groundTargetPosition);
}
else
{
Debug.Log($"[GroundTarget] Broadcast: hasGroundTarget=false (일반 시전)");
skillController.ExecuteSkill(loadoutEntry, targetOverride);
}
}
@@ -869,6 +872,14 @@ namespace Colosseum.Player
{
actionState = GetOrCreateActionState();
}
// 인디케이터가 없으면 자동 생성
if (groundTargetIndicator == null)
{
groundTargetIndicator = GetComponent<GroundTargetIndicator>();
if (groundTargetIndicator == null)
groundTargetIndicator = gameObject.AddComponent<GroundTargetIndicator>();
}
}
#region (Ground Target)
@@ -912,6 +923,10 @@ namespace Colosseum.Player
{
currentTargetingMode = TargetingMode.GroundTarget;
pendingGroundTargetSlotIndex = slotIndex;
if (groundTargetIndicator != null)
groundTargetIndicator.Show();
Debug.Log($"[GroundTarget] 타겟팅 모드 진입: 슬롯 {slotIndex}");
}
@@ -925,6 +940,9 @@ namespace Colosseum.Player
currentTargetingMode = TargetingMode.None;
pendingGroundTargetSlotIndex = -1;
if (groundTargetIndicator != null)
groundTargetIndicator.Hide();
Debug.Log("[GroundTarget] 타겟팅 모드 취소");
}
@@ -948,7 +966,7 @@ namespace Colosseum.Player
return false;
}
Ray ray = mainCamera.ScreenPointToRay(new Vector3(Screen.width * 0.5f, Screen.height * 0.5f, 0f));
Ray ray = mainCamera.ScreenPointToRay(Mouse.current.position.ReadValue());
if (!Physics.Raycast(ray, out RaycastHit hit, groundTargetMaxRange, groundTargetLayers))
return false;
@@ -975,6 +993,9 @@ namespace Colosseum.Player
currentTargetingMode = TargetingMode.None;
pendingGroundTargetSlotIndex = -1;
if (groundTargetIndicator != null)
groundTargetIndicator.Hide();
// 캐릭터를 타겟 방향으로 회전
Vector3 flatTargetPos = new Vector3(groundPosition.x, transform.position.y, groundPosition.z);
if ((flatTargetPos - transform.position).sqrMagnitude > 0.01f)
@@ -983,7 +1004,7 @@ namespace Colosseum.Player
}
// 서버에 스킬 실행 요청
RequestSkillExecutionRpc(slotIndex, 0, groundPosition);
RequestSkillExecutionRpc(slotIndex, 0, groundPosition, true);
}
private void Update()
@@ -991,6 +1012,12 @@ namespace Colosseum.Player
if (currentTargetingMode != TargetingMode.GroundTarget)
return;
// 인디케이터 위치 실시간 갱신
if (groundTargetIndicator != null && RaycastForGroundPosition(out Vector3 currentPos))
{
groundTargetIndicator.UpdatePosition(currentPos);
}
// 좌클릭: 지면 타겟 확정
if (Mouse.current != null && Mouse.current.leftButton.wasPressedThisFrame)
{
@@ -1029,7 +1056,7 @@ namespace Colosseum.Player
return null;
}
Ray ray = mainCamera.ScreenPointToRay(Input.mousePosition);
Ray ray = mainCamera.ScreenPointToRay(Mouse.current.position.ReadValue());
float maxDistance = allyTargetMaxRange > 0f ? allyTargetMaxRange : Mathf.Infinity;
if (!Physics.Raycast(ray, out RaycastHit hit, maxDistance, allyDetectionLayers))