使用 PhysicsJS 构建一个 2D 的浏览器游戏

jopen 10年前

04072202_zpyr.jpg

作为一名Web开发人员同事又是物理极客,每当我试图使用JavaScript做2D物理的时候总觉得少了点什么。我想要一个可扩展的框架,紧随其他现代JavaScript API的脚步。我不希望它干涉性的假设我想要重力朝下,或者说我想要没有引力的物理环境... 这驱使我创造出了PhysicsJS,我希望它不会辜负其口号::

一个模块化,可扩展的,易于使用的JavaScript物理引擎.

还有一些新的JavaScript物理引擎正暂露头角,他们都有各自的优点。而我想告诉你的是我所觉得的PhysicsJS中真正很酷的东西,我也相信这会帮助其在其它引擎中脱颖而出。

在这篇文章中,我将指导你完成一个完整的2D小行星风格的游戏,里面充满着火爆绚丽的图形。在这一过程中,你会看到我的一些开发风格,它会向你展示一些洞察PhysicsJS的更高级用法。你可以在GitHub上的PhysicsJS库(可以看到一些实例/飞船)中下载完整的演示代码,还可以看到CodePen的最终产品。而在我们开始之前,让我先阐述一个重要的声明:

PhysicsJS是一个新的库,并且我们正在努力让它在未来几个月内进入公测。该API正在不断变化中,而且在API中仍然有错误和漏洞。所以请记住这一点。 (我们正在开发PhysicsJS v0.5.2)。我正在寻找的任何水平有保证的贡献者。请即刻就到PhysicsJS GitHub并且指出其中谬误吧!我也会对StackOverflow上有关PhysicsJS的问题作答复,只要你在问题上标记了“physicsjs”。

Stage 0: 空间的虚无

第一件要做的事情。我们需要设置好我们的HTML。我们将使用HTML  canvas进行渲染,所以就要从标准HTML5的样板代码开始。我将假定脚本放在body标签的尾部,使我们不用担心DOM的加载。我们也将使用RequireJS来加载所有的脚本。如果你还没有使用过RequireJS,并且不喜欢学习,那么你就必须拜托任何define()和任何require()的方法,并且将所有的JavaScript以正确的顺序组织在一起(而我则强烈推荐你使用RequireJS)。
下面是我工作时的目录结构:

index.html  images/      ...  js/      require.js      physicsjs/      ...

让我们继续,下载RequireJS,PhysicsJS-0.5.2以及相关的图像,并把它们放到上面这样的目录结构中。

接下来,在头部添加CSS,根据游戏显示图片和信息的类名规定他们的样式:

html, body {      padding: 0;      margin: 0;      width: 100%;      height: 100%;      overflow: hidden;  }  body:after {      font-size: 30px;      line-height: 140px;      text-align: center;      color: rgb(60, 16, 11);      background: rgba(255, 255, 255, 0.6);      position: absolute;      top: 50%;      left: 50%;      width: 400px;      height: 140px;      margin-left: -200px;      margin-top: -70px;      border-radius: 5px;  }  body.before-game:after {      content: 'press "z" to start';  }  body.lose-game:after {      content: 'press "z" to try again';  }  body.win-game:after {      content: 'Win! press "z" to play again';  }  body {      background: url(images/starfield.png) 0 0 repeat;  }

在body标签结束的地方添加下面代码用来载入RequireJS和初始化我们的应用:

<script src="js/require.js"></script>  <script src="js/main.js"></script>

下一步我们需要创建main.js文件,这个文件将在本教程中不断被更改。请注意,我上传的代码是在CodePen上可以执行的示例,所以你可能需要更改你本地示例中的RequireJS的文件路径。下面的代码可能对你有些帮助:

// 开始 main.js  require(      {          // use top level so we can access images          baseUrl: './',          packages: [{              name: 'physicsjs',              location: 'js/physicsjs',              main: 'physicsjs'          }]      },      ...

OK,让我们开始创建第一个版本的main.js吧。

这将是一个——史无前例的——绝无仅有的——空前绝后的——无聊的游戏(靠!)。

那么应该从哪开始呢?首先,我们以RequireJS的依赖库的形式加载PhisicsJS库。我们也加载一些其他的依赖库。这些是PhisicJS的扩展,可以为它添加一些功能,比如说循环体,碰撞检测等。当使用RequireJS(和AMD模块)时,我们需要像这样声明每一个必需的依赖。它允许我们只加载我们实际上使用的依赖库。

在我们的RequireJS工厂函数中,我们可以建立起我们的游戏。首先添加一个class名到body标签中,这样就可以显示“按下'Z'键开始”消息,然后我们监听“Z”键的键盘事件,触发时调用我们的helper中的newGame()方法。

接下来我们设置canvas渲染填充窗口并且监听窗体的resize事件以调整canvas的大小。

接下来,使用init()函数初始化我们的基本元素。init()函数将被传给Physics()构造器,用来创建一个世界。在这个函数中,我们需要进行一些设置:

  • 一艘“船”(现在也就是个〇而已)
  • 一个星球 (另一个〇,不过要给它加上图片)
  • 一个监听器,用来监听世界中发生的操作

对于星球,我可能需要作出一些额外的解释。事实上,它是这样的:

planet.view = new Image();  planet.view.src = require.toUrl('images/planet.png');

这是什么鬼东西0.0?

上面的操作是用来创建一个图片并将其储存在view属性中。该图片将会载入planet.png(使用RequireJS解析图片路径)。但是我们为什么要这么做呢?这是为了定制我们的对象的显示方式。cancas渲染器会监视所有它需要渲染的元素,如果没有设置这个属性,那么canvas将会画出指定的元素(圆形、多边形)并作为图片存在缓存中,图片就存在body的view属性中。如果属性中已存在图片,那么渲染器将会使用这个现成的图片。

注意: 这远非定制渲染过程的最佳方案,但是这里有两个好消息要告诉你:

  1. 渲染器极易扩展或被其他你想要的东西替代,这完全取决于你是否要创建一个完全不同的渲染器。此处有关于这方面的详细文档
  2. 在未来的几周, ChallengePost将会为PhysicsJS搭建功能更加丰富和强大的渲染器。你可以考虑使用这个渲染器。即使你不用,至少你也可以得到些好处。

扯远了,让我们继续。现在我们继续看init()函数,这是完成这个函数的最后一步:

// add things to the world  world.add([    ship,    planet,    Physics.behavior('newtonian', { strength: 1e-4 }),    Physics.behavior('sweep-prune'),    Physics.behavior('body-collision-detection'),    Physics.behavior('body-impulse-response'),    renderer  ]);

我们一次性奖所有的组件都加入世界:创建的船、星球、一些动作、渲染器。下面我们逐一分析这些动作:

  • newtonian: 为世界加入牛顿引力(newtonian gravity)。物体会被1e-4的平方大小的相互作用力吸引.
  • sweep-prune: 这是一个广义相位算法,加速碰撞检测.如果你的游戏涉及到物体碰撞,那么最好用上这个东西。
  • body-collision-detection: 这个狭义碰撞检测算法用来监听sweep-prune事件并用像GJK一类的算法计算出碰撞效果。这只会检测碰撞,而不会对碰撞做出任何反应。
  • body-impulse-response: 这才是对碰撞做出响应的相关动作。它监听碰撞事件并通过对碰撞的物体应用一定的冲量对碰撞做出响应。

希望就在前方!你现在已经开始接触PhysicsJS模块化的本质了。无论是物体亦或是世界本身都不包含任何物理逻辑,如果你想要在你的世界中运用物理规律而不是在真空环境中,你需要创建一些行为并附加在世界中、表现在物体上。

接下来我们在main.js脚本中加入一个新的Game()辅助函数,通过调用Physics(init)来达到清理和创建新游戏的目的。它同时还监听新世界中的用户事件来终止和重启游戏。现在这些事件还不会做出任何响应,不过快了。

最后,我们与心跳建立连接(requestAnimationFramehelper)让每个框架都能调用world.step函数。

步骤1: 组织元素

越来越有趣了不是么?特别是当我们可以开着飞船移动的时候。和我们对星球所做的操作一样,我们也可以对飞船应用相应的操作,但是在此我们需要多个自定义皮肤。我们需要一些方法来操纵飞船,这里最佳的选择也是我最喜欢PhysicsJS的地方:几乎无限制的扩展。在这个例子中,我们将会对body进行扩展。

创建一个新的文件,命名为player.js。这个文件用来定义一个名为player的自定义物体。我们假设一个特别的飞船 (奶牛飞船), 然后向WIKI中说的那样扩展PhysicsJS。

为模块定义RequireJS:

define(  [      'require',      'physicsjs',      'physicsjs/bodies/circle',      'physicsjs/bodies/convex-polygon'  ],  function(      require,      Physics  ){      // code here...  });

现在我们获得了一个圆形物体和一些被摧毁的多边形碎片(爆炸效果嘛,之前承诺过的不是)。准备就绪,动起来~

// extend the circle body  Physics.body('player', 'circle', function( parent ){      // private helpers      // ...      return {          // extension definition      };  });

body将会被附加如PhysicsJS,在其中加入些死人辅助函数并传回一个普通对象用以扩展圆形的主体。

这是我们的私有helper:

// private helpers  var deg = Math.PI/180;  var shipImg = new Image();  var shipThrustImg = new Image();  shipImg.src = require.toUrl('images/ship.png');  shipThrustImg.src = require.toUrl('images/ship-thrust.png');    var Pi2 = 2 * Math.PI;  // VERY crude approximation to a gaussian random number.. but fast  var gauss = function gauss( mean, stddev ){      var r = 2 * (Math.random() + Math.random() + Math.random()) - 3;      return r * stddev + mean;  };  // will give a random polygon that, for small jitter, will likely be convex  var rndPolygon = function rndPolygon( size, n, jitter ){        var points = [{ x: 0, y: 0 }]          ,ang = 0          ,invN = 1 / n          ,mean = Pi2 * invN          ,stddev = jitter * (invN - 1/(n+1)) * Pi2          ,i = 1          ,last = points[ 0 ]          ;        while ( i < n ){          ang += gauss( mean, stddev );          points.push({              x: size * Math.cos( ang ) + last.x,              y: size * Math.sin( ang ) + last.y          });          last = points[ i++ ];      }        return points;  };

这是我们对圆形物体的扩展(具体细节请看注释):

return {      // we want to do some setup when the body is created      // so we need to call the parent's init method      // on "this"      init: function( options ){          parent.init.call( this, options );          // set the rendering image          // because of the image i've chosen, the nose of the ship          // will point in the same angle as the body's rotational position          this.view = shipImg;      },      // this will turn the ship by changing the      // body's angular velocity to + or - some amount      turn: function( amount ){          // set the ship's rotational velocity          this.state.angular.vel = 0.2 * amount * deg;          return this;      },      // this will accelerate the ship along the direction      // of the ship's nose      thrust: function( amount ){          var self = this;          var world = this._world;          if (!world){              return self;          }          var angle = this.state.angular.pos;          var scratch = Physics.scratchpad();          // scale the amount to something not so crazy          amount *= 0.00001;          // point the acceleration in the direction of the ship's nose          var v = scratch.vector().set(              amount * Math.cos( angle ),               amount * Math.sin( angle )           );          // accelerate self          this.accelerate( v );          scratch.done();            // if we're accelerating set the image to the one with the thrusters on          if ( amount ){              this.view = shipThrustImg;          } else {              this.view = shipImg;          }          return self;      },      // this will create a projectile (little circle)      // that travels away from the ship's front.      // It will get removed after a timeout      shoot: function(){          var self = this;          var world = this._world;          if (!world){              return self;          }          var angle = this.state.angular.pos;          var cos = Math.cos( angle );          var sin = Math.sin( angle );          var r = this.geometry.radius + 5;          // create a little circle at the nose of the ship          // that is traveling at a velocity of 0.5 in the nose direction          // relative to the ship's current velocity          var laser = Physics.body('circle', {              x: this.state.pos.get(0) + r * cos,              y: this.state.pos.get(1) + r * sin,              vx: (0.5 + this.state.vel.get(0)) * cos,              vy: (0.5 + this.state.vel.get(1)) * sin,              radius: 2          });          // set a custom property for collision purposes          laser.gameType = 'laser';            // remove the laser pulse in 600ms          setTimeout(function(){              world.removeBody( laser );              laser = undefined;          }, 600);          world.add( laser );          return self;      },      // 'splode! This will remove the ship      // and replace it with a bunch of random      // triangles for an explosive effect!      blowUp: function(){          var self = this;          var world = this._world;          if (!world){              return self;          }          var scratch = Physics.scratchpad();          var rnd = scratch.vector();          var pos = this.state.pos;          var n = 40; // create 40 pieces of debris          var r = 2 * this.geometry.radius; // circumference          var size = 8 * r / n; // rough size of debris edges          var mass = this.mass / n; // mass of debris          var verts;          var d;          var debris = [];            // create debris          while ( n-- ){              verts = rndPolygon( size, 3, 1.5 ); // get a random polygon              if ( Physics.geometry.isPolygonConvex( verts ) ){                  // set a random position for the debris (relative to player)                  rnd.set( Math.random() - 0.5, Math.random() - 0.5 ).mult( r );                  d = Physics.body('convex-polygon', {                      x: pos.get(0) + rnd.get(0),                      y: pos.get(1) + rnd.get(1),                      // velocity of debris is same as player                      vx: this.state.vel.get(0),                      vy: this.state.vel.get(1),                      // set a random angular velocity for dramatic effect                      angularVelocity: (Math.random()-0.5) * 0.06,                      mass: mass,                      vertices: verts,                      // not tooo bouncy                      restitution: 0.8                  });                  d.gameType = 'debris';                  debris.push( d );              }          }            // add debris          world.add( debris );          // remove player          world.removeBody( this );          scratch.done();          return self;      }  };
你可能注意到我们正在使用一些叫做Phisics.scratchpad的东西。这是你的好朋友。一个scratchpad是一个通过回收临时对象(数组)来减少创建对象和回收 垃圾时间的helper。点击 这里,你可以读到更多关于scratchpads的信息。

那么现在我们有了一个玩家,但是它还没有和任何用户输入相关联起来。我们要做的就是创建一个玩家动作以响应用户的输入。所以我们用相似的方式创建另一个文件,叫做player-behavior.js(具体细节请看注释):

define(  [      'physicsjs'  ],  function(      Physics  ){        return Physics.behavior('player-behavior', function( parent ){            return {              init: function( options ){                  var self = this;                  parent.init.call(this, options);                  // the player will be passed in via the config options                  // so we need to store the player                  var player = self.player = options.player;                  self.gameover = false;                    // events                  document.addEventListener('keydown', function( e ){                      if (self.gameover){                          return;                      }                      switch ( e.keyCode ){                          case 38: // up                              self.movePlayer();                          break;                          case 40: // down                          break;                          case 37: // left                              player.turn( -1 );                          break;                          case 39: // right                              player.turn( 1 );                          break;                          case 90: // z                              player.shoot();                          break;                      }                      return false;                  });                  document.addEventListener('keyup', function( e ){                      if (self.gameover){                          return;                      }                      switch ( e.keyCode ){                          case 38: // up                              self.movePlayer( false );                          break;                          case 40: // down                          break;                          case 37: // left                              player.turn( 0 );                          break;                          case 39: // right                              player.turn( 0 );                          break;                          case 32: // space                          break;                      }                      return false;                  });              },                // this is automatically called by the world              // when this behavior is added to the world              connect: function( world ){                    // we want to subscribe to world events                  world.subscribe('collisions:detected', this.checkPlayerCollision, this);                  world.subscribe('integrate:positions', this.behave, this);              },                // this is automatically called by the world              // when this behavior is removed from the world              disconnect: function( world ){                    // we want to unsubscribe from world events                  world.unsubscribe('collisions:detected', this.checkPlayerCollision);                  world.unsubscribe('integrate:positions', this.behave);              },                // check to see if the player has collided              checkPlayerCollision: function( data ){                    var self = this                      ,world = self._world                      ,collisions = data.collisions                      ,col                      ,player = this.player                      ;                    for ( var i = 0, l = collisions.length; i < l; ++i ){                      col = collisions[ i ];                        // if we aren't looking at debris                      // and one of these bodies is the player...                      if ( col.bodyA.gameType !== 'debris' &&                           col.bodyB.gameType !== 'debris' &&                           (col.bodyA === player || col.bodyB === player)                       ){                          player.blowUp();                          world.removeBehavior( this );                          this.gameover = true;                            // when we crash, we'll publish an event to the world                          // that we can listen for to prompt to restart the game                          world.publish('lose-game');                          return;                      }                  }              },                // toggle player motion              movePlayer: function( active ){                    if ( active === false ){                      this.playerMove = false;                      return;                  }                  this.playerMove = true;              },                behave: function( data ){                    // activate thrusters if playerMove is true                  this.player.thrust( this.playerMove ? 1 : 0 );              }          };      });  });
接下来我们可以声明js/playerandjs/player-behavior作为依赖库,并将它添加进我们的main.js文件的init()函数里,这样我们就可以使用了。
/ in init()  var ship = Physics.body('player', {      x: 400,      y: 100,      vx: 0.08,      radius: 30  });    var playerBehavior = Physics.behavior('player-behavior', { player: ship });    // ...    world.add([      ship,      playerBehavior,      //...  ]);
在我们看到我们的第二个场景之前,我们最后需要添加的东西是获取渲染的画布来跟踪用户的动作。这可以通过添加一些代码到stepevent listener来做到,在它调用world.render()方法前改变渲染的位置,就像下面这样:
// inside init()...  // render on every step  world.subscribe('step', function(){      // middle of canvas      var middle = {           x: 0.5 * window.innerWidth,           y: 0.5 * window.innerHeight      };      // follow player      renderer.options.offset.clone( middle ).vsub( ship.state.pos );      world.render();  });
我们第二个迭代现在看起来更像一个游戏了。

步骤2:“自找麻烦”

现在,它已经是一个可爱的小游戏了。但是我们需要一些“敌人”。接下来,让我们来创建一些吧!

我们将要做几乎相同的事情,就像刚刚我们对玩家角色所做的一样。我们将要创建一个新的身体(扩展的圆环),但是我们接下来要做的事非常简单。因为我们只要敌人爆炸,所以我们采用不同的方式来创建它们,并且不创建动作,因为这些功能是不需要的。我们创建一个叫做dufo.js的新文件,并且给我们的UFO一个简单方法blowUp()。

define(  [      'require',      'physicsjs',      'physicsjs/bodies/circle'  ],  function(      require,      Physics  ){        Physics.body('ufo', 'circle', function( parent ){          var ast1 = new Image();          ast1.src = require.toUrl('images/ufo.png');            return {              init: function( options ){                  parent.init.call(this, options);                    this.view = ast1;              },              blowUp: function(){                  var self = this;                  var world = self._world;                  if (!world){                      return self;                  }                  var scratch = Physics.scratchpad();                  var rnd = scratch.vector();                  var pos = this.state.pos;                  var n = 40;                  var r = 2 * this.geometry.radius;                  var size = r / n;                  var mass = 0.001;                  var d;                  var debris = [];                    // create debris                  while ( n-- ){                      rnd.set( Math.random() - 0.5, Math.random() - 0.5 ).mult( r );                      d = Physics.body('circle', {                          x: pos.get(0) + rnd.get(0),                          y: pos.get(1) + rnd.get(1),                          vx: this.state.vel.get(0) + (Math.random() - 0.5),                          vy: this.state.vel.get(1) + (Math.random() - 0.5),                          angularVelocity: (Math.random()-0.5) * 0.06,                          mass: mass,                          radius: size,                          restitution: 0.8                      });                      d.gameType = 'debris';                        debris.push( d );                  }                    setTimeout(function(){                      for ( var i = 0, l = debris.length; i < l; ++i ){                          world.removeBody( debris[ i ] );                      }                      debris = undefined;                  }, 1000);                    world.add( debris );                  world.removeBody( self );                  scratch.done();                  world.publish({                      topic: 'blow-up',                       body: self                  });                  return self;              }          };      });  });
接下来,我们在main.js的innit()方法中 创建一些敌人。

// inside init()...  var ufos = [];  for ( var i = 0, l = 30; i < l; ++i ){        var ang = 4 * (Math.random() - 0.5) * Math.PI;      var r = 700 + 100 * Math.random() + i * 10;        ufos.push( Physics.body('ufo', {          x: 400 + Math.cos( ang ) * r,          y: 300 + Math.sin( ang ) * r,          vx: 0.03 * Math.sin( ang ),          vy: - 0.03 * Math.cos( ang ),          angularVelocity: (Math.random() - 0.5) * 0.001,          radius: 50,          mass: 30,          restitution: 0.6      }));  }    //...    world.add( ufos );
这里的数学运算只是为了让它们用这样一种方式随机地绕行地球,而且慢慢地朝地球蠕动过去。但现在,我们还不能射击他们,因此让我们向init()函数添加更多的代码来跟踪我们消灭了多少敌人,而如果我们消灭了它们,就会释放出AWIN-game事件。我们还将侦听这个世界中的碰撞:detected事件,并且任何冲突都会有激光在其中,如果它支持的话,此时我方会对其大发脾气。
// inside init()...  // count number of ufos destroyed  var killCount = 0;  world.subscribe('blow-up', function( data ){        killCount++;      if ( killCount === ufos.length ){          world.publish('win-game');      }  });    // blow up anything that touches a laser pulse  world.subscribe('collisions:detected', function( data ){      var collisions = data.collisions          ,col          ;        for ( var i = 0, l = collisions.length; i < l; ++i ){          col = collisions[ i ];            if ( col.bodyA.gameType === 'laser' || col.bodyB.gameType === 'laser' ){              if ( col.bodyA.blowUp ){                  col.bodyA.blowUp();              } else if ( col.bodyB.blowUp ){                  col.bodyB.blowUp();              }              return;          }      }  });

请注意,这里我们可以创建一个新的行为来管理这些UFO,而代码非常少,所以这里提没有必要。我也想要向大家展示PhysicsJS是如何的多才多艺,因为它能容纳不同的编码风格,达成目标可以用不同的方式。

好吧,让我们来看看我们的main.js的第三次迭代吧:

太棒了!我们几乎已经完成了。只是有一件事我还想提醒你一下...

第3步:寻找我们的方式

空间会让人少许有一点点迷惑。你很难把你的周围观察清楚,因此有时你需要一些帮助。为了解决这个问题,就让我们创建一个雷达小地图吧!那么,我们如何才能做到这一点呢?

我们得把所有天体的位置并且在顶部右侧角落的一块小画布上画上小点。虽然渲染器的功能并不是完备的,但是我想在这一点上,我们可以使用我们自己掌握的一些有用的辅助方法。我们要做的是将所谓的渲染器完成对物体的渲染这一个事件绑定到render事件。然后,我们使用这些辅助方法就可以将我们的小地图绘制到帧上面。下面是代码:

// inside init()...  // draw minimap  world.subscribe('render', function( data ){      // radius of minimap      var r = 100;      // padding      var shim = 15;      // x,y of center      var x = renderer.options.width - r - shim;      var y = r + shim;      // the ever-useful scratchpad to speed up vector math      var scratch = Physics.scratchpad();      var d = scratch.vector();      var lightness;        // draw the radar guides      renderer.drawCircle(x, y, r, { strokeStyle: '#090', fillStyle: '#010' });      renderer.drawCircle(x, y, r * 2 / 3, { strokeStyle: '#090' });      renderer.drawCircle(x, y, r / 3, { strokeStyle: '#090' });        for (var i = 0, l = data.bodies.length, b = data.bodies[ i ]; b = data.bodies[ i ]; i++){            // get the displacement of the body from the ship and scale it          d.clone( ship.state.pos ).vsub( b.state.pos ).mult( -0.05 );          // color the dot based on how massive the body is          lightness = Math.max(Math.min(Math.sqrt(b.mass*10)|0, 100), 10);          // if it's inside the minimap radius          if (d.norm() < r){              // draw the dot              renderer.drawCircle(x + d.get(0), y + d.get(1), 1, 'hsl(60, 100%, '+lightness+'%)');          }      }        scratch.done();  });

OK,让我们把这个添加到ourmain.js上,然后看看成品!

结束语

瞧!就是这样!谢谢你忍受了我这篇常常的教程。

显然,这个游戏的体验不是最好的,而且有更多的地方亟需完善(比如使用RequireJS构建工具压缩我们的脚本等等),但我的目标是让你对PhysicsJS的功能有一个了解,以及即使是在它的早期阶段它也是那么实用。希望这些东西能够给你足够的思考空间。如果有任何疑问,请你不要忘了,可以在评论列表或者StackOverflow上随意自由发表评论。