彩色圖塊排列在棋盤格上,象徵 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)則是加入與刪除發生在不同端,常見操作是 enqueuedequeue,資料取出的順序是先進先出(FIFO, First-in-First-out)。排隊買票、坐公車比較像 Queue;疊盤子、發牌、走迷宮回頭,則比較像 Stack。

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

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

RevertBtn 如何送出復原事件?

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

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

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.tsdraw() 方法中,把 RevertBtn 加到 application.stage。這裡保留原本的場景組裝順序:先放背景與按鈕,再放棋盤、連線效果與角色。

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 中監聽事件:

constructor() {
    super();
    this.createNewGame();
    this.x = 175;
    this.y = 20;

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

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

export class GameFlowEvent{
    public static TipsRequest: string = "TipsRequest";
    public static LinkedLineSuccess: string = "LinkedLineSuccess";
    public static RevertBackRequest:string = "RevertBackRequest";
}

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

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

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

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

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() 判斷成功後,先把 pathboard.getValue(this.select1) 存進 Stack,再畫線、清除兩個圖塊,最後發出成功事件。

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()pathHistoryvalueHistory
還原上一動GameBoard.tspop() 後寫回兩格path.point1path.point2value
更新畫面GameBoard.ts呼叫 drawBoardIcon()最新 board data
播放音效SoundMgr播放 Backback.mp3

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

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

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

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

  • 線上展示:http://claire-chang.com/ironman2018/1107
  • 今日成果下載:ironman20181107.zip(原下載連結已失效)

本次整理時,對應壓縮檔存在於 markdown-export/uploads/2018/11/ironman20181107.zip。壓縮檔內含 RevertBtn.tsGameScene.tsGameBoard.tsEvent.tsButton.pngButton.jsonback.mp3,可以對照復原按鈕、事件與音效資源。

常見問題

QPixiJS Undo 功能一定要用 Stack 嗎?

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

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

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

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

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

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

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

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

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

QpathHistoryvalueHistory 可以合併嗎?

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

參考資料

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

延伸閱讀

最後更新

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

關於作者

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

首次發布:2018-11-07