SupaLidlGame/Characters/NPC.cs

302 lines
8.3 KiB
C#

#define DEBUG_NPC
using Godot;
using SupaLidlGame.Extensions;
using SupaLidlGame.Items;
using System;
namespace SupaLidlGame.Characters;
public partial class NPC : Character
{
/// <summary>
/// Time in seconds it takes for the NPC to think FeelsDankCube
/// </summary>
public const float ThinkTime = 0.125f;
public float[] Weights => _weights;
protected float _preferredWeightDistance = 64.0f;
protected float _maxWeightDistance = 8.0f;
protected float _preferredWeightDistanceSq = 4096.0f;
protected float _maxWeightDistanceSq = 64.0f;
[Export]
public float PreferredWeightDistance
{
get => _preferredWeightDistance;
protected set
{
_preferredWeightDistance = value;
_preferredWeightDistanceSq = value * value;
}
}
[Export]
public float MaxWeightDistance
{
get => _maxWeightDistance;
protected set
{
_maxWeightDistance = value;
_maxWeightDistanceSq = value * value;
}
}
[Export]
public Items.Item DefaultSelectedItem { get; set; }
[Export]
public bool ShouldMoveWhenUsingItem { get; set; } = true;
[Export]
public State.Thinker.ThinkerStateMachine ThinkerStateMachine { get; set; }
public bool ShouldMove { get; set; } = true;
public bool CanAttack { get; set; } = true;
public Vector2 LastSeenPosition { get; set; }
protected float[] _weights = new float[16];
protected int _bestWeightIdx;
protected double _thinkTimeElapsed = 0;
protected Vector2 _blockingDir;
protected static readonly Vector2[] _weightDirs = new Vector2[16];
static NPC()
{
for (int i = 0; i < 16; i++)
{
float y = Mathf.Sin(Mathf.Pi * i * 2 / 16);
float x = Mathf.Cos(Mathf.Pi * i * 2 / 16);
_weightDirs[i] = new Vector2(x, y);
}
}
public override void _Ready()
{
base._Ready();
Array.Fill(_weights, 0);
if (DefaultSelectedItem is not null)
{
Inventory.SelectedItem = DefaultSelectedItem;
}
Inventory.UsedItem += (Items.Item item) =>
{
if (item is Items.Weapon)
{
if (AttackAnimation is not null)
{
AttackAnimation.TryPlay("attack");
}
}
};
}
public override void _Draw()
{
#if DEBUG
for (int i = 0; i < 16; i++)
{
Vector2 vec = _weightDirs[i] * _weights[i] * 32;
Color c = Colors.Green;
if (_bestWeightIdx == i)
{
c = Colors.Blue;
}
else if (_weights[i] < 0)
{
c = Colors.Red;
vec = -vec;
}
DrawLine(Vector2.Zero, vec, c);
}
#endif
base._Draw();
}
public virtual Character FindBestTarget()
{
float bestScore = float.MaxValue;
Character bestChar = null;
// NOTE: this relies on all Characters being under the Entities node
foreach (Node node in GetParent().GetChildren())
{
if (node is Character character)
{
bool isFriendly = character.Faction == Faction;
if (isFriendly || character.Health <= 0)
{
continue;
}
float score = 0;
score += Position.DistanceTo(character.Position);
score *= (character.Stealth + 1);
// if the character has enough stealth, the dot product of the
// enemy's current direction and to the character will affect
// the score
// TODO: implement
if (score < bestScore)
{
// if the character has enough stealth, they won't be
// targeted if the NPC is not able to see
if (!HasLineOfSight(character) && character.Stealth >= 1)
{
continue;
}
bestScore = score;
bestChar = character;
}
}
}
return bestChar;
}
public override void _Process(double delta)
{
ThinkerStateMachine.Process(delta);
base._Process(delta);
}
public override void _PhysicsProcess(double delta)
{
ThinkerStateMachine.PhysicsProcess(delta);
base._PhysicsProcess(delta);
}
public void ThinkProcess(double delta)
{
if ((_thinkTimeElapsed += delta) > ThinkTime)
{
_thinkTimeElapsed = 0;
Think();
#if DEBUG_NPC
QueueRedraw();
#endif
}
if (!ShouldMove || (!ShouldMoveWhenUsingItem && Inventory.IsUsingItem))
{
Direction = Vector2.Zero;
}
else
{
Direction = _weightDirs[_bestWeightIdx];
}
}
public void UpdateWeights(Vector2 pos)
{
// FIXME: TODO: remove all the spaghetti
Vector2 dir = Target.Normalized();
float distSq = GlobalPosition.DistanceSquaredTo(pos);
var spaceState = GetWorld2D().DirectSpaceState;
var exclude = new Godot.Collections.Array<Godot.Rid>();
exclude.Add(this.GetRid());
// calculate weights based on distance
for (int i = 0; i < 16; i++)
{
float directDot = _weightDirs[i].Dot(dir);
// clamp dot from [-1, 1] to [0, 1]
directDot = (directDot + 1) / 2;
float strafeDot = Math.Abs(_weightDirs[i].Dot(dir.Clockwise90()));
float currDirDot = (_weightDirs[i].Dot(Direction) + 1) / 16;
strafeDot = Mathf.Pow((strafeDot + 1) / 2, 2) + currDirDot;
// favor strafing when getting closer
if (distSq > _preferredWeightDistanceSq)
{
_weights[i] = directDot;
}
else if (distSq > _maxWeightDistanceSq)
{
float dDotWeight = Mathf.Sqrt(distSq / 4096);
float sDotWeight = 1 - dDotWeight;
_weights[i] = (dDotWeight * directDot) +
(sDotWeight * strafeDot);
}
else
{
_weights[i] = strafeDot;
}
}
// subtract weights that collide
for (int i = 0; i < 16; i++)
{
var rayParams = new PhysicsRayQueryParameters2D
{
Exclude = exclude,
CollideWithBodies = true,
From = GlobalPosition,
To = GlobalPosition + (_weightDirs[i] * 24),
CollisionMask = 1 + 2 + 16
};
var result = spaceState.IntersectRay(rayParams);
// if we hit something
if (result.Count > 0)
{
// then we subtract the value of this from the other weights
float oldWeight = _weights[i];
for (int j = 0; j < 16; j++)
{
if (i == j)
{
_weights[i] = 0;
}
else
{
float dot = _weightDirs[i].Dot(_weightDirs[j]);
_weights[j] -= _weights[j] * dot;
}
}
}
}
float bestWeight = 0;
for (int i = 0; i < 16; i++)
{
if (_weights[i] > bestWeight)
{
_bestWeightIdx = i;
bestWeight = _weights[i];
}
}
}
protected virtual void Think()
{
// TODO: the entity should wander if it doesn't find a best target
Character bestTarget = FindBestTarget();
if (bestTarget is not null)
{
Vector2 pos = FindBestTarget().GlobalPosition;
Target = pos - GlobalPosition;
Vector2 dir = Target;
float dist = GlobalPosition.DistanceSquaredTo(pos);
UpdateWeights(pos);
if (dist < 1600 && CanAttack)
{
if (Inventory.SelectedItem is Weapon weapon)
{
UseCurrentItem();
}
}
}
}
}