65.9K
CodeProject 正在变化。 阅读更多。
Home

WPF Grand Prix

starIconstarIconstarIconstarIcon
emptyStarIcon
starIcon

4.97/5 (110投票s)

2010年11月30日

CPOL

10分钟阅读

viewsIcon

411077

downloadIcon

6311

一篇展示如何利用 WPF 强大的功能创建赛车游戏的文章

WPF Grand Prix

目录

引言

制作一款赛车游戏一直以来都是我的梦想。而且这绝对是我迄今为止发现的最具挑战性的任务之一。但幸运的是,WPF 在我身边,到目前为止,这项任务不仅已经完成,而且是以一种轻松的方式完成的。好吧,也许不是那么轻松,但事实是 Windows Presentation Foundation 提供了所有工具。所有工具。所以,我敢说,一旦你开始使用 WPF,你就很难放弃它。

本文讲述了应用程序背后的概念和涉及的技术。目标是让我们的读者学到一些关于 WPF 的知识,或者至少,享受阅读和应用程序本身。

Youtube

您可以通过点击下面的链接一睹游戏的风采

系统要求

要使用本文提供的 WPF GrandPrix,如果您已经有了 Visual Studio 2010,那么运行此应用程序就足够了。如果您没有,您可以直接从 Microsoft 下载以下 100% 免费的开发工具。

因特拉戈斯赛道

对于游戏,只使用了一条赛道,“何塞·卡洛斯·佩斯”(José Carlos Pace),也被称为因特拉戈斯赛道,位于巴西圣保罗。我选择这条赛道,不仅因为我是巴西人,还因为这条赛道是对驾驶能力的绝佳考验——它有很多弯道,需要非常慢的速度,以及长直道,您可以在那里轻松地发挥出汽车的最高速度。因此,它很好地服务于将我们的应用程序推向极限的目的。

The Interlagos Circuit

图 1. 现实生活中的因特拉戈斯赛道。

The Interlagos Circuit

图 2. 游戏中的因特拉戈斯赛道。

虽然游戏中只有一个赛道,但您可以根据需要替换成另一个赛道——您只需要复制文件Interlagos.xaml,并将构成赛道图形的点替换为您想要创建的赛道所需的点。乍一看这可能不那么友好,但事实是,应用程序已准备好处理您绘制的任何赛道——您只需要为新赛道重新定义这些点。

以下代码显示了包含用于生成赛道的点的 XAML 代码。

    <Path x:Name="trackPath" Stroke="Yellow" StrokeThickness="8">
	<Path x:Name="trackPath" Stroke="Yellow" StrokeThickness="8">
		<Path.Data>
			<PathGeometry>
				<PathFigureCollection>
					<PathFigure StartPoint="550,430">
						<PolyLineSegment Points="776,354"/>
						<PolyLineSegment Points="736,303"/>
						<PolyLineSegment Points="762,237"/>
						<PolyLineSegment Points="755,181"/>
						<PolyLineSegment Points="677,112"/>
						<PolyLineSegment Points="221,12"/>
						<PolyLineSegment Points="189,107"/>
						<PolyLineSegment Points="197,138"/>
						<PolyLineSegment Points="220,174"/>
						<PolyLineSegment Points="436,326"/>
						<PolyLineSegment Points="446,375"/>
						<PolyLineSegment Points="417,428"/>
						<PolyLineSegment Points="316,452"/>
						<PolyLineSegment Points="292,428"/>
						<PolyLineSegment Points="318,376"/>
						<PolyLineSegment Points="283,345"/>
						<PolyLineSegment Points="214,404"/>
						<PolyLineSegment Points="138,409"/>
						<PolyLineSegment Points="135,382"/>
						<PolyLineSegment Points="189,336"/>
						<PolyLineSegment Points="201,297"/>
						<PolyLineSegment Points="178,245"/>
						<PolyLineSegment Points="41,170"/>
						<PolyLineSegment Points="3,263"/>
						<PolyLineSegment Points="36,379"/>
						<PolyLineSegment Points="91,444"/>
						<PolyLineSegment Points="332,497"/>
						<PolyLineSegment Points="550,430"/>
					</PathFigure>
				</PathFigureCollection>
			</PathGeometry>
		</Path.Data>
	</Path>  

