A skill tooltip overlay panel that sits at the root of the UI. It tracks hovering over skill diamonds in roster and catalog cards and draws a positioned tooltip with name, grade, effect, stamina cost, cooldown and linked skills, localized in Russian or English.
using System;
using System.Linq;
using Sandbox;
using Sandbox.UI;
using Socer;
// A single skill tooltip portal, outside every card and roster scroll clip.
[StyleSheet("/SkillHoverOverlay.scss")]
public sealed class SkillHoverOverlay : Panel
{
static SkillHoverOverlay active;
static Panel interactionSource;
readonly Panel tooltip;
Panel source;
SkillBadgeView emblem;
int contentStamp=int.MinValue,layoutStamp=int.MinValue;
Rect lastEmblemRect,lastRootRect;
float lastScale,lastTooltipHeight,keepUntil,showAt;
bool showing,animationArmed;
public SkillHoverOverlay()
{
AddClass("skill-hover-overlay");AddClass("localized-text");
tooltip=Add.Panel("skill-tooltip localized-text");
}
// Only the painted diamond, rather than the whole card, opens the tooltip.
public static void Track(Panel owner,Panel strip)
{
if(owner==null)return;
if(owner.IsDeleting || !owner.IsVisible || strip==null || strip.IsDeleting ||
(interactionSource!=null && !interactionSource.IsDeleting))
{Leave(owner);return;}
var mouse=owner.PanelPositionToScreenPosition(owner.MousePosition);
if(ScreenBounds(strip).IsInside(mouse) && SameUiLayerHovered(owner) && InsideScrollViewport(owner,mouse))
{
foreach(var child in strip.Children)
if(child is SkillBadgeView badge && !badge.IsDeleting && UniqueSkills.Get(badge.Skill)!=null && OnDiamond(badge,mouse))
{ShowFor(owner,badge);return;}
}
Leave(owner);
}
static void ShowFor(Panel owner,SkillBadgeView badge)
{
if(owner==null || badge==null || owner.IsDeleting || badge.IsDeleting)return;
var root=owner.FindRootPanel();if(root==null)return;
if(active==null || active.IsDeleting || active.Parent!=root)
{
if(active!=null && !active.IsDeleting)active.Delete(true);
active=root.AddChild<SkillHoverOverlay>();
}
if(active.source!=owner || active.emblem!=badge)
{
if(active.source!=null && !active.source.IsDeleting)active.source.SetClass("skill-hovering",false);
active.source=owner;active.emblem=badge;
active.contentStamp=int.MinValue;active.layoutStamp=int.MinValue;
active.SetClass("visible",false);active.showing=true;active.animationArmed=false;
}
// Clean up old flyout hotload state; the static originals stay visible.
owner.SetClass("skill-hovering",false);
active.keepUntil=Time.Now+.13f;
active.UpdateContents();
}
public static void Leave(Panel owner)
{
if(active==null || owner==null || active.source!=owner)return;
if(!active.KeepOpen())active.Hide();
}
public static void Interaction(Panel owner,bool pressed)
{
if(owner==null)return;
if(pressed){interactionSource=owner;if(active!=null)active.Hide();}
else if(interactionSource==owner)interactionSource=null;
}
public static void Forget(Panel owner)
{
if(owner==null)return;
if(active!=null && active.source==owner)active.Hide();
if(interactionSource==owner)interactionSource=null;
}
static Rect ScreenBounds(Panel panel)
{
var a=panel.PanelPositionToScreenPosition(Vector2.Zero);
var b=panel.PanelPositionToScreenPosition(new Vector2(panel.Box.Rect.Width,panel.Box.Rect.Height));
return new Rect(a.x,a.y,b.x-a.x,b.y-a.y);
}
static bool OnDiamond(Panel badge,Vector2 mouse)
{
var rect=ScreenBounds(badge);
if(rect.Width<=0 || rect.Height<=0 || !rect.IsInside(mouse))return false;
return Math.Abs(mouse.x-rect.Center.x)/(rect.Width*.5f)+
Math.Abs(mouse.y-rect.Center.y)/(rect.Height*.5f)<=.98f;
}
static bool InsideScrollViewport(Panel owner,Vector2 mouse)
{
var scroll=owner.Ancestors.FirstOrDefault(p=>p.HasClass("bench-strip") || p.HasClass("catalog-grid"));
return scroll==null || ScreenBounds(scroll).IsInside(mouse);
}
static bool SameUiLayerHovered(Panel owner)
{
var scope=owner.Ancestors.FirstOrDefault(p=>p.HasClass("player-profile-dialog") || p.HasClass("club-body"));
return scope!=null && !scope.IsDeleting && scope.IsVisible && scope.HasHovered;
}
bool KeepOpen()
{
if(source==null || emblem==null || source.IsDeleting || emblem.IsDeleting ||
!source.IsVisible || !SameUiLayerHovered(source))return false;
var mouse=source.PanelPositionToScreenPosition(source.MousePosition);
if(OnDiamond(emblem,mouse) && InsideScrollViewport(source,mouse))
{keepUntil=Time.Now+.13f;return true;}
if(HasClass("visible") && ScreenBounds(tooltip).IsInside(mouse))
{keepUntil=Time.Now+.13f;return true;}
return Time.Now<keepUntil;
}
void Hide()
{
if(source!=null && !source.IsDeleting)source.SetClass("skill-hovering",false);
source=null;emblem=null;showing=false;SetClass("visible",false);
}
void AddText(Panel parent,string text,string css)
{
if(string.IsNullOrEmpty(text))return;
var label=parent.AddChild<Label>(css);label.Text=text;
}
void UpdateContents()
{
if(emblem==null || emblem.IsDeleting)return;
var ability=UniqueSkills.Get(emblem.Skill);if(ability==null){Hide();return;}
int stamp=HashCode.Combine(emblem.Skill,emblem.Grade,GamePreferences.Russian);
if(stamp==contentStamp)return;
contentStamp=stamp;tooltip.DeleteChildren(true);
bool ru=GamePreferences.Russian;
var instance=new SkillInstance{Skill=emblem.Skill,Grade=emblem.Grade};
tooltip.SetClass("skill-grade-2",emblem.Grade>=2);
var header=tooltip.Add.Panel("skill-tooltip-header");
var image=header.Add.Panel("skill-tooltip-icon");
image.Style.Set("background-image",$"url('{UniqueSkills.ArtPath(emblem.Skill,emblem.Grade)}')");
var heading=header.Add.Panel("skill-tooltip-heading");
AddText(heading,ability.Name(ru),"skill-tooltip-name");
AddText(heading,$"{UniqueSkills.GradeName(emblem.Grade,ru)} · {ability.Role(ru)} · "+
(ability.IsPassive?(ru?"Постоянный":"Passive"):(ru?"Приём":"Active")),"skill-tooltip-grade");
AddText(tooltip,UniqueSkills.EffectDescription(instance,ru),"skill-tooltip-description");
AddText(tooltip,ability.Bonus(ru,emblem.Grade),"skill-tooltip-bonus");
var condition=tooltip.Add.Panel("skill-tooltip-condition");
AddText(condition,ru?"Условия":"Trigger","skill-tooltip-caption");
AddText(condition,ability.Trigger(ru),"skill-tooltip-trigger");
if(ability.IsPassive)
AddText(tooltip,ru?"Не расходует стамину.":"No stamina cost.","skill-tooltip-cost");
else
{
AddText(tooltip,ru?$"Стамина −{ability.StaminaCost*100:0}% · требуется {ability.MinimumStamina*100:0}%":
$"Stamina −{ability.StaminaCost*100:0}% · requires {ability.MinimumStamina*100:0}%","skill-tooltip-cost");
AddText(tooltip,ru?$"Перезарядка: {ability.CooldownSeconds/4:0.#} мин. матча":
$"Cooldown: {ability.CooldownSeconds/4:0.#} match minutes","skill-tooltip-cooldown");
}
var linkedSkills=UniqueSkills.LinkedSkills(emblem.Skill);
var synergyText=ability.Synergy(ru);
if((linkedSkills?.Count??0)>0 || !string.IsNullOrEmpty(synergyText))
{
var synergy=tooltip.Add.Panel("skill-tooltip-synergy");
AddText(synergy,ru?"Связка":"Combination","skill-tooltip-caption");
if(linkedSkills!=null && linkedSkills.Count>0)
{
var links=synergy.Add.Panel("skill-tooltip-links");
foreach(var linkedSkill in linkedSkills)
{
var linkedAbility=UniqueSkills.Get(linkedSkill);if(linkedAbility==null)continue;
var link=links.Add.Panel("skill-tooltip-linked");
var icon=link.Add.Panel("skill-tooltip-linked-icon");
icon.Style.Set("background-image",$"url('{UniqueSkills.ArtPath(linkedSkill,1)}')");
AddText(link,linkedAbility.Name(ru),"skill-tooltip-linked-name");
}
}
AddText(synergy,synergyText,"skill-tooltip-combination");
}
}
void PositionTooltip()
{
if(emblem==null || emblem.IsDeleting)return;
var root=FindRootPanel();if(root==null)return;
var bounds=ScreenBounds(root);var rect=ScreenBounds(emblem);float scale=root.ScaleFromScreen;
float height=Math.Max(180,tooltip.Box.Rect.Height*scale);
if(layoutStamp==contentStamp && lastEmblemRect.Equals(rect) && lastRootRect.Equals(bounds) &&
lastScale==scale && Math.Abs(lastTooltipHeight-height)<.1f)return;
float screenW=bounds.Width*scale,screenH=bounds.Height*scale;
if(screenW<80 || screenH<80)return;
float width=Math.Min(312,screenW-24);
var cardBounds=ScreenBounds(source);
float x=(cardBounds.Right-bounds.Left)*scale+10;
if(x+width>screenW-12)x=(cardBounds.Left-bounds.Left)*scale-width-10;
float y=(rect.Center.y-bounds.Top)*scale-31;
x=Math.Clamp(x,12,Math.Max(12,screenW-width-12));
y=Math.Clamp(y,12,Math.Max(12,screenH-height-12));
tooltip.Style.Left=Length.Pixels(x);tooltip.Style.Top=Length.Pixels(y);
tooltip.Style.Width=Length.Pixels(width);tooltip.Style.MaxHeight=Length.Pixels(screenH-24);
layoutStamp=contentStamp;lastEmblemRect=rect;lastRootRect=bounds;lastScale=scale;lastTooltipHeight=height;
if(!animationArmed){animationArmed=true;showAt=Time.Now+.025f;}
}
public override void Tick()
{
base.Tick();if(!showing)return;
if(!KeepOpen()){Hide();return;}
UpdateContents();PositionTooltip();
if(animationArmed && Time.Now>=showAt)SetClass("visible",true);
}
public override void OnDeleted()
{
if(source!=null && !source.IsDeleting)source.SetClass("skill-hovering",false);
if(active==this)active=null;
base.OnDeleted();
}
}