Files
Colosseum/Assets/_Game/Scripts/UI/AllyTargetIndicator.cs
dal4segno 8cd2623163 feat: 아군 타게팅 시스템 구현 — SingleAlly 투사체형 치유/보호막
- 치유/보호막 스킬을 즉발 자가시전에서 투사체형 아군 1인 타겟팅으로 전환

- TargetType.SingleAlly 추가, targetOverride 매개변수로 외부 타겟 주입 지원

- PlayerSkillInput: 카메라 레이캐스트 기반 아군 탐지, 서버 검증, RPC 타겟 ID 전달

- AllyTargetIndicator: 호버 아군 위에 디스크 인디케이터 표시, 사거리/초과 색상 변경

- SpawnEffect: 타겟 방향 회전 보정

- 투사체 스폰 이펙트 에셋 생성 (치유/보호막 각각)

- 인디케이터 프리팹 + URP/Unlit 머티리얼 생성

- Player 프리팹에 AllyTargetIndicator 컴포넌트 추가 및 설정

- Input.mousePosition → Mouse.current.position.ReadValue() 수정 (Input System 호환)
2026-03-31 23:06:13 +09:00

201 lines
6.6 KiB
C#

using System;
using UnityEngine;
using UnityEngine.InputSystem;
using Colosseum.Combat;
namespace Colosseum.UI
{
/// <summary>
/// 아군 타게팅 UI 피드백 컴포넌트.
/// 커서가 아군 위에 있을 때 시각적 인디케이터를 표시하고,
/// 현재 호버 중인 아군 정보를 외부에 제공합니다.
/// Player 프리팹에 부착하여 사용합니다.
/// </summary>
public class AllyTargetIndicator : MonoBehaviour
{
[Header("레이캐스트 설정")]
[Tooltip("아군 탐지용 레이캐스트 레이어")]
[SerializeField] private LayerMask allyDetectionLayers;
[Tooltip("아군 타게팅 최대 사거리 (0이면 무제한)")]
[Min(0f)] [SerializeField] private float maxRange = 30f;
[Tooltip("시야 차단 확인 여부")]
[SerializeField] private bool requireLineOfSight = true;
[Tooltip("시야 차단 확인용 레이어")]
[SerializeField] private LayerMask lineOfSightBlockLayers;
[Header("인디케이터 설정")]
[Tooltip("타겟 위에 표시할 인디케이터 프리팹 (World Space UI)")]
[SerializeField] private GameObject indicatorPrefab;
[Tooltip("인디케이터 높이 오프셋 (타겟 위치 + 이 값)")]
[Min(0f)] [SerializeField] private float indicatorHeightOffset = 2.2f;
[Tooltip("사거리 내 아군 색상")]
[SerializeField] private Color inRangeColor = new Color(0.2f, 1f, 0.2f, 0.8f);
[Tooltip("사거리 외 아군 색상")]
[SerializeField] private Color outOfRangeColor = new Color(1f, 0.3f, 0.3f, 0.8f);
[Header("범위 기준")]
[Tooltip("SingleAlly 스킬의 사거리를 기준으로 색상을 변경합니다")]
[SerializeField] private bool useSkillRange = true;
private Camera mainCamera;
private GameObject currentIndicator;
private GameObject lastHoveredTarget;
/// <summary>
/// 현재 호버 중인 아군 GameObject
/// </summary>
public GameObject CurrentHoverTarget => lastHoveredTarget;
/// <summary>
/// 호버 대상이 변경되었을 때 발생합니다.
/// null이면 호버 대상이 없음을 의미합니다.
/// </summary>
public event Action<GameObject> OnHoverTargetChanged;
private void Start()
{
mainCamera = Camera.main;
}
private void Update()
{
if (mainCamera == null)
{
mainCamera = Camera.main;
if (mainCamera == null)
return;
}
if (!enabled)
return;
GameObject newTarget = PerformRaycast();
if (newTarget != lastHoveredTarget)
{
lastHoveredTarget = newTarget;
UpdateIndicator();
OnHoverTargetChanged?.Invoke(newTarget);
}
}
/// <summary>
/// 카메라에서 커서 방향으로 레이캐스트하여 아군을 탐색합니다.
/// </summary>
private GameObject PerformRaycast()
{
if (allyDetectionLayers.value == 0)
return null;
Ray ray = mainCamera.ScreenPointToRay(Mouse.current.position.ReadValue());
float maxDistance = maxRange > 0f ? maxRange : Mathf.Infinity;
if (!Physics.Raycast(ray, out RaycastHit hit, maxDistance, allyDetectionLayers))
return null;
GameObject hitObject = hit.collider.gameObject;
// 팀 체크
if (!Team.IsSameTeam(gameObject, hitObject))
return null;
// 생존 체크
var damageable = hitObject.GetComponent<IDamageable>();
if (damageable != null && damageable.IsDead)
return null;
// 시야 차단 체크
if (requireLineOfSight && lineOfSightBlockLayers.value != 0)
{
Vector3 casterPos = transform.position + Vector3.up * 1.5f;
Vector3 targetPos = hitObject.transform.position + Vector3.up * 1.5f;
Vector3 direction = targetPos - casterPos;
float distance = direction.magnitude;
if (Physics.Raycast(casterPos, direction.normalized, out RaycastHit losHit, distance, lineOfSightBlockLayers))
{
if (losHit.collider.gameObject != hitObject)
return null;
}
}
return hitObject;
}
/// <summary>
/// 인디케이터 프리팹을 생성/이동/제거하여 시각 피드백을 업데이트합니다.
/// </summary>
private void UpdateIndicator()
{
if (lastHoveredTarget == null)
{
DestroyIndicator();
return;
}
if (indicatorPrefab == null)
return;
if (currentIndicator == null)
{
currentIndicator = Instantiate(indicatorPrefab);
}
// 타겟 위치에 인디케이터 배치
Vector3 targetPosition = lastHoveredTarget.transform.position;
targetPosition.y += indicatorHeightOffset;
currentIndicator.transform.position = targetPosition;
// 사거리에 따라 색상 변경
bool isInRange = true;
if (useSkillRange && maxRange > 0f)
{
float distance = Vector3.Distance(transform.position, lastHoveredTarget.transform.position);
isInRange = distance <= maxRange;
}
SetIndicatorColor(isInRange ? inRangeColor : outOfRangeColor);
}
private void DestroyIndicator()
{
if (currentIndicator != null)
{
Destroy(currentIndicator);
currentIndicator = null;
}
}
private void SetIndicatorColor(Color color)
{
if (currentIndicator == null)
return;
var renderer = currentIndicator.GetComponent<Renderer>();
if (renderer != null)
{
renderer.material.color = color;
return;
}
#if UNITY_UI
// UI Toolkit / UGUI 색상 변경 (필요 시 확장)
#endif
}
private void OnDestroy()
{
DestroyIndicator();
}
private void OnDisable()
{
DestroyIndicator();
lastHoveredTarget = null;
OnHoverTargetChanged?.Invoke(null);
}
}
}