iT邦幫忙

2022 iThome 鐵人賽

DAY 8
4
Modern Web

30個遊戲程設的錦囊妙計系列 第 8

Trick 7: 追著主角跑的攝影機大哥

  • 分享至 

  • xImage
  •  

若說有一個隱藏在畫面背後,卻掌管著遊戲躍動的重要舵手,那指的就是遊戲的攝影機了。攝影機的操作和性能,除了直接連結了視覺效果,也可能影響角色的操縱性,甚至改變玩家對遊戲的認識和玩法。

我們今天來實作一個2D遊戲的攝影機,瞭解一下基本的攝影機應有的架構。有了正確的認識,未來才比較容易開發更為複雜的攝影機模組。

從基礎開始

我們先來瞭解一下,攝影機需要遊戲場景中的哪些資訊,才能正確地調整它的位置。

  • 可視畫面的尺寸:通常是指視窗畫面的長與寬,但也可能因UI讓可視畫面變小。
  • 遊戲場景的尺寸:就是指目前關卡的舞台展開後的全長與全寬。

camera1 camera2
攝影機可藉由這兩個資訊,計算出攝影機最大可移動的範圍(上圖的白色虛線範圍)。如果攝影機跑出最大可移動的範圍,那麼畫面上就會出現一塊沒有地圖的空白背景。

程式中會用到CG提供的一般矩形類別。
原始碼在這 Rectangle.ts

/** 定義攝影機的類別 */
class Camera {
    /** 準備一個攝影機可移動的範圍矩形 */
    moveArea = new Rectangle();
    /** 攝影機的尺寸(也就是畫面的尺寸) */
    size = new Size();
    /** 以可視畫面和遊戲場景的尺寸來調整移動範圍 */
    updateMoveArea(screenSize: Size, mapSize: Size) {
        this.moveArea.x = screenSize.width / 2;
        this.moveArea.y = screenSize.height / 2;
        this.moveArea.width = mapSize.width - screenSize.width;
        this.moveArea.height = mapSize.height - screenSize.height;
        // 儲存畫面的尺寸
        this.size = screenSize;
    }
}

另外,我們還需要一個重要的角色,就是攝影機跟隨聚焦的物件。雖然大部分需要聚焦的物件都是遊戲中的人物,但實際在過場動畫、提示過關條件、或警告場景的改變,都有可能讓攝影機暫時聚焦在人物以外的聚焦物,所以在定義這個聚焦物件時,就需要使用程式中常用的interface。

interface是用來定義有著某些屬性的介面,不管是什麼類別的物件,只要裏面有著符合interface定義的屬性,就可以將其視為這個interface來操作。
文末的註解會再詳加解釋。

/** 定義一個interface給Camera當作聚焦物件
 * 不管是什麼型別,只要內含x和y的屬性
 * 就可以當作聚焦物件給Camera用
 */
interface CameraFocus {
    x: number;
    y: number;
}
/** 繼續補充Camera的內容 */
class Camera {
    ...
    focus: CameraFocus;
}

有了這些資料,我們就能夠在每一幀的更新中,讓攝影機去追聚焦物件了。

/** 我們需要使用前天講過的靠近演算法 
 * current: 目前的數值
 * target: 想要靠近的目標值
 * rate: 靠近的速率
 * 函式會回傳更新後的數值
 */
function numberFollowTarget(current: number, target: number, rate: number): number {
    return current * (1 - rate) + target * rate;
}
/** 繼續補充Camera的內容 */
class Camera {
    ...
    // 我們需要攝影機有x和y屬性來儲存目前攝影機的位置
    x = 0;
    y = 0;
    // 以及追隨聚焦物的時候用的靠近率
    followRate = 0.1;
    // 更新攝影機位置的函式
    updatePosition() {
        // 如果存在focus,就要去追focus
        if (this.focus) {
            // 先把focus的X,Y限制在moveArea的範圍內
            let focusX = Math.max(
                this.moveArea.left,
                Math.min(this.moveArea.right, this.focus.x)
            );
            let focusY = Math.max(
                this.moveArea.top,
                Math.min(this.moveArea.bottom, this.focus.y)
            );
            // 使用靠近演算法去追focus的X和Y
            const followRate = this.followRate;
            this.x = numberFollowTarget(this.x, focusX, followRate);
            this.y = numberFollowTarget(this.y, focusY, followRate);
        }
    }
}

計算完攝影機的位置後,我們就需要依照攝影機的位置,反著去改變整個遊戲場景在畫面上的位置,讓攝影機聚焦的範圍呈現在畫面上。

示範程式中使用Pixi.js作為繪圖引擎,而Pixi.js裏的PIXI.Container就是用來當遊戲場景的容器,不管是地圖、人物、特效、道具,都要放在場景的容器內,如此一來只要我們移動場景容器,容器內的所有物件就會跟著一起移動。

/** 繼續補充Camera的內容 */
class Camera {
    ...
    // 依攝影機位置去更新場景的函式
    updateScene(scene: PIXI.Container) {
        /**
         * 場景的原點一般是設定在場景的左上角,
         * 而攝影機的位置代表了目前畫面中心在場景中的位置,
         * 所以場景的x要先移動攝影機寬度的一半,
         * 才會對齊到攝影機的中心。
         * 場景的原點對齊到攝影機的中心後,再減去攝影機的x,
         * 就等價於攝影機跑到場景x的位置上。
         */
        scene.x = this.size.width / 2 - this.x;
        scene.y = this.size.height / 2 - this.y;
    }
}

