Memory Match Game in Unity3D – Part 2

UPDATE: ALL THESE AND MORE CAN BE FOUND ON MY NEW SITE http://www.returnofthebrain.co.uk/

We ended the previous tutorial with a card and gamestate model that allows us to keep track of the state of our cards and our game progress, as well as a visual game object to represent our card.

flippy

This time we’re going to focus on the management objects which will control our card grid, card textures, and progress the game state.

CardTexturesManager

Before we can generate our game grid, we’re going to need to be able to load the textures we want to use on the front of our cards and apply them to the card views. If you remember from part 1, we gave the card views the ability to take a Texture2D and apply it. Here, we will write the script that will supply those textures to the code that generates the cards for our game board. This script also gives us the ability to load textures for our card backs in the same way.

We want to be able to access this code from anywhere, as we may also need it when designing the menu interface, additionally it doesn’t require a game object, so it seemed best to use the singleton pattern to access it. This allows us to load the textures just once, but access them from anywhere in the application.

For this to work, first add a few variables to our GameVars class.

    // Card texture vars
    public const string CardSpriteLocation = "textures/cards/";
    public const string DefaultCardBack = "cardBack_";
    public const string DefaultCardFace = "card_";
    public const int numDecks = 6;
    public const int numFaces = 15;

CardSpriteLocation represents the location of our card textures within the ‘Resources’ folder.

DefaultCardBack and DefaultCardFace represents the filename of our texture files, we will need to prepend a number in order to complete the filename.

Lastly, ‘numDecks’ and ‘numFaces’ should be the number of card back and card face textures you actually have sitting in that folder.

The card texture manager itself is relatively straight-forward. It maintains two lists of textures, which we can access by calling GetFaceTexture or GetDeckTexture and supplying an index representing which texture we require.

using UnityEngine;
using System.Collections;
using System.Collections.Generic;

public class CardTexturesManager
{
    public static CardTexturesManager _instance;
    public static CardTexturesManager GetInstance()
    {

        if (_instance == null)
        {
            _instance = new CardTexturesManager();
            _instance.Initialize();
        }

        return _instance;
    }

    private List faceTextures;
    private List deckTextures;

    private int maxFaceTextures = GameVars.numFaces;

    public CardTexturesManager()
    {

    }

    private void Initialize()
    {

        LoadFaceTextures(maxFaceTextures);
        LoadDeckTextures();
    }

    public Texture2D GetFaceTexture(int faceIndex)
    {
        if (faceTextures == null || faceIndex < 0 || faceIndex > maxFaceTextures)
        {
            Debug.LogError("Error: Cannot get face texture for index: " + faceIndex);
            return null;
        }

        return faceTextures[faceIndex];
    }

    public int GetNumDeckTextures()
    {
        if (deckTextures != null)
            return deckTextures.Count - 1;

        return 0;
    }

    public Texture2D GetDeckTexture(int deckIndex)
    {
        if (deckTextures == null || deckIndex < 0 || deckIndex > deckTextures.Count - 1)
        {
            Debug.LogError("Error: Cannot get deck texture for index: " + deckIndex);
            return null;
        }

        Debug.Log("Returning Deck Texture: " + deckIndex);

        return deckTextures[deckIndex];
    }

    private void LoadDeckTextures()
    {
        deckTextures = new List();

        int count = GameVars.numDecks;
        string path = GameVars.CardSpriteLocation + GameVars.DefaultCardBack;

        for (int i = 0; i < count + 0; i++)
        {
            string spriteString = path + i.ToString();
            Texture2D t2d = Resources.Load(spriteString) as Texture2D;
            deckTextures.Add(t2d);
        }
    }

    private void LoadFaceTextures(int required)
    {

        if (required < 1 || required > maxFaceTextures)
        {
            Debug.LogError("Error: Cannot load face textures.");
            return;
        }

        faceTextures = new List();
        int count = GameVars.numFaces;
        string path = GameVars.CardSpriteLocation + GameVars.DefaultCardFace;

        for (int i = 1; i < count + 1; i++)
        {
            string spriteString = path + i.ToString();
            Texture2D t2d = Resources.Load(spriteString) as Texture2D;
            faceTextures.Add(t2d);
        }
    }
}
</code></pre>
<strong>CardGridManager</strong>

