.NET手撸2048小游戏

2048是一款益智小游戏,得益于其规则简单,又和2的倍数有关,因此广为人知,特别是广受程序员的喜爱。

本文将再次使用我自制的“准游戏引擎”FlysEngine,从空白窗口开始,演示如何“手撸”2048小游戏,并在编码过程中感受C#的魅力和.NET编程的快乐。

说明:FlysEngine是封装于Direct2D,重复本文示例,只需在.NET Core 3.0下安装NuGetFlysEngine.Desktop即可。

并不一定非要做一层封装才能用,只是FlysEngine简化了创建设备、处理设备丢失、设备资源管理等“新手劝退”级操作,

首先来看一下最终效果:

小游戏的三原则

在开始做游戏前,我先聊聊CRUD程序员做小游戏时,我认为最重要的三大基本原则。很多时候我们有做个游戏的心,但发现做出来总不是那么回事。这时可以对照一下,看是不是违反了这三大原则中的某一个:

  • MVC
  • 应用程序驱动(而非事件驱动)
  • 动画

MVC

或者MVP……关键是将逻辑与视图分离。它有两大特点:

  • 视图层完全没有状态;
  • 数据的变动不会直接影响呈现的画面。

也就是所有的数据更新,都只应体现在内存中。游戏中的数据变化可能非常多,应该积攒起来,一次性更新到界面上。

这是因为游戏实时渲染特有的性能所要求的,游戏常常有成百上千个动态元素在界面上飞舞,这些动作必须在一次垂直同步(如16ms或更低)的时间内完成,否则用户就会察觉到卡顿。

常见的反例有knockout.js,它基于MVVM,也就是数据改变会即时通知到视图(DOM),导致视图更新不受控制。

另外,MVC还有一个好处,就是假如代码需要移植平台时(如C#移植到html5),只需更新呈现层即可,模型层所有逻辑都能保留。

应用程序驱动(而非事件驱动)

应用程序驱动的特点是界面上的动态元素,之所以“动”,是由应用程序触发——而非事件触发的。

这一点其实与MVC也是相辅相成。应用程序驱动确保了MVC的性能,不会因为依赖变量重新求值次数过多而影响性能。

另外,如果界面上有状态,就会导致逻辑变得非常复杂,比如变量之间的依赖求值、界面上某些参数的更新时机等。不如简单点搞!直接全部重新计算,全部重新渲染,绝对不会错!

细心的读者可能发现最终效果demo中的总分显示就有bug,开始游戏时总分应该是4,而非72。这就是由于该部分没有使用应用程序驱动求值,导致逻辑复杂,导致粗心……最终导致出现了bug

html5canvas中,实时渲染的“心脏”是requestAnimationFrame()函数,在FlysEngine中,“心脏”是RenderLoop.Run()函数:

  1. using var form = new RenderWindow { ClientSize = new System.Drawing.Size(400, 400) };
  2. form.Draw += (RenderWindow sender, DeviceContext ctx) =>
  3. {
  4. ctx.Clear(Color.CornflowerBlue);
  5. };
  6. RenderLoop.Run(form, () => form.Render(1, PresentFlags.None)); // 心脏

动画

动画是小游戏的灵魂,一个游戏做得够不够精致,有没有“质感”,除了UI把关外,就靠我们程序员把动画做好了。

动画的本质是变量从一个值按一定的速度变化到另一个值:

  1. using var form = new RenderWindow { StartPosition = System.Windows.Forms.FormStartPosition.CenterScreen };
  2. float x = 0;
  3. form.Draw += (w, ctx) =>
  4. {
  5. ctx.Clear(Color.CornflowerBlue);
  6. var brush = w.XResource.GetColor(Color.Red);
  7. ctx.FillRectangle(new RectangleF(x, 50, 50, 50), brush);
  8. ctx.DrawText($"x = {x}", w.XResource.TextFormats[20], new RectangleF(0, 0, 100, 100), brush);
  9. x += 1.0f;
  10. };
  11. RenderLoop.Run(form, () => form.Render(1, PresentFlags.None));

运行效果如下:

然而,如果用应用程序驱动——而非事件驱动做动画,代码容易变得混乱不堪。尤其是多个动画、动画与动画之间做串联等等。

这时代码需要精心设计,将代码写成像事件驱动那么容易,下文将演示如何在2048小游戏中做出流畅的动画。

2048小游戏

回到2048小游戏,我们将在制作这个游戏,慢慢体会我所说的“小游戏三原则”。

起始代码

这次我们创建一个新的类GameWindow,继承于RenderWindow(不像之前直接使用RenderWindow类),这样有利于分离视图层:

  1. const int MatrixSize = 4;
  2. void Main()
  3. {
  4. using var g = new GameWindow() { ClientSize = new System.Drawing.Size(400, 400) };
  5. RenderLoop.Run(g, () => g.Render(1, PresentFlags.None));
  6. }
  7. public class GameWindow : RenderWindow
  8. {
  9. protected override void OnDraw(DeviceContext ctx)
  10. {
  11. ctx.Clear(new Color(0xffa0adbb));
  12. }
  13. }

OnDraw重载即为渲染的方法,提供了一个ctx参数,对应Direct2D中的ID2D1DeviceContext类型,可以用来绘图。

其中0xffa0adbb是棋盘背景颜色,它是用ABGR的顺序表示的,运行效果如下:

棋盘

首先我们需要“画”一个棋盘,它分为背景和棋格子组成。这部分内容是完全静态的,因此可以在呈现层直接完成。

棋盘应该随着窗口大小变化而变化,因此各个变量都应该动态计算得出。

如图,2048游戏区域应该为正方形,因此总边长fullEdge应该为窗口的高宽属性的较小者(以刚好放下一个正方形),代码表示如下:

  1. float fullEdge = Math.Min(ctx.Size.Width, ctx.Size.Height);

方块与方块之间的距离定义为总边长的1/8再除以MatrixSize(也就是4),此时单个方块的边长就可以计算出来了,为总边长fullEdge减去5个gap再除以MatrixSize,代码如下:

  1. float gap = fullEdge / (MatrixSize * 8);
  2. float edge = (fullEdge - gap * (MatrixSize + 1)) / MatrixSize;

然后即可按循环绘制44列方块位置,使用矩阵变换可以让代码更简单:

  1. foreach (var v in MatrixPositions)
  2. {
  3. float centerX = gap + v.x * (edge + gap) + edge / 2.0f;
  4. float centerY = gap + v.y * (edge + gap) + edge / 2.0f;
  5. ctx.Transform =
  6. Matrix3x2.Translation(-edge / 2, -edge / 2) *
  7. Matrix3x2.Translation(centerX, centerY);
  8. ctx.FillRoundedRectangle(new RoundedRectangle
  9. {
  10. RadiusX = edge / 21,
  11. RadiusY = edge / 21,
  12. Rect = new RectangleF(0, 0, edge, edge),
  13. }, XResource.GetColor(new Color(0x59dae4ee)));
  14. }

注意foreach (var v in MatrixPositions)是以下代码的简写:

  1. for (var x = 0; x < MatrixSize; ++x)
  2. {
  3. for (var y = 0; y < MatrixSize; ++y)
  4. {
  5. // ...
  6. }
  7. }

由于2048将多次遍历xy,因此定义了一个变量MatrixPositions来简化这一过程:

  1. static IEnumerable<int> inorder = Enumerable.Range(0, MatrixSize);
  2. static IEnumerable<(int x, int y)> MatrixPositions =>
  3. inorder.SelectMany(y => inorder.Select(x => (x, y)));

运行效果如下:

加入数字方块

数据方块由于是活动的,为了代码清晰,需要加入额外两个类,CellMatrix

Cell类

Cell是单个方块,需要保存当前的数字N,其次还要获取当前的颜色信息:

  1. class Cell
  2. {
  3. public int N;
  4. public Cell(int n)
  5. {
  6. N = n;
  7. }
  8. public DisplayInfo DisplayInfo => N switch
  9. {
  10. 2 => DisplayInfo.Create(),
  11. 4 => DisplayInfo.Create(0xede0c8ff),
  12. 8 => DisplayInfo.Create(0xf2b179ff, 0xf9f6f2ff),
  13. 16 => DisplayInfo.Create(0xf59563ff, 0xf9f6f2ff),
  14. 32 => DisplayInfo.Create(0xf67c5fff, 0xf9f6f2ff),
  15. 64 => DisplayInfo.Create(0xf65e3bff, 0xf9f6f2ff),
  16. 128 => DisplayInfo.Create(0xedcf72ff, 0xf9f6f2ff, 45),
  17. 256 => DisplayInfo.Create(0xedcc61ff, 0xf9f6f2ff, 45),
  18. 512 => DisplayInfo.Create(0xedc850ff, 0xf9f6f2ff, 45),
  19. 1024 => DisplayInfo.Create(0xedc53fff, 0xf9f6f2ff, 35),
  20. 2048 => DisplayInfo.Create(0x3c3a32ff, 0xf9f6f2ff, 35),
  21. _ => DisplayInfo.Create(0x3c3a32ff, 0xf9f6f2ff, 30),
  22. };
  23. }

其中,DisplayInfo类用来表达方块的文字颜色、背景颜色和字体大小:

  1. struct DisplayInfo
  2. {
  3. public Color Background;
  4. public Color Foreground;
  5. public float FontSize;
  6. public static DisplayInfo Create(uint background = 0xeee4daff, uint color = 0x776e6fff, float fontSize = 55) =>
  7. new DisplayInfo { Background = new Color(background), Foreground = new Color(color), FontSize = fontSize };
  8. }

文章中的“魔法”数字0xeee4daff等,和上文一样,是颜色的ABGR顺序表示的。通过一个简单的Create方法,即可实现默认颜色、默认字体的代码简化,无需写过多的if/else

注意:

  • 我特意使用了struct而非class关键字,这样创建的是值类型而非引用类型,可以无需分配和回收堆内存。在应用或游戏中,内存分配和回收常常是最影响性能和吞吐性的指标之一。
  • N switch { ... }这样的代码,是C# 8.0switch expression特性(下文将继续大量使用),可以通过表达式——而非语句的方式表达一个逻辑,可以让代码大大简化。该特性现在在.NET Core 3.0项目中默认已经打开,某些支持的早期版本,需要将项目中的<LangVersion>属性设置为8.0才可以使用。

根据2048的设计文档和参考其它项目,一个方块创建时有90%机率是210%机率是4,这可以通过.NET中的Random类实现:

  1. static Random r = new Random();
  2. public static Cell CreateRandom() => new Cell(r.NextDouble() < 0.9 ? 2 : 4);

使用时,只需调用CreateRandom()即可。

Matrix类

Matrix用于管理和控制多个Cell类。它包含了一个二维数组Cell[,],用于保存4x4Cell

  1. class Matrix
  2. {
  3. public Cell[,] CellTable;
  4. public IEnumerable<Cell> GetCells()
  5. {
  6. foreach (var c in CellTable)
  7. if (c != null) yield return c;
  8. }
  9. public int GetScore() => GetCells().Sum(v => v.N);
  10. public void ReInitialize()
  11. {
  12. CellTable = new Cell[MatrixSize, MatrixSize];
  13. (int x, int y)[] allPos = MatrixPositions.ShuffleCopy();
  14. for (var i = 0; i < 2; ++i) // 2: initial cell count
  15. {
  16. CellTable[allPos[i].y, allPos[i].x] = Cell.CreateRandom();
  17. }
  18. }
  19. }

其中ReInitialize方法对Cell[,]二维数组进行了初始化,然后在随机位置创建了两个Cell。值得一提的是ShuffleCopy()函数,该函数可以对IEnumerable<T>进行乱序,然后复制为数组:

  1. static class RandomUtil
  2. {
  3. static Random r = new Random();
  4. public static T[] ShuffleCopy<T>(this IEnumerable<T> data)
  5. {
  6. var arr = data.ToArray();
  7. for (var i = arr.Length - 1; i > 0; --i)
  8. {
  9. int randomIndex = r.Next(i + 1);
  10. T temp = arr[i];
  11. arr[i] = arr[randomIndex];
  12. arr[randomIndex] = temp;
  13. }
  14. return arr;
  15. }
  16. }

该函数看似简单,能写准确可不容易。尤其注意for循环的终止条件不是i >= 0,而是i > 0,这两者有区别,以后我有机会会深入聊聊这个函数。今天最简单的办法就是——直接使用它即可。

最后回到GameWindow类的OnDraw方法,如法炮制,将Matrix“画”出来即可:

  1. // .. 继之前的OnDraw方法内容
  2. foreach (var p in MatrixPositions)
  3. {
  4. var c = Matrix.CellTable[p.y, p.x];
  5. if (c == null) continue;
  6. float centerX = gap + p.x * (edge + gap) + edge / 2.0f;
  7. float centerY = gap + p.y * (edge + gap) + edge / 2.0f;
  8. ctx.Transform =
  9. Matrix3x2.Translation(-edge / 2, -edge / 2) *
  10. Matrix3x2.Translation(centerX, centerY);
  11. ctx.FillRectangle(new RectangleF(0, 0, edge, edge), XResource.GetColor(c.DisplayInfo.Background));
  12. var textLayout = XResource.TextLayouts[c.N.ToString(), c.DisplayInfo.FontSize];
  13. ctx.Transform =
  14. Matrix3x2.Translation(-textLayout.Metrics.Width / 2, -textLayout.Metrics.Height / 2) *
  15. Matrix3x2.Translation(centerX, centerY);
  16. ctx.DrawTextLayout(Vector2.Zero, textLayout, XResource.GetColor(c.DisplayInfo.Foreground));
  17. }

此时运行效果如下:

如果想测试所有方块颜色,可将ReInitialize()方法改为如下即可:

  1. public void ReInitialize()
  2. {
  3. CellTable = new Cell[MatrixSize, MatrixSize];
  4. CellTable[0, 0] = new Cell(2);
  5. CellTable[0, 1] = new Cell(4);
  6. CellTable[0, 2] = new Cell(8);
  7. CellTable[0, 3] = new Cell(16);
  8. CellTable[1, 0] = new Cell(32);
  9. CellTable[1, 1] = new Cell(64);
  10. CellTable[1, 2] = new Cell(128);
  11. CellTable[1, 3] = new Cell(256);
  12. CellTable[2, 0] = new Cell(512);
  13. CellTable[2, 1] = new Cell(1024);
  14. CellTable[2, 2] = new Cell(2048);
  15. CellTable[2, 3] = new Cell(4096);
  16. CellTable[3, 0] = new Cell(8192);
  17. CellTable[3, 1] = new Cell(16384);
  18. CellTable[3, 2] = new Cell(32768);
  19. CellTable[3, 3] = new Cell(65536);
  20. }

运行效果如下:

嗯,看起来……有那么点意思了。

引入事件,把方块移动起来

本篇也分两部分,事件,和方块移动逻辑。

事件

首先是事件,要将方块移动起来,我们再次引入大名鼎鼎的Rx(全称:Reactive.NETNuGet包:System.Reactive)。然后先引入一个基础枚举,用于表示上下左右:

  1. enum Direction
  2. {
  3. Up, Down, Left, Right,
  4. }

然后将键盘的上下左右事件,转换为该枚举的IObservable<Direction>流(可以写在GameWindow构造函数中),然后调用该“流”的.Subscribe方法直接订阅该“流”:

  1. var keyUp = Observable.FromEventPattern<KeyEventArgs>(this, nameof(this.KeyUp))
  2. .Select(x => x.EventArgs.KeyCode);
  3. keyUp.Select(x => x switch
  4. {
  5. Keys.Left => (Direction?)Direction.Left,
  6. Keys.Right => Direction.Right,
  7. Keys.Down => Direction.Down,
  8. Keys.Up => Direction.Up,
  9. _ => null
  10. })
  11. .Where(x => x != null)
  12. .Select(x => x.Value)
  13. .Subscribe(direction =>
  14. {
  15. Matrix.RequestDirection(direction);
  16. Text = $"总分:{Matrix.GetScore()}";
  17. });
  18. keyUp.Where(k => k == Keys.Escape).Subscribe(k =>
  19. {
  20. if (MessageBox.Show("要重新开始游戏吗?", "确认", MessageBoxButtons.OKCancel) == System.Windows.Forms.DialogResult.OK)
  21. {
  22. Matrix.ReInitialize();
  23. // 这行代码没写就是文章最初说的bug,其根本原因(也许忘记了)就是因为这里不是用的MVC/应用程序驱动
  24. // Text = $"总分:{Matrix.GetScore()}";
  25. }
  26. });

每次用户松开上下左右四个键之一,就会调用MatrixRequestDirection方法(马上说),松下Escape键,则会提示用户是否重新开始玩,然后重新显示新的总分。

注意:

  1. 我再次使用了C# 8.0switch expression语法,它让我省去了if/elseswitch case,代码精练了不少;
  2. 不是非得要用Rx,但Rx相当于将事件转换为了数据,可以让代码精练许多,且极大地提高了可扩展性。

移动逻辑

我们先在脑子里面想想,感受一下这款游戏的移动逻辑应该是怎样的。(你可以在草稿本上先画画图……)

我将2048游戏的逻辑概括如下:

  • 将所有方块,向用户指定的方向遍历,找到最近的方块位置
  • 如果找到,且数字一样,则合并(删除对面,自己加倍)
  • 如果找到,但数字不一样,则移动到对面的前一格
  • 如果发生过移动,则生成一个新方块

如果想清楚了这个逻辑,就能写出代码如下:

  1. public void RequestDirection(Direction direction)
  2. {
  3. if (GameOver) return;
  4. var dv = Directions[(int)direction];
  5. var tx = dv.x == 1 ? inorder.Reverse() : inorder;
  6. var ty = dv.y == 1 ? inorder.Reverse() : inorder;
  7. bool moved = false;
  8. foreach (var i in tx.SelectMany(x => ty.Select(y => (x, y))))
  9. {
  10. Cell cell = CellTable[i.y, i.x];
  11. if (cell == null) continue;
  12. var next = NextCellInDirection(i, dv);
  13. if (WithinBounds(next.target) && CellTable[next.target.y, next.target.x].N == cell.N)
  14. { // 对面有方块,且可合并
  15. CellTable[i.y, i.x] = null;
  16. CellTable[next.target.y, next.target.x] = cell;
  17. cell.N *= 2;
  18. moved = true;
  19. }
  20. else if (next.prev != i) // 对面无方块,移动到prev
  21. {
  22. CellTable[i.y, i.x] = null;
  23. CellTable[next.prev.y, next.prev.x] = cell;
  24. moved = true;
  25. }
  26. }
  27. if (moved)
  28. {
  29. var nextPos = MatrixPositions
  30. .Where(v => CellTable[v.y, v.x] == null)
  31. .ShuffleCopy()
  32. .First();
  33. CellTable[nextPos.y, nextPos.x] = Cell.CreateRandom();
  34. if (!IsMoveAvailable()) GameOver = true;
  35. }
  36. }

其中,dvtxty三个变量,巧妙地将Direction枚举转换成了数据,避免了过多的if/else,导致代码膨胀。然后通过一行简单的LINQ,再次将两个for循环联合在一起。

注意示例还使用了(x, y)这样的语法(下文将继续大量使用),这叫Value Tuple,或者值元组Value TupleC# 7.0的新功能,它和C# 6.0新增的Tuple的区别有两点:

  • Value Tuple可以通过(x, y)这样的语法内联,而Tuple要使用Tuple.Create(x, y)来创建
  • Value Tuple故名思义,它是值类型,可以无需内存分配和GC开销(但稍稍增长了少许内存复制开销)

我还定义了另外两个字段:GameOverKeepGoing,用来表示是否游戏结束和游戏胜利时是否继续:

  1. public bool GameOver,KeepGoing;

其中,NextCellInDirection用来计算方块对面的情况,代码如下:

  1. public ((int x, int y) target, (int x, int y) prev) NextCellInDirection((int x, int y) cell, (int x, int y) dv)
  2. {
  3. (int x, int y) prevCell;
  4. do
  5. {
  6. prevCell = cell;
  7. cell = (cell.x + dv.x, cell.y + dv.y);
  8. }
  9. while (WithinBounds(cell) && CellTable[cell.y, cell.x] == null);
  10. return (cell, prevCell);
  11. }

IsMoveAvailable函数用来判断游戏是否还能继续,如果不能继续将设置GameOver = true

它的逻辑是如果方块数不满,则显示游戏可以继续,然后判断是否有任意相邻方块数字相同,有则表示游戏还能继续,具体代码如下:

  1. public bool IsMoveAvailable() => GetCells().Count() switch
  2. {
  3. MatrixSize * MatrixSize => MatrixPositions
  4. .SelectMany(v => Directions.Select(d => new
  5. {
  6. Position = v,
  7. Next = (x: v.x + d.x, y: v.y + d.y)
  8. }))
  9. .Where(x => WithinBounds(x.Position) && WithinBounds(x.Next))
  10. .Any(v => CellTable[v.Position.y, v.Position.x]?.N == CellTable[v.Next.y, v.Next.x]?.N),
  11. _ => true,
  12. };

注意我再次使用了switch expressionValue Tuple和令人拍案叫绝的LINQ,相当于只需一行代码,就将这些复杂的逻辑搞定了。

最后别忘了在GameWindowOnUpdateLogic重载函数中加入一些弹窗提示,显示用于恭喜和失败的信息:

  1. protected override void OnUpdateLogic(float dt)
  2. {
  3. base.OnUpdateLogic(dt);
  4. if (Matrix.GameOver)
  5. {
  6. if (MessageBox.Show($"总分:{Matrix.GetScore()}\r\n重新开始吗?", "失败!", MessageBoxButtons.YesNo) == DialogResult.Yes)
  7. {
  8. Matrix.ReInitialize();
  9. }
  10. else
  11. {
  12. Matrix.GameOver = false;
  13. }
  14. }
  15. else if (!Matrix.KeepGoing && Matrix.GetCells().Any(v => v.N == 2048))
  16. {
  17. if (MessageBox.Show("您获得了2048!\r\n还想继续升级吗?", "恭喜!", MessageBoxButtons.YesNo) == DialogResult.Yes)
  18. {
  19. Matrix.KeepGoing = true;
  20. }
  21. else
  22. {
  23. Matrix.ReInitialize();
  24. }
  25. }
  26. }

这时,游戏运行效果显示如下:

优化

其中到了这一步,2048已经可堪一玩了,但总感觉不是那么个味。还有什么可以做的呢?

动画

上文说过,动画是灵魂级别的功能。和CRUD程序员的日常——“功能”实现了就万事大吉不同,游戏必须要有动画,没有动画简直就相当于游戏白做了。

在远古jQuery中,有一个$(element).animate()方法,实现动画挺方便,我们可以模仿该方法的调用方式,自己实现一个:

  1. public static GameWindow Instance = null;
  2. public static Task CreateAnimation(float initialVal, float finalVal, float durationMs, Action<float> setter)
  3. {
  4. var tcs = new TaskCompletionSource<float>();
  5. Variable variable = Instance.XResource.CreateAnimation(initialVal, finalVal, durationMs / 1000);
  6. IDisposable subscription = null;
  7. subscription = Observable
  8. .FromEventPattern<RenderWindow, float>(Instance, nameof(Instance.UpdateLogic))
  9. .Select(x => x.EventArgs)
  10. .Subscribe(x =>
  11. {
  12. setter((float)variable.Value);
  13. if (variable.FinalValue == variable.Value)
  14. {
  15. tcs.SetResult(finalVal);
  16. variable.Dispose();
  17. subscription.Dispose();
  18. }
  19. });
  20. return tcs.Task;
  21. }
  22. public GameWindow()
  23. {
  24. Instance = this;
  25. // ...
  26. }

注意,我实际是将一个动画转换成为了一个Task,这样就可以实际复杂动画、依赖动画、连续动画的效果。

使用该函数,可以轻易做出这样的效果,动画部分代码只需这样写(见animation-demo.linq):

  1. float x = 50, y = 150, w = 50, h = 50;
  2. float red = 0;
  3. protected override async void OnLoad(EventArgs e)
  4. {
  5. var stage1 = new[]
  6. {
  7. CreateAnimation(initialVal: x, finalVal: 340, durationMs: 1000, v => x = v),
  8. CreateAnimation(initialVal: h, finalVal: 100, durationMs: 600, v => h = v),
  9. };
  10. await Task.WhenAll(stage1);
  11. await CreateAnimation(initialVal: h, finalVal: 50, durationMs: 1000, v => h = v);
  12. await CreateAnimation(initialVal: x, finalVal: 20, durationMs: 1000, v => x = v);
  13. while (true)
  14. {
  15. await CreateAnimation(initialVal: red, finalVal: 1.0f, durationMs: 500, v => red = v);
  16. await CreateAnimation(initialVal: red, finalVal: 0.0f, durationMs: 500, v => red = v);
  17. }
  18. }

运行效果如下,请注意最后的黑色-红色闪烁动画,其实是一个无限动画,各位可以想像下如果手撸状态机,这些代码会多么麻烦,而C#支持协程,这些代码只需一些await和一个while (true)语句即可完美完成:

有了这个基础,开工做动画了,首先给Cell类做一些修改:

  1. class Cell
  2. {
  3. public int N;
  4. public float DisplayX, DisplayY, DisplaySize = 0;
  5. const float AnimationDurationMs = 120;
  6. public bool InAnimation =>
  7. (int)DisplayX != DisplayX ||
  8. (int)DisplayY != DisplayY ||
  9. (int)DisplaySize != DisplaySize;
  10. public Cell(int x, int y, int n)
  11. {
  12. DisplayX = x; DisplayY = y; N = n;
  13. _ = ShowSizeAnimation();
  14. }
  15. public async Task ShowSizeAnimation()
  16. {
  17. await GameWindow.CreateAnimation(DisplaySize, 1.2f, AnimationDurationMs, v => DisplaySize = v);
  18. await GameWindow.CreateAnimation(DisplaySize, 1.0f, AnimationDurationMs, v => DisplaySize = v);
  19. }
  20. public void MoveTo(int x, int y, int n = default)
  21. {
  22. _ = GameWindow.CreateAnimation(DisplayX, x, AnimationDurationMs, v => DisplayX = v);
  23. _ = GameWindow.CreateAnimation(DisplayY, y, AnimationDurationMs, v => DisplayY = v);
  24. if (n != default)
  25. {
  26. N = n;
  27. _ = ShowSizeAnimation();
  28. }
  29. }
  30. public DisplayInfo DisplayInfo => N switch // ...
  31. static Random r = new Random();
  32. public static Cell CreateRandomAt(int x, int y) => new Cell(x, y, r.NextDouble() < 0.9 ? 2 : 4);
  33. }

加入了DisplayXDisplayYDisplaySize三个属性,用于管理其用于在界面上显示的值。还加入了一个InAnimation变量,用于判断是否处理动画状态。

另外,构造函数现在也要求传入xy的值,如果位置变化了,现在必须调用MoveTo方法,它与Cell建立关联了(之前并不会)。

ShowSizeAnimation函数是演示该动画很好的示例,它先将方块放大至1.2倍,然后缩小成原状。

有了这个类之后,MatrixGameWindow也要做一些相应的调整(详情见2048.linq),最终做出来的效果如下(注意合并时的动画):

撤销功能

有一天突然找到了一个带撤销功能的2048,那时我发现2048带不带撤销,其实是两个游戏。撤销就像神器,给爱挑(mian)战(zi)的玩(ruo)家(ji)带来了轻松与快乐,给予了第二次机会,让玩家转危为安。

所以不如先加入撤销功能。

用户每次撤销的,都是最新状态,是一个经典的后入先出的模式,也就是,因此在.NET中我们可以使用Stack<T>,在Matrix中可以这样定义:

  1. Stack<int[]> CellHistory = new Stack<int[]>();

如果要撤销,必将调用Matrix的某个函数,这个函数定义如下:

  1. public void TryPopHistory()
  2. {
  3. if (CellHistory.TryPop(out int[] history))
  4. {
  5. foreach (var pos in MatrixPositions)
  6. {
  7. CellTable[pos.y, pos.x] = history[pos.y * MatrixSize + pos.x] switch
  8. {
  9. default(int) => null,
  10. _ => new Cell(history[pos.y * MatrixSize + pos.x]),
  11. };
  12. }
  13. }
  14. }

注意这里存在一个一维数组二维数组的转换,通过控制下标求值,即可轻松将一维数组转换为二维数组

然后是创建撤销的时机,必须在准备移动前,记录当前历史:

  1. int[] history = CellTable.Cast<Cell>().Select(v => v?.N ?? default).ToArray();

注意这其实也是C#中将二维数组转换为一维数组的过程,数组继承于IEnumerable,调用其Cast<T>方法即可转换为IEnumerable<T>,然后即可愉快地使用LINQ.ToArray()了。

然后在确定移动之后,将历史入栈

  1. if (moved)
  2. {
  3. CellHistory.Push(history);
  4. // ...
  5. }

最后当然还需要加入事件支持,用户按下Back键即可撤销:

  1. keyUp.Where(k => k == Keys.Back).Subscribe(k => Matrix.TryPopHistory());

运行效果如下:

注意,这里又有一个bug,撤销时总分又没变,聪明的读者可以试试如何解决。

如果使用MVC和应用程序驱动的实时渲染,则这种bug不可能发生。

手势操作

2048可以在平板或手机上玩,因此手势操作必不可少,虽然电脑上有键盘,但多一个功能总比少一个功能好。

不知道C#窗口上有没有做手势识别这块的开源项目,但借助RX,这手撸一个也不难:

  1. static IObservable<Direction> DetectMouseGesture(Form form)
  2. {
  3. var mouseDown = Observable.FromEventPattern<MouseEventArgs>(form, nameof(form.MouseDown));
  4. var mouseUp = Observable.FromEventPattern<MouseEventArgs>(form, nameof(form.MouseUp));
  5. var mouseMove = Observable.FromEventPattern<MouseEventArgs>(form, nameof(form.MouseMove));
  6. const int throhold = 6;
  7. return mouseDown
  8. .SelectMany(x => mouseMove
  9. .TakeUntil(mouseUp)
  10. .Select(x => new { X = x.EventArgs.X, Y = x.EventArgs.Y })
  11. .ToList())
  12. .Select(d =>
  13. {
  14. int x = 0, y = 0;
  15. for (var i = 0; i < d.Count - 1; ++i)
  16. {
  17. if (d[i].X < d[i + 1].X) ++x;
  18. if (d[i].Y < d[i + 1].Y) ++y;
  19. if (d[i].X > d[i + 1].X) --x;
  20. if (d[i].Y > d[i + 1].Y) --y;
  21. }
  22. return (x, y);
  23. })
  24. .Select(v => new { Max = Math.Max(Math.Abs(v.x), Math.Abs(v.y)), Value = v})
  25. .Where(x => x.Max > throhold)
  26. .Select(v =>
  27. {
  28. if (v.Value.x == v.Max) return Direction.Right;
  29. if (v.Value.x == -v.Max) return Direction.Left;
  30. if (v.Value.y == v.Max) return Direction.Down;
  31. if (v.Value.y == -v.Max) return Direction.Up;
  32. throw new ArgumentOutOfRangeException(nameof(v));
  33. });
  34. }

这个代码非常精练,但其本质是RxMouseDownMouseUpMouseMove三个窗口事件“拍案叫绝”级别的应用,它做了如下操作:

  • MouseDown触发时开始记录,直到MouseUp触发为止
  • MouseMove的点集合起来生成一个List
  • 记录各个方向坐标递增的次数
  • 如果次数大于指定次数(6),即认可为一次事件
  • 在各个方向中,取最大的值(以减少误差)

测试代码及效果如下:

  1. void Main()
  2. {
  3. using var form = new Form();
  4. DetectMouseGesture(form).Dump();
  5. Application.Run(form);
  6. }

到了集成到2048游戏时,Rx的优势又体现出来了,如果之前使用事件操作,就会出现两个入口。但使用Rx后触发入口仍然可以保持统一,在之前的基础上,只需添加一行代码即可解决:

  1. keyUp.Select(x => x switch
  2. {
  3. Keys.Left => (Direction?)Direction.Left,
  4. Keys.Right => Direction.Right,
  5. Keys.Down => Direction.Down,
  6. Keys.Up => Direction.Up,
  7. _ => null
  8. })
  9. .Where(x => x != null && !Matrix.IsInAnimation())
  10. .Select(x => x.Value)
  11. .Merge(DetectMouseGesture(this)) // 只需加入这一行代码
  12. .Subscribe(direction =>
  13. {
  14. Matrix.RequestDirection(direction);
  15. Text = $"总分:{Matrix.GetScore()}";
  16. });

简直难以置信,有传言说我某个同学,使用某知名游戏引擎,做小游戏集成手势控制,搞三天三夜都没做出来。

总结

重新来回顾一下最终效果:

所有这些代码,都可以在我的Github上下载,请下载LINQPad 6运行。用Visual Studio 2019/VS Code也能编译运行,只需手动将代码拷贝至项目中,并安装FlysEngine.DesktopSystem.Reactive两个NuGet包即可。

下载地址如下:https://github.com/sdcb/blog-data/tree/master/2019/20191030-2048-by-dotnet

其中:

  • 2048.linq是最终版,可以完整地看到最终效果;
  • 最初版是2048-r4-no-cell.linq,可以从该文件开始进行演练;
  • 演练的顺序是r4, r3, r2, r1,最后最终版,因为写这篇文章是先把所有东西做出来,然后再慢慢删除做“阉割版”的示例;
  • animation-demo.linq_mouse-geature.linq是周边示例,用于演示动画和鼠标手势;
  • 我还做了一个2048-old.linq,采用的是一维数组而非二维储存Cell[,],有兴趣的可以看看,有少许区别

其实除了C#版,我多年前还做了一个html5/canvasjs版本,Github地址如下:https://github.com/sdcb/2048 其逻辑层和渲染层都有异曲同工之妙,事实也是我从js版本移动到C#并没花多少心思。这恰恰说明的“小游戏第一原则”——MVC的重要性。

……但完成这篇文章我花了很多、很多心思

.NET手撸2048小游戏的更多相关文章

  1. Unity手撸2048小游戏——模块拆分

    最近惹女票生气了,想起撸个游戏来哄哄她,加之以前在小恩爱App上,玩过那情侣版的2048,加之她喜欢玩这类益智类的游戏,打算撸一个3D的情侣版2048.不过之前没怎么独立做过游戏,就从2D的开始吧. ...

  2. Unity手撸2048小游戏——自动生成4*4棋盘

    1.新建文件夹,命prefabs,将刚刚做成的Chessman拖入该文件下,做成预制体 2.删除panel下的Chessman 3.在panel下,新建一个空对象,命名为Chessboard,大小设置 ...

  3. Unity手撸2048小游戏——背景文字控制

    今天继续昨天的计划吧 1.新建项目.场景命名啥的都不说了吧. 2.直接开始新建一个Image,顺便把Image改名成Chessman 3.选中Image新建一个Text对象,调整下大小位置.这样就算完 ...

  4. c#撸的控制台版2048小游戏

    1.分析 最近心血来潮,突然想写一个2048小游戏.于是搜索了一个在线2048玩玩,熟悉熟悉规则. 只谈核心规则:(以左移为例) 1.1合并 以行为单位,忽略0位,每列依次向左进行合并,且每列只能合并 ...

  5. js、jQuery实现2048小游戏

    2048小游戏 一.游戏简介:  2048是一款休闲益智类的数字叠加小游戏 二. 游戏玩法: 在4*4的16宫格中,您可以选择上.下.左.右四个方向进行操作,数字会按方向移动,相邻的两个数字相同就会合 ...

  6. HTML+CSS+JavaScript实现2048小游戏

    相信很多人都玩过2048小游戏,规则易懂.操作简单,我曾经也“痴迷”于它,不到2048不罢休,最高成绩合成了4096,现在正好拿它来练练手. 我对于2048的实现,除了使用了现有2048小游戏的配色, ...

  7. jQuery实践-网页版2048小游戏

    ▓▓▓▓▓▓ 大致介绍 看了一个实现网页版2048小游戏的视频,觉得能做出自己以前喜欢玩的小游戏很有意思便自己动手试了试,真正的验证了这句话-不要以为你以为的就是你以为的,看视频时觉得看懂了,会写了, ...

  8. C# 开发2048小游戏

    这应该是几个月前,闲的手痒,敲了一上午代码搞出来的,随之就把它丢弃了,当时让别人玩过,提过几条更改建议,但是时至今日,我也没有进行过优化和更改(本人只会作案,不会收场,嘎嘎),下面的建议要给代码爱好的 ...

  9. Swift实战之2048小游戏

    上周在图书馆借了一本Swift语言实战入门,入个门玩一玩^_^正好这本书的后面有一个2048小游戏的实例,笔者跟着实战了一把. 差不多一周的时间,到今天,游戏的基本功能已基本实现,细节我已不打算继续完 ...

随机推荐

  1. 基于C#的机器学习--c# .NET中直观的深度学习

    在本章中,将会学到: l  如何使用Kelp.Net来执行自己的测试 l  如何编写测试 l  如何对函数进行基准测试 Kelp.Net是一个用c#编写的深度学习库.由于能够将函数链到函数堆栈中,它在 ...

  2. 12.Django基础十之Form和ModelForm组件

    一 Form介绍 我们之前在HTML页面中利用form表单向后端提交数据时,都会写一些获取用户输入的标签并且用form标签把它们包起来. 与此同时我们在好多场景下都需要对用户的输入做校验,比如校验用户 ...

  3. java基础之缓存:session、cookie和cache的区别

    以前实现数据的缓存有很多种方法,有客户端的Cookie,有服务器端的Session和Application. 其中Cookie是保存在客户端的一组数据,主要用来保存用户名等个人信息. Session则 ...

  4. ThinkPHP5实现定时任务

    ThinkPHP5实现定时任务 最近使用ThinkPHP5做了个项目,项目中需要定时任务的功能,感觉有必要分享下 TP5做定时任务使用到command.php的 步骤如下: 1.配置command.p ...

  5. Spark 学习笔记之 map/flatMap/filter/mapPartitions/mapPartitionsWithIndex/sample

    map/flatMap/filter/mapPartitions/mapPartitionsWithIndex/sample:

  6. Kafka 学习笔记之 Topic日志清理

    Topic日志清理 server.properties: log.cleanup.policy=delete (默认) 1. 按时间维度进行Kafka日志清理 log.retention.hours= ...

  7. pycharm 激活码 2019/12最新福利(3)

    K6IXATEF43-eyJsaWNlbnNlSWQiOiJLNklYQVRFRjQzIiwibGljZW5zZWVOYW1lIjoi5o6I5p2D5Luj55CG5ZWGOiBodHRwOi8va ...

  8. Centos7安装及配置DHCP服务

    DHCP服务概述: 名称:DHCP  - Dynamic Host Configuration Protocol  动态主机配置协议. 功能:DHCP(Dynamic Host Configurati ...

  9. MySQL基础(三)多表查询(各种join连接详解)

    Mysql 多表查询详解 一.前言 二.示例 三.注意事项 一.前言 上篇讲到Mysql中关键字执行的顺序,只涉及了一张表:实际应用大部分情况下,查询语句都会涉及到多张表格 : 1.1 多表连接有哪些 ...

  10. Bran的内核开发教程(bkerndev)-02 准备工作

    准备工作   内核开发是编写代码以及调试各种系统组件的漫长过程.一开始这似乎是一个让人畏惧的任务,但是并不需要大量的工具集来编写自己的内核.这个内核开发教程主要涉及使用GRUB将内核加载到内存中.GR ...