今天我們就要來開始第一個小遊戲了!我希望能用第一個遊戲來認識 Terminal 是一個怎樣的環境,同時也在開發遊戲的過程中慢慢鍛鍊自己拆解問題、小步測試的能力,當然也會開始讓問題主導我們的設計走向,藉由問出「如果…會怎樣呢?」的問題,來試著做不同的嘗試。所以我選擇了一個非常非常簡單的遊戲:推箱子。
玩法很簡單,以上下左右鍵操控角色 @ 推動 $ 箱子 到 . 目標點上就算成功了。雖然是很簡單的遊戲,實際做了之後才會逐漸發現原來我們對於一個完整的遊戲還有更多的期待,就等到後面再和讀者一起來發現理想與現實的落差並且逐步追平比分吧!
對於一個從來沒做過的事情,拆解目標對我來說總是很有幫助,讓我可以有一個整體的工作地圖,而且我每次只需要專注處理單一任務就好,不需要看著一塊空白的畫布發呆。
我的拆解方式是藉由一個又一個的問題不斷延伸下去,如果前一個問題解決的話,下一個問題會是什麼?
當然我們在回答每一個問題的時候,也可能會衍伸出更多的問題,不過就當作是一場探險,我們就逐一的來解題吧!
那麼,馬上來回答第一個問題吧!希望各位都已經下載 node.js 了,我們馬上執行一個簡單的指令來建立一個專案
npm init -y
如果看見 package.json 檔案出現,就表示成功了
接著我們建立一個 index.js 的檔案,並且在裡面撰寫
console.log('hello world');
並且請打開你的 terminal ,輸入 node index.js

如果你在這裡問出要如何開啟 terminal ,非常棒!你已經知道該如何問下一步的問題了,我也相信你能快速的在 google 的搜尋框輸入 vscode terminal 怎麼開 來找到答案,並回來加入我們,之後所有我沒講到的問題,你都可以比照辦理!
既然可以印出一行字,那我應該也可以用這個方式畫出整個畫面!沒問題!
console.log('########')
console.log('# #')
console.log('# $. #')
console.log('# @ #')
console.log('# #')
console.log('########')
嗯,很好,結束!
彷彿看到資深工程師在我背後露出「你完蛋了」的微笑,令我寒毛直豎啊!但不行,作為菜鳥工程師,抱持著失敗為成功之母的精神,我們還是要堅持己見的這樣寫,出問題再說!
這邊我們就要來學到一個 setInterval 的方法,這個方法可以讓我們設定定時器,每隔一段時間就觸發一次函式,使用的方法如下:
setInterval(fn, 1000) // 每隔 1000 毫秒,就執行一次 fn 函式。
因此我們試著將前面的地圖改成這個寫法
let count = 3; // 總共要執行幾次
let step = 0; // 紀錄走到了第幾步
const timer = setInterval(() => {
console.log('########');
console.log('# #');
console.log('# $. #');
/// 第一行是 # 代表牆壁,起始時空兩格,第三格是 @ ,接著都是空格,最後是牆壁
/// 接下來每一秒渲染時都動態往後推一格
console.log('#' + " ".repeat(2 + step) + '@' + " ".repeat(3 - step) + '#');
console.log('# #');
console.log('########');
// 印一條分隔線,方便看清楚每一秒的變化
console.log(`--- 第 ${step + 1} 秒的地圖狀態 --- `);
count--; // 每執行一次就將 count 減一
step++; // 步數加 1,下一秒的 @ 就會往右挪一格
// 一但執行次數 < 0 就清除掉這個計時器,下一次就不再執行
if (count < 0) {
clearInterval(timer);
}
}, 1000);
一樣執行 node index.js 可以看到這邊每一秒都會渲染出新的畫面,不斷往下堆疊。

但我們期望的其實是可以在同一個畫面去重新渲染,這時就是問下一個問題的好時機了。
先來看這個範例,每一秒重新執行 console.log 的時候都會出現新的一行字
let count = 5;
const timer = setInterval(() => {
// 每一秒都提醒倒數時間
console.log(`目前倒數:${count} 秒啦啦啦啦啦`);
count--;
// 秒數少於 0 就終止
if (count < 0) {
clearInterval(timer);
}
}, 1000);
這一個的執行結果會是
目前倒數:5 秒啦啦啦啦啦
目前倒數:4 秒啦啦啦啦啦
目前倒數:3 秒啦啦啦啦啦
目前倒數:2 秒啦啦啦啦啦
目前倒數:1 秒啦啦啦啦啦
目前倒數:0 秒啦啦啦啦啦
因為 console.log 每一次印都會換行,所以我們要改用原生的 process.stdout.write 來重繪
let count = 5;
const timer = setInterval(() => {
// \r 游標回到行首,且不用 console.log 換行
process.stdout.write(`目前倒數:${count} 秒啦啦啦啦啦 \r`);
count--;
if (count < 0) {
clearInterval(timer);
}
}, 1000);
Terminal 需要知道的指令:
\r回到行首
現在可以看到輸出變成在同一行輸出
我們要把這個方法帶回到遊戲的專案內,所以剛剛的專案改成
let count = 3;
let step = 0; // 紀錄走到了第幾步
const timer = setInterval(() => {
process.stdout.write('########\n');
process.stdout.write('# #\n');
process.stdout.write('# $. #\n');
/// 第一行是 # 代表牆壁,起始時空兩格,第三格是 @ ,接著都是空格,最後是牆壁
/// 接下來每一秒渲染時都動態往後推一格
process.stdout.write('#' + " ".repeat(2 + step) + '@' + " ".repeat(3 - step) + '#\n');
process.stdout.write('# #\n');
process.stdout.write('########\n');
// 印一條分隔線,方便看清楚每一秒的變化
process.stdout.write(`--- 第 ${step + 1} 秒的地圖狀態 --- \n`);
count--;
step++; // 步數加 1,下一秒的 @ 就會往右挪一格
if (count < 0) {
clearInterval(timer);
}
}, 1000);
Terminal 需要知道的指令:
\n換行
但是我們發現印出來的還是每秒一個畫面,那是因為我們沒有強制每一次執行後回到起始點。
現在請在 timer 函式一開始加入這一行:
process.stdout.write("\x1b[H");
完整程式碼如下
let count = 3;
let step = 0;
const timer = setInterval(() => {
// 就是這行!每一秒要畫圖前,先把游標強行抓回 Terminal 的左上角 (0,0)
process.stdout.write("\x1b[H");
// ... 其他的內容不變
if (count < 0) {
clearInterval(timer);
}
}, 1000);
\x1b[這是下給終端機的指令開場\x1b[H這意思是要執行 CUP (Cursor Position,游標定位),完整的寫法是\x1b[5;3H意思是要去到第 5 行第 3 列,如果不帶座標,就預設回到1;1 也就是左上,這屬於絕對座標定位法
結果我們發現到他會從整個終端機的最左上角開始渲染,這會導致我們的畫面破版如下
於是我們得想另外一個方法,這個方法就是我們先往下推出我們需要的空間,接著回到該空間的最左上角,再開始繪製,然後每秒都回到這個位置
let count = 3;
let step = 0; // 紀錄走到了第幾步
// 我們先往後推 7 行空地,確保把舊的「node index.js」指令推到上面去
process.stdout.write("\n".repeat(7));
const timer = setInterval(() => {
// 每一次要繪製畫面之前都強制從最左上開始繪製
process.stdout.write("\x1b7A");
// ... 其他的內容不變
if (count < 0) {
clearInterval(timer);
}
}, 1000);
\x1b[7A這是相對定位的意思,A 代表向上移動,7 則是 7 行的意思,因此組合起來就是向上移動 7 行
這時候就可以看到一開始就會出現一個 7 行的空間,接著繪製之後才會開始有內容
如果要把空間隱藏,不讓使用者發現的話,也可以在空間推出來之後,立即讓游標回到左上。
let count = 3;
let step = 0;
process.stdout.write("\n".repeat(7));
// 空間推出來之後,立即回到左上,就不會被發現
process.stdout.write("\x1b7A");
const timer = setInterval(() => {
// 為了避免重複的回到左上,第一次渲染就不用執行這一行
if (step > 0) {
process.stdout.write("\x1b[7A");
}
// .. 其他的內容不變
if (count < 0) {
clearInterval(timer);
}
}, 1000);
現在畫面看起來就順一點了
不過除了游標移動到開頭之外,還有沒有更好的方法呢?比方說隱藏游標?這個就給大家自己來嘗試看看!
我們今天就先到這裡,明天要繼續來回答剩下的問題,那麼明天見囉!!
如果你想要瀏覽完整的程式碼,可以參考這裡:第二天程式碼