11 сентября 2011

GUI: Круговой выбор элементов

Недавно я приводил список ближайших добавлений в наш замечательный сундучок простых gui-элементов, созданных в связке Delphi+GlScene. Так вот первым в списке был круговой выбор элементов или как еще его называют "радиальное меню". Его и делаем сегодня!
Применений у такого объекта множество:
  • в RPG-играх: выбор заклинания, выбор оружия, а также для меню возможных действий - диалог с NPC, торговля и т.д.
  • в квестах: выбор действия с объектом - поднять предмет, посмотреть на объект (подсказка), активировать элемент (открыть дверь, нажать кнопку и т.д.)
  • в RTS: меню для строений - произвести юниты, продать строение, апгрейд и всякое такое
В казуалках тоже может пригодиться... Так что давайте пополнять свой арсенал интерфейсных элементов для игр!

Итак, мы делаем вот такой список, отображаемый по кругу:


Механика такова: пользователь наводит курсор мыши на объект - и по кругу появляются доступные действия в виде кнопок. В общем, дело безхитростное - такое нужно садиться и программировать! Первым делом определяемся с сеттингом демки, а раз меню круглое и объекты все круглые -  я решил, что данный элемент отлично впишется в космический сеттинг с планетами впереди и темным глубоким фоном позади...
Немного погуглив, нашлись подходящие картинки планет и космоса. Общее наполнение в итоге стало выглядеть так:


Радиальное меню
Ну и сразу за программирование радиального меню! Описание класса получилось довольно объемное:

  TRadialGroup = class (TGlHudSprite)
  protected
    fElements: TList;
    fShowSpeed: Single;
    fHideSpeed: Single;
    fVisible: Boolean;

    fMousePos: TVector;
    fSelected: Integer;
    fBasePosition: TVector;
    fGroupRadius: Single;

    fOnMouseClick: TOnMouseClick;
    fLButtonDown: Boolean;

    fTweener: TTweener;
  protected
    Function GetItem(aIndex: Integer): TRadialElement;
    Function GetItemRect(aIndex: Integer): TVector;
  public
    property ShowSpeed: Single read fShowSpeed write fShowSpeed;
    property HideSpeed: Single read fHideSpeed write fHideSpeed;
    property GroupRadius: Single read fGroupRadius write fGroupRadius;
    property OnMouseClick: TOnMouseClick read fOnMouseClick write fOnMouseClick;

    Procedure Show;
    Procedure Hide;
    Procedure AddElements(fMaterialNames: Array of String);
    Function  GetSelected: Integer;
    Procedure SetMousePos(aMousePos: TVector);
    Procedure DoRender(var ARci: TRenderContextInfo; ARenderSelf, ARenderChildren: Boolean); override;
    Procedure DoProgress(const progressTime: TProgressTimes); override;
    Constructor Create(AOwner: TComponent); override;
  end;

В принципе, ничего серьезного, но строчек много получилось, сам не ожидал... Пройдусь по основным моментам:
  • В Show() и Hide() методах запускаются tween'ы для каждой кнопки с небольшой задержкой между друг другом. Таким образом можно достичь появления кнопок одной за другой, как бы последовательно. Мне показалось, что так красивее :)
  • GetItemRect(aIndex: Integer) выдает вектор-прямоугольник для элемента под номером aIndex. Для того, чтобы добраться до размеров текстуры, приходится переключать материал и уже по значениям ширины и высоты текстуры строить вектор результата:
      Material.LibMaterialName := GetItem(aIndex).fMaterialName;
      Angle := pi/2 + aIndex * pi * 2 / fElements.Count;
      w := Material.GetActualPrimaryTexture.Image.Width;
      h := Material.GetActualPrimaryTexture.Image.Height;
      Result[0] := fBasePosition[0] + fGroupRadius * cos(Angle) - w/2;
      Result[1] := fBasePosition[1] + fGroupRadius * sin(Angle) - h/2;
      Result[2] := Result[0] + w;
      Result[3] := Result[1] + h;
  • В перекрытом DoRender() теперь дело за малым: по значениям из GetItemRect() правильно позиционировать спрайт, выставить ему альфу и размеры... Все это умещается в таком маленьком коде:
      for i := 0 to fElements.Count - 1  do
      begin
        r := GetItemRect(i);
        Position.x := (r[0] + r[2]) / 2;
        Position.y := (r[1] + r[3]) / 2;
        Width  := (r[2] - r[0]) * GetItem(i).fScale;
        Height := (r[3] - r[1]) * GetItem(i).fScale;
        Material.GetActualPrimaryMaterial.FrontProperties.Diffuse.Alpha := GetItem(i).fAlpha;
        inherited DoRender(ARci, true, false);
      end
  • Ну а DoProgress() как всегда занимается апдейтом состояний - находится подсвеченный элемент, вызывается для него tween'инг размеров; а также проверяется состояние кнопок мыши, чтобы при необходимости вызвать событие OnMouseClick()
  • На мой взгляд очень удобно работать с получившимся классом снаружи: для добавления элементов достаточно вызвать метод AddElements() со списком материалов, которые будут отображаться в качестве элементов самого меню. То есть пишим одну строку
    fGroup.AddElements(['gallery', 'audio', 'default', 'chat']); 
    и в наше меню добавляются четыре кнопки-элемента. Все эти кнопки обрабатываются в вышеописанных методах. Ничего больше делать не нужно.
