实现 2048 游戏的演练





5.00/5 (9投票s)
C# 中制作简单 2048 控制台应用程序的介绍
引言
在本文中,我想花一些时间来讨论如何在控制台应用程序中实现一个简单的 2048 游戏,包括一些可以简化实现的实用技巧。
该项目针对 .NET Core 和 .NET Standard 1.6。 但是,它可以很容易地适应其他 .NET Framework 版本。 完整的源代码可以在 GitHub 上查看。
背景
2048 游戏的实现最初是作为 Heuristic Suite 的一个示例项目创建的,该项目是 A* 算法的通用实现,并使用该算法尽可能地达到最佳分数。 然而,在开始示例项目之前,验证实际游戏中的游戏逻辑是必要的,这成为了本文的来源。
游戏循环
一切都从游戏循环开始。 循环非常简单,很多人可能都知道。 最初,我们在棋盘上有 2 个随机位置的数字。 当数字向四个方向之一移动时,将添加一个新数字,该数字可以是 2 或 4。 同时,同一行或同一列上的一对相同数字将被合并并求和。 如果棋盘已满且没有更多移动可用,则游戏结束。
棋盘
棋盘状态存储在一个二维整数数组中,其中 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;
}
四个移动方向
现在,让我们来看看如何在棋盘上四个方向移动数字,这是实现的最重要部分。
在同一行或同一列上移动数字可以分解为两个阶段
- 合并并求和每一对相同的相邻数字,或未被其他数字中断的相同数字。
- 将剩余数字向该方向移动。
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;
}
MoveRight
与 MoveLeft
非常相似,其中两个 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;
}
MoveUp
和 MoveDown
有点有趣。 在我们继续之前,让我们考虑如何重用 MoveLeft
和 MoveRight
方法来减少代码冗余。 由于唯一的区别是移动是垂直的还是水平的,因此这似乎很有可能。
事实上,如果我们能够以集合的形式访问同一列上的所有元素,那么实现将非常容易。 因此,我们有以下类来实现 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
类的帮助,MoveUp
和 MoveDown
变得非常简单,如下所示。
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.
}
}
就这样! 这是应用程序的样子
进一步的任务
到目前为止,我们已经完成了 2048 游戏最简单的实现和所有必要的基础设施。 但是,有一些任务可以使实现更好,包括
- 评分
- 移动动画
- 撤消机制
历史
- 2017-03-03 初始发布
- 2017-03-05 添加了向左移动示例图