从直线创建贝塞尔曲线

Creating Bezier Curves From Straight Lines

图 3. 在赛道转弯处,直线被替换为贝塞尔曲线。

该应用程序允许我们创建一个由直线组成的赛道,而这些直线又由我们之前看到的点组成。稍后,在渲染赛道时,应用程序将在赛道的每个转弯处创建贝塞尔曲线。这使得我们的赛道更加逼真和流畅。

我们如何做到这一点?

首先,我们必须记住,要绘制二次贝塞尔曲线,我们需要 3 个控制点。因此,我们必须为赛道的每个转弯确定 3 个控制点。中间的控制点就是转弯点本身,而另外 2 个控制点位于相邻的线段上,距离中心控制点有一定距离。

Straight Lines

图 4. 每个贝塞尔曲线段需要 3 个控制点。

Animation of a Bezier curve

图 5. 贝塞尔曲线动画(来源:Wikipedia)。

接下来,我们必须创建整个路径,使用所有这些控制点。我们通过在直线(构成赛道的大部分)和连接这些直线的贝塞尔曲线之间交替来实现这一点。

foreach (var segment in trackLineList)
{
	var point = polyLineSegment.Points[segment.Index];

	if (index > 0)
	{
		strPoints.AppendFormat(" C {0},{1} {2},{3} {4},{5} L {6},{7} ",
		(int)lastCurvePoint.X, (int)lastCurvePoint.Y,
		(int)segment.P1.X, (int)segment.P1.Y,
		(int)segment.P3.X, (int)segment.P3.Y,
		(int)segment.P2.X, (int)segment.P2.Y);
 
		strMapPoints.AppendFormat("L {0},{1} ", point.X, point.Y);
	}
	else
	{
		strPoints.AppendFormat(" C {0},{1} {2},{3} {4},{5} L {6},{7} ",
		(int)lastCurvePoint.X, (int)lastCurvePoint.Y,
		(int)segment.P1.X, (int)segment.P1.Y,
		(int)segment.P3.X, (int)segment.P3.Y,
		(int)segment.P4.X, (int)segment.P4.Y);

		strMapPoints.AppendFormat("L {0},{1} ", point.X, point.Y);
	}
 
	lastCurvePoint = segment.P4;

	index++;

	pointCount++;
}

Bezier Curves With Control Points

图 6. 由 V 形线分隔的贝塞尔曲线段。

赛道的可见部分

渲染后,赛道的背景图像会变得非常大。大到足以成为应用程序性能的瓶颈。我找到的解决方案是将这个大图像分解成包含该大图像小部分的更小的控件,这样就可以只显示屏幕上当前显示的方块。也就是说,由于应用程序的“摄像机”一次只能显示赛道的一部分,因此其余的赛道都可以设为不可见。当然,可能还有更好、更优雅的处理方式,但这种技术尤其解决了性能问题,所以我对此很满意。

Visible Part Of The Circuit

图 7. 通过使大部分赛道不可见,我们可以提高性能。

下面的代码显示,屏幕上只显示了赛道的 5x5 单元格的一部分——所有其他单元格都被隐藏了。

    .
    .
    .
    foreach (var childToHid in pnlTrack.Children)
	{
		((UserControl)childToHid).Visibility = Visibility.Hidden;
	}
 
	for (var y = trackSegment.Row - 2; y <= trackSegment.Row + 2; y++)
	{
		for (var x = trackSegment.Column - 2; x <= trackSegment.Column + 2; x++)
		{
			if (x >= 0 && x < TRACK_ARRAY_WIDTH &&
			y >= 0 && y < TRACK_ARRAY_HEIGHT)
			{
				ITrackSegment segmentToShow = (ITrackSegment)
				pnlTrack.Children[y * TRACK_ARRAY_WIDTH + x];
				((UserControl)segmentToShow).Visibility = 
					Visibility.Visible;
			}
		}
    }
    .
    .
    .  

经过一段时间的思考,我找到了一个简单的解决方案来绘制赛道:我只是使用原始赛道点通过绘制相同的赛道点来重新绘制一个大路径。但这不仅仅是一个路径。它是一系列分层的路径:最宽的用于绘制红白赛道边缘。另一个路径更窄,代表沥青路面。中央路径最窄,将赛道分成两个区域。

    trackWhiteLine = new Path()
    {
		Stroke = new SolidColorBrush(Color.FromRgb(0xE0, 0xE0, 0xE0)),
		StrokeThickness = 200,
		StrokeDashArray = new DoubleCollection(new double[] { 0.1, 0.1 }),
		StrokeDashOffset = 0.0,
		Margin = new Thickness(0, 0, 0, 0),
		HorizontalAlignment = System.Windows.HorizontalAlignment.Left,
		VerticalAlignment = System.Windows.VerticalAlignment.Top
    };
    trackRedLine = new Path()
    {
		Stroke = new SolidColorBrush(Color.FromRgb(0xFF, 0x00, 0x00)),
		StrokeThickness = 200,
		StrokeDashArray = new DoubleCollection(new double[] { 0.1, 0.1 }),
		StrokeDashOffset = 0.1,
		Margin = new Thickness(0, 0, 0, 0),
		HorizontalAlignment = System.Windows.HorizontalAlignment.Left,
		VerticalAlignment = System.Windows.VerticalAlignment.Top
    };
    trackGrayTrackLine = new Path()
    {
		Stroke = new SolidColorBrush(Color.FromRgb(0x80, 0x80, 0x80)),
		StrokeThickness = 180,
		Margin = new Thickness(0, 0, 0, 0),
		HorizontalAlignment = System.Windows.HorizontalAlignment.Left,
		VerticalAlignment = System.Windows.VerticalAlignment.Top
    };
    trackCenterLine = new Path()
    {
		Stroke = new SolidColorBrush(Color.FromRgb(0xC0, 0xC0, 0x80)),
		StrokeThickness = 4,
		StrokeDashArray = new DoubleCollection(new double[] { 3, 2 }),
		StrokeDashOffset = 0.0,
		Margin = new Thickness(0, 0, 0, 0),
		HorizontalAlignment = System.Windows.HorizontalAlignment.Left,
		VerticalAlignment = System.Windows.VerticalAlignment.Top
    };
    .
    .
    .
    //The following code lines show that all track paths follow the same points
    trackWhiteLine.Data = Geometry.Parse(strPoints.ToString());
    trackRedLine.Data = Geometry.Parse(strPoints.ToString());
    trackGrayTrackLine.Data = Geometry.Parse(strPoints.ToString());
    trackCenterLine.Data = Geometry.Parse(strPoints.ToString());  

Track Layers

图 8. 赛道由 3 层路径元素组成(从宽到窄),共享相同的曲线。

赛车

正如您所见,我称之为“卡丁车”的汽车实际上更像一辆 F1 赛车。原来的汽车是红色的,但也有其他颜色——我们只需要配置颜色即可。

The Race Car

图 9. 完全由 XAML 组成的汽车形状。车轮可以左右转动。