Итак, настройка такого радиального меню занимает всего несколько строк:
      fGroup := TRadialGroup.CreateAsChild(fMainDummy);
      fGroup.AddElements(['chat', 'gallery', 'chat']);
      fGroup.Position.SetPoint(fPlanet.Position.AsVector);
      fGroup.Material.MaterialLibrary := fMatLib;
      fGroup.OnMouseClick := OnItemMouseClick;
      fGroup.GroupRadius := 90;
Последняя строка (присвоение GroupRadius) необходима для установки, на каком расстоянии от центра будут располагаться кнопки. Остается только передавать положение курсора мышки, чтобы меню смогло корректно подсвечивать свои элементы:

  GetCursorPos(Mpos);
  MPos := fViewer.ScreenToClient(MPos);
  fGroup.SetMousePos(VectorMake(MPos.x, MPos.y, 0));

Планета-кнопка
Для того, чтобы на экране отобразить сразу несколько планет с доступными для них радиальными менюшками, я решил создать небольшой support-класс TPlanetButton, описание которого получилось тоже довольно увестистое:
  TOnMouseOver = Procedure (aSelf: TGlHudSprite) of object;
  TOnMouseOut  = Procedure (aSelf: TGlHudSprite) of object;
TPlanetButton = class (TGlHudSprite)
  protected
    fMousePos: TVector;
    fSelected: Boolean;
    fOverRadius: Single;
    fOutRadius: Single;
    fAnimationTime: Single;
  protected  
    fOnMouseOver: TOnMouseOver;
    fOnMouseOut: TOnMouseOut;
  public
    property OnMouseOver: TOnMouseOver read fOnMouseOver write fOnMouseOver;
    property OnMouseOut: TOnMouseOut read fOnMouseOut write fOnMouseOut;

    property OverRadius: Single read fOverRadius write fOverRadius;
    property OutRadius: Single read fOutRadius write fOutRadius;
    Function IsHit(aPosition: TVector): Boolean;
    Procedure SetMousePos(aMousePos: TVector);
    Procedure DoProgress(const progressTime: TProgressTimes); override;
    Constructor Create(AOwner: TComponent); override;
  end;
По сути такая кнопка ничего не умеет, за исключением вызова событий OnMouseOver и OnMouseOut при движениях мыши по своей области.

Итог
Вот и все...Выглядит это вот так:


Наводим на любую планету - показывается соответствующее меню. Каждый элемент немного меняет размеры при движениях по ним мыши. При клике - показывается хинт о том, по какому элементу нажали.

Как всегда скачать демку с исходниками можно отсюда; либо можно обновиться через svn, в котором сейчас всего 12 ревизий :)

p.s. Очень хочется выложить видео с демонстрацией того, как это все анимировано, но у меня к сожалению сейчас слишком медленный интернет для этого...

p.p.s Появилась возможность выложить видео - с радостью добавляю!

Сообщения, схожие по тематике: