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; } /// /// Networked player nickname (fixed-capacity string). Use .ToString() to read. /// [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 { 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 _seenPlayerHits = new(); private const float PLAYER_HIT_TTL = 3f; private List _seenHitsTmp; private void PruneSeenPlayerHits() { if (_seenPlayerHits.Count == 0) return; float now = Time.time; _seenHitsTmp ??= new List(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(); } /// /// Sets the character data and updates the character model. /// /// The character data to set. 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(); } /// /// Sets the nickname locally; in online sessions it propagates to StateAuthority. /// /// Desired nickname /// If true and online, requests host to update the networked property. 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; } } /// /// Trims and clamps nickname length. /// 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(FindObjectsSortMode.None); #else var all = FindObjectsOfType(); #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; } /// /// Pushes current HP/MP/Shield and status icons to networked mini-HUD (authority only). /// 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; } /// /// Server-side RPC that validates the caller and deduplicates each hit before applying. /// [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()) { 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 /// /// Instantiates the character model from CharacterData and sets up the _anim reference. /// 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(); } 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."); } } /// /// Initializes all core character components using the assigned CharacterData. /// public void InitializeCharacter() { // Cache component references if not assigned if (characterStatsComponent == null) characterStatsComponent = GetComponent(); if (characterControllerComponent == null) characterControllerComponent = GetComponent(); if (characterAttackComponent == null) characterAttackComponent = GetComponent(); if (characterBuffsComponent == null) characterBuffsComponent = GetComponent(); if(characterUIHandlerComponent == null) characterUIHandlerComponent = GetComponent(); // 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 ------------------------------- /// /// Executes the auto-attack action using the AutoAttack skill from characterData. /// 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(); } /// /// 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. /// /// The index of the skill to use. /// The input direction from a joystick or other source. 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)] /// Play auto‐attack animation on all clients. public void RPC_PlayAttack() { if (Object.HasInputAuthority) return; EventBus.Publish(new AnimationOnActionEvent(characterModel, true)); } [Rpc(RpcSources.InputAuthority, RpcTargets.All)] /// Play skill animation on all clients. public void RPC_PlaySkill(int skillIndex) { if (Object.HasInputAuthority) return; EventBus.Publish(new AnimationOnActionEvent(characterModel, false, skillIndex)); } [Rpc(RpcSources.StateAuthority, RpcTargets.All)] /// Play receive‐damage animation on all clients. 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 /// /// Resets the ability to auto-attack after a delay. /// /// The time to wait before resetting auto-attack. public void ApplyResetAutoAttack(float delay) { ResetAutoAttackAsync(0.8f, this.GetCancellationTokenOnDestroy()).Forget(); } /// /// Applies a skill perk and manages its execution based on level and cooldown. /// /// The skill perk to apply. 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); /// /// Tries to consume the specified amount of MP. Returns true if successful. /// /// Amount of mana to consume. /// True if mana was consumed, false if insufficient mana. public bool ConsumeMP(float amount) => characterStatsComponent.ConsumeMP(amount); private void IncrementBuffCount() { activeBuffCount++; } private void DecrementBuffCount() { activeBuffCount--; activeBuffCount = Mathf.Max(activeBuffCount, 0); } /// /// Applies a positive move speed buff and reverts after duration. /// Ensures the amount is always positive. /// public void ApplyMoveSpeedBuff(float amount, float duration) { amount = Mathf.Abs(amount); characterBuffsComponent.ApplyMoveSpeedBuff(amount, duration); } /// /// Applies a negative move speed debuff and reverts after duration. /// Ensures the amount is always negative. /// public void ReceiveMoveSpeedDebuff(float amount, float duration) { amount = -Mathf.Abs(amount); characterBuffsComponent.ApplyMoveSpeedBuff(amount, duration); } /// /// Temporarily increases the character's attack speed and reverts after duration. /// public void ApplyAttackSpeedBuff(float amount, float duration) { characterBuffsComponent.ApplyAttackSpeedBuff(amount, duration); } /// /// Temporarily increases the character's defense and reverts after duration. /// public void ApplyDefenseBuff(float amount, float duration) { characterBuffsComponent.ApplyDefenseBuff(amount, duration); } /// /// Temporarily increases the character's damage and reverts after duration. /// public void ApplyDamageBuff(float extraDamage, float duration) { characterBuffsComponent.ApplyDamageBuff(extraDamage, duration); } /// /// Increases the shield of the character. /// /// The amount of shield to increase. public void ApplyShield(int amount) { characterBuffsComponent.ApplyShield(amount); } /// /// Increases the shield of the character for a specified duration. /// /// The amount of shield to increase. /// Duration in seconds for which the shield remains. /// Set to true if the buff is temporary. Default is false. public void ApplyShield(int amount, float duration = 0f, bool isTemporary = false) { characterBuffsComponent.ApplyShield(amount, duration, isTemporary); } /// /// Increases the maximum HP of the character. /// /// Amount to increase max HP by. public void AddMaxHP(int amount) { amount = Mathf.Abs(amount); characterStatsComponent.AlterMaxHp(amount); } /// /// Decreases the maximum HP of the character. /// /// Amount to increase max HP by. public void RemoveMaxHP(int amount) { amount = -Mathf.Abs(amount); characterStatsComponent.AlterMaxHp(amount); } /// /// Sets the character invincible for a duration. /// /// Duration in seconds. public override void ApplyInvincible(float duration) { isInvincible = true; RemoveInvincibilityAfterAsync(duration, this.GetCancellationTokenOnDestroy()).Forget(); } /// /// Heals the character by a specified amount up to their maximum health. /// /// The amount to heal. public void ApplyHealHP(int amount) { amount = Mathf.Abs(amount); characterStatsComponent.HealHP(amount); } /// /// Heals the character's MP. /// /// The amount to heal. public void ApplyHealMP(int amount) { amount = Mathf.Abs(amount); characterStatsComponent.HealMP(amount); } /// /// Applies health leech based on the damage dealt. /// /// The total damage dealt. public void ApplyHpLeech(float damage) { characterStatsComponent.ApplyHpLeech(damage); } /// /// Increases the cooldown reduction percentage. /// /// The amount to increase cooldown reduction by. public void AddCooldownReduction(float amount) { amount = Mathf.Abs(amount); characterStatsComponent.AlterCooldownReduction(amount); } /// /// Decreases the cooldown reduction percentage. /// /// The amount to increase cooldown reduction by. public void RemoveCooldownReduction(float amount) { amount = -Mathf.Abs(amount); characterStatsComponent.AlterCooldownReduction(amount); } /// /// Adds XP to the character and handles leveling up. /// /// The amount of XP to add. 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 ------------------------------- /// /// Moves the character and handles animation locally. Triggers animation RPC only when state changes. /// /// Direction to move in world space. 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(); } /// /// Smoothly rotates the visible model to a direction. /// Cancels any previous rotation to avoid jitter. /// 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 ------------------------------- /// /// Displays a status message related to insufficient MP or other notifications. /// /// The message to display. public void DisplayMPInsufficientMessage(string message) { insufficientMPText.text = message; HideStatusMessageAfterDelayAsync(insufficientMPText, 1.5f, this.GetCancellationTokenOnDestroy()).Forget(); } /// /// Updates the directional aim's rotation based on the joystick input and updates the corresponding indicator. /// /// The direction from the skill joystick. /// Index of the skill being aimed. 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; } } /// /// Activates or deactivates the directional aim and manages indicator animations. /// /// True to activate the directional aim; false to deactivate. /// Index of the skill being used. /// True if the skill was used (triggering cast indicator animation); false if canceled. 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 ------------------------------- /// /// Public entry point: only owner/authority should cause damage; dedup by (attacker, hitId). /// Offline falls back to local application. /// 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 } /// /// Applies damage on the authoritative instance and updates network/local UI. /// 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(); } /// /// Receives damage, reducing shield first if available and then HP. /// Damage is reduced by current defense but not lower than a global minimum. /// /// The damage to receive. 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(); } /// /// called when the character revives. /// public override void OnCharacterRevive() { ApplyInvincible(reviveInvincibleTime); } /// /// Final death routine – runs once. Stops movement/inputs, plays animation, /// notifies GameplayManager and (optionally) replicates via Fusion. /// 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() : 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 ------------------------------- /// /// Returns the current CharacterData assigned to this entity. /// /// The character data, or null if not set. public CharacterData GetCharacterData() { return characterData; } /// /// Returns the CharacterTypeData from the current character, if available. /// /// The CharacterTypeData, or null if CharacterData is not assigned. public CharacterTypeData GetCharacterType() { if (characterData == null) { Debug.LogWarning("CharacterData is null. Cannot retrieve CharacterType."); return null; } return characterData.characterType; } /// /// Retrieves the SkillData for a given skill index. /// /// The skill index. /// The SkillData object, or null if invalid. 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]; } /// /// Returns the index of a specific skill in the character's skill array. /// /// The skill to find. /// The index of the skill, or -1 if not found. 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; } /// /// Retrieves the icon sprite for a specific skill. /// /// The index of the skill in the character's skill array. /// The icon sprite of the skill, or null if invalid. public Sprite GetSkillIcon(int skillIndex) { SkillData skill = GetSkillData(skillIndex); return skill != null ? skill.icon : null; } /// /// Retrieves the cooldown duration for a specific skill. /// /// The index of the skill in the character's skill array. /// The cooldown duration in seconds, or 0 if invalid. 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; } /// /// Retrieves a list of perks available to the player at level-up. /// /// List of perk options private List GetAvailablePerks() => GameplayManager.Singleton.GetRandomPerks(); public bool HasStatPerk(StatPerkData perk) => characterStatsComponent.HasStatPerk(perk); /// /// Retrieves the list of skill perks associated with the character. /// public List GetSkillsPerkData() => characterAttackComponent.GetSkillsPerkData(); /// /// Retrieves the list of stat perks associated with the character. /// public List GetStatsPerkData() => characterStatsComponent.GetStatsPerkData(); /// /// Gets the current level of the character. /// public int GetCurrentLevel() => characterStatsComponent.CurrentLevel; /// /// Gets the current XP of the character. /// public int GetCurrentXP() => characterStatsComponent.CurrentXP; /// /// Gets the XP required for the next level. /// 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 ------------------------------- /// /// Rotates the character model visually toward the given direction for a duration. /// Y component is ignored to prevent vertical tilt. /// /// Direction to face. /// Duration of the rotation effect. /// Optional cancellation token. 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; } } /// /// Disables auto-attack for a duration, then re-enables it. /// Uses UniTask for better performance and cancelation. /// /// Delay before allowing auto-attack again. /// Optional cancellation token. private async UniTask ResetAutoAttackAsync(float delay, CancellationToken token = default) { canAutoAttack = false; if (delay > 0f) await UniTask.Delay(TimeSpan.FromSeconds(delay), cancellationToken: token); canAutoAttack = true; } /// /// Removes invincibility after a set duration. /// Uses UniTask for better performance and optional cancellation. /// /// The time to remain invincible. /// Optional cancellation token. private async UniTask RemoveInvincibilityAfterAsync(float duration, CancellationToken token = default) { if (duration > 0f) await UniTask.Delay(TimeSpan.FromSeconds(duration), cancellationToken: token); isInvincible = false; } /// /// Stops the player's movement for a specified duration using UniTask. /// /// Duration in seconds to stop movement. /// If true, allows rotation while movement is stopped. /// Optional cancellation token. 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; } /// /// Internal coroutine that enforces the stun and restores the previous state. /// 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; } } } /// /// Hides the status message after a delay using UniTask. /// /// The TextMeshProUGUI component displaying the status message. /// The delay in seconds before hiding the message. /// Optional cancellation token. 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 } }