DirectX 棋盘游戏引擎






4.64/5 (17投票s)
一篇关于如何创建像跳棋或国际象棋这样的棋盘游戏的通用引擎的文章。
引言
本文试图描述一种创建类集的方法,这些类集足够通用,可以轻松实现多种棋盘游戏。只有实际的游戏逻辑(游戏规则)需要随着实现的改变而改变。
更新时间
本文已更新两项内容
- API 的通用清理,包括一个通用的
System.Windows.Forms.Form
实现,进一步缩短了实现时间。 - Connect Four 的实现
这两项新增内容将在本文中进一步讨论。
要求
在开始这个项目时,我为最终代码设定了一系列必须满足的要求。
- 实现必须足够通用,以便在不修改棋盘游戏实现的情况下,可以创建跳棋和国际象棋的实现。
- 游戏的可视化必须使用 DirectX 进行 3D 渲染。
- 在实现新的棋盘游戏时,实现者不需要具备任何 3D 数学或 Direct3D 知识。
- 实现不应该需要“游戏循环”,因为这对于不熟悉游戏实现的人来说通常是一个不熟悉的概念。
- 应该使用
System.Window.Forms.Panel
组件来渲染游戏,以便像其他可视化组件一样将其放置在窗体上。
Using the Code
Visual Studio 解决方案由两个项目组成:
- 一个包含适用于任何棋盘游戏的通用代码的类库(我将称其为“框架”)
- 一个演示棋盘游戏库的 Windows 应用程序,使用了跳棋的实现。
游戏逻辑(在本例中为跳棋实现)必须实现一个名为 IBoardGameLogic
的接口,该接口定义如下:
using System;
using System.Collections.Generic;
namespace Bornander.Games.BoardGame
{
public interface IBoardGameLogic
{
/// <summary>
/// Number of rows on the board.
/// </summary>
int Rows
{
get;
}
/// <summary>
/// Number of columns on the board.
/// </summary>
int Columns
{
get;
}
/// <summary>
/// Returns the state (the piece) at a specific row and column.
/// </summary>
/// The row on the board (0 based).
/// The column on the board (0 based).
/// <returns>A value indicating the
/// piece that occupies this row and column.
/// For empty squares zero should be returned.</returns />
int this[int row, int column]
{
get;
set;
}
/// <summary>
/// This method returns the same as int this[int row, int column].
/// </summary>
int this[Square square]
{
get;
set;
}
/// <summary>
/// Return the currently available moves.
/// </summary>
List<move /> GetAvailableMoves();
/// <summary>
/// Initializes the game to its start state.
/// </summary>
void Initialize();
/// <summary>
/// Used to determine if the game is over.
/// </summary>
/// A string describing the game
/// over state if the game is over, example "Player one lost!".
/// <returns>True if the game is over.</returns>
bool IsGameOver(out string gameOverStatement);
/// <summary>
/// Moves a piece on the board.
/// </summary>
/// From square.
/// To square.
/// <returns>The available moves after
/// the move was made.</returns>
List<move> Move(Square square, Square allowedSquare);
}
}
通过暴露这几个方法,框架可以控制游戏流程并确保遵循游戏规则。但是,还有一件事缺少:框架无法弄清楚要显示什么。它可以推断出每个方格的状态,但它没有任何关于屏幕上应该渲染什么来可视化该状态的信息。这可以通过向框架提供另一个名为 IBordGameModelRepository
的接口实例来解决。
using System;
using Bornander.Games.Direct3D;
namespace Bornander.Games.BoardGame
{
public interface IBoardGameModelRepository
{
/// <summary>
/// Initializes the Models.
/// </summary>
void Initialize(Microsoft.DirectX.Direct3D.Device device);
/// <summary>
/// Returns the visual representation for the board square
/// at the location given by the Square.
/// This is for example either a black box or a white box for
/// a chess implementation.
/// </summary>
Model GetBoardSquareModel(Square square);
/// <summary>
/// Returns the visual representation for a specific id.
/// This is for example the 3D model of a pawn in a chess
/// implementation.
/// </summary>
Model GetBoardPieceModel(int pieceId);
}
}
通过将 IBoardGameLogic
和 IBoardGameModelRepository
接口分开,我们可以允许游戏逻辑与视觉表示完全解耦。这一点很重要,因为我们可能希望将此游戏移植到 Windows Mobile 设备等平台,在这些平台上,2D 表示比 3D 表示更受欢迎。
现在框架已经拥有了渲染游戏状态所需的所有信息,是时候考虑渲染实现了。几乎所有的游戏渲染都由 VisualBoard
处理。该类查询 IBoardGameLogic
,并结合 IBoardGameModelRepository
返回的 Model
来渲染棋盘和棋子。
VisualBoard
不渲染的一个元素是当前选中的棋子,即用户当前正在拖动的棋子。另一个名为 GamePanel
的类(继承自 System.Windows.Forms.Panel
)负责处理输入以及在棋盘上选择和移动棋子。这种实现方式可能会降低 GamePanel
类的内部内聚性,但我选择这样做是因为我希望 VisualBoard
渲染棋盘游戏的状态。该状态对当前正在移动的棋子一无所知。
类概述
棋盘游戏类库
这些是类库中的类:
GamePanel
继承自System.Windows.Forms.Panel
,负责处理用户输入、DirectX 设置和部分渲染。VisualBoard
负责渲染IBoardGameLogic
。IBoardGameLogic
是一个接口,代表棋盘游戏规则。IBoardGameModelRepository
是一个接口,代表用于渲染IBoardGameLogic
的 3D 模型存储库。Move
代表从特定Square
开始的所有可能移动。Square
通过保存行和列信息来表示棋盘方格。Camera
代表 3D 渲染时使用的相机。Model
将Mesh
、Material
以及位置和方向组合在一起以方便使用。
跳棋应用程序
这些是跳棋应用程序中的类:
CheckersModelRepository
实现IBoardGameModelRepository
,负责返回与渲染跳棋棋盘相关的模型。CheckersLogic
是跳棋游戏逻辑的实现;它实现了CheckersLogic
。
渲染游戏状态
渲染游戏状态相当直接:遍历所有棋盘方格,渲染方格,然后渲染占据该方格的任何棋子。很简单。但是,我们还需要向用户指示哪些移动是有效的。这是通过高亮显示鼠标下的棋盘方格来完成的,如果可以从该方格移动(或者在“持有”棋子时可以移动到该方格)。
需要强调的是,框架假设方格的宽度为 1 单位,深度为 1 单位(高度由游戏开发者决定)。在创建游戏的网格时必须考虑到这一点。为了提供帮助,Model
类包含两个材质:“普通”材质和“高亮”材质。
通过检查模型是否处于“Selected
”状态,在渲染之前,它会将其材质设置为普通或高亮,如下所示:
/// <summary>
/// Array containing two materials, at 0 the normal material
/// and at 1 the highlighted or selected material.
/// </summary>
class Model
{
private Material[] material;
...
public void Render(Device device)
{
device.Transform.World = this.World;
device.Material = material[selected ? 1 : 0];
mesh.DrawSubset(0);
}
}
VisualBoard
仅在其 Render
方法中,在渲染每个棋盘方格模型之前设置其选中状态。
public class VisualBoard
{
...
public void Render(Device device)
{
for (int row = 0; row < gameLogic.Rows; ++row)
{
for (int column = 0; column < gameLogic.Columns; ++column)
{
Square currentSquare = new Square(row, column);
Model boardSquare =
boardGameModelRepository.GetBoardSquareModel
(currentSquare);
boardSquare.Position =
new Vector3((float)column, 0.0f, (float)row);
boardSquare.Selected = currentSquare.Equals(selectedSquare);
boardSquare.Render(device);
// Check that the current piece isn't grabbed by the mouse,
// because in that case we don't render it.
if (!currentPieceOrigin.Equals(currentSquare))
{
// Check which kind of model we need to render,
// move our "template" to the
// right position and render it there.
Model pieceModel =
boardGameModelRepository.GetBoardPieceModel
(gameLogic[currentSquare]);
if (pieceModel != null)
{
pieceModel.Position = new Vector3((float)column,
0.0f, (float)row);
pieceModel.Render(device);
}
}
}
}
}
}
确定哪个方格实际被选中,就是找到鼠标“下方”的方格。在 2D 中,这是一个非常简单的操作,但在 3D 中会稍微复杂一些。我们需要捕获屏幕上的鼠标坐标,并使用投影和视图矩阵将屏幕坐标解投影到 3D 坐标。然后,当我们将鼠标位置转换为 3D 位置时,我们可以向我们所有的棋盘方格 Model
s 发射一条射线,以查看是否发生相交。对于不熟悉 3D 数学的人来说,这段代码可能难以理解。这就是为什么框架必须为我们处理它,以便我们(实现棋盘游戏的开发者)不必担心这些事情。VisualBoard
类中的一个函数处理了所有这些。
/// <summary>
/// This method allows to check for "MouseOver" on Models in 3D space.
/// </summary>
public bool GetMouseOverBlockModel(Device device, int mouseX, int mouseY,
out Square square, List<square /> highlightIfHit)
{
selectedSquare = Square.Negative;
square = new Square();
bool foundMatch = false;
float closestMatch = int.MaxValue;
for (int row = 0; row < gameLogic.Rows; ++row)
{
for (int column = 0; column < gameLogic.Columns; ++column)
{
Square currentSquare = new Square(row, column);
Model boardSquare =
boardGameModelRepository.GetBoardSquareModel(currentSquare);
boardSquare.Position =
new Vector3((float)column, 0.0f, (float)row);
Vector3 near = new Vector3(mouseX, mouseY, 0.0f);
Vector3 far = new Vector3(mouseX, mouseY, 1.0f);
// Unproject a vector from the screen X,Y space into
// 3D space using the World matrix of the mesh we are checking.
near.Unproject(device.Viewport, device.Transform.Projection,
device.Transform.View, boardSquare.World);
far.Unproject(device.Viewport, device.Transform.Projection,
device.Transform.View, boardSquare.World);
far.Subtract(near);
// Find the closes match of all blocks, that is the one
// the mouse is over.
IntersectInformation closestIntersection;
if (boardSquare.Mesh.Intersect(near, far,
out closestIntersection)
&& closestIntersection.Dist < closestMatch)
{
closestMatch = closestIntersection.Dist;
square = new Square(row, column);
// If a list of squares is passed in we are over
// one of those squares we highlight that
// this is used to indicated valid moves to the user
if (highlightIfHit != null)
{
foreach (Square highlightSquare in highlightIfHit)
{
if (highlightSquare.Equals(square))
{
selectedSquare = new Square(row, column);
}
}
}
foundMatch = true;
}
}
}
return foundMatch;
}
处理输入
显然,必须有一种在棋盘上移动棋子。我决定最直观的方法是使用鼠标左键拖放棋子。这实际上是简单棋盘游戏所需的所有输入,但我还希望允许用户从不同角度查看棋盘。这意味着将相机定位在不同的位置。我决定使用鼠标右键拖动来执行此操作,而滚动鼠标滚轮则用于放大和缩小。这意味着我必须在 GamePanel
类中处理 MouseDown
、MouseUp
、MouseMove
和 MouseWheel
事件。
public void HandleMouseWheel(object sender, MouseEventArgs e)
{
// If the user scrolls the mouse wheel we zoom out or in
cameraDistanceFactor = Math.Max(0.0f, cameraDistanceFactor +
Math.Sign(e.Delta) / 5.0f);
SetCameraPosition();
Render();
}
private void GamePanel_MouseMove(object sender, MouseEventArgs e)
{
// Dragging using the right mousebutton moves the camera
// along the X and Y axis.
if (e.Button == MouseButtons.Right)
{
cameraAngle += (e.X - previousPoint.X) / 100.0f;
cameraElevation = Math.Max(0, cameraElevation +
(e.Y - previousPoint.Y) / 10.0f);
SetCameraPosition();
previousPoint = e.Location;
}
Square square;
if (e.Button == MouseButtons.Left)
{
if (ponderedMove != null)
{
if (board.GetMouseOverBlockModel
(device, e.X, e.Y, out square, ponderedMove.Destinations))
{
// Set the dragged pieces location to the current square
selectedPiecePosition.X = square.Column;
selectedPiecePosition.Z = square.Row;
}
}
}
else
{
board.GetMouseOverBlockModel(device, e.X, e.Y, out square,
GamePanel.GetSquaresFromMoves(availableMoves));
}
// Render since we might have moved the camera
Render();
}
private void GamePanel_MouseDown(object sender, MouseEventArgs e)
{
// The previous point has to be set here or the distance dragged
// can be too big.
previousPoint = e.Location;
// If the mouse is over a block (see GetMouseOverBlockModel)
// for details on how determining that
// and the left button is down, try to grab the piece
// (if there is one at the square and it has valid moves).
if (e.Button == MouseButtons.Left)
{
ponderedMove = null;
Square square;
if (board.GetMouseOverBlockModel(device, e.X, e.Y, out square, null))
{
foreach (Move move in availableMoves)
{
// We have a move and it is started
// from the square we're over, start dragging a piece
if (square.Equals(move.Origin))
{
selectedPieceModel = board.PickUpPiece(square);
selectedPiecePosition =
new Vector3(square.Column, 1.0f, square.Row);
ponderedMove = move;
break;
}
}
}
}
Render();
}
private void GamePanel_MouseUp(object sender, MouseEventArgs e)
{
if (e.Button == MouseButtons.Left)
{
Square square;
if (board.GetMouseOverBlockModel(device, e.X, e.Y, out square, null))
{
// ponderedMove keeps track of the current potential move
// that will take place
// if we drop the piece onto a valid square, if ponderedMove
// is not null that means
// we're currently dragging a piece.
if (ponderedMove != null)
{
foreach (Square allowedSquare in ponderedMove.Destinations)
{
// Was it drop on a square that's a legal move?
if (square.Equals(allowedSquare))
{
// Move the piece to the target square
availableMoves = gameLogic.Move
(ponderedMove.Origin, allowedSquare);
break;
}
}
}
}
board.DropPiece();
selectedPieceModel = null;
Render();
CheckForGameOver();
}
}
棋子控制
鼠标方法会检查它们是否作为鼠标左键按下事件的结果被调用。如果是,它们会使用 VisualBoard.GetMouseOverBlockModel
方法来确定事件是否发生在光标位于特定方格上方时。然后,这将用于确定用户是否可以从当前方格拾取棋子或将棋子放到当前方格上。此外,VisualBoard.GetMouseOverBlockModel
内部会自动处理方格高亮。
相机控制
如果鼠标右键在拖动鼠标时处于按下状态,我可以计算两次更新之间的差值,并使用该信息来更新两个成员。当鼠标滚轮滚动时,第三个成员会被更新。
private float cameraAngle = -((float)Math.PI / 2.0f);
private float cameraElevation = 7.0f;
private float cameraDistanceFactor = 1.5f;
GamePanel
中的另一个方法然后使用该信息来计算相机的位置。该位置被限制在棋盘周围的一个圆上(半径在缩放时调整),并且相机沿着 Y 轴被限制,使其永远不会低于零。
private void SetCameraPosition()
{
// Calculate a camera position, this is a radius from the center
// of the board and then cameraElevation up.
float cameraX = gameLogic.Columns /
2.0f + (cameraDistanceFactor * gameLogic.Columns *
(float)Math.Cos(cameraAngle));
float cameraZ = gameLogic.Rows /
2.0f + (cameraDistanceFactor * gameLogic.Rows *
(float)Math.Sin(cameraAngle));
camera.Position = new Vector3(
cameraX, cameraElevation, cameraZ);
}
创建模型
CheckersModelRepository
类用于创建渲染 Checkers
游戏的所有模型。它实现了 IBoardGameModelRepository
,以便框架可以通过 IBoardGameLogic
数据以通用方式访问模型。
class CheckersModelRepository
{
...
public void Initialize(Microsoft.DirectX.Direct3D.Device device)
{
// Create a box to be used as a board square
// The .Clone call is used to get a Mesh that has vertices that
// contain both position, normal and color which I need to render
// them using flat shading.
Mesh blockMesh = Mesh.Box(device, 1.0f, 0.5f, 1.0f).Clone
(MeshFlags.Managed, VertexFormats.PositionNormal |
VertexFormats.Specular, device);
// Create some red and black material and their
// highlighted counterparts.
Material redMaterial = new Material();
redMaterial.Ambient = Color.Red;
redMaterial.Diffuse = Color.Red;
Material highlightedRedMaterial = new Material();
highlightedRedMaterial.Ambient = Color.LightSalmon;
highlightedRedMaterial.Diffuse = Color.LightSalmon;
Material squareBlackMaterial = new Material();
Color squareBlack = Color.FromArgb(0xFF, 0x30, 0x30, 0x30);
squareBlackMaterial.Ambient = squareBlack;
squareBlackMaterial.Diffuse = squareBlack;
Material blackMaterial = new Material();
blackMaterial.Ambient = Color.Black;
blackMaterial.Diffuse = Color.Black;
Material highlightedBlackMaterial = new Material();
highlightedBlackMaterial.Ambient = Color.DarkGray;
highlightedBlackMaterial.Diffuse = Color.DarkGray;
Material[] reds = new Material[]
{ redMaterial, highlightedRedMaterial };
Material[] blacks = new Material[]
{ blackMaterial, highlightedBlackMaterial };
blackSquare = new Model(blockMesh, new Material[]
{ squareBlackMaterial, highlightedBlackMaterial });
redSquare = new Model(blockMesh, reds);
blackSquare.PositionOffset = new Vector3(0.0f, -0.25f, 0.0f);
redSquare.PositionOffset = new Vector3(0.0f, -0.25f, 0.0f);
// Create meshes for the pieces.
Mesh pieceMesh = Mesh.Cylinder(device, 0.4f, 0.4f, 0.2f, 32, 1).Clone
(MeshFlags.Managed, VertexFormats.PositionNormal |
VertexFormats.Specular, device);
Mesh kingPieceMesh =
Mesh.Cylinder(device, 0.4f, 0.2f, 0.6f, 32, 1).Clone
(MeshFlags.Managed, VertexFormats.PositionNormal |
VertexFormats.Specular, device);
redPiece = new Model(pieceMesh, new Material[]
{ redMaterial, redMaterial });
blackPiece = new Model(pieceMesh, new Material[]
{ blackMaterial, blackMaterial });
redKingPiece = new Model(kingPieceMesh, new Material[]
{ redMaterial, redMaterial });
blackKingPiece = new Model(kingPieceMesh, new Material[]
{ blackMaterial, blackMaterial });
redPiece.PositionOffset = new Vector3(0.0f, 0.1f, 0.0f);
redKingPiece.PositionOffset = new Vector3(0.0f, 0.3f, 0.0f);
blackPiece.PositionOffset = new Vector3(0.0f, 0.1f, 0.0f);
blackKingPiece.PositionOffset = new Vector3(0.0f, 0.3f, 0.0f);
// The Mesh.Cylinder creates a cylinder that extends along the Z axis
// but I want it to extend along the Y axis, this is easily fixed by
// rotating it 90 degrees around the X axis.
// First create the rotation...
Quaternion rotation = Quaternion.RotationAxis
(new Vector3(1.0f, 0.0f, 0.0f), (float)Math.PI / 2.0f);
/// ... then apply it to all piece models
redPiece.Orientation = rotation;
blackPiece.Orientation = rotation;
redKingPiece.Orientation = rotation;
blackKingPiece.Orientation = rotation;
}
}
旋转和平移
我将不详细解释 3D 数学如何用于在 3D 空间中旋转和平移对象,但我将解释示例实现中所做的工作。诸如 redPiece.PositionOffset = new Vector3(0.0f, 0.1f, 0.0f);
这样的代码语句用于确保模型的“原点”沿着 Y 轴偏移 0.1
。这样做是因为 Mesh::Cylinder
创建了一个圆柱体,其原点位于圆柱体的中心,而我们需要它位于圆柱体的边缘才能将其正确放置在棋盘上。此外,我们必须围绕 X 轴旋转 90 度(PI / 2 弧度),因为它创建时沿着 Z 轴延伸,而我们希望它沿着 Y 轴延伸。这就是使用此代码的原因:
...
// The Mesh.Cylinder creates a cylinder that extends along the Z axis
// but I want it to extend along the Y axis, this is easily fixed by
// rotating it 90 degrees around the X axis.
// First create the rotation...
Quaternion rotation = Quaternion.RotationAxis(new Vector3(1.0f, 0.0f, 0.0f),
(float)Math.PI / 2.0f);
/// ... then apply it to all piece models
redPiece.Orientation = rotation;
...
顶点格式
拥有一个适合我们目的的顶点格式的 Mesh
也很重要。3D 模型中的顶点可以包含不同的信息,具体取决于其使用方式。至少必须包含位置数据。但是,如果模型要具有颜色,则还必须包含漫射数据。在框架中,使用方向光来着色场景,使其看起来更好。因此,还必须包含法线数据。
从用于创建不同几何网格(如盒子和圆柱体)的 Mesh
上的 static
方法返回的 Mesh
不返回具有我们所需顶点格式的 Mesh
。为了解决这个问题,我们克隆网格并在克隆时传递所需的顶点格式。
// Create a box to be used as a board square
// The .Clone call is used to get a Mesh that has vertices that
// contain both position, normal and color which I need to render them
// using flat shading.
Mesh blockMesh = Mesh.Box(device, 1.0f, 0.5f, 1.0f).Clone(MeshFlags.Managed,
VertexFormats.PositionNormal | VertexFormats.Specular, device);
代码清理
(此部分在版本 2 中添加)
在实现第二个游戏 Connect Four 时,我意识到整个窗体设置都可以重用,因此决定在 API 类库中提供一个实现。这减少了实现游戏所需的实际代码量。然后,Checkers
游戏的窗体创建和启动代码简化为:
is
{
Application.EnableVisualStyles();
Application.SetCompatibleTextRenderingDefault(false);
Application.Run(new GameForm(
new CheckersLogic(),
new CheckersModelRepository(),
"Checkers",
"Checkers, a most excellent game!"));
}
这会创建一个新的 GameForm
对象,并在构造函数中传递 IBoardGameLogic
、IBoardGameModelRepository
、窗口标题和关于文本。为了使 GameForm
类能够显示当前是哪个玩家的回合,我不得不向 IBoardGameLogic
添加另一个方法。我不想让 GameForm
轮询游戏逻辑来获取此信息,因此决定使用回调来实现委托。这需要在 interface
上添加一个新方法,还需要一个 delegate
。
public delegate void NextPlayerHandler(string playerIdentifier);
public interface IBoardGameLogic
{
...
void SetNextPlayerHandler(NextPlayerHandler nextPlayerHandler);
}
现在,窗体实现可以将其方法之一添加为游戏逻辑的 NextPlayerHandler
。由游戏逻辑决定何时指示玩家更改。非常简单!
Connect Four 实现
(此部分在版本 2 中添加)
为了展示实现另一个游戏的容易程度,我决定使用此 API 编写一个 Connect Four 游戏。我选择 Connect Four 是因为它在某些方面与 Checkers
有根本不同。我想表明,无论这些差异如何,实现不仅是可能的,而且实际上相当简单。
最大的不同是,在 Connect Four 中,你不会一开始就把所有棋子都放在棋盘上。相反,你从一堆中拾取棋子,然后将它们放在棋盘上。通过使用比实际棋盘更大的“逻辑”棋盘,我创建了两个区域,可以从中获取无限量的棋子。通过让 IBoardGameModelRepository
为不属于实际棋盘或“堆积”区域的方格返回 null
,GamePanel
可以忽略渲染这些方格。
上面展示的是使用 **非常** 酷的茶壶作为棋子的 Connect Four 实现。Connect Four 游戏实际的游戏逻辑实现非常简单,大约花了从伦敦到布莱顿往返火车的时间。:)
最终结果
那么,最终实现如何达到我设定的要求呢?很遗憾,我必须说我未能完全遵守第 3 项要求,即在实现新的棋盘游戏时,实现者不需要具备任何 3D 数学或 Direct3D 知识。在 CheckersModelRepository
类中可以看到 3D 知识的必要性,其中创建、平移和旋转网格(使用可怕的四元数进行旋转!)。这些都需要至少对 3D 数学有初步了解。
这离一个完整的框架还有很长的路要走,因为它目前不支持计算机玩家。此外,由于我决定不要求任何游戏循环,因此在移动棋子时没有流畅的动画。除此之外,我认为它做得相当不错。一旦框架完全实现,我花了一个小时不到就实现了 Checkers
游戏,我认为这表明使用这个框架实现棋盘游戏很容易。
我感谢任何评论,无论是关于代码还是本文。
历史
- 2007/11/11:第一个版本
- 2007/11/25:第二个版本,添加了 Connect Four 并清理了实现。