1768 lines
64 KiB
C#
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

using BulletHellTemplate.Core.Events;
using Cysharp.Threading.Tasks;
using System;
using System.Collections.Generic;
using System.Threading;
using TMPro;
using UnityEngine;
using UnityEngine.UI;
using BulletHellTemplate.VFX;
using BulletHellTemplate.PVP;
#if FUSION2
using Fusion;
#endif
namespace BulletHellTemplate
{
public class CharacterEntity : BaseCharacterEntity
{
[Header("Settings")]
public float reviveInvincibleTime = 2.5f;
public float decreaseHpBarDelay = 0.12f;
public float decreaseHPBarSpeed = 1.5f;
public float mpLerpSpeed = 4.0f;
[Header("Transforms")]
public Transform characterModelTransform;
public Transform launchTransform;
public Transform effectsTransform;
public GameObject directionalAim;
[Header("Managers")]
[Tooltip("Reference to the indicator manager.")]
public IndicatorManager indicatorManager;
[Header("UI Elements")]
public Image hpBar;
public Image hpDecreaseBar;
public Image mpBar;
public Image shieldBar;
public Image shieldIcon;
public Image invincibleIcon;
public Image buffIcon;
public Image debuffIcon;
public TextMeshProUGUI level;
public TextMeshProUGUI HpText;
public TextMeshProUGUI insufficientMPText;
public TextMeshProUGUI playerNicknameText;
[Header("PVP")]
public GameObject[] teamObjects;
public Image hpBarAlly;
public Image hpBarEnemy;
private bool isStunned = false;
public bool IsStunned => isStunned;
private bool wasMoving;
private Vector2 lastDir2D;
public bool IsNetworked { get; private set; } = false;
private CancellationTokenSource stunCts;
#if FUSION2
private bool _visualsApplied = false;
[HideInInspector][Networked, OnChangedRender(nameof(OnTeamChanged))] public byte TeamId { get; set; }
#else
public int Team; // offline
#endif
#if FUSION2
/* ---------- Network fields ---------- */
[HideInInspector][Networked, OnChangedRender(nameof(OnNetworkDataChanged))]public byte CharacterId { get; set; }
[HideInInspector][Networked, OnChangedRender(nameof(OnNetworkDataChanged))] public byte SkinId { get; set; }
/// <summary>
/// Networked player nickname (fixed-capacity string). Use .ToString() to read.
/// </summary>
[HideInInspector][Networked, OnChangedRender(nameof(OnNetworkDataChanged))]
public NetworkString<_32> PlayerNick { get; set; }
/* ---------- Network stats ---------- */
[HideInInspector][Networked, OnChangedRender(nameof(OnMiniHudChanged))] public ushort NetHP { get; set; }
[HideInInspector][Networked, OnChangedRender(nameof(OnMiniHudChanged))] public ushort NetHPMax { get; set; }
[HideInInspector][Networked, OnChangedRender(nameof(OnMiniHudChanged))] public ushort NetMP { get; set; }
[HideInInspector][Networked, OnChangedRender(nameof(OnMiniHudChanged))] public ushort NetMPMax { get; set; }
[HideInInspector][Networked, OnChangedRender(nameof(OnMiniHudChanged))] public ushort NetShield { get; set; }
[HideInInspector][Networked, OnChangedRender(nameof(OnMiniHudChanged))] public byte NetIcons { get; set; }
private bool _wasNetworkedMoving = false;
private Vector2 _lastSentDirection = Vector2.zero;
private NetworkId _lastHitBy;
private struct HitKey : System.IEquatable<HitKey>
{
public NetworkId Attacker;
public ulong HitId;
public bool Equals(HitKey other) => Attacker == other.Attacker && HitId == other.HitId;
public override bool Equals(object obj) => obj is HitKey hk && Equals(hk);
public override int GetHashCode() => (Attacker.GetHashCode() * 486187739) ^ HitId.GetHashCode();
}
private readonly Dictionary<HitKey, float> _seenPlayerHits = new();
private const float PLAYER_HIT_TTL = 3f;
private List<HitKey> _seenHitsTmp;
private void PruneSeenPlayerHits()
{
if (_seenPlayerHits.Count == 0) return;
float now = Time.time;
_seenHitsTmp ??= new List<HitKey>(8);
_seenHitsTmp.Clear();
foreach (var kv in _seenPlayerHits)
if (now - kv.Value > PLAYER_HIT_TTL)
_seenHitsTmp.Add(kv.Key);
for (int i = 0; i < _seenHitsTmp.Count; i++)
_seenPlayerHits.Remove(_seenHitsTmp[i]);
}
byte _cachedCid = byte.MaxValue;
byte _cachedSkin = byte.MaxValue;
private byte _cid = byte.MaxValue;
private byte _skin = byte.MaxValue;
private string _name = string.Empty;
private byte _cachedTeam = 0;
#endif
#region ------------------------------- Init & Setup -------------------------------
protected override void Awake()
{
base.Awake();
}
protected override void Start()
{
base.Start();
if (characterModelTransform == null) characterModelTransform = transform;
if (launchTransform == null) launchTransform = transform;
if (effectsTransform == null) effectsTransform = transform;
shieldIcon.gameObject.SetActive(false);
buffIcon.gameObject.SetActive(false);
debuffIcon.gameObject.SetActive(false);
invincibleIcon.gameObject.SetActive(false);
#if FUSION2
IsNetworked = Runner != null && Runner.IsRunning;
#endif
}
protected override void OnEnable()
{
base.OnEnable();
if (!GameplayManager.Singleton) return;
if (!GameplayManager.Singleton.IsRunnerActive || GameplayManager.Singleton.IsLeader)
GameplayManager.Singleton.ActiveCharactersList.Add(transform);
GameplayManager.Singleton.RegisterAlive(this);
}
protected override void OnDisable()
{
base.OnDisable();
if (!GameplayManager.Singleton) return;
if (!GameplayManager.Singleton.IsRunnerActive || GameplayManager.Singleton.IsLeader)
GameplayManager.Singleton.ActiveCharactersList.Remove(transform);
GameplayManager.Singleton.UnregisterAlive(this);
#if FUSION2
UIGameplay.Singleton?.NotifyCharacterDespawned(this);
#endif
}
protected override void OnDestroy()
{
base.OnDestroy();
}
private void OnPlayerDiedEvent(PlayerDiedEvent evt)
{
if (evt.Target != this) return;
OnDeath();
}
/// <summary>
/// Sets the character data and updates the character model.
/// </summary>
/// <param name="_characterData">The character data to set.</param>
public async void SetCharacterData(CharacterData _characterData, int _skinIndex)
{
characterData = _characterData;
skinIndex = _skinIndex;
UpdateCharacterModel();
if (directionalAim != null)
directionalAim.SetActive(false);
#if FUSION2
if (Runner != null && Runner.IsRunning && !Object.HasInputAuthority)
return;
#endif
playerNicknameText.text = PlayerSave.GetPlayerName();
InitializeCharacter();
if (UIGameplay.Singleton == null)
{
var hud = Instantiate(GameInstance.Singleton.GetUIGameplayForPlatform());
if (hud.minimapImage) hud.minimapImage.sprite = hud.unlockedSprite;
}
UIGameplay.Singleton?.SetCharacterEntity(this);
await GameplayManager.Singleton.SetupCharacterEntity(this);
TopDownCameraController.Singleton.SetTarget(gameObject.transform);
characterStatsComponent.SetUIGameplayStats();
#if FUSION2
if (Runner && Runner.IsRunning && Object && Object.HasStateAuthority)
PushMiniHudFromStats();
#endif
}
#if FUSION2
public void SetInitialNetworkData(byte cid, byte skin, string name, byte team = 0)
{
_cachedCid = cid;
_cachedSkin = skin;
_name = SanitizeNickname(name);
_cachedTeam = team;
}
private void OnNetworkDataChanged()
{
ApplyNetworkVisuals();
}
public override void Spawned()
{
if (HasStateAuthority && _cachedCid != byte.MaxValue)
{
CharacterId = _cachedCid;
SkinId = _cachedSkin;
PlayerNick = _name;
TeamId = _cachedTeam;
}
ApplyNetworkVisuals();
if (Object.HasInputAuthority && UIGameplay.Singleton == null)
{
var hud = Instantiate(GameInstance.Singleton.GetUIGameplayForPlatform());
if (hud.minimapImage) hud.minimapImage.sprite = hud.unlockedSprite;
}
UIGameplay.Singleton?.NotifyCharacterSpawned(this);
OnMiniHudChanged();
RefreshTeamObjects();
GameplayManager.Singleton?.RegisterCharacter(this);
}
private void ApplyNetworkVisuals()
{
if (CharacterId == byte.MaxValue)
return;
bool changed = !_visualsApplied || _cid != CharacterId || _skin != SkinId;
if (!changed)
return;
if (playerNicknameText != null)
playerNicknameText.text = PlayerNick.ToString();
_visualsApplied = true;
_cid = CharacterId;
_skin = SkinId;
var data = GameInstance.Singleton.GetCharacterDataById(_cid);
if (data == null)
{
Debug.LogError($"CharacterData id {_cid} not found.");
return;
}
characterData = data;
skinIndex = _skin;
UpdateCharacterModel();
if (directionalAim != null)
directionalAim.SetActive(false);
SelectHpBarVariant();
InitializeCharacter();
RefreshTeamObjects();
}
[Rpc(RpcSources.All, RpcTargets.StateAuthority)]
public void RPC_RequestPlaceAtTeamSpawn(byte team, RpcInfo info = default)
{
var gm = GameplayManager.Singleton;
Vector3 pos = gm ? gm.GetTeamSpawnPosition(team) : Vector3.zero;
Quaternion rot = gm ? gm.GetTeamSpawnRotation(team) : Quaternion.identity;
if (characterControllerComponent)
characterControllerComponent.Teleport(pos, rot, snapToGround: true);
else
transform.SetPositionAndRotation(pos, rot);
RPC_OnPlacedForOwner();
}
[Rpc(RpcSources.StateAuthority, RpcTargets.InputAuthority)]
private void RPC_OnPlacedForOwner()
{
characterControllerComponent?.ResumeMovement();
characterAttackComponent?.ResumeAutoAttack();
if (LoadingManager.Singleton && LoadingManager.Singleton.isLoading)
LoadingManager.Singleton.Close();
}
[Rpc(RpcSources.InputAuthority, RpcTargets.StateAuthority)]
private void RPC_RequestSetNickname(string nick)
{
if (!Object || !Object.HasStateAuthority)
return;
PlayerNick = SanitizeNickname(nick);
}
[Rpc(RpcSources.All, RpcTargets.StateAuthority)]
public void RPC_AssignTeam(byte team)
{
TeamId = team;
Debug.Log($"[PVP] TeamId set -> {team} ({PlayerNick})");
}
private void OnTeamChanged()
{
RefreshTeamObjects();
SelectHpBarVariant();
}
public void RefreshTeamObjects()
{
bool isPvp = GameplayManager.Singleton && GameplayManager.Singleton.IsPvp;
if (teamObjects == null || teamObjects.Length == 0) return;
int myTeam = 0;
#if FUSION2
myTeam = TeamId;
#else
myTeam = Team;
#endif
for (int i = 0; i < teamObjects.Length; i++)
{
if (!teamObjects[i]) continue;
teamObjects[i].SetActive(isPvp && i == myTeam);
}
SelectHpBarVariant();
}
/// <summary>
/// Sets the nickname locally; in online sessions it propagates to StateAuthority.
/// </summary>
/// <param name="newNick">Desired nickname</param>
/// <param name="propagateIfOnline">If true and online, requests host to update the networked property.</param>
public void SetNickname(string newNick, bool propagateIfOnline = true)
{
string sanitized = SanitizeNickname(newNick);
if (Runner && Object)
{
if (Object.HasStateAuthority)
{
PlayerNick = sanitized;
}
else if (Object.HasInputAuthority && propagateIfOnline)
{
RPC_RequestSetNickname(sanitized);
}
}
else
{
if (playerNicknameText != null)
playerNicknameText.text = sanitized;
}
}
/// <summary>
/// Trims and clamps nickname length.
/// </summary>
private static string SanitizeNickname(string raw)
{
if (string.IsNullOrWhiteSpace(raw))
return "Player";
string trimmed = raw.Trim();
return trimmed.Length > 31 ? trimmed.Substring(0, 31) : trimmed;
}
private void OnMiniHudChanged()
{
if (Object.HasStateAuthority) return;
if (characterUIHandlerComponent != null)
{
characterUIHandlerComponent.SetHpImmediate(NetHP, NetHPMax);
characterUIHandlerComponent.SetMpImmediate(NetMP, NetMPMax);
characterUIHandlerComponent.SetShieldImmediate(NetShield, NetHPMax);
}
if (UIGameplay.Singleton)
{
float hpNorm = NetHPMax > 0 ? (float)NetHP / NetHPMax : 0f;
UIGameplay.Singleton.UpdateAllyMiniHud(this, hpNorm);
}
invincibleIcon?.gameObject.SetActive((NetIcons & 1) != 0);
buffIcon?.gameObject.SetActive((NetIcons & 2) != 0);
debuffIcon?.gameObject.SetActive((NetIcons & 4) != 0);
}
private void SelectHpBarVariant()
{
bool isLocalOwner =
#if FUSION2
Object && Object.HasInputAuthority;
#else
true;
#endif
if (hpBar) hpBar.gameObject.SetActive(false);
if (hpBarAlly) hpBarAlly.gameObject.SetActive(false);
if (hpBarEnemy) hpBarEnemy.gameObject.SetActive(false);
Image chosen = hpBar;
if (isLocalOwner || !IsNetworked)
{
if (hpBar) hpBar.gameObject.SetActive(true);
chosen = hpBar ?? chosen;
}
else
{
bool isPvp = GameplayManager.Singleton && GameplayManager.Singleton.IsPvp;
bool isAlly = true;
#if FUSION2
if (isPvp)
{
byte localTeam = 255;
CharacterEntity local = null;
#if UNITY_6000_0_OR_NEWER
var all = FindObjectsByType<CharacterEntity>(FindObjectsSortMode.None);
#else
var all = FindObjectsOfType<CharacterEntity>();
#endif
foreach (var c in all)
if (c && c.Object && c.Object.HasInputAuthority) { local = c; break; }
if (local) localTeam = local.TeamId;
isAlly = local && TeamId == localTeam;
}
else
{
isAlly = true;
}
#endif
if (isAlly && hpBarAlly)
{
hpBarAlly.gameObject.SetActive(true);
chosen = hpBarAlly;
}
else if (!isAlly && hpBarEnemy)
{
hpBarEnemy.gameObject.SetActive(true);
chosen = hpBarEnemy;
}
else
{
if (hpBar) hpBar.gameObject.SetActive(true);
chosen = hpBar ?? chosen;
}
}
if (chosen != null) hpBar = chosen;
}
/// <summary>
/// Pushes current HP/MP/Shield and status icons to networked mini-HUD (authority only).
/// </summary>
private void PushMiniHudFromStats()
{
if (!Object || !Object.HasStateAuthority) return;
ushort hp = ToUShort(characterStatsComponent.CurrentHP);
ushort hpMax = ToUShort(characterStatsComponent.MaxHp);
ushort mp = ToUShort(characterStatsComponent.CurrentMP);
ushort mpMax = ToUShort(characterStatsComponent.MaxMp);
ushort shield = ToUShort(characterStatsComponent.CurrentShield);
bool inv = isInvincible;
bool buff = activeBuffCount > 0;
bool debuf = false;
PushMiniHud(hp, hpMax, mp, mpMax, shield, inv, buff, debuf);
}
public void PushMiniHud(ushort hp, ushort hpMax, ushort mp, ushort mpMax, ushort shield, bool inv, bool buff, bool debuff)
{
if (!Object || !Object.HasStateAuthority) return;
NetHP = hp;
NetHPMax = hpMax;
NetMP = mp;
NetMPMax = mpMax;
NetShield = shield;
byte flags = 0;
if (inv) flags |= 1 << 0;
if (buff) flags |= 1 << 1;
if (debuff) flags |= 1 << 2;
NetIcons = flags;
}
private static ushort ToUShort(float v)
{
int iv = Mathf.RoundToInt(v);
if (iv < 0) iv = 0;
if (iv > 65535) iv = 65535;
return (ushort)iv;
}
/// <summary>
/// Server-side RPC that validates the caller and deduplicates each hit before applying.
/// </summary>
[Rpc(RpcSources.All, RpcTargets.StateAuthority)]
private void RPC_RequestPlayerDamage(float amount, bool critical, NetworkId attackerId, ulong hitId, RpcInfo info = default)
{
if (!Object || !Object.HasStateAuthority) return;
if (amount <= 0f) return;
var src = info.Source;
if (attackerId.IsValid && Runner.TryFindObject(attackerId, out var attackerObj))
{
if (src != attackerObj.InputAuthority && src != Object.InputAuthority)
return;
}
var key = new HitKey { Attacker = attackerId, HitId = hitId };
PruneSeenPlayerHits();
if (_seenPlayerHits.ContainsKey(key)) return;
_seenPlayerHits[key] = Time.time;
if (attackerId.IsValid) _lastHitBy = attackerId;
ApplyDamageAuthority(ScalePvpDamageIfNeeded(attackerId, amount), critical);
}
#endif
#if FUSION2
private float ScalePvpDamageIfNeeded(NetworkId attackerId, float raw)
{
if (!PvpSync.Instance) return raw;
if (!attackerId.IsValid) return raw;
if (Runner.TryFindObject(attackerId, out var attObj))
{
if (attObj && attObj.GetComponent<CharacterEntity>())
{
var mult = Mathf.Clamp01(PvpSync.Instance.PlayerDamageTakenMultiplier);
return raw * mult;
}
}
return raw;
}
#endif
#if FUSION2
[Rpc(RpcSources.StateAuthority, RpcTargets.All)]
private void RPC_SpawnDamagePopup(int amount, bool critical)
{
DamagePopup.Show(amount,transform.position + Vector3.up * 1.6f, critical);
}
#endif
/// <summary>
/// Instantiates the character model from CharacterData and sets up the _anim reference.
/// </summary>
public void UpdateCharacterModel()
{
if (characterData == null)
{
Debug.LogError("CharacterData is null.");
return;
}
// Destroy any existing child models to avoid duplicates
foreach (Transform child in characterModelTransform)
{
Destroy(child.gameObject);
}
// Choose model from skins or default
CharacterModel selectedModel = null;
if (characterData.characterSkins != null &&
characterData.characterSkins.Length > 0 &&
skinIndex >= 0 &&
skinIndex < characterData.characterSkins.Length)
{
selectedModel = characterData.characterSkins[skinIndex].skinCharacterModel;
}
else
{
selectedModel = characterData.characterModel;
}
// Instantiate model if valid
if (selectedModel != null)
{
characterModel = Instantiate(selectedModel, characterModelTransform.position, characterModelTransform.rotation, characterModelTransform);
// Try to find the Animator on the instantiated model
if (!characterModel.TryGetComponent(out animator))
{
animator = characterModel.GetComponentInChildren<Animator>();
}
if (animator == null)
{
Debug.LogError("Animator component not found on the character model or its children.");
}
}
else
{
Debug.LogError("No valid character model found in CharacterData.");
}
}
/// <summary>
/// Initializes all core character components using the assigned CharacterData.
/// </summary>
public void InitializeCharacter()
{
// Cache component references if not assigned
if (characterStatsComponent == null)
characterStatsComponent = GetComponent<CharacterStatsComponent>();
if (characterControllerComponent == null)
characterControllerComponent = GetComponent<CharacterControllerComponent>();
if (characterAttackComponent == null)
characterAttackComponent = GetComponent<CharacterAttackComponent>();
if (characterBuffsComponent == null)
characterBuffsComponent = GetComponent<CharacterBuffsComponent>();
if(characterUIHandlerComponent == null)
characterUIHandlerComponent = GetComponent<CharacterUIHandlerComponent>();
// Validate essential components before initializing
if (characterData == null)
{
Debug.LogWarning("CharacterData is null. Initialization aborted.");
return;
}
if (characterUIHandlerComponent != null)
characterUIHandlerComponent.Initialize(this);
else
Debug.LogWarning("CharacterUIHandlerComponent not found.");
if (characterStatsComponent != null)
characterStatsComponent.Initialize(characterData);
else
Debug.LogWarning("CharacterStatsComponent not found.");
if (characterAttackComponent != null)
characterAttackComponent.Initialize(characterData, launchTransform, effectsTransform);
else
Debug.LogWarning("CharacterAttackComponent not found.");
if (characterBuffsComponent != null)
characterBuffsComponent.Initialize();
else
Debug.LogWarning("CharacterBuffsComponent not found.");
}
#endregion
#region ------------------------------- Update Loop -------------------------------
private void UpdateStatPerkUI()
{
UIGameplay.Singleton.UpdateSkillPerkUI();
}
#endregion
#region ------------------------------- Skill & Attack -------------------------------
/// <summary>
/// Executes the auto-attack action using the AutoAttack skill from characterData.
/// </summary>
public override void Attack()
{
if (isStunned) return;
base.Attack();
if (characterData == null || characterData.autoAttack == null)
{
Debug.LogWarning("Cannot perform auto-attack: characterData or autoAttack skill is null.");
return;
}
if (GameplayManager.Singleton.IsPaused() || !canAutoAttack)
return;
characterAttackComponent.Attack();
}
/// <summary>
/// Uses the skill at the specified index and launches it in the specified direction.
/// The skill is launched from the launchTransform in the direction specified.
/// </summary>
/// <param name="index">The index of the skill to use.</param>
/// <param name="inputDirection">The input direction from a joystick or other source.</param>
public override void UseSkill(int index, Vector2 inputDirection)
{
if (isStunned) return;
base.UseSkill(index, inputDirection);
if (characterData == null || characterData.skills == null)
{
Debug.LogWarning("Cannot use skill: characterData or skills array is null.");
return;
}
if (index < 0 || index >= characterData.skills.Length)
{
Debug.LogWarning($"Skill index {index} is out of range.");
return;
}
SkillData skill = characterData.skills[index];
if (skill == null)
{
Debug.LogWarning($"Skill at index {index} is null.");
return;
}
if (GameplayManager.Singleton.IsPaused())
return;
if (!ConsumeMP(skill.manaCost))
{
DisplayMPInsufficientMessage("Not enough MP to use skill.");
return;
}
characterAttackComponent.UseSkill(index, skill, inputDirection);
}
public void PlayAttack()
{
if (characterModel == null) return;
#if FUSION2
if (Runner)
{
if (!Object.HasInputAuthority || isStunned) return;
RPC_PlayAttack();
}
#endif
EventBus.Publish(new AnimationOnActionEvent(characterModel, true));
}
public void PlaySkill(int skillIndex)
{
if (characterModel == null) return;
#if FUSION2
if (Runner)
{
if (!Object.HasInputAuthority || isStunned) return;
RPC_PlaySkill(skillIndex);
}
#endif
EventBus.Publish(new AnimationOnActionEvent(characterModel, false, skillIndex));
}
public void PlayReceiveDamage()
{
if (characterModel == null) return;
EventBus.Publish(new AnimationOnReceiveDamageEvent(characterModel));
}
public void PlayDeath()
{
if (characterModel == null) return;
EventBus.Publish(new AnimationOnDiedEvent(characterModel));
}
#if FUSION2
//─────────────────────── RPCs ───────────────────────//
[Rpc(RpcSources.InputAuthority, RpcTargets.All)]
/// <summary>Play autoattack animation on all clients.</summary>
public void RPC_PlayAttack()
{
if (Object.HasInputAuthority) return;
EventBus.Publish(new AnimationOnActionEvent(characterModel, true));
}
[Rpc(RpcSources.InputAuthority, RpcTargets.All)]
/// <summary>Play skill animation on all clients.</summary>
public void RPC_PlaySkill(int skillIndex)
{
if (Object.HasInputAuthority) return;
EventBus.Publish(new AnimationOnActionEvent(characterModel, false, skillIndex));
}
[Rpc(RpcSources.StateAuthority, RpcTargets.All)]
/// <summary>Play receivedamage animation on all clients.</summary>
public void RPC_PlayReceiveDamage()
{
if (Object.HasInputAuthority) return;
EventBus.Publish(new AnimationOnReceiveDamageEvent(characterModel));
}
[Rpc(RpcSources.StateAuthority, RpcTargets.All)]
private void RPC_PlayMoveAnim(bool moving, Vector2 dir)
{
if (Object.HasInputAuthority)
return;
if (moving)
EventBus.Publish(new AnimationOnRunEvent(characterModel, dir));
else
EventBus.Publish(new AnimationOnIdleEvent(characterModel));
}
[Rpc(RpcSources.InputAuthority, RpcTargets.All)]
private void RPC_ApplyRotateCharacterModel(Vector3 dir, float duration)
{
if (Object.HasInputAuthority)
return;
RotateCharacterModelAsync(dir, duration, this.GetCancellationTokenOnDestroy()).Forget();
}
#endif
/// <summary>
/// Resets the ability to auto-attack after a delay.
/// </summary>
/// <param name="delay">The time to wait before resetting auto-attack.</param>
public void ApplyResetAutoAttack(float delay)
{
ResetAutoAttackAsync(0.8f, this.GetCancellationTokenOnDestroy()).Forget();
}
/// <summary>
/// Applies a skill perk and manages its execution based on level and cooldown.
/// </summary>
/// <param name="skillPerk">The skill perk to apply.</param>
public void ApplySkillPerk(SkillPerkData skillPerk)
{
characterAttackComponent.ApplySkillPerk(skillPerk, false);
}
#endregion
#region ------------------------------- Stats / Buffs / Debuffs -------------------------------
public void ApplyStatPerk(StatPerkData statPerk, int level) => characterStatsComponent.ApplyStatPerk(statPerk, level);
/// <summary>
/// Tries to consume the specified amount of MP. Returns true if successful.
/// </summary>
/// <param name="amount">Amount of mana to consume.</param>
/// <returns>True if mana was consumed, false if insufficient mana.</returns>
public bool ConsumeMP(float amount) => characterStatsComponent.ConsumeMP(amount);
private void IncrementBuffCount()
{
activeBuffCount++;
}
private void DecrementBuffCount()
{
activeBuffCount--;
activeBuffCount = Mathf.Max(activeBuffCount, 0);
}
/// <summary>
/// Applies a positive move speed buff and reverts after duration.
/// Ensures the amount is always positive.
/// </summary>
public void ApplyMoveSpeedBuff(float amount, float duration)
{
amount = Mathf.Abs(amount);
characterBuffsComponent.ApplyMoveSpeedBuff(amount, duration);
}
/// <summary>
/// Applies a negative move speed debuff and reverts after duration.
/// Ensures the amount is always negative.
/// </summary>
public void ReceiveMoveSpeedDebuff(float amount, float duration)
{
amount = -Mathf.Abs(amount);
characterBuffsComponent.ApplyMoveSpeedBuff(amount, duration);
}
/// <summary>
/// Temporarily increases the character's attack speed and reverts after duration.
/// </summary>
public void ApplyAttackSpeedBuff(float amount, float duration)
{
characterBuffsComponent.ApplyAttackSpeedBuff(amount, duration);
}
/// <summary>
/// Temporarily increases the character's defense and reverts after duration.
/// </summary>
public void ApplyDefenseBuff(float amount, float duration)
{
characterBuffsComponent.ApplyDefenseBuff(amount, duration);
}
/// <summary>
/// Temporarily increases the character's damage and reverts after duration.
/// </summary>
public void ApplyDamageBuff(float extraDamage, float duration)
{
characterBuffsComponent.ApplyDamageBuff(extraDamage, duration);
}
/// <summary>
/// Increases the shield of the character.
/// </summary>
/// <param name="amount">The amount of shield to increase.</param>
public void ApplyShield(int amount)
{
characterBuffsComponent.ApplyShield(amount);
}
/// <summary>
/// Increases the shield of the character for a specified duration.
/// </summary>
/// <param name="amount">The amount of shield to increase.</param>
/// <param name="duration">Duration in seconds for which the shield remains.</param>
/// <param name="isTemporary">Set to true if the buff is temporary. Default is false. </param>
public void ApplyShield(int amount, float duration = 0f, bool isTemporary = false)
{
characterBuffsComponent.ApplyShield(amount, duration, isTemporary);
}
/// <summary>
/// Increases the maximum HP of the character.
/// </summary>
/// <param name="amount">Amount to increase max HP by.</param>
public void AddMaxHP(int amount)
{
amount = Mathf.Abs(amount);
characterStatsComponent.AlterMaxHp(amount);
}
/// <summary>
/// Decreases the maximum HP of the character.
/// </summary>
/// <param name="amount">Amount to increase max HP by.</param>
public void RemoveMaxHP(int amount)
{
amount = -Mathf.Abs(amount);
characterStatsComponent.AlterMaxHp(amount);
}
/// <summary>
/// Sets the character invincible for a duration.
/// </summary>
/// <param name="duration">Duration in seconds.</param>
public override void ApplyInvincible(float duration)
{
isInvincible = true;
RemoveInvincibilityAfterAsync(duration, this.GetCancellationTokenOnDestroy()).Forget();
}
/// <summary>
/// Heals the character by a specified amount up to their maximum health.
/// </summary>
/// <param name="amount">The amount to heal.</param>
public void ApplyHealHP(int amount)
{
amount = Mathf.Abs(amount);
characterStatsComponent.HealHP(amount);
}
/// <summary>
/// Heals the character's MP.
/// </summary>
/// <param name="amount">The amount to heal.</param>
public void ApplyHealMP(int amount)
{
amount = Mathf.Abs(amount);
characterStatsComponent.HealMP(amount);
}
/// <summary>
/// Applies health leech based on the damage dealt.
/// </summary>
/// <param name="damage">The total damage dealt.</param>
public void ApplyHpLeech(float damage)
{
characterStatsComponent.ApplyHpLeech(damage);
}
/// <summary>
/// Increases the cooldown reduction percentage.
/// </summary>
/// <param name="amount">The amount to increase cooldown reduction by.</param>
public void AddCooldownReduction(float amount)
{
amount = Mathf.Abs(amount);
characterStatsComponent.AlterCooldownReduction(amount);
}
/// <summary>
/// Decreases the cooldown reduction percentage.
/// </summary>
/// <param name="amount">The amount to increase cooldown reduction by.</param>
public void RemoveCooldownReduction(float amount)
{
amount = -Mathf.Abs(amount);
characterStatsComponent.AlterCooldownReduction(amount);
}
/// <summary>
/// Adds XP to the character and handles leveling up.
/// </summary>
/// <param name="xpAmount">The amount of XP to add.</param>
public void AddXP(int xpAmount)
{
xpAmount = Mathf.Abs(xpAmount);
characterStatsComponent.AddXP(xpAmount);
}
public void ReceiveKnockback(Vector3 senderDirection,float distance, float duration)
{
characterControllerComponent.ApplyKnockback(senderDirection,distance,duration);
}
public void ReceiveStun(float duration)
{
stunCts?.Cancel();
stunCts = new CancellationTokenSource();
StunRoutine(duration, stunCts.Token).Forget();
}
public void ReceiveDot(MonsterSkill sourceSkill, int totalDmg, float duration)
{
characterStatsComponent.ApplyDot(sourceSkill, totalDmg, duration);
}
#endregion
#region ------------------------------- Animation & Movement -------------------------------
/// <summary>
/// Moves the character and handles animation locally. Triggers animation RPC only when state changes.
/// </summary>
/// <param name="worldDir">Direction to move in world space.</param>
public override void Move(Vector3 worldDir)
{
base.Move(worldDir);
bool isMoving = worldDir.sqrMagnitude > 0.01f;
Vector2 currentDir2D = Vector2.zero;
if (isMoving)
{
Vector3 local3D = characterModelTransform.InverseTransformDirection(new Vector3(worldDir.x, 0f, worldDir.z));
currentDir2D = new Vector2(local3D.x, local3D.z).normalized;
}
// ─── Always trigger animation locally ──────────────────────
if (isMoving)
EventBus.Publish(new AnimationOnRunEvent(characterModel, currentDir2D));
else
EventBus.Publish(new AnimationOnIdleEvent(characterModel));
#if FUSION2
if (Runner && Object.HasStateAuthority)
{
bool sendMoveRpc = false;
// Start/stop movement change
if (isMoving != _wasNetworkedMoving)
{
sendMoveRpc = true;
}
// Direction change while moving
if (isMoving && Vector2.Distance(currentDir2D, _lastSentDirection) > 0.1f)
{
sendMoveRpc = true;
}
if (sendMoveRpc)
{
_wasNetworkedMoving = isMoving;
_lastSentDirection = currentDir2D;
RPC_PlayMoveAnim(isMoving, currentDir2D);
}
}
#endif
characterControllerComponent.Move(worldDir);
wasMoving = isMoving;
}
public void ApplyStopMovement(float duration, bool allowRotation = false)
{
StopMovementAsync(duration, allowRotation, this.GetCancellationTokenOnDestroy()).Forget();
}
/// <summary>
/// Smoothly rotates the visible model to a direction.
/// Cancels any previous rotation to avoid jitter.
/// </summary>
public void ApplyRotateCharacterModel(Vector3 dir, float duration, CancellationToken token)
{
RotateCharacterModelAsync(dir, duration, token).Forget();
#if FUSION2
//if (Runner && Object.HasInputAuthority)
// RPC_ApplyRotateCharacterModel(dir, duration);
#endif
}
#endregion
#region ------------------------------- UI -------------------------------
/// <summary>
/// Displays a status message related to insufficient MP or other notifications.
/// </summary>
/// <param name="message">The message to display.</param>
public void DisplayMPInsufficientMessage(string message)
{
insufficientMPText.text = message;
HideStatusMessageAfterDelayAsync(insufficientMPText, 1.5f, this.GetCancellationTokenOnDestroy()).Forget();
}
/// <summary>
/// Updates the directional aim's rotation based on the joystick input and updates the corresponding indicator.
/// </summary>
/// <param name="joystickDirection">The direction from the skill joystick.</param>
/// <param name="skillIndex">Index of the skill being aimed.</param>
public void UpdateDirectionalAim(Vector2 joystickDirection, int skillIndex)
{
if (directionalAim == null)
{
Debug.LogWarning("Directional aim object is not set.");
return;
}
// Rotate visual
if (joystickDirection.magnitude > 0)
{
Vector3 direction3D = new Vector3(joystickDirection.x, 0, joystickDirection.y).normalized;
directionalAim.transform.rotation = Quaternion.LookRotation(direction3D, Vector3.up);
}
if (!directionalAim.activeSelf || indicatorManager == null)
return;
SkillData skillData = GetSkillData(skillIndex);
if (skillData == null || skillData.rangeIndicatorType == RangeIndicatorType.None)
return;
float maxRange = skillData.rangeIndicatorType switch
{
RangeIndicatorType.RadialAoE => skillData.radialAoEIndicatorSettings.radiusMaxRange,
RangeIndicatorType.Radial => skillData.radialIndicatorSettings.radiusArea,
_ => 1f
};
Vector3 aimWorldPos = GetAimWorldPosition(joystickDirection, maxRange);
switch (skillData.rangeIndicatorType)
{
case RangeIndicatorType.Radial:
indicatorManager.ShowCircleRangeIndicator(maxRange);
break;
case RangeIndicatorType.RadialAoE:
indicatorManager.ShowCircleRangeIndicator(skillData.radialAoEIndicatorSettings.radiusMaxRange);
indicatorManager.ShowPositionAoECircle(skillData.radialAoEIndicatorSettings.radiusArea, aimWorldPos);
break;
case RangeIndicatorType.Arrow:
indicatorManager.ShowArrowIndicator();
indicatorManager.UpdateArrowIndicator(transform.position, aimWorldPos.magnitude, maxRange, directionalAim.transform.rotation);
break;
case RangeIndicatorType.Cone:
indicatorManager.ShowConeIndicator();
indicatorManager.UpdateConeIndicator(transform.position, aimWorldPos.magnitude, maxRange, directionalAim.transform.rotation);
break;
}
}
/// <summary>
/// Activates or deactivates the directional aim and manages indicator animations.
/// </summary>
/// <param name="isActive">True to activate the directional aim; false to deactivate.</param>
/// <param name="skillIndex">Index of the skill being used.</param>
/// <param name="skillUsed">True if the skill was used (triggering cast indicator animation); false if canceled.</param>
public void SetDirectionalAimActive(bool isActive, int skillIndex, bool skillUsed)
{
if (directionalAim != null)
directionalAim.SetActive(isActive);
if (!isActive && indicatorManager != null)
{
SkillData skillData = GetSkillData(skillIndex);
if (skillData == null || skillData.rangeIndicatorType == RangeIndicatorType.None)
return;
indicatorManager.HidePositionAoECircle();
if (!skillUsed)
{
indicatorManager.CloseIndicators();
return;
}
float castDuration = skillData.delayToLaunch;
Vector3 startPos = transform.position;
Vector3 endPos = startPos + transform.forward * (
skillData.rangeIndicatorType == RangeIndicatorType.RadialAoE ?
skillData.radialAoEIndicatorSettings.radiusMaxRange : 1f
);
switch (skillData.rangeIndicatorType)
{
case RangeIndicatorType.Radial:
if (skillData.radialIndicatorSettings.useCastIndicator)
indicatorManager.StartCastCircleIndicator(skillData.radialIndicatorSettings.radiusArea, castDuration);
else
indicatorManager.CloseIndicators();
break;
case RangeIndicatorType.RadialAoE:
if (skillData.radialAoEIndicatorSettings.useCastIndicator)
{
indicatorManager.StartCastDamageIndicator(skillData.radialAoEIndicatorSettings.radiusArea, castDuration);
indicatorManager.ShowAoECurveLine(startPos, endPos);
StartCoroutine(indicatorManager.HideAoECurveLineAfterDelay(castDuration + 0.1f));
}
else
{
indicatorManager.CloseIndicators();
}
break;
case RangeIndicatorType.Arrow:
if (skillData.arrowIndicatorSettings.useCastIndicator)
indicatorManager.StartCastArrowIndicator(skillData.arrowIndicatorSettings.arrowSize, castDuration);
else
indicatorManager.CloseIndicators();
break;
case RangeIndicatorType.Cone:
if (skillData.coneIndicatorSettings.useCastIndicator)
indicatorManager.StartCastConeIndicator(skillData.coneIndicatorSettings.ConeSize, castDuration);
else
indicatorManager.CloseIndicators();
break;
}
}
}
#endregion
#region ------------------------------- Damage & Life Cycle -------------------------------
/// <summary>
/// Public entry point: only owner/authority should cause damage; dedup by (attacker, hitId).
/// Offline falls back to local application.
/// </summary>
public void ApplyDamageToSelfFromHit(
#if FUSION2
NetworkObject attacker,
#else
object attacker,
#endif
ulong hitId, float amount, bool critical)
{
#if FUSION2
if (Runner == null || !Runner.IsRunning || Object == null)
{
ApplyDamageAuthority(ScalePvpDamageIfNeeded(attacker ? attacker.Id : default, amount), critical);
return;
}
if (Object.HasStateAuthority)
{
var key = new HitKey { Attacker = attacker ? attacker.Id : default, HitId = hitId };
PruneSeenPlayerHits();
if (_seenPlayerHits.ContainsKey(key)) return;
_seenPlayerHits[key] = Time.time;
if (attacker)
_lastHitBy = attacker.Id;
ApplyDamageAuthority(ScalePvpDamageIfNeeded(attacker ? attacker.Id : default, amount), critical);
}
else
{
RPC_RequestPlayerDamage(amount, critical, attacker ? attacker.Id : default, hitId);
}
#else
ApplyDamageAuthority(amount, critical);
#endif
}
/// <summary>
/// Applies damage on the authoritative instance and updates network/local UI.
/// </summary>
private void ApplyDamageAuthority(float amount, bool critical)
{
if (amount <= 0f) return;
if (GameplayManager.Singleton.IsPaused()) return;
if (isInvincible) return;
characterStatsComponent.ReceiveDamage(amount);
#if FUSION2
if (Runner && Object && Object.HasStateAuthority)
{
RPC_SpawnDamagePopup(Mathf.Max(Mathf.RoundToInt(amount)), critical);
RPC_PlayReceiveDamage();
}
#endif
PlayReceiveDamage();
if (characterUIHandlerComponent != null)
{
characterUIHandlerComponent.SetHpImmediate(characterStatsComponent.CurrentHP, characterStatsComponent.MaxHp);
characterUIHandlerComponent.SetMpImmediate(characterStatsComponent.CurrentMP, characterStatsComponent.MaxMp);
characterUIHandlerComponent.SetShieldImmediate(characterStatsComponent.CurrentShield, characterStatsComponent.MaxHp);
}
#if FUSION2
if (Runner && Object && Object.HasStateAuthority)
PushMiniHudFromStats();
#endif
if (characterStatsComponent.CurrentHP <= 0f)
OnDeath();
}
/// <summary>
/// Receives damage, reducing shield first if available and then HP.
/// Damage is reduced by current defense but not lower than a global minimum.
/// </summary>
/// <param name="damage">The damage to receive.</param>
public override void ReceiveDamage(float damage)
{
#if FUSION2
if (Runner && Runner.IsRunning && Object && !Object.HasStateAuthority)
return;
#endif
base.ReceiveDamage(damage);
if (GameplayManager.Singleton.IsPaused()) return;
if (isInvincible) return;
characterStatsComponent.ReceiveDamage(damage);
}
public void CharacterRevive()
{
if (!IsDead) return;
IsDead = false;
characterStatsComponent.Revive();
canAutoAttack = true;
isMovementStopped = false;
characterControllerComponent.ResumeMovement();
characterAttackComponent.ResumeAutoAttack();
GameplayManager.Singleton?.MarkCharacterRevived(this);
#if FUSION2
GameplaySync.Instance?.SyncPlayerRevived(this);
#endif
OnCharacterRevive();
}
/// <summary>
/// called when the character revives.
/// </summary>
public override void OnCharacterRevive()
{
ApplyInvincible(reviveInvincibleTime);
}
/// <summary>
/// Final death routine runs once. Stops movement/inputs, plays animation,
/// notifies GameplayManager and (optionally) replicates via Fusion.
/// </summary>
public override void OnDeath()
{
if (IsDead) return;
IsDead = true;
#if FUSION2
if (Runner && Object && Object.HasStateAuthority && PvpSync.IsSpawnedReady && PvpSync.Instance)
{
NetworkObject killerObj = null;
if (_lastHitBy.IsValid) Runner.TryFindObject(_lastHitBy, out killerObj);
var killerCE = killerObj ? killerObj.GetComponent<CharacterEntity>() : null;
if (killerCE)
{
PvpSync.Instance.RPC_NotifyKill(killerCE.CharacterId, killerCE.PlayerNick,
this.CharacterId, this.PlayerNick, killerCE.TeamId);
if (PvpSync.Instance.Mode == PvpModeType.BattleRoyale)
PvpSync.Instance.AnnounceEliminationNow(this);
}
PvpSync.Instance.ScheduleRevive(this).Forget();
}
#endif
canAutoAttack = false;
isMovementStopped = true;
characterControllerComponent.CancelDash();
characterControllerComponent.StopMovement();
characterAttackComponent.StopAutoAttack();
PlayDeath();
directionalAim?.SetActive(false);
GameplayManager.Singleton?.MarkCharacterDead(this);
#if FUSION2
GameplaySync.Instance?.SyncPlayerDied(this);
#endif
}
#endregion
#region ------------------------------- Utility / Getters -------------------------------
/// <summary>
/// Returns the current CharacterData assigned to this entity.
/// </summary>
/// <returns>The character data, or null if not set.</returns>
public CharacterData GetCharacterData()
{
return characterData;
}
/// <summary>
/// Returns the CharacterTypeData from the current character, if available.
/// </summary>
/// <returns>The CharacterTypeData, or null if CharacterData is not assigned.</returns>
public CharacterTypeData GetCharacterType()
{
if (characterData == null)
{
Debug.LogWarning("CharacterData is null. Cannot retrieve CharacterType.");
return null;
}
return characterData.characterType;
}
/// <summary>
/// Retrieves the SkillData for a given skill index.
/// </summary>
/// <param name="index">The skill index.</param>
/// <returns>The SkillData object, or null if invalid.</returns>
public SkillData GetSkillData(int index)
{
if (characterData == null || characterData.skills == null)
{
Debug.LogWarning("CharacterData or skills array is null.");
return null;
}
if (index < 0 || index >= characterData.skills.Length)
{
Debug.LogWarning($"Invalid skill index: {index}. Array length: {characterData.skills.Length}");
return null;
}
return characterData.skills[index];
}
/// <summary>
/// Returns the index of a specific skill in the character's skill array.
/// </summary>
/// <param name="skill">The skill to find.</param>
/// <returns>The index of the skill, or -1 if not found.</returns>
public int GetSkillIndex(SkillData skill)
{
if (skill == null)
{
Debug.LogWarning("Skill is null. Cannot find index.");
return -1;
}
if (characterData == null || characterData.skills == null)
{
Debug.LogWarning("CharacterData or skills array is null. Cannot search for skill.");
return -1;
}
for (int index = 0; index < characterData.skills.Length; index++)
{
if (characterData.skills[index] == skill)
return index;
}
Debug.LogWarning($"Skill '{skill.skillName}' not found in the character's skills.");
return -1;
}
/// <summary>
/// Retrieves the icon sprite for a specific skill.
/// </summary>
/// <param name="skillIndex">The index of the skill in the character's skill array.</param>
/// <returns>The icon sprite of the skill, or null if invalid.</returns>
public Sprite GetSkillIcon(int skillIndex)
{
SkillData skill = GetSkillData(skillIndex);
return skill != null ? skill.icon : null;
}
/// <summary>
/// Retrieves the cooldown duration for a specific skill.
/// </summary>
/// <param name="skillIndex">The index of the skill in the character's skill array.</param>
/// <returns>The cooldown duration in seconds, or 0 if invalid.</returns>
public float GetSkillCooldown(int skillIndex)
{
SkillData skill = GetSkillData(skillIndex);
return skill != null ? skill.cooldown : 0f;
}
private Vector3 GetAimWorldPosition(Vector2 joystickDirection, float maxRange)
{
float joystickMag = Mathf.Clamp01(joystickDirection.magnitude);
float actualDistance = joystickMag * maxRange;
return transform.position + transform.forward * actualDistance;
}
/// <summary>
/// Retrieves a list of perks available to the player at level-up.
/// </summary>
/// <returns>List of perk options</returns>
private List<object> GetAvailablePerks() => GameplayManager.Singleton.GetRandomPerks();
public bool HasStatPerk(StatPerkData perk) => characterStatsComponent.HasStatPerk(perk);
/// <summary>
/// Retrieves the list of skill perks associated with the character.
/// </summary>
public List<SkillPerkData> GetSkillsPerkData() => characterAttackComponent.GetSkillsPerkData();
/// <summary>
/// Retrieves the list of stat perks associated with the character.
/// </summary>
public List<StatPerkData> GetStatsPerkData() => characterStatsComponent.GetStatsPerkData();
/// <summary>
/// Gets the current level of the character.
/// </summary>
public int GetCurrentLevel() => characterStatsComponent.CurrentLevel;
/// <summary>
/// Gets the current XP of the character.
/// </summary>
public int GetCurrentXP() => characterStatsComponent.CurrentXP;
/// <summary>
/// Gets the XP required for the next level.
/// </summary>
public int GetXPToNextLevel() => characterStatsComponent.GetXPToNextLevel();
public float GetCurrentHP() => characterStatsComponent.CurrentHP;
public float GetMaxHP() => characterStatsComponent.MaxHp;
public float GetCurrentHPRegen() => characterStatsComponent.CurrentHPRegen;
public float GetCurrentHPLeech() => characterStatsComponent.CurrentHPLeech;
public float GetCurrentMP() => characterStatsComponent.CurrentMP;
public float GetMaxMP() => characterStatsComponent.MaxMp;
public float GetCurrentMPRegen() => characterStatsComponent.CurrentMPRegen;
public float GetCurrentDamage() => characterStatsComponent.CurrentDamage;
public float GetCurrentAttackSpeed() => characterStatsComponent.CurrentAttackSpeed;
public float GetCurrentCooldownReduction() => characterStatsComponent.CurrentCooldownReduction;
public float GetCurrentCriticalRate() => characterStatsComponent.CurrentCriticalRate;
public float GetCurrentCriticalDamageMultiplier() => characterStatsComponent.CurrentCriticalDamageMultiplier;
public float GetCurrentDefense() => characterStatsComponent.CurrentDefense;
public float GetCurrentShield() => characterStatsComponent.CurrentShield;
public float GetCurrentMoveSpeed() => characterStatsComponent.CurrentMoveSpeed;
public float GetCurrentCollectRange() => characterStatsComponent.CurrentCollectRange;
public float GetCurrentMaxStats() => characterStatsComponent.CurrentMaxStats;
public float GetCurrentMaxSkills() => characterStatsComponent.CurrentMaxSkills;
#endregion
#region ------------------------------- Coroutines -------------------------------
/// <summary>
/// Rotates the character model visually toward the given direction for a duration.
/// Y component is ignored to prevent vertical tilt.
/// </summary>
/// <param name="direction">Direction to face.</param>
/// <param name="duration">Duration of the rotation effect.</param>
/// <param name="token">Optional cancellation token.</param>
private async UniTask RotateCharacterModelAsync(Vector3 direction, float duration, CancellationToken token = default)
{
if (!this || transform == null) return;
if (characterModelTransform == null)
{
Debug.LogWarning("Character model transform is not set.");
return;
}
Vector3 flatDirection = new Vector3(direction.x, 0f, direction.z).normalized;
if (flatDirection == Vector3.zero)
{
return;
}
Quaternion targetRotation = Quaternion.LookRotation(flatDirection);
float timer = 0f;
try
{
while (timer < duration)
{
token.ThrowIfCancellationRequested();
Quaternion desiredLocalRotation = Quaternion.Inverse(transform.rotation) * targetRotation;
characterModelTransform.localRotation = desiredLocalRotation;
timer += Time.deltaTime;
await UniTask.Yield(PlayerLoopTiming.Update, token);
}
}
catch (OperationCanceledException)
{
}
finally
{
characterModelTransform.localRotation = Quaternion.identity;
}
}
/// <summary>
/// Disables auto-attack for a duration, then re-enables it.
/// Uses UniTask for better performance and cancelation.
/// </summary>
/// <param name="delay">Delay before allowing auto-attack again.</param>
/// <param name="token">Optional cancellation token.</param>
private async UniTask ResetAutoAttackAsync(float delay, CancellationToken token = default)
{
canAutoAttack = false;
if (delay > 0f)
await UniTask.Delay(TimeSpan.FromSeconds(delay), cancellationToken: token);
canAutoAttack = true;
}
/// <summary>
/// Removes invincibility after a set duration.
/// Uses UniTask for better performance and optional cancellation.
/// </summary>
/// <param name="duration">The time to remain invincible.</param>
/// <param name="token">Optional cancellation token.</param>
private async UniTask RemoveInvincibilityAfterAsync(float duration, CancellationToken token = default)
{
if (duration > 0f)
await UniTask.Delay(TimeSpan.FromSeconds(duration), cancellationToken: token);
isInvincible = false;
}
/// <summary>
/// Stops the player's movement for a specified duration using UniTask.
/// </summary>
/// <param name="duration">Duration in seconds to stop movement.</param>
/// <param name="allowRotation">If true, allows rotation while movement is stopped.</param>
/// <param name="token">Optional cancellation token.</param>
private async UniTask StopMovementAsync(float duration, bool allowRotation, CancellationToken token = default)
{
if (characterControllerComponent == null)
{
Debug.LogWarning("CharacterController is not assigned.");
return;
}
characterControllerComponent.StopMovement(allowRotation);
isMovementStopped = true;
if (duration > 0f)
await UniTask.Delay(TimeSpan.FromSeconds(duration), cancellationToken: token);
characterControllerComponent.ResumeMovement();
isMovementStopped = false;
}
/// <summary>
/// Internal coroutine that enforces the stun and restores the previous state.
/// </summary>
private async UniTaskVoid StunRoutine(float duration, CancellationToken token)
{
if (isStunned) return;
bool prevAutoAttack = canAutoAttack;
bool prevMovementStop = isMovementStopped;
characterControllerComponent.CancelDash();
characterControllerComponent.StopMovement();
isMovementStopped = true;
canAutoAttack = false;
isStunned = true;
try
{
await UniTask.Delay(TimeSpan.FromSeconds(duration), cancellationToken: token);
}
catch (OperationCanceledException) { }
finally
{
isStunned = false;
canAutoAttack = prevAutoAttack;
if (!prevMovementStop)
{
characterControllerComponent.ResumeMovement();
isMovementStopped = false;
}
}
}
/// <summary>
/// Hides the status message after a delay using UniTask.
/// </summary>
/// <param name="statusText">The TextMeshProUGUI component displaying the status message.</param>
/// <param name="delay">The delay in seconds before hiding the message.</param>
/// <param name="token">Optional cancellation token.</param>
private async UniTask HideStatusMessageAfterDelayAsync(TextMeshProUGUI statusText, float delay, CancellationToken token = default)
{
if (delay > 0f)
await UniTask.Delay(TimeSpan.FromSeconds(delay), cancellationToken: token);
if (statusText != null)
statusText.text = string.Empty;
}
#endregion
}
}