Board Game Programming Tutorial – View

Since we follow the Model/View/Controller design pattern, the View contains the graphical elements that make up the user interface, but no knowledge about the state of the game. All the View classes have a draw() function which draws the GUI to match the state of the game. The View classes also have the OnClick() callbacks for handling player input.

The View classes are also responsible for any animation that is needed. The Event.Act functions call these animation functions to move the pieces.

For player input, the View enables and disables buttons on the GUI based on the state of the game and then adds events to the controller when the buttons are pressed. Enabling/disabling the buttons allows me to use Unity’s button animation or sprite swaps to display what input is allowed to the players.

The View classes obviously depend a lot on the specifics of the game, but I’ve developed a convention to always have a MainMenu and GameGUI classes to match the MainMenu and Game canvases. I also always have a PlayerGUI for drawing the player areas during the game and a PlayerLoginArea for drawing the player areas on the main menu.

MainMenu

The MainMenu displays the “Start”/”Resume”/”Load Game” buttons, all the game options and the credits. It also has the code for populating the load screen. We always save games in <Application.presistentDataPath>/savedGames. Each game gets a directory with the date/time that the game was started. Inside the directory are three files; the Events, the Pending events and the Screenshot.

The other function of this class is to manage the player colors. My convention is to display all the possible colors and then, once the player joins the game, their selected color in a larger icon above the color options. If another player selects the same color, the color is given to the new player and the original player gets the next free color.

When there are enough players, the “Start” button turns on. When the button is pressed, this class creates “EAddPlayer” events for each player and an EInitialize event with any game options

public class MainMenu : MonoBehaviour {

  public GameObject LoadCanvas;
  public GameObject MainMenuCanvas;

  public Button PlayButton;
  public Button ResumeButton;

  public GameObject CreditsDialog;
  public GameObject SavedGameEntryPrefab;

  // Use this for initialization
  public PlayerLoginArea[] PlayerLoginAreas;
  HashSet&amp;lt;Player.PlayerColors&amp;gt; _freeColors = new HashSet&amp;lt;Player.PlayerColors&amp;gt;();

  void Start() {
    init();
  }
  public void init()
  {
    // This code will only execute during development. If the MainMenuGUI is turned off, just skip all this
    // and let the GameGUI load the last game.
    if (!MainMenuCanvas.activeInHierarchy)
      return;

    LoadCanvas.SetActive(false);
    setPlayButtonState();

    if (PlayerPrefs.GetString(Game.PlayerPrefSettings.LAST_FILE_LOADED.ToString(), "NONE") == "NONE")
      ResumeButton.interactable = false;

    foreach (Player.PlayerColors color in Enum.GetValues(typeof(Player.PlayerColors)))
      _freeColors.Add(color);

    foreach (PlayerLoginArea area in PlayerLoginAreas)
      area.reset();

    AudioPlayer.PlayClip(AudioPlayer.AudioClipEnum.INTRO, 1);
  }

  public void OnPlayButton()
  {
    // Stop playing the intro music
    AudioPlayer.Stop();
    AudioPlayer.PlayClip(AudioPlayer.AudioClipEnum.CLICK);

    // Turn off the main menu and start the game
    MainMenuCanvas.SetActive(false);
    GameGUI.theGameGUI.startOrLoadGame();

    // Init the timeline with initial events
    Timeline.theTimeline.reset();
    Timeline.theTimeline.addEvent(new EResetGame());
    foreach (PlayerLoginArea area in PlayerLoginAreas)
    {
      if (area.isPlaying())
        Timeline.theTimeline.addEvent(new EAddPlayer(area.Position, area.Color.Value));
    }

    EInitialize initEvt = new EInitialize();

    Timeline.theTimeline.addEvent(initEvt);

    string saveName = Timeline.theTimeline.save(null);
    PlayerPrefs.SetString(Game.PlayerPrefSettings.LAST_FILE_LOADED.ToString(),
      saveName);
  }

