Saving tile systems at runtime

jasmyn
New Member
Registered: 2014-04-23
Posts: 6

Topic

Hi. Is there a way to save a prefab of the tile system at runtime? I'm trying to save the level I make in the runtime editor. What's the best way to go about this?

Lea Hayes
Rotorz Limited
From: United Kingdom
Registered: 2014-03-04
Posts: 638

Response 1

Hey there!

Unfortunately Unity doesn't provide an out-of-the-box solution for persisting game objects at runtime; sadly their prefab system only exists in editor-land.


Important: The source code in this topic is covered under a BSD-style license which can be found further down this page.

In order to persist the state of your level, actors, tiles, etc. you will need to create a script which loops through each of your tiles and outputs them to a data format which you find most appealing. I would suggest JSON or XML since these offer a simple syntax whilst allowing you to structure your data.

Depending upon the nature of your game you might decide to store your data (be it JSON, XML, etc) as a file, or you might even opt to synchronize with an external server allowing your players to share levels with one another.

The asset store contains a couple of great assets which might also help with the process of persisting in-game data, so that might be worth looking into.

XML Example

Here is a little example as to how you might choose to structure your level data:

<?xml version="1.0" encoding="utf-8"?>
<level>
    <info>
        <title>Some Amazing Level</title>
        <description>Get to the end without falling down the hole.</description>
    </info>
    <brushes>
        <!-- Ordered list of brush identifiers so that
             we can use indices instead of long strings
          -->
        <guid>7360cee2-3a47-4b73-a8aa-89d1eccab30f</guid>  <!--  0  -->
        <guid>3bfdeb2d-aefb-4443-91bd-67187916c65f</guid>  <!--  1  -->
        <guid>d7ccfe5c-e4b5-4e25-a4b4-7e1570bed889</guid>  <!-- etc -->
    </brushes>
    <map rows="10" columns="10">
        <tile row="2" column="2" brush="0"/>
        <tile row="8" column="4" brush="1">
            <param name="type">door</param>
            <param name="required-key">yellow</param>
        </tile>
    </map>
</level>
Associating brushes with your persisted data

You will need a custom MonoBehaviour or ScriptableObject script which maps each of the brushes available within your level editor with unique identifiers; a sort of runtime brush database. These unique identifiers can later be written to your data format allowing you to identify which brushes should be used to paint each tile.

For the purposes of this example I have chosen to define the runtime database using a custom MonoBehaviour which can be added to an empty game object. This can then be saved as a prefab for reuse to ensure that brush identifiers remain consistent across scenes.

It is likely that you will want to store multiple fields for each record in your runtime database, so it makes sense to implement a serializable class to do just that.

Assets/RuntimeBrushDatabase/RuntimeBrushRecord.cs

using UnityEngine;

using Rotorz.Tile;

/// <summary>
/// Record representing a brush within the runtime brush database.
/// </summary>
[System.Serializable]
public sealed class RuntimeBrushRecord {

    [SerializeField]
    private string _guid;
    [SerializeField]
    private Brush _brush;
    [SerializeField]
    private Tileset _tileset;

    /// <summary>
    /// Gets unique GUID representing brush in database asset.
    /// </summary>
    public string Guid {
        get { return _guid; }
    }
    /// <summary>
    /// Gets the associated brush asset.
    /// </summary>
    public Brush Brush {
        get { return _brush; }
    }
    /// <summary>
    /// Gets the associated tileset asset (if any); otherwise a value
    /// of <c>null</c>.
    /// </summary>
    public Tileset Tileset {
        get { return _tileset; }
    }

    /// <summary>
    /// Initialize new <see cref="RuntimeBrushRecord"/> with brush.
    /// </summary>
    /// <param name="guid">Unique GUID for brush record.</param>
    /// <param name="brush">The brush.</param>
    public RuntimeBrushRecord(string guid, Brush brush) {
        _guid = guid;
        _brush = brush;

        var tilesetBrush = brush as TilesetBrush;
        if (tilesetBrush != null)
            _tileset = tilesetBrush.Tileset;
    }

}

Records can then be stored within a custom MonoBehaviour class like shown below. I have added a number of methods making it relatively easy to lookup brushes, tilesets and identifiers.

Assets/RuntimeBrushDatabase/RuntimeBrushDatabase.cs

using UnityEngine;

using System.Collections.Generic;

using Rotorz.Tile;

/// <summary>
/// Runtime version of brush database which can be manually synchronized
/// against the editor brush database using the inspector.
/// </summary>
public class RuntimeBrushDatabase : MonoBehaviour {

    /// <summary>
    /// Simple array of brush records within runtime database.
    /// </summary>
    [SerializeField]
    private RuntimeBrushRecord[] _brushRecords = {};

    #region Brush Lookup

    /// <summary>
    /// Collection of brush records mapped by their unique GUID.
    /// </summary>
    private Dictionary<string, RuntimeBrushRecord> _brushGuidMap;
    /// <summary>
    /// Maps brushes with their associated tileset.
    /// </summary>
    private Dictionary<Tileset, Brush[]> _brushTilesetMap;
    /// <summary>
    /// Simple array of brushes in database.
    /// </summary>
    private Brush[] _brushes;
    /// <summary>
    /// Simple array of tilesets in database.
    /// </summary>
    private Tileset[] _tilesets;

    /// <summary>
    /// Clear lookup map to force lazy initialisation once again.
    /// </summary>
    private void ClearLookupCache() {
        _brushGuidMap = null;
        _brushTilesetMap = null;
        _brushes = null;
        _tilesets = null;
    }

    /// <summary>
    /// Gets collection of brush records mapped by using their unique GUIDs.
    /// </summary>
    private IDictionary<string, RuntimeBrushRecord> BrushGuidMap {
        get {
            if (_brushGuidMap == null) {
                _brushGuidMap = new Dictionary<string, RuntimeBrushRecord>();
                foreach (var record in _brushRecords) {
                    // Exclude records where brush asset is missing.
                    // For instance, deleted or excluded from builds.
                    if (record.Brush != null)
                        _brushGuidMap[record.Guid] = record;
                }
            }
            return _brushGuidMap;
        }
    }
    /// <summary>
    /// Gets collection which maps brushes with their associated tilesets.
    /// </summary>
    private IDictionary<Tileset, Brush[]> BrushTilesetMap {
        get {
            if (_brushTilesetMap == null) {
                var brushList = new List<Brush>();

                _brushTilesetMap = new Dictionary<Tileset, Brush[]>();
                foreach (var tileset in Tilesets) {
                    brushList.Clear();

                    // Find all brushes associated with tileset.
                    foreach (var record in _brushRecords)
                        if (record.Tileset == tileset)
                            brushList.Add(record.Brush);

                    _brushTilesetMap[tileset] = brushList.ToArray();
                }
            }
            return _brushTilesetMap;
        }
    }

    /// <summary>
    /// Gets array of all brushes in runtime database.
    /// DO NOT attempt to modify content of the returned array.
    /// </summary>
    public Brush[] Brushes {
        get {
            if (_brushes == null) {
                _brushes = new Brush[BrushGuidMap.Count];
                int i = 0;
                foreach (var record in BrushGuidMap.Values)
                    _brushes[i++] = record.Brush;
            }
            return _brushes;
        }
    }
    /// <summary>
    /// Gets array of all tilesets in runtime database.
    /// DO NOT attempt to modify content of the returned array.
    /// </summary>
    public Tileset[] Tilesets {
        get {
            if (_tilesets == null) {
                var uniqueTilesets = new List<Tileset>();
                foreach (var record in _brushRecords) {
                    if (record.Tileset != null
                        && !uniqueTilesets.Contains(record.Tileset))
                    {
                        uniqueTilesets.Add(record.Tileset);
                    }
                }
                _tilesets = uniqueTilesets.ToArray();
            }
            return _tilesets;
        }
    }

    /// <summary>
    /// Lookup record for brush for given GUID.
    /// </summary>
    /// <param name="guid">GUID of brush.</param>
    /// <returns>
    /// The associated <see cref="RuntimeBrushRecord"/> if brush is found;
    /// otherwise a value of <c>null</c> if brush was not found.
    /// </returns>
    public RuntimeBrushRecord Lookup(string guid) {
        return BrushGuidMap.ContainsKey(guid)
            ? BrushGuidMap[guid]
            : null;
    }

    /// <summary>
    /// Empty brush array.
    /// </summary>
    private static readonly Brush[] s_EmptyBrushArray = new Brush[0];

    /// <summary>
    /// Lookup all of the brushes which are associated with given tileset.
    /// </summary>
    /// <param name="tileset">The tileset.</param>
    /// <returns>
    /// An array of brushes which are associated with the specified tileset.
    /// DO NOT attempt to modify the contents of this array.
    /// </returns>
    public Brush[] LookupBrushes(Tileset tileset) {
        return BrushTilesetMap.ContainsKey(tileset)
            ? BrushTilesetMap[tileset]
            : s_EmptyBrushArray;
    }

    /// <summary>
    /// Find GUID for given brush by searching through runtime database.
    /// </summary>
    /// <param name="brush">The brush.</param>
    /// <returns>
    /// GUID for record associated with brush; or a value of <c>null</c> if
    /// runtime database does not contain a record for the specified brush.
    /// </returns>
    public string FindGuid(Brush brush) {
        if (_brushRecords != null)
            foreach (var record in _brushRecords)
                if (record.Brush == brush)
                    return record.Guid;
        return null;
    }

    #endregion

#if UNITY_EDITOR

    /// <summary>
    /// Allow editor script to modify the array of brush records.
    /// </summary>
    /// <param name="records">Array of brush records.</param>
    public void SetBrushRecords(RuntimeBrushRecord[] records) {
        _brushRecords = records;
        ClearLookupCache();

        UnityEditor.EditorUtility.SetDirty(gameObject);
    }

#endif

}

Lookups are fast since they are backed by .NET dictionaries (i.e. hash maps) which are lazily initialized upon first access.

  • db.Brushes - Simple array of all brushes in database.

  • db.Tilesets - Simple array of all tilesets in database.

  • db.Lookup - Lookup data record for a specific identifier.

  • db.LookupBrushes - Lookup all brushes from a specific tileset.

  • db.FindGuid - Find unique identifier for a brush.

Synchronizing with editor database

To automatically synchronize our runtime brush database we can create a custom inspector with a button which enumerates the editor-land brush database.

Assets/RuntimeBrushDatabase/Editor/RuntimeBrushDatabaseInspector.cs

using UnityEngine;
using UnityEditor;

using System.Collections.Generic;

using Rotorz.Tile.Editor;

/// <summary>
/// Custom inspector for the runtime brush database behaviour.
/// </summary>
[CustomEditor(typeof(RuntimeBrushDatabase))]
public class RuntimeBrushDatabaseInspector : Editor {

    public override void OnInspectorGUI() {
        if (GUILayout.Button("Sync with Editor Database"))
            SyncWithEditorDatabase();
        
        var runtimeDatabase = target as RuntimeBrushDatabase;
        int brushes = runtimeDatabase.Brushes.Length;
        int tilesets = runtimeDatabase.Tilesets.Length;

        GUILayout.Label("Brushes: " + brushes + "  Tilesets: " + tilesets);
    }

    /// <summary>
    /// Sync runtime brush database records from the editor database.
    /// </summary>
    /// <remarks>
    /// <para>GUIDs will be reused for brush records which already exist
    /// within runtime database to avoid breaking existing data files.
    /// However, GUIDs will be generated for brushes which do not already
    /// exist within the runtime brush database.</para>
    /// <para>The implementation of this method could be altered to apply
    /// custom filters to available brushes.</para>
    /// </remarks>
    private void SyncWithEditorDatabase() {
        var editorDatabase = BrushDatabase.Instance;
        var runtimeDatabase = target as RuntimeBrushDatabase;

        // Prepare empty list ready to populate from editor database!
        var records = new List<RuntimeBrushRecord>();

        foreach (var record in editorDatabase.BrushRecords) {
            // Does brush database already contain a record for this brush?
            string guid = runtimeDatabase.FindGuid(record.Brush);
            // Nope? just generate a new one!
            if (guid == null)
                guid = System.Guid.NewGuid().ToString();

            // Create record for brush!
            records.Add(new RuntimeBrushRecord(guid, record.Brush));
        }

        // Synchronise the runtime brush database asset!
        runtimeDatabase.SetBrushRecords(records.ToArray());
    }

}
Setting up your runtime database
  1. Create a game object called "Runtime Brush Database".

  2. Add the RuntimeBrushDatabase behaviour.

  3. Click "Sync with Editor Database" using the inspector.

The synchronization logic could even be customized to include or exclude brushes which match certain criteria. For instance, you might wish to exclude all brushes from a specific category.

Depending upon your game you could even define multiple brush databases which contain different selections of brushes. Perhaps based upon category, some custom criteria, or even just plain old manual selection.

Saving tile data using runtime database

The following snippet does not demonstrate how to persist to a specific data format, but it does demonstrate how you might grab all data which is relevant for your game.

for (int row = 0; row < system.RowCount; ++row) {
    for (int column = 0; column < system.ColumnCount; ++column) {
        var tile = system.GetTile(row, column);
        if (tile == null)
            continue; // skip empty tiles

        // `YourTileData` represents a wrapper class for serialization
        var yourTile = new YourTileData();
        yourTile.row = row;
        yourTile.column = column;
        yourTile.brush = runtimeDb.FindGuid(tile.brush);
        yourTile.variation = tile.variationIndex;
        yourTile.rotation = tile.PaintedRotation;

        yourMap.Add(yourTile);
    }
}
Loading tile data using runtime database
// Clear any existing tiles from tile system.
system.EraseAllTiles();

// Temporary instance to avoid unnecessary allocations.
var tempTile = new TileData();
tempTile.Empty = false;

foreach (var yourTile in yourMap) {
    tempTile.brush = runtimeDb.Lookup(yourTile.brush).Brush;
    if (tempTile.brush == null) {
        Debug.LogError("Unable to locate brush with GUID: " + yourTile.brush);
        // We can't paint this tile, skip over it!
        continue;
    }

    tempTile.variationIndex = (byte)yourTile.variation;
    tempTile.PaintedRotation = yourTile.rotation;
    system.SetTileFrom(yourTile.row, yourTile.column, tempTile);
}

// Force all tiles to be repainted!
system.RefreshAllTiles(RefreshFlags.Force | RefreshFlags.UpdateProcedural);
Closing words

I hope that you find this response useful albeit quite long. This is an amalgamation of my past responses in the Unity forum and customer E-mails. This post will probably be updated and refined in the future.

License for Example Code

The MIT License (MIT)

Copyright (c) 2014 Rotorz Limited

Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:

The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.

THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.

jasmyn
New Member
Registered: 2014-04-23
Posts: 6

Response 2

Great reply, thanks. :)

jasmyn
New Member
Registered: 2014-04-23
Posts: 6

Response 3

One related question... In the documentation it says to create the prefab through the button on the script rather than the usual way. Is there any way to get whatever advantages this affords with a level created at runtime?

Lea Hayes
Rotorz Limited
From: United Kingdom
Registered: 2014-03-04
Posts: 638

Response 4

jasmyn wrote:

One related question... In the documentation it says to create the prefab through the button on the script rather than the usual way. Is there any way to get whatever advantages this affords with a level created at runtime?

Unity does not have an API for creating prefabs at runtime.

The "Save Prefab..." button on the tile system inspector was originally needed to circumvent a limitation in the regular Unity mechanism but Unity have since resolved that issue. This button no longer offers any special advantages, it has been retained for convenience.

jasmyn
New Member
Registered: 2014-04-23
Posts: 6

Response 5

Trying to implement this. I read the system into a JSON object, serialize the JSON and save it to disk. On subsequent runs I erase the current contents of the system, then try to loop through the deserialized object to rebuild it, but I get errors that Lookup() doesn't find anything with the GUID I sent . I manually populated the RuntimeBrushDatabase and it shows the correct number of Brushes and Tilesets. Looking at the deserialized object shows that the GUID I am passing is the correct one...

void Reconstructor (JSONObject level) {

    var tempTile = new TileData();
    tempTile.Empty = false;

    for (int x = 0; x < level["Map"].Count; ++x) {
        var tile = level["Map"][x];

        string sRow = level["Map"][x]["Row"].ToString();
        int iRow = int.Parse(sRow);

        string sColumn = level["Map"][x]["Column"].ToString();
        int iColumn = int.Parse(sColumn);

        var brush = tile["Brush"];
        string sBrush = brush.ToString(); // sBrush = "5ed3c0b2-3818-466d-84b9-6bf8dd7e0f92" (correct)

        var variation_string = tile["Variation"].ToString();
        var rotation_string = tile["Rotation"].ToString();

        RuntimeBrushRecord brushRecord = db.Lookup(sBrush); // always NULL
        ...

Any ideas? Been at this all night... :(

Last edited by jasmyn (2014-04-24 16:07:40)

Lea Hayes
Rotorz Limited
From: United Kingdom
Registered: 2014-03-04
Posts: 638

Response 6

For the benefit of future readers, you appear to be using the following JSON library:
http://wiki.unity3d.com/index.php/JSONObject

I have created a little serializer/deserializer and this seems to be working well. I haven't used this particular JSON library before, so please excuse any deficiencies within my usage. Also I should point out that I have not added any validation, but I would encourage that you add lots of validation to your deserialization logic to catch any bad input.

Assets/RuntimeBrushDatabase/ExampleMapSerializer.cs

using UnityEngine;

using System.Collections.Generic;

using Rotorz.Tile;

public class ExampleMapSerializer : MonoBehaviour {

    public RuntimeBrushDatabase runtimeDb;
    public TileSystem system;

    // You can insert / view the persisted JSON data using the inspector
    public string persistedJsonData;

    private void OnGUI() {
        if (GUI.Button(new Rect(10, 10, 100, 40), "Save Map"))
            persistedJsonData = SerializeMapToJson("Test Map", "Watch the hole!");

        if (!string.IsNullOrEmpty(persistedJsonData))
            if (GUI.Button(new Rect(10, 60, 100, 40), "Load Map"))
                DeserializeMapFromJson(persistedJsonData);
    }

    #region Serialize Map

    private struct PersistedBrushReference {
        public string guid;
        public int index;
    }

    // Maps brush to its identifier and index.
    private Dictionary<Brush, int> _guidMap = new Dictionary<Brush, int>();
    // List of GUID and index pairs.
    private List<PersistedBrushReference> _guidIndices = new List<PersistedBrushReference>();

    /// <summary>
    /// Record brush ready for output within data structure.
    /// </summary>
    /// <param name="brush">The brush.</param>
    /// <returns>
    /// Persisted reference which includes brush GUID and its index within the data structure.
    /// </returns>
    private PersistedBrushReference RecordBrush(Brush brush) {
        if (_guidMap.ContainsKey(brush))
            return _guidIndices[_guidMap[brush]];

        var persistedReference = new PersistedBrushReference {
            guid   = runtimeDb.FindGuid(brush),
            index  = _guidMap.Count
        };
        _guidMap[brush] = persistedReference.index;
        _guidIndices.Add(persistedReference);

        return persistedReference;
    }

    private void ClearTempData() {
        _guidMap.Clear();
        _guidIndices.Clear();
    }

    private string SerializeMapToJson(string title, string description) {
        ClearTempData();

        // Prepare JSON data for tile map.
        var yourMap = new List<JSONObject>();

        for (int row = 0; row < system.rows; ++row) {
            for (int column = 0; column < system.columns; ++column) {
                var tile = system.GetTile(row, column);
                if (tile == null || tile.brush == null)
                    continue; // skip empty tiles

                var persistedReference = RecordBrush(tile.brush);

                var yourTile = new JSONObject(JSONObject.Type.OBJECT);
                yourTile.SetField("row", row);
                yourTile.SetField("column", column);
                yourTile.SetField("brush", persistedReference.index);
                yourTile.SetField("variation", tile.variationIndex);
                yourTile.SetField("rotation", tile.PaintedRotation);

                yourMap.Add(yourTile);
            }
        }
        
        var jsonLevel = new JSONObject(JSONObject.Type.OBJECT);

        // Add level information to level data.
        var jsonInfo = new JSONObject(JSONObject.Type.OBJECT);
        jsonInfo.AddField("title", title);
        jsonInfo.AddField("description", description);
        jsonLevel.AddField("info", jsonInfo);

        // Add brush map to level data.
        var jsonGuids = new JSONObject(JSONObject.Type.ARRAY);
        foreach (var index in _guidIndices)
            jsonGuids.Add(index.guid);
        jsonLevel.AddField("brushes", jsonGuids);

        // Add tiles to level data.
        var jsonMap = new JSONObject(JSONObject.Type.ARRAY);
        foreach (var jsonTile in yourMap)
            jsonMap.Add(jsonTile);
        jsonLevel.AddField("map", jsonMap);

        return jsonLevel.ToString();
    }

    #endregion

    #region Deserialize Map

    private void DeserializeMapFromJson(string json) {
        var jsonLevel = new JSONObject(json);
        var jsonInfo = jsonLevel["info"];
        var jsonGuids = jsonLevel["brushes"];
        var jsonMap = jsonLevel["map"];

        //!TODO: Add validation!

        system.EraseAllTiles();

        // Temporary instance to avoid unnecessary allocations.
        var tempTile = new TileData();
        tempTile.Empty = false;

        for (int i = 0; i < jsonMap.Count; ++i) {
            var jsonTile = jsonMap[i];

            // Lookup brush from data structure.
            int guidIndex = (int)jsonTile["brush"].n;
            tempTile.brush = runtimeDb.Lookup(jsonGuids[guidIndex].str).Brush;
            if (tempTile.brush == null) {
                Debug.LogError("Unable to locate brush with GUID: " + jsonGuids[guidIndex].str);
                // We can't paint this tile, skip over it!
                continue;
            }

            // Fetch remainder of tile data.
            tempTile.variationIndex = (byte)jsonTile["variation"].n;
            tempTile.Rotation = tempTile.PaintedRotation = (int)jsonTile["rotation"].n;
            system.SetTileFrom((int)jsonTile["row"].n, (int)jsonTile["column"].n, tempTile);
        }

        // Force all tiles to be repainted!
        system.RefreshAllTiles(RefreshFlags.Force | RefreshFlags.UpdateProcedural);
    }

    #endregion

}

Which outputs JSON with the following structure:

{
   "info":{
      "title":"Test Map",
      "description":"Watch the hole!"
   },
   "brushes":[
      "5b23b8ba-8b13-425a-87ba-5a6ddc2a1ba4",
      "d4469947-10c2-4575-bf77-d0dfe3e6d8a4"
   ],
   "map":[
      {
         "row":0,
         "column":7,
         "brush":0,
         "variation":0,
         "rotation":0
      },
      {
         "row":1,
         "column":0,
         "brush":1,
         "variation":1,
         "rotation":0
      }
   ]
}


I hope that this helps :)



Download Example Scripts:
RuntimeBrushDatabase+Example.zip

Example Requires:
http://wiki.unity3d.com/index.php/JSONObject

Last edited by Lea Hayes (2014-04-25 15:11:16)

jasmyn
New Member
Registered: 2014-04-23
Posts: 6

Response 7

Got it working, thanks. :)

TrentSterling
Member
Registered: 2014-04-07
Posts: 28

Response 8

Nicely done. I've been avoiding serialization for a while because I figured it would be a big headache.

Turns out you've done it for me! Thank you!