/MatchSweets

Match 3 game template

Primary LanguageC#MIT LicenseMIT

Match Sweets

A Match 3 game template.

Gameplay
MatchSweetsGameplay.mp4

πŸ“– Table of Contents

πŸ“ About

A Match 3 game template with three implementations to fill the playing field. Use this project as a starting point for creating your own Match 3 game.

Simple Fill Strategy Fall Down Fill Strategy Slide Down Fill Strategy
ItemsScaleStrategy ItemsDropStrategy ItemsRollDownStrategy

Note: The FallDownFillStrategy & SlideDownFillStrategy are given as an example. Consider to implement an object pooling technique for the ItemMoveData to reduce memory pressure.

🌡 Folder Structure

.
β”œβ”€β”€ Art
β”‚   β”œβ”€β”€ Icons
β”‚   β”‚   β”œβ”€β”€ Food
β”‚   β”‚   └── Sweets
β”‚   β”œβ”€β”€ Sprites
β”‚   └── Textures
β”‚
β”œβ”€β”€ Plugins
β”‚   └── Match3
β”‚       β”œβ”€β”€ App
β”‚       β”‚   β”œβ”€β”€ Internal
β”‚       β”‚   β”‚   β”œβ”€β”€ ...
β”‚       β”‚   β”‚   β”œβ”€β”€ GameBoard.cs
β”‚       β”‚   β”‚   └── JobsExecutor.cs
β”‚       β”‚   β”œβ”€β”€ ...
β”‚       β”‚   β”œβ”€β”€ GameConfig.cs
β”‚       β”‚   β”œβ”€β”€ Job.cs
β”‚       β”‚   β”œβ”€β”€ LevelGoal.cs
β”‚       β”‚   └── Match3Game.cs
β”‚       └── Core
β”‚
β”œβ”€β”€ Prefabs
β”‚   β”œβ”€β”€ ItemPrefab.prefab
β”‚   └── TilePrefab.prefab
β”‚
β”œβ”€β”€ Scenes
β”‚   └── MainScene.unity
β”‚
β”œβ”€β”€ Scripts
β”‚   β”œβ”€β”€ Common
β”‚   β”‚   β”œβ”€β”€ AppModes
β”‚   β”‚   β”‚   β”œβ”€β”€ DrawGameBoardMode.cs
β”‚   β”‚   β”‚   β”œβ”€β”€ GameInitMode.cs
β”‚   β”‚   β”‚   └── GamePlayMode.cs
β”‚   β”‚   β”œβ”€β”€ LevelGoals
β”‚   β”‚   β”œβ”€β”€ SequenceDetectors
β”‚   β”‚   β”œβ”€β”€ ...
β”‚   β”‚   β”œβ”€β”€ GameBoardRenderer.cs
β”‚   β”‚   β”œβ”€β”€ GameBoardSolver.cs
β”‚   β”‚   β”œβ”€β”€ ItemGenerator.cs
β”‚   β”‚   β”œβ”€β”€ LevelGoalsProvider.cs
β”‚   β”‚   └── UnityItem.cs
β”‚   β”œβ”€β”€ FillStrategies
β”‚   β”‚   β”œβ”€β”€ Jobs
β”‚   β”‚   β”œβ”€β”€ Models
β”‚   β”‚   β”œβ”€β”€ FallDownFillStrategy.cs
β”‚   β”‚   β”œβ”€β”€ SimpleFillStrategy.cs
β”‚   β”‚   └── SlideDownFillStrategy.cs
β”‚   β”œβ”€β”€ App.cs
β”‚   β”œβ”€β”€ AppContext.cs
β”‚   └── Game.cs

πŸš€ How To Use

Add new icons set

To add a new icons set, simply create a SpriteAtlas and add it to the AppContext via the Inspector.

AppContextInspector

Note: You can change icons size by changing the Pixels Per Unit option in the sprite settings.

Create animation job

Let's create a SlideIn animation to show the items and a SlideOut animation to hide the items. These animations will be used further.

Π‘reate a class ItemsSlideOutJob and inherit from the Job.

public class ItemsSlideOutJob : Job
{
    private const float FadeDuration = 0.15f;
    private const float SlideDuration = 0.2f;

    private readonly IEnumerable<IUnityItem> _items;

    public ItemsSlideOutJob(IEnumerable<IUnityItem> items, int executionOrder = 0) : base(executionOrder)
    {
        _items = items; // Items to animate.
    }

    public override async UniTask ExecuteAsync()
    {
        var itemsSequence = DOTween.Sequence();

        foreach (var item in _items)
        {
            // Calculate the item destination position.
            var destinationPosition = item.GetWorldPosition() + Vector3.right;

            _ = itemsSequence
                .Join(item.Transform.DOMove(destinationPosition, SlideDuration)) // Smoothly move the item.
                .Join(item.SpriteRenderer.DOFade(0, FadeDuration)); // Smoothly hide the item.
        }

        await itemsSequence.SetEase(Ease.Flash);
    }
}

Then create a class ItemsSlideInJob.

public class ItemsSlideInJob : Job
{
    private const float FadeDuration = 0.15f;
    private const float SlideDuration = 0.2f;

    private readonly IEnumerable<IUnityItem> _items;

    public ItemsSlideInJob(IEnumerable<IUnityItem> items, int executionOrder = 0) : base(executionOrder)
    {
        _items = items; // Items to animate.
    }

    public override async UniTask ExecuteAsync()
    {
        var itemsSequence = DOTween.Sequence();

        foreach (var item in _items)
        {
            // Save the item current position.
            var destinationPosition = item.GetWorldPosition();

            // Move the item to the starting position.
            item.SetWorldPosition(destinationPosition + Vector3.left);
            
            // Reset the item scale.
            item.Transform.localScale = Vector3.one;
            
            // Reset the sprite alpha to zero.
            item.SpriteRenderer.SetAlpha(0);
            
            // Activate the item game object.
            item.Show();

            _ = itemsSequence
                .Join(item.Transform.DOMove(destinationPosition, SlideDuration)) // Smoothly move the item.
                .Join(item.SpriteRenderer.DOFade(1, FadeDuration)); // Smoothly show the item.
        }

        await itemsSequence.SetEase(Ease.Flash);
    }
}

Jobs with the same executionOrder run in parallel. Otherwise, they run one after the other according to the executionOrder.

Execution Order Demonstration
SlideOutJob: 0
SlideInJob: 0
SlideOutJob: 0
SlideInJob: 1
ItemsSlideAnimation ItemsSlideAnimation

Create fill strategy

First of all, create a class SidewayFillStrategy and inherit from the IBoardFillStrategy<TItem>.

We'll need an IGameBoardRenderer to transform grid positions to world positions and an IItemsPool<TItem> to get the pre-created items from the pool. Let's pass them to the constructor.

public class SidewayFillStrategy : IBoardFillStrategy<IUnityItem>
{
    private readonly IGameBoardRenderer _gameBoardRenderer;
    private readonly IItemsPool<IUnityItem> _itemsPool;

    public SidewayFillStrategy(IGameBoardRenderer gameBoardRenderer, IItemsPool<IUnityItem> itemsPool)
    {
        _itemsPool = itemsPool;
        _gameBoardRenderer = gameBoardRenderer;
    }

    public string Name => "Sideway Fill Strategy";

    public IEnumerable<IJob> GetFillJobs(IGameBoard<IUnityItem> gameBoard)
    {
        throw new NotImplementedException();
    }

    public IEnumerable<IJob> GetSolveJobs(IGameBoard<IUnityItem> gameBoard,
        IEnumerable<ItemSequence<IUnityItem>> sequences)
    {
        throw new NotImplementedException();
    }
}

Then let's implement the GetFillJobs method. This method is used to fill the playing field.

public IEnumerable<IJob> GetFillJobs(IGameBoard<IUnityItem> gameBoard)
{
    // List of items to show.
    var itemsToShow = new List<IUnityItem>();

    for (var rowIndex = 0; rowIndex < gameBoard.RowCount; rowIndex++)
    {
        for (var columnIndex = 0; columnIndex < gameBoard.ColumnCount; columnIndex++)
        {
            var gridSlot = gameBoard[rowIndex, columnIndex];
            if (gridSlot.State != GridSlotState.Empty)
            {
                continue;
            }

            // Get an item from the pool.
            var item = _itemsPool.GetItem();
            
            // Set the position of the item.
            item.SetWorldPosition(_gameBoardRenderer.GetWorldPosition(rowIndex, columnIndex));

            // Set the item to the grid slot.
            gridSlot.SetItem(item);
            
            // Add the item to the list to show.
            itemsToShow.Add(item);
        }
    }

    // Create a job to show items.
    return new[] { new ItemsShowJob(itemsToShow) };
}

Next, we implement the GetSolveJobs method. This method is used to deal with solved sequences of items.

public IEnumerable<IJob> GetSolveJobs(IGameBoard<IUnityItem> gameBoard,
    IEnumerable<ItemSequence<IUnityItem>> sequences)
{
    // List of items to hide.
    var itemsToHide = new List<IUnityItem>();
    
    // List of items to show.
    var itemsToShow = new List<IUnityItem>();

    foreach (var solvedGridSlot in sequences.GetUniqueGridSlots())
    {
        // Get a new item from the pool.
        var newItem = _itemsPool.GetItem();
        
        // Get the current item of the grid slot.
        var currentItem = solvedGridSlot.Item;

        // Set the position of the new item.
        newItem.SetWorldPosition(currentItem.GetWorldPosition());
        
        // Set the new item to the grid slot.
        solvedGridSlot.SetItem(newItem);

        // Add the current item to the list to hide.
        itemsToHide.Add(currentItem);
        
        // Add the new item to the list to show.
        itemsToShow.Add(newItem);
        
        // Return the current item to the pool.
        _itemsPool.ReturnItem(currentItem);
    }

    // Create jobs to hide and show items using the animations we created above.
    return new IJob[] { new ItemsSlideOutJob(itemsToHide), new ItemsSlideInJob(itemsToShow) };
}

Once the SidewayFillStrategy is implemented. Register it in the AppContext class.

public class AppContext : MonoBehaviour, IAppContext
{
    ...

    private IBoardFillStrategy<IUnityItem>[] GetBoardFillStrategies(IGameBoardRenderer gameBoardRenderer,
        IItemsPool<IUnityItem> itemsPool)
    {
        return new IBoardFillStrategy<IUnityItem>[]
        {
            ...
            new SidewayFillStrategy(gameBoardRenderer, itemsPool)
        };
    }
    
    ...
}
Video Demonstration
ItemsSlideFillStrategy.mp4

Create level goal

Let's say we want to add a goal to collect a certain number of specific items. First of all, create a class CollectItems and inherit from the LevelGoal<TItem>.

public class CollectItems : LevelGoal<IUnityItem>
{
    private readonly int _contentId;
    private readonly int _itemsCount;

    private int _collectedItemsCount;

    public CollectItems(int contentId, int itemsCount)
    {
        _contentId = contentId;
        _itemsCount = itemsCount;
    }

    public override void RegisterSolvedSequences(IEnumerable<ItemSequence<IUnityItem>> sequences)
    {
        foreach (var solvedGridSlot in sequences.GetUniqueGridSlots())
        {
            if (solvedGridSlot.Item.ContentId == _contentId)
            {
                _collectedItemsCount++;
            }
        }

        if (_collectedItemsCount >= _itemsCount)
        {
            MarkAchieved();
        }
    }
}

Once the level goal is implemented. Don't forget to register it in the LevelGoalsProvider.

public class LevelGoalsProvider : ILevelGoalsProvider<IUnityItem>
{
    public LevelGoal<IUnityItem>[] GetLevelGoals(int level, IGameBoard<IUnityItem> gameBoard)
    {
        return new LevelGoal<IUnityItem>[]
        {
            ...
            new CollectItems(0, 25)
        };
    }
}

Create sequence detector

Let's implement a new sequence detector to detect square shapes. Create a class SquareShapeDetector and inherit from the ISequenceDetector<TItem>.

First of all, we have to declare an array of lookup directions.

public class SquareShapeDetector : ISequenceDetector<IUnityItem>
{
    private readonly GridPosition[][] _squareLookupDirections;

    public SquareShapeDetector()
    {
        _squareLookupDirections = new[]
        {
            new[] { GridPosition.Up, GridPosition.Left, GridPosition.Up + GridPosition.Left },
            new[] { GridPosition.Up, GridPosition.Right, GridPosition.Up + GridPosition.Right },
            new[] { GridPosition.Down, GridPosition.Left, GridPosition.Down + GridPosition.Left },
            new[] { GridPosition.Down, GridPosition.Right, GridPosition.Down + GridPosition.Right },
        };
    }

    public ItemSequence<IUnityItem> GetSequence(IGameBoard<IUnityItem> gameBoard, GridPosition gridPosition)
    {
        throw new NotImplementedException();
    }
}

Then let's implement the GetSequence method.

public ItemSequence<IUnityItem> GetSequence(IGameBoard<IUnityItem> gameBoard, GridPosition gridPosition)
{
    var sampleGridSlot = gameBoard[gridPosition];
    var gridSlots = new List<GridSlot<IUnityItem>>(4);

    foreach (var lookupDirections in _squareLookupDirections)
    {
        foreach (var lookupDirection in lookupDirections)
        {
            var position = gridPosition + lookupDirection;
            if (gameBoard.IsPositionOnBoard(position) == false)
            {
                break;
            }

            var gridSlot = gameBoard[position];
            if (gridSlot.Item == null)
            {
                break;
            }

            if (gridSlot.Item.ContentId == sampleGridSlot.Item.ContentId)
            {
                gridSlots.Add(gridSlot);
            }
        }

        if (gridSlots.Count == 3)
        {
            gridSlots.Add(sampleGridSlot);
            break;
        }

        gridSlots.Clear();
    }

    return gridSlots.Count > 0 ? new ItemSequence<IUnityItem>(GetType(), gridSlots) : null;
}

Finally, add the SquareShapeDetector to the sequence detector list of the GameBoardSolver constructor in the AppContext class.

public class AppContext : MonoBehaviour, IAppContext
{
    ...

    private IGameBoardSolver<IUnityItem> GetGameBoardSolver()
    {
        return new GameBoardSolver(new ISequenceDetector<IUnityItem>[]
        {
            ...
            new SquareShapeDetector()
        });
    }

    ...
}

🎯 ToDo

Here are some features which are either under way or planned:

  • Add tests
  • Build .unitypackage
  • Publish on Asset Store
  • Optimize ItemsDrop & ItemsRollDown fill strategies

πŸ“‘ Contributing

You may contribute in several ways like creating new features, fixing bugs or improving documentation and examples. Find more information in CONTRIBUTING.md.

Report a bug

If you find a bug in the source code, please create bug report.

Please browse existing issues to see whether a bug has previously been reported.

Request a feature

If you have an idea, or you're missing a capability that would make development easier, please submit feature request.

If a similar feature request already exists, don't forget to leave a "+1" or add additional information, such as your thoughts and vision about the feature.

Show your support

Give a ⭐ if this project helped you!

Buy Me A Coffee

βš–οΈ License

Usage is provided under the MIT License.