用于游戏和其他项目的六边形网格 - 第一部分






4.88/5 (32投票s)
用于游戏或其他应用的六边形网格的第一个版本。
引言
我的项目目标是创建一个模块化、可重用的基于六边形的地图,可用于简单的游戏和 ALife 应用程序。我想尽可能多地利用 .NET 的功能,这意味着使用 GDI+ 和 Forms。使用 GDI+ 绘制形状和使用 Forms 捕获鼠标事件都相当简单,这将使我能够将编程时间花在解决更重要的问题上(比如六边形几何!)。这是六边形地图的第一个“版本”,远未完成。
六边形
与简单的基于正方形的游戏(如国际跳棋棋盘)相比,基于六边形的游戏,无论是传统的棋盘游戏还是基于电脑的游戏,都能提供更具战略性和战术性的游戏玩法。六边形有六条边,这允许六个方向的移动,而不是四个。从一个六边形的中心到每个相邻六边形的中心的距离相等,这消除了传统基于正方形的地图中计算对角线距离的失真。六边形看起来更令人愉悦,这算是一件好事,对吧?
我的核心代码基于六边形的几何结构。当我使用“六边形”这个词时,我实际上指的是正六边形,它是一个六边形多边形,所有六条边的长度都相同。基于六边形的地图的美妙之处在于,你实际上只需要知道一件事:六边形的边长。之后,你可以计算出你需要知道的一切。
如果你知道边长 s,那么你可以计算出 r 和 h。a 和 b 的值几乎不相关,因为你可以从 s、r 和 h 计算它们,而且你根本不需要 a 和 b 进行任何计算。那么,你如何找到 r 和 h 呢?
h = sin( 30°) * s
r = cos( 30°) * s
b = s + 2 * h
a = 2 * r
我的命名空间是 Hexagonal
由于没有更好的术语,我将我的命名空间命名为 Hexagonal
,所有核心类都驻留在此处。Math
类有一堆静态方法来处理几何计算。有些人可能会争辩说这些是三角计算,但就我的目的而言,三角学是几何学的一个子集。
public static float CalculateH(float side)
{
return ConvertToFloat(System.Math.Sin(DegreesToRadians(30)) * side);
}
public static float CalculateR(float side)
{
return ConvertToFloat(System.Math.Cos(DegreesToRadians(30)) * side);
}
public static double DegreesToRadians(double degrees)
{
return degrees * System.Math.PI / 180;
}
System.Math
中的 Sin
和 Cos
方法以弧度而不是度数作为参数。因此,我们需要一个辅助方法将度数转换为弧度。
Hex
对象表示一个六边形。创建 Hex
对象时,你需要知道几件事——边长、上顶点的 x,y 坐标以及六边形的方向。我引入了方向的概念,以便六边形可以平坦侧朝下或尖锐侧朝下创建。方向将影响顶点的计算方式。
顶点在我这里有些随意编号,但我们需要以某种方式引用顶点。Hex
中重要的方法是 CalculateVertices()
,它是 private
并在构造函数中调用。我还为六边形方向创建了一个枚举。
public class Hex
{
private System.Drawing.PointF[] points;
private float side;
private float h;
private float r;
private Hexagonal.HexOrientation orientation;
private float x;
private float y;
...
private void CalculateVertices()
{
h = Hexagonal.Math.CalculateH(side);
r = Hexagonal.Math.CalculateR(side);
switch (orientation)
{
case Hexagonal.HexOrientation.Flat:
// x,y coordinates are top left point
points = new System.Drawing.PointF[6];
points[0] = new PointF(x, y);
points[1] = new PointF(x + side, y);
points[2] = new PointF(x + side + h, y + r);
points[3] = new PointF(x + side, y + r + r);
points[4] = new PointF(x, y + r + r);
points[5] = new PointF(x - h, y + r );
break;
case Hexagonal.HexOrientation.Pointy:
//x,y coordinates are top center point
points = new System.Drawing.PointF[6];
points[0] = new PointF(x, y);
points[1] = new PointF(x + r, y + h);
points[2] = new PointF(x + r, y + side + h);
points[3] = new PointF(x, y + side + h + h);
points[4] = new PointF(x - r, y + side + h);
points[5] = new PointF(x - r, y + h);
break;
default:
throw new Exception("No HexOrientation defined for Hex object.");
}
}
}
public enum HexOrientation
{
Flat = 0,
Pointy = 1,
}
Hex
类被设计得非常简单。它所做的只是记住它在二维空间中的位置。Board
类是 Hex
对象的集合,表示一个游戏板。对于这个第一个版本,唯一可以创建的板是矩形的。使用二维数组可以相当简单地将六边形排列成矩形形状。例如,具有 Flat
方向的板将映射到这样的二维数组:
Board
类中最重要的方法是 Initialize()
,它是 private
并在构造函数中调用。Initialize()
创建一个二维 Hex
对象数组,其中包含所有六边形顶点的计算。
public class Board
{
private Hexagonal.Hex[,] hexes;
private int width;
private int height;
private int xOffset;
private int yOffset;
private int side;
private float pixelWidth;
private float pixelHeight;
private Hexagonal.HexOrientation orientation;
...
private void Initialize(int width, int height, int side,
Hexagonal.HexOrientation orientation,
int xOffset, int yOffset)
{
this.width = width;
this.height = height;
this.xOffset = xOffset;
this.yOffset = yOffset;
this.side = side;
this.orientation = orientation;
hexes = new Hex[height, width];
//opposite of what we'd expect
this.boardState = new BoardState();
// short side
float h = Hexagonal.Math.CalculateH(side);
// long side
float r = Hexagonal.Math.CalculateR(side);
//
// Calculate pixel info..remove?
// because of staggering, need to add an extra r/h
float hexWidth = 0;
float hexHeight = 0;
switch (orientation)
{
case HexOrientation.Flat:
hexWidth = side + h;
hexHeight = r + r;
this.pixelWidth = (width * hexWidth) + h;
this.pixelHeight = (height * hexHeight) + r;
break;
case HexOrientation.Pointy:
hexWidth = r + r;
hexHeight = side + h;
this.pixelWidth = (width * hexWidth) + r;
this.pixelHeight = (height * hexHeight) + h;
break;
default:
break;
}
bool inTopRow = false;
bool inBottomRow = false;
bool inLeftColumn = false;
bool inRightColumn = false;
bool isTopLeft = false;
bool isTopRight = false;
bool isBotomLeft = false;
bool isBottomRight = false;
// i = y coordinate (rows), j = x coordinate
// (columns) of the hex tiles 2D plane
for (int i = 0; i < height; i++)
{
for (int j = 0; j < width; j++)
{
// Set position booleans
#region Position Booleans
if (i == 0)
{
inTopRow = true;
}
else
{
inTopRow = false;
}
if (i == height - 1)
{
inBottomRow = true;
}
else
{
inBottomRow = false;
}
if (j == 0)
{
inLeftColumn = true;
}
else
{
inLeftColumn = false;
}
if (j == width - 1)
{
inRightColumn = true;
}
else
{
inRightColumn = false;
}
if (inTopRow && inLeftColumn)
{
isTopLeft = true;
}
else
{
isTopLeft = false;
}
if (inTopRow && inRightColumn)
{
isTopRight = true;
}
else
{
isTopRight = false;
}
if (inBottomRow && inLeftColumn)
{
isBotomLeft = true;
}
else
{
isBotomLeft = false;
}
if (inBottomRow && inRightColumn)
{
isBottomRight = true;
}
else
{
isBottomRight = false;
}
#endregion
//
// Calculate Hex positions
//
if (isTopLeft)
{
//First hex
switch (orientation)
{
case HexOrientation.Flat:
hexes[0, 0] = new Hex(0 + h + xOffset,
0 + yOffset, side, orientation);
break;
case HexOrientation.Pointy:
hexes[0, 0] = new Hex(0 + r + xOffset,
0 + yOffset, side, orientation);
break;
default:
break;
}
}
else
{
switch (orientation)
{
case HexOrientation.Flat:
if (inLeftColumn)
{
// Calculate from hex above
hexes[i, j] = new Hex(hexes[i - 1, j].
Points[(int)Hexagonal.FlatVertice.BottomLeft],
side, orientation);
}
else
{
// Calculate from Hex to the left
// and need to stagger the columns
if (j % 2 == 0)
{
// Calculate from Hex to left's
// Upper Right Vertice plus h and R offset
float x = hexes[i, j - 1].Points[
(int)Hexagonal.FlatVertice.UpperRight].X;
float y = hexes[i, j - 1].Points[
(int)Hexagonal.FlatVertice.UpperRight].Y;
x += h;
y -= r;
hexes[i, j] = new Hex(x, y, side, orientation);
}
else
{
// Calculate from Hex to left's Middle Right Vertice
hexes[i, j] = new Hex(hexes[i, j - 1].Points[
(int)Hexagonal.FlatVertice.MiddleRight],
side, orientation);
}
}
break;
case HexOrientation.Pointy:
if (inLeftColumn)
{
// Calculate from hex above and need to stagger the rows
if (i % 2 == 0)
{
hexes[i, j] = new Hex(hexes[i - 1, j].Points[
(int)Hexagonal.PointyVertice.BottomLeft],
side, orientation);
}
else
{
hexes[i, j] = new Hex(hexes[i - 1, j].Points[
(int)Hexagonal.PointyVertice.BottomRight],
side, orientation);
}
}
else
{
// Calculate from Hex to the left
float x = hexes[i, j - 1].Points[
(int)Hexagonal.PointyVertice.UpperRight].X;
float y = hexes[i, j - 1].Points[
(int)Hexagonal.PointyVertice.UpperRight].Y;
x += r;
y -= h;
hexes[i, j] = new Hex(x, y, side, orientation);
}
break;
default:
break;
}
}
}
}
}
}
public enum FlatVertice
{
UpperLeft = 0,
UpperRight = 1,
MiddleRight = 2,
BottomRight = 3,
BottomLeft = 4,
MiddleLeft = 5,
}
public enum PointyVertice
{
Top = 0,
UpperRight = 1,
BottomRight = 2,
Bottom = 3,
BottomLeft = 4,
TopLeft = 5,
}
此方法首先在数组位置 0,0 处创建一个 Hex
。创建 Hex
对象后,可以创建每个其他 Hex
,因为 Hex
的某个顶点也是另一个 Hex
的顶点。因此,你可以从上到下、从左到右遍历二维数组,创建 Hex
。方向将影响计算。我还创建了枚举来给顶点赋予友好的名称。需要注意的是,我们有一个 Hex
对象的二维数组,我们还在计算六边形的 x,y 像素坐标,所以当你看到 x,y 或 i,j 或 0,0 时很容易混淆。
上面的 Hex
和 Board
代码不完整,你必须下载源代码才能查看所有内容。这里要显示的东西太多了。我只向你展示了执行重要工作的核心方法。Board
的 Initialize()
方法中的位置布尔值并非严格必要,也并非所有都使用,但我目前保留了它们。
public class GraphicsEngine
{
private Hexagonal.Board board;
private float boardPixelWidth;
private float boardPixelHeight;
private int boardXOffset;
private int boardYOffset;
...
public void Draw(Graphics graphics)
{
int width = Convert.ToInt32(System.Math.Ceiling(board.PixelWidth));
int height = Convert.ToInt32(System.Math.Ceiling(board.PixelHeight));
// seems to be needed to avoid bottom and right from being chopped off
width += 1;
height += 1;
//
// Create drawing objects
//
Bitmap bitmap = new Bitmap(width, height);
Graphics bitmapGraphics = Graphics.FromImage(bitmap);
Pen p = new Pen(Color.Black);
SolidBrush sb = new SolidBrush(Color.Black);
//
// Draw Board background
//
sb = new SolidBrush(board.BoardState.BackgroundColor);
bitmapGraphics.FillRectangle(sb, 0, 0, width, height);
//
// Draw Hex Background
//
for (int i = 0; i < board.Hexes.GetLength(0); i++)
{
for (int j = 0; j < board.Hexes.GetLength(1); j++)
{
bitmapGraphics.FillPolygon(new SolidBrush(board.Hexes[i,j].
HexState.BackgroundColor), board.Hexes[i, j].Points);
}
}
//
// Draw Hex Grid
//
p.Color = board.BoardState.GridColor;
p.Width = board.BoardState.GridPenWidth;
for (int i = 0; i < board.Hexes.GetLength(0); i++)
{
for (int j = 0; j < board.Hexes.GetLength(1); j++)
{
bitmapGraphics.DrawPolygon(p, board.Hexes[i, j].Points);
}
}
//
// Draw Active Hex, if present
//
if (board.BoardState.ActiveHex != null)
{
p.Color = board.BoardState.ActiveHexBorderColor;
p.Width = board.BoardState.ActiveHexBorderWidth;
bitmapGraphics.DrawPolygon(p, board.BoardState.ActiveHex.Points);
}
//
// Draw internal bitmap to screen
//
graphics.DrawImage(bitmap, new Point(this.boardXOffset,
this.boardYOffset));
//
// Release objects
//
bitmapGraphics.Dispose();
bitmap.Dispose();
}
GraphicsEngine
类接收一个 Board
对象并使用 GDI+ 将其写入屏幕。我不会花太多时间解释 GDI+,但 Draw()
方法接受一个从调用窗体派生的 Graphics
对象。然后 Draw()
将 Board
和 Hex
写入位图变量,最后将该位图显示到屏幕。你会注意到 HexState
和 BoardState
类分别是 Hex
和 Board
类的属性。HexState
和 BoardState
类包含关于 Hex
或 Board
的状态类型信息。在这种情况下,状态信息是颜色。这些类并非严格必要,但我想尽可能保持 Hex
和 Board
类的纯粹性,这意味着它们只包含关于几何和像素的信息。这样,状态信息就被分离出来,可以独立开发。
在表单中将所有内容整合在一起
为了使这一切工作,你需要创建一个带有 GraphicsEngine
对象和 Board
对象的 Form
。然后,为窗体的 Paint
事件创建一个处理程序。
private void Form_Paint(object sender, PaintEventArgs e)
{
foreach (Control c in this.Controls)
{
c.Refresh();
}
if (graphicsEngine != null)
{
graphicsEngine.Draw(e.Graphics);
}
//Force the next Paint()
this.Invalidate();
}
另一种选择是重写表单的 OnPaint
方法。我见过这两种做法,但我决定保留 OnPaint
。我不确定哪种方法最好,但它们都有效。此外,表单的 DoubleBuffered
属性需要设置为 true
。这可以在代码中完成,也可以在设计器中完成。双缓冲可以防止在屏幕上绘图时出现闪烁(将其设置为 false
看看会发生什么)。
要捕获鼠标点击,请为窗体的 MouseClick
或 MouseDown
事件创建一个处理程序。
private void Form_MouseClick(object sender, MouseEventArgs e)
{
if (board != null && graphicsEngine != null)
{
//
// need to account for any offset
//
Point mouseClick = new Point(e.X - graphicsEngine.BoardXOffset,
e.Y - graphicsEngine.BoardYOffset);
Hex clickedHex = board.FindHexMouseClick(mouseClick);
if (clickedHex == null)
{
board.BoardState.ActiveHex = null;
}
else
{
board.BoardState.ActiveHex = clickedHex;
if (e.Button == MouseButtons.Right)
{
clickedHex.HexState.BackgroundColor = Color.Blue;
}
}
}
}
GraphicsEngine
可以做的一件事是跟踪 x,y 偏移量,以便 Board
对象可以绘制在窗体上的任何位置。如果存在偏移量,鼠标点击需要考虑该偏移量并将新的 x,y 值传递给 Board
的 FindHexMouseClick()
方法。FindHexMouseClick()
方法非常重要,因为它将 x,y 像素坐标转换为 Board/Hex
坐标。有几种方法可以将像素转换为六边形坐标,谷歌“像素到六边形”。我发现了一个非常巧妙的算法,它适用于任何多边形,而不仅仅是六边形。该算法通过绘制穿过多边形边缘的线来确定一个点是否位于多边形内。可以在此处找到完整的描述。我的实现位于我的 Math
类中。
public static bool InsidePolygon(PointF[] polygon, int N, PointF p)
{
int counter = 0;
int i;
double xinters;
PointF p1,p2;
p1 = polygon[0];
for (i=1;i<=N;i++)
{
p2 = polygon[i % N];
if (p.Y > System.Math.Min(p1.Y,p2.Y))
{
if (p.Y <= System.Math.Max(p1.Y,p2.Y))
{
if (p.X <= System.Math.Max(p1.X,p2.X))
{
if (p1.Y != p2.Y)
{
xinters = (p.Y-p1.Y)*(p2.X-p1.X)/(p2.Y-p1.Y)+p1.X;
if (p1.X == p2.X || p.X <= xinters)
counter++;
}
}
}
}
p1 = p2;
}
if (counter % 2 == 0)
return false;
else
return true;
}
结论
请下载源代码项目,因为不可能将每一行代码都包含在这篇文章中。我的源代码是一个 Visual Studio 2005 控制台项目。控制台项目实际上会启动窗体。我这样做是因为你可以从窗体调用 Console.WriteLine()
并向控制台发送消息,这显然有助于调试。
这是 Hexagonal
命名空间的第一个“版本”。我想在有时间的时候添加更多功能。我想添加的一些功能包括渐变背景和图像、可滚动板以及不同形状的板。能够将 Board
对象序列化为 XML 以进行存储也会很好。欢迎评论和建议。