游戏包含一套 5 辆汽车:黑色黄色蓝色橙色红色。用户始终驾驶红色汽车。所有汽车都来自原始卡丁车用户控件并相应配置。

    myCar.Name = "myCar";
    myCar.PilotName = "Captain Red";
    myCar.BodyColor1 = Color.FromRgb(0xFF, 0xFF, 0xFF);
    myCar.BodyColor2 = Color.FromRgb(0xFF, 0x00, 0x00);
    myCar.BodyColor3 = Color.FromRgb(0x80, 0x00, 0x00);
    myCar.MaxSpeed = 10.0;
 
    yellowCar.Name = "Yellow";
    yellowCar.PilotName = "Yellow Storm";
    yellowCar.BodyColor1 = Color.FromRgb(0xFF, 0xFF, 0xFF);
    yellowCar.BodyColor2 = Color.FromRgb(0xFF, 0xFF, 0x00);
    yellowCar.BodyColor3 = Color.FromRgb(0x80, 0x80, 0x00);
    yellowCar.MaxSpeed = 14.0;
 
    blueCar.Name = "Blue";
    blueCar.PilotName = "Jimmy Blue";
    blueCar.BodyColor1 = Color.FromRgb(0xFF, 0xFF, 0xFF);
    blueCar.BodyColor2 = Color.FromRgb(0x00, 0x00, 0xFF);
    blueCar.BodyColor3 = Color.FromRgb(0x00, 0x00, 0x80);
    blueCar.MaxSpeed = 18.0;
 
    blackCar.Name = "Black";
    blackCar.PilotName = "Black Jack";
    blackCar.BodyColor1 = Color.FromRgb(0xFF, 0xFF, 0xFF);
    blackCar.BodyColor2 = Color.FromRgb(0x40, 0x40, 0x40);
    blackCar.BodyColor3 = Color.FromRgb(0x00, 0x00, 0x00);
    blackCar.MaxSpeed = 13.0;
 
    orangeCar.Name = "Orange";
    orangeCar.PilotName = "Johnny Orange";
    orangeCar.BodyColor1 = Color.FromRgb(0xFF, 0xFF, 0xFF);
    orangeCar.BodyColor2 = Color.FromRgb(0xFF, 0x6A, 0x00);
    orangeCar.BodyColor3 = Color.FromRgb(0x80, 0x30, 0x00);
    orangeCar.MaxSpeed = 10.0;  

前轮可以根据用户的操作向左或向右转动。每个车轮最多可以向两侧转动 30 度。当用户松开方向盘(哦,是键盘上的左右箭头键)时,车轮会自动缓慢地与汽车方向对齐。

起跑线

每辆车在比赛开始时都占有特定位置。在我们的应用程序中,红色汽车是车列中的最后一辆,所以用户总是从最后一个位置开始。因此,他或她必须争取位置才能赢得比赛。

The Starting Grid

图 10. 第一段赛道的起始网格。

下面是我们定义初始汽车位置的片段。

    foreach (var kart in kartList)
    {
		kart.NearestTrackLineSegment = trackLineList[0];
 
		cnvTrack.Children.Add(kart);
		kart.Index = carIndex;
 
		var firstSegment = trackLineList[0];
 
		var rad = (-(firstSegment.Angle - 270) + 180) / (2.0 * Math.PI);
 
		if (kart.Index >= 0)
		{
			kart.CarTranslateTransform.X = 
			firstSegment.P1.X + Math.Cos(rad) * 100.0 * (kart.Index + 1);
			kart.CarTranslateTransform.Y = 
			firstSegment.P1.Y - Math.Sin(rad) * 100.0 * (kart.Index + 1);
		}
		else
		{
			kart.CarTranslateTransform.X = firstSegment.P1.X;
			kart.CarTranslateTransform.Y = firstSegment.P1.Y;
		}
 
		kart.CarRotateTransform.Angle = -firstSegment.Angle;
 
		carIndex++;
    }

我们将去往何处?

对我们人类来说,加速、让汽车保持在赛道上、减速和根据需要左转右转似乎很容易。但对于我们可怜的虚拟飞行员来说,这些任务绝非易事。

问题在于,我们必须让我们的虚拟车手看起来像真正的车手。我们必须赋予他们一些智能,让他们看起来不像一群白痴互相碰撞、冲出赛道、漫无目的地驾驶。相反,我们应该为他们提供真正的驾驶“感觉”,并确保他们“知道”比赛的目标是什么。

首先,车手应该知道正确的方向。正如文章开头所述,整个赛道由直线段组成,由圆角连接。默认情况下,游戏应用程序假定汽车应从起跑线(第 0 段)出发,朝向下一段(第 1、2、3 段等)行驶,最后到达最后一个段与第一个段相交的点。

var nextIndex = (car.CurrentSegmentIndex < trackLineList.Count - 1) ? 
		car.CurrentSegmentIndex + 1 : 0;
var nextSegment = trackLineList[nextIndex];
.
.
.
var nextTargetPoint = nextSegment.P1;
var dX1 = nextTargetPoint.X - car.CarTranslateTransform.X;
var dY1 = nextTargetPoint.Y - car.CarTranslateTransform.Y;
var h1 = Math.Sqrt(dX1 * dX1 + dY1 * dY1);
distanceFromCarToSegmentP1 = h1;
distanceFromCarToCurrentSegmentP1 = h1;

var carDX = dX + car.Index * 4;
var carDY = dY + car.Index * 4;

var angle = GetAngleFromDXDY(h, carDX, carDY);

targetTrackAngle = angle;

Track Directions

图 11. 每段赛道都有一个角度和方向。

一旦虚拟赛车手知道正确的方向,我们就应该给他们一个目标。比赛的主要目标当然是冲过终点线。但如果你仔细观察,比赛目标可以分解为更小的目标,那就是尽快完成每一段。

通过“完成每一段”,我们必须理解为向当前段的末尾行驶。现在我们有了段的方向和汽车的方向。有了这些信息,就可以调整汽车的方向;然后汽车可以对齐,使其沿着从汽车当前位置开始并以下一段末尾结束的直线行驶。

总是追逐当前段末尾的问题在于,当您到达该点时,已经太晚调整方向以适应下一段了。在现实世界中,当您接近段的末尾时,您必须已经开始进入下一段。

//by default, the cars must go to the end of the current segment...
var targetPoint = targetSegment.P2;
var dX = targetPoint.X - car.CarTranslateTransform.X;
var dY = targetPoint.Y - car.CarTranslateTransform.Y;
var h = Math.Sqrt(dX * dX + dY * dY);
distanceFromCarToSegmentP2 = h;

if (distanceFromCarToSegmentP2 < 200)
{
	//...but if a car get closer to the end of the current segment, 
	//it must go to the end of the next segment
	targetPoint = nextSegment.P2;
	dX = targetPoint.X - car.CarTranslateTransform.X;
	dY = targetPoint.Y - car.CarTranslateTransform.Y;
	h = Math.Sqrt(dX * dX + dY * dY);
	distanceFromCarToSegmentP2 = h;
}

Track Directions 2

图 12. 黑箭头表示汽车应该在完成当前段之前瞄准下一段的末尾。

执行曲线

在现实世界中,您不能高速过弯,在游戏中也是如此。如果您不及时减速,您一定会冲出赛道。所以,建议在直道上达到最高速度,并在接近弯道时减速。

Performing Curves

图 13. 过弯需要降低速度。否则汽车会冲出赛道,卡在草地上。

停靠位置

大多数赛车游戏都提供了一个“屏幕显示”,您可以在其中看到赛道地图,上面有对应于比赛竞争对手相对位置的点。此应用程序也不例外。对于此功能,我们只需将原始赛道用户控件(即Interlagos.xaml文件中描述的相同用户控件)显示在屏幕顶部。此外,我们创建了一些带有不同颜色的小圆圈,代表竞争对手。结果,我们获得了一种酷炫实用的赛道导航方式!

Spotting Positions

图 14. 地图是赛道导航的有用方式。

最初,我们为比赛中的每辆车创建一个圆圈。

