iT邦幫忙

2026 iThome 鐵人賽

DAY 20
0
JavaScript

重新拿回思考力!一起來用 JavaScript 打造 CLI 好玩遊戲區!系列 第 20 篇

Day 20 共用入口1:從承認不知道開始的經驗值提升之旅!

  • 分享至 

  • xImage
  •  

昨天結束時,你有想到你可能會想做怎樣的遊戲或在現有架構上加上那些調整嗎?我的想法是既然叫做好玩遊戲區,怎麼能讓使用者自己執行指令開啟不同的遊戲呢?應該要有一個共用的好玩遊戲區入口,接著可以在裡面選擇遊戲、遊玩。這件事從開始撰寫鐵人賽時就一直放在我心裡,但我一直在等待一個適合的時機來執行,現在終於讓我等到了!!

到現在為止,我們總共一起完成了兩個遊戲,現在是時候來整理一個共用的入口,我的期待是只需要一個指令,比方說 node index.js 就可以開啟這個共用的入口,可以看到遊戲選單,並且用上下鍵選擇遊戲進入遊玩。

但老實說,我腦袋中只有產品完成的畫面,但我又再度不知道該怎麼從 0 開始實作出來了。

不過請允許我先鼓勵一下自己:不知道怎麼做出一個想像中的功能真的是正常的,我們平常體驗到的都是別人設計好的東西,除非我們自己真的經歷過一遍從 0 設計到打造出東西的過程,不然就沒辦法真正的去產生「我知道這個東西是怎麼做出來的」的那種經驗,而這個經驗就是我們現在正在累積的。

所以遇到問題,不怕!先找個切入點試著做看看就知道了!

那麼要如何找到切入點呢?前面我們分享過從使用者的角度來描述我想要打造的功能,先來試試看把整個體驗的流程描述一次:

  1. 執行指令後出現選單
  2. 可以用上下鍵選擇遊戲
  3. 選擇遊戲後載入遊戲畫面
  4. 開始遊玩
  5. 獲勝或失敗之後不直接結束,可以按下 q 或 Q 回到選擇遊戲,或者按 ctrl + C 結束程序

OK,那想到這邊,我腦海中其實是有畫面選單可能可以怎麼做,但是對於寫好的兩個遊戲到底該怎麼接到這個共用的架構內,我真的還是一點想法都沒有。

卡了幾天(對的,你沒聽錯),我想那我何不就承認我還不知道這件事該怎麼做,先把可以做的做出來呢?不知道的事情,就說不知道,先去把知道的寫出來,然後晚點再說吧!不要把程式碼放在腦袋裡想想想,寫出來,到時候看著它來想,坐著想、躺著想、趴著想、倒立著想都可以的!咳嗯…別說廢話了,趕快開始吧。

開檔案是最快樂的事了

從來沒有比開一個新的檔案更簡單的事情了對嗎,可以忘記掉前面寫得亂七八糟的那坨東西,動動小手又是全新的一個畫面,這麼快樂的事情又可以再做一次了(?),趕快來動手!

我將原本的推箱子小遊戲 ( index.js ) 改名為 box.js ,然後建立一個新的 index.js 用來寫這個共用入口。

https://ithelp.ithome.com.tw/upload/images/20261003/20182439Nh9NeeW3Ji.png

選單

首先要實作出選單,我去複製了前面遊戲寫好的邏輯過來改成這樣:

// 遊戲註冊區
const games = [
    {id:1,name: '推箱子'},
    {id:2,name: '爆爆王'}
]

// 畫面高度
const height = games.length;

//渲染畫面
function render() {

    //儲存整個畫面的字串
    let frame = ''

    for (const game of games ){

        let row = '';

        row += `${game.id} ${game.name}`;

    frame += row + '\n'

    }

    // 印出來看看結果
    process.stdout.write(`\x1b${height}A`);
    process.stdout.write(frame);

}

//初次渲染
process.stdout.write("\n".repeat(height));
render()

// stdin ,預設是暫停的,因此我們需要先喚醒才能使用
process.stdin.resume();
// 把監聽到的資料轉換成我們看得懂的語言
process.stdin.setEncoding('utf8');

// 加入這一段,讓他能把資料傳給 node ,不須經過 enter
process.stdin.setRawMode(true);

// 接收到這些狀況的時候被觸發,'data' 指有新資料進來的時候
process.stdin.on('data', (key) => {

    // 如果按下字母 'q' 或是 'Q',就回到遊戲
    if (key === 'q' || key === 'Q') {
        // 回到遊戲
    }

    // 如果按下 Ctrl + C 就強制退出遊戲
    if (key === '\x03') {
        process.exit();
    }

    // 上方向鍵
    if (key === '\x1b[A') {
        // focus 上一個
    }

    // 下方向鍵
    if (key === '\x1b[B') {
        // focus 下一個
    }

    // 重新渲染
    render();
});

其實我也就是把我知道該怎麼寫的寫下來,剩下目前還不知道的問題,就用註解留空,晚點再想。

比較值得說明的部分是我制定了 games 這個遊戲註冊的入口,我的期待是希望未來只要有新增遊戲,我只要修改這個陣列把新的遊戲加進去就好了。

另外一個是 height ,由於在入口處我們沒有地圖了,但是我們每個遊戲都會新增一個選項,會占用掉一行的空間,所以我改成用 games 陣列的長度來計算。另外像之前那樣寫在程式碼裡面,我覺得有一點不好管理,所以統一提取出來,這樣就可以同時使用在推出高度以及讓游標回到最上方這兩個位置。

到這邊大概就是我目前想到的東西了,那麼我們就接著來看看「晚點再說」的那些項目吧!

某項目被選擇的表示

首先是介面問題,要如何表示一個項目被選擇了呢?在前端的介面我們可以寫個 focus ,但如果是 CLI 我們要怎麼處理?

我想到兩種做法:1. 使用顏色表示 2. 使用圖形 ex. 三角形來表示,咦?那如果混合的話效果會更好嗎?不過我們先實作出第一種,剩下的留給讀者有空的時候去實驗好了XD 這是一種取捨的練習,絕對不是偷懶喔。

第二個是程式碼問題:要怎麼達到用顏色表示被選擇的項目呢?

我知道要去哪裡改顏色!所以我就到 render 裡面去把原本沒有顏色的,加上顏色:

        if (// 這裡要怎麼判斷?) {
            row += `\x1b[33m${game.id} ${game.name}\x1b[0m`;
        } else {
            row += `${game.id} ${game.name}`;
        }

有被關注的才要改成黃色,沒被關注的就維持原本的顏色。但是要用什麼判斷有沒有被關注?加一個 focus 的變數好了。

let focus = 1;

function render() {

    //其他部分不變

        if (focus === game.id) {
            row += `\x1b[33m${game.id} ${game.name}\x1b[0m`;
        } else {
            row += `${game.id} ${game.name}`;
        }
        
   //其他部分不變

}

那要怎麼改變 focus ?而且上跟下都會用到。

// 改變 focus
function changeFocus(num) {
    focus += num;
}

process.stdin.on('data', (key) => {

    // 前面不變

    // 上方向鍵
    if (key === '\x1b[A') {
        changeFocus(-1); // 上方向鍵是數字變小
    }

    // 下方向鍵
    if (key === '\x1b[B') {
        changeFocus(+1); // 下方向鍵數字要變大
    }

    // 重新渲染
    render();
});

啟動看看:

1

現在我們就可以看到顏色的變化了,其實還蠻簡單的吧!只是沒辦法一步到位的去想清楚,我們就是從一個已知的切入點開始,遇到下一個問題就再問下一個我知道什麼?然後就可以繼續往前走。

那麼現在我們遇到的問題是當我按了很多次上或下方向鍵之後, focus 可能會為 0、-1、-2,或者超過 2 ,這時候因為 focus 就不等於任何一個 game.id 了,就會兩個都不被選取,可是我的理想是往上最多就是選到 1 ,往下最多就是選到最後一個項目,所以我們來想想看切入點是什麼?

我想到的切入點是:必須要限制 focus 的數字介於 1 到 game.length 之間。

// 改變 focus
function changeFocus(num) {

    if (focus + num < 1) {
        return
    }

    if (focus + num > games.length) {
        return
    }

    focus += num;

}

再試一次:

2

按下 Enter 和回到選單的佔位提示

按下 Enter 之後理論上要切換到遊戲畫面,如果遊戲畫面一時還想不到,我們就先把切換的佔位文字給寫出來。同時按下 q 或 Q 回到選單的佔位提示我們也先寫出來。

首先我想加一行提示詞,同時藉由改變這個提示詞來顯示狀態的切換,讓我們可以判斷 enter 是不是有監聽成功。首先我先把提示詞寫出來,並且在高度上多加一行來保留這個位置:

const height = games.length +1; // 高度多一行用來顯示提示詞
let guideline = `上下:選擇遊戲 Enter:進入遊戲` //這行提示詞的內容

渲染的時候一樣要加上這一行:

function render() {

    //前面不變

    frame += guideline+'\n'; // 把 guideline 加到畫面最下方

    process.stdout.write(`\x1b${height}A`);
    process.stdout.write(frame);

}

監聽器的部分主要是在按下不同按鍵時改變 guideline 的內容

    // 如果按下字母 'q' 或是 'Q',就回到遊戲
    if (key === 'q' || key === 'Q') {
        guideline = `上下:選擇遊戲 Enter:進入遊戲`;
    }

    // Enter
    if (key === '\r' || key === '\n') {
        // 進入選擇的遊戲
        const gameName = games.find(game => game.id === focus).name;
        guideline = `你選擇的是 ${gameName}`;
    }

3

試著印出之後發現一個之前沒遇過的問題,因為之前不管是推箱子或是爆爆王,每一列的長度都是固定的,所以不會有覆蓋不乾淨的問題,但這次因為 guideline 變化的時候長度不一樣,就會導致殘留。

所以我們要在渲染出畫面之前先清空所有內容:

function render() {

    //前面不變

    process.stdout.write(`\x1b${height}A`); // 回到左上角
    process.stdout.write('\x1b[J'); // 加上這句先清空畫面
    process.stdout.write(frame); // 再印新的畫面

}

4

現在這樣切換就沒問題了!

無論如何都得先引入再說

接著就先來引入檔案吧,終究是得引入的,先假設這兩個檔案已經有可以引用的模組,然後把引用近來的模組放到遊戲註冊區:

import { startBomb } from "./bomb.js";
import { startBox } from "./box.js";

// 遊戲註冊區
const games = [
    { id: 1, name: '推箱子',script: startBox },
    { id: 2, name: '爆爆王',script: startBomb }
]

接著要去整理這兩個檔案,我目前想到的做法比較暴力(?),把所有執行時會用到的邏輯全部剪下,貼上到函式裡,要記得用 export ,才可以在其他檔案使用 import 引入喔:

export function startBox() {
    //初次渲染
    process.stdout.write("\n".repeat(map.length));
    render()

    // stdin 為了節省資源,預設是暫停的,因此我們需要先喚醒才能使用
    process.stdin.resume();
    // 把監聽到的資料轉換成我們看得懂的語言
    process.stdin.setEncoding('utf8');

    // 加入這一段,讓他能正確地把資料傳給 node
    process.stdin.setRawMode(true);

    // 接收到這些狀況的時候被觸發,'data' 指有新資料進來的時候
    process.stdin.on('data', (key) => {

        // 如果按下字母 'q' 或是 'Q',就正式退出遊戲
        if (key === 'q' || key === 'Q') {
            console.log("\n遊戲結束,謝謝遊玩!");
            process.exit();
        }

        // 如果按下 Ctrl + C (在 Raw Mode 下對應的編碼是 '\x03'),這是強制退出遊戲
        if (key === '\x03') {
            process.exit();
        }

        // 監聽器內四個方向換上這個邏輯
        if (key === '\x1bA') {
            movePlayer(0, -1)
        }
        if (key === '\x1b[B') {

            movePlayer(0, 1)
        }
        if (key === '\x1b[C') {

            movePlayer(1, 0)
        }
        if (key === '\x1b[D') {
            movePlayer(-1, 0)
        }

        // 重新渲染
        render();

        // 獲勝判定
        const isOnGoal = boxes.find(box => box.x === goal.x && box.y === goal.y);
        if (isOnGoal) {
            process.stdout.write("\n恭喜獲勝\n");
            process.exit();
        }

        const position1 = checkBox(map, box1);
        const position2 = checkBox(map, box2);

        // 如果箱子2在按鈕上,可以推出 box1
        if (box2.y === button.y && box2.x === button.x && !position1.isDeadlock) {
            if (position1.top) {
                moveBoxIfAvailable(box1, 0, 1);
            } else if (position1.bottom) {
                moveBoxIfAvailable(box1, 0, -1);
            } else if (position1.left) {
                moveBoxIfAvailable(box1, 1, 0);
            } else if (position1.right) {
                moveBoxIfAvailable(box1, -1, 0);
            }
            render();
        }
        // 如果箱子1在按鈕上,可以推出 box2
        if (box1.y === button.y && box1.x === button.x && !position2.isDeadlock) {
            if (position2.top) {
                moveBoxIfAvailable(box2, 0, 1);
            } else if (position2.bottom) {
                moveBoxIfAvailable(box2, 0, -1);
            } else if (position2.left) {
                moveBoxIfAvailable(box2, 1, 0);
            } else if (position2.right) {
                moveBoxIfAvailable(box2, -1, 0);
            }
            render();
        }

        // 改成兩個箱子都在死角,就結束遊戲
        if (position1.isDeadlock && position2.isDeadlock) {
            process.stdout.write("\n箱子卡住了,遊戲結束\n");
            process.exit();
        }
    });
}

那麼推箱子小遊戲的暴力整理就交給各位,我不重複貼了XD

最後試著在 enter 時加入執行遊戲:

    if (key === '\r' || key === '\n') {
        // 進入選擇的遊戲
        const game = games.find(game => game.id === focus);
        guideline = `你選擇的是 ${game.name}`;
        game.script();
    }

感覺一定有某些狀況是我們沒想到的,沒關係,先執行出來再觀察看看是什麼問題:

5

好消息是可以玩了,但壞消息是版面亂成一團,那我們明天就會從觀察開始找到可以修改的方向,再逐步把這個共用入口給完成。

今天的這一段從 0 開始找到切入點,然後逐步解決問題的作法,我們在整個系列當中已經重複過相當多次,但我仍持續不斷這麼做的原因要從我最近在工作上的煩惱開始說起。

先前有說過我在轉職成為工程師的時候就已經是 AI 時代了,我發現無論在哪個團隊都會有期待你能快速利用 AI 產出成果的傾向。

仔細反思近期的工作狀態,由於 AI 總是很快的能提供解答,似乎也讓我們逐漸忘了經驗是需要累積的。我們不能期待任何一個新手工程師可以看到一個新功能馬上就知道該怎麼實作,除非他自己經歷思考、查詢資料、反覆試錯的過程,他才會獲得解決這個問題的實作經驗,下一次遇到問題他才知道該怎麼解決。

但是現在我們經常忘記我們是需要這樣成長過來的,這也就是我這一整個系列不斷重複一樣的流程的原因,我並不是希望大家不要使用 AI ,而是希望大家可以想起我們是這樣成長的,然後允許自己前進得慢一點,比起快速的前進卻沒有累積經驗,不如慢慢地前進但是逐步累積經驗之後,相信我們遇到問題就會有更多好的想法出現了。

明天我也依然會不改初衷的從 0 開始練習思考、找出解決的方法。明天見!

如果你想要瀏覽完整的程式碼,可以參考這裡:第 20 天程式碼


上一篇
Day19 爆爆王11: 先玩 10 遍,不行的話就 20 遍
下一篇
Day21 共用入口2:觀察 bug 是怎麼發生的
系列文
重新拿回思考力!一起來用 JavaScript 打造 CLI 好玩遊戲區! 共 21 篇
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言