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

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

starIconstarIconstarIconstarIcon
emptyStarIcon
starIcon

4.88/5 (32投票s)

2006 年 7 月 26 日

CPOL

7分钟阅读

viewsIcon

316189

downloadIcon

5244

用于游戏或其他应用的六边形网格的第一个版本。

Sample Image - app_screenshot.jpg

引言

我的项目目标是创建一个模块化、可重用的基于六边形的地图,可用于简单的游戏和 ALife 应用程序。我想尽可能多地利用 .NET 的功能,这意味着使用 GDI+ 和 Forms。使用 GDI+ 绘制形状和使用 Forms 捕获鼠标事件都相当简单,这将使我能够将编程时间花在解决更重要的问题上(比如六边形几何!)。这是六边形地图的第一个“版本”,远未完成。

六边形

与简单的基于正方形的游戏(如国际跳棋棋盘)相比,基于六边形的游戏,无论是传统的棋盘游戏还是基于电脑的游戏,都能提供更具战略性和战术性的游戏玩法。六边形有六条边,这允许六个方向的移动,而不是四个。从一个六边形的中心到每个相邻六边形的中心的距离相等,这消除了传统基于正方形的地图中计算对角线距离的失真。六边形看起来更令人愉悦,这算是一件好事,对吧?

我的核心代码基于六边形的几何结构。当我使用“六边形”这个词时,我实际上指的是正六边形,它是一个六边形多边形,所有六条边的长度都相同。基于六边形的地图的美妙之处在于,你实际上只需要知道一件事:六边形的边长。之后,你可以计算出你需要知道的一切。

hexagon geometry

如果你知道边长 s,那么你可以计算出 rhab 的值几乎不相关,因为你可以从 srh 计算它们,而且你根本不需要 ab 进行任何计算。那么,你如何找到 rh 呢?

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 中的 SinCos 方法以弧度而不是度数作为参数。因此,我们需要一个辅助方法将度数转换为弧度。

Hex 对象表示一个六边形。创建 Hex 对象时,你需要知道几件事——边长、上顶点的 x,y 坐标以及六边形的方向。我引入了方向的概念,以便六边形可以平坦侧朝下或尖锐侧朝下创建。方向将影响顶点的计算方式。

Flat and Pointy Hexagons

顶点在我这里有些随意编号,但我们需要以某种方式引用顶点。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 方向的板将映射到这样的二维数组:

two dimensional hexagon array

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 时很容易混淆。

上面的 HexBoard 代码不完整,你必须下载源代码才能查看所有内容。这里要显示的东西太多了。我只向你展示了执行重要工作的核心方法。BoardInitialize() 方法中的位置布尔值并非严格必要,也并非所有都使用,但我目前保留了它们。

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()BoardHex 写入位图变量,最后将该位图显示到屏幕。你会注意到 HexStateBoardState 类分别是 HexBoard 类的属性。HexStateBoardState 类包含关于 HexBoard 的状态类型信息。在这种情况下,状态信息是颜色。这些类并非严格必要,但我想尽可能保持 HexBoard 类的纯粹性,这意味着它们只包含关于几何和像素的信息。这样,状态信息就被分离出来,可以独立开发。

在表单中将所有内容整合在一起

为了使这一切工作,你需要创建一个带有 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 看看会发生什么)。

要捕获鼠标点击,请为窗体的 MouseClickMouseDown 事件创建一个处理程序。

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 值传递给 BoardFindHexMouseClick() 方法。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 以进行存储也会很好。欢迎评论和建议。

© . All rights reserved.