JavaScript 游戏中的面向对象的设计
简介: 从程序角度考虑,许多 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
和 render
。 render
方法通常会根据 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 设计(基本的游戏循环、状态机和事件冒泡)的结构和易维护性模式中获得极大的利益。本文只是对常见问题的解决方案进行了简要介绍。通过实践,您会熟练掌握如何编写具有表现力强的代码,并会最终减少在编写代码上花费的时间,增加创作的时间。