前言
2048
是一款益智小游戏,得益于其规则简单,又和 2
的倍数有关,因此广为人知,特别是广受程序员的喜爱。
本文将再次使用我自制的“准游戏引擎” FlysEngine
,从空白窗口开始,演示如何“手撸” 2048
小游戏,并在编码过程中感受 C#
的魅力和 .NET
编程的快乐。
说明:
FlysEngine
是封装于Direct2D
,重复本文示例,只需在.NETCore3.0
下安装NuGet
包FlysEngine.Desktop
即可。并不一定非要做一层封装才能用,只是
FlysEngine
简化了创建设备、处理设备丢失、设备资源管理等“新手劝退”级操作,
首先来看一下最终效果:
小游戏的三原则
在开始做游戏前,我先聊聊 CRUD
程序员做小游戏时,我认为最重要的三大基本原则。很多时候我们有做个游戏的心,但发现做出来总不是那么回事。这时可以对照一下,看是不是违反了这三大原则中的某一个:
MVC
应用程序驱动(而非事件驱动)
动画
MVC
或者 MVP
……关键是将逻辑与视图分离。它有两大特点:
视图层完全没有状态;
数据的变动不会直接影响呈现的画面。
也就是所有的数据更新,都只应体现在内存中。游戏中的数据变化可能非常多,应该积攒起来,一次性更新到界面上。
这是因为游戏实时渲染特有的性能所要求的,游戏常常有成百上千个动态元素在界面上飞舞,这些动作必须在一次垂直同步(如 16ms
或更低)的时间内完成,否则用户就会察觉到卡顿。
常见的反例有 knockout.js
,它基于 MVVM
,也就是数据改变会即时通知到视图( DOM
),导致视图更新不受控制。
另外, MVC
还有一个好处,就是假如代码需要移植平台时(如 C#
移植到 html5
),只需更新呈现层即可,模型层所有逻辑都能保留。
应用程序驱动(而非事件驱动)
应用程序驱动的特点是界面上的动态元素,之所以“动”,是由应用程序触发——而非事件触发的。
这一点其实与 MVC
也是相辅相成。应用程序驱动确保了 MVC
的性能,不会因为依赖变量重新求值次数过多而影响性能。
另外,如果界面上有状态,就会导致逻辑变得非常复杂,比如变量之间的依赖求值、界面上某些参数的更新时机等。不如简单点搞!直接全部重新计算,全部重新渲染,绝对不会错!
细心的读者可能发现最终效果
demo
中的总分显示就有bug
,开始游戏时总分应该是4
,而非72
。这就是由于该部分没有使用应用程序驱动求值,导致逻辑复杂,导致粗心……最终导致出现了bug
。
在 html5
的 canvas
中,实时渲染的“心脏”是 requestAnimationFrame()
函数,在 FlysEngine
中,“心脏”是 RenderLoop.Run()
函数:
using var form = new RenderWindow { ClientSize = new System.Drawing.Size(400, 400) };
form.Draw += (RenderWindow sender, DeviceContext ctx) =>
{ ctx.Clear(Color.CornflowerBlue);
};
RenderLoop.Run(form, () => form.Render(1, PresentFlags.None)); // 心脏
动画
动画是小游戏的灵魂,一个游戏做得够不够精致,有没有“质感”,除了 UI
把关外,就靠我们程序员把动画做好了。
动画的本质是变量从一个值按一定的速度变化到另一个值:
using var form = new RenderWindow { StartPosition = System.Windows.Forms.FormStartPosition.CenterScreen };
float x = 0;
form.Draw += (w, ctx) =>
{ ctx.Clear(Color.CornflowerBlue); var brush = w.XResource.GetColor(Color.Red); ctx.FillRectangle(new RectangleF(x, 50, 50, 50), brush); ctx.DrawText($"x = {x}", w.XResource.TextFormats[20], new RectangleF(0, 0, 100, 100), brush); x += 1.0f;
};
RenderLoop.Run(form, () => form.Render(1, PresentFlags.None));
运行效果如下:
然而,如果用应用程序驱动——而非事件驱动做动画,代码容易变得混乱不堪。尤其是多个动画、动画与动画之间做串联等等。
这时代码需要精心设计,将代码写成像事件驱动那么容易,下文将演示如何在 2048
小游戏中做出流畅的动画。
2048小游戏
回到2048小游戏,我们将在制作这个游戏,慢慢体会我所说的“小游戏三原则”。
起始代码
这次我们创建一个新的类 GameWindow
,继承于 RenderWindow
(不像之前直接使用 RenderWindow
类),这样有利于分离视图层:
const int MatrixSize = 4;
void Main()
{ using var g = new GameWindow() { ClientSize = new System.Drawing.Size(400, 400) }; RenderLoop.Run(g, () => g.Render(1, PresentFlags.None));
}
public class GameWindow : RenderWindow
{ protected override void OnDraw(DeviceContext ctx) { ctx.Clear(new Color(0xffa0adbb)); }
}
OnDraw
重载即为渲染的方法,提供了一个 ctx
参数,对应 Direct2D
中的 ID2D1DeviceContext
类型,可以用来绘图。
其中 0xffa0adbb
是棋盘背景颜色,它是用 ABGR
的顺序表示的,运行效果如下:
棋盘
首先我们需要“画”一个棋盘,它分为背景和棋格子组成。这部分内容是完全静态的,因此可以在呈现层直接完成。
棋盘应该随着窗口大小变化而变化,因此各个变量都应该动态计算得出。
如图, 2048
游戏区域应该为正方形,因此总边长 fullEdge
应该为窗口的高宽属性的较小者(以刚好放下一个正方形),代码表示如下:
float fullEdge = Math.Min(ctx.Size.Width, ctx.Size.Height);
方块与方块之间的距离定义为总边长的 1/8
再除以 MatrixSize
(也就是4),此时单个方块的边长就可以计算出来了,为总边长 fullEdge
减去5个 gap
再除以 MatrixSize
,代码如下:
float gap = fullEdge / (MatrixSize * 8);
float edge = (fullEdge - gap * (MatrixSize + 1)) / MatrixSize;
然后即可按循环绘制 4
行 4
列方块位置,使用矩阵变换可以让代码更简单:
foreach (var v in MatrixPositions)
{ float centerX = gap + v.x * (edge + gap) + edge / 2.0f; float centerY = gap + v.y * (edge + gap) + edge / 2.0f; ctx.Transform = Matrix3x2.Translation(-edge / 2, -edge / 2) * Matrix3x2.Translation(centerX, centerY); ctx.FillRoundedRectangle(new RoundedRectangle { RadiusX = edge / 21, RadiusY = edge / 21, Rect = new RectangleF(0, 0, edge, edge), }, XResource.GetColor(new Color(0x59dae4ee)));
}
注意 foreach(varvinMatrixPositions)
是以下代码的简写:
for (var x = 0; x < MatrixSize; ++x)
{ for (var y = 0; y < MatrixSize; ++y) { // ... }
}
由于 2048
将多次遍历 x
和 y
,因此定义了一个变量 MatrixPositions
来简化这一过程:
static IEnumerable<int> inorder = Enumerable.Range(0, MatrixSize);
static IEnumerable<(int x, int y)> MatrixPositions => inorder.SelectMany(y => inorder.Select(x => (x, y)));
运行效果如下:
加入数字方块
数据方块由于是活动的,为了代码清晰,需要加入额外两个类, Cell
和 Matrix
。
Cell类
Cell
是单个方块,需要保存当前的数字 N
,其次还要获取当前的颜色信息:
class Cell
{ public int N; public Cell(int n) { N = n; } public DisplayInfo DisplayInfo => N switch { 2 => DisplayInfo.Create(), 4 => DisplayInfo.Create(0xede0c8ff), 8 => DisplayInfo.Create(0xf2b179ff, 0xf9f6f2ff), 16 => DisplayInfo.Create(0xf59563ff, 0xf9f6f2ff), 32 => DisplayInfo.Create(0xf67c5fff, 0xf9f6f2ff), 64 => DisplayInfo.Create(0xf65e3bff, 0xf9f6f2ff), 128 => DisplayInfo.Create(0xedcf72ff, 0xf9f6f2ff, 45), 256 => DisplayInfo.Create(0xedcc61ff, 0xf9f6f2ff, 45), 512 => DisplayInfo.Create(0xedc850ff, 0xf9f6f2ff, 45), 1024 => DisplayInfo.Create(0xedc53fff, 0xf9f6f2ff, 35), 2048 => DisplayInfo.Create(0x3c3a32ff, 0xf9f6f2ff, 35), _ => DisplayInfo.Create(0x3c3a32ff, 0xf9f6f2ff, 30), };
}
其中, DisplayInfo
类用来表达方块的文字颜色、背景颜色和字体大小:
struct DisplayInfo
{ public Color Background; public Color Foreground; public float FontSize; public static DisplayInfo Create(uint background = 0xeee4daff, uint color = 0x776e6fff, float fontSize = 55) => new DisplayInfo { Background = new Color(background), Foreground = new Color(color), FontSize = fontSize };
}
文章中的“魔法”数字 0xeee4daff
等,和上文一样,是颜色的 ABGR
顺序表示的。通过一个简单的 Create
方法,即可实现默认颜色、默认字体的代码简化,无需写过多的 if/else
。
注意:
我特意使用了
struct
而非class
关键字,这样创建的是值类型而非引用类型,可以无需分配和回收堆内存。在应用或游戏中,内存分配和回收常常是最影响性能和吞吐性的指标之一。Nswitch{...}
这样的代码,是C# 8.0
的switchexpression
特性(下文将继续大量使用),可以通过表达式——而非语句的方式表达一个逻辑,可以让代码大大简化。该特性现在在.NETCore3.0
项目中默认已经打开,某些支持的早期版本,需要将项目中的<LangVersion>
属性设置为8.0
才可以使用。
根据 2048
的设计文档和参考其它项目,一个方块创建时有 90%
机率是 2
, 10%
机率是 4
,这可以通过 .NET
中的 Random
类实现:
static Random r = new Random();
public static Cell CreateRandom() => new Cell(r.NextDouble() < 0.9 ? 2 : 4);
使用时,只需调用 CreateRandom()
即可。
Matrix类
Matrix
用于管理和控制多个 Cell
类。它包含了一个二维数组 Cell[,]
,用于保存 4x4
的 Cell
:
class Matrix
{ public Cell[,] CellTable; public IEnumerable<Cell> GetCells() { foreach (var c in CellTable) if (c != null) yield return c; } public int GetScore() => GetCells().Sum(v => v.N); public void ReInitialize() { CellTable = new Cell[MatrixSize, MatrixSize]; (int x, int y)[] allPos = MatrixPositions.ShuffleCopy(); for (var i = 0; i < 2; ++i) // 2: initial cell count { CellTable[allPos[i].y, allPos[i].x] = Cell.CreateRandom(); } }
}
其中 ReInitialize
方法对 Cell[,]
二维数组进行了初始化,然后在随机位置创建了两个 Cell
。值得一提的是 ShuffleCopy()
函数,该函数可以对 IEnumerable<T>
进行乱序,然后复制为数组:
static class RandomUtil
{ static Random r = new Random(); public static T[] ShuffleCopy<T>(this IEnumerable<T> data) { var arr = data.ToArray(); for (var i = arr.Length - 1; i > 0; --i) { int randomIndex = r.Next(i + 1); T temp = arr[i]; arr[i] = arr[randomIndex]; arr[randomIndex] = temp; } return arr; }
}
该函数看似简单,能写准确可不容易。尤其注意 for
循环的终止条件不是 i>=0
,而是 i>0
,这两者有区别,以后我有机会会深入聊聊这个函数。今天最简单的办法就是——直接使用它即可。
最后回到 GameWindow
类的 OnDraw
方法,如法炮制,将 Matrix
“画”出来即可:
// .. 继之前的OnDraw方法内容
foreach (var p in MatrixPositions)
{ var c = Matrix.CellTable[p.y, p.x]; if (c == null) continue; float centerX = gap + p.x * (edge + gap) + edge / 2.0f; float centerY = gap + p.y * (edge + gap) + edge / 2.0f; ctx.Transform = Matrix3x2.Translation(-edge / 2, -edge / 2) * Matrix3x2.Translation(centerX, centerY); ctx.FillRectangle(new RectangleF(0, 0, edge, edge), XResource.GetColor(c.DisplayInfo.Background)); var textLayout = XResource.TextLayouts[c.N.ToString(), c.DisplayInfo.FontSize]; ctx.Transform = Matrix3x2.Translation(-textLayout.Metrics.Width / 2, -textLayout.Metrics.Height / 2) * Matrix3x2.Translation(centerX, centerY); ctx.DrawTextLayout(Vector2.Zero, textLayout, XResource.GetColor(c.DisplayInfo.Foreground));
}
此时运行效果如下:
如果想测试所有方块颜色,可将 ReInitialize()
方法改为如下即可:
public void ReInitialize()
{ CellTable = new Cell[MatrixSize, MatrixSize]; CellTable[0, 0] = new Cell(2); CellTable[0, 1] = new Cell(4); CellTable[0, 2] = new Cell(8); CellTable[0, 3] = new Cell(16); CellTable[1, 0] = new Cell(32); CellTable[1, 1] = new Cell(64); CellTable[1, 2] = new Cell(128); CellTable[1, 3] = new Cell(256); CellTable[2, 0] = new Cell(512); CellTable[2, 1] = new Cell(1024); CellTable[2, 2] = new Cell(2048); CellTable[2, 3] = new Cell(4096); CellTable[3, 0] = new Cell(8192); CellTable[3, 1] = new Cell(16384); CellTable[3, 2] = new Cell(32768); CellTable[3, 3] = new Cell(65536);
}
运行效果如下:
嗯,看起来……有那么点意思了。
引入事件,把方块移动起来
本篇也分两部分,事件,和方块移动逻辑。
事件
首先是事件,要将方块移动起来,我们再次引入大名鼎鼎的 Rx
(全称: Reactive.NET
, NuGet
包: System.Reactive
)。然后先引入一个基础枚举,用于表示上下左右:
enum Direction
{ Up, Down, Left, Right,
}
然后将键盘的上下左右事件,转换为该枚举的 IObservable<Direction>
流(可以写在 GameWindow
构造函数中),然后调用该“流”的 .Subscribe
方法直接订阅该“流”:
var keyUp = Observable.FromEventPattern<KeyEventArgs>(this, nameof(this.KeyUp)) .Select(x => x.EventArgs.KeyCode);
keyUp.Select(x => x switch { Keys.Left => (Direction?)Direction.Left, Keys.Right => Direction.Right, Keys.Down => Direction.Down, Keys.Up => Direction.Up, _ => null }) .Where(x => x != null) .Select(x => x.Value) .Subscribe(direction => { Matrix.RequestDirection(direction); Text = $"总分:{Matrix.GetScore()}"; });
keyUp.Where(k => k == Keys.Escape).Subscribe(k =>
{ if (MessageBox.Show("要重新开始游戏吗?", "确认", MessageBoxButtons.OKCancel) == System.Windows.Forms.DialogResult.OK) { Matrix.ReInitialize(); // 这行代码没写就是文章最初说的bug,其根本原因(也许忘记了)就是因为这里不是用的MVC/应用程序驱动 // Text = $"总分:{Matrix.GetScore()}"; }
});
每次用户松开上下左右四个键之一,就会调用 Matrix
的 RequestDirection
方法(马上说),松下 Escape
键,则会提示用户是否重新开始玩,然后重新显示新的总分。
注意:
我再次使用了
C# 8.0
的switchexpression
语法,它让我省去了if/else
或switchcase
,代码精练了不少;不是非得要用
Rx
,但Rx
相当于将事件转换为了数据,可以让代码精练许多,且极大地提高了可扩展性。
移动逻辑
我们先在脑子里面想想,感受一下这款游戏的移动逻辑应该是怎样的。(你可以在草稿本上先画画图……)
我将 2048
游戏的逻辑概括如下:
将所有方块,向用户指定的方向遍历,找到最近的方块位置
如果找到,且数字一样,则合并(删除对面,自己加倍)
如果找到,但数字不一样,则移动到对面的前一格
如果发生过移动,则生成一个新方块
如果想清楚了这个逻辑,就能写出代码如下:
public void RequestDirection(Direction direction)
{ if (GameOver) return; var dv = Directions[(int)direction]; var tx = dv.x == 1 ? inorder.Reverse() : inorder; var ty = dv.y == 1 ? inorder.Reverse() : inorder; bool moved = false; foreach (var i in tx.SelectMany(x => ty.Select(y => (x, y)))) { Cell cell = CellTable[i.y, i.x]; if (cell == null) continue; var next = NextCellInDirection(i, dv); if (WithinBounds(next.target) && CellTable[next.target.y, next.target.x].N == cell.N) { // 对面有方块,且可合并 CellTable[i.y, i.x] = null; CellTable[next.target.y, next.target.x] = cell; cell.N *= 2; moved = true; } else if (next.prev != i) // 对面无方块,移动到prev { CellTable[i.y, i.x] = null; CellTable[next.prev.y, next.prev.x] = cell; moved = true; } } if (moved) { var nextPos = MatrixPositions .Where(v => CellTable[v.y, v.x] == null) .ShuffleCopy() .First(); CellTable[nextPos.y, nextPos.x] = Cell.CreateRandom(); if (!IsMoveAvailable()) GameOver = true; }
}
其中, dv
、 tx
与 ty
三个变量,巧妙地将 Direction
枚举转换成了数据,避免了过多的 if/else
,导致代码膨胀。然后通过一行简单的 LINQ
,再次将两个 for
循环联合在一起。
注意示例还使用了
(x,y)
这样的语法(下文将继续大量使用),这叫ValueTuple
,或者值元组
。ValueTuple
是C# 7.0
的新功能,它和C# 6.0
新增的Tuple
的区别有两点:
ValueTuple
可以通过(x,y)
这样的语法内联,而Tuple
要使用Tuple.Create(x,y)
来创建
ValueTuple
故名思义,它是值类型
,可以无需内存分配和GC
开销(但稍稍增长了少许内存复制开销)
我还定义了另外两个字段: GameOver
和 KeepGoing
,用来表示是否游戏结束和游戏胜利时是否继续:
public bool GameOver,KeepGoing;
其中, NextCellInDirection
用来计算方块对面的情况,代码如下:
public ((int x, int y) target, (int x, int y) prev) NextCellInDirection((int x, int y) cell, (int x, int y) dv)
{ (int x, int y) prevCell; do { prevCell = cell; cell = (cell.x + dv.x, cell.y + dv.y); } while (WithinBounds(cell) && CellTable[cell.y, cell.x] == null); return (cell, prevCell);
}
IsMoveAvailable
函数用来判断游戏是否还能继续,如果不能继续将设置 GameOver=true
。
它的逻辑是如果方块数不满,则显示游戏可以继续,然后判断是否有任意相邻方块数字相同,有则表示游戏还能继续,具体代码如下:
public bool IsMoveAvailable() => GetCells().Count() switch
{ MatrixSize * MatrixSize => MatrixPositions .SelectMany(v => Directions.Select(d => new { Position = v, Next = (x: v.x + d.x, y: v.y + d.y) })) .Where(x => WithinBounds(x.Position) && WithinBounds(x.Next)) .Any(v => CellTable[v.Position.y, v.Position.x]?.N == CellTable[v.Next.y, v.Next.x]?.N), _ => true,
};
注意我再次使用了 switchexpression
、 ValueTuple
和令人拍案叫绝的 LINQ
,相当于只需一行代码,就将这些复杂的逻辑搞定了。
最后别忘了在 GameWindow
的 OnUpdateLogic
重载函数中加入一些弹窗提示,显示用于恭喜和失败的信息:
protected override void OnUpdateLogic(float dt)
{ base.OnUpdateLogic(dt); if (Matrix.GameOver) { if (MessageBox.Show($"总分:{Matrix.GetScore()}\r\n重新开始吗?", "失败!", MessageBoxButtons.YesNo) == DialogResult.Yes) { Matrix.ReInitialize(); } else { Matrix.GameOver = false; } } else if (!Matrix.KeepGoing && Matrix.GetCells().Any(v => v.N == 2048)) { if (MessageBox.Show("您获得了2048!\r\n还想继续升级吗?", "恭喜!", MessageBoxButtons.YesNo) == DialogResult.Yes) { Matrix.KeepGoing = true; } else { Matrix.ReInitialize(); } }
}
这时,游戏运行效果显示如下:
优化
其中到了这一步, 2048
已经可堪一玩了,但总感觉不是那么个味。还有什么可以做的呢?
动画
上文说过,动画是灵魂级别的功能。和 CRUD
程序员的日常——“功能”实现了就万事大吉不同,游戏必须要有动画,没有动画简直就相当于游戏白做了。
在远古 jQuery
中,有一个 $(element).animate()
方法,实现动画挺方便,我们可以模仿该方法的调用方式,自己实现一个:
public static GameWindow Instance = null;
public static Task CreateAnimation(float initialVal, float finalVal, float durationMs, Action<float> setter)
{ var tcs = new TaskCompletionSource<float>(); Variable variable = Instance.XResource.CreateAnimation(initialVal, finalVal, durationMs / 1000); IDisposable subscription = null; subscription = Observable .FromEventPattern<RenderWindow, float>(Instance, nameof(Instance.UpdateLogic)) .Select(x => x.EventArgs) .Subscribe(x => { setter((float)variable.Value); if (variable.FinalValue == variable.Value) { tcs.SetResult(finalVal); variable.Dispose(); subscription.Dispose(); } }); return tcs.Task;
}
public GameWindow()
{ Instance = this; // ...
}
注意,我实际是将一个动画转换成为了一个 Task
,这样就可以实际复杂动画、依赖动画、连续动画的效果。
使用该函数,可以轻易做出这样的效果,动画部分代码只需这样写(见 animation-demo.linq
):
float x = 50, y = 150, w = 50, h = 50;
float red = 0;
protected override async void OnLoad(EventArgs e)
{ var stage1 = new[] { CreateAnimation(initialVal: x, finalVal: 340, durationMs: 1000, v => x = v), CreateAnimation(initialVal: h, finalVal: 100, durationMs: 600, v => h = v), }; await Task.WhenAll(stage1); await CreateAnimation(initialVal: h, finalVal: 50, durationMs: 1000, v => h = v); await CreateAnimation(initialVal: x, finalVal: 20, durationMs: 1000, v => x = v); while (true) { await CreateAnimation(initialVal: red, finalVal: 1.0f, durationMs: 500, v => red = v); await CreateAnimation(initialVal: red, finalVal: 0.0f, durationMs: 500, v => red = v); }
}
运行效果如下,请注意最后的黑色-红色闪烁动画,其实是一个无限动画,各位可以想像下如果手撸状态机,这些代码会多么麻烦,而 C#
支持协程,这些代码只需一些 await
和一个 while(true)
语句即可完美完成:
有了这个基础,开工做动画了,首先给 Cell
类做一些修改:
class Cell
{ public int N; public float DisplayX, DisplayY, DisplaySize = 0; const float AnimationDurationMs = 120; public bool InAnimation => (int)DisplayX != DisplayX || (int)DisplayY != DisplayY || (int)DisplaySize != DisplaySize; public Cell(int x, int y, int n) { DisplayX = x; DisplayY = y; N = n; _ = ShowSizeAnimation(); } public async Task ShowSizeAnimation() { await GameWindow.CreateAnimation(DisplaySize, 1.2f, AnimationDurationMs, v => DisplaySize = v); await GameWindow.CreateAnimation(DisplaySize, 1.0f, AnimationDurationMs, v => DisplaySize = v); } public void MoveTo(int x, int y, int n = default) { _ = GameWindow.CreateAnimation(DisplayX, x, AnimationDurationMs, v => DisplayX = v); _ = GameWindow.CreateAnimation(DisplayY, y, AnimationDurationMs, v => DisplayY = v); if (n != default) { N = n; _ = ShowSizeAnimation(); } } public DisplayInfo DisplayInfo => N switch // ... static Random r = new Random(); public static Cell CreateRandomAt(int x, int y) => new Cell(x, y, r.NextDouble() < 0.9 ? 2 : 4);
}
加入了 DisplayX
, DisplayY
、 DisplaySize
三个属性,用于管理其用于在界面上显示的值。还加入了一个 InAnimation
变量,用于判断是否处理动画状态。
另外,构造函数现在也要求传入 x
和 y
的值,如果位置变化了,现在必须调用 MoveTo
方法,它与 Cell
建立关联了(之前并不会)。
ShowSizeAnimation
函数是演示该动画很好的示例,它先将方块放大至 1.2
倍,然后缩小成原状。
有了这个类之后, Matrix
和 GameWindow
也要做一些相应的调整(详情见 2048.linq
),最终做出来的效果如下(注意合并时的动画):
撤销功能
有一天突然找到了一个带撤销功能的 2048
,那时我发现 2048
带不带撤销,其实是两个游戏。撤销就像神器,给爱挑( mian
)战( zi
)的玩( ruo
)家( ji
)带来了轻松与快乐,给予了第二次机会,让玩家转危为安。
所以不如先加入撤销功能。
用户每次撤销的,都是最新状态,是一个经典的后入先出的模式,也就是 栈
,因此在 .NET
中我们可以使用 Stack<T>
,在 Matrix
中可以这样定义:
Stack<int[]> CellHistory = new Stack<int[]>();
如果要撤销,必将调用 Matrix
的某个函数,这个函数定义如下:
public void TryPopHistory()
{ if (CellHistory.TryPop(out int[] history)) { foreach (var pos in MatrixPositions) { CellTable[pos.y, pos.x] = history[pos.y * MatrixSize + pos.x] switch { default(int) => null, _ => new Cell(history[pos.y * MatrixSize + pos.x]), }; } }
}
注意这里存在一个
一维数组
与二维数组
的转换,通过控制下标求值,即可轻松将一维数组
转换为二维数组
。
然后是创建撤销的时机,必须在准备移动前,记录当前历史:
int[] history = CellTable.Cast<Cell>().Select(v => v?.N ?? default).ToArray();
注意这其实也是
C#
中将二维数组
转换为一维数组
的过程,数组继承于IEnumerable
,调用其Cast<T>
方法即可转换为IEnumerable<T>
,然后即可愉快地使用LINQ
和.ToArray()
了。
然后在确定移动之后,将历史 入栈
:
if (moved)
{ CellHistory.Push(history); // ...
}
最后当然还需要加入事件支持,用户按下 Back
键即可撤销:
keyUp.Where(k => k == Keys.Back).Subscribe(k => Matrix.TryPopHistory());
运行效果如下:
注意,这里又有一个
bug
,撤销时总分又没变,聪明的读者可以试试如何解决。如果使用
MVC
和应用程序驱动的实时渲染,则这种bug
则不可能发生。
手势操作
2048
可以在平板或手机上玩,因此手势操作必不可少,虽然电脑上有键盘,但多一个功能总比少一个功能好。
不知道 C#
窗口上有没有做 手势识别
这块的开源项目,但借助 RX
,这手撸一个也不难:
static IObservable<Direction> DetectMouseGesture(Form form)
{ var mouseDown = Observable.FromEventPattern<MouseEventArgs>(form, nameof(form.MouseDown)); var mouseUp = Observable.FromEventPattern<MouseEventArgs>(form, nameof(form.MouseUp)); var mouseMove = Observable.FromEventPattern<MouseEventArgs>(form, nameof(form.MouseMove)); const int throhold = 6; return mouseDown .SelectMany(x => mouseMove .TakeUntil(mouseUp) .Select(x => new { X = x.EventArgs.X, Y = x.EventArgs.Y }) .ToList()) .Select(d => { int x = 0, y = 0; for (var i = 0; i < d.Count - 1; ++i) { if (d[i].X < d[i + 1].X) ++x; if (d[i].Y < d[i + 1].Y) ++y; if (d[i].X > d[i + 1].X) --x; if (d[i].Y > d[i + 1].Y) --y; } return (x, y); }) .Select(v => new { Max = Math.Max(Math.Abs(v.x), Math.Abs(v.y)), Value = v}) .Where(x => x.Max > throhold) .Select(v => { if (v.Value.x == v.Max) return Direction.Right; if (v.Value.x == -v.Max) return Direction.Left; if (v.Value.y == v.Max) return Direction.Down; if (v.Value.y == -v.Max) return Direction.Up; throw new ArgumentOutOfRangeException(nameof(v)); });
}
这个代码非常精练,但其本质是 Rx
对 MouseDown
、 MouseUp
和 MouseMove
三个窗口事件“拍案叫绝”级别的应用,它做了如下操作:
MouseDown
触发时开始记录,直到MouseUp
触发为止将
MouseMove
的点集合起来生成一个List
记录各个方向坐标递增的次数
如果次数大于指定次数(
6
),即认可为一次事件在各个方向中,取最大的值(以减少误差)
测试代码及效果如下:
void Main()
{ using var form = new Form(); DetectMouseGesture(form).Dump(); Application.Run(form);
}
到了集成到 2048
游戏时, Rx
的优势又体现出来了,如果之前使用事件操作,就会出现两个入口。但使用 Rx
后触发入口仍然可以保持统一,在之前的基础上,只需添加一行代码即可解决:
keyUp.Select(x => x switch { Keys.Left => (Direction?)Direction.Left, Keys.Right => Direction.Right, Keys.Down => Direction.Down, Keys.Up => Direction.Up, _ => null }) .Where(x => x != null && !Matrix.IsInAnimation()) .Select(x => x.Value) .Merge(DetectMouseGesture(this)) // 只需加入这一行代码 .Subscribe(direction => { Matrix.RequestDirection(direction); Text = $"总分:{Matrix.GetScore()}"; });
简直难以置信,有传言说我某个同学,使用某知名游戏引擎,做小游戏集成手势控制,搞三天三夜都没做出来。
总结
重新来回顾一下最终效果:
所有这些代码,都可以在我的 Github
上下载,请下载 LINQPad6
运行。用 VisualStudio2019
/ VSCode
也能编译运行,只需手动将代码拷贝至项目中,并安装 FlysEngine.Desktop
和 System.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/canvas
的 js
版本, Github
地址如下:https://github.com/sdcb/2048 其逻辑层和渲染层都有异曲同工之妙,事实也是我从 js
版本移动到 C#
并没花多少心思。这恰恰说明的“小游戏第一原则”—— MVC
的重要性。
……但完成这篇文章我花了很多、很多心思😂。微信限制文章可能无法评论,喜欢的朋友 可进入我的博客园进行评论(https://www.cnblogs.com/sdflysha/p/20191030-2048-by-dotnet.html),并关注我的微信公众号:【DotNet骚操作】