# Match 3 SDK A cross-platform library that makes it easy to create your own Match 3 game. ![TerminalAndUnityImplementationMac](https://user-images.githubusercontent.com/28132516/164034219-561688ef-c5ed-41f8-b30e-8e66c4eb2dfb.png) ## :open_book: Table of Contents - [About](#pencil-about) - [Unity sample](#unity-sample) - [Terminal sample](#terminal-sample) - [Folder Structure](#cactus-folder-structure) - [Installation](#gear-installation) - [How To Use](#rocket-how-to-use) - [Add new icons set](#add-new-icons-set) - [Create animation job](#create-animation-job) - [Create fill strategy](#create-fill-strategy) - [Create level goal](#create-level-goal) - [Create sequence detector](#create-sequence-detector) - [Create special item](#create-special-item) - [Contributing](#bookmark_tabs-contributing) - [Discussions](#discussions) - [Report a bug](#report-a-bug) - [Request a feature](#request-a-feature) - [Show your support](#show-your-support) - [License](#balance_scale-license) ## :pencil: 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
https://user-images.githubusercontent.com/28132516/164045071-e2038177-1bc2-475c-8dbc-4b4f77d6895b.mp4
### Terminal sample A Match 3 game sample designed for text terminals.
Gameplay Demonstration
https://user-images.githubusercontent.com/28132516/164049550-467590dc-bbf8-4109-a1bb-38dfe6674cd6.mp4
> **Note:** The sample was tested using Rider's internal console. If you have a problem displaying symbols, configure your terminal to support [Unicode](https://en.wikipedia.org/wiki/Unicode) (in [UTF-8](https://en.wikipedia.org/wiki/UTF-8) form). For Windows, you can use the new [Windows Terminal](https://aka.ms/terminal). ## :cactus: Folder Structure . ├── samples │ ├── Terminal.Match3 │ └── Unity.Match3 │ ├── src │ ├── Match3.App │ ├── Match3.Core │ ├── Match3.Template │ └── Match3.UnityPackage # Auto-generated │ ├── Match3.sln ## :gear: Installation Dependencies: - Match3-SDK: [UniTask](https://www.nuget.org/packages/UniTask/) - Unity Match3-SDK: [UniTask](https://openupm.com/packages/com.cysharp.unitask/) - Unity Match3-Sample: [UniTask](https://openupm.com/packages/com.cysharp.unitask/), [DOTween](https://openupm.com/packages/com.demigiant.dotween/) & [Match3-SDK](https://openupm.com/packages/com.chebanovdd.match3sdk/) You can install Match3-SDK in one of the following ways:
1. Install via Package Manager
The package is available on the [OpenUPM](https://openupm.com/packages/com.chebanovdd.match3sdk/). - 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.
### [Releases Page](https://github.com/ChebanovDD/Match3-SDK/releases) - **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](https://docs.unity3d.com/Manual/upm-ui-tarball.html) > **Note:** Dependencies must be installed before installing the packages. ## :rocket: 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](https://user-images.githubusercontent.com/28132516/160287440-7c0eba00-c704-4cc1-959c-5044ad924e95.png) > **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`. ```csharp public class ItemsSlideOutJob : Job { private const float FadeDuration = 0.15f; private const float SlideDuration = 0.2f; private readonly IEnumerable _items; public ItemsSlideOutJob(IEnumerable 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`. ```csharp public class ItemsSlideInJob : Job { private const float FadeDuration = 0.15f; private const float SlideDuration = 0.2f; private readonly IEnumerable _items; public ItemsSlideInJob(IEnumerable 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`. We'll need an `IUnityGameBoardRenderer` to transform grid positions to world positions and an `IItemsPool` to get the pre-created items from the pool. Let's pass them to the constructor. ```csharp public class SidewayFillStrategy : IBoardFillStrategy { private readonly IItemsPool _itemsPool; private readonly IUnityGameBoardRenderer _gameBoardRenderer; public SidewayFillStrategy(IUnityGameBoardRenderer gameBoardRenderer, IItemsPool itemsPool) { _itemsPool = itemsPool; _gameBoardRenderer = gameBoardRenderer; } public string Name => "Sideway Fill Strategy"; public IEnumerable GetFillJobs(IGameBoard gameBoard) { throw new NotImplementedException(); } public IEnumerable GetSolveJobs(IGameBoard gameBoard, SolvedData solvedData) { throw new NotImplementedException(); } } ``` Then let's implement the `GetFillJobs` method. This method is used to fill the playing field. ```csharp public IEnumerable GetFillJobs(IGameBoard gameBoard) { // List of items to show. var itemsToShow = new List(); 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. ```csharp public IEnumerable GetSolveJobs(IGameBoard gameBoard, SolvedData solvedData) { // List of items to hide. var itemsToHide = new List(); // List of items to show. var itemsToShow = new List(); // 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. ```csharp public class AppContext : MonoBehaviour, IAppContext { ... private IBoardFillStrategy[] GetBoardFillStrategies(IUnityGameBoardRenderer gameBoardRenderer, IItemsPool itemsPool) { return new IBoardFillStrategy[] { ... new SidewayFillStrategy(gameBoardRenderer, itemsPool) }; } ... } ```
Video Demonstration
https://user-images.githubusercontent.com/28132516/160768194-4dd0688d-b91c-4130-94fc-a49e951898db.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`. ```csharp public class CollectItems : LevelGoal { 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 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`. ```csharp public class LevelGoalsProvider : ILevelGoalsProvider { public LevelGoal[] GetLevelGoals(int level, IGameBoard gameBoard) { return new LevelGoal[] { ... 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`. First of all, we have to declare an array of lookup directions. ```csharp public class SquareShapeDetector : ISequenceDetector { 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 GetSequence(IGameBoard gameBoard, GridPosition gridPosition) { throw new NotImplementedException(); } } ``` Then let's implement the `GetSequence` method. ```csharp public ItemSequence GetSequence(IGameBoard gameBoard, GridPosition gridPosition) { var sampleGridSlot = gameBoard[gridPosition]; var resultGridSlots = new List(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(GetType(), resultGridSlots) : null; } ``` Finally, add the `SquareShapeDetector` to the sequence detector list in the `AppContext` class. ```csharp public class AppContext : MonoBehaviour, IAppContext { ... private ISequenceDetector[] GetSequenceDetectors() { return new ISequenceDetector[] { ... 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. ```csharp public enum TileGroup { Unavailable = 0, Available = 1, Ice = 2, Stone = 3 } ``` Create a class `StoneState` and inherit from the `StatefulGridTile`. ```csharp 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` interface. Create a `StoneItemDetector` class and inherit from the `ISpecialItemDetector`. ```csharp public class StoneItemDetector : ISpecialItemDetector { private readonly GridPosition[] _lookupDirections; public StoneItemDetector() { _lookupDirections = new[] { GridPosition.Up, GridPosition.Down, GridPosition.Left, GridPosition.Right }; } public IEnumerable GetSpecialItemGridSlots(IGameBoard 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. ```csharp public class AppContext : MonoBehaviour, IAppContext { ... private ISpecialItemDetector[] GetSpecialItemDetectors() { return new ISpecialItemDetector[] { ... 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](https://user-images.githubusercontent.com/28132516/164171867-3f8b90bf-98d0-482f-bd9e-8d5209932398.png)
Configure the `StoneTilePrefab` by adding the `StoneState` script to it and filling in a `State Sprite Names` list. ![ConfigureStoneTilePrefab](https://user-images.githubusercontent.com/28132516/164176865-91287ec1-abf5-4c9b-989f-4ee52b2c2630.png) > **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
https://user-images.githubusercontent.com/28132516/164196506-80ebe446-7a7a-4ae6-930c-46586f1b2c25.mp4
## :bookmark_tabs: Contributing You may contribute in several ways like creating new features, fixing bugs or improving documentation and examples. ### Discussions Use [discussions](https://github.com/ChebanovDD/Match3-SDK/discussions) to have conversations and post answers without opening issues. Discussions is a place to: * Share ideas * Ask questions * Engage with other community members ### Report a bug If you find a bug in the source code, please [create bug report](https://github.com/ChebanovDD/MatchSweets/issues/new?assignees=ChebanovDD&labels=bug&template=bug_report.md&title=). > Please browse [existing issues](https://github.com/ChebanovDD/MatchSweets/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](https://github.com/ChebanovDD/MatchSweets/issues/new?assignees=ChebanovDD&labels=enhancement&template=feature_request.md&title=). > 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 :star: if this project helped you! Buy Me A Coffee ## :balance_scale: License Usage is provided under the [MIT License](LICENSE).