1. 程式人生 > >.NET手擼2048小遊戲

.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的重要性。

……但完成這篇文章我花了很多、很多心思