WPF 拼图






5.00/5 (196投票s)
如何创建一个探索 Windows Presentation Foundation 的一些出色功能的拼图。

目录
引言

世界上几乎每个人都曾在生活的某个时刻玩过拼图游戏。它们可以提高认知能力、激发记忆力、锻炼逻辑思维、培养社交技能等等。换句话说,它们能让你更聪明。
很久以前,我就一直想着要用 WPF(或许还有 Silverlight)创建一个这样的拼图游戏,很高兴现在时机已到,WPF 拼图现已可供下载。
系统要求
要使用本文提供的 WPF 拼图,如果您已有 Visual Studio 2010,则足以运行该应用程序。如果您没有,可以从 Microsoft 直接下载以下 100% 免费的开发工具。
创建拼图块
创建拼图块的方式有无数种,包括方形、六边形,甚至精心手工雕刻的拼图块。所有这些都可以完美地工作。在这里展示一个由简单方形拼图块组成的拼图会容易得多。但对我来说,拼图块的美妙之处在于并非所有拼图块都能恰好匹配一个特定的位置,而方形拼图块就不会出现这种情况。也就是说,对于拼图中给定的缝隙,不仅图像部分,拼图块的形状也必须与该缝隙匹配。
尽管这个拼图游戏可以用传统的 Windows Forms 来完成,但它会缺少 WPF 技术提供的许多精细功能,例如我在这里介绍的:
- 能够渲染平滑的矢量图形
- 能够以不产生令人不悦的像素化效果的方式缩放拼图
- 能够轻松处理动画,特别是旋转动画
- 能够使用阴影位图效果,从而获得逼真的 3D 效果

形状构造的核心是 BezierCurve
元素。与 WPF 中的所有视觉元素一样,它是一个矢量化元素,因此您可以任意放大它,它都不会产生难看的“像素化”效果。

每个形状由 4 条贝塞尔曲线构成,每条边一条,根据随机拼图块生成过程,每条边可以有两种可能的形状:
- 凸舌形
- 凹槽形
所有贝塞尔曲线段均由以下坐标构成:
var curvyCoords = new double[]
{
0, 0, 35, 15, 37, 5,
37, 5, 40, 0, 38, -5,
38, -5, 20, -20, 50, -20,
50, -20, 80, -20, 62, -5,
62, -5, 60, 0, 63, 5,
63, 5, 65, 15, 100, 0
};
虽然在这个拼图游戏中,拼图块的边缘是弯曲的,但技术上来说,每个拼图块都是一个方形。但您看不到这一点,因为方形的背景是透明的。内部形状不规则的部分是容纳该拼图块图像的部分。一旦拼图块就位,您就无法透过透明背景看到桌面的背景,因为任何特定拼图块的边缘都会“侵入”相邻拼图块的空白区域。这种技术被称为 **镶嵌**(tesselation),它被应用于许多领域,从艺术(如马赛克)到结构工程(如砌墙)甚至自然界(如蜂窝)。
在 WPF 中,这得益于 Path
元素,它由一个 PathGeometry
元素构成,而 PathGeometry
又由 4 个 PolyBezierSegment
元素(每条边一个)构成,每个 PolyBezierSegment
则由一组点构成,BezierCurves
将基于这些点生成。
private static PathGeometry GetPathGeometry(int upperConnection,
int rightConnection, int bottomConnection, int leftConnection,
double[] blankCoords, double[] tabCoords)
{
var upperPoints = new List<point>();
var rightPoints = new List<point>();
var bottomPoints = new List<point>();
var leftPoints = new List<point>();
for (var i = 0; i < (tabCoords.Length / 2); i++)
{
double[] upperCoords = (upperConnection ==
(int)ConnectionType.Blank) ? blankCoords : tabCoords;
double[] rightCoords = (rightConnection ==
(int)ConnectionType.Blank) ? blankCoords : tabCoords;
double[] bottomCoords = (bottomConnection ==
(int)ConnectionType.Blank) ? blankCoords : tabCoords;
double[] leftCoords = (leftConnection ==
(int)ConnectionType.Blank) ? blankCoords : tabCoords;
upperPoints.Add(new Point(upperCoords[i * 2], 0 +
upperCoords[i * 2 + 1] * upperConnection));
rightPoints.Add(new Point(100 - rightCoords[i * 2 + 1] *
rightConnection, rightCoords[i * 2]));
bottomPoints.Add(new Point(100 - bottomCoords[i * 2],
100 - bottomCoords[i * 2 + 1] * bottomConnection));
leftPoints.Add(new Point(0 + leftCoords[i * 2 + 1] *
leftConnection, 100 - leftCoords[i * 2]));
}
var upperSegment = new PolyBezierSegment(upperPoints, true);
var rightSegment = new PolyBezierSegment(rightPoints, true);
var bottomSegment = new PolyBezierSegment(bottomPoints, true);
var leftSegment = new PolyBezierSegment(leftPoints, true);
var pathFigure = new PathFigure()
{
IsClosed = false,
StartPoint = new Point(0, 0)
};
pathFigure.Segments.Add(upperSegment);
pathFigure.Segments.Add(rightSegment);
pathFigure.Segments.Add(bottomSegment);
pathFigure.Segments.Add(leftSegment);
var pathGeometry = new PathGeometry();
pathGeometry.Figures.Add(pathFigure);
return pathGeometry;
}
形状构造从第一行第一列的位置开始,然后是第一行第二列的位置,即从左到右,从上到下的方向。
为了正确构造形状,必须考虑一些规则:
- 顶行的每个拼图块都必须有一个平坦的顶部边
- 最左列的每个拼图块都必须有一个平坦的左侧边
- 最右列的每个拼图块都必须有一个平坦的右侧边
- 底行的每个拼图块都必须有一个平坦的底部边
- 任何特定拼图块的左侧边形状必须与其同一行前一个拼图块的右侧边形状匹配
- 任何特定拼图块的顶部边形状必须与其同一列前一个拼图块的底部边形状匹配
形状构造就到这里。下一步是如何从原始图像中切割出这些拼图块。
切割图像
对于这些形状不规则的拼图块,我们在切割图像时必须小心,否则当所有拼图块都就位时,它们将无法恢复原始图像。
但这里的技术并不复杂。首先,我们必须考虑到每个拼图块会“侵占”相邻拼图块的区域。这是因为我们有凸舌和凹槽相互连接。如果使用简单的方形拼图块,我们显然不必担心这个问题。但由于是拼图游戏,我们必须注意这些细节。

所有拼图块的尺寸为 140 x 140 像素,但每个形状都有一个内部正方形(虽然不可见),尺寸为 100 x 100 像素。这个内部区域不会被相邻的拼图块“侵占”。这样,在 4 个边缘各留出 20 像素,用于填充连接凸舌和凹槽。
以下代码片段位于 Piece
类的构造函数中,它定义了将作为特定拼图块填充物的原始图像部分。
path.Fill = new ImageBrush()
{
ImageSource = imageSource,
Stretch = Stretch.None,
Viewport = new Rect(-20, -20, 140, 140),
ViewportUnits = BrushMappingMode.Absolute,
Viewbox = new Rect(
x * 100 - 10,
y * 100 - 10,
120,
120
),
ViewboxUnits = BrushMappingMode.Absolute,
Transform = imageScaleTransform
};
打乱拼图块
这里我们有一个水平堆栈面板(stack panel),用于存放将被拾取的拼图块。我们必须以随机方式将拼图块添加到该堆栈面板中,这是一个简单的任务:我们只需从有效拼图块索引中选择一个随机索引。
接下来,我们将每个拼图块旋转 4 种不同的随机角度:0、90、180 和 270 度。
您可以在下面的代码片段中看到我们是如何随机填充拾取面板的。
foreach (var p in pieces)
{
Random random = new Random();
int i = random.Next(0, pnlPickUp.Children.Count);
p.ScaleTransform.ScaleX = 1.0;
p.ScaleTransform.ScaleY = 1.0;
p.RenderTransform = tt;
p.X = -1;
p.Y = -1;
pnlPickUp.Children.Insert(i, p);
int angle = angles[rnd.Next(0, 4)];
p.Rotate(angle);
shadowPieces[p.Index].Rotate(angle);
}
完成这些之后,打乱顺序就完成了,如下图所示。

放置拼图块
游戏用户有 20 x 20 个格子来放置拼图块。用户点击拾取面板上的鼠标,然后移动到游戏棋盘上,将该拼图块放置在其中一个格子上。
游戏棋盘尺寸为 2000 x 2000 像素。如果您想玩大于此尺寸的图像,请复制并将其调整为较小的尺寸。或者,您可以自己修改代码以允许这样做。
当鼠标在游戏棋盘上移动时,它会以原始尺寸的 10% 进行缩放,这样做只是为了让拼图块看起来像是漂浮在棋盘上。
另一个有用的效果是 DropShadowBitmapEffect
,它可以创建拼图块形状投射在下方棋盘上的逼真阴影。

以下是创建阴影效果的方法:
shadowEffect = new DropShadowBitmapEffect()
{
Color = Colors.Black,
Direction = 320,
ShadowDepth = 25,
Softness = 1,
Opacity = 0.5
};
.
.
.
chosenPiece.BitmapEffect = shadowEffect;
您应该知道的一点是,DropShadowBitmapEffect
的性能开销很大,因此我们小心使用 DropShadowBitmapEffect
以避免性能问题(一次只有一个拼图块,在此游戏中)。
如果您正在移动一个已选定的拼图块在棋盘上,并单击鼠标左键,游戏将尝试将该拼图块放置在该位置。如果拼图块能放置在那里,阴影效果就会被移除,拼图块也会被正确放置。下一节将解释如何匹配连接器。
选择拼图块组:套索工具
这是应一些读者要求添加的功能,幸运的是,它已在当前版本中实现。移动一组拼图块的能力在拼图游戏中非常有意义。它可以节省大量时间和精力。这得益于新的矩形选择 **套索** 工具。

private void MouseUp()
{
if (currentSelection.Count == 0)
{
double x1 = (double)rectSelection.GetValue(Canvas.LeftProperty) - 20;
double y1 = (double)rectSelection.GetValue(Canvas.TopProperty) - 20;
double x2 = x1 + rectSelection.Width;
double y2 = y1 + rectSelection.Height;
int cellX1 = (int)(x1 / 100);
int cellY1 = (int)(y1 / 100);
int cellX2 = (int)(x2 / 100);
int cellY2 = (int)(y2 / 100);
var query = from p in pieces
where
(p.X >= cellX1) && (p.X <= cellX2) &&
(p.Y >= cellY1) && (p.Y <= cellY2)
select p;
//all pieces within that area will be selected
foreach (var currentPiece in query)
{
currentSelection.Add(currentPiece);
currentPiece.SetValue(Canvas.ZIndexProperty, 5000);
shadowPieces[currentPiece.Index].SetValue(Canvas.ZIndexProperty, 4999);
currentPiece.BitmapEffect = shadowEffect;
currentPiece.RenderTransform = stZoomed;
currentPiece.IsSelected = true;
shadowPieces[currentPiece.Index].RenderTransform = stZoomed;
}
SetSelectionRectangle(-1, -1, -1, -1);
}
...
我们不再一次移动一个拼图块,而是按下鼠标按钮,在按钮按下的状态下选择一个矩形区域,然后释放按钮。该区域内的所有拼图块将被自动选中。

选中一组拼图块后,您可以自由地移动它们,尝试将它们放置在匹配的位置。
private void MouseMoving()
{
var newX = Mouse.GetPosition((IInputElement)cnvPuzzle).X - 20;
var newY = Mouse.GetPosition((IInputElement)cnvPuzzle).Y - 20;
int cellX = (int)((newX) / 100);
int cellY = (int)((newY) / 100);
if (Mouse.LeftButton == MouseButtonState.Pressed)
{
SetSelectionRectangle(initialRectangleX, initialRectangleY, newX, newY);
}
else
{
if (currentSelection.Count > 0)
{
var firstPiece = currentSelection[0];
//This can move around more than one piece at the same time
foreach (var currentPiece in currentSelection)
{
var relativeCellX = currentPiece.X - firstPiece.X;
var relativeCellY = currentPiece.Y - firstPiece.Y;
double rotatedCellX = relativeCellX;
double rotatedCellY = relativeCellY;
currentPiece.SetValue
(Canvas.LeftProperty, newX - 50 + rotatedCellX * 100);
currentPiece.SetValue(Canvas.TopProperty, newY - 50 + rotatedCellY * 100);
shadowPieces[currentPiece.Index].SetValue
(Canvas.LeftProperty, newX - 50 + rotatedCellX * 100);
shadowPieces[currentPiece.Index].SetValue
(Canvas.TopProperty, newY - 50 + rotatedCellY * 100);
}
}
}
}
匹配连接器
4 个拼图块的边都可能具有不同的形状:**凸舌**、**凹槽** 或 **平坦**。平坦的边意味着该边适合原始图像的边缘之一。两个相邻的平坦边意味着该拼图块是拼图的角落之一(但您显然已经知道了……)。凸舌意味着该边连接到另一个拼图块的凹槽边。就这么简单。

在将选定的拼图块放置在所需单元格之前,游戏必须测试该拼图块是否适合那里。为此使用了一些规则:
- 目标单元格必须是空的,也就是说,还没有被其他任何拼图块占用
- 如果目标单元格上方的单元格被某个拼图块占用,则这两个拼图块的边缘必须匹配。也就是说,它们必须是凸舌/凹槽或凹槽/凸舌的边缘。如果是凸舌/凸舌、凹槽/凹槽或平坦/(任何类型),则拼图块不适合那里,并且不会放置该拼图块。
- 上述规则以类似的方式应用于右侧、底部和左侧。
下面的函数使用 Linq 来判断上述规则是否得到遵守。否则,该拼图块就不能放置在目标单元格中。
请注意,我们必须测试当前选定的每个拼图块,并考虑它们与选择组中主拼图块的位置关系。如果其中任何一个与目标位置不兼容,则该组将不会被放置。
private bool TrySetCurrentPiecePosition(double newX, double newY)
{
bool ret = true;
double cellX = (int)((newX) / 100);
double cellY = (int)((newY) / 100);
var firstPiece = currentSelection[0];
foreach (var currentPiece in currentSelection)
{
var relativeCellX = currentPiece.X - firstPiece.X;
var relativeCellY = currentPiece.Y - firstPiece.Y;
var q = from p in pieces
where (
(p.Index != currentPiece.Index) &&
(!p.IsSelected) &&
(cellX + relativeCellX > 0) &&
(cellY + relativeCellY > 0) &&
(
((p.X == cellX + relativeCellX) &&
(p.Y == cellY + relativeCellY))
|| ((p.X == cellX + relativeCellX - 1) &
& (p.Y == cellY + relativeCellY) &&
(p.RightConnection + currentPiece.LeftConnection != 0))
|| ((p.X == cellX + relativeCellX + 1) &
& (p.Y == cellY + relativeCellY) &&
(p.LeftConnection + currentPiece.RightConnection != 0))
|| ((p.X == cellX + relativeCellX) &&
(p.Y == cellY - 1 + relativeCellY) &&
(p.BottomConnection + currentPiece.UpperConnection != 0))
|| ((p.X == cellX + relativeCellX) &&
(p.Y == cellY + 1 + relativeCellY) &&
(p.UpperConnection + currentPiece.BottomConnection != 0))
)
)
select p;
if (q.Any())
{
ret = false;
break;
}
}
return ret;
}
旋转拼图块
如前所述,为了增加游戏难度,拼图块最初会随机旋转到四个可能的角度之一(0、90、180、270 度)。
旋转拼图块非常简单直接:只需单击鼠标右键,选定的拼图块就会顺时针旋转 90 度。再次单击,拼图块将再旋转 90 度。持续旋转,直到找到正确的旋转角度。

作为副作用,此旋转功能需要游戏重新计算新侧面的侧面连接器。也就是说,一旦您将一个拼图块顺时针旋转 90 度,连接器也会随之旋转:顶部连接器变为右侧连接器。右侧连接器变为底部连接器。依此类推……
例如,这是我们根据拼图块的角度重新计算顶部连接器的方式。
public int UpperConnection
{
get
{
var connection = 0;
switch (angle)
{
case 0:
connection = initialUpperConnection;
break;
case 90:
connection = initialLeftConnection;
break;
case 180:
connection = initialBottomConnection;
break;
case 270:
connection = initialRightConnection;
break;
}
return connection;
}
}
旋转拼图块组
旋转拼图块组不像旋转单个拼图块那样简单。我们不仅要旋转拼图块,还要确定旋转的中心点。这使得我们能够将整个组作为一个整体进行旋转。

public void Rotate(Piece axisPiece, double rotationAngle)
{
var deltaCellX = this.X - axisPiece.X;
var deltaCellY = this.Y - axisPiece.Y;
double rotatedCellX = 0;
double rotatedCellY = 0;
int a = (int)rotationAngle;
switch (a)
{
case 0:
rotatedCellX = deltaCellX;
rotatedCellY = deltaCellY;
break;
case 90:
rotatedCellX = -deltaCellY;
rotatedCellY = deltaCellX;
break;
case 180:
rotatedCellX = -deltaCellX;
rotatedCellY = -deltaCellY;
break;
case 270:
rotatedCellX = deltaCellY;
rotatedCellY = -deltaCellX;
break;
}
this.X = axisPiece.X + rotatedCellX;
this.Y = axisPiece.Y + rotatedCellY;
var rt1 = (RotateTransform)tg1.Children[1];
var rt2 = (RotateTransform)tg2.Children[1];
angle += rotationAngle;
if (angle == -90)
angle = 270;
if (angle == 360)
angle = 0;
rt1.Angle =
rt2.Angle = angle;
this.SetValue(Canvas.LeftProperty, this.X * 100);
this.SetValue(Canvas.TopProperty, this.Y * 100);
}
测试拼图完成情况
每次放置一个拼图块时,游戏应用程序都会测试拼图是否已完成。有一些必须遵守的规则:
- 所有拼图块必须旋转 0 度(相对于原始图像)
- 所有拼图块必须水平连接(没有任何间隙)
- 所有拼图块必须垂直连接(没有任何间隙)

同样,我们再次求助于 Linq 来找出拼图是否已完成。
private bool IsPuzzleCompleted()
{
//All pieces must have rotation of 0 degrees
var query = from p in pieces
where p.Angle != 0
select p;
if (query.Any())
return false;
//All pieces must be connected horizontally
query = from p1 in pieces
join p2 in pieces on p1.Index equals p2.Index - 1
where (p1.Index % columns < columns - 1) && (p1.X + 1 != p2.X)
select p1;
if (query.Any())
return false;
//All pieces must be connected vertically
query = from p1 in pieces
join p2 in pieces on p1.Index equals p2.Index - columns
where (p1.Y + 1 != p2.Y)
select p1;
if (query.Any())
return false;
return true;
}
一旦拼图完成,将邀请用户保存一个包含拼图图像的文件。
保存完整的拼图图像
这并不是一个拼图游戏真正必需的功能,更多的是考虑到我们花费大量时间玩游戏的用户的感受。因此,一旦拼图完成,用户就可以保存该拼图的图像。
在 WPF 中保存视觉元素的图像很容易,并且非常直接,您可以在下面看到。在这种情况下,源元素是容纳拼图块的 Canvas
。
private void SavePuzzle()
{
var sfd = new SaveFileDialog()
{
Filter = "All Image Files ( JPEG,GIF,BMP,PNG)|" +
"*.jpg;*.jpeg;*.gif;*.bmp;*.png|JPEG Files ( *.jpg;*.jpeg )|"+
"*.jpg;*.jpeg|GIF Files ( *.gif )|*.gif|BMP Files ( *.bmp )|"+
"*.bmp|PNG Files ( *.png )|*.png",
Title = "Save the image of your completed puzzle",
FileName = srcFileName.Split('.')[0] + "_puzzle." +
srcFileName.Split('.')[1]
};
sfd.DefaultExt = "png";
sfd.ShowDialog();
var query = from p in pieces
select p;
var minX = query.Min(x => x.X);
var maxX = query.Max(x => x.X);
var minY = query.Min(x => x.Y);
var maxY = query.Max(x => x.Y);
var rtb = new RenderTargetBitmap((int)(maxX - minX + 1) * 100 + 40,
(int)(maxY - minY + 1) * 100 + 40, 100, 100, PixelFormats.Pbgra32);
cnvPuzzle.Arrange(new Rect(-minX * 100, -minY * 100,
(int)(maxX - minX + 1) * 100 + 40, (int)(maxY - minY + 1) * 100 + 40));
rtb.Render(cnvPuzzle);
png = new PngBitmapEncoder();
png.Frames.Add(BitmapFrame.Create(rtb));
using (StreamWriter sw = new StreamWriter(sfd.FileName))
{
png.Save(sw.BaseStream);
}
}
最终考虑
如果您一直看到这里,我想非常感谢您的耐心和兴趣。请在下方留言,告诉我您喜欢或不喜欢这个游戏的地方。
我希望这篇文章能在某种程度上对您有所帮助。或者,至少,您能玩得很开心。
历史
- 2010-10-10:初始版本
- 2010-10-13:代码块格式已更正
- 2010-10-16:增强功能
- 能够移动一组拼图块
- 同时旋转一组拼图块
- 用于选择多个拼图块的矩形套索工具
- 使用 Wrap panel 而不是 Stack panel 来容纳拼图块
- 2010-10-19:旋转算法的更正