From 2be2e52fe49f538c9637015b67c3421061c2f823 Mon Sep 17 00:00:00 2001 From: Ben Calegari Date: Tue, 9 Jun 2026 21:50:03 -0700 Subject: [PATCH] Painted units correspond to damage types --- Assets/_Project/Scripts/Combat/Projectile.cs | 130 ++++++++++---- Assets/_Project/Scripts/Combat/TowerCombat.cs | 165 ++++++++++++++---- Project_Roadmap.md | 22 ++- 3 files changed, 242 insertions(+), 75 deletions(-) diff --git a/Assets/_Project/Scripts/Combat/Projectile.cs b/Assets/_Project/Scripts/Combat/Projectile.cs index c22e8fb..bb10724 100644 --- a/Assets/_Project/Scripts/Combat/Projectile.cs +++ b/Assets/_Project/Scripts/Combat/Projectile.cs @@ -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 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 /// 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()) + 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() - ?.ApplyEffect(damageType, magnitude, effectDuration, sourceOwner); + ?.ApplyEffect(damageType, magnitude, effectDuration, sourceOwner); } } } -} +} \ No newline at end of file diff --git a/Assets/_Project/Scripts/Combat/TowerCombat.cs b/Assets/_Project/Scripts/Combat/TowerCombat.cs index d270b96..acb06ef 100644 --- a/Assets/_Project/Scripts/Combat/TowerCombat.cs +++ b/Assets/_Project/Scripts/Combat/TowerCombat.cs @@ -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(); 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 { primary.transform.position }; var alreadyHit = new HashSet { 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() - ?.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().Spawn(); } diff --git a/Project_Roadmap.md b/Project_Roadmap.md index cb329f2..a02701f 100644 --- a/Project_Roadmap.md +++ b/Project_Roadmap.md @@ -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).