Match 3 Gameplay logics need help

Hi all, I’ve stuck for a couple of days finding solution the problem, but seems to no avail.

Anyway I’ve been creating a match 3 system for a week right now, and have succeeded in creating the basic core, such as swapping tiles, check for match, destroy if there is any match 3 or greater, and inserting new blocks. But I cannot find the best method to match check the new inserted tiles.

I’ve succeeded creating match 3 system using playmaker before, but it is rather complicated to adjust, and its been a while since I fiddle with it. Now I’m starting to understand c#, I want to create it using c#.

does anyone have insight on how to do just that?

Here is my code, please excuse the spaghetti code, I’m a noob with c#, and have no programming background whatsoever:

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

public class PlayerInput : MonoBehaviour {
  
    public LayerMask tilesLayer;
    public GameObject indicator;
    public float switchSpeed;
    public float gravity = 0.5f;

    public bool debugMode;

    private GridManager gridManager;
    private GameObject activeTile;
    private GameObject secondTile;

    private List<GameObject> matchPositionsX = new List<GameObject>();
    private List<GameObject> matchPositionsY = new List<GameObject>();

    private List<GameObject> matchPositionsX2 = new List<GameObject>();
    private List<GameObject> matchPositionsY2 = new List<GameObject>();

    private List<GameObject> tileFallList = new List<GameObject>();

    private int gridWidth;
    private int gridHeight;
    private float posOffset;

    private bool isMoving = false;
    private bool checkAfterFall = false;

    private int firstX = -1;
    private int firstY = -1;
  
    void Awake ()
    {
        //Store GridManager script reference to var gridManager, and the needed variable
        gridManager = GetComponent<GridManager>();
        gridWidth = gridManager.gridWidth;
        gridHeight = gridManager.gridHeight;
        posOffset = gridManager.posOffset;
    }
  
    void Update ()
    {
        if (Input.GetButtonDown("Fire1"))
        {
            if (activeTile == null ) //if there are no selected tile yet, select the first tile
                SelectTile ();
            else
                //else move the neighbor-ing tile
                AttemptMove ();
        }

        if (checkAfterFall)
        {
            StartCoroutine(CheckAfterFall(tileFallList, matchPositionsX, matchPositionsY));
        }

        if (Input.GetKeyDown(KeyCode.Escape))
        {
            Application.Quit();
        }
    }

    //function to select the first tile
    void SelectTile ()
    {
        Vector2 screenPos = Camera.main.ScreenToWorldPoint(Input.mousePosition);
        RaycastHit2D hit = Physics2D.Raycast(screenPos, Vector2.zero, 50f, tilesLayer);

        if (hit.collider != null)
        {
            activeTile = hit.collider.gameObject;
            indicator.transform.position = activeTile.transform.position;
            indicator.SetActive(true);
            indicator.transform.parent = activeTile.transform;

            if (debugMode)
                print (activeTile);
        }
    }

    //function to move a tile
    void AttemptMove ()
    {
        Vector2 screenPos = Camera.main.ScreenToWorldPoint(Input.mousePosition);
        RaycastHit2D hit = Physics2D.Raycast(screenPos, Vector2.zero, 50f, tilesLayer);
        Vector2 activeTilePos;
        Vector2 secondTilePos;

        if (hit.collider != null)
        {
            secondTile = hit.collider.gameObject;
          
            if (debugMode)
                    print (secondTile);

            if (NeighborCheck (secondTile))
            {
                if (debugMode)
                    print (NeighborCheck (secondTile));

                activeTilePos = gridManager.gridPos[(int)activeTile.transform.position.x, (int)activeTile.transform.position.y];
                secondTilePos = gridManager.gridPos[(int)secondTile.transform.position.x, (int)secondTile.transform.position.y];

                //Switch!
                StartCoroutine(MoveTiles(activeTile, secondTile, switchSpeed, activeTilePos, secondTilePos, 0.05f, false));
              
                //Switch grid id temporary to check match Possibilites using CheckMatches method
                SwitchGrid(activeTilePos, secondTilePos);

                //Check if CheckMatches found matches on the switched positions
                if (CheckMatches(activeTilePos.x, secondTilePos.y, matchPositionsX, matchPositionsY, true) ||
                    CheckMatches(secondTilePos.x, activeTilePos.y, matchPositionsX2, matchPositionsY2, true))
                {
                    //Make sure secondTile get checked again, incase the checkMatch activeTile resulting true
                    //because OR operand skip the second condition if the first one is true
                    CheckMatches(secondTilePos.x, activeTilePos.y, matchPositionsX2, matchPositionsY2, true);

                    //If found, destroy matched tiles --
                    //CheckMatches by running the public void from GridManager Script
                    StartCoroutine(DestroyMatches(matchPositionsX, matchPositionsY));
                    StartCoroutine(DestroyMatches(matchPositionsX2, matchPositionsY2));
                    StartCoroutine(CheckEmpty(tileFallList));

                    //Fill new Tiles
                }
                else
                {
                    //Found no match, then switch back!
                    StartCoroutine(MoveTiles(secondTile, activeTile, switchSpeed, activeTilePos, secondTilePos, 0.05f, true));

                    //Switch grid id back from previous temporary modification
                    SwitchGrid(activeTilePos, secondTilePos);
                }
            }

            activeTile = null;
            secondTile = null;
        }
    }

    bool NeighborCheck (GameObject objectToCheck)
    {
        int xDifference = (int) Mathf.Abs (activeTile.transform.position.x - objectToCheck.transform.position.x);
        int yDifference = (int) Mathf.Abs (activeTile.transform.position.y - objectToCheck.transform.position.y);
      
        if (xDifference + yDifference == 1)
            return true;
        else
            return false;
    }

    void SwitchGrid(Vector3 obj1pos, Vector3 obj2pos)
    {
        GameObject activeTileID;
        //Switch ID base on switched Tiles
        activeTileID = gridManager.gridObj[(int)obj1pos.x, (int)obj1pos.y];
        gridManager.gridObj[(int)obj1pos.x, (int)obj1pos.y] = gridManager.gridObj[(int)obj2pos.x, (int)obj2pos.y];
        gridManager.gridObj[(int)obj2pos.x, (int)obj2pos.y] = activeTileID;
    }

    IEnumerator MoveTiles (GameObject obj1, GameObject obj2, float smooth, Vector3 obj1pos, Vector3 obj2pos, float snap, bool resetTile)
    {
        while (isMoving)
            yield return null;

        isMoving = true;

        while (Vector3.Distance(obj1.transform.position, obj2pos) > snap)
        {
            obj1.transform.position = Vector3.Lerp (obj1.transform.position, obj2pos, smooth * Time.deltaTime);
            obj2.transform.position = Vector3.Lerp (obj2.transform.position, obj1pos, smooth * Time.deltaTime);
            yield return null;
        }

        //Make sure obj1 and obj2 switch pos at the end of lerp
        obj1.transform.position = obj2pos;
        obj2.transform.position = obj1pos;

        indicator.SetActive(false);
        indicator.transform.parent = null;

        isMoving = false;

        //reset both activeTile and secondTile variable
        if (resetTile)
        {
            activeTile = null;
            secondTile = null;
        }
    }

    bool CheckMatches(float x1, float y1, List<GameObject> listX, List<GameObject> listY, bool clearList)
    {
        if (clearList)
        {
            listX.Clear();
            listY.Clear();
        }

        string tileTag = gridManager.gridObj[(int)x1,(int)y1].tag;
        GameObject currentObj;
      
        if (debugMode)
            print ("Check Match Tiles Tag: " + gridManager.gridObj[(int)x1,(int)y1].tag + " x:" + (int)x1 + " y:" + (int)y1);

        //---HORIZONTAL CHECK
        //Check to the Right after Swap
        for (int x = (int)x1; x < gridWidth; x++)
        {
            int y = (int)y1;
            currentObj = gridManager.gridObj[x,y];
          
            if (currentObj.tag == tileTag || x == (int)x1)
            {
                if (!listX.Contains(currentObj))
                    listX.Add(currentObj);
            } else
                break;
        }
      
        //Check to the Left after Swap
        for (int x = (int)x1; x >= 0; x--)
        {
            int y = (int)y1;
            currentObj = gridManager.gridObj[x,y];
          
            if (currentObj.tag == tileTag || x == (int)x1)
            {
                if (!listX.Contains(currentObj))
                    listX.Add(currentObj);
            } else
                break;
        }
      
        //----VERTICAL CHECK
        //Check to the Top after Swap
        for (int y = (int)y1; y < gridHeight; y++)
        {
            int x = (int)x1;
            currentObj = gridManager.gridObj[x,y];
          
            if (currentObj.tag == tileTag || y == (int)y1)
            {
                if (!listY.Contains(currentObj))
                    listY.Add(currentObj);
            } else
                break;
        }
      
        //Check to the Bottom after Swap
        for (int y = (int)y1; y >= 0; y--)
        {
            int x = (int)x1;
            currentObj = gridManager.gridObj[x,y];
          
            if (currentObj.tag == tileTag || y == (int)y1)
            {
                if (!listY.Contains(currentObj))
                    listY.Add(currentObj);
            } else
                break;
        }
      
        if (listX.Count > 2 || listY.Count > 2)
            return true;
        else
        {
            listX.Clear();
            listY.Clear();
            return false;
        }
    }
  
  
    IEnumerator DestroyMatches(List<GameObject> listX, List<GameObject> listY)
    {
        while (isMoving)
            yield return null;

        // Check if found Match more than 2
      
        if (listX.Count > 2)
        {
            // Destroy gameObjects in listX
            foreach (GameObject obj in listX)
            {
                if (debugMode)
                    print ("Tiles destroyed horizontally: " + obj.tag + " x:" + obj.transform.position.x + " y:" + obj.transform.position.y);
              
                Destroy(obj, 0f);
            }
        }
      
        if (listY.Count > 2)
        {
            // Destroy gameObjects in listY
            foreach (GameObject obj in listY)
            {
                if (debugMode)
                    print ("Tiles destroyed vertically: " + obj.tag + " x:" + obj.transform.position.x + " y:" + obj.transform.position.y);
              
                Destroy(obj, 0f);
              
                if (debugMode)
                    print ("Destroy Executed at: " + Time.time);
            }
        }
      
        listX.Clear();
        listY.Clear();
    }
  
  
    IEnumerator CheckEmpty(List<GameObject> tilesFall)
    {
        while (isMoving)
            yield return null;

        Vector2 currentPos;
        GameObject currentObj;

        //Check all of the grid from index 0
        for (int x = 0; x < gridWidth; x++)
        {
            List<GameObject> newTilesCol = new List<GameObject>();

            for (int y = 0; y < gridHeight; y++)
            {
                //store the current grid position to a variable
                currentPos = new Vector2 ((float)x + posOffset, (float)y + posOffset);
              
                //Raycast to see if there is a tile
                RaycastHit2D hit = Physics2D.Raycast(currentPos, Vector2.zero, 50f, tilesLayer);

                if (hit.collider == null) //if raycast hit nothing, check all the way up
                {
                    if (firstX == -1 && firstY == -1)
                    {
                        firstX = x;
                        firstY = y;
                    }
                    //declare emptySpaces variable to count the row with empty spaces
                    int emptySpaces = 0;

                    int fillSpaces = 0;
                  
                    //Check vertically
                    for (int y2 = y; y2 < gridHeight; y2++)
                    {
                        currentPos = new Vector2 ((float)x + posOffset, (float)y2 + posOffset);
                        //get the current iteration X Y as vec2
                        currentObj = gridManager.gridObj[x,y2]; //store the current ID
                      
                        RaycastHit2D hit2 = Physics2D.Raycast(currentPos, Vector2.zero, 50f, tilesLayer);
                      
                        if (hit2.collider != null)
                            currentObj = hit2.collider.gameObject;
                        else
                            currentObj = null;
                      
                      
                        if (currentObj != null)
                        {
                            //switch grid ID base on the new position
                            int yNew = (int)(currentPos.y - (float)emptySpaces);
                            gridManager.gridObj[x,yNew] = currentObj;

                            //increment the fillspaces var, for getting y position later for the new tiles
                            fillSpaces++;
                          
                            //Animate tiles with empty grid blow to fall (gravity effect)
                            StartCoroutine(TilesFall(currentObj, currentPos.y - (float)emptySpaces, gravity, 3));
                          
                            tilesFall.Add(currentObj);

                            if (debugMode)
                                print ("Tiles Fell: " + currentObj.tag + " new x: " + x + " new y: " + yNew);
                        } else {
                            emptySpaces++;

                            GameObject newTilesObj = gridManager.GenerateTile(x, y2);
                          
                            newTilesCol.Add(newTilesObj);
                            newTilesObj.transform.position += new Vector3 (0f, 8f, 0f);
                        }

                    }

                    //Drop the Generated new Tiles
                    foreach (GameObject obj in newTilesCol)
                    {
                        float targetY = (obj.transform.position.y - 8f) + (float)fillSpaces;

                        StartCoroutine(TilesFall(obj, targetY, gravity, 3));

                        gridManager.gridObj[x, (int)targetY] = obj;

                        tilesFall.Add(obj);

                        if (debugMode)
                            print ("New Tile: " + gridManager.gridObj[x, (int)targetY].tag + " x:" + x + " y:" + (int)targetY);
                    }

                    //set y to gridHeight amount, to stop vertical for-loop outside this loop from looping again.
                    y = gridHeight;
                  
                    if (debugMode)
                        print ("Check Empty Executed at: " + Time.time);
                }
            }
        }

        checkAfterFall = true;
        //CheckEmpty ends
    }

    IEnumerator CheckAfterFall(List<GameObject> tileList, List<GameObject> listX, List<GameObject> listY)
    {
        checkAfterFall = false;

        foreach (GameObject obj in tileList)
        {
            while (obj.transform.position.y > gridHeight)
                yield return null;

            if (obj != null)
                CheckMatches(obj.transform.position.x, obj.transform.position.y, listX, listY, false);

            obj.GetComponent<SpriteRenderer>().color += new Color (0.3f,0f,0f);
        }

        //StartCoroutine(DestroyMatches(listX, listY));

        tileList.Clear ();
        listX.Clear();
        listY.Clear();

        yield return null;
    }
  
    IEnumerator TilesFall (GameObject obj, float targetY, float gravity, int bounce)
    {
        float speed = 0f;

        isMoving = true;

        if (obj != null)
        {
            while (bounce > 0)
            {
                speed -= gravity;

                //Bounce effect
                if (obj.transform.position.y < targetY)
                {
                    obj.transform.position = new Vector3 (obj.transform.position.x, targetY, obj.transform.position.z);
                    speed *= -0.35f;
                    bounce--;
                }
                  
                obj.transform.position += new Vector3 (0, speed * Time.deltaTime, 0);

                yield return null;
            }

            obj.transform.position = new Vector3 (obj.transform.position.x, targetY, obj.transform.position.z);

            isMoving = false;
        }
    }

}

Thanks for any help before!

I noticed you are destroying your matched tiles in your:

IEnumerator DestroyMatches(List<GameObject> listX, List<GameObject> listY)
{
   ...
}

function, which is a waste. Instead destroying them, assuming your tiles are falling from up just like every other match3 game, you can move their transform.position.y way up and propagate their grid position up, swapping it with other tiles from above.

That way you can nicely animate your tiles without worrying to replace destroyed tiles in function like:

IEnumerator CheckEmpty(List<GameObject> tilesFall)
{
   ...
}

Since this code is literally spaghetti, I would recommend redo it from scratch, but keeping few things in mind:

  1. Separate responsibility - currently PlayerInput acts like main script for whole program. I would structure the game like this:

PuzzleController - Main script, handles how tiles move, asks Matchfinder for tiles that need update, handles grid propagation and animation
TileController - small script that is used to identify the tile, contains gridPosition, tile color or whatever
Matchfinder - logic script whose sole purpose is to find combinations from given tiles
TileCombination - match logic; abstract class used by matchfinder, need to subclass to create matches like vertical 3, horizontal 4, etc.
Define states - something like Idle, Animating, Dragging, etc.

  1. Reuse game objects - Generate your grid and tiles once in Start() method. If you need to visually describe that a tile has been destroyed, you can Instantiate() a copy of that tile on the same spot and let it have custom destroy animation. Original tiles should be disabled, or in this case just positioned above the screen from where they will fall.

  2. Match logic - Using abstract Match logic enables you to eventually expand your games functionality. Adding a square check 2x2 in your current code, while possible, would result in script becoming more and more unreadable. Separating such things in small classes helps organise code and extending it without much headache.

Some examples:

public class CombinationResult
{
   public List<TileController> foundTiles;
}

public abstract class TileCombination
{
    public abstract Vector2 LookupRange();
    public abstract bool HasDiagonalCheck();
    public abstract int Priority();
    public abstract bool Test(TileController tile);
    public abstract int Points();
  
