Files
2024-11-20 15:21:28 +01:00

637 lines
25 KiB
C#

// Copyright (c) Pixel Crushers. All rights reserved.
using UnityEngine;
using UnityEngine.Events;
using PixelCrushers.DialogueSystem.UnityGUI;
namespace PixelCrushers.DialogueSystem
{
/// <summary>
/// This component implements a selector that allows the player to target and use a usable
/// object.
///
/// To mark an object usable, add the Usable component and a collider to it. The object's
/// layer should be in the layer mask specified on the Selector component.
///
/// The selector can be configured to target items under the mouse cursor or the middle of
/// the screen. When a usable object is targeted, the selector displays a targeting reticle
/// and information about the object. If the target is in range, the inRange reticle
/// texture is displayed; otherwise the outOfRange texture is displayed.
///
/// If the player presses the use button (which defaults to spacebar and Fire2), the targeted
/// object will receive an "OnUse" message.
///
/// You can hook into SelectedUsableObject and DeselectedUsableObject to get notifications
/// when the current target has changed.
/// </summary>
[AddComponentMenu("")] // Use wrapper.
public class Selector : MonoBehaviour
{
/// <summary>
/// This class defines the textures and size of the targeting reticle.
/// </summary>
[System.Serializable]
public class Reticle
{
public Texture2D inRange;
public Texture2D outOfRange;
public float width = 64f;
public float height = 64f;
}
/// <summary>
/// Specifies how to target: center of screen or under the mouse cursor. This is where it raycasts to.
/// </summary>
public enum SelectAt { CenterOfScreen, MousePosition, CustomPosition }
/// <summary>
/// Specifies whether to compute range from the targeted object (distance to the camera
/// or distance to the selector's game object).
/// </summary>
public enum DistanceFrom { Camera, GameObject }
/// <summary>
/// Specifies whether to do 2D or 3D raycasts.
/// </summary>
public enum Dimension { In2D, In3D }
/// <summary>
/// The default layermask is just the Default layer.
/// </summary>
private static LayerMask DefaultLayer = 1;
/// <summary>
/// How to target (center of screen or under mouse cursor). Default is center of screen.
/// </summary>
[Tooltip("How to target. This is where the raycast points to.")]
public SelectAt selectAt = SelectAt.CenterOfScreen;
/// <summary>
/// The layer mask to use when targeting objects. Objects on others layers are ignored.
/// </summary>
[Tooltip("Layer mask to use when targeting objects; objects on others layers are ignored.")]
public LayerMask layerMask = DefaultLayer;
/// <summary>
/// How to compute range to targeted object. Default is from the camera.
/// </summary>
[Tooltip("How to compute range to targeted object.")]
public DistanceFrom distanceFrom = DistanceFrom.Camera;
/// <summary>
/// The max selection distance. The selector won't target objects farther than this.
/// </summary>
[Tooltip("Don't target objects farther than this; targets may still be unusable if beyond their usable range.")]
public float maxSelectionDistance = 30f;
/// <summary>
/// Specifies whether to run 2D or 3D raycasts.
/// </summary>
public Dimension runRaycasts = Dimension.In3D;
/// <summary>
/// Set <c>true</c> to check all objects within the raycast range for usables.
/// If <c>false</c>, the check stops on the first hit, even if it's not a usable.
/// This prevents selection through walls.
/// </summary>
[Tooltip("Check all objects within raycast range for usables, even passing through obstacles.")]
public bool raycastAll = false;
/// <summary>
/// If <c>true</c>, uses a default OnGUI to display a selection message and
/// targeting reticle.
/// </summary>
[Tooltip("")]
public bool useDefaultGUI = true;
/// <summary>
/// The GUI skin to use for the target's information (name and use message).
/// </summary>
[Tooltip("GUI skin to use for the target's information (name and use message).")]
public GUISkin guiSkin;
/// <summary>
/// The name of the GUI style in the skin.
/// </summary>
[Tooltip("Name of the GUI style in the skin.")]
public string guiStyleName = "label";
/// <summary>
/// The text alignment.
/// </summary>
public TextAnchor alignment = TextAnchor.UpperCenter;
/// <summary>
/// The text style for the text.
/// </summary>
public TextStyle textStyle = TextStyle.Shadow;
/// <summary>
/// The color of the text style's outline or shadow.
/// </summary>
public Color textStyleColor = Color.black;
/// <summary>
/// The color of the information labels when the target is in range.
/// </summary>
[Tooltip("Color of the information labels when target is in range.")]
public Color inRangeColor = Color.yellow;
/// <summary>
/// The color of the information labels when the target is out of range.
/// </summary>
[Tooltip("Color of the information labels when target is out of range.")]
public Color outOfRangeColor = Color.gray;
/// <summary>
/// The reticle images.
/// </summary>
public Reticle reticle;
/// <summary>
/// The key that sends an OnUse message.
/// </summary>
public KeyCode useKey = KeyCode.Space;
/// <summary>
/// The button that sends an OnUse message.
/// </summary>
public string useButton = "Fire2";
/// <summary>
/// The default use message. This can be overridden in the target's Usable component.
/// </summary>
[Tooltip("Default use message; can be overridden in the target's Usable component")]
public string defaultUseMessage = "(spacebar to interact)";
/// <summary>
/// If ticked, the OnUse message is broadcast to the usable object's children.
/// </summary>
[Tooltip("Tick to also broadcast to the usable object's children")]
public bool broadcastToChildren = true;
/// <summary>
/// The actor transform to send with OnUse. Defaults to this transform.
/// </summary>
[Tooltip("Actor transform to send with OnUse; defaults to this transform")]
public Transform actorTransform = null;
/// <summary>
/// If set, show this alert message if attempt to use something beyond its usable range.
/// </summary>
[Tooltip("If set, show this alert message if attempt to use something beyond its usable range")]
public string tooFarMessage = string.Empty;
public UsableUnityEvent onSelectedUsable = new UsableUnityEvent();
public UsableUnityEvent onDeselectedUsable = new UsableUnityEvent();
/// <summary>
/// The too far event handler.
/// </summary>
public UnityEvent tooFarEvent = new UnityEvent();
/// <summary>
/// If <c>true</c>, draws gizmos.
/// </summary>
[Tooltip("Tick to draw gizmos in Scene view")]
public bool debug = false;
/// <summary>
/// Gets or sets the custom position used when the selectAt is set to SelectAt.CustomPosition.
/// You can use, for example, to slide around a targeting icon onscreen using a gamepad.
/// </summary>
/// <value>
/// The custom position.
/// </value>
public Vector3 CustomPosition { get; set; }
/// <summary>
/// Gets the current selection.
/// </summary>
/// <value>The selection.</value>
public Usable CurrentUsable
{
get { return usable; }
set { SetCurrentUsable(value); }
}
/// <summary>
/// Gets the distance from the current usable.
/// </summary>
/// <value>The current distance.</value>
public float CurrentDistance { get { return distance; } }
/// <summary>
/// Gets the GUI style.
/// </summary>
/// <value>The GUI style.</value>
public GUIStyle GuiStyle { get { SetGuiStyle(); return guiStyle; } }
/// <summary>
/// Occurs when the selector has targeted a usable object.
/// </summary>
public event SelectedUsableObjectDelegate SelectedUsableObject = null;
/// <summary>
/// Occurs when the selector has untargeted a usable object.
/// </summary>
public event DeselectedUsableObjectDelegate DeselectedUsableObject = null;
protected GameObject selection = null; // Currently under the selection point.
protected Usable usable = null; // Usable component of the current selection.
protected GameObject clickedDownOn = null; // Selection when the mouse button was pressed down.
protected string heading = string.Empty;
protected string useMessage = string.Empty;
protected float distance = 0;
protected GUIStyle guiStyle = null;
protected float guiStyleLineHeight = 16f;
protected Ray lastRay = new Ray();
protected RaycastHit lastHit = new RaycastHit();
protected RaycastHit[] lastHits = null;
protected int numLastHits = 0;
private const int MaxHits = 100;
#if USE_PHYSICS2D || !UNITY_2018_1_OR_NEWER
RaycastHit2D[] lastHits2D = null;
#endif
protected bool hasReportedInvalidCamera = false;
public virtual void Start()
{
if (Camera.main == null) Debug.LogError("Dialogue System: The scene is missing a camera tagged 'MainCamera'. The Selector may not behave the way you expect.", this);
}
/// <summary>
/// Runs a raycast to see what's under the selection point. Updates the selection and
/// calls the selection delegates if the selection has changed. If the player hits the
/// use button, sends an OnUse message to the selection.
/// </summary>
protected virtual void Update()
{
// Exit if disabled or paused:
if (!enabled || (Time.timeScale <= 0)) return;
// Exit if there's no camera:
if (UnityEngine.Camera.main == null) return;
// Exit if using mouse selection and is over a UI element:
if ((selectAt == SelectAt.MousePosition) && (UnityEngine.EventSystems.EventSystem.current != null) && UnityEngine.EventSystems.EventSystem.current.IsPointerOverGameObject()) return;
// Raycast 2D or 3D:
switch (runRaycasts)
{
case Dimension.In2D:
Run2DRaycast();
break;
default:
case Dimension.In3D:
Run3DRaycast();
break;
}
// If the player presses the use key/button on a target:
if (IsUseButtonDown()) UseCurrentSelection();
}
/// <summary>
/// Calls OnUse on the current selection.
/// </summary>
public virtual void UseCurrentSelection()
{
if (usable != null && usable.enabled && usable.gameObject.activeInHierarchy)
{
clickedDownOn = null;
if (distance <= usable.maxUseDistance)
{
usable.OnUseUsable();
// If within range, send the OnUse message:
var fromTransform = (actorTransform != null) ? actorTransform : this.transform;
if (broadcastToChildren)
{
usable.gameObject.BroadcastMessage("OnUse", fromTransform, SendMessageOptions.DontRequireReceiver);
}
else
{
usable.gameObject.SendMessage("OnUse", fromTransform, SendMessageOptions.DontRequireReceiver);
}
}
else
{
// Otherwise report too far if configured to do so:
if (!string.IsNullOrEmpty(tooFarMessage))
{
DialogueManager.ShowAlert(tooFarMessage);
}
tooFarEvent.Invoke();
}
}
}
protected virtual void Run2DRaycast()
{
#if USE_PHYSICS2D || !UNITY_2018_1_OR_NEWER
var mainCamera = UnityEngine.Camera.main;
if (!mainCamera.orthographic && !hasReportedInvalidCamera)
{
hasReportedInvalidCamera = true;
Debug.LogWarning("In 2D mode, Selector requires an orthographic camera.", this);
}
if (raycastAll)
{
// Run Physics2D.RaycastAll:
if (lastHits2D == null) lastHits2D = new RaycastHit2D[MaxHits];
int numHits = Physics2D.RaycastNonAlloc(mainCamera.ScreenToWorldPoint(GetSelectionPoint()), Vector2.zero, lastHits2D, maxSelectionDistance, layerMask);
bool foundUsable = false;
for (int i = 0; i < numHits; i++)
{
var hit = lastHits2D[i];
float hitDistance = (distanceFrom == DistanceFrom.Camera) ? 0 : Vector3.Distance(gameObject.transform.position, hit.collider.transform.position);
if (selection == hit.collider.gameObject)
{
foundUsable = true;
distance = hitDistance;
break;
}
else
{
Usable hitUsable = hit.collider.GetComponent<Usable>();
if (hitUsable != null && hitUsable.enabled)
{
foundUsable = true;
distance = hitDistance;
SetCurrentUsable(hitUsable);
break;
}
}
}
if (!foundUsable)
{
DeselectTarget();
}
}
else
{
// Cast a ray and see what we hit:
RaycastHit2D hit;
hit = Physics2D.Raycast(mainCamera.ScreenToWorldPoint(GetSelectionPoint()), Vector2.zero, maxSelectionDistance, layerMask);
if (hit.collider != null)
{
distance = (distanceFrom == DistanceFrom.Camera) ? 0 : Vector3.Distance(gameObject.transform.position, hit.collider.transform.position);
if (selection != hit.collider.gameObject)
{
Usable hitUsable = hit.collider.GetComponent<Usable>();
if (hitUsable != null && hitUsable.enabled)
{
heading = string.Empty;
useMessage = string.Empty;
SetCurrentUsable(hitUsable);
}
else
{
DeselectTarget();
}
}
}
else
{
DeselectTarget();
}
}
#endif
}
protected virtual void Run3DRaycast()
{
Ray ray = UnityEngine.Camera.main.ScreenPointToRay(GetSelectionPoint());
lastRay = ray;
// New Variable rayCastDistance is used below for the raycasts instead of maxSelectionDistance to be able to set it to infinity (if using DistanceFrom.GameObject) instead of maxSelectionDistance:
// Credit: Daniel D. (Thank you!)
float raycastDistance = (distanceFrom == DistanceFrom.GameObject) ? Mathf.Infinity : maxSelectionDistance;
if (raycastAll)
{
// Run RaycastAll:
if (lastHits == null) lastHits = new RaycastHit[MaxHits];
numLastHits = Physics.RaycastNonAlloc(ray, lastHits, raycastDistance, layerMask);
bool foundUsable = false;
for (int i = 0; i < numLastHits; i++)
{
var hit = lastHits[i];
float hitDistance = (distanceFrom == DistanceFrom.Camera) ? hit.distance : Vector3.Distance(gameObject.transform.position, hit.collider.transform.position);
if (selection == hit.collider.gameObject)
{
foundUsable = true;
distance = hitDistance;
break;
}
else
{
Usable hitUsable = hit.collider.GetComponent<Usable>();
if (hitUsable != null && hitUsable.enabled && hitDistance <= maxSelectionDistance)
{
foundUsable = true;
distance = hitDistance;
SetCurrentUsable(hitUsable);
break;
}
}
}
if (!foundUsable)
{
DeselectTarget();
}
}
else
{
// Cast a ray and see what we hit:
RaycastHit hit;
if (Physics.Raycast(ray, out hit, maxSelectionDistance, layerMask))
{
distance = (distanceFrom == DistanceFrom.Camera) ? hit.distance : Vector3.Distance(gameObject.transform.position, hit.collider.transform.position);
Usable hitUsable = hit.collider.GetComponent<Usable>();
if (hitUsable != null && hitUsable.enabled)
{
if (selection != hit.collider.gameObject)
{
SetCurrentUsable(hitUsable);
}
}
else
{
DeselectTarget();
}
}
else
{
DeselectTarget();
}
lastHit = hit;
}
}
public virtual void SetCurrentUsable(Usable usable)
{
if (usable == null)
{
DeselectTarget();
}
else
{
this.usable = usable;
selection = usable.gameObject;
heading = string.Empty;
useMessage = string.Empty;
OnSelectedUsableObject(usable);
}
}
protected void OnSelectedUsableObject(Usable usable)
{
if (SelectedUsableObject != null) SelectedUsableObject(usable);
onSelectedUsable.Invoke(usable);
if (usable != null) usable.OnSelectUsable();
}
protected void OnDeselectedUsableObject(Usable usable)
{
if (DeselectedUsableObject != null) DeselectedUsableObject(usable);
onDeselectedUsable.Invoke(usable);
if (usable != null) usable.OnDeselectUsable();
}
protected virtual void DeselectTarget()
{
OnDeselectedUsableObject(usable);
usable = null;
selection = null;
heading = string.Empty;
useMessage = string.Empty;
}
protected virtual bool IsUseButtonDown()
{
if (DialogueManager.IsDialogueSystemInputDisabled()) return false;
// First check for button down to remember what was selected at the time:
if (!string.IsNullOrEmpty(useButton) && DialogueManager.getInputButtonDown(useButton))
{
clickedDownOn = selection;
}
// Check for use key or button (only if releasing button on same selection):
if ((useKey != KeyCode.None) && InputDeviceManager.IsKeyDown(useKey)) return true;
if (!string.IsNullOrEmpty(useButton))
{
if (DialogueManager.instance != null && DialogueManager.getInputButtonDown == DialogueManager.instance.StandardGetInputButtonDown)
{
return InputDeviceManager.IsButtonUp(useButton) && (selection == clickedDownOn);
}
else
{
return DialogueManager.GetInputButtonDown(useButton);
}
}
return false;
}
protected virtual Vector3 GetSelectionPoint()
{
switch (selectAt)
{
case SelectAt.MousePosition:
return Input.mousePosition;
case SelectAt.CustomPosition:
return CustomPosition;
default:
case SelectAt.CenterOfScreen:
return new Vector3(Screen.width / 2, Screen.height / 2);
}
}
/// <summary>
/// If useDefaultGUI is <c>true</c> and a usable object has been targeted, this method
/// draws a selection message and targeting reticle.
/// </summary>
public virtual void OnGUI()
{
if (!useDefaultGUI) return;
if (guiStyle == null && (Event.current.type == EventType.Repaint || usable != null))
{
SetGuiStyle();
}
if (usable != null)
{
bool inUseRange = (distance <= usable.maxUseDistance);
guiStyle.normal.textColor = inUseRange ? inRangeColor : outOfRangeColor;
if (string.IsNullOrEmpty(heading))
{
heading = usable.GetName();
useMessage = DialogueManager.GetLocalizedText(string.IsNullOrEmpty(usable.overrideUseMessage) ? defaultUseMessage : usable.overrideUseMessage);
}
UnityGUITools.DrawText(new Rect(0, 0, Screen.width, Screen.height), heading, guiStyle, textStyle, textStyleColor);
UnityGUITools.DrawText(new Rect(0, guiStyleLineHeight, Screen.width, Screen.height), useMessage, guiStyle, textStyle, textStyleColor);
Texture2D reticleTexture = inUseRange ? reticle.inRange : reticle.outOfRange;
if (reticleTexture != null) GUI.Label(new Rect(0.5f * (Screen.width - reticle.width), 0.5f * (Screen.height - reticle.height), reticle.width, reticle.height), reticleTexture);
}
}
protected void SetGuiStyle()
{
guiSkin = UnityGUITools.GetValidGUISkin(guiSkin);
GUI.skin = guiSkin;
guiStyle = new GUIStyle(string.IsNullOrEmpty(guiStyleName) ? GUI.skin.label : (GUI.skin.FindStyle(guiStyleName) ?? GUI.skin.label));
guiStyle.alignment = alignment;
guiStyleLineHeight = guiStyle.CalcSize(new GUIContent("Ay")).y;
}
/// <summary>
/// Draws raycast result gizmos.
/// </summary>
public virtual void OnDrawGizmos()
{
if (!debug) return;
Gizmos.color = Color.yellow;
Gizmos.DrawLine(lastRay.origin, lastRay.origin + lastRay.direction * maxSelectionDistance);
if (raycastAll)
{
if (lastHits != null)
{
for (int i = 0; i < numLastHits; i++)
{
var hit = lastHits[i];
var usable = hit.collider.GetComponent<Usable>();
bool isUsable = (usable != null) && usable.enabled;
Gizmos.color = isUsable ? Color.green : Color.red;
Gizmos.DrawWireSphere(hit.point, 0.2f);
}
}
}
else
{
if (lastHit.collider != null)
{
var usable = lastHit.collider.GetComponent<Usable>();
bool isUsable = (usable != null) && usable.enabled;
Gizmos.color = isUsable ? Color.green : Color.red;
Gizmos.DrawWireSphere(lastHit.point, 0.2f);
}
}
}
}
}