HTML5 2D 游戏开发: 操纵时间,第 1 部分

利用线性运动实现跳跃

在本系列文章中,HTML5 专家 David Geary 将向您展示如何逐步实现一个 HTML5 2D 视频游戏。在这由连续两部分组成的系列文章的第一篇文章中,将实现 runner sprite(跑步小人)的跳跃行为。

David Geary, 作者和演讲家, Clarity Training, Inc.

David GearyDavid Geary 是 Core HTML5 Canvas 的作者,也是 HTML5 Denver User's Group 的合著者和其他 8 本 Java 图书的作者,其中包括畅销书 Swing 和 JavaServer Faces。David 经常在各大会议发表演讲,包括 JavaOne、Devoxx、Strange Loop、NDC 和 OSCON,他曾三次当选为 JavaOne 之星。他还为 developerWorks 撰写了 JSF 2 fuGWT fu 系列文章。您可以通过 @davidgeary 在 Twitter 上关注他。



2013 年 7 月 15 日

在本系列的 上一篇文章 中,我介绍了如何将 sprite 的活动(比如跑步、跳起、踱步或爆炸)封装在称之为行为 的可插入对象中。在运行时,您可以使用任何想要的行为轻松装饰 sprite。在诸多优点中,灵活性鼓励您探索可能被隐藏起来的游戏方面。

在本文中,我将继续讨论 sprite 行为。首先,本文是本系列文章中专门介绍单个 sprite 行为(跑步小人跳跃行为)的连续两部分文章的第一篇文章。在 “操纵时间,第 2 部分” 的结尾,Snail Bait 最终将会实现图 1 中描绘的自然跳跃序列。

图 1. 一个自然的跳跃序列
Snail Bait 的截图显示了跑步小人的自然跳跃序列

碰撞检测延迟

我推迟了对 Snail Bait 碰撞检测的介绍,在跑步小人跳跃时,应将注意力全部集中在它的动作上。如果想要进行碰撞检测,跑步小人应该停落在平台上,这会导致跳跃中断;如果不进行碰撞检测,跳跃会一直持续直至跳跃结束。要得到完整的跳跃效果,请 下载 本文代码,亲自尝试该效果。

其次,和 前期文章 介绍的其他行为不一样,跳跃行为不能无限期地重复。由于这个简单的区别,Snail Bait 必须记录跳跃所用的时间。这一需求导致需要使用一个类似秒表的工具,因此我将实现一个 JavaScript 秒表,用它来记录跑步小人跳跃时的上升和下落时间。

跑步小人踪迹和平台顶部

Snail Bait 平台沿着三条轨道水平移动,如图 2 所示:

图 2. 平台轨道
屏幕截图显示了 Snail Bait 的三条平台轨道

轨道间隔为 100 像素。这给高度为 60 像素的跑步小人提供了足够的跳跃空间。

清单 1 显示 Snail Bait 如何设置跑步小人的高度和平台垂直位置,还给出了一个返回某个轨道(1、2 或 3)相应基线的便捷方法: calculatePlatformTop()

清单 1. 从轨道基线开始计算平台顶部位置
var SnailBait = function () {
   // Height of the runner's animation cells:

   this.RUNNER_CELLS_HEIGHT = 60, // pixels

   // Track baselines:

   this.TRACK_1_BASELINE = 323, // pixels
   this.TRACK_2_BASELINE = 223,
   this.TRACK_3_BASELINE = 123,
   ...
};
...

SnailBait.prototype = {
   ...
   calculatePlatformTop: function (track) {
      var top;
   
      if      (track === 1) { top = this.TRACK_1_BASELINE; }
      else if (track === 2) { top = this.TRACK_2_BASELINE; }
      else if (track === 3) { top = this.TRACK_3_BASELINE; }

      return top;
   ...
};

Snail Bait 使用 calculatePlatformTop() 定位几乎所有游戏 sprite 的位置。


初始跳跃实现

正如上篇文章结尾处所介绍的,Snail Bait 拥有最简单的跳跃算法,如清单 2 所示:

清单 2. 跳跃活动的键盘操作
window.onkeydown = function (e) {
   var key = e.keyCode;
   ...
   
   if (key === 74) { // 'j'
      if (snailBait.runner.track === 3) { // At the top; nowhere to go
         return;
      }

      snailBait.runner.track++;
      snailBait.runner.top = snailBait.calculatePlatformTop(snailBait.runner.track) -
                              snailBait.RUNNER_CELLS_HEIGHT;
   }
};
...

当玩家按下 j 键时,Snail Bait 会立即将跑步小人放在所在轨道上(假设跑步小人不在顶部轨道),如图 3 所示:

图 3. 不平稳的跳跃序列:容易实现但不自然
Snail Bait 跑步小人的颠簸跳跃序列截图,该跳跃是使用清单 2 中的最简单算法实现的。

清单 2 显示的跳跃实现有两个严重缺陷。首先,跑步小人从一个轨道移动到另一个轨道的方式(即时移动)远未达到预期效果。其次,跳跃实现发生在错误的抽象阶段。窗口事件处理程序没必要直接控制跑步者的属性;跑步小人本身要负责跳跃活动。


将跳跃责任移交给跑步小人

清单 3 显示了一个重构的窗口 onkeydown 事件处理程序实现,该实现比 清单 2 中的实现更简单,它将跳跃责任从事件处理程序移交给跑步小人。

清单 3. 窗口的关键处理程序委托给跑步小人
window.onkeydown = function (e) {
   var key = e.keyCode;
   ...
   
   if (key === 74) { // 'j'
      runner.jump();
   }
};

启动游戏时,Snail Bait 调用了一个方法 equipRunner(),如清单 4 所示:

清单 4. 在游戏启动时装备跑步小人
SnailBait.prototype = {
   ...
   start: function () {
      this.createSprites();
      this.initializeImages();
      this.equipRunner();
      this.splashToast('Good Luck!');
   },
};

如清单 5 所示,equipRunner() 方法为跑步小人添加了属性和 jump() 方法:

清单 5. 装备跑步小人:跑步小人的 jump() 方法
SnailBait.prototype = {
   equipRunner: function () {
      // This function sets runner attributes:

      this.runner.jumping = false; // 'this' is snailBait
      this.runner.track = this.INITIAL_RUNNER_TRACK;

      ... // More runner attributes omitted for brevity

      // This function also implements the runner's jump() method:

      this.runner.jump = function () {
         if ( ! this.jumping) {    // 'this' is the runner.
            this.jumping = true; // Start the jump
         } 
      };
   },
},

视图和控件

跑步小人的跳跃行为及其相应的 jump() 方法类似于一个视图/控件对。Snail Bait 在跑步小人跳跃时绘制跑步小人的方式是在行为中实现的,反之,跑步小人的 jump() 方法充当了一个简单控制器,控制跑步小人目前是否跳跃。

此外,跑步小人也有属性呈现其当前踪迹以及是否正在跳动。

如果跑步小人目前没有跳动,runner.jump() 将其 jumping 属性设置为 true。Snail Bait 在单独的行为对象中实现跳跃操作,就像实现跑步小人的其他行为一样,比如跑步和降落,事实上,所有 sprite 行为都是这样实现的。Snail Bait 在创建跑步小人时会将对象添加到其行为数组中,如清单 6 所示:

清单 6. 创建跑步小人及其行为
var SnailBait = function () {
   ...
   this.jumpBehavior = {
      execute: function(sprite, time, fps) {

         // Implement jumping here

      },
      ...
   };
   ...

   this.runner = new Sprite('runner',          // type
                            this.runnerArtist,  // artist
                            [ this.runBehavior, // behaviors
                              this.jumpBehavior,
                              this.fallBehavior
                            ]); 
   ...
};

现在基础架构已经为初始化跳跃活动做好准备,我可以将全部精力集中在跳跃行为上了。


跳跃行为

在清单 7 中,我们展示了一个跑步小人的初始实现,其功能与 清单 2 中的代码相同。如果跑步小人的 jumping 属性(通过跑步小人的 jump() 方法设置,参见 清单 5)是 false,那么该行为将不执行任何操作。如果跑步小人在最上面的轨道上,那么该行为也不执行任何操作。

清单 7. 一个不切实际的跳跃行为实现
var SnailBait =  function () {
   ...

   this.jumpBehavior = {
      ...

      execute: function(sprite, time, fps) {
         if ( ! sprite.jumping || sprite.track === 3) {
            return;
         }

         sprite.track++;

         sprite.top = snailBait.calculatePlatformTop(sprite.track) -
                      snailBait.RUNNER_CELLS_HEIGHT;

         sprite.jumping = false;
      } 
   },
   ...
};

死循环

回想一下,Snail Bait 实际上是一个死循环,不断执行每个可见 sprite 的所有行为。跑步小人的 jump() 方法只需将其 jumping 属性设置为 true 就可以启动跳跃行为。下一次,在 Snail Bait 执行跑步小人跳跃行为时,该设置将导致行为采取相应行动。

如果跑步小人正在跳动,而且不在顶部轨道上,那么 清单 7 中实现的跳跃行为会将它移动到下一个轨道上,并将其 jumping 属性设置为 false 来完成跳跃。

清单 2 中的跳跃实现一样,清单 7 中的实现立即将跑步小人从一个轨道移动到另一轨道。在实际的跳跃运动中,必须在特定时间内逐步将跑步小人从一个轨道移动到另一个轨道。


计时动画:秒表

迄今为止,在 Snail Bait 中实现的所有运动都是不变的;例如,游戏中的所有 sprite(跑步小人除外)都是在水平方向来回移动,纽扣和蜗牛在其平台上不断来回踱步。(参阅本系列第 2 篇文章中的 滚动背景 小节,了解这一动作是如何实现的。)硬币、蓝宝石和红宝石也可以慢慢上下跳动,甚至不需要停下来休息。

但是,跳跃行为不是固定不变的,它有一个明确的开始时间和结束时间,因此,要实现跳跃行为,需要采用一种方法不断监控跳跃已经开始了多长时间,因此我需要一个秒表。

清单 8 显示了 Stopwatch JavaScript 对象的实现:

清单 8. 一个 Stopwatch 对象
// Stopwatch..................................................................
//
// You can start and stop a stopwatch and you can find out the elapsed
// time the stopwatch has been running. After you stop a stopwatch,
// its getElapsedTime() method returns the elapsed time
// between the start and stop.

Stopwatch = function ()  {
   this.startTime = 0;
   this.running = false;
   this.elapsed = undefined;

   this.paused = false;
   this.startPause = 0;
   this.totalPausedTime = 0;
};

// You can get the elapsed time while the stopwatch is running, or after it's
// stopped.

Stopwatch.prototype = {
   start: function () {
      this.startTime = +new Date();
      this.running = true;
      this.totalPausedTime = 0;
      this.startPause = 0;
   },

   stop: function () {
      if (this.paused) {
         this.unpause();
      }
      
      this.elapsed = (+new Date()) - this.startTime -
                                     this.totalPausedTime;
      this.running = false;
   },

   pause: function () {
      this.startPause = +new Date(); 
      this.paused = true;
   },

   unpause: function () {
      if (!this.paused) {
         return;
      }

      this.totalPausedTime += (+new Date()) - this.startPause; 
      this.startPause = 0;
      this.paused = false;
   },
   
   getElapsedTime: function () {
      if (this.running) {
         return (+new Date()) - this.startTime - this.totalPausedTime;
      }
      else {
        return this.elapsed;

      }
   },


   isPaused: function() {
      return this.paused;
   },

   isRunning: function() {
      return this.running;
   },

   reset: function() {
     this.elapsed = 0;
     this.startTime = +new Date();
     this.running = false;
     this.totalPausedTime = 0;
     this.startPause = 0;
   }
};

您可以启动、停止、暂停、取消暂停和重置 清单 8 中的秒表对象。您还可以获取其运行时间,确定秒表是正在运行还是已经停了下来。

在本系列第 3 篇文章的 冻结游戏 一节中,我介绍了 如何通过计算游戏暂停时间,从游戏停止位置精确恢复某个暂停的游戏。就像游戏一样,暂停秒表必须从游戏停止的地方精确恢复,因此它们也需要考虑暂停时间。

秒表实现尽管比较简单,但意义重大,因为它使您实现了持续一段时间的行为,在本例中,该行为是更为自然的跳跃。


细化跳跃行为

现在已经有了秒表,让我们使用它来细化跳跃行为。首先,修改 清单 5 中的 equipRunner() 方法,如清单 9 所示:

清单 9. 修改后的equipRunner() 方法
SnailBait.prototype = {
   ...

   this.RUNNER_JUMP_HEIGHT = 120,    // pixels
   this.RUNNER_JUMP_DURATION = 1000, // milliseconds

   equipRunnerForJumping: function () {
      this.runner.JUMP_HEIGHT = this.RUNNER_JUMP_HEIGHT;
      this.runner.JUMP_DURATION = this.RUNNER_JUMP_DURATION;

      this.runner.jumping = false;


      this.runner.ascendStopwatch  = new Stopwatch(this.runner.JUMP_DURATION/2);
      this.runner.descendStopwatch = new Stopwatch(this.runner.JUMP_DURATION/2);

      this.runner.jump = function () {
         if (this.jumping) // 'this' is the runner
            return;

         this.jumping = true;
         this.runAnimationRate = 0; // Freeze the runner while jumping
         this.verticalLaunchPosition = this.top;
         this.ascendStopwatch.start();
      };
   },
  
   equipRunner: function () {
      ...

      this.equipRunnerForJumping();
   },
   ...
};

修改后的 equipRunner() 实现调用了一个新方法:equipRunnerForJumping()。顾名思义,该方法为跑步小人装备了跳跃行为。该方法创建了两个秒表:runner.ascendStopwatch 用于上升,runner.descendStopwatch 用于下落。

跳跃开始时,jump() 方法启动跑步小人的上升秒表,正如您在 清单 9 中所看到的。该方法还将跑步小人的跑步动画速度(通过其跑动动画定义了跑步小人的速度有多快)设置为零,让跑步小人停止跑步,此时跑步小人在空中。run() 方法还记录了跑步小人的垂直位置,以便在跳跃完成时让其返回原地。

表 1 对 清单 9 中设置的所有跑步者属性进行了总结:

表 1. 跑步小人与相关的属性
属性描述
JUMP_DURATION一个常量,表示跳跃持续的时间:1000 毫秒。
JUMP_HEIGHT一个常量,代表跳跃高度:120 像素。跳跃的最高点距下一个轨道 20 像素。
ascendStopwatch记录跑步小人在跳跃过程中所用的上升时间的秒表。
descendStopwatch记录跑步小人在跳跃过程中所用的下落时间的秒表。
jumpApex跑步小人跳跃的最高点;跳跃行为使用 apex 确定在下落过程中跑步小人在每个框架中所用的下落空间。
jumping一个标记,跑步小人跳跃时其值为 true
verticalLaunchPosition跳跃开始时,跑步小人的位置(跑步小人 sprite 的左上角)。完成跳跃后返回该位置。

接着,我将在清单 10 中重构最初在 清单 7 中实现的跳跃行为:

清单 10. 跳跃行为,重新修改
var SnailBait =  function () {
   this.jumpBehavior = {
      ...

      execute: function(sprite, context, time, fps) {
         if ( ! sprite.jumping) {
            return;
         }

         if (this.isJumpOver(sprite)) {
            sprite.jumping = false;
            return;
         }

         if (this.isAscending(sprite)) {
            if ( ! this.isDoneAscending(sprite)) this.ascend(sprite);
            else                               this.finishAscent(sprite);
         }
         else if (this.isDescending(sprite)) {
            if ( ! this.isDoneDescending(sprite)) this.descend(sprite); 
            else                                this.finishDescent(sprite);
         }
      } 
   },
   ...

清单 10 中的跳跃行为是一个高级别抽象的实现,它将跳跃细节留给另一个方法,比如 ascend()isDescending()。现在需要做的就是填充细节,通过使用跑步小人的上升和下降秒表来实现以下方法:

  • isJumpOver()
  • ascend()
  • isAscending()
  • isDoneAscending()
  • finishAscent()
  • descend()
  • isDescending()
  • isDoneDescending()
  • finishDescent()

直线运动

到目前为止,上面列出的方法都可以产生直线运动,这意味着跑步小人以恒定速率跳起或落下,如图 4 所示。

图 4. 平稳的直线跳跃序列
Snail Bait 跑步小人的平稳跳跃序列的截图

直线运动的结果是生成一个不自然的跳跃运动,当跑步者下落或跳起时,重力会使它们不断加速或减速。在下一期中,我将重新实现这些方法,使之产生非直线运动,如 图 1 所示。现在,继续介绍直线运动的简单案例。

首先,清单 11 展示了跳跃行为 isJumpOver() 方法的实现,无论运动是线性的还是非线性的,这个方法都是相同的:一旦结束跳跃,所有秒表都将不再运行:

清单 11. 确定跳跃是否结束
SnailBait.prototype = {
   this.jumpBehavior = {
      isJumpOver: function (sprite) {
         return !sprite.ascendStopwatch.isRunning() &&
                !sprite.descendStopwatch.isRunning();
      },
      ...
   },
   ...
};

处理上升运动的跳跃行为方法如清单 12 所示:

清单 12. 上升
SnailBait.prototype = {
   ...

   this.jumpBehavior = {
      isAscending: function (sprite) {
         return sprite.ascendStopwatch.isRunning();
      },

      ascend: function (sprite) {
         var elapsed = sprite.ascendStopwatch.getElapsedTime(),
             deltaY  = elapsed / (sprite.JUMP_DURATION/2) * sprite.JUMP_HEIGHT;

         sprite.top = sprite.verticalLaunchPosition - deltaY; // Moving up
      },

      isDoneAscending: function (sprite) {
         return sprite.ascendStopwatch.getElapsedTime() > sprite.JUMP_DURATION/2;
      },
      
      finishAscent: function (sprite) {
         sprite.jumpApex = sprite.top;
         sprite.ascendStopwatch.stop();
         sprite.descendStopwatch.start();
      }
   },
   ...
};

表 2 对 清单 12 中的方法进行了总结:

表 2. jumpBehavior 的上升方法
方法描述
isAscending()如果跑步小人的上升秒表正在运行,则返回 true
ascend()根据上一个动画帧的运行时间、跳跃持续时间和跳跃高度,向上 移动跑步小人。
isDoneAscending()返回 true,如果跑步小人上升秒表中的运行时间大于跳跃持续时间的一半。
finishAscent()

通过停止跑步小人的上升秒表来结束上升运动,并启动下降秒表。

当跑步小人处于跳跃最高点时,jumpBehavior 会调用该方法,finishAscent() 在跑步小人的 jumpApex 属性中存储其位置。descend() 方法将会使用该属性。

回忆一下跑步小人的 jump() 方法,如 清单 9 所示,启动跑步小人的上升秒表。 随后,运行的秒表将导致跳跃行为的 isAscending() 方法临时返回 true。当跑步小人完成上升过程时(这意味着跳跃已完成了一半),跑步小人的跳跃行为会重复调用 ascend() 方法,正如您在 清单 10 中所看到的。

上升和下降

ascend() 方法使跑步小人逐渐向上移动。它可以计算像素来移动每个动画帧中的跑步小人:秒表的运行时间(毫秒)除以跳跃时间(毫秒)的一半,然后乘以跳跃高度(像素)。毫秒相互抵消,生成的像素值作为 deltaY 值的测量单元。因此,该值表示当前动画帧中跑步小人在垂直方向移动的像素。

在跑步小人完成上升过程后,跳跃行为的 finishAscent() 方法会记录跳跃到顶点时 sprite 的位置,然后停止上升秒表,并启动下降秒表。

与下降运动相关的跳跃行为方法如清单 13 所示:

清单 13. 下降运动
SnailBait.prototype = {
   this.jumpBehavior = {
      isDescending: function (sprite) {
         return sprite.descendStopwatch.isRunning();
      },

      descend: function (sprite, verticalVelocity, fps) {
         var elapsed = sprite.descendStopwatch.getElapsedTime(),
             deltaY  = elapsed / (sprite.JUMP_DURATION/2) * sprite.JUMP_HEIGHT;

         sprite.top = sprite.jumpApex + deltaY; // Moving down
      },
      
      isDoneDescending: function (sprite) {
         return sprite.descendStopwatch.getElapsedTime() > sprite.JUMP_DURATION/2;
      },

      finishDescent: function (sprite) {
         sprite.top = sprite.verticalLaunchPosition;
         sprite.descendStopwatch.stop();
         sprite.jumping = false;
         sprite.runAnimationRate = snailBait.RUN_ANIMATION_RATE;
      }
   },
   ...
};

表 3 对 清单 13 中的方法进行了总结:

表 3. jumpBehavior 的下降方法
isDescending()如果跑步小人下降秒表正在运行,则返回 true
descend()根据上一动画帧的运行时间、跳跃持续时间和跳跃高度,向下 移动跑步小人。
isDoneDescending()如果跑步小人下落到跳跃之前的位置,则返回 true
finishDescent()

通过停止跑步小人的下降秒表,并将跑步小人的 jumping 标记分别设置为 false,停止下降运动,然后跳起。

完成下降运动后,跑步小人可能不在跳跃开始的那个位置,因此 finishDescent() 将跑步小人的位置设置为起跳之前的垂直位置。

最后,finishDescent() 将跑步小人的动画速度设置为正常值,跑步小人开始跑步。

清单 12 中的上升方法和 清单 13 中的下降方法具有对称性。ascend()descend() 将计算像素值,采用相同的方法沿着当前帧的垂直方向移动跑步小人。不过,descend() 方法会将该值添加到跳跃顶点,反之,从起跳位置减去该值。(回忆一下画布 Y 轴自上而下的增加。)

完成跳跃的下降过程后,finishDescent() 将跑步小人放回到起跳之前的相同垂直位置,然后重新开始跑步动画。


结束语

在本系列的下一篇文章中,我将向您展示如何实现非线性运动,形成 图 1 中所示的实际跳跃运动。顺便我还向您介绍如何延伸时间,时间的其他衍生(比如颜色变化)可以产生非线性效果。下期见!


下载

描述名字大小
样例代码j-html5-game6.zip1.2MB

参考资料

学习

获得产品和技术

  • Replica Island:您可以下载这个面向 Android 的流行开源平台视频游戏的资源。大多数 Snail Bait sprite 都来自 Replica Island(在许可的情况下使用)。

讨论

  • 加入 developerWorks 社区。探索由开发人员推动的博客、论坛、组和维基,并与其他 developerWorks 用户进行交流。

条评论

developerWorks: 登录

标有星(*)号的字段是必填字段。


需要一个 IBM ID?
忘记 IBM ID?


忘记密码?
更改您的密码

单击提交则表示您同意developerWorks 的条款和条件。 查看条款和条件

 


在您首次登录 developerWorks 时,会为您创建一份个人概要。您的个人概要中的信息(您的姓名、国家/地区,以及公司名称)是公开显示的,而且会随着您发布的任何内容一起显示,除非您选择隐藏您的公司名称。您可以随时更新您的 IBM 帐户。

所有提交的信息确保安全。

选择您的昵称



当您初次登录到 developerWorks 时,将会为您创建一份概要信息,您需要指定一个昵称。您的昵称将和您在 developerWorks 发布的内容显示在一起。

昵称长度在 3 至 31 个字符之间。 您的昵称在 developerWorks 社区中必须是唯一的,并且出于隐私保护的原因,不能是您的电子邮件地址。

标有星(*)号的字段是必填字段。

(昵称长度在 3 至 31 个字符之间)

单击提交则表示您同意developerWorks 的条款和条件。 查看条款和条件.

 


所有提交的信息确保安全。


static.content.url=http://www.ibm.com/developerworks/js/artrating/
SITE_ID=10
Zone=Java technology, Web development
ArticleID=937288
ArticleTitle=HTML5 2D 游戏开发: 操纵时间,第 1 部分
publish-date=07152013