.NET手擼2048小遊戲
.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()}\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
上下載,請下載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
的重要性。
……但完成這篇文章我花了很多、很多心思