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`,可以對照復原按鈕、事件與音效資源。
延伸閱讀
- PixiJS 按鈕製作基礎:Sprite 互動、ButtonBase 與靜音切換
- PixiJS 按鈕 Tween 動態:用 GSAP 做縮放回彈效果
- PixiJS 如何實作連連看盤面與消除邏輯
- PixiJS 提示與重整按鈕教學:連連看遊戲功能實作
- PixiJS 逐格動畫教學:連連看公仔角色動畫實作
常見問題
PixiJS Undo 功能一定要用 Stack 嗎?
PixiJS Undo 功能不一定只能用 Stack,但 Stack 最符合「最後一步先復原」的操作順序。連連看每次成功消除都是一筆歷史紀錄,`push()` 與 `pop()` 就能處理基本復原需求。
PixiJS 復原按鈕應該直接呼叫 GameBoard 方法嗎?
PixiJS 復原按鈕不建議直接呼叫 `GameBoard` 方法。按鈕發出 `RevertBackRequest` 事件,`GameBoard` 自己監聽並處理盤面還原,會讓 UI 與遊戲規則比較好分開維護。
復原紀錄要存圖塊值還是整個棋盤?
基本連連看只需要存被消除的圖塊值與兩個座標,因為一次消除的兩格值相同。若遊戲有重排、特殊方塊或連鎖效果,建議改存完整 move snapshot,甚至保存整個棋盤狀態。
為什麼要在清除圖塊之前 push 歷史紀錄?
歷史紀錄必須在 `clearIcon()` 之前保存,因為清除後棋盤上的值會變成空格。先保存 `path` 與 `board.getValue(this.select1)`,復原時才知道要把哪個圖塊放回哪兩個位置。
復原後需要重新檢查盤面是否有解嗎?
復原後最好重新檢查盤面是否有解,尤其是遊戲同時支援自動重排或提示功能時。本文保留 2018 年的基礎實作,重點在 Undo Stack;正式專案可以在 `drawBoardIcon()` 後補上盤面狀態檢查。
`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
