FG Project 4 - Citadel
Game project with students at Futuregames in Unity3D.
During this project I worked with the Enemy AI, Menu UI, Options UI and Item Drops(Loot Table).
Enemy AI
For the AI we used Unity's NavMesh Agent and a State Machine.
public class AI : Character, ITaggable, ISavable
{
[Header("AI Components")]
[SerializeField] private ProjectileData m_ProjectileData;
[SerializeField] private Transform m_Eyes;
private ElementState m_CurrentElement;
[Header("Agent Settings")]
[SerializeField] private float m_Speed = 3.5f;
[SerializeField] private float m_TurnSpeed = 5f;
[SerializeField] private float m_Acceleration = 8f;
[SerializeField] protected Bar healthBar;
[Header("Detection Settings")]
[SerializeField] private float m_PlayerDetectionRadius = 10f;
[SerializeField] private float m_MaximumAttackRange = 10f;
[SerializeField] private float m_AIAttackRange = 5f;
[SerializeField] private LayerMask m_PlayerLayer;
private Vector3 m_DestinationNearPlayer = Vector3.zero;
[SerializeField] private LayerMask m_GroundLayer;
[SerializeField] [Range(0f, -10f)] private float m_FallSpeedAfterPushBack = -3f;
[SerializeField] public float m_FallSpeedDeathTreshhold = 5f;
private IState m_StateOnSave = null;
private GameObject m_PlayerTargetOnSave = null;
private Vector3 m_Vector3Infinity = Vector3.one * Mathf.Infinity;
public float SqrMaxAttackRange => m_MaximumAttackRange * m_MaximumAttackRange;
public float SqrDetectionRadius => m_PlayerDetectionRadius * m_PlayerDetectionRadius;
public float SqrAttackRange => m_AIAttackRange * m_AIAttackRange;
public float PlayerDetectionRadius => m_PlayerDetectionRadius;
public bool CanSeePlayer { get; set; } = false;
public bool IsTagged { get; set; } = false;
public bool IsOnGround { get; set; } = false;
public Vector3 LastKnownTargetPosition { get; set; } = default;
public LayerMask PlayerLayer
{
get => m_PlayerLayer;
set => m_PlayerLayer = value;
}
public override float StunTime
{
get => m_StunTime;
set
{
if (Agent)
{
Agent.enabled = false;
}
StopCharging(m_ProjectileData.ElementType);
m_StunTime = value + Time.time;
}
}
private Vector3 m_RandomDirection = Vector3.zero;
private float m_AttackTimer = 0f;
private bool ShowDebugThings = false;
// AI Components
protected override Vector3 MovementVector => Agent.desiredVelocity;
protected override float Hp { set { healthBar.SetValue(value); base.Hp = value; } }
protected override float ChargingMovementSpeedMultiplier => m_CastWeight * m_ProjectileData.ChargingMovementSpeedDividend;
protected override Vector3 AimTargetPosition => PlayerTarget ? PlayerTarget.transform.position : transform.position;
public NavMeshAgent Agent { get; private set; }
public StateMachine StateMachine { get; private set; }
public Idle IdleState { get; private set; }
public Follow FollowState { get; private set; }
public Attack AttackState { get; private set; }
public AISearch SearchState { get; private set; }
public Rigidbody Rigidbody { get; private set; }
public GameObject PlayerTarget { get; set; }
public bool ObjectActiveOnSave { get; set; }
private LineRenderer m_Line;
public event Action OnTag;
private void Start()
{
Rigidbody = GetComponent();
m_Line = gameObject.AddComponent();
IdleState = new Idle();
FollowState = new Follow();
AttackState = new Attack();
SearchState = new AISearch();
StateMachine = new StateMachine(this);
StateMachine.ChangeState(IdleState);
AgentSetup();
switch (m_ProjectileData.ElementType)
{
case ElementType.Fire:
SpellColor = Color.red;
break;
case ElementType.Ice:
SpellColor = Color.blue;
break;
case ElementType.Lightning:
SpellColor = Color.white;
break;
case ElementType.Oil:
SpellColor = Color.green;
break;
}
m_SpellBasedMaterial.SetColor("_EmissiveColor", SpellColor * m_MaxEmissionIntensity / 2f);
}
public override void Update()
{
base.Update();
DebugAgentPath();
AIOnGround();
if (PlayerTarget)
{
PlayerInLineOfSight(m_Eyes.position, DirectionToPlayer(transform.position, PlayerTarget.transform.position));
}
PlayerIsDead();
if (Agent.enabled)
{
StateMachine.UpdateState();
UpdateLookRotation();
}
}
protected override void Die()
{
base.Die();
StopCharging(m_ProjectileData.ElementType);
this.enabled = false;
healthBar.gameObject.SetActive(false);
DropItem(transform.position);
healthBar.gameObject.SetActive(false);
}
protected override IEnumerator Charge(ProjectileData spell)
{
Agent.SetDestination(transform.position);
if (spell.SpellType == SpellType.ChanneledRaycast)
{
m_LightningPfxParticle.target = PlayerTarget.transform;
}
StartCoroutine(base.Charge(spell));
yield return null;
}
protected override void UpdateChargeVariables(ProjectileData spell)
{
base.UpdateChargeVariables(spell);
if (!CanSeePlayer && m_ChargeCoroutine != null && SqrDistanceToPlayer(transform.position, PlayerTarget.transform.position) > SqrMaxAttackRange)
StopCharging(spell.ElementType);
if (ChargeTimeAccu > spell.TimeToReachFullCharge)
{
switch (spell.SpellType)
{
case SpellType.Projectile:
case SpellType.TargetAOE:
FireProjectile(AimTargetPosition, spell);
break;
case SpellType.ChanneledRaycast:
if (DistanceToPlayer(transform.position, PlayerTarget.transform.position) > m_MaximumAttackRange)
StopCharging(spell.ElementType);
break;
}
}
}
private void AgentSetup()
{
Agent = GetComponent();
Agent.speed = MovementSpeed;
Agent.acceleration = m_Acceleration;
}
private void AIOnGround()
{
IsOnGround = Physics.Raycast(transform.position, Vector3.down, out RaycastHit hit, maxDistance: 2f, m_GroundLayer);
if (!IsOnGround)
{
IncreaseGravity();
}
if (IsOnGround && !IsStunned)
{
Agent.enabled = true;
if (Rigidbody.velocity.y >= m_FallSpeedDeathTreshhold)
Die();
}
}
private void IncreaseGravity()
{
Rigidbody.velocity += Vector3.down * m_FallSpeedAfterPushBack;
}
public bool SetDestinationNearPlayer(Vector3 playerPosition)
{
if (NavMesh.SamplePosition(playerPosition, out NavMeshHit hit, 2f, NavMesh.AllAreas))
{
m_DestinationNearPlayer = hit.position;
return true;
}
m_DestinationNearPlayer = m_Vector3Infinity;
return false;
}
public void GoToNewDestination()
{
if (m_DestinationNearPlayer == m_Vector3Infinity)
return;
Agent.SetDestination(m_DestinationNearPlayer);
Agent.isStopped = false;
}
public void FollowMove()
{
if (!PlayerTarget)
return;
Vector3 curPos = Agent.transform.position;
Vector3 playerPos = PlayerTarget.transform.position;
if (SqrDistanceToPlayer(curPos, playerPos) > SqrAttackRange)
{
Agent.SetDestination(playerPos);
}
}
private void MoveBack()
{
if (!PlayerTarget)
return;
Vector3 curPos = Agent.transform.position;
Vector3 playerPos = PlayerTarget.transform.position;
if (SqrDistanceToPlayer(curPos, playerPos) < SqrAttackRange * 0.1f)
{
MoveAgent(-DirectionToPlayer(curPos, playerPos), m_Speed);
}
}
private void MoveAgent(Vector3 direction, float speed)
{
Agent.Move(direction * speed * Time.deltaTime);
}
private void UpdateLookRotation()
{
if (CanSeePlayer && PlayerTarget)
{
Vector3 direction = DirectionToPlayer(transform.position, PlayerTarget.transform.position);
transform.rotation = Quaternion.Slerp(transform.rotation, Quaternion.LookRotation(direction), m_TurnSpeed * Time.deltaTime);
}
else if (m_RandomDirection != Vector3.zero)
{
Vector3 direction = m_RandomDirection;
transform.rotation = Quaternion.Slerp(transform.rotation, Quaternion.LookRotation(direction), m_TurnSpeed * Time.deltaTime);
}
}
public Vector3 DirectionToPlayer(Vector3 curPos, Vector3 playerPos)
{
return (playerPos - curPos).normalized;
}
public float SqrDistanceToPlayer(Vector3 curPos, Vector3 playerPos)
{
return (playerPos - curPos).sqrMagnitude;
}
public float DistanceToPlayer(Vector3 curPos, Vector3 playerPos)
{
return (playerPos - curPos).magnitude;
}
private void PlayerInLineOfSight(Vector3 origin, Vector3 direction)
{
float rayDistance = DistanceToPlayer(origin, PlayerTarget.transform.position);
origin += Vector3.up * 0.05f;
if (Physics.Raycast(origin, direction, out RaycastHit hit, rayDistance))
{
if(CanSeePlayer = hit.collider.GetComponent())
{
LastKnownTargetPosition = PlayerTarget.transform.position;
}
}
}
public void AttackPlayer()
{
if (m_CanCharge)
{
m_ChargeCoroutine = Charge(m_ProjectileData);
StartCoroutine(m_ChargeCoroutine);
}
else if (Agent.remainingDistance <= Agent.stoppingDistance)
{
if (SqrDistanceToPlayer(transform.position, PlayerTarget.transform.position) <= SqrAttackRange)
{
MovePerpendicularToDirection(DirectionToPlayer(transform.position, PlayerTarget.transform.position), 1f);
}
else SetDestinationPerpendicularToDirection(GetClosestPointWithDistanceToTarget(PlayerTarget.transform.position, m_AIAttackRange),
DirectionToPlayer(transform.position, PlayerTarget.transform.position), 1f);
}
}
private void MovePerpendicularToDirection(Vector3 dir, float xValue = 1f)
{
Agent.SetDestination(transform.position + Vector3.Cross(dir, Vector3.up) * xValue);
}
private void SetDestinationPerpendicularToDirection(Vector3 startPos, Vector3 dir, float xValue = 1f)
{
Agent.SetDestination(startPos + Vector3.Cross(dir, Vector3.up) * xValue);
}
private Vector3 GetClosestPointWithDistanceToTarget(Vector3 targetPos, float distance)
{
return targetPos + ((transform.position - targetPos).normalized * distance);
}
public void SetState(IState state)
{
StateMachine.ChangeState(state);
}
private void DropItem(Vector3 spawnPosition)
{
WeightedLootTable.S_WeightedLootTable.RandomItem(spawnPosition);
}
private void PlayerIsDead()
{
if (!PlayerTarget)
return;
if (!PlayerTarget.gameObject.activeSelf)
{
PlayerTarget = null;
}
}
private void DebugAgentPath()
{
if (Input.GetKeyDown(KeyCode.O))
ShowDebugThings = !ShowDebugThings;
if (ShowDebugThings)
{
if (Agent.hasPath)
{
m_Line.positionCount = Agent.path.corners.Length;
m_Line.SetPositions(Agent.path.corners);
m_Line.enabled = true;
}
else
{
m_Line.enabled = false;
}
}
else
{
m_Line.enabled = false;
}
}
public void Tag(GameObject sender)
{
PlayerTarget = sender;
OnTag?.Invoke();
LastKnownTargetPosition = sender.transform.position;
}
public void OnSave(StreamWriter sw, out int addedCount)
{
sw.WriteLine(transform.position.Serialize());
sw.WriteLine(transform.rotation.Serialize());
addedCount = 2;
if (StateMachine != null)
{
m_StateOnSave = StateMachine.currentState;
}
m_PlayerTargetOnSave = PlayerTarget;
}
public void OnLoad(string[] savedData, int startIndex)
{
transform.position = savedData[startIndex++].DeserializeToVector3();
transform.rotation = savedData[startIndex++].DeserializeToQuaternion();
if(StateMachine != null && m_StateOnSave != StateMachine.currentState)
{
SetState(m_StateOnSave);
}
PlayerTarget = m_PlayerTargetOnSave;
gameObject.SetActive(ObjectActiveOnSave);
}
}
Options UI
public class PauseMenu : MonoBehaviour
{
[Header("Components")]
[SerializeField] private GameManager m_GameManager;
[SerializeField] private AudioMixer m_VolumeMixer;
[Header("UI")]
[SerializeField] private GameObject m_PauseMenuUI;
[SerializeField] private GameObject m_SettingsMenuUI;
[SerializeField] private GameObject m_ControlMenuUI;
public static bool GameIsPaused { get; private set; }
private void DisableUI()
{
m_PauseMenuUI.SetActive(false);
m_SettingsMenuUI.SetActive(false);
}
private void Awake()
{
GameIsPaused = false;
DisableUI();
}
private void Update()
{
if (Input.GetKeyDown(KeyCode.Escape))
{
if (m_ControlMenuUI.activeInHierarchy)
{
ControlsBack();
return;
}
if (m_SettingsMenuUI.activeInHierarchy)
{
SettingsBack();
return;
}
if (GameIsPaused)
{
Resume();
}
else { Pause(); }
}
}
private void Pause()
{
m_PauseMenuUI.SetActive(true);
GameIsPaused = true;
Time.timeScale = 0f;
}
public void Resume()
{
m_PauseMenuUI.SetActive(false);
GameIsPaused = false;
Time.timeScale = 1f;
}
public void MainMenu()
{
if (m_GameManager.MainMenuSceneName.Length < 1)
{
Debug.LogError("Main Menu scene name is invalid. Check GameManager");
return;
}
SceneManager.LoadScene(m_GameManager.MainMenuSceneName);
}
public void Controls()
{
m_ControlMenuUI.SetActive(true);
m_PauseMenuUI.SetActive(false);
}
public void ControlsBack()
{
m_ControlMenuUI.SetActive(false);
m_PauseMenuUI.SetActive(true);
}
public void Settings()
{
m_SettingsMenuUI.SetActive(true);
m_PauseMenuUI.SetActive(false);
}
public void SettingsBack()
{
m_SettingsMenuUI.SetActive(false);
m_PauseMenuUI.SetActive(true);
}
public void RestartGame()
{
SceneManager.LoadScene(m_GameManager.GameSceneName);
if (GameIsPaused)
{
DisableUI();
Resume();
}
}
public void SetMusicVolume(float volume)
{
m_VolumeMixer.SetFloat("MusicVolume", volume);
}
public void SetSoundVolume(float volume)
{
m_VolumeMixer.SetFloat("SoundVolume", volume);
}
public void SetMasterVolume(float volume)
{
m_VolumeMixer.SetFloat("MasterVolume", volume);
}
public void QuitGame()
{
#if UNITY_EDITOR
UnityEditor.EditorApplication.isPlaying = false;
#else
Application.Quit();
#endif
}
}
Weighted Loot Table
public class WeightedLootTable : MonoBehaviour
{
[HideInInspector] public GameObject[] m_ItemNames = new GameObject[(int)ItemEnums.ITEM_ENUMS_MAX];
[HideInInspector] public uint[] m_ProbabilityToDrop = new uint[(int)ItemEnums.ITEM_ENUMS_MAX];
[SerializeField] private ItemEnums[] m_Trackables = new ItemEnums[(int)ItemEnums.ITEM_ENUMS_MAX];
[SerializeField] private bool m_IsActive = false;
private readonly ObjectPool[] m_ItemPools = new ObjectPool[(int)ItemEnums.ITEM_ENUMS_MAX];
private GameObject m_Parent;
public static WeightedLootTable S_WeightedLootTable { get; set; }
public ItemEnums[] Trackables
{
get => m_Trackables;
set => m_Trackables = value;
}
public GameObject[] ItemNames
{
get => m_ItemNames;
set => m_ItemNames = value;
}
public bool IsActive
{
get => m_IsActive;
set => m_IsActive = value;
}
private void Awake()
{
S_WeightedLootTable = this;
CreateObjectPools();
}
private void CreateObjectPools()
{
for (int i = 0; i < (int)ItemEnums.ITEM_ENUMS_MAX; i++)
{
if (m_ItemNames[i])
{
m_Parent = new GameObject("Pool of " + m_ItemNames[i].name);
m_Parent.transform.parent = transform;
m_ItemPools[i] = new ObjectPool(5, m_ItemNames[i], 1, m_Parent.transform);
}
}
}
private void Spawn(int index, Vector3 spawnPosition)
{
var loot = m_ItemPools[index]?.Rent(true);
if (loot == null)
return;
loot.transform.position = spawnPosition;
}
public void RandomItem(Vector3 spawnPosition)
{
uint range = 0;
for (int i = 0; i < m_ProbabilityToDrop.Length; i++)
{
range += m_ProbabilityToDrop[i];
}
uint selected = (uint)Random.Range(0, range);
uint topItem = 0;
for (int i = 0; i < m_ProbabilityToDrop.Length; i++)
{
topItem += m_ProbabilityToDrop[i];
if (selected < topItem)
{
Spawn(i, spawnPosition);
return;
}
}
}
}