Skip to content

Latest commit

 

History

10 Commits

Folders and files

NameName
Last commit message
Last commit date
 
 
 
 
 
 
 
 

Repository files navigation

🎭 Act

Status: Alpha License

A game design pattern for creating and managing complex behaviors with parallelism at its core.

⚙️ Game Engine Specific

Need a port for a different game engine? Open a feature request to let the developer know.

💡 Principle

The entire pattern revolves around Acts. Each act is the smallest self-contained unit of behaviour that can be performed in the game e.g. Walk Act, Run Act, Jump Act, Reload Act, Shoot Act, etc.

Since every Act is self-contained they can all perform in parallel. However, when acts conflict (e.g. Walk Act & Run Act) or depend on other acts (e.g. Reload Act & Shoot Act) there are 2 mechanisms to resolve these: Blocking & Prologuing

1. Blocking

Each act can block certain other acts when it performs. An act that is blocked cannot perform nor block any other act until it is unblocked. There are 2 types of blocks:

  • Persistent Block: This type of block remains persistent on the blockee till the blocker act has completed its perform.
    e.g. If Sword Slash Act persistent blocks Move Act then the player cannot move till the sword slash has completed.

  • Interrupt Block: This type only interrupts the blockee act's perform when the blocker act starts its perform. The blockee act can then be performed again even if the blocker act is still performing.
    e.g. If Reload Act interrupt blocks Aim Act then aiming will stop when reloading starts but the player can press aim again mid reloading to cancel and start aiming again.

2. Prologuing

Each act can have prologue acts i.e. acts that need to be performed before the main act can perform.
In example 1, the arrow pointing from an act denotes "who is my prologue" therefore:

  • B is a prologue of A
  • C is a prologue of B
  • D is a prologue of C

and as such, first D performs then C then B then finally A.
prologue chain example

Also keep in mind that no acts within the same prologue chain can block each other.

Terminology Note:

  1. Acts that come before an act are called prologue acts (e.g. B is a prologue of A)
  2. Acts that come after an act are called epilogue acts (e.g. C is an epilogue of D)
  3. Acts that are at the top of the chain are called top epilogue acts (e.g. A is a top epilogue of B, C, D)
An act can have more than one prologue

In such a case both prologues work in parallel and only when both have finished does the act perform. So in example 2, first C & D perform together in parallel then B then A.
prologue branched example

An act can have more than one epilogue

In such a case both epilogues will wait for the prologue to complete first before performing themselves. So in example 3, first D will perform then C will perform then A & B will perform together in parallel.
prologue merging example

Cyclic prologues are not allowed

An act cannot be a prologue of itself or of any descendants. Therefore example 4 is invalid.
prologue cyclic incorrect example

🧭 Usage

Note:
All examples are in Unity but the same concepts applies to other engines as well.
For examples in other engines take a look at Game Engine Specific Implementations

It is also recommended that you go through the documentation first for your desired engine specific implementation.

Creating your first act

Create a class inheriting from Act class and override the Enter() method like this:

public class MyFirstAct : Act
{
    protected override Outcome Enter()
    {
        Debug.Log("Hello World!");
        return Outcome.Success;
    }
}

Then initialize it and use it by invoking Perform() from wherever you'd like:

void Awake()
{
    // Initialize
    MyFirstAct myFirstAct = new();
    myFirstAct.Init();
    

    // Use
    myFirstAct.Perform();
}

That's it! Congrats you've just created & performed your first act.

How to use an act

The act follows this perform lifecycle:

act lifecycle

You can implement the desired behaviour by overriding these methods:

  1. Setup(): Where your one time initialization setup logic lives.
  2. CanPerform(): Define conditions that allow/disallow performing
  3. Enter(): Where the actual core logic lives on each perform. You have the following return options:
    • Outcome.Success: Return this if core behaviour was successfully completed.
    • Outcome.Pending: Return this if you don't want to immediately exit.
    • Outcome.Failure: Return this if core behaviour failed to complete.
    • Outcome.Retry: Return this if you want the act to perform again.
  4. Tick()/PhysicsTick()/LateTick(): Incase the core logic needs continuous ticking updates while performing
  5. Exit(): Used for cleanup after core logic on each perform
  6. Cleanup(): Where your deinitialization teardown logic lives.

You can also assign these properties in Setup() to further finetune your behaviour:

  1. _canReperform: Set true if act is allowed to perform again while already ongoing.
  2. _tickFlags: The type of ticking (if any) while performing
Ticking an act