  public void OnLoadButton()
  {
    AudioPlayer.PlayClip(AudioPlayer.AudioClipEnum.CLICK);
    // Bring up (or hide) the Load screen
    MainMenuCanvas.SetActive(!MainMenuCanvas.activeInHierarchy);
    LoadCanvas.SetActive(!LoadCanvas.activeInHierarchy);

    if (LoadCanvas.activeInHierarchy)
      StartCoroutine(populateSavedGameList());
    else
      setPlayButtonState();
  }
  public void OnResumeButton()
  {
    loadGame(PlayerPrefs.GetString(Game.PlayerPrefSettings.LAST_FILE_LOADED.ToString()));
  }
  public void OnQuitButton()
  {
#if UNITY_EDITOR
    UnityEditor.EditorApplication.isPlaying = false;
#else
    Application.Quit();
#endif
  }

  IEnumerator populateSavedGameList()
  {
    Transform savedGameList = LoadCanvas.transform.FindChild("Panel/Scroll View/Viewport/Content");
    savedGameList.gameObject.DestroyChildren();

    Directory.CreateDirectory(Application.persistentDataPath + "/savedGames");
    foreach (string dirName in Directory.GetDirectories(Application.persistentDataPath + "/savedGames").Reverse() )
    {
      DateTime gameStart = DateTime.MinValue;
      string timeString;
      string strippedDirName;
      try
      {
        strippedDirName = dirName.Split('\\').Last();
        string[] dateParts = strippedDirName.Split('_'); // yyyy_MM_dd_HH_mm_ss
        int year = int.Parse(dateParts[0]);
        int month = int.Parse(dateParts[1]);
        int day = int.Parse(dateParts[2]);
        int hour = int.Parse(dateParts[3]);
        int minute = int.Parse(dateParts[4]);
        int second = int.Parse(dateParts[5]);
        gameStart = new DateTime(year, month, day, hour, minute, second);
        timeString = " @ ";
        if (hour &amp;lt; 10) timeString += "0" + hour; else timeString += hour;
        timeString += ":";
        if (minute &amp;lt; 10) timeString += "0" + ((minute / 5) * 5); else timeString += ((minute / 5) * 5);
      }
      catch (Exception)
      {
        Debug.Log("Skipping directory: " + dirName);
        //Debug.LogException(e);
        continue;
      }
      DateTime midnightToday = DateTime.Today;
      GameObject saveEntry = Instantiate(SavedGameEntryPrefab);
      saveEntry.transform.SetParent(savedGameList);
      saveEntry.transform.localScale = Vector3.one;

      RawImage screenCapture = saveEntry.transform.FindChild("Screen Capture").GetComponent&amp;lt;RawImage&amp;gt;();
      Text dateText = saveEntry.transform.FindChild("Date Text").GetComponent&amp;lt;Text&amp;gt;();
      Button loadButton = saveEntry.GetComponent&amp;lt;Button&amp;gt;();

      if (gameStart &amp;gt;= midnightToday)
        dateText.text = "Today" + timeString;
      else if (gameStart &amp;gt;= midnightToday.AddDays(-1))
        dateText.text = "Yesterday" + timeString;
      else if (gameStart &amp;gt;= midnightToday.AddDays(-6))
        dateText.text = gameStart.DayOfWeek.ToString() + timeString;
      else
        dateText.text = gameStart.ToString("d MMM yy") + timeString;

      loadButton.onClick.AddListener(() =&amp;gt; loadGame(strippedDirName));

      if (File.Exists(dirName + "/Snapshot.png"))
      {
        string url = "file:///" + dirName.Replace("\\", "/").Replace(" ", "%20") + "/Snapshot.png";
        var www = new WWW(url);
        yield return www;
        Texture2D tex = new Texture2D(www.texture.width, www.texture.height);
        www.LoadImageIntoTexture(tex);
        screenCapture.texture = tex;
      }
    }
  }
  public void loadGame(string name)
  {
    // Stop the intro music
    AudioPlayer.Stop();
    PlayerPrefs.SetString(Game.PlayerPrefSettings.LAST_FILE_LOADED.ToString(), name);
    // Startup the game
    MainMenuCanvas.SetActive(false);
    LoadCanvas.SetActive(false);
    GameGUI.theGameGUI.startOrLoadGame();
    // Init the timeline with the saved game
    Timeline.theTimeline.reset();
    Timeline.theTimeline.reprocessEvents(Timeline.load(name));
  }
  public void onCreditsClick()
  {
    CreditsDialog.SetActive(!CreditsDialog.activeInHierarchy);
  }
  public void assignColorToPlayer(int position, Player.PlayerColors color)
  {

    if (_freeColors.Contains(color))
    {
      _freeColors.Remove(color);
      if (PlayerLoginAreas[position].Color.HasValue)
        _freeColors.Add(PlayerLoginAreas[position].Color.Value);
    }
    else
    {
      Player.PlayerColors replacementColor;
      PlayerLoginArea otherArea = PlayerLoginAreas.First(a =&amp;gt; a.Color == color);
      if (PlayerLoginAreas[position].Color.HasValue)
        replacementColor = PlayerLoginAreas[position].Color.Value;
      else
      {
        replacementColor = _freeColors.First();
        _freeColors.Remove(replacementColor);
      }

      otherArea.Color = replacementColor;
    }
    PlayerLoginAreas[position].Color = color;
    setPlayButtonState();
  }
  public void assignFreeColorToPlayer(int position)
  {
    PlayerLoginAreas[position].Color = _freeColors.First();
    _freeColors.Remove(PlayerLoginAreas[position].Color.Value);
    setPlayButtonState();
  }
  public void releaseColorFromPlayer(int position)
  {
    _freeColors.Add(PlayerLoginAreas[position].Color.Value);
    PlayerLoginAreas[position].Color = null;
    setPlayButtonState();
  }
  void setPlayButtonState()
  {
    int count = PlayerLoginAreas.Count(o =&amp;gt; o.isPlaying());
    PlayButton.interactable = count &amp;gt;= PlayerList.MIN_PLAYERS &amp;amp;&amp;amp;
      count &amp;lt;= PlayerList.MAX_PLAYERS;
  }
}

PlayerLoginArea

The PlayerLoginArea draws the available colors, the player’s selected color (if they have joined) and has Join/Leave buttons.

This class breaks the pure Model/View/Controller pattern and stores which players have joined and what color they have picked. The Timeline isn’t running yet during this part of the game, and the state is simple enough to not need a real model and controller to manage it.

public class PlayerLoginArea : MonoBehaviour {

  public MainMenu MainMenu;
  public GameObject ChosenColor;
  public int Position;

  // Use this for initialization
  void Start() {
    reset();
  }

  public void reset()
  {
    _color = null;
    ChosenColor.SetActive(_color.HasValue);
    int i = 0;
    foreach (Image colorImg in transform.FindChild("colorButtons").GetComponentsInChildren&amp;lt;Image&amp;gt;())
    {
      colorImg.color = GameGUI.SolidColors[i++];
    }
  }
  public Player.PlayerColors? Color
  {
    get {return _color;}
    set
    {
      if (value != null)
      {
        Transform color = transform.FindChild("colorButtons").GetChild((int)value);
        ChosenColor.GetComponent&amp;lt;Image&amp;gt;().color = color.GetComponent&amp;lt;Image&amp;gt;().color;
      }
      _color = value;
    }
  }
  Player.PlayerColors? _color;
  public void OnColorClicked(int colorIndex)
  {
    AudioPlayer.PlayClip(AudioPlayer.AudioClipEnum.CLICK);
    ChosenColor.SetActive(true);
    gameObject.GetComponentOnChild&amp;lt;Text&amp;gt;("joinArea/Text").text = "Tap to leave game";
    MainMenu.assignColorToPlayer(Position, (Player.PlayerColors)colorIndex);
  }
  public void OnJoinClicked()
  {
    AudioPlayer.PlayClip(AudioPlayer.AudioClipEnum.CLICK);
    ChosenColor.SetActive(!ChosenColor.activeSelf);
    gameObject.GetComponentOnChild&amp;lt;Text&amp;gt;("joinArea/Text").text = 
      (ChosenColor.activeSelf ? "Tap to leave game" : "Tap to join game");
    if (ChosenColor.activeSelf)
      MainMenu.assignFreeColorToPlayer(Position);
    else
      MainMenu.releaseColorFromPlayer(Position);
  }
  public bool isPlaying()
  {
    return ChosenColor.activeSelf;
  }
}

GameGUI

The GameGUI is the main view class for running the game. It will often have sub-classes responsible for drawing different areas of the board.

This class manages three canvases. They are the main game canvas, a load overlay that is displayed while the GUI is being drawn and the game loaded so that players don’t see any graphics artifacts while the GUI is populated and laid out, and an Animation canvas that is used to draw moving pieces above the rest of the board. This is overkill for many games, but using the same standard for all games maximizes the amount of reuse.

This class also holds lists of Sprites for the rest of the View to use when drawing and animating, and lists of colors for the players. I tend to use the same set of colors for the players in all games, so I leave these color definitions here.

This class provides callbacks for the buttons that are on the main game screen: “Save and Exit” and “Undo”

using UnityEngine;
using System.Collections.Generic;
using System.Linq;
using UnityEngine.UI;
using UniExtensions;
using UnityEngine.Events;
using DG.Tweening;
using UnityEngine.SceneManagement;
using System;

public class GameGUI : MonoBehaviour {

  public static GameGUI theGameGUI = null;

  //public ScoreboardGUI Scoreboard;

  public GameObject LoadOverlay;
  public GameObject GameCanvas;
  public GameObject AnimationCanvas;

  public List&amp;lt;PlayerGUI&amp;gt; PlayerPads;

  public Sprite[] Pieces;
  public Sprite[] RibbonSprites;

  public static Color[] LightColors = { new Color(1,0.3f,0.3f), new Color(0.3f,1,0.3f), new Color(0.3f,0.3f,1),
    new Color(1,1,0.3f), new Color(0.3f,1,1), new Color(1,0.3f,1), new Color(1,180f/255f,50f/255f) };

  public static Color[] SolidColors = {
    new Color(1,0f,0f), new Color(0f,1,0f), new Color(0f,0f,1),
    new Color(1,1,0f), new Color(0f,1,1), new Color(1,0f,1), new Color(1,140f/255f,0) };
  public static Color[] VeryLightColors =
  {
    new Color(1,0.8f,0.8f), new Color(0.8f,1,0.8f), new Color(0.8f,0.8f,1),
    new Color(1,1,0.8f), new Color(0.8f,1,1), new Color(1,0.8f,1), new Color(1,200f/255f,100f/255f) };

  public static GameObject cloneOnCanvas(GameObject source)
  {
    GameObject movingResource = Instantiate(source);
    movingResource.SetActive(true);
    movingResource.transform.SetParent(GameGUI.theGameGUI.AnimationCanvas.transform, false);
    movingResource.transform.rotation = source.transform.rotation;
    movingResource.GetComponent<RectTransform>().anchorMin = new Vector2(0.5f, 0.5f);
    movingResource.GetComponent<RectTransform>().anchorMax = new Vector2(0.5f, 0.5f);
    movingResource.GetComponent<RectTransform>().pivot = new Vector2(0.5f, 0.5f);
    movingResource.transform.position = source.transform.position;
    movingResource.GetComponent<RectTransform>().sizeDelta = source.GetComponent<RectTransform>().rect.size;
    movingResource.GetComponent<RectTransform>().localScale = new Vector3(1, 1, 1);
    return movingResource;
  }

  void Awake()
  {
    theGameGUI = this;
  }
  // Use this for initialization
  void Start () {
    // If I am active, load the last game. This only happens during development. For a real build,
    // the GameCanvas is hidden and the MainMenu is active.
    if (GameCanvas.activeSelf)
    {
      GetComponent&amp;lt;MainMenu&amp;gt;().loadGame(PlayerPrefs.GetString(Game.PlayerPrefSettings.LAST_FILE_LOADED.ToString()));
    }
    GameCanvas.SetActive(false);
    LoadOverlay.SetActive(false);
  }

  void OnDestroy () {
    theGameGUI = null;
  }

  public static Sprite pieceSprite(Game.PieceType type)
  {
    if ((int)type &amp;gt;= theGameGUI.Pieces.Length)
    {
      Debug.LogError("Requesting piece sprite that doesn't exist: " + type.ToString());
      return null;
    }
    return theGameGUI.Pieces[(int)type];
  }
  public static Sprite ribbonSprite(int place)
  {
    if (place &amp;lt; 0 || place &amp;gt;= theGameGUI.RibbonSprites.Length)
    {
      Debug.LogError("Requesting ribbon sprite that doesn't exist: " + place);
      return null;
    }
    return theGameGUI.RibbonSprites[place];
  }

  public static PlayerGUI currentPlayerPad()
  {
    if (Game.theGame.CurrentPlayer == null )
    {
      Debug.LogError("Requesting playerGUI for the current player which hasn't been set");
      return null;
    }
    return playerPadForPlayer(Game.theGame.CurrentPlayer);
  }
  public static PlayerGUI playerPadForPlayer(Player player)
  {
    return playerPadForPosition(player.Position);
  }
  public static PlayerGUI playerPadForPosition(int position)
  {
    PlayerGUI retVal = theGameGUI.PlayerPads.FirstOrDefault(p =&amp;gt; p.Position == position);
    if (retVal == null)
      Debug.LogError("Requesting playerGUI for player at position " + position + " which doesn't exist.");
    return retVal;
  }
  public void startOrLoadGame()
  {
    GameCanvas.SetActive(true);
    LoadOverlay.SetActive(true);
  }
  public void draw()
  {
    LoadOverlay.SetActive(Game.theGame.CurrentGameState == Game.GameState.LOGIN);
    GameCanvas.SetActive(Game.theGame.CurrentGameState != Game.GameState.LOGIN);
    // Scoreboard.draw()

    foreach (PlayerGUI pad in theGameGUI.PlayerPads)
    {
      pad.draw();
    }
  }
  public void saveAndExit()
  {
    AudioPlayer.PlayClip(AudioPlayer.AudioClipEnum.CLICK);

    Timeline.theTimeline.save(PlayerPrefs.GetString(Game.PlayerPrefSettings.LAST_FILE_LOADED.ToString()));
    Timeline.theTimeline.saveScreenshot(PlayerPrefs.GetString(Game.PlayerPrefSettings.LAST_FILE_LOADED.ToString()));
    SceneManager.LoadScene(0);
  }
  public void undoAction()
  {
    AudioPlayer.Stop();
    StopAllCoroutines();
    AudioPlayer.PlayClip(AudioPlayer.AudioClipEnum.CLICK);
    DOTween.CompleteAll(true);
    Dictionary&amp;lt;int, float&amp;gt; times = new Dictionary&amp;lt;int, float&amp;gt;();
    foreach (Player p in PlayerList.Players)
      times[p.Position] = p.ActingTime;
    Timeline.theTimeline.undo();
    foreach ( var kvp in times )
      PlayerList.playerAtPosition(kvp.Key).ActingTime = kvp.Value;
  }

  // During development, I'll often add a save button to the screen so that I can quickly
  // see that the save file looks correct
  public void save()
  {
    Timeline.theTimeline.save(PlayerPrefs.GetString(Game.PlayerPrefSettings.LAST_FILE_LOADED.ToString()));
  }
}

PlayerGUI

The PlayerGUI replaces the PlayerLoginArea during the game but provides the same capability of drawing the player area.

The PlayerGUI stores the Player by their position instead of by pointer. When a move is undone a new Player class is created, so I try not to store any pointers to Players.

