Kinect 开发 —— 骨骼追踪进阶(上)

Kinect传感器核心只是发射红外线,并探测红外光反射,从而可以计算出视场范围内每一个像素的深度值。从深度数据中最先提取出来的是物体主体和形状,以及每一个像素点的游戏者索引信息。然后用这些形状信息来匹配人体的各个部分,最后计算匹配出来的各个关节在人体中的位置。这就是我们之前介绍过的骨骼追踪。

红外影像和深度数据对于Kinect系统来说很重要,它是Kinect的核心,在Kinect系统中其重要性仅次于骨骼追踪。事实上,这些数据相当于一个输入终端。随着Kinect或者其他深度摄像机的流行和普及。开发者可以不用关注原始的深度影像数据,他们变得不重要或者只是作为获取其他数据的一个基础数据而已。我们现在就处在这个阶段,Kinect SDK并没有提供给开发者访问原始红外影像数据流的接口,但是其它第三方的SDK可以这么做。可能大多数开发者不会使用原始的深度数据,用到的只是Kinect处理好了的骨骼数据。但是,一旦姿势和手势识别整合到Kinect SDK并成为其一部分时,可能开发者甚至不用接触到骨骼数据了。


用户交互

在底层,鼠标,触摸板或者手写设备都是提供一些X,Y坐标,操作系统将这些X,Y坐标从其在的空间坐标系统转换到计算机屏幕上,这一点和上篇文章讨论的空间变换类似。操作系统的职责是响应这些标准输入设备输入的数据,然后将其转换到图形用户界面或者应用程序中去。操作系统的图形用户界面显示光标位置,并响应用户的输入。在有些时候,这个过程没有那么简单,需要我们了解GUI平台。以WPF应用程序为例,它并没有对Kinect提供像鼠标和键盘那样的原生的支持。这个工作就落到开发者身上了,我们需要从Kinect中获取数据,然后利用这些数据与按钮,下拉框或者其他控件进行交互。根据应用程序或者用户界面的复杂度的不同,这种工作可能需要我们了解很多有关WPF的知识。

WPF 应用程序中输入系统

当开发一个WPF应用程序时,开发者并不需要特别关注用户输入机制。WPF会为我们处理这些机制使得我们可以关注于如何响应用户的输入。毕竟作为一个开发者,我们更应该关心如何对用户输入的信息进行分析处理,而不是重新造轮子来考虑如何去收集用户的输入。如果应用程序需要一个按钮,只需要从工具箱中拖一个按钮出来放在界面上,然后在按钮的点击事件中编写处理逻辑即可。在大多数情况下,开发者可能需要对按钮设置不同的外观以响应用户鼠标的不同状态。WPF会在底层上为我们实现这些事件,诸如鼠标何时悬停在按钮上,或者被点击。

WPF有一个健全的输入系统来从输入设备中获取用户的输入信息,并响应这些输入信息所带来的控件变化。这些API位于System.Windows.Input命名空间中(Presentation.Core.dll),这些API直接从操作系统获取输入设备输入的数据,例如,名为Keyboard,Mouse,Stylus,Touch和Cursor的这些类。InputManager这个类负责管理所有输入设备获取的信息,并将这些信息传递到表现框架中。

WPF的另一类组件是位于System.Windows命名空间(PresentationCore.dll)下面的四个类,他们是UIElement,ContentElement,FrameworkElement以及FrameworkContentElement 。FrameworkElement继承自UIElement,FrameworkContentElement继承自ContentElement。这几个类是WPF中所有可视化元素的基类,如Button,TextBlock及ListBox。

InputManager监听所有的输入设备,并通过一系列方法和事件来通知UIElement和ContentElement对象,告知这些对象输入设备进行了一些有关可视化元素相关的操作。例如,在WPF中,当鼠标光标进入到可视化控件的有效区域时就会触发MouseEnterEvent事件。UIElement和ContentElement对象也有OnMouseEnter事件。这使得任何继承自UIElement或者ContentElement类的对象也能够接受来自输入设备的所触发的事件。WPF会在触发任何其它输入事件之前调用这些方法。在UIElement和ContentElement类中也有一些类似的事件包括MouseEnter,MouseLeave,MouseLeftButtonDown,MouseLeftButtonUp,TouchEnter,TouchLeave,TouchUp和TouchDown。

有时候开发者需要直接访问鼠标或者其他输出设备,InputManager对象有一个称之为PrimaryMouseDevice的属性。他返回一个MouseDevice对象。使用MouseDevice对象,能够在任何时候通过调用GetScreenPositon来获取鼠标的位置。另外,MouseDevice有一个名为GetPositon的方法,可以传入一个UI界面元素,将会返回在该UI元素所在的坐标空间中的鼠标位置。当需要判断鼠标悬停等操作时,这些信息尤其重要。当Kinect SDK每一次产生一幅新的SkeletonFrame帧数据时,我们需要进行坐标空间转换,将关节点位置信息转换到UI空间中去,使得可视化元素能够直接使用这些数据。当开发者需要将鼠标作为输入设备时, MouseDevice对象中的GetScreenPositon和GetPosition方法能提供当前鼠标所在点的位置信息。

在有些情况下,Kinect虽然和鼠标相似,但是某些方面差别很大。骨骼节点进入或者离开UI上的可视化元素这一点和鼠标移入移出行为类似。换句话说,关节点的悬停行为和鼠标光标一样。但是,类似鼠标点击和鼠标按钮的按下和弹起这些交互,关节点与UI的交互是没有。在后面的文章中,可以看到使用手可以模拟点击操作。在Kinect中相对于实现鼠标移入和移出操作来说,对鼠标点击这种支持相对来说较弱。

