359 lines
11 KiB
C#
359 lines
11 KiB
C#
using Invector.vCharacterController.AI.FSMBehaviour;
|
||
using Lean.Pool;
|
||
using UnityEngine;
|
||
using UnityEngine.Animations;
|
||
using UnityEngine.Playables;
|
||
|
||
namespace DemonBoss.Magic
|
||
{
|
||
/// <summary>
|
||
/// Spawns multiple meteors behind the BOSS and launches them toward the player's position.
|
||
/// </summary>
|
||
[CreateAssetMenu(menuName = "Invector/FSM/Actions/DemonBoss/Call Meteor")]
|
||
public class SA_CallMeteor : vStateAction
|
||
{
|
||
public override string categoryName => "DemonBoss/Magic";
|
||
public override string defaultName => "Call Meteor";
|
||
|
||
[Header("Meteor Setup")]
|
||
[Tooltip("Prefab with MeteorProjectile component")]
|
||
public GameObject meteorPrefab;
|
||
|
||
[Tooltip("Distance behind the BOSS to spawn meteor (meters)")]
|
||
public float behindBossDistance = 3f;
|
||
|
||
[Tooltip("Height above the BOSS to spawn meteor (meters)")]
|
||
public float aboveBossHeight = 8f;
|
||
|
||
[Header("Multi-Meteor Configuration")]
|
||
[Tooltip("Number of meteors to spawn in sequence")]
|
||
public int meteorCount = 5;
|
||
|
||
[Tooltip("Delay before first meteor spawns (wind-up)")]
|
||
public float initialCastDelay = 0.4f;
|
||
|
||
[Tooltip("Time between each meteor spawn")]
|
||
public float meteorSpawnInterval = 0.6f;
|
||
|
||
[Header("Muzzle Flash Effect")]
|
||
[Tooltip("Particle effect prefab for muzzle flash at spawn position")]
|
||
public GameObject muzzleFlashPrefab;
|
||
|
||
[Tooltip("Duration to keep muzzle flash alive (seconds)")]
|
||
public float muzzleFlashDuration = 1.5f;
|
||
|
||
[Header("Targeting")]
|
||
[Tooltip("Tag used to find the target (usually Player)")]
|
||
public string targetTag = "Player";
|
||
|
||
[Header("One-off Overlay Clip (No Animator Params)")]
|
||
public AnimationClip overlayClip;
|
||
|
||
[Tooltip("Playback speed (1 = normal)")] public float overlaySpeed = 1f;
|
||
[Tooltip("Blend-in seconds (instant in this minimal impl)")] public float overlayFadeIn = 0.10f;
|
||
[Tooltip("Blend-out seconds (instant in this minimal impl)")] public float overlayFadeOut = 0.10f;
|
||
|
||
[Header("Debug")]
|
||
public bool enableDebug = false;
|
||
|
||
private Transform _boss;
|
||
private Transform _target;
|
||
|
||
// --- Multi-meteor state ---
|
||
private int _meteorsSpawned = 0;
|
||
|
||
private bool _spawningActive = false;
|
||
|
||
// --- Playables runtime ---
|
||
private PlayableGraph _overlayGraph;
|
||
|
||
private AnimationPlayableOutput _overlayOutput;
|
||
private AnimationClipPlayable _overlayPlayable;
|
||
private bool _overlayPlaying;
|
||
private float _overlayStopAtTime;
|
||
|
||
public override void DoAction(vIFSMBehaviourController fsm, vFSMComponentExecutionType execType = vFSMComponentExecutionType.OnStateUpdate)
|
||
{
|
||
if (execType == vFSMComponentExecutionType.OnStateEnter)
|
||
{
|
||
OnEnter(fsm);
|
||
}
|
||
else if (execType == vFSMComponentExecutionType.OnStateUpdate)
|
||
{
|
||
// Keep the state active until all meteors are spawned
|
||
if (_spawningActive && _meteorsSpawned < meteorCount)
|
||
{
|
||
// Don't allow the state to exit while spawning
|
||
return;
|
||
}
|
||
|
||
if (_overlayPlaying && Time.time >= _overlayStopAtTime)
|
||
{
|
||
StopOverlayWithFade();
|
||
}
|
||
|
||
// Only signal completion when all meteors are done AND overlay is finished
|
||
if (!_spawningActive && !_overlayPlaying && _meteorsSpawned >= meteorCount)
|
||
{
|
||
if (enableDebug) Debug.Log($"[SA_CallMeteor] Sequence complete, ready to exit state");
|
||
}
|
||
}
|
||
else if (execType == vFSMComponentExecutionType.OnStateExit)
|
||
{
|
||
OnExit(fsm);
|
||
}
|
||
}
|
||
|
||
private void OnEnter(vIFSMBehaviourController fsm)
|
||
{
|
||
_boss = fsm.transform;
|
||
_target = GameObject.FindGameObjectWithTag(targetTag)?.transform;
|
||
|
||
if (_target == null)
|
||
{
|
||
if (enableDebug) Debug.LogWarning("[SA_CallMeteor] No target found – abort");
|
||
return;
|
||
}
|
||
|
||
// Reset multi-meteor state
|
||
_meteorsSpawned = 0;
|
||
_spawningActive = true;
|
||
|
||
// SET COOLDOWN IMMEDIATELY when meteor ability is used
|
||
DEC_CheckCooldown.SetCooldownStatic(fsm, "Meteor", 80f);
|
||
|
||
if (enableDebug) Debug.Log($"[SA_CallMeteor] Boss: {_boss.name}, Target: {_target.name}, Count: {meteorCount}");
|
||
|
||
// Fire overlay clip (no Animator params)
|
||
PlayOverlayOnce(_boss);
|
||
|
||
// Start the meteor sequence
|
||
if (initialCastDelay > 0f)
|
||
_boss.gameObject.AddComponent<DelayedInvoker>().Init(initialCastDelay, StartMeteorSequence);
|
||
else
|
||
StartMeteorSequence();
|
||
}
|
||
|
||
private void OnExit(vIFSMBehaviourController fsm)
|
||
{
|
||
// Stop any active spawning
|
||
_spawningActive = false;
|
||
StopOverlayImmediate();
|
||
|
||
// Clean up any DelayedInvokers attached to the boss
|
||
var invokers = _boss?.GetComponents<DelayedInvoker>();
|
||
if (invokers != null)
|
||
{
|
||
foreach (var invoker in invokers)
|
||
{
|
||
if (invoker != null) Object.Destroy(invoker);
|
||
}
|
||
}
|
||
|
||
if (enableDebug) Debug.Log($"[SA_CallMeteor] State exited. Spawned {_meteorsSpawned}/{meteorCount} meteors");
|
||
}
|
||
|
||
private void StartMeteorSequence()
|
||
{
|
||
if (!_spawningActive) return;
|
||
SpawnNextMeteor();
|
||
}
|
||
|
||
private void SpawnNextMeteor()
|
||
{
|
||
if (!_spawningActive || _meteorsSpawned >= meteorCount) return;
|
||
|
||
if (_boss == null || _target == null)
|
||
{
|
||
if (enableDebug) Debug.LogError("[SA_CallMeteor] Missing boss or target reference");
|
||
return;
|
||
}
|
||
|
||
SpawnSingleMeteor();
|
||
_meteorsSpawned++;
|
||
|
||
if (enableDebug) Debug.Log($"[SA_CallMeteor] Spawned meteor {_meteorsSpawned}/{meteorCount}");
|
||
|
||
// Schedule next meteor if needed
|
||
if (_meteorsSpawned < meteorCount && _spawningActive)
|
||
{
|
||
if (enableDebug) Debug.Log($"[SA_CallMeteor] Scheduling next meteor in {meteorSpawnInterval}s");
|
||
_boss.gameObject.AddComponent<DelayedInvoker>().Init(meteorSpawnInterval, SpawnNextMeteor);
|
||
}
|
||
else
|
||
{
|
||
// All meteors spawned
|
||
_spawningActive = false;
|
||
if (enableDebug) Debug.Log("[SA_CallMeteor] All meteors spawned, sequence complete");
|
||
}
|
||
}
|
||
|
||
private void SpawnSingleMeteor()
|
||
{
|
||
if (meteorPrefab == null)
|
||
{
|
||
if (enableDebug) Debug.LogError("[SA_CallMeteor] Missing meteorPrefab");
|
||
return;
|
||
}
|
||
|
||
// Calculate spawn position: behind the BOSS + height
|
||
Vector3 bossForward = _boss.forward.normalized;
|
||
Vector3 behindBoss = _boss.position - (bossForward * behindBossDistance);
|
||
Vector3 spawnPos = behindBoss + Vector3.up * aboveBossHeight;
|
||
|
||
// Add slight randomization to spawn position for multiple meteors
|
||
if (_meteorsSpawned > 0)
|
||
{
|
||
Vector3 randomOffset = new Vector3(
|
||
Random.Range(-1f, 1f),
|
||
Random.Range(-0.5f, 0.5f),
|
||
Random.Range(-1f, 1f)
|
||
);
|
||
spawnPos += randomOffset;
|
||
}
|
||
|
||
if (enableDebug) Debug.Log($"[SA_CallMeteor] Spawning meteor #{_meteorsSpawned + 1} at: {spawnPos}");
|
||
|
||
// Spawn muzzle flash effect first
|
||
SpawnMuzzleFlash(spawnPos);
|
||
|
||
// Spawn the meteor
|
||
var meteorGO = LeanPool.Spawn(meteorPrefab, spawnPos, Quaternion.identity);
|
||
|
||
// Configure the projectile to target the player
|
||
var meteorScript = meteorGO.GetComponent<MeteorProjectile>();
|
||
if (meteorScript != null)
|
||
{
|
||
// Update target position for each meteor (player might be moving)
|
||
Vector3 targetPos = _target.position;
|
||
|
||
// Add slight prediction/leading for moving targets
|
||
var playerRigidbody = _target.GetComponent<Rigidbody>();
|
||
if (playerRigidbody != null)
|
||
{
|
||
Vector3 playerVelocity = playerRigidbody.linearVelocity;
|
||
float estimatedFlightTime = 2f; // rough estimate
|
||
targetPos += playerVelocity * estimatedFlightTime * 0.5f; // partial leading
|
||
}
|
||
|
||
meteorScript.useOverrideImpactPoint = true;
|
||
meteorScript.overrideImpactPoint = targetPos;
|
||
meteorScript.snapImpactToGround = true;
|
||
|
||
if (enableDebug) Debug.Log($"[SA_CallMeteor] Meteor #{_meteorsSpawned + 1} configured to target: {targetPos}");
|
||
}
|
||
else
|
||
{
|
||
if (enableDebug) Debug.LogError("[SA_CallMeteor] Meteor prefab missing MeteorProjectile component!");
|
||
}
|
||
}
|
||
|
||
private void SpawnMuzzleFlash(Vector3 position)
|
||
{
|
||
if (muzzleFlashPrefab == null) return;
|
||
|
||
var muzzleFlash = LeanPool.Spawn(muzzleFlashPrefab, position, Quaternion.identity);
|
||
|
||
if (muzzleFlashDuration > 0f)
|
||
{
|
||
// Auto-despawn muzzle flash after duration
|
||
_boss.gameObject.AddComponent<DelayedInvoker>().Init(muzzleFlashDuration, () =>
|
||
{
|
||
if (muzzleFlash != null)
|
||
{
|
||
LeanPool.Despawn(muzzleFlash);
|
||
}
|
||
});
|
||
}
|
||
|
||
if (enableDebug) Debug.Log($"[SA_CallMeteor] Muzzle flash spawned at: {position}");
|
||
}
|
||
|
||
private void PlayOverlayOnce(Transform owner)
|
||
{
|
||
if (overlayClip == null) return;
|
||
|
||
var animator = owner.GetComponent<Animator>();
|
||
if (animator == null) return;
|
||
|
||
StopOverlayImmediate();
|
||
|
||
_overlayGraph = PlayableGraph.Create("ActionOverlay(CallMeteor)");
|
||
_overlayGraph.SetTimeUpdateMode(DirectorUpdateMode.GameTime);
|
||
|
||
_overlayPlayable = AnimationClipPlayable.Create(_overlayGraph, overlayClip);
|
||
_overlayPlayable.SetApplyFootIK(false);
|
||
_overlayPlayable.SetApplyPlayableIK(false);
|
||
_overlayPlayable.SetSpeed(Mathf.Max(0.0001f, overlaySpeed));
|
||
|
||
_overlayOutput = AnimationPlayableOutput.Create(_overlayGraph, "AnimOut", animator);
|
||
_overlayOutput.SetSourcePlayable(_overlayPlayable);
|
||
|
||
_overlayOutput.SetWeight(1f);
|
||
_overlayGraph.Play();
|
||
_overlayPlaying = true;
|
||
|
||
// Calculate total sequence duration for overlay
|
||
float totalSequenceDuration = initialCastDelay + (meteorCount * meteorSpawnInterval) + 2f; // +2s buffer
|
||
float overlayDuration = overlayClip.length / Mathf.Max(0.0001f, overlaySpeed);
|
||
|
||
// Use the longer of the two durations, ensuring overlay covers entire sequence
|
||
float finalDuration = Mathf.Max(overlayDuration, totalSequenceDuration);
|
||
_overlayStopAtTime = Time.time + finalDuration;
|
||
|
||
if (enableDebug) Debug.Log($"[SA_CallMeteor] Overlay clip started via Playables, duration: {finalDuration:F1}s (sequence: {totalSequenceDuration:F1}s, clip: {overlayDuration:F1}s)");
|
||
}
|
||
|
||
private void StopOverlayImmediate()
|
||
{
|
||
if (_overlayGraph.IsValid())
|
||
{
|
||
_overlayGraph.Stop();
|
||
_overlayGraph.Destroy();
|
||
}
|
||
_overlayPlaying = false;
|
||
}
|
||
|
||
private void StopOverlayWithFade()
|
||
{
|
||
if (!_overlayPlaying) { StopOverlayImmediate(); return; }
|
||
if (_overlayOutput.IsOutputNull() == false) _overlayOutput.SetWeight(0f);
|
||
StopOverlayImmediate();
|
||
if (enableDebug) Debug.Log("[SA_CallMeteor] Overlay clip stopped");
|
||
}
|
||
|
||
// Public methods for external monitoring
|
||
public bool IsSequenceActive() => _spawningActive;
|
||
|
||
public int GetMeteorsSpawned() => _meteorsSpawned;
|
||
|
||
public int GetTotalMeteorCount() => meteorCount;
|
||
|
||
public float GetSequenceProgress() => meteorCount > 0 ? (float)_meteorsSpawned / meteorCount : 1f;
|
||
|
||
/// <summary>
|
||
/// Tiny helper MonoBehaviour to delay a callback without coroutines here.
|
||
/// </summary>
|
||
private sealed class DelayedInvoker : MonoBehaviour
|
||
{
|
||
private float _timeLeft;
|
||
private System.Action _callback;
|
||
|
||
public void Init(float delay, System.Action callback)
|
||
{
|
||
_timeLeft = delay;
|
||
_callback = callback;
|
||
}
|
||
|
||
private void Update()
|
||
{
|
||
_timeLeft -= Time.deltaTime;
|
||
if (_timeLeft <= 0f)
|
||
{
|
||
try { _callback?.Invoke(); }
|
||
finally { Destroy(this); }
|
||
}
|
||
}
|
||
}
|
||
}
|
||
} |