Skip to content

Hadjime/Match3-SDK

 
 

Repository files navigation

Match 3 SDK

A cross-platform library that makes it easy to create your own Match 3 game.

TerminalAndUnityImplementationMac

📖 Table of Contents

📝 About

The Match 3 SDK is designed to speed up the development of Match 3 games. Use the samples as a starting point for creating your own Match 3 game.

Unity sample

A Match 3 game sample with three implementations to fill the playing field.

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.

Gameplay Demonstration
UnityMatch3Gameplay.mp4

Terminal sample

A Match 3 game sample designed for text terminals.

Gameplay Demonstration
TerminalMatch3Gameplay.mp4

Note: The sample was tested using Rider's internal console. If you have a problem displaying symbols, configure your terminal to support Unicode (in UTF-8 form).

🌵 Folder Structure

.
├── samples
│   ├── Terminal.Match3
│   └── Unity.Match3
│
├── src
│   ├── Match3.App
│   ├── Match3.Core
│   ├── Match3.Template
│   └── Match3.UnityPackage   # Auto-generated
│
├── Match3.sln

⚙️ Installation

Dependencies:

You can install Match3-SDK in one of the following ways:

1. Install via Package Manager

The package is available on the OpenUPM.

  • Open Edit/Project Settings/Package Manager

  • Add a new Scoped Registry (or edit the existing OpenUPM entry)

    Name      package.openupm.com
    URL       https://package.openupm.com
    Scope(s)  com.cysharp.unitask
              com.chebanovdd.match3sdk
    
  • Open Window/Package Manager

  • Select My Registries

  • Install UniTask and Match3-SDK packages

2. Install via Git URL

You can add https://github.com/ChebanovDD/Match3-SDK.git?path=src/Match3.UnityPackage/Assets/Plugins/Match3 to the Package Manager.

If you want to set a target version, Match3-SDK uses the v*.*.* release tag, so you can specify a version like #v0.1.2. For example https://github.com/ChebanovDD/Match3-SDK.git?path=src/Match3.UnityPackage/Assets/Plugins/Match3#v0.1.2.

Note: Dependencies must be installed before installing the package.

  • Match3.SDK.zip - to use the Match3-SDK outside of Unity (eg. just as a normal C# project)
  • Match3.Unity.SDK.unitypackage - contains Match3-SDK source code
  • Match3.Unity.Sample.unitypackage - contains the sample project for Unity
  • com.chebanovdd.match3sdk-*.tgz - for installing the Match3-SDK from a local tarball file

Note: Dependencies must be installed before installing the packages.

🚀 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(CancellationToken cancellationToken = default)
    {
        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).WithCancellation(cancellationToken);
    }
}

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(CancellationToken cancellationToken = default)
    {
        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 sprite alpha to zero.
            item.SpriteRenderer.SetAlpha(0);
            
            // Reset the item scale.
            item.SetScale(1);
            
            // 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).WithCancellation(cancellationToken);
    }
}

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<TGridSlot>.

We'll need an IUnityGameBoardRenderer 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<IUnityGridSlot>
{
    private readonly IItemsPool<IUnityItem> _itemsPool;
    private readonly IUnityGameBoardRenderer _gameBoardRenderer;

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

    public string Name => "Sideway Fill Strategy";

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

    public IEnumerable<IJob> GetSolveJobs(IGameBoard<IUnityGridSlot> gameBoard,
        SolvedData<IUnityGridSlot> solvedData)
    {
        throw new NotImplementedException();
    }
}

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

