Polewik.cs
namespace CryptidHunt;
public enum PolewikState
{
Idle,
Patrolling,
Stalking,
Following,
Attacking,
Fleeing,
Pain,
Yell,
AttackPersistent,
Jumpscare
}
public partial class Polewik : Component
{
[Property]
public SkinnedModelRenderer ModelRenderer { get; set; }
[Property]
public GameObject Camera { get; set; }
[Property]
public GameObject SpitPrefab { get; set; }
[Property]
public GameObject BloodParticle { get; set; }
public TimeSince LastDamage { get; set; } = 10f;
public bool Alive { get; set; } = true;
public float JumpscareDistance => 120f;
public float DetectDistance => 1400f;
public float StalkingDistance => 700f;
public float AttackDistance => 500f;
public float GiveUpDistance => 3600f;
public float GiveUpAfter => 25f;
public float AttackAfterStalking => 15f;
public float AttackAfterStalling => 90f;
public float WaitUntilNextAttack => 30f;
[Property]
public SoundEvent HeartbeatSound { get; set; }
public SoundHandle Heartbeart { get; set; }
public Vector3 StuckPosition;
public TimeSince LastStuck;
private bool _firstYell = true;
public Vector3? FirstInterceptPoint()
{
var posA = Player.Instance.WorldPosition;
var velA = Player.Instance.Controller.Velocity.WithZ( 0f );
var posB = WorldPosition;
var speedB = CurrentSpeed;
var toTarget = posA - posB;
var a = Vector3.Dot( velA, velA ) - speedB * speedB;
var b = 2f * Vector3.Dot( velA, toTarget );
var c = Vector3.Dot( toTarget, toTarget );
// Handle near-zero 'a' (velA magnitude ≈ speedB) as linear:
if ( MathF.Abs( a ) < 1e-6f )
{
// bt + c = 0 → t = -c/b
if ( MathF.Abs( b ) < 1e-6f ) return null;
var tLin = -c / b;
if ( tLin <= 0f ) return null;
return posA + velA * tLin;
}
var disc = b * b - 4f * a * c;
if ( disc < 0f ) return null;
var sqrtDisc = MathF.Sqrt( disc );
var t1 = (-b + sqrtDisc) / (2f * a);
var t2 = (-b - sqrtDisc) / (2f * a);
float t;
if ( t1 > 0f && t2 > 0f )
t = MathF.Min( t1, t2 );
else if ( t1 > 0f )
t = t1;
else if ( t2 > 0f )
t = t2;
else
return null;
return posA + velA * t;
}
PolewikState currentState { get; set; } = PolewikState.Patrolling;
public PolewikState CurrentState
{
get => currentState;
set
{
currentState = value;
if ( value == PolewikState.Patrolling )
{
NavigateTo( NearestNode.WorldPosition );
CurrentPathId = PatrolPath.IndexOf( NearestNode );
Heartbeart?.Stop();
}
if ( value == PolewikState.Pain )
{
Heartbeart?.Stop();
Agent.Stop();
Agent.Velocity = 0f;
_lastAttack = 0f;
Sound.Play( "pain", WorldPosition );
Task.RunInThreadAsync( async () =>
{
await Task.MainThread();
ModelRenderer.Set( "growl", true );
await Task.DelaySeconds( 0.1f );
ModelRenderer.Set( "growl", false );
await Task.DelaySeconds( 0.9f );
CurrentState = PolewikState.Fleeing;
} );
}
if ( value == PolewikState.Yell )
{
Heartbeart?.Stop();
GameTask.RunInThreadAsync( async () =>
{
await Task.MainThread();
await Task.DelaySeconds( 0.5f );
Player.Instance.AddCameraShake( 4f, 5f );
await Task.DelaySeconds( 1f );
ModelRenderer.Set( "howl", true );
var howl = Sound.Play( "howl_far", WorldPosition );
howl.Volume *= _firstYell ? 1f : MathX.Remap( Player.Instance.WorldPosition.Distance( WorldPosition ), 500f, 3000f, 0.8f, 0.2f );
_firstYell = false;
await Task.DelaySeconds( 4.5f );
CurrentState = PolewikState.AttackPersistent;
} );
}
if ( value == PolewikState.Fleeing )
{
Heartbeart?.Stop();
_lastAttack = 0f;
TargetPosition = FurthestNode.WorldPosition;
NavigateTo( FurthestNode.WorldPosition );
CurrentPathId = PatrolPath.IndexOf( FurthestNode );
GameTask.RunInThreadAsync( async () =>
{
await Task.MainThread();
await GameTask.DelaySeconds( Game.Random.Float( 3f, 6f ) );
CurrentState = PolewikState.Patrolling;
} );
}
if ( value == PolewikState.Stalking )
{
_startedStalking = 0f;
Heartbeart ??= Sound.Play( HeartbeatSound );
}
if ( value == PolewikState.Following || value == PolewikState.AttackPersistent )
{
Heartbeart ??= Sound.Play( HeartbeatSound );
_startedFollowing = 0f;
}
if ( value == PolewikState.Attacking )
{
_lastAttack = 0f;
Heartbeart ??= Sound.Play( HeartbeatSound );
ModelRenderer.Set( "leap", true );
Sound.Play( "jump", WorldPosition );
GameTask.RunInThreadAsync( async () =>
{
await Task.MainThread();
await GameTask.DelaySeconds( 0.05f );
ModelRenderer.Set( "leap", false );
await GameTask.DelaySeconds( 0.9f );
if ( CurrentState == PolewikState.Attacking )
CurrentState = PolewikState.Following;
} );
}
if ( value == PolewikState.Jumpscare )
{
Heartbeart?.Stop();
_lastAttack = 0f;
Agent.Stop();
Agent.Velocity = 0f;
WorldRotation = Rotation.LookAt( Vector3.Direction( WorldPosition, Player.Instance.WorldPosition ) );
Player.Instance.LockInputs = true;
ModelRenderer.Set( "attack", true );
var mouth = ModelRenderer.GetAttachmentObject( "mouth" );
var spit = SpitPrefab.Clone( mouth.WorldPosition, mouth.WorldRotation );
spit.SetParent( mouth );
Sound.Play( "jumpscare", WorldPosition );
Player.Instance.AddCameraShake( 1.6f, 15f );
GameTask.RunInThreadAsync( async () =>
{
await Task.MainThread();
await GameTask.DelaySeconds( 1.2f );
Player.Instance.ChangeHolding(null, true);
Player.Instance.HP -= 1;
await GameTask.DelaySeconds( 1f );
if ( CurrentState == PolewikState.Jumpscare )
CurrentState = PolewikState.Fleeing;
Player.Instance.LockInputs = false;
// TODO: Make player camera follow the camera attachment
} );
}
}
}
[Property]
public List<GameObject> PatrolPath { get; set; } = new List<GameObject>();
public int CurrentPathId { get; set; } = 0;
public Vector3 TargetPosition { get; set; }
[Property]
public NavMeshAgent Agent { get; set; }
public Dictionary<PolewikState, float> Speeds = new()
{
{ PolewikState.Idle, 0f },
{ PolewikState.Patrolling, 350f },
{ PolewikState.Stalking, 200f },
{ PolewikState.Following, 420f },
{ PolewikState.Attacking, 1800f },
{ PolewikState.Fleeing, 700f },
{ PolewikState.Pain, 0f },
{ PolewikState.Yell, 0f },
{ PolewikState.AttackPersistent, 410f },
{ PolewikState.Jumpscare, 0f }
};
public float CurrentSpeed => Speeds[CurrentState];
private float _hp { get; set; } = 100f;
public float HP
{
get => _hp;
set
{
if ( !Alive ) return;
var damage = _hp - value;
_hp = value;
LastDamage = 0f;
if ( HP <= 0 )
{
RagdollModel();
Sound.Play( "pain", WorldPosition );
Alive = false;
}
else
{
if ( damage >= 10f && CurrentState != PolewikState.Pain && CurrentState != PolewikState.Fleeing )
CurrentState = PolewikState.Pain;
if ( damage > 0f )
BloodParticle.Clone( WorldPosition + Vector3.Up * 30f, WorldRotation );
}
}
}
private BBox _towerZone = BBox.FromPoints( new List<Vector3>() { new Vector3( 2868f, 5616f, 612f ), new Vector3( 2436f, 5932f, 132f ) } ); // too lazy...
TimeSince _startedStalking;
TimeSince _startedFollowing;
TimeSince _lastAttack;
public GameObject ClosestNodeTo( Vector3 pos ) => PatrolPath.OrderBy( x => x.WorldPosition.Distance( pos ) ).FirstOrDefault();
public GameObject NearestNode => ClosestNodeTo( WorldPosition );
public GameObject FurthestNode => PatrolPath.OrderBy( x => x.WorldPosition.Distance( WorldPosition ) ).LastOrDefault();
public bool WithinAttackRange => !_towerZone.Contains( Player.Instance.WorldPosition ) && Player.Instance.WorldPosition.Distance( WorldPosition ) <= AttackDistance && Math.Abs( Player.Instance.WorldPosition.z - WorldPosition.z ) <= 200f;
public bool OutsideDistance => _towerZone.Contains( Player.Instance.WorldPosition ) || Player.Instance.WorldPosition.Distance( WorldPosition ) >= GiveUpDistance || Math.Abs( Player.Instance.WorldPosition.z - WorldPosition.z ) > 200f;
public bool OutsidePersistentDistance => Player.Instance.WorldPosition.Distance( WorldPosition ) >= GiveUpDistance * 4f || Math.Abs( Player.Instance.WorldPosition.z - WorldPosition.z ) > 200f;
protected override void OnStart()
{
if ( ModelRenderer.IsValid() )
ModelRenderer.OnFootstepEvent += OnFootstepEvent;
_startedStalking = 0f;
_startedFollowing = 0f;
_lastAttack = 0f;
}
protected override void OnFixedUpdate()
{
if ( HP <= 0f ) return;
ComputeAnimation();
Agent.MaxSpeed = CurrentSpeed;
/*
DebugOverlay.Sphere( Position, JumpscareDistance, Color.Red, 0f, false );
DebugOverlay.Sphere( Position, DetectDistance, Color.Green );
DebugOverlay.Sphere( Position, StalkingDistance, Color.Yellow );
DebugOverlay.Sphere( Position, AttackDistance, Color.Orange );
DebugOverlay.Sphere( Position, GiveUpDistance, Color.Blue );*/
if ( CurrentState != PolewikState.Idle && CurrentState != PolewikState.Pain && CurrentState != PolewikState.Jumpscare )
{
ComputePath();
if ( StuckPosition.Distance( WorldPosition ) > 100f )
{
StuckPosition = WorldPosition;
LastStuck = 0f;
}
if ( LastStuck >= 5f )
{
LastStuck = 0f;
WorldPosition = TargetPosition;
}
}
if ( CurrentState == PolewikState.Patrolling )
{
NavigateTo( TargetPosition );
if ( _lastAttack >= WaitUntilNextAttack && Player.Instance.WorldPosition.Distance( WorldPosition ) <= DetectDistance )
{
_lastAttack = 0f;
CurrentState = PolewikState.Stalking;
}
if ( _lastAttack >= AttackAfterStalling )
{
_lastAttack = 0f;
CurrentState = PolewikState.Yell;
}
Agent.UpdateRotation = true;
}
if ( CurrentState == PolewikState.Stalking )
{
NavigateTo( ClosestNodeTo( Player.Instance.WorldPosition ).WorldPosition );
Agent.UpdateRotation = false;
WorldRotation = Rotation.LookAt( Vector3.Direction( WorldPosition, Player.Instance.WorldPosition ) );
if ( Player.Instance.WorldPosition.Distance( WorldPosition ) <= StalkingDistance || _startedStalking >= AttackAfterStalking )
{
Sound.Play( "scream_scare", WorldPosition );
CurrentState = PolewikState.Following;
}
if ( OutsideDistance )
CurrentState = PolewikState.Fleeing;
var lookingTrace = Scene.Trace.Sphere( 200f, Player.Instance.Camera.WorldPosition, Player.Instance.Camera.WorldPosition + Player.Instance.Camera.WorldRotation.Forward * 2000f )
.WithTag( "Polewik" )
.IgnoreStatic()
.IgnoreGameObjectHierarchy( Player.Instance.GameObject )
.Run();
if ( lookingTrace.Hit && lookingTrace.GameObject == GameObject )
CurrentState = PolewikState.Following;
}
if ( CurrentState == PolewikState.Following )
{
var intercept = FirstInterceptPoint();
if ( intercept != null )
NavigateTo( intercept.Value );
if ( _startedFollowing >= GiveUpAfter )
CurrentState = PolewikState.Patrolling;
if ( WithinAttackRange )
CurrentState = PolewikState.Attacking;
if ( OutsideDistance )
CurrentState = PolewikState.Fleeing;
}
if ( CurrentState == PolewikState.AttackPersistent )
{
var intercept = FirstInterceptPoint();
if ( intercept != null )
NavigateTo( intercept.Value );
if ( _startedFollowing >= GiveUpAfter * 2f )
CurrentState = PolewikState.Patrolling;
if ( WithinAttackRange )
CurrentState = PolewikState.Attacking;
if ( OutsidePersistentDistance )
CurrentState = PolewikState.Fleeing;
}
if ( CurrentState == PolewikState.Attacking )
{
var intercept = FirstInterceptPoint();
if ( intercept != null )
NavigateTo( intercept.Value );
if ( Player.Instance.WorldPosition.Distance( WorldPosition ) <= 100f )
CurrentState = PolewikState.Jumpscare;
if ( OutsideDistance )
CurrentState = PolewikState.Fleeing;
}
if ( CurrentState == PolewikState.Fleeing && PatrolPath != null )
{
NavigateTo( TargetPosition );
}
}
protected override void OnUpdate()
{
if ( CurrentState == PolewikState.Jumpscare )
{
Player.Instance.CameraPosition = WorldTransform.PointToWorld( new Vector3( 90f, 0f, 70f ) );
Player.Instance.CameraRotation = Rotation.LookAt( Vector3.Direction( Player.Instance.Camera.WorldPosition, Camera.WorldPosition ), Vector3.Up );
}
}
public void OnFootstepEvent( SceneModel.FootstepEvent footstepEvent )
{
var footTrace = Scene.Trace.Ray( WorldPosition, WorldPosition + Vector3.Down * 10f )
.Radius( 2f )
.IgnoreDynamic()
.IgnoreGameObjectHierarchy( GameObject )
.Run();
if ( !footTrace.Hit ) return;
var tag = footTrace.Tags
.Where( x => x != "solid" && x != "world" )
.FirstOrDefault();
var sound = tag switch
{
"metal" => "footstep-metal",
"grass" => "footstep-grass",
"dirt" => "footstep-dirt",
_ => "footstep-concrete"
};
Sound.Play( sound, footTrace.EndPosition ).Volume *= Agent.Velocity.WithZ( 0f ).Length / 7f;
}
public virtual void ComputeAnimation()
{
ModelRenderer.Set( "speed", Agent.Velocity.Length / 3 );
if ( CurrentState == PolewikState.Following ||
CurrentState == PolewikState.Stalking ||
CurrentState == PolewikState.Attacking ||
CurrentState == PolewikState.AttackPersistent ||
CurrentState == PolewikState.Jumpscare )
{
var local = WorldTransform.PointToLocal( Player.Instance.WorldPosition );
ModelRenderer.Set( "lookat", local.WithX( Math.Max( local.x, 0 ) ) + Vector3.Forward * 300f );
}
}
public virtual bool NavigateTo( Vector3 pos )
{
Agent.MoveTo( pos );
return true;
}
public virtual void ComputePath()
{
if ( PatrolPath == null ) return;
TargetPosition = PatrolPath[CurrentPathId].WorldPosition;
if ( WorldPosition.Distance( TargetPosition ) < MathF.Max( Agent.Velocity.Length, 100f ) * 20f * Time.Delta )
CurrentPathId = (CurrentPathId + 1) % PatrolPath.Count;
}
public async void RagdollModel()
{
if ( !ModelRenderer.IsValid() ) return;
var ragdoll = ModelRenderer.GameObject.AddComponent<ModelPhysics>();
ragdoll.Renderer = ModelRenderer;
ragdoll.Model = ModelRenderer.Model;
Agent.Velocity = Vector3.Zero;
Agent.Enabled = false;
await Task.DelayRealtimeSeconds( 5f );
GameUI.BlackScreen();
await Task.DelayRealtimeSeconds( 2.5f );
GameManager.Instance.EndGame();
}
}