彩色圖塊排列在棋盤格上,象徵 PixiJS 連連看遊戲的復原按鈕功能

← INSIGHTS & PERSPECTIVES | 前端開發

PixiJS 復原按鈕教學:用 Stack 實作連連看 Undo 功能

說明 PixiJS 連連看遊戲如何用 Stack 記錄消除歷史,並透過 RevertBtn 與 GameBoard 實作復原按鈕。

PixiJS 連連看遊戲的復原按鈕可以用 Stack 記錄每次成功消除的圖塊值與路徑,玩家按下 Undo 時再從最後一筆紀錄還原棋盤。這種做法符合「最後一步先復原」的操作直覺,也能把按鈕互動、事件通知與盤面狀態分開維護。

復原功能為什麼適合用 Stack?

復原功能適合用 Stack,因為 Undo 永遠從最後一次成功操作開始倒回去。Stack 的後進先出特性,正好對應連連看消除紀錄的還原順序。

堆疊(Stack)是一種加入與刪除都發生在同一端的有序串列。常見操作是 `push` 加入資料、`pop` 取出資料,資料取出的順序會是後進先出(LIFO, Last-in-First-out),也可以說是先進後出(FILO, First-in-Last-out)。

佇列(Queue)則是加入與刪除發生在不同端,常見操作是 `enqueue` 與 `dequeue`,資料取出的順序是先進先出(FIFO, First-in-First-out)。排隊買票、坐公車比較像 Queue;疊盤子、發牌、走迷宮回頭,則比較像 Stack。

資料結構加入與取出位置取出順序適合例子
Stack同一端後進先出疊盤子、發牌、走迷宮、復原操作
Queue不同端先進先出排隊買票、坐公車

本文的資訊增益是把 2018 年 PixiJS 連連看專案裡的 Undo 實作拆成三件事:按鈕只送事件,`GameBoard` 只還原棋盤,歷史紀錄只保存足夠重建上一動的資料。

RevertBtn 如何送出復原事件?

`RevertBtn` 應該只負責把玩家點擊轉成 `RevertBackRequest` 事件。復原按鈕不直接修改棋盤,可以避免 UI 元件知道太多遊戲規則。

首先建立 `RevertBtn.ts`,讓復原按鈕繼承前面文章整理過的 `ButtonBase`。建構式指定 spritesheet 裡的 `Button`、`Revert` 貼圖名稱,以及按鈕放在左側 UI 欄的位置。

```ts

import { ButtonBase } from "./ButtonBase";

import { eventEmitter } from "../Main";

import { GameFlowEvent } from "../core/Event";

export class RevertBtn extends ButtonBase {

constructor() {

super('Button','Revert',50,345);

}

public trigger(){

eventEmitter.emit(GameFlowEvent.RevertBackRequest);

}

}

```

這段程式碼延續前幾篇 PixiJS 連連看文章的架構:具體按鈕不處理棋盤資料,只發出遊戲流程事件。之後若要加上音效、按鈕停用狀態或快捷鍵,也可以沿用同一個事件入口。

GameScene 要怎麼把復原按鈕加到畫面?

`GameScene` 加入復原按鈕時,只要把 `new RevertBtn()` 放進 stage。復原按鈕和聲音按鈕、棋盤、連線效果、角色動畫一樣,都是場景中的顯示物件。

在 `GameScene.ts` 的 `draw()` 方法中,把 `RevertBtn` 加到 `application.stage`。這裡保留原本的場景組裝順序:先放背景與按鈕,再放棋盤、連線效果與角色。

```ts

export class GameScene {

public static draw(){

//加入背景

application.stage.addChild(PIXI.Sprite.from(Loader.resources["background"].texture));

//加入按鈕

application.stage.addChild(new SoundBtn());

application.stage.addChild(new RevertBtn());

//加入連連看牌面

application.stage.addChild(new GameBoard());

application.stage.addChild(LinkedLine.instance);

//角色動畫

application.stage.addChild(new Character());

}

}

```

如果同一個場景已經有提示、重整、音效等按鈕,復原按鈕最好和其他 UI 控制一起集中建立。這樣做不是為了讓 `GameScene` 變聰明,而是讓場景一眼看得出有哪些互動入口。

GameBoard 如何註冊復原事件?

`GameBoard` 應在建構時註冊 `RevertBackRequest`,並把事件綁到 `revertBoard()`。棋盤是唯一知道盤面陣列、圖塊座標與重畫流程的地方。