We again need to add some values to our GameVars class. We need a root object to parent our game board to, and it will be useful to know the name of this object so we'll define it here. Then, we need to be able to control the spacing between cards in both the X and Y axis.
<pre><code>
    // Gameobject Root
    public const string RootObjectName = "_GameRoot";

    // Card spacing 
    public const float CardXSpacing = 0.1f;
    public const float CardYSpacing = 0.1f;

  The CardGridManager is a little more involved. It’s responsible for setting up our game board by creating instances of our card view class. In order to do this, it will require a GameStateModel that we will create later in our GameManager class. First thing the grid manager does is pull all the values it needs from the game state, and calculate the total deck size, number of cards required to match, etc. Then it creates the card models that will represent our ‘deck’ of cards.

public class CardGridManager
{
    public static CardGridManager _instance;
    public static CardGridManager GetInstance()
    {

        if (_instance == null)
        {
            _instance = new CardGridManager();
            _instance.Initialize();
        }

        return _instance;
    }

    // vars
    private CardView[] cardViews;
    private int grid_x, grid_y;

    // root transform
    Transform rootTransform;

    private void Initialize()
    {
    }

    // Initialization
    public void CreateCardGrid(GameStateModel state)
    {
        if (state.matchLength == 0 || state.grid_x == 0 || state.grid_y == 0)
        {
            Debug.LogError("Cannot create card grid, state not initialized.");
            return;
        }

        int matchLength = state.matchLength;
        grid_x = state.grid_x;
        grid_y = state.grid_y;
        int deckSize = grid_x * grid_y;
        int faceTypesCount = (deckSize % matchLength);

        if (faceTypesCount > 0)
        {
            Debug.LogError("Decksize is not divisible by MatchLength, cannot create deck.");
            return;
        }

        // instantiate the root object
        GameObject root = GameObject.Find(GameVars.RootObjectName);
        if (root == null)
        {
            Debug.LogError("Cannot find root object.");
            return;
        }
        rootTransform = root.transform;

        // position the root object
        CenterRootObject(state);

        // create the card models
        CardModel[] cardModels = new CardModel[deckSize];
        int faceType = 1;

        // for each face type
        for (int i = 0; i < deckSize; i += matchLength)
        {
            // create that many cards
            for (int j = 0; j < matchLength; j++)
            {
                int index = i + j;
                cardModels[index] = new CardModel();
                cardModels[index].FaceValue = faceType;
            }
            faceType++;
        }

        state.cards = cardModels;
    }

The next thing we do is shuffle the deck of cards. I’ve used a Fisher-Yates shuffle algorithm. This seemed like a simple shuffle algorithm to implement, and there are plenty of sources that can explain it better than I’d be able to.


    public void ShuffleCardGrid(GameStateModel state)
    {
        // use fisher-yates shuffle to re-arrange the array
        CardModel[] cards = state.cards;
        int n = cards.Length;
        System.Random rand = new System.Random();

        for (int i = 0; i < n; i++)
        {
            int r = i + (int)(rand.NextDouble() * (n - i));
            CardModel temp = cards[r];
            cards[r] = cards[i];
            cards[i] = temp;
        }

        state.cards = cards;
    }

The last part of our grid setup process is creating the card views. First, we create one to match each of the card models created previously, the next function positions those cards based on their size and the spacing values we placed into our GameVars class. Lastly we position our root object in order to move our game board into the center of the screen. This is done by using the same values to calculate the width and height of the game board, then offsetting the root object by half of that. Finally, we loop through all of our card views and assign the appropriate face texture.

    // Initialize the card view objects and face textures
    public void InitializeGridCardViews(GameStateModel state)
    {

        CardModel[] cardModels = state.cards;
        cardViews = new CardView[cardModels.Length];

        Vector3 origin = new Vector3(0f, 0f, 0f);

        for (int x = 0; x < grid_x; x++)
        {
            for (int y = 0; y < grid_y; y++)
            {
                GameObject cardObj = new GameObject("card");
                int idx = IdxForGridPos(x, y);
                CardView cardView = cardObj.AddComponent();
                cardViews[idx] = cardView;
                cardView.refIdx = idx;
                cardView.transform.SetParent(rootTransform);
                cardView.transform.localPosition = origin + new Vector3(x, y, 0);
            }
        }
    }

