841 lines
32 KiB
C#
841 lines
32 KiB
C#
|
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 auto‐attack 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 auto‐attack: 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");
|
|||
|
}
|
|||
|
|
|||
|
}
|
|||
|
}
|