using BulletHellTemplate.Core.Events; using BulletHellTemplate.VFX; using Cysharp.Threading.Tasks; using System; using System.Collections.Generic; using System.Linq; using System.Threading; using UnityEngine; using static Unity.Collections.Unicode; #if FUSION2 using Fusion; #endif namespace BulletHellTemplate { public partial class CharacterAttackComponent : MonoBehaviour { private CharacterData characterData; private CharacterBuffsComponent characterBuffsComponent; private CharacterControllerComponent characterControllerComponent; private CharacterStatsComponent characterStatsComponent; private CharacterEntity characterOwner; public CharacterEntity CharacterOwner => characterOwner; private Transform launchTransform; private Transform effectsTransform; private CancellationTokenSource autoAttackCts; private readonly Dictionary activeSkillTokens = new(); private readonly HashSet skillsPerkData = new(); private bool ShouldDriveAttacks { get { #if FUSION2 if (GameplayManager.Singleton.IsRunnerActive) { var no = GetComponent(); return no && no.HasInputAuthority; } #endif return true; // offline } } private void Awake() { characterBuffsComponent = GetComponent(); characterControllerComponent = GetComponent(); characterStatsComponent = GetComponent(); characterOwner = GetComponent(); } public void Initialize(CharacterData data, Transform launch, Transform effects) { characterData = data; launchTransform = launch; effectsTransform = effects; if (ShouldDriveAttacks) StartAutoAttack(this.GetCancellationTokenOnDestroy()); } /// /// Starts auto-attack loop with cancelable token. /// private void StartAutoAttack(CancellationToken externalToken) { autoAttackCts?.Cancel(); autoAttackCts = CancellationTokenSource.CreateLinkedTokenSource(externalToken); AutoAttackLoop(autoAttackCts.Token).Forget(); } /// /// Cancels the auto-attack loop. /// public void StopAutoAttack() { autoAttackCts?.Cancel(); autoAttackCts = null; } public void ResumeAutoAttack() { if (ShouldDriveAttacks) StartAutoAttack(this.GetCancellationTokenOnDestroy()); } /// /// Cancels the current auto-attack loop and restarts it after seconds. /// /// Time in seconds before auto attack resumes. public void ResetAutoAttackDelay(float delay) { if (!ShouldDriveAttacks) return; StopAutoAttack(); ResumeAutoAttackAfterDelayAsync(delay).Forget(); } private async UniTaskVoid ResumeAutoAttackAfterDelayAsync(float delay) { try { await UniTask.Delay(TimeSpan.FromSeconds(delay), cancellationToken: this.GetCancellationTokenOnDestroy()); StartAutoAttack(this.GetCancellationTokenOnDestroy()); } catch (OperationCanceledException) { } } private static bool ShouldWaitPvpRules() { #if FUSION2 var gm = GameplayManager.Singleton; return gm != null && gm.IsRunnerActive && gm.IsPvp && PvpSync.Instance != null && !PvpSync.Instance.RulesReady; #else return false; #endif } /// /// Performs auto-attacks with dynamic cooldown using UniTask. /// private async UniTaskVoid AutoAttackLoop(CancellationToken token) { Debug.Log($"[ATK] Start loop – auth={ShouldDriveAttacks} pvp={GameplayManager.Singleton.IsPvp}"); try { while (!token.IsCancellationRequested) { while (GameplayManager.Singleton.IsPaused() || characterOwner == null || characterOwner.IsDead || !ShouldDriveAttacks #if FUSION2 || (GameplayManager.Singleton.IsPvp && (PvpSync.Instance != null && !PvpSync.Instance.RulesReady)) #endif ) { await UniTask.Yield(PlayerLoopTiming.Update, token); } Attack(); float cooldown = Mathf.Max(0.05f, characterStatsComponent.CurrentAttackSpeed); await UniTask.Delay(TimeSpan.FromSeconds(cooldown), cancellationToken: token); } } catch (OperationCanceledException) { } } /// /// Performs a skill use: applies damage locally and replicates visuals to other clients. /// /// Index of the skill in characterData.skills. /// The SkillData to use. /// Raw input direction vector. public void UseSkill(int index, SkillData skill, Vector2 inputDirection) { #if FUSION2 if (PvpSync.Instance && !PvpSync.Instance.RulesReady) { Debug.Log("[SKILL] blocked: RulesReady==false"); return; } #endif if (characterOwner == null || characterOwner.IsDead) return; if (!ShouldDriveAttacks) return; #if FUSION2 bool runnerActive = false; runnerActive = GameplayManager.Singleton != null && GameplayManager.Singleton.IsRunnerActive; #endif bool amAuthority = #if FUSION2 !runnerActive || (characterOwner && characterOwner.Object && characterOwner.Object.HasStateAuthority); #else true; #endif UseSkillInternal(index, skill, inputDirection, isReplica: !amAuthority); #if FUSION2 if (runnerActive) GameplaySync.Instance?.SyncPlayerUseSkill(characterOwner, index, inputDirection); #endif } /// /// Internal skill logic: spawns visuals and optionally applies damage. /// /// Index of the skill in characterData.skills. /// The SkillData to use. /// Raw input direction vector. /// True = visual only; False = visual + damage. public void UseSkillInternal(int index, SkillData skill, Vector2 inputDirection, bool isReplica) { if (characterOwner == null || characterOwner.IsDead) return; // Spawn effect if (skill.spawnEffect != null) { var effectInstance = GameEffectsManager.SpawnEffect( skill.spawnEffect, effectsTransform.position, effectsTransform.rotation); var auto = effectInstance.GetComponent() ?? effectInstance.AddComponent(); } // Play spawn audio if (skill.spawnAudio != null && !skill.playSpawnAudioEaShot) AudioManager.Singleton.PlayAudio(skill.spawnAudio, "vfx"); // Play skill audio if (skill.skillAudio != null && !skill.playSkillAudioEaShot) PlaySkillAudioAsync(skill.skillAudio, skill.playSkillAudioAfter, this.GetCancellationTokenOnDestroy()).Forget(); // Apply buffs/debuffs ApplySkillBuffs(skill); // Determine skill direction Vector3 dir = GetSkillDirection(skill.AimMode, inputDirection, launchTransform); // Rotate model if needed if (skill.isRotateToEnemy) characterOwner.ApplyRotateCharacterModel(dir, skill.rotateDuration, this.GetCancellationTokenOnDestroy()); // Movement delay if (skill.delayToMove > 0f) characterOwner.ApplyStopMovement(skill.delayToMove, skill.canRotateWhileStopped); // Dash logic if (skill.advancedDashSettings != null && skill.advancedDashSettings.enableAdvancedDash) { HandleAdvancedDash(skill.advancedDashSettings, dir, FindNearestTarget(), index); } else if (skill.isDash) { Vector3 dashDir = skill.isReverseDash ? -dir : dir; EventBus.Publish(new PlayerDashEvent(characterOwner, dashDir, skill.dashSpeed, skill.dashDuration, this.GetCancellationTokenOnDestroy())); } // Reset auto‐attack cooldown ResetAutoAttackDelay(skill.autoAttackDelay); // Launch damage or skip if replica _ = LaunchSkillAsync( skill, launchTransform, dir, isAutoAttack: false, index: index, isReplica, this.GetCancellationTokenOnDestroy() ); } /// /// Public API to perform auto‐attack: dispara dano local + RPC para réplica visual. /// public void Attack() { if (characterOwner == null || characterOwner.IsDead) return; if (!ShouldDriveAttacks) return; Transform target = FindNearestTarget(); if (target == null) return; Vector3 dir = (target.position - launchTransform.position); dir.y = 0f; dir.Normalize(); #if FUSION2 bool runnerActive = false; runnerActive = GameplayManager.Singleton != null && GameplayManager.Singleton.IsRunnerActive; #endif bool amAuthority = #if FUSION2 !runnerActive || (characterOwner && characterOwner.Object && characterOwner.Object.HasStateAuthority); #else true; #endif AttackInternal(dir, isReplica: !amAuthority); #if FUSION2 if (runnerActive) GameplaySync.Instance?.SyncPlayerAttack(characterOwner, dir); #endif } /// /// Internal attack logic: spawns visuals and optionally applies damage. /// /// Normalized attack direction. /// /// If true, only visual effects/play animations; /// if false, also launch damage entities. /// public void AttackInternal(Vector3 direction, bool isReplica) { characterOwner.PlayAttack(); var skill = characterData.autoAttack; if (skill.spawnEffect != null) { var effectInstance = GameEffectsManager.SpawnEffect( skill.spawnEffect, effectsTransform.position, effectsTransform.rotation); var auto = effectInstance.GetComponent() ?? effectInstance.AddComponent(); } // Play spawn audio if (skill.spawnAudio != null && !skill.playSpawnAudioEaShot) AudioManager.Singleton.PlayAudio(skill.spawnAudio, "vfx"); // Play skill audio if (skill.skillAudio != null && !skill.playSkillAudioEaShot) PlaySkillAudioAsync(skill.skillAudio, skill.playSkillAudioAfter, this.GetCancellationTokenOnDestroy()).Forget(); if (skill.isRotateToEnemy) characterOwner.ApplyRotateCharacterModel(direction, skill.rotateDuration, this.GetCancellationTokenOnDestroy()); _ = LaunchSkillAsync( skill, launchTransform, direction, isAutoAttack: true, index: -1, isReplica, this.GetCancellationTokenOnDestroy() ); } /// /// Launches a skill after an optional delay, without waiting for the animation to finish. /// Uses async UniTask with cancellation for performance. /// /// The skill data. /// The transform from which the skill originates. /// The direction to launch the skill. /// If true, triggers attack animation instead of skill. /// The index used for naming the trigger animation. /// Optional cancellation token. public async UniTask LaunchSkillAsync( SkillData skill, Transform launchTransform, Vector3 direction, bool isAutoAttack, int index, bool isReplica, CancellationToken token = default) { string trigger = isAutoAttack ? "Attack" : $"UseSkill{index}"; if (isAutoAttack) characterOwner.PlayAttack(); else characterOwner.PlaySkill(index); if (skill.delayToLaunch > 0f) await UniTask.Delay(TimeSpan.FromSeconds(skill.delayToLaunch), cancellationToken: token); skill.LaunchDamage(launchTransform, direction, characterOwner, isReplica); } public List GetSkillsPerkData() => skillsPerkData.ToList(); /// /// Applies a skill perk and manages its execution based on level and cooldown. /// /// The skill perk to apply. public void ApplySkillPerk(SkillPerkData skillPerk, bool isReplica) { if (skillPerk == null) { Debug.LogWarning("SkillPerkData is null."); return; } int currentLevel = GameplayManager.Singleton.GetSkillLevel(skillPerk); if (skillsPerkData.Contains(skillPerk)) { if (activeSkillTokens.TryGetValue(skillPerk, out var oldToken)) { oldToken.Cancel(); oldToken.Dispose(); } var newToken = CancellationTokenSource.CreateLinkedTokenSource(this.GetCancellationTokenOnDestroy()); activeSkillTokens[skillPerk] = newToken; ExecuteSkillAsync(skillPerk, currentLevel, isReplica, newToken.Token).Forget(); } else { skillsPerkData.Add(skillPerk); var token = CancellationTokenSource.CreateLinkedTokenSource(this.GetCancellationTokenOnDestroy()); activeSkillTokens.Add(skillPerk, token); ExecuteSkillAsync(skillPerk, currentLevel, isReplica, token.Token).Forget(); } } /// /// Executes a skill repeatedly based on cooldown and current targets. /// /// The skill perk data. /// The current level of the skill. /// Cancellation token. private async UniTask ExecuteSkillAsync(SkillPerkData skillPerk, int level,bool isReplica ,CancellationToken token) { float cooldown = skillPerk.withoutCooldown ? 0f : skillPerk.cooldown; while (!token.IsCancellationRequested) { while (GameplayManager.Singleton.IsPaused()) await UniTask.Yield(PlayerLoopTiming.Update, token); token.ThrowIfCancellationRequested(); Transform nearestTarget = FindNearestTarget(); if (nearestTarget == null) { await UniTask.Delay(TimeSpan.FromSeconds(cooldown), cancellationToken: token); continue; } Vector3 direction = (nearestTarget.position - transform.position).normalized; // Skill multishot if (skillPerk.isMultiShot) { bool isMaxLevel = level == skillPerk.maxLevel; int shots = isMaxLevel && skillPerk.evolveChanges ? skillPerk.shotsEvolved : skillPerk.shots; float angle = isMaxLevel && skillPerk.evolveChanges ? skillPerk.angleEvolved : skillPerk.angle; float delay = isMaxLevel && skillPerk.evolveChanges ? skillPerk.delayEvolved : skillPerk.delay; await MultiShotDamageEntityAsync(skillPerk, level, direction, shots, angle, delay, isReplica, token); } else { SpawnDamageEntity(skillPerk, level, direction,isReplica); } // Dash if (skillPerk.isDash) { EventBus.Publish(new PlayerDashEvent( characterOwner, transform.forward, skillPerk.dashSpeed, skillPerk.dashDuration, token)); } // Shield if (skillPerk.isShield) { characterBuffsComponent.ApplyShield( skillPerk.shieldAmount, skillPerk.shieldDuration, true); } float cooldownReduction = characterStatsComponent.CurrentCooldownReduction / 100f; float finalCooldown = cooldown * (1 - cooldownReduction); await UniTask.Delay(TimeSpan.FromSeconds(finalCooldown), cancellationToken: token); } } /// /// Spawns a damage entity from a SkillPerk at a given level, /// re-using the pool and configuring all runtime data. /// /// SkillPerk data. /// Current level of that perk. /// Launch direction. private void SpawnDamageEntity(SkillPerkData perk, int level, Vector3 dir, bool isReplica) { DamageEntity prefab = level >= perk.maxLevel ? perk.maxLvDamagePrefab : perk.initialDamagePrefab; if (prefab == null) return; #if FUSION2 var runner = FusionHelpers.GetRunner(characterOwner); bool canNetSpawn = runner != null && runner.IsRunning && characterOwner != null && characterOwner.Object != null && characterOwner.Object.HasStateAuthority; if (canNetSpawn) { runner.Spawn( prefab.gameObject, launchTransform.position, Quaternion.LookRotation(dir), null, (r, obj) => { var de = obj.GetComponent(); de.Init(new SkillPerkDamageProvider(perk, level, isReplica), characterOwner, dir.normalized * perk.speed, isReplica); }); return; } #endif // ─── OFFLINE / local ─── GameObject go = GameEffectsManager.SpawnEffect( prefab.gameObject, launchTransform.position, Quaternion.LookRotation(dir)); var deLocal = go.GetComponent(); deLocal.Init(new SkillPerkDamageProvider(perk, level, isReplica), characterOwner, dir.normalized * perk.speed, isReplica); } /// /// Finds the nearest target with the tags "Monster" or "Box". /// /// The transform of the nearest target, or null if no targets are found. public Transform FindNearestMonster() { float bestSqr = float.PositiveInfinity; Transform best = null; foreach (var t in GameplayManager.Singleton.ActiveMonstersList) { if (!t) continue; float d = (t.position - transform.position).sqrMagnitude; if (d < bestSqr) { bestSqr = d; best = t; } } foreach (var box in GameplayManager.Singleton.ActiveBoxesList) { float d = (box.position - transform.position).sqrMagnitude; if (d < bestSqr) { bestSqr = d; best = box; } } if (best && best.TryGetComponent(out MonsterEntity me)) EventBus.Publish(new EnemyTargetEvent(me)); return best; } private Transform FindNearestTarget() { var gm = GameplayManager.Singleton; if (gm != null && gm.IsPvp) return FindNearestEnemyPlayer(); return FindNearestMonster(); } private Transform FindNearestEnemyPlayer() { #if FUSION2 if (GameplayManager.Singleton.IsPvp && (PvpSync.Instance != null && !PvpSync.Instance.RulesReady)) return null; #endif #if UNITY_6000_0_OR_NEWER var all = FindObjectsByType(FindObjectsSortMode.None); #else var all = FindObjectsOfType(); #endif var gm = GameplayManager.Singleton; bool pvp = gm != null && gm.IsPvp; bool pvpReady = false; #if FUSION2 pvpReady = pvp && PvpSync.Instance != null && PvpSync.Instance.RulesReady; #endif byte myTeam = 0; #if FUSION2 if (characterOwner) myTeam = characterOwner.TeamId; #endif float bestSqr = float.PositiveInfinity; Transform best = null; foreach (var ce in all) { if (!ce || ce == characterOwner || ce.IsDead) continue; byte otherTeam = 1; // default #if FUSION2 otherTeam = ce.TeamId; #endif if (pvpReady && otherTeam == myTeam) continue; float d = (ce.transform.position - transform.position).sqrMagnitude; if (d < bestSqr) { bestSqr = d; best = ce.transform; } } return best; } /// /// Spawns multiple damage entities with angle and delay using UniTask. /// /// The skill perk data. /// Skill level. /// Base direction for the first shot. /// Number of shots to fire. /// Angle between each shot. /// Delay between each shot in seconds. /// Cancellation token (optional). private async UniTask MultiShotDamageEntityAsync( SkillPerkData skillPerk, int level, Vector3 baseDirection, int shots, float angle, float delay, bool isReplica, CancellationToken token = default) { float halfAngle = (shots - 1) * angle / 2f; for (int i = 0; i < shots; i++) { token.ThrowIfCancellationRequested(); while (GameplayManager.Singleton.IsPaused()) await UniTask.Yield(PlayerLoopTiming.Update, token); float shotAngle = -halfAngle + i * angle; Quaternion rotation = Quaternion.Euler(0f, shotAngle, 0f); Vector3 shotDirection = rotation * baseDirection; SpawnDamageEntity(skillPerk, level, shotDirection, isReplica); if (delay > 0f && i < shots - 1) await UniTask.Delay(TimeSpan.FromSeconds(delay), cancellationToken: token); } } /// /// Applies the skill's characterBuffsComponent and debuffs to this character (heal, shield, speed buff, etc.). /// /// The skill data containing buff/debuff options. public void ApplySkillBuffs(SkillData skill) { if (skill.receiveHeal && skill.healAmount > 0f) { characterBuffsComponent.ApplyHealHp((int)skill.healAmount); } if (skill.receiveShield && skill.shieldAmount > 0) { characterBuffsComponent.ApplyShield(skill.shieldAmount, skill.shieldDuration, true); } if (skill.receiveMoveSpeed && skill.moveSpeedAmount > 0f) { characterBuffsComponent.ApplyMoveSpeedBuff(skill.moveSpeedAmount, skill.moveSpeedDuration); } if (skill.receiveAttackSpeed && skill.attackSpeedAmount > 0f) { characterBuffsComponent.ApplyAttackSpeedBuff(skill.attackSpeedAmount, skill.attackSpeedDuration); } if (skill.receiveDefense && skill.defenseAmount > 0f) { characterBuffsComponent.ApplyDefenseBuff(skill.defenseAmount, skill.defenseDuration); } if (skill.receiveDamage && skill.damageAmount > 0f) { characterBuffsComponent.ApplyDamageBuff(skill.damageAmount, skill.damageDuration); } if (skill.isInvincible && skill.invincibleDuration > 0f) { characterOwner.ApplyInvincible(skill.invincibleDuration); } if (skill.receiveSlow && skill.slowDuration > 0f) { float slow = Mathf.Abs(skill.receiveSlowAmount); characterBuffsComponent.ApplyMoveSpeedDebuff(slow, skill.slowDuration); } } /// /// Converts a 2D direction vector to a 3D direction. /// /// The 2D direction vector. /// The 3D direction vector. private Vector3 GetDirection(Vector2 direction) { return new Vector3(direction.x, 0, direction.y); } /// /// Determines the skill direction based on the provided AimType and input direction. /// /// The aiming mode defined by the skill. /// The input direction vector (e.g., from joystick). /// The transform from which the skill is launched. /// The direction vector in which the skill should be launched. private Vector3 GetSkillDirection(AimType aimMode, Vector2 inputDirection, Transform launchTransform) { switch (aimMode) { case AimType.InputDirection: return GetDirection(inputDirection).normalized; case AimType.TargetNearestEnemy: Transform nearestTarget = FindNearestTarget(); return (nearestTarget != null) ? (nearestTarget.position - launchTransform.position).normalized : transform.forward; case AimType.FowardDirection: return transform.forward; case AimType.ReverseDirection: return -transform.forward; case AimType.RandomDirection: return GetRandomDirection(); default: return transform.forward; } } /// /// Generates a random direction on the XZ plane. /// /// A normalized Vector3 representing a random direction. private Vector3 GetRandomDirection() { float randomAngle = UnityEngine.Random.Range(0f, 360f); float radian = randomAngle * Mathf.Deg2Rad; return new Vector3(Mathf.Cos(radian), 0, Mathf.Sin(radian)).normalized; } private async void HandleAdvancedDash(AdvancedDashSettings dashSettings, Vector3 dir, Transform nearestTarget, int skillIndex) { await HandleAdvancedDashAsync(dashSettings, dir, nearestTarget, skillIndex, this.GetCancellationTokenOnDestroy()); } /// /// Coroutine to handle advanced dash without waiting for the animation to complete. /// Each dash wave may trigger an action animation (UseSkillX) if AnimationTriggerEachDash is true. /// /// Advanced dash settings. /// The dash direction input. /// Nearest target transform (if any). /// Index for the UseSkill animation trigger. private async UniTask HandleAdvancedDashAsync(AdvancedDashSettings dashSettings, Vector3 dir, Transform nearestTarget, int skillIndex, CancellationToken cancellationToken) { for (int i = 0; i < dashSettings.dashWaves; i++) { Vector3 dashDir = dashSettings.dashMode switch { DashMode.InputDirection => dir.normalized, DashMode.ForwardOnly => transform.forward, DashMode.ReverseOnly => -dir.normalized, DashMode.NearestTarget => (nearestTarget != null) ? (nearestTarget.position - transform.position).normalized : transform.forward, DashMode.RandomDirection => GetRandomDirection(), _ => transform.forward }; if (dashSettings.AnimationTriggerEachDash) { characterOwner.PlaySkill(skillIndex); } var dashCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); EventBus.Publish(new PlayerDashEvent( characterOwner, dashDir, dashSettings.dashSpeed, dashSettings.dashDuration, dashCts.Token )); await UniTask.Delay(TimeSpan.FromSeconds(dashSettings.dashDuration), cancellationToken: cancellationToken); if (i < dashSettings.dashWaves - 1 && dashSettings.delayBetweenWaves > 0f) { await UniTask.Delay(TimeSpan.FromSeconds(dashSettings.delayBetweenWaves), cancellationToken: cancellationToken); } } } /// /// Plays a skill audio clip after a delay. /// /// The audio clip to play. /// Delay in seconds before playing the clip. /// Optional cancellation token. private async UniTask PlaySkillAudioAsync(AudioClip skillAudio, float delay, CancellationToken token = default) { if (delay > 0f) await UniTask.Delay(TimeSpan.FromSeconds(delay), cancellationToken: token); AudioManager.Singleton.PlayAudio(skillAudio, "vfx"); } } }