You cannot select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
678 lines
22 KiB
C#
678 lines
22 KiB
C#
// Copyright © Sascha Graeff/13Pixels.
|
|
|
|
namespace ThirteenPixels.Placr
|
|
{
|
|
using UnityEngine;
|
|
using UnityEditor;
|
|
using UnityEditor.EditorTools;
|
|
using System.Collections.Generic;
|
|
using System.Linq;
|
|
|
|
/// <summary>
|
|
/// Placr increases your level design speed by letting you place objects quickly.
|
|
/// </summary>
|
|
// Enable the Placr Tool and select a Prefab or 3D model in your project view.
|
|
// Options are available in the scene view.
|
|
// Controls:
|
|
// * Left click to place the object.
|
|
// * Right click to de-select the object.
|
|
// * Hold Ctrl to rotate the object with your mouse.
|
|
// * Hold Shift to change the vertical offset. Right click while holding Shift to reset.
|
|
[EditorTool(nameof(Placr))]
|
|
internal class Placr : EditorTool
|
|
{
|
|
#region Operations
|
|
private abstract class PlacrOperationBase
|
|
{
|
|
public abstract bool isActive { get; }
|
|
public abstract void Update(Vector3 position, Quaternion rotation);
|
|
public abstract void Apply(PlacrModifierList modifiers);
|
|
public abstract void Abort();
|
|
}
|
|
|
|
private class PlaceNewPrefabInstancesOperation : PlacrOperationBase
|
|
{
|
|
private GameObject[] prefabs;
|
|
private GameObject selectedPrefab;
|
|
private Transform prototype;
|
|
|
|
public override bool isActive => prototype != null;
|
|
|
|
|
|
public PlaceNewPrefabInstancesOperation(GameObject[] prefabs)
|
|
{
|
|
this.prefabs = prefabs;
|
|
|
|
SelectRandomPrefab();
|
|
|
|
CreatePrototype();
|
|
}
|
|
|
|
private void SelectRandomPrefab()
|
|
{
|
|
selectedPrefab = prefabs[Random.Range(0, prefabs.Length)];
|
|
}
|
|
|
|
private void CreatePrototype()
|
|
{
|
|
var prototypeGameObject = Instantiate(selectedPrefab);
|
|
prototypeGameObject.hideFlags = HideFlags.HideAndDontSave;
|
|
|
|
var colliders = prototypeGameObject.GetComponentsInChildren<Collider>();
|
|
foreach (var collider in colliders)
|
|
{
|
|
DestroyImmediate(collider);
|
|
}
|
|
|
|
var scripts = prototypeGameObject.GetComponentsInChildren<MonoBehaviour>();
|
|
foreach (var script in scripts)
|
|
{
|
|
DestroyImmediate(script);
|
|
}
|
|
|
|
prototype = prototypeGameObject.transform;
|
|
}
|
|
|
|
public override void Update(Vector3 position, Quaternion rotation)
|
|
{
|
|
prototype.position = position;
|
|
prototype.rotation = rotation;
|
|
}
|
|
|
|
public override void Apply(PlacrModifierList modifiers)
|
|
{
|
|
var newInstance = (GameObject)PrefabUtility.InstantiatePrefab(selectedPrefab);
|
|
var newTransform = newInstance.transform;
|
|
if (Settings.Parent.parent)
|
|
{
|
|
newTransform.SetParent(Settings.Parent.parent);
|
|
}
|
|
newTransform.position = prototype.position;
|
|
newTransform.rotation = prototype.rotation;
|
|
|
|
modifiers.ApplyTo(newInstance);
|
|
|
|
Undo.RegisterCreatedObjectUndo(newInstance, "Placr Place Object");
|
|
|
|
if (prefabs.Length > 1)
|
|
{
|
|
var previousSelected = selectedPrefab;
|
|
SelectRandomPrefab();
|
|
if (selectedPrefab != previousSelected)
|
|
{
|
|
DestroyImmediate(prototype.gameObject);
|
|
CreatePrototype();
|
|
}
|
|
}
|
|
}
|
|
|
|
public override void Abort()
|
|
{
|
|
selectedPrefab = null;
|
|
prefabs = null;
|
|
|
|
if (prototype)
|
|
{
|
|
DestroyImmediate(prototype.gameObject);
|
|
}
|
|
}
|
|
}
|
|
|
|
private class MoveExistingGameObjectOperation : PlacrOperationBase
|
|
{
|
|
private Transform target;
|
|
|
|
private Vector3 originalPosition;
|
|
private Quaternion originalRotation;
|
|
private readonly List<Collider> temporarilyDisabledColliders = new List<Collider>();
|
|
|
|
public override bool isActive => target != null;
|
|
|
|
|
|
public MoveExistingGameObjectOperation(GameObject gameObject)
|
|
{
|
|
target = gameObject.transform;
|
|
originalPosition = target.position;
|
|
originalRotation = target.rotation;
|
|
|
|
DisableTargetColliders(gameObject);
|
|
}
|
|
|
|
private void DisableTargetColliders(GameObject gameObject)
|
|
{
|
|
var colliders = gameObject.GetComponentsInChildren<Collider>();
|
|
foreach (var collider in colliders)
|
|
{
|
|
if (collider.enabled)
|
|
{
|
|
collider.enabled = false;
|
|
temporarilyDisabledColliders.Add(collider);
|
|
}
|
|
}
|
|
}
|
|
|
|
public override void Update(Vector3 position, Quaternion rotation)
|
|
{
|
|
target.position = position;
|
|
target.rotation = rotation;
|
|
}
|
|
|
|
public override void Apply(PlacrModifierList modifiers)
|
|
{
|
|
foreach (var collider in temporarilyDisabledColliders)
|
|
{
|
|
var serializedObject = new SerializedObject(collider);
|
|
PrefabUtility.RevertPropertyOverride(serializedObject.FindProperty("m_Enabled"), InteractionMode.AutomatedAction);
|
|
serializedObject.ApplyModifiedPropertiesWithoutUndo();
|
|
if (!collider.enabled)
|
|
{
|
|
collider.enabled = true;
|
|
}
|
|
}
|
|
temporarilyDisabledColliders.Clear();
|
|
|
|
var newPosition = target.position;
|
|
var newRotation = target.rotation;
|
|
|
|
Update(originalPosition, originalRotation);
|
|
|
|
Undo.RecordObject(target, "Placr Move Object");
|
|
|
|
Update(newPosition, newRotation);
|
|
|
|
target = null;
|
|
}
|
|
|
|
public override void Abort()
|
|
{
|
|
Update(originalPosition, originalRotation);
|
|
target = null;
|
|
}
|
|
}
|
|
#endregion
|
|
|
|
private static class Settings
|
|
{
|
|
public static class Grid
|
|
{
|
|
public static bool isActive { get; private set; }
|
|
private static Vector3 offset;
|
|
private static Vector3 size = new Vector3(1, 0, 1);
|
|
|
|
public static Vector3 Align(Vector3 v)
|
|
{
|
|
v.x = RoundToNext(v.x, offset.x, size.x);
|
|
v.y = RoundToNext(v.y, offset.y, size.y);
|
|
v.z = RoundToNext(v.z, offset.z, size.z);
|
|
return v;
|
|
}
|
|
|
|
public static void DisplayOptionsGUI()
|
|
{
|
|
isActive = GUILayout.Toggle(isActive, new GUIContent("Grid"));
|
|
GUI.enabled = isActive;
|
|
|
|
GUILayout.BeginVertical(GUILayout.Width(150));
|
|
|
|
GUILayout.BeginHorizontal();
|
|
GUILayout.Label("Offset", GUILayout.Width(50));
|
|
offset = EditorGUILayout.Vector3Field(GUIContent.none, offset);
|
|
GUILayout.EndHorizontal();
|
|
|
|
GUILayout.BeginHorizontal();
|
|
GUILayout.Label("Size", GUILayout.Width(50));
|
|
size = EditorGUILayout.Vector3Field(GUIContent.none, size);
|
|
GUILayout.EndHorizontal();
|
|
|
|
GUILayout.EndVertical();
|
|
|
|
GUI.enabled = true;
|
|
}
|
|
}
|
|
|
|
public static class AngleSnap
|
|
{
|
|
private static float snapValue = 0f;
|
|
|
|
public static void DisplayOptionsGUI()
|
|
{
|
|
GUILayout.BeginHorizontal(GUILayout.Width(260));
|
|
GUILayout.Label("Angle Snap", GUILayout.Width(75));
|
|
snapValue = EditorGUILayout.FloatField(snapValue);
|
|
snapValue = Mathf.Clamp(snapValue, 0, 180);
|
|
|
|
void DisplayAngleSnapButton(float angle)
|
|
{
|
|
if (GUILayout.Button(angle + "", EditorStyles.miniButton))
|
|
{
|
|
snapValue = angle;
|
|
}
|
|
}
|
|
|
|
DisplayAngleSnapButton(0);
|
|
DisplayAngleSnapButton(15);
|
|
DisplayAngleSnapButton(45);
|
|
DisplayAngleSnapButton(90);
|
|
|
|
GUILayout.EndHorizontal();
|
|
}
|
|
|
|
public static float CalculateSnappedAngle(float unsnappedAngle)
|
|
{
|
|
if (snapValue > 0)
|
|
{
|
|
return Mathf.Round(unsnappedAngle / snapValue) * snapValue;
|
|
}
|
|
return unsnappedAngle;
|
|
}
|
|
}
|
|
|
|
// This setting was obsolete if Unity would let us use the default parent feature...
|
|
public static class Parent
|
|
{
|
|
public static Transform parent { get; private set; }
|
|
|
|
public static void DisplayOptionsGUI()
|
|
{
|
|
parent = (Transform)EditorGUILayout.ObjectField(new GUIContent("New Instance Parent"), parent, typeof(Transform), true);
|
|
if (parent && IsPrefab(parent.gameObject))
|
|
{
|
|
parent = null;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
private static class LastHit
|
|
{
|
|
public static Vector3 position { get; private set; }
|
|
public static Vector3 normal { get; private set; }
|
|
public static float distance { get; private set; }
|
|
|
|
public static void Update(Vector3 position, Vector3 normal, float distance)
|
|
{
|
|
LastHit.position = position;
|
|
LastHit.normal = normal;
|
|
LastHit.distance = distance;
|
|
}
|
|
}
|
|
|
|
private static class RaycastModes
|
|
{
|
|
private static readonly string[] titles = { "Ground Raycast", "Surface Raycast", "Fixed Ground Plane" };
|
|
public const int SURFACE_RAYCAST = 1;
|
|
public const int FIXED_GROUND_PLANE = 2;
|
|
|
|
public static int activeMode { get; private set; } = 0;
|
|
|
|
public static void DisplayToolbar()
|
|
{
|
|
activeMode = GUILayout.Toolbar(activeMode, titles);
|
|
}
|
|
}
|
|
|
|
private const float windowWidth = 380f;
|
|
|
|
private PlacrOperationBase currentOperation;
|
|
private bool isRunningOperation => currentOperation != null;
|
|
|
|
private static float verticalOffset = 0f;
|
|
private static float unsnappedYaw = 0f;
|
|
private static float yaw = 0f;
|
|
|
|
// Variables for proper right-click-to-exit detection
|
|
private bool exiting;
|
|
private Vector2 exitClickPosition;
|
|
|
|
[SerializeField, HideInInspector]
|
|
private PlacrModifierList modifierList = default;
|
|
private bool displayModifiers = false;
|
|
|
|
[SerializeField]
|
|
private Texture2D toolIcon;
|
|
public GUIContent _toolbarIconContent;
|
|
public override GUIContent toolbarIcon => _toolbarIconContent;
|
|
|
|
internal static Placr instance { get; private set; }
|
|
public static bool isActive => instance != null;
|
|
|
|
|
|
private void OnEnable()
|
|
{
|
|
_toolbarIconContent = new GUIContent()
|
|
{
|
|
image = toolIcon,
|
|
text = " " + nameof(Placr),
|
|
tooltip = nameof(Placr)
|
|
};
|
|
|
|
#if !UNITY_2020_2_OR_NEWER
|
|
instance = this;
|
|
OnToolActivated();
|
|
#endif
|
|
}
|
|
|
|
#if UNITY_2020_2_OR_NEWER
|
|
public override void OnActivated()
|
|
{
|
|
OnToolActivated();
|
|
}
|
|
|
|
public override void OnWillBeDeactivated()
|
|
{
|
|
OnToolDeactivated();
|
|
}
|
|
#else
|
|
private static bool placrIsActive = false;
|
|
|
|
[InitializeOnLoadMethod]
|
|
private static void Intialize()
|
|
{
|
|
EditorTools.activeToolChanged += OnActiveToolChanged;
|
|
}
|
|
|
|
private static void OnActiveToolChanged()
|
|
{
|
|
if (EditorTools.IsActiveTool(instance))
|
|
{
|
|
if (!placrIsActive)
|
|
{
|
|
instance.OnToolActivated();
|
|
placrIsActive = true;
|
|
}
|
|
}
|
|
else
|
|
{
|
|
if (placrIsActive)
|
|
{
|
|
instance.OnToolDeactivated();
|
|
placrIsActive = false;
|
|
}
|
|
}
|
|
}
|
|
#endif
|
|
|
|
private void OnToolActivated()
|
|
{
|
|
Selection.selectionChanged += OnSelectionChange;
|
|
EditorApplication.update += RepaintSceneViews;
|
|
ResetVerticalOffset();
|
|
ResetAngle();
|
|
|
|
modifierList = CreateInstance<PlacrModifierList>();
|
|
|
|
OnSelectionChange();
|
|
instance = this;
|
|
}
|
|
|
|
private void OnToolDeactivated()
|
|
{
|
|
Selection.selectionChanged -= OnSelectionChange;
|
|
EditorApplication.update -= RepaintSceneViews;
|
|
AbortCurrentOperation();
|
|
|
|
DestroyImmediate(modifierList);
|
|
instance = null;
|
|
}
|
|
|
|
private void RepaintSceneViews()
|
|
{
|
|
if (isRunningOperation)
|
|
{
|
|
SceneView.RepaintAll();
|
|
}
|
|
}
|
|
|
|
public override void OnToolGUI(EditorWindow window)
|
|
{
|
|
#if !UNITY_2021_2_OR_NEWER
|
|
Handles.BeginGUI();
|
|
GUILayout.Window(0, new Rect(10, 30, windowWidth, 0), id => OnGUI(), toolbarIcon);
|
|
if (displayModifiers)
|
|
{
|
|
GUILayout.Window(1, new Rect(10, 200, windowWidth, 0), id => modifierList.Display(), new GUIContent("Modifiers"));
|
|
}
|
|
Handles.EndGUI();
|
|
#endif
|
|
|
|
if (isRunningOperation)
|
|
{
|
|
PerformPlacrLogic(SceneView.currentDrawingSceneView);
|
|
}
|
|
}
|
|
|
|
#if UNITY_2021_2_OR_NEWER
|
|
public void DrawOverlayGUI()
|
|
{
|
|
GUILayout.BeginVertical(GUILayout.Width(windowWidth));
|
|
GUILayout.Space(8);
|
|
DrawWindow(0, id => OnGUI(), toolbarIcon);
|
|
if (displayModifiers)
|
|
{
|
|
GUILayout.Space(8);
|
|
DrawWindow(1, id => modifierList.Display(), new GUIContent("Modifiers"));
|
|
}
|
|
GUILayout.FlexibleSpace();
|
|
GUILayout.EndVertical();
|
|
}
|
|
|
|
private static void DrawWindow(int id, GUI.WindowFunction func, GUIContent title)
|
|
{
|
|
GUILayout.BeginVertical(title, GUI.skin.GetStyle("Window"));
|
|
func(id);
|
|
GUILayout.EndVertical();
|
|
}
|
|
#endif
|
|
|
|
/// <summary>
|
|
/// Renders the in-SceneView GUI.
|
|
/// </summary>
|
|
private void OnGUI()
|
|
{
|
|
RaycastModes.DisplayToolbar();
|
|
|
|
GUILayout.BeginHorizontal();
|
|
GUILayout.BeginVertical();
|
|
Settings.Grid.DisplayOptionsGUI();
|
|
Settings.AngleSnap.DisplayOptionsGUI();
|
|
GUILayout.EndVertical();
|
|
GUILayout.BeginVertical();
|
|
DisplayPickUpButton();
|
|
DisplayModifierButton();
|
|
GUILayout.EndVertical();
|
|
GUILayout.EndHorizontal();
|
|
Settings.Parent.DisplayOptionsGUI();
|
|
}
|
|
|
|
private void DisplayPickUpButton()
|
|
{
|
|
GUI.enabled = !isRunningOperation &&
|
|
Selection.activeGameObject &&
|
|
!IsPrefab(Selection.activeGameObject);
|
|
if (GUILayout.Button("Pick up", GUILayout.Height(58)))
|
|
{
|
|
StartOperation(new MoveExistingGameObjectOperation(Selection.activeGameObject));
|
|
}
|
|
GUI.enabled = true;
|
|
}
|
|
|
|
private void DisplayModifierButton()
|
|
{
|
|
if (modifierList == null)
|
|
{
|
|
modifierList = CreateInstance<PlacrModifierList>();
|
|
}
|
|
|
|
var activeModifierCount = modifierList.activeModifierCount;
|
|
var label = $"{modifierList.activeModifierCount} modifier{(modifierList.activeModifierCount == 1 ? "" : "s")}";
|
|
if (GUILayout.Button(label))
|
|
{
|
|
displayModifiers = !displayModifiers;
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Performs the Placr logic as described in the class summary.
|
|
/// </summary>
|
|
private void PerformPlacrLogic(SceneView sceneView)
|
|
{
|
|
HandleUtility.AddDefaultControl(GUIUtility.GetControlID(FocusType.Passive));
|
|
|
|
var e = Event.current;
|
|
|
|
if (!e.control && !e.shift)
|
|
{
|
|
EditorGUIUtility.AddCursorRect(sceneView.position, MouseCursor.MoveArrow);
|
|
var ray = HandleUtility.GUIPointToWorldRay(e.mousePosition);
|
|
|
|
if (RaycastModes.activeMode == RaycastModes.FIXED_GROUND_PLANE)
|
|
{
|
|
var plane = new Plane(Vector3.up, Vector3.zero);
|
|
if (plane.Raycast(ray, out var distance))
|
|
{
|
|
LastHit.Update(ray.GetPoint(distance), Vector3.up, distance);
|
|
}
|
|
}
|
|
else
|
|
{
|
|
RaycastHit hit;
|
|
if (Physics.Raycast(ray, out hit))
|
|
{
|
|
var normal = RaycastModes.activeMode == RaycastModes.SURFACE_RAYCAST ? hit.normal : Vector3.up;
|
|
LastHit.Update(hit.point, normal, hit.distance);
|
|
}
|
|
}
|
|
}
|
|
else if (e.control)
|
|
{
|
|
EditorGUIUtility.AddCursorRect(sceneView.position, MouseCursor.RotateArrow);
|
|
unsnappedYaw -= e.delta.x;
|
|
unsnappedYaw = Mathf.Repeat(unsnappedYaw, 360f);
|
|
yaw = Settings.AngleSnap.CalculateSnappedAngle(unsnappedYaw);
|
|
}
|
|
else if (e.shift && e.button != 1)
|
|
{
|
|
EditorGUIUtility.AddCursorRect(sceneView.position, MouseCursor.ResizeVertical);
|
|
verticalOffset += e.delta.y * -0.001f * LastHit.distance;
|
|
}
|
|
|
|
var newPosition = LastHit.position + Vector3.up * verticalOffset;
|
|
if (Settings.Grid.isActive)
|
|
{
|
|
newPosition = Settings.Grid.Align(newPosition);
|
|
}
|
|
var newRotation = Quaternion.AngleAxis(yaw, LastHit.normal) * Quaternion.FromToRotation(Vector3.up, LastHit.normal);
|
|
currentOperation.Update(newPosition, newRotation);
|
|
|
|
CheckMouseClicks();
|
|
}
|
|
|
|
private static float RoundToNext(float value, float offset, float scale)
|
|
{
|
|
if (scale <= 0) return value;
|
|
|
|
value -= offset;
|
|
value /= scale;
|
|
value = Mathf.Round(value);
|
|
return value * scale + offset;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Check if the left or right mouse button has been clicked.
|
|
/// Place object or de-select asset accordingly.
|
|
/// </summary>
|
|
private void CheckMouseClicks()
|
|
{
|
|
var e = Event.current;
|
|
if (e.type == EventType.MouseDown && !e.alt)
|
|
{
|
|
if (e.button == 0)
|
|
{
|
|
currentOperation.Apply(modifierList);
|
|
if (!currentOperation.isActive)
|
|
{
|
|
currentOperation = null;
|
|
}
|
|
}
|
|
else if (e.button == 1)
|
|
{
|
|
if (e.shift)
|
|
{
|
|
ResetVerticalOffset();
|
|
}
|
|
else
|
|
{
|
|
exiting = true;
|
|
exitClickPosition = e.mousePosition;
|
|
}
|
|
}
|
|
}
|
|
|
|
if (exiting)
|
|
{
|
|
var dist = Vector2.Distance(e.mousePosition, exitClickPosition);
|
|
if (dist >= 5)
|
|
{
|
|
exiting = false;
|
|
}
|
|
else if (e.type == EventType.MouseUp && e.button == 1)
|
|
{
|
|
AbortCurrentOperation();
|
|
|
|
Selection.activeGameObject = null;
|
|
}
|
|
}
|
|
}
|
|
|
|
private void StartOperation(PlacrOperationBase operation)
|
|
{
|
|
AbortCurrentOperation();
|
|
|
|
currentOperation = operation;
|
|
}
|
|
|
|
private void AbortCurrentOperation()
|
|
{
|
|
if (currentOperation != null)
|
|
{
|
|
currentOperation.Abort();
|
|
currentOperation = null;
|
|
}
|
|
}
|
|
|
|
private void OnSelectionChange()
|
|
{
|
|
AbortCurrentOperation();
|
|
ResetVerticalOffset();
|
|
|
|
var selection = Selection.gameObjects;
|
|
|
|
if (selection.Length > 0)
|
|
{
|
|
var selectionIsAllPrefabs = selection.All(go => IsPrefab(go));
|
|
|
|
if (selectionIsAllPrefabs)
|
|
{
|
|
StartOperation(new PlaceNewPrefabInstancesOperation(selection.ToArray()));
|
|
}
|
|
}
|
|
}
|
|
|
|
private static bool IsPrefab(GameObject gameObject)
|
|
{
|
|
return gameObject && gameObject.scene.path == null;
|
|
}
|
|
|
|
private void ResetVerticalOffset()
|
|
{
|
|
verticalOffset = 0f;
|
|
}
|
|
|
|
private void ResetAngle()
|
|
{
|
|
unsnappedYaw = 0f;
|
|
yaw = 0f;
|
|
}
|
|
}
|
|
}
|