Skip to content

Instantly share code, notes, and snippets.

@Joshalexjacobs
Last active August 16, 2023 20:08
Show Gist options
  • Save Joshalexjacobs/e57277799b5a30428b06f8b827cfc688 to your computer and use it in GitHub Desktop.
Save Joshalexjacobs/e57277799b5a30428b06f8b827cfc688 to your computer and use it in GitHub Desktop.
JSON-based Level Editor for Unity
using System;
using UnityEngine;
namespace JSONLevelEditor {
[Serializable]
public class LevelResource {
public LevelEvent[] levelRoute;
}
[Serializable]
public class LevelEvent {
public EnemyGroup[] enemyGroups;
}
[Serializable]
public class EnemyGroup {
public string gameObjectReference = "";
public Vector2 spawnPoint;
}
}
using CandyCoded;
using MyBox;
using System.Collections.Generic;
using System.Linq;
using JSONLevelEditor;
using Unity.Plastic.Newtonsoft.Json;
using UnityEngine;
using TextAsset = UnityEngine.TextAsset;
#if UNITY_EDITOR
using UnityEditor;
#endif
/// <summary>
/// JSONLevelEditorWindow handles the basic editor logic that allows us to load, update, and save JSON-based levels.
///
/// There are 2 dependencies required for this editor to work:
/// 1. CandyCoded (https://github.com/CandyCoded/CandyCoded)
/// 2. MyBox (https://github.com/Deadcows/MyBox)
///
/// JSONLevelEditorWindow.cs should be placed in a directory labeled "Editor", eg:
/// "Assets/Editor/JSONLevelEditorWindow.cs"
///
/// JSONLevelEditor.cs can be placed anywhere in the Assets directory except for the Editor folder, eg:
/// "Assets/JSONLevelEditor.cs"
///
/// To open the editor window, navigate to "JSON Level Editor" > "JSON Level Editor" from the menu bar.
///
/// Once open, drag in the Template.json to begin editing your level.
///
/// Levels and Game Object References should be stored in the Resources folder eg:
/// "Assets/Resources/Game Object References"
/// "Assets/Resources/Levels"
/// </summary>
public class JSONLevelEditorWindow : EditorWindow {
private TextAsset _temp;
private TextAsset _source;
private string _title;
private LevelResource _parsedLevel;
private LevelEvent _currentEvent;
private EnemyGroup _currentEnemyGroup;
private int _eventSelected = 0;
private int _enemyGroupSelected = 0;
private bool _changesMade = false;
private readonly GUIStyle _headerStyle = new GUIStyle();
private readonly GUIStyle _subHeaderStyle = new GUIStyle();
[MenuItem("JSON Level Editor/JSON Level Editor")]
public static void Init() {
GetWindow<JSONLevelEditorWindow>(false, "JSON Level Editor", true);
}
private void OnEnable() {
_headerStyle.fontSize = 16;
_headerStyle.normal.textColor = Color.white;
_headerStyle.padding = new RectOffset(6, 0, 0, 4);
_subHeaderStyle.fontSize = 12;
_subHeaderStyle.normal.textColor = Color.white;
_subHeaderStyle.fontStyle = FontStyle.Bold;
_subHeaderStyle.padding = new RectOffset(6, 0, 0, 2);
}
private void OnGUI() {
EditorGUI.BeginChangeCheck();
_source = EditorGUILayout.ObjectField("Level JSON", _source, typeof(TextAsset), true) as TextAsset;
if (EditorGUI.EndChangeCheck()) {
_enemyGroupSelected = 0;
_eventSelected = 0;
if (_source) {
AssetDatabase.Refresh();
_parsedLevel = JsonUtility.FromJson<LevelResource>(_source.text);
}
else {
_parsedLevel = null;
}
}
if (_source && _parsedLevel != null) {
EditorGUI.BeginChangeCheck();
if (EditorGUI.EndChangeCheck()) {
_changesMade = true;
}
EditorUtil.DrawUILine(EditorUtil.DefaultUILineColor);
GUILayout.Label("Event Details", _headerStyle);
_currentEvent = _parsedLevel.levelRoute[_eventSelected];
DisplaySelectedEvent();
}
else {
if (!_source) {
_parsedLevel = null;
EditorGUILayout.HelpBox("Select a JSON file.", MessageType.Warning);
}
}
EditorUtil.DrawUILine(EditorUtil.DefaultUILineColor);
if (_parsedLevel != null) {
GUILayout.Label($"Event {_eventSelected + 1}", _headerStyle);
RenderEvents(_parsedLevel);
if (_eventSelected < _parsedLevel.levelRoute.Length) {
if (!_parsedLevel.levelRoute[_eventSelected].enemyGroups.IsNullOrEmpty()) {
var enemyDictionary = _parsedLevel.levelRoute[_eventSelected].enemyGroups
.Where((group) => group.gameObjectReference.Length > 0)
.GroupBy(p => p.gameObjectReference)
.ToDictionary(p => p.Key, q => q.Count());
EditorGUILayout.Space();
GUILayout.Label("Enemies:", _subHeaderStyle);
foreach (var keyValuePair in enemyDictionary) {
GUILayout.Label($"{keyValuePair.Key} x{keyValuePair.Value}");
}
}
}
}
Repaint();
RenderAddDeleteAndSaveButtons();
}
private void DisplaySelectedEvent() {
if (_currentEvent != null) {
DisplaySelectedEventFields(_currentEvent);
}
else {
EditorGUI.BeginDisabledGroup(true);
DisplaySelectedEventFields();
EditorGUI.EndDisabledGroup();
}
}
private void DisplaySelectedEventFields(LevelEvent levelEvent = null) {
if (levelEvent != null) {
GUILayout.BeginHorizontal();
EditorGUI.BeginChangeCheck();
if (GUILayout.Button("New Enemy")) {
var enemyGroupsList = !levelEvent.enemyGroups.IsNullOrEmpty()
? levelEvent.enemyGroups.ToList() : new List<EnemyGroup> () {};
var newEnemyGroup = new EnemyGroup();
if (!levelEvent.enemyGroups.IsNullOrEmpty() && _enemyGroupSelected < levelEvent.enemyGroups.Length) {
newEnemyGroup.spawnPoint = levelEvent.enemyGroups[_enemyGroupSelected].spawnPoint;
newEnemyGroup.gameObjectReference = levelEvent.enemyGroups[_enemyGroupSelected].gameObjectReference;
}
enemyGroupsList.Add(newEnemyGroup);
levelEvent.enemyGroups = enemyGroupsList.ToArray();
_enemyGroupSelected = levelEvent.enemyGroups.Length - 1;
ForceSceneRepaint();
}
GUI.enabled = !levelEvent.enemyGroups.IsNullOrEmpty();
if (GUILayout.Button("Delete Enemy")) {
var enemyGroupsList = levelEvent.enemyGroups.ToList();
enemyGroupsList.RemoveAt(_enemyGroupSelected);
_enemyGroupSelected--;
if (_enemyGroupSelected < 0)
_enemyGroupSelected = 0;
levelEvent.enemyGroups = enemyGroupsList.ToArray();
ForceSceneRepaint();
}
if (EditorGUI.EndChangeCheck()) {
_changesMade = true;
}
GUI.enabled = true;
GUILayout.EndHorizontal();
if (!levelEvent.enemyGroups.IsNullOrEmpty()) {
string[] options = levelEvent.enemyGroups.Select(((group, i) => {
string enemyName = group.gameObjectReference.Length > 0 ? group.gameObjectReference : "None Selected";
return $"{enemyName} ({i + 1})";
})).ToArray();
_enemyGroupSelected = EditorGUILayout.Popup("Current Enemy", _enemyGroupSelected, options);
EditorGUI.BeginChangeCheck();
var gameObjectReference = levelEvent.enemyGroups[_enemyGroupSelected].gameObjectReference.Length > 0
? EditorUtil.GetEnemyReference(levelEvent.enemyGroups[_enemyGroupSelected].gameObjectReference)
: null;
var gameObjectReferenceObj = EditorGUILayout.ObjectField("GameObjectReference", gameObjectReference, typeof(GameObjectReference), false) as GameObjectReference;
levelEvent.enemyGroups[_enemyGroupSelected].gameObjectReference = gameObjectReferenceObj != null ? gameObjectReferenceObj.name : "";
if (gameObjectReferenceObj != null)
EditorGUILayout.ObjectField("Enemy Prefab", gameObjectReferenceObj.DefaultValue, typeof(GameObject), false);
levelEvent.enemyGroups[_enemyGroupSelected].spawnPoint = EditorGUILayout.Vector2Field("Spawn Point", levelEvent.enemyGroups[_enemyGroupSelected].spawnPoint);
if (EditorGUI.EndChangeCheck()) {
_changesMade = true;
}
}
else {
EditorGUILayout.Popup("Enemies", _enemyGroupSelected, new []{"No Enemies Found"});
}
}
}
private readonly int _eventButtonWidth = 30;
private void RenderEvent(int index) {
GUI.enabled = _eventSelected != index;
if (GUILayout.Button((index + 1).ToString(), GUILayout.Width(_eventButtonWidth))) {
_eventSelected = index;
_enemyGroupSelected = 0;
ForceSceneRepaint();
}
GUI.enabled = true;
}
private void RenderEvents(LevelResource parsedLevel) {
GUILayout.BeginHorizontal();
int newLine = 0;
for (int i = 0; i < parsedLevel.levelRoute.Length; i++) {
if (((i - newLine) * (_eventButtonWidth + 9f)) > EditorGUIUtility.currentViewWidth) {
newLine = i;
}
RenderEvent(i);
}
GUILayout.EndHorizontal();
}
private void RenderAddDeleteAndSaveButtons() {
if (_parsedLevel == null) return;
EditorGUILayout.Space();
GUILayout.Label("Controls", _headerStyle);
if (GUILayout.Button("Add Event")) {
AddEvent();
}
GUI.enabled = _parsedLevel.levelRoute.Length > 1;
if (GUILayout.Button("Delete Event")) {
DeleteEvent();
}
GUI.enabled = true;
if (GUILayout.Button("Save Level")) {
SaveToJSON();
}
if (_changesMade) {
EditorGUILayout.HelpBox("Unsaved Changes Detected.", MessageType.Warning);
}
}
private void AddEvent() {
var levelEventToAdd = new LevelEvent {};
var levelEventsList = _parsedLevel.levelRoute.ToList();
levelEventsList.Add(levelEventToAdd);
_enemyGroupSelected = 0;
_parsedLevel.levelRoute = levelEventsList.ToArray();
_eventSelected = _parsedLevel.levelRoute.Length - 1;
_changesMade = true;
ForceSceneRepaint();
}
private void DeleteEvent() {
var levelEventsList = _parsedLevel.levelRoute.ToList();
levelEventsList.RemoveAt(_eventSelected);
_eventSelected = 0;
_parsedLevel.levelRoute = levelEventsList.ToArray();
_changesMade = true;
ForceSceneRepaint();
}
private void ForceSceneRepaint() {
EditorWindow view = EditorWindow.GetWindow<SceneView>();
view.Repaint();
}
private void SaveToJSON() {
var localAssetPath = $"{Application.dataPath.Replace("Assets", "")}{AssetDatabase.GetAssetPath(_source)}";
var updatedJson = JsonUtility.ToJson(_parsedLevel);
try {
System.IO.File.WriteAllText(localAssetPath, updatedJson);
}
catch (JsonWriterException e) {
Debug.LogError($"Unable to write to {localAssetPath}:{e.Message}");
Debug.LogError($"Raw JSON: {updatedJson}");
}
_changesMade = false;
Debug.Log("JSON Saved!");
AssetDatabase.Refresh();
}
/* Editor Window Management */
void OnFocus() {
SceneView.duringSceneGui -= OnSceneGUI;
SceneView.duringSceneGui += OnSceneGUI;
}
private void OnBecameInvisible() {
SceneView.duringSceneGui -= OnSceneGUI;
}
void OnDestroy() {
SceneView.duringSceneGui -= OnSceneGUI;
}
void OnSceneGUI(SceneView sceneView) {
Handles.BeginGUI();
Handles.color = Color.red;
if (_currentEvent != null && !_currentEvent.enemyGroups.IsNullOrEmpty()) {
for (int i = 0; i < _currentEvent.enemyGroups.Length; i++) {
var enemyGroup = _currentEvent.enemyGroups[i];
if (enemyGroup != null && enemyGroup.gameObjectReference.Length > 0) {
var spawnPoint = new Vector3(enemyGroup.spawnPoint.x, enemyGroup.spawnPoint.y, 0f);
Handles.DrawWireDisc(spawnPoint, new Vector3(0, 0, 180), 0.1f, 3f);
Handles.Label(spawnPoint + new Vector3(0f, -0.5f, 0f), $"{enemyGroup.gameObjectReference} ({i + 1})\n({spawnPoint.x}, {spawnPoint.y})", _subHeaderStyle);
}
}
}
Handles.EndGUI();
}
}
{
"title":"Template",
"levelRoute":[
{
"enemyGroups":[]
}
]
}
@Joshalexjacobs
Copy link
Author

This tool is primarily used to generate basic JSON files which we can then parse using a simple script:

using System;
using System.Collections;
using System.Collections.Generic;
using System.Linq;
using CandyCoded;
using UnityEngine;
using UnityEngine.SceneManagement;
using JSONLevelEditor;

public class LevelControllerService : MonoBehaviour {
    
    [SerializeField] 
    private TextAsset _levelResource;

    private List<GameObject> _activeEnemies = new();
   
    private void Awake() {
        StartLevel();
    }
 
    private void StartLevel() {
        LevelResource parsedLevel = JsonUtility.FromJson<LevelResource>(_levelResource.text);
            
        StartCoroutine(PlayLevel(parsedLevel));
    }
       
    private IEnumerator PlayLevel(LevelResource parsedLevel) {
        foreach (var levelEvent in parsedLevel.levelRoute) {
            if (levelEvent.enemyGroups.Length > 0) {
                _activeEnemies = levelEvent.enemyGroups
                    .Where((e) => e.gameObjectReference.Length > 0)
                    .Select(SpawnPrefab).ToList();

                yield return new WaitUntil(() => {
                    return _activeEnemies.Where((g) => g != null).ToList().Count <= 0;
                });
            }
        }

        yield return null;
    }

    private GameObject SpawnPrefab(EnemyGroup enemyGroup) {
        var gameObjectReference = enemyGroup.gameObjectReference.Length > 0 ?
            Resources.Load<GameObjectReference>(
                $"Game Object References/{enemyGroup.gameObjectReference}") : null;

        if (gameObjectReference == null) 
            return null;
        
        return Instantiate(gameObjectReference.DefaultValue, new Vector3(enemyGroup.spawnPoint.x, enemyGroup.spawnPoint.y, 0f),
            Quaternion.identity);
    }

}

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment