​.NET手撸2048小游戏

前言

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

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

说明: FlysEngine是封装于 Direct2D,重复本文示例,只需在 .NETCore3.0下安装 NuGet包 FlysEngine.Desktop即可。

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

首先来看一下最终效果: 

640?wx_fmt=gif

小游戏的三原则

在开始做游戏前,我先聊聊 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));

运行效果如下: 

640?wx_fmt=gif

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

这时代码需要精心设计,将代码写成像事件驱动那么容易,下文将演示如何在 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的顺序表示的,运行效果如下: 

640?wx_fmt=png

棋盘

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

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

640?wx_fmt=png

如图, 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)));

运行效果如下: 

640?wx_fmt=png

加入数字方块

数据方块由于是活动的,为了代码清晰,需要加入额外两个类, 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));	
}

此时运行效果如下: 

640?wx_fmt=png

如果想测试所有方块颜色,可将 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);	
}

运行效果如下: 

640?wx_fmt=png

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

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

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

事件

首先是事件,要将方块移动起来,我们再次引入大名鼎鼎的 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键,则会提示用户是否重新开始玩,然后重新显示新的总分。

注意:

  1. 我再次使用了 C# 8.0的 switchexpression语法,它让我省去了 if/else或 switchcase,代码精练了不少;

  2. 不是非得要用 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();	}	}	
}

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

640?wx_fmt=gif

优化

其中到了这一步, 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)语句即可完美完成:

640?wx_fmt=gif

有了这个基础,开工做动画了,首先给 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),最终做出来的效果如下(注意合并时的动画): 

640?wx_fmt=gif

撤销功能

有一天突然找到了一个带撤销功能的 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());

运行效果如下: 

640?wx_fmt=gif

注意,这里又有一个 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);	
}

640?wx_fmt=gif

到了集成到 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()}";	});

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

总结

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

640?wx_fmt=gif

所有这些代码,都可以在我的 Github上下载,请下载 LINQPad6运行。用 VisualStudio2019VSCode也能编译运行,只需手动将代码拷贝至项目中,并安装 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骚操作】

640?wx_fmt=jpeg

本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若转载,请注明出处:http://www.mzph.cn/news/313156.shtml

如若内容造成侵权/违法违规/事实不符,请联系多彩编程网进行投诉反馈email:809451989@qq.com,一经查实,立即删除!

相关文章

自行实现高性能MVC

wcf虽然功能多、扩展性强但是也面临配置忒多&#xff0c;而且restful的功能相当怪异&#xff0c;并且目前没法移植。asp.net core虽然支持webapi&#xff0c;但是功能也相对繁多、配置复杂。就没有一个能让码农们安安心心的写webapi&#xff0c;无需考虑性能、配置、甚至根据问…

caffe matio问题

http://blog.csdn.net/houqiqi/article/details/46469981 注&#xff1a;如果指令行模式实在解决不了/lib/libcaffe.so: undefined reference to Mat_VarReadDataLinear问题&#xff0c;可以尝试在QT下进行训练和测试。 1&#xff0c; 下载matio(http://sourceforge.NET/pro…

技术管理者怎样跳出“泥潭”

近几年面试了不少新人&#xff0c;当问到职业规划时&#xff0c;大多都会说先积累技术&#xff0c;然后往架构师的方向发展。这可能是技术人的一个特质&#xff0c;喜欢跟机器相处&#xff0c;沉浸在代码之中&#xff0c;而不喜欢跟人打交道。现实的情况是&#xff0c;一些中小…

你或许以为你不需要领域驱动设计

作者&#xff1a;邹溪源&#xff0c;长沙资深互联网从业者&#xff0c;架构师社区合伙人&#xff01;一犹记得刚刚参加工作时&#xff0c;是地图厂商四维图新集团旗下的一家子公司&#xff0c;主要从事规划测绘相关软件研发的公司。当时我的项目是为勘测设计院提供相对应的应用…

redis为什么这么火该怎么用

最近一些人在介绍方案时&#xff0c;经常会出现redis这个词&#xff0c;于是很多小伙伴百度完redis也就觉得它是一个缓存&#xff0c;然后项目里面把数据丢进去完事&#xff0c;甚至有例如将实体属性拆分塞进redis hash里面的奇怪用法等等&#xff01;原因是什么呢&#xff1f;…

.Net Core实现健康检查

ASP.NET Core 提供运行状况检查中间件和库&#xff0c;以用于报告应用基础结构组件的运行状况。运行状况探测可以由容器业务流程协调程和负载均衡器用于检查应用的状态。例如&#xff0c;容器业务流程协调程序可以通过停止滚动部署或重新启动容器来响应失败的运行状况检查。负载…

微软宣布加入 OpenJDK,看网上各派的热闹

微软宣布加入 OpenJDK 项目&#xff08;https://www.oschina.net/news/111036/microsoft-to-participate-in-openidk&#xff09;&#xff0c;这两天在微信公众号里面有几种论调&#xff1a;上面这些都是Javaer的观点&#xff0c;在CSharper 对这件事情的反应更奇怪了&#xff…

这6点解释了罗永浩为什么要卖艺

01是的&#xff0c;我们的‘老赖又上热搜了。&#xff08;ps:还是传统的语法&#xff0c;换了个人而已&#xff0c;味道有点改变&#xff09;11 月 3 日下午&#xff0c;罗永浩因锤子科技的 375 万欠款被江苏丹阳法院限制高消费&#xff0c;他不得乘坐飞机头等舱、软卧、高铁等…

微软发布研究报告:企业数据管理普遍混乱,揭秘大数据分析趋势以及PowerBI的崛起机遇...

本文非常重要&#xff0c;忽略者责任自负。我们时常看到很多新闻说企业的数据分析或大数据如何如何高大上&#xff0c;但你自己感觉你自己所处的环境呢&#xff1f;很多小伙伴在群里真切的抱怨到&#xff1a;感觉是一坨祥云。为什么你看到的和你感受到的有如此巨大的反差&#…

Magicodes.Pay,打造开箱即用的统一支付库,已提供ABP模块封装

Magicodes.Pay&#xff0c;打造开箱即用的统一支付库&#xff0c;已提供ABP模块封装简介Magicodes.Pay&#xff0c;是心莱科技团队提供的统一支付库&#xff0c;相关库均使用.NET标准库编写&#xff0c;支持.NET Framework以及.NET Core。目前已提供Abp模块的封装&#xff0c;支…

在.NET Core 3.0中发布单个Exe文件(PublishSingleFile)

假设我有一个简单的“ Hello World”控制台应用程序&#xff0c;我想发送给朋友来运行。朋友没有安装.NET Core&#xff0c;所以我知道我需要为他构建一个独立的应用程序。很简单&#xff0c;我只需在项目目录中运行以下命令&#xff1a;dotnet publish -r win-x64 -c Release …

python import 问题

https://my.oschina.net/leejun2005/blog/109679 python中&#xff0c;每个py文件被称之为模块&#xff0c;每个具有__init__.py文件的目录被称为包。只要模块或者包所在的目录在sys.path中&#xff0c;就可以使用import 模块或import 包来使用。 如果想使用非当前模块中的…

.NET如何写正确的“抽奖”——数组乱序算法

.NET如何写正确的“抽奖”——数组乱序算法数组乱序算法常用于抽奖等生成临时数据操作。就拿年会抽奖来说&#xff0c;如果你的算法有任何瑕疵&#xff0c;造成了任何不公平&#xff0c;在年会现场 code review时&#xff0c;搞不好不能活着走出去。这个算法听起来很简单&#…

maximum mean discrepancy

http://blog.csdn.net/a1154761720/article/details/51516273 MMD&#xff1a;maximum mean discrepancy。最大平均差异。最先提出的时候用于双样本的检测&#xff08;two-sample test&#xff09;问题&#xff0c;用于判断两个分布p和q是否相同。它的基本假设是&#xff1a;如…

FineUICore基础版部署到docker实战

文 | 蒙古海军司令 合作者FineUI用了好多年&#xff0c;最近出了FineUICore版本&#xff0c;一直没时间是试一下docker&#xff0c;前几天买了一个腾讯云服务器&#xff0c;1核2g&#xff0c;装了centos7.6&#xff0c;开始的时候主要是整个个人博客&#xff0c;在腾讯云安装了…

2019全球Microsoft 365开发者训练营(北京站)

Microsoft365介绍&#xff1a;Microsoft365不仅仅是Office 365&#xff0c;它还包括Windows 10操作系统&#xff0c;以及诸多企业级移动和安全应用。它是一套可用于从小型到集团化企业的办公、协作、沟通的企业信息化解决方案。在2017年7月11日举行的Inspire年度合作伙伴大会上…

caffe/common.cu error: function atomicadd has already been defined

http://blog.csdn.NET/houqiqi/article/details/46469981 1, 下载matio(http://sourceforge.NET/projects/matio/) 2,&#xff0c;安装 $ tar zxf matio-X.Y.Z.tar.gz $ cd matio-X.Y.Z $ ./configure $ make $ make check $ make install sudo ldconfig (如果不执行&#x…

微软备战 RPA 市场,Power Platform,Ready GO!

最大赌注就在刚刚&#xff0c;微软在 Microsoft Ignite 2019 大会上&#xff0c;首席执行官萨蒂亚纳德拉&#xff08;Satya Nadella&#xff09;宣布了 Microsoft Power Platform 新平台的发布&#xff0c;并且说到&#xff1a;在与Azure合作方面&#xff0c;微软365&#xff0…

C# 8 新特性 - 只读struct成员

从C# 8开始&#xff0c;我们可以在struct的成员上使用readonly修饰符。 为struct的成员添加readonly修饰符就表示告诉编译器和开发者该成员不可以修改struct的状态。 看下面这个例子&#xff1a; 这里的ToString()方法不会修改Point这个struct的状态&#xff0c;所以我们可以在…

.NET Core 3.0 中间件 Middleware

中间件官网文档解释&#xff1a;中间件是一种装配到应用管道以处理请求和响应的软件 每个中间件&#xff1a;选择是否将请求传递到管道中的下一个组件。可在管道中的下一个组件前后执行工作。使用 IApplicationBuilder 创建中间件管道ASP.NET Core 请求管道包含一系列请求委托&…