Create a Jigsaw Puzzle Game in Unity

Create a Jigsaw Board from an Existing Image

In this tutorial, we will learn how to create a Jigsaw game in Unity.

Contact me

This tutorial is the fourth tutorial from a more extensive tutorial on Create a Jigsaw Puzzle Game in Unity.

In Part 3, Create a Jigsaw Board from an Existing Image, we learnt about creating a Jigsaw board from an existing image using the Bezier curve.

Find the GitHub repository of this tutorial at https://github.com/shamim-akhtar/jigsaw-puzzle.


Click to Play the WebGL version of the Jigsaw game (mobile devices may not support it).

Download the Jigsaw game from Google Play.

Part 4: Create a Jigsaw Puzzle Game in Unity

Now that we know how to create Jigsaw tiles and a Jigsaw board using the Bezier curve from an existing image, we will apply that knowledge to create an entire Jigsaw game.

Before we get into the details on the main topic, let’s start a scene and do some basic setup to test and display our features as we move along.

1. The Scene

Please create a new scene in Unity and name it JigsawGame. We will now proceed to implement our Jigsaw game as follows.

  • Refactor/Amend existing BoardGen.cs to cater for reusability
  • Camera manipulation to handle camera movement, zoom-in and zoom-out.
  • Canvas and the menu elements.
  • Jigsaw game data to store game data across scenes
  • Finite State Machine to handle game states
  • Audio
  • Serializing game data

2. Refactor/Amend existing BoardGen.cs to cater for reusability

In our previous tutorial, we implemented the BoardGen script component to handle the creation of Jigsaw tiles. This tutorial will refactor our codes and do some enhancements to ensure that we can reuse the class for our game.

We want to refactor the first thing to park all the codes we had in the Start to a new function named CreateJigsawBoard. We also move the lines 5-7 (highlighted below) immediately after loading the base opaque texture. These three lines were previously in the function CreateJigsawTiles.

