.NET手擼2048小遊戲

  • 2019 年 11 月 3 日
  • 筆記

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

加入了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騷操作