JavaScript遊戲中的物件導向的設計

IBM developerworks發表於2012-12-25

  簡介: 從程式角度考慮,許多 JavaScript 都基於迴圈和大量的 if/else 語句。在本文中,我們可瞭解一種更聰明的做法 — 在 JavaScript 遊戲中使用物件導向來設計。本文將概述原型繼承和使用 JavaScript 實現基本的物件導向的程式設計 (OOP)。學習如何在 JavaScript 中使用基於經典繼承的庫從 OOP 中獲得更多的好處。本文還將介紹架構式設計模式,來展示瞭如何使用遊戲迴圈、狀態機和事件冒泡 (event bubbling) 示例來編寫更整潔的程式碼。

  在本文中,您將瞭解 JavaScript 中的 OOP,來探索原型繼承模型和經典繼承模型。舉例說明遊戲中能夠從 OOP 設計的結構和可維護性中獲得極大利益的模式。我們的最終目標是讓每一塊程式碼都成為人類可讀的程式碼,並代表一種想法和一個目的,這些程式碼的結合超越了指令和演算法的集合,成為一個精緻的藝術品。

  JavaScript 中的 OPP 的概述

  OOP 的目標就是提供資料抽象、模組化、封裝、多型性和繼承。通過 OOP,您可以在程式碼編寫中抽象化程式碼的理念,從而提供優雅、可重用和可讀的程式碼,但這會消耗檔案計數、行計數和效能(如果管理不善)。

  過去,遊戲開發人員往往會避開純 OOP 方式,以便充分利用 CPU 週期的效能。很多 JavaScript 遊戲教程採用的都是非 OOP 方式,希望能夠提供一個快速演示,而不是提供一種堅實的基礎。與其他遊戲的開發人員相比,JavaScript 開發人員面臨不同的問題:記憶體是非手動管理的,且 JavaScript 檔案在全域性的上下文環境中執行,這樣一來,無頭緒的程式碼、名稱空間的衝突和迷宮式的 if/else 語句可能會導致可維護性的噩夢。為了從 JavaScript 遊戲的開發中獲得最大的益處,請遵循 OOP 的最佳實踐,顯著提高未來的可維護性、開發進度和遊戲的表現能力。

  原型繼承

  與使用經典繼承的語言不同,在 JavaScript 中,沒有內建的類結構。函式是 JavaScript 世界的一級公民,並且,與所有使用者定義的物件類似,它們也有原型。用 new 關鍵字呼叫函式實際上會建立該函式的一個原型物件副本,並使用該物件作為該函式中的關鍵字 this的上下文。清單 1 給出了一個例子。

  清單 1. 用原型構建一個物件

// constructor function
function MyExample() {
  // property of an instance when used with the 'new' keyword
  this.isTrue = true;
};

MyExample.prototype.getTrue = function() {
  return this.isTrue;
}

MyExample();
// here, MyExample was called in the global context, 
// so the window object now has an isTrue property—this is NOT a good practice

MyExample.getTrue;
// this is undefined—the getTrue method is a part of the MyExample prototype, 
// not the function itself

var example = new MyExample();
// example is now an object whose prototype is MyExample.prototype

example.getTrue; // evaluates to a function
example.getTrue(); // evaluates to true because isTrue is a property of the 
                   // example instance

  依照慣例,代表某個類的函式應該以大寫字母開頭,這表示它是一個建構函式。該名稱應該能夠代表它所建立的資料結構。

  建立類例項的祕訣在於綜合新的關鍵字和原型物件。原型物件可以同時擁有方法和屬性,如 清單 2 所示。

  清單 2. 通過原型化的簡單繼承

// Base class
function Character() {};

Character.prototype.health = 100;

Character.prototype.getHealth = function() {
  return this.health;
}

// Inherited classes

function Player() {
  this.health = 200;
}

Player.prototype = new Character;

function Monster() {}

Monster.prototype = new Character;

var player1 = new Player();

var monster1 = new Monster();

player1.getHealth(); // 200- assigned in constructor

monster1.getHealth(); // 100- inherited from the prototype object

  為一個子類分配一個父類需要呼叫 new 並將結果分配給子類的 prototype 屬性,如 清單 3 所示。因此,明智的做法是保持建構函式儘可能的簡潔和無副作用,除非您想要傳遞類定義中的預設值。

  如果您已經開始嘗試在 JavaScript 中定義類和繼承,那麼您可能已經意識到該語言與經典 OOP 語言的一個重要區別:如果已經覆蓋這些方法,那麼沒有 super 或 parent 屬性可用來訪問父物件的方法。對此有一個簡單的解決方案,但該解決方案違背了 “不要重複自己 (DRY)” 原則,而且很有可能是如今有很多庫試圖模仿經典繼承的最重要的原因。

  清單 3. 從子類呼叫父方法

function ParentClass() {
  this.color = 'red';
  this.shape = 'square';
}

function ChildClass() {
  ParentClass.call(this);  // use 'call' or 'apply' and pass in the child 
                           // class's context
  this.shape = 'circle';
}

ChildClass.prototype = new ParentClass(); // ChildClass inherits from ParentClass

ChildClass.prototype.getColor = function() {
  return this.color; // returns "red" from the inherited property
};

  在 清單 3 中, color 和 shape 屬性值都不在原型中,它們在 ParentClass 建構函式中賦值。ChildClass 的新例項將會為其形狀屬性賦值兩次:一次作為 ParentClass 建構函式中的 “squre”,一次作為 ChildClass 建構函式中的 “circle”。將類似這些賦值的邏輯移動到原型將會減少副作用,讓程式碼變得更容易維護。

  在原型繼承模型中,可以使用 JavaScript 的 call 或 apply 方法來執行具有不同上下文的函式。雖然這種做法十分有效,可以替代其他語言的 super 或 parent,但它帶來了新的問題。如果需要通過更改某個類的名稱、它的父類或父類的名稱來重構這個類,那麼現在您的文字檔案中的很多地方都有了這個 ParentClass 。隨著您的類越來越複雜,這類問題也會不斷增長。更好的一個解決方案是讓您的類擴充套件一個基類,使程式碼減少重複,尤其在重新建立經典繼承時。

  經典繼承

  雖然原型繼承對於 OOP 是完全可行的,但它無法滿足優秀程式設計的某些目標。比如如下這些問題:

  ● 它不是 DRY 的。類名稱和原型隨處重複,讓讀和重構變得更為困難。

  ● 建構函式在原型化期間呼叫。一旦開始子類化,就將不能使用建構函式中的一些邏輯。

  ● 沒有為強封裝提供真正的支援。

  ● 沒有為靜態類成員提供真正的支援。

  很多 JavaScript 庫試圖實現更經典的 OOP 語法來解決上述問題。其中一個更容易使用的庫是 Dean Edward 的 Base.js(請參閱 參考資料),它提供了下列有用特性:

  ● 所有原型化都是用物件組合(可以在一條語句中定義類和子類)完成的。

  ● 用一個特殊的建構函式為將在建立新的類例項時執行的邏輯提供一個安全之所。

  ● 它提供了靜態類成員支援。

  ● 它對強封裝的貢獻止步於讓類定義保持在一條語句內(精神封裝,而非程式碼封裝)。

  其他庫可以提供對公共和私有方法和屬性(封裝)的更嚴格支援,Base.js 提供了一個簡潔、易用、易記的語法。

  清單 4 給出了對 Base.js 和經典繼承的簡介。該示例用一個更為具體的 RobotEnemy 類擴充套件了抽象 Enemy 類的特性。

  清單 4. 對 Base.js 和經典繼承的簡介

// create an abstract, basic class for all enemies
// the object used in the .extend() method is the prototype
var Enemy = Base.extend({
    health: 0,
    damage: 0,
    isEnemy: true,

    constructor: function() {
        // this is called every time you use "new"
    },

    attack: function(player) {
        player.hit(this.damage); // "this" is your enemy!
    }
});

// create a robot class that uses Enemy as its parent
// 
var RobotEnemy = Enemy.extend({
    health: 100,
    damage: 10,

    // because a constructor isn't listed here, 
    // Base.js automatically uses the Enemy constructor for us

    attack: function(player) {
        // you can call methods from the parent class using this.base
        // by not having to refer to the parent class
        // or use call / apply, refactoring is easier
        // in this example, the player will be hit
        this.base(player); 

        // even though you used the parent class's "attack" 
        // method, you can still have logic specific to your robot class
        this.health += 10;
    }
});

  遊戲設計中的 OOP 模式

  基本的遊戲引擎不可避免地依賴於兩個函式:update 和 renderrender 方法通常會根據 setInterval 或 polyfill 進行requestAnimationFrame,比如 Paul Irish 使用的這個(請參閱 參考資料)。使用 requestAnimationFrame 的好處是僅在需要的時候呼叫它。它按照客戶監視器的重新整理頻率執行(對於桌上型電腦,通常是一秒 60 次),此外,在大多數瀏覽器中,通常根本不會執行它,除非遊戲所在的選項卡是活動的。它的優勢包括:

  ● 在使用者沒有盯著遊戲時減少客戶機上的工作量

  ● 節省移動裝置上的用電。

  ● 如果更新迴圈與呈現迴圈有關聯,那麼可以有效地暫停遊戲。

  出於這些原因,與 setInterval 相比,requestAnimationFrame 一直被認為是 “客戶友好” 的 “好公民”。

  將 update 迴圈與 render 迴圈捆綁在一起會帶來新的問題:要保持遊戲動作和動畫的速度相同,而不管呈現迴圈的執行速度是每秒 15 幀還是 60 幀。這裡要掌握的技巧是在遊戲中建立一個時間單位,稱為滴答 (tick),並傳遞自上次更新後過去的時間量。然後,就可以將這個時間量轉換成滴答數量,而模型、物理引擎和其他依賴於時間的遊戲邏輯可以做出相應的調整。比如,一箇中毒的玩家可能會在每個滴答接受 10 次損害,共持續 10 個滴答。如果呈現迴圈執行太快,那麼玩家在某個更新呼叫上可能不會接受損害。但是,如果垃圾回收在最後一個導致過去 1 個半滴答的呈現迴圈上生效,那麼您的邏輯可能會導致 15 次損害。

  另一個方式是將模型更新從檢視迴圈中分離出來。在包含很多動畫或物件或是繪製佔用了大量資源的遊戲中,更新迴圈與 render 迴圈的耦合會導致遊戲完全慢下來。在這種情況下,update 方法能夠以設定好的間隔執行(使用 setInterval),而不管requestAnimationFrame 處理程式何時會觸發,以及多久會觸發一次。在這些迴圈中花費的時間實際上都花費在了呈現步驟中,所以,如果只有 25 幀被繪製到螢幕上,那麼遊戲會繼續以設定好的速度執行。在這兩種情況下,您可能都會想要計算更新週期之間的時間差;如果一秒更新 60 次,那麼完成函式更新最多有 16ms 的時間。如果執行此操作的時間更長(或如果執行了瀏覽器的垃圾回收),那麼遊戲還是會慢下來。 清單 5 顯示了一個示例。

  清單 5. 帶有 render 和 update 迴圈的基本應用程式類

// requestAnim shim layer by Paul Irish
    window.requestAnimFrame = (function(){
      return  window.requestAnimationFrame       || 
              window.webkitRequestAnimationFrame || 
              window.mozRequestAnimationFrame    || 
              window.oRequestAnimationFrame      || 
              window.msRequestAnimationFrame     || 
              function(/* function */ callback, /* DOMElement */ element){
                window.setTimeout(callback, 1000 / 60);
              };
    })();

var Engine = Base.extend({
    stateMachine: null,  // state machine that handles state transitions
    viewStack: null,     // array collection of view layers, 
                         // perhaps including sub-view classes
    entities: null,      // array collection of active entities within the system
                         // characters, 
    constructor: function() {
        this.viewStack = []; // don't forget that arrays shouldn't be prototype 
		                     // properties as they're copied by reference
        this.entities = [];

        // set up your state machine here, along with the current state
        // this will be expanded upon in the next section

        // start rendering your views
        this.render();
       // start updating any entities that may exist
       setInterval(this.update.bind(this), Engine.UPDATE_INTERVAL);
    },

    render: function() {
        requestAnimFrame(this.render.bind(this));
        for (var i = 0, len = this.viewStack.length; i < len; i++) {
            // delegate rendering logic to each view layer
            (this.viewStack[i]).render();
        }
    },

    update: function() {
        for (var i = 0, len = this.entities.length; i < len; i++) {
            // delegate update logic to each entity
            (this.entities[i]).update();
        }
    }
}, 

// Syntax for Class "Static" properties in Base.js. Pass in as an optional
// second argument to.extend()
{
    UPDATE_INTERVAL: 1000 / 16
});

  如果您對 JavaScript 中 this 的上下文不是很熟悉,請注意 .bind(this) 被使用了兩次:一次是在 setInterval 呼叫中的匿名函式上,另一次是在 requestAnimFrame 呼叫中的 this.render.bind() 上。setInterval 和 requestAnimFrame 都是函式,而非方法;它們屬於這個全域性視窗物件,不屬於某個類或身份。因此,為了讓此引擎的呈現和更新方法的 this 引用我們的 Engine 類的例項,呼叫.bind(object) 會迫使此函式中的 this 與正常情況表現不同。如果您支援的是 Internet Explorer 8 或其更早版本,則需要新增一個 polyfill,將它用於繫結。

  狀態機

  狀態機模式已被廣泛採用,但人們並不怎麼認可它。它是 OOP(從執行抽象程式碼的概念)背後的原理的擴充套件。比如,一個遊戲可能具有以下狀態:

  ● 預載入

  ● 開始螢幕

  ● 活動遊戲

  ● 選項選單

  ● 遊戲接受(贏、輸或繼續)

  這些狀態中沒有關注其他狀態的可執行程式碼。您的預載入程式碼不會知曉何時開啟 Options 選單。指令式(過程式)程式設計可能會建議組合使用 if 或 switch 條件語句,從而獲得順序正確的應用程式邏輯,但它們並不代表程式碼的概念,這使得它們變得很難維護。如果增加條件狀態,比如遊戲中選單,等級間轉變等特性,那麼會讓條件語句變得更難維護。

  相反,您可以考慮使用 清單 6 中的示例。

  清單 6. 簡化的狀態機

// State Machine
var StateMachine = Base.extend({
    states: null, // this will be an array, but avoid arrays on prototypes.
                  // as they're shared across all instances!
    currentState: null, // may or may not be set in constructor
    constructor: function(options) {
        options = options || {}; // optionally include states or contextual awareness

        this.currentState = null;
        this.states = {};

        if (options.states) {
            this.states = options.states;
        }

        if (options.currentState) {
            this.transition(options.currentState);
        }
    },

    addState: function(name, stateInstance) {
        this.states[name] = stateInstance;
    },

    // This is the most important function—it allows programmatically driven
    // changes in state, such as calling myStateMachine.transition("gameOver")
    transition: function(nextState) {
        if (this.currentState) {
            // leave the current state—transition out, unload assets, views, so on
            this.currentState.onLeave();
        }
        // change the reference to the desired state
        this.currentState = this.states[nextState];
        // enter the new state, swap in views, 
        // setup event handlers, animated transitions
        this.currentState.onEnter();
    }
});

// Abstract single state
var State = Base.extend({
    name: '',       // unique identifier used for transitions
    context: null,  // state identity context- determining state transition logic

    constructor: function(context) {
        this.context = context;
    },

    onEnter: function() {
        // abstract

        // use for transition effects
    },

    onLeave: function() {
        // abstract

        // use for transition effects and/or
        // memory management- call a destructor method to clean up object
        // references that the garbage collector might not think are ready, 
        // such as cyclical references between objects and arrays that 
        // contain the objects
    }
});

  您可能無需為應用程式建立狀態機的特定子類,但確實需要為每個應用程式狀態建立 State 的子類。通過將轉變邏輯分離到不同的物件,您應該:

  ● 使用建構函式作為立即開始預載入資產的機會。

  ● 向遊戲新增新的狀態,比如在出現遊戲結束螢幕之前出現的一個繼續螢幕,無需嘗試找出某個單片的 if/else 或 switch 結構中的哪個條件語句中的哪個全域性變數受到了影響。

  ● 如果是基於從伺服器載入的資料建立狀態,那麼可以動態地定義轉換邏輯。

  您的主要應用程式類不應關注狀態中的邏輯,而且您的狀態也不應太多關注主應用程式類中的內容。例如,預載入狀態可能負責基於構建在頁面標記中的資產來例項化某個檢視,並查詢某個資產管理器中的最小的遊戲資產(電影片斷、影像和聲音)。雖然該狀態初始化了預載入檢視類,但它無需考慮檢視。在本例中,此理念(此狀態所代表的物件)在責任上限於定義它對應用程式意味著處於一種預載入資料狀態。

  請記住狀態機模式並不限於遊戲邏輯狀態。各檢視也會因為從其代表邏輯中刪除狀態邏輯而獲益,尤其在管理子檢視或結合責任鏈模式處理使用者互動事件時。

  責任鏈:在畫布上模擬事件冒泡

  可以將 HTML5 canvas 元素視為一個允許您操縱各畫素的影像元素。如果有一個區域,您在該區域中繪製了一些草、一些戰利品 以及站在這些上面的一個人物,那麼該畫布並不瞭解使用者在畫布上單擊了什麼。如果您繪製了一個選單,畫布也不會知道哪個特定的區域代表的是一個按鈕,而附加到事件的惟一 DOM 元素就是畫布本身。為了讓遊戲變得可玩,遊戲引擎需要翻譯當使用者在畫布上單擊時會發生什麼。

  責任鏈設計模式旨在將事件的傳送者(DOM 元素)與接受者分離開來,以便更多的物件有機會處理事件(檢視和模型)。典型的實現,比如 Web 頁,可能會讓檢視或模型實現一個處理程式介面,然後將所有的滑鼠事件 指派到某個場景圖,這有助於找到被單擊的相關的“事物”並在擷取畫面時讓每一個事物都有機會。更簡單的方法是讓此畫布本身託管在執行時定義的處理程式鏈,如 清單 7 所示。

  清單 7. 使用責任鏈模式處理事件冒泡

var ChainOfResponsibility = Base.extend({
        context: null,      // relevant context- view, application state, so on
        handlers: null,     // array of responsibility handlers
        canPropagate: true, // whether or not 

        constructor: function(context, arrHandlers) {
            this.context = context;
            if (arrHandlers) {
                this.handlers = arrHandlers;
            } else {
                this.handlers = [];
            }
        },

        execute: function(data) 
            for (var i = 0, len = this.handlers.length; i < len; i++) {
                if (this.canPropagate) {
                    // give a handler a chance to claim responsibility
                    (this.handlers[i]).execute(this, data);
                } else {
                    // an event has claimed responsibility, no need to continue
                    break;
                } 
            }
            // reset state after event has been handled
            this.canPropagate = true;
        },

        // this is the method a handler can call to claim responsibility
        // and prevent other handlers from acting on the event
        stopPropagation: function() {
            this.canPropagate = false;
        },

        addHandler: function(handler) {
            this.handlers.push(handler);
        }
});

var ResponsibilityHandler = Base.extend({
    execute: function(chain, data) {

        // use chain to call chain.stopPropegation() if this handler claims
        // responsibility, or to get access to the chain's context member property
        // if this event handler doesn't need to claim responsibility, simply
        // return; and the next handler will execute
    }
});

  ChainOfResponsibility 類沒有子類化也能很好地工作,這是因為所有特定於應用程式的邏輯都會包含在 ResponsibilityHandler 子類中。在各實現之間惟一有所改變的是傳入了一個適當的上下文,比如它代表的檢視。例如,有一個選項選單,在開啟它時,仍會顯示處於暫停狀態的遊戲,如 清單 8 所示。如果使用者單擊選單中的某個按鈕,背景中的人物不應對此單擊操作有任何反應。

  清單 8. 選項選單關閉處理程式

var OptionsMenuCloseHandler = ResponsibilityHandler.extend({
    execute: function(chain, eventData) {
        if (chain.context.isPointInBackground(eventData)) {
            // the user clicked the transparent background of our menu
            chain.context.close(); // delegate changing state to the view
            chain.stopPropegation(); // the view has closed, the event has been handled
        }
    }
});

// OptionMenuState
// Our main view class has its own states, each of which handles
// which chains of responsibility are active at any time as well
// as visual transitions

// Class definition...
constructor: function() {
    // ...
    this.chain = new ChainOfResponsibility(
        this.optionsMenuView, // the chain's context for handling responsibility
        [
            new OptionsMenuCloseHandler(), // concrete implementation of 
			                               // a ResponsibilityHandler
            // ...other responsibility handlers...
        ]
    );
}

// ...
onEnter: function() {
    // change the view's chain of responsibility
    // guarantees only the relevant code can execute
    // other states will have different chains to handle clicks on the same view
    this.context.setClickHandlerChain(this.chain);
}
// ...

  在 清單 8 中,view 類包含針對一組狀態的一個引用,並且每個狀態決定了物件將會負責單擊事件的處理。這樣一來,檢視的邏輯限於此檢視身份所代表的邏輯:顯示此選項選單。如果更新遊戲,以包含更多的按鈕、更漂亮的效果或新檢視的轉換,那麼這裡提供了一個獨立物件,它能夠處理每個新特性,無需更改、中斷或重寫現有邏輯。通過巧妙組合 mousedown、mousemove、mouseup 和 click 事件的責任鏈,並管理從選單到人物的所有事情,能夠以高度結構化、有組織的方式處理拖放庫存螢幕,不會增加程式碼的複雜性。

  結束語

  設計模式和 OOP 本身是很中立的概念,將這二者捆綁使用會帶來一些問題,而不是解決問題。本文提供了 JavaScript 中的 OOP 概述,探討了原型繼承模型和典型繼承模型。我們瞭解了遊戲中一些常見模式,這些模式能夠從 OOP 設計(基本的遊戲迴圈、狀態機和事件冒泡)的結構和易維護性模式中獲得極大的利益。本文只是對常見問題的解決方案進行了簡要介紹。通過實踐,您會熟練掌握如何編寫具有表現力強的程式碼,並會最終減少在編寫程式碼上花費的時間,增加創作的時間。

相關文章