UI/TreeSkillPanel/TreeSkillPanel.razor
@using System;
@using System.Linq;
@using Milaine.Managers;
@using Milaine.Settings;
@using Milaine.TreeSkills;
@using Milaine.Utils;
@using Sandbox;
@using Sandbox.UI;
@inherits PanelComponent
@namespace Milaine.UI

<root class="viewport" style="display: @(isOpened ? "flex" : "none");">
  <div class="canvas"
       style="left: @(Pixel.Inv(PanX))px; top: @(Pixel.Inv(PanY))px; transform: scale(@(Pixel.Inv(Zoom)));">
    @foreach (var (node, neighbor) in GetConnectionsToDraw()) {
      var (x1, y1) = GetNodeCender(node);
      var (x2, y2) = GetNodeCender(neighbor);

      float dx = x2 - x1;
      float dy = y2 - y1;
      float length = MathF.Sqrt(dx * dx + dy * dy);
      float angle = MathF.Atan2(dy, dx) * (180f / MathF.PI);
      bool bothUnlocked = Tree.IsUnlocked(node) && Tree.IsUnlocked(neighbor);

      <div class="connection @(bothUnlocked ? "active" : "")"
           style="left: @(Pixel.Inv(x1))px; top: @(Pixel.Inv(y1))px; width: @(Pixel.Inv(length))px; transform: rotate(@(Pixel.Inv(angle))deg);" />
    }

    @foreach (var node in Tree.Nodes) {
      bool unlocked = Tree.IsUnlocked(node);
      bool canUnlock = Tree.CanUnlock(node);
      bool canPurchase = Tree.CanPurchase(node);

      <button
        class="skill-node @(unlocked ? "unlocked" : "") @(!unlocked && canUnlock ? "available" : "") @(!unlocked && !unlocked ? "locked" : "") @(canPurchase ? "pointer" : "")"
        style="left: @(Pixel.Inv(node.Column * CELL_PITCH))px; top: @(Pixel.Inv(node.Row * CELL_PITCH))px;" role="button"
        onclick=@(() => { OnNodeClicked(node); }) onmouseover=@(() => hoveredNodeId = node.Id) onmouseout=@(() =>
        hoveredNodeId = "")>
        <div class="icon">@node.Icon</div>
        <div class="name">@TranslationsInstance.T(node.Name)</div>

        @if (!unlocked) {
          <div class="cost @(canPurchase ? "text-green" : "")">@node.Cost</div>
        }

        @if (hoveredNodeId == node.Id) {
          <div class="popup">
            <div class="popup-container">
              <p>@TranslationsInstance.T(node.Description, data)</p>
            </div>
          </div>
        }
      </button>
    }
  </div>
  <div class="reset-button" onclick=@ResetState>
    @* #milaine.reset-position *@
    @TranslationsInstance.T("milaine.reset-position")
  </div>
</root>

@code
{
  private const float CELL_PITCH = 220f;
  private const float NODE_SIZE = 140f;
  private const float MIN_ZOOM = 0.3f;
  private const float MAX_ZOOM = 2f;
  private const float STANDARD_ZOOM = 1f;
  private const float ZOOM_SPEED = 0.1f;
  private const float DEFAULT_PAN_X = 400f;
  private const float DEFAULT_PAN_Y = 0f;

  [Property, Title("Translations")]
  public Translations TranslationsInstance { get; set; }

  private float PanX = DEFAULT_PAN_X;
  private float PanY = DEFAULT_PAN_Y;
  private float Zoom = 1f;

  private bool isOpened = false;

  private Vector2 dragStartMouse;
  private float dragStartPanX;
  private float dragStartPanY;

  private Dictionary<string, object> data = [];
  private string hoveredNodeId;

  private TreeSkillState Tree => TreeSkillState.Instance;

  protected override int BuildHash() => System.HashCode.Combine(isOpened, MouseManager.State, hoveredNodeId,
TranslationsInstance.language);

  protected override void OnTreeFirstBuilt() {
    base.OnTreeFirstBuilt();
    Tree.OnChanged += StateHasChanged;

    foreach (SkillNodeData node in Tree.Nodes) {
      data[node.KeyDescription] = node.Point;
    }
  }

  protected override void OnDestroy() {
    base.OnDestroy();
    Tree.OnChanged -= StateHasChanged;
  }

  protected override void OnUpdate() {
    base.OnUpdate();

    if (!Panel.HasMouseCapture) {
      return;
    }

    Vector2 delta = Mouse.Position - dragStartMouse;
    PanX = dragStartPanX + delta.x;
    PanY = dragStartPanY + delta.y;
    StateHasChanged();
  }

  protected override void OnMouseDown(MousePanelEvent e) {
    base.OnMouseDown(e);

    if (e.Button == "mousemiddle") {
      Panel.SetMouseCapture(true);
      dragStartMouse = Mouse.Position;
      dragStartPanX = PanX;
      dragStartPanY = PanY;
      e.StopPropagation();
    }
  }

  protected override void OnMouseUp(MousePanelEvent e) {
    base.OnMouseUp(e);

    if (e.Button == "mousemiddle") {
      Panel.SetMouseCapture(false);
    }
  }

  protected override void OnMouseWheel(Vector2 delta) {
    base.OnMouseWheel(delta);

    float wheel = -delta.y;

    if (MathF.Abs(wheel) < 0.01f) {
      return;
    }

    float oldZoom = Zoom;
    float newZoom = (Zoom + wheel * ZOOM_SPEED).Clamp(MIN_ZOOM, MAX_ZOOM);

    if (newZoom.AlmostEqual(oldZoom)) {
      return;
    }

    var rect = Panel.Box.Rect;

    if (rect.Width <= 0 || rect.Height <= 0) {
      Zoom = newZoom;
      StateHasChanged();
      return;
    }

    Vector2 localMouse = Mouse.Position - rect.Position;

    float worldX = (localMouse.x - PanX) / oldZoom;
    float worldY = (localMouse.y - PanY) / oldZoom;

    Zoom = newZoom;

    PanX = localMouse.x - worldX * newZoom;
    PanY = localMouse.y - worldY * newZoom;

    StateHasChanged();
  }

  public void Open() {
    MouseManager.State = StateMouse.Busy;
    isOpened = true;
    ResetState();
    Sound.Play("panelopen");
  }

  public void Close() {
    MouseManager.State = StateMouse.Relased;
    isOpened = false;
    ResetState();
    Sound.Play("panelopen");
  }

  private void ResetState() {
    PanX = DEFAULT_PAN_X;
    PanY = DEFAULT_PAN_Y;
    Zoom = STANDARD_ZOOM;
    StateHasChanged();
  }

  private void OnNodeClicked(SkillNodeData node) {
    Tree.TryUnlock(node);
    StateHasChanged();
    Panel.Blur();
  }

  private (float x, float y) GetNodeCender(SkillNodeData node) {
    float x = node.Column * CELL_PITCH + NODE_SIZE / 2f;
    float y = node.Row * CELL_PITCH + NODE_SIZE / 2;

    return (x, y);
  }

  private IEnumerable<(SkillNodeData, SkillNodeData)> GetConnectionsToDraw() {
    foreach (var node in Tree.Nodes) {
      foreach (var neighbor in Tree.GetExistingNeighbors(node)) {
        bool isForward = neighbor.Row > node.Row || (neighbor.Row == node.Row && neighbor.Column > node.Column);

        if (isForward) {
          yield return (node, neighbor);
        }
      }
    }
  }
}