foreach (var kart in kartList)
{
	var ell = new Ellipse()
	{
		Width = 16,
		Height = 16,
		Stroke = new SolidColorBrush(Colors.White),
		StrokeThickness = 2,
		Fill = new SolidColorBrush(kart.BodyColor2),
		Margin = new Thickness(-8, -8, 8, 8),
		HorizontalAlignment = HorizontalAlignment.Left,
		VerticalAlignment = VerticalAlignment.Top
	};
 
	ell.RenderTransform = new TranslateTransform() { X = 0, Y = 0 };
 
	mapCarPositionMarkerList.Add(ell);
	grdMap.Children.Add(ell);  

然后,在游戏循环运行时,我们用相应的汽车位置更新每个圆圈。

var mapCarPositionMarker = mapCarPositionMarkerList[car.Index];
var tt = (TranslateTransform)mapCarPositionMarker.RenderTransform;
 
tt.X = nearestTrackPoint.X / 16.0 - 12.0;
tt.Y = nearestTrackPoint.Y / 16.0 - 12.0;  

统计面板

统计面板是另一种屏幕显示。它为用户提供了有关经过时间、位置、速度、领跑者、圈数和剩余圈数的有用信息。

Stats Panel

图 15. 统计面板:您知道发生了什么。

在这里,我们可以看到统计面板在代码不同位置的不同时间点是如何更新的。

statsPanel.Laps = car.Laps;
statsPanel.LapsToGo = TOTAL_LAPS - car.Laps;
.
.
.
statsPanel.Time = new DateTime(diffTimeSpan.Ticks);
statsPanel.Speed = ((car.Speed / METERS_PER_TRACK_SEGMENT) / 
	(gameLoopTimer.Interval.TotalMilliseconds / 1000.0)) * 3.6;
statsPanel.Laps = car.Laps;
statsPanel.LapsToGo = TOTAL_LAPS - car.Laps;
.
.
.
foreach (var pair in orderByVal)
{
	if (pos == 1)
		statsPanel.Leader = kartList[pair.Key].PilotName;
 
		if (pair.Key == 0)
		{
			statsPanel.Position = pos;
		}
		pos--;
}  

完成比赛

当某个车手最终完成所有 5 圈时,比赛就赢了。当这种情况发生时,他的名字将以大而粗的消息显示在屏幕上,并且所有汽车都会减速。这给人一种真实的效果,即车手在真实比赛结束时自然会减慢他们的汽车。

Finishing The Race

图 16. 第一个冲过终点线的车手将被加冕为冠军。

当汽车刚刚离开最后一段赛道并进入第一段赛道,最终完成了所有 5 圈时,应用程序就知道该汽车赢得了比赛。

if (car.NearestTrackLineSegment.Index != car.LastNearestTrackLineSegment.Index)
{
	if (car.NearestTrackLineSegment.Index == 
			car.LastNearestTrackLineSegment.Index + 1)
	{
		car.CircuitOffset += car.LastNearestTrackLineSegment.Length;
	}
	else if ((car.NearestTrackLineSegment.Index == 0) &&
	(car.LastNearestTrackLineSegment.Index == trackLineList.Count - 1))
	{
		if (car.CircuitOffset &gt (circuitLength - 
				car.LastNearestTrackLineSegment.Length))
		{
			car.Laps++;
			car.CircuitOffset = 0;

			if (!gameOver &&
			(TOTAL_LAPS == car.Laps))
			{
				gameOver = true;
				var winner = GetWinner();
				txtLargeMessage1.Text =
				txtLargeMessage2.Text = string.Format("{0} Wins!", 
							winner.PilotName);

				txtSmallMessage1.Text =
				txtSmallMessage2.Text = "Click [Continue] 
						to start another race";
 
				pnlMessage.Visibility = Visibility.Visible;
			}
		}
 
		car.CircuitOffset += car.LastNearestTrackLineSegment.Length;
		}
	}  

最终思考

就这样!正如文章开头所说,WPF 提供了工具。但最终能否充分利用它们取决于我们。

我非常感谢您的时间和耐心。您的反馈对我来说非常宝贵,请在下方留言,告诉我您喜欢和不喜欢这个应用程序的地方。

历史

  • 2010-11-30:初始版本
  • 2010-12-05:在赛道转弯处添加了贝塞尔曲线。增强了 A.I. 驾驶逻辑。增强了摄像机移动逻辑。
  • 2010-12-08:文章格式化,添加了图片,添加了注释。
  • 2010-12-15:解释了 A.I.(完成段落)。
© . All rights reserved.