ZombieBrain.cs
using Sandbox;
using Sandbox.Navigation;
using System;
using System.Threading.Tasks;
using static Ballistics;
using static HealthSystem;
public enum ZombieState { Wander, Approach, Leap, Slam, Staggered, Culled }
public sealed class ZombieBrain : Component, HealthSystem.IHealthEvent
{
[Property] NavMeshAgent Agent;
GameObject Player;
[Property] Rigidbody Body;
[Property] ModelRenderer Model;
[Property] ModelRenderer LeftArmModel;
[Property] ModelRenderer RightArmModel;
[Property] TextRenderer StateDebugText;
[Property] bool DebugMode;
[Property] GameObject DeadZombie;
[Property] GameObject BloodDecal;
[Property] ParticleRingEmitter AttackParticle;
[Property] ParticleEffect AttackEffect;
[Property] float MoveCooldown = 1f;
float WanderCooldown = 5f;
[Property] float LeapCooldown = 5f;
[Property] float SlamCooldown = 3f;
[Property] float SlamRadius = 350f;
[Property] int ApproachRange = 7000;
[Property] int CulledRange = 15000;
[Property] int LeapRange = 2000;
[Property] int SlamRange = 1000;
int RangeOffset;
// --- Leap-Parameter zum Tunen ---
[Property] float LeapFlightTime = 1.5f; // Konstante Flugzeit in Sekunden
[Property] float LeapMaxHeightOffset = 800f; // Wie viel höher der Bogen gehen soll
float DistanceToPlayer;
Vector3 TargetPos;
public ZombieState CurrentState;
TimeUntil NextMove;
TimeUntil NextWander;
TimeUntil NextLeap;
TimeUntil NextSlam;
TimeUntil NextChargeSlam;
public TimeUntil KnockBack;
bool IsChargingSlam;
TimeSince SinceLeapAnimationStart;
TimeSince SinceSlamAnimationStart;
TimeSince SinceHitAnimationStart;
Vector3 originalScale;
int HitFlailCounter = 0;
Random random = new Random();
SceneTraceResult groundCheck;
NavMesh NavMesh;
protected override void OnStart()
{
CurrentState = ZombieState.Wander;
Agent.MoveTo( Body.WorldPosition );
Player = Scene.FindAllWithTag("carbody").First<GameObject>();
originalScale = WorldScale;
RangeOffset = random.Int( -500, 500 );
LeapRange += RangeOffset;
if ( !DebugMode ) { StateDebugText.Enabled = false; }
// Log.Info( $"[START] Player found: {Player.Name}" );
}
void IHealthEvent.OnDeath()
{
groundCheck = Scene.Trace.Ray( WorldPosition, WorldPosition + Vector3.Down * 3500 )
.IgnoreGameObjectHierarchy( GameObject )
.WithoutTags( "enemy", "dead", "player", "playerclip" )
.Run();
GameObject newDecal = BloodDecal.Clone( groundCheck.EndPosition, Rotation.LookAt( groundCheck.Normal, Vector3.Up ).Angles().WithYaw(random.Int(-180, 180)) );
GameObject _deadClone = DeadZombie.Clone( WorldPosition, WorldRotation, originalScale );
foreach (GameObject child in _deadClone.Children )
{
child.GetComponent<Rigidbody>().Velocity = Body.Velocity;
child.GetComponent<Rigidbody>().ApplyImpulse( (child.WorldPosition - GameObject.WorldPosition).Normal * 1000 );
child.GetComponent<Rigidbody>().AngularVelocity = random.VectorInSphere( random.Float( 3, 5) );
// child.Enabled = random.NextDouble() >= 0.5;
}
Sound.Play( "sounds/zombie-dies.sound", _deadClone.WorldPosition );
}
protected override void OnFixedUpdate()
{
// DebugOverlay.Sphere( new Sphere( Body.WorldPosition, SlamRadius ), Color.Orange );
DistanceToPlayer = (Player.WorldPosition - WorldPosition).Length;
switch ( CurrentState )
{
default:
Agent.Stop();
StateDebugText.Text = "Default";
break;
case ZombieState.Culled: // STATE IS CULLED
StateDebugText.Text = "Culled";
Agent.Stop();
Agent.Enabled = false;
// DebugOverlay.Sphere(new Sphere(WorldPosition, 15000));
if ( DistanceToPlayer < CulledRange )
{
Agent.Enabled = true;
Agent.MoveTo( Body.WorldPosition );
CurrentState = ZombieState.Wander;
}
break;
case ZombieState.Wander: // STATE IS WANDER
StateDebugText.Text = "Wander";
Agent.MaxSpeed = 500;
Agent.Acceleration = 500;
if ( NextWander && Agent.TargetPosition.HasValue && ((Vector3)Agent.TargetPosition - Body.WorldPosition).IsNearlyZero(300) )
{
DoWander();
NextWander = WanderCooldown + random.Float(0f, 0.1f);
}
if ( DistanceToPlayer > CulledRange ) { CurrentState = ZombieState.Culled; }
if ( DistanceToPlayer < ApproachRange ) { CurrentState = ZombieState.Approach; }
break;
case ZombieState.Approach: // STATE IS APPROACH
StateDebugText.Text = "Approach";
if ( !(Agent.AgentPosition - Body.WorldPosition).IsNearlyZero(50f) ) { Agent.SetAgentPosition( Body.WorldPosition ); }
Agent.UpdatePosition = true; Agent.UpdateRotation = false;
Agent.MaxSpeed = 800;
Agent.Acceleration = 500;
// Walk to Player and rotate
if ( NextMove )
{
Agent.MoveTo( Player.WorldPosition + (Body.WorldPosition - Player.WorldPosition).Normal * 200 );
NextMove = MoveCooldown + random.Float(0f, 0.1f);
}
LookAtPlayer();
AnimateArms();
// Go to wander if player is far
if ( DistanceToPlayer > ApproachRange )
{
Agent.UpdateRotation = true;
Agent.MaxSpeed = 240;
CurrentState = ZombieState.Wander;
}
// Go to slam if near and NextSlam
if ( DistanceToPlayer < (SlamRadius * 2) && NextSlam ) { NextChargeSlam = 1; CurrentState = ZombieState.Slam; }
// Go to Leap if NextMove, Distance und sightlineCheck
if ( DistanceToPlayer < LeapRange && DistanceToPlayer > SlamRadius * 3 && NextLeap && NextSlam )
{
SceneTraceResult checkSightline = Scene.Trace.Sphere( 64, Body.WorldPosition + Vector3.Up * 300, Player.WorldPosition + Vector3.Up * 300 )
.IgnoreGameObjectHierarchy( GameObject )
.WithoutTags( "enemy", "player", "world" )
.Run();
// DebugOverlay.Trace( checkSightline );
if ( !checkSightline.Hit ) CurrentState = ZombieState.Leap;
}
break;
case ZombieState.Slam: // STATE IS SLAM
StateDebugText.Text = "Slam";
Agent.Stop();
LookAtPlayer();
if ( !IsChargingSlam ) AnimateSlam();
// Increase Charge to 100 then DoSlam()
if ( NextChargeSlam ) { IsChargingSlam = false; DoSlam(); }
if ( DistanceToPlayer < 7000 && NextChargeSlam ) { CurrentState = ZombieState.Approach; }
break;
case ZombieState.Leap: // STATE IS LEAP
StateDebugText.Text = "Leap";
Agent.Stop();
Agent.UpdatePosition = false; Agent.UpdateRotation = false;
groundCheck = Scene.Trace.Ray( WorldPosition + Vector3.Up * 10, WorldPosition + Vector3.Down * 5 )
.Radius( 100 )
.IgnoreGameObjectHierarchy( GameObject )
.WithoutTags( "enemy" )
.Run();
// DebugOverlay.Trace( groundCheck );
if (groundCheck.Hit)
{
LookAtPlayer();
}
else
{
Body.SmoothRotate( Rotation.LookAt( Body.WorldPosition + Body.Velocity.WithZ(0) * 1000, Vector3.Up ), 0.5f, 0.01f );
AnimateArms(0, -165);
}
// Einmal am anfang leapen
if ( NextLeap )
{
DoLeap();
AnimateLeap();
}
// wenn er wieder aufkommt slammen und zu approach
if ( NextSlam && groundCheck.Hit && !NextLeap && Body.Velocity.z < 10 )
{
DoSlam();
Body.GravityScale = 1f;
CurrentState = ZombieState.Approach;
}
break;
case ZombieState.Staggered: // STATE IS STAGGERED
Agent.Stop();
Agent.UpdatePosition = false;
Agent.SetAgentPosition( Agent.WorldPosition );
Body.GravityScale = 1f;
groundCheck = Scene.Trace.Ray( WorldPosition + Vector3.Up * 10, WorldPosition + Vector3.Down * 35 )
.Radius( 64 )
.IgnoreGameObjectHierarchy( GameObject )
.WithoutTags( "enemy" )
.Run();
// DebugOverlay.Trace( groundCheck );
if (KnockBack && groundCheck.Hit)
{
Agent.UpdatePosition = true;
CurrentState = ZombieState.Wander;
}
break;
}
}
private void LookAtPlayer()
{
if ( Body.WorldRotation.Forward.Angle( Player.WorldPosition - GameObject.WorldPosition ) > 1 )
{
Body.SmoothRotate( Rotation.LookAt( Player.WorldPosition - GameObject.WorldPosition, Vector3.Up ).Angles().WithPitch(0), 0.5f, 0.01f );
}
}
void DoWander()
{
Vector3 possibleTargetPos = Body.WorldPosition + (Vector3)random.VectorInCircle(1000);
SceneTraceResult wanderTrace = Scene.Trace.Sphere( 100, possibleTargetPos, possibleTargetPos )
.Radius( 100 )
.IgnoreGameObjectHierarchy( GameObject )
.WithoutTags( "enemy", "player", "world" )
.Run();
// Animate Arms
// AnimateArms();
if ( !wanderTrace.Hit )
{
Agent.MoveTo( possibleTargetPos );
}
else
{
int tries = 0;
while ( wanderTrace.Hit && tries < 10 )
{
possibleTargetPos = Body.WorldPosition + (Vector3)random.VectorInCircle( 1000 );
wanderTrace = Scene.Trace.Sphere( 300, possibleTargetPos, possibleTargetPos )
.Radius( 300 )
.IgnoreGameObjectHierarchy( GameObject )
.WithoutTags( "enemy", "player", "world" )
.Run();
tries++;
// DebugOverlay.Trace( wanderTrace );
}
if ( !wanderTrace.Hit )
{
Agent.MoveTo( possibleTargetPos );
}
}
}
void DoSlam()
{
AnimateHit();
Log.Info( $"{GameObject.Name} is slamming!" );
// Attack Trace
// AnimateSlam();
var attackTrace = Scene.Trace.Sphere( SlamRadius, Body.WorldPosition, Body.WorldPosition )
.IgnoreGameObjectHierarchy( GameObject )
.WithAllTags( "player", "carbody" )
.Run();
// DebugOverlay.Trace(attackTrace);
// Partikel, Sound
AttackParticle.WorldTransform = new Transform(Body.WorldPosition, Rotation.FromPitch(0));
AttackParticle.ResetEmitter();
Sound.Play( "sounds/falling-game-character.sound", Body.WorldPosition );
// Wenn er den player trifft
if ( attackTrace.Hit )
{
if ( !attackTrace.GameObject.IsValid ) return;
Log.Info( $"Slam hit: {attackTrace.GameObject.Name}" );
attackTrace.GameObject.GetComponentInParent<HealthSystem>().Damage( 500 );
if ( !attackTrace.GameObject.GetComponent<Rigidbody>().IsValid ) return;
if (Player.GetComponent<Rigidbody>().Velocity.z < 100 )
{
attackTrace.GameObject.GetComponent<Rigidbody>().ApplyImpulse( Vector3.Up * 50000 + (Player.WorldPosition.WithZ(0) - Body.WorldPosition.WithZ(0)).Normal * 300000 );
attackTrace.GameObject.GetComponent<Rigidbody>().AngularVelocity
= attackTrace.GameObject.WorldRotation.Forward * attackTrace.GameObject.GetComponent<Rigidbody>().Mass * 5 * attackTrace.GameObject.WorldRotation.Right.Dot( ( Player.WorldPosition - Body.WorldPosition ).Normal);
}
}
NextSlam = SlamCooldown;
}
void DoLeap()
{
Sound.Play( "sounds/whaa.sound", Body.WorldPosition );
var playerRb = Player.GetComponent<Rigidbody>();
// find ground below player to get accurate target position and flight time
SceneTraceResult findGround = Scene.Trace.Ray( Player.WorldPosition, Player.WorldPosition + Vector3.Down * 5000 )
.Radius( 32 )
.IgnoreGameObjectHierarchy( GameObject )
.WithoutTags( "enemy", "player" )
.Run();
// DebugOverlay.Sphere(new Sphere(findGround.HitPosition, 32), Color.Black, 3f );
Vector2 randomOffset = random.VectorInCircle() * random.Int(200, 300);
// === Konvertiere s&box → Unity (X,Y,Z) → (X,Z,Y) ===
Vector3 zombieUnity = new Vector3( WorldPosition.x, WorldPosition.z, WorldPosition.y );
Vector3 playerUnity = new Vector3( playerRb.WorldPosition.x + randomOffset.x, findGround.HitPosition.z, playerRb.WorldPosition.y + randomOffset.y );
Vector3 playerVelUnity = new Vector3( playerRb.Velocity.x, 0, playerRb.Velocity.y );
// DebugOverlay.Sphere( new Sphere( new Vector3( playerRb.WorldPosition.x, playerRb.WorldPosition.y, findGround.HitPosition.z ), 32 ), Color.White, 3f );
// === Berechne requiredLateralSpeed für feste Flugzeit ===
Vector3 directionXZ = new Vector3( playerUnity.x - zombieUnity.x, 0f, playerUnity.z - zombieUnity.z );
float horizontalDistance = directionXZ.Length;
float requiredLateralSpeed = horizontalDistance / LeapFlightTime;
// === Nutze solve_ballistic_arc_lateral ===
if ( Ballistics.solve_ballistic_arc_lateral(
zombieUnity,
requiredLateralSpeed,
playerUnity, // random offset, damit der Leap nicht immer exakt auf den Spieler zielt
playerVelUnity,
LeapMaxHeightOffset,
out Vector3 fireVelUnity,
out float gravityNeeded,
out Vector3 impactPointUnity ) )
{
// === Konvertiere Unity → s&box (X,Z,Y) → (X,Y,Z) ===
Vector3 fireVelocity = new Vector3( fireVelUnity.x, fireVelUnity.z, fireVelUnity.y );
Vector3 impactPoint = new Vector3( impactPointUnity.x, impactPointUnity.z, impactPointUnity.y );
float worldGravity = Scene.PhysicsWorld.Gravity.Length;
float gravityScale = Math.Max( 0.5f, gravityNeeded / worldGravity );
Log.Info( $"[LEAP] SUCCESS gravityScale: {gravityScale:F2}" );
// === Wende Leap an ===
TargetPos = impactPoint;
Body.GravityScale = gravityScale;
Body.Velocity = fireVelocity;
NextLeap = LeapCooldown;
}
else if ( Ballistics.solve_ballistic_arc_lateral(
zombieUnity,
requiredLateralSpeed,
playerUnity,
LeapMaxHeightOffset,
out fireVelUnity,
out gravityNeeded ) )
{
// === Konvertiere Unity → s&box (X,Z,Y) → (X,Y,Z) ===
Vector3 fireVelocity = new Vector3( fireVelUnity.x, fireVelUnity.z, fireVelUnity.y );
Vector3 impactPoint = new Vector3( playerUnity.x, playerUnity.z, playerUnity.y );
float worldGravity = Scene.PhysicsWorld.Gravity.Length;
float gravityScale = Math.Max( 0.5f, gravityNeeded / worldGravity );
Log.Info( $"[LEAP] NO PREDICTION gravityScale: {gravityScale:F2}" );
// === Wende Leap an ===
TargetPos = impactPoint;
Body.GravityScale = gravityScale;
Body.Velocity = fireVelocity;
NextLeap = LeapCooldown;
}
}
async Task AnimateLeap()
{
float duration = LeapFlightTime;
SinceLeapAnimationStart = 0;
while ( SinceLeapAnimationStart < duration * 0.5f )
{
if ( CurrentState != ZombieState.Leap ) { break; }
Model.WorldScale = Model.WorldScale.WithZ( Model.WorldScale.z.LerpTo( originalScale.z + 0.2f, 0.01f ) );
await Task.FrameEnd();
}
while ( !groundCheck.Hit )
{
if ( CurrentState != ZombieState.Leap ) { break; }
Model.WorldScale = Model.WorldScale.WithZ( Model.WorldScale.z.LerpTo( originalScale.z - 0.5f, 0.01f ) );
await Task.FrameEnd();
}
if ( !this.IsValid() ) return;
Model.WorldScale = originalScale;
}
async Task AnimateSlam()
{
float duration = 1f;
SinceSlamAnimationStart = 0;
IsChargingSlam = true;
while ( SinceSlamAnimationStart < duration * 0.9f )
{
if ( CurrentState != ZombieState.Slam ) { IsChargingSlam = false; break; }
Model.WorldScale = Model.WorldScale.WithZ( Model.WorldScale.z.LerpTo( originalScale.z + 0.2f, 0.01f ) );
await Task.FrameEnd();
}
SinceSlamAnimationStart = 0;
while ( SinceSlamAnimationStart < duration * 0.1f )
{
if ( CurrentState != ZombieState.Slam ) { IsChargingSlam = false; break; }
Model.WorldScale = Model.WorldScale.WithZ( Model.WorldScale.z.LerpTo( originalScale.z - 0.5f, 0.1f ) );
await Task.FrameEnd();
}
if ( !this.IsValid() ) return;
Model.WorldScale = originalScale;
}
// Nur für ModelRenderer muss noch gemacht werden
public async Task AnimateHit()
{
float duration = 0.1f;
SinceHitAnimationStart = 0;
Model.WorldScale = Model.WorldScale.WithZ( originalScale.z * (1f - 0.15f) );
while (SinceHitAnimationStart < duration)
{
Model.WorldScale = Model.WorldScale.WithZ(Model.WorldScale.z.LerpTo(originalScale.z, 0.1f));
if ( HitFlailCounter == 0 )
{
LeftArmModel.WorldRotation = Model.WorldRotation.Angles().WithRoll( Game.Random.Int( 60, 135 ) ).WithPitch( Game.Random.Int( -45, 45 ) );
RightArmModel.WorldRotation = Model.WorldRotation.Angles().WithRoll( Game.Random.Int( -135, -65 ) ).WithPitch( Game.Random.Int( -45, 45 ) );
}
await Task.FrameEnd();
}
if ( !this.IsValid() ) return;
Model.WorldScale = originalScale;
HitFlailCounter++;
if (HitFlailCounter == 2) HitFlailCounter = 0;
}
void AnimateArms(int roll = 0, int pitch = -90)
{
LeftArmModel.WorldRotation = Model.WorldRotation.Angles().WithRoll( roll ).WithPitch( pitch );
RightArmModel.WorldRotation = Model.WorldRotation.Angles().WithRoll( roll ).WithPitch( pitch );
}
}