It’s a little funny that we’ve come so far into a series on making a board game and still haven’t even looked at the game board. In this lesson we will fix that. In addition, we’ll make some pawns, and actually implement the ability to “roll” and move your pawn around the board on your turn.
Board
The board script already exists in its entirety in your project, because I left placeholder variables that were connected in the inspector in-tact. This simple script merely has two public fields, one of which isn’t even used anymore. The “tilePrefab” was used with some early code that helped programmatically generate the board which I then saved as a prefab in the project. The GameViewController holds a reference to this prefab and will instantiate it for us later. The other field, which is actually useful, is the list of tiles. We will need access to these in order to determine if anything special exists wherever a player’s pawn is located.
public class Board : MonoBehaviour { public GameObject tilePrefab; public List<Transform> tiles = new List<Transform>(); }
Pawn
The pawn script and prefab is also already fully completed in your project just like the board. Pawns have a simple structure made up of three Game Objects each parented to the next. The root object “Pawn” will be used to position the pawn on the horizontal axis as it moves from tile to tile. The next child, “Jumper”, allows me to animate the “Pawn” moving up and down on the vertical axis without having to worry about calculating intermediate postions between tiles on the horizontal axis. Finally, the “Avatar” at the end has a sprite renderer that actually shows the sprite that represents the pawn. Since the avatar is on its own object we can also easily provide offsets as necessary to make it stand on the ground. In this case, all of the Pokemon sprites had their anchor in the middle of the image instead of at their feet, so without this offset they would look like they were buried waist deep in the ground.
The script is simple, and merely holds references to the Transform of the “Jumper” object for convenience when animating, as well as to the SpriteRenderer to make it easier to swap out the sprite we are showing.
public class Pawn : MonoBehaviour { public Transform jumper; public SpriteRenderer spriteRenderer; }
Data Controller
Much like the game, our board holds important data that will potentially need to be used all over the project. To make it easily accessible, we will store it in the Data Controller.
public Board board;
Base View Controller
We wont need to do much here. I simply want to add a reference to the board. This property is simply a wrapper around the DataController’s field, much like we did for the game. I allowed this to be “set” as well because the “GameViewController” currently holds the reference to the prefab that will be instantiated and shared everywhere else.
protected Board board { get { return DataController.instance.board; } set { DataController.instance.board = value; } }
Game View Controller
I’ve used the “View Controller” postfix naming convention for several of my screens so far in the app. I used it again here although it may not be what you were expecting. The “view” in this case is the 3d view of the board and pawns. This controller will handle instantiating and configuring anything that needs to appear related to the board, as well as handle the animation of moving pawns around the board.
This class is pretty lengthy, so I’m going to break it down into smaller chunks. To begin, I modified the base class to be “BaseViewController”. I don’t need any of the transition stuff, but other references such as to the “Game” instance will be handy to have.
public class GameViewController : BaseViewController { // ...Other code in here }
I left the fields mostly in tact with what had already been there, although I added a “moveComplete” action. I also removed the lazily loaded property for the board since I want the base class to have a property by the same name. Finally, I added a const called “moveDuration” to represent the time in seconds that it will take to animate a pawn moving from one square to another.
public Action moveComplete; public Transform cameraRig; public Board boardPrefab; public SetPooler pawnPool; public List<Pawn> pawns = new List<Pawn>(); const float moveDuration = 0.5f;
We only need to instantiate one game board, regardless of how many times we play the game, because it can be modified as necessary to reflect any new game settings. The “Awake” method is only called once, and it is called early, so it will be a good location to make sure that the board is created and available for use.
void Awake () { board = Instantiate(boardPrefab).GetComponent<Board>(); }
After we have gone through the game setup flow and determined the number of players, as well as player names and Pokemon buddies, we will complete setup by loading the views necessary to reflect those choices. I have provided the public method “LoadGame” to complete this process.
public void LoadGame () { LoadPlayers (); // TODO: Load Gyms NextPlayer (); }
I broke the steps of “LoadGame” into sub methods as steps for clarity. The first step is to load the pawns for the players. There, we begin with another sub method to set the number of players, then loop thru each player to update the avatar to match the starting pokemon. Finally we move the pawn to the starting tile, or if we are resuming a previous game, it will be whatever tile the player was last on.
void LoadPlayers () { SetPlayerCount (game.players.Count); for (int i = 0; i < game.players.Count; ++i) { UpdateBuddy (i); var location = game.players [i].tileIndex; pawns[i].transform.ResetParent(board.tiles[location]); } }
The “SetPlayerCount” method makes sure that the number of active pawns matches the number of players in the current game. It begins by clearing any previous pawns, and then adds a new pawn per player.
void SetPlayerCount (int count) { ClearPawns(); for (int i = 0; i < count; ++i) { AddPawn (); } }
Our GameViewController was configured to use a pooler. It might be a bit of overkill for such a simple game, but basically it means that you dont need to constantly destroy and recreate objects – once they are created they can be pooled for reuse later. You can read more about poolers in my 4 part series here.
When I call “ClearPawns” I first need to make sure that the camera is no longer parented to any of them. Otherwise, the game camera would end up disabled as the Pawn entered the pool. Then I use a method on the pooler to enqueue all of the pawns it had created. Finally I clear the local list of references to the active pawns.
void ClearPawns () { cameraRig.ResetParent (null); pawnPool.EnqueueAll(); pawns.Clear(); }
When I call “AddPawn” I grab an instance from the pooler, and activate it then position it at a default location on the board. Finally I cache the reference to its Pawn component in my own list of active pawns.
void AddPawn () { Poolable item = pawnPool.Dequeue(); item.gameObject.SetActive (true); item.transform.ResetParent(board.tiles[0]); pawns.Add(item.GetComponent<Pawn>()); }
The “UpdateBuddy” method handles the fine details of matching a Pawn’s avatar to the primary Pokemon of a player. If the player has no Pokemon then the method will abort early. Once a sprite has been set to the avatar, I also examine the bounds of the sprite so that I can offset it vertically and make sure it stands on the ground. This method is public, because it can also be called whenever a player modifies their buddy on the Team management screen.
public void UpdateBuddy (int index) { var player = game.players [index]; if (player.pokemon.Count == 0) return; var pokemon = game.players[index].pokemon[0]; var pawn = pawns[index]; pawn.spriteRenderer.sprite = pokemon.GetAvatar(); var yPos = GetMinYPos(pawn.spriteRenderer.sprite); pawn.spriteRenderer.transform.localPosition = new Vector3(0, -yPos, 0); }
To determine the bounds of the sprite I simply loop over the “vertices” property of the sprite. I store the minimum y-value and return it:
float GetMinYPos (Sprite sprite) { float minY = 0; foreach (Vector2 vec in sprite.vertices) { minY = Mathf.Min(vec.y, minY); } return minY; }
Whenever the current player changes in the game, I will need to move the camera to focus on the player’s pawn. I do this by simply reparenting it, which has the added bonus of allowing the pawn to be tracked as it animates across the board. The method is public so that a system or flow controller can activate it as necessary:
public void NextPlayer () { Pawn currentPawn = pawns[game.currentPlayerIndex]; cameraRig.ResetParent(currentPawn.transform); }
Finally we have some methods to handle moving a pawn across the board. Note that this only moves the pawn one tile at a time – this is because after the move to the new tile I check for other activities that can take place along the way. Refer back to the flow charts if necessary.
The actual animation is probably simpler than it looks. Basically I begin by getting a reference to the current player’s pawn and whatever the next tile will be. I use the modulous operator to help me loop around the board as necessary. Once I know what to move and where to move it, I parent-in-place the pawn to the new tile. Then, because the pawn is now offset I can simply animate it back to the origin of its parent (Vector3.zero) to get it where it needs to be.
To handle the little “jump” that happens along the way I use two animations on the pawns “Jumper” transform. One to move it up, and one to move it back down – each with a duration that is half the duration of the horizontal move.
Finally once the move has completed we invoke the new completion action.
public void MovePawn () { StartCoroutine(MoveSequence()); } IEnumerator MoveSequence () { var currentPlayer = game.CurrentPlayer; var currentPawn = pawns[game.currentPlayerIndex]; int nextIndex = (currentPlayer.tileIndex + 1) % board.tiles.Count; Transform nextTile = board.tiles[nextIndex]; currentPawn.transform.SetParent(nextTile, true); currentPawn.transform.MoveToLocal(Vector3.zero, moveDuration, EasingEquations.Linear); Tweener tweener = currentPawn.jumper.MoveToLocal(new Vector3(0, 0.5f, 0), moveDuration / 2.0f, EasingEquations.EaseOutCubic); while (tweener.IsPlaying) yield return null; tweener = currentPawn.jumper.MoveToLocal(Vector3.zero, moveDuration / 2.0f, EasingEquations.EaseInCubic); while (tweener.IsPlaying) yield return null; currentPlayer.tileIndex = nextIndex; if (moveComplete != null) moveComplete(); }
Setup Complete State
Open up the “SetupCompleteState” so we can use it to trigger the GameViewController’s loading. Replace the “TODO” comment with the following:
gameViewController.LoadGame();
Next Player State
Open up the “NextPlayerState” so we can make sure to trigger the GameViewController’s method to keep track of the correct pawn. Replace the “TODO” comment with the following:
gameViewController.NextPlayer ();
Roll View Controller
If you were to play the game now, you would already see the game board and pawns instantiated for each player – pretty cool. Still, you can’t actually do anything with them yet, so let’s extend this lesson a bit more. At the moment we can get to the “Get Set” screen where we can click on a dice button. This should trigger our next screen where we sort of simulate a dice roll by showing a bunch of random numbers in sequence. If you’ve followed along with grabbing the resources that I posted earlier, your screen should now look something like this:
Note that this screen inherits from BaseViewController which means it gets the fancy transition functionality we introduced in the last lesson. When the screen is fully visible we begin a new animation that shows a sequence of 10 random numbers.
After displaying the last number, I pause for a full second to help give the player a chance to read it. Then we update the “destinationTileIndex” of the current player so that the Journey flow will know how far to move it based on our roll. I also award some candies which players will be able to use to power up and evolve their Pokemon. The general idea is that the more you travel, the more you are gaining “experience” and that can be applied as candies accordingly.
public class RollViewController : BaseViewController { public Action didComplete; [SerializeField] Text rollLabel; protected override void DidShow (Action complete) { base.DidShow (complete); StartCoroutine(RollSequence()); } IEnumerator RollSequence () { int roll = 0; for (int i = 0; i < 10; ++i) { roll = UnityEngine.Random.Range(2, 13); rollLabel.text = roll.ToString(); yield return new WaitForSeconds(1.0f / 30.0f); } yield return new WaitForSeconds(1); var currentPlayer = game.CurrentPlayer; currentPlayer.destinationTileIndex = (currentPlayer.tileIndex + roll) % board.tiles.Count; currentPlayer.candies += roll; if (didComplete != null) didComplete(); } }
Roll State
The state responsible for displaying our new “Roll View Controller” is, not surprisingly, the “Roll State”. Hopefully you are comfortable with everything listed here because we have done it all several times before. We simply show the view controller, and then use nested actions so that we can ultimately hide it again and then continue the flow at the right times.
public partial class FlowController : MonoBehaviour { State RollState { get { if (_rollState == null) _rollState = new State(OnEnterRollState, null, "Roll"); return _rollState; } } State _rollState; void OnEnterRollState () { rollViewController.Show (delegate { rollViewController.didComplete = delegate { rollViewController.didComplete = null; rollViewController.Hide(delegate { stateMachine.ChangeState (JourneyState); }); }; }); } }
Get Set State
Open up the “GetSetState” for another quick change. Currently it has a switch which allows it to branch based on the exit that the user selected. When a roll is selected, we had used placeholder code to change state to the “NextPlayerState” – I also marked it with a “TODO” comment that said “RollState”. Well, now that we actually have the state, let’s go ahead and replace that whole line with this one:
stateMachine.ChangeState (RollState);
Journey State
The Journey state is another “wrapper” state pointing to the real first state…
public partial class FlowController : MonoBehaviour { State JourneyState { get { if (_journeyState == null) _journeyState = new State(OnEnterJourneyState, null, "Journey"); return _journeyState; } } State _journeyState; void OnEnterJourneyState () { stateMachine.ChangeState (MoveState); } }
Move State
In this state, which is the first state of our Journey, we will make use of the GameViewController to move the player’s pawn on the board. If you look at the flow chart, after we have moved a tile, we begin looking for other events which could occur such as checking to see if we have visited the Poke Center. For now, I am going to skip all of that and jump straight to the “CheckDestinationState” which will loop back here if needed, or change players otherwise. I left a “TODO” comment to remind us to come back later.
public partial class FlowController : MonoBehaviour { State MoveState { get { if (_moveState == null) _moveState = new State(OnEnterMoveState, OnExitMoveState, "Move"); return _moveState; } } State _moveState; void OnEnterMoveState () { gameViewController.moveComplete = delegate { stateMachine.ChangeState (CheckDestinationState); // TODO: PokeCenterState }; gameViewController.MovePawn(); } void OnExitMoveState () { gameViewController.moveComplete = null; } }
Check Destination State
This state normally appears near the end of the Journey, but I want to implement it now so we can complete a loop of moving pawns around the board. Eventually it will include additional logic to check for game over, but for now it is sufficient to allow a journey to run to completion and then change players.
public partial class FlowController : MonoBehaviour { State CheckDestinationState { get { if (_checkDestinationState == null) _checkDestinationState = new State(OnEnterCheckDestinationState, null, "CheckDestination"); return _checkDestinationState; } } State _checkDestinationState; void OnEnterCheckDestinationState () { Player current = game.CurrentPlayer; if (current.tileIndex == current.destinationTileIndex) { // TODO: Check for game over stateMachine.ChangeState (NextPlayerState); // TODO: CheckWinState } else { stateMachine.ChangeState (MoveState); } } }
Demo
Go ahead and run the game. Configure the game with at least two players so that you can verify that we correctly make new pawns for each, apply avatars, and move the camera between them on their new turn. When you get to the “Get Set” screen, choose the “Roll” button so that you can see our new screen, and to test out the ability of the pawns to be moved around the board.
Summary
This was a bit of a long lesson, but we have really come a long way. It acutally looks like we are playing a board game now, and we can enjoy watching the pawns move around the board! From here on out, we wont really be learning much new in the way of techniques – we will just continue to build on the existing foundation until all the features are fully implemented.
Don’t forget that there is a repository for this project located here. Also, please remember that this repository is using placeholder (empty) assets so attempting to run the game from here is pretty pointless – you will need to follow along with all of the previous lessons first.
can change dice roll by this, it’ll be better for viewer and player 😀
https://www.assetstore.unity3d.com/en/#!/content/165