在 `GameBoard.ts` 的 constructor 中監聽事件:

```ts

constructor() {

super();

this.createNewGame();

this.x = 175;

this.y = 20;

eventEmitter.on(GameFlowEvent.RevertBackRequest,this.revertBoard.bind(this));

}

```

事件名稱建議放在 `GameFlowEvent`,而不是散落在各個檔案中用字串硬寫。當時的專案在 `Event.ts` 裡新增了 `RevertBackRequest`:

```ts

export class GameFlowEvent{

public static TipsRequest: string = "TipsRequest";

public static LinkedLineSuccess: string = "LinkedLineSuccess";

public static RevertBackRequest:string = "RevertBackRequest";

}

```

這個分工讓 `RevertBtn` 與 `GameBoard` 只共享事件名稱,不共享彼此的實作細節。復原流程以事件串起來,後面要接角色動畫或音效,也不需要讓按鈕直接呼叫棋盤方法。

revertBoard 如何還原上一組消除圖塊?

`revertBoard()` 應從兩個 Stack 各取出一筆資料:被消除的圖塊值與該次連線路徑。只要兩筆資料都存在,就把兩個端點寫回棋盤並重畫圖塊。

當時的專案用 `valueHistory` 保存被消掉的符號 id,用 `pathHistory` 保存這次消除的兩個座標。復原時先 `pop()`,再把同一個 `value` 寫回 `path.point1` 與 `path.point2`。

```ts

revertBoard = ()=>{

let value = this.valueHistory.pop();

let path = this.pathHistory.pop();

if(value != null && path != null){

board.board[path.point1.x][path.point1.y] = value;

board.board[path.point2.x][path.point2.y] = value;

this.drawBoardIcon();

SoundMgr.play('Back');

}

}

```

這裡有一個實作細節很重要:連連看一次消除的是兩個相同圖塊,所以只存一個 `value` 就足夠還原兩格。如果遊戲後來改成特殊方塊、不同圖塊組合或道具效果,歷史紀錄就不能只存單一值,應改成保存兩格各自的完整狀態。

成功連線時要存哪些歷史紀錄?

成功連線時至少要存兩種歷史紀錄:連線路徑與被消除圖塊的值。紀錄必須發生在清除圖塊之前,否則被消除的資料會先從棋盤消失。

在 `createIcon()` 的點擊流程中,當 `path.canLinkInLine()` 判斷成功後,先把 `path` 與 `board.getValue(this.select1)` 存進 Stack,再畫線、清除兩個圖塊,最後發出成功事件。

```ts

createIcon = (id, x, y)=>{

let icon = new GameIcon(id,x,y);

this.addChild(icon);

let iconClickHandler = ()=>{

if (this.selected) {

let selectCorrect = false;

this.select2 = new Point(x, y);

this.iconSelected(this.select2);

setTimeout(()=>{

if (board.hasSameValue(this.select1, this.select2)) {

if (! (this.select1.x == x && this.select1.y == y) ) {

let path = new Path(this.select1, this.select2, board);

if(path.canLinkInLine()){

this.pathHistory.push(path);

this.valueHistory.push(board.getValue(this.select1));

LinkedLine.instance.drawPath(path);

this.clearIcon(this.select1);

this.clearIcon(this.select2);

eventEmitter.emit(GameFlowEvent.LinkedLineSuccess);

selectCorrect = true;

//判斷還有沒有路走

if(board.gameRoundEnd()){

alert("恭喜完成遊戲!");

this.createNewGame();

}else if(board.getFirstExistPath() == null){

this.reloadTimes--;

board.rearrangeBoard();

}

}

}

}

if(selectCorrect){

SoundMgr.play('Sound_select_crrect');

}else{

SoundMgr.play('Sound_select_error');

this.iconUnSelected(this.select1);

this.iconUnSelected(this.select2);

}

this.selected = false;

},0);

} else {

this.select1 = new Point(x, y);

this.iconSelected(this.select1);

this.selected = true;

SoundMgr.play('Sound_select_1');

}

};

icon.on("click", iconClickHandler);

icon.on("tap", iconClickHandler);

}

```

這段程式還保留了當時的自動重排邏輯:若成功消除後盤面沒有可連線路徑,就扣一次 `reloadTimes` 並呼叫 `board.rearrangeBoard()`。實務上要注意一件事:自動重排會改變盤面位置,若復原功能只記錄舊路徑座標,復原與重排同時存在時就需要重新設計歷史紀錄,否則 Undo 可能把圖塊還原到重排前的位置。

