576 lines
16 KiB
C#
576 lines
16 KiB
C#
using System;
|
|
using System.Collections.Generic;
|
|
using UnityEngine;
|
|
using UnityEngine.InputSystem;
|
|
|
|
public class SkillModule : MonoBehaviour
|
|
{
|
|
[SerializeField] private int _maxSlots = 4;
|
|
[SerializeField] private LayerMask _groundLayer; // 바닥 레이캐스트용
|
|
|
|
// 범위 지정 시작/종료 이벤트 (외부에서 RotationMode/UI 등 반응)
|
|
public event Action OnAreaSelectStarted;
|
|
public event Action OnAreaSelectEnded;
|
|
|
|
private PlayerStateMachine _stateMachine;
|
|
private Animator _anim;
|
|
private Transform _transform;
|
|
|
|
private SkillInstance[] _equippedSkills;
|
|
private ISkillEffect[] _skillEffects;
|
|
private SkillData[] _equippedSkillDataCache; // 장착 순서 보존용
|
|
|
|
// 차지
|
|
private int _chargingSlot = -1;
|
|
private float _chargeTimer = 0f;
|
|
|
|
// 캐스팅
|
|
private int _castingSlot = -1;
|
|
private float _castTimer = 0f;
|
|
|
|
// 채널링
|
|
private int _channelingSlot = -1;
|
|
private float _channelTimer = 0f;
|
|
private float _channelTickAccumulator = 0f;
|
|
|
|
// 범위 지정 (Remote)
|
|
private int _areaSelectSlot = -1;
|
|
private GameObject _areaIndicator;
|
|
|
|
// Remote 타겟 위치
|
|
private Vector3 _remoteTargetPos;
|
|
private bool _hasRemoteTarget;
|
|
|
|
public bool IsAreaSelecting => _areaSelectSlot >= 0;
|
|
public bool IsCasting => _castingSlot >= 0;
|
|
public bool IsChanneling => _channelingSlot >= 0;
|
|
|
|
[SerializeField] private List<WeaponSkillSet> _weaponSkills;
|
|
[SerializeField] private Weapon _equippedWeapon;
|
|
|
|
private Dictionary<WeaponType, WeaponSkillSet> _dicSkills = new Dictionary<WeaponType, WeaponSkillSet>();
|
|
|
|
private void Awake()
|
|
{
|
|
_stateMachine = GetComponent<PlayerStateMachine>();
|
|
_anim = GetComponent<Animator>();
|
|
_transform = transform;
|
|
|
|
_equippedSkills = new SkillInstance[_maxSlots];
|
|
_skillEffects = new ISkillEffect[_maxSlots];
|
|
_equippedSkillDataCache = new SkillData[_maxSlots];
|
|
}
|
|
|
|
private void Start()
|
|
{
|
|
// 무기별 스킬셋 테이블 구성
|
|
foreach (WeaponSkillSet wss in _weaponSkills)
|
|
{
|
|
_dicSkills[wss.WeaponType] = wss;
|
|
}
|
|
|
|
// 현재 장착 무기의 스킬셋만 "장착 상태"로 로드 (퀵슬롯 자동 바인딩 없음)
|
|
if (_equippedWeapon != null
|
|
&& _dicSkills.TryGetValue(_equippedWeapon.WType, out WeaponSkillSet out_wss)
|
|
&& out_wss != null)
|
|
{
|
|
LoadWeaponSkills(out_wss);
|
|
}
|
|
}
|
|
|
|
// 장착된 스킬 목록 반환 (스킬창 UI에서 사용)
|
|
public IReadOnlyList<SkillData> GetEquippedSkillDatas() => _equippedSkillDataCache;
|
|
|
|
private void Update()
|
|
{
|
|
TickCooldowns();
|
|
TickCharge();
|
|
TickCast();
|
|
TickChannel();
|
|
TickAreaSelect();
|
|
}
|
|
|
|
#region 스킬 장착
|
|
public void LoadWeaponSkills(WeaponSkillSet skillSet)
|
|
{
|
|
for (int i = 0; i < _maxSlots; i++)
|
|
{
|
|
if (i < skillSet.Skills.Count && skillSet.Skills[i] != null)
|
|
{
|
|
_equippedSkills[i] = new SkillInstance(skillSet.Skills[i]);
|
|
_skillEffects[i] = ResolveEffect(skillSet.Skills[i].TargetType);
|
|
_equippedSkillDataCache[i] = skillSet.Skills[i];
|
|
}
|
|
else
|
|
{
|
|
_equippedSkills[i] = null;
|
|
_skillEffects[i] = null;
|
|
_equippedSkillDataCache[i] = null;
|
|
}
|
|
}
|
|
|
|
ApplyPassives();
|
|
}
|
|
|
|
private ISkillEffect ResolveEffect(TargetType targetType)
|
|
{
|
|
return targetType switch
|
|
{
|
|
TargetType.Self => GetComponent<BuffEffect>(),
|
|
TargetType.Single => GetComponent<DamageEffect>(),
|
|
TargetType.Area => GetComponent<AreaEffect>(),
|
|
TargetType.Projectile => GetComponent<ProjectileEffect>(),
|
|
TargetType.Zone => GetComponent<ZoneEffect>(),
|
|
_ => null
|
|
};
|
|
}
|
|
#endregion
|
|
|
|
#region 입력
|
|
public void SkillInput(int slotIndex, InputState inputState)
|
|
{
|
|
if (slotIndex < 0 || slotIndex >= _maxSlots) return;
|
|
|
|
SkillInstance skill = _equippedSkills[slotIndex];
|
|
if (skill == null) return;
|
|
if (skill.Data.SkillType == SkillType.Passive) return;
|
|
|
|
if (inputState == InputState.Started)
|
|
{
|
|
// 범위 지정 중에 같은 스킬 키를 다시 누르면 취소
|
|
if (_areaSelectSlot == slotIndex)
|
|
{
|
|
CancelAreaSelect();
|
|
return;
|
|
}
|
|
|
|
if (!CanUseSkill(skill)) return;
|
|
|
|
// Remote 스킬: 먼저 범위 지정 모드 진입
|
|
if (skill.Data.CastMethod == CastMethod.Remote)
|
|
{
|
|
StartAreaSelect(slotIndex);
|
|
}
|
|
else
|
|
{
|
|
// Self 스킬: 바로 ActivationType 흐름
|
|
BeginActivation(slotIndex);
|
|
}
|
|
}
|
|
else if (inputState == InputState.Canceled)
|
|
{
|
|
if (_chargingSlot == slotIndex)
|
|
{
|
|
ReleaseCharge();
|
|
}
|
|
else if (_channelingSlot == slotIndex)
|
|
{
|
|
CancelChannel();
|
|
}
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// 범위 지정 모드 중 마우스 클릭 입력 (InputManager에서 호출)
|
|
/// </summary>
|
|
public void AreaConfirmInput(InputState inputState)
|
|
{
|
|
if (_areaSelectSlot < 0) return;
|
|
|
|
if (inputState == InputState.Started)
|
|
{
|
|
ConfirmAreaSelect();
|
|
}
|
|
}
|
|
#endregion
|
|
|
|
#region 발동 분기
|
|
private void BeginActivation(int slotIndex)
|
|
{
|
|
SkillInstance skill = _equippedSkills[slotIndex];
|
|
|
|
switch (skill.Data.ActivationType)
|
|
{
|
|
case ActivationType.Instant:
|
|
ExecuteSkill(slotIndex);
|
|
break;
|
|
case ActivationType.Charge:
|
|
StartCharge(slotIndex);
|
|
break;
|
|
case ActivationType.Cast:
|
|
StartCast(slotIndex);
|
|
break;
|
|
case ActivationType.Channel:
|
|
StartChannel(slotIndex);
|
|
break;
|
|
}
|
|
}
|
|
#endregion
|
|
|
|
#region 실행
|
|
private bool CanUseSkill(SkillInstance skill)
|
|
{
|
|
if (skill.IsOnCooldown) return false;
|
|
|
|
PlayerState state = _stateMachine.CurrentState;
|
|
if (state == PlayerState.Dead || state == PlayerState.Hit
|
|
|| state == PlayerState.Dodge || state == PlayerState.Trans
|
|
|| state == PlayerState.Action || state == PlayerState.Cast
|
|
|| state == PlayerState.Channel)
|
|
return false;
|
|
|
|
return true;
|
|
}
|
|
|
|
private void ExecuteSkill(int slotIndex)
|
|
{
|
|
SkillInstance skill = _equippedSkills[slotIndex];
|
|
ISkillEffect effect = _skillEffects[slotIndex];
|
|
|
|
_stateMachine.ChangeState(PlayerState.Attack);
|
|
|
|
if (!string.IsNullOrEmpty(skill.Data.AnimTrigger))
|
|
_anim.SetTrigger(skill.Data.AnimTrigger);
|
|
|
|
float chargeRatio = 1f;
|
|
if (skill.Data.ActivationType == ActivationType.Charge)
|
|
{
|
|
float maxCharge = skill.CurrentLevelData.ChargeTimeMax;
|
|
chargeRatio = maxCharge > 0 ? Mathf.Clamp01(_chargeTimer / maxCharge) : 1f;
|
|
}
|
|
|
|
// CastMethod에 따라 실행 방식 분기
|
|
if (_hasRemoteTarget)
|
|
{
|
|
effect?.ExecuteAtPosition(skill, _transform, _remoteTargetPos, chargeRatio);
|
|
ClearRemoteTarget();
|
|
}
|
|
else
|
|
{
|
|
effect?.Execute(skill, _transform, chargeRatio);
|
|
}
|
|
|
|
skill.StartCooldown();
|
|
_chargeTimer = 0f;
|
|
_chargingSlot = -1;
|
|
|
|
// ActionDuration 경과 후 Idle 복귀
|
|
float duration = skill.CurrentLevelData.ActionDuration;
|
|
if (duration > 0f)
|
|
{
|
|
_ = Util.RunDelayed(duration, () =>
|
|
{
|
|
if (this != null && _stateMachine != null
|
|
&& _stateMachine.CurrentState == PlayerState.Attack)
|
|
_stateMachine.ChangeState(PlayerState.Idle);
|
|
}, default);
|
|
}
|
|
else
|
|
{
|
|
_stateMachine.ChangeState(PlayerState.Idle);
|
|
}
|
|
}
|
|
|
|
private void ClearRemoteTarget()
|
|
{
|
|
_hasRemoteTarget = false;
|
|
}
|
|
#endregion
|
|
|
|
#region 차지
|
|
private void StartCharge(int slotIndex)
|
|
{
|
|
_chargingSlot = slotIndex;
|
|
_chargeTimer = 0f;
|
|
|
|
_stateMachine.ChangeState(PlayerState.Charge);
|
|
|
|
SkillInstance skill = _equippedSkills[slotIndex];
|
|
if (!string.IsNullOrEmpty(skill.Data.AnimTrigger))
|
|
_anim.SetTrigger(skill.Data.AnimTrigger);
|
|
}
|
|
|
|
private void ReleaseCharge()
|
|
{
|
|
if (_chargingSlot < 0) return;
|
|
ExecuteSkill(_chargingSlot);
|
|
}
|
|
|
|
private void TickCharge()
|
|
{
|
|
if (_chargingSlot < 0) return;
|
|
|
|
SkillInstance skill = _equippedSkills[_chargingSlot];
|
|
_chargeTimer += Time.deltaTime;
|
|
|
|
if (_chargeTimer >= skill.CurrentLevelData.ChargeTimeMax)
|
|
{
|
|
ReleaseCharge();
|
|
}
|
|
}
|
|
#endregion
|
|
|
|
#region 캐스팅
|
|
private void StartCast(int slotIndex)
|
|
{
|
|
_castingSlot = slotIndex;
|
|
_castTimer = 0f;
|
|
|
|
_stateMachine.ChangeState(PlayerState.Cast);
|
|
|
|
SkillInstance skill = _equippedSkills[slotIndex];
|
|
if (!string.IsNullOrEmpty(skill.Data.AnimTrigger))
|
|
_anim.SetTrigger(skill.Data.AnimTrigger);
|
|
|
|
_stateMachine.OnStateChanged += OnCastInterrupted;
|
|
}
|
|
|
|
private void TickCast()
|
|
{
|
|
if (_castingSlot < 0) return;
|
|
|
|
_castTimer += Time.deltaTime;
|
|
|
|
SkillInstance skill = _equippedSkills[_castingSlot];
|
|
if (_castTimer >= skill.CurrentLevelData.CastTime)
|
|
{
|
|
CompleteCast();
|
|
}
|
|
}
|
|
|
|
private void CompleteCast()
|
|
{
|
|
if (_castingSlot < 0) return;
|
|
|
|
_stateMachine.OnStateChanged -= OnCastInterrupted;
|
|
ExecuteSkill(_castingSlot);
|
|
_castingSlot = -1;
|
|
_castTimer = 0f;
|
|
}
|
|
|
|
private void CancelCast()
|
|
{
|
|
_stateMachine.OnStateChanged -= OnCastInterrupted;
|
|
_castingSlot = -1;
|
|
_castTimer = 0f;
|
|
ClearRemoteTarget();
|
|
}
|
|
|
|
private void OnCastInterrupted(PlayerState newState)
|
|
{
|
|
if (newState == PlayerState.Hit || newState == PlayerState.Dead
|
|
|| newState == PlayerState.Dodge)
|
|
{
|
|
CancelCast();
|
|
}
|
|
}
|
|
#endregion
|
|
|
|
#region 채널링
|
|
private void StartChannel(int slotIndex)
|
|
{
|
|
_channelingSlot = slotIndex;
|
|
_channelTimer = 0f;
|
|
_channelTickAccumulator = 0f;
|
|
|
|
_stateMachine.ChangeState(PlayerState.Channel);
|
|
|
|
SkillInstance skill = _equippedSkills[slotIndex];
|
|
if (!string.IsNullOrEmpty(skill.Data.AnimTrigger))
|
|
_anim.SetTrigger(skill.Data.AnimTrigger);
|
|
|
|
// 첫 틱 즉시 실행
|
|
ISkillEffect effect = _skillEffects[slotIndex];
|
|
ExecuteChannelTick(skill, effect);
|
|
|
|
// 쿨다운은 채널 시작 시점에 시작
|
|
skill.StartCooldown();
|
|
|
|
_stateMachine.OnStateChanged += OnChannelInterrupted;
|
|
}
|
|
|
|
private void TickChannel()
|
|
{
|
|
if (_channelingSlot < 0) return;
|
|
|
|
SkillInstance skill = _equippedSkills[_channelingSlot];
|
|
SkillLevelData data = skill.CurrentLevelData;
|
|
|
|
_channelTimer += Time.deltaTime;
|
|
_channelTickAccumulator += Time.deltaTime;
|
|
|
|
if (_channelTickAccumulator >= data.TickInterval)
|
|
{
|
|
_channelTickAccumulator -= data.TickInterval;
|
|
ISkillEffect effect = _skillEffects[_channelingSlot];
|
|
ExecuteChannelTick(skill, effect);
|
|
}
|
|
|
|
if (_channelTimer >= data.ChannelDuration)
|
|
{
|
|
EndChannel();
|
|
}
|
|
}
|
|
|
|
private void ExecuteChannelTick(SkillInstance skill, ISkillEffect effect)
|
|
{
|
|
if (_hasRemoteTarget)
|
|
{
|
|
effect?.ExecuteAtPosition(skill, _transform, _remoteTargetPos, 1f);
|
|
}
|
|
else
|
|
{
|
|
effect?.Execute(skill, _transform, 1f);
|
|
}
|
|
}
|
|
|
|
private void EndChannel()
|
|
{
|
|
_stateMachine.OnStateChanged -= OnChannelInterrupted;
|
|
_stateMachine.ChangeState(PlayerState.Idle);
|
|
_channelingSlot = -1;
|
|
_channelTimer = 0f;
|
|
_channelTickAccumulator = 0f;
|
|
ClearRemoteTarget();
|
|
}
|
|
|
|
private void CancelChannel()
|
|
{
|
|
_stateMachine.OnStateChanged -= OnChannelInterrupted;
|
|
_channelingSlot = -1;
|
|
_channelTimer = 0f;
|
|
_channelTickAccumulator = 0f;
|
|
ClearRemoteTarget();
|
|
}
|
|
|
|
private void OnChannelInterrupted(PlayerState newState)
|
|
{
|
|
if (newState == PlayerState.Hit || newState == PlayerState.Dead
|
|
|| newState == PlayerState.Dodge)
|
|
{
|
|
CancelChannel();
|
|
}
|
|
}
|
|
#endregion
|
|
|
|
#region 범위 지정 (Remote)
|
|
private void StartAreaSelect(int slotIndex)
|
|
{
|
|
_areaSelectSlot = slotIndex;
|
|
SkillInstance skill = _equippedSkills[slotIndex];
|
|
|
|
if (skill.Data.AreaIndicatorPrefab != null)
|
|
{
|
|
_areaIndicator = Instantiate(skill.Data.AreaIndicatorPrefab);
|
|
float range = skill.CurrentLevelData.Range;
|
|
_areaIndicator.transform.localScale = new Vector3(range * 2f, range * 2f, range * 2f);
|
|
}
|
|
|
|
// 범위 지정 중에는 좌클릭이 확정 용도로 쓰이므로 기본 공격 차단
|
|
InputManager.Instance.SuppressNormalAttack = true;
|
|
|
|
// 이벤트 발행 — 구독자(PlayerCharacterController 등)가 회전모드 등 처리
|
|
OnAreaSelectStarted?.Invoke();
|
|
}
|
|
|
|
private void TickAreaSelect()
|
|
{
|
|
if (_areaSelectSlot < 0 || _areaIndicator == null) return;
|
|
|
|
if (Mouse.current == null) return;
|
|
Vector2 mousePos = Mouse.current.position.ReadValue();
|
|
Ray ray = Camera.main.ScreenPointToRay(mousePos);
|
|
if (Physics.Raycast(ray, out RaycastHit hit, 200f, _groundLayer))
|
|
{
|
|
_areaIndicator.transform.position = hit.point;
|
|
}
|
|
}
|
|
|
|
private void ConfirmAreaSelect()
|
|
{
|
|
if (_areaSelectSlot < 0) return;
|
|
|
|
int slotIndex = _areaSelectSlot;
|
|
|
|
// 타겟 위치 저장
|
|
_remoteTargetPos = _areaIndicator.transform.position;
|
|
_hasRemoteTarget = true;
|
|
|
|
// 인디케이터 정리
|
|
Destroy(_areaIndicator);
|
|
_areaIndicator = null;
|
|
_areaSelectSlot = -1;
|
|
|
|
// 범위 지정 종료 → 기본 공격 복구
|
|
InputManager.Instance.SuppressNormalAttack = false;
|
|
OnAreaSelectEnded?.Invoke();
|
|
|
|
// ActivationType에 따라 흐름 시작
|
|
BeginActivation(slotIndex);
|
|
}
|
|
|
|
public void CancelAreaSelect()
|
|
{
|
|
if (_areaIndicator != null)
|
|
Destroy(_areaIndicator);
|
|
|
|
_areaIndicator = null;
|
|
_areaSelectSlot = -1;
|
|
|
|
// 범위 지정 종료 → 기본 공격 복구
|
|
InputManager.Instance.SuppressNormalAttack = false;
|
|
OnAreaSelectEnded?.Invoke();
|
|
}
|
|
#endregion
|
|
|
|
#region 쿨다운
|
|
private void TickCooldowns()
|
|
{
|
|
foreach (var skill in _equippedSkills)
|
|
{
|
|
skill?.TickCooldown(Time.deltaTime);
|
|
}
|
|
}
|
|
#endregion
|
|
|
|
#region 패시브
|
|
private void ApplyPassives()
|
|
{
|
|
foreach (var skill in _equippedSkills)
|
|
{
|
|
if (skill != null && skill.Data.SkillType == SkillType.Passive)
|
|
{
|
|
// PlayerStat에 스탯 보정 적용
|
|
}
|
|
}
|
|
}
|
|
#endregion
|
|
|
|
#region 외부 접근
|
|
public SkillInstance GetSkill(int slotIndex)
|
|
{
|
|
if (slotIndex < 0 || slotIndex >= _maxSlots) return null;
|
|
return _equippedSkills[slotIndex];
|
|
}
|
|
|
|
public void SkillInputByData(SkillData data, InputState inputState)
|
|
{
|
|
int slotIndex = FindSlotByData(data);
|
|
if (slotIndex < 0) return;
|
|
SkillInput(slotIndex, inputState);
|
|
}
|
|
|
|
private int FindSlotByData(SkillData data)
|
|
{
|
|
for (int i = 0; i < _maxSlots; i++)
|
|
{
|
|
if (_equippedSkills[i] != null && _equippedSkills[i].Data == data)
|
|
return i;
|
|
}
|
|
return -1;
|
|
}
|
|
|
|
public int MaxSlots => _maxSlots;
|
|
#endregion
|
|
}
|