    public void SetCardSpacing(GameStateModel state)
    {
        float xSpacing = GameVars.CardXSpacing;
        float ySpacing = GameVars.CardYSpacing;
        float yMultiplier = GameVars.CardAspect;

        Vector3 origin = new Vector3(0f, 0f, 0f);

        for (int x = 0; x < grid_x; x++)
        {
            for (int y = 0; y < grid_y; y++)
            {
                int idx = IdxForGridPos(x, y);
                Transform t = cardViews[idx].transform;
                Vector3 position = new Vector3(
                    x + x * xSpacing,
                    y * yMultiplier + y * ySpacing,
                    0f);
                t.localPosition = origin + position;
            }
        }
    }

    public void CenterRootObject(GameStateModel state)
    {
        // work out the width of the grid and the height of the grid
        //  position the grid based on this offset

        float cardWidth = GameVars.CardWidth;
        float boardWidth = cardWidth * grid_x;
        float xSpacing = GameVars.CardXSpacing * (grid_x - 1);
        float xOffset = (boardWidth / 2) + (xSpacing / 2) - (cardWidth / 2);

        float cardHeight = GameVars.CardWidth * GameVars.CardAspect;
        float boardHeight = cardHeight * grid_y;
        float ySpacing = GameVars.CardYSpacing * (grid_y - 1);
        float yOffset = (boardHeight / 2) + (ySpacing / 2) - (cardHeight / 2);

        Vector3 position = rootTransform.localPosition;
        position.x = -xOffset;
        position.y = -yOffset;
        rootTransform.localPosition = position;
    }

    public void SetCardTextures(GameStateModel state)
    {

        // Give cards the face textures associated with their face type
        CardModel[] cards = state.cards;
        int deckSize = cards.Length;

        for (int i = 0; i < deckSize; i++)
        {
            int faceValue = cards[i].FaceValue;
            Texture2D faceTex = CardTexturesManager.GetInstance().GetFaceTexture(faceValue);
            cardViews[i].SetFaceTexture(faceTex);
        }
    }

</code></pre>
I've also written a couple of utility functions that we can use. SetCardViewsMatched will lock a set of card views after a successful match, so that they remain face-up.

The next two functions help make it a bit easier to locate specific cards. IdxForGridPos gives us the card index for the requested grid position, and GridPositionForIdx does the reverse.
<pre><code>    // Utilities
    public void SetCardViewsMatched(int[] indexes)
    {
        for (int i = 0; i < indexes.Length; i++)
        {
            SetCardViewMatched(indexes[i]);
        }
    }

    public void SetCardViewMatched(int idx)
    {
        cardViews[idx].SetMatched();
    }

    public int IdxForGridPos(int x, int y)
    {
        return y + (x * grid_y);
    }

    public Vector2 GridPositionForIdx(int idx)
    {
        int x = (int)(idx / grid_x);
        int y = (int)(idx % grid_x);
        return new Vector2(x, y);
    }

    public CardView GetCardViewForGridPosition(int x, int y)
    {
        return cardViews[IdxForGridPos(x, y)];
    }
}

The last set of functions are used to produce the animation that will give players a quick peek at the cards before the game begins. The first two functions simply loop through all of our cards and flip them up, or flip them down.

‘FlipAllUnmatchedCardsDown’ allows us to flip cards back down in the event of an failed match attempt. And PeekAnimation makes use of FlipAllCardsUp and FlipAllCardsDown to create our animation.

PeekAtCards is blank for the moment, we will use it to begin the PeekAnimation coroutine. But first, we’re going to write a utility script that will allow us to start coroutines from scripts that aren’t monobehaviors.


    // Animations
    public void FlipAllCardsUp()
    {
        if (cardViews == null)
        {
            Debug.LogError("Error: Cannot flip cards, CardGridManager not initialized!");
        }

        for (int i = 0; i < cardViews.Length; i++)
        {
            CardView cardView = cardViews[i];
            cardView.FlipUp();
        }
    }

    public void FlipAllUnmatchedCardsDown()
    {
        if (cardViews == null)
        {
            Debug.LogError("Error: Cannot flip cards, CardGridManager not initialized!");
        }

        for (int i = 0; i < cardViews.Length; i++)
        {
            CardView cardView = cardViews[i];
            if (!cardView.isMatched)
                cardView.FlipDown();
        }
    }

    public void FlipAllCardsDown()
    {
        if (cardViews == null)
        {
            Debug.LogError("Error: Cannot flip cards, CardGridManager not initialized!");
        }

        for (int i = 0; i < cardViews.Length; i++)
        {
            CardView cardView = cardViews[i];
            cardView.FlipDown();
        }
    }

    public void PeekAtCards(float delay, float peekDuration)
    {

    }

    IEnumerator PeekAnimation(float delay, float peekDuration)
    {
        yield return new WaitForSeconds(delay);

        CardGridManager cgm = CardGridManager.GetInstance();
        cgm.FlipAllCardsUp();

        yield return new WaitForSeconds(peekDuration);

        cgm.FlipAllCardsDown();
    }

