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,15 +206,15 @@ 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();
}

View file

@ -155,10 +155,30 @@ Flagged for revisit; not blocking anything.
- Center-on-builder hotkey (e.g., Space)
- Initial camera position taking race or match phase into account
### 1.10 Tower Customization & Meta-Progression (DEFERRED — DESIGN CAPTURED)
### 1.10 Tower Customization & Meta-Progression (DEFERRED — DESIGN CAPTURED; IN-MATCH PAINT v1 + EFFECTS IMPLEMENTED)
Long-term cross-match progression loop. Recorded here so Phase 1.8 race-system design and Phase 2 visual prototype work can anticipate the customization data model when they're scheduled. Not blocking Phase 1 exit criteria — a single match is fully playable without it.
**Implemented — in-match paint v1 + paint-driven effects (2026-06-03 / 2026-06-09)**
A first-pass, in-match paint mechanic is built, verified in-engine, and now drives both tower visuals **and** projectile behavior.
Paint UI + recolor (2026-06-03):
- **Build / Paint tabs** in the command grid (shown when the builder is selected). Build tab is the existing tower menu.
- **Paint tab:** Red / Green / Blue swatches plus a **Reset** (clear) brush, selectable by click or **Q / W / E / R** hotkeys. The active brush swaps the cursor to a tinted paint circle.
- **Click a tower you own → recolors it**; Reset reverts it to its owner color.
- **Server-authoritative & networked:** `PaintColor` enum + `paintColor` `NetworkVariable` on `TowerInstance`, set via `RequestPaintServerRpc` (own-tower-only validation), replicated to all clients. Tint applied through the existing `MaterialPropertyBlock` path (`TowerInstance.ApplyTint`, which falls back to all child `MeshRenderer`s when none are explicitly listed).
- **New code:** `TowerPaintController` (client-local, mirrors `TowerPlacementController`), `PaintColors` palette in Core, Paint tab + swatches in `HUDController`.
Paint-driven combat effects (2026-06-09):
- `TowerCombat` resolves an effective **`CombatProfile`** per shot from the tower's `TowerDefinition` overridden by its `Paint` color, threaded through both the projectile and hitscan paths (unpainted towers fire as authored).
- **Red = Splash** (radius 2 tiles), **Green = Poison DoT** (½ base damage/sec for 3s), **Blue = Cold slow** (50% speed for 2s). Reuses the existing `EnemyStatus.ApplyEffect` / splash systems. Magnitudes are named constants in `TowerCombat` (promote to a ScriptableObject if designer tuning without recompile is needed).
- **Projectiles tint to the paint color**, replicated via a `PaintColor` `NetworkVariable` on `Projectile` (same pre-spawn → `OnNetworkSpawn` pattern as `TowerInstance`).
Still pending for the full meta-progression:
- Persistence / player profile, end-of-match reward rolls, decals, stat-modifier stack, layering / set bonuses, and the customization menu — all remain deferred as described below.
- The current paint→effect mapping is **override** (paint replaces authored effects) and a fixed 3-color set; the eventual customization system will generalize this to a layered modifier stack drawn from a reward pool.
**Core concept**
- Every player starts each match with the same **base tower set** — a small fixed roster (universal or per-race; see open question below).