Code/SlotDispatcher.cs
using System;
using Sandbox;
using Sandbox.UI;

namespace HitShapes;

/// <summary>Holds an <see cref="IHitShape"/> and tracks per-slot enter/leave/click transitions.</summary>
public sealed class SlotDispatcher
{
    readonly IHitShape _shape;
    int? _currentSlot;

    public SlotDispatcher(IHitShape shape)
    {
        _shape = shape ?? throw new ArgumentNullException(nameof(shape));
    }

    public IHitShape Shape => _shape;
    public int? CurrentSlot => _currentSlot;

    public Action<int> OnSlotEnter { get; init; } = _ => { };
    public Action<int> OnSlotLeave { get; init; } = _ => { };
    public Action<int, MousePanelEvent> OnSlotClick { get; init; } = (_, _) => { };

    public void HandleMouseEnter(MousePanelEvent e) => UpdateHoverFrom(e);
    public void HandleMouseMove(MousePanelEvent e) => UpdateHoverFrom(e);

    /// <summary>Hover update using explicit receiver size. Safe under event bubbling.</summary>
    public void HandleMouseEnter(MousePanelEvent e, Panel receiver) => UpdateHoverAt(e.LocalPosition, receiver.Box.Rect.Size);    
    public void HandleMouseMove(MousePanelEvent e, Panel receiver) => UpdateHoverAt(e.LocalPosition, receiver.Box.Rect.Size);

    public void HandleMouseLeave(MousePanelEvent e)
    {
        if (_currentSlot is int oldSlot)
        {
            _currentSlot = null;
            OnSlotLeave?.Invoke(oldSlot);
        }
    }

    public void HandleClick(MousePanelEvent e)
    {
        var size = e.Target?.Box.Rect.Size ?? Vector2.Zero;
        var slot = _shape.Resolve(e.LocalPosition, size);
        if (slot is int s) OnSlotClick?.Invoke(s, e);
    }

    /// <summary>Click using explicit receiver size. Safe under event bubbling.</summary>
    public void HandleClick(MousePanelEvent e, Panel receiver)
    {
        var slot = _shape.Resolve(e.LocalPosition, receiver.Box.Rect.Size);
        if (slot is int s) OnSlotClick?.Invoke(s, e);
    }

    /// <summary>Drive the hover state machine without a MousePanelEvent.</summary>
    public void UpdateHoverAt(Vector2 localPosition, Vector2 panelSize)
    {
        var newSlot = _shape.Resolve(localPosition, panelSize);
        if (newSlot == _currentSlot) return;

        var oldSlot = _currentSlot;
        _currentSlot = newSlot;
        if (oldSlot is int leaving) OnSlotLeave?.Invoke(leaving);
        if (newSlot is int entering) OnSlotEnter?.Invoke(entering);
    }

    /// <summary>Clear hover state. Call after panel recreation if the dispatcher survives it.</summary>
    public void Reset() => _currentSlot = null;

    void UpdateHoverFrom(MousePanelEvent e)
    {
        var size = e.Target?.Box.Rect.Size ?? Vector2.Zero;
        UpdateHoverAt(e.LocalPosition, size);
    }
}