To implement our card peek animation, we could write a separate script, instantiate a game object, place the script on that object and then call on it. But this is just pointless extra steps frankly… so instead, we’re going to create a utility script that allows us to call coroutines from non-monobehavior scripts. Luckily, this is a very simple script. All it does is take away the minor annoyance of having to create an object every time. It should be noted, this should only be used for coroutines that you don’t really care about needing to be able to stop.

Using a simple singleton class that simply attaches itself to a dummy object and returns itself as a static instance, we can trigger coroutines from anywhere in our game.

public class CoroutineSingleton : MonoBehaviour {

	// A simple dummy object for running coroutines from non-monobehaviour classes

	public static CoroutineSingleton _instance;
	public static CoroutineSingleton GetInstance() {

		if (_instance == null) {
			Initialize ();
		}

		return _instance;
	}

	static void Initialize () {
		GameObject dummy = new GameObject ("coroutine_dummy");
		_instance = dummy.AddComponent ();
	}

}

Now that we have a nice simple way of starting our peek animation from within the card grid manager, we can implement ‘PeekAtCards’ properly.

    public void PeekAtCards(float delay, float peekDuration)
    {
        CoroutineSingleton.GetInstance().StartCoroutine(PeekAnimation(delay, peekDuration));
    }

GameManager

The game manager class will be responsible for beginning and ending our game, and updating the game state to reflect the results of the players match attempts. Again, we will first need to update our GameVars class.

This time, as well as some variables to control the length of the peek animation, we will store a static instance of the game manager script itself, so that other objects will be able to access it.

    // Current Game Manager instance
     public static GameManager GameManager { get; set; }

    // Animation Durations
    public const float PeekDuration = 1.0f;
    public const float FlipCheckDuration = 0.5f;

The first three functions in the game manager class form its startup routine.

‘Initialize’ temporarily disables input, creates a list that will hold references to cards the player selects, creates and initializes a fresh game state, and then does the same with our game grid. This should always be called first when the game manager is instantiated.

‘RevealGameFieldAndStartGame’ begins the game by briefly showing the cards to the player and then calling the last startup method ‘StartGame’ which turns input on so the player can start the game.

using UnityEngine;
using System.Collections;
using System.Collections.Generic;

public class GameManager {

    // this handles one instance of a gamestate
    private GameStateModel state;

    // wether or not game input is being accepted right now
    public bool InputEnabled { get; set; }

    // Currently selected cards waiting to be matched
    private List<int> selectedCards;

    // Use this for initialization and prefetch
    public IEnumerator Initialize()
    {
        InputEnabled = false;
        selectedCards = new List<int>();

        // setup the default game model
        GameVars.GameStateConfig : new GameStateModelConfig();
        state = new GameStateModel();
        state.grid_x = 6;
        state.grid_y = 2;
        state.matchLength = 2;

        // initialize the grid
        CardGridManager cgm = CardGridManager.GetInstance();
        cgm.CreateCardGrid(state);
        cgm.ShuffleCardGrid(state);
        cgm.InitializeGridCardViews(state);
        cgm.SetCardSpacing(state);
        cgm.SetCardTextures(state);

        yield return new WaitForSeconds(0.2f);
    }

    // Reveal the game field
    public IEnumerator RevealGameFieldAndStartGame()
    {
        // give the player a peek at the cards
        CardGridManager cgm = CardGridManager.GetInstance();

        float peekDuration = GameVars.PeekDuration;
        float flipDuration = GameVars.CardFlipDuration;
        float delay = 0.5f;

        // Reveal the cards briefly
        cgm.PeekAtCards(delay, peekDuration);
        yield return new WaitForSeconds(delay + peekDuration + flipDuration);

        // start the game when we're done
        StartGame();
    }

