2048 拼图游戏 .NET8 运行时版本





0/5 (0投票)
C# .NET8 控制台实现的 2048 拼图游戏。
引言
最初,2048 游戏是用 Javascript 编写的,但现在有许多主流编程语言的版本。C++ 特别受欢迎,而 C# 则落后不少。这令人惊讶,因为 .NET8 框架提供了专门用于操作集合的功能,其中不仅包括 system.Linq
、索引器和 Lists
。这应该使游戏的编码相对直接,因为 2048 游戏就是关于操作一个瓦片集合。
玩游戏
游戏在一个 4x4 的矩阵上进行,填充着不同值的瓦片。移动包括将特定方向(上、下、左或右)的所有瓦片滑动。合并的相同瓦片会合并成一个值是其现有值两倍的新瓦片,但一个瓦片在同一次移动中只能合并一次。每次移动后,会在棋盘上一个空的随机位置放置一个新的瓦片,其值为 2 或 4,这两种值的出现概率比例为 9:1。游戏开始时,会在棋盘上随机放置两个新瓦片。当一个瓦片的值达到 2048 时,游戏获胜。当棋盘上没有空格且所有行或列都没有可合并的瓦片时,游戏失败。
举个例子,上面第二个棋盘显示了将第一个棋盘的瓦片向右滑动后的结果。第 0 行的 32 瓦片已滑到棋盘右边缘,第 1 行的 16 瓦片与 8 瓦片碰撞。在这两行中,没有与相同值碰撞,所以瓦片的值没有改变。第 2 行的情况有所不同。第 2 列的 2 瓦片与最后一列的 2 瓦片碰撞。这导致最后一列的瓦片合并为 4 瓦片,而另一个相同的 2 瓦片被移除。其他成对的 2 瓦片也发生了类似的情况。一个瓦片只能合并一次的规则的影响体现在最后一行。最后两个 2 瓦片合并成一个 4 瓦片,然后现有 4 瓦片与新合并的 4 瓦片碰撞,但没有进一步合并。新瓦片在移动完成后被添加到第 3 行第 0 列的一个随机空位。
一些设计考虑
一种有用的设计技术是将游戏分解成其组成部分,并使用不同的类来实现这些组件。将类结构化,使其包含相关方法,并且每个方法都有特定的职责,通常可以产生干净的代码,并且 if 语句
的污染最少。使用接口有助于测试,并允许轻松替换实现相同接口的类。通过使用 .NET 的 依赖注入容器提供的工厂方法,可以简化面向接口而非具体类型的编码。游戏的主要组成部分可以认为是:
- 一个驱动游戏引擎的游戏管理器
- 一个允许游戏与用户交互的用户界面
- 一个包含用于推进游戏的方法的游戏引擎。
- 一个数据管理系统,可以为瓦片提供结构化存储,并响应游戏引擎关于瓦片相对位置及其值的信息请求。
游戏管理器
GameManager
类初始化游戏并在其 Play
方法中调用主游戏循环。
[Flags]
public enum Direction
{
Up,
Left,
Down,
Right,
Exit
}
public class GameManager(IGameEngine gameEngine,
IGameGUI consoleGUI) : IGameManager
{
private readonly IGameEngine gameEngine = gameEngine;
private readonly IGameGUI consoleGUI = consoleGUI;
public void StartGame()
{
consoleGUI.DisplayBoard();
gameEngine.Reset();
gameEngine.AddNewTilesToCollection(2);
consoleGUI.ShowTiles(0);
Play();
}
public void Play()
{
bool isRunning = true;
int total = 0;
while (isRunning)
{
var direction = consoleGUI.GetNextMove();
if (direction == Direction.Exit) break;
(isRunning, int score) = PlayMove(direction);
total += score;
consoleGUI.ShowTiles(total);
}
consoleGUI.DisplayGameResult(gameEngine.IsWinner);
}
private (bool, int) PlayMove(Direction direction)
{
if (direction == Direction.Exit) return (false, 0);
int score = gameEngine.SlideTiles(direction);
bool isRunning = gameEngine.CompleteMove();
return (isRunning, score);
}
}
用户界面。
ConsoleGUI
类通过 switch
语句返回 Direction 枚举
的成员作为移动选择,从而提供用户输入。
public Direction GetNextMove()
{
var consoleKey = (Console.ReadKey(true).Key);
return consoleKey switch
{
ConsoleKey.UpArrow => Direction.Up,
ConsoleKey.DownArrow => Direction.Down,
ConsoleKey.LeftArrow => Direction.Left,
ConsoleKey.RightArrow => Direction.Right,
ConsoleKey.Escape => Direction.Exit,//used to quit app
_ => Direction.Up
};
}
在此控制台应用程序中,它主要是 Console 类的包装器,但它实现了 IGameGUI
接口,因此可以被适用于其他类型应用程序的 GUI 替换。
游戏引擎
游戏的大部分实现位于 GameEngine
类中。它依赖于数据管理类 TileRepository
来设置和获取有关当前游戏状态的信息,以及一个 TileSlider
类来处理瓦片滑动的机制。游戏管理器的 PlayMove
方法调用引擎的 SlideTiles
方法。
//returns the score for the move
public int SlideTiles(Direction direction) =>
direction switch
{
Direction.Up => tileSlider.SlideAllColumns(Direction.Up, tileRepository),
Direction.Down => tileSlider.SlideAllColumns(Direction.Down, tileRepository),
Direction.Left => tileSlider.SlideAllRows(Direction.Left, tileRepository),
Direction.Right => tileSlider.SlideAllRows(Direction.Right, tileRepository),
_ => throw new ArgumentException("Invalid enum value", nameof(direction))
};
CompleteMove
方法更新瓦片并确定游戏是否可以继续。
public bool CompleteMove()
{
var emptytileCollection = tileRepository.EmptyTileIndices();
//if no change since the last move, return true and skip adding a new tile
if (previousBlankTiles.SequenceEqual(emptytileCollection)) return true;
if (tileRepository.IsGameWon())
{
IsWinner = true;
return false;
}
int tileId = GetNewTileId();
var newtileValue = GetRandomTileValue();
tileRepository[tileId] = newtileValue;
UpdateAllTiles(false);
return tileRepository.CanPlayContinue();
}
瓦片滑动器
TileSlider
类有一个 Slide
方法,该方法在每次移动后被调用。根据需要,每一行或每一列从瓦片数组中提取出来,并以连续(无空白)的整数列表形式传递给该方法。Slide 方法比较相邻的瓦片是否匹配。如果找到匹配项,则第一个瓦片的值会增加,另一个瓦片设置为零,以防止其在下一次瓦片比较时再次匹配。当所有瓦片都比较完毕后,移除空白瓦片,并用零填充列表以达到正确的长度。格式化的列表和滑动得分将作为 Value Tuple 返回。
private (List<int> formattedList, int score) Slide(List<int> tileList,
Direction direction)
{
//default sliding is left/up
// so need to reverse the order for right/ down sliding
bool isReverse = direction == Direction.Right || direction == Direction.Down;
if (isReverse) tileList.Reverse();
int slideScore = 0;
//slide matching values together
for (int index = 0; index < tileList.Count - 1; index++)
{
if (tileList[index] == tileList[index + 1])
{
//mark tile for deletion
tileList[index + 1] = 0;
tileList[index] += 1;//promote tile
slideScore += 1 << tileList[index];//no need to use Math.Pow()
index++;//skip blank tile
}
}
//No spaces allowed between values so remove the blanks created by combining values
var formattedList = tileList.Where((n) => n != 0).ToList();
//add zeros to pad the row up to the row length
formattedList.AddRange(Enumerable.Repeat(0, 4 - formattedList.Count));
if (isReverse) formattedList.Reverse();
return (formattedList, slideScore);
}
数据管理系统
TileRepository
类负责管理瓦片集合。瓦片值存储为 2 的幂,范围为 0-11,其中 2^11=2048。后备存储是一个 int[16]
数组,它使用索引器来允许通过行和列坐标访问瓦片。
//indexer, this enables tileRepository[row,col] access to the tiles
public int this[int row, int col]
{
get => tiles[row * 4 + col];
set => tiles[row * 4 + col] = value;
}
使用一维数组可以通过强大的 system.Linq IEnumerable
扩展方法来访问瓦片值。以下是存储库中使用的一些简单的 Linq 方法:
public bool IsGameWon() => tiles.Contains(11);
public bool IsCollectionFull() => tiles.Any((v) => v == 0) is false;
可枚举类型本身不能通过索引访问,但是,一些 Linq 方法提供了一个索引。但它们的执行时间和内存使用通常不如等效的 for
循环。因此,这里的方法比第二个方法更受欢迎。
public IEnumerable<int> EmptyTileIndices()
{
for (int i = 0;i<tiles.Length;i++)
{
if (tiles[i] == 0)
{
yield return i;
}
}
}
public IEnumerable<int> EmptyTileIndices()
{
return tiles.Select((v, index) => index).Where((i) => tiles[i] == 0);
}
存储库有一个 CanPlayContinue
方法,在添加新瓦片(移动完成后)时被调用。
public bool CanPlayContinue()
=> IsCollectionFull() is false || IsMatchOnRows() || IsMatchOnCols();
private bool IsMatchOnRows()
{
for (int r = 0; r < 4; r++)
{
for (int c = 0; c < 3; c++)
{
if (this[r, c] == this[r, c + 1]) return true;
}
}
return false;
}
private bool IsMatchOnCols()
{
for (int c = 0; c < 3; c++)
{
for (int r = 0; c < r; r++)
{
if (this[r, c] == this[r + 1, c]) return true;
}
}
return false;
}
有一个等效的单语句 Linq 方法,但它慢 50%,内存占用是三倍。
public bool CanPlayContinue()
{
//return true if the collect is not full
return !IsCollectionFull()
//Or if there are any possible matches on the rows
|| tiles.Where((v, i) => (i < 15 && tiles[i] == tiles[i + 1] && i / 4 == (i + 1) / 4)
//Or if there are any possible matches on the cols
|| (i < 12 && tiles[i] == tiles[i + 4])).Any();
}
一些人工智能的考虑。
我的经验是,要在一致地赢得 2048 游戏,唯一的方法就是使用人工智能(AI)。几乎所有的人工智能算法都要求在尽可能短的时间内考虑大量的移动可能性,同时内存开销要最小。C# 可以用于实现这一点,但 ITileRepository
和 ITileSliding
类型需要操作在二进制位移和掩码级别。幸运的是,这个游戏似乎是专门为满足这一要求而设计的。瓦片值可以存储为 4 位半字节(0-11)的 2 的幂。一行或一列有 4 个瓦片,因此它们可以容纳在一个无符号短整型(2 字节)中。有 4 行 4 个短整型,这意味着整个棋盘可以用一个无符号长整型(8 字节)表示。在二进制位移和位操作级别进行操作可能具有挑战性。这里有一个例子,说明了所使用的主要技术。冒着说显而易见的风险,一个半字节(4 位)可以用十六进制表示为单个字符 0..9A..F,所以十六进制 0xF =所有 4 位都设置为 1 =十进制 15。
public ulong SetNibbleFromIndex(int index, byte nibble, ulong board)
{
//the nibble here is a byte with bits 4,5,6,7 cleared(set to 0)
int shiftCount = index * 4;
// shift bits up by shiftCount on a ulong where only bits 0,1,2,3 are set
ulong shifted = (ulong)0xF << shiftCount;
//invert the bits so only the required nibble's bits are 0
ulong mask = ~shifted;
//AND in the mask to clear the nibble's portion of the board's bits
var cleared = board & mask;
//move the new nibble as a ulong into position
var update = ((ulong)nibble) << shiftCount;
//OR in the new nibble
return cleared | update;
}
彻底测试使用二进制运算符的方法非常重要,因为它们可能会接受和返回各种不正常的值,而不会报错或崩溃。
结论
这个 2048 游戏实现说明了一种编码方法,它会产生大量的类、接口和方法。这可能不是所有人都喜欢,但希望那些刚开始编程之旅的人会发现它很有用。