diff --git a/IdleSpectator/DossierAvatar.cs b/IdleSpectator/DossierAvatar.cs
index 9a732a5..6877136 100644
--- a/IdleSpectator/DossierAvatar.cs
+++ b/IdleSpectator/DossierAvatar.cs
@@ -21,6 +21,17 @@ public static class DossierAvatar
public static bool UsingVanilla => _usingVanilla;
+ /// True if is inside the live portrait rect.
+ public static bool ContainsScreenPoint(Vector2 screenPoint)
+ {
+ if (_host == null || !_host.gameObject.activeInHierarchy)
+ {
+ return false;
+ }
+
+ return RectTransformUtility.RectangleContainsScreenPoint(_host, screenPoint, null);
+ }
+
public static void ResetHost()
{
_template = null;
diff --git a/IdleSpectator/FocusTooltipPatches.cs b/IdleSpectator/FocusTooltipPatches.cs
new file mode 100644
index 0000000..2e23000
--- /dev/null
+++ b/IdleSpectator/FocusTooltipPatches.cs
@@ -0,0 +1,77 @@
+using HarmonyLib;
+
+namespace IdleSpectator;
+
+///
+/// Idle pins so relationship arrows draw.
+/// That same pin would always show the focus unit tip via
+/// .
+/// Allow tips for a real cursor unit or the dossier live sprite; suppress the pin tip on empty world / dossier chrome.
+///
+[HarmonyPatch(typeof(CursorTooltipHelper), nameof(CursorTooltipHelper.updateGameplayTooltip))]
+internal static class FocusTooltipPatches
+{
+ [HarmonyPrefix]
+ private static bool Prefix(ref bool __result, ref Actor __state)
+ {
+ __state = null;
+ if (!SpectatorMode.Active)
+ {
+ return true;
+ }
+
+ // Live sprite: tip for the focused unit (last_actor is already pinned).
+ if (WatchCaption.IsPointerOverAvatar())
+ {
+ return true;
+ }
+
+ // Dossier chrome (not the sprite): no tip.
+ if (WatchCaption.IsPointerOverPanel())
+ {
+ Tooltip.hideTooltip();
+ __result = false;
+ return false;
+ }
+
+ Actor near = GetActorNearCursorSafe();
+ if (near != null && near.isAlive())
+ {
+ // Real world hover: briefly use that unit for the tip, then restore the pin.
+ __state = UnitSelectionEffect.last_actor;
+ UnitSelectionEffect.setLastActor(near);
+ return true;
+ }
+
+ // Empty world (grass, water, etc.): do not leak the idle focus tip.
+ Tooltip.hideTooltip();
+ __result = false;
+ return false;
+ }
+
+ [HarmonyPostfix]
+ private static void Postfix(Actor __state)
+ {
+ if (__state != null)
+ {
+ UnitSelectionEffect.setLastActor(__state);
+ }
+ }
+
+ private static Actor GetActorNearCursorSafe()
+ {
+ try
+ {
+ if (World.world == null || ControllableUnit.isControllingUnit())
+ {
+ return null;
+ }
+
+ return World.world.getActorNearCursor();
+ }
+ catch
+ {
+ return null;
+ }
+ }
+}
diff --git a/IdleSpectator/WatchCaption.cs b/IdleSpectator/WatchCaption.cs
index 8a98ec6..c7abde5 100644
--- a/IdleSpectator/WatchCaption.cs
+++ b/IdleSpectator/WatchCaption.cs
@@ -19,6 +19,8 @@ public static class WatchCaption
private const float HistoryIcon = 10f;
private const float HistoryColMinW = 118f;
private const float PadX = 4f;
+ /// Inset from the canvas top-right corner (grows left via pivot).
+ private const float ScreenInset = 12f;
private const float PadTop = 4f;
private const float HeaderH = 18f;
private const float BodyH = 44f;
@@ -133,6 +135,23 @@ public static class WatchCaption
return pixelRect.width > 8f && pixelRect.height > 8f;
}
+ /// True if the mouse is over the dossier panel (including the live sprite).
+ public static bool IsPointerOverPanel()
+ {
+ if (_rootRt == null || _root == null || !_root.activeInHierarchy)
+ {
+ return false;
+ }
+
+ return RectTransformUtility.RectangleContainsScreenPoint(_rootRt, Input.mousePosition, null);
+ }
+
+ /// True if the mouse is over the dossier live sprite only.
+ public static bool IsPointerOverAvatar()
+ {
+ return DossierAvatar.ContainsScreenPoint(Input.mousePosition);
+ }
+
public static void Clear()
{
_current = null;
@@ -770,6 +789,7 @@ public static class WatchCaption
float height = Mathf.Max(HeaderH + PadTop * 2f, y + PadTop - Gap);
if (_rootRt != null)
{
+ ApplyRootPlacement();
_rootRt.sizeDelta = new Vector2(_panelWidth, height);
LastPanelSize = _rootRt.sizeDelta;
}
@@ -1159,11 +1179,8 @@ public static class WatchCaption
_root.transform.SetParent(canvas.transform, false);
_rootRt = _root.GetComponent();
- _rootRt.anchorMin = new Vector2(0f, 1f);
- _rootRt.anchorMax = new Vector2(0f, 1f);
- _rootRt.pivot = new Vector2(0f, 1f);
+ ApplyRootPlacement();
_rootRt.sizeDelta = new Vector2(LiveMax + PadX * 2f, HeaderH + PadTop * 2f);
- _rootRt.anchoredPosition = new Vector2(12f, -12f);
Image bg = _root.GetComponent();
bg.raycastTarget = false;
@@ -1250,6 +1267,22 @@ public static class WatchCaption
LogService.LogInfo("[IdleSpectator] Dossier HUD ready (nametag chips + mini history)");
}
+ ///
+ /// Pin the card to the canvas top-right. Pivot (1,1) so width growth expands left and stays on-screen.
+ ///
+ private static void ApplyRootPlacement()
+ {
+ if (_rootRt == null)
+ {
+ return;
+ }
+
+ _rootRt.anchorMin = new Vector2(1f, 1f);
+ _rootRt.anchorMax = new Vector2(1f, 1f);
+ _rootRt.pivot = new Vector2(1f, 1f);
+ _rootRt.anchoredPosition = new Vector2(-ScreenInset, -ScreenInset);
+ }
+
private static void PlaceLine(RectTransform lineRt, float yFromTop, float height, float left)
{
if (lineRt == null)
diff --git a/IdleSpectator/mod.json b/IdleSpectator/mod.json
index 648131e..652042e 100644
--- a/IdleSpectator/mod.json
+++ b/IdleSpectator/mod.json
@@ -1,7 +1,7 @@
{
"name": "IdleSpectator",
"author": "dazed",
- "version": "0.9.28",
- "description": "AFK Idle Spectator (I) + Chronicle (F9). Dossier grows wide before history wraps.",
+ "version": "0.9.31",
+ "description": "AFK Idle Spectator (I) + Chronicle (F9). Dossier top-right; tip on world hover or live sprite.",
"GUID": "com.dazed.idlespectator"
}