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 Появилась возможность выложить видео - с радостью добавляю!

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

8 коммент.:

  1. Ух) красиво получилось =) буду ждать Drag'n'Drop.

    Давно хотел спросить:
    Сколько примерно уходит времени на подобную демку? такое чувство будто.. будто сел написал за пару минут и готово.

    И откуда берутся подобные формулы - "Angle := pi/2 + aIndex * pi * 2 / fElements.Count;"? как они выводятся?

    ОтветитьУдалить
  2. Как-как) Садишся да выводиш формулу какую надо) Потом параметры корректируеш и смотриш на результат.
    Это быстрее чем что-то готовое под частные случаи искать.
    В этом-то ничего сверхестественного, а вот время написания правда интересно.

    ОтветитьУдалить
  3. Ulop, благодарю!

    по времени точно не знаю - никогда не засекал... наверно часа 3-4, может больше)) могу в следующий раз замерить!

    по поводу формулы - мне нужно отобразить элементы по кругу! так как я хочу, чтобы кнопки были симметричны относительно вертикали, тогда я выбрал, чтобы первому элементу соответствовал угол pi/2. всего fElements.Count элементов, которые нужно расположить от pi/2 до pi/2 + 2*pi (обойдем полный круг).
    выход обычная линейная зависимость:
    K*X + B, в нашем случае:
    Pi/2 + aIndex * (Pi*2) / fElements.Count

    где pi/2 - это B, сдвиг на четверть круга, чтобы первая кнопка была внизу по центру.

    Pi*2/fElements.Count - есть K, а именно указывается, что всего нужно пройти Pi*2, но количество элементов fElements.Count на которые мы и делим...

    прикидывая в голове можно быстро построить цепочку для 4ех элементов (индексы идут от 0 до 3):
    0: Pi/2 + 0 * Pi*2 / 4 = Pi/2
    1: Pi/2 + 1 * Pi*2 / 4 = Pi
    2: Pi/2 + 2 * Pi*2 / 4 = 3*Pi/2
    3: Pi/2 + 3 * Pi*2 / 4 = 2*Pi

    Monax,
    ок, в следующий раз постараюсь замерить время))

    ОтветитьУдалить
  4. Спасибо за пояснение) вот что значит математика) всё оказалось намного проще

    ОтветитьУдалить
  5. Lampogolovii, подскажите, почему при запуске успешно скомпилированного мной любого вашего исходника у меня возникает "Access violation...".
    GLScene 5593 (Delphi 2010)

    ОтветитьУдалить
  6. Ulop, здорово, что все понятно! а то я не всегда могу вразумительно объяснить математические выкладки...

    Анонимный, хм... а какой у тебя снимок сцены? и что там на строке 5593?

    ОтветитьУдалить
  7. Что за снимок сцены? 5593 - это ревизия GLScene.
    Вот например демки для начинающих: http://glscene.ru/download.php?view.566 (по описания, вроде ваши). Там в комментариях человек пишет, что у него запускается только TextLEDAlpha, остальные выдают "Access violation...".
    И у меня так же, TextLEDAlpha - нормально, остальное компилируется, но ошибка при запуске

    ОтветитьУдалить
  8. Анонимный,
    хм... интересно! а у тебя есть аська? можешь кинуть мне ее на
    lampogolovii[собака]mail.ru
    так быстрее локализуем проблему =)

    ОтветитьУдалить