本文章是由機器翻譯。

UI 前沿技術

XNA 顏色滾動程式

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