使用以上介紹的方法,就可以實作出一個有基本功能的2D遊戲攝影機。在這個架構之上,同學們就方便開發更多讓遊戲酷起來的延伸功能,比如在炸彈炸開時讓畫面震動,或是使用狙擊槍時,距焦至主角前方一段距離等等有趣的玩法。

CG示範專案
在示範專案中,使用WASD或方向鍵去移動主角,使用Q鍵把焦點在主角和地圖上的物件之間切換。
同學們可以注意一下使用靠近演算法讓攝影機在焦點切換時的自然滑動,以及主角突然停下來的時候,攝影機剎車的感覺。
有興趣的朋友也可以看看Base模組中功能較為完整的 GameCamera.ts


註解

Interface

Interface是個和class(類別)有點像又不一樣的東西,它的功用是定義一個可以用來操作的介面。

Interface裏可以規定符合這個介面的物件應該要有的樣子,但僅此而已,並不會在interface裏實際編寫邏輯,也不會指定各屬性的值。

之所以要有interface,是一方面可以在定義函式時,不需要強制指定參數的類別,留給函式更大的使用彈性;一方面又可以在製作類別時,提醒我們要實作介面上定義的屬性或方法有哪些。

/** 定義一個類似平面點的介面
 * 只要是IPoint,就會有x,y兩個屬性
 * 還會有一個normalize()的函式
 */
interface IPoint {
    x: number;
    y: number;
    normalize(): number;
}

假設我們有了上面定義的IPoint介面,那麼我們在想定義一個符合IPoint介面的Position類別時,就可以這樣寫。

/** 宣告類別時,使用 implements 關鍵字
 * 來表示這個類別必須實作後面指定的介面
 */
class Position implements IPoint {
    // 我們給了x,y兩個屬性,符合IPoint的規定
    constructor(public x: number, public y: number) {
    }
    // 還要實作normalize()
    normalize(): number {
        const x = this.x;
        const y = this.y;
        let length = Math.sqrt(x * x + y * y);
        this.x /= length;
        this.y /= length;
        return length;
    }
}

我們還可以定義另一個符合IPoint介面的類別,UnitVector。

class UnitVector implements IPoint {
    // 單位向量只需要一個角度的屬性
    constructor(public angle: number) {
    }
    // 我們需要實作x和y的getter
    // getter實作完,用法就會像一個正常的屬性一樣
    // 文章後面會再詳加解說
    get x(): number {
        return Math.cos(this.angle);
    }
    get y(): number {
        return Math.sin(this.angle);
    }
    // 還要實作normalize(), 單位向量的長度一定是1
    normalize(): number {
        return 1;
    }
}

有了兩個實作了IPoint介面的類別後,假設我們要製作一個可以把兩個向量相加的函式,那就可以這樣寫。

// 利用IPoint作為參數的類型
function addVectors(vec1: IPoint, vec2: IPoint): Position {
    // 因為IPoint一定有x和y屬性,所以我們可以放心拿來用
    return new Position(vec1.x + vec2.x, vec1.y + vec2.y);
}
// 實際使用範例
let pos = new Position(2, 3);
let unit = new UnitVector(Math.PI);
/**
 * pos是一個Position, 也可以看成是一個 IPoint
 * unit是一個UnitVector, 也可以看成是一個 IPoint
 * 所以兩者都能當成addVectors的參數
 */
let addResult = addVectors(pos, unit);
console.log(`result = (${addResult.x}, ${addResult.y})`);

Getter / Setter

一般的類別內,我們可以宣告有某些屬性,也可以直接給預設值。

class People {
    public name: string;
    public age = 18;
}

不過有時候類別的屬性並沒有那麼獨立,而是依附著其他的屬性,隨之變化。比如我們在People裏,年齡是會隨著觀測年份而變化的,真正不變的是出生的年份。所以我們應該要這樣改寫一下Poeple,利用 get 關鍵字來設計age這個屬性。

class People {
    public name: string;
    public birthYear = 1999;
    /**
     * 使用 get 關鍵字,就可以宣告一個getter函式
     */
    public get age(): number {
        // new Date()會得到目前的系統時間
        // Date.getFullYear()可得到時間中的年份
        let thisYear = new Date().getFullYear();
        return thisYear - birthYear;
    }
}
// 使用實例
let haska = new People();
// haska.age 就可以得到 get age(): number 算出來的值
console.log(`Haska今年${haska.age}歲!`);

相對的,我們也可以用關鍵字 set 來寫setter。

class People {
    ...
    /**
     * 使用 set 關鍵字,就可以宣告一個setter函式
     * setter函式不能有回傳值的型別
     */
    public set age(value: number) {
        // new Date()會得到目前的系統時間
        // Date.getFullYear()可得到時間中的年份
        let thisYear = new Date().getFullYear();
        // 以今年的年代和參數給的年齡來推等出生年份
        this.birthYear = thisYear - value;
    }
}
// 使用實例
let haska = new People();
// 下面這行會呼叫 set age(value: number) 這個setter函式
haska.age = 100;
console.log(`Haska是西元${haska.birthYear}年出生的!`);

如果類別中有定義名叫age的getter函式,而沒有定義同樣叫age的setter函式,那這個age就會變成是一個唯讀的屬性。


上一篇
Trick 6: 顏色的靠近演算法
下一篇
Trick 8: 狙擊槍的彈著點是在哈囉?
系列文
30個遊戲程設的錦囊妙計32
圖片
  直播研討會
圖片
{{ item.channelVendor }} {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言