.NET手擼2048小游戲


.NET手擼2048小游戲

2048是一款益智小游戲,得益於其規則簡單,又和2的倍數有關,因此廣為人知,特別是廣受程序員的喜愛。

本文將再次使用我自制的“准游戲引擎”FlysEngine,從空白窗口開始,演示如何“手擼”2048小游戲,並在編碼過程中感受C#的魅力和.NET編程的快樂。

說明:FlysEngine是封裝於Direct2D,重復本文示例,只需在.NET Core 3.0下安裝NuGetFlysEngine.Desktop即可。

並不一定非要做一層封裝才能用,只是FlysEngine簡化了創建設備、處理設備丟失、設備資源管理等“新手勸退”級操作,

首先來看一下最終效果:

小游戲的三原則

在開始做游戲前,我先聊聊CRUD程序員做小游戲時,我認為最重要的三大基本原則。很多時候我們有做個游戲的心,但發現做出來總不是那么回事。這時可以對照一下,看是不是違反了這三大原則中的某一個:

  • MVC
  • 應用程序驅動(而非事件驅動)
  • 動畫

MVC

或者MVP……關鍵是將邏輯與視圖分離。它有兩大特點:

  • 視圖層完全沒有狀態;
  • 數據的變動不會直接影響呈現的畫面。

也就是所有的數據更新,都只應體現在內存中。游戲中的數據變化可能非常多,應該積攢起來,一次性更新到界面上。

這是因為游戲實時渲染特有的性能所要求的,游戲常常有成百上千個動態元素在界面上飛舞,這些動作必須在一次垂直同步(如16ms或更低)的時間內完成,否則用戶就會察覺到卡頓。

常見的反例有knockout.js,它基於MVVM,也就是數據改變會即時通知到視圖(DOM),導致視圖更新不受控制。

另外,MVC還有一個好處,就是假如代碼需要移植平台時(如C#移植到html5),只需更新呈現層即可,模型層所有邏輯都能保留。

應用程序驅動(而非事件驅動)

應用程序驅動的特點是界面上的動態元素,之所以“動”,是由應用程序觸發——而非事件觸發的。

這一點其實與MVC也是相輔相成。應用程序驅動確保了MVC的性能,不會因為依賴變量重新求值次數過多而影響性能。

另外,如果界面上有狀態,就會導致邏輯變得非常復雜,比如變量之間的依賴求值、界面上某些參數的更新時機等。不如簡單點搞!直接全部重新計算,全部重新渲染,絕對不會錯!

細心的讀者可能發現最終效果demo中的總分顯示就有bug,開始游戲時總分應該是4,而非72。這就是由於該部分沒有使用應用程序驅動求值,導致邏輯復雜,導致粗心……最終導致出現了bug

html5canvas中,實時渲染的“心臟”是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;

然后即可按循環繪制44列方塊位置,使用矩陣變換可以讓代碼更簡單:

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將多次遍歷xy,因此定義了一個變量MatrixPositions來簡化這一過程:

static IEnumerable<int> inorder = Enumerable.Range(0, MatrixSize);
static IEnumerable<(int x, int y)> MatrixPositions => 
	inorder.SelectMany(y => inorder.Select(x => (x, y)));

運行效果如下:

加入數字方塊

數據方塊由於是活動的,為了代碼清晰,需要加入額外兩個類,CellMatrix

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.0switch expression特性(下文將繼續大量使用),可以通過表達式——而非語句的方式表達一個邏輯,可以讓代碼大大簡化。該特性現在在.NET Core 3.0項目中默認已經打開,某些支持的早期版本,需要將項目中的<LangVersion>屬性設置為8.0才可以使用。

根據2048的設計文檔和參考其它項目,一個方塊創建時有90%機率是210%機率是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[,],用於保存4x4Cell

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.NETNuGet包: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()}";
    }
});

每次用戶松開上下左右四個鍵之一,就會調用MatrixRequestDirection方法(馬上說),松下Escape鍵,則會提示用戶是否重新開始玩,然后重新顯示新的總分。

注意:

  1. 我再次使用了C# 8.0switch expression語法,它讓我省去了if/elseswitch case,代碼精練了不少;
  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;
    }
}

其中,dvtxty三個變量,巧妙地將Direction枚舉轉換成了數據,避免了過多的if/else,導致代碼膨脹。然后通過一行簡單的LINQ,再次將兩個for循環聯合在一起。

注意示例還使用了(x, y)這樣的語法(下文將繼續大量使用),這叫Value Tuple,或者值元組Value TupleC# 7.0的新功能,它和C# 6.0新增的Tuple的區別有兩點:

  • Value Tuple可以通過(x, y)這樣的語法內聯,而Tuple要使用Tuple.Create(x, y)來創建
  • Value Tuple故名思義,它是值類型,可以無需內存分配和GC開銷(但稍稍增長了少許內存復制開銷)

我還定義了另外兩個字段:GameOverKeepGoing,用來表示是否游戲結束和游戲勝利時是否繼續:

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 expressionValue Tuple和令人拍案叫絕的LINQ,相當於只需一行代碼,就將這些復雜的邏輯搞定了。

最后別忘了在GameWindowOnUpdateLogic重載函數中加入一些彈窗提示,顯示用於恭喜和失敗的信息:

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);
}

加入了DisplayXDisplayYDisplaySize三個屬性,用於管理其用於在界面上顯示的值。還加入了一個InAnimation變量,用於判斷是否處理動畫狀態。

另外,構造函數現在也要求傳入xy的值,如果位置變化了,現在必須調用MoveTo方法,它與Cell建立關聯了(之前並不會)。

ShowSizeAnimation函數是演示該動畫很好的示例,它先將方塊放大至1.2倍,然后縮小成原狀。

有了這個類之后,MatrixGameWindow也要做一些相應的調整(詳情見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));
        });
}

這個代碼非常精練,但其本質是RxMouseDownMouseUpMouseMove三個窗口事件“拍案叫絕”級別的應用,它做了如下操作:

  • 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.DesktopSystem.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/canvasjs版本,Github地址如下:https://github.com/sdcb/2048 其邏輯層和渲染層都有異曲同工之妙,事實也是我從js版本移動到C#並沒花多少心思。這恰恰說明的“小游戲第一原則”——MVC的重要性。

……但完成這篇文章我花了很多、很多心思😂。喜歡的朋友 請關注我的微信公眾號:【DotNet騷操作】

DotNet騷操作


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM