Conveyance/TriggerAreaEffect.cs
using Sandbox;
using System.Collections.Generic;
using System.Linq;
/// <summary>
/// Drop on any trigger volume (Collider with IsTrigger = true) to apply a
/// continuous or instant effect to entities that enter it.
///
/// EFFECT TYPES
/// InstantDeath — kills the player immediately on entry (sharks, lava)
/// DamagePerSec — deals damage every second while inside (fire, acid)
/// SlowZone — slows Players, PatrolMovers, and SimpleAnimalControllers
/// PushZone — applies a constant directional force (wind, updraft)
///
/// NETWORKING — execution machine per effect type
/// InstantDeath, DamagePerSec → Host-only.
/// Player.OnDamage is authoritative on the host; calling it from a
/// remote client has no effect, which is why the older IsProxy guard
/// silently dropped damage for non-host players.
/// SlowZone → Every machine.
/// PlayerController.WalkSpeed is read locally on each client, so each
/// machine needs to apply the slow to its own simulation. Tickets are
/// tracked per-target so overlaps compose cleanly on each side.
/// PushZone → Host-only.
/// Rigidbody.Velocity changes should originate on the host so the
/// network-replicated physics state stays authoritative.
///
/// SLOW ZONE — overlap handling
/// Overlapping slow zones do NOT stack multiplicatively. If two zones with
/// multipliers 0.5 and 0.3 both contain you, your effective speed is 0.3×
/// (the minimum), not 0.15×.
///
/// SLOW ZONE — supported targets
/// • Players — slows Walk/Run/Ducked speeds on PlayerController
/// • PatrolMovers — slows the Speed field
/// • SimpleAnimalControllers — slows SpeedWalk/SpeedFollow + NavMeshAgent.MaxSpeed
/// </summary>
[Category( "Conveyance" ), Icon( "dangerous" )]
public sealed class TriggerAreaEffect : Component, Component.ITriggerListener
{
// -------------------------------------------------------------------------
// Configuration
// -------------------------------------------------------------------------
public enum AreaEffectType { InstantDeath, DamagePerSec, SlowZone, PushZone }
[Property] public AreaEffectType EffectType { get; set; } = AreaEffectType.InstantDeath;
[Property] public TagSet AffectTags { get; set; } = new TagSet( new[] { "player" } );
[Property, Group( "Damage" )] public string DamageTag { get; set; } = "trigger";
[Property, Group( "Damage" )] public float DamagePerSecond { get; set; } = 25f;
[Property, Group( "Slow Zone" ), Range( 0.05f, 1f )]
public float SpeedMultiplier { get; set; } = 0.35f;
[Property, Group( "Push Zone" )] public Vector3 PushDirection { get; set; } = Vector3.Up;
[Property, Group( "Push Zone" )] public float PushPower { get; set; } = 20f;
// -------------------------------------------------------------------------
// Slow tracking — global, shared across every TriggerAreaEffect instance
// -------------------------------------------------------------------------
private sealed class SlowState
{
public readonly Dictionary<GameObject, float> Tickets = new();
public Player Player;
public PlayerController Controller;
public PatrolMover Patrol;
public SimpleAnimalController Animal;
public float BaseWalkSpeed;
public float BaseRunSpeed;
public float BaseDuckedSpeed;
public float BasePatrolSpeed;
public float BaseAnimalWalk;
public float BaseAnimalFollow;
}
private static readonly Dictionary<GameObject, SlowState> SlowStates = new();
// -------------------------------------------------------------------------
// State for *this* instance (non-slow effects)
// -------------------------------------------------------------------------
private readonly HashSet<Player> _inside = new();
// -------------------------------------------------------------------------
// Trigger callbacks
// -------------------------------------------------------------------------
void ITriggerListener.OnTriggerEnter( Collider other )
{
var go = other.GameObject.Root;
if ( !go.Tags.HasAny( AffectTags ) ) return;
// Damage and push are host-authoritative — only run there.
// Slow zone runs on every machine so each client's local PlayerController
// sees the speed change.
bool needsHost = EffectType is AreaEffectType.InstantDeath
or AreaEffectType.DamagePerSec
or AreaEffectType.PushZone;
if ( needsHost && !Networking.IsHost ) return;
switch ( EffectType )
{
case AreaEffectType.InstantDeath:
{
var player = go.Components.Get<Player>();
if ( player.IsValid() ) ApplyInstantDeath( player );
break;
}
case AreaEffectType.SlowZone:
AddSlowTicket( go );
break;
case AreaEffectType.DamagePerSec:
case AreaEffectType.PushZone:
{
var player = go.Components.Get<Player>();
if ( player.IsValid() ) _inside.Add( player );
break;
}
}
}
void ITriggerListener.OnTriggerExit( Collider other )
{
var go = other.GameObject.Root;
// Match the enter-side authority rules so we don't leave dangling state.
bool needsHost = EffectType is AreaEffectType.InstantDeath
or AreaEffectType.DamagePerSec
or AreaEffectType.PushZone;
if ( needsHost && !Networking.IsHost ) return;
if ( EffectType == AreaEffectType.SlowZone )
{
RemoveSlowTicket( go );
return;
}
var player = go.Components.Get<Player>();
if ( player.IsValid() ) _inside.Remove( player );
}
// -------------------------------------------------------------------------
// Continuous effects (damage / push) — only ever tick on the host
// -------------------------------------------------------------------------
protected override void OnFixedUpdate()
{
if ( _inside.Count == 0 ) return;
if ( !Networking.IsHost ) return;
_inside.RemoveWhere( p => !p.IsValid() );
foreach ( var player in _inside )
{
switch ( EffectType )
{
case AreaEffectType.DamagePerSec: ApplyDamagePerSec( player ); break;
case AreaEffectType.PushZone: ApplyPush( player ); break;
}
}
}
// -------------------------------------------------------------------------
// Damage / push implementations
// -------------------------------------------------------------------------
private void ApplyInstantDeath( Player player )
{
var dmg = new DamageInfo( 9999f, GameObject, GameObject );
dmg.Tags.Add( DamageTag );
player.OnDamage( in dmg );
}
private void ApplyDamagePerSec( Player player )
{
var dmg = new DamageInfo( DamagePerSecond * Time.Delta, GameObject, GameObject );
dmg.Tags.Add( DamageTag );
player.OnDamage( in dmg );
}
private void ApplyPush( Player player )
{
var pc = player.Controller;
if ( pc.IsValid() ) pc.PreventGrounding( 0.1f );
var rb = player.Components.Get<Rigidbody>( FindMode.EverythingInSelfAndParent );
if ( rb.IsValid() ) rb.Velocity += PushDirection * PushPower;
}
// -------------------------------------------------------------------------
// Slow ticket — add / remove / recompose
// -------------------------------------------------------------------------
private void AddSlowTicket( GameObject target )
{
if ( !SlowStates.TryGetValue( target, out var state ) )
{
state = CreateSlowState( target );
if ( state == null ) return;
SlowStates[target] = state;
}
state.Tickets[GameObject] = SpeedMultiplier;
Recompose( target, state );
}
private void RemoveSlowTicket( GameObject target )
{
if ( !SlowStates.TryGetValue( target, out var state ) ) return;
state.Tickets.Remove( GameObject );
if ( state.Tickets.Count == 0 )
{
RestoreBaselines( state );
SlowStates.Remove( target );
}
else
{
Recompose( target, state );
}
}
private static SlowState CreateSlowState( GameObject target )
{
var state = new SlowState
{
Player = target.Components.Get<Player>(),
Patrol = target.Components.Get<PatrolMover>(),
Animal = target.Components.Get<SimpleAnimalController>(),
};
bool any = false;
if ( state.Player.IsValid() && state.Player.Controller.IsValid() )
{
state.Controller = state.Player.Controller;
state.BaseWalkSpeed = state.Controller.WalkSpeed;
state.BaseRunSpeed = state.Controller.RunSpeed;
state.BaseDuckedSpeed = state.Controller.DuckedSpeed;
any = true;
}
if ( state.Patrol.IsValid() )
{
state.BasePatrolSpeed = state.Patrol.Speed;
any = true;
}
if ( state.Animal.IsValid() )
{
state.BaseAnimalWalk = state.Animal.SpeedWalk;
state.BaseAnimalFollow = state.Animal.SpeedFollow;
any = true;
}
return any ? state : null;
}
private static void Recompose( GameObject target, SlowState state )
{
float min = state.Tickets.Values.Min();
if ( state.Controller.IsValid() )
{
state.Controller.WalkSpeed = state.BaseWalkSpeed * min;
state.Controller.RunSpeed = state.BaseRunSpeed * min;
state.Controller.DuckedSpeed = state.BaseDuckedSpeed * min;
}
if ( state.Patrol.IsValid() )
state.Patrol.Speed = state.BasePatrolSpeed * min;
if ( state.Animal.IsValid() )
{
state.Animal.SpeedWalk = state.BaseAnimalWalk * min;
state.Animal.SpeedFollow = state.BaseAnimalFollow * min;
}
}
private static void RestoreBaselines( SlowState state )
{
if ( state.Controller.IsValid() )
{
state.Controller.WalkSpeed = state.BaseWalkSpeed;
state.Controller.RunSpeed = state.BaseRunSpeed;
state.Controller.DuckedSpeed = state.BaseDuckedSpeed;
}
if ( state.Patrol.IsValid() )
state.Patrol.Speed = state.BasePatrolSpeed;
if ( state.Animal.IsValid() )
{
state.Animal.SpeedWalk = state.BaseAnimalWalk;
state.Animal.SpeedFollow = state.BaseAnimalFollow;
}
}
// -------------------------------------------------------------------------
// Cleanup — when a slow zone is disabled / destroyed, drop its tickets
// -------------------------------------------------------------------------
protected override void OnDisabled()
{
foreach ( var kv in SlowStates.ToList() )
{
if ( kv.Value.Tickets.Remove( GameObject ) )
{
if ( kv.Value.Tickets.Count == 0 )
{
RestoreBaselines( kv.Value );
SlowStates.Remove( kv.Key );
}
else
{
Recompose( kv.Key, kv.Value );
}
}
}
_inside.Clear();
}
// -------------------------------------------------------------------------
// Editor gizmo
// -------------------------------------------------------------------------
protected override void DrawGizmos()
{
Gizmo.Draw.Color = EffectType switch
{
AreaEffectType.InstantDeath => Color.Red.WithAlpha( 0.25f ),
AreaEffectType.DamagePerSec => Color.Orange.WithAlpha( 0.25f ),
AreaEffectType.SlowZone => Color.Cyan.WithAlpha( 0.25f ),
AreaEffectType.PushZone => Color.Green.WithAlpha( 0.25f ),
_ => Color.White.WithAlpha( 0.1f ),
};
if ( EffectType == AreaEffectType.PushZone )
Gizmo.Draw.Arrow( Vector3.Zero, PushDirection * 60f );
}
}