WiiEarthVR – захватывающий эффект присутствия с Virtual Earth 3D

Опубликовано 14 ноября 08 12:57:00 | Coding4Fun

clip_image002

В этой статье Брайан Пик (Brian Peek) демонстрирует возможности применения пульта Nintendo Wii Remote (Wiimote), сенсорной доски Wii Fit Balance Board и очков Vuzix VR920 в качестве устройств ввода в программе Microsoft Virtual Earth 3D для реализации впечатляющего эффекта присутствия.

Брайан Пик (Brian Peek (EN))

ASPSOFT, Inc. (EN)

Сложность: средняя.

Необходимое время: 2-3 часа.

Затраты: $60 на Wiimote и Nunchuk, $90 на Wii Fit (включая доску Balance Board), $400 на очки Vuzix VR920 (EN).

ПО : Управляемая библиотека для Nintendo Wiimote, Visual Basic или Visual C# Express Editions.

Оборудование : Пульт Nintendo Wii Remote (Wiimote) с Nunchuk, доска Wii Fit Balance Board, очки Vuzix VR920, Bluetooth-адаптер для ПК.

Загрузки: Загрузить

Дискуссионный форум: здесь (EN).

Введение

Virtual Earth — это 3D-интерфейс службы Microsoft Live Maps. Этот компонент предназначен для загрузки веб-обозревателем и поддерживает взаимодействие через клавиатуру, мышь и контроллер Xbox 360. В этой статье мы рассмотрим работу с элементом управления Virtual Earth 3D вне браузера, в приложении WinForms с использованием пульта Nintendo Wii Remote (Wiimote) и очков Vuzix VR920, которые обеспечат трехмерное стереоскопическое изображение, обеспечивающее полный эффект присутствия. Обратите внимание: в настоящее время применение компонента Virtual Earth 3D подобным способом не описано в документации и не обеспечивается техподдержкой. По этой причине некоторые утверждения, содержащиеся в статье, являются предположениями и могут не полностью соответствовать действительности…

Изначально проект был реализован как простая привязка Wiimote к Virtual Earth 3D (см. видеоролик). Уже после этого я узнал об очках VR920 и сенсорной доске Wii Fit Balance Board и решил добиться более впечатляющего эффекта с помощью этих устройств. Результат демонстрировался на конференции PDC2008, как показано в видеоролике (EN).

Get Microsoft Silverlight

Подготовка

Прежде чем начать, надо установить компонент Virtual Earth 3D. Если он еще не установлен на вашей машине, на странице https://maps.live.com/ щелкните ссылку 3D, чтобы установить данный элемент управления и сопутствующее ПО.

clip_image002[4]

Кроме того, познакомьтесь с моей статьей Managed Library for Nintendo's Wiimote (EN). Некоторые основополагающие сведения из той статьи я не буду повторять в этой публикации. Вам также надо установить и настроить очки Vuzix VR920 в соответствии с прилагаемым к ним руководством. Данный процесс здесь также не описан.

Реализация
Элемент управления Virtual Earth 3D

Применение элемента управления Virtual Earth 3D (VE3D) предполагается на веб-страницах посредством подробно документированного JavaScript-интерфейса, однако доступ к очкам VR920 из JavaScript невозможен. Поэтому мы будем работать с элементом VE3D через его собственный, но совершенно не документированный интерфейс.

Начнем с создания приложения Windows Forms под названием WiiEarthVR на C# или VB. Как и при использовании любых других компонентов или библиотек от сторонних производителей, для библиотек Virtual Earth 3D надо установить ссылки. Установите ссылки на следующие элементы:

· Microsoft.MapPoint.Data

· Microsoft.MapPoint.Data.VirtualEarthTileDataSource

· Microsoft.MapPoint.Geometry

· Microsoft.MapPoint.Rendering3D

· Microsoft.MapPoint.Rendering3D.Utility

Если они не отображаются среди ссылок .NET, эти элементы можно найти, перейдя на вкладке Browse (Просмотр) в каталог C:\Program Files\Virtual Earth 3D\ или C:\Program Files (x86)\Virtual Earth 3D\. После установки ссылок можно открыть проект и увидеть ссылки в папке ссылок обозревателя решения.

clip_image004

Создание экземпляра этого компонента выполняется в программе, как и для любого другого элемента управления. Приведенный ниже код создает элемент VE3D и добавляет его на форму как полностью закрепленный. Это может быть сделано в конструкторе формы или в обработчике события ее загрузки:

C#

    1: private GlobeControl _globeControl;
    2:  
    3: public MainForm()
    4: {
    5:     InitializeComponent();
    6:  
    7:     _globeControl = new GlobeControl();
    8:     SuspendLayout();
    9:     _globeControl.Location = new System.Drawing.Point(0, 0);
   10:     _globeControl.Name = "_globeControl";
   11:     _globeControl.Size = ClientSize;
   12:     _globeControl.Anchor = AnchorStyles.Left | AnchorStyles.Right | AnchorStyles.Top | AnchorStyles.Bottom;
   13:     _globeControl.TabIndex = 0;
   14:     _globeControl.SendToBack(); // помещаем кнопку сверху
   15:  
   16:     pnlGlobe.Controls.Add(_globeControl);
   17:     ResumeLayout(false);
   18: }

VB

    1: Private _globeControl As GlobeControl
    2:  
    3: Public Sub New()
    4:     InitializeComponent()
    5:  
    6:     _globeControl = New GlobeControl()
    7:     SuspendLayout()
    8:     _globeControl.Location = New System.Drawing.Point(0, 0)
    9:     _globeControl.Name = "_globeControl"
   10:     _globeControl.Size = ClientSize
   11:     _globeControl.Anchor = AnchorStyles.Left Or AnchorStyles.Right Or AnchorStyles.Top Or AnchorStyles.Bottom
   12:     _globeControl.TabIndex = 0
   13:     _globeControl.SendToBack()' помещаем кнопку сверху
   14:  
   15:     pnlGlobe.Controls.Add(_globeControl)
   16:     ResumeLayout(False)
   17: End Sub

Элемент управления VE3D устанавливается в состояние по умолчанию. Если запустить приложение с таким кодом, будет видна только Земля. Навигационные и другие элементы будут отсутствовать.

Мы будем добавлять элементы VE3D, прослушивая событие FirstFrameRendered объекта GlobeControl и затем устанавливая соответствующие свойства. Попытка установить эти события ранее может привести к непредсказуемым результатам.

Если вы хотите вывести на экран стандартные навигационные элементы, надо использовать объект PlugInLoader в обработчике события FirstFrameRendered. PlugInLoader создается статическим методом CreateLoader, которому передается объект Host экземпляра GlobeControl. Затем может быть загружен и активирован NavigationPlugIn следующим образом:

C#

    1: // Загрузка разнообразных элементов пользовательского интерфейса
    2: PlugInLoader loader = PlugInLoader.CreateLoader(this.globeControl.Host);
    3: loader.LoadPlugIn(typeof(NavigationPlugIn));
    4: loader.ActivatePlugIn(typeof(NavigationPlugIn).GUID, null);

VB

    1: ' Загрузка разнообразных элементов пользовательского интерфейса
    2: Dim loader As PlugInLoader = PlugInLoader.CreateLoader(Me.globeControl.Host)
    3: loader.LoadPlugIn(GetType(NavigationPlugIn))
    4: loader.ActivatePlugIn(GetType(NavigationPlugIn).GUID, Nothing)

Последнее, что надо добавить для базовой функциональности — это данные. Единственное, что будет видно на земном шаре, это изображения континентов. При увеличении масштаба эти изображения будут лишь размываться.

Слои данных, называемые манифестами содержимого, создаются из источников данных особого формата, предоставляемых maps.live.com. Это XML-файлы, указывающие компоненту VE3D как загружать данные, требуемые в каждом из представлений. Слои содержимого добавляются с помощью объекта DataSources экземпляра GlobeControl. Можно добавить любой из следующих слоев (имейте в виду, что maps.live.com может предоставлять и другие манифесты, я привожу только те пять из них, с которыми я разобрался):

URL

Тип DataSourceUsage

Описание

https://local.live.com/Manifests/HD.xml

ElevationMap

Топографические данные

https://local.live.com/Manifests/MO.xml

Model

3D-строения

https://local.live.com/Manifests/AT.xml

TextureMap

Непомеченные наземные объекты

https://local.live.com/Manifests/HT.xml

TextureMap

Помеченные надземные объекты

https://local.live.com/Manifests/RT.xml

TextureMap

Только для чтения

Чтобы обеспечить хорошее качество изображения слои ElevationMap, Model и Aerial TextureMap добавляются следующим образом:

C#

    1: // Устанавливаем источники для возвышенностей, топографических данных и моделей.
    2: _globeControl.Host.DataSources.Add(new DataSourceLayerData("Elevation", "Elevation", @"https://maps.live.com//Manifests/HD.xml", DataSourceUsage.ElevationMap));
    3: _globeControl.Host.DataSources.Add(new DataSourceLayerData("Texture", "Texture", @"https://maps.live.com//Manifests/AT.xml", DataSourceUsage.TextureMap));
    4: _globeControl.Host.DataSources.Add(new DataSourceLayerData("Models", "Models", @"https://maps.live.com//Manifests/MO.xml", DataSourceUsage.Model));

VB

    1: ' Устанавливаем источники для возвышенностей, топографических данных и моделей.
    2: _globeControl.Host.DataSources.Add(New DataSourceLayerData("Elevation", "Elevation", "https://maps.live.com//Manifests/HD.xml", DataSourceUsage.ElevationMap))
    3: _globeControl.Host.DataSources.Add(New DataSourceLayerData("Texture", "Texture", "https://maps.live.com//Manifests/AT.xml", DataSourceUsage.TextureMap))
    4: _globeControl.Host.DataSources.Add(New DataSourceLayerData("Models", "Models", "https://maps.live.com//Manifests/MO.xml", DataSourceUsage.Model))

Задав URL манифеста содержимого, имя слоя и его содержимое, мы создаем DataSource, который используется для порождения объекта DataSourceLayerData, передаваемого элементу управления VE3D. Это также необходимо делать в обработчике события FirstFrameRendered.

Необходимо также задать в VE3D отключение всех элементов взаимодействия с пользователем, включить отображение атмосферных эффектов и обеспечить полную видимость. Для этого — опять же в обработчике события FirstFrameRendered — используется следующий код:

C#

    1: // Включить красивые атмосферные эффекты
    2: _globeControl.Host.WorldEngine.Environment.AtmosphereDisplay = Microsoft.MapPoint.Rendering3D.Atmospherics.EnvironmentManager.AtmosphereStyle.Scattering;
    3:  
    4: // По умолчанию все отключено
    5: _globeControl.Host.WorldEngine.Display3DCursor = false;
    6: _globeControl.Host.WorldEngine.SetWindowsCursor(null);
    7: _globeControl.Host.WorldEngine.ShowNavigationControl = false;
    8: _globeControl.Host.WorldEngine.ShowCursorLocationInformation = false;
    9: _globeControl.Host.WorldEngine.ShowScale = false;
   10: _globeControl.Host.WorldEngine.ShowUI = false;
   11: _globeControl.Host.WorldEngine.Environment.SunPosition = null;
   12: _globeControl.Host.WorldEngine.Environment.LocalWeatherEnabled = true;
   13: _globeControl.Host.WorldEngine.BaseCopyrightText = " "; // workaround for a display issue

VB

    1: ' Включить красивые атмосферные эффекты
    2: _globeControl.Host.WorldEngine.Environment.AtmosphereDisplay = Microsoft.MapPoint.Rendering3D.Atmospherics.EnvironmentManager.AtmosphereStyle.Scattering
    3:  
    4: ' По умолчанию все отключено
    5: _globeControl.Host.WorldEngine.Display3DCursor = False
    6: _globeControl.Host.WorldEngine.SetWindowsCursor(Nothing)
    7: _globeControl.Host.WorldEngine.ShowNavigationControl = False
    8: _globeControl.Host.WorldEngine.ShowCursorLocationInformation = False
    9: _globeControl.Host.WorldEngine.ShowScale = False
   10: _globeControl.Host.WorldEngine.ShowUI = False
   11: _globeControl.Host.WorldEngine.Environment.SunPosition = Nothing
   12: _globeControl.Host.WorldEngine.Environment.LocalWeatherEnabled = True
   13: _globeControl.Host.WorldEngine.BaseCopyrightText = " " ' workaround for a display issue

Если теперь запустить приложение, мы увидим все возможности компонента Virtual Earth 3D с корректными данными и навигацией.

Схема управления и привязки

Пользователь будет управлять VE3D с помощью Wiimote, держа «нунчак» в левой руке: с помощью его джойстика пользователь будет перемещаться вперед/назад/влево/вправо. Кнопки C и Z на передней панели нунчака будут использоваться для увеличения и уменьшения высоты камеры. Пульт Wiimote, удерживаемый в правой руке, будет использоваться для переключения различных параметров и работы с меню. При этом пользователь будет стоять на сенсорной доске Balance Board, которая будет отслеживать его центр тяжести.

Привязки VE3D позволяют изменять или создавать новые схемы управления для VE3D, записывая определенные XML-файлы в следующие каталоги:

  • Vista: C:\Users\<имя-пользователя>\AppData\LocalLow\Microsoft\Virtual Earth 3D
  • XP: C:\Documents and Settings\<имя-пользователя>\Local Settings\Microsoft\Virtual Earth 3D

В этих каталогах хранятся файлы Bindings.xml. Это XML-схемы, определяющие стандартную клавиатуру, мышь, игровую панель, а также свойства других устройств ввода. Откройте такой файл и ознакомьтесь со схемой, используемой для определения событий и параметров.

По умолчанию VE3D будет загружать из этих каталогов любой файл с именем Bindings*.xml. Создайте новую схему управления для Wiimote в файле BindingsWiiEarthVR.xml и сохраните в соответствующем каталоге. Вот содержимое этого файла:

    1: <?xml version="1.0" encoding="utf-8" ?>
    2: <Bindings>
    3:     <BindingSet Name="WiiEarthVRBindings" AutoUse="True" Cursor="Drag">
    4:         <!Нунчак-джойстик -->
    5:         <Bind Event="Wiimote.NunchukX" Factor="0.5"><Action Name="Strafe"/></Bind>
    6:         <Bind Event="Wiimote.NunchukY" Factor="1"><Action Name="Move"/></Bind>
    7:  
    8:         <!нунчака -->
    9:         <Bind Event="Wiimote.NunchukC" Factor="0.20"><Action Name="Ascend"/></Bind>
   10:         <Bind Event="Wiimote.NunchukZ" Factor="-0.20"><Action Name="Ascend"/></Bind>
   11:  
   12:         <!-- Balance Board -->
   13:         <Bind Event="Wiimote.BalanceBoardX" Factor="-0.0009"><Action Name="Turn"/></Bind>
   14:         <Bind Event="Wiimote.BalanceBoardY" Factor="0.0009"><Action Name="Ascend"/></Bind>
   15:  
   16:         <!-- Кнопки Wiimote -->
   17:         <Bind Event="Wiimote.Home"><Action Name="ResetOnCenter"/></Bind>
   18:         <Bind Event="Wiimote.A" Factor="1"><Action Name="Locations, WiiEarthVR, Version=1.0.0.0, Culture=neutral, PublicKeyToken=null"/></Bind>
   19:  
   20:         <Bind Event="Wiimote.Left" Factor="-1"><Action Name="Locations, WiiEarthVR, Version=1.0.0.0, Culture=neutral, PublicKeyToken=null"/></Bind>
   21:         <Bind Event="Wiimote.Up" Factor="-1"><Action Name="LocationsMove, WiiEarthVR, Version=1.0.0.0, Culture=neutral, PublicKeyToken=null"/></Bind>
   22:         <Bind Event="Wiimote.Down" Factor="1"><Action Name="LocationsMove, WiiEarthVR, Version=1.0.0.0, Culture=neutral, PublicKeyToken=null"/></Bind>
   23:  
   24:         <!-- Управление с клавиатуры при отсутствии нунчака -->
   25:         <Bind Event="Key.W" Factor="22"><Action Name="Move" /></Bind>
   26:         <Bind Event="Key.S" Factor="-22"><Action Name="Move" /></Bind>
   27:         <Bind Event="Key.D" Factor="22"><Action Name="Strafe" /></Bind>
   28:         <Bind Event="Key.A" Factor="-22"><Action Name="Strafe" /></Bind>
   29:         <Bind Event="Key.Space" Factor="20"><Action Name="Ascend" /></Bind>
   30:         <Bind Event="Key.C" Factor="-20"><Action Name="Ascend" /></Bind>
   31:  
   32:         <!-- Прочие кнопки -->
   33:         <Bind Event="Key.F1"><Action Name="VR920SetZero, WiiEarthVR, Version=1.0.0.0, Culture=neutral, PublicKeyToken=null"/></Bind>
   34:         <Bind Event="Key.F2"><Action Name="BalanceBoardSetZero, WiiEarthVR, Version=1.0.0.0, Culture=neutral, PublicKeyToken=null"/></Bind>
   35:         <Bind Event="Key.F3"><Action Name="ToggleVR920Stereo, WiiEarthVR, Version=1.0.0.0, Culture=neutral, PublicKeyToken=null"/></Bind>
   36:  
   37:         <Bind Event="Wiimote.Minus" Factor="-0.1"><Action Name="VR920SetEyeDistance, WiiEarthVR, Version=1.0.0.0, Culture=neutral, PublicKeyToken=null"/></Bind>
   38:         <Bind Event="Wiimote.Plus" Factor="0.1"><Action Name="VR920SetEyeDistance, WiiEarthVR, Version=1.0.0.0, Culture=neutral, PublicKeyToken=null"/></Bind>
   39:  
   40:         <Bind Event="Wiimote.One"><Action Name="ToggleBalanceBoard, WiiEarthVR, Version=1.0.0.0, Culture=neutral, PublicKeyToken=null"/></Bind>
   41:         <Bind Event="Wiimote.Two"><Action Name="ToggleVR920, WiiEarthVR, Version=1.0.0.0, Culture=neutral, PublicKeyToken=null"/></Bind>
   42:         <Bind Event="Key.B"><Action Name="ToggleBalanceBoard, WiiEarthVR, Version=1.0.0.0, Culture=neutral, PublicKeyToken=null"/></Bind>
   43:         <Bind Event="Key.V"><Action Name="ToggleVR920, WiiEarthVR, Version=1.0.0.0, Culture=neutral, PublicKeyToken=null"/></Bind>
   44:         <Bind Event="Key.F"><Action Name="FullScreen, WiiEarthVR, Version=1.0.0.0, Culture=neutral, PublicKeyToken=null"/></Bind>
   45:     </BindingSet>
   46: </Bindings>

