Recherche personnalisée

vendredi 23 août 2013

Scriptable Objects in Unity3D - Creating a holder for game level for simple board tile based game.

In this tutorial we will create a simple level holder for a board tile based game and construct a level from the holder object. 

Suppose we have a game that consists of a square board (like Flow) and multiple different elements. In our case elements will be small and large cubes and spheres.

Before starting this tutorial you might want to look at the following articles:

(In this article file and directory names from Unity are formatted like this - MainGameScene, class and method names are formatted like this - GameLevelHolder)

When editing the particular level all the information about the level will be immediately stored into .asset file. For this purpose we use Unity3D Scriptable Objects which are most useful for assets which are only meant to store data.

Let's assume our level will be a square board consisting of different elements of different colors. For instance size can be 3x3, 4x4, 5x5.

Create new Unity project. Save the scene, name it MainGameScene. Make the following folder structure for convenience:
We will have board tiles of different types and colors, so lets create folder called Enums inside Scripts folder and create two enums representing tile type and tile color.
// ElementTypes.cs
public enum ElementTypes
{
SmallCube,
LargeCube,
SmallSpere,
LargeSphere
}
// ElementColors.cs
public enum ElementColors
{
NoColor,
Red,
Green,
Blue
}
Now let's create a class that will represent a single tile of our board and a class representing level of our game. We have to make our tile Serializable for it to be displayed in the Unity inspector the same way other Unity objects are (e.g. Vector3)
// BoardTile.cs

[System.Serializable]
public class BoardTile
{
public int tileId;
public ElementTypes elementType;
public ElementColors elementColor;
}
// GameLevelHolder.cs

using UnityEngine;
using System.Collections.Generic;

public class GameLevelHolder : ScriptableObject
{
public string levelName = "Default Level Name";
public int boardSize = 3;

public List<BoardTile> tiles;
}
As you can see GameLevelHolder inherits from ScriptableObject which allows us to save GameLevelHolder objects as .asset files.

Let's also create a custom MenuItem to create a holder for our game level any time we need to. We will store our game levels inside Resources/Levels directory for convenience. CreateGameLevelMenuItem.cs creates an instance of GameLevelHolder, initializes the list of tiles and saves it as an .asset file that represents our game level.
// CreateGameLevelMenuItem.cs

using UnityEngine;
using UnityEditor;
using System.Collections.Generic;

public static class CreateGameLevelMenuItem
{
[MenuItem("Custom/Game Levels/Create New Game Level Holder")]
public static void CreateGameLevelHolder()
{
GameLevelHolder levelHolder =
ScriptableObject.CreateInstance<GameLevelHolder>();
levelHolder.tiles = new List<BoardTile>();

int numberOfElements = levelHolder.boardSize * levelHolder.boardSize;

for (int i = 0; i < numberOfElements; i++)
{
levelHolder.tiles.Add(new BoardTile());
}

AssetDatabase.CreateAsset(levelHolder,
"Assets/Resources/Levels/NewGameLevelHolder.asset");
AssetDatabase.SaveAssets();

EditorUtility.FocusProjectWindow();
Selection.activeObject = levelHolder;
}
}
Now we can test our game level creation menu item.
After clicking on our custom menu item a new file inside Resources/Levels directory should appear. The first step is done - now we have a container to store a game level. We will able to make changes to this file through the wizard GUI. When playing a game we will be able to read this file and build the level using this .asset file.
Now let's read this level we have just created. Create a static class Utils which will provide us a method to retrieve our newly created level. Change "Level Name" field to "First Level" in the inspector tab.
// Utils.cs

using UnityEngine;

public static class Utils
{
private const string _levelsPath = "Levels/";
private const string _defaultLevelName = "NewGameLevelHolder";

public static GameLevelHolder ReadDefaultGameLevelFromAsset()
{
object o = Resources.Load(_levelsPath + _defaultLevelName);
GameLevelHolder retrievedGameLevel = (GameLevelHolder) o;
return retrievedGameLevel;
}
}
Now let's create the class that will construct our game level using the info retrieved from the .asset file. Let's name this class BoardConstructor and retrieve our game level inside Start()method. Also create an empty GameObject, reset its transform and drag the BoardConstructor script onto it. Now rename it to "pref_BoardConstructor" and drag into the "Prefabs" folder.
// BoardConstructor.cs

using UnityEngine;

public class BoardConstructor : MonoBehaviour
{
private GameLevelHolder _currentGameLevel;

void Start()
{
_currentGameLevel = Utils.ReadDefaultGameLevelFromAsset();
Debug.Log("Game Level Name: " + _currentGameLevel.levelName +
", board size is " + _currentGameLevel.boardSize);
}
}
You can now press "Play" button and have a look at the Console tab. If you have done everything right you should see the name of our game level displayed there. 
We have successfully read the level so let's try to use our BoardConstructor to create it's visual representation. 

First we need to prepare prefabs which our board will consist of. Create the following GameObjects and save them as prefabs inside Prefabs/BoardElements directory (reset each element position to 0, 0, 0):
  • A cube with scale (1, 1, 0.1) and name it pref_BoardFoundation
  • A cube with scale (0.7, 0.7, 0.7) and name it pref_CubeLarge
  • A cube with scale (0.3, 0.3, 0.3) and name it pref_CubeSmall
  • A sphere with scale (0.7, 0.7, 0.7) and name it pref_SphereLarge
  • A sphere with scale (0.3, 0.3, 0.3) and name it pref_SphereSmall
Also create a black material named mat_Foundation and assign it to pref_BoardFoundation.
Now update our script so it could construct the board from our NewGameLevelHolder.asset file. 
// BoardConstructor.cs

using UnityEngine;
using System.Collections.Generic;

public class BoardConstructor : MonoBehaviour
{
public Transform foundationPrefab;

public Transform smallCubePrefab;
public Transform largeCubePrefab;
public Transform smallSpherePrefab;
public Transform largeSpherePrefab;

private const float _elementSize = 1.0f;

private IDictionary<ElementTypes, Transform> _elementPrefabs;
private IDictionary<ElementColors, Color> _elementColors;
private GameLevelHolder _currentGameLevel;

private float _offset;

void Start()
{
_currentGameLevel = Utils.ReadDefaultGameLevelFromAsset();
Debug.Log("Game Level Name: " + _currentGameLevel.levelName +
", board size is " + _currentGameLevel.boardSize);
// we need this offset to position elements correctly
_offset = (_currentGameLevel.boardSize / 2f) - (_elementSize / 2f);

InitPrefabsDictionary();
InitColorsDictionary();

BuildBoardFoundation();
BuildBoardTiles();
}

private void InitPrefabsDictionary()
{
_elementPrefabs = new Dictionary<ElementTypes, Transform>();
_elementPrefabs.Add(ElementTypes.SmallCube, smallCubePrefab);
_elementPrefabs.Add(ElementTypes.LargeCube, largeCubePrefab);
_elementPrefabs.Add(ElementTypes.SmallSpere, smallSpherePrefab);
_elementPrefabs.Add(ElementTypes.LargeSphere, largeSpherePrefab);
}

private void InitColorsDictionary()
{
_elementColors = new Dictionary<ElementColors, Color>();
_elementColors.Add(ElementColors.NoColor, Color.white);
_elementColors.Add(ElementColors.Red, Color.red);
_elementColors.Add(ElementColors.Green, Color.green);
_elementColors.Add(ElementColors.Blue, Color.blue);
}

// creates board foundation the same size as the board
private void BuildBoardFoundation()
{
Transform boardFoundation =
Instantiate(foundationPrefab, Vector3.zero, Quaternion.identity) as Transform;
int boardSize = _currentGameLevel.boardSize;
boardFoundation.transform.localScale =
new Vector3(boardSize, boardSize, boardFoundation.localScale.z);
}

private void BuildBoardTiles()
{
int boardSize = _currentGameLevel.boardSize;
List<BoardTile> tiles = _currentGameLevel.tiles;

for (int row = 0; row < boardSize; row++)
{
for (int column = 0; column < boardSize; column++)
{
int elementIndex = CalculateElementIndex(row, column, boardSize);
BoardTile element = tiles[elementIndex];

// Choose element prefab
Transform elementPrefab = _elementPrefabs[element.elementType];

Vector3 elementPosition = CalculateElementPosition(row, column);
Transform elementTransform =
Instantiate(elementPrefab, elementPosition, Quaternion.identity) as Transform;

// Set element color
Color elementColor = _elementColors[element.elementColor];
elementTransform.renderer.material.color = elementColor;
}
}
}

private int CalculateElementIndex(int row, int column, int boardSize)
{
return row * boardSize + column;
}

private Vector3 CalculateElementPosition(int row, int column)
{
float x = row - _offset;
float y = column - _offset;
return new Vector3(x, y, 0);
}
}
The script looks big but there is nothing complicated in it. First we declare public Transform variables to be able to set them in Inspector tab. After this we declare dictionaries with element types and colors for convenient access to prefabs and Color values and initialize them. After this we construct our board using information read from NewGameLevelHolder.asset file.

Drag and drop element prefabs created before to corresponding empty slots of BoardConstructor script.
After you finish the script and drag-and-drop prefabs press "Play" button and you will see the simple board constructed. The result should look like this (depends on your changes in the .asset file, here I've changed some colors and element types):
Now try to edit level file and change element types and colors. Press "Play" button again and you will see that your updates are visible after the board is constructed.

So we now we have constructed a board using information that was read from the .asset file. This shows how Scriptable Objects can be useful when you need to store object data. 

Download zipped Unity3D project. Please leave comments if you have any issues.

Aucun commentaire:

Enregistrer un commentaire