Ludum Dare 43 - Part 4

Making the Minotaur a (State) Machine - February 10, 2019 by Steve

Back Home

Minotaur_Maitre_D.png

In Part 3 I created all the elements of the game, including the Minotaur. However, I only gave the Minotaur a two dimensional brain in that he knew how to eat and wander the halls. I wanted to add some more excitement to the game, and that would mean having the Minotaur feel more feelings so to speak.

Since I'm recapping what I wrote, you're not getting the full editing history of the game. The version of the Minotaur you saw in Part 3 has the skeleton of the state machine that would ultimately control him. In reality at this point he had a single if statement or something along those lines. But don't feel cheated, that code was not very good anyway, and you're probably better off having not seen it. Plus, it only was that way for about 10 minutes due to the compressed time frame for the Ludum Dare.

What is a State Machine?

To give the Minotaur some depth, I went with a state machine. A state machine, simply put, is a series of states (Wandering, Eating, Chasing, etc.) and a bit of rules to transition between them. That's it really. Most of this happens in the Update() method with large switch statement. When we hit the Update() the switch statement basically says "Since we're in state X, do Y". Sometimes this means changing states, like when the Minotaur is in "Wander", and it checks to see if there is food on the altar, then the state changes to "Eat" after setting the new navigation target. I ended up with the six states below, and a pretty simple flow between them.

Waiting
Mintoaur is in the maze not moving
Wander
Minotaur is moving to a random point in the maze
Alarmed
Minotaur has seen the player and is moving slightly faster to the last place it saw the player
Chase
Minotaur sees the player and is actively moving faster to the player
Eat
Minotaur is heading to the altar to eat the offerings there
GameOver
Game is over, so the Minotaur shouldn't be doing anything

State Machine Diagram

The Waiting and GameOver states are special in that the state machine itself never goes to those states. When the Minotaur's reset() is called due to starting a game, or a new level the state machine is put into Waiting, once the maze has been fully generated and the game/level actually starts, the machine switches to Wander. The GameOver state is used when the Minotaur collides with the player, and the game is over. It's a special state that just sits there doing nothing. The navigation target is set to itself, to stop movement at this time as well.

There are two ways out of Wander: if there is food on the altar, or if the Minotaur sees the player. Since both of these checks happen in multiple places, they get handled in the checkSeePlayer(activeState) and shouldEat(activeState) methods.

    private bool shouldEat(Activity currentState)
    {
        if (_altarController.hasOffering &&
            _altarController.offeringValue >= offeringThreshold)
        {
            if (currentState != Activity.Eat)
            {
                if (tooCloseToAltar())
                {
                    return false;  // if we won't trip the collider because we're too close, don't eat yet
                }
                // Don't need to set the target if we're already set to Eat
                Debug.Log("Nom Nom Nom!");
                spriteRenderer.color = baseColor;
                ai.maxSpeed = baseSpeed;
                activeState = Activity.Eat;
                destinationSetter.target = altar.transform;
            }
            return true;
        }
        return false;
    }

The shouldEat() method does a few things. First we see if the altar has an offering and if the offering count is higher than some threshold. For the version of the game I ended up submitting, this was always 1, but in theory higher levels could require more than one offering before the Minotaur would consider heading over to it. If the Minotaur is not already in Activity.Eat there is a check to see if the Minotaur is on top of the altar already. This was needed because the collider on the altar only trips when the Minotaur enters it, and at that point it checks to see if the current state is Activity.Eat. Switching to Activity.Eat while in the collider does not re-trip the collider, so it never eats the offerings and just sits there for ever. By having a method that short circuits this the Minotaur will stay in the Activity.Wander state, and move away from the altar for a bit. Otherwise, if the Minotaur is far enough away from the altar, and there is an offering on it, the state gets changed to Activity.Eat and it starts to move to the altar at the baseSpeed.

private bool checkSeePlayer(Activity currentState)
    {
        if (seePlayer)
        {
            if (currentState == Activity.Wander ||
                currentState == Activity.Alarmed ||
                currentState == Activity.Eat)
            {
                interruptedTargetPosition = destinationSetter.target.transform.position;
                destinationSetter.target = player.transform;
                activeState = Activity.Chase;
                spriteRenderer.color = chaseColor;
                ai.maxSpeed = baseSpeed * chaseSpeedModifier;
            }

            return true;
        }
        else
        {
            if (currentState == Activity.Chase)
            {
                WanderTarget.transform.position = lastPlayerPosition;
                destinationSetter.target = WanderTarget.transform;
                activeState = Activity.Alarmed;
                cooldownTime = 0f;
                spriteRenderer.color = alarmedColor;
                ai.maxSpeed = baseSpeed * (.75f * chaseSpeedModifier);
            }
            return false;
        }
    }

The checkSeePlayer() method relies on the seePlayer boolean which gets set in the FixedUpdate() method. There a simple Physics2D.Raycast() between the player and the Minotaur is performed and if there isn't a wall between them and the player is within a certain range, the Minotaur moves out of Wander, Alarmed, or Eat and into Chase. The previous navigation target is stored in the interruptedTargetPosition variable so that if the Minotaur does not catch the player, it'll resume wandering to wherever it was going before. The navigation system is then told to chase the player, and the Minotaur speeds up. I decided it would be nice to see a bit of feedback on what state the Minotaur is in, so we switch the sprite to the chaseColor which I picked to be red.

If the Minotaur is already in Chase but no longer sees the player, it will slow down a bit and change to a reddish orange color, but continue to the last seen player position.

Everything else is handled in the Update() method with the following switch statement.

            switch (activeState)
            {
                case Activity.GameOver:
                    WanderTarget.transform.position = transform.position;
                    destinationSetter.target = WanderTarget.transform;
                    break;

                case Activity.Waiting: 
                    // We're still initializing, once everything is set, we'll start wandering.
                    if (altar && player && maze 
                        && !GameController.instance.isWaitingForNewLevel() 
                        && GameController.instance.isRunning())
                    {
                        _altarController = altar.GetComponent<AltarController>();
                        activeState = Activity.Wander;
                        firstCall = true;
                    }
                    break;

                case Activity.Wander:
                    if (!checkSeePlayer(activeState) &&
                        !shouldEat(activeState) &&
                        (!ai.pathPending 
                            && (ai.reachedDestination || !ai.hasPath)))
                    {
                        WanderTarget.transform.position = maze.getRandomMazePoint();
                        destinationSetter.target = WanderTarget.transform;
                    }
                    break;

                case Activity.Chase:
                    checkSeePlayer(activeState); // no logic other than what is in the function
                    break;

                case Activity.Alarmed:
                    if (checkSeePlayer(activeState))
                    {
                        break;
                    }

                    // if we haven't seen the player, wait till we get to where we last did before starting the cool down.
                    if (!ai.pathPending 
                        && (ai.reachedDestination 
                            || !ai.hasPath 
                            || Vector3.Distance(lastPlayerPosition, transform.position) < 0.6f ))
                    {
                        cooldownTime += Time.deltaTime;
                        if (cooldownTime > alarmCooldown &&
                            !shouldEat(activeState)) // we'll go eat if we are supposed to
                        {
                            // go back to wander
                            activeState = Activity.Wander;
                            ai.maxSpeed = baseSpeed;
                            spriteRenderer.color = baseColor;
                            WanderTarget.transform.position = interruptedTargetPosition;
                            destinationSetter.target = WanderTarget.transform;
                        }
                    }

                    break;

                case Activity.Eat:
                    if (!checkSeePlayer(activeState) &&
                        !shouldEat(activeState))
                    {
                        // If we're not going to chase the player, and we've finished eating (i.e. got to the altar 
                        // and removed offerings.... go back to Wander
                        activeState = Activity.Wander;
                        WanderTarget.transform.position = maze.getRandomMazePoint();
                        destinationSetter.target = WanderTarget.transform;
                    }
                    break;
            }

The ordering of the various if statements is important. For example both the case Activity.Eat: and case Activity.Wander: have the !checkSeePlayer(ActiveState) && as the first argument of the if statement because if we see the player, the Minotaur should immediately switch to Activity.Chase and short circuit the rest of the logic. Most of the logic is pretty self explanatory:

  • If the Minotaur is in Wander and it does not see the player and there is nothing to Eat, then pick a new destination if it reached its old destination.
  • If the Minotaur is in Eat, and shouldEat() comes back false (i.e. Minotaur has reached the altar and eaten), then switch back to Wander
  • If the Minotaur is in Chase, do whatever checkSeePlayer(activeState) says to do

The tricky one is Alarmed because I wanted the Minotaur to reach the last place the player was and linger for a moment, as if he was looking around for the player. So once the Minotaur reaches the last known player position a cool down timer is started, after which the Minotaur either goes to Eat or resumes Wandering to the previous Wander target at the normal speed.

The only other thing I did to the Minotaur's behavior is make it so that after a set amount of time the Minotaur speeds up. This is accomplished in the Update() method after the switch statement.

if (!GameController.instance.isWaitingForNewLevel() && GameController.instance.isRunning())
        {
            if (firstCall)
            {
                timeSinceSpeedup = 0;
                firstCall = false;
            }
            else
            {
                timeSinceSpeedup += Time.deltaTime;
                if (timeSinceSpeedup > secondsToSpeedup)
                {
                    Debug.Log("Speedingup");
                    baseSpeed += .5f;
                    if (activeState == Activity.Wander || activeState == Activity.Eat)
                    {
                        ai.maxSpeed = baseSpeed;
                    }
                    timeSinceSpeedup = 0;
                }
            }    
        }

Since I did not want the thing speeding up right away, when the state machine moves out of Activity.Waiting the firstCall flag gets set to true indicating it's the first real time through the loop. That just resets the speed up timer rather than applying the speed up. We'll get to overall game flow and control in Part 5.

The full series: