841 lines
32 KiB
C#
Raw Permalink Normal View History

2025-09-19 19:43:49 +05:00
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<SkillPerkData, CancellationTokenSource> activeSkillTokens = new();
private readonly HashSet<SkillPerkData> skillsPerkData = new();
private bool ShouldDriveAttacks
{
get
{
#if FUSION2
if (GameplayManager.Singleton.IsRunnerActive)
{
var no = GetComponent<NetworkObject>();
return no && no.HasInputAuthority;
}
#endif
return true; // offline
}
}
private void Awake()
{
characterBuffsComponent = GetComponent<CharacterBuffsComponent>();
characterControllerComponent = GetComponent<CharacterControllerComponent>();
characterStatsComponent = GetComponent<CharacterStatsComponent>();
characterOwner = GetComponent<CharacterEntity>();
}
public void Initialize(CharacterData data, Transform launch, Transform effects)
{
characterData = data;
launchTransform = launch;
effectsTransform = effects;
if (ShouldDriveAttacks)
StartAutoAttack(this.GetCancellationTokenOnDestroy());
}
/// <summary>
/// Starts auto-attack loop with cancelable token.
/// </summary>
private void StartAutoAttack(CancellationToken externalToken)
{
autoAttackCts?.Cancel();
autoAttackCts = CancellationTokenSource.CreateLinkedTokenSource(externalToken);
AutoAttackLoop(autoAttackCts.Token).Forget();
}
/// <summary>
/// Cancels the auto-attack loop.
/// </summary>
public void StopAutoAttack()
{
autoAttackCts?.Cancel();
autoAttackCts = null;
}
public void ResumeAutoAttack()
{
if (ShouldDriveAttacks)
StartAutoAttack(this.GetCancellationTokenOnDestroy());
}
/// <summary>
/// Cancels the current auto-attack loop and restarts it after <paramref name="delay"/> seconds.
/// </summary>
/// <param name="delay">Time in seconds before auto attack resumes.</param>
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
}
/// <summary>
/// Performs auto-attacks with dynamic cooldown using UniTask.
/// </summary>
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) { }
}
/// <summary>
/// Performs a skill use: applies damage locally and replicates visuals to other clients.
/// </summary>
/// <param name="index">Index of the skill in characterData.skills.</param>
/// <param name="skill">The SkillData to use.</param>
/// <param name="inputDirection">Raw input direction vector.</param>
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
}
/// <summary>
/// Internal skill logic: spawns visuals and optionally applies damage.
/// </summary>
/// <param name="index">Index of the skill in characterData.skills.</param>
/// <param name="skill">The SkillData to use.</param>
/// <param name="inputDirection">Raw input direction vector.</param>
/// <param name="isReplica">True = visual only; False = visual + damage.</param>
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<ReturnEffectToPool>() ??
effectInstance.AddComponent<ReturnEffectToPool>();
}
// 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 autoattack cooldown
ResetAutoAttackDelay(skill.autoAttackDelay);
// Launch damage or skip if replica
_ = LaunchSkillAsync(
skill,
launchTransform,
dir,
isAutoAttack: false,
index: index,
isReplica,
this.GetCancellationTokenOnDestroy()
);
}
/// <summary>
/// Public API to perform autoattack: dispara dano local + RPC para réplica visual.
/// </summary>
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
}
/// <summary>
/// Internal attack logic: spawns visuals and optionally applies damage.
/// </summary>
/// <param name="direction">Normalized attack direction.</param>
/// <param name="isReplica">
/// If true, only visual effects/play animations;
/// if false, also launch damage entities.
/// </param>
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<ReturnEffectToPool>() ??
effectInstance.AddComponent<ReturnEffectToPool>();
}
// 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()
);
}
/// <summary>
/// Launches a skill after an optional delay, without waiting for the animation to finish.
/// Uses async UniTask with cancellation for performance.
/// </summary>
/// <param name="skill">The skill data.</param>
/// <param name="launchTransform">The transform from which the skill originates.</param>
/// <param name="direction">The direction to launch the skill.</param>
/// <param name="isAutoAttack">If true, triggers attack animation instead of skill.</param>
/// <param name="index">The index used for naming the trigger animation.</param>
/// <param name="token">Optional cancellation token.</param>
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<SkillPerkData> GetSkillsPerkData() => skillsPerkData.ToList();
/// <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, 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();
}
}
/// <summary>
/// Executes a skill repeatedly based on cooldown and current targets.
/// </summary>
/// <param name="skillPerk">The skill perk data.</param>
/// <param name="level">The current level of the skill.</param>
/// <param name="token">Cancellation token.</param>
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);
}
}
/// <summary>
/// Spawns a damage entity from a SkillPerk at a given level,
/// re-using the pool and configuring all runtime data.
/// </summary>
/// <param name="skillPerk">SkillPerk data.</param>
/// <param name="level">Current level of that perk.</param>
/// <param name="direction">Launch direction.</param>
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<DamageEntity>();
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<DamageEntity>();
deLocal.Init(new SkillPerkDamageProvider(perk, level, isReplica),
characterOwner,
dir.normalized * perk.speed,
isReplica);
}
/// <summary>
/// Finds the nearest target with the tags "Monster" or "Box".
/// </summary>
/// <returns>The transform of the nearest target, or null if no targets are found.</returns>
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<CharacterEntity>(FindObjectsSortMode.None);
#else
var all = FindObjectsOfType<CharacterEntity>();
#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;
}
/// <summary>
/// Spawns multiple damage entities with angle and delay using UniTask.
/// </summary>
/// <param name="skillPerk">The skill perk data.</param>
/// <param name="level">Skill level.</param>
/// <param name="baseDirection">Base direction for the first shot.</param>
/// <param name="shots">Number of shots to fire.</param>
/// <param name="angle">Angle between each shot.</param>
/// <param name="delay">Delay between each shot in seconds.</param>
/// <param name="token">Cancellation token (optional).</param>
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);
}
}
/// <summary>
/// Applies the skill's characterBuffsComponent and debuffs to this character (heal, shield, speed buff, etc.).
/// </summary>
/// <param name="skill">The skill data containing buff/debuff options.</param>
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);
}
}
/// <summary>
/// Converts a 2D direction vector to a 3D direction.
/// </summary>
/// <param name="direction">The 2D direction vector.</param>
/// <returns>The 3D direction vector.</returns>
private Vector3 GetDirection(Vector2 direction)
{
return new Vector3(direction.x, 0, direction.y);
}
/// <summary>
/// Determines the skill direction based on the provided AimType and input direction.
/// </summary>
/// <param name="aimMode">The aiming mode defined by the skill.</param>
/// <param name="inputDirection">The input direction vector (e.g., from joystick).</param>
/// <param name="launchTransform">The transform from which the skill is launched.</param>
/// <returns>The direction vector in which the skill should be launched.</returns>
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;
}
}
/// <summary>
/// Generates a random direction on the XZ plane.
/// </summary>
/// <returns>A normalized Vector3 representing a random direction.</returns>
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());
}
/// <summary>
/// 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.
/// </summary>
/// <param name="dashSettings">Advanced dash settings.</param>
/// <param name="dir">The dash direction input.</param>
/// <param name="nearestTarget">Nearest target transform (if any).</param>
/// <param name="skillIndex">Index for the UseSkill animation trigger.</param>
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);
}
}
}
/// <summary>
/// Plays a skill audio clip after a delay.
/// </summary>
/// <param name="skillAudio">The audio clip to play.</param>
/// <param name="delay">Delay in seconds before playing the clip.</param>
/// <param name="token">Optional cancellation token.</param>
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");
}
}
}