Painted units correspond to damage types

This commit is contained in:
Ben Calegari 2026-06-09 21:50:03 -07:00
parent 04ead32846
commit 2be2e52fe4
3 changed files with 242 additions and 75 deletions

View file

@ -1,4 +1,5 @@
// Assets/_Project/Scripts/Combat/Projectile.cs
using Unity.Netcode;
using UnityEngine;
using TD.Core;
@ -35,17 +36,32 @@ namespace TD.Combat
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;
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];
@ -58,35 +74,74 @@ namespace TD.Combat
/// </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)
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.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;
initialized = true;
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()
@ -138,6 +193,7 @@ namespace TD.Combat
HitEnemy(eh);
}
}
break;
}
}
@ -150,16 +206,16 @@ namespace TD.Combat
{
float magnitude = damageType switch
{
DamageType.Cold => slowFactor,
DamageType.Fire => dotDamagePerSecond,
DamageType.Cold => slowFactor,
DamageType.Fire => dotDamagePerSecond,
DamageType.Poison => dotDamagePerSecond,
_ => 0f,
_ => 0f,
};
if (magnitude > 0f)
eh.GetComponent<EnemyStatus>()
?.ApplyEffect(damageType, magnitude, effectDuration, sourceOwner);
?.ApplyEffect(damageType, magnitude, effectDuration, sourceOwner);
}
}
}
}
}

View file

