Creating AI Ghost

This page documents the workflow to follow when creating a new Ghost AI

Setup

Make sure the head bone forward is the blue Z-Axis, up is green Y-Axis; if not, create a child component such that its forward is the blue Z-Axis and up is green Y-Axis and assign it as the head bone.

Head Bone setup orientation

Eye Man Implementation:

// Copyright Š 2024 by MADKEV Studio, all rights reserved

using System;
using System.Collections;
using Animancer;
using UnityEngine;

/// <summary>
/// Implementation for Eye Man ghost character.
/// </summary>
public class EyeMan : BaseGhostAI<EyeMan.AIStates>
{
    private const float WALK_SPEED = 3f;
    private const float RUN_SPEED = 6.8f;
    private const int CHOKE_ATTACK_AMOUNT = 100;
    private const int LONG_ATTACK_AMOUNT = 68;
    private const float ATTACK_RANGE = 2.86f;
    private const float CHOKE_ATTACK_RANGE = 1.26f;
    [Header("Setup")]
    public Transform mHandTransform;
    public AnimationClip mWalkClip;
    public AnimationClip mRunClip;
    public AnimationClip mStandingClip;
    public AnimationClip mChokeAttackClip;
    public AnimationClip mLongAttackClip;

    private float mDoorWaitTime;
    private Door mObstacleDoor;
    protected override AISettings OnInitializeAISettings()
    {
        AISettings setting = new AISettings();
        setting.StartingState = AIStates.Wondering;
        setting.AlertDistance = 5;
        setting.VisionAngle = 145;
        setting.VisionDistance = 10;
        return setting;
    }

    protected override void OnLosePlayerTargetRequested()
    {
        // Get difficulty multiplier to see if we should lose player
        // More difficult has higher percentage, so here we need to 1 - percent to calculate percentage chance of losing player
        if (AIMath.Decide2(LIMENDefine.GetAIProbabilityPercent(1 - GameManager.Instance.GetDifficulty())))
        {
            // Lose player: set state to wondering. We don't clear the current player target,
            // but if player target is no longer visible, AI won't keep chasing
            ChangeAIStateServer(AIStates.Wondering);
        }
    }

    public enum AIStates
    {
        Wondering, // This will be the default state since it's the first enum
        Chase,
        Attack,
        HandleDoor,
    }

    protected override void OnAIDoorObstacleTick(Door obstacleDoor)
    {
        mObstacleDoor = obstacleDoor;
        ChangeAIStateServer(AIStates.HandleDoor);
    }

    // TICKING //
    protected override void OnAIPreTickServer()
    {
        base.OnAIPreTickServer();
        // Validate if player target becomes null (Player disconnected) before invoking base
        // So we don't have to null check or validate if player is already killed in every ticking state later
        // Pre tick validate
        if (!ValidatePlayerTarget() && (GetCurrentState() == AIStates.Chase || GetCurrentState() == AIStates.Attack))
            ChangeAIStateServer(AIStates.Wondering);
    }

    // State - HandleDoor
    protected void OnStateEnterHandleDoor()
    {
        SetMovementState(BaseGhostAI<AIStates>.MovementState.NoMovement);
        mDoorWaitTime = UnityEngine.Random.Range(1.0f, 3.0f);
    }

    protected void OnStateTickHandleDoor()
    {
        PlayAnimation(mStandingClip);
        if (mObstacleDoor)
            FaceTarget(mObstacleDoor.transform);
        // Wait a random amount before proceeding to open the door or if door is no longer closed
        if (!IsObstacleDoor(mObstacleDoor) || GetCurrentStateTimer() >= mDoorWaitTime)
        {
            // This function will also leave a hand-print on the door after opening it
            TryOpenDoor(mObstacleDoor);

            // Change to attack, AI pre-tick would fallback to wondering if does not have valid player target.
            ChangeAIStateServer(AIStates.Chase);
        }
    }

    // State - Wondering //
    protected void OnStateEnterWondering()
    {
        SetMovementState(BaseGhostAI<AIStates>.MovementState.MoveTowardsWaypointTarget);
    }

    protected void OnStateTickWondering()
    {
        PlayAnimation(mWalkClip);
        SetSpeed(WALK_SPEED);
        // Patrol waypoint
        if (GetWaypointTarget() == null || IsWithinDistance(GetWaypointTarget().transform.position, 2f))
            SetWaypointTarget(GlobalWaypoint.Instance.GetNextWaypoint(GetWaypointTarget()));

        // Search for player
        Player foundPlayer = FindClosestVisiblePlayer(true);
        if (foundPlayer)
        {
            SetPlayerTarget(foundPlayer);
            ChangeAIStateServer(AIStates.Chase);
        }
    }

    // State - Chase //
    protected void OnStateEnterChase()
    {
        SetMovementState(BaseGhostAI<AIStates>.MovementState.MoveTowardsPlayerTarget);
    }
    protected void OnStateTickChase()
    {
        GetPlayerTarget().NotifyChase(this);
        PlayAnimation(mRunClip);
        SetSpeed(RUN_SPEED);
        // Thanks to our player target check in OnAITickServer, we don't have to null check here for player target
        if (IsWithinDistance(GetPlayerTarget().transform.position, ATTACK_RANGE))
            ChangeAIStateServer(AIStates.Attack);
    }

    protected void OnStateEnterAttack()
    {
        // Decide which attack to use, choke attack or ranged attack
        // If player is still very close and visible to AI, use choke attack. Else, use long range attack that deals regional damage to other players in radius as well.
        bool useChokeAttack = CanSeePlayer(GetPlayerTarget()) && IsWithinDistance(GetPlayerTarget().transform.position, CHOKE_ATTACK_RANGE);
        if (useChokeAttack)
        {
            GetPlayerTarget().ServerPinPlayerToTransform(HumanBodyBones.Head, mHandTransform, mChokeAttackClip.length);
            PlayAnimation(mChokeAttackClip, OnAttackEnded, () => { DoAttack(true); }, 0.52f);
        }
        else
            PlayAnimation(mLongAttackClip, OnAttackEnded, () => { DoAttack(false); }, 0.426f);
    }

    protected void OnStateTickAttack()
    {
        GetPlayerTarget().NotifyChase(this);
        SetMovementState(BaseGhostAI<AIStates>.MovementState.NoMovement);
        FaceTarget(GetPlayerTarget().transform);
    }

    // Invoked after attack animation has finished playing.
    private void OnAttackEnded()
    {
        // Reset state to chase, pre-tick will handle fallback to wonder if no valid player target is set
        ChangeAIStateServer(AIStates.Chase);
    }

    // Perform the actual attack and deal damage to players
    private void DoAttack(bool isChokeAttack)
    {
        if (isChokeAttack)
            GetPlayerTarget().OnSanityHit(this, CHOKE_ATTACK_AMOUNT);
        else
            TryAttackAllPlayersInRadius(ATTACK_RANGE, LONG_ATTACK_AMOUNT);
    }
}

Implementation Requiements:

Configure AI settings by creating and returning an AISettings instance in override OnInitializeAISettings()

Default values
public TState StartingState;
[Tooltip("The angle in degrees of AI's field of view")]
public float VisionAngle = 120;
[Tooltip("The vision distance of AI.")]
public float VisionDistance = 10;
[Tooltip("The distance AI would be alerted if target is within, even if not visible or seen.")]
public float AlertDistance = 3;
[Tooltip("The distance AI is considered to have reached a target.")]
public float TargetReachedDistance = 3;
[Tooltip("If this Ghost will leave hand print when opening a door")]
public bool LeaveHandPrint = true;
[Tooltip("If this Ghost will leave foot print when opening a door")]
public bool LeaveFootPrint = true;
[Tooltip("Minimum number of foot steps to print on ground when leaving a foot print evidence trail")]
public int MinFootPrintsInTrail = 3;
[Tooltip("Maximum number of foot steps to print on ground when leaving a foot print evidence trail")]
public int MaxFootPrintsInTrail = 6;
[Tooltip("Time interval between sequential footprints")]
public float FootstepInterval = 0.86f;

Your AI implementation must handle the following:

  1. Mirage Visibility Change - HandleAIVisibilityChangeClient covers basics, override for additional logic

  2. Door Handling - Override OnAIDoorObstacleTick(Door obstacleDoor), recommended to cache the parameter Door and switch to door handling state

  3. Handle Request to Lose Player - Override OnLosePlayerTargetRequested() for logic, does not have to accept the request to lose player

Below are optional but recommended:

  1. React to HostMachine

    • Override OnHostMachineAttackedServer to handle response logic whenever a machine is attacked

    • Override OnHostMachineDestroyedServer to handle response logic whenever a machine is destroyed

Last updated