Painted units correspond to damage types
This commit is contained in:
parent
04ead32846
commit
2be2e52fe4
3 changed files with 242 additions and 75 deletions
|
|
@ -1,4 +1,5 @@
|
||||||
// Assets/_Project/Scripts/Combat/Projectile.cs
|
// Assets/_Project/Scripts/Combat/Projectile.cs
|
||||||
|
|
||||||
using Unity.Netcode;
|
using Unity.Netcode;
|
||||||
using UnityEngine;
|
using UnityEngine;
|
||||||
using TD.Core;
|
using TD.Core;
|
||||||
|
|
@ -47,6 +48,21 @@ namespace TD.Combat
|
||||||
private PlayerSlot sourceOwner;
|
private PlayerSlot sourceOwner;
|
||||||
private bool initialized;
|
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];
|
private static readonly Collider[] s_overlapBuffer = new Collider[32];
|
||||||
|
|
||||||
// ----- Pre-spawn init ---------------------------------------------
|
// ----- Pre-spawn init ---------------------------------------------
|
||||||
|
|
@ -67,7 +83,8 @@ namespace TD.Combat
|
||||||
float effectDuration,
|
float effectDuration,
|
||||||
float speed,
|
float speed,
|
||||||
LayerMask enemyLayerMask,
|
LayerMask enemyLayerMask,
|
||||||
PlayerSlot sourceOwner)
|
PlayerSlot sourceOwner,
|
||||||
|
PaintColor tint = PaintColor.None)
|
||||||
{
|
{
|
||||||
this.target = target;
|
this.target = target;
|
||||||
this.damage = damage;
|
this.damage = damage;
|
||||||
|
|
@ -80,6 +97,7 @@ namespace TD.Combat
|
||||||
this.speed = speed;
|
this.speed = speed;
|
||||||
this.enemyLayerMask = enemyLayerMask;
|
this.enemyLayerMask = enemyLayerMask;
|
||||||
this.sourceOwner = sourceOwner;
|
this.sourceOwner = sourceOwner;
|
||||||
|
this.pendingTint = tint;
|
||||||
initialized = true;
|
initialized = true;
|
||||||
|
|
||||||
if (targetType == TargetType.Chain)
|
if (targetType == TargetType.Chain)
|
||||||
|
|
@ -87,6 +105,43 @@ namespace TD.Combat
|
||||||
"This projectile will hit the primary target 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 ----------------------------
|
// ----- Server movement + hit detection ----------------------------
|
||||||
|
|
||||||
private void Update()
|
private void Update()
|
||||||
|
|
@ -138,6 +193,7 @@ namespace TD.Combat
|
||||||
HitEnemy(eh);
|
HitEnemy(eh);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -226,73 +226,162 @@ namespace TD.Combat
|
||||||
{
|
{
|
||||||
Vector3 targetPos = currentTarget.transform.position;
|
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)
|
if (def.ProjectilePrefab == null)
|
||||||
{
|
{
|
||||||
// Hitscan — apply damage this frame.
|
// Hitscan — apply damage this frame.
|
||||||
ApplyDamageToTarget(def, currentTarget, targetPos);
|
ApplyDamageToTarget(profile, currentTarget, targetPos);
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
SpawnProjectile(def, currentTarget);
|
SpawnProjectile(def, profile, paint, currentTarget);
|
||||||
}
|
}
|
||||||
|
|
||||||
FireClientRpc(targetPos);
|
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 ------------------------------------------
|
// ----- 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;
|
PlayerSlot owner = towerInstance.Owner;
|
||||||
|
|
||||||
switch (def.TargetType)
|
switch (p.TargetType)
|
||||||
{
|
{
|
||||||
case TargetType.Single:
|
case TargetType.Single:
|
||||||
HitEnemy(def, primary, owner);
|
HitEnemy(p, primary, owner);
|
||||||
break;
|
break;
|
||||||
|
|
||||||
case TargetType.Splash:
|
case TargetType.Splash:
|
||||||
HitEnemy(def, primary, owner);
|
HitEnemy(p, primary, owner);
|
||||||
ApplySplash(def, primary, primaryPos, owner);
|
ApplySplash(p, primary, primaryPos, owner);
|
||||||
break;
|
break;
|
||||||
|
|
||||||
case TargetType.Chain:
|
case TargetType.Chain:
|
||||||
ApplyChain(def, primary, owner);
|
ApplyChain(p, primary, owner);
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private void ApplySplash(TowerDefinition def, EnemyHealth primary,
|
private void ApplySplash(in CombatProfile p, EnemyHealth primary,
|
||||||
Vector3 origin, PlayerSlot owner)
|
Vector3 origin, PlayerSlot owner)
|
||||||
{
|
{
|
||||||
if (def.SplashRadius <= 0f) return;
|
if (p.SplashRadius <= 0f) return;
|
||||||
|
|
||||||
int count = Physics.OverlapSphereNonAlloc(
|
int count = Physics.OverlapSphereNonAlloc(
|
||||||
origin, def.SplashRadius, s_overlapBuffer, enemyLayerMask);
|
origin, p.SplashRadius, s_overlapBuffer, enemyLayerMask);
|
||||||
|
|
||||||
for (int i = 0; i < count; i++)
|
for (int i = 0; i < count; i++)
|
||||||
{
|
{
|
||||||
var eh = s_overlapBuffer[i].GetComponent<EnemyHealth>();
|
var eh = s_overlapBuffer[i].GetComponent<EnemyHealth>();
|
||||||
if (eh == null || eh.IsDead || (object)eh == (object)primary) continue;
|
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 hitPositions = new List<Vector3> { primary.transform.position };
|
||||||
var alreadyHit = new HashSet<EnemyHealth> { primary };
|
var alreadyHit = new HashSet<EnemyHealth> { primary };
|
||||||
|
|
||||||
HitEnemy(def, primary, owner);
|
HitEnemy(p, primary, owner);
|
||||||
|
|
||||||
EnemyHealth current = primary;
|
EnemyHealth current = primary;
|
||||||
for (int jump = 0; jump < def.ChainCount; jump++)
|
for (int jump = 0; jump < p.ChainCount; jump++)
|
||||||
{
|
{
|
||||||
EnemyHealth next = null;
|
EnemyHealth next = null;
|
||||||
float bestSqr = float.MaxValue;
|
float bestSqr = float.MaxValue;
|
||||||
|
|
||||||
int count = Physics.OverlapSphereNonAlloc(
|
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++)
|
for (int i = 0; i < count; i++)
|
||||||
{
|
{
|
||||||
|
|
@ -307,40 +396,41 @@ namespace TD.Combat
|
||||||
|
|
||||||
alreadyHit.Add(next);
|
alreadyHit.Add(next);
|
||||||
hitPositions.Add(next.transform.position);
|
hitPositions.Add(next.transform.position);
|
||||||
HitEnemy(def, next, owner);
|
HitEnemy(p, next, owner);
|
||||||
current = next;
|
current = next;
|
||||||
}
|
}
|
||||||
|
|
||||||
ChainFiredClientRpc(hitPositions.ToArray());
|
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);
|
target.TakeDamage(p.Damage, p.DamageType, owner);
|
||||||
ApplyStatusEffect(def, target, 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.Cold => p.SlowFactor,
|
||||||
DamageType.Fire => def.DotDamagePerSecond,
|
DamageType.Fire => p.DotDamagePerSecond,
|
||||||
DamageType.Poison => def.DotDamagePerSecond,
|
DamageType.Poison => p.DotDamagePerSecond,
|
||||||
_ => 0f,
|
_ => 0f,
|
||||||
};
|
};
|
||||||
|
|
||||||
if (magnitude <= 0f) return;
|
if (magnitude <= 0f) return;
|
||||||
|
|
||||||
target.GetComponent<EnemyStatus>()
|
target.GetComponent<EnemyStatus>()
|
||||||
?.ApplyEffect(def.DamageType, magnitude, def.EffectDuration, owner);
|
?.ApplyEffect(p.DamageType, magnitude, p.EffectDuration, owner);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ----- Projectile spawning -----------------------------------------
|
// ----- 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);
|
var go = Instantiate(def.ProjectilePrefab, transform.position, Quaternion.identity);
|
||||||
|
|
||||||
|
|
@ -356,16 +446,17 @@ namespace TD.Combat
|
||||||
|
|
||||||
proj.InitializeServer(
|
proj.InitializeServer(
|
||||||
target,
|
target,
|
||||||
def.Damage,
|
p.Damage,
|
||||||
def.DamageType,
|
p.DamageType,
|
||||||
def.TargetType,
|
p.TargetType,
|
||||||
def.SplashRadius,
|
p.SplashRadius,
|
||||||
def.SlowFactor,
|
p.SlowFactor,
|
||||||
def.DotDamagePerSecond,
|
p.DotDamagePerSecond,
|
||||||
def.EffectDuration,
|
p.EffectDuration,
|
||||||
def.ProjectileSpeed,
|
p.ProjectileSpeed,
|
||||||
enemyLayerMask,
|
enemyLayerMask,
|
||||||
towerInstance.Owner);
|
towerInstance.Owner,
|
||||||
|
tint);
|
||||||
|
|
||||||
go.GetComponent<NetworkObject>().Spawn();
|
go.GetComponent<NetworkObject>().Spawn();
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -155,10 +155,30 @@ Flagged for revisit; not blocking anything.
|
||||||
- Center-on-builder hotkey (e.g., Space)
|
- Center-on-builder hotkey (e.g., Space)
|
||||||
- Initial camera position taking race or match phase into account
|
- 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.
|
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**
|
**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).
|
- Every player starts each match with the same **base tower set** — a small fixed roster (universal or per-race; see open question below).
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue