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

@@ -0,0 +1,87 @@
using UnityEngine;
namespace Colosseum.Player
{
/// <summary>
/// 지면 타겟팅 인디케이터.
/// 마우스 커서 위치의 지면에 표시되는 반투명 범위 원입니다.
/// </summary>
public class GroundTargetIndicator : MonoBehaviour
{
[Header("설정")]
[Min(0.1f)] [SerializeField] private float radius = 5f;
[SerializeField] private Color indicatorColor = new Color(1f, 0.5f, 0f, 0.35f);
private GameObject indicatorObject;
private Material indicatorMaterial;
private void Awake()
{
CreateIndicator();
Hide();
}
private void CreateIndicator()
{
// 얇은 실린더로 범위 원 생성
indicatorObject = GameObject.CreatePrimitive(PrimitiveType.Cylinder);
indicatorObject.name = "GroundTargetIndicator";
indicatorObject.transform.SetParent(transform);
indicatorObject.transform.localScale = new Vector3(radius * 2f, 0.02f, radius * 2f);
// Default 레이어(0) — 지면 레이캐스트에 걸리지 않도록 Ground 레이어와 분리
indicatorObject.layer = 0;
// 콜라이더 제거 — 물리 간섭 방지
Destroy(indicatorObject.GetComponent<Collider>());
// 반투명 Unlit 머티리얼
indicatorMaterial = new Material(Shader.Find("Unlit/Transparent"));
indicatorMaterial.color = indicatorColor;
indicatorObject.GetComponent<MeshRenderer>().material = indicatorMaterial;
}
/// <summary>
/// 인디케이터를 표시합니다.
/// </summary>
public void Show()
{
if (indicatorObject != null)
indicatorObject.SetActive(true);
}
/// <summary>
/// 인디케이터를 숨깁니다.
/// </summary>
public void Hide()
{
if (indicatorObject != null)
indicatorObject.SetActive(false);
}
/// <summary>
/// 인디케이터 위치를 갱신합니다.
/// </summary>
public void UpdatePosition(Vector3 groundPosition)
{
if (indicatorObject != null)
indicatorObject.transform.position = groundPosition + Vector3.up * 0.05f;
}
/// <summary>
/// 인디케이터 반경을 변경합니다.
/// </summary>
public void SetRadius(float newRadius)
{
radius = newRadius;
if (indicatorObject != null)
indicatorObject.transform.localScale = new Vector3(radius * 2f, 0.02f, radius * 2f);
}
private void OnDestroy()
{
if (indicatorMaterial != null)
Destroy(indicatorMaterial);
}
}
}

View File

@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 18816e6a80579d84baf9ab4ef148e9f3

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))

View File

