用 Unity 編寫象棋遊戲
原文:How to Make a Chess Game with Unity
作者:Brian Broom
譯者:kmyhy
並不是所有成功的遊戲都包括打外星人或拯救世界。棋盤遊戲,尤其是國際象棋,有著數千年的歷史。它們不僅玩起來很有趣,而且將它們從現實生活中轉變成視訊遊戲也很有趣。
在本教程中,你將用 Unity 編寫一個 3D 象棋遊戲。在這個過程中,你將學習:
- 選擇要移動的棋子
- 判斷移動是否合法
- 切換玩家
- 判斷輸贏
當您完成本教程時,你將建立一個富有功能的棋類遊戲,你可以將它作為其他棋盤遊戲的起點。
注意:你應該具有 Unity 和 C# 語言基礎。如果你需要學習 C#,那麼
Unity C# 初階屏播系列很適合初學者。
開始
請下載本教程的開始專案。你可以在本文頂部或底部找到下載連結。用 Unity 開啟開始專案。
象棋通常會做成簡單的 2D 遊戲。但是,本 3D 版本模擬了你和朋友坐在桌子旁邊下棋。此外…… 3D 比較棒了。
開啟 Scenes 資料夾中的 Main 場景。你會看到一個表示棋盤的 Board 物件和一個 GameManager 物件。這些物件都已經綁定了指令碼。
- Prefabs: 包含了棋盤、 棋子、和移動過程中的指示方塊。
- Materials: 包含了棋盤、棋子和瓦片的材質。
- Scripts: 包含了在結構檢視中已經繫結到物件中的元件。
- Board: 記錄棋子的視覺化狀態。這個元件還會處理每顆棋子的高亮狀態。
- Geometry.cs: 工具類,負責處理行列轉換和 Vector3 點。
- Player.cs: 記錄玩家的棋子,玩家手執的棋子。儲存玩家棋子移動的方向,比如小兵。
- Piece.cs: 一個基類,定義了所有例項化的棋子物件的列舉。它還包含確定遊戲中有效移動的邏輯。
- GameManager.cs: 儲存遊戲邏輯,比如允許的移動,遊戲一開始時棋子的位置等。它是一個單例物件,所以其他類很容易呼叫它。
GameManager 的 pieces 儲存了一個 2D 陣列,記錄了棋子在棋盤上的位置。可以看一下 AddPiece、PieceAtGrid 和 GridForPiece 的邏輯。
進入試玩模式,你會看到一個棋盤,棋子準備好後就可以下棋了。
移動棋子
首先需要找出要移動哪枚棋子。
射線查詢可用於找出使用者滑鼠正在經過哪一塊瓦片。如果你不熟悉 Unity 的射線查詢,請檢視我們的 Unity 指令碼教程入門或者我們的熱門教程炸彈超人。
一旦玩家選擇了一個棋子,你需要在棋子可以移動的地方生成有效的瓦片。然後,你選擇其中一個瓦片。你將新增兩個新指令碼來實現這個功能。TileSelector 用於將選擇移動的棋子,MoveSelector 用於選擇目的地。
兩個元件的基本方法是類似的:
- Start: 進行一次性的設定。
- EnterState: 進行本次動作的設定。
- Update: 當滑鼠移動時,執行射線查詢。
- ExitState: 清除本次狀態,呼叫下一狀態的 EnterState。
這實現了一個基本的狀態機。如果你有更多狀態,可以寫得更規範點,當然程式碼也會更復雜。
選擇瓦片
在結構檢視中選擇棋盤。然後在檢視器視窗,點選 Add Component 按鈕。然後在文字框中輸入 TileSelector 並點選 New Script。最後,點選 Create and Add,繫結指令碼。
注:建立新指令碼的時候,記得將它們移動到正確的目錄。保持 Assets 資料夾的合理有序。
高亮選中瓦片
雙擊 TileSelector.cs,開啟檔案,新增變數:
public GameObject tileHighlightPrefab;
private GameObject tileHighlight;
這兩個變數構成了一個透明遮罩,用於凸顯你所選中的瓦片。預製件將在編輯模式下被賦值,而元件負責跟蹤和移動高亮狀態。
然後在 Start 方法中新增下列程式碼:
Vector2Int gridPoint = Geometry.GridPoint(0, 0);
Vector3 point = Geometry.PointFromGrid(gridPoint);
tileHighlight = Instantiate(tileHighlightPrefab, point, Quaternion.identity, gameObject.transform);
tileHighlight.SetActive(false);
Start 方法初始化高亮瓦片的行和列,將其轉換為 point,並通過預製件建立一個遊戲物件。這個物件一開始是未啟用的,所以在需要時才會顯示。
注:以行列引用座標是很有用的,它是一個 Vector2Int 型別,即一個 GridPoint。Vector2Int 有兩個整數值:x和y。當你需要在遊戲場景中放入一個物件時,你需要用 Vector3 座標。Vector3 座標包含三個浮點值:x、y 和 z。
在 Geometry.cs 有進行兩者間轉換的實用方法:
* GridPoint(int col, int row): gives you a GridPoint for a given column and row.
* PointFromGrid(Vector2Int gridPoint): turns a GridPoint into a Vector3 actual point in the scene.
* GridFromPoint(Vector3 point): gives the GridPoint for the x and z value of that 3D point, and the y value is ignored.
然後是 EnterState 方法:
public void EnterState()
{
enabled = true;
}
當選擇另一顆棋子時,重新啟用元件。
然後是 Update 方法:
Ray ray = Camera.main.ScreenPointToRay(Input.mousePosition);
RaycastHit hit;
if (Physics.Raycast(ray, out hit))
{
Vector3 point = hit.point;
Vector2Int gridPoint = Geometry.GridFromPoint(point);
tileHighlight.SetActive(true);
tileHighlight.transform.position =
Geometry.PointFromGrid(gridPoint);
}
else
{
tileHighlight.SetActive(false);
}
這裡,你從鏡頭中建立了一束射線,經過滑鼠點,射向無限遠!
Physics.Raycast 會檢測射線是否和系統中的物理碰撞體發生相交。由於棋盤是唯一擁有碰撞體的物件,你不必擔心棋子互相遮擋。
如果射線和碰撞體相交,RaycastHit 中會包含交點資料。你將交點轉換成 GridPoint(使用助手方法),然後設定高亮瓦片的位置。
由於滑鼠指標位於棋盤上方,你可以啟用高光瓦片,以便讓它顯示。
最後,在結構檢視中選擇 Board 然後在專案視窗中單擊 Prefabs。然後,將 Selection-Yellow 預製件拖到棋盤的 Tile Selector 元件的 Tile Hightlight Prefab 方框中。
進入遊戲試玩模式,當滑鼠指標移動時會有一個黃色的高亮瓦片跟隨。
選擇棋子
要選中某顆棋子,你需要判斷按下的滑鼠按鈕是哪一顆。在啟用完瓦片的 hightlight之後,用一個 if 語句中新增一個判斷:
if (Input.GetMouseButtonDown(0))
{
GameObject selectedPiece =
GameManager.instance.PieceAtGrid(gridPoint);
if(GameManager.instance.DoesPieceBelongToCurrentPlayer(selectedPiece))
{
GameManager.instance.SelectPiece(selectedPiece);
// Reference Point 1: add ExitState call here later
}
}
如果滑鼠按鈕被按下,GameManager 就會為你獲取該位置的棋子。你還必須確保這個棋子是屬於當前玩家的,因為玩家不允許移動對手的棋子。
注:在一個複雜的遊戲中,最好為元件清晰地劃分職責。棋盤負責顯示和凸顯棋子。GameManager 負責保棋子的 GridPoint。助手方法負責告訴棋子在哪裡以及它們屬於哪個玩家。
進入試玩模式,選擇一枚棋子。
現在你手上已經有棋子了,把它移動到別的地方吧。
選擇移動目標
現在,TileSelector 已經完。接下來是另一個元件:MoveSelector。
這個元件和 TileSelector 類似。和之前一樣,在結構檢視中選中 Board 物件,新增一個新元件,名為 MoveSelector。
傳遞控制
第一件事情是將控制從 TileSelector 傳遞給 MoveSelector。這樣我們就需要用到 ExitState 了。在 TileSelector.cs 中,新增一個方法:
private void ExitState(GameObject movingPiece)
{
this.enabled = false;
tileHighlight.SetActive(false);
MoveSelector move = GetComponent<MoveSelector>();
move.EnterState(movingPiece);
}
這裡我們隱藏 overlay 瓦片,然後禁用 TileSelector 元件。在 Unity 中,在禁用元件上你無法呼叫 Update 方法。因為你想呼叫另外一個元件的 Update 方法,所以就禁用原元件,防止干擾。
在 Update 方法的 Referenct point 1 之後呼叫這個方法。
ExitState(selectedPiece);
然後開啟 MoveSelector 新增一個例項變數:
public GameObject moveLocationPrefab;
public GameObject tileHighlightPrefab;
public GameObject attackLocationPrefab;
private GameObject tileHighlight;
private GameObject movingPiece;
這些變數用於儲存滑鼠高亮、移動點和攻擊點的瓦片圖層,以及原先的高亮瓦片以及前面選中的棋子。
然後,在 Start 方法中加入:
this.enabled = false;
tileHighlight = Instantiate(tileHighlightPrefab, Geometry.PointFromGrid(new Vector2Int(0, 0)),
Quaternion.identity, gameObject.transform);
tileHighlight.SetActive(false);
這個元件一開始必須 disabled,因為首先要執行 TitleSelector。然後,載入高亮圖層。
移動棋子
然後新增 EnterState 方法:
public void EnterState(GameObject piece)
{
movingPiece = piece;
this.enabled = true;
}
當這個方法呼叫時,它會儲存被移動的棋子,然後禁用元件自身。
在 MoveSelector 的 Update 方法中,新增程式碼:
Ray ray = Camera.main.ScreenPointToRay(Input.mousePosition);
RaycastHit hit;
if (Physics.Raycast(ray, out hit))
{
Vector3 point = hit.point;
Vector2Int gridPoint = Geometry.GridFromPoint(point);
tileHighlight.SetActive(true);
tileHighlight.transform.position = Geometry.PointFromGrid(gridPoint);
if (Input.GetMouseButtonDown(0))
{
// Reference Point 2: check for valid move location
if (GameManager.instance.PieceAtGrid(gridPoint) == null)
{
GameManager.instance.Move(movingPiece, gridPoint);
}
// Reference Point 3: capture enemy piece here later
ExitState();
}
}
else
{
tileHighlight.SetActive(false);
}
這個 Update 方法和 TileSelector 的 Update 方法類似,同樣使用射線檢查滑鼠正在點選那個瓦片。當滑鼠鍵被按下,呼叫 GameManager 移動棋子到新瓦片上。
最後是 ExitState 方法,做一些清理工作併為下次移動做好準備:
private void ExitState()
{
this.enabled = false;
tileHighlight.SetActive(false);
GameManager.instance.DeselectPiece(movingPiece);
movingPiece = null;
TileSelector selector = GetComponent<TileSelector>();
selector.EnterState();
}
這裡禁用了元件,將高亮瓦片隱藏。因為棋子已經移動完成,你可以清空它,讓 Gamemanager 取消棋子的高亮狀態。然後,呼叫 TileSelector 的 EnterState,以便再次開始整個過程。
回到編輯器,選中 Board 物件,從 prefab 資料夾將 tile overlay 預製件拖到 MoveSelector 的這些地方:
- Move Location Prefab 的值應該是 Selection-Blue
- Tile Highlight Prefab 的值應該是 Selection-Yellow.
- Attack Location Prefab 的值應該是 Selection-Red
你可以通過修改材質來調整它們的顏色。
開啟試玩模式,移動棋子試試。
你會發現,你可以將棋子移動到任何空格子上。這在象棋中完全是不合理的。接下來應該讓棋子的移動符合遊戲規則。
算出合法的移動
在國際象棋中,每個棋子能夠做出的動作是不一樣的。有的棋子能夠往任意方向移動,有的棋子可以移動任意個空格,有的棋子只能在某個方向上進行移動。你如何記住這些規則?
一種方法是用一個抽象的基類來表示所有棋子,然後由子類來實現具體的方法,以計算移動的位置。
另一個問題是:這些動作要在哪裡生成?
一個選擇是在 MoveSelector 的 EnterStat 方法中。這是你將棋子可以移動的位置顯示給使用者的方法,因此這是一個合理的選擇 。
計算有效目標的集合
常見的策略是選中一顆棋子,然後讓 GameMananger 返回一個有效目標(比如移動)的集合。GameManager 使用該棋子的子類計算可能的目標集合。然後,過濾掉其中已經被佔的或者已經離開棋盤的位置。
過濾後的集合傳回給 MoveSelector,將合理的移動高亮顯示,等待玩家做出選擇。
小兵的移動最為簡單,因此我們從它開始。
開啟 Pieces 下面的 Pawn.cs,修改 MoveLocation 方法:
public override List MoveLocations(Vector2Int gridPoint)
{
var locations = new List<Vector2Int>();
int forwardDirection = GameManager.instance.currentPlayer.forward;
Vector2Int forward = new Vector2Int(gridPoint.x, gridPoint.y + forwardDirection);
if (GameManager.instance.PieceAtGrid(forward) == false)
{
locations.Add(forward);
}
Vector2Int forwardRight = new Vector2Int(gridPoint.x + 1, gridPoint.y + forwardDirection);
if (GameManager.instance.PieceAtGrid(forwardRight))
{
locations.Add(forwardRight);
}
Vector2Int forwardLeft = new Vector2Int(gridPoint.x - 1, gridPoint.y + forwardDirection);
if (GameManager.instance.PieceAtGrid(forwardLeft))
{
locations.Add(forwardLeft);
}
return locations;
}
這個方法做了以下事情:
- 這段程式碼首先建立一個空的 list 用於儲存位置。然後,建立了一個 location 用於表示前方的一個空格。
- 因為白子和黑子移動方向相反,玩家物件有一個值表示了小兵可以移動的方向。對於第一個玩家這個值是 +1,第二個玩家這個值是 -1。
- 小兵有一個特殊的移動方式和幾點特殊規則。它雖然可以往前移動一步,但它卻不能吃那個格子中的對方棋子,而是吃前方對角線上的棋子。在把前面這個格子標記為有效移動位置之前,首先要判斷這個地方有沒有被其它棋子佔據。如果沒有,你可以將這個瓦片放到 list 中。
- 對於吃子,你必須檢查那個位置是否有棋子。如果有,才能吃子。
現在還不需要關心需要判斷是自己的棋子還是對方的棋子——這個稍後再說。
在 GameManager.cs 中,在 Move 方法後新增一個方法:
public List MovesForPiece(GameObject pieceObject)
{
Piece piece = pieceObject.GetComponent();
Vector2Int gridPoint = GridForPiece(pieceObject);
var locations = piece.MoveLocations(gridPoint);
// filter out offboard locations
locations.RemoveAll(tile => tile.x < 0 || tile.x > 7
|| tile.y < 0 || tile.y > 7);
// filter out locations with friendly piece
locations.RemoveAll(tile => FriendlyPieceAt(tile));
return locations;
}
這裡,你從 GameOject 中獲取一個 Piece 元件,以及它的當前位置。
然後,要求 GameManager 返回一個該棋子的 location 集合,並過濾掉其中無效的值。
RemoveAll 方法使用一個回撥 lamda 表示式作為引數。這個方法遍歷 list 中所有值,把它傳給表示式中的 tile 變數。如果表示式返回 ture,這個值就會從 list 中移除。
第一個表示式移除所有 x、y 值超出棋盤以外的 location。第二個表示式移除所有己方棋子的位置。
在 MoveSelector.cs 中,新增一個例項變數:
private List<Vector2Int> moveLocations;
private List<GameObject> locationHighlights;
第一個變數儲存了一個移動位置的 GridPoint 陣列;第二個變數儲存了玩家是否可以移動到那個地方的 overlay 瓦片陣列。
在 EnterState 方法最後新增:
moveLocations = GameManager.instance.MovesForPiece(movingPiece);
locationHighlights = new List<GameObject>();
foreach (Vector2Int loc in moveLocations)
{
GameObject highlight;
if (GameManager.instance.PieceAtGrid(loc))
{
highlight = Instantiate(attackLocationPrefab, Geometry.PointFromGrid(loc),
Quaternion.identity, gameObject.transform);
}
else
{
highlight = Instantiate(moveLocationPrefab, Geometry.PointFromGrid(loc),
Quaternion.identity, gameObject.transform);
}
locationHighlights.Add(highlight);
}
這段程式碼做了這幾件事情:
首先,它從 GameManager 獲取有效 location 陣列,構造一個空陣列用於儲存 overlay 瓦片物件。然後對 lcoation 陣列進行遍歷,如果在某個位置已經有棋子,那麼必定是對方棋子,因為己方棋子已經被過濾掉了。
對方棋子加上攻擊蒙層,而其它棋子則新增移動蒙層。
執行動作
在 Reference Point 2 處新增程式碼,就在判斷滑鼠按鈕的 if 語句中:
if (!moveLocations.Contains(gridPoint))
{
return;
}
如果玩家玩家點選到無效的瓦片,退出函式。
最後,在 MoveSelector.cs 的 ExitState 中新增程式碼:
foreach (GameObject highlight in locationHighlights)
{
Destroy(highlight);
}
這時,玩家已經選擇了一個落點,你可以移除 overlay 物件了。
哇!改了這麼多程式碼,僅僅是讓小兵動一步而已。現在你已經完成了最艱鉅的工作,其它棋子的移動就簡單了。
下一個玩家
只有一邊可以動的遊戲並不多見。該解決下這個問題了!
為了讓兩邊都能玩,你必須知道如何切換玩家以及在哪裡新增程式碼。
因為 GameManager 負責所有遊戲規則,切換玩家的程式碼應該放在這裡。
實際上,切換玩家十分簡單。在 GameManager 中定義有當前玩家和其它玩家的變數,你只需要交換二者即可。
更麻煩一點的問題是:在哪裡呼叫切換玩家的方法?
當玩家移動了一顆棋子後,這個玩家的回合就結束了。MoveSelector 的 ExitState 方法在棋子被移動之後都會呼叫,因此這裡就是進行切換的好地方。
在 GameManager.cs 最後新增這個方法:
public void NextPlayer()
{
Player tempPlayer = currentPlayer;
currentPlayer = otherPlayer;
otherPlayer = tempPlayer;
}
交換需要使用臨時變數;否則在拷貝一個值之前會導致原來的值被覆蓋。
回到 MoveSelector.cs,在 ExitState 方法中,呼叫 EnterState 之前新增程式碼:
GameManager.instance.NextPlayer();
這就可以了!ExitState 和 EnterState 會進行清理工作。
進入試玩模式,你可以移動雙方棋子了。距離真正的遊戲不遠嘍!
吃子
吃子是棋類遊戲中的重要內容。俗話說得好,“在真正失去騎士之前,一切都不過是遊戲”。
因為遊戲規則由 GameManager 負責,所以開啟它增加一個方法:
public void CapturePieceAt(Vector2Int gridPoint)
{
GameObject pieceToCapture = PieceAtGrid(gridPoint);
currentPlayer.capturedPieces.Add(pieceToCapture);
pieces[gridPoint.x, gridPoint.y] = null;
Destroy(pieceToCapture);
}
這裡,GameManager 查詢位於目標位置的棋子。這顆棋子被新增到當前玩家的“吃掉的棋子”陣列。然後,從 GameManager 的棋盤貼片中刪除該棋子的記錄,然後銷燬 GameObject,導致從場景中移除該棋子。
要吃掉一顆棋子,你需要移動到其位置並點選。因此應當在 MoveSelector.cs 中呼叫這個方法。
在 Update 方法中,找到註釋 Reference Point 3 的地方,編寫程式碼:
else
{
GameManager.instance.CapturePieceAt(gridPoint);
GameManager.instance.Move(movingPiece, gridPoint);
}
之前 if 語句是判斷目標位置是否有棋子。因為之前的移動已經過濾掉了己方棋子,那麼如果有棋子則肯定是敵方棋子。
將敵方棋子移除後,就可以將手持的棋子放進去了。
點選 play,移動小兵,吃掉一顆棋子。
結束遊戲
當玩家殺死對方的王之後,遊戲就結束了。在你吃子時,需要判斷對方是否是王。如果是,遊戲結束。
當怎樣才能結束遊戲呢?一種方法是刪除棋盤上的 TileSelector 和 MoveSelector 指令碼。
在 GameManager.cs 的 CapturePieceAt 中,在銷燬被殺死的棋子之前,新增程式碼:
if (pieceToCapture.GetComponent<Piece>().type == PieceType.King)
{
Debug.Log(currentPlayer.name + " wins!");
Destroy(board.GetComponent<TileSelector>());
Destroy(board.GetComponent<MoveSelector>());
}
光是禁用這些元件還不夠。因為下一次呼叫 ExitState 和 EnterState 時會重新 enable 它們,那樣遊戲又可以玩了。
Destroy 方法不僅僅可用於 GameObject 類,還可以刪除這個物件上繫結的元件。
點選 play。移動小兵,吃掉對方的王。你會看到 Unity 控制檯打印出“勝利”的字樣。
你可以挑戰一下自己,新增顯示 Game Over 和跳轉回主選單畫面的 UI。
接下來祭出我們的大殺器,移動威力更強的棋子!
特殊移動
Piece 及其子類是封裝特殊移動的好地方。
你可以使用和小兵一樣的方法為其它棋子新增特殊移動。能夠向不同方向移動一個空格的棋子,比如國王和騎士,都可以用同樣的方式建立。試試看你能不能實現這些移動規則。
如果需要幫助,請閱讀最終的專案程式碼。
多格移動
能夠向某一方向移動多格的棋子要難一點。比如象、車和王后。為了簡便起見,我們以象為例。
開啟 Bishop.cs, 將 MoveLocations 替換為:
public override List<Vector2Int> MoveLocations(Vector2Int gridPoint)
{
List<Vector2Int> locations = new List<Vector2Int>();
foreach (Vector2Int dir in BishopDirections)
{
for (int i = 1; i < 8; i++)
{
Vector2Int nextGridPoint = new Vector2Int(gridPoint.x + i * dir.x, gridPoint.y + i * dir.y);
locations.Add(nextGridPoint);
if (GameManager.instance.PieceAtGrid(nextGridPoint))
{
break;
}
}
}
return locations;
}
foreach 迴圈每個方向。對於每一個方向,再次對棋子可以移動的位置進行迴圈。因為棋盤以外的位置會被過濾,所以你只需保證格子足夠多不會遺漏任何瓦片即可。
在每一步裡,建立一個 GridPoint 網點並新增到 list 裡。然後判斷當前位置是否有棋子。如果有,中斷內層迴圈進入下一個方向。
因為如果已經有棋子的話會阻斷棋子的移動,因此必須 break。同時,在後面會過濾掉己方棋子的位置,所以在這裡你不需要關心這個問題。
注:如果你需要區分前後的方向,或者左右方向,那麼你需要考慮黑白棋子在移動方向上的區別。
對於國際象棋,只有小兵才需要考慮這個問題,但如果是其它遊戲則也可能需要進行這種區別。
好了!點選 play 試玩一下。
移動王后
王后是最強大的棋子,因此把它放到最後。
王后的移動是象和車的結合;在基類中,有一個數組用來儲存每個棋子的移動方向。你可以用這個陣列將兩者結合。
將 Queen.cs 的 MoveLocations 修改為:
public override List<Vector2Int> MoveLocations(Vector2Int gridPoint)
{
List<Vector2Int> locations = new List<Vector2Int>();
List<Vector2Int> directions = new List<Vector2Int>(BishopDirections);
directions.AddRange(RookDirections);
foreach (Vector2Int dir in directions)
{
for (int i = 1; i < 8; i++)
{
Vector2Int nextGridPoint = new Vector2Int(gridPoint.x + i * dir.x, gridPoint.y + i * dir.y);
locations.Add(nextGridPoint);
if (GameManager.instance.PieceAtGrid(nextGridPoint))
{
break;
}
}
}
return locations;
}
唯一不同的地方是,你把方向陣列轉變成 List。
List 的特點是可以將其他陣列中的方向新增進來,把所有方向都新增到這個 List。該方法的其餘部分和 Bishop 類相同。
點選 play,把小兵移開,檢查一下效果是否實現。
接下來去哪裡?
還有一些內容需要你完成,比如實現王、騎士和車的移動。如果做不錯來,請參考下載下來的專案資原始碼。
還有一些特殊規則有待實現,比如允許兵第一步可以移動兩格而不是一格,王車易位等。
一般的模式是向 GameManager 新增變數和方法,以記錄這些情況,並檢查它們在移動時是否可用。如果可用,則在 MoveLocations 中新增相應的位置。
還可以在視覺方面進行改進。例如,棋子平滑移動到目標位置,或者可以旋轉鏡頭以表示其它玩家在進行回合時的檢視。
有任何問題和建議,或者想秀一下你的 3D 象棋遊戲,請在下面留言。