SkillHoverOverlay.cs

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();
    }
}