public IEnumerable<IJob> GetFillJobs(IGameBoard<IUnityGridSlot> 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.CanSetItem == false)
            {
                continue;
            }

            // Get an item from the pool.
            var item = _itemsPool.GetItem();

            // Set the position of the item.
            item.SetWorldPosition(_gameBoardRenderer.GetWorldPosition(gridSlot.GridPosition));

            // 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<IUnityGridSlot> gameBoard,
    SolvedData<IUnityGridSlot> solvedData)
{
    // List of items to hide.
    var itemsToHide = new List<IUnityItem>();

    // List of items to show.
    var itemsToShow = new List<IUnityItem>();

    // Iterate through the solved items.
    // Get unique and only movable items.
    foreach (var solvedGridSlot in solvedData.GetUniqueSolvedGridSlots(true))
    {
        // 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);
    }

    // Iterate through the special items (can be empty).
    // Get all special items except occupied.
    foreach (var specialItemGridSlot in solvedData.GetSpecialItemGridSlots(true))
    {
        var item = _itemsPool.GetItem();
        item.SetWorldPosition(_gameBoardRenderer.GetWorldPosition(specialItemGridSlot.GridPosition));

        specialItemGridSlot.SetItem(item);
        itemsToShow.Add(item);
    }

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

Note: The SolvedSequences & SpecialItemGridSlots can contain overlapping items.

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

public class AppContext : MonoBehaviour, IAppContext
{
    ...

    private IBoardFillStrategy<IUnityGridSlot>[] GetBoardFillStrategies(IUnityGameBoardRenderer gameBoardRenderer,
        IItemsPool<IUnityItem> itemsPool)
    {
        return new IBoardFillStrategy<IUnityGridSlot>[]
        {
            ...
            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<TGridSlot>.

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

    private int _collectedItemsCount;

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

    public override void OnSequencesSolved(SolvedData<IUnityGridSlot> solvedData)
    {
        // Get unique and only movable items.
        foreach (var solvedGridSlot in solvedData.GetUniqueSolvedGridSlots(true))
        {
            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<IUnityGridSlot>
{
    public LevelGoal<IUnityGridSlot>[] GetLevelGoals(int level, IGameBoard<IUnityGridSlot> gameBoard)
    {
        return new LevelGoal<IUnityGridSlot>[]
        {
            ...
            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<TGridSlot>.

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

public class SquareShapeDetector : ISequenceDetector<IUnityGridSlot>
{
    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<IUnityGridSlot> GetSequence(IGameBoard<IUnityGridSlot> gameBoard, GridPosition gridPosition)
    {
        throw new NotImplementedException();
    }
}

Then let's implement the GetSequence method.

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

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

            var lookupGridSlot = gameBoard[lookupPosition];
            if (lookupGridSlot.HasItem == false)
            {
                break;
            }

            if (lookupGridSlot.Item.ContentId == sampleGridSlot.Item.ContentId)
            {
                resultGridSlots.Add(lookupGridSlot);
            }
        }

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

        resultGridSlots.Clear();
    }

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

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

public class AppContext : MonoBehaviour, IAppContext
{
    ...

    private ISequenceDetector<IUnityGridSlot>[] GetSequenceDetectors()
    {
        return new ISequenceDetector<IUnityGridSlot>[]
        {
            ...
            new SquareShapeDetector()
        };
    }

    ...
}

Create special item

Let's create a stone item that is only destroyed when a match happens in one of the neighbour tiles.

Add a Stone value to the TileGroup enum.

public enum TileGroup
{
    Unavailable = 0,
    Available = 1,
    Ice = 2,
    Stone = 3
}

Create a class StoneState and inherit from the StatefulGridTile.

public class StoneState : StatefulGridTile
{
    private bool _isLocked = true;
    private bool _canContainItem;
    private int _group = (int) TileGroup.Stone;

    // Defines the tile group id.
    public override int GroupId => _group;
    
    // Prevents the block from move.
    public override bool IsLocked => _isLocked;
    
    // Prevents the item creation.
    public override bool CanContainItem => _canContainItem;

    // Occurs when all block states have completed.
    protected override void OnComplete()
    {
        _isLocked = false;
        _canContainItem = true;
        _group = (int) TileGroup.Available;
    }

    // Occurs when the block state is reset.
    protected override void OnReset()
    {
        _isLocked = true;
        _canContainItem = false;
        _group = (int) TileGroup.Stone;
    }
}

To respond to any changes in one of the neighbour tiles, we have to implement an ISpecialItemDetector<TGridSlot> interface. Create a StoneItemDetector class and inherit from the ISpecialItemDetector<TGridSlot>.

public class StoneItemDetector : ISpecialItemDetector<IUnityGridSlot>
{
    private readonly GridPosition[] _lookupDirections;

    public StoneItemDetector()
    {
        _lookupDirections = new[]
        {
            GridPosition.Up,
            GridPosition.Down,
            GridPosition.Left,
            GridPosition.Right
        };
    }

    public IEnumerable<IUnityGridSlot> GetSpecialItemGridSlots(IGameBoard<IUnityGridSlot> gameBoard,
        IUnityGridSlot gridSlot)
    {
        if (gridSlot.IsMovable == false)
        {
            yield break;
        }

        foreach (var lookupDirection in _lookupDirections)
        {
            var lookupPosition = gridSlot.GridPosition + lookupDirection;
            if (gameBoard.IsPositionOnGrid(lookupPosition) == false)
            {
                continue;
            }

            var lookupGridSlot = gameBoard[lookupPosition];
            if (lookupGridSlot.State.GroupId == (int) TileGroup.Stone)
            {
                yield return lookupGridSlot;
            }
        }
    }
}

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

public class AppContext : MonoBehaviour, IAppContext
{
    ...

    private ISpecialItemDetector<IUnityGridSlot>[] GetSpecialItemDetectors()
    {
        return new ISpecialItemDetector<IUnityGridSlot>[]
        {
            ...
            new StoneItemDetector()
        };
    }
    
    ...
}

Next, move on to setting up the scene and prefabs.

First of all, add a block state sprites to the TilesSpriteAtlas and create a StoneTilePrefab prefab varian from the StatefulBlankPrefab.

Prefab Variant Creation

CreatePrefabVariant

Configure the StoneTilePrefab by adding the StoneState script to it and filling in a State Sprite Names list.

ConfigureStoneTilePrefab

Note: You can create more than one visual state for a block by adding more state sprites.

Finally, select a GameBoard object in the scene and add the StoneTilePrefab to a GridTiles list of the UnityGameBoardRenderer script.

Video Demonstration
StoneBlockGameplay.mp4

📑 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.

About

SDK for creating Match 3 games

Resources

License

Stars

Watchers

Forks

Packages

No packages published

Languages

  • C# 100.0%