.NET手擼2048小遊戲
- 2019 年 11 月 3 日
- 筆記
.NET手擼2048小遊戲
2048
是一款益智小遊戲,得益於其規則簡單,又和2
的倍數有關,因此廣為人知,特別是廣受程式設計師的喜愛。
本文將再次使用我自製的「准遊戲引擎」FlysEngine
,從空白窗口開始,演示如何「手擼」2048
小遊戲,並在編碼過程中感受C#
的魅力和.NET
編程的快樂。
說明:
FlysEngine
是封裝於Direct2D
,重複本文示例,只需在.NET Core 3.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 (var v in MatrixPositions)
是以下程式碼的簡寫:
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
關鍵字,這樣創建的是值類型而非引用類型,可以無需分配和回收堆記憶體。在應用或遊戲中,記憶體分配和回收常常是最影響性能和吞吐性的指標之一。 N switch { ... }
這樣的程式碼,是C# 8.0
的switch expression
特性(下文將繼續大量使用),可以通過表達式——而非語句的方式表達一個邏輯,可以讓程式碼大大簡化。該特性現在在.NET Core 3.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
的switch expression
語法,它讓我省去了if/else
或switch case
,程式碼精練了不少;- 不是非得要用
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)
這樣的語法(下文將繼續大量使用),這叫Value Tuple
,或者值元組
。Value Tuple
是C# 7.0
的新功能,它和C# 6.0
新增的Tuple
的區別有兩點:
Value Tuple
可以通過(x, y)
這樣的語法內聯,而Tuple
要使用Tuple.Create(x, y)
來創建Value Tuple
故名思義,它是值類型
,可以無需記憶體分配和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, };
注意我再次使用了switch expression
、Value Tuple
和令人拍案叫絕的LINQ
,相當於只需一行程式碼,就將這些複雜的邏輯搞定了。
最後別忘了在GameWindow
的OnUpdateLogic
重載函數中加入一些彈窗提示,顯示用於恭喜和失敗的資訊:
protected override void OnUpdateLogic(float dt) { base.OnUpdateLogic(dt); if (Matrix.GameOver) { if (MessageBox.Show($"總分:{Matrix.GetScore()}rn重新開始嗎?", "失敗!", 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!rn還想繼續升級嗎?", "恭喜!", 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
上下載,請下載LINQPad 6
運行。用Visual Studio 2019
/VS Code
也能編譯運行,只需手動將程式碼拷貝至項目中,並安裝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
的重要性。
……但完成這篇文章我花了很多、很多心思?。喜歡的朋友 請關注我的微信公眾號:【DotNet騷操作】