iT邦幫忙

2026 iThome 鐵人賽

DAY 24
0
JavaScript

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

Day24 RPG2:為了夢想與希望!用力思考得出的攝影機跟隨效果

  • 分享至 

  • xImage
  •  

今天開始前呢,請允許我聊一聊最近在看的《奇案 84 的奇趣民宿-第二季》(超好看的!!推薦),故事的內容就是由奇案 84 設計出異想天開的房子,並且在這房子裡接待客人的故事。

非常特別的是無論是第一季或第二季,奇案 84 設計出的房子永遠都「非常的不方便」,從民宿的大門開始,就會讓人有「這到底要怎麼進去啊」的感覺,接著進入到房子裡面,廚房在要爬上爬下的地方、睡覺也要爬上爬下,甚至可能是在風吹日曬雨淋的位置睡覺,可是整個房子非常奇幻、沒有現實感。

奇案老闆認為就是因為這樣,才能夠創造回憶,大家合力克服地形上的障礙,讓整個民宿體驗充滿激勵人心的故事XD

我想講的是在這裡:有時候有人會很想要作弊,比方說從一些比較好攀爬的入口進入民宿,或者用一些小技巧一個人關閉大門等等,但這時候老闆就會說好好的重做一次吧!這都是為了夢想與希望!即使他自己也累到不行,他說他就是一直在挖坑給自己跳。

我覺得這實在是蠻符合這系列的主題啊!!!其實寫這一篇的時候,我卡了蠻久,其中也有一度請 AI 幫我實作程式碼、省略自己思考的環節。但我想到奇趣民宿,有時候當老闆自己也想要耍小聰明妥協於方便的時候,一開始很想要妥協的員工反而會出來提醒老闆要為了夢想與希望堅持下去!

不能妥協!那就讓我們為了夢想與希望堅持下去吧!

我想要做出怎樣的遊戲畫面

首先依據慣例,我想說明一下最終期待的遊戲畫面應該是這個樣子的:

########## 任務:0
#     P  # 背包:0
#        #
#   @    #
#      % #
#        #
#        #
##########

如同畫面所示,右手邊是儀表板,可以隨著我們開發的功能越多,逐步往下擴充。左手邊則是冒險地圖。一樣 @ 作為玩家,另外 P 是 NPC、 % 是該地圖的出口。不過因為冒險地圖很大,但是遊玩的畫面比較小,所以這邊就會需要有那種鏡頭隨著玩家畫面移動的感覺。

那麼就進到程式碼開發的環節。

經驗複製

寫這一段的時候頗有種我在市場挑菜的感覺,我把之前寫好的檔案打開來,一段一段看哪一段是我需要的,就全部複製起來,貼到我新建好的 rpg.js 檔案裡面,再改成符合需求的內容,因為不是今天的重點,直接貼給你們看。

//地圖初始化
function createMap(width, height) {
    // 一樣先有一個陣列來儲存整個地圖
    const map = [];

    // 熟悉的雙層迴圈,這次是直接用寬高的數值來計算
    for (let y = 0; y < height; y++) {

        // 用來記錄這一行的每一個座標
        const row = [];

        for (let x = 0; x < width; x++) {

            // 判斷是不是邊邊
            const isBorder = y === 0 || y === height - 1 || x === 0 || x === width - 1;
            if (isBorder) {
                row.push('#')
            } else {
                row.push(' ')
            }

        }

        map.push(row)
    }

    return map
}

function movePlayer(dx, dy) {
    // 藉由傳入的 dx 和 dy 來定位下一個座標,這樣我們就知道要往哪邊走
    const nextX = player.x + dx; // 下一個 x  
    const nextY = player.y + dy; // 下一個 y 

    if (map[nextY][nextX] === ' ') {
        player.x = nextX; // player 的座標移動到下一個位置
        player.y = nextY;
    }
}