能够想象到,如果Kinect被完整的整合进WPF,可能会有一个称之为SkeletonDevice的类。他和Kinect SDK中的SkeletonFrame对象类似。每一个Skeleton对象会有一个称之为GetJointPoint的方法,他和MouseDevice的GetPositon和TouchDevice的GetTouchPoint类似。另外,核心的可视化元素(UElement, ContentElement, FrameworkElement, FrameworkContentElement) 有能够相应的事件或者方法能够通知并处理骨骼关节点交互。例如,可能有一个JointEnter,JointLeave,和JointHover事件。更进一步,就像触控类有一个ManipulationStarted和ManipulationEnded事件一样,在Kinect输入的时候可能伴随GetstureStarted和GestureEnded事件。

    目前,Kinect SDK和WPF是完全分开的,因此他和输入系统没有在底层进行整合。所以作为开发者的我们需要追踪骨骼关节点位置,并判断节点位置是否和UI界面上的元素有交互。当关节点在对应的UI坐标系可视化界面的有效范围内时,我们必须手动的改变这些可视化元素的外观以响应这种交互。


探测用户的交互

在确定用户是否和屏幕上的某一可视化元素进行交互之前,我们必须定义什么叫用户和可视化元素的交互。在以鼠标或者光标驱动的应用程序中有两种用户交互方式。鼠标悬停和点击交互。这些将事件划分为更精细的交互。就拿光标悬停来说,它必须进行可视化组件的坐标空间区域,当光标离开这一区域,悬停交互也就结束了。在WPF中,当用户进行这些操作时,会触发MouseEnter和MouseLeave操作。

除了点击和悬停外,鼠标还有另外一种常用的交互,那就是拖放。当光标移动到可视化组件上方,按下鼠标左键,然后在屏幕上拖动,我们称之为拖动(drag),当用户松开鼠标左键时,我们之位释放操作(drop)。鼠标拖动和释放是一个比较复杂的交互,这点和Kinect中的手势类似

应用程序在屏幕坐标空间中产生一些点的位置(数字),但是这些点并没有直接从屏幕空间派生。这些点只是存储在变量中的数据而已。我们改变屏幕大小使得很容易展现出来。在接收到新的骨骼数据帧之前。骨骼数据中手的位置被转换到屏幕中点所在的空间坐标系,然后我们判断手所在的位置的点是否在点序列中。技术上来讲,这个应用程序即使没有用户界面也能够正常运行。用户界面是动态的由这些数据产生的。用户直接和这些数据而不是和界面进行交互。


命中测试

判断用户的手是否在点的附近远没有判断手是否在点的位置上那么简单。每一个点只是一个象元。为了使得应用程序能够工作。我们并不要求手的位置敲好在这个点所在的象元上,而是要求在以这个点为中心的某一个区域范围内。我们在点的周围创建了一个圆圈代表点的区域范围,用户的手的中心必须进入到这个点的区域范围才被认为是悬停在该点上。如图所示在圆形中的白色的点是实际的点,虚线绘制的圆形是该点的最大可触及范围。手形图标的中心用白色的点表示。所以,有可能手的图标和点的最大范围接触了,但是手的中心却不在该点的最大范围内。判断手的中心是否在点的最大范围之内称之为命中测试。

image

WPF提供了一些工具来帮助我们实现命中测试。在System.Windows.Media命名空间下的VisualTreeHelper帮助类中有一个HitTest方法。这个方法有很多个重载,但是最基本的方法接受两个参数,一个是可视化控件对象,另一个是待测试的点。他返回可视化对象树中该点所命中的最顶层的那个可视化对象。听起来可能有点复杂,一个最简单的解释是,在WPF中有一个分层的可视化输出,有多个对象可能占据同一个相对空间,但是在不同的层。如果该点所在位置有多个对象,那么HitTest返回处在可视化树中处在最顶层的可视化对象。由于WPF的样式和模板系统使得一个控件能够由一个或者多个元素或者其它控件组成,所在通常在一个点可能有多个可视化元素。

image

上图可能帮助我们理解可视元素的分层。图中有三个元素:圆形,矩形和按钮。所有三个元素都在Canvas容器中。圆形和按钮在矩形之上,左边第一幅图中,鼠标位于圆形之上,在这点上的命中测试结果将返回这个圆形。第二幅图,即使矩形最底层,由于鼠标位于矩形上,所以命中测试会返回矩形。这是因为矩形在最底层,他是唯一个占据了鼠标光标象元所在位置的可视化元素。在第三幅图中,光标在按钮的文字上,命中测试将返回TextBlock对象,如果鼠标没有位于按钮的文字上,命中测试将会返回ButtonChrome元素。按钮的可视化表现通常由一个或者多个可视化控件组成,并能够定制。实际上,按钮没有继承可视化样式,它是一个没有可视化表现的控件。上图中的按钮使用的是默认样式,它由TextBlock和ButtonChrome这两个控件构成的。在这个例子中,我们通常会获得到有按钮样式组成的元素,但是永远获取不到实际的按钮控件。

为了使得命中测试更为方便,WPF提供了其他的方法来协助进行命中测试。UIElement类定义了一个InputHitTest方法,它接受一个Point对象,并返回该Point对象指定的一个IIputElement元素。UIElement和ContentElement两个类都实现了IInputElement接口。这意味着所有的WPF用户界面元素都实现了这个接口。VisualTreeHelper类中的HitTest方法可以用在一般的场合。

Note: MSDN中关于UIElement.InputHitTest方法的建议“应用程序一般不需要调用该方法,只有应用程序需要自己重新实现一系列已经实现了的底层输入特征,例如要重新实现鼠标设备的输入逻辑时才会去调用该方法。”由于Kinect并没有原生的集成到WPF中,所以必须重新实现类似鼠标设备的输入逻辑。

WPF中,命中测试依赖于两个变量,一个是可视化元素,另一个是点。测试首先该点转换到可视化元素所在坐标空间,然后确定是否处于该可视化元素的有效范围内。下图可以更好的理解可视化元素的坐标空间。WPF中的每一个可视化元素,不论其形状和大小,都有一个外轮廓:这个轮廓是一个矩形,它包含可视化元素并定义了可视化元素的宽度和高度。布局系统使用这个外轮廓来确定可视化元素的整体尺寸以及如何将其排列在屏幕上。当开发者使用Canvas,Grid,StackPanel等容器来布局其子元素时,元素的外轮廓是这些容器控件如进行布局计算的基础。用户看不到元素的外轮廓,下图中,可视化元素周围的虚线矩形显示了这些元素的外轮廓。此外,每一个元素有一个X,Y坐标用来指定该元素在其父容器中的位置。可以通过System.Windows.Controls.Primitives命名空间中的LayoutInformation静态类中的GetLayoutSlot方法来获取元素的外轮廓和其位置。举例来说,图中三角形的外轮廓的左上角坐标点为(0,0),三角形的宽和高都是200像素。所以在三角形外轮廓中,三角形的三个点的坐标分别为(100,0),(200,200),(0,200)。并不是在三角形外轮廓中的所有点在命中测试中都会成功,只有在三角形内部的点才会成功。点(0,0)不会命中,而三角形的中心(100,100)则能命中。

image

命中测试的结果依赖于可视化元素的布局。在目前所有的项目中,我们使用Canvas容器来包含所有可视化元素。Canvas是一个可视化的容器,能够使得开发者对可视化元素的位置进行完全控制,这一点在使用Kinect的时候尤其明显。像手部跟踪这类基本的方法也可以使用WPF中的其他容器,但是需要做很多其他工作,并且性能没有使用Canvas好。使用Cnavas容器,用户可以通过CanvasLeft和CanvasTop显式设定其所有子元素的起始X,Y的位置。前面讨论的坐标空间转换使用Cnavas作为容器,因为不需要太多的处理操作,转换也非常明了,只需要少量的代码就可以实现较好的性能。

    使用Canvas作为容器的缺点也是其的优点。由于开发者可以完全控制在Canvas中子元素的位置,所以当窗体大小发生改变或者有比较复杂的布局时,也需要开发者去更新这些可视化元素的位置。而另外一些容器控件,如Grid,StackPanel则会帮助我们实现这些更新操作。但是,这些容器控件增加了可视化树的结构和坐标空间,从而增加了命中测试的复杂度。坐标空间越多,需要的点的转换就越多。这些容器还有alignment属性(水平和垂直)和相对于FrameworkElement的margin属性,进一步增加了命中测试的计算复杂度。如果可是化元素有RenderTransforms方法的话,我们可以直接使用这些方法而不用去自己写命中测试的算法了。

   一个折中的方法是,将那些基于骨骼节点位置的需要频繁变化的可视化元素,如手形图标放在Canvas容器内,而将其他UI元素放在其他容器控件内。这种布局模式需要多个坐标空间转换,会影响程序性能,并且在进行坐标空间转换计算时可能会引入一些bug。这种混合的布局方案在大多数情况下是最好的选择,它充分利用了WPF布局系统的优点


“我说你做”游戏

该项目使用手部关节点数据模仿鼠标或者触控板和用户界面进行交互。这个项目的目标是展示如何进行命中测试和使用WPF可视化元素来创建用户界面

这是个很好的使用Kinect展示如何和用户界面进行交互的例子。这个游戏也有一些规则。下图展示了我们将要做的用户界面,他包含四个矩形,他用来模拟游戏中的按钮。界面上方是游戏标题,中间是游戏的操作指南。

image

这个Kinect版的Simon says游戏追踪游戏者的手部关节。当用户的手碰到了这四个填充了颜色的方框中的任何一个时,程序认为游戏者按下了一个按钮。在Kinect应用程序中,使用悬停或者点击来和按钮进行交互很常见。现在,我们的游戏操作指南还很简单。游戏一开始,我们提示用户将手放在界面上红色矩形中手势图标所在的位置。在用户将双手放到指定位置后,界面开始发出指令。如果游戏者不能够重复这个过程,游戏将会结束,并返回到这个状态。


用户界面设计

将所有的主界面的UI元素包含在Viewbox容器中,让他来帮助我们进行不同显示器分辨率下面的缩放操作。主UI界面分辨率设置为1920*1080。UI界面共分为4个部分:标题及游戏指导,游戏界面,游戏开始界面以及用来追踪手部的手形图标。第一个TextBlock用来显示标题,游戏引导放在接下来的StackPanel元素中。这些元素是用来给游戏者提供当前游戏状态。他们没有功能性的作用,和Kinect或者骨骼追踪没有关系。

    GameCanvas,ControlCanvas和HandCanvas包含了所有的和Kienct相关的UI元素,这些元素是基于当前用户手的位置和用户界面进行交互的。手的位置来自骨骼追踪。HandCanvas应该比较熟悉,程序中有两个手形图标,用来追踪游戏者两只手的运动。ControlCanvas存储的UI元素用来触发开始游戏。GameCanvas用来存储这4个矩形,在游戏中,用户需要点击这些矩形。不同的交互元素存储在不同的容器中,使得用户界面能够比较容易使用代码进行控制。比如,当用户开始游戏后,我们需要隐藏所有的ControlCanvas容器内的子元素,显然隐藏这个容器比隐藏其每个子控件容易的多。

private void KinectDevice_SkeletonFrameReady(object sender, SkeletonFrameReadyEventArgs e)
{
    using (SkeletonFrame frame = e.OpenSkeletonFrame())
    {
        if (frame != null)
        {
            frame.CopySkeletonDataTo(this.frameSkeletons);
            Skeleton skeleton = GetPrimarySkeleton(this.frameSkeletons);

            if (skeleton == null)
            {
                ChangePhase(GamePhase.GameOver);
            }
            else
            {
                LeftHandElement.Visibility = Visibility.Collapsed;
                RightHandElement.Visibility = Visibility.Collapsed;
            }
        }
    }
}

private static Skeleton GetPrimarySkeleton(Skeleton[] skeletons)
{
    Skeleton skeleton = null;

    if (skeletons != null)
    {
        //Find the closest skeleton       
        for (int i = 0; i < skeletons.Length; i++)
        {
            if (skeletons[i].TrackingState == SkeletonTrackingState.Tracked)
            {
                if (skeleton == null)
                {
                    skeleton = skeletons[i];
                }
                else
                {
                    if (skeleton.Position.Z > skeletons[i].Position.Z)
                    {
                        skeleton = skeletons[i];
                    }
                }
            }
        }
    }

    return skeleton;
}

上面代码中TrackHand和GetJointPoint代码和Kinect连线游戏中相同。对于大多数游戏来说,使用“拉模型”来获取数据比使用事件模型获取数据性能要好。游戏通常是一个循环,可以手动的从骨骼数据流中获取下一帧骨骼数据。但是在我们的例子中,仍然使用的是事件模型,为的是能够减少代码量和复杂度。


添加游戏基本元素

Simon say游戏分成三步。起始步骤,我们之为GameOver,意味着当前没有可以玩的游戏。这是游戏的默认状态。这也是当Kinect探测不到游戏者时所切换到的状态。然后游戏开始循环,Simon给出一些指令,然后游戏者重复执行这些指令,重复这一过程,直到用户没能够正确的执行Simon给出的指令为止。应用程序定义了一个枚举变量来描述游戏所有可能的状态,以及定义了一个变量来跟踪游戏这当前所执行了的指令位置。另外我们需要一个变量来描述游戏者成功的次数或者游戏等级。当游戏者成功的执行了Simon给出的指令后,这个变量加1。下面的代码展示了这个枚举以及变量,变量的初始化在类的够着函数中执行。

当应用程序探测不到骨骼数据时,游戏会终止,并切换到Game Over阶段。当游戏者离开Kinect视野时会发生这种情况。当游戏处在Simon给出操作步骤阶段时,隐藏界面上的手势图标。否则,更新这两个图标的位置。当游戏处在其它状态时,程序基于当前特定的游戏阶段调用特定的处理方法。


开始新游戏

当游戏处在GameOver阶段时,应用程序只调用了一个方法:该方法判断用户是否想玩游戏。当用户将相应的手放在UI界面上手势所处的位置时,游戏开始。左右手需要分别放在LeftHandStartElement和RightHandStartElement所处的位置内。在这个例子中,我们使用WPF自带的命中测试功能。我们的UI界面很小也很简单。InputHitTest操作所需要处理的UI元素很少,因此性能上没有太大问题。

private void ProcessGameOver(Skeleton skeleton)
        {
            //判断用户是否想开始新的游戏
            if (HitTest(skeleton.Joints[JointType.HandLeft], LeftHandStartElement) && HitTest(skeleton.Joints[JointType.HandRight], RightHandStartElement))
            {
                ChangePhase(GamePhase.SimonInstructing);
            }
        }

        private bool HitTest(Joint joint, UIElement target)
        {
            return (GetHitTarget(joint, target) != null);
        }

        private IInputElement GetHitTarget(Joint joint, UIElement target)
        {
            Point targetPoint = LayoutRoot.TranslatePoint(GetJointPoint(this.KinectDevice, joint, LayoutRoot.RenderSize, new Point()), target);
            return target.InputHitTest(targetPoint);    // 返回指定坐标上的当前元素中的输入元素(相对于当前元素的源)
        }

ProcessGameOver方法的逻辑简单明了:如果游戏者的任何一只手在UI界面上的对应位置,就切换当前游戏所处的状态。GetHitTarget方法用来测试给定的关节点是否在可视化控件有效范围内。他接受关节点数据和可视化控件,返回该点所在的特定的IInputElement对象。

命中测试算法包含三个步骤,首先需要将关节点所在的骨骼空间坐标系中坐标转换到对应的LayoutRoot元素所在的空间坐标中来。GetJointPoint实现了这个功能。其次,使用UIElement类中的TranslatePoint方法将关节点从LayoutRoot元素所在的空间坐标转换到目标元素所在的空间坐标中。最后,点和目标元素在一个坐标空间之后,调用目标元素的InputHitTest方法,方法返回目标对象树中,点所在的确切的UI元素,任何非空值都表示命中测试成功。

    注意到逻辑之所以这么简单是因为我们采用的UI布局方式,应用程序假定全屏运行并且不能调整大小。将UI界面设置为静态的,确定大小能够极大的简化计算量。另外,将所有的可交互的UI元素放在Canvas容器内使得我们只有一个坐标空间。使用其他容器空间来包含元素或者使用诸如HorizonAlignment,VerticalAlignment或者Margin这些自动布局属性会增加命中测试的复杂性。简言之,越是复杂的UI布局,命中测试的逻辑越复杂,也越会影响程序的性能。


更改游戏状态

private void ChangePhase(GamePhase newPhase)
{
    if (newPhase != this.currentPhase)
    {
        this.currentPhase = newPhase;

        switch (this.currentPhase)
        {
            case GamePhase.GameOver:
                this.currentLevel = 0;
                RedBlock.Opacity = 0.2;
                BlueBlock.Opacity = 0.2;
                GreenBlock.Opacity = 0.2;
                YellowBlock.Opacity = 0.2;

                GameStateElement.Text = "GAME OVER!";
                ControlCanvas.Visibility = Visibility.Visible;
                GameInstructionsElement.Text = "将手放在对象上开始新的游戏。";
                break;

            case GamePhase.SimonInstructing:
                this.currentLevel++;
                GameStateElement.Text = string.Format("Level {0}", this.currentLevel);
                ControlCanvas.Visibility = Visibility.Collapsed;
                GameInstructionsElement.Text = "注意观察Simon的指示。";
                GenerateInstructions();
                DisplayInstructions();
                break;

            case GamePhase.PlayerPerforming:
                this.instructionPosition = 0;
                GameInstructionsElement.Text = "请重复 Simon的指示";
                break;
        }
    }
}

在GameOver状态时,矩形框会渐变消失,然后改变操作指示,显示按钮来开始一个新的游戏。SimonInStructing状态不在更新UI界面讨论范围内,他调用了两个方法,用来产生指令集合 (GenerateInstructions),并将这些指令显示到UI界面上(DisplayInstructions),代码中也定义了instructionPosition变量,来维护当前所完成的指令步骤。


显示Simon的指令

instructionSequence变量用来存储一系列的UIElements对象,这些对象组成了Simon的指令集合。游戏者必须用手依次移动到这些指令上。这些指令的顺序是随机设定的。每一关指令的个数和当前等级是一样的。比如,到了第五关,就有5个指令。代码也显示了DisplayInstruction方法,他创建并触发了一个故事板动画效果来根据指令的顺序来改变每一个矩形的透明度。


执行 Simon 的指令

注意到当故事版动画完成了显示Simon的指令后,程序调用ChangePhase方法使游戏进入PlayerPerforming阶段。当在PlayerPerforming阶段时,应用程序执行ProcessPlayerPerforming方法。表面上,实现该方法很简单。逻辑是游戏者重复Simon给出的操作步骤,将手放在对应矩形上方。这和之前做的命中测试逻辑是一样的。但是,和测试两个静态的UI对象不同,我们测试指令集合中的下一个指令对应的元素。

如果两只手都在UI元素上,游戏结束。我们的游戏很简单,只能允许一次点击一个矩形。当两只手都不在UI元素上时,什么都不做。如果一只手命中了期望的对象,我们就把当前指令步骤加1。当指令集合中还有其他指令时游戏继续运行,直到完成了指令集合中的最后一个指令。当完成了最后一个指令后,游戏状态又变为了SimonInstruction状态,然后将游戏者带入下一轮游戏。直到游戏者不能重复Simon指令而进入GameOver状态。

    如果游戏者动作够快,那么上面代码工作正常,因为只要用户手进入到了可视化元素有效区域,那么指令位置就会自增,游戏者在进入到下一个指令之前,没有时间来从UI元素所在的空间上移除手。这么快的速度不可能使得游戏者能够闯过第二关。当游戏者成功的闯过第二关的指令后,游戏就会突然停止。

解决这个问题的办法是在进入到下一个指令前等待,直到游戏者的手势从UI界面上清除。这使得游戏者有机会能够调整手势的位置开始进入下一条指令,我们需要记录用户的手什么时候进入和离开UI对象。

    在WPF中,每一个UIElement对象都会在鼠标进入和离开其有效范围内时触发MouseEnter和MouseLeave事件。不幸的是,如前面所讨论的,WPF本身并不支持Kinect产生的关节点数据和UI的直接交互,如果当关节点进入或者离开可视化元素时能够触发诸如JointEnter和JointLeave事件,那么就简单多了。既然不支持,那么我们只有自己手动实现这个逻辑了。要实现一个可重用,优雅,并能像鼠标那样能够在底层追踪关节点运动这样的控件不太容易并且不容易做成通用的。我们只针对我们当前遇到的问题来实现这个功能。

    要修正游戏中的这个问题比较容易。我们添加一系列成员变量来保存UI元素上的哪一个鼠标手势最后悬停在上面。当用户的手经过UI元素的上方时,更新这个变量。对于每一个新的骨骼数据帧。我们检查游戏者手的位置,如果它离开了UI元素空间,那么我们处理这个UI元素。

private IInputElement leftHandTarget;
private IInputElement rightHandTarget;
private void ProcessPlayerPerforming(Skeleton skeleton)
{
    //判断用户是否手势是否在目标对象上面,且在指定中的正确顺序
    UIElement correctTarget = this.instructionSequence[this.instructionPosition];
    IInputElement leftTarget = GetHitTarget(skeleton.Joints[JointType.HandLeft], GameCanvas);
    IInputElement rightTarget = GetHitTarget(skeleton.Joints[JointType.HandRight], GameCanvas);
    bool hasTargetChange = (leftTarget != this.leftHandTarget) || (rightTarget != this.rightHandTarget);

    if (hasTargetChange)
    {
        if (leftTarget != null && rightTarget != null)
        {
            ChangePhase(GamePhase.GameOver);
        }
        else if ((leftHandTarget == correctTarget && rightHandTarget == null) ||
                (rightHandTarget == correctTarget && leftHandTarget == null))
        {
            this.instructionPosition++;

            if (this.instructionPosition >= this.instructionSequence.Length)
            {
                ChangePhase(GamePhase.SimonInstructing);
            }
        }
        else if (leftTarget != null || rightTarget != null)
        {
            //Do nothing - target found
        }
        else
        {
            ChangePhase(GamePhase.GameOver);
        }

        if (leftTarget != this.leftHandTarget)
        {
            if (this.leftHandTarget != null)
            {
                ((FrameworkElement)this.leftHandTarget).Opacity = 0.2;
            }

            if (leftTarget != null)
            {
                ((FrameworkElement)leftTarget).Opacity = 1;
            }

            this.leftHandTarget = leftTarget;
        }

        if (rightTarget != this.rightHandTarget)
        {
            if (this.rightHandTarget != null)
            {
                ((FrameworkElement)this.rightHandTarget).Opacity = 0.2;
            }

            if (rightTarget != null)
            {
                ((FrameworkElement)rightTarget).Opacity = 1;
            }

            this.rightHandTarget = rightTarget;
        }
    }
}

需要改进:

基于Kinect的应用程序和游戏比较新颖,在这种应用达到成熟前,要想获得良好的用户体验需要进行很多实验和测试。我们的Simon Say游戏的用户界面就有很多值得改进的地方。Simon Say的游戏者可能会意外的触摸到游戏的区间。游戏时在游戏开始的时候,有可能会碰到开始按钮。一旦两只手都在指定的区间,游戏就开始产生指令,如果用户没有及时的放开手,他可能会无意识的碰到一个游戏对象。一个有效的解决方法是在产生指令之前,给予用户一定的时间让其重新设置手的位置。因为人们会自然而然的将手垂在身体两边。一个比较好的变通方法是简单的给一个倒计时。在不同的关卡间,也可以给这样一个时间间隔。在开始新的一关时,用户应该有时间来从可视化元素中移开手。

产生游戏指令序列的逻辑比较简单。指令序列中指令的数目和当前的关卡是一致的。每一条指令所选择的可视化元素是随机选择的。在原始的Simon Say游戏中,新一轮的游戏通常会添加一些新的指令。例如,第一关中有红的,第二关中有红的和蓝的,第三关增加了绿的。因此在第三关指令可以是,红绿蓝。另一种改进可以不在每一关增加一个指令。而是将指令的个数设置为当前关卡数的2倍。软件开发一个有趣的地方就是应用程序可以有多种产生指令序列的算法。例如,应用程序可以分为容易,中等,难三种产生指令序列的方法供用户选择。最基本的产生指令序列的逻辑就是每一关要尽可能的比前一关要长,并且指令显示速度要以一个常量的速度显示。要增加游戏的难度,在显示指令序列时可以减少指令展示给用户的时间。

创建一个赋予表现力的程序远不止我们这里所介绍的这些内容。可能做一点改动就可以将我们的UI做的更加好看,比如,可以在显示指令提示,以及用户移入和离开指定区域时可以采用一些比较好看的动画。当用户执行的指令正确时,可以展现一个动画效果给予奖励。同样的,在游戏结束时也可以展现出一个动画。


namespace SimpleSay
{
    public enum GamePhase
    {
        GameOver = 0,
        SimonInstructing = 1,
        PlayerPerforming = 2
    }

    /// <summary>
    /// MainWindow.xaml 的交互逻辑
    /// </summary>
    /// 

    public partial class MainWindow : Window
    {

        private KinectSensor kinectDevice;
        private Skeleton[] frameSkeletons;
        private GamePhase currentPhase; // 所处的当前游戏的状态
        private UIElement[] instructionSequence;
        private int instructionPosition;    // 
        private int currentLevel;   // 描述游戏者成功的次数或者游戏等级
        private Random rnd = new Random();
        private IInputElement leftHandTarget;
        private IInputElement rightHandTarget;


        public KinectSensor KinectDevice
        {
            get { return this.kinectDevice; }
            set
            {
                if (this.kinectDevice!=null)
                {
                    // Uninitialize
                    this.kinectDevice.Stop();
                    this.kinectDevice.SkeletonFrameReady -= KinectDevice_SkeletonFrameReady;
                    this.kinectDevice.SkeletonStream.Disable();
                    this.frameSkeletons = null;
                }

                this.kinectDevice = value;
                if (this.kinectDevice!=null)
                {
                    // Initialize
                    if (this.kinectDevice.Status == KinectStatus.Connected)
                    {
                        this.kinectDevice.SkeletonStream.Enable();
                        this.frameSkeletons = new Skeleton[this.kinectDevice.SkeletonStream.FrameSkeletonArrayLength];

                        this.kinectDevice.Start();

                        // SkeletonViewerElement.KinectDevice = this.kinectDevice;  // 属性依赖
                        this.kinectDevice.SkeletonFrameReady += KinectDevice_SkeletonFrameReady;
                    }
                }
            }
        }
        
        public MainWindow()
        {
            InitializeComponent();
            KinectSensor.KinectSensors.StatusChanged += KinectSensors_StatusChanged;
            this.KinectDevice = KinectSensor.KinectSensors.FirstOrDefault(x => x.Status == KinectStatus.Connected);

            ChangePhase(GamePhase.GameOver);
            this.currentLevel = 0;
        }

        private void KinectSensors_StatusChanged(Object sender, StatusChangedEventArgs e)
        {
            switch (e.Status)
            {
                case KinectStatus.Initializing:
                case KinectStatus.Connected:
                case KinectStatus.NotPowered:
                case KinectStatus.NotReady:
                case KinectStatus.DeviceNotGenuine:
                    this.KinectDevice = e.Sensor;
                    break;
                case KinectStatus.Disconnected:
                    //TODO: Give the user feedback to plug-in a Kinect device.                    
                    this.KinectDevice = null;
                    break;
                default:
                    //TODO: Show an error state
                    break;
            }
        }

        private void KinectDevice_SkeletonFrameReady(Object sender, SkeletonFrameReadyEventArgs e)
        {
            using (SkeletonFrame frame = e.OpenSkeletonFrame())
            {
                frame.CopySkeletonDataTo(this.frameSkeletons);
                Skeleton skeleton = GetPrimarySkeleton(this.frameSkeletons);

                if (skeleton == null)
                {
                    ChangePhase(GamePhase.GameOver);
                }
                else
                {
                    if (this.currentPhase == GamePhase.SimonInstructing)
                    {
                        LeftHandElement.Visibility = Visibility.Collapsed;
                        RightHandElement.Visibility = Visibility.Collapsed;
                    }
                    else
                    {
                        TrackHand(skeleton.Joints[JointType.HandLeft], LeftHandElement, LayoutRoot);
                        TrackHand(skeleton.Joints[JointType.HandRight], RightHandElement, LayoutRoot);

                        switch (this.currentPhase)
                        {
                            case GamePhase.GameOver:
                                ProcessGameOver(skeleton);
                                break;
                            case GamePhase.PlayerPerforming:
                                ProcessPlayerPerforming(skeleton);
                                break;
                        }
                    }
                }
            }
            
        }

        private void TrackHand(Joint hand, FrameworkElement cursorElement, FrameworkElement container)
        {
            if (hand.TrackingState == JointTrackingState.NotTracked)
            {
                cursorElement.Visibility = Visibility.Collapsed;    // 不显示元素,且不为其保留布局空间
            }
            else
            {
                cursorElement.Visibility = Visibility.Visible;
                Point jointPoint = GetJointPoint(this.KinectDevice, hand, container.RenderSize, new Point(cursorElement.ActualWidth / 2.0, cursorElement.ActualHeight / 2.0));
                Canvas.SetLeft(cursorElement, jointPoint.X);
                Canvas.SetTop(cursorElement, jointPoint.Y);
            }
        }
    
        private void ProcessGameOver(Skeleton skeleton)
        {
            //判断用户是否想开始新的游戏
            if (HitTest(skeleton.Joints[JointType.HandLeft], LeftHandStartElement) && HitTest(skeleton.Joints[JointType.HandRight], RightHandStartElement))
            {
                ChangePhase(GamePhase.SimonInstructing);
            }
        }

        private bool HitTest(Joint joint, UIElement target)
        {
            return (GetHitTarget(joint, target) != null);
        }

        private IInputElement GetHitTarget(Joint joint, UIElement target)
        {
            Point targetPoint = LayoutRoot.TranslatePoint(GetJointPoint(this.KinectDevice, joint, LayoutRoot.RenderSize, new Point()), target);
            return target.InputHitTest(targetPoint);    // 返回指定坐标上的当前元素中的输入元素(相对于当前元素的源)
        }

        private static Skeleton GetPrimarySkeleton(Skeleton[] skeletons)
        {
            Skeleton skeleton = null;

            if (skeletons != null)
            {
                //Find the closest skeleton       
                for (int i = 0; i < skeletons.Length; i++)
                {
                    if (skeletons[i].TrackingState == SkeletonTrackingState.Tracked)
                    {
                        if (skeleton == null)
                        {
                            skeleton = skeletons[i];
                        }
                        else
                        {
                            if (skeleton.Position.Z > skeletons[i].Position.Z)
                            {
                                skeleton = skeletons[i];
                            }
                        }
                    }
                }
            }

            return skeleton;
        }

        private static Point GetJointPoint(KinectSensor kinectDevice, Joint joint, Size containerSize, Point offset)
        {
            DepthImagePoint point = kinectDevice.MapSkeletonPointToDepth(joint.Position, kinectDevice.DepthStream.Format);
            point.X = (int)((point.X * containerSize.Width / kinectDevice.DepthStream.FrameWidth) - offset.X);
            point.Y = (int)((point.Y * containerSize.Height / kinectDevice.DepthStream.FrameHeight) - offset.Y);

            return new Point(point.X, point.Y);
        }

        private void ChangePhase(GamePhase newPhase)
        {
            if (newPhase != this.currentPhase)
            {
                this.currentPhase = newPhase;

                switch (this.currentPhase)
                {
                    case GamePhase.GameOver:
                        this.currentLevel = 0;
                        RedBlock.Opacity = 0.2;
                        BlueBlock.Opacity = 0.2;
                        GreenBlock.Opacity = 0.2;
                        YellowBlock.Opacity = 0.2;

                        GameStateElement.Text = "GAME OVER!";
                        ControlCanvas.Visibility = Visibility.Visible;
                        GameInstructionsElement.Text = "将手放在对象上开始新的游戏。";
                        break;

                    case GamePhase.SimonInstructing:
                        this.currentLevel++;
                        GameStateElement.Text = string.Format("Level {0}", this.currentLevel);
                        ControlCanvas.Visibility = Visibility.Collapsed;
                        GameInstructionsElement.Text = "注意观察Simon的指示。";
                        GenerateInstructions();
                        DisplayInstructions();
                        break;

                    case GamePhase.PlayerPerforming:
                        this.instructionPosition = 0;
                        GameInstructionsElement.Text = "请重复 Simon的指示";
                        break;
                }
            }
        }

        private void GenerateInstructions()
        {
            this.instructionSequence = new UIElement[this.currentLevel];

            for (int i = 0; i < this.currentLevel; i++)
            {
                switch (rnd.Next(1, 4))
                {
                    case 1:
                        this.instructionSequence[i] = RedBlock;
                        break;

                    case 2:
                        this.instructionSequence[i] = BlueBlock;
                        break;

                    case 3:
                        this.instructionSequence[i] = GreenBlock;
                        break;

                    case 4:
                        this.instructionSequence[i] = YellowBlock;
                        break;
                }
            }
        }

        private void DisplayInstructions()
        {

                // Storyboard 为容器的子动画提供对象和属性目标信息的容器时间线。
            Storyboard instructionsSequence = new Storyboard();
            DoubleAnimationUsingKeyFrames animation;    // 对一组 KeyFrames 中的 Double 属性的值进行动画处理

            for (int i = 0; i < this.instructionSequence.Length; i++)
            {
                this.instructionSequence[i].ApplyAnimationClock(FrameworkElement.OpacityProperty, null);

                animation = new DoubleAnimationUsingKeyFrames();
                animation.FillBehavior = FillBehavior.Stop;
                animation.BeginTime = TimeSpan.FromMilliseconds(i * 1500);
                Storyboard.SetTarget(animation, this.instructionSequence[i]);
                Storyboard.SetTargetProperty(animation, new PropertyPath("Opacity"));
                instructionsSequence.Children.Add(animation);

                animation.KeyFrames.Add(new EasingDoubleKeyFrame(0.3, KeyTime.FromTimeSpan(TimeSpan.Zero)));
                animation.KeyFrames.Add(new EasingDoubleKeyFrame(1, KeyTime.FromTimeSpan(TimeSpan.FromMilliseconds(500))));
                animation.KeyFrames.Add(new EasingDoubleKeyFrame(1, KeyTime.FromTimeSpan(TimeSpan.FromMilliseconds(1000))));
                animation.KeyFrames.Add(new EasingDoubleKeyFrame(0.3, KeyTime.FromTimeSpan(TimeSpan.FromMilliseconds(1300))));
            }


            instructionsSequence.Completed += (s, e) => { ChangePhase(GamePhase.PlayerPerforming); };
            instructionsSequence.Begin(LayoutRoot);
        }

        private void ProcessPlayerPerforming(Skeleton skeleton)
        {
            //判断用户是否手势是否在目标对象上面,且在指定中的正确顺序
            UIElement correctTarget = this.instructionSequence[this.instructionPosition];
            IInputElement leftTarget = GetHitTarget(skeleton.Joints[JointType.HandLeft], GameCanvas);
            IInputElement rightTarget = GetHitTarget(skeleton.Joints[JointType.HandRight], GameCanvas);
            bool hasTargetChange = (leftTarget != this.leftHandTarget) || (rightTarget != this.rightHandTarget);


            if (hasTargetChange)
            {
                if (leftTarget != null && rightTarget != null)
                {
                    ChangePhase(GamePhase.GameOver);
                }
                else if ((leftHandTarget == correctTarget && rightHandTarget == null) ||
                        (rightHandTarget == correctTarget && leftHandTarget == null))
                {
                    this.instructionPosition++;

                    if (this.instructionPosition >= this.instructionSequence.Length)
                    {
                        ChangePhase(GamePhase.SimonInstructing);
                    }
                }
                else if (leftTarget != null || rightTarget != null)
                {
                    //Do nothing - target found
                }
                else
                {
                    ChangePhase(GamePhase.GameOver);
                }


                if (leftTarget != this.leftHandTarget)
                {
                    if (this.leftHandTarget != null)
                    {
                        ((FrameworkElement)this.leftHandTarget).Opacity = 0.2;
                    }

                    if (leftTarget != null)
                    {
                        ((FrameworkElement)leftTarget).Opacity = 1;
                    }

                    this.leftHandTarget = leftTarget;
                }


                if (rightTarget != this.rightHandTarget)
                {
                    if (this.rightHandTarget != null)
                    {
                        ((FrameworkElement)this.rightHandTarget).Opacity = 0.2;
                    }

                    if (rightTarget != null)
                    {
                        ((FrameworkElement)rightTarget).Opacity = 1;
                    }

                    this.rightHandTarget = rightTarget;
                }
            }
        }
    
    }
    
}
原文地址:https://www.cnblogs.com/sprint1989/p/3844429.html