@ -226,73 +226,162 @@ namespace TD.Combat
{
Vector3 targetPos = currentTarget.transform.position;
// Resolve the effective combat profile for this shot: the tower's authored
// stats, overridden by its Paint color (Red = splash, Green = poison DoT,
// Blue = slow). Unpainted towers fire exactly as authored.
PaintColor paint = towerInstance.Paint;
CombatProfile profile = BuildProfile(def, paint);
if (def.ProjectilePrefab == null)
{
// Hitscan — apply damage this frame.
ApplyDamageToTarget(def, currentTarget, targetPos);
ApplyDamageToTarget(profile, currentTarget, targetPos);
}
else
{
SpawnProjectile(def, currentTarget);
SpawnProjectile(def, profile, paint, currentTarget);
}
FireClientRpc(targetPos);
}
// ----- Paint → combat effect tuning --------------------------------
//
// Paint overrides the tower's effect profile (paint takes precedence over any
// authored effect). Centralized here so the numbers are easy to find and tweak;
// promote to a ScriptableObject if designers need to tune them without a recompile.
private const float PaintSplashRadius = 2.0f; // world units (tiles)
private const float PaintPoisonDpsFraction = 0.5f; // DoT/sec = base Damage × this
private const float PaintPoisonDuration = 3.0f; // seconds
private const float PaintSlowSpeedRetained = 0.5f; // Cold magnitude: 0.5 = half speed
private const float PaintSlowDuration = 2.0f; // seconds
// The firing-relevant stats for a single shot, after applying paint overrides.
// Mirrors the TowerDefinition fields the firing path reads so the rest of the
// code is agnostic to whether a value came from the asset or from paint.
private readonly struct CombatProfile
{
public readonly float Damage;
public readonly DamageType DamageType;
public readonly TargetType TargetType;
public readonly float SplashRadius;
public readonly float SlowFactor;
public readonly float DotDamagePerSecond;
public readonly float EffectDuration;
public readonly float ProjectileSpeed;
public readonly int ChainCount;
public readonly float ChainRange;
public CombatProfile(float damage, DamageType damageType, TargetType targetType,
float splashRadius, float slowFactor, float dotDamagePerSecond,
float effectDuration, float projectileSpeed, int chainCount, float chainRange)
{
Damage = damage;
DamageType = damageType;
TargetType = targetType;
SplashRadius = splashRadius;
SlowFactor = slowFactor;
DotDamagePerSecond = dotDamagePerSecond;
EffectDuration = effectDuration;
ProjectileSpeed = projectileSpeed;
ChainCount = chainCount;
ChainRange = chainRange;
}
}
private static CombatProfile BuildProfile(TowerDefinition def, PaintColor paint)
{
// Start from the tower's authored stats.
float damage = def.Damage;
DamageType damageType = def.DamageType;
TargetType targetType = def.TargetType;
float splash = def.SplashRadius;
float slow = def.SlowFactor;
float dot = def.DotDamagePerSecond;
float duration = def.EffectDuration;
switch (paint)
{
case PaintColor.Red: // area splash
targetType = TargetType.Splash;
splash = PaintSplashRadius;
break;
case PaintColor.Green: // poison damage-over-time
damageType = DamageType.Poison;
dot = def.Damage * PaintPoisonDpsFraction;
duration = PaintPoisonDuration;
break;
case PaintColor.Blue: // chilling slow
damageType = DamageType.Cold;
slow = PaintSlowSpeedRetained;
duration = PaintSlowDuration;
break;
case PaintColor.None:
default:
break; // fire as authored
}
return new CombatProfile(damage, damageType, targetType, splash, slow, dot,
duration, def.ProjectileSpeed, def.ChainCount, def.ChainRange);
}
// ----- Damage application ------------------------------------------
private void ApplyDamageToTarget(TowerDefinition def, EnemyHealth primary, Vector3 primaryPos)
private void ApplyDamageToTarget(in CombatProfile p, EnemyHealth primary, Vector3 primaryPos)
{
PlayerSlot owner = towerInstance.Owner;
switch (def.TargetType)
switch (p.TargetType)
{
case TargetType.Single:
HitEnemy(def, primary, owner);
HitEnemy(p, primary, owner);
break;
case TargetType.Splash:
HitEnemy(def, primary, owner);
ApplySplash(def, primary, primaryPos, owner);
HitEnemy(p, primary, owner);
ApplySplash(p, primary, primaryPos, owner);
break;
case TargetType.Chain:
ApplyChain(def, primary, owner);
ApplyChain(p, primary, owner);
break;
}
}
private void ApplySplash(TowerDefinition def, EnemyHealth primary,
private void ApplySplash(in CombatProfile p, EnemyHealth primary,
Vector3 origin, PlayerSlot owner)
{
if (def.SplashRadius <= 0f) return;
if (p.SplashRadius <= 0f) return;
int count = Physics.OverlapSphereNonAlloc(
origin, def.SplashRadius, s_overlapBuffer, enemyLayerMask);
origin, p.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)primary) continue;
HitEnemy(def, eh, owner);
HitEnemy(p, eh, owner);
}
}
private void ApplyChain(TowerDefinition def, EnemyHealth primary, PlayerSlot owner)
private void ApplyChain(in CombatProfile p, EnemyHealth primary, PlayerSlot owner)
{
var hitPositions = new List<Vector3> { primary.transform.position };
var alreadyHit = new HashSet<EnemyHealth> { primary };
HitEnemy(def, primary, owner);
HitEnemy(p, primary, owner);
EnemyHealth current = primary;
for (int jump = 0; jump < def.ChainCount; jump++)
for (int jump = 0; jump < p.ChainCount; jump++)
{
EnemyHealth next = null;
float bestSqr = float.MaxValue;
int count = Physics.OverlapSphereNonAlloc(
current.transform.position, def.ChainRange, s_overlapBuffer, enemyLayerMask);
current.transform.position, p.ChainRange, s_overlapBuffer, enemyLayerMask);
for (int i = 0; i < count; i++)
{
@ -307,40 +396,41 @@ namespace TD.Combat
alreadyHit.Add(next);
hitPositions.Add(next.transform.position);
HitEnemy(def, next, owner);
HitEnemy(p, next, owner);
current = next;
}
ChainFiredClientRpc(hitPositions.ToArray());
}
private void HitEnemy(TowerDefinition def, EnemyHealth target, PlayerSlot owner)
private void HitEnemy(in CombatProfile p, EnemyHealth target, PlayerSlot owner)
{
target.TakeDamage(def.Damage, def.DamageType, owner);
ApplyStatusEffect(def, target, owner);
target.TakeDamage(p.Damage, p.DamageType, owner);
ApplyStatusEffect(p, target, owner);
}
private void ApplyStatusEffect(TowerDefinition def, EnemyHealth target, PlayerSlot owner)
private void ApplyStatusEffect(in CombatProfile p, EnemyHealth target, PlayerSlot owner)
{
if (def.EffectDuration <= 0f) return;
if (p.EffectDuration <= 0f) return;
float magnitude = def.DamageType switch
float magnitude = p.DamageType switch
{
DamageType.Cold => def.SlowFactor,
DamageType.Fire => def.DotDamagePerSecond,
DamageType.Poison => def.DotDamagePerSecond,
DamageType.Cold => p.SlowFactor,
DamageType.Fire => p.DotDamagePerSecond,
DamageType.Poison => p.DotDamagePerSecond,
_ => 0f,
};
if (magnitude <= 0f) return;
target.GetComponent<EnemyStatus>()
?.ApplyEffect(def.DamageType, magnitude, def.EffectDuration, owner);
?.ApplyEffect(p.DamageType, magnitude, p.EffectDuration, owner);
}
// ----- Projectile spawning -----------------------------------------
private void SpawnProjectile(TowerDefinition def, EnemyHealth target)
private void SpawnProjectile(TowerDefinition def, in CombatProfile p,
PaintColor tint, EnemyHealth target)
{
var go = Instantiate(def.ProjectilePrefab, transform.position, Quaternion.identity);
@ -356,16 +446,17 @@ namespace TD.Combat
proj.InitializeServer(
target,
def.Damage,
def.DamageType,
def.TargetType,
def.SplashRadius,
def.SlowFactor,
def.DotDamagePerSecond,
def.EffectDuration,
def.ProjectileSpeed,
p.Damage,
p.DamageType,
p.TargetType,
p.SplashRadius,
p.SlowFactor,
p.DotDamagePerSecond,
p.EffectDuration,
p.ProjectileSpeed,
enemyLayerMask,
towerInstance.Owner);
towerInstance.Owner,
tint);
go.GetComponent<NetworkObject>().Spawn();
}