//渲染畫面
function render() {

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

    for (let y = 0; y < map.length; y++) {
        let row = ''

        for (let x = 0; x < map[y].length; x++) {

            // 小優化,把常用的寫法儲存成變數
            const cell = map[y][x]

            if (player.x === x && player.y === y) {
                row += `\x1b33m@\x1b[0m`
            } else if (npc.x === x && npc.y === y) {
                row += `\x1b[34mP\x1b[0m`
            } else if (exit.x === x && exit.y === y) {
                row += `\x1b[34m%\x1b[0m`
            } else {
                row += `\x1b[32m${cell}\x1b[0m`
            }

        }

        //把這行儲存進去 frame,並記得結尾換行
        frame += row + '\n'
    }

    // 一樣先印出來看看結果
    process.stdout.write(`\x1b[${map.length}A`);
    process.stdout.write(frame);

}

export function startRPG(backToMenu) {

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

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

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

    // 接收到這些狀況的時候被觸發,'data' 指有新資料進來的時候
    function handleRPGInput(key) {

        // 如果按下字母 'q' 或是 'Q',就回到選單
        if (key === 'q' || key === 'Q') {
            process.stdout.write(`\x1b[${map.length}A`);
            process.stdout.write('\x1b[J');
            backToMenu();
            return;
        }

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

        // 監聽器內四個方向換上這個邏輯
        if (key === '\x1b[A') {
            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();
    }

    process.stdin.on('data', handleRPGInput);
}

const map = createMap(30, 9);

let player = {x:1,y:1};
let npc = {x:20,y:5};
let exit = {x:29,y:4};

startRPG(() => process.exit(0));

執行之後就可以印出一個空白的 30 * 9 地圖,加上玩家、NPC、出口的初始定位了,測試看看玩家也可以動得起來。

2

字串陣列算出地圖

接著我想要有一個地方儲存視覺化的地圖,再從這裡計算出 牆壁、NPC、出口的位置,這樣以後改地圖就是直接去看圖像修改就可以了。

有感覺到可以用市場上的哪一種菜嗎XD 沒錯,就是我們在第二天曾經寫過的字串陣列地圖,我也去複製了程式碼過來改:

function createVillage() {
    let walls = [];
    let npc;
    let exit;

    // 之後換地圖只要改這裡
    const village = [
        '##############################',
        '#           ##               #',
        '#           ##    ##         #',
        '#           ## P  ##         #',
        '#     ##    ########   ##    #',
        '#     ##               ##   %#',
        '#     ##         ########    #',
        '#     ##         ##          #',
        '##############################',
    ]

    for (let y = 0; y < village.length; y++) {

        for (let x = 0; x < village[y].length; x++) {

            if (village[y][x] === '#') {
                walls.push({ x:x, y:y })

            }
            if (village[y][x] === 'P') {
                npc = { x, y }
            }
            if (village[y][x] === '%') {
                exit = { x, y }
            }
        }

    }

    return { walls, npc, exit };
}

對應的地方都要做修改,npc 和 exit 、walls 都要從 village 取出來。

let player = { x: 1, y: 1 };

const village = createVillage();
const npc = village.npc;
const exit = village.exit;
const walls = village.walls;
const map = createMap(30, 9,walls); 

另外我也參考了爆爆王直接在 createMap 函式裡算出內牆的作法,我把 walls 傳入了 createMap 裡面去計算。

function createMap(width, height,walls = []) {
    // 前面不變

        for (let x = 0; x < width; x++) {

            // 判斷是不是牆壁
            const isWall = walls.find(wall=>x === wall.x && y === wall.y);
            if (isWall) {
                row.push('#')
            } else {
                row.push(' ')
            }

        }

   // 後面不變
}

這樣就可以印出完整的地圖了!

1

到這邊是不是都蠻簡單的,都還只熱身而已,接下來才是真正讓人苦惱的地方啊...。

怎麼做出顯示視窗的感覺

這裡就是我卡住了蠻久的地方,甚至一度直接請 AI 寫出一版程式碼,但我覺得他的實作很複雜,我當時不明白為什麼我沒辦法直接接受他給的答案,即使可以完美地按照我想要的方式呈現出畫面,移動也很順暢。

後來一邊在看《奇案 84 的奇趣民宿》的時候,我好像終於了解為什麼我會覺得「這樣不對吧!」、「這樣好像有點偷懶」的原因。我想是因為至少在這個專案裡,我不想妥協於自己看不懂的程式碼,我還是期待自己可以用力的思考、寫出這個功能來。

那就讓我們忘記 AI 產出的完美程式碼,我們重新用「站在巨人肩膀上」的方式,搜尋一般都會怎麼做吧:

https://ithelp.ithome.com.tw/upload/images/20261007/20182439OSn8iD5Cwt.png

然後我很用力地思索了幾天,有時候一直坐在程式碼前似乎想不到什麼好想法,不如一邊看看奇趣民宿,一邊出去走走,腦袋裡掛著我自己畫的這個畫面:

https://ithelp.ithome.com.tw/upload/images/20261007/20182439EU86fppwPm.png

某天早上我終於想到可以怎麼拆解目標了!

  1. 定義視窗尺寸
  2. 標記出相機左上角的頂點
  3. 印出畫面以前要多計算是不是在相機範圍內,是的話才顯示
  4. 手動移動相機頂點座標,確認是不是可以改變顯示範圍
  5. 相機頂點跟隨玩家移動:這部分的規則比較複雜,相機畫面不能超出地圖之外,並且要跟隨玩家。

這裡面目前還是沒有想法的是第五點,至於前四點也沒有一個明確該怎麼寫的輪廓,不過感覺可以邊寫邊想了,試著實作看看吧!

先來定義視窗尺寸:

//視窗大小
let viewWidth = 10;
let viewHeight = 8;

接著我們來定義頂點,根據前面我畫的圖,相機一開始會放在 {x:0,y:0} 的位置。

// 相機頂點
let cameraVertex = {x:0, y:0};

然後我們要在 render 時去判斷每一格目前是否位於可視範圍內,可視範圍從相機頂點向外延伸出視窗的大小,也就是相機要拍攝到的範圍。

所以我在 render 內加上判斷的邏輯,先算出最小的 X 和 Y 以及最大的 X 和 Y,如果超出範圍就不計入畫面。

    const canvaMinX = cameraVertex.x;
    const canvaMaxX = cameraVertex.x + viewWidth -1 ; // 這邊減一是扣除相機頂點本身也是畫面的一部分
    const canvaMinY = cameraVertex.y;
    const canvaMaxY = cameraVertex.y + viewHeight -1;

    for (let y = 0; y < map.length; y++) {
        // 不在可以印出的 y 內就不往下執行
        if(y < canvaMinY || y > canvaMaxY) {
            continue;
        }

        let row = ''

        for (let x = 0; x < map[y].length; x++) {
            // 不在可以印出的 x 內就不往下執行
            if(x < canvaMinX || x > canvaMaxX) {
                continue;
            }

            // 小優化,把常用的寫法儲存成變數
            const cell = map[y][x]

            if (player.x === x && player.y === y) {
                row += `\x1b33m@\x1b[0m`
            } else if (npc.x === x && npc.y === y) {
                row += `\x1b[34mP\x1b[0m`
            } else if (exit.x === x && exit.y === y) {
                row += `\x1b[37m%\x1b[0m`
            } else {
                row += `\x1b[32m${cell}\x1b[0m`
            }

        }

這時候印出來看看:

3

可以耶!!!! 不過現在移動的時候會大破版,推出的行數應該要按照 viewHeight 而不是 map ,修復一下,在程式碼中找到這兩行,改成 viewHeight :

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

process.stdout.write("\n".repeat(viewHeight));

再試試看:

4

現在就可以只顯示部分畫面了,但是角色移動的時候我們的相機鏡頭沒有跟著動,這就是我剛剛說會卡住的點,不過現在倒是清楚一點了,我想我們應該就是動態依據 player 的座標來更新 cameraVertex 似乎就可以?

所以我想說寫一個函式來更新 cameraVertex :

function resetCameraVertex(){
    // 依據 player 座標更新 cameraVertex
}

每次 render 之前先計算一次:

function render() {

    // 每次開始 render 前先重算一次
    resetCameraVertex();
    
    // 後面不變   
}

畫面隨玩家移動

最後就是來思考一下要怎麼寫出「依據 player 座標更新 cameraVertex」,我畫給大家看一下:

5

首先,如同畫面上看到的粉紅色外框(可視範圍)與綠色點點(玩家)之間的關係,我們期待玩家盡可能位於可視範圍的中心位置,但這麼一來,當玩家靠近邊緣時,畫面就會超出地圖邊界。

所以理想的狀態應該是這樣:(請原諒我用繪圖的方式沒辦法把整個動畫表達得很流暢)顯示範圍最多就是在地圖範圍內,所以當顯示範圍已經靠到邊上時就只有玩家移動,畫面不動。

6

也就是說我們要做到下面這兩件事:

  1. 使用者要維持在中間,除非靠近邊緣
  2. canvaMinY 和 canvaMaxY 都不能超過地圖範圍

我覺得到這邊算邏輯比較清楚了,實作就交給 AI 比較快!

https://ithelp.ithome.com.tw/upload/images/20261007/20182439MgKHnct1j4.png

矮油,這邊就有點數學的部分了,有點耐心的來讀一下才知道到底有沒有符合我們定的邏輯。

這邊的 maxCameraX 、 maxCameraY 和剛剛我們自己寫的 canvaMinX 這系列四個變數不太一樣,這邊主要是要計算出頂點本身最大值是在哪一個位置,所以他拿地圖的寬高減掉畫面的尺寸,來計算出超過特定的 x 或 y 就不再移動,那表示已經靠到邊緣了,往反面也是,最小就是 0 ,代表靠到了另一邊的邊緣。

往下就是重設頂點的邏輯了,從裡面往外看,首先因為玩家要置中,所以相機會位於玩家的左半邊、上半邊,因此玩家座標減掉視窗範圍的一半,就會是頂點的位置,但是呢,如果這個頂點大於 maxCameraX 或 maxCameraY 當然就不能使用,所以這兩個數值必須選擇小的那一個。不過如果這個數值小於 0 ,代表他又超過了另一邊的範圍,所以最小就是 0。

其實最簡單來理解就是我們將相機頂點限縮在 0 - 地圖寬高減掉畫面寬高的範圍之內就對了!我畫了這張圖幫助大家理解:因為 player 所在位置的相機頂點超過了最大允許的範圍,所以最後套用的是紅色星星的這一個最大允許值。
https://ithelp.ithome.com.tw/upload/images/20261007/20182439NLloPFN8fV.png

最後來測試看看是否有成功吧!

7

太好了!!終於完成了這個任務,如果一開始就讓 AI 來代勞,似乎就沒有辦法自己經歷這個解謎的過程了,雖然多花了一點時間,證明我實在不是個天才,不過我想下一次當遇到類似捲動畫面等問題的時候,我就知道該怎麼做了!

最後來回答一個或許你也會有的問題:為什麼今天不引入好玩遊戲區?一開始我也有想過將這個排入今天的行程,不過我發現在開發階段,其實我不希望每次開發之前都還要選擇遊戲之後才能看到遊戲畫面,也因此我選擇先優先完成遊戲的邏輯!

這也回應到前幾天我們努力精簡遊戲串接回共用入口的邏輯,就是為了不要造成日後開發完成還要耗費大量力氣去調整遊戲邏輯。

那麼明天我們就要來實作切換地圖囉!大家先想想可以怎麼做吧!

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


上一篇
Day23 RPG1:在冒險式 RPG 遊戲之前,先認識狀態機
下一篇
Day25 RPG3:用狀態機來完成切換地圖!
系列文
重新拿回思考力!一起來用 JavaScript 打造 CLI 好玩遊戲區! 共 27 篇
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言