@@ -0,0 +1,122 @@
using UnityEngine;
namespace Colosseum.Skills.Effects
{
/// <summary>
/// VFX 생성 효과.
/// 지정된 위치에 파티클 프리팹을 생성하고, 생명주기 및 재생 옵션을 제어합니다.
/// </summary>
[CreateAssetMenu(fileName = "VfxEffect", menuName = "Colosseum/Skills/Effects/VFX")]
public class VfxEffect : SkillEffect
{
[Header("VFX 설정")]
[Tooltip("생성할 VFX 프리팹")]
[SerializeField] private GameObject vfxPrefab;
[Header("위치 설정")]
[Tooltip("VFX 생성 위치: Caster=시전자, CasterForward=시전자 정면, Target=대상 위치, GroundPoint=지면 타겟 위치")]
[SerializeField] private VfxSpawnLocation spawnLocation = VfxSpawnLocation.Caster;
[Tooltip("위치 오프셋")]
[SerializeField] private Vector3 offset = Vector3.zero;
[Header("생명주기")]
[Tooltip("VFX 자동 파괴 시간 (0이면 파괴하지 않음)")]
[Min(0f)] [SerializeField] private float lifetime = 2f;
[Tooltip("시전자에게 부모 설정 여부")]
[SerializeField] private bool parentToCaster = false;
[Tooltip("VFX 크기 배율")]
[Min(0.01f)] [SerializeField] private float scaleMultiplier = 1f;
[Header("파티클 설정")]
[Tooltip("ParticleSystem 자동 재생 여부 (false면 스폰만 하고 외부에서 제어)")]
[SerializeField] private bool autoPlay = true;
[Tooltip("ParticleSystem 루핑 여부")]
[SerializeField] private bool loop = false;
/// <summary>
/// VFX 효과는 순수 시각 효과이므로 모든 클라이언트에서 로컬 실행됩니다.
/// </summary>
public override bool IsVisualOnly => true;
private Vector3? groundPosition;
/// <summary>
/// 지면 타겟 위치를 캡처하여 ApplyEffect에서 사용할 수 있도록 합니다.
/// </summary>
public override void ExecuteOnCast(GameObject caster, GameObject targetOverride = null, Vector3? groundPosition = null)
{
this.groundPosition = groundPosition;
base.ExecuteOnCast(caster, targetOverride, groundPosition);
this.groundPosition = null;
}
/// <summary>
/// VFX 프리팹을 생성하고 파티클을 재생합니다.
/// </summary>
protected override void ApplyEffect(GameObject caster, GameObject target)
{
if (vfxPrefab == null || caster == null) return;
try
{
Vector3 spawnPos = GetSpawnPosition(caster, target);
Quaternion spawnRot = caster.transform.rotation;
Transform parent = parentToCaster ? caster.transform : null;
// 비제네릭 Instantiate로 캐스팅 예외 방지
GameObject instance = (GameObject)Object.Instantiate(vfxPrefab, spawnPos + offset, spawnRot, parent);
instance.transform.localScale *= scaleMultiplier;
if (parentToCaster)
instance.transform.localPosition = offset;
if (autoPlay)
{
ParticleSystem[] particleSystems = instance.GetComponentsInChildren<ParticleSystem>(true);
foreach (var ps in particleSystems)
{
var main = ps.main;
main.loop = loop;
if (loop)
main.stopAction = ParticleSystemStopAction.None;
ps.Clear(true);
ps.Play(true);
}
}
if (lifetime > 0f)
Object.Destroy(instance, lifetime);
}
catch (System.Exception e)
{
Debug.LogError($"[VfxEffect] VFX 생성 실패: {e.Message}\n{e.StackTrace}", this);
}
}
/// <summary>
/// spawnLocation에 따른 VFX 생성 위치를 계산합니다.
/// </summary>
private Vector3 GetSpawnPosition(GameObject caster, GameObject target)
{
return spawnLocation switch
{
VfxSpawnLocation.Caster => caster.transform.position,
VfxSpawnLocation.CasterForward => caster.transform.position + caster.transform.forward * 2f,
VfxSpawnLocation.Target => target != null ? target.transform.position : caster.transform.position,
VfxSpawnLocation.GroundPoint => groundPosition ?? caster.transform.position,
_ => caster.transform.position
};
}
}
public enum VfxSpawnLocation
{
Caster,
CasterForward,
Target,
GroundPoint
}
}

View File

@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 69581e050479a094782d2ca9eb142fe4

View File

@@ -74,6 +74,7 @@ namespace Colosseum.Skills
private int currentRepeatCount = 1;
private int currentIterationIndex = 0;
private GameObject currentTargetOverride;
private Vector3? currentGroundTargetPosition;
// 쿨타임 추적
private Dictionary<SkillData, float> cooldownTracker = new Dictionary<SkillData, float>();
@@ -270,6 +271,17 @@ namespace Colosseum.Skills
return ExecuteSkillInternal(loadoutEntry);
}
/// <summary>
/// 지면 타겟 위치와 함께 스킬 시전.
/// Ground Target 스킬에서 사용합니다.
/// </summary>
public bool ExecuteSkill(SkillLoadoutEntry loadoutEntry, GameObject targetOverride, Vector3 groundTargetPosition)
{
currentTargetOverride = targetOverride;
currentGroundTargetPosition = groundTargetPosition;
return ExecuteSkillInternal(loadoutEntry);
}
/// <summary>
/// 스킬 시전 공통 로직
/// </summary>
@@ -328,17 +340,29 @@ namespace Colosseum.Skills
if (currentSkill == null)
return;
// VFX는 모든 클라이언트에서 로컬 생성 (서버 가드 무시)
for (int i = 0; i < currentCastStartEffects.Count; i++)
{
SkillEffect effect = currentCastStartEffects[i];
if (effect != null && effect.IsVisualOnly)
{
if (debugMode) Debug.Log($"[Skill] Cast start VFX: {effect.name} (index {i})");
effect.ExecuteOnCast(gameObject, currentTargetOverride, currentGroundTargetPosition);
}
}
// 게임플레이 효과는 서버에서만 실행
if (NetworkManager.Singleton != null && !NetworkManager.Singleton.IsServer)
return;
for (int i = 0; i < currentCastStartEffects.Count; i++)
{
SkillEffect effect = currentCastStartEffects[i];
if (effect == null)
if (effect == null || effect.IsVisualOnly)
continue;
if (debugMode) Debug.Log($"[Skill] Cast start effect: {effect.name} (index {i})");
effect.ExecuteOnCast(gameObject, currentTargetOverride);
effect.ExecuteOnCast(gameObject, currentTargetOverride, currentGroundTargetPosition);
}
if (currentCastStartAbnormalities.Count <= 0)
@@ -371,23 +395,35 @@ namespace Colosseum.Skills
if (currentSkill == null || currentTriggeredEffects.Count == 0)
return;
if (NetworkManager.Singleton != null && !NetworkManager.Singleton.IsServer)
return;
if (currentSkill.SkillClip != null && currentSkill.SkillClip.events != null && currentSkill.SkillClip.events.Length > 0)
return;
if (!currentTriggeredEffects.TryGetValue(0, out List<SkillEffect> effectsAtZero))
return;
// VFX는 모든 클라이언트에서 로컬 생성 (서버 가드 무시)
for (int i = 0; i < effectsAtZero.Count; i++)
{
SkillEffect effect = effectsAtZero[i];
if (effect == null || effect.TargetType != TargetType.Self)
if (effect != null && effect.IsVisualOnly && effect.TargetType == TargetType.Self)
{
if (debugMode) Debug.Log($"[Skill] Immediate self VFX: {effect.name} (index {i})");
effect.ExecuteOnCast(gameObject, currentTargetOverride, currentGroundTargetPosition);
}
}
// 게임플레이 효과는 서버에서만 실행
if (NetworkManager.Singleton != null && !NetworkManager.Singleton.IsServer)
return;
for (int i = 0; i < effectsAtZero.Count; i++)
{
SkillEffect effect = effectsAtZero[i];
if (effect == null || effect.TargetType != TargetType.Self || effect.IsVisualOnly)
continue;
if (debugMode) Debug.Log($"[Skill] Immediate self effect: {effect.name} (index {i})");
effect.ExecuteOnCast(gameObject, currentTargetOverride);
effect.ExecuteOnCast(gameObject, currentTargetOverride, currentGroundTargetPosition);
}
}
@@ -561,8 +597,6 @@ namespace Colosseum.Skills
/// </summary>
public void OnEffect(int index)
{
if (NetworkManager.Singleton != null && !NetworkManager.Singleton.IsServer) return;
if (currentSkill == null)
{
if (debugMode) Debug.LogWarning("[Effect] No skill executing");
@@ -583,10 +617,24 @@ namespace Colosseum.Skills
return;
}
// VFX는 모든 클라이언트에서 로컬 생성 (서버 가드 무시)
for (int i = 0; i < effects.Count; i++)
{
SkillEffect effect = effects[i];
if (effect == null)
if (effect != null && effect.IsVisualOnly)
{
if (debugMode) Debug.Log($"[Effect] VFX: {effect.name} (index {index})");
effect.ExecuteOnCast(gameObject, currentTargetOverride, currentGroundTargetPosition);
}
}
// 게임플레이 효과는 서버에서만 실행
if (NetworkManager.Singleton != null && !NetworkManager.Singleton.IsServer) return;
for (int i = 0; i < effects.Count; i++)
{
SkillEffect effect = effects[i];
if (effect == null || effect.IsVisualOnly)
continue;
if (debugMode) Debug.Log($"[Effect] {effect.name} (index {index})");
@@ -594,13 +642,11 @@ namespace Colosseum.Skills
// 공격 범위 시각화
if (showAreaDebug)
{
effect.DrawDebugRange(gameObject, debugDrawDuration);
effect.DrawDebugRange(gameObject, debugDrawDuration, currentGroundTargetPosition);
}
effect.ExecuteOnCast(gameObject, currentTargetOverride);
effect.ExecuteOnCast(gameObject, currentTargetOverride, currentGroundTargetPosition);
}
ApplyTriggeredAbnormalities(index, effects);
}
/// <summary>
@@ -684,6 +730,7 @@ namespace Colosseum.Skills
currentTriggeredAbnormalities.Clear();
currentTriggeredTargetsBuffer.Clear();
currentTargetOverride = null;
currentGroundTargetPosition = null;
currentClipSequenceIndex = 0;
currentRepeatCount = 1;
currentIterationIndex = 0;
@@ -709,7 +756,7 @@ namespace Colosseum.Skills
if (effect == null || effect.TargetType == TargetType.Self)
continue;
effect.CollectTargets(gameObject, currentTriggeredTargetsBuffer, currentTargetOverride);
effect.CollectTargets(gameObject, currentTriggeredTargetsBuffer, currentTargetOverride, currentGroundTargetPosition);
}
if (currentTriggeredTargetsBuffer.Count == 0)

View File

@@ -45,14 +45,21 @@ namespace Colosseum.Skills
public float FanRadius => fanRadius;
public float FanHalfAngle => fanHalfAngle;
public bool IncludeCasterInArea => includeCasterInArea;
public AreaCenterType AreaCenter => areaCenter;
/// <summary>
/// 이 효과가 순수 시각 효과인지 확인합니다.
/// true이면 서버 가드를 무시하고 모든 클라이언트에서 로컬 실행됩니다.
/// </summary>
public virtual bool IsVisualOnly => false;
/// <summary>
/// 스킬 시전 시 호출
/// </summary>
public void ExecuteOnCast(GameObject caster, GameObject targetOverride = null)
public virtual void ExecuteOnCast(GameObject caster, GameObject targetOverride = null, Vector3? groundPosition = null)
{
List<GameObject> targets = new List<GameObject>();
CollectTargets(caster, targets, targetOverride);
CollectTargets(caster, targets, targetOverride, groundPosition);
for (int i = 0; i < targets.Count; i++)
{
@@ -64,7 +71,7 @@ namespace Colosseum.Skills
/// 현재 효과가 영향을 줄 대상 목록을 수집합니다.
/// 젬의 적중 이상상태 적용 등에서 동일한 타겟 해석을 재사용하기 위한 경로입니다.
/// </summary>
public void CollectTargets(GameObject caster, List<GameObject> destination, GameObject targetOverride = null)
public void CollectTargets(GameObject caster, List<GameObject> destination, GameObject targetOverride = null, Vector3? groundPosition = null)
{
if (caster == null || destination == null)
return;
@@ -76,7 +83,7 @@ namespace Colosseum.Skills
break;
case TargetType.Area:
CollectAreaTargets(caster, destination);
CollectAreaTargets(caster, destination, groundPosition);
break;
case TargetType.SingleAlly:
@@ -135,9 +142,9 @@ namespace Colosseum.Skills
};
}
private void CollectAreaTargets(GameObject caster, List<GameObject> destination)
private void CollectAreaTargets(GameObject caster, List<GameObject> destination, Vector3? groundPosition = null)
{
Vector3 center = GetAreaCenter(caster);
Vector3 center = GetAreaCenter(caster, groundPosition);
Collider[] hits = Physics.OverlapSphere(center, Mathf.Max(areaRadius, fanRadius), targetLayers);
foreach (var hit in hits)
{
@@ -189,7 +196,7 @@ namespace Colosseum.Skills
return angle <= fanHalfAngle;
}
private Vector3 GetAreaCenter(GameObject caster)
private Vector3 GetAreaCenter(GameObject caster, Vector3? groundPosition = null)
{
if (caster == null) return Vector3.zero;
@@ -197,6 +204,7 @@ namespace Colosseum.Skills
{
AreaCenterType.Caster => caster.transform.position,
AreaCenterType.CasterForward => caster.transform.position + caster.transform.forward * areaRadius,
AreaCenterType.GroundPoint => groundPosition ?? caster.transform.position,
_ => caster.transform.position
};
}
@@ -205,7 +213,7 @@ namespace Colosseum.Skills
/// <summary>
/// 공격 범위 시각화 (런타임 디버그용)
/// </summary>
public void DrawDebugRange(GameObject caster, float duration = 1f)
public void DrawDebugRange(GameObject caster, float duration = 1f, Vector3? groundPosition = null)
{
if (targetType != TargetType.Area) return;
@@ -214,12 +222,15 @@ namespace Colosseum.Skills
if (areaShape == AreaShapeType.Sphere)
{
Vector3 center = GetAreaCenter(caster);
Vector3 center = GetAreaCenter(caster, groundPosition);
DebugDrawSphere(center, areaRadius, Color.red, duration);
}
else if (areaShape == AreaShapeType.Fan)
{
DebugDrawFan(casterPos, forward, fanOriginDistance, fanRadius, fanHalfAngle, Color.red, duration);
Vector3 center = GetAreaCenter(caster, groundPosition);
DebugDrawFan(center, groundPosition.HasValue && areaCenter == AreaCenterType.GroundPoint
? (center - casterPos).normalized
: forward, fanOriginDistance, fanRadius, fanHalfAngle, Color.red, duration);
}
}
@@ -296,7 +307,8 @@ namespace Colosseum.Skills
public enum AreaCenterType
{
Caster,
CasterForward
CasterForward,
GroundPoint // 지면 지점 타겟팅 (Ground Target)
}
public enum AreaShapeType

View File

@@ -22,11 +22,18 @@ namespace Colosseum.Skills
[SerializeField] private GameObject hitEffect;
[SerializeField] private float hitEffectDuration = 2f;
[Header("트레일 이펙트")]
[Tooltip("투사체 트레일 프리팹 (TrailRenderer가 포함된 프리팹). 미설정 시 트레일 없음.")]
[SerializeField] private GameObject trailPrefab;
[Tooltip("투사체 파괴 후 트레일이 남는 시간 (초)")]
[Min(0.1f)] [SerializeField] private float trailDuration = 0.3f;
private GameObject caster;
private SkillEffect sourceEffect;
private int penetrationCount;
private Rigidbody rb;
private bool initialized;
private GameObject trailInstance;
/// <summary>
/// 투사체 초기화
@@ -51,6 +58,17 @@ namespace Colosseum.Skills
Physics.IgnoreCollision(mc, cc);
}
public override void OnNetworkSpawn()
{
// 트레일 생성 (서버/클라이언트 양쪽에서 실행)
if (trailPrefab != null)
{
trailInstance = Instantiate(trailPrefab, transform);
trailInstance.transform.localPosition = Vector3.zero;
trailInstance.transform.localRotation = Quaternion.identity;
}
}
private void Start()
{
// 서버에서만 수명 관리
@@ -75,11 +93,12 @@ namespace Colosseum.Skills
if (!sourceEffect.IsValidTarget(caster, other.gameObject))
return;
// 충돌 이펙트 (서버에서 스폰 → 클라이언트에도 표시되려면 NetworkObject여야 함)
// 충돌 이펙트 (서버 + 클라이언트 모두 표시)
if (hitEffect != null)
{
var effect = Instantiate(hitEffect, transform.position, transform.rotation);
Destroy(effect, hitEffectDuration);
HitEffectClientRpc(transform.position, transform.rotation.eulerAngles);
}
// 효과 적용
@@ -96,6 +115,15 @@ namespace Colosseum.Skills
private void ServerDespawn()
{
if (!IsServer || !IsSpawned) return;
// 트레일을 월드에 남겨서 자연스럽게 사라지게 함
if (trailInstance != null)
{
trailInstance.transform.SetParent(null);
Destroy(trailInstance, trailDuration);
trailInstance = null;
}
NetworkObject.Despawn(true);
}
@@ -103,5 +131,18 @@ namespace Colosseum.Skills
{
transform.rotation = Quaternion.LookRotation(direction.normalized);
}
/// <summary>
/// 클라이언트에 충돌 이펙트 표시
/// </summary>
[Rpc(SendTo.NotServer)]
private void HitEffectClientRpc(Vector3 position, Vector3 eulerAngles)
{
if (hitEffect != null)
{
var effect = Instantiate(hitEffect, position, Quaternion.Euler(eulerAngles));
Destroy(effect, hitEffectDuration);
}
}
}
}