472 lines
14 KiB
C#
472 lines
14 KiB
C#
using Invector;
|
||
using Invector.vCharacterController;
|
||
using Lean.Pool;
|
||
using UnityEngine;
|
||
using UnityEngine.Events;
|
||
|
||
namespace DemonBoss.Magic
|
||
{
|
||
public class MeteorProjectile : MonoBehaviour
|
||
{
|
||
#region Configuration
|
||
|
||
[Header("Targeting")]
|
||
[Tooltip("Tag of the target to pick impact point from on spawn")]
|
||
public string targetTag = "Player";
|
||
|
||
[Tooltip("If true, raycasts down from impact point to find ground (better visuals)")]
|
||
public bool snapImpactToGround = true;
|
||
|
||
[Tooltip("LayerMask used when snapping impact point to ground")]
|
||
public LayerMask groundMask = ~0;
|
||
|
||
[Header("Entry Geometry")]
|
||
[Tooltip("If > 0, teleports start Y above impact point by this height (keeps current XZ). 0 = keep original height.")]
|
||
public float spawnHeightAboveTarget = 12f;
|
||
|
||
[Tooltip("Entry angle measured DOWN from horizontal (e.g. 15° = shallow, 45° = steeper)")]
|
||
[Range(0f, 89f)]
|
||
public float entryAngleDownFromHorizontal = 20f;
|
||
|
||
[Tooltip("How closely to aim towards the target in XZ before tilting down (0..1). 1 = purely towards target, 0 = purely downward.")]
|
||
[Range(0f, 1f)]
|
||
public float azimuthAimWeight = 0.85f;
|
||
|
||
[Header("Movement")]
|
||
[Tooltip("Initial speed magnitude in units per second")]
|
||
public float speed = 30f;
|
||
|
||
[Tooltip("Extra downward acceleration to add a slight curve (0 = disabled)")]
|
||
public float gravityLikeAcceleration = 0f;
|
||
|
||
[Tooltip("Rotate the meteor mesh to face velocity")]
|
||
public bool rotateToVelocity = true;
|
||
|
||
[Tooltip("Seconds before the meteor auto-despawns")]
|
||
public float maxLifeTime = 12f;
|
||
|
||
[Header("Impact / AOE")]
|
||
[Tooltip("Explosion radius at impact (0 = no AOE, only single target)")]
|
||
public float explosionRadius = 0f;
|
||
|
||
[Tooltip("Layers that should receive AOE damage")]
|
||
public LayerMask explosionMask = ~0;
|
||
|
||
[Tooltip("If true, AOE will only damage colliders with the same tag as targetTag")]
|
||
public bool aoeOnlyTargetTag = false;
|
||
|
||
[Tooltip("Sphere radius for continuous collision checks")]
|
||
public float castRadius = 0.25f;
|
||
|
||
[Tooltip("Layers that block the meteor during flight (ground/walls/etc.)")]
|
||
public LayerMask collisionMask = ~0;
|
||
|
||
[Tooltip("Damage that will be dealt to Player")]
|
||
public float damage = 40.0f;
|
||
|
||
[Tooltip("Knockback that will be applied to Player")]
|
||
public float knockbackForce = 5.0f;
|
||
|
||
[Header("Debug")]
|
||
[Tooltip("Enable verbose logs and debug rays")]
|
||
public bool enableDebug = false;
|
||
|
||
[Header("Events")]
|
||
[Tooltip("Invoked once on any impact (use for VFX/SFX/CameraShake)")]
|
||
public UnityEvent onImpact;
|
||
|
||
#endregion Configuration
|
||
|
||
#region Runtime
|
||
|
||
private Vector3 impactPoint;
|
||
private Vector3 velocityDir;
|
||
private Vector3 velocity;
|
||
private float timer = 0f;
|
||
private bool hasDealtDamage = false;
|
||
private bool hasImpacted = false;
|
||
private Vector3 prevPos;
|
||
|
||
#endregion Runtime
|
||
|
||
#region Unity
|
||
|
||
/// <summary>
|
||
/// Resets runtime state, locks the impact point, computes entry direction and initial velocity.
|
||
/// </summary>
|
||
private void OnEnable()
|
||
{
|
||
timer = 0f;
|
||
hasDealtDamage = false;
|
||
hasImpacted = false;
|
||
|
||
// Acquire player and lock impact point
|
||
Vector3 targetPos = GetPlayerPosition();
|
||
impactPoint = snapImpactToGround ? SnapPointToGround(targetPos) : targetPos;
|
||
|
||
// Optionally start from above to guarantee top-down feel
|
||
if (spawnHeightAboveTarget > 0f)
|
||
{
|
||
Vector3 p = transform.position;
|
||
p.y = impactPoint.y + spawnHeightAboveTarget;
|
||
transform.position = p;
|
||
}
|
||
|
||
Vector3 toImpactXZ = new Vector3(impactPoint.x, transform.position.y, impactPoint.z) - transform.position;
|
||
Vector3 azimuthDir = toImpactXZ.sqrMagnitude > 0.0001f ? toImpactXZ.normalized : transform.forward;
|
||
|
||
Vector3 tiltAxis = Vector3.Cross(Vector3.up, azimuthDir);
|
||
if (tiltAxis.sqrMagnitude < 0.0001f) tiltAxis = Vector3.right;
|
||
Quaternion downTilt = Quaternion.AngleAxis(-entryAngleDownFromHorizontal, tiltAxis);
|
||
Vector3 tiltedDir = (downTilt * azimuthDir).normalized;
|
||
|
||
velocityDir = Vector3.Slerp(Vector3.down, tiltedDir, Mathf.Clamp01(azimuthAimWeight)).normalized;
|
||
|
||
velocity = velocityDir * Mathf.Max(0f, speed);
|
||
|
||
if (rotateToVelocity && velocity.sqrMagnitude > 0.0001f)
|
||
transform.rotation = Quaternion.LookRotation(velocity.normalized);
|
||
|
||
if (enableDebug)
|
||
{
|
||
Debug.Log($"[Meteor] Impact={impactPoint}, start={transform.position}, dir={velocityDir}");
|
||
Debug.DrawLine(transform.position, transform.position + velocityDir * 6f, Color.red, 3f);
|
||
Debug.DrawLine(transform.position, impactPoint, Color.yellow, 2f);
|
||
}
|
||
|
||
prevPos = transform.position;
|
||
}
|
||
|
||
/// <summary>
|
||
/// Integrates motion with optional downward acceleration, performs a SphereCast for continuous collision,
|
||
/// rotates to face velocity, and handles lifetime expiry.
|
||
/// </summary>
|
||
private void Update()
|
||
{
|
||
timer += Time.deltaTime;
|
||
|
||
// Downward "gravity-like" acceleration
|
||
if (gravityLikeAcceleration > 0f)
|
||
velocity += Vector3.down * gravityLikeAcceleration * Time.deltaTime;
|
||
|
||
Vector3 nextPos = transform.position + velocity * Time.deltaTime;
|
||
|
||
// Continuous collision: SphereCast from current position toward next
|
||
Vector3 castDir = nextPos - transform.position;
|
||
float castDist = castDir.magnitude;
|
||
if (castDist > 0.0001f)
|
||
{
|
||
if (Physics.SphereCast(transform.position, castRadius, castDir.normalized,
|
||
out RaycastHit hit, castDist, collisionMask, QueryTriggerInteraction.Ignore))
|
||
{
|
||
transform.position = hit.point;
|
||
Impact(hit.collider, hit.point, hit.normal);
|
||
return;
|
||
}
|
||
}
|
||
|
||
// No hit — apply movement
|
||
transform.position = nextPos;
|
||
|
||
// Face velocity if requested
|
||
if (rotateToVelocity && velocity.sqrMagnitude > 0.0001f)
|
||
transform.rotation = Quaternion.LookRotation(velocity.normalized);
|
||
|
||
prevPos = transform.position;
|
||
|
||
// Auto-despawn on lifetime cap
|
||
if (timer >= maxLifeTime)
|
||
{
|
||
if (enableDebug) Debug.Log("[Meteor] Max lifetime reached → Despawn");
|
||
Despawn();
|
||
}
|
||
}
|
||
|
||
/// <summary>
|
||
/// Trigger-based contact: funnels into unified Impact().
|
||
/// </summary>
|
||
private void OnTriggerEnter(Collider other)
|
||
{
|
||
// Even if not the intended target, a meteor typically impacts anything it touches
|
||
Impact(other, transform.position, -SafeNormal(velocity));
|
||
}
|
||
|
||
/// <summary>
|
||
/// Collision-based contact: funnels into unified Impact().
|
||
/// </summary>
|
||
private void OnCollisionEnter(Collision collision)
|
||
{
|
||
var cp = collision.contacts.Length > 0 ? collision.contacts[0] : default;
|
||
Impact(collision.collider, cp.point != default ? cp.point : transform.position, cp.normal != default ? cp.normal : Vector3.up);
|
||
}
|
||
|
||
#endregion Unity
|
||
|
||
#region Helpers: Target / Ground
|
||
|
||
/// <summary>
|
||
/// Gets the target's current position (by tag). If not found, projects forward from current position.
|
||
/// </summary>
|
||
private Vector3 GetPlayerPosition()
|
||
{
|
||
GameObject playerObj = GameObject.FindGameObjectWithTag(targetTag);
|
||
if (playerObj != null)
|
||
return playerObj.transform.position;
|
||
|
||
if (enableDebug) Debug.LogWarning($"[Meteor] No target with tag '{targetTag}' found, using forward guess.");
|
||
return transform.position + transform.forward * 10f;
|
||
}
|
||
|
||
/// <summary>
|
||
/// Raycasts down from above the source point to find ground; returns original point if no hit.
|
||
/// </summary>
|
||
private Vector3 SnapPointToGround(Vector3 source)
|
||
{
|
||
Vector3 start = source + Vector3.up * 50f;
|
||
if (Physics.Raycast(start, Vector3.down, out RaycastHit hit, 200f, groundMask, QueryTriggerInteraction.Ignore))
|
||
return hit.point;
|
||
|
||
return source;
|
||
}
|
||
|
||
/// <summary>
|
||
/// Returns a safe normalized version of a vector (falls back to Vector3.down if tiny).
|
||
/// </summary>
|
||
private static Vector3 SafeNormal(Vector3 v)
|
||
{
|
||
return v.sqrMagnitude > 0.0001f ? v.normalized : Vector3.down;
|
||
}
|
||
|
||
#endregion Helpers: Target / Ground
|
||
|
||
#region Impact & Damage
|
||
|
||
/// <summary>
|
||
/// Unified impact handler (idempotent). Deals single-target damage if applicable,
|
||
/// optional AOE damage, invokes events, and despawns.
|
||
/// </summary>
|
||
private void Impact(Collider hitCol, Vector3 hitPoint, Vector3 hitNormal)
|
||
{
|
||
if (hasImpacted) return;
|
||
hasImpacted = true;
|
||
|
||
if (enableDebug)
|
||
Debug.Log($"[Meteor] Impact with {(hitCol ? hitCol.name : "null")} at {hitPoint}");
|
||
|
||
// Single target (original logic)
|
||
if (!hasDealtDamage && hitCol != null && hitCol.CompareTag(targetTag))
|
||
DealDamageToTarget(hitCol);
|
||
|
||
// AOE (if enabled)
|
||
if (explosionRadius > 0f)
|
||
DealAreaDamage(hitPoint);
|
||
|
||
// Hook for VFX/SFX/CameraShake
|
||
onImpact?.Invoke();
|
||
|
||
Despawn();
|
||
}
|
||
|
||
/// <summary>
|
||
/// Deals damage to the intended single target using Invector interfaces/components.
|
||
/// </summary>
|
||
private void DealDamageToTarget(Collider targetCollider)
|
||
{
|
||
if (enableDebug) Debug.Log($"[MeteorDamage] Dealing {damage} damage to: {targetCollider.name}");
|
||
|
||
Vector3 hitPoint = GetClosestPointOnCollider(targetCollider);
|
||
Vector3 hitDirection = GetHitDirection(targetCollider);
|
||
|
||
vDamage damageInfo = new vDamage(Mathf.RoundToInt(damage))
|
||
{
|
||
sender = transform,
|
||
hitPosition = hitPoint
|
||
};
|
||
|
||
if (knockbackForce > 0f)
|
||
damageInfo.force = hitDirection * knockbackForce;
|
||
|
||
bool damageDealt = false;
|
||
|
||
// vIDamageReceiver
|
||
var receiver = targetCollider.GetComponent<vIDamageReceiver>() ??
|
||
targetCollider.GetComponentInParent<vIDamageReceiver>();
|
||
if (receiver != null)
|
||
{
|
||
receiver.TakeDamage(damageInfo);
|
||
damageDealt = true;
|
||
hasDealtDamage = true;
|
||
if (enableDebug) Debug.Log("[MeteorDamage] Damage via vIDamageReceiver");
|
||
}
|
||
|
||
// vHealthController
|
||
if (!damageDealt)
|
||
{
|
||
var health = targetCollider.GetComponent<vHealthController>() ??
|
||
targetCollider.GetComponentInParent<vHealthController>();
|
||
if (health != null)
|
||
{
|
||
health.TakeDamage(damageInfo);
|
||
damageDealt = true;
|
||
hasDealtDamage = true;
|
||
if (enableDebug) Debug.Log("[MeteorDamage] Damage via vHealthController");
|
||
}
|
||
}
|
||
|
||
// vThirdPersonController (including Beyond variant)
|
||
if (!damageDealt)
|
||
{
|
||
var tpc = targetCollider.GetComponent<vThirdPersonController>() ??
|
||
targetCollider.GetComponentInParent<vThirdPersonController>();
|
||
if (tpc != null)
|
||
{
|
||
if (tpc is Beyond.bThirdPersonController beyond)
|
||
{
|
||
if (!beyond.GodMode && !beyond.isImmortal)
|
||
{
|
||
tpc.TakeDamage(damageInfo);
|
||
damageDealt = true;
|
||
hasDealtDamage = true;
|
||
if (enableDebug) Debug.Log("[MeteorDamage] Damage via bThirdPersonController");
|
||
}
|
||
else if (enableDebug)
|
||
{
|
||
Debug.Log("[MeteorDamage] Target is immortal/GodMode – no damage");
|
||
}
|
||
}
|
||
else
|
||
{
|
||
tpc.TakeDamage(damageInfo);
|
||
damageDealt = true;
|
||
hasDealtDamage = true;
|
||
if (enableDebug) Debug.Log("[MeteorDamage] Damage via vThirdPersonController");
|
||
}
|
||
}
|
||
}
|
||
|
||
if (!damageDealt && enableDebug)
|
||
Debug.LogWarning("[MeteorDamage] No valid damage receiver found!");
|
||
}
|
||
|
||
/// <summary>
|
||
/// Deals AOE damage to all colliders within explosionRadius using Invector-compatible logic.
|
||
/// </summary>
|
||
private void DealAreaDamage(Vector3 center)
|
||
{
|
||
Collider[] hits = Physics.OverlapSphere(center, explosionRadius, explosionMask, QueryTriggerInteraction.Ignore);
|
||
foreach (var col in hits)
|
||
{
|
||
if (col == null) continue;
|
||
if (aoeOnlyTargetTag && !col.CompareTag(targetTag)) continue;
|
||
|
||
// Avoid double-hitting the same single target if already processed
|
||
if (col.CompareTag(targetTag) && hasDealtDamage) continue;
|
||
|
||
Vector3 hp = col.ClosestPoint(center);
|
||
Vector3 dir = (col.bounds.center - center).normalized;
|
||
|
||
vDamage damageInfo = new vDamage(Mathf.RoundToInt(damage))
|
||
{
|
||
sender = transform,
|
||
hitPosition = hp,
|
||
force = knockbackForce > 0f ? dir * knockbackForce : Vector3.zero
|
||
};
|
||
|
||
bool dealt = false;
|
||
|
||
var receiver = col.GetComponent<vIDamageReceiver>() ?? col.GetComponentInParent<vIDamageReceiver>();
|
||
if (receiver != null) { receiver.TakeDamage(damageInfo); dealt = true; }
|
||
|
||
if (!dealt)
|
||
{
|
||
var health = col.GetComponent<vHealthController>() ?? col.GetComponentInParent<vHealthController>();
|
||
if (health != null) { health.TakeDamage(damageInfo); dealt = true; }
|
||
}
|
||
|
||
if (!dealt)
|
||
{
|
||
var tpc = col.GetComponent<vThirdPersonController>() ?? col.GetComponentInParent<vThirdPersonController>();
|
||
if (tpc != null)
|
||
{
|
||
if (tpc is Beyond.bThirdPersonController beyond)
|
||
{
|
||
if (!beyond.GodMode && !beyond.isImmortal) { tpc.TakeDamage(damageInfo); dealt = true; }
|
||
}
|
||
else { tpc.TakeDamage(damageInfo); dealt = true; }
|
||
}
|
||
}
|
||
}
|
||
|
||
// Consider AOE as the final damage application for this projectile
|
||
hasDealtDamage = true;
|
||
}
|
||
|
||
/// <summary>
|
||
/// Gets the closest point on a collider to this projectile's position.
|
||
/// </summary>
|
||
private Vector3 GetClosestPointOnCollider(Collider col)
|
||
{
|
||
return col.ClosestPoint(transform.position);
|
||
}
|
||
|
||
/// <summary>
|
||
/// Computes a hit direction from projectile toward a collider's center; falls back to current velocity.
|
||
/// </summary>
|
||
private Vector3 GetHitDirection(Collider col)
|
||
{
|
||
Vector3 dir = (col.bounds.center - transform.position).normalized;
|
||
return dir.sqrMagnitude > 0.0001f ? dir : SafeNormal(velocity);
|
||
}
|
||
|
||
#endregion Impact & Damage
|
||
|
||
#region Pooling
|
||
|
||
/// <summary>
|
||
/// Returns this projectile to the LeanPool.
|
||
/// </summary>
|
||
private void Despawn()
|
||
{
|
||
if (enableDebug) Debug.Log("[Meteor] Despawn via LeanPool");
|
||
LeanPool.Despawn(gameObject);
|
||
}
|
||
|
||
#endregion Pooling
|
||
|
||
#if UNITY_EDITOR
|
||
|
||
/// <summary>
|
||
/// Editor-only gizmos to visualize entry direction and impact point when selected.
|
||
/// </summary>
|
||
private void OnDrawGizmosSelected()
|
||
{
|
||
Gizmos.color = Color.cyan;
|
||
Gizmos.DrawWireSphere(transform.position, 0.25f);
|
||
|
||
Gizmos.color = Color.red;
|
||
Gizmos.DrawLine(transform.position, transform.position + SafeNormal(velocity.sqrMagnitude > 0 ? velocity : velocityDir) * 3f);
|
||
|
||
Gizmos.color = Color.yellow;
|
||
Gizmos.DrawWireSphere(impactPoint, 0.35f);
|
||
}
|
||
|
||
#endif
|
||
|
||
#region Validation
|
||
|
||
/// <summary>
|
||
/// Clamps and validates configuration values in the inspector.
|
||
/// </summary>
|
||
private void OnValidate()
|
||
{
|
||
speed = Mathf.Max(0f, speed);
|
||
maxLifeTime = Mathf.Max(0.01f, maxLifeTime);
|
||
explosionRadius = Mathf.Max(0f, explosionRadius);
|
||
castRadius = Mathf.Clamp(castRadius, 0.01f, 2f);
|
||
entryAngleDownFromHorizontal = Mathf.Clamp(entryAngleDownFromHorizontal, 0f, 89f);
|
||
azimuthAimWeight = Mathf.Clamp01(azimuthAimWeight);
|
||
}
|
||
|
||
#endregion Validation
|
||
}
|
||
} |