前七篇文章奠定了 Kinect 感測器(彩色、深度、紅外)的基礎操作。本文進入骨骼追蹤進階階段,核心主題是:如何將 Kinect 視為 WPF 應用程式的原生輸入裝置,並透過一個完整的「西蒙說」遊戲實作,示範命中測試、視覺回饋、遊戲狀態機等關鍵技術。
WPF 內建完整的輸入系統(System.Windows.Input),原生支援滑鼠、鍵盤、觸控筆、觸控板。輸入管理器(InputManager)負責將裝置座標轉換為 UI 元素座標,並觸發 MouseEnter、MouseLeftButtonDown 等路由事件。Kinect 尚未整合進 WPF 核心,因此開發者必須自行實作:
SkeletonFrameReady 取得關節點座標。MapSkeletonPointToDepth + 縮放)。VisualTreeHelper.HitTest 或 UIElement.InputHitTest)。WPF 提供兩種主要命中測試 API:
| 方法 | 適用場景 | 回傳值 |
|---|---|---|
VisualTreeHelper.HitTest(visual, point) | 通用視覺樹測試 | 最上層 DependencyObject |
UIElement.InputHitTest(point) | 僅需 IInputElement(如 Button、TextBox) | IInputElement |
關鍵步驟:
LayoutRoot 座標(GetJointPoint)。LayoutRoot 座標 → 目標元素局部座標(TranslatePoint)。InputHitTest,非 null 即命中。| 特性 | 滑鼠 | 觸控 | Kinect 骨骼 |
|---|---|---|---|
| 互動點數 | 1 | 多點(單一主點) | 最多 20 關節點 × 2 玩家 |
| Z 軸資訊 | 無 | 接觸面積 | 真實深度(米) |
| 點擊等價動作 | 按鍵 | 觸控抬起 | 需自訂手勢/姿勢 |
| 懸停偵測 | 原生 | 原生 | 需自行實作 |
本文採用「雙手各自獨立追蹤,僅手部關節點參與 UI 互動」的簡化模型。
GameCanvas。ControlCanvas 放置「雙手對準手型圖示啟動遊戲」的起始區域。HandCanvas 顯示左右手游標(右手圖片 + ScaleTransform.ScaleX = -1 產生左手鏡像)。Viewbox 自動縮放適應螢幕。<Canvas x:Name="GameCanvas">
<Rectangle x:Name="RedBlock" Fill="Red" Canvas.Left="170" Canvas.Top="90" Opacity="0.2" />
<Rectangle x:Name="BlueBlock" Fill="Blue" Canvas.Left="170" Canvas.Top="550" Opacity="0.2" />
<Rectangle x:Name="GreenBlock" Fill="Green" Canvas.Left="1350" Canvas.Top="550" Opacity="0.2" />
<Rectangle x:Name="YellowBlock" Fill="Yellow" Canvas.Left="1350" Canvas.Top="90" Opacity="0.2" />
</Canvas>

public enum GamePhase { GameOver = 0, SimonInstructing = 1, PlayerPerforming = 2 }
private GamePhase currentPhase;
private int currentLevel;
private UIElement[] instructionSequence;
private int instructionPosition;
private DispatcherTimer poseTimer; // 後續姿勢版擴充用
SkeletonFrameReady 事件根據 currentPhase 分派處理:
private void KinectDevice_SkeletonFrameReady(...)
{
using (var frame = e.OpenSkeletonFrame())
{
if (frame == null) return;
frame.CopySkeletonDataTo(frameSkeletons);
var skeleton = GetPrimarySkeleton(frameSkeletons); // 最近的追蹤中骨架
if (skeleton == null) { ChangePhase(GamePhase.GameOver); return; }
if (currentPhase == GamePhase.SimonInstructing)
{
LeftHandElement.Visibility = RightHandElement.Visibility = Visibility.Collapsed;
}
else
{
TrackHand(skeleton.Joints[JointType.HandLeft], LeftHandElement, LayoutRoot);
TrackHand(skeleton.Joints[JointType.HandRight], RightHandElement, LayoutRoot);
switch (currentPhase)
{
case GamePhase.GameOver: ProcessGameOver(skeleton); break;
case GamePhase.PlayerPerforming: ProcessPlayerPerforming(skeleton); break;
}
}
}
}
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)
=> GetHitTarget(joint, target) != null;
private IInputElement GetHitTarget(Joint joint, UIElement target)
{
Point p = LayoutRoot.TranslatePoint(GetJointPoint(KinectDevice, joint, LayoutRoot.RenderSize, new Point()), target);
return target.InputHitTest(p);
}
private void ChangePhase(GamePhase newPhase)
{
if (newPhase == currentPhase) return;
currentPhase = newPhase;
switch (currentPhase)
{
case GamePhase.GameOver:
currentLevel = 0;
SetBlocksOpacity(0.2);
GameStateElement.Text = "GAME OVER!";
ControlCanvas.Visibility = Visibility.Visible;
GameInstructionsElement.Text = "將手放在對象上開始新的遊戲。";
break;
case GamePhase.SimonInstructing:
currentLevel++;
GameStateElement.Text = $"Level {currentLevel}";
ControlCanvas.Visibility = Visibility.Collapsed;
GameInstructionsElement.Text = "注意觀察 Simon 的指示。";
GenerateInstructions();
DisplayInstructions(); // Storyboard 動畫依序亮起矩形
break;
case GamePhase.PlayerPerforming:
instructionPosition = 0;
GameInstructionsElement.Text = "請重復 Simon 的指示";
break;
}
}
private void GenerateInstructions()
{
instructionSequence = new UIElement[currentLevel];
for (int i = 0; i < currentLevel; i++)
{
switch (rnd.Next(1, 5)) // 1~4
{
case 1: instructionSequence[i] = RedBlock; break;
case 2: instructionSequence[i] = BlueBlock; break;
case 3: instructionSequence[i] = GreenBlock; break;
case 4: instructionSequence[i] = YellowBlock; break;
}
}
}
private void DisplayInstructions()
{
var sb = new Storyboard();
for (int i = 0; i < instructionSequence.Length; i++)
{
var anim = new DoubleAnimationUsingKeyFrames { FillBehavior = FillBehavior.Stop, BeginTime = TimeSpan.FromMilliseconds(i * 1500) };
Storyboard.SetTarget(anim, instructionSequence[i]);
Storyboard.SetTargetProperty(anim, new PropertyPath("Opacity"));
anim.KeyFrames.Add(new EasingDoubleKeyFrame(0.3, KeyTime.FromTimeSpan(TimeSpan.Zero)));
anim.KeyFrames.Add(new EasingDoubleKeyFrame(1.0, KeyTime.FromTimeSpan(TimeSpan.FromMilliseconds(500))));
anim.KeyFrames.Add(new EasingDoubleKeyFrame(1.0, KeyTime.FromTimeSpan(TimeSpan.FromMilliseconds(1000))));
anim.KeyFrames.Add(new EasingDoubleKeyFrame(0.3, KeyTime.FromTimeSpan(TimeSpan.FromMilliseconds(1300))));
sb.Children.Add(anim);
}
sb.Completed += (s, e) => ChangePhase(GamePhase.PlayerPerforming);
sb.Begin(LayoutRoot);
}
初版邏輯:只要手進入目標矩形即判定成功。但玩家手若停留過久,下一幀仍在矩形內,會被誤判為連續點擊同一目標。解決方案:記錄上一幀命中元素,僅在「命中目標變化」且「上一目標為正確目標」時前進。
private IInputElement leftHandTarget, rightHandTarget;
private void ProcessPlayerPerforming(Skeleton skeleton)
{
UIElement correctTarget = instructionSequence[instructionPosition];
IInputElement leftTarget = GetHitTarget(skeleton.Joints[JointType.HandLeft], GameCanvas);
IInputElement rightTarget = GetHitTarget(skeleton.Joints[JointType.HandRight], GameCanvas);
bool targetChanged = (leftTarget != leftHandTarget) || (rightTarget != rightHandTarget);
if (!targetChanged) return; // 手未移動,不處理
// 雙手同時命中任何元素 → 犯規
if (leftTarget != null && rightTarget != null) { ChangePhase(GamePhase.GameOver); return; }
// 單手命中且為正確目標
bool leftCorrect = (leftTarget == correctTarget && rightTarget == null);
bool rightCorrect = (rightTarget == correctTarget && leftTarget == null);
if (leftCorrect || rightCorrect)
{
instructionPosition++;
if (instructionPosition >= instructionSequence.Length)
ChangePhase(GamePhase.SimonInstructing); // 過關
// else 繼續等待下一指令
}
// 單手命中錯誤目標、或雙手皆未命中但手移動了 → 視為失誤
else if (leftTarget != null || rightTarget != null)
{
ChangePhase(GamePhase.GameOver);
}
// 更新上一幀目標並給予視覺回饋(透明度變化)
UpdateTargetVisual(leftTarget, ref leftHandTarget);
UpdateTargetVisual(rightTarget, ref rightHandTarget);
}
private void UpdateTargetVisual(IInputElement newTarget, ref IInputElement oldTarget)
{
if (newTarget == oldTarget) return;
if (oldTarget is FrameworkElement oldFE) oldFE.Opacity = 0.2;
if (newTarget is FrameworkElement newFE) newFE.Opacity = 1.0;
oldTarget = newTarget;
}

本文完整示範了如何在 WPF 中將 Kinect 骨骼資料轉化為類滑鼠/觸控輸入,涵蓋:
InputHitTest)。下一篇將延續此專案,導入姿勢識別取代矩形碰撞測試,並討論可擴充的姿勢引擎架構,敬請期待!
作者:yangecnu(博客園)|原文連結:https://www.cnblogs.com/yangecnu/