Many games end up having to bring up a dialog for the Player to provide input. I keep all player dialogs together in a prefab that is instantiated at startup and added to the dialog canvas. That keeps the dialogs above the rest of the board.

The drawLater function is a standard feature of most GUI classes and allows that section of the board to be re-drawn at a later time. This is used for animations where a piece is moving from one place on the board to another. During the animation, the part of the GUI receiving the moving piece isn’t re-drawn to match the model(which was already updated by the Controller) till the new piece arrives. For example, say Player A pays Player B one chip. The Model is updated immediately, but Player B’s GUI isn’t redrawn while the chip is animated from A to B.

public class PlayerGUI : MonoBehaviour {

  Player _player = null;
  public Player Player
  {
    get { if (_player == null) _player = PlayerList.playerAtPosition(Position); return _player; }
    set { _player = value; }
  }

  public int Position;

  public GameObject PlayerDialogPrefab;

  [HideInInspector]
  public PlayerDialogGUI DialogGUI;

  public void init()
  {
    _player = null;
    string dialogsName = "playerDialogs_" + Position;
    Transform dialogCanvas = GameObject.FindGameObjectWithTag("dialogCanvas").transform;
    if (!dialogCanvas.FindChild(dialogsName))
    {
      GameObject dialogs = Instantiate(PlayerDialogPrefab);
      dialogs.name = dialogsName;
      dialogs.transform.SetParent(dialogCanvas, false);
      dialogs.transform.SetSiblingIndex(0);
      RectTransform newRect = dialogs.GetComponent&amp;lt;RectTransform&amp;gt;();
      RectTransform oldRect = transform.GetComponent&amp;lt;RectTransform&amp;gt;();
      newRect.anchorMax = oldRect.anchorMax;
      newRect.anchorMin = oldRect.anchorMin;
      newRect.offsetMax = oldRect.offsetMax;
      newRect.offsetMin = oldRect.offsetMin;
      newRect.pivot = oldRect.pivot;
      newRect.anchoredPosition = oldRect.anchoredPosition;
      newRect.rotation = oldRect.rotation;

      DialogGUI = dialogs.GetComponent&amp;lt;PlayerDialogGUI&amp;gt;();
      DialogGUI.ParentGUI = this;
    }
  }

  bool _isAnimating = false;
  public void draw()
  {
    if (_isAnimating) return;
    if (Player == null) return;

    // Some part of the player GUI is usually colored with the player's color
    //GetComponent&amp;lt;Image&amp;gt;().color = Player.veryLightColor();

    DialogGUI.draw();
  }
  public void drawLater(float time)
  {
    _isAnimating = true;
    this.ExecuteLater(time, doneAnimating);
  }
  void doneAnimating()
  {
    _isAnimating = false;
    draw();
  }
  public void OnHelpClick()
  {
    DialogGUI.showHelp();
  }
}

PlayerDialogGUI

The PlayerDialogGUI draws any dialog that is presented to the player. Each player has their own set of dialogs so that multiple players can interact with dialogs at the same time. A typical game may have several input dialogs, and a help dialog. Simple games may just have the Ribbon that is displayed at the end of the game.

public class PlayerDialogGUI : MonoBehaviour {

  PlayerGUI _parentGUI = null;
  public PlayerGUI ParentGUI { set {
      _parentGUI = value;
    } get { return _parentGUI; } }

  public Image Ribbon;
  public GameObject HelpDialog;

  void Awake()
  {
    Ribbon.gameObject.SetActive(false);
    HelpDialog.SetActive(false);
  }
  public void showHelp()
  {
    HelpDialog.SetActive(!HelpDialog.activeInHierarchy);
  }
  public void draw()
  {
    gameObject.SetActive(true);
    Ribbon.gameObject.SetActive(Game.theGame.CurrentGameState == Game.GameState.GAME_OVER);
    Ribbon.sprite = GameGUI.ribbonSprite(ParentGUI.Player.Place);
  }
}