UnityTowerDefense/Assets/_Project/Scripts/Combat/Projectile.cs
2026-06-09 21:50:03 -07:00

221 lines
No EOL
8 KiB
C#

// Assets/_Project/Scripts/Combat/Projectile.cs
using Unity.Netcode;
using UnityEngine;
using TD.Core;
using TD.Gameplay;
namespace TD.Combat
{
/// <summary>
/// A traveling projectile spawned by <see cref="TowerCombat"/> when a tower has
/// a non-null <c>ProjectilePrefab</c> configured.
/// </summary>
/// <remarks>
/// <b>Authority:</b> Movement and hit detection run server-only.
/// <c>NetworkTransform</c> (required on the prefab) replicates position to clients
/// so the projectile is visible on all peers.
///
/// <b>Initialization:</b> Call <see cref="InitializeServer"/> after
/// <c>Instantiate</c> and before <c>NetworkObject.Spawn()</c>.
///
/// <b>Kill attribution:</b> <paramref name="sourceOwner"/> is the
/// <see cref="PlayerSlot"/> of the firing tower. It is passed to
/// <see cref="EnemyHealth.TakeDamage"/> and <see cref="EnemyStatus.ApplyEffect"/>
/// so kill gold credits the correct player even for projectile kills.
///
/// <b>Chain + Projectile:</b> <see cref="TargetType.Chain"/> is hitscan-only.
/// A projectile with Chain type will hit the primary target only and log a warning.
///
/// <b>Prefab requirements:</b> <c>NetworkObject</c>, <c>NetworkTransform</c>,
/// and this component at the root.
/// </remarks>
[RequireComponent(typeof(NetworkObject))]
public class Projectile : NetworkBehaviour
{
private const float HitThresholdSq = 0.09f; // 0.3 world units
private EnemyHealth target;
private float damage;
private DamageType damageType;
private TargetType targetType;
private float splashRadius;
private float slowFactor;
private float dotDamagePerSecond;
private float effectDuration;
private float speed;
private LayerMask enemyLayerMask;
private PlayerSlot sourceOwner;
private bool initialized;
// Paint tint. Set pre-spawn (pendingTint), committed to the replicated
// NetworkVariable in OnNetworkSpawn on the server, and applied as a mesh tint
// on every client so a painted tower's projectiles match its color.
private PaintColor pendingTint;
private readonly NetworkVariable<PaintColor> tintColor =
new(
PaintColor.None,
NetworkVariableReadPermission.Everyone,
NetworkVariableWritePermission.Server);
private MaterialPropertyBlock tintBlock;
private static readonly int ColorPropertyId = Shader.PropertyToID("_Color");
private static readonly int BaseColorPropertyId = Shader.PropertyToID("_BaseColor");
private static readonly Collider[] s_overlapBuffer = new Collider[32];
// ----- Pre-spawn init ---------------------------------------------
/// <summary>
/// Called by <see cref="TowerCombat"/> after <c>Instantiate</c> and before
/// <c>NetworkObject.Spawn()</c>. <paramref name="sourceOwner"/> is the firing
/// tower's <see cref="PlayerSlot"/> for kill-gold attribution.
/// </summary>
public void InitializeServer(
EnemyHealth target,
float damage,
DamageType damageType,
TargetType targetType,
float splashRadius,
float slowFactor,
float dotDamagePerSecond,
float effectDuration,
float speed,
LayerMask enemyLayerMask,
PlayerSlot sourceOwner,
PaintColor tint = PaintColor.None)
{
this.target = target;
this.damage = damage;
this.damageType = damageType;
this.targetType = targetType;
this.splashRadius = splashRadius;
this.slowFactor = slowFactor;
this.dotDamagePerSecond = dotDamagePerSecond;
this.effectDuration = effectDuration;
this.speed = speed;
this.enemyLayerMask = enemyLayerMask;
this.sourceOwner = sourceOwner;
this.pendingTint = tint;
initialized = true;
if (targetType == TargetType.Chain)
Debug.LogWarning("[Projectile] TargetType.Chain is hitscan-only. " +
"This projectile will hit the primary target only.");
}
// ----- Spawn lifecycle (tint) -------------------------------------
public override void OnNetworkSpawn()
{
// Commit the pre-spawn tint on the server; NGO includes this write in the
// initial sync so every client reads the right value in its OnNetworkSpawn.
if (IsServer)
tintColor.Value = pendingTint;
tintColor.OnValueChanged += HandleTintChanged;
ApplyTint();
}
public override void OnNetworkDespawn()
{
tintColor.OnValueChanged -= HandleTintChanged;
}
private void HandleTintChanged(PaintColor previous, PaintColor current) => ApplyTint();
// Tints all renderers to the paint color via a MaterialPropertyBlock. No-op when
// unpainted so the prefab's authored projectile material shows through.
private void ApplyTint()
{
if (tintColor.Value == PaintColor.None) return;
Color c = PaintColors.Get(tintColor.Value);
c.a = 1f;
tintBlock ??= new MaterialPropertyBlock();
tintBlock.SetColor(ColorPropertyId, c);
tintBlock.SetColor(BaseColorPropertyId, c);
foreach (var rend in GetComponentsInChildren<Renderer>())
rend.SetPropertyBlock(tintBlock);
}
// ----- Server movement + hit detection ----------------------------
private void Update()
{
if (!IsServer || !initialized) return;
if (target == null || target.IsDead || (target as UnityEngine.Object) == null)
{
NetworkObject.Despawn();
return;
}
Vector3 toTarget = target.transform.position - transform.position;
if (toTarget.sqrMagnitude <= HitThresholdSq)
{
ApplyHit();
NetworkObject.Despawn();
return;
}
transform.rotation = Quaternion.LookRotation(toTarget);
transform.position += toTarget.normalized * (speed * Time.deltaTime);
}
// ----- Hit application --------------------------------------------
private void ApplyHit()
{
switch (targetType)
{
case TargetType.Single:
case TargetType.Chain:
HitEnemy(target);
break;
case TargetType.Splash:
HitEnemy(target);
if (splashRadius > 0f)
{
int count = Physics.OverlapSphereNonAlloc(
transform.position, splashRadius,
s_overlapBuffer, enemyLayerMask);
for (int i = 0; i < count; i++)
{
var eh = s_overlapBuffer[i].GetComponent<EnemyHealth>();
if (eh == null || eh.IsDead || (object)eh == (object)target) continue;
HitEnemy(eh);
}
}
break;
}
}
private void HitEnemy(EnemyHealth eh)
{
eh.TakeDamage(damage, damageType, sourceOwner);
if (effectDuration > 0f)
{
float magnitude = damageType switch
{
DamageType.Cold => slowFactor,
DamageType.Fire => dotDamagePerSecond,
DamageType.Poison => dotDamagePerSecond,
_ => 0f,
};
if (magnitude > 0f)
eh.GetComponent<EnemyStatus>()
?.ApplyEffect(damageType, magnitude, effectDuration, sourceOwner);
}
}
}
}