To make an act tick you need to do 3 things:

  1. Make sure the act has been assigned a theater
  2. Assign _tickFlags in setup
  3. Return Outcome.Pending in Enter()
public class MyTickAct : Act
{
	// Private
	private int _tickCounter = 0;

	protected override void Setup()
	{
		_tickFlags = TickFlags.Tick;
	}
    protected override Outcome Enter(){
        return Outcome.Pending;
    }
	protected override Outcome Tick()
	{
        // Tick 5 times then exit
		_tickCounter++;
		if (5 <= _tickCounter)
		{
			return Outcome.Success;
		}

		return Outcome.Pending;
	}
}
Delaying exit in act

To delay exit without using tick for something like a timer simply return Outcome.Pending and make sure no tick flag is assigned.

public class MyTimerAct : Act
{
    private Coroutine waitCoroutine;

    private IEnumerator WaitRoutine()
    {
        yield return new WaitForSeconds(5.0f);  // Wait for 5 seconds then exit 
        Finish(Outcome.Success);
    }
    protected override Outcome Enter()
    {
        waitCoroutine = GetTheater().StartCoroutine(WaitRoutine());
        return Outcome.Pending;
    }
    protected override void Exit()
    {
        if (waitCoroutine != null)
        {
            GetTheater().StopCoroutine(waitCoroutine);
            waitCoroutine = null;
        }
    }
}
Blocking in act

You can assign which acts to block as such:

void Awake()
{
    // Initialize
    MyAct myAct = new();
    myAct.Init();

    MyBlockingAct myBlockingAct = new();
    myBlockingAct.AddToBlock(new() { myAct });
    myBlockingAct.Init();
    

    // Use
    myBlockingAct.Perform();
    myAct.Perform();  // Will fail till myBlockingAct perform has completed
}

You can also directly disable an act:

void Awake()
{
    // Initialize
    MyAct myAct = new();
    myAct.Init();


    // Use
    myAct.SetEnabled(false);
    myAct.Perform();  // Will fail since act is disabled

    myAct.SetEnabled(true);
    myAct.Perform();  // Will work since act has been re-enabled
}
Prologuing in act

You can assign which acts to prologue as such:

void Awake()
{
    // Initialize
    MyAct myAct = new();
    myAct.Init();

    MyMainAct myMainAct = new();
    myMainAct.prologue = (Act act) => new() { myAct };
    myMainAct.Init();
    

    // Use
    myMainAct.Perform();  // This will perform myAct first then myMainAct
}

If you have multiple acts you want to chain in prologue sequence you can also use Seq() as such:

void Awake()
{
    // Initialize
    Act actA = new();
    actA.Init();

    Act actB = new();
    actB.Init();

    Act actC = new();
    actC.Init();

    Act actD = new();
    actD.Init();

    MyMainAct myMainAct = new();
    myMainAct.prologue = (Act act) => Act.Seq(new() { 
        new() { actA }, 
        new() { actB, actC }, 
        new() { actD } 
    });
    myMainAct.Init();
    

    // Use
    myMainAct.Perform();  // Order of perform: actA -> actB & actC (in parallel) -> actD
}
Using a theater

A Theater can be used if you want to organize & manage your acts together.

[SerializeField] Theater theater;

void Awake()
{
    // Initialize
    Act actA = new();
    actA.Init("My Act A", theater);

    Act actB = new();
    actB.Init("My Act B", theater);
    

    // Uses
    theater.SetEnabled(false);  // If a theater is disabled all it's acts will be disabled as well
    theater.IsEnabled();
    theater.AbortAll();
    theater.AreAnyOngoing();
    theater.GetAllActs();
}

🗺️ Example

Top down game shooter player:

public class Player : MonoBehaviour
{
    // Act Properties
    [SerializeField] Theater theater;
    [SerializeField] MoveAct moveAct = new();
    [SerializeField] LookAct aimAct = new();
    [SerializeField] ShootAct shootAct = new();


    // Override Methods
    void Update()
    {
        // Move
        float horizontalInput = Input.GetAxisRaw("Horizontal");
        float verticalInput = Input.GetAxisRaw("Vertical");
        moveAct.direction = new Vector2(horizontalInput, verticalInput).normalized;
        moveAct.Perform();


        // Aim towards mouse pointer
        Vector3 mouseWorldPosition = Camera.main.ScreenToWorldPoint(Input.mousePosition);
        aimAct.targetPosition = mouseWorldPosition;
        aimAct.Perform();


        // Shoot
        if (Input.GetMouseButtonDown(0))
        {
            Vector2 mouseWorldPos2D = mouseWorldPosition;
            Vector2 playerPos2D = transform.position;
            shootAct.direction = (mouseWorldPos2D - playerPos2D).normalized;
            shootAct.Perform();
        }
    }
    void Awake()
    {
        // Setup acts
        theater = GetComponent<Theater>();
        moveAct.Init("Move Act", theater);
        aimAct.turnSpeed = -1.0f;  // Instant turn
        aimAct.Init("Aim Act", theater);
        shootAct.Init("Shoot Act", theater);
    }
}
Move Act Class
[Serializable]
public class MoveAct : Act
{
    // Public Properties
    [SerializeField] public float speed = 5f;
    [HideInInspector] public Vector2 direction = new();


    // Private Properties
    private Transform ownerTransform;


    // Override Methods
    protected override void Setup()
    {
        ownerTransform = GetOwner().transform;
    }
    protected override bool CanPerform()
    {
        return ownerTransform != null;
    }
    protected override Outcome Enter()
    {
        ownerTransform.position = (Vector2)ownerTransform.position + direction * speed * GetDelta();
        return Outcome.Success;
    }
    protected override void Exit()
    {
        direction = Vector2.zero;
    }
}
Look Act Class
[Serializable]
public class LookAct : Act
{
    // Public Properties
    [SerializeField] public float turnSpeed = 150f;  // Negative = instant turn
    [SerializeField] public float acceptanceAngle = 0.5f;
    [HideInInspector] public Vector2 targetPosition = new();
    [HideInInspector] public Transform targetTransform = null;


    // Private Transform
    Transform ownerTransform;

 
    // Override Methods
    protected override void Setup()
    {
        _canReperform = true;
        _tickFlags = TickFlags.PhysicsTick;  // Enable ticking
        ownerTransform = GetOwner().transform;
    }
    protected override Outcome PhysicsTick()
    {
        // Get rotation
        Vector2 finalPosition = targetTransform != null ? (Vector2)targetTransform.position : targetPosition;
        Vector2 direction = finalPosition - ownerTransform.position;
        float goalRotation = Mathf.Atan2(direction.y, direction.x) * Mathf.Rad2Deg;


        // Rotate owner
        float currentRotation = ownerTransform.eulerAngles.z;
        float newRotation = turnSpeed < 0f ? goalRotation : Mathf.MoveTowardsAngle(currentRotation, goalRotation, turnSpeed * GetPhysicsDelta());
        ownerTransform.rotation = Quaternion.Euler(0f, 0f, newRotation);


        // Exit if reached goal rotation
        float angleDiff = Mathf.Abs(Mathf.DeltaAngle(currentRotation, goalRotation));
        return angleDiff <= acceptanceAngle ? Outcome.Success : Outcome.Pending;
    }
}
Shoot Act Class
[Serializable]
public class ShootAct : Act
{
    // Public Properties
    [SerializeField] public GameObject projectilePrefab;
    [HideInInspector] public Vector2 direction = new();


    // Override Methods
    protected override bool CanPerform()
    {
        return projectilePrefab != null;
    }
    protected override Outcome Enter()
    {
        var spawnPosition = GetOwner().transform.position;
        GameObject projectileObj = MonoBehaviour.Instantiate(
            projectilePrefab, 
            spawnPosition, 
            Quaternion.identity
        );
        Projectile projectile = projectileObj.GetComponent<Projectile>();
        projectile.direction = direction;
        return Outcome.Success;
    }
    protected override void Exit()
    {
        direction = Vector2.zero;
    }
}

Top down web shooting spider enemy AI:

public class ShooterSpider : MonoBehaviour
{
    // Act Properties
    [SerializeField] Theater theater;
    [SerializeField] PerpetualAct liveAct = new();
    [SerializeField] GotoAct wanderAct = new();
    [SerializeField] WaitAct waitAct = new();
    [SerializeField] LookAct lookAct = new();
    [SerializeField] LookAct aimAct = new();
    [SerializeField] ShootAct shootWebAct = new();
    [SerializeField] DamageAct damageAct = new();


