Shattered Veil
Game Project 2 Summary
This was the last project in Unity with a large team of
20 people. And as expected, there were quite a number of
communication issues that came up throughout the
project's development. From multiple ideas being
interpreted in their own ways, to somehow the game
becoming an Action FPS.
The game was supposed
to be a narrative-driven exploration game with a touch
of combat. And the world is based on Celtic mythology.
We also had the restriction of having some mobile
compatibility, which we struggled a lot with because our
game barely launched on mobile.
Check out the
game here: Shattered Veil
My Contributions
Enemy AI
One of my responsibilities was implementing different enemies that the player will encounter as the game progresses. At the end of production, I had made two types of basic enemies, one melee and the other being ranged. Lastly, we had a boss enemy. While developing the AI, I found that making interesting behaviors is not as simple as I thought in the beginning.
public class EnemyMeleeBehaviourTree : BehaviourTree
{
Animator enemyAnimator;
GameObject player;
NavMeshAgent agent;
EnemyAIStats enemyMeele;
public override void StartEnemyBehabiour()
{
base.StartEnemyBehabiour();
myBlackBoard = new BlackBoard();
player =
GameObject.FindGameObjectWithTag("Player");
agent = GetComponent<NavMeshAgent>();
enemyMeele = GetComponent<EnemyAIStats>();
enemyAnimator = GetComponentInChildren<Animator>();
myRootNode =
new Sequence(new List<BehaviourNode>
{
new IsStuned(new List<BehaviourNode>()),
new Selector(new List<BehaviourNode>
{
new CanSeePlayer(new List<BehaviourNode>()),
new Patrol(new List<BehaviourNode>())
}),
new ChasePlayer(new List<BehaviourNode>()),
new AttackPlayer(new List<BehaviourNode>()),
});
myBlackBoard.data.Add("PlayerGameObject",
player);
myBlackBoard.data.Add("ThisEnemyTransform",
transform);
myBlackBoard.data.Add("EnemyAgent", agent);
myBlackBoard.data.Add("EnemyMeele",
enemyMeele);
myBlackBoard.data.Add("EnemyAnimator",
enemyAnimator);
myRootNode.PopulateBlackBoard(myBlackBoard);
}
void Update()
{
UpdateTree();
enemyAnimator.SetFloat("Speed",
agent.velocity.magnitude / agent.speed);
}
}public class CanSeePlayer : BehaviourNode
{
public CanSeePlayer(List<BehaviourNode> someChildren)
: base(someChildren)
{
}
float timer = 0f;
public override ReturnState Evaluate()
{
GameObject player =
myBlackBoard.data["PlayerGameObject"] as
GameObject;
Transform enemyTransform =
myBlackBoard.data["ThisEnemyTransform"] as
Transform;
NavMeshAgent agent =
myBlackBoard.data["EnemyAgent"] as
NavMeshAgent;
EnemyAIStats enemy =
myBlackBoard.data["EnemyMeele"] as
EnemyAIStats;
if
(myBlackBoard.data.TryGetValue("StoppingDistance",
out object value))
{
agent.stoppingDistance = float.Parse(value.ToString());
}
if (player != null)
{
float distanceToPlayer =
Vector3.Distance(enemyTransform.position,
player.transform.position);
if (distanceToPlayer < enemy.SightDistance)
{
Vector3 targetDirection = player.transform.position -
enemyTransform.position - (Vector3.up * enemy.EyeHeight);
float angleToPlayer = Vector3.Angle(targetDirection,
enemy.transform.forward);
if (angleToPlayer >= -enemy.FielOfView &&
angleToPlayer <= enemy.FielOfView)
{
Ray ray = new Ray(enemyTransform.position + (Vector3.up *
enemy.EyeHeight), targetDirection);
RaycastHit hitInfo = new RaycastHit();
if (Physics.Raycast(ray, out hitInfo,
enemy.SightDistance))
{
if (hitInfo.transform.gameObject == player)
{
RotateEnemyToPlayer(enemy, player);
Debug.DrawRay(ray.origin, ray.direction *
enemy.SightDistance);
timer = 0f;
return ReturnState.Success;
}
else if (timer < enemy.LooseSightTime)
{
RotateEnemyToPlayer(enemy, player);
Debug.DrawRay(ray.origin, ray.direction *
enemy.SightDistance);
timer += Time.deltaTime;
return ReturnState.Success;
}
}
}
else if (timer < enemy.LooseSightTime)
{
RotateEnemyToPlayer(enemy, player);
timer += Time.deltaTime;
return ReturnState.Success;
}
}
else if (timer < enemy.LooseSightTime)
{
RotateEnemyToPlayer(enemy, player);
timer += Time.deltaTime;
return ReturnState.Success;
}
else
{
myBlackBoard.data["PlayerOutOfSight"] = true;
return ReturnState.Failure;
}
}
myBlackBoard.data["PlayerOutOfSight"] = false;
return ReturnState.Failure;
}
public void RotateEnemyToPlayer(EnemyAIStats enemy,
GameObject player)
{
Quaternion targetRotation = Quaternion.LookRotation(new
Vector3(player.transform.position.x -
enemy.transform.position.x, 0, player.transform.position.z -
enemy.transform.position.z), Vector3.up);
enemy.transform.rotation =
Quaternion.Slerp(enemy.transform.rotation, targetRotation,
Time.deltaTime * enemy.RotationSpeed);
}
}public class ChasePlayer : BehaviourNode
{
private EventInstance walkSoundInstance;
private bool isWalking = false;
public ChasePlayer(List<BehaviourNode> someChildren) :
base(someChildren)
{
walkSoundInstance =
RuntimeManager.CreateInstance("event:/SFX/Enemy_WalkRun");
EnemyAIStats.OnStopEnemySound += StopEnemySound;
}
float newDestinationCoolDown = 0.5f;
public override ReturnState Evaluate()
{
GameObject player =
myBlackBoard.data["PlayerGameObject"] as
GameObject;
NavMeshAgent agent =
myBlackBoard.data["EnemyAgent"] as
NavMeshAgent;
Animator enemyAnim =
myBlackBoard.data["EnemyAnimator"] as Animator;
if (player != null)
{
if (newDestinationCoolDown <= 0)
{
newDestinationCoolDown = 0.5f;
agent.SetDestination(player.transform.position);
if (!isWalking &&
myBlackBoard.data.ContainsKey("PlayerOutOfSight"))
{
walkSoundInstance.start();
isWalking = true;
}
}
newDestinationCoolDown -= Time.deltaTime;
if
(myBlackBoard.data.ContainsKey("PlayerOutOfSight")
&&
(bool)myBlackBoard.data["PlayerOutOfSight"])
{
StopEnemySound();
}
return ReturnState.Success;
}
else
{
StopEnemySound();
return ReturnState.Failure;
}
}
public void StopEnemySound()
{
if (isWalking)
{
walkSoundInstance.stop(FMOD.Studio.STOP_MODE.ALLOWFADEOUT);
isWalking = false;
}
}
~ChasePlayer()
{
EnemyAIStats.OnStopEnemySound -= StopEnemySound;
}
}
Color Puzzle
Another feature I implement is the color puzzle. It is where you need to put the specific colored key into the same lock. With some locks requiring multiple keys. And if you solved the puzzle, the three types of doors would open.
public enum DoorType
{
NormalDoor,
StoneDoor,
FogDoor
}
public class RitualStoneDoor :
MonoBehaviour
{
public DoorType DoorType;
public AnimationCurve DoorOpeningAnimationSpeed;
public Transform NormalDoorPivotPoint;
public float OpenDoorRotationAngle = 100f;
public List<GameObject> ConditionObjects;
private ParticleSystem fogParticles;
private Collider fogCollider;
private Animator rockDoorAnimator;
private int completedObjects = 0;
private RotationDirection direction;
private void Start()
{
if (OpenDoorRotationAngle > 0)
{
direction = RotationDirection.PositiveNums;
}
else
{
direction = RotationDirection.NegativeNums;
}
}
public void TryOpenDoor()
{
completedObjects = 0;
for (int i = 0; i < ConditionObjects.Count; i++)
{
if
(ConditionObjects[i].TryGetComponent<RitualStoneLock>(out
RitualStoneLock TheLock))
{
if (TheLock.Completed == true)
{
completedObjects++;
}
}
}
if (completedObjects == ConditionObjects.Count)
{
switch (DoorType)
{
case DoorType.NormalDoor:
StartCoroutine(DoorOpenInProgress());
break;
case DoorType.StoneDoor:
rockDoorAnimator =
gameObject.GetComponent<Animator>();
rockDoorAnimator.SetTrigger("Open");
break;
case DoorType.FogDoor:
fogParticles =
gameObject.GetComponent<ParticleSystem>();
fogCollider = gameObject.GetComponent<Collider>();
fogCollider.enabled = false;
var tempMain = fogParticles.main;
tempMain.loop = false;
tempMain.startLifetime = 0.5f;
tempMain.simulationSpeed = 3f;
break;
}
}
else
{
Debug.Log("Not all conditions are met");
}
}
public IEnumerator DoorOpenInProgress()
{
switch (direction)
{
case RotationDirection.PositiveNums:
while (NormalDoorPivotPoint.localEulerAngles.y + 0.0001f
< OpenDoorRotationAngle -
(Math.Round(OpenDoorRotationAngle / 360) * 360))
{
NormalDoorPivotPoint.localRotation =
Quaternion.Lerp(NormalDoorPivotPoint.localRotation,
Quaternion.Euler(new Vector3(0, OpenDoorRotationAngle, 0)),
DoorOpeningAnimationSpeed.Evaluate(Time.deltaTime));
yield return null;
}
break;
case RotationDirection.NegativeNums:
while (NormalDoorPivotPoint.localEulerAngles.y - 0.0001f
> (OpenDoorRotationAngle -
(Math.Round(OpenDoorRotationAngle / 360) * 360)) + 360 ||
NormalDoorPivotPoint.localEulerAngles.y <= 0f)
{
NormalDoorPivotPoint.localRotation =
Quaternion.Lerp(NormalDoorPivotPoint.localRotation,
Quaternion.Euler(new Vector3(0, OpenDoorRotationAngle, 0)),
DoorOpeningAnimationSpeed.Evaluate(Time.deltaTime));
yield return null;
}
break;
}
}
private void OnDrawGizmos()
{
if (DoorType == DoorType.NormalDoor)
{
Gizmos.color = Color.red;
var direction = Quaternion.AngleAxis(OpenDoorRotationAngle,
transform.up) * NormalDoorPivotPoint.forward;
Gizmos.DrawRay(NormalDoorPivotPoint.position, direction *
2f);
Gizmos.color = Color.blue;
Gizmos.DrawRay(NormalDoorPivotPoint.position,
NormalDoorPivotPoint.forward * 2f);
}
}
}
public class RitualStoneLock : MonoBehaviour
{
public GameObject StoneKey;
public UnityEvent TryOpenDoor;
public bool Completed = false;
private Collider thisObjectsCollider;
private void Start()
{
thisObjectsCollider = GetComponent<Collider>();
}
private void OnTriggerEnter(Collider other)
{
if (other.gameObject.CompareTag("canPickUp")
&& other.gameObject == StoneKey)
{
var temp =
other.GetComponentInParent<PickupAndThrow>();
if (temp != null)
{
temp.DropObject();
}
if (other.TryGetComponent<Rigidbody>(out Rigidbody
tempRB))
{
tempRB.isKinematic = true;
}
other.gameObject.tag = "Untagged";
other.transform.parent = transform;
other.transform.localPosition = Vector3.zero;
other.transform.localRotation = Quaternion.identity;
Completed = true;
TryOpenDoor.Invoke();
FMODUnity.RuntimeManager.PlayOneShot("event:/MainMenu_Click",
GetComponent<Transform>().position);
thisObjectsCollider.enabled = false;
}
}
}
My Takeaways
Some miscommunication issues during the project.
Making
compelling AI is difficult.
Optimizing for mobile is
difficult while trying to keep similar visuals.
Might
have overscoped a bit too much. It would have been better to use
the extra time to polish.