    public TileController[] foundTiles = new TileController[0];
    public int color = 0;

    public CombinationResult GetResult()
    {
       CombinationResult result = new CombinationResult();
       result.foundTiles = new List<TileController>( foundTiles );
       return result;
    }
}

public class TileThreeVerticalCombination : TileCombination
{
    public override bool Test(TileController tile)
    {
       if (tile.lookupHorizontal || foundTiles.Length != 3)
          return false;

       return color == tile.color;
    }

     public override Vector2 LookupRange ()
    {
        return new Vector2(1, 3);
    }

    public override bool HasDiagonalCheck ()
    {
        return false;
    }

    public override int Priority ()
    {
        return 1;
    }

    public override int Points ()
    {
        return 20;
    }
}

public class TileFourHorizontalCombination : TileCombination
{
    public override bool Test(TileController tile)
    {
       if (tile.lookupHorizontal || foundTiles.Length != 4)
          return false;

       return color == tile.color;
    }

     public override Vector2 LookupRange ()
    {
        return new Vector2(4, 1);
    }

    public override bool HasDiagonalCheck ()
    {
        return false;
    }

    public override bool Test ()
    {
        // insert your test to check if valid
    }

    public override int Priority ()
    {
        return 2;
    }

    public override int Points ()
    {
        return 50;
    }
}
public class TileController : MonoBehaviour
{
   public Vector2 gridPosition;
   public int color;
   public bool lookupVertical;
   public bool lookupHorizontal;
   public bool lookupDiagonalUp;
   public bool lookupDiagonalDown;

   public void ResetLookup()
   {
      lookupVertical = false;
      lookupHorizontal = false;
      lookupDiagonalUp = false;
      lookupDiagonalDown = false;
   }
}

Matchfinder would look something like:

public class MatchFinder : MonoBehaviour
{
    public PuzzleController puzzleController;
  
    TileCombination[] m_baseCombinations;

    void Start()
    {
        //NOTE: higher number match go first in order to have bigger priority
        m_baseCombinations = new TileCombinations()
        {
             new FourHorizontalCombination(),
             new ThreeVerticalCombination(),
             ... //etc etc
        }
    }

    public CombinationResult[] FindTileCombinations(TileController[] tiles)
    {
        foreach (TileController tile in tiles)
            tile.ResetLookup();

        List<CombinationResult> list = new List<CombinationResult>();

        foreach (TileCombination combo in m_baseCombinations)
        {
            foreach (TileController tile in tiles)
            {
                if (ValidateCombination(combo, tile, tiles))  //<---- here you evaluate is combination is valid
                {
                    list.Add(combo.GetResult());
                }
            }
        }

        return list.ToArray();
    }

    bool ValidateCombination(TileCombination combination, TileController tile, TileController[] tiles)
    {
          TileController[] list = puzzleController.FindRange(tile, tiles, LookupRange(), HasDiagonalCheck()); //<-- your own lookup code goes here, result should find all eligible tiles

          if (list == null)
               return false;

          if (list.Length == 0)
               return false;

          combination.color = tile.color; // we check for this color combination        
          combination.foundTiles = list;

          bool test = true; // so far we found eligible tiles, now we test if they fail color test
          foreach (TileController tile in list)
          {
                if ( !combination.Test(tile) )
                {
                       test = false;
                       break;
                }
          }

          return test;
}
}

Do note, this code is a highly stripped version of what I use so please consider it a good starting point to expand your code. More like proof of concept :slight_smile:

Sorry for the wall of text, hope it helps. Good luck with your project!

2 Likes

I posted some info on structure a while back here:

Somewhere I posted the matching logic, but I can’t seem to find it.

2 Likes

@Fajlworks , thanks a lot for the insight, it is indeed really valuable. As for instantiating and destroying, I’m going to change that later to an object pooling, once I get the basic mechanic working. I’ve change a couple things though after I posted this, but still not sure about the efficiency, so I’ll figure out your proof of concept, thanks a lot!

@zombiegorilla , Wow, that is a really neat game you have there, and a great idea structure you have there, thanks a lot for sharing it here!

@zombiegorilla , thanks a lot for the insight of your project, it made me trying dotween, and now my Match-3 system it’s working flawlessly. Those callbacks are indeed precious :slight_smile: