Skip to content

Instantly share code, notes, and snippets.

@Kleptine
Last active April 26, 2024 07:53
Show Gist options
  • Star 10 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save Kleptine/ac4b7db7714003f7968f4c532f0dc82d to your computer and use it in GitHub Desktop.
Save Kleptine/ac4b7db7714003f7968f4c532f0dc82d to your computer and use it in GitHub Desktop.
A Construction Script is a system to help generate procedural Gameobjects at Edit-Time in a Unity scene.
using System.Linq;
using UnityEngine;
#if UNITY_EDITOR
using System;
using UnityEditor;
#endif
namespace Global.Scripts.ConstructionScript
{
/// <summary>
/// A Construction Script is a system to help generate procedural Gameobjects at Edit-Time in a Unity scene. It does
/// three main things:
/// <para>1. Generates a child gameobject wrapped in a <see cref="HideFlags.DontSave" /> gameobject.</para>
/// <para>
/// 2. Runs a set of <see cref="IOnConstruct" /> scripts on this generated object, whenever this component's
/// gameobject updates in Edit mode.
/// </para>
/// <para>3. On Standalone build, bakes this game object (after running construct scripts) into the scene file.</para>
/// </summary>
/// <remarks>
/// There are two main types: <see cref="ConstructionScript" /> and <see cref="IOnConstruct" />. You can either
/// extend this class, implementing IOnConstruct, or add a IOnConstruct script on the child (within the prefab). Often
/// you may want to extend this script to store properties in the scene, but have a separate IOnConstruct script
/// running on the prefab to actually modify the gameobject. Having a separate IOnConstruct script allows that script
/// to directly reference objects within the prefab.
/// </remarks>
[ExecuteInEditMode]
public class ConstructionScript : MonoBehaviour
{
public GameObject Child
{
get
{
// In standalone, we need to hook up the baked child object, as it's not generated at runtime.
#if !UNITY_EDITOR
if (child == null)
{
child = transform.GetChild(0).GetChild(0).gameObject;
}
#endif
return child;
}
}
/// <summary>The Prefab to use as the base for generation, before running construction scripts.</summary>
[Header("Construction Script")]
[Tooltip("This prefab will be spawned when the child is regenerated.")]
public GameObject ConstructedPrefab;
/// <summary>
/// Stores a reference to the generated object. This object is not marked "DontSave", because marking it as such
/// keeps you from applying prefab changes in the editor. Instead we create an additional "DontSave" gameobject as the
/// parent of this object, which correctly lets you apply prefab changes.
/// </summary>
private GameObject child;
// The vast majority of code in this class only acts in the editor. At build time, we bake the generated
// objects directly into the scene files.
#if UNITY_EDITOR
/// <summary>
/// The immediate child object of this gameobject. In the editor, it is marked with
/// <see cref="HideFlags.DontSave" /> so that it is not serialized into the scene.
/// </summary>
private GameObject dontSave;
/// <summary>The previous value of ToSpawn in the editor.</summary>
private GameObject prevToSpawn;
private void Awake()
{
// When a script is ExecuteInEditMode Awake is called in Editor as soon the component is created
// or when the scene is loaded. However, in edit mode Awake, we aren't able to do normal scene
// changes like creating gameobjects. Because of this, we have to recreate the prefab async,
// doing the actual operation on the next Editor GUI update frame.
// However, when Unity is building the player, it reloads all scenes (calling Awake here),
// and oddly, normal scene changes *are* allowed, so we're able to recreate the child immediately.
// This is lucky, because it allows us to save the generated GameObject into the scene at build time.
RecreateChild(!BuildPipeline.isBuildingPlayer);
}
private void RecreateChild(bool async)
{
if (async)
{
RunOnceDelayed(RecreateChildImmediate);
}
else
{
RecreateChildImmediate();
}
}
/// <summary>Re-creates the generated child object. This can only be done on the Unity main thread.</summary>
/// <remarks>
/// There are certain phases of the Unity editor (OnValidate, [ExecuteInEditMode]Awake) that do not allow scene
/// modifications. We can't run this function during those phases.
/// </remarks>
private void RecreateChildImmediate()
{
if (dontSave != null)
{
DestroyImmediate(dontSave);
}
dontSave = new GameObject("(Generated)");
dontSave.transform.parent = transform;
dontSave.transform.localPosition = Vector3.zero;
dontSave.transform.localRotation = Quaternion.identity;
dontSave.transform.localScale = Vector3.one;
// Don't save the object that's created, in the editor.
// The only time we save the object is during the standalone build
// process, such that the constructed object is packaged into the scene file of the build.
if (!BuildPipeline.isBuildingPlayer)
{
dontSave.hideFlags = HideFlags.DontSave;
}
if (ConstructedPrefab != null)
{
// This should only happen in the editor, when creating a new component of this type.
child = (GameObject) PrefabUtility.InstantiatePrefab(ConstructedPrefab, dontSave.transform);
}
else
{
if (BuildPipeline.isBuildingPlayer)
{
child = new GameObject(gameObject.name);
child.transform.parent = dontSave.transform;
}
else
{
child = new GameObject(gameObject.name);
child.transform.parent = dontSave.transform;
}
}
RunConstruction();
}
/// <summary>Called when the inspector is modified.</summary>
private void OnValidate()
{
if (ConstructedPrefab != prevToSpawn)
{
// Can only modify the scene on the main thread.
// OnValidate isn't allowed to run any scene modifications.
RecreateChild(true);
}
prevToSpawn = ConstructedPrefab;
}
/// <summary>Runs a given action on the next editor gui update.</summary>
private void RunOnceDelayed(Action action)
{
void OnDelayCall()
{
action();
EditorApplication.delayCall -= OnDelayCall;
}
EditorApplication.delayCall += OnDelayCall;
}
/// <summary>Runs in the editor whenever this gameobject is modified, such as dragging it or changing its properties.</summary>
private void Update()
{
if (Application.isPlaying)
{
return;
}
if (child == null)
{
return;
}
// Skip updating the construction if we have the child selected.
if (Selection.activeGameObject == null
|| !Selection.activeGameObject.transform.IsChildOf(Child.transform)
|| Selection.activeGameObject == Child)
{
RunConstruction();
}
}
/// <summary>
/// Runs all construction scripts on this object and on the child. While this script controls the lifecycle of the
/// generated object, the Construction scripts actually setup the generated object and do the modification.
/// </summary>
private void RunConstruction()
{
// Any scripts on this object or on the child.
var constructionScripts = child.GetComponents<IOnConstruct>().Concat(GetComponents<IOnConstruct>());
foreach (IOnConstruct onConstruct in constructionScripts)
{
onConstruct.OnConstruct();
}
}
/// <summary>When this component is destroyed, remove the generated object.</summary>
private void OnDestroy()
{
if (child != null)
{
DestroyImmediate(dontSave);
}
}
#endif
}
}
namespace Global.Scripts.ConstructionScript
{
/// <summary>
/// Implement this interface, and place the component on a <see cref="ConstructionScript" /> gameobject or Child,
/// and this script will be run whenever the generated object is updated.
/// </summary>
public interface IOnConstruct
{
/// <summary>Called when the generated object is updated.</summary>
void OnConstruct();
}
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment