承接上一篇骨骼物件模型介紹,本文將透過一個「連連看」小遊戲,完整示範如何將骨骼追蹤資料應用於實際專案:手部追蹤判斷、UI 座標映射、遊戲邏輯與繪圖同步。並補充說明骨骼/深度/彩色/UI 四大座標系轉換細節、鏡像處理,最後封裝可重用的 SkeletonViewer 自訂控制項,便於開發期除錯與視覺化。
Point),儲存於 DotPuzzle.Dots List<Point>。HandLeft 或 HandRight,Z 值最小者)控制游標。Polyline 連線。public class DotPuzzle { public List<Point> Dots { get; set; } = new List<Point>(); }
private DotPuzzle puzzle = new DotPuzzle();
private int puzzleDotIndex = -1; // -1 表示尚未開始
<Viewbox>
<Grid x:Name="LayoutRoot" Width="1920" Height="1200">
<Polyline x:Name="CrayonElement" Stroke="Black" StrokeThickness="3" />
<Canvas x:Name="PuzzleBoardElement" /> <!-- 點位圖層 -->
<Canvas x:Name="GameBoardElement"> <!-- 手游標圖層(最上層) -->
<Image x:Name="HandCursorElement" Source="Images/hand.png" Width="75" Height="75"
RenderTransformOrigin="0.5,0.5">
<Image.RenderTransform>
<ScaleTransform x:Name="HandCursorScale" ScaleX="1" />
</Image.RenderTransform>
</Image>
</Canvas>
</Grid>
</Viewbox>
Viewbox + 固定 Grid 尺寸:簡化座標轉換,確保手部移動流暢。Canvas:重設遊戲時僅清除 PuzzleBoardElement.Children,不影響游標與連線。private void KinectDevice_SkeletonFrameReady(...)
{
using (var frame = e.OpenSkeletonFrame())
{
if (frame == null) return;
frame.CopySkeletonDataTo(frameSkeletons);
var skeleton = GetPrimarySkeleton(frameSkeletons); // 最近的追蹤中骨架
if (skeleton == null) { HandCursorElement.Visibility = Visibility.Collapsed; return; }
var hand = GetPrimaryHand(skeleton); // Z 值較小者
TrackHand(hand);
TrackPuzzle(hand.Position);
}
}
private Joint GetPrimaryHand(Skeleton s)
{
var left = s.Joints[JointType.HandLeft];
var right = s.Joints[JointType.HandRight];
if (right.TrackingState == JointTrackingState.NotTracked) return left;
if (left.TrackingState == JointTrackingState.NotTracked) return right;
return (left.Position.Z < right.Position.Z) ? left : right; // 較近者
}
private void TrackHand(Joint hand)
{
if (hand.TrackingState == JointTrackingState.NotTracked)
{ HandCursorElement.Visibility = Visibility.Collapsed; return; }
HandCursorElement.Visibility = Visibility.Visible;
var dp = KinectDevice.MapSkeletonPointToDepth(hand.Position, KinectDevice.DepthStream.Format);
dp.X = (int)(dp.X * LayoutRoot.ActualWidth / KinectDevice.DepthStream.FrameWidth - HandCursorElement.ActualWidth / 2);
dp.Y = (int)(dp.Y * LayoutRoot.ActualHeight / KinectDevice.DepthStream.FrameHeight - HandCursorElement.ActualHeight / 2);
Canvas.SetLeft(HandCursorElement, dp.X);
Canvas.SetTop(HandCursorElement, dp.Y);
HandCursorScale.ScaleX = (hand.JointType == JointType.HandRight) ? 1 : -1; // 左手鏡像
}
private void TrackPuzzle(SkeletonPoint handPos)
{
if (puzzleDotIndex == puzzle.Dots.Count) return; // 已通關
// 取得下一個目標點(最後一個點連回第一個)
Point target = (puzzleDotIndex + 1 < puzzle.Dots.Count)
? puzzle.Dots[puzzleDotIndex + 1]
: puzzle.Dots[0];
// 手部位置映射至 UI 座標
var dp = KinectDevice.MapSkeletonPointToDepth(handPos, KinectDevice.DepthStream.Format);
Point handPt = new Point(
dp.X * LayoutRoot.ActualWidth / KinectDevice.DepthStream.FrameWidth,
dp.Y * LayoutRoot.ActualHeight / KinectDevice.DepthStream.FrameHeight);
double dist = Math.Sqrt(Math.Pow(target.X - handPt.X, 2) + Math.Pow(target.Y - handPt.Y, 2));
int lastIdx = CrayonElement.Points.Count - 1;
if (dist < 25) // 命中半徑
{
if (lastIdx > 0) CrayonElement.Points.RemoveAt(lastIdx); // 移除暫時終點
CrayonElement.Points.Add(target); // 確定終點
CrayonElement.Points.Add(target); // 新線段起點
puzzleDotIndex++;
if (puzzleDotIndex == puzzle.Dots.Count) { /* 通關提示 */ }
}
else if (lastIdx > 0) // 未命中:更新暫時終點跟隨手部
{
var tempEnd = CrayonElement.Points[lastIdx];
CrayonElement.Points.RemoveAt(lastIdx);
tempEnd.X = handPt.X; tempEnd.Y = handPt.Y;
CrayonElement.Points.Add(tempEnd);
}
}

ResetPuzzle() 清除連線、重設索引、可加入倒數動畫。| 座標系 | 單位 | 原點 | 範圍 | 備註 |
|---|---|---|---|---|
| 骨骼 | 公尺 | 感測器光心 | X: ±2.2m, Y: ±1.6m, Z: 0~4m | 右手制,Y 向上 |
| 深度 | 像素/毫米 | 影像左上角 | 640×480, Z: mm | Z 為 0 表無效 |
| 彩色 | 像素 | 影像左上角 | 1280×960 等 | 與深度不同視角 |
| UI (WPF) | 裝置獨立像素 (1/96吋) | 容器左上角 | 依版面而定 | 需自行縮放映射 |
KinectSensor.MapSkeletonPointToDepth(SkeletonPoint, DepthImageFormat) → DepthImagePoint(含 X、Y、Depth)。KinectSensor.MapDepthToSkeletonPoint(DepthImageFormat, x, y, depth) → SkeletonPoint。DepthImageFrame.MapToSkeletonPoint / MapToColorImagePoint:同功能,屬於幀物件。典型流程:骨骼 → 深度 → UI 縮放
var dp = kinect.MapSkeletonPointToDepth(joint.Position, kinect.DepthStream.Format);
double uiX = dp.X * LayoutRoot.ActualWidth / kinect.DepthStream.FrameWidth;
double uiY = dp.Y * LayoutRoot.ActualHeight / kinect.DepthStream.FrameHeight;
注意:
DepthStream.Format決定解析度與視野,轉換時必須與啟動時一致。DepthStream不需實際啟用也能轉換,只需格式正確。
骨骼資料預設為鏡像(使用者舉左手,骨架顯示右手)。若需「第一人稱視角」(角色模仿真人左右),需反轉 X 軸:
// GetJointPoint 修正版
private Point GetJointPoint(Joint joint)
{
var dp = KinectDevice.MapSkeletonPointToDepth(joint.Position, KinectDevice.DepthStream.Format);
dp.X = (int)(-dp.X * LayoutRoot.ActualWidth / KinectDevice.DepthStream.FrameWidth); // X 乘 -1
dp.Y = (int)(dp.Y * LayoutRoot.ActualHeight / KinectDevice.DepthStream.FrameHeight);
return new Point(dp.X, dp.Y);
}
<UserControl x:Class="KinectDrawDotsGame.SkeletonViewer" ...>
<Grid x:Name="LayoutRoot">
<Grid x:Name="SkeletonsPanel" /> <!-- 繪製骨架線條 -->
<Canvas x:Name="JointInfoPanel" /> <!-- 顯示關節座標文字 -->
</Grid>
</UserControl>
public static readonly DependencyProperty KinectDeviceProperty =
DependencyProperty.Register("KinectDevice", typeof(KinectSensor), typeof(SkeletonViewer),
new PropertyMetadata(null, OnKinectDeviceChanged));
private static void OnKinectDeviceChanged(DependencyObject d, DependencyPropertyChangedEventArgs e)
{
var viewer = (SkeletonViewer)d;
if (e.OldValue is KinectSensor oldSensor)
oldSensor.SkeletonFrameReady -= viewer.KinectDevice_SkeletonFrameReady;
if (e.NewValue is KinectSensor newSensor)
{
viewer.KinectDevice = newSensor;
newSensor.SkeletonFrameReady += viewer.KinectDevice_SkeletonFrameReady;
viewer._FrameSkeletons = new Skeleton[newSensor.SkeletonStream.FrameSkeletonArrayLength];
}
}
private void KinectDevice_SkeletonFrameReady(...)
{
SkeletonsPanel.Children.Clear();
JointInfoPanel.Children.Clear();
using (var frame = e.OpenSkeletonFrame())
{
if (frame == null || !IsEnabled) return;
frame.CopySkeletonDataTo(_FrameSkeletons);
foreach (var sk in _FrameSkeletons)
{
if (sk.TrackingState != SkeletonTrackingState.Tracked) continue;
var brush = _SkeletonBrushes[Array.IndexOf(_FrameSkeletons, sk) % _SkeletonBrushes.Length];
DrawSkeleton(sk, brush);
TrackJoint(sk.Joints[JointType.HandLeft], brush);
TrackJoint(sk.Joints[JointType.HandRight], brush);
}
}
}
private void TrackJoint(Joint joint, Brush brush)
{
if (joint.TrackingState == JointTrackingState.NotTracked) return;
var pt = GetJointPoint(joint);
double z = joint.Position.Z;
var container = new Canvas();
var dot = new Ellipse { Width = 15, Height = 15, Fill = brush };
Canvas.SetLeft(dot, -7.5); Canvas.SetTop(dot, -7.5);
container.Children.Add(dot);
var txt = new TextBlock
{
Text = $"<{pt.X:0.00}, {pt.Y:0.00}, {z:0.00}>",
Foreground = brush, FontSize = 24
};
txt.Measure(new Size(double.PositiveInfinity, double.PositiveInfinity));
Canvas.SetLeft(txt, 35); Canvas.SetTop(txt, 15);
container.Children.Add(txt);
Canvas.SetLeft(container, pt.X); Canvas.SetTop(container, pt.Y);
JointInfoPanel.Children.Add(container);
}
<Window ... xmlns:c="clr-namespace:KinectDrawDotsGame">
<Viewbox>
<Grid x:Name="LayoutRoot" Width="1920" Height="1200">
<c:SkeletonViewer x:Name="SkeletonViewerElement" />
<!-- 既有遊戲元素 -->
</Grid>
</Viewbox>
</Window>
// 後台:KinectSensor 設定器同步指派給 SkeletonViewer
public KinectSensor KinectDevice
{
set
{
if (kinectDevice != null) { /* 釋放舊感測器事件、停止串流 */ }
kinectDevice = value;
if (kinectDevice != null)
{
kinectDevice.SkeletonStream.Enable();
frameSkeletons = new Skeleton[kinectDevice.SkeletonStream.FrameSkeletonArrayLength];
SkeletonViewerElement.KinectDevice = kinectDevice; // 關鍵:傳遞感測器實例
kinectDevice.Start();
}
}
}

本文以「連連看」遊戲為主軸,串聯了骨骼追蹤的完整應用流程:
Polyline 動態繪圖技巧。SkeletonViewer 自訂控制項,支援 DependencyProperty 綁定、事件弱參考防記憶體洩漏、關節即時座標標註。下一篇將進入骨骼追蹤進階篇,結合 WPF 輸入系統與「西蒙說」遊戲,展示更豐富的互動模式,敬請期待!
作者:yangecnu(博客園)|原文連結:https://www.cnblogs.com/yangecnu/