.NET手撸2048小游戏
前言
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騷操作】
創作挑戰賽新人創作獎勵來咯,堅持創作打卡瓜分現金大獎總結
以上是生活随笔為你收集整理的.NET手撸2048小游戏的全部內容,希望文章能夠幫你解決所遇到的問題。
- 上一篇: C# 8 新特性 - 静态本地方法
- 下一篇: 自行实现高性能MVC