UI 前沿技术

XNA 颜色滚动程序

Charles Petzold

下载代码示例

image: Charles Petzold
我最早编写并发布的 Windows 程序之一名为 COLORSCR(“颜色滚动程序”),发表在本杂志的前身 Microsoft Systems Journal 的 1987 年 5 月号中。

很多年过去了,我时常发现针对其他 API 和框架重写此程序很有意义。尽管这个程序很简单(操控红、绿、蓝三种颜色值对应的滚动条或滑块来创建自定义的颜色),但它涉及很多重要的任务,例如布局和事件处理。而且从功能角度而言,该程序也不是简单的、无意义的演示程序。如果您要创建一个用来选择颜色的对话框,这款程序将是很好的起点。

本文将为您介绍如何使用 Windows Phone 7 中的 XNA Framework 编写颜色滚动程序。结果如图 1 所示。

图 1 Xnacolorscroll 程序

XNA 是用于游戏编程的基于 Microsoft .NET Framework 的托管代码,但如果您搜索 Microsoft.Xna.Framework 命名空间,您会发现在各个类中都找不到滚动条或滑块,甚至也找不到按钮!XNA 并不是那种类型的框架。如果您的程序中需要滑块,必须专门创建。

不管怎样,为您介绍如何编写 XNA 颜色滚动程序不是简单的学术练习。我的目的更加宽泛一些。

如您所知,Windows Phone 7 既支持 Silverlight 也支持 XNA,一般来说要选择哪种技术来创建特定应用程序是很明显的。但是,您会发现应用程序在图形方面的需求已超出了 Silverlight 力所能及的范围,但您又会犹豫不决是否该改用 XNA,因为它缺少您熟悉的元素,例如按钮和滑块。

我的回答是:不要怕。在 XNA 中编写简单的控件其实比您预想的要简单。

您也可以将本文视作针对 Windows Presentation Foundation (WPF) 和 Silverlight 程序员的 XNA 编程介绍。我发现我在这项工作中使用了一些来自 WPF 和 Silverlight 的概念,因此 XNA 颜色滚动程序是一项博采众家之长的成果。

所有可下载的源代码都包含在名为 XnaColorScroll 的 Visual Studio 解决方案中。此解决方案包括针对 Windows Phone 7 的 XnaColorScroll 项目和一个名为 Petzold.Xna.Controls 的库,库中包含我为此程序创建的四个 XNA“控件”。(显然,要将解决方案加载到 Visual Studio 中,您必须先安装 Windows Phone 开发工具。)不用担心,您可以拿走这些控件,放入您自己的 XNA 控件库中。

游戏和组件

与 WPF 的主类一样,Silverlight 或 Windows 窗体程序是派生自 Window、Page 或 Form 的类,而 XNA 程序的主类派生自 Game。XNA 程序使用此 Game 派生类在屏幕上绘制位图、文字和 3D 图形。

为了帮助您按照独立的功能实体来组织程序,XNA 支持组件 的概念,这可能是 XNA 中最接近控件的概念。组件有两种:GameComponent 派生类和 DrawableGameComponent 派生类。DrawableGameComponent 本身派生自 GameComponent,而其名称则表明了与 GameComponent 的不同之处。我在这个练习中将使用这两个基类。

Game 类定义了一个名为 Components、类型为 GameComponentCollection 的属性。游戏创建的所有组件都必须加入此集合中。这样可以确保组件获得游戏本身获得的对各种重写方法的相同调用。DrawableGameComponent 在 Game 绘制内容之前进行绘制。

让我们开始分析 XnaColorScroll 程序,不是使用 Game 派生类,而是使用 GameComponent 派生类。图 2 显示了一个名为 TextBlock 的组件的源代码,其使用方法与 WPF 或 Silverlight TextBlock 基本相同。

图 2 TextBlock 游戏组件

public class TextBlock : DrawableGameComponent
{
  SpriteBatch spriteBatch;
  Vector2 textOrigin;

  public TextBlock(Game game) : base (game)
  {
    // Initialize properties
    this.Color = Color.Black;
  }

  public string Text { set; get; }
  public SpriteFont Font { set; get; }
  public Color Color { set; get; }
  public Vector2 Position { set; get; }
  public HorizontalAlignment HorizontalAlignment { set; get; }
  public VerticalAlignment VerticalAlignment { set; get; }

  public Vector2 Size
  {
    get
    {
      if (String.IsNullOrEmpty(this.Text) || this.Font == null)
          return Vector2.Zero;

      return this.Font.MeasureString(this.Text);
    }
  }

  protected override void LoadContent()
  {
    spriteBatch = new SpriteBatch(this.GraphicsDevice);
    base.LoadContent();
  }

  public override void Update(GameTime gameTime)
  {
    if (!String.IsNullOrEmpty(Text) && Font != null)
    {
      Vector2 textSize = this.Size;
      float xOrigin = (int)this.HorizontalAlignment * textSize.X / 2;
      float yOrigin = (int)this.VerticalAlignment * textSize.Y / 2;
      textOrigin = new Vector2((int)xOrigin, (int)yOrigin);
    }
    base.Update(gameTime);
  }

  public override void Draw(GameTime gameTime)
  {
    if (!String.IsNullOrEmpty(Text) && Font != null)
    {
      spriteBatch.Begin();
      spriteBatch.DrawString(this.Font, this.Text, this.Position, this.Color,
                            0, textOrigin, 1, SpriteEffects.None, 0);
      spriteBatch.End();
    }
    base.Draw(gameTime);
  }
}

请注意图 2 中构造函数后面的公共属性。 它们表示文本本身,以及字体、颜色和文本相对于屏幕的位置。 另外两个属性 HorizontalAlignment 和 VerticalAlignment 指示该位置引用文字字符串的侧边或中心。 在 XnaColorScroll 中,利用这些属性可以方便地使文字相对滑块居中。

这个类还包括一个只读的 Size 属性,返回使用特定字体呈现的文字的大小。 (XNA Vector2 结构经常用于代表二维的位置和大小,以及矢量。)

TextBlock 组件重写 Update 和 Draw 方法,这对可绘制组件来说很常见,对于 Game 派生类也很常见。 这两个方法构成了程序的“游戏循环”,它们被调用的频率与视频显示相同,在 Windows Phone 7 中是每秒 30 次。 您可以将 Draw 方法看作 WM_PAINT 消息或 OnPaint 重写,不同之处在于每次屏幕刷新时都会调用它。 Update 方法在每次 Draw 调用之前调用,其目的是为 Draw 方法执行所有必要的计算和准备。

这样的 TextBlock 有很大的性能提升空间。 例如,它可以缓存 Size 属性的值或者只是重新计算 textOrigin 字段(只要对其有影响的属性有所更改)。 如果您发现由于程序中所有 Update 和 Draw 调用的执行都超过 0.03 秒,而导致程序变慢,那么这些增强就是您应该优先考虑的。

组件和子组件

图 1 所示,XnaColorScroll 有六个 TextBlock 组件,还有三个滑块,这可能会带来一些难度。

很多年之前,我会尝试将整个滑块的代码放入一个类中。 但是,根据我对 WPF 和 Silverlight 控件模板的经验,我意识到构造 Slider 控件的最好方法可能是组合 Thumb 控件和 RepeatButton 控件。 因此尝试从类似的子组件创建一个 XNA Slider 组件应该是合理的。

Game 类通过向其 GameComponentCollection 类型的 Components 属性添加子组件而为自己添加了子组件。 现在,大问题来了:GameComponent 有针对其子组件的 Components 属性吗?

遗憾的是,答案是否定的。 但是,组件可以访问它的父 Game 类,而且如果组件需要实例化自己的子组件,它可以向 Game 类的同一个 Components 集合添加那些组件。 按照正确的顺序操作,您甚至无需弄乱 DrawOrder 属性(由 DrawableGameComponent 定义),该属性可以帮助控制呈现组件的顺序。 默认情况下,会首先调用 Game 中的 Draw 方法,然后按照所有 Draw 方法出现在 Components 集合中的顺序调用它们。

现在让我们开始。 和 TextBlock 一样,Thumb 类派生自 DrawableGameComponent。 完整的 Thumb 类(不包括触摸处理)如图 3 所示。 Thumb 定义的三个事件与 WPF 和 Silverlight Thumb 控件的一样。

图 3 Thumb 类的大部分内容

public class Thumb : DrawableGameComponent
{
  SpriteBatch spriteBatch;
  Texture2D tinyTexture;
  int?
touchId;

  public event EventHandler<DragEventArgs> DragStarted;
  public event EventHandler<DragEventArgs> DragDelta;
  public event EventHandler<DragEventArgs> DragCompleted;

  public Thumb(Game game) : base(game)
  {
    // Initialize properties
    this.Color = Color.White;
  }

  public Rectangle Position { set; get; }
  public Color Color { set; get; }

  protected override void LoadContent()
  {
    spriteBatch = new SpriteBatch(this.GraphicsDevice);
    tinyTexture = new Texture2D(this.GraphicsDevice, 1, 1);
    tinyTexture.SetData<Color>(new Color[] { Color.White });

    base.LoadContent();
  }

  ...
public override void Draw(GameTime gameTime)
  {
    spriteBatch.Begin();
    spriteBatch.Draw(tinyTexture, this.Position, this.Color);
    spriteBatch.End();

    base.Draw(gameTime);
  }
}

图 2 的 TextBlock 组件中,Position 属性是 Vector2 类型的点;在 Thumb 中,Position 属性是 Rectangle 对象,不仅定义 Thumb 的位置,还定义它的矩形大小。 Thumb 的外观完全由白色的 1 x 1 像素位图构成,按照 Position 属性规定的位置和大小显示。 (使用延展为特定大小的单像素位图是我喜欢的 XNA 编程技巧之一。)

Thumb 可处理触摸输入,但自身并不移动。 Slider 组件负责处理来自 Thumb 的 DragDelta 事件,以及设置新的 Position 属性。

除了 Thumb,我的 Slider 还包含两个 RepeatButton 组件,分别位于 Thumb 的两端。 与 WPF 和 Silverlight RepeatButton 控件一样,如果您按住它们,这些组件就生成一系列 Click 事件。

在我的 XNA Slider 中,RepeatButton 组件在屏幕上占据一定的区域,但实际上是不可见的。 (延伸到中心的细竖条是由 Slider 绘制的。)这表示 RepeatButton 可以派生自 GameComponent 而不是 DrawableGameComponent。 图 4 显示了完整的 RepeatButton 控件(不包括触摸处理)。

图 4 RepeatButton 类的大部分内容

public class RepeatButton : GameComponent
{
  static readonly TimeSpan REPEAT_DELAY = TimeSpan.FromMilliseconds(500);
  static readonly TimeSpan REPEAT_TIME = TimeSpan.FromMilliseconds(100);

  int?
touchId;
  bool delayExceeded;
  TimeSpan clickTime; 

  public event EventHandler Click;

  public RepeatButton(Game game) : base(game)
  {
  }

  public Rectangle Position { set; get; }

  ...
}

此时,我们已经可以构建 Slider 组件。 为了简化起见,我决定采用竖直方向。 Slider 具有常见的公共属性:Minimum、Maximum 和 Value 等等,还定义了一个 ValueChanged 事件。 在其 Initialize 重写中,Slider 创建三个子组件(并保存为字段),设置事件处理程序并将它们添加到其父 Game 类(可以通过其 Game 属性访问)的 Components 集合中:

public override void Initialize()
{
  thumb = new Thumb(this.Game);
  thumb.DragDelta += OnThumbDragDelta;
  this.Game.Components.Add(thumb);

  btnDecrease = new RepeatButton(this.Game);
  btnDecrease.Click += OnRepeatButtonClick;
  this.Game.Components.Add(btnDecrease);

  btnIncrease = new RepeatButton(this.Game);
  btnIncrease.Click += OnRepeatButtonClick;
  this.Game.Components.Add(btnIncrease);

  base.Initialize();
}

XNA Game 派生类和 GameComponent 派生类有三种方法可以执行初始化:类构造函数、重写 Initialize 方法和重写 LoadContent 方法。但何时使用哪种方法则有点令人困惑。 顾名思义,LoadContent 对于加载内容(例如字体或位图)很有用,应该用于所有其他依赖于 GraphicsDevice 对象的初始化情况。

我喜欢创建组件并在 Initialize 重写过程中添加到 Components 集合中,就如前文的代码片段所示。 在 XnaColorScroll 中,Game 派生类也在其 Initialize 重写中创建六个 TextBlock 组件和三个 Slider 组件。 如果最终调用基类中的 Initialize 方法,则子组件的所有 Initialize 方法也将被调用。 然后每个 Slider 中的 Initialize 方法创建 Thumb 和两个 RepeatButton 组件。 这种架构确保所有这些组件都按照呈现所需的正确顺序添加到 Components 集合中。

处理触摸输入

在大部分 Windows Phone 7 程序中,用户输入的主要方式是多点触摸,XNA 也不例外。 (XNA 程序也支持键盘输入,而且可以使用电话的加速感应器作为输入设备。)

XNA 程序在 Update 重写期间获得触摸输入。 不存在触摸事件。 所有触摸输入均被轮询,并可通过 TouchPanel 类访问。 静态的 TouchPanel.GetState 方法提供低级的触摸输入,由指示 Pressed、Moved 和 Released 活动以及位置信息的 TouchLocation 对象以及用于区分多个手指的整数 ID 数字组成。 TouchPanel.ReadGesture 方法(我在此程序中未使用)提供高级的手势支持。

当我最早接触游戏组件时,我认为每个组件可以独立获得触摸输入,获取所需内容并忽略其他内容。 但这样做的效果并不好,似乎游戏类和组件都竞相争用触摸输入,而不是友好地共享。

我决定开发其他系统来处理触摸输入。 只有 Game 类调用 TouchPanel.GetState。 然后每个 TouchLocation 对象都通过我称之为 ProcessTouch 的方法传递到子组件。 这样一来,组件就获得输入的优先处理权。 利用 TouchLocation 对象的组件从 ProcessTouch 方法返回 True,该 TouchLocation 对象的处理就结束。 (从 ProcessTouch 返回 True 与在 WPF 或 Silverlight 中将 RoutedEventArgs 的 Handled 属性设置为 True 类似。)

在 XnaColorScroll 中,Game 主类中的 Update 方法调用 TouchPanel.GetState 获得所有触摸输入,然后调用每个 Slider 组件中的 ProcessTouch 方法,如下所示:

TouchCollection touches = TouchPanel.GetState();

foreach (TouchLocation touch in touches)
{
  for (int primary = 0; primary < 3; primary++)
    if (sliders[primary].ProcessTouch(gameTime, touch))
      break;
}

请注意,每次 ProcessTouch 返回 True 时,就会停止对每个 TouchLocation 对象的进一步处理。

然后,每个 Slider 控件中的 ProcessTouch 方法调用两个 RepeatButton 组件和 Thumb 组件中的 ProcessTouch 方法:

public bool ProcessTouch(GameTime gameTime, TouchLocation touch)
{
  if (btnIncrease.ProcessTouch(gameTime, touch))
    return true;

  if (btnDecrease.ProcessTouch(gameTime, touch))
    return true;

  if (thumb.ProcessTouch(gameTime, touch))
    return true;

  return false;
}

如果这些组件中的任意一个(或者 Game 类本身)希望执行它自己的触摸处理,它将在每个子组件中先调用 ProcessTouch。 然后会检查子组件还未处理的 TouchLocation 对象是否可以由类自己使用。 实际上,这样的架构使得视觉上位于最前方的子组件获得对触摸输入的优先访问,而这正是您需要的。 这很像在 WPF 和 Silverlight 中实施的路由事件处理。

如果手指首先触摸到 RepeatButton 或 Thumb 组件的 Position 矩形指示的区域内,这两个组件都会处理触摸输入。 组件会通过保存触摸 ID 来“捕获”该手指。 具有该 ID 的所有其他触摸输入都将属于这个组件,直至手指松开。

传播事件

Thumb 和 RepeatButton 组件的触摸处理正是 Slider 控件的关键所在,如果您有兴趣,可以自行深入学习。 当 Thumb 组件检测到在其表面有手指运动时,它会生成 DragDelta 事件;当 RepeatButton 组件检测到点击或持续的按下时,它会触发 Click 事件。

这两个事件都由父 Slider 组件处理,并相应地调整它的 Value 属性,而且这会从 Slider 触发 ValueChanged 事件。 Slider 还负责设置根据新的 Value 设置 Thumb 和两个 RepeatButton 组件的 Position 属性。

Game 类在一个事件处理程序中处理来自三个 Slider 组件的 ValueChanged 事件,因此该方法直接设置三个 TextBlock 组件的新值和新的颜色:

void OnSliderValueChanged(object sender, EventArgs args)
{
  txtblkValues[0].Text = sliders[0].Value.ToString("X2");
  txtblkValues[1].Text = sliders[1].Value.ToString("X2");
  txtblkValues[2].Text = sliders[2].Value.ToString("X2");

  selectedColor = new Color(sliders[0].Value, 
                            sliders[1].Value, 
                            sliders[2].Value);
}

这通过两项工作在 Game 类中实现 Draw 重写:绘制黑色的矩形作为组件的背景,使用新的 selectedColor 在右侧绘制矩形。 这两项工作都需要延展 1 x 1 像素的位图来填充半个屏幕。

优于 Silverlight?

最近我还为 Windows Phone 7 编写了 Silverlight 版本的颜色滚动程序,但我发现这个程序只让我一次操作一个 Slider。 但是,如果您将 XnaColorScroll 程序部署到电话上,您可以独立操作所有三个 Slider 控件。 我发现这个差异非常有趣。 这再次证明高级界面(例如 Silverlight)会使我们的生活更简单,但通常需要牺牲其他一些东西。 这正应了那句俗语:天下没有免费的午餐。

XNA 在某些方面比较简陋,但它会让我们想起“垦荒时代”。 通过聪明地利用组件(包括从其他组件构建的组件)并模拟路由事件处理,XNA 也可以为我们所用,让它更接近 Silverlight,甚至是更好。

Charles Petzold  MSDN 杂志 *的长期特约编辑。*他的新书《Programming Windows Phone 7》(Microsoft Press,2010)可从 bit.ly/cpebookpdf 免费下载获得。

衷心感谢以下技术专家对本文的审阅:Shawn Hargreaves