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

实现 2048 游戏的演练

starIconstarIconstarIconstarIconstarIcon

5.00/5 (9投票s)

2017年3月3日

CPOL

4分钟阅读

viewsIcon

38367

downloadIcon

558

C# 中制作简单 2048 控制台应用程序的介绍

引言

在本文中,我想花一些时间来讨论如何在控制台应用程序中实现一个简单的 2048 游戏,包括一些可以简化实现的实用技巧。

该项目针对 .NET Core 和 .NET Standard 1.6。 但是,它可以很容易地适应其他 .NET Framework 版本。 完整的源代码可以在 GitHub 上查看。

背景

2048 游戏的实现最初是作为 Heuristic Suite 的一个示例项目创建的,该项目是 A* 算法的通用实现,并使用该算法尽可能地达到最佳分数。 然而,在开始示例项目之前,验证实际游戏中的游戏逻辑是必要的,这成为了本文的来源。

游戏循环

一切都从游戏循环开始。 循环非常简单,很多人可能都知道。 最初,我们在棋盘上有 2 个随机位置的数字。 当数字向四个方向之一移动时,将添加一个新数字,该数字可以是 2 或 4。 同时,同一行或同一列上的一对相同数字将被合并并求和。 如果棋盘已满且没有更多移动可用,则游戏结束。

Game Loop

棋盘

棋盘状态存储在一个二维整数数组中,其中 0 表示空位。

var array = new int[BoardSize][BoardSize];

然后,为棋盘上的每个位置定义一个 Position 结构,其中 Empty 表示不存在的位置。

public struct Position : IEquatable<Position>
{
    public static readonly Position Empty = new Position(int.MinValue, int.MinValue);
    
    public int Col { get; private set; }
    public int Row { get; private set; } 
    public bool IsEmpty { get; private set; }
    
    public Position(int col, int row)
    {
        this.Col = col;
        this.Row = row;
        this.IsEmpty = col == int.MinValue && row == int.MinValue;
    }
    
    public bool Equals(Position other)
    {
        return this.Col == other.Col && this.Row == other.Row;
    }
    
    public override int GetHashCode()
    {
        return this.Col ^ (0 - this.Row);
    }
}

现在,我们可以通过以下实现将一个位置随机的数字放到棋盘上。 调用者可以决定要将哪个数字放到棋盘上,或者让数字随机化。

private static readonly Random rand = new Random(Environment.TickCount);

public const int BoardSize = 4

public static Position PutNumberOnArray(int[][] board, int? number = null)
{
    if (CountPlaces(board) == BoardSize * BoardSize) // no empty place to put new number
        return Position.Empty;
        
    var pos = new Position(rand.Next(BoardSize), rand.Next(BoardSize));
    
    while (board[pos.Row][pos.Col] != 0)
        pos = new Position(rand.Next(BoardSize), rand.Next(BoardSize));
        
    if (number == null)
        number = rand.Next(4) != 1 ? 2 : 4; // 3/4 chance to get 2, 1/4 chance to get 4
        
    board[pos.Row][pos.Col] = number.Value;
    
    return pos;
}

因为初始棋盘状态需要有两个数字,所以我们实现以下方法,该方法调用 PutNumberOnArray 两次。

public static int[][] InitalizeArray()
{
    var board = Enumerable.Repeat(BoardSize, BoardSize).Select(size => new int[size]).ToArray();
    
    PutNumberOnArray(board, 2);
    PutNumberOnArray(board, 2);
    
    return board;
}

四个移动方向

现在,让我们来看看如何在棋盘上四个方向移动数字,这是实现的最重要部分。

在同一行或同一列上移动数字可以分解为两个阶段

  1. 合并并求和每一对相同的相邻数字,或未被其他数字中断的相同数字。
  2. 将剩余数字向该方向移动。

Example: Moving Left

MoveLeft 是最容易实现的一个。 我们有以下方法操作一行,其中第一阶段是扫描并求和每一对相同的数字,第二阶段是将剩余的数字向左移动。(请注意,参数类型是 IList(T) 而不是实际的数组。这种小小的灵活性将在以后对我们有很大帮助。)

public static bool MoveLeft(IList<int> row)
{
    // Phase 1: merge numbers
    var col = -1;
    var length = row.Count;
    var modified = false;
    
    for (var y = 0; y < length; y++)
    {
        if (row[y] == 0)
            continue;
        if (col == -1)
        {
            col = y; // remember current col
            continue;
        }
        if (row[col] != row[y])
        {
            col = y; // update
            continue;
        }
        if (row[col] == row[y])
        {
            row[col] += row[y]; // merge same numbers
            row[y] = 0;
            col = -1; // reset
            modified = true;
        }
    }
    // Phase 2: move numbers
    for (var i = 0; i < length * length; i++)
    {
        var y = i % length;
        
        if (y == length - 1) continue;
        if (row[y] == 0 && row[y + 1] != 0) // current is empty and next is not 
        {
            row[y] = row[y + 1]; // move next to current
            row[y + 1] = 0;
            modified = true;
        }
    }
    return modified;
}

MoveRightMoveLeft 非常相似,其中两个 for 循环以相反的方式操作。

public static bool MoveRight(IList<int> row)
{
    // Phase 1: merge numbers
    var col = -1;
    var length = row.Count;
    var modified = false;
    
    for (var y = length - 1; y >= 0; y--)
    {
        if (row[y] == 0)
            continue;
        if (col == -1)
        {
            col = y; // remember current col
            continue;
        }
        if (row[col] != row[y])
        {
            col = y; // update
            continue;
        }
        if (row[col] == row[y])
        {
            row[col] += row[y]; // merge same numbers
            row[y] = 0;
            col = -1; // reset
            modified = true;
        }
    }
    // Phase 2: move numbers
    for (var i = length * length - 1; i >= 0; i--)
    {
        var y = i % length;
        
        if (y == 0) continue;
        if (row[y] == 0 && row[y - 1] != 0) // current is empty and next is not 
        {
            row[y] = row[y - 1]; // move next to current
            row[y - 1] = 0;
            modified = true;
        }
    }
    return modified;
}

MoveUpMoveDown 有点有趣。 在我们继续之前,让我们考虑如何重用 MoveLeftMoveRight 方法来减少代码冗余。 由于唯一的区别是移动是垂直的还是水平的,因此这似乎很有可能。

事实上,如果我们能够以集合的形式访问同一列上的所有元素,那么实现将非常容易。 因此,我们有以下类来实现 IList(T) 接口。

/// <summary>
/// A wrapper that enables user to access specific column in a two-dimension array as a collection.
/// </summary>
/// <typeparam name="T">The type of element.</typeparam>
public class ColumnWrapper<T> : IList<T>, IReadOnlyList<T>
{
    private readonly T[][] array;
    private readonly int col;
    
    public ColumnWrapper(T[][] array, int col)
    {
        this.array = array;
        this.col = col;
    }
    
    public T this[int index]
    {
        get { return this.array[index][col]; }
        set { this.array[index][col] = value; }
    }
    
    public int Count
    {
        get { return this.array.Length; }
    }
    
    public bool IsReadOnly
    {
        get { return false; }
    }
    
    public bool Contains(T item)
    {
        return this.array.Select(row => row[col]).Contains(item);
    }
    
    public int IndexOf(T item)
    {
        var ec = EqualityComparer<T>.Default;
        
        foreach (var i in Enumerable.Range(0, this.array.Length))
            if (ec.Equals(this.array[i][col], item))
                return i;
                
        return -1;
    }
    
    public void CopyTo(T[] array, int arrayIndex)
    {
        this.ToArray().CopyTo(array, arrayIndex);
    }
    
    public IEnumerator<T> GetEnumerator()
    {
        return this.array.Select(row => row[col]).GetEnumerator();
    }
    
    IEnumerator IEnumerable.GetEnumerator()
    {
        return this.GetEnumerator();
    }
    
    #region Not Supported Interface Methods
    
    // Omitted
    
    #endregion
}

有了 ColumnWrapper 类的帮助,MoveUpMoveDown 变得非常简单,如下所示。

public static bool MoveUp(int[][] array, int col)
{
    return MoveLeft(new ColumnWrapper<int>(array, col));
}

public static bool MoveDown(int[][] array, int col)
{
    return MoveRight(new ColumnWrapper<int>(array, col));
}

现在可以提供操作整个棋盘的四个移动方向。 每个方法都接受一个布尔数组,使我们能够快速了解哪些行或列已被修改,以及在移动完成后棋盘是否已更改。

public static void MoveUp(int[][] array, bool[] rowStates)
{
    for (var col = 0; col < BoardSize; col++)
        rowStates[col] = MoveUp(array, col);
}

public static void MoveDown(int[][] array, bool[] rowStates)
{
    for (var col = 0; col < BoardSize; col++)
        rowStates[col] = MoveDown(array, col);
}

public static void MoveLeft(int[][] array, bool[] colStates)
{
    for (var row = 0; row < BoardSize; row++)
        colStates[row] = MoveLeft(array[row]);
}

public static void MoveRight(int[][] array, bool[] colStates)
{
    for (var row = 0; row < BoardSize; row++)
        colStates[row] = MoveRight(array[row]);
}

游戏结束检查

由于没有更多有效的移动,因此有多种方法可以检查游戏是否已结束。 在此实现中,我们采用最简单的方法,将原始棋盘状态复制到另一个实例,并检查四个移动方向是否可以修改新数组。

public static IEnumerable<MoveDirection> GetNextAvailableMoves(int[][] current)
{
    var copied = Clone(current); // create a copy
    var states = new bool[BoardSize];
    
    MoveUp(copied, states);
    
    if (states.Contains(true)) // if any col/row is modified.
        yield return MoveDirection.Up;
        
    MoveDown(copied, states);
    
    if (states.Contains(true)) // if any col/row is modified.
        yield return MoveDirection.Down;
        
    MoveLeft(copied, states);
    
    if (states.Contains(true)) // if any col/row is modified.
        yield return MoveDirection.Up;
        
    MoveRight(copied, states);
    
    if (states.Contains(true)) // if any col/row is modified.
        yield return MoveDirection.Up;
}

因为棋盘上的空位保证至少有两个有效的移动,所以游戏结束检查只会在棋盘已满时发生。

主程序

有了以上所有准备工作,我们终于可以开始游戏本身了。 以下是我们在开始时讨论的游戏循环的实现。

public class Program
{
    public static void Main(string[] args)
    {
        var key = default(ConsoleKey);
        var previous = Helper.InitalizeArray();
        var newNumPos = Position.Empty;
        var colStates = new bool[Helper.BoardSize]; // remember modified cols
        var rowStates = new bool[Helper.BoardSize]; // remember modified rows
        
        do
        {
            Console.Clear();
            
            Array.Clear(colStates, 0, Helper.BoardSize);
            Array.Clear(rowStates, 0, Helper.BoardSize);
            
            var current = Helper.Clone(previous); // create a copy
            
            Move(key, current, colStates, rowStates);
            
            if (colStates.Contains(true) || rowStates.Contains(true)) // if any col/row is modified.
                newNumPos = Helper.PutNumberOnArray(current);
            else
                newNumPos = Position.Empty;
                
            Print(current, previous, newNumPos);
            
            // if no new number is added
            if (newNumPos.IsEmpty && !Helper.GetNextAvailableMoves(current).Any())
            {
                Console.WriteLine("Game Over"); break;
            }
            previous = current;
        }
        while ((key = Console.ReadKey(true).Key) != ConsoleKey.X);
        Console.ReadKey(true);
    }
    
    private static void Move(ConsoleKey key, int[][] array, bool[] colStates, bool[] rowStates)
    {
        switch (key)
        {
            case ConsoleKey.UpArrow:
                Helper.MoveUp(array, rowStates);
                break;
            case ConsoleKey.DownArrow:
                Helper.MoveDown(array, rowStates);
                break;
            case ConsoleKey.LeftArrow:
                Helper.MoveLeft(array, colStates);
                break;
            case ConsoleKey.RightArrow:
                Helper.MoveRight(array, colStates);
                break;
        }
    }
    
    public static void Print(int[][] current, int[][] previous, Position newNumPos)
    {
        // Omitted. You may make it better-looking on your own.
    }
}

就这样! 这是应用程序的样子

Game Loop

进一步的任务

到目前为止,我们已经完成了 2048 游戏最简单的实现和所有必要的基础设施。 但是,有一些任务可以使实现更好,包括

  • 评分
  • 移动动画
  • 撤消机制

历史

  • 2017-03-03 初始发布
  • 2017-03-05 添加了向左移动示例图
© . All rights reserved.