Привязки элемента управления сгруппированы в теге <BindingSet> . Его обязательным параметром является Name, а опциональным — Cursor. Если набор привязок будет использоваться по умолчанию (что имеет место в большинстве случаев), надо установить параметр AutoUse в True. Внутри содержатся теги <Bind> . В них обязательными являются параметры Event, а дополнительными — Factor. Параметр Event устанавливает соответствие между привязкой и обработчиком события, который будет написан позднее. Синтаксис такой: <Имя обработчика>.<Имя события>. Параметр Factor необязательный, он может использоваться как масштабирующий коэффициент для уменьшения и увеличения чувствительности к поступающим входным данным. Содержащийся внутри Bind тег Action применяется для определения конкретных параметров соответствующих методов. Когда мы напишем обработчик, все сказанное выше будет понятней.

Приведенные выше привязки соответствуют нашей схеме управления: NunchukX/Y описывают реакцию на перемещение аналогового джойстика нунчака, NunchukC/Z — реакцию на нажатие кнопок C/Z и т. д.

Данные привязки также рассчитаны на различные вариации. Они определены как для координат ИК-датчика (IRX, IRY), так и для значений датчика ускорений (AX, AY). Если сенсорная панель ИК не доступна, обрабатываются данные с акселерометра Wiimote. Кроме того, используется привязка для клавиатуры (использование клавиш WASD), на случай, если не используется нунчак.

Обратите внимание: некоторые привязки могут связывать два события вместе знаком +. Это используется для комбинаций клавиш. В случае использования акселерометра и ИК-датчика это будет означать, что мы хотим реагировать на событие, только если нажата некоторая кнопка. Таким образом, для события, требующего нажатой клавиши, указывается Wiimote.B+ и событие, с которым оно комбинируется.

Для событий, требующих нестандартных действий и рассчитаны на специальные обработчики, которые не предусмотрены в VE3D, параметр Action должен содержать название этого действия, за которым через запятую следует полное имя сборки:

    1: <Bind Event="Wiimote.One">
    2:     <Action Name="ToggleBalanceBoard, WiiEarthVR, Version=1.0.0.0, Culture=neutral, PublicKeyToken=null"/>
    3: </Bind>

Вы можете изменить привязку для любой кнопки, изменив приведенный XML-файл и сохранив его в указанном выше каталоге.

Источник события

Источник события EventSource будет получать данные от Wiimote и передавать их VE3D в соответствии с параметрами рассмотренного выше файла привязки. Создадим класс WiimoteEventSource производный от Microsoft.MapPoint.Binding.EventSource.

Теперь создадим перечислитель WiimoteEvent (имя должно быть именно таким), содержащий все элементы Name из XML-файла привязки. Он будет выглядеть так:

C#

    1: // Все события из XML-файла, обрабатываемые данным источником событий
    2: public enum WiimoteEvent
    3: {
    4:     IRX,        // координата X ИК-панели
    5:     IRY,        // координата Y ИК-панели
    6:     NunchukX,    // координата X джойстика нунчака
    7:     NunchukY,    // координата Y джойстика нунчака
    8:     NunchukC,    // кнопка С нунчака
    9:     NunchukZ,    // кнопка Z нунчака
   10:     AX,            // X акселерометра Wiimote
   11:     AY,            // Y акселерометра Wiimote
   12:     Up,            // вверх на пульте 
   13:     Down,        // вниз на пульте
   14:     Left,        // влево на пульте
   15:     Right,        // вправо на пульте
   16:     A,            // кнопка A 
   17:     B,            // кнопка B 
   18:     Minus,        // кнопка «минус»
   19:     Home,        // кнопка Home на Wiimote 
   20:     Plus,        // Wiimote «плюс» 
   21:     One,        // кнопка One на Wiimote 
   22:     Two,        // кнопка Two на Wiimote
   23:     BalanceBoardX,    // координата X центра тяжести на Balance Board
   24:     BalanceBoardY    // координата Y центра тяжести на Balance Board
   25: }

VB

    1: ' Все события из XML-файла, обрабатываемые данным источником событий
    2: Public Enum WiimoteEvent
    3:     IRX ' координата X ИК-панели
    4:     IRY ' координата Y ИК-панели
    5:     NunchukX ' координата X джойстика нунчака
    6:     NunchukY ' координата Y джойстика нунчака
    7:     NunchukC ' кнопка С нунчака
    8:     NunchukZ ' кнопка Z нунчака
    9:     AX '  X акселерометра Wiimote
   10:     AY ' Y акселерометра Wiimote
   11:     Up ' вверх на пульте 
   12:     Down ' вниз на пульте
   13:     Left ' влево на пульте
   14:     Right ' вправо на пульте
   15:     A ' кнопка A 
   16:     B ' кнопка B 
   17:     Minus ' кнопка «минус»
   18:     Home ' кнопка Home на Wiimote 
   19:     Plus ' Wiimote «плюс» 
   20:     One ' кнопка One на Wiimote 
   21:     Two ' кнопка Two на Wiimote
   22:     BalanceBoardX ' координата X центра тяжести на Balance Board
   23:     BalanceBoardY ' координата Y центра тяжести на Balance Board
   24: End Enum

Теперь переопределим несколько методов объекта EventSource: GetEventData, IsModifier, CanModify, TryGetEventId, TryGetEventName, Name. Назначение этих методов следующее:

Метод/свойство

Описание

GetEventData

Пока неизвестно...

IsModifier

Возвращает булевское значение, указывающее, является ли переданный входной параметр ID события модификатором (наподобие описанного выше Wiimote.B)

CanModify

Возвращает булевское значение, указывающее, допустимо ли текущее событие в качестве модификатора

TryGetEventID

Устанавливает соответствие между строкой, представляющей название события, и целым значением перечислителя

TryGetEventName

Устанавливает соответствие между целочисленным значением ID события и строкой, представляющей название события

Name (свойство)

Возвращает название обработчика, которое должно соответствовать имени, приведенному в XML-файле (в данном случае Wiimote)

Вот код этих методов:

C#

    1: // Возвращает значение из перечисления
    2: public override bool TryGetEventId(string eventName, out int eventId)
    3: {
    4:     eventId = (int)Enum.Parse(typeof(WiimoteEvent), eventName);
    5:     return true;
    6: }
    7:  
    8: // Возвращает строку, соответствующую значению из перечисления
    9: public override bool TryGetEventName(int eventId, out string eventName)
   10: {
   11:     eventName = ((WiimoteEvent)eventId).ToString();
   12:     return true;
   13: }
   14:  
   15: // Неизвестно
   16: public override EventData GetEventData(int eventId, EventActivateState state)
   17: {
   18:     throw new NotImplementedException();
   19: }
   20:  
   21: // Можно ли использовать данное событие как модификатор?
   22: public override bool IsModifier(int eventId)
   23: {
   24:     // Пока для всех устанавливаем положительный ответ
   25:     return true;
   26: }
   27:  
   28: // Можно ли использовать передаваемое событие как модификатор?
   29: public override bool CanModify(int eventId, EventKey other)
   30: {
   31:     // Только если оно от нас
   32:     return (other.Source == this);
   33: }
   34:  
   35: // Это должно соответствовать Source из XML-файла
   36: public override string Name
   37: {
   38:     get { return "Wiimote"; }
   39: }

VB

    1: ' Возвращает значение из перечисления
    2: Public Overrides Function TryGetEventId(ByVal eventName As String, <System.Runtime.InteropServices.Out()> ByRef eventId As Integer) As Boolean
    3:     eventId = CInt(Fix(System.Enum.Parse(GetType(WiimoteEvent), eventName)))
    4:     Return True
    5: End Function
    6:  
    7: ' Возвращает строку, соответствующую значению из перечисления
    8: Public Overrides Function TryGetEventName(ByVal eventId As Integer, <System.Runtime.InteropServices.Out()> ByRef eventName As String) As Boolean
    9:     eventName = (CType(eventId, WiimoteEvent)).ToString()
   10:     Return True
   11: End Function
   12:  
   13: ' Неизвестно
   14: Public Overrides Function GetEventData(ByVal eventId As Integer, ByVal state As EventActivateState) As EventData
   15:     Throw New NotImplementedException()
   16: End Function
   17:  
   18: ' Можно ли использовать данное событие как модификатор?
   19: Public Overrides Function IsModifier(ByVal eventId As Integer) As Boolean
   20:     ' Пока для всех устанавливаем положительный ответ
   21:     Return True
   22: End Function
   23:  
   24: ' Можно ли использовать передаваемое событие как модификатор?
   25: Public Overrides Function CanModify(ByVal eventId As Integer, ByVal other As EventKey) As Boolean
   26:     ' Только если оно от нас
   27:     Return (other.Source Is Me)
   28: End Function
   29:  
   30: ' Это должно соответствовать Source из XML-файла
   31: Public Overrides ReadOnly Property Name() As String
   32:     Get
   33:         Return "Wiimote"
   34:     End Get
   35: End Property

Теперь реализуем конструктор, который будет вызывать конструктор базового класса и подключаться к Wiimote. Предполагается, что вы прочитали статью, о которой я упоминал в начале, и разбираетесь в работе библиотеки для Wiimote.

Конструктор принимает единственный аргумент из главной формы: экземпляр ActionSystem из GlobeControl. Он передается как есть конструктору родительского объекта. Вот код конструктора:

C#

    1: public WiimoteEventSource(ActionSystem actionSystem) : base(actionSystem)
    2: {
    3:     // Получить все подключенные Wiimotes
    4:     WiimoteCollection wc = new WiimoteCollection();
    5:     wc.FindAllWiimotes();
    6:  
    7:     // Установить wiimotes и обработчики событий
    8:     foreach(Wiimote wm in wc)
    9:     {
   10:         wm.WiimoteChanged += new EventHandler<WiimoteChangedEventArgs>(OnWiimoteChanged);
   11:         wm.WiimoteExtensionChanged += new EventHandler<WiimoteExtensionChangedEventArgs>(OnWiimoteExtensionChanged);
   12:         wm.Connect();
   13:  
   14:         // Если расширения нет, установить тип сообщений только для ИК-панели и акселерометра
   15:         if(!wm.WiimoteState.Extension && wm.WiimoteState.ExtensionType != ExtensionType.BalanceBoard)
   16:             wm.SetReportType(InputReport.IRAccel, true);
   17:  
   18:         if(wm.WiimoteState.ExtensionType == ExtensionType.BalanceBoard)
   19:             _bb = wm;
   20:         else
   21:             _wm = wm;
   22:  
   23:         // Отключить все светодиодные индикаторы
   24:         wm.SetLEDs(0x00);
   25:     }
   26: }

VB

    1: Public Sub New(ByVal actionSystem As ActionSystem)
    2:     MyBase.New(actionSystem)
    3:  
    4:     ' Получить все подключенные Wiimotes
    5:     Dim wc As New WiimoteCollection()
    6:     wc.FindAllWiimotes()
    7:  
    8:     ' Установить wiimotes и обработчики событий
    9:     For Each wm As Wiimote In wc
   10:         AddHandler wm.WiimoteChanged, AddressOf OnWiimoteChanged
   11:         AddHandler wm.WiimoteExtensionChanged, AddressOf OnWiimoteExtensionChanged
   12:         wm.Connect()
   13:  
   14:         ' Если расширения нет, установить тип сообщений только для ИК-панели и акселерометра
   15:         If (Not wm.WiimoteState.Extension) AndAlso wm.WiimoteState.ExtensionType <> ExtensionType.BalanceBoard Then
   16:             wm.SetReportType(InputReport.IRAccel, True)
   17:         End If
   18:  
   19:         If wm.WiimoteState.ExtensionType = ExtensionType.BalanceBoard Then
   20:             _bb = wm
   21:         Else
   22:             _wm = wm
   23:         End If
   24:  
   25:         ' Отключить все светодиодные индикаторы
   26:         wm.SetLEDs(&H00)
   27:     Next wm
   28: End Sub

Метод OnWiimoteExtensionChanged устанавливает тип сообщения Wiimote в соответствии с тем, подключен ли манипулятор Nunchuk:

C#

    1: private void OnWiimoteExtensionChanged(object sender, WiimoteExtensionChangedEventArgs args)
    2: {
    3:     if(_wm == null)
    4:         return;
    5:  
    6:     // Если нунчак подключен, установить расширенное сообщение
    7:     if(args.ExtensionType == ExtensionType.Nunchuk && args.Inserted)
    8:         _wm.SetReportType(InputReport.IRExtensionAccel, true);
    9:     else // во всех прочих случаях установить тип сообщений только для ИК-панели и акселерометра
   10:         _wm.SetReportType(InputReport.IRAccel, true);
   11: }

VB

    1: Private Sub OnWiimoteExtensionChanged(ByVal sender As Object, ByVal args As WiimoteExtensionChangedEventArgs)
    2:     If _wm Is Nothing Then
    3:         Return
    4:     End If
    5:  
    6:     ' Если нунчак подключен, установить расширенное сообщение
    7:     If args.ExtensionType = ExtensionType.Nunchuk AndAlso args.Inserted Then
    8:         _wm.SetReportType(InputReport.IRExtensionAccel, True)
    9:     Else ' во всех прочих случаях установить тип сообщений только для ИК-панели и акселерометра
   10:         _wm.SetReportType(InputReport.IRAccel, True)
   11:     End If
   12: End Sub

В обработчике события OnWiimoteChanged данные, поступающие от Wiimote, обрабатываются и отправляются элементу управления VE3D для отображения соответствующих изменений. Сначала обработаем данные от Balance Board. Если Balance Board является контроллером, посылающим сообщения, возьмем значение центра тяжести и передадим его VE3D. Этот код анализирует соответствующие значения, определяет, выходят ли они за границы «мертвой» зоны и, если это так, активирует событие с данным значением с помощью метода Execute. Execute — это метод базового класса EventSource. Он активирует событие, соответствующее идентификатору из перечисления, построенного в соответствии с XML-файлом, о котором рассказано выше. Методу Execute необходимо передать тот или иной объект EventData, который перед этим надо создать. Есть два известных мне типа EventData: AxisEventData и ButtonEventData.AxisEventData надо использовать, когда требуется изменить местоположение на карте. Соответственно, если карта была повернута, изменяется высота и т. д. ButtonEventData используется, если событием является какое-то простое переключение, типа нажатия и отпускания кнопки.

Код для Balance Board показан ниже:

C#

    1: if(ws.ExtensionType == ExtensionType.BalanceBoard && this.BalanceBoardEnabled)
    2: {
    3:     float x1 = ws.BalanceBoardState.CenterOfGravity.X;
    4:     if(x1 > Properties.Settings.Default.BBDeadX || x1 < -Properties.Settings.Default.BBDeadX)
    5:         this.Execute(new AxisEventData(new EventKey(this, (int)WiimoteEvent.BalanceBoardX), x1 - _zero.X));
    6:     float y1 = ws.BalanceBoardState.CenterOfGravity.Y;
    7:     if(y1 > Properties.Settings.Default.BBDeadY || y1 < -Properties.Settings.Default.BBDeadY)
    8:         this.Execute(new AxisEventData(new EventKey(this, (int)WiimoteEvent.BalanceBoardY), y1 - _zero.Y));
    9: }

VB

    1: If ws.ExtensionType = ExtensionType.BalanceBoard AndAlso Me.BalanceBoardEnabled Then
    2:     Dim x1 As Single = ws.BalanceBoardState.CenterOfGravity.X
    3:     If x1 > My.Settings.Default.BBDeadX OrElse x1 < -My.Settings.Default.BBDeadX Then
    4:         Me.Execute(New AxisEventData(New EventKey(Me, CInt(Fix(WiimoteEvent.BalanceBoardX))), x1 - _zero.X))
    5:     End If
    6:     Dim y1 As Single = ws.BalanceBoardState.CenterOfGravity.Y
    7:     If y1 > My.Settings.Default.BBDeadY OrElse y1 < -My.Settings.Default.BBDeadY Then
    8:         Me.Execute(New AxisEventData(New EventKey(Me, CInt(Fix(WiimoteEvent.BalanceBoardY))), y1 - _zero.Y))
    9:     End If

Приступим к обработке данных от ИК-панели и акселерометра. Координата ИК-панели, представленная в объекте WiimoteState, будет использоваться для активации событий IRX и IRY, которые мы определили выше в XML-файле привязок. Значения X и Y акселерометра будут использоваться для активации событий AX и AY.

В приводимом примере кода предполагается, что в проекте создано свойство булевского типа UseIR, определяющее, используются ли данные ИК-панели или акселерометра. Кроме того, предполагается, что у ИК-датчиков и акселерометра существуют свойства, содержащие значения координат X/Y для «мертвых зон». Мертвые зоны позволяют активировать события только при выходе параметров за установленные пределы. Благодаря этому можно указать границы, за которыми движение руки не будет восприниматься.

Наше приложение использует следующие мертвые зоны:

  • NunchukDeadX/Y -> 0.025
  • NunchukDeadX/Y -> 0.025
  • NunchukDeadX/Y -> 0.025
  • NunchukDeadX/Y -> 0.025

C#

    1: // Если используется ИК-сенсор
    2: if(Properties.Settings.Default.UseIR)
    3: {
    4:     // и найдены оба светодиода
    5:     if(ws.IRState.Found1 && ws.IRState.Found2)
    6:     {
    7:         // привести средние точки к диапазону от -0.5 до 0.5 (из исходного 0 - 1.0)
    8:         float x = ws.IRState.MidX - 0.5f;
    9:         float y = ws.IRState.MidY - 0.5f;
   10:  
   11:         // Если вышли за границы, активировать события
   12:         if(x > Properties.Settings.Default.IRDeadX || x < -Properties.Settings.Default.IRDeadX)
   13:             this.Execute(new AxisEventData(new EventKey(this, (int)WiimoteEvent.IRX), x));
   14:         if(y > Properties.Settings.Default.IRDeadY || y < -Properties.Settings.Default.IRDeadY)
   15:             this.Execute(new AxisEventData(new EventKey(this, (int)WiimoteEvent.IRY), y));
   16:  
   17:         // Сохранить последние значения для ИК, на случай выхода из допустимого диапазона:
   18:         // тогда будут задействованы последние использованные координаты, пока Wiimote не вернется в диапазон.
   19:         this._lastIRX = x;
   20:         this._lastIRY = y;
   21:     }
   22:     else // один или оба светодиода не светятся
   23:     {
   24:         // Активировать события с применением последней известной позиции
   25:         if(this._lastIRX > Properties.Settings.Default.IRDeadX || this._lastIRX < -Properties.Settings.Default.IRDeadX)
   26:             this.Execute(new AxisEventData(new EventKey(this, (int)WiimoteEvent.IRX), this._lastIRX));
   27:         if(this._lastIRY > Properties.Settings.Default.IRDeadY || this._lastIRY < -Properties.Settings.Default.IRDeadY)
   28:             this.Execute(new AxisEventData(new EventKey(this, (int)WiimoteEvent.IRY), this._lastIRY));
   29:     }
   30: }
   31: else // мы используем акселерометр
   32: {
   33:     // Активировать события в соответствии со значениями акселерометра
   34:     if(ws.AccelState.X > Properties.Settings.Default.WiimoteDeadX || ws.AccelState.X < -Properties.Settings.Default.WiimoteDeadX)
   35:         this.Execute(new AxisEventData(new EventKey(this, (int)WiimoteEvent.AX), ws.AccelState.X));
   36:     if(ws.AccelState.Y > Properties.Settings.Default.WiimoteDeadY || ws.AccelState.Y < -Properties.Settings.Default.WiimoteDeadY)
   37:         this.Execute(new AxisEventData(new EventKey(this, (int)WiimoteEvent.AY), ws.AccelState.Y));
   38: }

VB

    1: ' Если используется ИК-сенсор
    2: If My.Settings.Default.UseIR Then
    3:     '  и найдены оба светодиода
    4:     If ws.IRState.Found1 AndAlso ws.IRState.Found2 Then
    5:         '  привести средние точки к диапазону от -0.5 до 0.5 (из исходного 0 - 1.0)
    6:         Dim x As Single = ws.IRState.MidX - 0.5f
    7:         Dim y As Single = ws.IRState.MidY - 0.5f
    8:  
    9:         '  Если вышли за границы, активировать события
   10:         If x > My.Settings.Default.IRDeadX OrElse x < -My.Settings.Default.IRDeadX Then
   11:             Me.Execute(New AxisEventData(New EventKey(Me, CInt(Fix(WiimoteEvent.IRX))), x))
   12:         End If
   13:         If y > My.Settings.Default.IRDeadY OrElse y < -My.Settings.Default.IRDeadY Then
   14:             Me.Execute(New AxisEventData(New EventKey(Me, CInt(Fix(WiimoteEvent.IRY))), y))
   15:         End If
   16:  
   17:         '  Сохранить последние значения для ИК, на случай выхода из допустимого диапазона:
   18:         ' тогда будут задействованы последние использованные координаты, пока Wiimote не вернется в диапазон
   19:         Me._lastIRX = x
   20:         Me._lastIRY = y
   21:     Else ' один или оба светодиода не светятся
   22:         ' Активировать события с применением последней известной позиции
   23:         If Me._lastIRX > My.Settings.Default.IRDeadX OrElse Me._lastIRX < -My.Settings.Default.IRDeadX Then
   24:             Me.Execute(New AxisEventData(New EventKey(Me, CInt(Fix(WiimoteEvent.IRX))), Me._lastIRX))
   25:         End If
   26:         If Me._lastIRY > My.Settings.Default.IRDeadY OrElse Me._lastIRY < -My.Settings.Default.IRDeadY Then
   27:             Me.Execute(New AxisEventData(New EventKey(Me, CInt(Fix(WiimoteEvent.IRY))), Me._lastIRY))
   28:         End If
   29:     End If
   30: Еlse ' мы используем акселерометр
   31:     ' Активировать события в соответствии со значениями акселерометра
   32:     If ws.AccelState.X > My.Settings.Default.WiimoteDeadX OrElse ws.AccelState.X < -My.Settings.Default.WiimoteDeadX Then
   33:         Me.Execute(New AxisEventData(New EventKey(Me, CInt(Fix(WiimoteEvent.AX))), ws.AccelState.X))
   34:     End If
   35:     If ws.AccelState.Y > My.Settings.Default.WiimoteDeadY OrElse ws.AccelState.Y < -My.Settings.Default.WiimoteDeadY Then
   36:         Me.Execute(New AxisEventData(New EventKey(Me, CInt(Fix(WiimoteEvent.AY))), ws.AccelState.Y))
   37:     End If
   38: End If

Затем необходимо прочитать данные от нунчака и активировать соответствующие события. Это делается так:

C#

    1: // Если нунчак подключен
    2: if(ws.Extension && ws.ExtensionType == ExtensionType.Nunchuk)
    3: {
    4:     // Активировать связанные с нунчаком события
    5:     if(ws.NunchukState.X > Properties.Settings.Default.NunchukDeadX || ws.NunchukState.X < -Properties.Settings.Default.NunchukDeadX)
    6:         this.Execute(new AxisEventData(new EventKey(this, (int)WiimoteEvent.NunchukX), ws.NunchukState.X));
    7:     if(ws.NunchukState.Y > Properties.Settings.Default.NunchukDeadY || ws.NunchukState.Y < -Properties.Settings.Default.NunchukDeadY)
    8:         this.Execute(new AxisEventData(new EventKey(this, (int)WiimoteEvent.NunchukY), ws.NunchukState.Y));
    9:     if(ws.NunchukState.C)
   10:         this.Execute(new AxisEventData(new EventKey(this, (int)WiimoteEvent.NunchukC), 1.0f));
   11:     if(ws.NunchukState.Z)
   12:         this.Execute(new AxisEventData(new EventKey(this, (int)WiimoteEvent.NunchukZ), 1.0f));
   13: }

VB

    1: ' Если нунчак подключен
    2: If ws.Extension AndAlso ws.ExtensionType = ExtensionType.Nunchuk Then
    3:     ' Активировать связанные с нунчаком события
    4:     If ws.NunchukState.X > My.Settings.Default.NunchukDeadX OrElse ws.NunchukState.X < -My.Settings.Default.NunchukDeadX Then
    5:         Me.Execute(New AxisEventData(New EventKey(Me, CInt(Fix(WiimoteEvent.NunchukX))), ws.NunchukState.X))
    6:     End If
    7:     If ws.NunchukState.Y > My.Settings.Default.NunchukDeadY OrElse ws.NunchukState.Y < -My.Settings.Default.NunchukDeadY Then
    8:         Me.Execute(New AxisEventData(New EventKey(Me, CInt(Fix(WiimoteEvent.NunchukY))), ws.NunchukState.Y))
    9:     End If
   10:     If ws.NunchukState.C Then
   11:         Me.Execute(New AxisEventData(New EventKey(Me, CInt(Fix(WiimoteEvent.NunchukC))), 1.0f))
   12:     End If
   13:     If ws.NunchukState.Z Then
   14:         Me.Execute(New AxisEventData(New EventKey(Me, CInt(Fix(WiimoteEvent.NunchukZ))), 1.0f))
   15:     End If
   16: End If

В завершение надо активировать события для кнопок. Для определения конкретной кнопки из всех имеющихся в Wiimote, используется вспомогательный метод, проверяющий текущее состояние кнопок. Если такая кнопка находится, с помощью Execute активируется соответствующее событие.

C#

    1: private void HandleButton(WiimoteEvent we, bool buttonState, bool lastButtonState)
    2: {
    3:     if(buttonState == lastButtonState)
    4:         return;
    5:     else
    6:     {
    7:         if(buttonState)
    8:             this.Execute(new ButtonEventData(new EventKey(this, (int)we), EventActivateState.Activate));
    9:         else
   10:             this.Execute(new ButtonEventData(new EventKey(this, (int)we), EventActivateState.Deactivate));
   11:     }
   12: }
   13:  
   14: // Обработать все кнопки Wiimote 
   15: HandleButton(WiimoteEvent.Up, ws.ButtonState.Up, _lastBS.Up);
   16: HandleButton(WiimoteEvent.Down, ws.ButtonState.Down, _lastBS.Down);
   17: HandleButton(WiimoteEvent.Left, ws.ButtonState.Left, _lastBS.Left);
   18: HandleButton(WiimoteEvent.Right, ws.ButtonState.Right, _lastBS.Right);
   19: HandleButton(WiimoteEvent.A, ws.ButtonState.A, _lastBS.A);
   20: HandleButton(WiimoteEvent.B, ws.ButtonState.B, _lastBS.B);
   21: HandleButton(WiimoteEvent.Minus, ws.ButtonState.Minus, _lastBS.Minus);
   22: HandleButton(WiimoteEvent.Home, ws.ButtonState.Home, _lastBS.Home);
   23: HandleButton(WiimoteEvent.Plus, ws.ButtonState.Plus, _lastBS.Plus);
   24: HandleButton(WiimoteEvent.One, ws.ButtonState.One, _lastBS.One);
   25: HandleButton(WiimoteEvent.Two, ws.ButtonState.Two, _lastBS.Two);
   26:  
   27: ...
   28:  
   29: // Сохранить текущее состояние кнопки на будущее
   30: _lastBS = ws.ButtonState;
   31: _lastNunchuk = ws.NunchukState;

VB

    1: Private Sub HandleButton(ByVal we As WiimoteEvent, ByVal buttonState As Boolean, ByVal lastButtonState As Boolean)
    2:     If buttonState = lastButtonState Then
    3:         Return
    4:     Else
    5:         If buttonState Then
    6:             Me.Execute(New ButtonEventData(New EventKey(Me, CInt(Fix(we))), EventActivateState.Activate))
    7:         Else
    8:             Me.Execute(New ButtonEventData(New EventKey(Me, CInt(Fix(we))), EventActivateState.Deactivate))
    9:         End If
   10:     End If
   11: End Sub
   12:  
   13: ...
   14:  
   15: ' Обработать все кнопки Wiimote 
   16: HandleButton(WiimoteEvent.Up, ws.ButtonState.Up, _lastBS.Up)
   17: HandleButton(WiimoteEvent.Down, ws.ButtonState.Down, _lastBS.Down)
   18: HandleButton(WiimoteEvent.Left, ws.ButtonState.Left, _lastBS.Left)
   19: HandleButton(WiimoteEvent.Right, ws.ButtonState.Right, _lastBS.Right)
   20: HandleButton(WiimoteEvent.A, ws.ButtonState.A, _lastBS.A)
   21: HandleButton(WiimoteEvent.B, ws.ButtonState.B, _lastBS.B)
   22: HandleButton(WiimoteEvent.Minus, ws.ButtonState.Minus, _lastBS.Minus)
   23: HandleButton(WiimoteEvent.Home, ws.ButtonState.Home, _lastBS.Home)
   24: HandleButton(WiimoteEvent.Plus, ws.ButtonState.Plus, _lastBS.Plus)
   25: HandleButton(WiimoteEvent.One, ws.ButtonState.One, _lastBS.One)
   26: HandleButton(WiimoteEvent.Two, ws.ButtonState.Two, _lastBS.Two)
   27:  
   28: ' Сохранить текущее состояние кнопки на будущее
   29: _lastBS = ws.ButtonState
   30: _lastNunchuk = ws.NunchukState

Текущее состояние кнопки сохраняется, чтобы при следующем событии не обрабатывать ее нажатие еще раз.

Чтобы созданный нами обработчик события можно было использовать, привяжем его к GlobeControl. Для этого создается экземпляр объекта WiimoteEventSource, передаваемый ActionSystem компонентаVE3D из объекта BindingsManager. Затем экземпляр источника события передается EventSourceManager объекта ActionSystem и регистрируется методом RegisterEventSource. Источники событий должны быть зарегистрированы до добавления элемента управления на форму.

C#

    1: // События wiimote 
    2: private WiimoteEventSource _wiimoteEventSource;
    3:  
    4: ...
    5:  
    6: // Создаем новый экземпляр обработчика событий Wiimote 
    7: _wiimoteEventSource = new WiimoteEventSource(this.globeControl.Host.BindingsManager.ActionSystem, this);
    8:  
    9: // Регистрируем его в списке источников событий
   10: _globeControl.Host.BindingsManager.ActionSystem.EventSourceManager.RegisterEventSource(this._wiimoteEventSource);

VB

    1: ' События wiimote 
    2: Private _wiimoteEventSource As WiimoteEventSource
    3:  
    4: ...
    5:  
    6: ' Создаем новый экземпляр обработчика событий Wiimote 
    7: _wiimoteEventSource = New WiimoteEventSource(Me.globeControl.Host.BindingsManager.ActionSystem, Me)
    8:  
    9: ' Регистрируем его в списке источников событий
   10: _globeControl.Host.BindingsManager.ActionSystem.EventSourceManager.RegisterEventSource(Me._wiimoteEventSource)
Действия и BindingManager

Наш список привязок содержит несколько типов действий, не определенных в качестве действий по умолчанию в VE3D. Эти действия и их обработчики необходимо зарегистрировать для элемента управления VE3D. Действия регистрируются в обработчике событий FirstFrameRendered следующим образом:

C#

    1: BindingsSource bs = new BindingsSource(base.GetType());
    2: _globeControl.Host.BindingsManager.RegisterAction(bs, "Locations", LocationsHandler);
    3: _globeControl.Host.BindingsManager.RegisterAction(bs, "LocationsMove", LocationsMoveHandler);
    4: _globeControl.Host.BindingsManager.RegisterAction(bs, "ToggleVR920", ToggleVR920Handler);
    5: _globeControl.Host.BindingsManager.RegisterAction(bs, "VR920SetZero", VR920SetZero);
    6: _globeControl.Host.BindingsManager.RegisterAction(bs, "BalanceBoardSetZero", BalanceBoardSetZero);
    7: _globeControl.Host.BindingsManager.RegisterAction(bs, "ToggleBalanceBoard", ToggleBalanceBoardHandler);
    8: _globeControl.Host.BindingsManager.RegisterAction(bs, "FullScreen", FullScreenHandler);
    9: _globeControl.Host.BindingsManager.RegisterAction(bs, "ToggleVR920Stereo", ToggleVR920Stereo);
   10: _globeControl.Host.BindingsManager.RegisterAction(bs, "VR920SetEyeDistance", VR920SetEyeDistance);

VB

    1: BindingsSource bs = new BindingsSource(base.GetType());
    2: _globeControl.Host.BindingsManager.RegisterAction(bs, "Locations", LocationsHandler);
    3: _globeControl.Host.BindingsManager.RegisterAction(bs, "LocationsMove", LocationsMoveHandler);
    4: _globeControl.Host.BindingsManager.RegisterAction(bs, "ToggleVR920", ToggleVR920Handler);
    5: _globeControl.Host.BindingsManager.RegisterAction(bs, "VR920SetZero", VR920SetZero);
    6: _globeControl.Host.BindingsManager.RegisterAction(bs, "BalanceBoardSetZero", BalanceBoardSetZero);
    7: _globeControl.Host.BindingsManager.RegisterAction(bs, "ToggleBalanceBoard", ToggleBalanceBoardHandler);
    8: _globeControl.Host.BindingsManager.RegisterAction(bs, "FullScreen", FullScreenHandler);
    9: _globeControl.Host.BindingsManager.RegisterAction(bs, "ToggleVR920Stereo", ToggleVR920Stereo);
   10: _globeControl.Host.BindingsManager.RegisterAction(bs, "VR920SetEyeDistance", VR920SetEyeDistance);

Имея зарегистрированные действия и связанные с ними обработчики, необходимо реализовать эти обработчики. Методы всех обработчиков событий должны иметь следующую сигнатуру:

C#

    1: public bool EventHandler(EventData cause)

VB

    1: Public Function EventHandler(ByVal cause As EventData) As Boolean

Давайте рассмотрим привязку FullScreen, назначение которой — включать и выключать строку состояния внизу окна:

C#

    1: private bool FullScreenHandler(EventData eventData)
    2: {
    3:     if(eventData.Activate)
    4:         BeginInvoke(new UIEventHandlerDelegate(FullScreen), eventData);
    5:     return true;
    6: }

VB

    1: Private Function FullScreenHandler(ByVal eventData As EventData) As Boolean
    2:     If eventData.Activate Then
    3:         BeginInvoke(New UIEventHandlerDelegate(AddressOf FullScreen), eventData)
    4:     End If
    5:     Return True
    6: End Function
    7:  
    8: Private Sub FullScreen(ByVal eventData As EventData)
    9:     statusStrip1.Visible = Not statusStrip1.Visible
   10: End Sub

Поскольку при вызове этого обработчика выполняется поток VE3D, для обращения к методу, отвечающему за пользовательский интерфейс, надо использовать BeginInvoke.

Полностью демонстрационный код можно загрузить по указанной ниже ссылке. Код методов Be sure to check the source code for the full demo linked above for the location handler methods. Некоторые методы я здесь не приводил, поскольку они похожи на показанный выше код.

Очки VR920

Обрабатывать информацию, связанную с очками VR920, достаточно просто. Мы создадим объект VR920Tracker, который будет извлекать данные с этого устройства и пересылать их VE3D.

Для начала надо определить сигнатуры P/Invoke для взаимодействия с очками:

C#

    1: [DllImport("IWEARDRV.dll", SetLastError = true, CharSet = CharSet.Auto)]
    2: private static extern int IWROpenTracker();
    3:  
    4: [DllImport("IWEARDRV.dll", SetLastError = true, CharSet = CharSet.Auto)]
    5: private static extern void IWRZeroSet();
    6:  
    7: [DllImport("IWEARDRV.dll", SetLastError = true, CharSet = CharSet.Auto)]
    8: private static extern int IWRGetTracking(out int yaw, out int pitch, out int roll);
    9:  
   10: private const int ERROR_SUCCESS  = 0;

VB

    1: <DllImport("IWEARDRV.dll", SetLastError := True, CharSet := CharSet.Auto)> _
    2: Private Shared Function IWROpenTracker() As Integer
    3: End Function
    4:  
    5: <DllImport("IWEARDRV.dll", SetLastError := True, CharSet := CharSet.Auto)> _
    6: Private Shared Sub IWRZeroSet()
    7: End Sub
    8:  
    9: <DllImport("IWEARDRV.dll", SetLastError := True, CharSet := CharSet.Auto)> _
   10: Private Shared Function IWRGetTracking(<System.Runtime.InteropServices.Out()> ByRef yaw As Integer, <System.Runtime.InteropServices.Out()> ByRef pitch As Integer, <System.Runtime.InteropServices.Out()> ByRef roll As Integer) As Integer
   11: End Function
   12:  
   13: private const int ERROR_SUCCESS  = 0;

Эти три метода позволяют открывать доступ к очкам, устанавливать начальную позицию и получать информацию от датчиков прокрутки, шага и отклонения.

В конструкторе объекта мы откроем описатель для очков и запустим таймер для регулярного опроса очков и получения данных с датчиков:

C#

    1: private const int TimerPeriod = 1;
    2: private MainForm _mainForm;
    3: private Timer _timer;
    4:  
    5: public VR920Tracker(MainForm mainForm)
    6: {
    7:     _mainForm = mainForm;
    8:  
    9:     int openResult = IWROpenTracker();
   10:     if(openResult != ERROR_SUCCESS)
   11:         throw new ApplicationException("Could not connect to VR920: " + openResult);
   12:  
   13:     _timer = new Timer(VR920Poller, null, Timeout.Infinite, TimerPeriod);
   14: }

VB

    1: Private Const TimerPeriod As Integer = 1
    2: Private _mainForm As MainForm
    3: Private _timer As Timer
    4:  
    5: Public Sub New(ByVal mainForm As MainForm)
    6:     _mainForm = mainForm
    7:  
    8:     Dim openResult As Integer = IWROpenTracker()
    9:     If openResult <> ERROR_SUCCESS Then
   10:         Throw New ApplicationException("Could not connect to VR920: " & openResult)
   11:     End If
   12:  
   13:     _timer = New Timer(AddressOf VR920Poller, Nothing, Timeout.Infinite, TimerPeriod)
   14: End Sub

Метод VR920Poller, на который есть ссылка выше, будет вызываться с определенным интервалом и считывать данные очков:

C#

    1: // Набор значений для усреднения
    2: private List<double> _yawValues = new List<double>();
    3: private List<double> _rollValues = new List<double>();
    4: private List<double> _pitchValues = new List<double>();
    5:  
    6: // Последние вычисленные значения
    7: private double _lastYaw;
    8: private double _lastPitch;
    9: private double _lastRoll;
   10:  
   11: private void VR920Poller(object state)
   12: {
   13:     lock(this)
   14:     {
   15:         int yaw, pitch, roll;
   16:  
   17:         int result = IWRGetTracking(out yaw, out pitch, out roll);
   18:  
   19:         if(result != ERROR_SUCCESS)
   20:             throw new ApplicationException("Could not get VR920 tracking information: " + result);
   21:  
   22:         _yawValues.Add(VR920ToRadians(yaw));
   23:         _rollValues.Add(VR920ToRadians(roll));
   24:         _pitchValues.Add(VR920ToRadians(pitch));
   25:  
   26:         if(_yawValues.Count == 5)
   27:         {
   28:             double y = Average(_yawValues, _lastYaw);
   29:             if(Math.Abs(y - _lastYaw) > 0.026)
   30:                 _lastYaw = y;
   31:  
   32:             double p = Average(_pitchValues, _lastPitch);
   33:             if(Math.Abs(p - _lastPitch) > 0.017)
   34:                 _lastPitch = p;
   35:  
   36:             _lastRoll = Average(_rollValues, _lastRoll);
   37:  
   38:             _yawValues.Clear();
   39:             _pitchValues.Clear();
   40:             _rollValues.Clear();
   41:  
   42:             RollPitchYaw rollPitchYaw = new RollPitchYaw(_lastRoll, _lastPitch, _lastYaw);
   43:             _mainForm.SetRollPitchYaw(rollPitchYaw);
   44:  
   45:             _mainForm.lblAxes.Text = VR920ToDegrees(yaw) + ", " + VR920ToDegrees(pitch) + ", " + VR920ToDegrees(roll);
   46:         }
   47:     }
   48: }
   49:  
   50: private double Average(List<double> values, double last)
   51: {
   52:     double total = 0;
   53:  
   54:     foreach(double value in values)
   55:         total += value;
   56:  
   57:     total += last;
   58:  
   59:     return total / (values.Count+1);
   60: }
   61:  
   62: private double VR920ToRadians(int vr920Value)
   63: {
   64:     return (vr920Value * .00549) * (Math.PI/180);
   65: }
   66:  
   67: private double VR920ToDegrees(int vr920Value)
   68: {
   69:     return (vr920Value * .00549);
   70: }

VB

    1: ' Набор значений для усреднения
    2: Private _yawValues As List(Of Double) = New List(Of Double)()
    3: Private _rollValues As List(Of Double) = New List(Of Double)()
    4: Private _pitchValues As List(Of Double) = New List(Of Double)()
    5:  
    6: ' Последние вычисленные значения
    7: Private _lastYaw As Double
    8: Private _lastPitch As Double
    9: Private _lastRoll As Double
   10:  
   11: Private Sub VR920Poller(ByVal state As Object)
   12:     SyncLock Me
   13:         Dim yaw, pitch, roll As Integer
   14:  
   15:         Dim result As Integer = IWRGetTracking(yaw, pitch, roll)
   16:  
   17:         If result <> ERROR_SUCCESS Then
   18:             Throw New ApplicationException("Could not get VR920 tracking information: " & result)
   19:         End If
   20:  
   21:         _yawValues.Add(VR920ToRadians(yaw))
   22:         _rollValues.Add(VR920ToRadians(roll))
   23:         _pitchValues.Add(VR920ToRadians(pitch))
   24:  
   25:         If _yawValues.Count = 5 Then
   26:             Dim y As Double = Average(_yawValues, _lastYaw)
   27:             If Math.Abs(y - _lastYaw) > 0.026 Then
   28:                 _lastYaw = y
   29:             End If
   30:  
   31:             Dim p As Double = Average(_pitchValues, _lastPitch)
   32:             If Math.Abs(p - _lastPitch) > 0.017 Then
   33:                 _lastPitch = p
   34:             End If
   35:  
   36:             _lastRoll = Average(_rollValues, _lastRoll)
   37:  
   38:             _yawValues.Clear()
   39:             _pitchValues.Clear()
   40:             _rollValues.Clear()
   41:  
   42:             Dim rollPitchYaw As New RollPitchYaw(_lastRoll, _lastPitch, _lastYaw)
   43:             _mainForm.SetRollPitchYaw(rollPitchYaw)
   44:  
   45:             _mainForm.lblAxes.Text = VR920ToDegrees(yaw) & ", " & VR920ToDegrees(pitch) & ", " & VR920ToDegrees(roll)
   46:         End If
   47:     End SyncLock
   48: End Sub
   49:  
   50: Private Function Average(ByVal values As List(Of Double), ByVal last As Double) As Double
   51:     Dim total As Double = 0
   52:  
   53:     For Each value As Double In values
   54:         total += value
   55:     Next value
   56:  
   57:     total += last
   58:  
   59:     Return total / (values.Count+1)
   60: End Function
   61:  
   62: Private Function VR920ToRadians(ByVal vr920Value As Integer) As Double
   63:     Return (vr920Value *.00549) * (Math.PI/180)
   64: End Function
   65:  
   66: Private Function VR920ToDegrees(ByVal vr920Value As Integer) As Double
   67:     Return (vr920Value *.00549)
   68: End Function

В этом фрагменте вызывается метод IWRGetTracking и результаты распределяются по трем спискам, соответствующим прокрутке, шагу и отклонению. Затем, когда в списках накапливается по пять элементов, для них вычисляются средние и если они выходят за некоторые границы, эти значения передаются методу SetRollPitchYaw из MainForm:

C#

    1: private double _lastYaw;
    2:  
    3: public void SetRollPitchYaw(RollPitchYaw rollPitchYaw)
    4: {
    5:     if(!_initialized || _globeControl.IsDisposed || (_globeControl.Host) == null || _globeControl.Host.CameraControllers.Current == null)
    6:         return;
    7:  
    8:     double y = ((_globeControl.Host.CameraControllers.Current as ActionCameraController).LastReportedViewpoint.LocalOrientation.Yaw - _lastYaw) + rollPitchYaw.Yaw;
    9:  
   10:     RollPitchYaw rpw = new RollPitchYaw(rollPitchYaw.Roll, rollPitchYaw.Pitch, y);
   11:     (_globeControl.Host.CameraControllers.Current as ActionCameraController).LastReportedViewpoint.LocalOrientation.RollPitchYaw = rpw;
   12:  
   13:     _lastYaw = rollPitchYaw.Yaw;
   14: }

VB

    1: Private _lastYaw As Double
    2:  
    3: Public Sub SetRollPitchYaw(ByVal rollPitchYaw As RollPitchYaw)
    4:     If (Not _initialized) OrElse _globeControl.IsDisposed OrElse (_globeControl.Host) Is Nothing OrElse _globeControl.Host.CameraControllers.Current Is Nothing Then
    5:         Return
    6:     End If
    7:  
    8:     Dim y As Double = ((TryCast(_globeControl.Host.CameraControllers.Current, ActionCameraController)).LastReportedViewpoint.LocalOrientation.Yaw - _lastYaw) + rollPitchYaw.Yaw
    9:  
   10:     Dim rpw As New RollPitchYaw(rollPitchYaw.Roll, rollPitchYaw.Pitch, y)
   11:     TryCast(_globeControl.Host.CameraControllers.Current, ActionCameraController).LastReportedViewpoint.LocalOrientation.RollPitchYaw = rpw
   12:  
   13:     _lastYaw = rollPitchYaw.Yaw
   14: End Sub

Этот метод принимает значения прокрутки, шага и отклонения и передает их текущему методу CameraController, используемому VE3D (по умолчанию это ActionCameraController).

Таким образом, этот код берет значения прокрутки, шага и отклонения головы пользователя и переводит их в аналогичные значения камеры VE3D, обеспечивая реалистичное изображение в VE3D.

Стереоскопические изображения

Последняя часть нашей головоломки — обеспечить такой вывод на очки VR920, чтобы изображение казалось пользователю трехмерным. Подход здесь похож на используемый Волшебным глазом (EN). А именно: для каждого кадра нам надо иметь текущее положение камеры VE3D и сдвинуть его на несколько позиций влево для левого глаза и вправо — для правого. Для этого мы создаем объект VR920StereoStep, производный от класса Step, предоставляемого VE3D, и добавляем его в StepManager. Теперь в конце прорисовки каждого кадра, осуществляемой VE3D, надо вызывать VR920StereoStep.

Как и в случае класса VR920Tracker, для драйвера стерео нам потребуется сигнатура метода P/Invoke:

C#

    1: [DllImport("iWrstDrv.dll", EntryPoint = "IWRSTEREO_Open", SetLastError=true)]
    2: public static extern IntPtr OpenStereo();
    3:  
    4: [DllImport("iWrstDrv.dll", EntryPoint = "IWRSTEREO_SetStereo")]
    5: public static extern Boolean SetStereoEnabled(IntPtr handle, Boolean enabled);
    6:  
    7: [DllImport("iWrstDrv.dll", EntryPoint = "IWRSTEREO_SetLR")]
    8: public static extern Boolean SetStereoLR(IntPtr handle, Boolean eye);
    9:  
   10: [DllImport("iWrstDrv.dll", EntryPoint = "IWRSTEREO_WaitForAck")]
   11: public static extern Byte WaitForOpenFrame(IntPtr handle, Boolean eye);

VB

    1: <DllImport("iWrstDrv.dll", EntryPoint := "IWRSTEREO_Open", SetLastError:=True)> _
    2: Public Shared Function OpenStereo() As IntPtr
    3: End Function
    4:  
    5: <DllImport("iWrstDrv.dll", EntryPoint := "IWRSTEREO_SetLR")> _
    6: Public Shared Function SetStereoLR(ByVal handle As IntPtr, ByVal eye As Boolean) As Boolean
    7: End Function
    8:  
    9: <DllImport("iWrstDrv.dll", EntryPoint := "IWRSTEREO_SetStereo")> _
   10: Public Shared Function SetStereoEnabled(ByVal handle As IntPtr, ByVal enabled As Boolean) As Boolean
   11: End Function
   12:  
   13: <DllImport("iWrstDrv.dll", EntryPoint := "IWRSTEREO_WaitForAck")> _
   14: Public Shared Function WaitForOpenFrame(ByVal handle As IntPtr, ByVal eye As Boolean) As Byte
   15: End Function

В конструкторе данного объекта мы открываем описатель драйвера стерео и включаем функциональность стереоэффекта:

C#

    1: private static readonly IntPtr INVALID_FILE_HANDLE = (IntPtr)(-1);
    2:  
    3: private IntPtr _hStereo = INVALID_FILE_HANDLE;
    4: private Host _host;
    5: private bool _stereoEnabled = true;
    6:  
    7: public double EyeDistance { get; set; }
    8:  
    9: public VR920StereoStep(StepManager manager, Host host) : base(manager)
   10: {
   11:     EyeDistance = 10;
   12:     _host = host;
   13:  
   14:     _hStereo = OpenStereo();
   15:     if(_hStereo != INVALID_FILE_HANDLE)
   16:         SetStereoEnabled(_hStereo, true);
   17:     else
   18:         _stereoEnabled = false;
   19: }

VB

    1: Private Shared ReadOnly INVALID_FILE_HANDLE As IntPtr = CType(-1, IntPtr)
    2:  
    3: Private _hStereo As IntPtr = INVALID_FILE_HANDLE
    4: Private _host As Host
    5: Private _stereoEnabled As Boolean = True
    6:  
    7: Private privateEyeDistance As Double
    8: Public Property EyeDistance() As Double
    9:     Get
   10:         Return privateEyeDistance
   11:     End Get
   12:     Set(ByVal value As Double)
   13:         privateEyeDistance = value
   14:     End Set
   15: End Property
   16:  
   17: Public Sub New(ByVal manager As StepManager, ByVal host As Host)
   18:     MyBase.New(manager)
   19:     EyeDistance = 10
   20:     _host = host
   21:  
   22:     _hStereo = OpenStereo()
   23:     If _hStereo <> INVALID_FILE_HANDLE Then
   24:         SetStereoEnabled(_hStereo, True)
   25:     Else
   26:         _stereoEnabled = False
   27:     End If
   28: End Sub

Затем следует переопределить метод OnExecute базового класса Step:

C#

    1: private bool _rightEye = true;
    2: private Vector3D _position;
    3:  
    4: public override void OnExecute(SceneState state)
    5: {
    6:     if(_hStereo != INVALID_FILE_HANDLE && _stereoEnabled && (_host.CameraControllers.Current as ActionCameraController).LastReportedViewpoint != null)
    7:     {
    8:         if(!_rightEye)
    9:         {
   10:             _position = (_host.CameraControllers.Current as ActionCameraController).LastReportedViewpoint.Position.Vector;
   11:             (_host.CameraControllers.Current as ActionCameraController).LastReportedViewpoint.Position.Vector = _position + new Vector3D(-EyeDistance, 0, 0);
   12:         }
   13:         else
   14:         {
   15:             _position = (_host.CameraControllers.Current as ActionCameraController).LastReportedViewpoint.Position.Vector;
   16:             (_host.CameraControllers.Current as ActionCameraController).LastReportedViewpoint.Position.Vector = _position + new Vector3D(EyeDistance, 0, 0);;
   17:         }
   18:  
   19:         SetStereoLR(_hStereo, _rightEye);
   20:         WaitForOpenFrame(_hStereo, _rightEye);
   21:         _rightEye = !_rightEye;
   22:     }
   23: }

VB

    1: Private _rightEye As Boolean = True
    2: Private _position As Vector3D
    3:  
    4: Public Overrides Sub OnExecute(ByVal state As SceneState)
    5:     If _hStereo <> INVALID_FILE_HANDLE AndAlso _stereoEnabled AndAlso (TryCast(_host.CameraControllers.Current, ActionCameraController)).LastReportedViewpoint IsNot Nothing Then
    6:         If (Not _rightEye) Then
    7:             _position = (TryCast(_host.CameraControllers.Current, ActionCameraController)).LastReportedViewpoint.Position.Vector
    8:             TryCast(_host.CameraControllers.Current, ActionCameraController).LastReportedViewpoint.Position.Vector = _position + New Vector3D(-EyeDistance, 0, 0)
    9:         Else
   10:             _position = (TryCast(_host.CameraControllers.Current, ActionCameraController)).LastReportedViewpoint.Position.Vector
   11:             TryCast(_host.CameraControllers.Current, ActionCameraController).LastReportedViewpoint.Position.Vector = _position + New Vector3D(EyeDistance, 0, 0)
   12:  
   13:         End If
   14:  
   15:         SetStereoLR(_hStereo, _rightEye)
   16:         WaitForOpenFrame(_hStereo, _rightEye)
   17:         _rightEye = Not _rightEye
   18:     End If
   19: End Sub

OnExecute будет вызываться в конце визуализации каждого кадра VE3D. В этом методе мы получаем текущее положение камеры VE3D и смещаем его на несколько пунктов влево или вправо (- или + EyeDistance). Вызывается SetStereoLR из состава API VR920 для указания левого или правого глаза (левый = false, правый = true). Затем вызывается метод WaitForOpenFrame, который приостанавливает действие на период, достаточный для прорисовки очками всего кадра для соответствующего глаза, чтобы предотвратить разрыв изображения. На самом деле этот метод ожидает, пока от очков не поступит сообщение об окончании прорисовки кадра для одного глаза, чтобы мы затем могли продолжить вывод следующего кадра для другого глаза. В завершение значение члена _rightEye меняется на противоположное и при следующем обращении к данному методу выполняется вывод для другого глаза.

Вернемся к MainForm и в обработчике события FirstFrameRendered породим экземпляр объекта и добавим его к StepManager:

C#

    1: private VR920StereoStep _vr920StereoStep;
    2:  
    3: _vr920StereoStep = new VR920StereoStep(_globeControl.Host.RenderEngine.StepManager, _globeControl.Host);
    4: _globeControl.Host.RenderEngine.StepManager.Add(_vr920StereoStep);

VB

    1: Private _vr920StereoStep As VR920StereoStep
    2:  
    3: _vr920StereoStep = New VR920StereoStep(_globeControl.Host.RenderEngine.StepManager, _globeControl.Host)
    4: _globeControl.Host.RenderEngine.StepManager.Add(_vr920StereoStep)
Выполнение приложения

Для запуска демо-примера:

  1. Скопируйте BindingsWiimote.xml в указанный выше каталог.
  2. Подключите Wiimote и Balance Board к компьютеру. О том, как это сделать, см. статью WiimoteLib (EN).
  3. Запустите исполняемый файл.
  4. Встаньте на доску Balance Board.
  5. Наденьте очки.
  6. Обнулите очки и Balance Board.
  7. Включите или выключите то, что вам хочется.

Управление

  • F1 – установка очков VR920 в начальное положение.
  • F2 – установка Balance Board в начальное положение.
  • F3 – включение/выключение режима стереоизображения.
  • 1 или B на Wiimote – включение/выключение Balance Board.
  • 2 или V на Wiimote – включение/выключение отслеживания движения головы на VR920.
  • F – включение/выключение полноэкранного режима.
  • X/Y на джойстике Nunchuk – перемещение.
  • Кнопки C/Z на Nunchuk – увеличение/уменьшение высоты.
  • A на Wiimote – открыть меню местоположений/выбрать местоположение (обратите внимание: после выбора нового места Balance Board выключается и должна быть заново инициализирована после «приземления»).
  • Up/Down на пульте Wiimote – перемещение по списку.
  • Balance Board – наклоняйте свой корпус в стороны для изменения среды VE3D.
Завершение

Мы написали программу, обеспечивающую очень интересный интерфейс для Virtual Earth 3D. Демонстрационное приложение и исходный код, на которые приведены ссылки в начале статьи, содержат еще несколько функций и привязок, кроме описанных, которые немного расширяют возможности этого приложения. С ними вы можете ознакомиться, загрузив код.

Дополнительные сведения
Об авторе

Брайан имеет звание Microsoft C# MVP (EN). Он активно программирует для .NET, начиная с ранних бета-версий этой платформы, вышедшей в 2000 г., а прочие технологии Майкрософт начал использовать еще раньше. Кроме .NET, Брайан отлично разбирается в C, C++ и языке ассемблера для различных процессоров. Он также является специалистом по таким технологиям, как веб-разработка, графическое представление документов, ГИС, графика, разработка игр и программирование устройств. Брайан имеет опыт разработки приложений для здравоохранения, а также в создании решений для портативных устройств. Кроме того, Брайан является соавтором книги «Debugging ASP.NET» (EN) издательства New Riders, а сейчас участвует в написании книги «Coding4Fun: 10 .NET Programming Projects for Wiimote, YouTube, World of Warcraft, and More» (EN), которая выйдет в издательстве O'Reilly в декабре 2008. Брайан также является одним из авторов веб-сайта Coding4Fun (EN). Связаться с Брайаном можно через его веб-узел https://www.brianpeek.com/.