HTML5 2D 游戏开发: 图形和动画

http://www.ibm.com/developerworks/cn/java/j-html5-game2/

HTML5 2D 游戏开发: 图形和动画

将事物绘制到画布上并让其运动起来

David Geary, 总裁, Clarity Training, Inc.
David Geary 的照片
David Geary 是一名作家、演讲家和顾问,也是 Clarity Training, Inc. 的总裁,他指导开发人员使用 JSF 和 Google Web Toolkit (GWT) 实现 Web 应用程序。他是 JSTL 1.0 和 JSF 1.0/2.0 专家组的成员,与人合作编写了 Sun 的 Web Developer 认证考试的内容,并且为多个开源项目作出贡献,包括 Apache Struts 和 Apache Shale。David 的 Graphic Java Swing 一直是关于 Java 的畅销书籍,而 Core JSF(与 Cay Horstman 合著)是关于 JSF 的畅销书。他还是 GWT Solutions 一书的作者。David 经常在各大会议和用户组发表演讲。他从 2003 年开始就一直是 NFJS tour 的定期演讲人,并且在 Java University 教授课程,三次当选为 JavaOne 之星。

简介: 在本系列文章中,HTML5 专家 David Geary 将告诉您如何一步一个脚印地实现 HTML5 的 2D 视频游戏。本期文章将介绍 Canvas 图形和 HTML5 动画。您将了解如何绘制游戏的图形,以及如何让它们运动起来。您还将学习利用 HTML5 实现动画的最佳方式,如何滚动背景,以及如何实现视差,从而模拟三维效果。

查看本系列更多内容

发布日期: 2012 年 11 月 07 日
级别: 中级 原创语言: 英文
访问情况 : 7630 次浏览
评论: 0 (查看 | 添加评论 - 登录)

平均分 5 星 共 10 个评分 平均分 (10个评分)
为本文评分

图形和动画是任何视频游戏最根本的方面,所以在本文中,我将从 Canvas2D API 的简要介绍开始,对 Snail Bait 的中央动画的实现进行讨论。在本文中,您将学习如何:

  • 将图像和图形基元绘制到画布上
  • 创建流畅的、无闪烁的动画
  • 实现游戏循环
  • 以帧数每秒为单位监视动画的速度
  • 滚动游戏的背景
  • 使用视差来模拟三维效果
  • 实现基于时间的运动

本文中所讨论的代码的最终结果如图 1 所示:


图 1. 滚动背景并监视帧速率
滚动 Snail Bait 的背景并监视帧速率

背景和平台水平滚动。这些平台在前景中,所以它们的移动明显快于背景,这样会形成一个温和的视差效果。在游戏开始时,背景由右至左滚动。在结束某个级别时,背景和平台开始逆转方向。

在开发的这个阶段,跑步者不动。此外,游戏还没有经过碰撞检测,所以当跑步者的下面没有平台时,她会漂浮在半空中。

最后,游戏画布的上方和左侧的图标会显示剩余生命的数量(如 本系列第一篇文章中的图 1 所示)。目前,该游戏会在这个位置上显示当前动画速度(以帧数每秒为单位)。

即时模式图形

Canvas 是一个即时模式 图形系统,这意味着它会即时绘制您指定的内容,然后即时忘记。可伸缩矢量图形 (Scalable Vector Graphics, SVG) 等其他图形系统实现了保留模式 图形,这意味着它们会保存一个将要绘制的对象的列表。由于不会因保存显示列表而产生开销,所以 Canvas 的速度比 SVG 更快一些;但是,如果您想保存一个用户可以操作的对象列表,则必须自己在 Canvas 中实现该功能。

在继续后面的操作之前,您可能想尝试创建一个这类游戏,因为它就在 图 1 里;如果您创建了这样的游戏,就会更容易理解相关的代码。(请参阅 下载,获得本期的 Snail Bait 实现。)

HTML5 Canvas 概述

Canvas 2D 上下文提供了一个广泛的图形 API,让您可以在平台视频游戏中实现文本编辑器中的一切。在我撰写这篇文章的时候,该 API 包含了超过 30 个方法,但 Snail Bait 只使用了其中的极少数,如表 1 所示:


表 1. Snail Bait 使用的 Canvas 2D 上下文方法
方法描述
drawImage()您可以在画布的某个特定位置上绘制全部或部分图像,也可以绘制另一个画布或来自 video 元素的一个帧。
save()在堆栈上保存上下文属性。
restore()将上下文属性移出堆栈,并将它们应用于上下文。
strokeRect()绘制一个未填充的矩形。
fillRect()填充一个矩形。
translate()平移坐标系。这是一个很强大的方法,在许多不同场景中都很有用。Snail Bait 中的所有滚动都是利用这一个方法调用来实现的。

基于路径的图形

与 Apple 的 Cocoa 和 Adobe 的 Illustrator 类似,Canvas API 也是基于路径的,这意味着您可以先创建一条路径,然后描画或填充这条路径,在画布上绘制图形基元。strokeRect()fillRect() 方法分别是描画或填充矩形的便捷方法。

除平台之外,Snail Bait 中的所有内容都是一个图像。背景、跑步者以及所有好人和坏人都是游戏使用 drawImage() 方法绘制的图像。

最终,Snail Bait 将使用 spritesheet(单个图像包含游戏的所有图形),但现在,我对背景和跑步者分别使用不同的图像。我使用 清单 1 所示的函数绘制跑步者:


清单 1. 绘制跑步者
				
function drawRunner() {
   context.drawImage(runnerImage,                                        // image
                     STARTING_RUNNER_LEFT,                               // canvas left
                     calculatePlatformTop(runnerTrack) - RUNNER_HEIGHT); // canvas top
}

drawRunner() 函数将三个参数传递给了 drawImage():一个图像、左侧坐标和顶部坐标,将在画布的这个位置上绘制图像。左侧坐标是一个常数,而顶部坐标由跑步者所驻留的平台决定。

我以类似的方式绘制背景,如清单 2 所示:


清单 2. 绘制背景
				
function drawBackground() {
   context.drawImage(background, 0, 0);
}

多用途的 drawImage() 方法

您可以使用 Canvas 2D 上下文的 drawImage() 方法在画布内的任何地方绘制一个完整的图像,或图像内的任何矩形区域,有选择地沿着路线缩放图像。除了图像外,您还可以利用 drawImage() 绘制另一个画布或一个 video 元素当前帧的内容。这只是其中一个方法,但 drawImage() 还有助于便利地实现有趣的或者难以实现的应用程序(如视频编辑软件)。

清单 2 中的 drawBackground() 函数在画布的 (0,0) 绘制背景图像。稍后,我会在本文中修改该函数,以便滚动背景。

绘制平台(它们不是图像)需要更广泛地使用 Canvas API,如清单 3 所示:


清单 3. 绘制平台
				
var platformData = [
    // Screen 1.......................................................
    {
       left:      10,
       width:     230,
       height:    PLATFORM_HEIGHT,
       fillStyle: 'rgb(150,190,255)',
       opacity:   1.0,
       track:     1,
       pulsate:   false,
    },
    ...
 ],
 ...

function drawPlatforms() {
   var data, top;

   context.save(); // Save the current state of the context

   context.translate(-platformOffset, 0); // Translate the coord system for all platforms
   
   for (var i=0; i < platformData.length; ++i) {
      data = platformData[i];
      top = calculatePlatformTop(data.track);

      context.lineWidth   = PLATFORM_STROKE_WIDTH;
      context.strokeStyle = PLATFORM_STROKE_STYLE;
      context.fillStyle   = data.fillStyle;
      context.globalAlpha = data.opacity;

      context.strokeRect(data.left, top, data.width, data.height);
      context.fillRect  (data.left, top, data.width, data.height);
   }

   context.restore(); // Restore context state saved above
}

清单 3 中的 JavaScript 定义一个名称为 platformData 的数组。该数组中的每个对象代表着描述一个独立平台的元数据。

drawPlatforms() 函数使用 Canvas 上下文的 strokeRect() fillRect() 方法来绘制平台矩形。这些矩形的特征存储在 platformData 数组内的对象中,用于设置上下文的填充风格和 globalAlpha 属性,该属性设置您之后在画布上绘制的任何图形的不透明度。

调用 context.translate() 将画布的坐标系(如图 2 所示)在水平方向平移指定数量的像素。该平移和属性设置是临时的,因为这些操作是在 context.save()context.restore() 调用之间执行的。


图 2. 默认的 Canvas 坐标系
默认的 Canvas 坐标系

默认情况下,坐标系的原点位于画布的左上角。您可以使用 context.translate() 移动坐标系的原点。

我会在 滚动背景 中讨论如何使用 context.translate() 滚动背景。但现在,您几乎已经知道了实现 Snail Bait 需要了解的与 HTML5 Canvas 有关的一切内容。在本系列的其余部分中,我将侧重于 HTML5 游戏开发的其他方面,从动画开始。


HTML5 动画

从根本上讲,实现动画很简单:您反复绘制一个图像序列,看起来就象对象在以某种方式运动。这意味着您必须实现一个定期绘制图像的循环。

传统上,会使用 setTimeout() 或如清单 4 所示的 setInterval() 在 JavaScript 中实现动画循环:


清单 4. 使用 setInterval() 实现动画
				
setInterval( function (e) { // Don't do this for time-critical animations
   animate();               // A function that draws the current animation frame
}, 1000 / 60);              // Approximately 60 frames/second (fps)

最佳实践

对于时间要求苛刻的动画,永远不要使用 setTimeout()setInterval()

毫无疑问,清单 4 中的代码通过反复调用一个绘制下一个动画帧的 animate() 函数来生成一个动画;然而,您可能会得到不满意的结果,因为 setInterval() setTimeout() 完全不知道如何制作动画。(注:您必须实现 animate() 函数;它不属于 Canvas API。)

清单 4 中,我将时间间隔设置为 1000/60 毫秒,这相当于大约每秒 60 帧。这个数字是我对最佳帧速率的最佳估值,它可能不是一个很好的值,但是,因为 setInterval()setTimeout() 完全不了解动画,所以由我指定帧速率。浏览器肯定比我更了解何时绘制下一个动画帧,因此,如果改为由浏览器指定帧速率,会产生更好的结果。

使用 setTimeoutsetInterval() 甚至有一个更严重的缺陷。虽然您传递以毫秒为单位指定的这些方法的时间间隔,但这些方法没有精确到毫秒;事实上,根据 HTML 规范,这些方法(为了节约资源)慷慨地拉长您指定的时间间隔。

为了避免这些缺陷,对于时间要求苛刻的动画,不应使用 setTimeout()setInterval();而是应该使用 requestAnimationFrame()

requestAnimationFrame()

Timing control for script-based animations 规范(请参阅 参考资料)中,W3C 在 window 对象上定义了一个名称为 requestAnimationFrame() 的方法。与 setTimeout()setInterval() 不同,requestAnimationFrame() 是专门用于实现动画的。因此,它不会具有与 setTimeout()setInterval() 有关的任何缺点。而且它简单易用,如 清单 5 所示:


清单 5. 使用 requestAnimationFrame() 实现动画
				
function animate(time) {           // Animation loop
   draw(time);                     // A function that draws the current animation frame
   requestAnimationFrame(animate); // Keep the animation going
};

requestAnimationFrame(animate);    // Start the animation

您可以将 requestAnimationFrame() 作为一个参考传递给回调函数,当浏览器准备好绘制下一个动画帧时,它就会调用这个回调函数。为了维持动画,回调函数还会调用 requestAnimationFrame()

正如您在 清单 5 中所见,浏览器会将一个 time 参数传递给您的回调函数。您可能会疑惑该 time 参数究竟有何意义。它是当前时间,还是浏览器绘制下一个动画帧的时间?

令人惊讶的是,这个时间并没有固定的定义。您惟一可以肯定的是,对于任何给定的浏览器,它试着代表着同样的事情;因此,您可以使用它来计算两帧之间的时间间隔,我会在 以 fps 计算动画速率 中说明这一点。

一个 requestAnimationFrame() polyfill

从许多方面来看,HTML5 是程序员的乌托邦。没有专用的 API,开发人员使用 HTML5 在无处不在的浏览器中实现跨平台运行的应用程序。规范发展迅速,不断采用新技术,同时改进现有的功能。

Polyfill:面向未来的编程

过去,大多数跨平台软件都在最低的共同点实现。Polyfill 彻底颠覆了这一概念,它让您能够访问高级特性(如果它们可用),并在必要时回退到一个能力较低的实现。

然而,新技术要实行规范,往往是通过特定浏览器现有的功能来实现的。浏览器厂商通常为这样的功能添加了前缀,使它们不会干扰其他浏览器的实现;例如,requestAnimationFrame() 最初被 Mozilla 实现为 mozRequestAnimationFrame()。然后 WebKit 实现了它,将其函数命名为 webkitRequestAnimationFrame()。最后,W3C 将它标准化为 requestAnimationFrame()

供应商提供了对前缀实现以及标准实现的不同支持,这使得新功能的使用变得非常棘手,所以 HTML5 社区发明了一种被称为 polyfill 的东西。Polyfill 针对特定功性确定浏览器的支持级别,如果浏览器已经实现了该功能,您就可以直接访问它,否则,浏览器会向您提供一个暂时尽量模仿标准功能的实现。

Polyfill 易于使用,但实现起来可能比较复杂。清单 6 演示了 requestAnimationFrame() 的一个 polyfill 的实现:


Listing 6. requestNextAnimationFrame() polyfill
				
// Reprinted from Core HTML5 Canvas

window.requestNextAnimationFrame =
   (function () {
      var originalWebkitRequestAnimationFrame = undefined,
          wrapper = undefined,
          callback = undefined,
          geckoVersion = 0,
          userAgent = navigator.userAgent,
          index = 0,
          self = this;

      // Workaround for Chrome 10 bug where Chrome
      // does not pass the time to the animation function
      
      if (window.webkitRequestAnimationFrame) {
         // Define the wrapper

         wrapper = function (time) {
           if (time === undefined) {
              time = +new Date();
           }
           self.callback(time);
         };

         // Make the switch
          
         originalWebkitRequestAnimationFrame = window.webkitRequestAnimationFrame;    

         window.webkitRequestAnimationFrame = function (callback, element) {
            self.callback = callback;

            // Browser calls the wrapper and wrapper calls the callback
            
            originalWebkitRequestAnimationFrame(wrapper, element);
         }
      }

      // Workaround for Gecko 2.0, which has a bug in
      // mozRequestAnimationFrame() that restricts animations
      // to 30-40 fps.

      if (window.mozRequestAnimationFrame) {
         // Check the Gecko version. Gecko is used by browsers
         // other than Firefox. Gecko 2.0 corresponds to
         // Firefox 4.0.
         
         index = userAgent.indexOf('rv:');

         if (userAgent.indexOf('Gecko') != -1) {
            geckoVersion = userAgent.substr(index + 3, 3);

            if (geckoVersion === '2.0') {
               // Forces the return statement to fall through
               // to the setTimeout() function.

               window.mozRequestAnimationFrame = undefined;
            }
         }
      }
      
      return window.requestAnimationFrame   ||
         window.webkitRequestAnimationFrame ||
         window.mozRequestAnimationFrame    ||
         window.oRequestAnimationFrame      ||
         window.msRequestAnimationFrame     ||

         function (callback, element) {
            var start,
                finish;


            window.setTimeout( function () {
               start = +new Date();
               callback(start);
               finish = +new Date();

               self.timeout = 1000 / 60 - (finish - start);

            }, self.timeout);
         };
      }
   )
();

Polyfill:定义

单词 polyfillpolymorphism(多态)和 backfill(回填)的混合。类似于多态,polyfill 在运行时选择适当的代码,并且它们回填 (backfill) 了缺失的功能。

清单 6 中实现的 polyfill 为 window 对象添加了一个名为 requestNextAnimationFrame() 的函数。函数名称中包含的 Next 使其能够区别于底层的 requestAnimationFrame() 函数。

该 polyfill 分配给 requestNextAnimationFrame() 的函数要么是 requestAnimationFrame()(如果浏览器支持它),要么是一个厂商前缀实现。如果浏览器对这两种方式均不支持,那么该函数会使用 setTimeout() 作为临时实现,以便尽可能地模仿 requestAnimationFrame()

几乎所有 polyfill 复杂性都涉及解决两个错误并在 return 语句前构成代码。第一个错误涉及 Chrome 10,它为时间传递一个 undefined 值。第二个错误涉及 Firefox 4.0,它将帧速率限制为每秒 35-40 帧。

虽然 requestNextAnimationFrame() polyfill 的实现很有趣,但不必理解它;相反,您只需要了解如何使用它即可,我会在下一节说明这一点。


游戏循环

既然图形和动画的先决条件已经得到满足,那么现在是时候让 Snail Bait 动起来了。首先,我在游戏的 HTML 中让 requestNextAnimationFrame() 包含 JavaScript,如清单 7 所示:


清单 7. HTML
				
<html>
   ...

   <body>
      ...

      <script src='js/requestNextAnimationFrame.js'></script>
      <script src='game.js'></script>
   </body>
</html>

清单 8 显示了游戏的动画循环,一般将该循环称为游戏循环


清单 8. 游戏循环
				
var fps;

function animate(now) { 
   fps = calculateFps(now); 
   draw();
   requestNextAnimationFrame(animate);
} 
          
function startGame() {
   requestNextAnimationFrame(animate);
}

startGame() 函数由背景图像的 onload 事件处理器调用,该函数通过调用 requestNextAnimationFrame() polyfill 启动游戏。在绘制游戏的第一个动画帧时,浏览器会调用 animate() 函数。

animate() 函数根据当前时间计算动画的帧速率。(参见 requestAnimationFrame(),了解有关 time 值的更多信息。)在计算帧速率之后,animate() 会调用一个 draw() 函数来绘制下一个动画帧。然后,animate() 调用 requestNextAnimationFrame() 来保持动画。

以 fps 计算动画速率

清单 9 显示了 Snail Bait 如何计算其帧速率,以及如何更新在 图 1 中显示的帧速率值:


清单 9. 计算 fps 并更新 fps 元素
				
var lastAnimationFrameTime = 0,
    lastFpsUpdateTime = 0,
    fpsElement = document.getElementById('fps');

function calculateFps(now) {
   var fps = 1000 / (now - lastAnimationFrameTime);
   lastAnimationFrameTime = now;

   if (now - lastFpsUpdateTime > 1000) {
      lastFpsUpdateTime = now;
      fpsElement.innerHTML = fps.toFixed(0) + ' fps';
   }

   return fps; 
}

帧速率只是自上一个动画帧开始计算的时间量,所以您也可以认为它是 frame per second(帧每秒)而不是 frames per second(每秒的帧数),这使得它不太像是一个速率。您可以采用更严格的方法,在几个帧中保持平均帧速率,但我还没有发现这样做的必要性,事实上,自最后一个动画帧起所用的时间就正是我在 基于时间的运动 中所需要的。

清单 9 还演示了一个重要的动画技术:执行任务的速率不同于动画速率。如果我在每一个动画帧都更新帧/秒值,则无法读取速率,因为它总是在不断变化;我将该设置改为每秒更新一次。

设置好了游戏循环和帧速率之后,我现在就准备开始滚动背景了。


滚动背景

Snail Bait 的背景(如图 3 所示)在水平方向缓慢滚动:


图 3. 背景图像
snail bait 背景图像

因为背景的左右边缘是完全相同的,所以背景可以无缝地滚动,如图 4 所示:


图 4. 完全相同的边缘实现平滑的过渡(左:右边缘;右:左边缘)
背景图像完全相同的边缘

Snail Bait 通过绘制两次背景,使背景无休止地滚动,如图 5 所示。最初,如图 5 的顶部截屏所示,左侧的背景图像完全在屏幕上,而右侧的背景图像则完全在屏幕外。随着时间的推移,背景开始滚动,如图 5 的中部和底部截屏所示:


图 5. 从右侧滚动到左侧:半透明区域代表在屏幕外的图像部分
从右到左滚动 Snail Bait 的背景

清单 10 显示了与 图 5 有关联的代码。drawBackground() 函数绘制两次图像,试着在同一位置上进行绘制。明显的滚动由不断将画布坐标系统平移到左侧而显示的,使得背景看似滚动到了右侧。

(您如何理解平移到左侧,但滚动到右侧的明显矛盾:将画布想象为在一张很长的纸上的一个空图片帧。这张纸就是坐标系,将它向左侧平移,就像将它在帧[画布]下面向左侧滑动左侧一样,因此,画布看起来就移动到右侧。)


清单 10. 滚动背景
				
var backgroundOffset; // This is set before calling drawBackground()

function drawBackground() {
   context.translate(-backgroundOffset, 0);

   // Initially onscreen:
   context.drawImage(background, 0, 0);

   // Initially offscreen:
   context.drawImage(background, background.width, 0);

   context.translate(backgroundOffset, 0);
}

setBackground() 函数在水平方向平移画布上下文 -backgroundOffset 像素。如果 backgroundOffset 是正数,那么背景会向右侧滚动;如果它是负数,那么背景会向左侧滚动。

在平移背景之后,drawBackground() 绘制了两次背景,然后将上下文平移回它在调用 drawBackground() 之前的位置。

一个看似琐碎的计算仍然保留:计算 backgroundOffset,这决定了为每个动画帧将画布的坐标系统平移多远。虽然该计算本身确实是琐碎的,但它具有重要的意义,所以我接下来将会讨论它。


基于时间的运动

动画的帧速率各不相同,但您不能让不同的帧速率影响您的动画运行速率。例如,无论动画的底层帧速率是多少,Snail Bait 都以 42 像素/秒的速度滚动背景。动画必须是基于时间的,这意味着速度以像素/秒指定,并且一定不能依赖于帧速率。

使用基于时间的运动来计算给定帧中移动某个对象的像素数,这很简单:用速度除以当前帧速率。速度(像素/秒)除以帧速率(帧/秒),结果是像素/帧,这意味着您在当前帧中需要将某个东西移动该数量的像素。

最佳实践

动画速度必须与帧速率无关。

清单 11 显示了 Snail Bait 如何使用基于时间的运动来计算背景的位移:


清单 11. 设置背景位移
				
var BACKGROUND_VELOCITY = 42, // pixels / second
    bgVelocity = BACKGROUND_VELOCITY;

function setBackgroundOffset() {
   var offset = backgroundOffset + bgVelocity/fps; // Time-based motion

   if (offset > 0 && offset < background.width) {
      backgroundOffset = offset;
   }
   else {
      backgroundOffset = 0;
   }
}

setBackgroundOffset() 函数计算在当前帧中背景需移动的像素数,用背景的速度除以当前帧速率来计算它。然后将该值加到当前背景的位移。

为了持续滚动背景,setBackgroundOffset() 在该值小于 0 或大于背景宽度时将背景位移重置为 0


视差

如果您曾经坐在行驶中的汽车的乘客座位上,看着您的手刀穿过高速的电线杆,你就知道靠近自己的的东西的移动速度比距离远的东西更快。这就是所谓的 视差

Snail Bait 是一个 2D 游戏平台,但它使用温和的视差效果,使平台看起来仿佛比背景更接近您。该游戏通过滚动平台的速度明显快于后台而实现视差。

图 6 演示了 Snail Bait 如何实现该视差。上面的截屏显示了在一个特定时间点上的背景,而底部的截屏显示了一些动画帧后面的背景。从这两个截屏可以看出,在相同的时间长度中,平台的移动比背景远得多。


图 6. 视差:平台(近)滚动得比背景(远)更快
视差

清单 12 显示了设置平台速度和位移的函数:


清单 12. 设置平台速度和位移
				
var PLATFORM_VELOCITY_MULTIPLIER = 4.35; 

function setPlatformVelocity() {
   // Platforms move 4.35 times as fast as the background
   platformVelocity = bgVelocity * PLATFORM_VELOCITY_MULTIPLIER; 
}

function setPlatformOffset() {
   platformOffset += platformVelocity/fps; // Time-based motion
}

回忆一下 清单 8,它列出了 Snail Bait 的游戏循环。该循环包括一个 animate() 函数,在需要绘制游戏的下一个动画帧时,浏览器会调用该函数。然后,该 animate() 函数调用一个 draw() 函数来绘制下一个动画帧。位于开发阶段中的 draw() 函数的代码如清单 13 所示:


清单 13. draw() 函数
				
function setOffsets() {
   setBackgroundOffset();
   setPlatformOffset();
}

function draw() {
   setPlatformVelocity();
   setOffsets();

   drawBackground();

   drawRunner();
   drawPlatforms();
}

draw() 函数设置了平台速度,并为背景和平台设置了位移。然后,它绘制背景、跑步者和平台。


结束语

在下期文章中,我会告诉您如何将 Snail Bait 代码封装在一个 JavaScript 对象中,以避免产生名称空间的冲突。我还将告诉您如何暂停游戏,包括如何在窗口失去焦点时自动暂停,以及如何在窗口重新获得焦点时通过倒计时重新启动游戏。您还可以了解如何用键盘控制该游戏的跑步者。接下来,我们将学习如何将 CSS 过渡和插话功能用于游戏循环。下次再见。


  • 0
    点赞
  • 2
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值