public void CreateJigsawBoard() { mBaseSpriteOpaque = LoadBaseTexture(); Texture2D baseTexture = mBaseSpriteOpaque.texture; NumTilesX = baseTexture.width / Tile.TileSize; NumTilesY = baseTexture.height / Tile.TileSize; mGameObjectOpaque = new GameObject(); mGameObjectOpaque.name = ImageFilename + "_Opaque"; mGameObjectOpaque.AddComponent<SpriteRenderer>().sprite = mBaseSpriteOpaque; mGameObjectOpaque.GetComponent<SpriteRenderer>().sortingLayerName = "Opaque"; mBaseSpriteTransparent = CreateTransparentView(); mGameObjectTransparent = new GameObject(); mGameObjectTransparent.name = ImageFilename + "_Transparent"; mGameObjectTransparent.AddComponent<SpriteRenderer>().sprite = mBaseSpriteTransparent; mGameObjectTransparent.GetComponent<SpriteRenderer>().sortingLayerName = "Transparent"; CreateJigsawTiles(); // Hide the mBaseSpriteOpaque game object. mGameObjectOpaque.gameObject.SetActive(false); }
Code language: C# (cs)

Then we call this method from the inside Start method of BoardGen.

protected void Start() { CreateJigsawBoard(); }
Code language: C# (cs)

The second thing that we want to do is make the entire process of creating textures, including the creation of tiles into coroutines. We want to do this so that we can distribute the output of tiles across multiple frames.

There are two advantages of doing so. The first is to ensure that our game does not hang when we create a giant Jigsaw puzzle, and the second is to show some animation while loading. The animation will show the progress of Jigsaw creation to the player.

Coroutines for creating tiles

IEnumerator Coroutine_CreateJigsawTiles() { Texture2D baseTexture = mBaseSpriteOpaque.texture; NumTilesX = baseTexture.width / Tile.TileSize; NumTilesY = baseTexture.height / Tile.TileSize; mTiles = new Tile[NumTilesX, NumTilesY]; mTileGameObjects = new GameObject[NumTilesX, NumTilesY]; for (int i = 0; i < NumTilesX; ++i) { for (int j = 0; j < NumTilesY; ++j) { Tile tile = new Tile(baseTexture); tile.xIndex = i; tile.yIndex = j; // Left side tiles if (i == 0) { tile.SetPosNegType(Tile.Direction.LEFT, Tile.PosNegType.NONE); } else { // We have to create a tile that has LEFT direction opposite operation // of the tile on the left's RIGHT direction operation. Tile leftTile = mTiles[i - 1, j]; Tile.PosNegType rightOp = leftTile.GetPosNegType(Tile.Direction.RIGHT); tile.SetPosNegType(Tile.Direction.LEFT, rightOp == Tile.PosNegType.NEG ? Tile.PosNegType.POS : Tile.PosNegType.NEG); } // Bottom side tiles if (j == 0) { tile.SetPosNegType(Tile.Direction.DOWN, Tile.PosNegType.NONE); } else { // We have to create a tile that has LEFT direction opposite operation // of the tile on the left's RIGHT direction operation. Tile downTile = mTiles[i, j - 1]; Tile.PosNegType rightOp = downTile.GetPosNegType(Tile.Direction.UP); tile.SetPosNegType(Tile.Direction.DOWN, rightOp == Tile.PosNegType.NEG ? Tile.PosNegType.POS : Tile.PosNegType.NEG); } // Right side tiles if (i == NumTilesX - 1) { tile.SetPosNegType(Tile.Direction.RIGHT, Tile.PosNegType.NONE); } else { float toss = Random.Range(0.0f, 1.0f); if (toss < 0.5f) { tile.SetPosNegType(Tile.Direction.RIGHT, Tile.PosNegType.POS); } else { tile.SetPosNegType(Tile.Direction.RIGHT, Tile.PosNegType.NEG); } } // Up side tiles if (j == NumTilesY - 1) { tile.SetPosNegType(Tile.Direction.UP, Tile.PosNegType.NONE); } else { float toss = Random.Range(0.0f, 1.0f); if (toss < 0.5f) { tile.SetPosNegType(Tile.Direction.UP, Tile.PosNegType.POS); } else { tile.SetPosNegType(Tile.Direction.UP, Tile.PosNegType.NEG); } } tile.Apply(); mTiles[i, j] = tile; // Create a game object for the tile. mTileGameObjects[i, j] = Tile.CreateGameObjectFromTile(tile); if (ParentForTiles != null) { mTileGameObjects[i, j].transform.SetParent(ParentForTiles); } } yield return null; } }
Code language: C# (cs)

The above method is the same as that of CreateJigsawTiles. The only differences are in the highlighted lines 1 and 93.

The first change is to change the function to be a coroutine and name the function accordingly. The second change is to yield a return null when we complete one column of Jigsaw tiles.

Now to make use of this coroutine, we create another function similar to CreateJigsawBoard. We name this new function as CreateJigsawBoardUsingCoroutines.

public void CreateJigsawBoardUsingCoroutines() { mBaseSpriteOpaque = LoadBaseTexture(); NumTilesX = mBaseSpriteOpaque.texture.width / Tile.TileSize; NumTilesY = mBaseSpriteOpaque.texture.height / Tile.TileSize; mGameObjectOpaque = new GameObject(); mGameObjectOpaque.name = ImageFilename + "_Opaque"; mGameObjectOpaque.AddComponent<SpriteRenderer>().sprite = mBaseSpriteOpaque; mGameObjectOpaque.GetComponent<SpriteRenderer>().sortingLayerName = "Opaque"; mBaseSpriteTransparent = CreateTransparentView(); mGameObjectTransparent = new GameObject(); mGameObjectTransparent.name = ImageFilename + "_Transparent"; mGameObjectTransparent.AddComponent<SpriteRenderer>().sprite = mBaseSpriteTransparent; mGameObjectTransparent.GetComponent<SpriteRenderer>().sortingLayerName = "Transparent"; StartCoroutine(Coroutine_CreateJigsawBoard()); } IEnumerator Coroutine_CreateJigsawBoard() { yield return StartCoroutine(Coroutine_CreateJigsawTiles()); // Hide the mBaseSpriteOpaque game object. mGameObjectOpaque.gameObject.SetActive(false); LoadingFinished = true; }
Code language: C# (cs)

The above completes our refactoring of the BoardGen.cs file. We now have two ways for us to create the Jigsaw board. The first is to call CreateJigsawBoard, a blocking way, and the second is to call CreateJigsawBoardUsingCoroutines, the non-blocking way. We can use either of them depending on our use case.

See the video below the effect when we use coroutines to create the Jigsaw tiles.

3. Camera manipulation

We will allow the player to pan and zoom the camera to adjust the view accordingly while playing. We will allow the player to pan the camera by holding down the left mouse button and drag.

Note that we also allow the same control to move Jigsaw tiles as well. So how should we differentiate between when we can select and drag a tile and when we can choose and drag the camera?

The answer is simple. We will disable camera panning whenever a tile is selected and dragged. Vice versa, we allow panning of the camera by clicking the mouse on an area where there is no tile or a tile is already in place. See the following video for our desired camera panning behaviour.

Select the Camera game object from the Hierarchy and add a new Script Component. Name it CameraMovement. Select the CameraMovement script, double-click and open in Visual Studio.

Add the following variables.

public float CameraSizeMin = 150.0f; public static bool CameraPanning { get; set; } = true; private Vector3 mDragPos; private Vector3 mOriginalPosition; private float mCameraSizeMax; private float mZoomFactor = 0.0f; private Camera mCamera;
Code language: C# (cs)

Then in the Start method, we set the values of some of these variables.

void Start() { mCamera = Camera.main; // cache the camera mCameraSizeMax = mCamera.orthographicSize; // set the max size mOriginalPosition = mCamera.transform.position; // copy the original position. }
Code language: C# (cs)

Instead of caching the Camera, you could improve the flexibility by allowing the camera to be set in the Editor. I will leave that to you.

Our next task will be to Reposition the camera after we create a Jigsaw board. This function will allow the setting of camera parameters based on how many tiles the Jigsaw has (or the dimension of the Jigsaw board). Let’s go ahead and write the RepositionCamera function.

public void RePositionCamera(int numTilesX, int numTilesY) { // We set the size of the camera. // You can implement your own way of doing this. mCamera.orthographicSize = numTilesX < numTilesY ? numTilesX * 100 : numTilesY * 100; // Set the position of the camera to be at the // centre of the board. mCamera.transform.position = new Vector3( (numTilesX * 100 + 40) / 2, (numTilesY * 100 + 40) / 2, -1000.0f); mCameraSizeMax = mCamera.orthographicSize; mOriginalPosition = mCamera.transform.position; }
Code language: C# (cs)

You can amend the above function and implement it differently. You can experiment and see what fits better.

We will now move on to implement the ZoomIn and ZoomOut functions. These two functions should be easy to implement. Since our camera is Orthographic, we can have the ZoomIn effect by reducing the camera size and ZoomOut by increasing the camera size.

public void Zoom(float value) { mZoomFactor = value; mZoomFactor = Mathf.Clamp01(mZoomFactor); //mSliderZoom.value = mZoomFactor; mCamera.orthographicSize = mCameraSizeMax - mZoomFactor * (mCameraSizeMax - CameraSizeMin); }
Code language: C# (cs)

I have kept the option for us to use a slider for zoom-in and zoom-out. Now we use the above method to implement ZoomIn and ZoomOut procedures as below.

public void ZoomIn() { Zoom(mZoomFactor + 0.01f); } public void ZoomOut() { Zoom(mZoomFactor - 0.01f); }
Code language: C# (cs)

Next we implement ResetCameraView function. This method will reset and revert the values of the camera parameters to the original.

public void ResetCameraView() { mCamera.transform.position = mOriginalPosition; mCamera.orthographicSize = mCameraSizeMax; mZoomFactor = 0.0f; //mSliderZoom.value = 0.0f; }
Code language: C# (cs)

Finally, we implement the Update method, where we will apply the camera panning. Note that we will allow camera panning only when we set the CameraPanning static variable to true. We also check if the pointer interaction is not on a UI element or the component is not enabled.

void Update() { // Camera panning is disabled when a tile is selected. if (!CameraPanning) return; // We also check if the pointer is not on UI item // or is disabled. if (EventSystem.current.IsPointerOverGameObject() || enabled == false) { return; } // Save the position in worldspace. if (Input.GetMouseButtonDown(0)) { mDragPos = mCamera.ScreenToWorldPoint(Input.mousePosition); } if (Input.GetMouseButton(0)) { Vector3 diff = mDragPos - mCamera.ScreenToWorldPoint(Input.mousePosition); diff.z = 0.0f; mCamera.transform.position += diff; } }
Code language: C# (cs)

Save your project. Go to Unity Editor, select the Main Camera from the Hierarchy and set the Camera Size Min field in the Inspector to 150 or any other value you want the minimum size of the camera to be. Note that too small a value will show the pixelation of the tiles.

Click Play and check if the camera panning is working. We can’t verify the camera zoom-in and zoom-out effects now as we have not implemented the UI items to handle zoom-in and zoom-out. For testing, you may add some Input.GetKeyDown events to call ZoomIn and ZoomOut and test the two effects. I will leave that to you.

4. Canvas and UI Items

The Canvas

Our objective for the UI will be to keep it simple and yet should cover all functionality. We want our game to load with a default image. However, we want the player to have the flexibility to choose an image from a set of images and choose a random picture for the Jigsaw.

We will implement the following User Interface for our Jigsaw game.

The user interface for the Jigsaw game

Once the player clicks on the Play button, the Jigsaw tiles will separate, and the UI will change to below.

The user interface for the Jigsaw game – Play mode

You may want to replace the zoom-in and zoom-out from buttons to a slider. To keep the consistency, I will implement these two functions as buttons.

Go ahead and create a Canvas. Set the following parameters to this Canvas.

Add the following elements to the Canvas:

An empty game object for the Top Menu group. Name it TopMenu.
  • An image for the background of the scoreboard. Name it ScoreBoard.
  • An image for the time background. Name it Time.
  • A text under the Time game object. Name ut TextTime.
  • An image for the icon that separates the time and the other scores. Name it Icon.
  • An image for the tiles in place score background. Name it TilesInPlace.
  • A text under the TilesInPlace game object. Name ut TextTilesInPlace.
  • An image for the total number of tiles’ background. Name it TotalTiles.
  • A text under the TotalTiles game object. Name ut TextTotalTiles.
An empty game object for the Bottom Menu group. Name it BottomMenu.
  • A button to go to the image selection menu. Name it Home.
  • A button to zoom in. Name it ZoomIn.
  • A button to reset the camera view. Name it Reset.
  • A button to zoom out. Name it ZoomOut.
  • A button to show the hint. Name it Hint.
  • A button to go to the next random image. Name it NextGame.
A button to start the play. Name it A button to go to the image selection menu. Name it Home..
A text to show the congratulations message when the player completes the Jigsaw. Name it TextWin.

Arrange the menu items as shown in the two pictures above. To get the precise locations, you may get the project from GitHub and open the Unity Project.

The Menu Script

Select the Canvas from the project Hierarchy and add a new Script Component. Name it Menu. Double-click and open it in Visual Studio,

Add the following public variables.

public CameraMovement CameraMovement; public Button BtnHome; public FixedButton BtnZoomIn; public Button BtnReset; public FixedButton BtnZoomOut; public FixedButton BtnHint; public Button BtnPlay; public Button BtnNext; public Text TextTotalTiles; public Text TextTilesInPlace; public Text TextTime; public Text TextWin; // Our game controls when the menu is enabled or disabled. // Enabled = false means that the UI won't handle // inputs. static public bool Enabled { get; set; } = true;
Code language: C# (cs)

Add three delegates. We will use these delegates for three of our button clicks.

public delegate void DelegateOnClick(); public DelegateOnClick OnClickHome; public DelegateOnClick OnClickPlay; public DelegateOnClick OnClickNext;
Code language: C# (cs)

Note that it is not necessary to have delegates here to handle their button clicks. We can directly tie the functions to button clicks. But using delegates reduces the dependency.

SetActivePlayBtn

We want a method that will allow us to set inactive/active the Play button (and other associated buttons) when we are in the Play mode of the game.

public void SetActivePlayBtn(bool flag) { BtnPlay.gameObject.SetActive(flag); BtnNext.gameObject.SetActive(flag); BtnZoomIn.gameObject.SetActive(!flag); BtnReset.gameObject.SetActive(!flag); BtnZoomOut.gameObject.SetActive(!flag); BtnHint.gameObject.SetActive(!flag); }
Code language: C# (cs)

By default, when a player enters the game, we will call SetActivePlayBtn with true as the input parameter. Then, once the player clicks on the Play button, we will call SetActivePlayBtn false as the input parameter. By doing so, we will ensure that we show the right buttons in the right mode.

FixedButton

We will require the functionality of press and hold for some of our buttons. These buttons are ZoomIn, ZoomOut and Hint. We want to be able to press and hold these buttons and not just click.

Please create a new script file and name it FizedButton. Double-click and open the FixedButton.cs file in Visual Studio.

Add the following code.

using UnityEngine; using UnityEngine.EventSystems; public class FixedButton : MonoBehaviour, IPointerUpHandler, IPointerDownHandler { [HideInInspector] public bool Pressed; // Use this for initialization void Start() { } // Update is called once per frame void Update() { } public void OnPointerDown(PointerEventData eventData) { Pressed = true; } public void OnPointerUp(PointerEventData eventData) { Pressed = false; } }
Code language: C# (cs)

Now drag and drop this script file to ZoomIn, ZoomOut and Hint button game objects.

Now we will continue with the remaining Menu script functionalities.

Update Method
// Update is called once per frame void Update() { if (!Enabled) return; if (BtnZoomIn.Pressed) { CameraMovement.ZoomIn(); } if (BtnZoomOut.Pressed) { CameraMovement.ZoomOut(); } }
Code language: C# (cs)

In the Update method, we check if the BtnZoomIn and BtnZoomOut are pressed. As long as they are pressed, we will either zoom in or zoom out.

Add The Helper Function
public void SetTotalTiles(int count) { TextTotalTiles.text = count.ToString(); } public void SetTilesInPlace(int count) { TextTilesInPlace.text = count.ToString(); } public void SetTimeInSeconds(double tt) { System.TimeSpan t = System.TimeSpan.FromSeconds(tt); string time = string.Format("{0:D2}:{1:D2}:{2:D2}", t.Hours, t.Minutes, t.Seconds); TextTime.text = time; }
Code language: C# (cs)

To be continued.

Leave a Reply

Your email address will not be published. Required fields are marked *