    // Override Methods
    void Awake()
    {
        // Get player transform
        Transform playerTransform = GameObject.FindGameObjectWithTag("Player").transform;
        
        
        // Setup Acts
        theater = GetComponent<Theater>();

        liveAct.prologue = (Act act) =>
        {
            // Get random position
            Vector2 randomOffset = UnityEngine.Random.insideUnitCircle * 100;
            Vector2 randomPosition = (Vector2)transform.position + randomOffset;
            wanderAct.targetPosition = randomPosition;
            lookAct.targetPosition = randomPosition;

            // Wander -> aim -> shoot -> wait
            return Act.Seq(new() {
                new() { wanderAct, lookAct },
                new() { aimAct },
                new() { shootWebAct },
                new() { waitAct }
            });
        };
        liveAct.Init("Live Act", theater);

        wanderAct.Init("Wander Act", theater);

        waitAct.duration = 2f;
        waitAct.Init("Wander Wait Act", theater);

        lookAct.turnSpeed = 200f;
        lookAct.Init("Look Act", theater);

        aimAct.turnSpeed = 200f;
        aimAct.targetTransform = playerTransform;
        aimAct.Init("Aim Act", theater);

        shootWebAct.OnPreEnter += (Act act) =>
        {
            Vector2 spiderPosition = transform.position;
            Vector2 playerPosition = playerTransform.position;
            shootWebAct.direction = (playerPosition - spiderPosition).normalized;
        };
        shootWebAct.Init("Shoot Web Act", theater);

        damageAct.AddToBlock(new() { liveAct });  // Stop AI behaviour while damaged
        damageAct.Init("Damage Act", theater);
    }
}
Perpetual Act Class
[Serializable]
public class PerpetualAct : Act
{
    protected override void Setup()
    {
        _canReperform = true;
        PerformDeferred();
    }
    protected override void Exit()
    {
        PerformDeferred();
    }
    protected override void UnblockSelf(Act byAct)
    {
        base.UnblockSelf(byAct);
        if (!IsBlocked())
        {
            PerformDeferred();
        }
    }
}
Goto Act Class
[Serializable]
public class GotoAct : Act
{
    // Public Properties
    [SerializeField] public float speed = 5f;
    [SerializeField] public float acceptanceRadius = 1f;
    [HideInInspector] public Vector2 targetPosition = new();
    [HideInInspector] public Transform targetTransform = null;


    // Private Properties
    private Transform ownerTransform;


    // Override Methods
    protected override void Setup()
    {
        _tickFlags = TickFlags.PhysicsTick;  // Enable ticking
        ownerTransform = GetOwner().transform;
    }
    protected override Outcome PhysicsTick()
    {
        Vector2 destination = targetTransform != null ? (Vector2)targetTransform.position : targetPosition;
        Vector2 currentPosition = ownerTransform.position;
        float distance = Vector2.Distance(currentPosition, destination);


        // Exit if already within range of target
        if (distance <= acceptanceRadius)
        {
            return Outcome.Success;
        }


        // Move towards destination
        Vector2 direction = (destination - currentPosition).normalized;
        Vector2 nextPosition = currentPosition + direction * speed * GetPhysicsDelta();
        ownerTransform.position = nextPosition;
        return Outcome.Pending;
    }
}
Wait Act Class
[Serializable]
public class WaitAct : Act
{
    // Public Properties
    [SerializeField] public float duration = 5f;


    // Private Properties
    private Coroutine waitCoroutine;


    // Private Methods
    private IEnumerator WaitRoutine()
    {
        yield return new WaitForSeconds(duration);
        Finish(Outcome.Success);
    }


    // Override Methods
    protected override Outcome Enter()
    {
        waitCoroutine = GetTheater().StartCoroutine(WaitRoutine());
        return Outcome.Pending;
    }
    protected override void Exit()
    {
        if (waitCoroutine != null)
        {
            GetTheater().StopCoroutine(waitCoroutine);
            waitCoroutine = null;
        }
    }
}
Damage Act Class
[Serializable]
public class DamageAct : Act
{
    // Public Properties
    [SerializeField] public float stunTime = 0.5f;


    // Private Properties
    private Coroutine stunCoroutine;


    // Private Methods
    private IEnumerator StunRoutine()
    {
        yield return new WaitForSeconds(stunTime);
        Finish(Outcome.Success);
    }


    // Override Methods
    protected override Outcome Enter()
    {
        // Damage logic here
        // ... 


        // Stun
        stunCoroutine = GetTheater().StartCoroutine(StunRoutine());
        return Outcome.Pending;
    }
    protected override void Exit()
    {
        if (stunCoroutine != null)
        {
            GetTheater().StopCoroutine(stunCoroutine);
            stunCoroutine = null;
        }
    }
}

❤️ Sponsors

If this has been useful in your projects consider supporting its development.
Any support motivates to keep the project well maintained, documented and growing.

🔑 License

MIT © Manas Ravindra Makde

About

A game design pattern for creating and managing complex behaviours with parallelism at its core.

Topics

Resources

Stars

0 stars

Watchers

0 watching

Forks

Sponsor this project

Contributors