Last active
August 16, 2023 20:08
-
-
Save Joshalexjacobs/e57277799b5a30428b06f8b827cfc688 to your computer and use it in GitHub Desktop.
JSON-based Level Editor for Unity
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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; | |
} | |
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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(); | |
} | |
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
{ | |
"title":"Template", | |
"levelRoute":[ | |
{ | |
"enemyGroups":[] | |
} | |
] | |
} |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
This tool is primarily used to generate basic JSON files which we can then parse using a simple script: