319 lines
13 KiB
C#
319 lines
13 KiB
C#
#if UNITY_EDITOR
|
|
#nullable enable
|
|
using System.Collections.Generic;
|
|
using UnityEngine;
|
|
using UnityEngine.UI;
|
|
|
|
namespace io.github.hatayama.uLoopMCP
|
|
{
|
|
// Canvas-based overlay that visualizes SimulateMouseUi cursor position and drag state on Game View.
|
|
// Animation is driven externally via SetCursorScale/SetAlpha from async functions in SimulateMouseUiTool.
|
|
public class SimulateMouseUiOverlay : MonoBehaviour
|
|
{
|
|
private const float WAYPOINT_MARKER_DIAMETER = 12f;
|
|
private const float LINE_THICKNESS = 2f;
|
|
|
|
private static readonly Color CLICK_COLOR = new Color(0f, 1f, 0.4f, 0.9f);
|
|
private static readonly Color DRAG_COLOR = new Color(1f, 0.6f, 0f, 0.9f);
|
|
|
|
[SerializeField] private CanvasGroup _canvasGroup = null!;
|
|
[SerializeField] private RectTransform _cursorGroup = null!;
|
|
[SerializeField] private Image _circleImage = null!;
|
|
[SerializeField] private Image _crosshairH = null!;
|
|
[SerializeField] private Image _crosshairV = null!;
|
|
[SerializeField] private Text _longPressText = null!;
|
|
[SerializeField] private Image _dragStartMarker = null!;
|
|
[SerializeField] private Sprite _circleSprite = null!;
|
|
|
|
private readonly List<Image> _pathSegments = new List<Image>();
|
|
private readonly List<Image> _waypointMarkers = new List<Image>();
|
|
|
|
private const float SELF_EXPAND_DURATION = SimulateMouseUiAnimationConstants.EXPAND_DURATION;
|
|
private const float SELF_EXPAND_START_SCALE = SimulateMouseUiAnimationConstants.EXPAND_START_SCALE;
|
|
private const float SELF_DISSIPATE_DURATION = SimulateMouseUiAnimationConstants.DISSIPATE_DURATION;
|
|
|
|
private enum SelfAnimState { None, Expanding, Dissipating }
|
|
private SelfAnimState _selfAnimState = SelfAnimState.None;
|
|
private float _selfAnimStartTime;
|
|
|
|
private void Awake()
|
|
{
|
|
Debug.Assert(_canvasGroup != null, "_canvasGroup must be assigned in prefab");
|
|
Debug.Assert(_cursorGroup != null, "_cursorGroup must be assigned in prefab");
|
|
Debug.Assert(_circleImage != null, "_circleImage must be assigned in prefab");
|
|
Debug.Assert(_crosshairH != null, "_crosshairH must be assigned in prefab");
|
|
Debug.Assert(_crosshairV != null, "_crosshairV must be assigned in prefab");
|
|
Debug.Assert(_longPressText != null, "_longPressText must be assigned in prefab");
|
|
Debug.Assert(_dragStartMarker != null, "_dragStartMarker must be assigned in prefab");
|
|
Debug.Assert(_circleSprite != null, "_circleSprite must be assigned in prefab");
|
|
|
|
_canvasGroup!.alpha = 0;
|
|
}
|
|
|
|
private void LateUpdate()
|
|
{
|
|
ConsumePendingAnimationRequests();
|
|
|
|
// Dissipate runs independently of overlay state (state is already cleared on release)
|
|
if (_selfAnimState == SelfAnimState.Dissipating)
|
|
{
|
|
float elapsed = Time.realtimeSinceStartup - _selfAnimStartTime;
|
|
float t = Mathf.Clamp01(elapsed / SELF_DISSIPATE_DURATION);
|
|
_cursorGroup.localScale = Vector3.one * Mathf.Lerp(1f, 0f, t);
|
|
_canvasGroup.alpha = Mathf.Lerp(1f, 0f, t);
|
|
if (t >= 1f)
|
|
{
|
|
_selfAnimState = SelfAnimState.None;
|
|
_canvasGroup.alpha = 0f;
|
|
}
|
|
return;
|
|
}
|
|
|
|
if (!SimulateMouseUiOverlayState.IsActive)
|
|
{
|
|
_canvasGroup.alpha = 0;
|
|
return;
|
|
}
|
|
|
|
// SimulateMouseUiTool controls alpha via SetAlpha during animations.
|
|
// For non-animated callers (e.g. InputReplayer), auto-show on first active frame.
|
|
if (_canvasGroup.alpha == 0f)
|
|
{
|
|
_canvasGroup.alpha = 1f;
|
|
_cursorGroup.localScale = Vector3.one;
|
|
}
|
|
|
|
if (_selfAnimState == SelfAnimState.Expanding)
|
|
{
|
|
float elapsed = Time.realtimeSinceStartup - _selfAnimStartTime;
|
|
float t = Mathf.Clamp01(elapsed / SELF_EXPAND_DURATION);
|
|
_cursorGroup.localScale = Vector3.one * Mathf.Lerp(SELF_EXPAND_START_SCALE, 1f, t);
|
|
if (t >= 1f)
|
|
{
|
|
_selfAnimState = SelfAnimState.None;
|
|
}
|
|
}
|
|
|
|
UpdateCursorPosition();
|
|
UpdateCursorMode();
|
|
UpdateDragPath();
|
|
}
|
|
|
|
private void ConsumePendingAnimationRequests()
|
|
{
|
|
if (SimulateMouseUiOverlayState.ConsumePendingExpandAnimation())
|
|
{
|
|
_canvasGroup.alpha = 1f;
|
|
_cursorGroup.localScale = Vector3.one * SELF_EXPAND_START_SCALE;
|
|
_selfAnimState = SelfAnimState.Expanding;
|
|
_selfAnimStartTime = Time.realtimeSinceStartup;
|
|
}
|
|
|
|
if (SimulateMouseUiOverlayState.ConsumePendingDissipateAnimation())
|
|
{
|
|
_selfAnimState = SelfAnimState.Dissipating;
|
|
_selfAnimStartTime = Time.realtimeSinceStartup;
|
|
}
|
|
}
|
|
|
|
public void SetCursorScale(float scale)
|
|
{
|
|
_cursorGroup.localScale = Vector3.one * scale;
|
|
// SimulateMouseUiTool drives animation externally; cancel any self-driven animation
|
|
_selfAnimState = SelfAnimState.None;
|
|
}
|
|
|
|
public void SetAlpha(float alpha)
|
|
{
|
|
_canvasGroup.alpha = alpha;
|
|
_selfAnimState = SelfAnimState.None;
|
|
}
|
|
|
|
// sim coordinate: simX = screen pixel X, simY = EditorScreen.height - canvasY (top-left origin)
|
|
// Canvas Screen Space Overlay position: bottom-left origin
|
|
private static Vector2 SimToScreen(Vector2 simPos)
|
|
{
|
|
Vector2 srcSize = SimulateMouseUiOverlayState.SourceScreenSize;
|
|
Debug.Assert(srcSize.x > 0f && srcSize.y > 0f, "SourceScreenSize must be set before SimToScreen is called");
|
|
return new Vector2(simPos.x, srcSize.y - simPos.y);
|
|
}
|
|
|
|
private void UpdateCursorPosition()
|
|
{
|
|
Vector2 screenPos = SimToScreen(SimulateMouseUiOverlayState.CurrentPosition);
|
|
_cursorGroup.position = new Vector3(screenPos.x, screenPos.y, 0f);
|
|
}
|
|
|
|
private void UpdateCursorMode()
|
|
{
|
|
bool isLongPress = SimulateMouseUiOverlayState.Action == MouseAction.LongPress;
|
|
_crosshairH.enabled = !isLongPress;
|
|
_crosshairV.enabled = !isLongPress;
|
|
_longPressText.enabled = isLongPress;
|
|
|
|
if (isLongPress)
|
|
{
|
|
_longPressText.text = SimulateMouseUiOverlayState.LongPressElapsed.ToString("F1") + "s";
|
|
|
|
// Pulse the circle between 1.0x and 1.2x scale over a 2-second cycle
|
|
float t = Mathf.PingPong(SimulateMouseUiOverlayState.LongPressElapsed, 2f) / 2f;
|
|
float scale = Mathf.Lerp(1.0f, 1.2f, t);
|
|
_circleImage.rectTransform.localScale = Vector3.one * scale;
|
|
}
|
|
else
|
|
{
|
|
_circleImage.rectTransform.localScale = Vector3.one;
|
|
}
|
|
}
|
|
|
|
private void UpdateDragPath()
|
|
{
|
|
if (!SimulateMouseUiOverlayState.DragStartPosition.HasValue)
|
|
{
|
|
_dragStartMarker.enabled = false;
|
|
HidePool(_pathSegments);
|
|
HidePool(_waypointMarkers);
|
|
// Keep a reasonable number of pooled objects for reuse across drags
|
|
TrimPool(_pathSegments, 5);
|
|
TrimPool(_waypointMarkers, 4);
|
|
return;
|
|
}
|
|
|
|
Color activeColor = GetActiveColor();
|
|
|
|
// Start marker
|
|
Vector2 startScreen = SimToScreen(SimulateMouseUiOverlayState.DragStartPosition.Value);
|
|
_dragStartMarker.enabled = true;
|
|
_dragStartMarker.color = activeColor;
|
|
_dragStartMarker.rectTransform.position = new Vector3(startScreen.x, startScreen.y, 0f);
|
|
|
|
// Polyline: start → waypoints → current
|
|
IReadOnlyList<Vector2> waypoints = SimulateMouseUiOverlayState.DragWaypoints;
|
|
Vector2 currentScreen = SimToScreen(SimulateMouseUiOverlayState.CurrentPosition);
|
|
|
|
int segmentCount = waypoints.Count + 1;
|
|
EnsurePoolCount(_pathSegments, segmentCount, CreatePooledSegment);
|
|
EnsurePoolCount(_waypointMarkers, waypoints.Count, CreatePooledWaypointMarker);
|
|
|
|
// All segments fade: oldest = transparent, newest = most visible (but not fully opaque)
|
|
float canvasScale = _pathSegments.Count > 0 ? _pathSegments[0].rectTransform.lossyScale.x : 1f;
|
|
Vector2 prev = startScreen;
|
|
for (int i = 0; i < segmentCount; i++)
|
|
{
|
|
Vector2 to = (i < waypoints.Count) ? SimToScreen(waypoints[i]) : currentScreen;
|
|
float alpha = (float)(i + 1) / (segmentCount + 1);
|
|
Color fadedColor = activeColor;
|
|
fadedColor.a = activeColor.a * alpha;
|
|
DrawSegment(_pathSegments[i], prev, to, fadedColor, canvasScale);
|
|
|
|
if (i < waypoints.Count)
|
|
{
|
|
_waypointMarkers[i].enabled = true;
|
|
_waypointMarkers[i].color = fadedColor;
|
|
_waypointMarkers[i].rectTransform.position = new Vector3(to.x, to.y, 0f);
|
|
}
|
|
|
|
prev = to;
|
|
}
|
|
HidePoolFrom(_pathSegments, segmentCount);
|
|
HidePoolFrom(_waypointMarkers, waypoints.Count);
|
|
}
|
|
|
|
private void DrawSegment(Image line, Vector2 from, Vector2 to, Color color, float canvasScale)
|
|
{
|
|
Vector2 delta = to - from;
|
|
float length = delta.magnitude;
|
|
|
|
if (length < 1f)
|
|
{
|
|
line.enabled = false;
|
|
return;
|
|
}
|
|
|
|
line.enabled = true;
|
|
line.color = color;
|
|
float angle = Mathf.Atan2(delta.y, delta.x) * Mathf.Rad2Deg;
|
|
|
|
RectTransform rect = line.rectTransform;
|
|
rect.position = new Vector3(from.x, from.y, 0f);
|
|
// sizeDelta is in local space; convert screen-pixel length via lossyScale
|
|
rect.sizeDelta = new Vector2(length / canvasScale, LINE_THICKNESS / canvasScale);
|
|
rect.localRotation = Quaternion.Euler(0f, 0f, angle);
|
|
}
|
|
|
|
private Image CreatePooledSegment()
|
|
{
|
|
Image line = CreateImage("PathSegment", gameObject.transform);
|
|
line.rectTransform.pivot = new Vector2(0f, 0.5f);
|
|
line.rectTransform.sizeDelta = Vector2.zero;
|
|
line.enabled = false;
|
|
return line;
|
|
}
|
|
|
|
private Image CreatePooledWaypointMarker()
|
|
{
|
|
Image marker = CreateImage("WaypointMarker", gameObject.transform);
|
|
marker.rectTransform.sizeDelta = new Vector2(WAYPOINT_MARKER_DIAMETER, WAYPOINT_MARKER_DIAMETER);
|
|
marker.sprite = _circleSprite;
|
|
marker.enabled = false;
|
|
return marker;
|
|
}
|
|
|
|
private void EnsurePoolCount(List<Image> pool, int count, System.Func<Image> factory)
|
|
{
|
|
while (pool.Count < count)
|
|
{
|
|
pool.Add(factory());
|
|
}
|
|
}
|
|
|
|
private static void HidePool(List<Image> pool)
|
|
{
|
|
for (int i = 0; i < pool.Count; i++)
|
|
{
|
|
pool[i].enabled = false;
|
|
}
|
|
}
|
|
|
|
// Pools only grow via EnsurePoolCount; trim excess to prevent unbounded accumulation
|
|
private static void TrimPool(List<Image> pool, int maxRetain)
|
|
{
|
|
while (pool.Count > maxRetain)
|
|
{
|
|
Image excess = pool[pool.Count - 1];
|
|
pool.RemoveAt(pool.Count - 1);
|
|
Destroy(excess.gameObject);
|
|
}
|
|
}
|
|
|
|
private static void HidePoolFrom(List<Image> pool, int startIndex)
|
|
{
|
|
for (int i = startIndex; i < pool.Count; i++)
|
|
{
|
|
pool[i].enabled = false;
|
|
}
|
|
}
|
|
|
|
private Color GetActiveColor()
|
|
{
|
|
return SimulateMouseUiOverlayState.Action == MouseAction.Click
|
|
? CLICK_COLOR
|
|
: DRAG_COLOR;
|
|
}
|
|
|
|
private static Image CreateImage(string name, Transform parent)
|
|
{
|
|
GameObject go = new GameObject(name);
|
|
go.transform.SetParent(parent, false);
|
|
RectTransform rect = go.AddComponent<RectTransform>();
|
|
rect.anchorMin = Vector2.zero;
|
|
rect.anchorMax = Vector2.zero;
|
|
rect.pivot = new Vector2(0.5f, 0.5f);
|
|
Image image = go.AddComponent<Image>();
|
|
image.raycastTarget = false;
|
|
return image;
|
|
}
|
|
}
|
|
}
|
|
#endif
|