    // Anything that needs to be done first frame after prefetch
    void StartGame()
    {
        // turn on relevant game components (such as input!)
        Debug.Log("Starting Game!");
        InputEnabled = true;
    }

The last 3 functions in GameManager control the actual game logic. ‘OnCardWasClicked’ responds to the user clicking on a card view object by flipping it over, adding it to the selected card list and then calling EvaluateMatch if the player has selected enough cards to attempt a match.

The last function, GameCompletionSequence will end the game if EvaluateMatch determines that all cards have been matched and the player has won the game.

    public void OnCardWasClicked(CardView cardView){

        if (!InputEnabled)
        {
            return;
        }

        // find our card index
        int cardIndex = cardView.refIdx;

        // if it's one we already selected, bail
        if (selectedCards.Contains(cardIndex))
        {
            return;
        }

        // flip!
        cardView.FlipUp();

        // store it
        selectedCards.Add(cardIndex);

        // evaluate match
        if (selectedCards.Count == state.matchLength)
        {
            // disable input
            InputEnabled = false;

            CoroutineSingleton.GetInstance().StartCoroutine(EvaluateMatch());
        }
    }

    IEnumerator EvaluateMatch()
    {
        // compare our selected cards to find a match
        int[] indexes = selectedCards.ToArray();
        bool matchFound = state.MatchWasSuccessful(indexes);
        CardGridManager cgm = CardGridManager.GetInstance();

        // reset all the cards after a delay
        yield return new WaitForSeconds(GameVars.FlipCheckDuration);

        if (matchFound)
        {
            // mark views as matched to lock them
            cgm.SetCardViewsMatched(indexes);
        }
        else
        {
            // return cards to their face-down positions
            cgm.FlipAllUnmatchedCardsDown();

            // account for the time it takes to flip cards back down, minus a small amount for feel
            yield return new WaitForSeconds(GameVars.CardFlipDuration * 0.5f);
        }

        // zero out the selected card list
        selectedCards = new List();

        if (state.IsComplete())
        {
            Debug.Log("Game is completed! Starting GameCompletion Sequence...");
            CoroutineSingleton.GetInstance().StartCoroutine(GameCompletionSequence());
            yield break;
        }

        // re-enable input!
        InputEnabled = true;
    }

    IEnumerator GameCompletionSequence()
    {
        yield return 0;

        // Cleanup! This is temporary so you can 'play' the game at least!
        GameObject.Destroy(GameObject.Find(GameVars.RootObjectName));
    }
}

TestScript

One last thing before we can test our game, we need to re-visit the card view script from part 1 briefly. Now that our Game Manager class exists, we can inform it when a card is clicked by the user.

IEnumerator OnMouseDown () {

	if(isMatched)
	    yield break;

	// if input is enabled, flip the card and wait
	GameManager _gm = GameVars.GameManager;

        // inform our input manager a card has been clicked
	_gm.OnCardWasClicked (this);

	yield return null;
    }

Lastly, in order to test our game we’ll need to use a simple test script to set up our games root object and our game manager. Then we can initialize it and begin the game.

public class TestGame : MonoBehaviour {

    private GameManager gm;

    void Awake () {
        // create a root object for our game to anchor to
        GameObject root = new GameObject(GameVars.RootObjectName);

        // create our game manager object
        gm = new GameManager();
        GameVars.GameManager = gm;

        StartCoroutine(StartupRoutine());
    }

    IEnumerator StartupRoutine () {
        // Initialize the Game Manager
        yield return StartCoroutine(gm.Initialize());

        // Reveal the cards and begin the game
        yield return StartCoroutine(gm.RevealGameFieldAndStartGame());
    }
}

gameboard

Technically, we have a game now. But in part 3 we’re going to add the ability to customize the deck texture, and add a menu system.

Advertisements
This entry was posted in Tutorials. Bookmark the permalink.

Leave a Reply

Fill in your details below or click an icon to log in:

WordPress.com Logo

You are commenting using your WordPress.com account. Log Out / Change )

Twitter picture

You are commenting using your Twitter account. Log Out / Change )

Facebook photo

You are commenting using your Facebook account. Log Out / Change )

Google+ photo

You are commenting using your Google+ account. Log Out / Change )

Connecting to %s