復原功能的事件流怎麼整理?

PixiJS 連連看復原功能可以整理成一條很短的事件流:按鈕送事件、棋盤取 Stack、棋盤重畫。事件流越短,越容易找出 Undo 壞掉時是哪一段出問題。

步驟負責檔案主要動作資料
玩家按下復原`RevertBtn.ts`發出 `RevertBackRequest`
棋盤接收事件`GameBoard.ts`呼叫 `revertBoard()``pathHistory`、`valueHistory`
還原上一動`GameBoard.ts``pop()` 後寫回兩格`path.point1`、`path.point2`、`value`
更新畫面`GameBoard.ts`呼叫 `drawBoardIcon()`最新 board data
播放音效`SoundMgr`播放 `Back``back.mp3`

我會把這個整理視為這篇筆記最實用的維護檢查表。若 Undo 按了沒反應,先看事件是否發出;若事件有發出,再看 Stack 裡是否有資料;若 Stack 有資料,最後看棋盤陣列與畫面重畫是否同步。

今日成果保留了哪些當時的連結?

2018 年筆記留下了線上展示與成果下載。因為當時網址是 HTTP,正式文章不把非 HTTPS 網址做成可點擊參考來源,但仍保留原始脈絡與本機匯出的壓縮檔位置。

當時記錄的今日成果如下:

  • 線上展示:`http://claire-chang.com/ironman2018/1107`
  • 今日成果下載:`http://claire-chang.com/wp-content/uploads/2018/11/ironman20181107.zip`

本次整理時,對應壓縮檔存在於 `markdown-export/uploads/2018/11/ironman20181107.zip`。壓縮檔內含 `RevertBtn.ts`、`GameScene.ts`、`GameBoard.ts`、`Event.ts`、`Button.png`、`Button.json` 與 `back.mp3`,可以對照復原按鈕、事件與音效資源。

延伸閱讀

常見問題

QPixiJS Undo 功能一定要用 Stack 嗎?

PixiJS Undo 功能不一定只能用 Stack,但 Stack 最符合「最後一步先復原」的操作順序。連連看每次成功消除都是一筆歷史紀錄,`push()` 與 `pop()` 就能處理基本復原需求。

QPixiJS 復原按鈕應該直接呼叫 GameBoard 方法嗎?

PixiJS 復原按鈕不建議直接呼叫 `GameBoard` 方法。按鈕發出 `RevertBackRequest` 事件,`GameBoard` 自己監聽並處理盤面還原,會讓 UI 與遊戲規則比較好分開維護。

Q復原紀錄要存圖塊值還是整個棋盤?

基本連連看只需要存被消除的圖塊值與兩個座標,因為一次消除的兩格值相同。若遊戲有重排、特殊方塊或連鎖效果,建議改存完整 move snapshot,甚至保存整個棋盤狀態。

Q為什麼要在清除圖塊之前 push 歷史紀錄?

歷史紀錄必須在 `clearIcon()` 之前保存,因為清除後棋盤上的值會變成空格。先保存 `path` 與 `board.getValue(this.select1)`,復原時才知道要把哪個圖塊放回哪兩個位置。

Q復原後需要重新檢查盤面是否有解嗎?

復原後最好重新檢查盤面是否有解,尤其是遊戲同時支援自動重排或提示功能時。本文保留 2018 年的基礎實作,重點在 Undo Stack;正式專案可以在 `drawBoardIcon()` 後補上盤面狀態檢查。

Q`pathHistory` 和 `valueHistory` 可以合併嗎?

`pathHistory` 和 `valueHistory` 可以合併成同一個 move 物件,例如 `{ path, value }`。合併後可以避免兩個 Stack 長度不同步,也更容易擴充成保存分數、剩餘時間或特殊道具狀態。

參考資料

  • 本文整理自 2018-11-07 的 PixiJS 連連看復原按鈕實作筆記與 `markdown-export/uploads/2018/11/ironman20181107.zip`。

最後更新

本文最後更新於 2018-11-07,依 2018 年 PixiJS 連連看復原按鈕實作整理為可發布的 GEO 技術文章。

關於作者 {#author}

Claire Chang | 企業 AI 導入與流程轉型顧問。專注於 AI Agent 架構設計、ERP 系統整合與企業 AI 治理。

首次發布:2018-11-07