通过创建有趣的游戏学习-HTML5-全-

通过创建有趣的游戏学习 HTML5(全)

原文:zh.annas-archive.org/md5/0598834ED79056F95FE4B258BB7FBDFD

译者:飞龙

协议:CC BY-NC-SA 4.0

前言

如果您想编写一款可以触及全球数十亿人的软件,那么这本书将帮助您开始这段旅程。如今,人们每天使用的大多数设备(计算机,笔记本电脑,平板电脑,智能手机等)都能够运行 HTML5 代码。而且,随着现代 Web 浏览器继续变得越来越强大,您基于 HTML5 的游戏和应用程序可以以本机应用程序性能水平或非常接近本机应用程序性能水平运行。

本书将帮助您了解 HTML5 的所有内容,包括语义标记元素,CSS3 样式和最新的支持 JavaScript API。有了这些知识和技能,我们将能够创建可以由连接到互联网的设备上的任何人玩的有趣游戏。

本书内容

第一章,HTML5 概述,解释了 HTML5 是什么,以及它如何适应开放 Web 平台范例。它还介绍了 HTML5 的三大支柱,即新的 HTML 元素,CSS3 和新的 JavaScript API。

第二章,HTML5 排版,介绍了本书中的第一个游戏,即基于 DOM 的排版游戏。本章描述的主要 HTML5 功能包括 Web 表单,元数据,Web 字体,过渡,动画,文本阴影,框阴影,window.JSON 和 querySelector。

第三章,理解 HTML5 的引力,构建了一个基本的果冻摇摆引力游戏。本章包括跨浏览器支持,polyfill 的讨论,以及如何解决不同浏览器之间的 API 实现差异。本章描述的主要 HTML5 功能包括 Web 音频,SVG 图形和拖放。

第四章,使用 HTML5 捕捉蛇,使用新的 HTML5 画布元素创建了一个传统的贪吃蛇游戏,以及其伴随的 2D 渲染上下文。本章描述的其他 HTML5 功能包括 Web Workers,离线存储和 RequestAnimationFrame。

第五章,改进贪吃蛇游戏,在上一章中创建的相同游戏基础上添加了窗口消息传递,Web 存储,本地存储,会话存储和 IndexedDB 等功能。

第六章,为您的游戏添加功能,重点讨论了高级 HTML5 概念,以及最新功能。尽管本章没有构建游戏,但所描述的 JavaScript 和 CSS API 代表了 HTML5 和 Web 开发的最新技术。本章描述的主要功能包括 WebGL,Web 套接字,视频,地理位置,CSS 着色器,CSS 列和 CSS 区域和排除。

第七章,HTML5 和移动游戏开发,通过构建一个完全针对移动游戏玩法进行优化的 2D 太空射击游戏来结束本书。本章的重点是 Web 开发中移动特定的考虑因素,包括讨论桌面和移动平台之间的差异。本章描述的主要 HTML5 功能包括媒体查询和触摸事件。

设置环境,介绍了本地 Web 开发环境的设置,包括安装开源 Apache 服务器。除了设置开发环境外,它还演示了如何使用新的 HTML5 元素构建 Web 门户,通过该门户我们可以访问本书中开发的游戏。该章节可在线获取:www.packtpub.com/sites/default/files/downloads/Setting_up_the_Environment.pdf

本书所需内容

您需要最新版本的现代网络浏览器,目前包括 Google Chrome,Mozilla Firefox,Safari,Opera 和 Internet Explorer(至少版本 10)。您还需要选择的基本文本编辑器,尽管您熟悉的任何代码编辑软件也可以。具有 HTML、CSS 和 JavaScript 的先验知识或经验是有帮助的,但不是必需的。

这本书是为谁写的

这本书主要是为有游戏开发经验的开发人员编写的,他们现在正在转向 HTML5。本书的重点不是游戏开发的复杂性和理论,而是帮助读者学习 HTML5,以及开放网络平台如何成为触达全球数十亿用户的手段。

约定

在本书中,您会发现一些文本样式,用于区分不同类型的信息。以下是一些这些样式的示例,以及它们的含义解释。

文本中的代码单词显示如下:“我们可以通过使用include指令来包含其他上下文。”

代码块设置如下:

[<div id="wrapper">
  <div id="header"></div>
  <div id="body">
    <div id="main_content">
      <p>Lorem Ipsum...</p>
    </div>
    <div id="sidebar"></div>
  </div>
  <div id="footer"></div>
</div>

当我们希望引起您对代码块的特定部分的注意时,相关的行或项目会以粗体显示:

<input type="text" name="firstName" value="First Name" class="hint-on"
 onblur="if (this.value == '') {

新术语重要单词以粗体显示。例如,屏幕上看到的单词,菜单或对话框中的单词会在文本中显示为:“点击下一步按钮会将您移动到下一个屏幕”。

注意

警告或重要提示会以这样的方式出现在框中。

提示

提示和技巧会以这样的方式出现。

第一章:HTML5 概述

欢迎来到迷人的网络开发世界!在我们开始 HTML5 开发的旅程时,我们将花时间回顾过去。毕竟,除非你知道自己来自哪里,否则你真的无法到达任何地方。除非你在过去几年里一直生活在石头下,你肯定听说过很多关于 Web 2.0、开放网络和 HTML5 的事情。如果你从三个不同的人那里听到这些术语,你可能也听到了至少三种不同的定义。因此,我们将简要描述这些术语的含义,为什么你应该对它们感到兴奋,以及 HTML5 是如何改变游戏规则的。

什么是 HTML?

在我们开始谈论开放网络以及 HTML5 在其中的核心作用之前,我们需要澄清的第一个术语是 HTML。简单来说,HTML 是一种非常基本的标记语言,用于描述文本文件给读取它们的程序。虽然这可能是对它的最概括的定义,但有趣的是这样一种基本技术如何在我们整个社会的发展中发挥了如此关键的作用。从不起眼的开始,最初只是为了服务一个非常具体的目的,HTML 已经成为了网络的主要标记语言,进而进入了世界上几乎每一个家庭,以及大多数公文包、口袋和其他电子设备。

鉴于 HTML 的这种戏剧性、广泛的影响,很快就清楚地意识到这种技术需要做的不仅仅是声明一些文本块的颜色,或者一些研究论文中存储的照片的宽度和高度。由于多年来网络被使用的许多不同方式,HTML 已经发展和演变,从一个简单的标记语言,发展成为强大、高度复杂的在线应用和服务的基础。

HTML 的简要历史

超文本标记语言,简称HTML,就像我们今天所知的那样,最早是由蒂姆·伯纳斯-李在 1989 年构想出来的。当时,他在瑞士日内瓦的欧洲粒子物理实验室工作,他认为将科学家们编写和使用的各种研究文档链接在一起会很有益处。这样,不仅可以阅读大量独立的文档,每个引用另一篇研究论文的文档都可以有一个超链接到另一篇文档,这样读者就可以轻松地从一篇文档导航到下一篇文档,而且时间上也更为及时。

为了实现他将文档超链接在一起的想法,蒂姆·伯纳斯-李将现有的标记语言作为他自己标记语言的基础——标准通用标记语言,简称SGML。SGML 是一种通过使用标签词汇来结构化文本的简单语言。例如,为了指定一个文本块被解释为一个段落,一个人会用一对“段落标签”将这样的文本包围起来,这看起来与今天 HTML 中的段落标签一样。虽然蒂姆版本的语言中的基本词汇保持不变,但添加了一个关键标签——超链接标签。因此,HTML 诞生了。

请记住,蒂姆对这种语言的愿景非常具体。通过 HTML 跨文档引用的能力,发表的科学研究论文可以更有效地进行研究。直到多年后,HTML 才开始被用于除了共享互联文本之外的其他目的。

万维网的演变

随着计算机变得更加普遍,越来越多的人开始拥有自己的机器,随着互联网的广泛使用,人们开始找到新的使用新技术的方式。人们开始使用 Web 不仅仅是阅读他人所写的内容,而是开始通过编写和发布文档与他人交流。不久之后,互联网就成为了一个巨大的虚拟社会。

20 世纪 90 年代,互联网继续增长,不同的用途不断出现。随着对这种惊人基础设施如何使用的新想法,必须想出新的方法来将这些想法变为现实,因为支持互联网的技术仍然是相同的。在本质上,Web 应用程序仍然只是一个基于文本的文档,使用 HTML 格式化。为了向这些否则静态数据添加一些逻辑,程序员使用存储在 Web 服务器中的程序来操作用户的输入,并动态创建 HTML 文档。再次强调,用户在浏览互联网时实际上与之交互的文档只不过是纯 HTML。

为了使互联网能够继续增长和适应其使用方式和目的,需要进行改变。与其仅向 HTML 阅读器(Web 浏览器)发送纯文本数据,不如找到一种方法在网页上添加某种代码,以便在浏览器上处理信息。因此,JavaScript 诞生了。

如今,网络在使用人数和使用方式和目的方面继续增长。好消息是,支持和运行网络的技术也在不断增长和发展,以便适应新的用例。

HTML5 是什么?

毫无疑问,你肯定听过人们在不同情境下使用 HTML5 这个术语,这可能至少引起了一些混淆。与大多数所谓的技术术语一样,它们进入了普通大众,并经常从非技术人员的嘴唇上掉下来,HTML5 进入普通大众实际上意味着不止一件事。在最基本的层面上,HTML5 指的是由 Tim Berners-Lee 创建的标记语言的下一个版本,现在有一个指导其进展的管理机构。该术语的另一个含义是指与标记语言相辅相成的其他技术,以及开放网络的概念,我们将在本章后面更多地讨论。

HTML5-演变的下一步

信不信由你,人们一直在努力开发旨在在 Web 浏览器中执行的功能齐全、复杂的应用程序。在很大程度上,最大的挑战是实现这一目标所需的技术直到相对最近才完全可用。使用早期版本的 HTML 创建大型 Web 应用程序如此困难的原因在于 HTML 最初并非为此而设计。然而,随着 Web 的发展,HTML 也在不断发展。

HTML5 的目标之一就是实现这一点-使开发人员能够创建完全在互联网上运行的功能强大的非平凡应用程序。HTML5 的另一个主要目标是完全向后兼容,以便用于其他目的(即超链接研究文档)的网页仍然可以正常运行。

正如 Tim Berners-Lee 向 SGML(以及其他标记)添加了超链接标记一样,HTML5 基本上就是这样-比以前版本的语言更多的标记(或更多的功能)。虽然这是 HTML5 是什么的一个很好的概述,但故事还有更多。除了向 HTML 规范添加的新标记外,HTML5 这个术语还指的是 Web 演变的下一步。

有些人称之为 Web 2.0,而其他人简单地称之为未来。当我提到 HTML 历史上的下一个步骤时,我将指的是对 HTML、CSS 和 JavaScript 的升级,因为这三种技术是这个新互联网的核心,其中 Web 应用程序(包括在线游戏)是关注的中心之一,也是本书的重点之一。

HTML5 不是一个单一的特性

在为开发人员提供新功能之前,HTML5 试图解决在以前版本的 HTML 中暴露出的核心问题,即编程架构。由于 HTML 最初并不是为了网页应用程序开发而创建的,当程序员开始将其用于此类目的时,他们很快发现自己的代码非常混乱。应用程序数据与呈现代码严重混合,而呈现代码又与应用程序逻辑紧密耦合。

为了解决这个问题,开发人员得到了层叠样式表CSS),它允许他们将 HTML 标记(信息)与信息的呈现方式分开。因此,HTML5 实际上指的是三种不同的技术,即 HTML5(新的语义元素或标签)、CSS3 和 JavaScript(所有新的 API,如 Web 存储、Web Workers 和 Web Sockets 等)。

更多语义化的文档结构

当开发人员看到现有技术的不同应用的需求,并对其进行实验时,他们会使用自己手头的工具,并将其适应新的环境。这就是以前版本的 HTML 的情况。由于只存在少数几个容器标签,开发人员使用相同的元素描述非常复杂的文档结构;虽然这完成了工作,但也使得结构混乱且难以维护。简而言之,如果你手头只有一把锤子,那么你看到的一切都会变成钉子。

例如,开发人员通常使用<div>标签来表示文档的每个部分,描述类似下图所示的结构时。

更多语义化的文档结构

图 1

上图显示了大多数上一代网页设计中使用的典型结构。

这样的设计可以用以下结构表示:

<div id="wrapper">
  <div id="header"></div>
  <div id="body">
    <div id="main_content">
      <p>Lorem Ipsum...</p>
    </div>
    <div id="sidebar"></div>
  </div>
  <div id="footer"></div>
</div>

虽然使用<div>标签来完成任何目的是完成工作的一种方式,但你可以看到这很快就会失控,将文档变成难以理解而需要仔细检查的东西。当你看到一长串闭合的<div>标签时,这种代码变得尤为麻烦——你怎么知道每个闭合标签实际上关闭了什么,因为所有标签都有相同的名称?更糟糕的是,你怎么知道你有恰好数量的闭合标签?

<div>范式之后设计 HTML 结构的另一个主要问题是,从语义角度来看,每个标签都是完全无意义的。为了使每个<div>标签稍微更有意义和自我描述,通常会添加额外的属性,通常以 ID 或类的形式。再次,这种解决方案只会加剧问题,因为更大、更复杂的文档需要更多这些属性,而这些属性需要跟踪,从而增加了本应简单的解决方案的复杂性。

值得庆幸的是,在 HTML5 中,这个问题以一种非常优雅的方式得到了解决。鉴于许多文档都使用<div>标签来定义共同的部分,如页眉、页脚、导航和主要内容,因此添加了新的标签来表示这些共同的部分。有了这些新标签,你现在可以直观地扫描设计结构,并非常快速地理解信息的布局方式。此外,完全消除了为了区分每个<div>标签而创建无尽的 ID 属性的需求。

使用 HTML5 提供的一些新标签,可以将图 1中的相同设计概念表示如下:

<header></header>
<section>
  <article>
    <p>Lorem Ipsum...</p>
  </article>
  <nav></nav>
</section>
<footer></footer>

提示

下载示例代码

您可以从您在www.packtpub.com的帐户中下载您购买的所有 Packt 图书的示例代码文件。如果您在其他地方购买了这本书,您可以访问www.packtpub.com/support并注册,以便将文件直接发送到您的电子邮件。

您可以看到代码变得更加描述性。还要记住,这种更有意义的结构的好处不仅仅是对人类更易读。使用 HTML5 中的新语义标签,搜索引擎(如 Google、微软的必应和雅虎!)能够更好地理解网页的内容,因此可以更好地根据其主题对您的网站进行索引,从而使网络变得更好。此外,通过使用更具体的标签定义 HTML 文件,屏幕阅读器软件能够更好地理解网页的内容,从而使依赖此类软件的用户更好地使用和享受互联网。

注意

由于互联网似乎使我们的世界变得完全扁平,你不应该假设只有你的朋友和邻居才能访问你在网上发布的内容。你的访问者不仅会来自其他国家和设备(如智能手机、平板电脑,甚至电视机),而且许多上网的人(因此,来到你的网站消费你提供给他们的材料)也有视觉或音频辅助设备或软件等特殊需求。因此,当你编写任何 HTML 代码时,请记住这一点,并考虑屏幕阅读器程序可能如何解释你的内容,以及用户使用和消费你的作品会有多容易。

以下标签是为了实现这种新的、更简化的语义顺序而添加到 HTML5 中的。请记住,每个标签都有几个属性,我们将在下一章中展示示例用法时详细讨论。此外,由于新的数据属性,元素可以任意扩展。

以下表格摘自HTML5 W3C 候选推荐 2012 年 12 月 17 日,可在www.w3.org/TR/2012/CR-html5-20121217/找到。

标签名称 描述
<address> 该标签表示与其关联的文章元素相关的联系信息,或者与 body 元素相关联时,表示与整个文档相关的联系信息。
<article> 该标签表示一个独立的内容片段,如文章或博客文章。文章元素可以嵌套,这种情况下,子文章节点将与其父节点相关联,但它仍然独立于文档中的所有其他内容。
<aside> 该标签表示与文档中的其他内容元素相关的内容片段,但仍然可以独立表示其相关元素。例如,子导航部分、侧边栏等。
<audio> 该标签表示来自单一来源的声音或音频流(或两者)。可以指定多个来源,但浏览器会选择最合适的来源进行流式传输。
<bdi> 该标签表示一个孤立的文本内容,可以以双向方式格式化。
<bdo> 该标签表示控制其子元素文本方向的元素。dir属性的值指定元素内的文本是从左到右流动(值为ltr)还是从右到左流动(值为rtl)。
<canvas> 该标签表示一个矩形面板,可以通过 JavaScript 公开的渲染上下文 API 来操作其内容。
<command> 该标签表示用户可以执行的命令,如键盘快捷键。
<details> 这个标签代表了与其他元素或内容相关的附加内容。
<figure> 这个标签代表了可以用作照片、插图等注释的独立内容。
<footer> 这个标签代表了一个包含有关其元素的信息的内容部分,比如版权信息和关于文章的其他细节。
<header> 这个标签代表了一个部分标题,比如目录和导航元素。
<hgroup> 这个标签代表了一个部分副标题,比如备用标题和署名。
<mark> 这个标签代表了用于引用的一部分内容,类似于对一段文本进行高亮。
<meter> 这个标签代表了在已知范围内的值,比如剩余能量的数量。请注意,由于有专门的progress元素,meter元素不应该用来表示进度条。
<nav> 这个标签代表了一个带有指向其他文档或同一文档内链接的导航元素。
<progress> 这个标签代表了在已知范围内完成的进度量,比如在注册过程中完成的步骤数。
<rt> 这个标签代表了 ruby 注释的文本组件。
<rp> 这个标签代表了当 ruby 注释不被支持时,浏览器显示的 ruby 注释的文本组件。
<section> 这个标签代表了文档中的一个通用部分,比如幻灯片,或者显示文章列表的部分。
<summary> 这个标签代表了一些内容的摘要。
<time> 这个标签代表了一个日期和时间,可以以人类可读和机器可读的格式显示。浏览器显示的内容是为人类消费而设计的,而数据属性则是为了被浏览器和其他应用程序使用而设计的。
<video> 这个标签代表了来自单一来源的视频流。可以指定多个来源,但浏览器会选择最合适的来源进行流式传输。
<wbr> 这个标签代表了一个换行机会,提示浏览器在需要时在何处进行换行。请注意,这个元素没有宽度,因此当不需要换行时,该元素是不可见的。

关于性能的警告

在网页设计和前端开发中经常被忽视的是性能。虽然今天的主流浏览器让渲染 HTML 看起来像是一项微不足道的任务,但实际上在幕后进行了大量工作,以将一系列 HTML 标签和 CSS 转化为一个漂亮的网页。更重要的是,随着在网页中添加鼠标悬停效果、下拉菜单和自动幻灯片变得更加容易,很容易忘记浏览器仍然需要做些什么来完成这项工作。

如果你把 HTML 文档看作是一棵树形结构,其中每个嵌套的标签就像结构中的一个分支,那么很容易理解深层布局相对于浅层布局会是什么样子。换句话说,你拥有的嵌套标签越多,结构就会越深。

总之,要牢记的是,HTML 节点中最微小的变化(比如文本标签的物理大小因为悬停效果导致文本变粗,从而在屏幕上多占据了几个像素)可能会触发所谓的回流,这实际上会导致浏览器对 HTML 结构中的每个分支(标签)进行多次计算,因为它需要重新计算每个元素的位置,以便正确地重绘页面。

你的 HTML 结构越浅,浏览器在重新绘制页面时需要进行的计算就越少,从而使体验更加流畅。虽然深度嵌套的<div>标签导致 HTML 文件变得难以阅读和维护的论点可能是主观的,但毫无疑问,深层 HTML 结构的性能远远不如更扁平的替代方案。

浏览器的本地功能

正如前面所述,HTML5 的一个优点是它反映了现实世界的需求,并为这些需求提供了优雅的解决方案。开发人员很少使用的功能(或者没有得到浏览器制造商的广泛采用)最终会从规范中消失。同样,开发人员反复努力解决重复出现的问题最终会导致新功能被提出,然后被添加到规范中。随着新的建议被接受为规范的一部分,浏览器制造商实现这些新功能,最终结果是浏览器被扩展,能够做开发人员以前需要手动编码的事情。

举个例子,让我们来看看占位字段。占位符是在 HTML 表单中的输入字段内的文本,它代替了单独的标签。当输入字段为空时,其中的文本描述了字段期望的数据(比如名字或电子邮件地址)。一旦用户开始在字段中输入,占位符文本就会消失,用户的实际输入会替代它。

浏览器的本地功能

虽然这种技术非常直观,但在表示上需要更多的物理空间,尤其是在较小的屏幕尺寸上。

如下面的屏幕截图所示,一个更加动态的解决方案是使用 JavaScript 和 CSS 的组合将字段标签放在字段内部:

浏览器的本地功能

在 HTML5 之前,实现这种效果需要写相当多的样板 JavaScript 代码:

<style>
.hint-on { color: #ddd; }
.hint-off { color: #333; }
</style>
<input type="text" name="firstName" value="First Name" class="hint-on"
 onblur="if (this.value == '') {
 this.className='hint-on';
 this.value='First Name';
 }"
 onfocus="if (this.value == 'First Name') {
 this.className='hint-off';
 this.value='';
 }" />

当然,有很多种方法可以用 JavaScript 实现相同的效果。在 HTML5 中,可以用一行代码实现相同的功能,如下面的代码片段所示:

<input type="text" placeholder="Last Name" />

这个第二个版本之所以有效,是因为placeholder属性被添加到了浏览器,以及使其工作所需的逻辑。虽然这可能看起来像是浏览器学会的一个小技巧,但让我们更仔细地看一下它给我们带来的一些主要好处:

  • 作为开发人员,你在整个项目过程中可能要写和测试的代码行数会减少数百行,因为浏览器提供了这样一个简单的替代方案

  • 开发时间减少有两个原因:你需要写的代码更少,你需要测试的代码也更少

  • 你的代码将更具可移植性和可靠性,因为你不需要为每个浏览器编写特定的代码逻辑实现,并且你可以相信每个浏览器都会正确地实现该功能(即使它们需要几次更新才能达到这一点)

换句话说,与其将大量精力投入到规范化代码,使其在尽可能多的浏览器中以相同方式运行,或者写大量样板代码来将应用程序带到最新的接受标准,现在你可以将大部分时间投入到构建独特的应用程序上,因为浏览器已经将重复的代码抽象化了。

关于 HTML5 这种积极循环的最后一个令人兴奋的点是,随着时间的推移,我们只能期待浏览器本地添加更多更加精彩和有用的功能。谁能想象得到未来几年浏览器将本地支持什么新功能呢?

截至本书,现代浏览器支持以下一些原生功能(关于这些功能的更多细节将在接下来的章节中给出,但这个列表应该让您对即将到来的内容有一个很好的预览)。

自动表单验证

您告诉浏览器您希望用户输入的确切格式,并且浏览器将强制执行该格式。提供任何无效输入(基于您的设置),浏览器甚至会向用户报告,让用户知道出了什么问题。

新的输入类型

以各种格式从用户那里收集数据,超出文本、列表、复选框和单选按钮。对于给定数值范围内的值,可以使用滑块输入(也称为范围输入)。您还可以输入与日期、颜色、电话号码或电子邮件地址相关的精确输入。在输入中指定这些限制只需要一个 HTML 元素属性。

电话友好的超链接

在浏览文本文档时,浏览器一直非常擅长从一个文档导航到下一个文档。只需告诉浏览器下一步去哪里就可以了,这需要一个锚标签。现在,智能手机在世界某些地区的互联网使用量中占据了近一半的份额,超链接可以有不同的上下文,比如告诉您的设备拨打一个号码。使用 HTML5,您可以告诉同一个锚标签将其链接视为要拨打的电话号码,类似于您当前告诉它将其资源视为电子邮件地址。

基于 CSS 的 DOM 选择器

除非你过去五年来一直生活在石头下,否则你一定听说过并可能使用过如今最流行的 JavaScript 库——jQuery。jQuery 变得如此流行并得到 Web 开发人员的广泛接受的主要原因之一是它革命性地允许你访问 DOM 元素的方式。在 jQuery 之前,访问 DOM 元素的三种最常见方式如下:

  • document.getElementsByTagName()

  • document.getElementsByClassName()

  • document.getElementById()

基于 jQuery 对访问文档节点的有限方式的解决方案,您现在可以通过指定 CSS 选择器从文档中检索元素(或一组元素)。新的选择命令返回与这些 CSS 选择器匹配的任何或所有节点:

  • document.querySelector("css 查询在这里");

  • document.querySelectorAll("css 查询在这里");

文本转语音

文本转语音可能是最令人兴奋和强大的功能之一,它被原生添加到浏览器中。虽然用户可以简单地在输入字段中输入一些内容,您可以对该文本输入进行任何操作,但浏览器现在可以让您从用户那里接收语音输入。无论用户通过麦克风向浏览器直接告诉浏览器什么,浏览器都会使用自己的文本分析算法,并为您提供等效的文本转录。通过向您的应用程序添加一行代码(而且是基于 Web 的应用程序),您现在可以更接近于只在电影中(或离线,在基于桌面的应用程序中)展示的界面类型。

CSS3

层叠样式表,通常简称为 CSS,是 HTML 和万维网成功的另一个贡献技术。CSS 是一种控制 HTML 结构呈现方式的样式语言。使用标记语言和样式语言的好处包括关注点分离、可重用的视觉设计、易于维护和可扩展性。作为 HTML5 革命的一部分,CSS 规范进行了一些重大更新,这也将语言提升到了一个全新的水平。

关注点分离

CSS 为游戏带来的第一个,可能也是最明显的好处是关注点分离。通过允许 HTML 描述其表示的数据,并且不担心如何呈现这些数据,CSS 能够控制数据的显示方式。这样,CSS 专家可以在不需要触及 HTML 文件的情况下处理 Web 应用程序的样式。最重要的是,CSS 专家绝对不需要了解给定项目中可能使用的任何其他技术。这样,无论项目的其余部分有多复杂,样式都可以独立和分开地完成。

可视设计的可重用性

有许多方法可以将 CSS 规则包含到 HTML 文档中。例如,您可以将所有 CSS 代码直接写入使用它的同一 HTML 文件中,也可以在创建每个 HTML 元素时将其写入其中,或者可以将 CSS 样式放在一个完全独立的文件中,然后将样式表导入到 HTML 文件中。最常见的做法是将所有 CSS 代码写在一个或多个单独的文件中(考虑关注点分离),然后将每个文件导入到您想要用于每组样式的 HTML 文件中。这样,您可以拥有一个描述特定视觉主题的单个样式表,然后可以通过一行代码将该主题在整个应用程序中重用(可能由成千上万个单独的 HTML 文件和片段组成),只需导入样式表:

<style>
p {
   color: #cc0000;
   font-size: 23px;
}
</style>
<p>Hello, World!</p>
<p style="color: #cc0000; font-size: 23px;">Hello, World!</p>

前面的代码是在特定元素上编写的 CSS 规则的示例。在这种情况下,只有这些 HTML 段落标记将使用由这个简单规则定义的样式(告诉浏览器以红色渲染文本,并且高度为 23 像素)。

(file: /my-style sheet.css)
p {
   color: #cc0000;
   font-size: 23px;
}
(file: /index.html)
<!doctype html>
<html>
<head>
   <link rel="style sheet" href="my-style sheet.css" />
   (...)

前面的代码是在一个单独的文档中编写的 CSS 规则的示例。在这种情况下,文件index.html中的任何 HTML 段落标记都将使用由这个简单规则定义的样式(告诉浏览器以红色渲染文本,并且高度为 23 像素)。如果样式表未导入到其他文档中,那么其他文档将不使用my-style sheet.css中找到的样式规则。

易于维护

通过将 HTML 文档的表现层与外部 CSS 文件分离,您获得的另一个巨大好处是维护变得非常容易。想象一下,如果您将所有样式都写在使用它们的同一文档中。如果您只有一个文档,那么这不是一个大问题。然而,大多数项目包括多个 HTML 文件。因此,想象一下,您只需将一个文件中的 CSS 复制并粘贴到下一个文件中,因为它们都共享相同的 CSS 规则。如果您现在需要更改其中一些规则,那么您将如何更新数十甚至数百个文件,因为需要更新的 CSS 在所有这些 HTML 文件中都可以找到?

因此,如果你只有几个只包含 CSS 代码的 CSS 文件,以及所有使用它们的 HTML 文件只是简单地导入样式表,那么当你需要为项目更改样式时,只需要更新一个 CSS 文件。一旦更新了该 CSS 文件,所有导入该 CSS 的其他 HTML 文件将自动使用新样式。

可扩展性

最后,使用 CSS 的优势在于它使项目的表现层非常具有可扩展性。一旦 CSS 代码就位,你可以在成千上万的文件中使用它(比如维基百科),并且样式将在所有文件中保持一致。如果你决定升级设计,只需要更改一个文件——样式表。

CSS 的演变

尽管有一个单独的样式语言来处理 HTML 文档的呈现层的想法无疑是很棒的,但是对于大多数设计师来说,CSS 一直是一种噩梦。由于不同的浏览器一直试图与其他竞争浏览器有足够的独特之处,以赢得用户的青睐,不同的浏览器以不同的方式实现了 CSS 的某些功能。例如,指定宽度为 500 像素的 CSS 规则在所有主要浏览器中的行为并不一致。通过 CSS 指定元素的宽度属性,大多数浏览器只会设置元素的内容宽度,同时允许任何填充、边框和边距宽度使元素的总宽度更大。然而,有些浏览器在设置 CSS 宽度属性时会包括元素的填充和边框宽度。

CSS 的演变

前面的图显示了 CSS 盒模型的一个例子。请注意,边距空间始终是透明的,而任何填充空间都会继承其相应元素的背景颜色或图像。

这种不一致性使得 CSS 的成功受到限制并且缓慢。当设计师接手一个项目时,一个设计需要考虑到许多浏览器,这也意味着需要在许多不同的浏览器中进行测试。不仅如此,CSS 提供的实际功能也是有限的。例如,在第 3 版之前,通过 CSS 创建具有圆角的元素的唯一方法是向元素添加背景图像,其中该图像是一个带有圆角的框。这并不是非常实用,通常需要更改 HTML 结构,这在一定程度上违背了外部样式表的目的。

然而,由于 Web 标准的响应性,随着新版本的 HTML 一起发布了 CSS 的新版本。正式命名为 CSS Level 3,新规范建立在 CSS Level 2 模块的基础上,并包括额外的模块。鉴于 CSS 的广泛接受和使用,主要的 Web 浏览器在更一致地实现功能方面做得更好,这意味着一个代码库更有可能在不同的浏览器上一致地运行。

实验性功能和供应商前缀

随着新功能被添加到规范中,规范本身的进展,浏览器供应商试图保持领先地位,并为设计师和最终用户提供最新和最好的功能。然而,截至本书出版时,并非所有列在 CSS3 规范中的功能都被所有浏览器完全实现。您可以通过 CSS 规则是否带有破折号和浏览器的代码名称前缀来判断浏览器是否尚未完全支持某个功能(或者某个功能可能停止被某个特定浏览器支持)。例如,-webkit-(规则名称)

供应商 前缀
Google Chrome -webkit-
Mozilla Firefox -moz-
Microsoft Internet Explorer -ms-
Opera -o-
Safari -webkit-

最终,规范将进一步稳定,所有浏览器都将以相同的方式实现 CSS 规范,您的样式表中将不再需要出现供应商前缀。但在那之前,您需要重复一些 CSS Level 3 规则,以便每个浏览器都能识别该功能。

CSS 预处理器

有时很难跟上所有 CSS 功能及其相应的浏览器支持。一些功能已经不再需要前缀(这意味着所有主要浏览器都支持规则,而不需要在规则关键字前面加上供应商前缀关键字)。但是,许多其他功能仍未完全摆脱这个实验阶段,只有一些主要浏览器支持它们而没有任何供应商前缀。

一些勇敢的开发人员努力跟上最新的浏览器更新,并相应地更新他们的代码,通过从样式表代码中删除多余的供应商前缀。其他人发现这种积极的努力是适得其反的,而是将所有可能的规则版本包含到他们的代码中,这样他们只需要在遥远的将来的某一天更新他们的样式表代码,如果有的话。

当然,用每个供应商前缀重复相同的规则,然后跟着非前缀规则,会迅速使你的样式表文件变得非常庞大,难以维护。找到最适合你的方法。还有各种工具可用于帮助你维护你的 CSS 文件,特别是关于供应商前缀的这种不断发展的情况。

最受欢迎的这类工具(也称为 CSS 预处理器)是 LESS(见lesscss.org/)和 SASS(见sass-lang.com/)。虽然每个预处理器略有不同,但它们都实现了同样的功能,即,接受普通的 CSS 样式表,然后在需要的地方添加所有必需的供应商前缀。

CSS3 模块

新的 CSS Level 3 模块可以分为几个模块,即样式属性选择器颜色媒体查询

样式属性告诉浏览器如何呈现(或样式化)元素。这可以是任何东西,从一个字符串文本被样式化为 23 像素的字体大小,到样式化一组图像,使其绕其 y 轴旋转 45 度并放置在自己的倒影上,再到样式化各种 HTML 节点以每半秒使用关键帧动画进行动画处理。

选择器是告诉浏览器要样式化哪些元素的方式。也就是说,通过 CSS 选择器的特殊表达语言,你可以寻址一个或多个元素,其样式规则遵循选择器声明。新的 CSS3 选择器基本上扩展了这种表达语言,使得可以以不同、更灵活的方式来定位不同的元素。

颜色,顾名思义,提示浏览器如何对元素进行着色。颜色可以应用于网页上的实际文本,也可以应用于文本或其他元素周围的边框,以及元素的背景,遵循盒模型方案。

最后,媒体查询允许样式表基于各种条件来定位文档(或其部分)。而且,媒体查询由浏览器实时触发。换句话说,例如,如果你指定了一个媒体查询,定义了只有在浏览器窗口不超过某个宽度时才应用的 CSS 规则,那么浏览器将根据需要自动更新网页,随着窗口的调整大小。这样,网页可以是响应式的,意味着它立即对其环境中的任何变化做出响应,使得任何媒体查询都变得有效。

这些模块的简要定义将在下文中进行,但更深入的讨论以及使用示例可以在随后的章节中找到,因为每个模块都被添加到我们的游戏中。

样式属性

在样式属性模块中,我们可以将特性细分为处理自定义字体、文本效果、其他效果和动画的较小模块。

属性 定义
border-radius 这指定了每个框的角要圆多少
border-image 这指定了要在框的边框上渲染的图像
box-shadow 这指定了相对于框的投影的方向和大小
background-size 这指定了背景图像的大小
background-origin 这指定了背景图像的偏移位置
background-clip 这指定了要绘制背景图像的程度
animation 这指定了动画的各个方面,比如关键帧、时间、效果等
transform 这指定了各种 2D 和 3D 变换
transition 这指定了两个属性应该如何从一个过渡到另一个
text-shadow 这指定了相对于文本的投影阴影的方向和大小
@font-face 这指定了浏览器可以下载到用户系统并用作本机字体的字体文件

选择器

CSS 选择器,最早在 CSS Level 1 中引入,一直以来都非常强大和全面。

属性 定义
E[foo^="bar"] 它选择了一个具有属性foo值以bar开头的E元素
E[foo$="bar"] 它选择了一个具有属性foo值以bar结尾的E元素
E[foo*="bar"] 它选择了一个具有属性foo值包含barE元素
E:root 它选择了文档根部的E元素
E:nth-child(n) 它选择了第 N 个E子元素
E:nth-last-child(n) 它选择了从最后一个子元素开始计数的第 N 个E子元素
E:nth-of-type(n) 它选择其类型的第 N 个E兄弟元素
E:nth-last-of-type(n) 它选择了从最后一个子元素开始计数的第 N 个E兄弟元素
E:last-child 它选择了最后一个E元素
E:first-of-type 它选择了其类型的第一个E兄弟元素
E:last-of-type 它选择了其类型的最后一个E兄弟元素
E:only-child 它选择了一个E元素,如果这是其父元素的唯一子节点
E:only-of-type 它选择了一个E元素,如果这是其父元素的唯一同类型的兄弟节点
E:empty 它选择了一个E元素,如果它没有子节点和文本内容
E:target 它选择了一个E元素,其ID属性与 URL 哈希符号匹配
E:enabled``E:disabled 它选择了通过相应属性被禁用的E元素
E:checked 它选择了通过相应属性或适当的用户交互已被选中的E元素
E:not(S) 它选择了一个不匹配选择器表达式SE元素
F ~ E 它选择了一个F元素之前的E元素

来源:层叠样式表(CSS)快照 2010,W3C 工作组注释 2011 年 5 月 12 日

颜色

CSS Level 3 中对颜色的两个主要添加是采用 HSL 颜色和额外的 alpha 通道。以前,您可以通过为每个通道(红色、绿色和蓝色)指定 0 到 255 之间的值来指定 RGB 颜色。现在,额外的 alpha 通道可以附加到属性的末尾,允许您控制透明度的级别:

div { background: RGBA(255, 255, 255, 0.5);

这个 CSS 规则指定了一个完全白色的背景,不透明度为 50%(半透明),用小数表示:

div { background: RGBA(100%, 100%, 100%, 50%);

或者,您可以使用百分比为所有值指定相同的 CSS 规则,这可能更容易阅读,并使表达更一致。

使用色调、饱和度和亮度HSL)指定颜色同样简单,而且可能更直观。您不再需要使用RGBRGBA关键字,而是通过使用关键字HSL(如果您想要添加额外的可选 alpha 通道,则使用HSLA)。使用HSL而不是RGB的另一个好处是,RGB是面向硬件的,而HSL不是。

div { background: HSL(359, 100%, 50%);

在这里,通过将饱和度设置为极限,并将颜色点亮一半,您可以指定一个非常明亮的红色背景颜色。请记住,将亮度通道设置为100%会使颜色完全变为白色(就像超级明亮的灯光一样),而将其设置为0%会使其完全变为黑色,就像在黑暗的房间中一样;例如,参见以下代码行:

div { background: HSLA(359, 100%, 50%, 50%);

或者,您可以通过添加 alpha 通道并将其设置为50%不透明度,为相同的 CSS 规则指定半透明外观。

HSL 的色调通道是 0 到 359 之间的数字,表示颜色轮上的角度,红色为 0 度,绿色为 120 度,蓝色为 240 度。请注意,这个数字是环绕的(因为它是一个角度值),所以 360 代表轮子上的相同位置为 0。饱和度和亮度通道表示完全表示和完全不表示之间的百分比。

媒体查询

媒体查询允许您检查渲染 HTML 文件的设备的特定功能。这在实时确定查看您网站的窗口的宽度和高度方面最常用。这个强大功能的常见用例是确定用户是否在移动设备上。理解媒体查询的一个简单方法是将它们视为条件语句,比如,“如果媒体是(…)”。例如,如下截图所示,当媒体宽度至少为 500 像素时,将应用一组 CSS 规则。当媒体宽度小于 500 像素时,将使用另一组 CSS 规则:

媒体查询

由于媒体查询,相同的 HTML 结构根据浏览器的当前状态呈现不同。

@media (orientation: portrait) {
   body {
      background: RGB(100%, 0%, 0%);
   }
}

这个简单的例子特别针对处于纵向模式的任何设备,并定义了指定body元素为红色背景颜色的 CSS 规则。

注意

在幕后,浏览器实现这个特定的媒体查询(portrait模式)的方式是通过计算查看页面的窗口的宽度与窗口的高度的比例。如果窗口的高度大于宽度,那么从实际目的上来说,页面被认为处于portrait模式。同样,如果您手动调整窗口大小,或者它刚好处于宽度大于高度的位置,那么浏览器将认为页面处于landscape模式,并且将触发针对该模式的任何媒体查询。

@media (max-width: 240px) {
   body {
      background: RGB(100%, 100%, 100%);
   }
}

在上一个例子中,我们告诉浏览器检查窗口是否小于或等于 240 像素宽。如果是,我们定义 CSS 规则,告诉body标签以白色背景呈现自己。

@media (min-width: 800px) and (max-width: 1200px), (min-height:  5000px) {
   body {
      background: RGB(0%, 100%, 0%);
   }
}

作为最后一个例子,我们告诉浏览器在上一个代码片段中检查几个不同的条件。如果至少一个条件求值为 true,则该媒体查询块内的 CSS 规则将对页面可用。在不同条件下重用规则或者简单地创建不同规则以应用于不同情况时,这将非常有帮助。在这种特殊情况下,我们将body标签的背景颜色设置为明亮的绿色,只要两个条件中的一个(或两个都是)为 true:窗口高度至少为 5000 像素,或者窗口宽度在 800 像素和 1200 像素之间(两个值都包括在内)。

JavaScript API

正如前面所述,当 HTML 被创建时,并不是用于开发大型企业应用程序。当 JavaScript 于 1995 年首次创建时,其主要目标是提供一种简单的脚本语言,使 Web 设计人员能够为其网页添加一些逻辑。这也不是用于开发大型复杂应用程序的基础工具。

然而,正如我们在 HTML 本身以及 CSS 中所看到的,JavaScript 已经被广泛使用,开发人员已经超越了其有限的能力。看到开发人员采用这种语言的方向,为了利用 Web 作为平台,浏览器供应商开始尽其所能改进 JavaScript。结果,非常强大的 JavaScript 引擎已经出现。因此,随着 JavaScript 的使用越来越广泛,浏览器变得越来越强大,JavaScript 也增加了一系列新的功能。

今天,JavaScript 是最流行的 Web 脚本语言。鉴于其功能和最新的工具,JavaScript 已成为开发大型应用程序的非常好的选择,特别是游戏。最新的 JavaScript API 允许进行 2D 和 3D 图形渲染,类似线程的行为,套接字,嵌入式数据库等等。最重要的是,这些新功能是以安全性为重点构建的,并且不仅适用于台式电脑,还可以在连接到全球网络的大多数设备上使用。

新的 JavaScript API

虽然以下内容并非所有新的和即将推出的 API 和语言特性的全面列表,但它确实涵盖了 JavaScript 最重要、最稳定的新增内容,特别是我们可以利用它们进行游戏开发。关于以下列表中列出的每个 API 的更详细解释以及使用示例,可在随后的章节中找到:

API 定义
画布 API 它渲染 2D 或 3D 图形
Web Audio API 它控制音频文件的播放
Web 视频 API 它控制视频文件的播放
地理位置 API 它提供对托管设备地理位置的访问
Web Socket API 它提供了与远程服务器进行双向通信的协议
Web Workers API 它提供了类似线程的后台工作程序以进行并发执行
消息 API 它提供了不同浏览器上下文之间通信的机制
Web 存储 API 它提供了一个键值对持久性机制
索引数据库 API 它提供了一个 NoSQL 对象存储机制
拖放 API 它提供了一种原生的拖放对象的机制
选择器 API 它提供了使用 CSS 选择器选择 DOM 元素的机制

注意

HTML5 中还有另一个持久性 API,称为 WebSQL。该 API 的规范定义了一种在客户端使用实际内置的基于 SQL 的数据库引擎进行存储和查询数据的异步方式。该规范已被弃用,并且完全被更强大、更受欢迎的 IndexedDB API 所取代。

作为平台的 Web

HTML5 最令人兴奋的事情之一是它是全球网络的主要语言。换句话说,几乎任何连接到网络的设备都能运行你在其中编写的任何游戏或应用程序。这使得 Web 成为一个非常独特的平台。

毫无疑问,你已经听说过或经历过“游戏机战争”之类的事情,不同的游戏机制造商争夺更大比例的市场份额。虽然一些人拥有多个游戏机,但大多数玩家只拥有一个系统。因此,对于游戏开发人员来说,为了使他们的游戏销售良好,或者换句话说,为了使他们的游戏被尽可能多的玩家玩和享受,他们需要为多个平台开发相同版本的游戏。这是一个昂贵且非常耗时的过程。创建一个游戏已经很昂贵和耗时了,更不用说为其他一个或两个平台复制所有这些工作了。

如今,随着全球范围内越来越多的用户使用互联网,你不必像游戏机开发人员那样经历。只要你的游戏在标准的现代 Web 浏览器中正常运行,它在世界上几乎任何其他浏览器中都会运行相同。换句话说,因为它们都运行符合 HTML5 的浏览器,所以超过十亿人可以享受你的相同代码库。这使得 Web 成为有史以来最大、最伟大的平台。最棒的是,完全免费开发。你不需要特殊许可证或向任何人支付版税,就可以为 Web 开发游戏或其他应用程序。

开放网络

正如我们在本章前面提到的,今天流传着许多术语,其含义充其量是不清楚的。根据谁告诉您有关人类未来的情况,术语“开放网络”的具体定义可能会有所不同。

最常见的情况下,“开放网络”一词不是指一系列技术,而是指一种哲学,如果您愿意的话。开放一词是指网络不对少数人关闭,也不受某人限制,也不是为了某一特定目的。万维网被设计成一个所有人都欢迎来创造、创新、消费和享受的地方。网络没有中央管理机构。实际上,每个人都拥有网络,尤其是因为没有数百万个个人服务器和文件,网络就不会成为现在的样子。

您可能会问自己这一切与 HTML 或 HTML5 有什么关系,或者对您有什么好处。简单地说,一切。真的。开放网络如此令人兴奋的原因(迄今为止如此成功)是因为大部分时间,每个人都站在同等的地位上。虽然有数十种不同的服务器端语言和技术,但用户与之交互的应用程序部分是用三种基本技术编写的,即 HTML、CSS 和 JavaScript。现在是成为开放网络一部分的令人兴奋的时刻,您应该觉得自己很幸运能够成为其中一部分的原因是,这些技术正在成熟和变得更加成熟。

HTML5 是为开放网络而构建的。它具有足够的能力来利用网络的分布式范例,并允许您,无论您是独立开发人员还是一个拥有数百名其他程序员的非常大的公司的成员,都可以在浏览器上创建类似桌面的体验,立即触达全球数亿人口。

HTML5 - 一个改变游戏规则的技术

当万维网首次推出时,其创始人心中只有一件事——信息交换。后来,HTML 被开发出来,其目标是描述文本文档。再次强调,主要目的是帮助交换和分发文本文档。

尽管 HTML5 完全向后兼容,并且仍然提供了一个极好的信息分发范例,但它也是为应用程序而设计的。今天,网络不再仅仅用于信息交换。人们现在使用网络作为一种娱乐手段——观看电影,收听广播,尤其是玩完整的、功能齐全的视频游戏。

HTML5 至少在三个方面是一个改变游戏规则的技术,即它的广泛采用、强大的功能以及它将其功能直接带到浏览器中——无需插件。这三种方式如下解释:

  • 广泛采用:不仅有超过十亿人使用万维网,而且几乎任何连接到网络的设备都能执行 HTML5 代码。这意味着您可以编写 HTML5 游戏,让它在台式电脑、笔记本电脑、智能手机、平板电脑,甚至电视上播放。

  • 强大的功能:在 HTML5 之前,许多常用的任务和功能都需要开发人员每次编程——拖放功能、表单验证、自定义字体等等。有了 HTML5,所有这些事情(以及更多)都由网络浏览器为您完成。您不再需要几十甚至几百行代码来创建拖放效果。浏览器会为您,开发人员,轻松完成这项工作。

  • 无需插件:虽然 HTML5 带来的许多特性在之前已经通过第三方软件(如 Macromedia Flash(后来被 Adobe 收购)或 Java 小程序)看到过,但使用这些技术作为网页应用的一部分的挑战在于用户必须安装(并经常升级)扩展浏览器本机功能的插件。不仅如此,开发人员还需要学习和维护至少两个不同语言编写的代码库。HTML5 通过提供自己强大的特性集来解决了这个问题,允许用户在不安装或担心任何插件的情况下获得类似甚至更好的体验。

总之,网络已经从一个交换信息的地方发展成为用户寻找优质娱乐的地方。为了响应这一变化,HTML5 被设计为让您能够在 Web 上创建用户寻找的娱乐,以有趣的视频游戏的形式。

通过游戏开发学习 HTML5

在这本书中,我们将学习关于 HTML5 的一切。我们将学习每个特性的用途以及如何使用它们。然而,更重要的是,我们希望使教学过程有趣、简单和值得记忆。因此,我们的方法可能与大多数其他书籍有些不同。

如果您仔细观察大多数教师和作者教授新概念的方式,您会注意到以下模式:首先解释主题,然后为了巩固学生对刚刚讲解的材料的理解,给出一个示例以展示主题如何应用。问题在于,这个示例通常既不实用也不可用。例如,在计算机编程书籍中,您会看到一个常见的主题描述动物、食物或其他抽象概念,这些概念在现实世界中并不适用。因此,学生可能会因缺乏真实世界的应用而感到沮丧。

有效学习的关键是一个好的例子或用例,学生可以在其中应用新获得的信息。这样,当学生发现自己处于真实世界的情况下,他们新获得的技能确实可以应用时,他们可以很容易地认识到这个机会并使用刚刚获得的知识。此外,如果学习过程过于抽象和理论化,学生往往会分心甚至感到无聊。另一方面,如果教学过程参与和有趣,学生更有可能记住概念,更重要的是,他们更有可能理解所教授的内容。

我们在这本书中的方法可能与您习惯的有些不同,因为我们将大部分精力集中在通过有趣的游戏来阐述每个主题,而不是尽可能多地列出关于 HTML5 及其涉及的理论信息。

为什么要通过游戏开发来教授 HTML5 呢?有很多原因。首先,游戏很有趣。游戏开发虽然有些朋友可能不同意,但也很有趣和有益的。另外,恰巧 HTML5 的大多数特性非常适合游戏开发,因此教授 HTML5 而不开发游戏也有点失礼。最后,游戏非常有趣,通过游戏开发学习新的编程技术将为学习者提供一组非常激动人心的示例,展示每个概念的实际应用,并且作为学习过程的强大实际产品。

然而,本书的目标并不是教你如何开发视频游戏。我们的目标是首先教你 HTML5。如果您已经了解游戏开发,并在这个领域有一些经验,您不仅将学习 HTML5 的最新和最伟大的功能,还将学习如何将它们直接应用于设计和编程视频游戏。如果您不是非常有经验的游戏开发人员,或者事实上根本没有进行过任何游戏开发,不要害怕!您仍然会学习一些游戏开发技巧,因为我们将引导您了解涉及的概念,但请记住,本书的重点是 HTML5。

我们将在本书中编写的游戏将是有趣的、完整的,并且易于扩展。我们将为每个游戏构建多个层,以便添加更多功能或重构其部分将变得非常简单。在每章的末尾,您将拥有自己的 HTML5 游戏,由于开放网络,您将能够与所有有互联网访问权限的朋友以及全球数亿人分享。

最后,由于 HTML 只是文本标记,而 JavaScript 是一种动态的、解释性的语言,我们不需要昂贵或复杂的工具来开发我们的游戏。如果您的计算机上有文本编辑器程序,以及 Google Chrome、Mozilla Firefox、Opera、Safari 或最新的 Internet Explorer 等现代 Web 浏览器,那么您就可以开始了。您还需要一种或另一种类型的 Web 服务器,我们将在下一章中详细介绍。

由于开放网络和 HTML5 的性质,您使用什么样的计算机系统都无所谓。您在自己的系统上编写的任何 HTML5 代码都将在其他人不同的计算机上运行。更好的是,不需要安装,这进一步降低了可能阻止您的游戏被全球数亿人享受的任何障碍。

摘要

在本章中,我们看了一下 HTML 是什么,它来自哪里,以及它的发展方向。我们讨论了开放网络是一个任何至少具有一些使其工作的技术知识和大量雄心(或足够的好奇心)的人都可以以几乎不存在的成本达到前所未有的观众的地方。

尽管 HTML5 是对以前版本的 HTML 的升级,但这个术语也指的是与标记语言配套升级的其他技术,比如 CSS 和 JavaScript。这三种语言都是为了满足当前的需求而进行升级,以便将网络带入下一个层次。许多新功能的添加旨在将常用功能的实现工作从开发人员转移到浏览器上。曾经由许多开发人员通过艰苦、耗时且经常昂贵的工作完成的工作,现在可以由程序员轻松地通过浏览器完成。此外,HTML5 的许多新功能和能力使网络平台成为桌面范式的一个非常强大的对手。个别桌面计算机在完全隔离的状态下运行,每个计算机从自己的中央存储系统运行程序的想法正在逐渐消失。取而代之的是基于云的范式,所需的软件从连接到网络的一个中央服务器发送到每个用户。由于这些 Web 应用程序在用户的浏览器中执行,因此应用程序的一些主要部分是用纯 HTML5 编写的。

HTML5 是现在掌握的完美技术,因为它是开放网络的核心。由于 HTML5 的响应和不断发展的性质,我们只能等待看看未来对我们有什么安排,因为浏览器继续变得更加强大,计算成本继续下降。

我们将探索令人兴奋的 HTML5 世界,并通过设计和开发有趣而引人入胜的游戏来介绍其主要概念和构建模块。我们采取这种方法不仅因为游戏很有趣,而且因为 HTML5 中许多新的功能非常适合解决编程视频游戏的复杂问题。此外,通过成功地使用纯 HTML5 技术编程完整的游戏,我们将能够测试和证明 HTML5 和开放网络的真正能力。

在下一章中,我们将迈出建立令人惊叹的基于网络的游戏的第一步。首先,我们将通过安装网络服务器来建立开发环境。接下来,我们将建立一个 HTML5 网络门户,通过它我们可以访问我们的游戏,并且可以练习使用新的语义元素。

第二章:HTML5 排版

现在我们的环境已经设置好,我们准备深入研究 HTML5 背后的实际代码。这是本书开始起飞的地方,因为无论你学习了多少理论,要掌握一门编程语言都很难没有一些键盘时间。

本章中我们将开发一个打字游戏,重点是其排版方面。再次提醒你,本书的重点不是教授游戏开发,而是教授你关于 HTML5 的一切。因此,我们将采取的一般方法来编写游戏的代码并不一定是最适合一般游戏开发的,尽管本书涵盖的所有游戏在大多数主要浏览器中都表现得相当不错。

游戏

出于缺乏创意,并且为了避免可能的来自一个脾气暴躁的游戏公司的诉讼,我们将这个第一个游戏简单地命名为Typography Game。我知道,这不是你听说过的最令人印象深刻的游戏,但至少它很好地解释了这个游戏大致是关于什么的。

游戏的整体故事情节以及其一般要点如下:正确地输入一个逐字显示给你的短语,就可以赢得你梦想的船。如果你不能正确且足够快地输入每个字符,那么Snooty McSnootington就会赢得船,你就输掉了游戏技能。

游戏

从单个截图中很难传达关于这个用户界面的所有细节,但是那美丽的海洋中的波浪实际上是非常平滑地动画,船也是自由漂浮并被波浪摇动。此外,虽然整个游戏中只有六张图片,但是游戏中使用的所有组件都是 DOM 元素。船、波浪和字符都是使用 div、图片和其他语义 HTML5 元素完成的。所有的动画都是使用原生 CSS3 功能完成的。

本章的其余部分将列出游戏中使用的所有 HTML、CSS 和 JavaScript 功能,展示如何使用它们以及它们在游戏中的使用方式。所使用的编码风格旨在简单,所以不要在全局变量、不一致的面向对象原则使用以及整体基本的图形上介意。有了基本的 HTML5 概念,你可以通过应用任何额外的改进来使游戏更加完善或从开发的角度来说更具可扩展性。

我们将游戏组织成三个单独的文件:一个index.html文件,我们将在其中托管所有的 HTML 结构并整合其他两个文件,即一个 CSS 文件和一个 JavaScript 文件。这应该是一个相当标准的文件结构,但请随意调整以最好地满足你的需求和习惯。

按照上一章我们建立网页门户时创建的文件结构约定,我们需要在项目的根目录内创建一个名为 typography 的目录。在这个目录内,我们将创建以下文件和目录:

  • packt/typography

  • packt/typography/index.html

  • packt/typography/fonts

  • packt/typography/css

  • packt/typography/css/style.css

  • packt/typography/js

  • packt/typography/main.js

  • packt/typography/img

注意

我将带你走过在本章后面的部分找到并下载自定义字体的过程。至于图片,你可以自己绘制或购买,或者从网站http://www.CHANGE-THIS-FOR-A-REAL-WEBSITE下载我为游戏绘制的相同图片。

游戏元素

在这个游戏中使用了九个 HTML5 元素。每个元素将在其主要类别(HTML、CSS 或 JavaScript)中进行解释。游戏本身由大约 15 个元素组成,如下图所示:

游戏元素

主游戏界面,带有微妙的选项小部件。

游戏结束后,无论玩家是否赢得游戏,都会显示一个记分板,玩家有机会输入他或她的名字,以及开始新游戏。

游戏元素

前面的截图显示了一个消息小部件,指示玩家已经赢了或输了,以及一个排行榜小部件。

为了我们能够轻松识别每个主要的视觉游戏组件,我们将列出它们如下:

选项小部件

这个小部件允许玩家选择预设的难度级别。选择更难的难度级别会使敌人在他的轨道上更快地移动到船上。此外,我们可以使玩家需要输入的短语根据难度设置更难或更容易。然而,我们把这个实现细节留给读者作为练习。

游戏标题

这个基于文本的小部件只是显示游戏的主标题。请注意,那里使用的字体是纯文本(没有图像),使用自定义网络字体。它的唯一目的是装饰用户界面。

这个动画小部件的目的是通过帮助讲述游戏故事来加强用户界面。船是一个简单的 HTML 元素(div),具有代表船的背景图像。船所遵循的动画路径严格由 CSS 完成,并完全由浏览器管理。

天空

这部分 HTML 用于封装用户界面顶部的所有元素,并使其可能对天空进行动画处理。随着游戏的进行,天空的颜色会微妙地变化,以模拟太阳的升起和落下。

波浪

有一个被分类为海洋的 HTML 部分,它封装了存储波浪的区域。每个波浪(在这种情况下有两个)都是一个宽度为 100%的div元素,并且具有代表波浪的背景图像。这个背景图像在整个div元素的宽度上重复,并通过 CSS 进行动画处理,以产生运动的错觉,遵循海洋中的波浪模式。

轨道

这个小部件是一个 HTML 部分,封装了各个轨道小部件,以及使用该轨道的玩家。

玩家

这些图标代表游戏中的个别玩家。为了尽可能简单,游戏中只有两个玩家:你(英雄)和 Snooty McSnootington 先生(敌人)。此外,我们可以很容易地在选项小部件中添加功能,允许玩家为英雄和敌人选择特定的图标,因为控制所使用的图标的代码是一个简单的 CSS 类,可以添加到图标对象中或从中删除。

主容器

这部分 HTML 包含控制游戏的元素,即天空小部件下方的所有内容。

要写的单词

在这里,我们显示用户在游戏开始时必须输入的单词。在幕后,这是一个简单的块级 HTML 元素,其唯一目的只是显示几个单词。

已写的单词

尽管这个小部件与写字小部件相同(当然样式略有不同),但这个小部件更加动态,因为它会响应用户的操作。当用户在键盘上按键时,输入会显示在那里。如果输入的字符与写字小部件中显示的期望字符匹配,那么字符将显示为白色。如果字符不正确,它将显示为红色,并带有一条线,非常明显地表明出现了错误。用户可以使用退格键删除在此小部件中显示的任何或所有字符。当输入每个正确的字符时,英雄将按比例向右移动,与要输入的字符总数相对应。

消息容器

这部分 HTML 显示在一个半透明的元素顶部,以呈现覆盖框的外观。该小部件主要用于作为一种通用的通信工具,通过它我们可以通知玩家事件,比如让他们知道游戏结束了,或者他们赢了或输了游戏。此外,我们还添加了其他四个元素,以使游戏更具吸引力。

消息标题

这个元素在样式和目的上与主游戏标题小部件非常相似,它只是简单地通知用户其余消息容器小部件的内容。

新冠军表单

这种形式背后的想法是模拟旧式街机游戏中使用的老式排行榜。一旦你赢得了与 Snooty McSnootington 先生的比赛,你就有机会输入你的姓名和电子邮件地址,以便在排行榜中显示,如下面的屏幕截图所示。当然,这些信息是象征性的,只是为了说明我们如何使用 HTML5 的网络表单。表单生成的信息不会保存在任何地方,因此,在每次页面刷新后(或者游戏关闭或导航离开时),信息就会消失。再次强调,要么将该表单的内容发送到电子邮件,将其保存到后端服务器,或者甚至在浏览器中本地存储,都是一项微不足道的任务,通过我们将在书的后面讨论的许多持久性或存储 API 中的任何一个。

排行榜

在新冠军表单中输入的任何数据(假设输入的数据有效)都会显示在这里。每个名称旁边的数字只是显示每个名称输入的顺序。名称右侧的星号表示游戏的难度级别(在这种特殊情况下为 1 级-简单)。为了获得更有吸引力的体验,我们可以追踪玩家完成游戏所花费的时间,犯了多少错误,或者以某种方式计算出总体得分并在这里显示。我们选择全名和电子邮件地址的原因是为了展示如何在 HTML5 中执行表单验证。这可能是本游戏中使用的 HTML5 最强大和令人兴奋的功能。开发者过去需要数百行代码,而且经常有大量重复的代码,现在只需要几个 HTML 属性,浏览器就会强制执行。

游戏控制

最后,消息容器小部件包括控件,允许玩家开始新游戏。

注意

为了使代码更简洁,更易于解释,我们将致力于更少的可移植代码,唯一的要求是代码至少在一个浏览器中正确运行。

大部分使用的 API 确实非常可移植(意味着代码在大多数主要浏览器中执行相同),但肯定不是全部,特别是任何需要供应商前缀的实验性 CSS API。为了与以下示例最大兼容性,我建议您使用最新版本的谷歌 Chrome 或谷歌 Chromium,或者至少使用任何基于 webkit 的浏览器,比如苹果 Safari。

采用这种方法的原因首先是为了简化教学过程。没有必要解释一个给定的功能,然后展示代码示例,该示例与之前的功能名称的第一部分不同,但在 99%的情况下是相同的,这是供应商名称。选择 webkit 作为首选浏览器引擎的原因也非常简单;我们必须选择一些东西,那为什么不选择 webkit 呢?此外,谷歌 Chrome 和苹果 Safari 在市场上有很大的渗透率,以及一套令人难以置信的工具,可以帮助我们进行开发(例如谷歌 Chrome 的开发者工具)。

最后,游戏中使用的个 HTML5 元素,以及它们对应的类别:

HTML

这个游戏中使用的 HTML 功能可以在 JavaScript 中或直接由浏览器使用。Web 表单元素和属性在浏览器中提供了很好的功能,而数据属性在与 JavaScript 绑定时更有意义。在我们的“排版游戏”中,我们在有意义的上下文中构建了这些元素,但我们当然可以使用其他技术(例如严格在代码中存储数据属性所代表的数据),或者以不同的方式使用我们使用的元素和属性。

Web 表单

HTML5 中添加的新表单 API 是语言中最显著的增加之一。通过它,您可以访问 13 种新的输入类型,以及无数新的属性,使表单开发快速、有趣和迷人。大多数增加的功能在视觉上可能会让您感到熟悉,因为它们在浏览器中的本地效果已经存在很长时间了。这些效果包括占位文本、表单验证、自动对焦字段等等。

游戏中使用了 13 种新的输入类型中的两种,以及每种输入类型的一些属性,包括表单验证和字段占位符。以下是这些元素在游戏中的简要说明。关于它们的工作原理和如何使用它们的深入讨论可以在下一节中找到。

范围输入

新的范围输入类型是一个滑块,允许用户通过水平移动滑块来选择数值。在游戏中,我们使用范围输入作为选择难度级别的手段。该游戏中指定的范围是从 1 到 3,1 表示最容易的难度级别,3 表示最困难的难度级别。

范围输入

包含范围输入类型的容器使用 CSS 在不使用时切换选项菜单并使其淡出。

电子邮件输入

电子邮件输入类型看起来与旧的文本输入类型完全相同,但具有一些好处。首先,在移动设备上使用时,输入类型提示操作系统它期望的信息类型,此时操作系统可以向用户显示特殊的键盘。例如,如果用户尝试输入数据到只允许数字的数字输入类型中,移动操作系统可以显示只有数字的键盘。在电子邮件类型的情况下,通常显示的键盘包括@符号,这使用户更容易更方便地输入信息到网络表单中。

使用电子邮件输入类型的第二个好处,也是对桌面用户的好处,是浏览器本身可以验证用户输入的数据。如果字段设置为验证,并且字段中的数据不符合电子邮件地址的基本格式,浏览器将告诉用户存在问题,表单将无法提交。

在游戏中,每当玩家获胜时,我们会要求用户输入他或她的全名和电子邮件地址。这些信息将用于排行榜,就像您可能在旧游戏中看到的那样。虽然用户不被强制输入表单中要求的任何信息,但如果用户选择输入任何信息,浏览器将自动验证数据。

自动表单验证的令人兴奋之处在于,您可以通过包括的 HTML 属性自定义表单,仅验证必填字段,错误消息的内容等等。此外,即使用户在浏览器中禁用了 JavaScript 功能,表单仍将由浏览器验证和处理。当然,您很清楚,从用户那里获取输入的主要规则之一是您永远不应信任用户,并始终在服务器上验证和清理任何和所有用户输入。

电子邮件输入

如果没有输入数据或格式错误,浏览器会告诉你,并阻止表单提交。

数据属性

作为努力的一部分,为了更语义化的文档结构,HTML5 允许我们创建自己的自定义元素属性。在 HTML5 之前,大多数浏览器简单地忽略了它不理解的元素属性(例如,专门为某个应用程序制作的自定义属性),但这种做法的缺点包括文档无法验证,行为有些不可预测,还有可能语言的新版本引入了同名的属性,从而使旧的自定义属性无效。现在,我们可以安全地为元素创建自定义属性,上述提到的任何缺点都不适用。

在我们的游戏中,我们使用数据属性来做两件事,即指定玩家可以在赛道上移动的最小速度,并指定一个通用按钮应该触发一个新游戏(因此,具有该数据属性的任何按钮都不需要在 JavaScript 中额外的逻辑来表现得像一个特殊按钮)。

CSS

由于这个游戏旨在展示 HTML5 更多的视觉方面,我们大部分的努力都集中在使游戏界面成为真正的眼睛糖果。由于 CSS 主要关注视觉呈现,大多数采用的特色都属于这个类别。

网络字体

在 HTML5 能够处理自定义字体之前,网页设计师和开发人员只能使用少数几种字体在网站或 Web 应用程序中使用。随着时间的推移,出现了一些解决这个问题的解决方案,但没有一个特别令人印象深刻,大多数(如果不是全部)都破坏了一些浏览器的功能。例如,一种常见的显示自定义文本的技术涉及使用 JavaScript 动态替换字符串中的每个字符,使用自定义字体的实际图像。这种方法的问题包括需要创建和管理所有这些图像。用户必须下载所有这些图像,最糟糕的是,生成的文本无法被选择、复制、调整大小,颜色也无法更改。

现在我们可以简单地指定字体的名称,以及实际的字体文件,如果用户的操作系统没有安装该字体,浏览器可以下载并像任何其他字体一样使用。

在游戏中,我们使用了三种不同的自定义字体来创建恰到好处的视觉效果,并使游戏中的文本与期望的视觉主题相匹配。使用的三种字体都是开源字体,可以从互联网上免费下载和使用。使用的字体是 Lemon,Mystery Quest 和 Fredericka the Great。这些名字很棒,你同意吗?

网络字体

在寻找游戏的一些字体之前,我不知道这些字体的存在。最重要的是,我只花了几分钟时间浏览了大量的开源字体(来自 Google 的 Web 字体工具),找到了我想要的。

由于字体文件是从服务器下载的外部资产,因此在浏览器开始下载字体文件和页面准备好渲染之间存在一段时间。不同的浏览器处理这种情况的方式不同。例如,webkit 会隐藏文本,直到字体资产准备就绪。其他浏览器可能会使用备用或默认字体渲染文本,直到网络字体文件准备就绪,然后切换字体并重新渲染文本。

过渡

CSS 过渡属性告诉浏览器它适用于哪些属性以及过渡应持续多长时间。一旦这些属性发生变化,浏览器将插值开始和结束状态,并在指定的持续时间内生成非常平滑的过渡效果。当然,这只能应用于由某些数值表示的属性,例如字体大小,背景颜色(由 RGB 或 HSL 值或十六进制数表示,所有这些都可以转换为百分比),元素位置等。在 CSS 过渡中不会平滑插值的值包括字体系列,背景图像或任何其他没有中间值的属性,例如显示块和显示无。

在游戏中,过渡的唯一用途是选项菜单,消息容器以及将玩家移动到轨道上。选项菜单设置为从屏幕左侧推出,代表它的主图标为 75%的透明度。一旦用户将鼠标悬停在该图标上,它的透明度就会过渡到零(完全可见),并且菜单的其余部分会过渡到其左侧,向右移动,直到其左边缘与浏览器的左边缘对齐。

消息容器使用类似的效果,它始终位于屏幕顶部,其宽度默认为窗口视口的 100%,高度默认设置为零(当容器处于“关闭”状态时)。当我们想向用户显示消息时,将 CSS 类open添加到容器小部件中,将容器的高度设置为 100%,从而触发平滑过渡,模拟滑入效果。

最后,我们使用过渡效果将玩家从右侧移动到各自绑定的轨道上。这是一个非常容易实现的任务,尽管英雄和敌人的控制方式略有不同。敌人移动的方式很简单:在游戏计时器的每个滴答声中,我们通过改变其左侧样式属性来增加敌人的水平位置,增加的数值是其数据速度数据属性中设置的。两点之间的平滑过渡由浏览器处理。英雄的移动方式类似,唯一的区别是数据速度始终设置为零(否则它会自动移动,而无需用户输入任何内容),每次按键按下时,我们都会检查用户输入的字符是否符合预期,如果是,则英雄会相对于正确输入的字符的百分比和总字符数的百分比向轨道的末端前进。例如,如果用户正确输入了具有 100 个字符的短语的第十个字符,那么我们将英雄移动到其轨道的 10%处。英雄和敌人都有检查机制,以防止它们被移动超出各自轨道的宽度。

动画

CSS3 最强大的功能可能是动画属性,它允许命名关键帧动画,非常类似于以前流行的 Adobe Flash。它的工作方式非常简单:您创建一个动画序列,给它一个名称,并指定一个或多个 CSS 属性,以便在每个关键帧上应用。每个关键帧代表这些属性应该添加到您将其与动画序列关联的任何元素上的时间点。然后,浏览器会平滑地插值两个关键帧之间的每个时间点,并实现动画的幻觉。

在游戏中,我们使用动画来赋予海浪生命,让船在其航道上移动,并随着时间的推移使天空变暗和变亮,从而模拟太阳的升起和落下。虽然这可能看起来是一项复杂的任务,但动画元素是如此简单,以至于你可能遇到的主要限制可能是创造力。如果你对 CSS 和如何使用它来为元素应用各种样式有一定了解,那么学习和使用动画 API 应该是一个自然的下一步。

动画

每个动画对象都只是一个带有背景图像的div元素。波浪的背景图像设置为在 x 轴上重复,而船的背景图像设置为不重复。船的宽度和高度设置为与其代表的图像相匹配,而每个波浪的高度设置不同(位于一切之后的波浪略高一些,以便它可以在另一组波浪后面看到),但宽度设置为 100%,以便始终填充查看应用程序的监视器的宽度,无论该监视器有多宽。在动画周期结束时,对象沿着相同的路径,但是反向移动,使得动画看起来连续而且始终平滑。

最容易动画化的元素是天空,因为它只有两个关键帧。在第一个关键帧中,代表天空的div元素的背景被设置为浅蓝色。最后一个关键帧将背景颜色更改为略深的蓝色。为了获得更加戏剧性的效果,我们可以让最后一个关键帧为背景定义一个非常深的颜色。为了更加戏剧性地表示夜晚降临,我们还可以在这个天空元素的顶部添加另一个div,其背景图像是一个带有散布的白点的透明图像。每个点代表一颗星星。然后,以与天空变暗相同的速度,我们将这个元素的不透明度设置为更加可见(不透明度更低),以便从完全透明到完全不透明进行动画。最终效果是随着天空变暗,星星会出现。

船只使用了三个关键帧进行动画:第一个将其放置在略高于海浪的某个位置,第二个将其向右上移动,第三个关键帧将其移动得更高,稍微向左移动。使得动画在这些点之间看起来有些自然,并且更像是真实生活中海洋中的运动的技巧是使得物体在两个不同关键帧之间移动的距离不同。例如,船在第一个和第二个关键帧之间移动的水平距离与第二个和第三个关键帧之间使用的水平位移不同。这些关键帧之间的垂直位移更加激烈。如果所有距离都相同,我们的眼睛会习惯于相同的运动模式,动画很快会显得太重复和无聊。

动画波浪同样容易。虽然有两组波浪,它们都使用相同的动画设置。唯一的区别是,位于另一组波浪后面的波浪(后面的波浪)设置为移动速度较慢,以便看起来它离得更远,动画看起来不同。

在这些波浪元素中进行的所有动画(记住,波浪元素只是一个带有重复背景图像的div)只是背景图像的位置。div元素本身始终是静态的,并且绝对定位在彼此之上。由于元素在其背景图像透明的地方是透明的,我们能够将背景颜色应用于包含这三个元素的元素(波浪和船),我们将其设置为天空元素,从而使背景颜色动画化。

尽管最终结果看起来有趣且稍微复杂,但组合这种东西所需的工作实际上并不比使用纯 CSS 设置任何其他设计更复杂或困难,特别是因为这只是纯粹的旧 CSS。

注意

在撰写本文时,有一些旨在帮助开发人员创建和管理关键帧动画的工具。虽然许多这些工具是免费的,许多完全基于云(使用 HTML5 技术编写),但如果您正在寻找一个企业级工具,以帮助您构建与 Adobe Flash 中看到的类似的专业动画,那么您需要投资一些现金来获得更先进和精细调整的工具。尽管其中一些工具可能不是预算有限的开发人员(或根本没有预算的开发人员)的最佳选择,但它们的质量和功能通常远远超出任何免费工具可以提供的范围。

如果您只是为了好玩或学习经验而开发,那么在线可用的大量免费工具应该足够让您开始使用 CSS3 关键帧动画。然而,如果您的目标是构建高端应用程序,并且需要对动画进行高精度和控制,那么专业工具可能是值得投资的。

一个特别受欢迎的免费基于 Web 的 CSS 动画生成器可以在www.css3maker.com/找到。另一方面,Adobe 推出了一款名为 Adobe Edge Animate 的出色产品,可以在html.adobe.com/edge/animate/上购买。

文本阴影

CSS 中的这个新文本属性允许您在文本周围模拟阴影效果。在幕后,浏览器真正做的是创建文本的副本,然后根据您指定的垂直和水平偏移值将其位移在原始文本的后面。您还可以告诉浏览器通过指定介于零和您所需的任何整数值之间的值来模糊这个“阴影”版本的文本。在某一点上,取决于原始文本的大小,模糊度很高,以至于文本几乎是看不见的,因此提供非常大的数字可能是适得其反的。

游戏中唯一使用文本阴影的情况是在消息容器的标题中。由于游戏的其余用户界面使用了非常平坦的图形,几乎没有渐变,我认为我可以使用文本阴影来添加一个坚实的、较浅的阴影,以延续平面、单维度图形的主题。

文本阴影

CSS3 文本阴影 API 允许您指定文本字符串的任意数量的位移和模糊副本。

框阴影

与文本阴影类似,框阴影在特定元素的后面放置一个或多个框,指定为参数的垂直和水平偏移量,第三个参数是要应用于阴影的模糊量。您可以指定纯色或使用可选的 alpha 通道,以添加不同级别的不透明度。或者,阴影可以应用于其绑定的容器的内部。请注意,如果元素的任何框阴影被放置在框的后面,它们将被放置在边框的外部,如果有的话,忽略元素可能具有的任何边距。放置在元素内部的阴影将被放置在边框的内部,如果有的话,忽略添加到元素的任何填充。

在游戏中,有两个 CSS 盒子阴影的实例。一个显示了元素后面的传统阴影,并应用于消息容器。游戏中另一个盒子阴影的实例应用于容纳每个玩家的轨道。在这种情况下,阴影旨在传达轨道被按入页面的效果,这是通过使用将阴影放置在框内的属性来实现的。如果阴影放在框外,给出的效果是框叠加在页面上。

盒子阴影

每个轨道底部的白线只是一个底部边框,但可以通过添加第二个盒子阴影来实现相同的效果。

边框半径

在边框半径属性可用之前,可以通过将圆角图像定位到元素的角落来实现它提供的相同效果。这是一项繁琐的任务,最终效果很少如预期那样令人印象深刻。有了 CSS3 的边框半径属性,我们可以将任意数量的圆角应用于一个或多个container元素的角落。

注意

请记住,尽管可以通过为所有四个角指定足够大的边框半径值使container元素看起来完全圆形,但对于浏览器来说,该元素仍然是一个矩形。换句话说,如果您浮动两个或更多通过 CSS 制作成圆形的元素,它们将像矩形而不是圆形一样行为。

HTML5 中元素和文本的流动仍然是基于矩形的,尽管有一些实验性的 API 允许我们通过指定任意形状来流动文本。有关这些特定的 CSS API(称为regionsexclusions)的更多信息,请参见第六章,为您的游戏添加功能

游戏中只使用了一次 CSS 边框半径,即在导航选项小部件的右侧。该 API 允许我们指定要应用边框半径的特定边,并为了演示这一特性,容器的四个边中只有两个被圆角化。

玩家图标本可以更符合 HTML5 的风格,而不仅仅是将透明图像应用于元素的背景。一种可能性是将每个图标都作为矩形图像,然后将其应用于代表每个玩家的容器的背景,然后我们可以使用边框半径使元素看起来完全圆形。还可以添加可选的盒子阴影,以创建与用于创建图像的照片编辑软件实现的相同阴影效果。使用这种技术的一个好处是,原生效果会更好地缩放,这意味着如果您放大或缩小页面,图像最终看起来会变形和像素化,而对元素添加的任何边框半径、盒子阴影或边框始终会看起来平滑和清新。

JavaScript

尽管驱动这个游戏的逻辑相当简单,而且相当受限,但为了使游戏运行,仍需要相当多的 JavaScript 代码。由于我们试图专注于 HTML5 的特性,我们只会看一下代码中使用的一个可以更多或少被视为 HTML5 的特定 API。这就是选择器 API,它是由 W3C 于 2006 年 5 月首次起草的。

查询选择器

如果您在过去几年中进行过任何网页开发,您肯定听说过或使用过并爱上了流行的 JavaScript 库 jQuery。在其众多强大功能中,jQuery 最有用的工具之一是其令人惊叹的 DOM 选择器 API,它允许您仅使用 CSS 选择器和伪选择器而不是使用有限的document.getElementById()document.getElementsByClassName()document.getElementsByName()等方法来检索 DOM 元素。

好消息是,这个强大的节点选择器 API 现在完全是现代浏览器的本机功能。由于该功能是浏览器的本机功能,因此速度更快,更稳定。此外,由于该功能是浏览器的本机功能,因此无需导入库来处理任务。

这个游戏,以及本书中描述的所有其他游戏,都使用了新的选择器 API 级别 1。由于没有可见的元素可以从查询选择器中看到,我们将在下一节深入讨论其用法,同时还将看一些代码示例。

API 使用

现在我们已经讨论了所有游戏元素以及每个 HTML5 功能是如何用来实现这一角色的,让我们更深入地了解如何充分利用这些 API。对于以下每个 API,我们将提供该功能的更具体定义,其预期用途是什么,并将跟随一个代码示例。您还可以参考附在本章末尾的完整源代码,以填补代码示例与该功能如何与游戏代码基础的其他部分配合的差距。还建议您跟着编码,并尝试各种设置和值,以便实验和更全面地理解每个 API。

Web 表单

新的 HTML5 Web 表单 API 添加了 13 种新的输入类型,可以实现更灵活、更强大的体验。更重要的是,Web 表单还能够自行验证,无需任何 JavaScript 干预。

新的输入类型

以下是 HTML5 规范中新的 Web 表单章节中定义的新输入类型:

日期

日期输入类型允许用户从浏览器提供的日历中选择特定的日期。此日历的特定格式和样式是与使用的浏览器和平台相关的。日期选择结果的数据格式为YYYY-MM-DD

<input type="date"
  min="1935-12-16"
  max="2013-08-19"
/>

可选属性minmax可用于强制验证用户选择的日期在给定范围内。日期输入类型的其他有效属性包括以下内容:

  • name(值必须是字符串):通过与属性关联的字符串值标识特定字段

  • disabled(可接受的值包括disabled""或空):指定该元素被禁用,无法接收控制

  • autocomplete(可接受的值包括onoff):指定浏览器是否应存储用户输入的值,因此将来可以由浏览器自动完成用户提示时输入的存储值

  • autofocus(可接受的值包括autofocus""或空):指定元素在文档加载完成后必须立即接收焦点

  • min(值必须是形式为"yyyy-mm-dd"的有效日期):指定用户可以选择的最低日期

  • max(值必须是形式为"yyyy-mm-dd"的有效日期):指定用户可以选择的最高日期

  • readonly(可接受的值包括readonly""或空):指定该元素的值不能被用户更改

  • step(可接受的值包括any或任何正整数):指定元素的值属性如何更改

  • required(可接受的值包括required""或空):指定该元素必须具有有效值,以便允许表单提交

  • value(值必须是形式为"yyyy-mm-dd"的有效日期):指定由该元素表示的实际日期

月份输入类型允许用户从浏览器提供的日历中选择特定的月份和年份。此日历的特定格式和样式是与使用的浏览器和平台相关的。日期选择结果的数据格式为YYYY-MM

<input type="month"
  min="1935-12"
  max="2013-08"
/>

日期输入类型的其他有效属性包括以下内容:

  • name(值必须是字符串):通过与属性关联的字符串值标识特定字段

  • disabled(可接受的值包括disabled""或空):指定该元素已禁用,不能接收控制

  • autocomplete(可接受的值包括onoff):指定浏览器是否应存储用户输入的值,以便将来可以由浏览器自动完成用户的提示输入的存储值

  • autofocus(可接受的值包括autofocus""或空):指定在文档完成加载后,元素必须立即获得焦点

  • min(值必须是形式为“yyyy-mm”的有效日期):指定用户可以选择的最早日期

  • max(值必须是形式为“yyyy-mm”的有效日期):指定用户可以选择的最高日期

  • readonly(可接受的值包括readonly""或空):指定此元素的值不能被用户更改

  • step(可接受的值包括any或任何正整数):指定元素的值属性如何更改

  • required(可接受的值包括required""或空):指定此元素必须具有有效值,以便允许表单提交

  • value(值必须是形式为“yyyy-mm”的有效日期):指定由此元素表示的实际日期

周输入类型允许用户从浏览器提供的日历中选择一年中的特定周。此日历的特定格式和样式是独特的,取决于所使用的浏览器和平台。日期选择的结果数据形式为YYYY-Www(例如,2013-W05)。

<input type="week"
  min="1935-W51"
  max="2013-W34"
/>

日期输入类型的其他有效属性包括以下内容:

  • name(值必须是字符串):通过与属性关联的字符串值标识特定字段

  • disabled(可接受的值包括disabled""或空):指定该元素已禁用,不能接收控制

  • autocomplete(可接受的值包括onoff):指定浏览器是否应存储用户输入的值,以便将来可以由浏览器自动完成用户的提示输入的存储值

  • autofocus(可接受的值包括autofocus""或空):指定在文档完成加载后,元素必须立即获得焦点

  • min(值必须是形式为“yyyy-Www”的有效日期,其中“ww”必须是周数的两位数字表示):指定用户可以选择的最早日期

  • max(值必须是形式为“yyyy-Www”的有效日期,其中“ww”必须是周数的两位数字表示):指定用户可以选择的最高日期

  • readonly(可接受的值包括readonly""或空):指定此元素的值不能被用户更改

  • step(可接受的值包括any或任何正整数):指定元素的值属性如何更改

  • required(可接受的值包括required""或空):指定此元素必须具有有效值,以便允许表单提交

  • value(值必须是形式为“yyyy-Www”的有效日期,其中“ww”必须是周数的两位数字表示):指定由此元素表示的实际日期

时间

时间输入类型允许用户选择一天中的特定时间。此元素中的数据格式为HH:MM:SS.Ms

<input type="time"
  min="16:23:42.108"
  max="23:59:59.999"
/>

日期输入类型的其他有效属性包括以下内容:

  • name(值必须是字符串):通过与属性关联的字符串值标识特定字段

  • disabled(可接受的值包括disabled""或空):指定该元素已禁用,不能接收控制

  • autocomplete(可接受的值包括onoff):指定浏览器是否应存储用户输入的值,以便将来可以由浏览器自动完成用户的提示输入的存储值

  • autofocus(可接受的值包括autofocus""或空):指定元素在文档加载完成后必须立即获得焦点

  • min(值必须是部分时间的有效形式,如"HH:MM:SS.Mss"、"HH:MM:SS"或"HH:MM"):指定用户可以选择的最低日期

  • max(值必须是部分时间的有效形式,如"HH:MM:SS.Mss"、"HH:MM:SS"或"HH:MM"):指定用户可以选择的最高日期

  • readonly(可接受的值包括readonly""或空):指定此元素的值不能被用户更改

  • step(可接受的值包括any或任何正整数):指定元素的值属性如何更改

  • required(可接受的值包括required""或空):指定此元素必须具有有效值,以便允许提交表单

  • value(值必须是部分时间的有效形式,如"HH:MM:SS.Mss"、"HH:MM:SS"或"HH:MM"):指定此元素表示的实际日期

日期时间

日期时间输入类型允许用户从浏览器提供的日历中选择特定日期和时间(包括时区)。此日历的特定格式和样式对于所使用的浏览器和平台是独特的。日期选择结果的数据形式为YYYY-MM-DDTHH:MM:SS-UTC

<input type="datetime"
  min="1935-12-16T16:23:42-08:00"
  max="2013-08-19T23:59:59-09:00"
/>

日期输入类型的其他有效属性包括以下内容:

  • name(值必须是字符串):通过与属性关联的字符串值标识特定字段

  • disabled(可接受的值包括disabled""或空):指定该元素被禁用,不能接收控制

  • autocomplete(可接受的值包括onoff):指定浏览器是否应存储用户输入的值,以便将来可以由浏览器自动完成用户的提示输入的存储值

  • autofocus(可接受的值包括autofocus""或空):指定元素在文档加载完成后必须立即获得焦点

  • min(值必须是有效的日期时间,如 RFC 3339 中定义):指定用户可以选择的最低日期

  • max(值必须是有效的日期时间,如 RFC 3339 中定义):指定用户可以选择的最高日期

  • readonly(可接受的值包括readonly""或空):指定此元素的值不能被用户更改

  • step(可接受的值包括any或任何正整数):指定元素的值属性如何更改

  • required(可接受的值包括required""或空):指定此元素必须具有有效值,以便允许提交表单

  • value(值必须是有效的日期时间,如 RFC 3339 中定义):指定此元素表示的实际日期

日期时间本地

日期时间本地输入类型允许用户从浏览器提供的日历中选择特定日期和时间(不包括时区)。此日历的特定格式和样式对于所使用的浏览器和平台是独特的。日期选择结果的数据形式为YYYY-MM-DDTHH:MM:SS

<input type="datetime-local"
  min="1935-12-16T16:23:42"
  max="2013-08-19T23:59:59"
/>

日期输入类型的其他有效属性包括以下内容:

  • name(值必须是字符串):通过与属性关联的字符串值标识特定字段

  • disabled(可接受的值包括disabled""或空):指定该元素被禁用,不能接收控制

  • autocomplete(可接受的值包括onoff):指定浏览器是否应存储用户输入的值,以便将来可以由浏览器自动完成用户提示的存储值

  • autofocus(可接受的值包括autofocus""或空):指定文档完成加载后元素必须立即接收焦点

  • min(值必须是有效的部分时间,格式为“YYYY-MM-DDTHH:MM:SS.Mss”,“YYYY-MM-DDTHH:MM:SS”或“YYYY-MM-DDTHH:MM”):指定用户可以选择的最低日期

  • max(值必须是有效的部分时间,格式为“YYYY-MM-DDTHH:MM:SS.Mss”,“YYYY-MM-DDTHH:MM:SS”或“YYYY-MM-DDTHH:MM”):指定用户可以选择的最高日期

  • readonly(可接受的值包括readonly""或空):指定此元素的值不能被用户更改

  • step(可接受的值包括any或任何正整数):指定元素的值属性如何更改

  • required(可接受的值包括required""或空):指定此元素必须具有有效值,以便允许提交表单

  • value(值必须是有效的部分时间,格式为“YYYY-MM-DDTHH:MM:SS.Mss”,“YYYY-MM-DDTHH:MM:SS”或“YYYY-MM-DDTHH:MM”):指定此元素表示的实际日期

颜色

颜色输入类型允许用户从浏览器提供的颜色选择器中选择特定颜色。此颜色选择器小部件的特定格式和样式是独特的,取决于所使用的浏览器和平台。尽管小部件的某些实现可能以不同格式(RGB 或 HSL)提供值,但从颜色选择结果的数据是颜色的十六进制表示形式,格式为#RRGGBB

<input type="color"
  value="#900CC1"
/>

日期输入类型的其他有效属性包括以下内容:

  • name(值必须是字符串):通过与属性关联的字符串值标识特定字段。

  • disabled(可接受的值包括disabled""或空):指定元素已禁用,无法接收控制。

  • 自动完成(可接受的值包括onoff):指定浏览器是否应存储用户输入的值,以便将来可以由浏览器自动完成用户提示的存储值。

  • autofocus(可接受的值包括autofocus""或空):指定文档完成加载后元素必须立即接收焦点。

  • value(值必须是有效的十六进制颜色,长度为七个字符,格式为“#rrggbb”或“#RRGGBB”):指定此元素表示的实际颜色。不允许使用关键字,如 Color。

电子邮件

电子邮件输入类型允许用户输入电子邮件地址。在提供数字键盘进行数据输入的移动设备上,此输入类型提示系统提供最适合输入电子邮件地址的键盘。

<input type="email"
  placeholder="Enter an email address"
  pattern="\w{3,}@packtpub\.com"
  maxlength="23"
/>

日期输入类型的其他有效属性包括以下内容:

  • name(值必须是字符串):通过与属性关联的字符串值标识特定字段。

  • disabled(可接受的值包括disabled""或空):指定元素已禁用,无法接收控制。

  • autocomplete(可接受的值包括onoff):指定浏览器是否应存储用户输入的值,以便将来可以由浏览器自动完成用户提示的存储值。

  • autofocus(可接受的值包括autofocus""或空):指定文档完成加载后元素必须立即接收焦点。

  • maxlength(值必须是非负整数):指定元素可以包含的最大字符数。

  • pattern(值必须是由 ECMA 262 定义的有效正则表达式模式):指定浏览器必须根据指定的输入验证的模式。

  • size(值必须是正整数):指定元素显示的最大字符数,尽管可能允许输入更多字符。

  • placeholder(值必须是字符串):指定要显示给用户作为提示的字符串,以指示字段期望的信息。当数据输入到字段中时,此字符串消失,并在字段变为空时显示。

  • multiple(可接受的值包括multiple""或空):指定此元素允许多个值。

  • readonly(可接受的值包括readonly""或空):指定此元素的值不能被用户更改。

  • required(可接受的值包括required""或空):指定此元素必须具有有效值,以便允许提交表单。

  • value(值必须是有效的电子邮件地址,并且必须遵守模式属性指定的任何进一步限制):指定此元素表示的实际电子邮件地址,或者当存在多个属性时,指定有效电子邮件地址的逗号分隔列表。

Note

对于不熟悉 JavaScript 正则表达式语言或需要复习的人,以下是语法的摘要:

[rodig](括号):用于匹配括号内找到的任何内容。例如:匹配括号内的任一字母。

[^rodig](负括号):用于匹配括号内未找到的任何内容。例如,匹配括号内字母之外的任何字符。

[D-M](范围):用于匹配一系列字符或数字。例如,匹配大写字母 D 和 M 之间的任何字符。

(me|you|us)(管道):用于匹配备选选项。例如,匹配括号内的任一单词。

.(句号):匹配任何字符,除了换行符或行终止符。

\w(单词字符):匹配任何字母、数字或下划线。

\W(非单词字符):匹配任何不是单词字符的字符。

\d(数字):匹配任何单个数字。

\D(非数字):匹配任何非数字字符。

\s(空格):匹配空格。

\S(非空格):匹配任何不是空格字符的字符。

\b(单词边界):在单词的开头或结尾找到匹配。

\B(非单词边界):查找不是单词边界的匹配。

\0(空字符):匹配字符串中的空字符。

\n(换行符):匹配换行符。

\f(换页符):匹配换页符。

\r(回车符):匹配回车符。

\t(制表符):匹配制表符。

\v(垂直制表符):匹配垂直制表符字符。

+(加号量词):匹配前一个表达式或字符一次或多次。

*(星号量词):匹配前一个表达式或字符零次或多次。

?(问号量词):匹配前一个表达式或字符零次或一次。

{3,5}(括号量词):分别匹配前一个表达式的最小和最大次数。如果最大数字缺失,匹配将继续直到找到一个非匹配。例如:\d{1,} 匹配一个或多个数字。

^(尖角修饰符):在字符串的开头匹配表达式。

$(美元修饰符):在字符串的末尾匹配表达式。

Number

数字输入类型允许用户从浏览器提供的任何机制中选择数字,或者如果浏览器只提供标准输入字段,则只需输入数字值。浏览器会验证该值,以确保用户确实输入了数字。在移动设备上,数字键盘用于数据输入,该输入类型提示系统应提供最适合输入数字的键盘。

<input type="number"
  min="42"
  max="108"
  step="2"
/>

日期输入类型的其他有效属性包括以下内容:

  • 名称(值必须是字符串):通过与属性关联的字符串值标识特定字段。

  • 禁用(可接受的值包括disabled""或空):指定该元素被禁用,不能接收控制。

  • 自动完成(可接受的值包括onoff):指定浏览器是否应存储用户输入的值,以便在用户提示时浏览器可以自动完成将来输入的存储值。

  • 自动对焦(可接受的值包括autofocus""或空):指定元素在文档加载完成后必须立即获得焦点。

  • 最小值(值必须是浮点数):指定用户可以选择的最小数字。

  • 最大值(值必须是浮点数):指定用户可以选择的最大数字。

  • 只读(可接受的值包括readonly""或空):指定该元素的值不能被用户更改。

  • 占位符(值必须是字符串):指定要显示给用户的字符串,作为字段期望的信息提示。当数据输入到字段中时,该字符串消失,并在字段变为空时显示。

  • 步长(可接受的值包括any或任何正整数):指定元素的值属性如何改变。

  • 必填(可接受的值包括required""或空):指定该元素必须具有有效值,以便允许表单提交。

  • 值(值必须是浮点数):指定由该元素表示的实际浮点数。

范围

范围输入类型允许用户使用浏览器提供的滑块小部件从指定范围中选择一个数字。范围选择的具体格式和样式是独特的,取决于所使用的浏览器和平台。范围选择的结果数据是一个浮点数。

<input type="range"
  min="42"
  max="108"
  step="0.5"
/>

日期输入类型的其他有效属性包括以下内容:

  • 名称(值必须是字符串):通过与属性关联的字符串值标识特定字段。

  • 禁用(可接受的值包括disabled""或空):指定该元素被禁用,不能接收控制。

  • 自动完成(可接受的值包括onoff):指定浏览器是否应存储用户输入的值,以便在用户提示时浏览器可以自动完成将来输入的存储值。

  • 自动对焦(可接受的值包括autofocus""或空):指定元素在文档加载完成后必须立即获得焦点。

  • 最小值(值必须是浮点数):指定用户可以选择的最小数字。

  • 最大值(值必须是浮点数):指定用户可以选择的最大数字。

  • 只读(可接受的值包括readonly""或空):指定该元素的值不能被用户更改。

  • 占位符(值必须是字符串):指定要显示给用户的字符串,作为字段期望的信息提示。当数据输入到字段中时,该字符串消失,并在字段变为空时显示。

  • 步长(可接受的值包括any或任何正整数):指定元素的值属性如何改变。

  • required(可接受的值包括required""或空):指定该元素必须具有有效值,以便允许表单提交。

  • value(值必须是浮点数):指定由该元素表示的实际浮点数。

搜索

搜索输入类型允许用户输入用于搜索的字符串。总体而言,搜索输入类型看起来和行为非常像常规文本输入类型。一些浏览器可能会为该字段添加杂项行为,例如内置图标或小部件,以立即清除字段,但这些都不是规范的一部分。

<input type="search"
  placeholder="Search"
  pattern="[^!\?]"
/>

日期输入类型的其他有效属性包括以下内容:

  • name(值必须是一个字符串):通过与属性关联的字符串值标识特定字段。

  • disabled(可接受的值包括disabled""或空):指定该元素已禁用,无法接收控制。

  • autocomplete(可接受的值包括onoff):指定浏览器是否应存储用户输入的值,以便将来可以由浏览器自动完成用户提示的存储值。

  • autofocus(可接受的值包括autofocus""或空):指定给浏览器,元素在文档完成加载后必须立即获得焦点。

  • maxlength(值必须是非负整数):指定元素可以包含的字符的最大长度。

  • pattern(值必须是由 ECMA 262 定义的有效正则表达式模式):指定浏览器必须根据指定的输入验证模式。

  • size(值必须是正整数):指定元素显示的最大字符数,尽管可能允许输入更多字符。

  • placeholder(值必须是一个字符串):指定要显示给用户的字符串,作为字段期望的信息的提示。当数据输入到字段中时,此字符串消失,并在字段变为空时显示。

  • multiple(可接受的值包括multiple""或空):指定此元素允许多个值。

  • readonly(可接受的值包括readonly""或空):指定该元素的值不能被用户更改。

  • required(可接受的值包括required""或空):指定该元素必须具有有效值,以便允许表单提交。

  • value(值必须是一个没有换行符或回车字符的字符串):指定由该元素表示的实际搜索查询。

电话

电话输入类型允许用户输入电话号码。

<input type="tel"
  placeholder="Enter your phone number"
  required
/>

日期输入类型的其他有效属性包括以下内容:

  • name(值必须是一个字符串):通过与属性关联的字符串值标识特定字段。

  • disabled(可接受的值包括disabled""或空):指定该元素已禁用,无法接收控制。

  • autocomplete(可接受的值包括onoff):指定浏览器是否应存储用户输入的值,以便将来可以由浏览器自动完成用户提示的存储值。

  • autofocus(可接受的值包括autofocus""或空):指定给浏览器,元素在文档完成加载后必须立即获得焦点。

  • maxlength(值必须是非负整数):指定元素可以包含的字符的最大长度。

  • pattern(值必须是由 ECMA 262 定义的有效正则表达式模式):指定浏览器必须根据指定的输入验证模式。

  • size(值必须是正整数):指定元素显示的最大字符数,尽管可能允许输入更多字符。

  • placeholder(值必须是字符串):指定要显示给用户的字符串,作为字段期望的信息的提示。当数据输入到字段中时,此字符串消失,并在字段变为空时显示。

  • multiple(可接受的值包括multiple""或空):指定此元素允许多个值。

  • readonly(可接受的值包括readonly""或空):指定此元素的值不能被用户更改。

  • required(可接受的值包括required""或空):指定此元素必须具有有效值,以便允许表单提交。

  • value(值必须是没有换行符或回车符的字符串):指定此元素表示的实际电话号码。

网址

url 输入类型允许用户输入网站网址。

<input type="url"
  placeholder="Enter your website address"
  required
/>

日期输入类型的其他有效属性包括以下内容:

  • name(值必须是字符串):通过与属性关联的字符串值标识特定字段。

  • disabled(可接受的值包括disabled""或空):指定元素已禁用,不能接收控制。

  • autocomplete(可接受的值包括onoff):指定浏览器是否应存储用户输入的值,以便将来可以由用户提示时由浏览器自动完成存储的值。

  • autofocus(可接受的值包括autofocus""或空):指定向浏览器,元素必须在文档完成加载后立即获得焦点。

  • maxlength(值必须是非负整数):指定元素可以包含的最大字符长度。

  • pattern(值必须是由 ECMA 262 定义的有效正则表达式模式):指定浏览器必须根据指定的输入验证模式进行验证。

  • size(值必须是正整数):指定元素显示的最大字符数,尽管可能允许输入更多字符。

  • placeholder(值必须是字符串):指定要显示给用户的字符串,作为字段期望的信息的提示。当数据输入到字段中时,此字符串消失,并在字段变为空时显示。

  • multiple(可接受的值包括multiple""或空):指定此元素允许多个值。

  • readonly(可接受的值包括readonly""或空):指定此元素的值不能被用户更改。

  • required(可接受的值包括required""或空):指定此元素必须具有有效值,以便允许表单提交。

  • value(值必须是没有换行符或回车符的字符串):指定此元素表示的实际网址。

表单验证

尽管表单提交将自动验证插入表单的数据并提醒用户可能的错误,但是有一个明确定义的 API,可以让我们更多地控制验证过程和报告,而不仅仅是默认的。

有效状态对象

每个form元素都附有一个类型为ValidityState的对象,其中包含与该节点的验证状态相关的属性列表。您可以直接从form元素访问此对象,并手动检查其属性:

var firstName = document.querySelector("#myForm input[name='firstName']");
//firstName.validity == ValidityState {
  valid : false,
  customError : false,
  badInput : false,
  stepMismatch : false,
  rangeOverflow : false,
  rangeUnderflow : false,
  tooLong : false,
  patternMismatch : false,
  typeMismatch : false,
  valueMissing : false
}

具有这些属性,我们能够检查每个form元素,并真正定制表单的验证程序。然而,鉴于自动验证是一个旨在节省时间和精力的吸引人的功能,我们将专注于可以最好地帮助我们实现自动验证的功能。

自定义验证

ValidState的属性之一是布尔值customError,它指定了是否为字段元素设置了自定义错误消息,或者浏览器是否要显示一个通用的错误消息,以防表单不通过验证。要设置自定义错误消息,我们可以调用form元素本身的setCustomValidity()方法,并在需要时为浏览器分配一条消息,如下所示:

var email = document.querySelector("#myForm input[type='email']");
email.pattern = "\\w{3,}@packtpub\\.com";
email.setCustomValidity("Please enter a valid Packt email address...");

自定义验证

刚刚看到的表单中的第一个条目是无效的,因为它不包含字符串packtpub.com。第二个表单是有效的,因为它符合指定的模式。请注意第一个表单上的自定义错误消息,这在任何支持这些特性的浏览器中都是相同的,而不是具有可能因浏览器而异的通用错误消息。

在游戏中使用

游戏中有两个单独的实例使用了 Web 表单 API。第一个是在游戏选项小部件中,使用了范围输入类型,允许用户选择游戏的难度,另一个是在新冠军表单中使用,允许用户输入他/她的全名以及电子邮件地址。

<nav id="navOptions">
  <div>
    <p>Difficulty &raquo; <span>1</span></p>
    <input type="range" step="1" min="1" max="3" value="1" />
  </div>
  <img src="img/options-icon.png" />
</nav>

在这里,我们设置了一个基本的范围输入,指定最大值为3。默认情况下,大多数浏览器会将stepminvalue属性的值都设置为1,但为了安全起见,我们将在这里指定这些值,以防某个浏览器在这些属性未指定的情况下处理方式有所不同。

<form>
  <input type="text" name="fullName"
    pattern="\w{2,16}\s\w{2,16}"
    placeholder="Full Name (Ex: John Doe)"
    autofocus
  />
  <input type="email" name="email"
    placeholder="Email address"
  />
  <input type="submit" value="Save" />
</form>

通常,当要求名字时,您会希望在表单中有两个单独的字段来分别输入名字和姓氏。然而,在单个字段中要求输入全名,使得该字段成为自定义模式属性的一个很好的候选项。

在这种情况下用于验证全名的模式非常简单;我们将输入视为 2 到 16 个单词字符(字母,在这种情况下甚至可能是数字)之间的一个单词,后面跟着一个单个的空格,然后是另一个长度大于 2 但小于 16 个字符的单词。

为了避免在表单中需要额外的标签,我们向两个输入字段元素添加了占位符字符串。这样,表单既可以简洁,又足够描述,用户永远不会困惑于表单在问什么。

数据属性

当需要在 HTML 元素中存储数据,而没有其他更适合保存该数据的属性时,HTML5 规范提供了一个特殊的属性来处理这种情况。尽管规范特别将这个属性称为自定义数据属性,但大多数人简单地称之为data属性。

自定义数据属性的工作方式非常简单。只需创建您选择的属性名称,以data-前缀开始名称关键字,然后使用您选择的任何长度至少为一个字符的关键字。唯一的限制是关键字不能包含任何大写字母,尽管所有 HTML 元素属性默认会自动转换为小写。您可以向单个元素添加任意数量的这样的自定义属性。最后,任何自定义数据属性都可以具有您选择的任何值,将空字符串作为其值,或者为空,在任何情况下该属性都被认为具有值 true(在其缺失的情况下为 false)。

<!-- Indicates an element that display some sort of score -->
<input type="text" id="scoreDisplay"
  <!-- Indicates that this score is not yet a new high score -->
  data-is-high-score="false"
  <!-- Indicates the current high score -->
  data-score-to-beat="891,958"
  <!-- Not a good use of data attributes, since the disabledattribute is a better choice -->
  data-enabled="false"
/>

在刚刚看到的示例代码中,我们有一些 DOM 节点,碰巧是一个input标签,代表了某个游戏的得分。前两个示例数据属性,data-is-high-scoredata-score-to-beat,都是很好的例子。仅仅通过观察每个属性的名称,我们就可以推断它们应该被用在什么样的上下文中。

第一个属性用于保存一个布尔值,表示标题元素显示的分数是否是新的最高分。由于它当前的值是 false,显然当前的分数还不是新的最高分。

第二个属性存储当前的最高分,这意味着如果玩家得分高于该值,他或她的分数将成为新的最高分,并且属性data-is-high-score应该更新为 true。请记住,这些属性是静态的和无意义的,您的应用程序逻辑负责根据它们的上下文赋予它们意义,并处理对它们的任何更新,例如前面描述的示例。

最后,注意第三个“数据”属性示例。这不是数据属性的一个非常实际的用法,因为存在另一个 HTML 元素属性,专门用于指定元素是否被禁用。

要以编程方式添加、删除和检查元素的属性及其值,可以使用以下 JavaScript API:

var myInput = document.getElementById("scoreDisplay");

// Check for the presence of an attribute
if (myInput.getAttribute("data-is-high-score") == null) {

  // If attribute is not present, add it to the element with somedefault value
  myInput.setAttribute("data-is-high-score", false);
}

// If attribute is present, check its value
else {

  var isHighScore = myInput.getAttribute("data-is-high-score");

  if (isHighScore) {
    // Do something with this new high score
  } else {
    // The current score is not yet a new high score
  }
}

在游戏中使用

在游戏中有几个不同目的和不同原因使用自定义“数据属性”的用法。如前所述,一个用途是指定玩家当前的速度。另外两个属性的用途是用于识别和区分一个玩家与另一个玩家,并将打算以相同方式行为的不同按钮分组。

注意

虽然官方规范规定应始终使用最合适的属性在 DOM 元素中存储数据,但您还应该记住,可能会有时候可能会变得模糊,这时您应该优先考虑您的具体需求和目标。

例如,如果您尝试使用“自定义数据”属性来命名一组相关元素,可能会出现一个问题,即简单的 CSS 类可以实现相同的结果。有人可能会认为,由于 CSS 类已经存在,它们的目的是将相关元素分组,其他人可能会认为,如果这些元素之间没有共享特定样式,那么使用这样的自定义数据属性是完全合理的。然而,最终的决定因素应该是特定的应用程序目标和需求,因此,例如,如果向这些元素添加一个符号 CSS 类会使其变得混乱,因为没有真正对应的 CSS 类存在,那么使用自定义数据属性的确是合理的。

<section class="tracks">
  <div class="track">
    <span data-name="badGuy" data-speed="0"></span>
  </div>

  <div class="track">
    <span data-name="goodGuy" data-speed="0"></span>
  </div>
</section>

请注意,div.track元素内的每个 span 元素都具有名称数据属性,用于区分英雄玩家和敌人玩家,以及速度数据属性,用于指定每个元素在游戏计时器的每个滴答中移动多少。无论该数字代表像素、百分比还是其他单位,都是无关紧要的,因为两个玩家的值都设置为零,这意味着它们在每个计时器周期中都不移动。

数据属性名称是否可以通过 CSS 类更好地表示可以在任何方向上进行辩论。在这种情况下,我选择使用数据属性,因为这样样式可以独立于任何内容,这个问题可以委托给应用程序的另一部分,而不会有任何犹豫。

<button data-intent="play">Play Again</button>
<section id="mainContainer">
  <div id="wordsToWrite"></div>
  <div id="wordsWritten"></div>
  <button data-intent="play">Play</button>
</section>

这里有两个分开的按钮,它们共享相同的数据属性intent = "play"。有了这个属性,以及分配的值,我们可以使用一些 JavaScript 逻辑来处理这些按钮,以及其他任何按钮,使它们的行为可预测和通用。

// Select all buttons with a custom data attribute of intent anda value of play
var playBtns = document.querySelectorAll("button[data-intent='play']");
// Assign the same click handler to all of these buttons
for (var i = 0, len = playBtns.length; i < len; i++) {
  playBtns[i].addEventListener("click", doOnPlayClicked);
}

// Now every button with data-intent="play" executes thisfunction when clicked
function  doOnPlayClicked(event) {
  event.preventDefault();

  // Play button click behavior goes here
}

查询选择器

作为新选择器接口的一部分,有两种不同但非常相似的 API。一个选择与使用的查询匹配的元素集合,另一个只匹配一个。如果单个选择器版本中的查询匹配了多个节点,则只返回第一个出现的节点。

要使用此接口,您需要在文档对象上调用适当的函数,并提供表示 CSS 查询的字符串。现在,您可以停止通过其 ID 选择元素,然后导航到奇怪的子路径,最终以编程方式定位要目标的元素。

注意

在此功能可用之前,开发人员通常会在其文档结构中添加 ID 和类属性,以使通过代码定位这些元素变得更加容易。虽然使用 CSS 表达式来定位特定节点可能很容易,以至于您可能会觉得不再需要向元素添加唯一 ID 或类,以便通过代码更容易地定位该元素,但请记住,长的 CSS 表达式可能会非常复杂,以至于应用程序的性能可能会因为导航到这些元素所需的时间而受到影响。请记住,CSS 查询越短,浏览器匹配涉及的元素就越容易。

考虑游戏中的以下代码片段,我们尝试向包含游戏选项的导航菜单中找到的图像元素注册点击事件侦听器:

<nav id="navOptions">
  <div>
    <p>Difficulty &raquo; <span>1</span></p>
    <input type="range" step="1" min="1" max="3" value="1" />
  </div>
  <img src="img/options-icon.png" />
</nav>

<script>
// 1\. Capture the image element inside that nav structurewith id="navOptions"

// ----------------------------------
// Without query selectors:
// ----------------------------------
var nav = document.getElementById("navOptions");
var img = null;

// Iterate through every child node of nav instead ofdirectly targeting the current
// position of that image element in case the structureof #navOptions change,
// in which case this code wouldn't need to be updated.
for (var i = 0, len = nav.children.length; i < len; i++) {
  if (nav.children[i].tagName == "IMG") {
    img = nav.children[i];
    break;
  }
}

// -----------------------------------
// With the query selectors:
// -----------------------------------
var img = document.querySelector("#navOptions img");

// 2\. Set the click handler
if (img) {
  img.addEventListener("click", doOnOptionsClicked);
}
</script>

您将注意到,演示选择元素的旧方法使用了非常防御性的编程风格。虽然微不足道的选择可能不需要这样的偏执措施,但是一个大型应用程序肯定会从这种方法中受益,以防 DOM 中找不到特定元素,并且尝试向保存空引用的变量添加事件侦听器。无论如何,您可以看到新的选择器 API 如何解决了这个特定情况下的问题,因为无论#navOptions子树中可能添加或删除了哪些其他元素,querySelector("#navOptions img")语句中使用的 CSS 查询仍然有效,而nav.children[1]可能不再引用相同的元素,如果#navOptions的结构发生变化。

此外,如果使用querySelectorAll接口,如果未使用提供的 CSS 查询匹配任何元素,则querySelector的调用将返回 null。当使用querySelectorAll接口时,请记住每当找到匹配时都会返回一个列表,无论选择了一个还是多个元素。因此,如果只匹配了一个元素,您仍然需要索引到结果集中,以匹配返回的唯一元素。

<div id="wordsWritten">
  <span class="correct">I</span>
  <span class="correct">love</span>
  <span class="correct">HTML5</span>
  <span class="wrong">?</span>
</div>

<script>
var correctWords = document.querySelectorAll("#wordsWritten .correct");
// correctWords == [
//  <span class="correct">I</span>,
//  <span class="correct">love</span>,
//  <span class="correct">HTML5</span>]

var wrongWords = document.querySelectorAll("#wordsWritten .wrong");
//  wrongWords == [
//    <span class="wrong">?</span>]
</script>

在游戏中使用

如前所述,游戏中的每个节点选择都是使用查询选择器完成的。值得一提的是,不可能一次向一组节点注册事件侦听器。您需要遍历整个列表(或至少部分列表)以触及列表中的一个或多个单独节点。

// Select a collection of zero, one, or more buttons
var playBtns = document.querySelectorAll("button[data-intent='play']");

// Assign the same click handler to all of these buttons
for (var i = 0, len = playBtns.length; i < len; i++) {
  playBtns[i].addEventListener("click", doOnPlayClicked);
}

// This does not work >> TypeError: Object [object Array] hasno method 'addEventListener'
  playBtns.addEventListener("click", doOnPlayClicked);

如果您尝试调用通常会直接在querySelectorAll调用的结果集上调用的任何函数,您将收到TypeError,因为调用的函数应用于数组元素而不是每个元素。

Web 字体

新的 Web 字体 API 对所有那些直到现在都不得不依赖图像才能使网络真正美丽的 Web 开发人员来说特别令人兴奋和解放。

要使用自定义字体,我们使用 CSS 属性@font-face并指定一些属性,例如字体的名称和字体文件,浏览器将遵循并下载到客户端,就像它对客户端调用的图像、视频和声音文件等资源一样。

@font-face {
  font-family: "Lemon",
  src: url("/fonts/lemon.woff") format("woff");
}

h1 {
  font-family: "Lemon", Arial, sans-serif;
}

Web 字体的唯一注意事项是并非所有浏览器都支持相同的字体类型。虽然解决方案很简单,但可能有点麻烦,因为它涉及将不同的文件格式上传到您的服务器,然后在 font-face 声明中指定每一个。当浏览器遇到您的自定义字体声明时,它可以下载它可以处理的文件格式并将其提供给客户端。

@font-face {
  font-family: "Lemon",
  src:url("/fonts/lemon.woff") format("woff"),
      url("/fonts/lemon.eot") format("eot"),
      url("/fonts/lemon.ttf") format("truetype"),
      url("/fonts/lemon.svg#font") format("svg");
}

截至目前,Google Chrome,Firefox 3.6 和 Microsoft Internet Explorer 9 接受.woff字体文件,而 Safari,Opera 和 Android 支持.ttf文件。苹果的 iOS 只支持.svg字体文件。

或者,您可以将整个字体文件编码为Data-URI字符串并将其嵌入到您的 CSS 中。

@font-face {
  font-family: "Lemon";
  src: url("data:font/opentype;base64,d09GRgABAAAAA...");
}

注意

一个很好的免费开源网络字体资源是 Google 的 Web 字体项目。在那里,您可以找到一个直接包含数百种不同字体文件的资源,您可以从中搜索并直接导入到您的项目中。每个文件都托管在 Google 服务器上,这意味着这些字体的可用性非常高,交付速度也非常快。更重要的是,通过他们的服务,一旦您找到一个想要导入到您的项目中的字体,Google 会为您提供三种导入选项:标准链接rel="stylesheet"标签,CSS@import语句或 JavaScript 替代方案。无论您做出哪种选择,最终提供给最终用户的字体文件都是请求浏览器支持的确切格式。这样,您就不需要在 CSS 文件中指定多个src: url属性。

过渡

CSS 过渡是向任何现有网站添加特殊效果的一种很好且简单的方法。很可能您现有的项目已经使用了某种基于不同事件的元素状态变化,比如悬停效果,这可能会导致元素改变大小、颜色或位置。

通过向这些元素添加 CSS 过渡属性,您可以更加精确地控制原始状态和最终状态之间的各种状态。例如,如果链接默认设置为蓝色,当用户将鼠标光标移动到文本上时,字体颜色变为紫色,CSS 过渡会使文本平滑地逐渐从蓝色变为紫色,而不是在眨眼之间改变颜色属性。

请记住,只有可能具有中间状态的属性才能在状态之间过渡。通常,您可以通过查看分配给属性的值来确定特定属性是否具有中间状态。如果值是一个数字,比如 10px、2.5em 或 50%,那么您可以确定过渡属性会导致逐渐变化到最终状态。由于颜色最终都是由数字表示的,无论是十六进制值还是其他值,我们也可以将过渡属性应用到颜色上。

#navOptions {
  position: relative;
  top: 10px;
  left: -230px;
  width: 325px;
  overflow: auto;
  padding: 10px;
  border-radius: 0 10px 10px 0;
  -webkit-transition: all 0.3s;
}

#navOptions.open {
  left: 0;
  background: rgba(100, 100, 100, 0.5);
  padding-right: 15px;
}

在这个例子中,具有navOptions属性的元素被赋予了一个过渡属性。默认情况下,该元素的左侧位置为-230px,填充为10px,没有背景颜色。然后我们定义了一个名为open的类,并将其与#navOptions元素具体关联。这个类为左侧、填充和背景属性指定了不同的值。因此,每当#navOptions元素被分配到类.open时,这三个属性将从默认值逐渐变化为新值。

请注意,过渡属性分配了一个特定于浏览器的前缀。这是为了简单起见,但在您的生产代码中,您可能希望检查每个浏览器关于该特定属性的状态,并指定可能需要的所有值,以及在浏览器删除前缀时的纯粹的非前缀版本:

#navOptions {
  position: relative;
  top: 10px;
  left: -230px;
  width: 325px;
  overflow: auto;
  padding: 10px;
  border-radius: 0 10px 10px 0;

  -webkit-transition: all 0.3s; /* Webkit-based browsers */
  -moz-transition: all 0.3s;     /* Mozilla Firefox */
  -o-transition: all 0.3s;          /* Opera */
  transition: all 0.3s;              /* One day, every browser. Today, any browser not in experimental */
}

刚才看到的示例使用简写来定义所有四个可能的属性,但它们也可以单独声明:

#navOptions {
  position: relative;
  top: 10px;
  left: -230px;
  width: 325px;
  overflow: auto;
  padding: 10px;
  border-radius: 0 10px 10px 0;

  transition-property: padding;
  transition-duration: 0.3s;
  transition-timing-function: linear;
  transition-delay: 1s;
}

简写中,这些参数的顺序是:属性持续时间时间函数延迟。您还可以通过使用逗号分隔的属性列表在同一声明中指定多个属性:

#navOptions {
  position: relative;
  top: 10px;
  left: -230px;
  width: 325px;
  overflow: auto;
  padding: 10px;
  border-radius: 0 10px 10px 0;

  transition: padding 0.3s ease-out 0.1s, left 0.5s linear,background ease-in 1s 1s;
}

请注意,您可以指定任意数量的属性,或者简单地使用关键字all。另外,如前面的示例所示,并非所有值都需要相同(每个属性可以具有不同的持续时间、时间函数或延迟)。默认延迟为0(意味着一旦触发属性更改,过渡就会立即开始),时间函数的默认值为 ease。

动画

动画 API 与过渡 API 有些相似,但主要区别在于我们可以指定两个或更多个关键帧,浏览器在这些关键帧之间进行过渡。关键帧就是时间点,为该特定时间点指定了一系列属性。

要使用 CSS 动画,首先需要创建一个命名的关键帧序列,为每个关键帧指定所有属性,然后将该关键帧序列分配给某个元素。与过渡类似,当您将关键帧序列指定给一个元素时,您还要指定该序列的配置,如动画名称、持续时间、定时函数、延迟、迭代次数(播放整个关键帧序列的次数)、方向(动画是从第一个关键帧到最后一个关键帧播放,还是从最后一个关键帧到第一个关键帧播放),以及播放状态(指示动画是运行还是暂停)。

设置关键帧序列,只需使用@keyframes关键字,后面跟着标识该序列的字符串。

@keyframes myAnimation {
}

然后,与其他 CSS 属性略有不同,我们在这个声明中嵌套其他表达式,其中每个子表达式都是一个单独的关键帧声明。由于每个关键帧都是一个时间点(在应用动画序列到元素时指定总时间,我们很快就会看到),我们可以以两种方式之一指定每个关键帧:我们可以指定时间的百分比,以确定何时调用关键帧,或者我们可以使用fromto关键字,指示已经过去了总动画时间的 0%和 100%的时间点。

@keyframes myAnimation {
  from {
    background: #ffffff;
  }

  to {
    background: #000000;
  }
}

请注意,from关键字的行为与 0%完全相同,to关键字与 100%完全相同。使用其中一个纯粹是个人偏好的问题。

@keyframes myAnimation {
  0% {
    left: 0px;
    top: 0px;
  }

  25% {
    left: 0px;
    top: 50%;
  }

  50% {
    left: 50px;
    top: 100%;
  }

  75% {
    left: 50px;
    top: 100%;
  }

  100% {
    left: 0px;
    top: 0px;
  }
}

毋庸置疑,与动画接口相关的供应商前缀问题也适用。

-webkit-@keyframes myAnimation {
  from {
    background: #ffffff;
  }

  to {
    background: #000000;
  }
}

#sky {
  -webkit-animation-name: myAnimation; 
  /* This is how you link a keyframe sequence to an element */
  -webkit-animation-duration: 3s; 
  /* Can be a value in seconds (s) or milliseconds (m) */
  -webkit-animation-timing-function: ease-out; 
  /* Can be linear, ease, ease-in, or ease-out */
  -webkit-animation-iteration-count: 23; 
  /* Can be any non-negative integer or "infinite" */
  -webkit-animation-direction: alternate; 
  /* Default is "normal" */
  -webkit-animation-play-state: running; 
  /* Can also be "paused" */
}

文本阴影

文本阴影接口比过渡或动画 API 更简单,因为它只有四个基本参数,但在增加美观的视觉元素方面同样强大。这些参数是阴影相对于文本的水平和垂直偏移量,要应用到阴影的模糊程度,最后是阴影的颜色,可以具有可选的 alpha 通道以增加不透明度。

h1 {
  text-shadow: -5px 5px 0 #000;
}

可以通过逗号分隔的列表添加多个阴影到同一个元素中:

h1 {
  text-shadow: -5px 5px 0 #000, 5px -5px 0 rgba(50, 50, 50, 0.3);
}

此外,文本阴影可以添加到通过 HTML5 的 Web 字体嵌入到页面中的自定义字体:

h1 {
  text-shadow: 1px 1px 5px #000;
  font-family: "Lemon";
}

盒子阴影

盒子阴影与文本阴影相同,只是有一些非常重要的区别。首先,它们不适用于文本,而只适用于盒子元素。实际上,您可以将盒子阴影属性应用到文本元素(如p标签、h1、h2、h3 等),但效果会截然不同。文本阴影效果本质上只是渲染应用了阴影的文本的偏移和模糊副本,而盒子阴影效果只是元素的宽度、高度、边距和边框创建的矩形的副本,以 CSS 中指定的颜色、偏移和模糊值渲染。

div {
  box-shadow: 5px 5px 3px #aaa;
}

与文本阴影一样,我们可以通过逗号分隔的阴影声明列表向同一个元素应用多个盒子阴影。

div {
  box-shadow: 5px 5px 3px #aaa, -10px -10px 30px rgba(255, 255, 255, 0.01);
}

如果像刚才展示的那样应用多个阴影,任何后续的阴影都应该绘制在先前绘制的阴影后面,如果它们碰巧重叠。例如,以下一组阴影将显示为单个红色阴影,因为红色(#cc0000)首先声明,并且它们恰好覆盖相同的区域。如果阴影有任何程度的模糊,效果将是阴影的混合。由于在这个特定的例子中,阴影完全实心,不会发生混合,前面的阴影优先(因为它在渲染堆栈中绘制得更高)。

div {
  box-shadow: 5px 5px 0 #cc0000, 5px 5px 0 #0000cc;
}

还有第四个值可以在盒子阴影中指定,它指定了阴影的扩散(或大小)。默认值为零,这意味着模糊将从包含元素创建的容器的边缘开始。阴影扩散产生的效果类似于放置在模糊和包含元素创建的容器之间的边框。

最后,可选的inset关键字告诉浏览器从容器的边框向内绘制阴影,而不是从边框(或者如果边框宽度大于零,则从边框的位置)向外沿水平和垂直偏移方向绘制。

div {
  box-shadow: inset 5px 5px 3px #aaa;
}

请注意,在多重阴影声明中,每个阴影都可以指定自己的渲染方向。

div {
  box-shadow: inset 5px 5px 3px #aaa, 
  /* This shadow is drawn inside the div */
    5px 5px 3px #aaa; /* And this shadow is drawn outside it */
}

边框半径

边框半径属性允许我们使元素尺寸形成的容器的角落变圆。如果有任何内容使角落的圆弧减少了容器的物理区域,那么该内容就不会被绘制。最后,边框半径声明可以通过单个值指定,其中该值应用于所有角落(请注意,这里我们指的是角落,而不是边,如边框声明中所述),通过提供四个不同的值(角落的顺序为左上,右上,右下和左下),或者通过单独定位每个角落。

div.one {
  border-radius: 5px; /* Make all four corners round by 5px */
}

div.two {
  border-radius: 5px 10px 
  /* Top left and bottom right = 5px, top right andbottom left = 10px */

div.three {
  border-top-left-radius: 4px;
  border-top-right-radius: 8px;
  border-bottom-left-radius: 15px;
  border-bottom-right-radius: 16px;
}

代码

现在您已经熟悉了在这个有趣游戏中使用的 HTML5 API,让我们来看看游戏是如何组合起来的。由于简洁和易于解释,这里只列出或解释了这个游戏源代码的主要部分。请务必在本书网站上下载游戏的完整源代码。

HTML 结构

这个游戏的第一个组件是 HTML 结构。它的主要部分是轨道,每个玩家移动的地方,以及显示用户需要输入的文本的容器。还有一个第二个容器,显示玩家实际输入的文本。为了定制,有一个输入类型范围,允许玩家改变游戏的难度级别,这在实际上只增加了敌方玩家的速度属性。

<section class="tracks">
  <div class="track">
    <span data-name="badGuy" data-speed="0"></span>
  </div>

  <div class="track">
    <span data-name="goodGuy" data-speed="0"></span>
  </div>
</section>

<section id="mainContainer">
  <div id="wordsToWrite"></div>
  <div id="wordsWritten"></div>
  <button data-intent="play">Play</button>
</section>

其他元素只是为了增加体验,要么是视觉上(通过动画和其他视觉组件),要么是通过更多的交互。但这些是基本的游戏组件,没有它们游戏就无法运行。

JavaScript 和逻辑

这个游戏的逻辑分为三个非常基本的组件,即一个Player类,封装了每个玩家的行为,一个游戏循环函数,根据游戏计时器定期调用,以及一些全局函数,封装了应用程序生命周期中使用的各种逻辑片段。

Player类持有一个引用,表示玩家的 DOM 节点,玩家奔跑的轨道,并定义了一些基本行为来控制玩家。

function Player(query) {
  // Hold a reference to the DOM element that they playerwill control
  var element = document.querySelector(query);
  var trackWidth = parseInt(element.parentElement.offsetWidth);
  var minLeft = 0 - parseInt(element.offsetWidth / 2);
  var maxLeft = trackWidth - parseInt(element.offsetWidth / 2);

  // Move the player based on whatever speed is set inits custom data attribute
  this.move = function() {
    var left = parseInt(element.style.left);
    var speed = parseInt(element.attributes.getNamedItem("data-speed").value);

    element.style.left = (left + speed) + "px";

    if (left > maxLeft) {
      this.moveToFinish();
    } else if (left < minLeft) {
      this.moveToStart();
    }
  };

  // Manually move the player to a certain point along its track,independent of
  // what its speed data attribute is.
  this.moveToPercent = function(percent) {
    element.style.left = parseInt(percent * maxLeft) + "px";

    if (percent >= 100) {
      this.moveToFinish();
    } else if (percent <= 0) {
      this.moveToStart();
    }
  };

  // Determine if the player has reached the end of its track
  this.isFinished = function() {
    return parseInt(element.style.left) >= maxLeft;
  };

  // Place the player at the beginning of its track
  this.moveToStart = function() {
    element.style.left = this.getMinLeft() + "px";
  };

  // Move the player to the very end of its track
  this.moveToFinish = function() {
    element.style.left = this.getMaxLeft() + "px";
  };
}

在全局范围内,我们创建了这个玩家类的两个实例,一个代表游戏的英雄,另一个代表我们试图击败的敌人。

var hero = new Player("[data-name='goodGuy']");
var enemy = new Player("[data-name='badGuy']");

游戏开始时,我们初始化一些代码,这意味着我们确定游戏计时器的运行速度,敌方玩家的移动速度,用户要输入的短语,最重要的是,我们在 HTML 结构的 body 元素上注册了一个键盘事件。换句话说,我们监听页面上任何地方的每次按键,以便在游戏开始后检测用户输入的内容。

这可能是游戏中最复杂的功能,因为我们必须自己处理每次按键。这意味着我们需要考虑用户是否按下了Shift键(在这种情况下,他们输入了大写字母),或者是否按下了特殊的键组合。例如,如果用户按下Backspace键,默认情况下浏览器会通过将网页导航回上次导航的页面来响应此事件。显然,我们不希望浏览器离开我们的游戏。相反,我们希望删除用户输入的最后一个字符。因此,诸如此类的细节必须考虑在内。

最后,对于用户输入的每个字母,我们必须检查该字母是否是我们期望的相同字母(正确的字母),或者用户是否输入了错误的字母。根据这个决定,我们将刚刚输入的字母输出到屏幕上,并附上一些 HTML,使我们能够根据是否是正确的键来以不同的方式呈现该字母。

function handleKeyPress(event) {

  var keyCodes = {
    SHIFT_KEY: 16,
    BACKSPACE_KEY: 8, 
    SPACEBAR_KEY: 32, 
    COMMA_KEY: 188, 
    PERIOD_KEY: 190
  };

  var wordsLen = wordsWritten.children.length;

  // If the Shift key was entered, just ignore it
  if (event.keyCode == keyCodes.SHIFT_KEY)
    return false;

  // If the backspace key was entered, don't let thebrowser navigate away
  if (event.keyCode == keyCodes.BACKSPACE_KEY) {
    event.preventDefault();

    // If we have deleted every letter entered by the user,don't do anything else
      if (wordsLen < 1)
        return false;

      // If the user has pressed the backspace key, andthere is at least one letter
      // that the user had typed in before, delete thatletter from where it was output.
      // Note that some browsers might not supportthe .remove() function on a node,
      // but rather use the removeChild() function onthe node's parent element.
    wordsWritten.children[wordsLen - 1].remove();
    return false;
  }

  // Determine what character the user has typed in
  var letter = String.fromCharCode(event.keyCode);

  // If the charactered enterd by the user is a letter,capitalize it if the Shift key was pressed
  if (!event.shiftKey && event.keyCode >= 65 &&event.keyCode <= 90)   
    letter = String.fromCharCode(event.keyCode + keyCodes.SPACEBAR_KEY);

  // Convert special character codes into their corresponding character
  if (event.keyCode == keyCodes.COMMA_KEY)
    letter = ",";

  if (event.keyCode == keyCodes.PERIOD_KEY)
    letter = ".";

  // Determine if they letter entered is right or wrong,and print special HTML to
  // indicate that. Move the hero if the letterentered was correct.
  if (letter == words[wordsLen]) {
    wordsWritten.innerHTML += "<span class='correct'>"+ letter + "</span>";
    var correct = document.querySelectorAll("#wordsWritten .correct").length;
    var percent = correct / words.length;
    hero.moveToPercent(percent);
  } else {
    wordsWritten.innerHTML += "<span class='wrong'>"+ letter + "</span>";
  }

  // By returning false from a key press event,we further prevent the browser
  // from taking any default action based on thekey combination entered.
  return false;
}

在主游戏计时器的每个滴答声中,我们可以根据玩家选择的难度使其变快或变慢,我们调用我们的主游戏循环,执行一些基本任务。首先我们移动每个玩家,然后检查他们是否到达了赛道的终点。这是通过调用每个玩家的isFinished()函数来完成的,该函数由Player类定义。如果是这种情况,那么我们知道游戏结束了。如果是这样,我们取消绑定到 body 元素的键盘事件,这样我们就不再检查用户的任何键盘输入。在确定游戏结束后,我们确定用户是否实际赢得或输掉了游戏。如果他们赢了,我们记录他们的胜利。如果他们输了,我们告诉他们,并允许他们玩新游戏。如果游戏还没有结束,我们只需等待游戏循环再次运行。

function tick() {

  hero.move();
  enemy.move();

  if (isGameOver()) {
    document.body.removeEventListener("keydown", handleKeyPress);

    if (hero.isFinished()) {
      gamesWon++;
      showWinPanel();
    } else if (enemy.isFinished()) {
      showLosePanel();
    }
  } else {
    setTimeout(tick, tickPeriod);
  }
}

摘要

在本章中,我们终于跳出了上一章设立的跳板,直接投入了 HTML5 游戏开发的深邃世界。我们首先建立了用于构建此游戏的项目结构,并讨论了游戏的主要目标和目的。接下来,我们查看了游戏中使用的每个组件,并讨论了它们的使用方式和原因。最后,我们深入研究了构成这些组件的每个 HTML5 API,并查看了一些代码示例来使它们工作。

由于游戏的完整源代码相当冗长,我们只是简要地查看了代码的主要结构,这样当您从 Packt 的网站下载完整的源代码时,代码对您来说会有些熟悉。再次提醒您,因为本书的重点不是游戏开发,而是 HTML5,用于制作此游戏的方法和技术可能不是最优化的游戏开发方法。尽管如此,这款游戏是纯 HTML5 编码的,在任何现代的基于 webkit 的浏览器中都能很好地运行和表现。游戏开发的讨论以及更复杂的技术超出了本书的范围。

第三章:理解 HTML5 的重要性

在我们深入探讨本章将构建的游戏之前,我们将研究为什么在多个不同的浏览器中部署 HTML 和 JavaScript 应用程序可能会很困难。我们将重点关注这些问题的简单和实用解决方案,特别是关于今天使用的 HTML5 和最新 API。

我们将在本章中构建的游戏是一个基本的果冻重力游戏。它将利用 HTML5 的新 API 进行矢量图形、本地音频处理和拖放。作为这个游戏渲染系统的支撑,我们将使用旧的 JavaScript 定时器,正如我们将看到的,这对于我们需要每秒多次更新的这种游戏来说并不合适。幸运的是,现代浏览器已经解决了这个问题,并考虑到了我们对高效渲染引擎的需求。然而,我们不会在下一个游戏之前讨论这个新功能。只是为了完整起见,这个新功能被称为requestAnimationFrame

浏览器兼容性

任何做过任何网页开发的人都很快就对不同浏览器解释和渲染相同代码的方式产生了非常深刻和彻底的厌恶。然而,如果我们深入研究一下这种现象,并寻找这些差异的根本原因,一些人会惊讶地意识到问题并不是看起来那样。虽然找到渲染差异的原因很容易,例如,一些浏览器以不同的方式定义框模型,但找到代码差异的原因可能并不那么清晰。令人惊讶的是,一些开发人员似乎对 JavaScript 语言感到厌恶,因为一些代码在某些浏览器中运行方式不同。然而,事实是 JavaScript 实际上是相当可移植的,它的 API 非常稳定和一致。

信不信由你,这些头疼大部分是由 DOM API 引起的,而不是 JavaScript 本身。一些浏览器以一种方式注册与 DOM 相关的事件,而其他浏览器则不承认该方法,而是使用自己的变体。对于操作 DOM 元素和子树也是如此。

例如,从 DOM 中删除节点的一种方法是在节点本身上调用remove方法。然而,截至目前,只有极少数浏览器公开了这个功能。通常,浏览器允许我们通过在父节点上调用removeChild方法,传递要从父节点中删除的子节点的引用,来从 DOM 树中删除节点。

这里要强调的关键点是:JavaScript 本身在不同浏览器中非常一致,但浏览器允许我们通过编程方式与 DOM 进行交互的方式,尽管这通常是通过 JavaScript 完成的,但在不同浏览器中可能会有所不同。虽然这对任何人来说都不是新闻,当然也不是 HTML5 独有的,但重要的是要记住,我们用于编程 Web 平台的主要工具,也就是 JavaScript,是一个非常强大和一致的工具。我们需要记住的问题是 DOM API(以及 CSS,尽管这个特定问题正在变得越来越不是问题,因为浏览器开始就与之相关的共同标准达成一致)。

支持不同的浏览器

在开发 HTML5 应用程序时,我们可以采取不同的方法来确保代码在不同浏览器中运行相同,并且设计也相同。其中一些做法是痛苦和繁琐的,另一些是不可靠的,还有一些是足够好的。不幸的是,只要今天存在这么多浏览器差异,就不会有一个单一的解决方案完全消除这个问题。

在编写在不同浏览器中运行几乎相同的代码时,主要目标有两个:尽可能少地为每个浏览器编写独特的代码,以及编写能够优雅降级的代码。专门针对特定浏览器的一些独特功能是一回事,但维护两个或更多个独立的代码库是完全不同的问题。记住,你可能写的最好的代码,无论是在执行效率还是安全性方面,都是你根本不需要写的代码。你写的代码越多,你的代码就越容易出错和故障。因此,避免写太多与你正在编写的其他代码相同的代码,但为不同的浏览器编写独特的代码。

虽然追求完美主义可能是一个很好的品质,但我们必须现实一点,我们不会很快达到完美。不仅如此,在大多数情况下(特别是涉及到视频游戏的所有情况),我们不需要编写接近完美的软件。在一天结束时,无论你是否同意,软件开发的目标是生产足够好的软件。只要程序解决了它被编写的问题,并以合理的方式做到这一点,那么从实际目的来看,我们可以说这个软件是好的。

在我们介绍完这些原则后,当你开发 HTML5 应用程序时,包括面向全球数亿人的游戏时,请记住这两个原则。确实,有一些特定于浏览器的功能可能会使游戏无法玩或者至少使用户体验有很大不同,最终结果可能不理想。但是,要密切关注你真正想要实现的目标,以便辨别哪些浏览器差异是足够好的。可能某个特定浏览器的功能被使用的用户太少,以至于这个功能没有成本效益。然而,我们绝对不希望部署一个无法使用的产品。

HTML5 库和框架

在我们寻求以成本效益的方式支持多个浏览器时,我们可以放心地知道我们并不孤单。今天,有许多旨在解决浏览器兼容性问题的开源项目,我们可能可以玩字母游戏,为字母表中的每个字母命名一个不同的 HTML5 库或框架。

这些工具存在的主要原因通常有两个,即抽象掉浏览器差异和加快开发速度。虽然今天的大多数 JavaScript 工具提供的抽象试图为客户端提供统一浏览器差异的接口,但许多这些库也提供功能,简单地加快开发时间和工作量。

jQuery

到目前为止,最受欢迎的 JavaScript 库是一个叫做 jQuery 的库。如果你以前没有听说过 jQuery,那么很可能你刚从一个非常深沉和深刻的冬眠中醒来,而你的身体穿越了遥远的星系。使用 jQuery 的一些主要好处包括非常强大的 DOM 查询和操作引擎,一个非常简单、统一的 XHR(XML HTTP 请求,也称为 Ajax)接口,以及通过一个良好定义的插件接口来扩展它的能力。

使用 JavaScript 库,特别是 jQuery,可以节省开发时间和精力的一个例子是尝试向服务器发出异步请求。没有 jQuery,我们需要编写一些样板代码,以便不同的浏览器都表现一致。代码如下:

var xhr = null;

// Attempt to create the xhr object the popular way
try {
  xhr = new XMLHttpRequest();
}
// If the browser doesn't support that construct, try a different one
catch (e) {
  try {
    xhr = new ActiveXObject("Microsoft.XMLHTTP");
  }
  // If it still doesn't support the previous 2 xhr constructs, just give up
  catch (e) {
    throw new Error("This browser doesn't support AJAX");
  }

// If we made it this far, then the xhr object is set, and the rest
// of the API is identical independent of which version we ended up with
xhr.open("GET", "//www.some-website.com", true);
xhr.onreadystatechange = function(response) {
  // Process response
  // (...)
};

xhr.send();

现在,相比之下,可以使用以下代码使用 jQuery 来实现相同的功能:

$.ajax({
  type: "GET",
  url: "//www.some-website.com",
  async: true,  /* This parameter is optional, as its default value is true */
  complete: function(response) {
    // Process response
    // (…)
  }
});

jQuery 的 XHR 功能的一个很棒的地方是它非常灵活。至少,我们可以以完全跨浏览器的方式实现与上一个代码中相同的行为,如下面的代码所示:

$.get("//www.some-website.com", function(response) {
  // Process response
  // (…)
});

总之,用很少的工作、时间和代码就可以做很多事情,这也带来了额外的好处,即该库是由一个非常专注和活跃的社区开发的。有关 jQuery 的更多信息,请访问官方网站www.jquery.com

Google Web Toolkit

另一个流行且非常强大的 JavaScript 工具是 Google Web Toolkit(GWT)。首先,GWT 不仅仅是一个提供了一些 JavaScript 抽象的库,而是一个完整的开发工具包,使用 Java 语言(本身具有所有的优势),然后将 Java 代码编译和转换为高度优化的、特定于浏览器的 JavaScript 代码。

愚蠢地将 jQuery 与 GWT 进行比较,因为它们解决不同的问题,并对 Web 开发有完全不同的看法。然而,值得一提的是,虽然 jQuery 是一个很棒的工具,几乎每个网页开发者的工具箱中都可以找到,但它并不适用于实际的游戏开发。另一方面,Google Web Toolkit 虽然不是小型琐碎的 HTML 和 JavaScript 项目的最合适工具,但非常适合游戏开发。事实上,流行的游戏《愤怒的小鸟》在开发 Google Chrome 版本时使用了 Google Web Toolkit。

总之,虽然 GWT 足够成为一本独立的书的主题,但在你接手下一个大型 Web 开发项目时,考虑使用它是一个很好的选择,其中一个目标是为你的应用程序提供多个浏览器的支持。有关 Google Web Toolkit 的更多信息,请访问官方网站developers.google.com/web-toolkit/

支持具有有限 HTML5 功能的浏览器

正如前面提到的,上述由浏览器引起的开发头疼问题都不是 HTML5 特有的。然而,重要的是要知道,HTML5 并没有解决这个问题(尚未)。此外,HTML5 带来了全新的跨浏览器噩梦。例如,虽然大多数与 HTML5 相关的 API 在文档规范中得到了很好的定义,但也有许多 API 目前处于实验阶段(有关实验性 API 和供应商前缀的讨论,请参阅在线章节《设置环境》和第二章《HTML5 排版》,在那里这个主题得到了更全面的讨论)。除此之外,还有一些浏览器尚未支持某些 HTML5 功能,或者目前提供有限的支持,或者更糟糕的是,它们通过与其他浏览器不同的接口提供支持。

再次,作为网页开发者,我们在创建新应用程序时必须始终把用户放在首要位置。由于浏览器兼容性问题仍然存在,一些人认为 HTML5 仍然是未来的事情,其新功能的实用性尚未得到验证。本节的其余部分将描述我们如何在今天使用 HTML5 而不必担心不太理想的浏览器,并为使用这些浏览器的用户提供功能性应用程序。

优雅地降级

如果您仔细关注先前的代码片段,我们尝试创建一个在许多不同浏览器中工作的XHR对象,您会注意到,如果执行代码的浏览器不支持代码搜索的两个选项中的一个,代码会故意停止执行。这是一个很好的例子,说明我们不应该这样做,如果可能的话。每当特定功能对某个浏览器不可用时,第一选择应该是提供替代构造,即使这种替代方法并不能完全提供相同的行为。我们应该尽力在最坏的情况下提供一个功能性的体验,即浏览器完全不支持我们要实现的功能的情况。

例如,HTML5 提供了一种新的存储机制,类似于 cookie(换句话说,是一种简单的键值对存储),但主要区别在于这种存储机制完全将数据存储在客户端,并且这些数据永远不会作为 HTTP 请求的一部分来回传输到服务器。虽然这种存储系统的具体内容和工作原理将在本书的后面进行介绍,但我们可以总结说,这种存储系统(称为本地存储)存储键值对,并通过一个名为localStorage的 Window 对象的属性的明确定义的接口来实现。

localStorage.setItem("name", "Rodrigo Silveira");
localStorage.length == 1; // true
localStorage.getItem("name"); // "Rodrigo Silveira"
localStorage.removeItem("name");
localStorage.length; // == 0

本地存储的一个强大应用是缓存用户发出的异步请求,以便后续请求可以直接从浏览器的本地存储中获取,从而避免往返到服务器。然而,如果浏览器不支持本地存储,在这种特定情况下的最坏情况是应用程序需要再次从服务器获取后续请求。虽然这并不实用或高效,但这绝对不是一个应该让人担心的问题,除非这意味着我们需要编写大量额外的代码来测试localStorage对象的存在,从而在每次需要使用它时污染代码库,因为会有很多重复的条件语句。

这种问题的一个简单解决方案是使用 polyfills,我们将在接下来更深入地讨论。简而言之,polyfill 是一个 JavaScript 替代方案,当原始实现尚不可用时,浏览器可以使用它。这样,如果浏览器需要,您可以加载 polyfill,而代码库的其余部分可以通过原始接口使用功能,而不知道它正在使用哪种实现。对于localStorage,我们可以简单地检查真实的 API 是否可用,并在不可用时编写模拟其行为的代码。以下代码片段展示了这种行为:

// If the browser doesn't know anything about localStorage,
// we create our own, or at least an interface that respond
// to the calls we'd make to the real storage object.
if (window.localStorage === undefined) {
  var FauxLocalStorage = function() {
    var items = {};
    this.length = 0;

    this.setItem = function(key, value) {
      items[key] = value;
      this.length++;
      };

    this.getItem = function(key) {
      if (items[key] === undefined)
        return undefined;

        return items[key];
      };

    this.removeItem = function(key) {
      if (items[key] === undefined)
        return undefined;

      this.length--;
        return delete items[key];
      };
  };

  // Now there exists a property of window that behaves just like
  // one would expect the local storage object to (although in this example
  // the functionality is reduced in order to make the point)
  window.localStorage = new FauxStorage();
}

// This code will work just fine whether or not the browser supports the real
// HTML5 API for local storage. No exceptions will be thrown.
localStorage.setItem("name", "Rodrigo Silveira");
localStorage.length == 1; // true
localStorage.getItem("name"); // "Rodrigo Silveira"
localStorage.removeItem("name");
localStorage.length; // == 0

尽管前面的 polyfill 实际上并没有存储任何数据超出当前会话,但这种本地存储 polyfill 的特定实现可能足够满足特定应用程序的需求。至少,这种实现允许我们编写符合官方接口的代码(调用规范定义的真实方法),并且浏览器不会抛出异常,因为这些方法确实存在。最终,每当不支持 HTML5 API 的浏览器使用我们的 polyfill 时,由于条件检查了浏览器是否支持该功能,这个条件将不再触发加载 polyfill,因此客户端代码将始终引用原始实现,而主源代码不需要进行任何更改。

虽然考虑 polyfills 对我们有什么作用是相当令人兴奋的,但细心的学生会很快注意到,编写完整、安全和准确的 polyfills 比在样式表中添加简单的 CSS hack 以使设计与不同浏览器兼容要复杂一些。即使之前展示的样本本地存储 polyfill 相对复杂,它也没有完全模仿官方接口,也没有完全实现它所实现的少量功能。很快,有组织的学生会问自己应该期望花费多少时间来编写防弹 polyfills。我很高兴地报告,答案在下一节中给出并解释。

Polyfills

回答前面的问题,即您应该期望花费多少时间来编写自己的强大 polyfills,以便能够开始使用 HTML5 功能,并且仍然使您的代码在多个不同的浏览器上运行,答案是零。除非您真的想要为不同的浏览器编写后备方案的经验,否则没有理由自己编写库等,因为这个领域已经有数百名其他开发人员为社区分享了他们的工作。

使用 polyfills 时,我们无法在 HTML5 项目的顶部使用单个 JavaScript 导入来神奇地扩展每个不足的浏览器,使它们 100%准备好使用 HTML5。然而,有许多单独的项目可用,因此,如果您想要使用特定元素,只需导入该特定的 polyfill 即可。虽然没有一个确定的来源可以找到所有这些 polyfills,但是简单地通过 Google 或 Bing 搜索您想要的特定功能,应该可以迅速连接到适当的 polyfill。

Modernizr

值得一提的一个工具是 Modernizr。这个 JavaScript 库检查加载它的页面,并检测用户浏览器中可用的 HTML5 功能。这样,我们可以非常容易地检查特定 API 是否可用,并相应地采取行动。

截至目前,当前版本的 Modernizr 允许我们测试特定的 API 或功能,并在测试结果为正或负时加载特定的 polyfills,这使得在需要时添加 polyfills 非常容易和轻松。

此外,Modernizr 还包括 HTML5 Shiv,这是一个非常小的 JavaScript 片段,允许我们在不识别它们的浏览器中使用所有 HTML5 语义标签。请注意,这不会添加标签的实际功能,而只是允许您通过 CSS 样式化这些标签。原因是在 Internet Explorer 8 及更低版本中,如果我们尝试为浏览器不识别的元素添加样式,它将简单地忽略应用于它的任何 CSS。然而,使用 Modernizr,这些元素被创建(使用 JavaScript),因此浏览器知道这些标签,从而允许应用 CSS。

有关 Modernizr 的更多信息,请访问官方网站modernizr.com/

游戏

我们将在本章中构建的项目游戏简称为基本果冻摇摆重力游戏。游戏的目标是喂我们的主角足够多的果冻,以至于他吃得太多而生病并倒在地板上。主角通过键盘上的左右箭头键控制,为了吃果冻,您只需将主角放在一个下落的果冻下面。每次喂主角一个果冻,他的健康指数都会略微下降。一旦喂了足够多的果冻,健康指数降到零,主角就会生病晕倒。如果让果冻掉在地板上,除了果冻到处溅开之外,什么也不会发生。这就是一个基本的果冻摇摆重力游戏。您能为乔治王子提供足够多的果冻直到他昏倒吗?

游戏

为了演示一些关于 HTML5 游戏开发的原则,我们将完全使用 DOM 元素构建这个游戏。虽然这种方法通常不是理想的方法,但你会注意到许多游戏在大多数现代浏览器和今天的普通台式机或笔记本电脑上仍然表现得相当不错。然而,正如我们将在接下来的章节中学到的那样,HTML5 中有一些技术、工具和 API 对于游戏开发来说更加合适。

此外,与本书一贯的做法一样,大多数游戏元素在复杂性方面都会保持在最低水平,以便能够轻松解释和理解。特别是在这个游戏中,我们只会使用 SVG 图形作为概念验证,而不是深入探讨 SVG 标准为我们提供的潜力和机会。拖放也是如此,还有很多可以做的事情。

代码结构

这段代码的结构非常简单。游戏中的每个元素都是通过 CSS 绝对定位的,并且每个元素都由一些带有背景图像或一些 CSS3 属性的 HTML 容器组成,这些属性赋予它们圆角、阴影等新鲜外观。尽管有些人可能更喜欢面向对象的编程而不是函数式编程,更喜欢更好的内聚而不是到处都是全局变量,但在这个游戏中,我们将采取这种方法,并专注于 HTML5 方面,而不是游戏的设计。同样,图形的风格和质量也是如此。你在这个游戏中看到的所有东西都是我用一个免费的照片编辑程序创建的,而且我用不到 30 分钟的时间就创建了你在游戏中看到的所有图形。这主要是为了表明即使你预算有限,或者没有专门的图形设计团队,也可以构建有趣的游戏。

由于我们将所有的 SVG 实体都直接加载到 HTML 结构中,我们将它们放在一个对用户隐藏的div容器中,然后克隆我们需要的每个实体,并在游戏中使用它们。我们对所有果冻和英雄都使用这种技术。英雄 SVG 与从矢量编辑软件导出的内容保持一致。果冻 SVG 稍作修改,去掉了它们设计时的所有颜色,并用 CSS 类替换。这样我们可以创建不同的 CSS 类来指定不同的颜色,每个果冻 SVG 的新实例都被分配一个随机类。最终结果是一个单一的 SVG 模型隐藏在不可见的div容器中,每个实例都被赋予不同的颜色,而不需要额外的代码,以增加游戏的多样性。我们也可以随机分配不同大小和旋转给每个果冻实例,但这被留作读者的练习。

<body>
  <div class="health-bar">
    <span></span>
  </div>

    <h1 id="message"></h1>

    <div id="table"></div>
    <div id="bowl"></div>
    <div id="bowl-top-faux-target"></div>
    <div id="bowl-top" class="dragging-icon bowl-closed"
      draggable="true"
      ondragstart="doOnDragStart(event)"
      ondragend="doOnDragEnd(event)"></div>
    <div id="bowl-top-target"
      ondrop="startGame()"
      ondragover="doOnDrop(event)"
      ondragleave="doOnDragLeave(event)"></div>

    <div class="dom-recs">
      <svg class="hero-svg">
      (…)
      </svg>
      <svg class="jelly-svg">
      (…)
      </svg>
    </div>
</body>

虽然我们可以使用数据属性而不是 ID 属性来表示所有这些元素,但在这种情况下,使用它们而不是 ID 并没有真正的好处,就像在这种情况下使用 ID 而不是数据属性也没有好处一样。

请注意,bowl-top可以拖放到两个目标上。实际上,只有一个目标,即bowl-top-target元素。另一个看起来像目标的元素,巧妙地被赋予了bowl-top-faux-target的 ID,只是为了视觉效果。由于真正的放置目标(拖动元素可以在拖动选项结束时放置的元素)只有在鼠标指针移动到它上面时才会被激活,所以在桌子上没有足够的空间来实现bowl-top似乎被放置在一个小轮廓区域的期望效果。

最后,在游戏中使用了一个全局计时器,用于控制我们调用游戏循环函数tick()的频率。虽然这不是一章关于正确游戏设计的内容,但我要指出,您应该避免诱惑去为不同目的创建多个计时器。有些人在这方面毫不犹豫,会通过一个独立于主游戏计时器的唯一计时器触发事件。这样做,特别是在 HTML5 游戏中,可能会对性能和所有事件的同步产生负面影响。

API 使用

游戏中使用的三个 API 是音频、SVG 和拖放。接下来将简要解释这些 API 在游戏中的使用方式,其中只给出了功能的概述。然而,在下一节中,我们将详细了解这些功能实际上是如何使用的,以及如何在这种和其他情况下使用它。有关此游戏的完整源代码,请查看 Packt Publishing 网站上的书页。

网络音频

音频被用作永无止境的循环,作为背景音乐,以及当果冻被发射,弹跳,溅在地板上,或被饥饿的英雄吃掉时,会发出单独的音效。当英雄因吃太多果冻而最终死亡时,也会发出一个老式的音效。

游戏中每个音频实体的管理方式是通过一个简单的封装,其中包含对单独音频文件的引用,并公开一个接口,允许我们播放文件,淡入淡出音频文件,以及将新的音频文件添加到此类管理的音频列表中。代码如下:

// ** By assigning an anonymous function to a variable, JavaScript
// allows us to later call the variable's referenced function with
// the keyword 'new'. This style co function creation essentially 
// makes the function behave like a constructor, which allows us to
// simulate classes in JavaScript
var SoundFx = function() {
  // Every sound entity will be stored here for future use
  var sounds = {};

  // ------------------------------------------------------------
  // Register a new sound entity with some basic configurations
  // ------------------------------------------------------------
  function addSound(name, file, loop, autoplay) {

    // Don't create two entities with the same name
    if (sounds[name] instanceof Audio)
      return false;

      // Behold, the new HTML5 Audio element!
      sounds[name] = new Audio();
      sounds[name].src = file;
      sounds[name].controls = false;
      sounds[name].loop = loop;
      sounds[name].autoplay = autoplay;
    }

    // -----------------------------------------------------------
    // Play a file from the beginning, even if it's already playing
    // -----------------------------------------------------------
  function play(name) {
    sounds[name].currentTime = 0;
    sounds[name].play();
  }

    // -----------------------------------------------------------
    // Gradually adjust the volume, either up or down
    // -----------------------------------------------------------
  function fade(name, fadeTo, speed, inOut) {
    if (fadeTo > 1.0)
      return fadeOut(name, 1.0, speed, inOut);

    if (fadeTo < 0.000)
      return fadeOut(name, 0.0, speed, inOut);

      var newVolume = parseFloat(sounds[name].volume + 0.01 * inOut);

    if (newVolume < parseFloat(0.0))
      newVolume = parseFloat(0.0);

      sounds[name].volume = newVolume;

    if (sounds[name].volume > fadeTo)
      setTimeout(function(){ fadeOut(name, fadeTo, speed, inOut); }, speed);
    else
      sounds[name].volume = parseFloat(fadeTo);

      return sounds[name].volume;
  }

    // -----------------------------------------------------------
    // A wrapper function for fade()
    // ------------------------------------------------------------
    function fadeOut(name, fadeTo, speed) {
      fade(name, fadeTo, speed, -1);
    }

    // -----------------------------------------------------------
    // A wrapper function for fade()
    // -----------------------------------------------------------
    function fadeIn(name, fadeTo, speed) {
      fade(name, fadeTo, speed, 1);
    }

    // -----------------------------------------------------------
    // The public interface through which the client can use the class
    // -----------------------------------------------------------
    return {
      add: addSound,
      play: play,
      fadeOut: fadeOut,
      fadeIn: fadeIn
    };
};

接下来,我们实例化了一个自定义的SoundFx类型的全局对象,其中存储了游戏中使用的每个音频剪辑。这样,如果我们想播放任何类型的声音,我们只需在这个全局引用上调用play方法。看一下以下代码:

// Hold every sound effect in the same object for easy access
var sounds = new SoundFx();

// Sound.add() Parameters:
// string: hash key
// string: file url
// bool: loop this sound on play?
// bool: play this sound automatically as soon as it's loaded?
sounds.add("background", "sound/techno-loop-2.mp3", true,  true);
sounds.add("game-over",  "sound/game-over.mp3",     false, false);
sounds.add("splash",     "sound/slurp.mp3",         false, false);
sounds.add("boing",      "sound/boing.mp3",         false, false);
sounds.add("hit",        "sound/swallow.mp3",       false, false);
sounds.add("bounce",     "sound/bounce.mp3",        false, false);

可伸缩矢量图形(SVG)

如前所述,游戏中使用 SVG 的方式受限于 SVG 规范非常强大且可能相当复杂。正如您将在 SVG API 的深入描述中看到的那样,我们可以对通过 SVG 绘制的每个基本形状做很多事情(例如原生动画化英雄的面部表情,或使每个果冻摇晃或旋转等)。

当果冻触地时,我们将代表果冻溅开的精灵切换成了一个相当巧妙的方法。当我们使用矢量编辑软件绘制果冻矢量时,我们创建了两个分离的图像,每个代表果冻的不同状态。这两个图像叠放在一起,以便正确对齐。然后,在 HTML 代码中,我们为这些图像分配了一个 CSS 类。这些类分别称为 jelly-block 和 splash,代表果冻的自然状态和果冻溅在地板上。在这两个类中,一个矢量被隐藏,另一个没有。根据每个果冻元素的状态,这两个类来回切换。这只需简单地将这两个矢量组中的一个分配给父 svg 元素的jelly-svg-onjelly-svg-off两个类之一,如下面的代码所示:

.jelly-svg-off g.jelly-block, .jelly-svg-on g.splash {
    display: none;
}

.jelly-svg-off g.splash, .jelly-svg-on g.jelly-block {
    display: block;
}

前面的样式驱动方式很简单。默认情况下,每个果冻元素都被赋予jelly-svg-on的 CSS 类,这意味着果冻没有溅开。然后,当计算出果冻已经触地时,我们移除该类,并添加jelly-svg-off的 CSS 类,如下面的代码片段所示:

// Iterate through each jelly and check its state
for (var i in jellies) {

  // Don't do anything to this jelly entity if it's outside the screen,
  // was eaten, or smashed on the floor
  if (!jellies[i].isInPlay())
    continue;

    // Determine if a jelly has already hit the floor
    stillFalling = jellies[i].getY() + jellies[i].getHeight() * 2.5 < document.body.offsetHeight;

    // If it hasn't hit the floor, let gravity move it down
    if (stillFalling) {
      jellies[i].move();
    } else {

    // Stop the jelly from falling
    jellies[i].setY(document.body.offsetHeight - jellies[i].getHeight() - 75);

      // Swap the vectors
      jellies[i].swapClass("jelly-svg-on", "jelly-svg-off");
      jellies[i].setInPlay(false);

      // Play the corresponding sound to this action
      sounds.play("splash");
    }
}

拖放

与 SVG 在游戏中的使用方式类似,拖放以次要的方式进入最终产品,而 Web 音频则占据主导地位。然而,拖放在游戏中扮演的角色可以说是最重要的,它启动了游戏。与其让游戏在页面加载时立即开始播放,或者让用户按下按钮或按键来开始游戏,玩家需要将盖子从存放所有果冻的碗中拖出,并将其放在桌子上碗的旁边。

HTML5 中拖放的工作方式简单而直观。我们至少注册一个对象作为可拖动对象(您拖动的对象),至少注册一个其他对象作为放置目标(可将可拖动对象放入其中的对象)。然后,我们为适用于拖放行为的任何事件注册回调函数。

在游戏中,我们只监听了五个事件,两个在可拖动元素上,三个在放置目标元素上。首先,我们监听用户首次拖动可拖动对象时触发的事件(拖动开始),我们会对此做出响应,使碗盖图像不可见,并在鼠标指针后面放置一个盖子的副本,以便看起来用户真的在拖动那个盖子。

接下来,我们监听用户最终释放鼠标按钮时触发的事件,表示拖动动作的结束(拖动结束)。在这一点上,我们只需将碗盖恢复到最初的位置,放在碗的顶部。每当拖动动作结束,且放置在有效的放置目标内时(用户没有在预期的位置放置盖子),就会触发此事件,从根本上重新启动该过程。

我们在放置目标上监听的三个事件是onDragLeaveonDragOveronDrop。每当可拖动对象放置在放置目标内时,目标的onDrop事件就会被触发。在这种情况下,我们所做的就是调用startGame()函数,这将启动游戏。作为startGame函数的设置的一部分,我们将碗盖元素移动到放置的确切像素位置,并删除可拖动属性,以便用户无法再拖动该元素。

onDragOveronDragLeave函数分别在鼠标指针移动到目标对象上方和悬停在目标对象外部时触发。在我们的情况下,在这些函数中我们所做的就是切换碗盖和在拖动发生时显示在鼠标指针后面的图像的可见性。可以在以下代码中看到:

// ------------------------------------------------------------
// Fired when draggable starts being dragged (onDragStart)
// ------------------------------------------------------------
function doOnDragStart(event) {
  if (bowlTop.isReady) {
    event.target.style.opacity = 0.0;
    event.dataTransfer.setDragImage(bowlTop, 100, 60);
  }
}

// ------------------------------------------------------------
// Fired when draggable is released outside a target (onDragEnd)
// ------------------------------------------------------------
function doOnDragEnd(event) {
  event.target.style.opacity = 1.0;
  document.querySelector("#bowl-top-faux-target").style.opacity = 0.0;
}

// ------------------------------------------------------------
// Fired when draggable enters target (onDragOver)
// ------------------------------------------------------------
function doOnDragOver(event) {
  event.preventDefault();
  document.querySelector("#bowl-top-faux-target").style.opacity = 1.0;
}

// ------------------------------------------------------------
// Fired when draggable is hovered away from a target (onDragLeave)
// ------------------------------------------------------------
function doOnDragLeave(event) {
  document.querySelector("#bowl-top-faux-target").style.opacity = 0.0;
}

// ------------------------------------------------------------
// Fired when draggable is dropped inside a target (onDrop)
// ------------------------------------------------------------
function startGame() {

  // Keep the game from starting more than once
  if (!isPlaying) {

    // Register input handlers
    document.body.addEventListener("keyup", doOnKeyUp);
    document.body.addEventListener("keydown", doOnKeyDown);

    // Reposition the bowl lid
    var bowlTop = document.querySelector("#bowl-top");
    bowlTop.classList.remove("bowl-closed");
    bowlTop.style.left = (event.screenX - bowlTop.offsetWidth + 65) + "px";
    bowlTop.style.top = (event.screenY - bowlTop.offsetHeight + 65 * 0) + "px";

    // Disable dragging on the lid by removing the HTML5 draggable attribute
    bowlTop.removeAttribute("draggable");
    bowlTop.classList.remove("dragging-icon");

    newJelly();
      isPlaying = true;

      // Start out the main game loop
      gameTimer = setInterval(tick, 15);
    }
};

Web 音频

新的 Web 音频 API 定义了一种在浏览器中播放音频而无需单个插件的方法。对于高级别的体验,我们可以简单地在整个 HTML 页面中添加一些音频标签,浏览器会负责显示播放器供用户进行交互和播放、暂停、停止、倒带、快进和调整音量。或者,我们可以使用可用的 JavaScript 接口,并使用它来控制页面上的音频标签,或者实现更强大和复杂的任务。

关于浏览器支持和 Web 音频 API 的一个关键细节是,不同的浏览器支持不同的文件格式。在定义音频标签时,类似于图像标签,我们指定源文件的路径。不同的是,对于音频,我们可以为同一文件指定多个源(但是不同的格式),然后浏览器可以选择它支持的文件,或者在支持多个文件格式的情况下选择最佳选项。目前,所有主要浏览器都支持三种音频格式,即.mp3.wav.ogg。截至目前,没有一种音频格式在所有主要浏览器中都受支持,这意味着每当我们使用 Web 音频 API 时,如果我们希望触及尽可能多的受众,我们将需要每个文件的至少两个版本。

最后,请记住,尽管我们可以(而且应该)为每个音频元素指定多个音频文件,但每个浏览器只下载其中一个文件。这是一个非常方便(和显而易见)的功能,因为下载多个相同文件的副本将非常低效且占用带宽。

如何使用它

使用 Web 音频 API 的最简单方法是使用内联 HTML5 元素。其代码如下:

<audio>
  <source src="img/sound-file.mp3" type="audio/mpeg" />
  <source src="img/sound-file.ogg" type="audio/ogg" />
</audio>

将上述片段添加到页面上不会导致任何可见的结果。为了对标签添加更多控制,包括向页面添加播放器以便用户可以与其交互,我们可以从与标签相关的元素中进行选择。这些属性如下:

  • autoplay:一旦浏览器下载完成,它立即开始播放文件。

  • controls:它显示一个可视化播放器,通过它用户可以控制音频播放。

  • loop:用于无限循环播放文件。

  • muted:当音频输出被静音时使用。

  • preload:它指定浏览器如何预加载音频资源。

通过 JavaScript 实现类似的结果,我们可以创建一个类型为音频的 DOM 元素,或者实例化一个类型为 Audio 的 JavaScript 对象。添加可选属性的方式与我们对任何其他 JavaScript 对象所做的方式相同。请注意,创建 Audio 的实例与创建对 DOM 元素的引用具有完全相同的效果:

// Creating an audio file from a DOM element
var soundOne = document.createElement("audio");
soundOne.setAttribute("controls", "controls");

soundOneSource = document.createElement("source");
soundOneSource.setAttribute("src", "sound-file.mp3");
soundOneSource.setAttribute("type", "audio/mpeg");

soundOne.appendChild(soundOneSource);

document.body.appendChild(soundOne);

// Creating an audio file from Audio
var soundTwo = new Audio("sound-file.mp3");
soundTwo.setAttribute("controls", "controls");

document.body.appendChild(soundTwo);

尽管 JavaScript 音频对象可能看起来更容易处理,特别是因为它采用了令人惊叹的构造函数参数,可以节省我们一行代码,但它们的行为完全相同,并且只有在运行时才能够区分它们。一个小细节是,当我们在 JavaScript 中创建音频引用时,不需要将其附加到 DOM 以播放文件。

无论您决定如何处理此设置步骤,一旦我们在 JavaScript 中有音频对象的引用,我们就可以使用与该对象相关的许多事件和属性来控制它。音频对象如下:

  • play():开始播放文件。

  • pause():它停止播放文件,并保持 currentTime 不变。

  • paused:表示当前播放状态的布尔值。

  • canPlayType:用于查找浏览器是否支持特定的音频类型。

  • currentSrc:它返回当前分配给对象的文件的绝对路径。

  • currentTime:它以浮点数形式返回当前播放位置(以秒为单位)。

  • duration:它以浮点数形式返回总播放时间(以秒为单位)。

  • ended:一个布尔值,指示 currentTime 是否等于 duration。

  • readyState:它指示源文件的下载状态。

  • volume:它指示文件的当前音量,范围从 0 到 1,包括 0 和 1。这个数字是相对于当前系统音量的。

SVG

可缩放矢量图形SVG)简称为 SVG,是一种描述图形的基于 XML 的格式。这种格式可能看起来足够复杂,以至于被误认为是用于 2D 图形的完整编程语言,但实际上它只是一种标记语言。虽然对一些 Web 开发人员来说,SVG 可能是新的,但该规范最早是在 1999 年开发的。

矢量图形和光栅图形(即位图)的主要区别在于图形的描述方式。在位图中,每个像素基本上由三个或四个数字表示,表示该单个像素的颜色(RGB),以及可能的不透明度级别。从更广泛的意义上看,位图只不过是像素网格。另一方面,矢量图形由一系列数学函数描述,这些函数描述了线条、形状和颜色,而不是整个图像上的每个单独点。简而言之,矢量图形在缩放其尺寸方面表现出色彩,如下面的屏幕截图所示:

SVG

如果放大或尝试拉伸矢量图形,它将始终与原始图像一样平滑,因为形状是使用相同的数学函数定义(如左侧图像所示)。另一方面,光栅图形只由相同的像素网格定义。缩放该网格只意味着将网格的尺寸乘以,导致右侧图像所代表的方块状、像素化的图像。

现在,SVG 标准不仅仅定义了形状、线条、路径和颜色。规范还定义了可以应用于任何单个基元、一组基元或整个 SVG 上下文的变换和动画。规范还允许 SVG 成为一种非常可访问的格式,这意味着可以将文本和其他元数据直接包含到文件中,以便其他应用程序可以以除了图形之外的其他方式理解文件。例如,搜索引擎可以爬行和索引,不仅您的网页,还有任何 SVG 图形。

由于 SVG 是基于文本的(与存储二进制数据相反,例如音频文件),因此也可以使用诸如流行的 Gzip 之类的压缩算法来压缩 SVG 图像,这在当今的 Web 开发世界中非常普遍。当 SVG 文件保存为自己的独立文件时,它被赋予扩展名.svg。如果文件经过 Gzip 压缩,那么扩展名应该是.svgz,这样浏览器就知道在处理之前解压缩文件。

SVG 文件可以以几种不同的方式在 HTML 文件中使用。由于文件本身可以保存为自己的文件,因此可以使用对象标签将整个文件嵌入到页面中,也可以使用普通图像标签,甚至可以使用 XHR 对象从服务器获取其内容,并将其注入到 HTML 文档中。或者,SVG 文件的内容可以手动复制到主机 HTML 文件中,以便其内容内联导入。

要将 SVG 图形内联导入到 HTML 文档中,我们只需插入一个svg标签,其中包含所有内容作为其子节点。截至目前,XML 命名空间属性是必需的,还需要版本号,如下面的代码所示:

<body>
  <svg

    version="1.1"
    width="150"
    height="150">

    <circle
      cx="75"
      cy="75"
      r="50"
      stroke="black"
      stroke-width="2"
      fill="red"></circle></svg>
</body>

虽然对于一个简单的红色圆圈来说可能很容易,但一旦图像变得更加复杂,就很难在一个文件中管理所有内容。因此,简单保存所有 SVG 文件并单独导入它们可能更方便。这种方法也更适合资源共享和重用,因为我们可以在多个文件中导入相同的图形,而无需每次都复制整个文件。

<body>
  <object type="image/svg+xml" data="red-circle.svg"
    width="100" height="100">
  </object>

  <img src="img/red-circle.svg" width="100" height="100" />
</body>

在我们深入一些实际示例之前,关于 SVG 的最后一点是,父svg标签内的每个节点(包括父节点)都由浏览器管理。因此,这些节点中的每一个都可以通过 CSS 进行样式设置。如果这还不够,SVG 图形中的每个节点都可以注册浏览器事件,允许我们与图形及其所有单独组件进行交互,就像大多数其他 DOM 元素一样。这使得 SVG 成为一种非常动态、高度灵活的图形格式。

如果 SVG 实例与 HTML 内联,则我们可以直接引用父 svg 节点,或者通过 JavaScript 直接引用任何子节点。一旦我们有了这个引用,我们就可以像处理任何其他 DOM 元素一样处理对象。然而,如果 SVG 是外部的,我们需要多做一步,将实际的 SVG 文件加载到 JavaScript 变量中。一旦完成了这一步,我们就可以像处理本地文件一样处理 SVG 的子树。

<body>
  <object type="image/svg+xml" data="red-circle.svg"
    width="100" height="100">
  </object>

  <script>
    var obj = document.querySelector("object");

    // Very important step! Before calling getSVGDocument, we must register
        // a callback to be fired once the SVG document is loaded.
    obj.onload = function(){
      init(obj.getSVGDocument());
    };

    function init(svg) {
      var circles = svg.getElementsByTagName("circle");

      // Register click handler on all circles
      for (var i = 0, len = circles.length; i < len; i++) {
        circles[i].addEventListener("click", doOnCircleClick);
      }

      // When a circle element is clicked, it adds a CSS class "blue"
            // to itself.
    function doOnCircleClick(event) {
      this.classList.add("blue");
    }
  }
  </script>
</body>

关于前面代码片段的一些重要细节,你应该始终记住的是:

  • 导入的 SVG 文档被视为外部文档(类似于 Iframe),这意味着该文档之外的任何 CSS(如宿主文档)都不在其范围之内。因此,如果你想对从getSVGDocument()调用中的 SVG 节点应用 CSS 类,那么该 CSS 类必须在最初导入的同一个 SVG 文件中定义。

  • SVG 的 CSS 属性略有不同。例如,你会定义填充颜色而不是背景颜色。基本上,用在 SVG 元素本身上的属性,也是你在相应的样式表声明中会用到的属性。

  • 任何特定于浏览器的 CSS 属性都可以应用到 SVG 节点上(例如,过渡、光标等)。

因此,前面的示例是通过以下.svg文件完成的,作为相应的red-circle.svg文件,如下面的代码片段中所使用的:

<svg

  version="1.1"
  width="150"
  height="150">

<style type="text/css">
.blue {
  /* CSS Specific to SVG */
  fill: #0000ff;

  /* CSS Specific to the browser */
  cursor: pointer;
  -webkit-transition: fill 1.25s;
}
</style>
  <circle
    cx="75"
    cy="75"
    r="50"
    stroke="black"
    stroke-width="2"
    fill="red"></circle>
</svg>

如何使用它

尽管强烈建议在组合复杂的 SVG 图形时使用专业的矢量编辑软件,比如 Inkspace 或 Adobe Illustrator,但本节将带你了解 SVG 组合的基础知识。这样你就可以手工绘制基本的形状和图表,或者至少熟悉 SVG 绘制的基础知识。

请记住,无论你是通过之前描述的任何方法将 SVG 图形导入到 HTML 中,内联绘制它们,甚至通过 JavaScript 动态创建它们,你都需要将 XML 命名空间包含到根svg元素中。这是 SVG 新手常犯的一个错误,可能导致你的图形在页面上不显示。

我们可以用 SVG 绘制的原始形状有矩形、圆、椭圆、线、折线、多边形和路径。其中一些原始形状共享属性(如宽度和高度),而其他一些具有特定于该形状的属性(如圆的半径)。在 SVG 图形中看到的一切都是这些原始形状在某种组合中使用的结果。

SVG 中的一切都是在 SVG 画布内绘制的,由父svg标签定义。这个画布总是矩形的,即使它内部的形状可以是由任何原始形状创建的任何形状。此外,画布有自己的坐标系,将原点放在画布的左上角。画布的宽度和高度(由父svg标签确定)决定了绘图区域的尺寸,所有svg的子元素内部的(x,y)点都是相对于该点的。

作为以下示例的样板,我们将假设有一个外部的svg文件,我们将把画布大小设置为 1000 x 1000 像素,并在其中绘制。要查看每个示例的最终结果,你可以使用前一节中描述的任何一种方法来将 SVG 图像加载到 HTML 文件中。以下代码片段显示了如何定义svg标签:

<svg  version="1.1" width="1000" height="1000">
</svg>

用 SVG 绘制矩形就像它可以得到的那样简单。只需为rect元素指定宽度和高度,就可以了。可选地,我们可以指定描边宽度和描边颜色(其中描边就是边框),以及背景颜色。看一下下面的代码:

<svg  version="1.1" width="1000" height="1000">
  <rect
    width="400"
    height="150" />
</svg>

默认情况下,每个形状都在原点(x = 0,y = 0)处呈现,没有描边(stroke-width = 0),并且背景颜色(填充)设置为全黑(十六进制值为#000000,RGB 值为 0, 0, 0)。

圆是通过指定至少三个属性来绘制的,即xy位置(由cxcy表示),以及半径值(由字母r表示)。圆的中心位于位置(cxcy),半径长度不考虑描边的宽度,如果存在的话。

<svg  version="1.1" width="1000" height="1000">
  <circle
    cx="0"
    cy="0"
    r="300"
    fill="#ff3" />

  <circle
    cx="200"
    cy="200"
    r="100"
    fill="#a0a" />
</svg>

您会注意到,默认情况下,就像定位的 DOM 元素一样,每个节点都具有相同的 z-index。因此,如果两个或更多元素重叠,无论哪个元素最后被绘制(意味着它在父元素之外的位置更远),都会呈现在顶部。

椭圆与圆非常相似,唯一的区别是它们在每个方向(垂直和水平)都有一个半径。除此之外,绘制椭圆与绘制圆是完全相同的。当然,我们可以通过绘制两个半径长度相同的椭圆来模拟圆。

<svg  version="1.1" width="1000" height="1000">
  <ellipse
    cx="400"
    cy="300"
    rx="300"
    ry="100"
    fill="#ff3" />

  <ellipse
    cx="230"
    cy="200"
    rx="75"
    ry="75"
    fill="#a0a" />
  <ellipse
    cx="560"
    cy="200"
    rx="75"
    ry="75"
    fill="#a0a" />
</svg>

有了这些基本形状,我们现在将继续绘制更复杂的形状。现在不仅仅是按照几个预定义的点和长度进行绘制,我们可以选择在我们将要绘制的形状中准确放置每个点。虽然这使得手工绘制形状稍微困难,但也使得可能性更加广泛。

绘制一条线既简单又快速。只需在 SVG 坐标空间内指定两个点,就可以得到一条线。每个点由一个枚举的(x,y)对指定。

<svg  version="1.1" width="1000" height="1000">
  <line
    x1="50"
    y1="50"
    x2="300"
    y2="500"
    stroke-width="50"
    stroke="#c00" />
</svg>

接下来我们将介绍折线,它是常规线的扩展。线和折线之间的区别在于,正如其名称所示,折线是一组线的集合。而常规线只接受两个坐标点,折线接受两个或更多点,并按顺序连接它们。此外,如果我们为折线指定了填充颜色,最后一个点将连接到第一个点,并且由该封闭区域形成的形状将应用填充。显然,如果没有指定填充,折线将呈现为由直线组成的简单形状。

<svg  version="1.1" width="1000" height="1000">
  <polyline
    points="50, 10, 100, 50, 30, 100, 175, 300, 250, 10, 10, 400"
    fill="#fff"
    stroke="#c00"
    stroke-width="10"/>
</svg>

我们将要看的下一个形状是多边形。与折线非常相似,多边形的绘制方式与折线完全相同,但有两个非常重要的区别。首先,多边形必须至少有三个点。其次,多边形总是一个封闭的形状。这意味着序列的最后一个点和第一个点在物理上是连接的,而在折线中,只有通过填充才会进行连接,如果为折线分配了填充的话:

<svg  version="1.1" width="1000" height="1000">
    <polygon
        points="50, 10, 100, 50, 30, 100, 175, 300, 250, 10, 10, 400"
        fill="#fff"
        stroke="#c00"
        stroke-width="10"/>
</svg>

如何使用

在前面的屏幕截图的左侧显示了折线,而右侧的形状是使用完全相同的点来描述其位置和方向的多边形。两者之间唯一的区别是多边形是强制闭合的。当然,我们也可以通过简单地手动连接最后一个点和第一个点来模拟这种行为,使用折线。

SVG 还允许我们使用平滑曲线来绘制非常复杂的形状,而不是之前介绍的基于线的形状。为此,我们可以使用路径元素,起初可能有点复杂,因为它有几个不同的属性可以操作。路径的一个关键特点是它允许我们将指针移动到坐标空间内的位置,或者画一条线到一个点。

描述路径的所有路径属性都放在d属性中。这些属性如下:

  • M:移动到

  • L:线到

  • H:水平线到

  • V:垂直线到

  • C:曲线到

  • S:平滑曲线到

  • Q:二次贝塞尔曲线

  • T:平滑二次贝塞尔曲线

  • A:椭圆弧

  • Z:关闭路径

这些属性可以根据需要重复多次,尽管将整体绘图分解为多个较小的路径可能是个好主意。将较大的绘图分成多个路径的一些原因是使图形更易管理,更易于故障排除和更易于理解。代码如下:

<svg  version="1.1" width="1000" height="1000">
  <path
    d="M 100 100
    L 100 300
    M 250 100
    L 250 300
    M 400 100
    L 400 300"
    fill="transparent"
    stroke-width="45"
    stroke="#333" />
</svg>

除非你练习并训练自己查看路径描述,否则很难仅凭这些代码来可视化路径。花点时间,逐个查看每个属性。前面的示例首先将指针移动到点(100, 100),然后从该点画一条线到另一个点(100, 300)。这样就从指针上次位置到由线条指定的点画了一条垂直线。接下来,光标从原来的位置改变到一个新位置(250, 100)。请注意,简单地移动光标不会影响任何以前的绘图调用,也不会在那时进行任何绘图。最后,画了第二条垂直线到点(250, 300)。第三条线与第一条线的距离相等。这可以在以下截图中看到:

如何使用

请注意,我们为填充、描边、描边宽度等定义的任何值都将应用于整个路径。想要不同的填充和描边值的解决方案是创建额外的路径。

绘制曲线仍然有点复杂。曲线需要三个值,即两个控制点和最终绘制线的点。为了说明控制点的工作原理,请观察以下示例:

<svg  version="1.1" width="1000" height="1000">
  <path
    d="M 250 100
    L 250 300
    M 400 100
    L 400 300"
    fill="transparent"
    stroke-width="45"
    stroke="#333" />
  <path
    d="M 150 300
    C 200 500,
    450 500,
    500 300"

    fill="transparent"
    stroke-width="45"
    stroke="#333" />

  <circle
    cx="150"
    cy="300"
    r="8"
    fill="#c00" />
  <circle
    cx="200"
    cy="500"
    r="8"
    fill="#c00" />
  <line
    x1="150"
    y1="300"
    x2="200"
    y2="500"
    stroke-width="5"
    stroke="#c00" />

  <circle
    cx="450"
    cy="500"
    r="8"
    fill="#c00" />
  <circle
    cx="500"
    cy="300"
    r="8"
    fill="#c00" />
  <line
    x1="450"
    y1="500"
    x2="500"
    y2="300"
    stroke-width="5"
    stroke="#c00" />
</svg>

在执行上述代码时,如下截图所示,我们可以看到控制点与线的曲率之间的关系:

如何使用

这是一个三次贝塞尔曲线,红线显示了第一个和最后一个曲线点与控制点连接的位置。

手动绘制所需的曲线正是一个相当复杂的问题。不同的曲线函数之间的行为不同,因此一定要尝试它们,直到你对它们的工作方式有了很好的感觉。请记住,尽管至少要有一些了解这些曲线和其他绘图原语的工作方式是个好主意,但强烈建议您始终使用适当的软件来帮助您创建您的绘图。理想情况下,我们会利用我们的创造力来创建绘图,让计算机来找出如何使用 SVG 表示它。

注意

路径的描述属性可以使用小写字母或大写字母来指定。区别在于大写字母表示点是绝对的,小写字母表示点是相对的。相对和绝对点的概念与 HTML 中的不完全相同,其中相对偏移意味着目标点相对于其自身原始位置的相对位置,而绝对点是完全相对于元素的父级的位置。

在 SVG 世界中,绝对点是相对于画布的原点,而相对点是相对于上次定义的点。例如,如果将指针移动到位置(10, 10),然后使用值为 10 15 进行相对移动,指针将最终停在位置(10, 15)而不是位置(10, 15),而是在 x 位置上离开 10 个单位,在 y 位置上离开 15 个单位。然后指针的新位置将是位置(20, 25)。

最后,SVG 能够将文本呈现到屏幕上。想象一下,如果要使用线条和路径手动渲染每个字母会耗费多少时间。幸运的是,SVG API 规定了一个非常简单的文本呈现接口。

<svg  version="1.1" width="1000" height="1000">
  <text
    x="100"
    y="300"
    fill="#c00"
    stroke="#333"
    stroke-width="2"
    style="font-size: 175px">I Love HTML5!</text>
</svg>

现在,SVG 标准不仅仅是定义形状、线条、路径和颜色。规范还定义了元素组,可以将一组节点组合在一起,使它们可能作为一个单一单元一起处理。还有变换、动画、渐变,甚至是照片滤镜,所有这些都可以应用于之前描述的简单基元。看一下下面的代码:

<svg  version="1.1" width="1000" height="1000">
  <rect
    x="500"
    y="500"
    width="900"
    height="600"
    fill="#c00"
    stroke="#333"
    stroke-width="2"
    transform="translate(800, 50)
      rotate(55, 0, 0)
      scale(0.25)">

    <animate
      dur="1.5s"
      attributeName="x"
      values="-50; 100; -50"
      repeatCount="indefinite" />

    <animate
      dur="1.5s"
      attributeName="height"
      values="50; 300; 50"
      repeatCount="indefinite" />
  </rect>
</svg>

拖放

尽管手动创建拖放功能并不是一个非常具有挑战性的任务,但 HTML5 将拖放提升到了一个全新的水平。通过新的 API,我们可以做的远不止让浏览器处理拖放操作。该接口允许自定义拖动的方式,拖动动作的外观,可拖动对象携带的数据等等。此外,不必担心在不同平台和设备上跟踪低级事件的方式是一个不错的、受欢迎的功能。

对于好奇的读者来说,我们可以实现自己的拖放行为的方式实际上非常简单;首先,我们监听要拖动的元素上的鼠标按下事件。当这种情况发生时,我们设置一个鼠标按下标志,一旦鼠标抬起事件被触发,无论是在我们希望拖动的元素上还是其他地方,我们就取消这个标志。接下来,我们监听鼠标移动事件,检查鼠标是否按下。如果鼠标在鼠标按下标志被设置的情况下移动,我们就有了一个拖动动作。处理它的一种方式是每次鼠标移动时更新可拖动元素的位置,然后在鼠标抬起事件被调用时设置元素的位置。当然,还有一些小细节我们需要跟踪,或者至少要注意,比如如何检测可拖动元素被放置的位置,以及如何在需要时将其移回原来的位置。

好消息是,浏览器提供的拖放 API 非常灵活和高效。自从这个功能首次引入以来,许多开发人员继续使用 JavaScript 实现它,原因有很多,但主要是因为很多人觉得原生的 HTML5 版本使用起来有点困难、有 bug,或者不如他们选择使用的其他库提供的版本实用。然而,如今这个 API 得到了广泛支持,相当成熟,并且深受推荐。

如何使用它

现在,拖放 API 的工作方式非常直接。首先,我们需要通过将draggable属性设置为 true 来标记一个或多个元素为可拖动,如下面的代码所示:

<ul>
  <li draggable="true" class="block"
    ondragstart="doOnDragStart(event)"
    data-name="Block 1">Block #1</li>
</ul>

仅仅这一步就可以使这些元素都可拖动。当然,除非我们有一个放置这些元素的地方,否则这没有任何用处。信不信由你,我们实际上可以在任何地方放置一个被拖动的元素。问题在于,我们没有任何代码来处理放置元素的事件。我们可以在任何元素上注册这样的事件,包括 body 标签,例如。下面的代码中展示了这一点:

document.body.ondragover = doOnDragOver;
document.body.ondragleave = doOnDragLeave;
document.body.ondrop = doOnDrop;

function doOnDragOver(event) {
  event.preventDefault();
  document.body.classList.add("dropme");
}

function doOnDragLeave(event) {
  event.preventDefault();
  document.body.classList.remove("dropme");
}

function doOnDrop(event) {
  event.preventDefault();
  document.body.classList.remove("dropme");
  var newItem = document.createElement("li");
  newItem.setAttribute("draggable", true);
  newItem.classList.add("block");

  document.querySelector("ul").appendChild(newItem);
}

在这个例子中,每当一个列表元素在页面的任何地方被放置时,我们都会向无序列表追加一个新的列表元素,因为页面上的每个元素都是 body 节点的子元素。此外,每当可拖动的元素悬停在 body 元素上时,我们会添加一个名为dropme的 CSS 类,这是为了给用户提供一个视觉反馈,让他们知道拖动事件正在发生。当可拖动的元素被放置时,我们会从 body 元素中移除该类,表示拖动动作的结束。

使用拖放 API 的一种方法是在对象之间传输数据。这些数据可以是字符串,或者可以转换为字符串的任何数据类型。我们可以通过在拖动操作期间设置dataTransfer对象来实现这一点。数据必须在系统触发拖动开始函数时设置。与dataTransfer数据相关联的键可以是我们选择的任何字符串,如下面的代码所示。

function doOnDragStart(event) {
    // First we set the data when the drag event first starts
 event.dataTransfer.setData("who-built-me", event.target.getAttribute("data-name"));
}

function doOnDrop(event) {
    event.preventDefault();
    document.body.classList.remove("dropme");

    var num = document.querySelectorAll("li").length + 1;

    // Then we retrieve that data when the drop event is fired by the browser
 var builtBy = event.dataTransfer.getData("who-built-me");

    var newItem = document.createElement("li");
    newItem.ondragstart = doOnDragStart;
    newItem.setAttribute("draggable", true);
    newItem.setAttribute("data-name", "Block " + num);
    newItem.innerText = "Block #" + num + ", built by " + builtBy;

    newItem.classList.add("block");

    document.querySelector("ul").appendChild(newItem);
}

总结

本章涉及了浏览器支持和代码可移植性这个非常重要的话题。作为高效的开发者,我们应该始终努力创建可维护的代码。因此,我们支持的浏览器越多,我们就越高效。为了帮助我们实现这个目标,我们可以创建封装了从浏览器到浏览器,从设备到设备都有所不同的代码的抽象。另一个选择是使用其他人编写的现有 polyfill,从而以可能更少的工作量和更可靠地实现相同的功能。

我们在本章构建的游戏利用了三个 HTML5 API,即拖放、Web 音频和 SVG。HTML5 提供的本机拖放远不止是在屏幕上拖动 DOM 元素。通过它,我们可以定制与拖放操作相关的许多可视元素,以及指定通过可拖动元素和放置目标携带的数据。

Web 音频允许我们管理多个音频实体。虽然大多数现代浏览器支持多种音频格式,但目前还没有一种音频格式被所有这些现代 Web 浏览器支持。因此,建议我们通过 API 链接每个音频文件的至少两种不同版本,以便所有现代浏览器都能播放该文件。虽然我们可以为每个音频元素指定多个来源(其中每个来源是相同文件的不同版本,但以不同格式编码),但浏览器足够智能,只下载它支持和知道如何播放的文件,或者对它来说最合适的文件。这样可以缩短加载时间,节省用户和服务器的带宽。

可伸缩矢量图形是一种基于 XML 的二维图形描述语言,可以以多种方式嵌入到网页中。由于所有的图形元素都不过是由浏览器渲染到 SVG 画布上的 XML 节点,每个图形元素都由浏览器管理,因此可以通过 CSS 进行样式设置,并且可以与用户输入事件相关联。我们还可以为由浏览器生成的事件(比如元素加载、聚焦、失焦等)注册回调函数。

最后,我们看到 JavaScript 提供的定时器函数都不适合快速游戏。幸运的是,有一个新的渲染 API,我们将在下一章中介绍,可以用来克服 JavaScript 定时器的不足。使用请求动画帧接口可以让我们更有效地渲染游戏,因为浏览器本身管理所使用的定时器,并且可以使我们的游戏更加 CPU 友好,不会渲染不可见的屏幕(比如当浏览器最小化或者焦点在不同的标签页上时)。

在下一章中,我们将编写一个传统的贪吃蛇游戏,主要关注点是使用 Canvas API 渲染整个游戏场景(而不是使用原始 DOM 元素),应用程序缓存以进行离线游戏,Web Workers 以及新而强大的 JavaScript 类型数组。正如本章前面提到的,我们还将看一下在 HTML5 应用程序中以新的方式渲染非常动态的图形,使用 requestAnimationFrame 来访问浏览器自己的渲染管道。

第四章:使用 HTML5 捕捉蛇

这一章是一个两部分系列的第一部分,在这里我们将构建游戏的第一个版本,然后在下一章中使用更多的 HTML5 API 来增加趣味性。两个版本都是完整可玩的,但是在同一章节中涵盖所有 API 会使章节变得非常庞大,因此我们将事情分解成更小的块,并编写两个单独的游戏。

游戏的第一个版本将涵盖五个新概念,即HTML5 的 2D 画布 API离线应用缓存Web Workers类型数组requestAnimationFrame。画布元素允许我们绘制 2D 和 3D 图形,并以非常低的级别操作图像数据,获得对单个像素信息的访问。离线应用缓存,也称为应用缓存,允许我们将特定资产从服务器缓存到用户的浏览器中,以便应用程序即使在没有互联网访问时也能工作。Web Workers 是一种类似线程的机制,允许我们在与主 UI 线程分离的单独线程中执行 JavaScript 代码。这样,用户界面永远不会被阻塞,用户也不会看到页面无响应的警告。Typed arrays是一种新的本机 JavaScript 数据类型,类似于数组,但效率更高,专门设计用于处理二进制数据。最后,requestAnimationFrame 是浏览器提供的一个 API,帮助我们执行基于时间的动画。我们可以让浏览器进行繁重的工作,优化动画,超出我们在 JavaScript 中单独实现的范围,而不是多次每秒使用 JavaScript 计时器(setTimeoutsetInterval)来执行动画。

游戏

你肯定以前见过或玩过这个游戏。你在一个 2D 网格中控制一条蛇,只能向上、下、左或右移动。当你改变蛇头移动的方向时,蛇身的每一部分都会逐渐改变方向,跟随着头部。如果你撞到墙壁或蛇的身体,你就输了。如果你引导蛇头经过一个水果,蛇的身体就会变大。蛇变得越大,游戏就越具挑战性。此外,蛇移动的速度可以增加,增加额外的挑战。为了保持这个经典游戏的老派特性,我们选择了老派的图形和字体,如下面的截图所示:

游戏

图像显示了游戏的外观和感觉。游戏刚开始时,蛇的总体长度为零——只有头部存在。一开始,蛇会随机放置在游戏网格的某个位置,并且没有给予一个初始移动方向。玩家可以用箭头键控制蛇,一旦蛇开始朝特定方向移动,就无法停止。例如,如果蛇向右移动,玩家可以将其向上或向下移动(但不能向后)。如果玩家希望将蛇向左移动(当它当前向右移动时),唯一可能的方法是先将蛇向上移动,然后向左移动,或者向下移动,然后向左移动。

每当游戏网格上没有水果时,会随机添加一个水果到网格中。该水果会一直留在那里,直到玩家吃掉它,此时会在网格中添加一个新的水果。为增加难度,如果蛇在几秒内无法到达水果,我们可以让水果消失。

API 使用

游戏中使用的每个 API 的一般描述和演示如下。要了解每个功能是如何整合到最终游戏中的,请查看以下代码部分。有关此游戏的完整源代码,请查看 Packt Publishing 网站上的书页。

在引入requestAnimationFrame之前,开发人员在 JavaScript 中创建动画的主要方法是使用定时器重复调用一个逐渐更新正在动画的元素的属性的函数。虽然这是一种简单直接的方法,但浏览器通过requestAnimationFrame提供的一些额外好处。首先,浏览器使用单个动画周期来处理页面的渲染,因此我们使用相同的周期进行的任何渲染都将导致更平滑的动画,因为浏览器可以为我们优化动画。此外,由于渲染将由浏览器的内部渲染机制完成,我们的动画在运行我们的动画的浏览器选项卡未显示时不会运行。这样我们就不会浪费电池寿命来动画显示不可见的内容。

如何使用

使用requestAnimationFrame非常简单,类似于setTimeout。我们在全局窗口对象上调用requestAnimationFrame函数,传递一个回调函数,该函数在浏览器准备好再次运行动画周期时执行。当调用回调函数时,会传递一个时间戳,通常在我们使用requestAnimationFrame注册的动画函数内部使用。

requestAnimationFrame有两种常见的使用方式,两种方式都能实现相同的结果。在第一种方法中,您定义动画函数时不引用requestAnimationFrame。然后,第二个函数调用该动画函数,然后调用requestAnimationFrame

function myAnimationLoop(time) {
   // 1\. Perform the animation
   myAnimation(time);

   // 2\. Register with request animation frame
   requestAnimationFrame(myAnimationLoop);
}

function myAnimation(time) {
   // Perform animation here
}

常用的第二种模式非常相似,只包括主要的动画函数。该函数本身负责在需要时调用requestAnimationFrame

function myAnimation(time) {
   // 1\. Perform the animation
   myAnimation(time);

   // 2\. Register with request animation frame
   requestAnimationFrame(myAnimationLoop);
}

时间参数有用的原因是,因为大多数情况下,您希望动画在不同的计算机上以更多或更少相同的速度运行。requestAnimationFrame尝试以尽可能接近每秒 60 次的速度运行。但是,根据您在其中执行的代码,该速率可能会显著下降。显然,更快的硬件能够更快地执行您的代码,并因此比一些较慢的硬件更频繁地显示在屏幕上。为了弥补这种可能性,我们可以使用实际时间来控制动画代码运行的频率。这样,我们可以指定一个刷新率上限,如果特定计算机能够以比这个速率更快的速度运行,可以简单地减慢该计算机的速度,所有用户都能体验到大致相同的动画。

这种技术的一种可能实现如下所示。虽然这可能看起来像是很多步骤,但概念实际上非常简单。其要点是:我们设置两个变量,一个用于跟踪动画运行的速度上限(以每秒帧数fps)为单位),另一个用于跟踪上次渲染帧的时间。然后,每当动画函数执行时,我们获取当前时间,减去上次渲染帧的时间,并检查它们的差是否大于或等于我们选择的理想 fps。如果小于我们期望的 fps,我们不会进行任何动画,但仍会注册requestAnimationFrame在未来回调我们。

我们这样做直到经过足够的时间,以便我们可以实现每秒帧数(换句话说,我们可能运行的最快帧速率就是我们的 fps)。如果系统运行速度比这慢,我们无能为力。这种技术的作用是控制最大速度。

一旦requestAnimationFrame调用了我们的动画函数,并且自上次渲染帧以来已经过了足够的时间,我们就会更新所有需要的数据,用于动画渲染到屏幕上(或者让浏览器完成,如果可以的话),并更新跟踪上次更新帧的变量。

// 1\. Create some element
var el = document.createElement("h1");
el.textContent = "I Love HTML5!";
el.style.position = "absolute";

// 2\. Attach it to the document
document.body.appendChild(el);

// 3\. Set some variables to control the animation
var loop = 0;
var lastFrame = 0;
var fps = 1000 / 60;

// 4\. Perform the animation one frame at a time
function slideRight(time) {

   // 5\. Control the animation to a set frames per second
   if (time - lastFrame >= fps) {

      var left = parseInt(el.style.left);

      // 6\. Perform the animation while some condition is true
      if (left + el.offsetWidth < document.body.offsetWidth) {
         el.style.left = (left + loop) + "px";
         loop += 5;

         // 7\. Perform the time control variable
         lastFrame = time;
      } else {

         // 8\. If the animation is done, return from this function
         el.style.left = document.body.offsetWidth - el.offsetWidth;
         return true;
      }
   }

   // 9\. If the animation is not done yet, do it again
   requestAnimationFrame(slideRight);
}

// 10\. Register some event to begin the animation
el.addEventListener("click", function(){
   el.style.left = 0;
   loop = 0;
   slideRight(0);
});

这个简单的代码片段创建了一个文档对象模型DOM)元素,为其设置一些文本,并为其注册了一个点击处理程序。当调用点击处理程序时,我们重置元素的一些样式属性(即将元素放在屏幕的最左侧),并启动动画例程。动画例程每帧将元素向右移动一点,直到元素到达屏幕的右侧。如果元素尚未到达屏幕的右侧,或者换句话说,如果动画尚未完成,我们执行动画(移动元素几个像素),然后将其自身注册到requestAnimationFrame,从而继续循环。一旦动画完成,我们就简单地停止调用requestAnimationFrame

记住的一个关键点是,浏览器使用requestAnimationFrame的主要优化之一是只在有东西需要渲染时调用它(换句话说,当包含页面的选项卡相对于其他选项卡处于活动状态时)。因此,如果用户在动画进行中切换选项卡,动画将暂停,直到再次选择该选项卡。

换句话说,我们应该让requestAnimationFrame调用处理游戏渲染的代码,而不是更新游戏状态的代码。这样,即使浏览器没有渲染,与动画相关的值仍会被动画化,但我们不会浪费 CPU 和 GPU 的功率,渲染看不见的东西。但是一旦浏览器选项卡再次变为活动状态,最新的数据状态将被渲染,就好像它一直在渲染一样。

这种技术对游戏特别有用,因为我们可能不希望用户切换浏览器选项卡时整个游戏都暂停。另一方面,我们总是可以通过在不需要时不向屏幕渲染数据来节省用户的电池。

注意

请记住,requestAnimationFrame将按定义将动画循环的帧速率限制为显示器的刷新速率。因此,requestAnimationFrame并不打算替代本机定时器实现,特别是在我们希望回调函数以与显示器刷新速率独立且可能更高的速率被调用的情况下。

类型化数组

多年来,JavaScript 引擎的速度变得惊人地快。然而,仅仅能够更快地处理数据并不一定等同于能够做更强大的事情。以 WebGL 为例。仅仅因为浏览器现在具有理解 OpenGL ES 的能力,并不一定意味着它具有我们开发人员需要利用的所有工具。

好消息是,JavaScript 语言也在一些方面取得了进展,以满足这一需求和其他需求。近年来 JavaScript 的一个新增内容是一种新的数据类型:类型化数组。一般来说,类型化数组提供了与 JavaScript 中已有的数组类型类似的结构。然而,这些新数组更加高效,并且是针对二进制数据设计的。

你问为什么和如何类型化数组比普通数组更高效?好吧,让我们看一个简单的例子,我们只是以旧的方式遍历一个整数数组。尽管大多数 JavaScript 引擎并不特别困难地快速完成这项任务,但我们不要忽视引擎需要做的所有工作。

var nums = [1, 2, 3, 4, 5];
for (var i = 0, len = nums.length; i < len; i++) {
   // ...
}

由于 JavaScript 不是强类型的,数组nums不受限于保存任何特定类型的数据。此外,nums数组可以为其中的每个元素存储不同的数据类型。虽然这对程序员来说有时可能很方便,但 JavaScript 引擎需要弄清楚每个元素存储在哪里,以及存储在该位置的数据类型是什么。与您可能认为的相反,在nums数组中的这五个元素可能不是存储在连续的内存块中,因为 JavaScript 就是这样做的。

另一方面,使用类型化数组,数组中的每个元素只能是整数浮点数。根据我们选择的数组类型,我们可以有不同类型的整数浮点数有符号无符号,8、16 或 32 位),但数组中的每个元素始终是我们决定使用的相同数据类型(整数或浮点数)。这样,浏览器就可以准确并立即知道nums[3]元素在内存中的位置,即在内存地址nums + 3处。这是因为类型化数组存储在连续的内存块中,就像 C 和 C++中的数组结构一样(顺便说一句,这是实现大多数,如果不是所有 JavaScript 引擎的语言)。

类型化数组的主要用例是,正如之前暗示的那样,WebGL(我们将在第六章中介绍,为您的游戏添加功能)。在 WebGL 中,我们可以直接从 JavaScript 执行 3D 渲染,可能需要处理超过一百万个元素的整数缓冲区。这些缓冲区可以用来表示我们希望绘制到屏幕上的 3D 模型。现在,想象一下浏览器需要遍历这样一个数组需要多长时间。对于每个元素,它都必须跟随一个内存位置,检查该位置的值,确保该值是一个数字,尝试将该值转换为数字,然后最终使用该值。听起来是不是很多工作?那是因为确实是。有了类型化数组,它可以以尽可能快的速度运行整个数组,知道每个元素确实是一个数字,并且确切地知道每个元素占用多少内存,因此跳转到下一个内存地址是一个一致和可预测的过程。

类型化数组也用于 2D 画布上下文。正如我们将在本章后面的画布 API 部分中看到的,我们可以从画布中绘制的任何内容中获取像素数据的方法。所有这些像素数据只是一个 8 位夹紧的无符号整数的长数组。这意味着该数组中的每个元素只能是介于 0 和 255 之间的整数值,这正是像素的可接受值。

如何使用它

使用类型化数组非常简单。如果您至少有一些 C 或 C++的经验,那么了解它们的工作原理可能会更容易。创建类型化数组的最简单方法是声明我们的数组变量,并为它分配特定类型的类型化数组实例。

var typedArr = new Int32Array(10);

在这个例子中,我们创建了一个整数数组的实例,其中每个元素可以是正数或负数(有符号)。每个元素将以 32 位数字的形式存储。我们传递的整数参数表示数组的大小。创建了这个数组之后,它的大小就不能改变了。浏览器会悄悄地忽略分配给它的任何超出其范围的值,以及任何非法值。

除了对这种特殊数组中可以存储什么的限制之外,对于未经训练的人来说,它可能看起来就像是一个普通的 JavaScript 数组。但是,如果我们深入研究一下,我们会注意到数组和类型化数组之间还有一些区别。

typedArr instanceof Int32Array; // True
typedArr.length == 10; // True

typedArr.push(23); // TypeError: <Int32Array> has no method 'push'
typedArr.pop(); // TypeError: <Int32Array> has no method 'pop'
typedArr.sort(); // TypeError; <Int32Array> has no method 'sort'

typedArr.buffer instanceof ArrayBuffer; // True
typedArr.buffer.byteLength == 40; //True

typedArr instanceof Array; // False

我们注意到的第一件事是,数组确实是一个Int32Array,而不是一个数组。接下来,我们很高兴地知道length属性仍然存在。到目前为止一切顺利。然后,事情开始分开,与普通数组相关的简单方法不再存在。不仅如此,类型化数组对象中还有一个名为buffer的新属性。这个缓冲区对象是ArrayBuffer类型,它有一个byteLength属性。在这种情况下,我们可以看到缓冲区的长度是40。很容易看出这个40是从哪里来的:buffer包含 10 个元素(typedArr.length),每个元素都是 32 位长(4 字节),总共在ArrayBuffer中有40字节(因此属性名为byteLength)。

由于类型化数组没有像普通 JavaScript 数组那样的辅助函数,我们使用旧的数组表示法来读取和写入数据,其中我们通过索引进入数组以读取或写入一个值。

var typedArr = new Uint32Array(3);

typedArr[] = 0; // SyntaxError

typedArr[0] = 3;
typedArr[1] = 4;
typedArr[2] = 9;

for (var i = 0, len = typedArr.length; i < len; i++) {
   typedArr[i] >= 0; // True
}

再次强调一点,与普通 JavaScript 数组相关的任何辅助函数或快捷方式都不适用于类型化数组,注意,尝试在不提供索引的情况下访问元素将导致浏览器抛出异常。

ArrayBuffer 和 ArrayBufferView

尽管所有先前的例子都直接使用了特定类型的数组,但类型化数组的工作方式要比那更复杂一些。实现被分解为两个单独的部分,即数组缓冲区和视图(或更具体地说,数组缓冲区视图)。数组缓冲区只是分配的一块内存,所以我们可以在那里存储我们的数据。关于这个缓冲区的事情是,它没有与之关联的类型,所以我们无法访问该内存来存储数据,或者从中读取数据。

为了能够使用数组缓冲区分配的内存空间,我们需要一个视图。尽管这个视图的基本类型是ArrayBufferView,但我们实际上需要ArrayBufferView的一个子类,它为数组缓冲区中存储的数据定义了一个特定的类型。

var buffer = new ArrayBuffer(32);
buffer.byteLengh == 32; // True

var i32View = new Int32Array(buffer);
i32View.length == 8; // True

这就是事情可能变得有点混乱的地方。数组缓冲区以字节为单位工作。作为复习,一个字节由 8 位组成。一个位是一个单一的二进制数字,它可以有一个值,要么是零,要么是一。这是数据在计算机中以最基本的格式表示的方式。

现在,如果一个缓冲区以字节为单位工作,当我们在示例中创建我们的缓冲区时,我们创建了一个32字节的块。我们创建的视图可以是九种可能类型之一,每种类型都指定了不同的数据大小(以位而不是字节为单位)。因此,类型为Int32的视图表示一个每个元素都是 32 位长的整数的缓冲区。换句话说,32 位视图可以恰好容纳 8 个字节(1 字节=8 位;32 位=8 字节),如下面的屏幕截图所示:

ArrayBuffer and ArrayBufferView

数组缓冲区以字节为单位工作。在图中,有 4 个字节,尽管视图类型是以位为单位工作的。因此,如果我们使用 32 位视图,将导致一个长度恰好为一个元素的数组。如果视图使用 16 位数据类型,那么数组将有 2 个元素(4 个字节除以 16 位)。最后,如果视图使用 8 位数据类型,那么存储在 4 个字节缓冲区中的数组将有 4 个元素。

提示

始终要记住的一件重要的事情是,当你创建一个数组缓冲区时,你选择的长度必须完全能够被你创建的数组缓冲区视图的大小整除。如果缓冲区中没有足够的空间来容纳整个字节,JavaScript 将抛出一个RangeError类型的错误。

在下图中,缓冲区只足够大以容纳 8 位,所有位都必须由整个字节占用。因此,视图是一个 8 位数,恰好可以容纳一个整个元素,这是可以的。16 位元素只能容纳一半的元素,这是不可能的。32 位元素同样只能容纳一部分,这也是不允许的。

ArrayBuffer 和 ArrayBufferView

正如您所看到的,只要数组缓冲区的位长度是视图中使用的数据类型的位大小的倍数,事情就会很顺利。如果视图为 8 位长,则 8、16、24、32 或 40 字节的数组缓冲区都可以很好地工作。如果视图为 32 位长,则缓冲区必须至少为 4 字节长(32 位)、8 字节(64 位)、24 字节(96 位)等。然后,通过将缓冲区中的字节数除以视图表示的数据类型的字节数,我们可以计算出我们可以放入所述数组的总元素数。

// 96 bytes in the buffer
var buffer = new ArrayBuffer(96);

// Each element in the buffer is 32 bits long, or 4 bytes
var view = new Int32Array(buffer);

// 96 / 4 = 24 elements in this typed array
view.length == 24;

类型化数组视图类型

总之,一个普通的数组缓冲区没有实际大小。虽然创建一个长度为 5 字节的数组缓冲区没有意义,但我们可以这样做。只有在创建了数组缓冲区后,我们才能创建一个视图来保存缓冲区。根据缓冲区的字节大小,我们可以通过选择适当的数据类型来确定数组缓冲区视图可以访问多少元素。目前,我们可以从九种数据类型中为数组缓冲区视图选择。

  • Int8Array:它是一个 8 位长的有符号整数,范围从 32,768 到 32,767

  • Uint8Array:它是一个 8 位长的无符号整数,范围从 0 到 65,535

  • Uint8ClampedArray:它是一个 8 位长的无符号整数,范围从 0 到 255

  • Int16Array:它是一个 16 位长的有符号整数,范围从 2,147,483,648 到 2,147,483,647

  • Uint16Array:它是一个 16 位长的无符号整数,范围从 0 到 4,294,967,295

  • Int32Array:它是一个 32 位长的有符号整数,范围从 9,223,372,036,854,775,808 到 9,223,372,036,854,775,807

  • Uint32Array:它是一个 32 位长的无符号整数,范围从 0 到 18,446,744,073,709,551,615

  • Float32Array:它是一个 32 位长的有符号浮点数,范围为 3.4E +/- 38(7 位数)

  • Float64Array:它是一个 64 位长的有符号浮点数,范围为 1.7E +/- 308(15 位数)

不用说,视图类型越大,缓冲区就需要越大来容纳数据。显然,创建的缓冲区越大,浏览器就需要为您设置更多的内存,无论您最终是否使用该内存。因此,我们应该始终注意我们实际可能需要多少内存,并尽量不要分配超过这个数量。如果为了表示游戏中的蛇而分配了一个 64 位长的 10,000 个元素的数组,这将是一种可怕的资源浪费,比如我们在本章中正在构建的游戏中,蛇的最大大小可能不会超过 50 个元素,每个元素的值也不会超过 10。

考虑到这些限制,我们可以计算出一个粗略但乐观的数组大小为 50,其中每个元素只需要 8 位(因为我们只需要大约 10 个唯一的值)。因此,50 个元素乘以每个一个字节,给我们一个总缓冲区大小为 50 字节。这应该足够我们的目的,而仅此缓冲区的内存消耗应该保持在 0.05 KB 左右。不错。

最后,您可能已经注意到,本节的第一部分演示了不使用显式ArrayBuffer构造来创建类型化数组。

// Create a typed array with 4 elements, each 32 bits long
var i32viewA = new Int32Array(4);

// Create the same typed array, but using an explicit ArrayBuffer first
var buffer = new ArrayBuffer(16)
var i32viewB = new Int32Array(buffer)

虽然上面的两个类型化数组指向两个独立的内存位置,但在运行时它们是相同的,无法区分(除非实际的数组保存了不同的值,当然);这里的重点是数组缓冲器视图构造函数可以接受ArrayBuffer,或者简单的integer。如果使用ArrayBuffer,所有上面提到的限制都适用,并且必须小心处理。如果只提供一个integer,浏览器将自动为您创建一个适当大小的数组缓冲器。在实践中,有时候会有少数情况和原因,您会想要手动创建一个独立的数组缓冲器。然而,值得注意的是,即使每个视图是不同的数据类型,也完全可以为同一个数组缓冲器创建多个数组缓冲器视图。请记住,由于缓冲器指向单个内存位置,因此绑定到同一个缓冲器的所有视图都共享该内存空间。

画布

也许没有其他 HTML5 功能像画布 API 一样强大,特别是对于 Web 平台的游戏开发。尽管我们可能已经拥有规范中的每一个功能,以及浏览器可能支持的任何即将推出的功能,但要使用 HTML 和 JavaScript 制作高质量、引人入胜、有趣的游戏几乎是不可能的。画布 API 允许我们在浏览器上创建 2D 和 3D 图形。它还允许我们操纵画布上存储的图形数据,甚至可以到像素级别。

画布图形和 SVG 图形之间的一个主要区别是,SVG 图形是基于矢量的,而画布图形始终是光栅图形,另外一个区别是画布是一个单一的 HTML 元素,其中绘制的所有内容在实际上对浏览器来说都是不存在的。因此,画布上绘制的任何实体的事件处理必须在应用程序级别进行处理。画布上有一些通用事件,我们可以观察和响应,比如点击、移动事件和键盘事件。除此之外,我们可以自由地做任何我们想做的事情。

除了在 HTML5 画布上可以进行基于形状的绘制之外,API 还有三个主要用例。我们可以创建基于精灵的 2D 游戏,完整的 3D 游戏(使用 WebGL 和画布的帮助),以及操纵照片。最后一个提到的用例:照片处理,尤其有趣。API 有一个非常方便的函数,不仅允许我们将画布中的数据导出为 PNG 或 JPG 图像,而且还支持各种类型的压缩。这意味着我们可以在画布上绘制,加载图形(例如照片),以像素级别操纵数据(例如应用类似 Photoshop 的滤镜),旋转、拉伸、缩放,或者以其他方式玩弄数据。然后,API 允许我们将这些数据导出为一个可以保存到文件系统的压缩文件。

对于本书的目的,我们将重点关注画布 API 的方面,这些方面对游戏开发最有用。尽管 WebGL 是画布元素的一个非常令人兴奋的方面,但我们将在第六章中简要介绍它,为您的游戏添加功能。对于画布 API 上其他可用的功能,我们将在下一节中简要介绍并举例说明。

如何使用它

我们需要了解关于画布元素的第一件事是,它有两个部分。一个是物理画布元素,另一个是我们可以通过它绘制到画布的渲染上下文。截至目前,我们可以在现代浏览器中使用两个渲染上下文,即CanvasRenderingContext2DWebGLRenderingContext

要获取画布的渲染上下文的引用,我们需要在画布元素本身上调用一个factory方法。

var canvasA = document.createElement("canvas");
var ctx2d = canvas.getContext("2d");
ctx2d instanceof CanvasRenderingContext2D; // True

var canvasB = document.createElement("canvas");
var ctx3d = canvas.getContext("webgl") || canvas.getContext("experimental-webgl");
ctx3d instanceof WebGLRenderingContext; // True

请注意,使用备用上下文是针对带有前缀的experimentalwebgl上下文。截至目前,大多数支持 WebGL 的浏览器都会通过实验标签来支持它。

本节的其余部分将专门涉及CanvasRenderingContext2D API。虽然从技术上讲,可以使用 WebGL 的 3D 画布上下文来完成 2D 画布上下文可以做的一切,但这两个 API 共同之处仅在于它们与 HTML5 画布元素的关联。WebGL 本身就是一种完整的编程语言,单独的一章是远远不够的。

现在,2D 渲染上下文的一个非常重要的方面是它的坐标空间。与大多数计算机坐标系统类似,原点位于画布的左上角。水平轴向右增加,垂直轴向下增加。用于表示画布的内存中的网格大小由生成渲染上下文的画布的物理大小决定,而不是画布的样式大小。这是一个无法过分强调的关键原则。默认情况下,画布是 300 x 150 像素。即使我们通过层叠样式表CSS)调整了画布的大小,它生成的渲染上下文仍然是那个大小(除非我们物理调整了画布的大小)。一旦渲染上下文被创建,它就无法调整大小。

<style>
canvas {
   border: 3px solid #ddd;
   width: 500px;
   height: 300px;
}
</style>

<script>
   var canvas = document.createElement("canvas");
   var ctx = canvas.getContext("2d");

   document.body.appendChild(canvas);

   alert(ctx.canvas.width);
</script>

如何使用

边框是为了使画布对我们有些可见,因为默认情况下,画布是透明的。

您将观察到 CSS 规则确实应用于画布元素,即使画布的实际大小仍然是默认的 300 x 150 像素。如果我们在画布中间画一个圆,圆看起来会变形,因为应用于画布的样式会拉伸实际绘制圆的坐标空间。

clearRect

我们将要查看的第一个绘图函数是clearRect。这个函数所做的就是清除画布的一个矩形区域。这个函数是在上下文对象上调用的,就像我们将在 2D 画布上进行的所有绘图调用一样。它所需要的四个参数依次代表了从画布原点的 x 和 y 偏移量,以及要清除的宽度和高度距离。请记住,与其他流行的绘图 API 不同,最后两个参数不是从原点开始测量的——它们是从由前两个参数指定的点的位移距离。

var canvas = document.querySelector("canvas");
var ctx = canvas.getContext("2d");

// Clear the entire canvas
ctx.clearRect(0, 0, canvas.width, canvas.height);

// Only clear the half inside area of the canvas
ctx.clearRect(canvas.width * 0.25, canvas.height * 0.25,
   canvas.width * 0.5, canvas.height * 0.5);

// Clear a square 100x100 at the lower right bottom of the canvas
ctx.clearRect(canvas.width - 100, canvas.height - 100, 100, 100);

通常,当每秒渲染许多帧时,我们会在绘制下一帧之前调用此函数来清除整个画布。幸运的是,在大多数 JavaScript 引擎中,这个函数的性能表现相当不错;因此,我们不需要过多担心定期优化要清除的精确区域。

填充和描边

在绘制诸如线条、路径、文本和其他形状等本机对象时,我们将处理描边和填充的概念;就像在 SVG 中一样,描边是指原始图形的轮廓(如边框或类似物),而填充是覆盖形状内部的内容。

我们可以通过将任何颜色分配给fillStylestrokeStyle属性来更改用于填充形状的颜色,或者用于描边形状的颜色。颜色可以是任何有效的 CSS 颜色字符串。

// Short hand hex colors are fine
ctx.fillStyle = "#c00";
ctx.fillRect(0, 0, canvas.width, canvas.height);

// Keyword colors are fine, though not as precise
ctx.strokeStyle = "white";

ctx.lineWidth = 10;
ctx.strokeRect(25, 25, 100, 100);
ctx.strokeRect(175, 25, 100, 100);

// Alpha transparency is also allowed
ctx.fillStyle = "rgba(100, 255, 100, 0.8)";

ctx.fillRect(5, 50, canvas.width - 10, 50);

填充和描边

任何有效的 CSS 颜色字符串都可以分配给 2D 渲染上下文中的颜色属性,包括带有不透明度的颜色。

注意

特别注意渲染上下文的行为很像一个状态机。一旦设置了填充或描边样式,以及任何其他属性,该属性将保持该值,直到您更改它。

另外,请注意,您发出的每个后续绘图调用都会绘制在画布上已有的内容之上。因此,我们可以通过仔细安排绘图调用的顺序来分层形状和图像。

线条

绘制线条就像调用lineTo函数一样简单,它只接受两个参数,表示线条的终点。对lineTo的后续调用将绘制一条线到函数调用指定的点,从上一次绘制线条的地方开始。更具体地说,线条从当前绘制指针的位置开始。

默认情况下,指针没有定义在任何地方,因此将线条绘制到其他点几乎没有意义。为了解决这个问题,我们可以使用moveTo函数,它可以移动绘制指针而不绘制任何东西。

最后,对lineTo的任何调用只是在内存中设置点。为了最终绘制线条,我们需要快速调用 stroke 函数。一旦进行了这个调用,当前设置的任何属性(如线宽和描边样式)都会被绘制。因此,在实际描边线条之前更改线条属性没有什么好处,而且可能会对性能产生负面影响。

ctx.fillStyle = "#fff";
ctx.fillRect(0, 0, canvas.width, canvas.height);

// This call is completely useless
ctx.strokeStyle = "#c0c";
ctx.lineWidth = 5;

ctx.moveTo(0, 0);
ctx.lineTo(100, 100);
ctx.lineTo(canvas.width, 0);

// This call is also useless because the line hasn't been drawn yet
ctx.strokeStyle = "#ca0";
ctx.moveTo(10, canvas.height - 10);
ctx.lineTo(canvas.width - 10, canvas.height * 0.5);

// This color is applied to every line drawn so far
ctx.strokeStyle = "#f5a";

// The line is finally drawn here
ctx.stroke();

线条

形状只有在调用stroke()之后才会被绘制,此时会使用当前的样式属性。

形状

我们可以非常轻松地绘制几种不同的形状。这些是矩形和圆。虽然没有像绘制矩形的rect函数那样的圆函数。但是,有一个arc函数,我们可以从中绘制圆。

rect函数接受四个参数,与fillRect完全相同。arc接受一个 x 和一个 y 坐标,然后是半径、起始角度(以弧度而不是度数表示)、结束角度和一个布尔值,指定弧是顺时针绘制还是逆时针绘制。要绘制一个圆,我们可以绘制一个从 0 到 PI 乘以 2 的弧,这与 360 度相同。

ctx.fillStyle = "#fff";
ctx.strokeStyle = "#c0c";

ctx.fillRect(0, 0, canvas.width, canvas.height);

ctx.rect(10, 10, 50, 50);
ctx.rect(75, 50, 50, 50);

ctx.moveTo(180, 100);
ctx.arc(180, 100, 30, 1, 3, true);

ctx.moveTo(225, 40);
ctx.arc(225, 40, 20, 0, Math.PI * 2, false);

ctx.stroke();

形状

弧(包括圆)是从它们的中心绘制的,而不是从轮廓上的某一点开始。

文本

在 HTML5 画布上绘制文本也非常简单。函数fillText接受一个字符串(要绘制的文本),以及一个 x 和 y 坐标,文本开始绘制的位置。此外,我们可以通过设置文本样式属性字符串到字体属性来对文本进行样式设置,就像通过 CSS 对文本进行样式设置一样。

ctx.fillStyle = "#fff";
ctx.fillRect(0, 0, canvas.width, canvas.height);

ctx.fillStyle = "#f00";
ctx.font = "2.5em 'Times New Roman'";

ctx.fillText("I Love HTML5!", 20, 75);

变换

画布 API 还定义了一些变换函数,允许我们对上下文的坐标系进行平移、缩放和旋转。在变换坐标系之后,我们可以像平常一样在画布上绘制,变换会应用到绘制上。

ctx.fillStyle = "#fff";
ctx.fillRect(0, 0, canvas.width, canvas.height);

// Now the origin is at point 50x50
ctx.translate(50, 50);

ctx.fillStyle = "#f00";
ctx.fillRect(0, 0, 50, 50);

旋转和缩放也是一样的。scale函数接受一个值,用于在每个轴上缩放坐标系。rotation函数接受一个参数,即要将坐标系旋转的角度(以弧度表示)。

ctx.fillStyle = "#fff";
ctx.fillRect(0, 0, canvas.width, canvas.height);

// With transformations, order is very important
ctx.scale(2, 1);
ctx.translate(50, 50);
ctx.rotate(0.80);
ctx.translate(10, -20);

ctx.fillStyle = "#f00";
ctx.fillRect(0, 0, 50, 50);

变换

在变换中,顺序非常重要。

绘制图像

从游戏开发的角度来看,2D 画布 API 最令人兴奋和有用的功能可能就是它能够在上面绘制图像。对我们来说,幸运的是,有几种方法可以直接在画布上绘制常规的 JPG、GIF 或 PNG 图像,包括处理从源到目标的图像缩放的函数。

关于画布元素,我们需要注意的另一点是它遵循相同的源策略。这意味着,为了能够在画布上绘制图像,尝试绘制图像的脚本必须与图像来自相同的域(以及相同的协议和端口号)。任何尝试从不同域加载图像到画布上下文的操作都会导致浏览器抛出异常。

ctx.fillStyle = "#fff";
ctx.fillRect(0, 0, canvas.width, canvas.height);

var img = new Image();
img.onload = function(){
   ctx.drawImage(img, 0, 0, this.width, this.height);
};

img.src = "img/html5-logo.png";

绘制图像的最简单调用只需要五个参数。第一个是图像的引用。接下来的两个参数是图像将被绘制到画布上的 x 和 y 位置,最后两个参数是将图像绘制到画布上的宽度和高度。如果最后两个参数不保持原始图像的宽高比,结果将是扭曲而不是裁剪。另外,请注意,如果原始图像大于画布,或者如果图像是从偏移处绘制的,以至于图像的一部分超出了画布,那么额外的数据将不会被绘制(显然),画布将忽略视图区域外的像素:

绘制图像

在画布渲染上绘制的 HTML5 标志。

一个非常重要的观察是,如果浏览器在调用drawImage时尚未完成从服务器下载图像资源,那么画布将不会绘制任何东西,因为要绘制到画布上的图像尚未加载。在使用某种游戏循环多次每秒绘制相同图像到画布的情况下,这并不是一个问题,因为图像最终加载时,游戏循环的下一次通过将成功绘制图像。然而,在只调用一次绘制图像的情况下(就像上面的例子一样),我们只有一次机会来绘制图像。因此,非常重要的是,我们不要在图像实际加载到内存并准备好绘制到画布之前进行调用。

为了确保在图像从服务器完全下载后才调用将图像绘制到画布的操作,我们可以简单地在图像的加载事件上注册一个回调函数。这样,一旦图像下载完成,浏览器就可以触发回调,最终可以调用绘制图像的操作。这样,我们可以确保在我们想要在画布中呈现图像时,图像确实已经准备好了。

还有另一个版本的相同函数,它考虑了从源到目的地的缩放。在上面的情况下,源图像大于画布。我们可以告诉画布将整个图像绘制到画布的较小区域,而不是使用照片编辑软件调整图像的大小。缩放由画布自动完成。我们还可以将图像绘制到比图像本身更大的区域,但这样做将根据我们缩放图像的程度而导致像素化。

该函数的参数是源图像,源 x 和 y 坐标(换句话说,从图像本身开始采样源图像的位置),源宽度和高度(换句话说,采样源图像的量),以及目标 x 和 y,然后是宽度和高度。

ctx.fillStyle = "#fff";
ctx.fillRect(0, 0, canvas.width, canvas.height);

var img = new Image();
img.onload = function(){

   ctx.drawImage(img,
      // Sample part of the upper left corner of the source image
      35, 60, this.width / 2, this.height / 2,

      // And draw it onto the entire canvas, even if it distorts the image
      0, 0, canvas.width, canvas.height);
};

img.src = "img/html5-logo.png";

绘制图像

在画布渲染上绘制的 HTML5 标志的一部分,有一些故意的拉伸。

操作像素

现在我们知道如何将图像绘制到画布中,让我们将事情推进一步,处理在画布中绘制的单个像素。有两个函数可以用来实现这一点。一个函数允许我们从画布上下文中检索像素数据,另一个函数允许我们将像素缓冲区放回到画布上下文中。此外,还有一个函数允许我们将像素数据作为数据 URL 检索出来,这意味着我们可以将画布中的图像数据保存到用户的文件系统中,就像我们可以使用<img />标签中的常规图像一样。

ctx.fillStyle = "#fff";
ctx.fillRect(0, 0, canvas.width, canvas.height);

var img = new Image();
img.onload = function(){
   ctx.drawImage(img, 35, 60, this.width / 2, this.height / 2, 0, 0, canvas.width, canvas.height);

   // Extract pixel data from canvas context
   var pixels = ctx.getImageData(0, 0, canvas.width, canvas.height);

   pixels instanceof ImageData; // True
   pixels.data instanceof Uint8ClampedArray; // True
   pixels.width == canvas.width; // True
   pixels.height == canvas.height; // True

   // Insert pixel data into canvas context
   ctx.putImageData(pixels, 0, 0);
};

img.src = "img/html5-logo.png";

要获取表示当前在画布上绘制的内容的像素数据,我们可以使用getImageData函数。四个参数是源图像上的 x 和 y 偏移量,以及要提取的宽度和高度。请注意,这个函数的输出是一个ImageData类型的对象,它有三个属性,即宽度、高度和包含实际像素信息的类型化数组。正如本章前面提到的,这个类型化数组是Uint8ClampedArray类型的,其中每个元素只能是一个值在 0 到 255 之间的整数。

像素数据是一个长度为(canvas.width x canvas.height x 4)的缓冲区。也就是说,每四个元素代表一个像素,按照红色、绿色、蓝色和 alpha 通道的顺序表示像素。因此,为了通过这个画布 API 操纵图像,我们对这个像素缓冲区进行各种计算,然后可以使用putImageData函数将其放回画布。

putImageData的三个参数是ImageData对象,以及目标画布上的 x 和 y 偏移量。从那里,画布将尽可能地呈现图像数据,裁剪任何多余的数据,否则会被绘制在画布外部。

作为我们可以用图像做的一个例子,我们将取出我们在画布上绘制的 HTML5 标志,并对代表它的像素数据应用灰度函数。如果这听起来像一个复杂的任务,不用担心。虽然有几种不同的公式可以将彩色图像转换为灰度图像,但最简单的方法是简单地对每个像素的红色、绿色和蓝色值求平均值。

ctx.fillStyle = "#fff";
ctx.fillRect(0, 0, canvas.width, canvas.height);

var img = new Image();
img.onload = function(){
   ctx.drawImage(img, 35, 60, this.width / 2, this.height / 2, 0, 0, canvas.width, canvas.height);

   // Extract pixel data from canvas context
   var pixels = ctx.getImageData(0, 0, canvas.width, canvas.height);

   // Iterate over every four elements, which together represent a single pixel
   for (var i = 0, len = pixels.data.length; i < len; i += 4) {
      var red = pixels.data[i];
      var green = pixels.data[i + 1];
      var blue = pixels.data[i + 2];
      var gray = (red + green + blue) / 3;

     // PS: Alpha channel can be accessed at pixels.data[i + 3]

      pixels.data[i] = gray;
      pixels.data[i + 1] = gray;
      pixels.data[i + 2] = gray;
   }

   // Insert pixel data into canvas context
   ctx.putImageData(pixels, 0, 0);
};

img.src = "img/html5-logo.png";

操作像素

操纵图像并不比对代表图像的像素缓冲区中的每个像素进行各种计算更复杂。

最后,我们可以通过调用toDataURL函数来从画布中导出图像。特别注意,这个函数是在画布对象上调用的,而不是在渲染上下文对象上调用的。画布对象的toDataURL函数接受两个可选参数,即表示输出图像的 MIME 类型的字符串,以及一个介于0.01.0之间的float,表示输出图像的质量。如果输出图像类型不是"image/jpeg",则忽略质量参数。

   ctx.putImageData(pixels, 0, 0);

   var imgUrl_LQ = canvas.toDataURL("image/jpeg", 0.0);
   var out = new Image();
   out.src = imgUrl_LQ;
   document.body.appendChild(out);

   var imgUrl_HQ = canvas.toDataURL("image/jpeg", 1.0);
   var out = new Image();
   out.src = imgUrl_HQ;
   document.body.appendChild(out);

   var imgUrl_raw = canvas.toDataURL("image/png");
   var out = new Image();
   out.src = imgUrl_raw;
   document.body.appendChild(out);

Web workers

Web workers 带来了在主 UI 线程之外执行代码的能力。这种类似线程的行为使我们能够执行长时间的任务而不阻塞用户界面。当一个 JavaScript 任务花费太长时间来完成时,浏览器会向用户显示一个警报,让用户知道页面没有响应。使用 web workers,我们可以解决这个问题。

关于 web workers,我们需要牢记一些限制。首先,workers 在 DOM 之外运行,因此任何与 DOM 相关的功能在 worker 线程内不可用。此外,workers 没有共享内存的概念——传递给 worker 的任何数据都会被复制到它自己的内存空间中。最后,传递给和从 worker 传递的任何对象都可以包含任何数据类型,除了函数。如果尝试传递函数给 worker(或者包含对函数引用的对象),浏览器将抛出一个DataCloneError(DOM Exception 25)。

另一方面,workers 完全能够发起 XHR 请求(Ajax 调用),启动其他 workers,并停止其他 workers,包括它们自己。一旦 worker 被终止,它就不能再启动,类似于其他语言中可用的其他线程构造。

如何使用它

在这一部分,我们将创建一个示例迷你应用程序,该应用程序在一个工作线程中生成素数。用户可以在应用程序中输入一个数字,应用程序将返回一个小于该数字的素数列表。然后这些素数将被传回主应用程序,主应用程序将把素数列表返回给用户。

要开始使用 Web Workers,我们必须首先创建一个单独的 JavaScript 文件,该文件将在工作线程中运行。该脚本将通过消息与其父线程通信。为了从父线程接收消息,工作线程需要注册一个回调函数,每当有消息传递给它时就会被调用。

self.addEventListener("message", getPrimes);

当接收到消息时,该函数在工作线程和其父线程中都会被调用,并且会传递一个MessageEvent对象。该对象包含许多属性,包括时间戳,最重要的是一个数据属性,其中包含传递给工作线程的任何数据。

要向工作线程或其父级发送消息,我们只需在适当的对象上调用postMessage函数(无论是工作对象还是在工作线程中的 self 对象),并将数据与函数调用一起传递。这些数据可以是单个值、数组或任何类型的对象,只要不包括函数。

最后,要创建一个worker对象,我们只需创建Worker类的一个实例,并将工作脚本的路径作为构造函数参数传递。这个worker对象将需要为它想要观察的任何事件注册回调函数:onMessageonError。要终止工作线程,我们可以直接在工作对象上调用terminate函数,或者在工作脚本上调用close函数。

// index.html
var worker = new Worker("get-primes.worker.js");

worker.addEventListener("message", function(event){
   var primes = event.data.primes;
   var ul = document.createElement("ul");

   // Parse each prime returned from the worker
   for (var i = 0, len = primes.length; i < len; i++) {
      var li = document.createElement("li");
      li.textContent = primes[i];
      ul.appendChild(li);
   }

   // Clear any existing list items
   var uls = document.querySelectorAll("ul");
   for (var i = 0, len = uls.length; i < len; i++)
      uls[i].remove();

   // Display the results
   document.body.appendChild(ul);
});

var input = document.createElement("input");
input.addEventListener("keyup", function(event){
   var key = event.which;

   // Call the worker when the Enter key is pressed
   if (key == 13 /* Enter */) {
      var input = this.value;

      // Only use input that's a positive number
      if (!isNaN(input) && input > 0) {
         worker.postMessage({max: input});
      } else if (input == -1) {
         worker.terminate();
         this.remove();
      }
   }
});

input.setAttribute("autofocus", true);
document.body.appendChild(input);

在上面的片段中,我们设置了两件事:一个工作线程和一个输入字段。然后我们在输入字段上设置了一个keydown监听器,这样用户就可以输入一个数字发送到工作线程。要将这个数字发送到工作线程,用户必须按下Enter键。当发生这种情况时,输入字段中的数字将是工作线程生成的最大可能的素数。如果用户输入数字-1,则工作线程将被终止,并且输入字段将从 DOM 中移除。

为了简单起见,工作线程将使用埃拉托斯特尼筛法来查找素数。请记住,这个练习只是一个概念验证,用来说明 Web Workers 的工作原理,而不是高级数学课程。

// get-primes.worker.js

// Register the onMessage callback
self.addEventListener("message", getPrimes);

// This function implements the Sieve of Eratosthenes to generate the primes.
// Don't worry about the algorithm so much – focus on the Worker API
function getPrimes(event) {

   var max = event.data.max;
   var primes = [];
   var d = [];

   for (var q = 2; q < max; q++) {
      if (d[q]) {
         for (var i = 0; i < d[q].length; i++) {
            var p = d[q][i];
            if (d[p + q])
               d[p + q].push(p);
            else
               d[p + q] = [p];
         }
         delete d[q];
      } else {
         primes.push(q);
         if (q * q < max)
            d[q * q] = [q];
      }
   }

   // Return the list of primes to the parent thread
   self.postMessage({primes: primes});
}

如何使用它

只要工作线程没有被终止,就可以无限次地调用工作线程。一旦终止,工作线程就可以被删除,因为从那时起它就没有任何有用的目的了。

离线应用程序缓存

离线应用程序缓存是一种在浏览器上存储资产以供用户在未连接到互联网时使用的方法。这个 API 进一步消除了本地应用程序和 Web 应用程序之间的任何障碍,因为它消除了将 Web 应用程序与本地应用程序区分开来的主要特征——对全球网络的连接需求。尽管用户显然仍然需要在某个时候连接到网络,以便可以最初下载应用程序;之后,应用程序可以完全从用户的缓存中运行。

离线应用程序缓存的主要用例可能是当用户的连接不稳定、一致或者在每次使用应用程序时都不连接的情况。这在游戏中尤其如此,因为用户可能选择在某些时间玩某个在线游戏,但之后离线。同样,如果游戏需要连接到后端服务器,以执行任何任务(例如检索新的游戏数据),只要用户连接,资源就可以再次被缓存在本地,新数据可以在用户的连接不可用时再次使用。

如何使用它

离线应用程序缓存 API 的核心是清单文件,它指定了浏览器应该为离线使用缓存哪些资源,哪些资源绝对不能被缓存,以及当尝试连接到服务器但找不到连接时浏览器应该做什么。

当加载应用程序时,清单文件与用户请求的 HTML 文件一起提供。更具体地说,主机 HTML 文件指定了清单文件的路径,然后浏览器并行获取和处理主应用程序的下载和处理。这是通过根html标记中的manifest属性完成的。

<!doctype html>
<html manifest="manifest.appcache">

请注意,上面的片段指定了一个名为manifest.appcache的清单文件,位于指定清单的 HTML 文件相同的目录中。文件的名称和扩展名完全是任意的。按照惯例,许多开发人员简单地将清单命名为manifest.appcachemanifest(没有扩展名)或appcache.manifest。但是,这个文件也可以被命名为manifest.php?id=2642my-manifest-file.txtthe_file.json

要记住的一件重要的事情是,清单文件必须以正确的 MIME 类型提供。如果浏览器尝试获取根 HTML 标记中manifest属性中列出的任何文件,并且 MIME 类型不是text/cache-manifest,那么浏览器将拒绝清单,并且不会发生离线应用程序缓存。

设置文件的 MIME 类型有很多种方法,但通常这是服务器设置。如果使用 Apache 服务器,比如我们在 WAMP、MAMP 或 LAMP 中使用的服务器(请参阅在线章节《设置环境》),我们可以通过.htaccess文件轻松实现这一点。例如,在我们项目的根目录中,我们可以创建一个名为.htaccess的文件,其中包含以下代码:

AddType text/cache-manifest .appcache

这将告诉服务器为任何扩展名为.appcache的文件添加正确的 MIME 类型。当然,如果您决定调整htaccess文件以为其他文件扩展名提供cache-manifest MIME 类型,如果您选择的扩展名已经与其他 MIME 类型相关联(例如.json),可能会遇到问题。

清单文件的第一行必须是以下字符串:

CACHE MANIFEST

如果这一行不存在,整个 API 将不起作用。如果在上述列出的字符串之前有多余的空格,浏览器将抛出以下错误,指示文件清单无效,并且不会被缓存:

Application Cache Error event: Failed to parse manifest

注意

在游戏中使用离线应用程序缓存时,请确保密切关注浏览器的 JavaScript 控制台。如果出现任何问题,比如找不到清单文件、解析清单或加载清单中描述的任何资源,浏览器会通过引发异常来告诉您发生了错误,但它会继续执行。与大多数致命的 JavaScript 异常不同,致命的离线应用程序缓存异常不会停止或影响启动缓存过程的脚本的执行。因此,您可能会遇到应用程序缓存异常而不知道,因此熟悉浏览器支持的任何开发人员工具,并充分利用它。

清单的其余部分可以分为三个主要类别,即要缓存的资产、永远不要缓存的资产和回退资产。注释可以放置在文件的任何位置,并以井号表示。井号后的整行将被清单解析器忽略。

CACHE MANIFEST

# HTML5 Snake, Version 1.0.0

CACHE:
index.html
js/next-empty.worker.js
js/renderer.class.js
js/snake.class.js
img/block-green.png
img/fruit-01.png
fonts/geo.woff
fonts/vt323.woff
css/style.css

NETWORK:
*

FALLBACK:
fallback.html

通过在网络部分使用通配符,我们指示任何未在缓存下指定的资源都属于网络部分,这意味着这些资源不会被缓存。在没有网络访问时尝试加载这些资源将导致加载回退文件。这是一个很好的选择,可以让用户知道需要网络访问,而无需特殊处理任何额外的代码。

一旦清单被解析并且所有资源都被缓存,所有资源将保持缓存,直到用户删除离线应用程序缓存数据(或浏览器缓存的所有数据),或者清单被更改。即使清单文件中只有一个字符发生变化,浏览器也会认为它是一个更新,因此所有资源都会被重新缓存。因此,许多开发人员在清单文件的顶部写下了一行注释,其中包括一些版本号,用于标识清单的唯一版本。这样,如果一个或多个资产发生变化,我们可以通过简单地更改清单文件中列出的版本号来强制浏览器重新缓存这些资产。请记住,浏览器只会检查清单文件中的文本,以确定是否需要下载新的资源。如果资源发生变化(比如,您更新了清单中列出的 JavaScript 代码,或者一些图形,或者任何其他资源),但清单文本没有变化,这些资源将不会从服务器上拉取,用户将继续使用应用程序中过时的资产,因为资产只从缓存中加载。

代码

这个游戏的布局实际上非常简单。HTML 只有三个小部件:游戏的标题,玩家当前得分的计分板,以及跨多个游戏的总高分计分板。这个最后的计分板在这个版本的游戏中没有使用,我们将在下一个游戏中更深入地讨论它(参见第五章,“改进蛇游戏”)。

<h1>HTML5 Snake</h1>

<section id="scores">
   <h3>Score: <span>0</span></h3>
   <h3>High Score: <span>0</span></h3>
</section>

<section id="gameMenu" class="hide">
   <h3>Ready!</h3>
   <button>Play</button>
</section>

为了将游戏中所有不同组件的各种责任分开,我们将整个游戏的渲染抽象成一个单独的Renderer类。这个类负责向给定的canvas引用绘制数据。它绘制的数据,无论是蛇还是其他对象,都以类型化数组的形式传递给它,表示实体要绘制的坐标,以及在类型化数组指定的位置绘制的图像资源。 Renderer类还包括一些辅助函数,帮助我们轻松清除画布,并将xy点转换为用于遍历表示 2D 数组的扁平数组的索引。

var Renderer = function(canvas) {

   var canvas = canvas;
   var ctx = canvas.getContext("2d");
   var width = canvas.width;
   var height = canvas.height;

   var getIndex = function(x, y) {
      return width * y + x;
   };

   var getPosition = function(index) {
      return {
         x: index % width,
         y: parseInt(index / width)
      };
   };

   this.clear = function() {
      ctx.clearRect(0, 0, canvas.width, canvas.height);
   };

   this.draw = function(points, img) {
      for (var i = 0, len = points.length; i < len; i += 2) {
         ctx.drawImage(img, points[i] * img.width, points[i + 1] * img.height, img.width, img.height);
      }
   };
};

接下来,我们创建了一个Snake类,它封装了与蛇相关的所有数据和行为。这个类存储的数据包括蛇头的当前位置,蛇身的当前长度,代表蛇的绘制图像,以及蛇是否存活。它处理的行为包括移动蛇和处理用户输入(为简单起见,这些都包含在这个类中)。还有一些辅助函数,允许我们将其他行为委托给客户端。例如,通过公开的 API,客户端可以在每一帧检查蛇是否超出了世界网格,它是否吃了水果,或者蛇是否撞到了自己的身体。客户端还可以使用提供的 API 对蛇采取行动,比如设置它的生命属性(死或活),以及重置用于绘制蛇的图像,或者它的任何其他属性。

var Snake = function(x, y, width, height, maxSize) {
   var isAlive = true;
   var size = 0;
   var body = new Int8Array(maxSize * 2);
   for (var i = 0, len = body.length; i < len; i++)
      body[i] = -1;
   body[0] = x, body[1] = y;
   var worldWidth = width;
   var worldHeight = height;
   var skin;
   var dir = { 38: false, 40: false, 37: false, 39: false };
   var keys = { UP: 38, DOWN: 40, LEFT: 37, RIGHT: 39 };
   // To move the snake, we first move each body part to where the
   // part before it used to be, starting at the tail and moving
   // towards the head. Lastly, we update the head's position
   var move = function() {
      // Traverse the snake backwards and shift each piece one spot
      for (var i = size * 2 + 1; i > 1; i -= 2) {
         body[i] = body[i - 2];
         body[i - 1] = body[i - 3];
      }
      if (dir[keys.UP]) {
         body[1]--;
      } else if (dir[keys.DOWN]) {
         body[1]++;
      } else if (dir[keys.LEFT]) {
         body[0]--;
      } else if (dir[keys.RIGHT]) {
         body[0]++;
      }
   };
   // Update the snake's position vectors on key presses
   this.doOnKeyDown = function(event) {
      var key = event.which;
      // Don't process a key that's already down
      if (dir[key])
         return;
      dir[keys.UP] = false;
      dir[keys.DOWN] = false;
      dir[keys.LEFT] = false;
      dir[keys.RIGHT] = false;
      if (key == keys.UP && !dir[keys.DOWN]) {
         return dir[keys.UP] = true;
      } else if (key === keys.DOWN && !dir[keys.UP]) {
         return dir[keys.DOWN] = true;
      } else if (key === keys.LEFT && !dir[keys.RIGHT]) {
         return dir[keys.LEFT] = true;
      } else if (key === keys.RIGHT && !dir[keys.LEFT]) {
         return dir[keys.RIGHT] = true;
      }
   };
   // This allows us to use different images to represent the snake
   this.setSkin = function(img) {
      skin = new Image();
      skin.onload = function() {
         skin.width = this.width;
         skin.height = this.height;
      };
      skin.src = img;
   };
      this.move = move;
   this.getSkin = function() { return skin; };
   this.setDead = function(isDead) { isAlive = !isDead; };
   this.isAlive = function() { return isAlive; };
   this.getBody = function() { return body; };
   this.getHead = function() { return {x: body[0], y: body[1]}; };
   this.grow = function() { if (size * 2 < body.length) return size++; };
   // Check if the snake is at a certain position on the grid
   this.isAt = function(x, y, includeHead) {
      var offset = includeHead ? 0 : 2;
      for (var i = 2, len = body.length; i < len; i += 2) {
         if (body[i] == x && body[i + 1] == y)
            return true;
      }
      return false;
   };
   this.reset = function(x, y) {
      for (var i = 0, len = body.length; i < len; i++)
         body[i] = -1;
      body[0] = x;
      body[1] = y;
      size = 0;
      isAlive = true;
      dir[keys.UP] = false;
      dir[keys.DOWN] = false;
      dir[keys.LEFT] = false;
      dir[keys.RIGHT] = false;
   };
};

snake类类似,我们还创建了一个类来封装蛇将要吃的水果。snake类和fruit类之间唯一的区别是fruit类除了出现在地图上之外不会做任何其他事情。在实际目的上,fruit类与snake类共享一个公共实体接口,允许它们被重置为默认状态,设置它们的位置,并检查碰撞。

var fruit = {
   position: new Int8Array(2),
   reset: function() {
      this.position[0] = -1;
      this.position[1] = -1;
   },
   isAt: function(x, y) {
      return this.position[0] == x && this.position[1] == y;
   },
   img: null
};

最后,在主代码中,我们执行以下设置任务:

  • 创建一个 canvas 元素并将其附加到 DOM。

  • 实例化renderersnakefruit对象。

  • 创建一个游戏循环,在没有水果存在时在网格上放置一个水果,更新蛇的位置,检查蛇的位置,并将游戏状态渲染到画布上。

我们还使用游戏循环来连接记分牌小部件,以增强用户体验。游戏的完整源代码可在 Packt Publishing 网站上的书页上找到,还包括额外的菜单,但由于简洁起见,这些菜单已经从这里显示的代码片段中删除。

在这个游戏循环中,我们还利用了requestAnimationFrameAPI。为了确保不同的 CPU 和 GPU 以相同的速度渲染游戏,我们在游戏循环内添加了一个简单的帧速率控制器。帧速率由一个变量控制,指定游戏应该尝试以多少 fps 运行。

function gameLoop() {
   // Only do anything here if the snake is not dead
   if (snake.isAlive()) {

      // Make the frame rate no faster than what we determine (30 fps)
      renderTime.now = Date.now();
      if (renderTime.now - renderTime.last >= renderTime.fps) {
         // If there is no fruit on the grid, place one somewhere. Here we
         // use a web worker to calculate an empty square on the map
         if (fruit.position[0] < 0) {
            cellGen.postMessage({
               points: snake.getBody(),
               width: worldWidth,
               height: worldHeight
            });
         } else {

            snake.move();
            head = snake.getHead();

            // Check if the snake has ran into itself, or gone outside the grid
            if (snake.isAt(head.x, head.y, false) ||
                   head.x < 0 || head.y < 0 ||
                   head.x >= worldWidth || head.y >= worldHeight) {
               snake.setDead(true);
            }

            // Check if the snake has eaten a fruit
            if (fruit.isAt(head.x, head.y)) {
               fruit.reset();
               snake.grow();
               score.up();
            }

            renderTime.last = renderTime.now;
         }
      }

      // Render everything: clear the screen, draw the fruit, draw the snake,
      // and register the callback with rAF
      renderer.clear();
      renderer.draw(fruit.position, fruit.img);
      renderer.draw(snake.getBody(), snake.getSkin());
      requestAnimationFrame(gameLoop);
   }

   // If the snake is dead, stop rendering and disable
   // the key handlers that controlled the snake
   else {
      document.body.removeEventListener("keydown", snake.doOnKeyDown);
   }
}

总结

在本章中,我们开始使用备受期待的 canvas API 进行 2D 渲染。我们研究了通过 canvas 渲染上下文可用的各种绘图函数,包括绘制简单的线条和形状,从外部图像源绘制图像,像素操作和图像提取,这使我们能够将画布上的图像保存回用户的文件系统。

我们还研究了通过 Web Worker 接口可用的新线程系统。这使我们能够释放用户界面线程,同时执行长时间运行的任务,否则会锁定界面,并导致浏览器显示非响应页面警报。不幸的是,Web Worker 存在一些限制,因为工作线程之间没有共享内存,也不允许在工作线程中关联或允许 DOM。尽管如此,HTML5 的这一壮丽新功能仍然可以完成许多工作。

在本章中,我们涵盖的另一个 HTML5 特定 API 是离线应用程序缓存。通过这种机制,我们可以从 Web 服务器保存特定资产,将其存储为快速、高可用的缓存,由用户的浏览器提供支持。浏览器保存的特定资产由清单文件指定,虽然它是一个简单的基于文本的文件,并且必须由服务器以text/cache-manifest MIME 类型提供。

最后,我们还研究了 JavaScript 语言的两个新功能,使游戏开发更加高效和令人兴奋。这两个功能中的第一个是requestAnimationFrame,它允许我们在单个同步调用中渲染所有内容,由浏览器自己管理。这通常是渲染所有图形的最佳方式,因为浏览器可以高度优化渲染过程。第二个功能是类型化数组数据类型,它允许更高效的数据存储和访问。这对游戏开发特别有吸引力,因为我们可以通过使用这种新的数据类型获得额外的性能提升,即使它看起来和行为几乎与常规数组完全相同。因此,使用类型化数组编写新代码应该完全没有学习曲线,因为迁移使用数组的现有代码是一种真正的享受。

在下一章中,我们将继续改进 Snake 游戏,使其更加健壮和功能丰富。我们将学习另外四个 HTML5 API,即 sessionStorage、localStorage、IndexedDB 和 web messaging。

第五章:改进贪吃蛇游戏

本章是我们构建更健壮的贪吃蛇游戏系列的第二部分,也是最后一部分。在本章中,我们将继续使用第三章中已有的内容,理解 HTML5 的重要性,并向其中添加更多的 HTML5 API,以使游戏更加丰富,提供更加引人入胜的用户体验。

游戏的第一个版本使用了五个 HTML5 概念,即 2D 画布渲染、离线应用程序缓存、Web Workers、类型化数组和 requestAnimationFrame。在这个版本中,我们将包括来自新 Web 存储 API 的两个功能,即本地存储和会话存储。我们还将研究 Web 存储的一部分,即 IndexedDB,以及包括跨域消息传递的 Web 消息传递功能。

本地存储和会话存储是两种机制,允许我们使用键值策略在用户的浏览器上保存数据。这类似于 cookie,其中每个值必须是一个字符串。这两种存储选项与 cookie 之间的区别首先是,cookie 始终通过 HTTP 请求发送回服务器。当我们希望存储更多数据时,这可能特别不希望发生,因为数据会在网络中传输,消耗额外的带宽,而我们无能为力。使用 HTML5 的 Web 存储,我们可以在本地保存更多数据,而这些数据永远不会离开用户的机器,尽管像 cookie 这样的 HTTP 组件会离开。

IndexedDB,也是 Web 存储的一部分,类似于本地和会话存储,数据以键值方式存储,但是与仅限于字符串的值不同,IndexedDB 更像是一个对象存储,我们可以存储整个 JavaScript 对象。当然,IndexedDB 远不止是一个简单的哈希映射,用于为我们保存对象。正如其名称所示,这个新的 API 允许我们对这些存储的对象进行索引,以便通过查询系统进行搜索。总之,IndexedDB 是一个通过异步编程接口访问的 NoSQL 数据库。

最后,Web 消息传递 API 提供了一个接口,通过该接口,HTML 文档可以与其他 HTML 上下文进行通信。这些文档可以通过 iframe 相关联,在单独的窗口中,甚至在不同的域中。

游戏

在游戏的第二个版本中添加了两个新功能。首先,我们现在可以跟踪玩家获得的最高分,并通过本地存储保存它。即使玩家关闭浏览器应用程序或关闭计算机,该值仍将安全地存储在玩家的硬盘上,并在游戏重新开始时加载。其次,我们使用会话存储在玩家在游戏中吃水果时以及玩家杀死蛇时保存游戏状态。这被用作额外的精彩之处,当玩家失败时,我们会显示玩家在游戏中实现的所有单独的升级,以及玩家撞墙或撞到蛇时的快照,如下图所示:

游戏

在每局游戏结束时,会显示玩家获得升级的瞬间图像,以及玩家最终死亡的快照。这些图像是通过 canvas API(调用toDataURL函数)创建的,并且组成每个图像的数据在整个游戏中都会被保存,并使用 Web 存储 API 进行存储。

有了这样一个功能,我们可以使游戏变得更加有趣,可能也更加社交化。想象一下,如果玩家不仅可以将他们的最高分发布到他们最喜欢的社交网络网站,还可以在关键时刻发布游戏的图片,那将会有多么强大。当然,这个功能的基础只是在本章中实现了(换句话说,我们只是在游戏的关键时刻拍摄了快照)。将实际功能添加到将这些数据发送到真正的社交网络应用程序中,留给读者作为练习。

API 使用

游戏中使用的每个 API 的一般描述和演示在以下部分中给出。要了解每个功能是如何被整合到最终游戏中的,请查看代码部分。要获取此游戏的完整源代码,请查看 Packt Publishing 网站上的书页。

Web 消息传递

Web 消息传递允许我们与其他 HTML 文档实例进行通信,即使它们不在同一个域中。例如,假设我们的贪吃蛇游戏托管在snake.fun-html5-games.com,通过iframe嵌入到一个社交网站中(假设这个社交网站托管在www.awesome-html5-games.net)。当玩家获得新的最高分时,我们希望将来自贪吃蛇游戏的数据直接发布到主页(加载游戏的iframe页面)。使用 Web 消息传递 API,这可以在本地完成,而无需任何服务器端脚本。

在 Web 消息传递之前,文档通常不允许与其他域中的文档通信,主要是因为安全性的原因。当然,如果我们盲目地接受来自任何应用程序的消息,Web 应用程序仍然可能容易受到恶意外部应用程序的攻击。然而,Web 消息传递 API 提供了一些可靠的安全措施来保护接收消息的页面。例如,我们可以指定消息要发送到的域,以便其他域无法拦截消息。在接收端,我们还可以检查消息的来源,从而忽略来自任何不受信任域的消息。最后,DOM 永远不会直接通过此 API 暴露,提供了另一层安全性。

如何使用它

与 Web Workers 类似,两个或多个 HTML 上下文之间通过 Web 消息传递 API 进行通信的方式是注册on-message事件的事件处理程序,并使用postMessage函数发送消息:

// ---------------------------------
// Host document: web-messaging.html
// ---------------------------------
var doc = document.querySelector("iframe").contentWindow;
// alternatively:
// var doc = window.open("web-messaging-rec.html", "", "width=800,height=600");
// Post a message to the child document
doc.postMessage({msg: "Hello!"}, "http://localhost");
// --------------------------------------
// Child document: web-messaging-rec.html
// --------------------------------------
window.addEventListener("message", function(event) {
   var data = event.data;
   // Post a message back to the parent document
   event.source.postMessage({msg: "Thanks for saying " + data.msg}, "*");
});

使用 Web 消息传递 API 的第一步是获取要与之通信的某个文档的引用。这可以通过获取iframe引用的contentWindow属性,或者打开一个新窗口并保留该引用来完成。持有此引用的文档称为父文档,因为这是通信发起的地方。尽管子窗口可以与其父窗口通信,但这只能在这种关系成立的情况下发生。换句话说,窗口不能与任何窗口通信;它需要一个引用,无论是通过父子关系还是通过子父关系。

一旦引用了子窗口,父窗口就可以通过postMessage函数向其子窗口发送消息。当然,如果子窗口没有定义回调函数来捕获和处理传入的消息,那么发送这些消息就没有什么意义。但是,父窗口无法知道子窗口是否定义了回调函数来处理传入的消息,所以我们能做的最好的事情就是假设(并希望)子窗口已经准备好接收我们的消息。

postMessage函数中使用的参数与 Web Workers 中使用的版本非常相似。也就是说,可以发送任何 JavaScript 值(数字、字符串、布尔值、对象文字和数组,包括类型化数组)。如果将函数作为postMessage的第一个参数发送(直接发送或作为对象的一部分),浏览器将引发DATA_CLONE_ERR: DOM Exception 25错误。第二个参数是一个字符串,表示我们允许消息被接收的域。这可以是绝对域,一个斜杠(表示与发送消息的文档相同的源域),或一个通配符字符(*),表示任何域。如果消息被不匹配postMessage中的第二个参数的域接收,整个消息将失败。

在接收消息时,子窗口首先在消息事件上注册一个回调。这个函数传递了一个MessageEvent对象,其中包含以下属性:

  • event.data:它返回消息的数据

  • event.origin:它返回消息的来源,用于服务器发送的事件和跨文档消息

  • event.lastEventId:它返回最后一个事件 ID 字符串,用于服务器发送的事件

  • event.sourceReturns:它是源窗口的 WindowProxy,用于跨文档消息

  • event.portsReturns:这是与消息一起发送的 MessagePort 数组,用于跨文档消息和通道消息

注意

来源:www.w3.org/TR/webmessaging/#messageevent

举个例子,我们可以在现实世界中使用这个功能,就游戏开发而言,想象一下能够玩我们的贪吃蛇游戏,但蛇可以穿过几个窗口。多有创意啊!当然,从实际角度来看,这可能不是玩游戏的最佳方式,但我很难反驳这样的事实,即这确实是对一个普通游戏非常独特和引人入胜的呈现。

如何使用

借助 Web 消息传递 API 的帮助,我们可以设置一个贪吃蛇,其中贪吃蛇不受限于单个窗口。想象一下,当我们将这个巧妙的 API 与另一个非常强大的 HTML5 功能结合起来时,这个功能非常适合游戏 - Web 套接字。通过将 Web 消息传递与 Web 套接字结合起来,我们不仅可以在多个窗口中玩贪吃蛇,还可以同时与多个玩家玩游戏。也许每个玩家在蛇进入给定窗口时都可以控制蛇,并且所有玩家可以同时看到所有窗口,即使他们每个人都在使用不同的计算机。这些可能性是无穷无尽的。

令人惊讶的是,用于设置贪吃蛇的多窗口端口的代码非常简单。基本设置是相同的,我们有一个一次只能朝一个方向移动的蛇。我们还有一个或多个蛇可以移动的窗口。如果我们将每个窗口存储在一个数组中,我们可以计算蛇需要呈现在哪个屏幕上,给定其当前位置。找出蛇应该呈现在哪个屏幕上,给定其世界位置,是最棘手的部分。

例如,假设每个窗口宽度为 200 像素。现在,假设有三个打开的窗口。每个窗口的画布也只有 200 像素宽,所以当蛇在位置 350 时,在所有画布中都会打印得太靠右。所以我们首先需要确定总世界宽度(画布宽度乘以画布的总数),计算蛇所在的窗口(位置/画布宽度),然后将位置从世界空间转换到画布空间,给定蛇所在的画布。

首先,在父文档中定义我们的结构。代码如下:

// 1\. Create an array to hold each frame (aka. window)
var frames = new Array();
// 2\. Let's keep track of some settings for these frames
frames.max = 3;
frames.width = 200;
frames.height = 300;
frames.margin = 50;
// 3\. Finally, we'll need a snake to move around
var snake = {
  max: 3,
  pos: {
    x: 0,
    y: 0
  },
  w: 25,
  h: 25,
  speed: 3,
  dir: {
    x: 1,
    y: 0
  },
  color: "#0a0"
};

当此脚本加载时,我们需要一种方法来创建新窗口,蛇将能够在其中移动。这可以通过单击按钮轻松完成,然后将该窗口添加到我们的帧数组中,以便我们可以遍历该数组,并告诉每个窗口蛇在哪里。此代码如下所示:

// Define a few global variables in order to keep the code shorter and simpler
var isPaused = true;
var timer;
var dirChange = 100;
var btn = document.createElement("button");
btn.textContent = "Add Window";
btn.addEventListener("click", function(event){
  var left = frames.length * frames.width + frames.margin * frames.length;
  frames[frames.length] = window.open("/packt/snake-v2/snake-panels.html", "",
    "width=" + frames.width + "," +
    "height=" + frames.height + "," +
    "top=100, left=" + left);
  isPaused = false;
  clearTimeout(timer);
  play();
}, false);
document.body.appendChild(btn);
// We'll close all the windows we have opened to save us the
// trouble of clicking each window when we want them closed
function closeAll() {
  for (var i = 0, len = frames.length; i < len; i++) {
    frames[i].close();
  }
}
window.onunload = closeAll;

现在,真正的魔法发生在以下方法中。我们要做的就是更新蛇的位置,然后告诉每个窗口蛇在哪里。这将通过将蛇的位置从世界坐标转换为画布坐标(因为每个画布的宽度都相同,这对于每个画布来说很容易),然后告诉每个窗口蛇应该在画布中的哪个位置呈现。由于该位置对每个窗口都有效,我们还单独告诉每个窗口是否应该呈现我们发送给它们的信息。只有我们计算出蛇在其中的窗口才会被告知继续呈现。

function play() {
  // This is used to change the snake's position randomly
  // from time to time. The reason for this is so we don't
  // need to implement any event handling to handle user input,
  // since this is just a simple demonstration.
  if (dirChange-- < 0) {
    dirChange = 100;
    var rand = parseInt(Math.random() * 1000) % 4;
    // Make the snake move to the right
    if (rand == 0) {
      snake.dir.x = 1;
      snake.dir.y = 0;
    // Make the snake move to the left
    } else if (rand == 1) {
      snake.dir.x = -1;
      snake.dir.y = 0;
    // Make the snake move down
    } else if (rand == 2) {
      snake.dir.x = 0;
      snake.dir.y = 1;
      // Make the snake move up
    } else if (rand == 3) {
      snake.dir.x = 0;
      snake.dir.y = -1;
    }
  };
  // Update the snake's position, making sure to wrap the snake
  // around each window. If it goes too far to the right, and
  // wanders off one window, it needs to wrap to the left side
  // of the next window.
  snake.pos.x += snake.dir.x * snake.speed;
  snake.pos.x %= frames.width * frames.length;
  snake.pos.y += snake.speed * snake.dir.y;
  if (snake.pos.y < 0)
    snake.pos.y = frames.height - snake.h;
  if (snake.pos.y + snake.h > frames.height)
    snake.pos.y = 0;
  if (snake.pos.x < 0)
    snake.pos.x = (frames.width - snake.w) * frames.width * frames.length;
  var shouldDraw;
  for (var i = 0, len = frames.length; i < len; i++) {
    // Determine which window the snake is in, and tell only that
    // window that it needs to render the snake
    shouldDraw = snake.pos.x + snake.w <= frames.width * (i + 1) &&
        snake.pos.x >= frames.width * i ||
        snake.pos.x <= frames.width * (i + 1) &&
        snake.pos.x >= frames.width * i;
    // Lastly, we pass all this information to each window in canvas coordinates.
    frames[i].postMessage({
      x: snake.pos.x % frames.width,
      y: snake.pos.y,
      w: snake.w,
      h: snake.h,
      shouldDraw: shouldDraw,
      color: snake.color
    }, "*");
  }
}

就是这样。构成所有其他窗口的代码对于它们所有来说都是相同的。实际上,我们只打开了指向完全相同脚本的一堆窗口。就每个窗口而言,它们是唯一打开的窗口。它们所做的就是通过消息 API 接收一堆数据,然后在shouldDraw标志设置时呈现该数据。否则,它们只清除它们的画布,并静静地等待来自其父窗口的进一步指示。

// 1\. Create a canvas
var canvas = document.createElement("canvas");
canvas.width = 400;
canvas.height = 300;
// 2\. Attach the canvas to the DOM
document.body.appendChild(canvas);
// 3\. Get a reference to the canvas' context
var ctx = canvas.getContext("2d");
// 4\. Set up the callback to receive messages from some parent window
function doOnMessage(event) {
  // 5\. For security, make sure we only process input from a trusted window
  if (event.origin == "http://localhost") {
    var data = event.data;
    ctx.clearRect(0, 0, canvas.width, canvas.height);
    // 6\. And here's where the magic happens for this window. If told to
    // draw something through the message received, go ahead and do so.
    if (data.shouldDraw) {
      ctx.fillStyle = data.color;
      ctx.fillRect(data.x, data.y, data.w, data.h);
    }
  }
}
window.addEventListener("message", doOnMessage, false);

Web 存储

在 HTML5 出现之前,Web 开发人员在客户端上存储数据的唯一方法是通过 cookie。虽然范围有限,但 cookie 确实做到了它们的本意,尽管它们有一些限制。首先,每当将 cookie 保存到客户端时,此后的每个 HTTP 请求都会包含该 cookie 的数据。这意味着数据总是明确暴露,而且每个 HTTP 请求都会带有不属于其中的额外数据。在考虑可能需要存储相对大量数据的 Web 应用程序时,这种效率特别低下。

通过新的 Web 存储 API,这些问题已得到解决和满足。现在有三种不同的客户端存储选项,它们都解决了不同的问题。但请记住,客户端存储的所有数据仍然以纯文本形式暴露给客户端,因此并不适合作为安全存储解决方案。

这三种存储解决方案是会话存储、本地存储和 IndexedDB NoSQL 数据存储。会话存储允许我们存储键值数据对,这些数据对在浏览器关闭之前(换句话说,在会话结束之前)都会持续存在。本地存储在每个方面都类似于会话存储,只是数据持续存在的时间更长。

即使会话关闭,存储在本地存储中的数据仍然存在。只有当用户明确告诉浏览器这样做,或者应用程序本身从存储中删除数据时,本地存储中的数据才会被清除。最后,IndexedDB 是一个强大的数据存储,允许我们存储自定义对象(不包括包含函数的对象),然后查询数据库以获取这些对象。当然,强大性带来了复杂性。虽然在浏览器中内置了专用的 NoSQL 数据库听起来很激动人心,但不要被愚弄。虽然使用 IndexedDB 可以成为 HTML 世界的迷人补充,但对于初学者来说绝不是一项微不足道的任务。与本地存储和会话存储相比,IndexedDB 具有相当陡峭的学习曲线,因为它涉及掌握一些复杂的数据库概念。

注意

如前所述,本地存储和会话存储之间唯一的区别在于会话存储在浏览器关闭时会自动清除。除此之外,两者的所有内容都完全相同。因此,学习如何使用两者将是一个简单的经验,因为学习其中一个也意味着学习另一个。然而,在决定何时使用其中一个时可能需要您多花一些时间思考。为了获得最佳结果,请在决定使用哪种存储 API 之前专注于您自己应用程序的独特特性和需求。更重要的是,要意识到在同一个应用程序中同时使用这两种存储系统是完全合法的。关键是专注于一个独特的特性,并决定哪种存储 API 最适合这些特定需求。

本地存储和会话存储对象都是Storage类的实例。通过storage类定义的接口,我们可以与这些存储对象进行交互,其定义如下(来源:Web Storage W3C 候选推荐,2011 年 12 月 08 日,www.w3.org/TR/webstorage/):

  • getItem(key): 返回与给定键关联的当前值。如果给定键在与对象关联的列表中不存在,则该方法必须返回 null。

  • setItem(key, value): 首先检查与对象关联的列表中是否已经存在具有给定键的键/值对。如果不存在,则必须向列表中添加一个新的键/值对,其中给定的键及其值设置为value。如果给定的键在列表中存在,则必须将其值更新为value。如果无法设置新值,则该方法必须抛出QuotaExceededError异常。(例如,如果用户已禁用了站点的存储,或者已超出配额,则设置可能会失败。)

  • removeItem(key): 如果存在具有给定键的键/值对,则导致该键/值对从与对象关联的列表中被移除。如果不存在具有该键的项目,则该方法不执行任何操作。

  • clear(): 当与对象关联的列表中存在任何键/值对时,它会自动导致该列表被清空。如果没有任何键/值对,则该方法不执行任何操作。

  • key(n): 返回列表中第 n 个键的名称。键的顺序由用户代理定义,但在对象内部必须保持一致,只要键的数量不变。(因此,添加或删除键可能会改变键的顺序,但仅更改现有键的值不得改变。)如果 n 大于或等于对象中键/值对的数量,则该方法必须返回 null。Storage 对象上支持的属性名称是与对象关联的列表中当前存在的每个键/值对的键。

  • length: 返回与对象关联的列表中当前存在的键/值对的数量。

本地存储

本地存储机制通过全局对象的属性访问,浏览器上是window对象。因此,我们可以通过window.localStorage显式访问存储属性,也可以隐式地简单地使用localStorage

window.localStorage.clear();

localStorage.length == 0; // True

由于 localStorage 只允许存储 DOMString 值,因此除字符串之外的任何其他值在存储到 localStorage 之前都会被转换为字符串。也就是说,我们不能在localStorage中存储数组、对象、函数等。只允许存储普通的 JavaScript 字符串。

var typedArray = new Uint32Array(100);
localStorage.setItem("my-array", typedArray);
var myArray = localStorage.getItem("my-array");
myArray == "[object Uint32Array]"; // True

现在,虽然这可能看起来像是存储 API 的限制,但实际上这是有意设计的。如果您的目标是存储复杂数据类型以供以后使用,localStorage 并不一定是为解决这个问题而设计的。在这种情况下,我们有一个更强大和方便的存储解决方案,我们很快就会看到(即 IndexedDB)。然而,有一种方法可以在 localStorage 中存储复杂数据(包括数组、类型化数组、对象等)。

关键在于美妙的JSON数据格式。现代浏览器在全局范围内有非常方便的JSON对象,我们可以访问两个重要的函数,即JSON.stringifyJSON.parse。使用这两种方法,我们可以序列化复杂数据,将其存储在localStorage中,然后从存储中反序列化检索到的数据,并继续在应用程序中使用它。

// 1\. Define some class
var Person = function(name) {
  this.name = name;
};
// 2\. Add functions to the class
Person.prototype.greet = function(){
  return "Hello, " + this.name;
};
// 3\. Create an array of objects of that class
var people = new Array();
people.push(new Person("Rodrigo"));
people.push(new Person("Silveira"));
// 4\. Stringify the complex array, and store it away
var json = JSON.stringify(people);
localStorage.setItem("people", json);
// 5\. Retrieve that serialized data, and parse it back into what it was
people = JSON.parse(localStorage.getItem("people"));
people[0].name == "Rodrigo"; // True
people[0] instanceof Person; // False
people[0].greet(); // TypeError: Object has no method 'greet'

虽然这是一个不错的小技巧,但你会注意到可能存在一个主要限制:JSON stringify不会序列化函数。此外,如果你仔细观察 JSON.stringify 的工作方式,你会意识到类实例会失去所有的“身份”,只保留硬数据。换句话说,当我们序列化和反序列化Person的实例后,结果将是一个简单的对象文字,没有构造函数或原型信息。尽管 localStorage 从未打算填补对象持久性的角色(而是简单的键值字符串对),但这应该被视为一个有限但非常巧妙的技巧。

会话存储

由于 sessionStorage 接口与 localStorage 的接口相同,因此没有理由重复刚才描述的所有信息。有关 sessionStorage 的更深入讨论,请查看前两节,并将“local”替换为“session”。上面提到的适用于本地存储的所有内容也适用于会话存储。再次强调,两者之间唯一的区别是在与客户端结束会话时(即,每当浏览器关闭时)擦除sessionStorage上保存的任何数据。

下面将展示如何使用 sessionStorage 的一些示例。在示例中,我们将尝试在 sessionStorage 中存储一个值,如果该值尚不存在。请记住,当我们将键值对设置为存储时,如果该键已经存在于存储中,那么与该键关联的任何值都将被覆盖。如果键不存在,它将自动创建。

var name = sessionStorage.getItem("coolestPerson");
// Only set a new value if the key exists,
// and the value is not what we want
if (name != null && name != "Rodrigo") {
  sessionStorage.setItem("coolestPerson", "Rodrigo");
}

请注意,我们还可以使用in运算符查询 sessionStorage 对象的特定键,该运算符返回如下所示的布尔值:

if ("coolestPerson" in sessionStorage) {
   // …
}

最后,尽管我们可以通过sessionStorage.length检查存储中的键的总数,但如果我们不知道所有不同的键是什么,那本身可能并不是非常有用。幸运的是,sessionStorage.key函数允许我们获取特定的键,通过它我们可以获得与该键存储的值。

sessionStorage.clear();
sessionStorage.length == 0; // True
sessionStorage.setItem("name", "Rodrigo");
sessionStorage.setItem("book", "Learn HTML5");
sessionStorage.setItem("publisher", "Packt Pub");
sessionStorage.setItem("isColor", true);
sessionStorage.setItem("rating", 5);
var values = new Array();
for (var i = 0, len = sessionStorage.length; i < len; i++) {
   var key = sessionStorage.key(i);
   var value = sessionStorage.getItem(key);
   values.push({key: key, value: value});
}
values.length == sessionStorage.length; // True
values[0].key == "book"; // True*
values[0].value == "Learn HTML5"; // True*

因此,我们可以查询sessionStorage中给定位置的键,并接收表示该键的字符串键。然后,使用该键,我们可以获得存储在该键下的值。然而,请注意,sessionStorage对象中存储项的顺序是完全任意的。虽然一些浏览器可能会按键值按字母顺序对存储的项目列表进行排序,但这在 HTML5 规范中明确规定为留给浏览器制造商决定的决定。

IndexedDB

尽管到目前为止 Web 存储 API 可能看起来很令人兴奋,但在某些情况下,我们的需求可能是序列化和反序列化数据,使用本地或会话存储可能不够。例如,想象一下,我们在本地存储中存储了几百(或者,几千)个类似的记录(比如我们正在存储 RPG 游戏中的敌人描述卡)。考虑如何使用本地存储来完成以下操作:

  • 按字母顺序检索存储的前五条记录

  • 删除所有存储的记录,这些记录包含特定特征(例如,不能在水中生存的敌人)

  • 检索存储的最多三条记录,这些记录包含特定特征(例如,敌人的生命值得分为 42,000 或更高)

重点是:我们可能想要对本地存储或会话存储中存储的数据进行任何查询,都必须由我们自己的代码处理。换句话说,我们将花费大量时间和精力编写代码,只是为了帮助我们获取一些数据。更不用说本地或会话存储中存储的任何复杂数据都会被转换为文字对象,而曾经属于这些对象的任何和所有函数现在都消失了,除非我们编写更多的代码来处理某种自定义的反序列化。

如果你现在还没有猜到,IndexedDB 非常漂亮地解决了这些问题和其他问题。在其核心,IndexedDB 是一个 NoSQL 数据库引擎,允许我们存储整个对象并对其进行索引,以实现快速插入、删除和检索。数据库系统还为我们提供了强大的查询引擎,这样我们就可以对已持久化的数据执行非常高级的计算。

下图显示了 IndexedDB 和传统关系数据库之间的一些相似之处。在关系数据库中,数据存储为特定表结构内的一组行。而在 IndexedDB 中,数据则是分组存储在被称为数据存储的广义定义的桶中。

IndexedDB

IndexedDB 的架构在某种程度上类似于当今大多数 Web 开发项目中使用的流行关系数据库系统。一个核心区别是,关系数据库存储数据在数据库中,这是一组相关表的集合,而 IndexedDB 系统将数据分组存储在数据库中,这是一组数据存储的集合。虽然在概念上相似,但在实践中,这两种架构实际上是非常不同的。

注意

如果你来自关系数据库背景,并且数据库、表、列和行的概念对你来说是有意义的,那么你已经在成为 IndexedDB 专家的路上了。正如你将看到的,这两种系统和方法之间有一些重要的区别。虽然你可能会倾向于简单地用数据存储替换表这个词,但要知道这两个概念之间的差异不仅仅是名称上的区别。

数据存储的一个关键特性是它们没有与之关联的特定模式。在关系数据库中,表由其非常特定的结构定义。每个列在表首次创建时就被指定。然后,在这样的表中保存的每条记录都遵循完全相同的格式。在 NoSQL 数据库(其中 IndexedDB 是一种类型)中,数据存储可以保存任何对象,无论它们的格式是什么。基本上,这个概念与在关系数据库表中为每条记录定义不同的模式是相同的。

IDBFactory

要开始使用 IndexedDB,我们首先需要创建一个数据库。这是通过 IDBFactory 的实现来完成的,在浏览器中,就是window.indexedDB对象。删除数据库也是通过 indexedDB 对象来完成的,我们很快就会看到。

为了打开一个数据库(或者如果它还不存在的话创建一个),我们只需调用indexedDB.open方法,传入数据库名称和版本号。如果没有提供版本号,将使用默认版本号 1,如下面的代码片段所示:

var dbName = "myDatabase";
var dbVersion = 1;
var request = indexedDB.open(dbName, dbVersion);

正如你很快会注意到的,IndexedDB 中用于异步请求的每个方法(例如indexedDB.open)都会返回一个 IDBRequest 类型的请求对象,或者它的实现。一旦我们有了那个请求对象,我们就可以在其属性上设置回调函数,当与它们相关的各种事件被触发时,这些回调函数就会被执行,如下面的代码片段所示:

var dbName = "myDatabase";
var dbVersion = 1;
var db = null;
var request = indexedDB.open(dbName, dbVersion);
request.onerror = function(event) {
   console.log("Error:", event);
};
request.onsuccess = function(event) {
   db = event.target.result;
};

IDBOpenDBRequest

正如在前一节中提到的,一旦我们对 IndexedDB API 进行了异步请求,立即返回的对象将是 IDBRequest 类型。在打开请求的特定情况下,返回给我们的对象是 IDBOpenDBRequest 类型。我们可能想要在这个对象上监听的两个事件在前面的代码片段中已经显示出来了(onerroronsuccess)。还有一个非常重要的事件,我们可以在这个事件中创建一个对象存储,这是这个存储系统的基础。这个事件是onupgradeneeded(即需要升级)事件。当数据库首次创建时,以及当打开数据库时使用的版本号高于上次打开数据库时使用的版本号时,这个事件将被触发,如下面的代码所示:

var dbName = "myDatabase";
var dbVersion = 1;
var db = null;
var store = null;
var request = indexedDB.open(dbName, dbVersion);
request.onupgradeneeded = function(event) {
   db = event.target.result;
   store = db.createObjectStore("myDataStore", {keyPath: "myKey"});
};

在数据库对象上进行的createObjectStore调用需要两个参数。第一个是表示对象存储名称的字符串。这个存储可以被认为是在关系数据库世界中的一个表。当然,我们不是将记录插入到表中的列中,而是将整个对象插入到数据存储中。第二个参数是定义数据存储属性的对象。这个对象必须定义的一个重要属性是keyPath对象,它使我们存储的每个对象都是唯一的。分配给这个属性的值可以是我们选择的任何东西。

现在,我们在这个数据存储中持久化的任何对象都必须具有与分配给keyPath相同的名称的属性。在这个例子中,我们的对象将需要一个myKey属性。如果持久化了一个新对象,它将根据这个属性的值进行索引。

存储的任何额外对象,如果具有相同的myKey值,将替换具有相同键的任何旧对象。因此,每次我们想要持久化一个唯一对象时,我们必须为这个对象提供一个唯一值。

或者,我们可以让浏览器为我们提供这个键的唯一值。同样地,将这个概念与关系数据库进行比较,我们可以将keyPath对象看作是特定元素的唯一 ID。就像大多数关系数据库系统都支持某种自动增量一样,IndexedDB 也是如此。为了指定我们想要自动增加的值,我们只需在数据存储首次创建(或升级)时将该标志添加到对象存储属性对象中,如下面的代码片段所示:

request.onupgradeneeded = function(event) {
  var settings = {
    keyPath: "myKey",
    autoIncrement: true
  };
  db = event.target.result;
  store = db.createObjectStore("myDataStore", settings);
};

现在,我们可以持久化一个对象,而无需为属性myKey提供唯一值。事实上,我们甚至不需要在存储在这里的任何对象中提供这个属性。IndexedDB 会为我们处理这个问题。看一下下面的图表:

IDBOpenDBRequest

使用谷歌 Chrome 的开发者工具,我们可以看到我们为我们的域创建的所有数据库和数据存储。请注意,主对象键,即我们在创建数据存储时给它的任何名称,都具有 IndexedDB 生成的值,正如我们所指定的,这些值是相对于上一个值递增的。

有了这个简单但冗长的样板代码,我们现在可以开始使用我们的数据库和数据存储了。从这一点开始,我们对数据库所采取的操作将在通过创建它们的数据库对象上访问的个别数据存储对象上进行。

IDBTransaction

在处理 IndexDB 时,我们需要记住的最后一件一般的事情是,我们与数据存储的每一次交互都是在事务内完成的。如果在事务过程中出现问题,整个事务将被回滚,没有任何效果。同样地,如果事务成功,IndexedDB 将自动为我们提交事务,这是一个非常方便的奖励。

要使用事务,我们需要获取对数据库的引用,然后请求特定数据存储的事务。一旦我们获得了对数据存储的引用,我们就可以执行与数据存储相关的各种功能,例如将数据放入其中,从中读取数据,更新数据,最后从数据存储中删除数据。

var TodoItem = function(task) {
  this.task = task;
  this.completed = false;
};
try {
  var trans = db.transaction(storeName, "readwrite");
  var store = trans.objectStore(storeName);
  var task1 = new TodoItem("Buy more pizza");
  var task2 = new TodoItem("Finish writing the book");
  var task3 = new TodoItem("Shave before going to work");
  var request = store.put(task1);
  // We can reuse this request object to store multiple objects
  request = store.put(task2);
  request = store.put(task3);
  request.onsuccess = function(e) {
    log("Success!" + value.key);
  };
  request.onerror = function(e) {
    log(e.stack);
  };
} catch (e) {
   log(e.stack);
}

要将项目存储到我们的数据存储中,我们需要遵循几个步骤。请注意,如果在此事务期间发生任何错误,我们只需捕获浏览器抛出的任何错误,并且由于 try/catch 块的存在,执行将继续不受中断。

在 IndexedDB 中持久化对象的第一步是启动一个事务。这是通过从我们之前打开的数据库中请求一个事务对象来完成的。事务始终与特定的数据存储相关联。此外,在请求事务时,我们可以指定要启动的事务类型。IndexedDB 中可能的事务类型如下:

读写

这种事务模式允许将对象存储到数据存储中,从中检索,更新和删除。换句话说,readwrite 模式允许进行完整的 CRUD 功能。

只读

这种事务模式类似于 readwrite,但明确限制了与数据存储的交互仅限于读取。不允许修改数据存储的任何内容,因此任何尝试创建新记录(换句话说,将新对象持久化到数据存储中),更新现有对象(换句话说,尝试保存已经在数据存储中的对象)或从数据存储中删除对象都将导致事务失败,并引发异常。

versionchange

这种事务模式允许我们创建或修改数据存储中使用的对象存储或索引。在这种模式的事务中,我们可以执行任何操作或操作,包括修改数据库的结构。

获取元素

如果我们无法在以后的某个时间点检索数据,那么简单地将数据存储到黑匣子中是毫无用处的。使用 IndexedDB,可以通过几种不同的方式来实现这一点。更常见的是,我们持久化数据的数据存储设置了一个或多个索引,这些索引通过特定字段对对象进行组织。对于习惯于关系数据库的人来说,这类似于对特定表列进行索引/应用键。如果我们想要获取一个对象,我们可以通过其唯一 ID 进行查询,或者我们可以搜索符合特定特征的对象的数据存储,这可以通过该对象的索引值来实现。

要在数据存储上创建索引,我们必须在创建数据存储时指定我们的意图(在首次创建存储时的onupgradeneeded回调内,或者在事务模式versionchange内)。代码如下:

request.onupgradeneeded = function(event) {
  var settings = {
    keyPath: "myKey",
    autoIncrement: true
  };
  db = event.target.result;
  store = db.createObjectStore("myDataStore", settings);
  var indexSettings = {
    unique: true
  };
  store.createIndex("taskIndex", "task", indexSettings);
};

在上面的示例中,我们为对象的 task 属性创建了一个索引。这个索引的名称可以是任何我们想要的,通常与它适用的对象属性的名称相同。在我们的例子中,我们只是将其命名为 taskIndex。我们可以配置的可能设置如下:

  • unique - 如果为 true,则存储具有相同属性的重复值的对象将被拒绝

  • multiEntry - 如果为 true,并且索引属性是一个数组,则每个元素都将被索引

注意

请注意,可以为数据存储创建零个或多个索引。与任何其他数据库系统一样,对数据库/数据存储进行索引可以真正提高存储容器的性能。但是,仅仅为了提供乐趣而添加索引并不是一个好主意,因为数据存储的大小会相应增长。一个良好的数据存储设计是考虑到数据存储与应用程序的特定上下文,并且每个索引字段都经过仔细考虑。在设计数据存储时要牢记的短语是:量一次,切一次。

尽管任何对象都可以保存在数据存储中(与关系数据库相反,在关系数据库中,存储的数据必须严格遵循表结构,由表的模式定义),为了优化应用程序的性能,尝试构建数据存储时要考虑存储的数据。任何数据都可以储存在任何数据存储中,但明智的开发人员在将数据提交到数据库之前会非常谨慎地考虑存储的数据。

一旦数据存储设置好,并且至少有一个有意义的索引,我们就可以开始从数据存储中提取数据。从数据存储中检索对象的最简单方法是使用索引,并查询特定对象,如下面的代码所示:

var TodoItem = function(task) {
  this.task = task;
  this.completed = false;
};
function getTask(taskName, callback) {
  // 1\. Open a transaction. Since we don't need to write anything to
  // the data store, a simple readonly transaction will sufice.
  var trans = db.transaction(storeName, "readonly");
  var store = trans.objectStore(storeName);
  // 2\. specify an index to use, and the data to get from it
  var req = store.index("taskIndex").get(taskName);
  req.onsuccess = function(e) {
    var todoItem = e.target.result;
    // todoItem.task => "Buy more pizza"
    // todoItem.completed => false
    callback(todoItem);
  };
  req.onerror = function(e) {
    // Handle error
  };
};
// Search for a TodoItem object with a task property of "Buy more pizza"
getTask("Buy more pizza", function(taskItem) {
  console.log("TaskItem object: " + taskItem.task);
});

上述函数尝试从我们的数据存储中检索单个保存的对象。搜索是针对具有与函数提供的任务名称匹配的任务属性的对象进行的。如果找到一个,它将从数据存储中检索出来,并通过传递给回调函数的事件对象传递给存储对象的请求。如果在过程中发生错误(例如,如果提供的索引不存在),则会触发onerror事件。最后,如果数据存储中没有对象与搜索条件匹配,通过请求参数对象传递的结果属性将为 null。

现在,要搜索多个项目,我们可以采用类似的方法,但是我们请求一个IndexedDBCursor对象。游标基本上是指向零个或多个对象结果集中特定结果的指针。我们可以使用游标遍历结果集中的每个对象,直到当前游标指向没有对象(null),表示结果集中没有更多对象了。

var TodoItem = function(task) {
  this.task = task;
  this.completed = false;
};
function getTask(taskName, callback) {
  // 1\. Open a transaction. Since we don't need to write anything to
  // the data store, a simple readonly transaction will sufice.
  var trans = db.transaction(storeName, "readonly");
  var store = trans.objectStore(storeName);
  // 2\. specify the range in the data store to request data from
  var keyRange = IDBKeyRange.lowerBound(0);
  var req = store.openCursor(keyRange);
  req.onsuccess = function(e) {
    // cursor IDBCursorWithValue
    //   key : int
    //   primaryKey : int
    //   source : IDBObjectStore
    //   value : Object
    //
    var cursor = e.target.result;
    // Before we continue, we need to make sure that we
    // haven't hit the end of the result set
    if (!cursor) {
      callback();
    }
    // If there are still results, let's process them
    //    cursor.value === todoItem
    //    cursor.value.task => "Buy more pizza"
    //    cursor.value.completed => false
    // Since results are plain, typeless object literals, we need to rebuild
    // each object from scratch.
    var todoItem = new TodoItem(cursor.value.task);
    todoItem.myKey = cursor.value.myKey;
    todoItem.completed = cursor.value.completed;
    todoItems.push(todoItem);
     // Tell the cursor to fetch the next result
      cursor.continue();
  };
  req.onerror = function(e) {
    // Handle error
  };
};
// Retrieve every TodoItem in the data store
var todoItems = new Array();
getTask("Buy more pizza", function() {
  for (var i = 0; i < todoItems.length; i++) {
    console.log("TaskItem object: " + todoItems[i].task);
  }
})

您会注意到上面的代码片段中有一些事情。首先,进入我们的 IndexedDB 数据存储的任何对象都被剥去了其 DNA,而只是存储了一个简单的哈希值。因此,如果我们从数据存储中检索到的每个对象的原型信息对应用程序很重要,我们将需要手动从我们从数据存储中获取的数据中重建每个对象。

其次,观察到我们可以过滤数据存储的子集,我们想从中取出。这是通过一个 IndexedDB Key Range 对象来实现的,它指定了从哪里开始获取数据的偏移量。在我们的情况下,我们指定了一个下限为零,意味着我们想要的最低主键值是零。换句话说,这个特定的查询请求数据存储中的所有记录。

最后,记住请求的结果不是单个结果或结果数组。相反,所有结果都以游标的形式一个接一个地返回。我们可以一起检查游标的存在,然后如果确实存在游标,就使用游标。然后,我们请求下一个游标的方式是在游标本身上调用continue()函数。

另一种思考游标的方式是想象一个电子表格应用程序。假设我们从请求中返回的 10 个对象中的每个对象都代表电子表格中的一行。因此,IndexedDB 将把这 10 个对象都取到内存中,并通过event.target.result属性在onsuccess回调中发送指向第一个结果的指针。通过调用cursor.continue(),我们只是告诉 IndexedDB 现在给我们一个指向结果集中下一个对象的引用(换句话说,我们要求电子表格中的下一行)。这将一直持续到第十个对象,之后结果集中就不再存在对象了(再次配合电子表格的比喻,在我们获取了最后一行之后,下一行就是 null-它不存在)。因此,数据存储将调用onsuccess回调,并传入一个 null 对象。如果我们尝试读取这个空引用中的属性,就好像我们正在处理从游标返回的真实对象一样,浏览器将抛出一个空指针异常。

与其尝试一次从游标重建一个对象的属性,我们可以以通用形式将此功能抽象化。由于被持久化到对象存储中的对象不能有任何函数,我们不允许在对象本身内部保留这样的功能。然而,由于 JavaScript 能够从对构造函数的引用构建对象,我们可以创建一个非常通用的对象构建函数,如下所示:

var TodoItem = function(task) {
  this.task = task;
  this.completed = false;
  this.toHTML = function() {
    var el = document.createElement("li");
    el.textContent = this.task;
    if (this.completed) {
      el.style.textDecoration = "line-through";
    }
    return el;
  };
};
function inflatObject(class, object) {
  // 1\. Create an instance of whatever class we reference
  var obj = new class();
  // 2\. Copy every property from the object returned by the cursor
  // into the newly created object
  for (var property in object) {
    obj[property] = object[property];
  }
  // 3\. Return the inflated object
  return obj;
}
// …
var req = store.openCursor(keyRange);
req.onsuccess = function(e) {
  var cursor = e.target.result;
  // Before we continue, we need to make sure that we
  // haven't hit the end of the result set
  if (!cursor) {
    callback();
  }
  var todoItem = inflatObject(TodoItem, cursor.value);
  // We could even call methods on the new inflated object
  var itemElement = todoItem.toHTML();
  document.body.appendChild(itemElement);
  todoItem.myKey == cursor.myKey; // True
  todoItem.task == cursor.task; // True
  todoItem.completed == cursor.completed; // True
  todoItems.push(todoItem);
  // Tell the cursor to fetch the next result
  cursor.continue();
};

删除元素

要从数据存储中删除特定元素,与检索数据涉及的原则相同。实际上,整个过程看起来与检索数据非常相似,只是我们在对象存储对象上调用删除函数。不用说,此操作中使用的事务必须是 readwrite,因为 readonly 会限制对象,使其无法进行任何更改(包括删除)。

删除对象的第一种方法是将对象的主键传递给delete函数。如下所示:

function deleteTask(taskId, callback) {
  // 1\. Open a transaction. Since we definitely need to change the object
  // in the data store, we need proper access and benefits
  var trans = db.transaction(storeName, "readwrite");
  var store = trans.objectStore(storeName);
  // 2\. specify an index to use, and the data to get from it
  var req = store.delete(taskId);
  req.onsuccess = function(e) {
    // Do something, then call callback
  };
  req.onerror = function(e) {
    // Handle error
  };
};

这种第一种方法的困难在于我们需要知道对象的 ID。在某些情况下,这将涉及到先前的事务请求,我们将根据一些更容易获得的数据检索对象。例如,如果我们想要删除所有属性设置为 true 的任务,我们首先需要查询数据存储以获取这些对象,然后使用每个结果关联的 ID,并在删除对象的事务中使用这些值。

从数据存储中删除数据的第二种方法是简单地在对象存储对象上调用clear()。同样,事务必须设置为 readwrite。这将消除数据存储中的每一个对象,即使它们都是不同类型的,如下面的代码片段所示:

var trans = db.transaction(storeName, "readwrite");
var store = trans.objectStore(storeName);
var req = store.clear();
req.onsuccess = function(e) {
  // Do something, then call callback
};
req.onerror = function(e) {
  // Handle error
};

最后,我们可以使用游标删除多条记录。这类似于我们检索对象的方式。当我们使用游标遍历结果集时,我们可以简单地删除游标当前所在位置的对象。在删除时,游标对象的引用被设置为 null,如下面的代码片段所示:

  // 1\. Be sure to set the transaction to readwrite. Else, there will be a nice
  // exception raised if we try to delete readonly data.
  var trans = db.transaction(storeName, "readwrite");
  var store = trans.objectStore(storeName);
  // 2\. specify the range in the data store to request data from
  var keyRange = IDBKeyRange.lowerBound(0);
  var req = store.openCursor(keyRange);
  req.onsuccess = function(e) {
    var cursor = e.target.result;
    // Before we continue, we need to make sure that we
    // haven't hit the end of the result set
    if (!cursor) {
      callback();
    }
    // Here, we could have accessed the object's primary ID through
    // the cursor object in cursor.value.myKey. However, accessing
    // cursor.primaryKey maps to the specific property name that holds
    // the value of the primary key.
    store.delete(cursor.primaryKey);
    // Tell the cursor to fetch the next result
    cursor.continue();
  };

这几乎与获取数据的过程相同。唯一的细节是我们绝对需要提供对象的键。键是存储在对象的keyPath属性中的值,可以是用户提供的,也可以是自动生成的。幸运的是,游标对象通过cursor.primaryKey属性返回至少两个对这个键的引用,以及通过对象自己的属性引用该值(在我们的情况下,我们选择将keyPath属性命名为myKey)。

代码

我们在游戏的第二个版本中添加的两个升级非常简单,但它们为游戏增添了很多价值。我们添加了一个持久化的最高分引擎,因此用户实际上可以跟踪他们的最新记录,并且可以保留过去的成功记录。我们还添加了一个非常巧妙的功能,每当玩家得分时,以及玩家最终死亡时,都会拍摄游戏板的快照。一旦玩家死亡,我们会显示在游戏中收集到的所有快照,允许玩家保存这些图像,并可能与他或她的朋友分享。

保存最高分

你可能注意到这个游戏的上一个版本的第一件事是,我们有一个高分的占位符,但那个数字从未改变过。现在我们知道如何持久保存数据,我们可以非常容易地利用这一点,并通过各种游戏持久保存玩家的最高分。在更现实的情况下,我们可能会将最高分数据发送到后端服务器,在那里每次提供游戏时,我们可以跟踪整体最高分,并且每个玩游戏的用户都会知道这个全局分数。然而,在我们的情况下,高分仅限于浏览器,因为持久性 API(本地和会话存储,以及 IndexedDB)不会在其他浏览器之间共享数据,也不会本地到远程服务器。

由于我们希望高分即使在一个月后,当计算机已经多次关闭电源(当然还有浏览器)后,仍然存在于玩家的浏览器中,将这个高分数据存储在 sessionStorage 中是愚蠢的。我们可以将这个单个数字存储在 IndexedDB 或 localStorage 中。由于我们不关心与该分数相关的任何其他信息(例如获得分数的日期等),我们实际上只是存储了一个数字。因此,我认为 localStorage 是一个更好的选择,因为可以只用 5 行代码就可以完成。使用 IndexedDB 也可以,但就像用大炮打蚊子一样:

function setHighScore(newScore, el) {
  var element = document.querySelector(el);
  // Multiply by 1 to cast the value from a string to a number
  var score = localStorage.getItem("high-score") * 1;
  // Check if there is a numerical score saved
  if (score && !isNaN(score)) {
    // Check if new score is higher than current high score
    if (newScore > element.textContent * 1) {
      localStorage.setItem("high-score", newScore);
      element.textContent = newScore;
    } else {
        element.textContent = score;
    }
  } else {
    localStorage.setItem("high-score", newScore);
    element.textContent = newScore;
  }
}

这个功能非常直接了当。我们传递给它的两个值是要设置为新高分的实际分数(这个值将被保存到 localStorage,并显示给用户),以及要显示该值的 HTML 元素。

首先,我们检索保存在键高分下的现有值,并将其转换为数字。我们可以使用函数parseInt(),但将字符串乘以数字会以稍微更快的执行速度执行相同的操作。

接下来,我们检查该值是否评估为真实的东西。换句话说,如果本地存储中没有保存高分值,那么变量分数将被评估为未定义乘以一,这不是一个数字。如果保存了与键高分相关的值,但该值不是可以转换为数字的东西(例如一串字母等),我们知道这不是一个有效的值。在这种情况下,我们将传入的分数设置为新的最高分。这将适用于当前持久值无效或不存在的情况(这将是游戏加载的第一次情况)。

接下来,一旦我们从本地存储中检索到有效的分数,我们就会检查新值是否高于旧的持久值。如果我们有更高的分数,我们就会持久保存该值,并在屏幕上显示它。如果新值不高于现有值,我们就不会持久保存任何东西,而是显示保存的值,因为那是当时的真正最高分。

拍摄游戏的屏幕截图

这个功能不像保存用户的最高分那么琐碎,但实施起来同样非常直接了当。因为我们不关心超过一个游戏之前捕获的快照,所以我们将使用sessionStorage实时保存玩家在游戏中的数据。

在幕后,我们所做的一切只是将游戏状态保存到sessionStorage中,然后在游戏结束时检索我们一直在保存的所有片段,并在不可见的画布中重建游戏。然后我们使用canvas.toDataURL()函数将该数据提取为图像。

function saveEvent(event, snake, fruit) {
  var eventObj = sessionStorage.getItem(event);
  // If this is the first time the event is set, create its structure
  if (!eventObj)  {
    eventObj = {
      snake: new Array(),
      fruit: new Array()
    };
    eventObj.snake.push(snake);
    eventObj.fruit.push(fruit);
    eventObj = JSON.stringify(eventObj);
    sessionStorage.setItem(event, eventObj);
  } else {
    eventObj = JSON.parse(eventObj);
    eventObj.snake.push(snake);
    eventObj.fruit.push(fruit);
    eventObj = JSON.stringify(eventObj);
    sessionStorage.setItem(event, eventObj);
  }
  return JSON.parse(eventObj);
}

每当玩家吃掉水果时,我们调用这个函数,将snake(我们游戏中的主角)和fruit(游戏目标)对象的引用传递给它。我们所做的实际上非常简单:我们创建一个表示蛇和水果状态的数组,每次捕获事件时都会更新。数组中的每个元素都是一个字符串,表示序列化数组,跟踪水果的位置以及蛇的每个身体部分的位置。

首先,我们检查这个对象当前是否存在于sessionStorage中。在我们开始游戏的第一次,这个对象还不存在。因此,我们创建一个引用这两个对象的对象,即snakefruit对象。接下来,我们对跟踪元素位置的缓冲区进行字符串化。每次添加新事件时,我们只需将其附加到这两个缓冲区中。

当然,如果用户关闭浏览器,那些数据将被浏览器自己擦除,因为这就是sessionStorage的工作原理。然而,我们可能不想保留上一局游戏的数据,所以我们还需要一种方法在每局游戏结束后清除我们自己的数据。

function clearEvent(event) {
  return sessionStorage.removeItem(event);
}

足够简单。我们只需要知道我们用来保存每个元素的键的名称。对于我们的目的,我们简单地将蛇吃的快照称为"eat",将蛇死亡的快照的缓冲区称为"die"。因此,在每局游戏开始之前,我们可以简单地使用这两个全局键值调用clearEvent(),缓存将在每次清除后重新清除。

接下来,每当发生事件时,我们只需调用我们定义的第一个函数,向其发送适当的数据,如下面的代码片段所示:

if (fruit.isAt(head.x, head.y)) {
  // Save current game state
  saveEvent("eat", snake.getBody(), fruit.position);
  fruit.reset();
  snake.grow();
  score.up();
  // Save high score if needed
  setHighScore(document.querySelector("#scores h3:first-child span").textContent);
}
// …
if (!snake.isAlive()) {
  saveEvent("die", snake.getBody(), fruit.position);
}

最后,每当我们希望显示所有这些快照时,我们只需要创建一个与游戏中使用的画布具有相同尺寸的单独画布,并将缓冲区绘制到该画布上。我们需要一个单独的画布元素的原因是因为我们不希望在玩家可以看到的同一个画布上绘制。这样,生成这些快照的过程就更加流畅和自然。一旦每个状态被绘制,我们可以提取每个图像,调整大小,并按照下面的代码显示给用户:

// Use each cached buffer to generate each screen shot
function getEventPictures(event, canvas) {
  // Take the buffer from session storage
  var obj = sessionStorage.getItem(event);
  // Create an array to hold the generated images
  var screenShots = new Array();
  if (!obj)
    return screenShots
  obj = JSON.parse(obj);
  var canvas = canvas.cloneNode();
  var renderer = new Renderer(canvas);
  // Go through each game state, and simply draw the data as though it
  // was being drawn for the actual game in action
  for (var i = 0, len = obj.snake.length; i < len; i++) {
    renderer.clear();
    renderer.draw(obj.snake[i], snake.getSkin());
    renderer.draw(obj.fruit[i], fruit.img);
    var screenShot = renderer.toImg();
    screenShots.push(screenShot);
  }
  return screenShots;
}
// Display a list of images to the user
function drawScreenShots(imgs) {
  var panel = document.querySelector("#screenShots");
  for (var i = 0, len = imgs.length; i < len; i++) {
    var a = document.createElement("a");
    a.target = "_blank";
    a.href = imgs[i].src;
    a.appendChild(imgs[i]);
    panel.appendChild(a);
  }
}

请注意,我们只是将表示蛇和水果的点绘制到画布上。画布中的所有其他点都被忽略,这意味着我们生成了一个透明的图像。如果我们想要图像有一个实际的背景颜色(即使只是白色),我们可以在绘制蛇和水果之前调用fillRect()覆盖整个画布表面,或者我们可以遍历渲染上下文中的pixelData数组中的每个像素,并将 alpha 通道设置为 100%不透明。即使我们手动为每个像素设置颜色,但没有设置 alpha 通道,我们会得到有颜色的像素,但是 100%透明。

总结

在本章中,我们在引人入胜的 2D 渲染世界中迈出了一些额外的步伐,使用了期待已久的画布 API。我们利用了画布导出图像的能力,使我们的游戏更具吸引力,可能也更具社交性。我们还通过在游戏之上添加持久层,使游戏更具吸引力和社交性,从而能够保存玩家的最高分。

HTML5 的另外两个强大功能,即 Web 消息传递和 IndexedDB,在本章中进行了探讨,尽管在游戏的这个版本中并没有使用这些功能。Web 消息传递 API 提供了一个机制,使两个或更多窗口可以通过消息传递直接进行通信。令人兴奋的是,这些窗口(或 HTML 上下文)不需要在同一个域中。尽管这可能听起来像一个安全问题,但有几个系统可以确保跨文档和跨域消息传递是安全和高效的。

Web 存储接口带来了三种不同的解决方案,用于客户端的长期数据持久性。这些是会话存储、本地存储和 IndexedDB。虽然 IndexedDB 是一个完整的、内置的、完全事务性和异步的 NoSQL 对象存储,但本地和会话存储为简单的需求提供了一个非常简单的键值对存储。这三种系统都比传统的基于 cookie 的数据存储引入了巨大的好处和收益,包括可以在浏览器中持久保存的数据总量更大,而且用户浏览器中保存的数据从未通过 HTTP 请求在服务器和客户端之间来回传输。

在下一章中,我们将讨论一些高级的 HTML5 主题,包括超越画布 2D 渲染上下文的下一步 - WebGL。虽然这些主题将被详细介绍,但随后添加的功能都不会被添加到游戏中。事实上,《第六章》《向您的游戏添加功能》,是本书中另一个罕见的游戏,它不是建立在一款有趣的 HTML5 游戏之上,因为我们一直在一起构建。我们将在《第七章》《HTML5 和移动游戏开发》中继续游戏开发项目,最后在移动空间射击游戏中结束本书。

第六章:为您的游戏添加功能

这一章与前几章略有不同,因为本章没有与之相关的游戏。我们之所以不使用本章的概念构建游戏,是因为所涵盖的概念要么对于单独的一章来说过于复杂(例如,整本书都致力于 WebGL 的主题),要么它们并不是游戏中特别好的匹配。此外,本章末尾提到的一些功能在浏览器支持方面仍然很少(如果有的话),API 的稳定性可能也不太可靠。因此,我们将简单解释每个 API,提供有意义的示例,并希望这种肤浅的介绍足以让您对每个 API 所涉及的前景感到兴奋。

本章的第一部分将涵盖四个非常令人兴奋和强大的 HTML5 API,它们是浏览器平台的重要补充。首先,我们将介绍WebGL,它将OpenGL ES的强大功能带入浏览器,实现了硬件加速的 3D 图形渲染,而无需任何插件。接下来,我们将讨论如何使用 Web 套接字实现类似线程的体验,视频 API 实现原生视频播放和操作,以及地理位置信息,它允许 JavaScript 确定用户的物理位置(地理位置)。

最后,我们将通过查看 HTML5 演变中的最新功能来结束本章。这些功能将 CSS 提升到一个新的水平,使其不再仅仅是一个基于矩形的渲染引擎。我们将学习的第一个新功能是 CSS 着色器,它允许我们指定每个像素的渲染方式。这是使用 GLSL 着色器完成的,正如我们在 WebGL 讨论中将看到的那样,它们是我们编写并在 GPU 上运行的独立程序,以尽可能低的层次控制渲染方式。通过自定义着色器,我们可以做的远远超出简单的预设 CSS 变换。

本章后半部分涵盖的其他新的 CSS 功能是 CSS 列和 CSS 区域和排除。CSS 列使得动态调整容器显示多少列文本变得非常容易。例如,如果我们希望一块文本以 3 个等宽或等高列显示,通常需要设置三个不同的容器,然后将每个容器浮动到左侧。使用列,我们可以简单地将所有文本存储在单个容器中,然后使用 CSS 生成列。最后,CSS 区域和排除使得在复杂图案内或周围呈现文本成为可能,而不是传统的矩形形状。您肯定见过杂志这样做,其中一块文本围绕着汽车轮廓或其他物体的轮廓。过去,使用纯文本(而不是使用图像)实现这种效果在 HTML 中几乎没有尝试,因为这需要极其复杂的操作。现在只需要几行 CSS 代码。

高级 HTML5 API

尽管以下 API 和功能在复杂性和学习曲线陡度上有很大差异,但我们的目标是至少对每个主题进行彻底介绍。为了更深入地了解和实践每个主题,建议您在这里提供的介绍中补充其他来源。

由于 HTML5 规范和功能的部分尚未完全成熟,一些 API 可能在所有浏览器中都不完全支持,即使是最新的现代浏览器也是如此。由于本章将涵盖 HTML5 的绝对最新功能(在撰写时),有可能一些浏览器可能不适合本章涵盖的示例。因此,建议您使用最先进的网络浏览器的最新版本。不仅如此,您还必须确保检查您的浏览器可用的任何实验性功能和/或安全标志。以下代码片段是专门针对谷歌 Chrome 编写的,因为它支持所有描述的功能。我们将注意到任何特定的配置设置,以确保功能正常工作,但随着新的 Web 浏览器更新的部署,这些可能需要或不需要。

WebGL

也许没有其他 HTML5 功能对游戏开发人员来说像 WebGL 那样令人兴奋。这个新的 JavaScript API 允许我们渲染高性能、硬件加速的 2D 和 3D 图形。该 API 是 OpenGL ES 2.0 的一种变体,并利用 HTML5 画布元素来弥合浏览器和用户计算机中的图形处理单元之间的差距。

虽然 3D 编程是一个值得一本书的话题,但以下概述足以让我们开始学习最重要的概念,并且将允许我们开始使用浏览器平台进行 3D 游戏开发。对于那些寻找 OpenGL ES 2 的良好学习资源的人,可以看看Munshi,Ginsburg 和 Shreiner 的 OpenGL ES 2.0 编程指南

注意

由于 WebGL 在很大程度上基于 OpenGL ES 2.0,您可能会想要从 OpenGL 书籍和其他来源寻找关于它的参考和补充材料。请记住,OpenGL 版本 1.5 及更早版本与 OpenGL 2.0(以及由此产生的 WebGL 的 OpenGL ES 2.0)有很大不同,可能不是一个完整的学习来源,尽管它可能是一个不错的起点。

这两个版本之间的主要区别是渲染管线。在早期版本中,API 使用了一个固定的管线,重活由幕后完成。新版本暴露了一个完全可编程的管线,我们需要提供自己的着色器程序来将我们的模型渲染到屏幕上。

你好,世界!

在进一步探讨 WebGL 和 3D 编程的理论方面之前,让我们快速看一下最简单的可能的 WebGL 应用程序,在这里我们将简单地渲染一个黄色三角形在绿色背景上。您会注意到这需要相当多的代码行。请记住,WebGL 解决的问题并不是一个微不足道的问题。WebGL 的目的是渲染最复杂的三维交互场景,而不是简单的静态二维形状,正如下面的例子所示。

为了避免大段的代码片段,我们将把示例分解成几个单独的部分。每个部分将按照它们执行的顺序呈现。

我们需要做的第一件事是设置我们的示例将运行的页面。这里有两个组件,两个着色器程序(关于着色器程序是什么的更多信息将在后面介绍)和WebGLRenderingContext对象的初始化。

<body>

  <script type="glsl-shader/x-fragment" id="glsl-frag-simple">
    precision mediump float;

    void main(void) {
      gl_FragColor = vec4(1.0, 1.0, 0.3, 1.0);
    }
  </script>

  <script type="glsl-shader/x-vertex" id="glsl-vert-simple">
    attribute vec3 aVertPos;

    uniform mat4 uMVMat;
    uniform mat4 uPMat;

    void main(void) {
      gl_Position = uPMat * uMVMat * vec4(aVertPos, 1.0);
    }
  </script>

  <script>
    (function main() {
      var canvas = document.createElement("canvas");
      canvas.width = 700;
      canvas.height = 400;
      document.body.appendChild(canvas);

      var gl = null;
      try {
        gl = canvas.getContext("experimental-webgl") ||
          canvas.getContext("webgl");
        gl.viewportWidth = canvas.width;
        gl.viewportHeight = canvas.height;
      } catch (e) {}

      if (!gl) {
        document.body.innerHTML =
          "<h1>This browser doesn't support WebGl</h1>";
      }

      var shaderFrag = document.getElementById
        ("glsl-frag-simple").textContent;
      var shaderVert = document.getElementById
      ("glsl-frag-simple").textContent;
    })();
  </script>
</body>

glsl-shader/x-vertexglsl-shader/x-fragment类型的script标签利用了 HTML 如何渲染未知标签。当浏览器解析一个带有它不理解的type属性的script标签(即一个虚构的类型,比如glsl-shader/x-vertex)时,它会简单地忽略标签的所有内容。由于我们想要在 HTML 文件中定义着色器程序的内容,但又不希望该文本显示在 HTML 文件中,这种小技巧非常方便。这样我们就可以定义这些脚本,访问它们,而不用担心浏览器不知道如何处理那种特定的语言。

如前所述,在 WebGL 中,我们需要向 GPU 提供所谓的着色器程序,这是用一种称为GLSL(OpenGL 着色语言)的语言编写的实际编译程序,它为 GPU 提供了渲染我们的模型所需的指令。变量shaderFragshaderVert保存了每个着色器程序的源代码的引用,这些源代码本身包含在我们自定义的script标签中。

接下来,我们创建一个常规的 HTML5 画布元素,将其注入到 DOM 中,并创建一个gl对象。注意 WebGL 和 2D 画布之间的相似之处。当然,在这一点之后,这两个 API 一个来自火星,一个来自金星,但在那之前,它们的初始化是相同的。我们不是从画布对象请求 2D 渲染上下文对象,而是简单地请求 WebGL 渲染上下文。由于大多数浏览器(包括谷歌 Chrome)在 WebGL 方面仍处于实验阶段,因此在请求上下文时,我们必须使用实验前缀提供webgl字符串。分隔两个getContext调用的布尔OR运算符表示我们正在从实验前缀请求上下文,或者不使用前缀。浏览器支持的调用将成功。

从这一点开始,对 WebGL 的每个 API 调用都是通过这个gl对象完成的。如果返回WebGLRenderingContext对象的对画布的调用失败,我们就无法对 WebGL 进行任何调用,最好是停止执行。否则,我们可以继续进行我们的程序,传递这个对象,以便我们可以与 WebGL 交互。

function getShader(gl, code, type) {
  // Step 1: Create a specific type of shader
  var shader = gl.createShader(type);

  // Step 2: Link source code to program
  gl.shaderSource(shader, code);

  // Step 3: Compile source code
  gl.compileShader(shader);

  return shader;
}

function getShaderProgram(gl, shaderFrag, shaderVert) {

  // Step 1: Create a shader program
  var program = gl.createProgram();

  // Step 2: Attach both shaders into the program
  gl.attachShader(program, shaderFrag);
  gl.attachShader(program, shaderVert);

  // Step 3: Link the program
  gl.linkProgram(program);

  return program;
}

(function main() {
  // ...

  var shaderFrag = getShader(gl,
    document.getElementById("glsl-frag-simple").textContent,
    gl.FRAGMENT_SHADER);

  var shaderVert = getShader(gl,
    document.getElementById("glsl-vert-simple").textContent,
    gl.VERTEX_SHADER);

  var shader = getShaderProgram(gl, shaderFrag, shaderVert);

  // Specify which shader program is to be used
  gl.useProgram(shader);

  // Allocate space in GPU for variables
  shader.attribVertPos = gl.getAttribLocation(shader, "aVertPos");
  gl.enableVertexAttribArray(shader.attribVertPos);

  shader.pMatrixUniform = gl.getUniformLocation
    (shader, "uPMatrix");
  shader.mvMatrixUniform = gl.getUniformLocation
    (shader, "uMVMatrix");
})();

这个过程的下一步是创建顶点和片段着色器,然后将它们组合成一个单一的着色器程序。顶点着色器的整个工作是指定最终渲染模型中顶点的位置,片段着色器的工作是指定两个或多个顶点之间每个像素的颜色。由于任何渲染都需要这两个着色器,WebGL 将它们合并成一个单一的着色器程序。

着色器程序成功编译后,它将被发送到 GPU,其中处理片段和顶点。我们可以通过在发送到 GPU 之前在着色器程序中指定的指针位置来将输入发送到我们的着色器中。这一步是通过在gl对象(WebGLRenderingContext对象)上调用get*Location方法来完成的。一旦我们有了对这些位置的引用,我们可以稍后为它们分配一个值。

请注意,我们的着色器脚本声明了vec4mat4类型的变量。在诸如 C 或 C++之类的强类型语言中,变量可以具有int(整数)、float(浮点数)、bool(布尔值)或char(字符)类型。在 GLSL 中,有一些新的数据类型是该语言的本机类型,这些类型在图形编程中特别有用。这些类型是向量和矩阵。我们可以使用数据类型vec2创建一个具有两个分量的向量,或者使用vec4创建一个具有四个分量的向量。同样,我们可以通过调用mat3创建一个 3 x 3 矩阵,它实质上创建了一个具有三个vec3元素的类似数组的结构。

function initTriangleBuffer(gl) {
  // Step 1: Create a buffer
  var buffer = gl.createBuffer();

  // Step 2: Bind the buffer with WebGL
  gl.bindBuffer(gl.ARRAY_BUFFER, buffer);

  // Step 3: Specify 3D model vertices
  var vertices = [
    0.0,   0.1, 0.0,
    -1.0, -1.0, 0.0,
    1.0,  -1.0, 0.0
  ];

  // Step 4: Fill the buffer with the data from the model
  gl.bufferData(gl.ARRAY_BUFFER, new Float32Array(vertices),
    gl.STATIC_DRAW);

  // Step 5: Create some variables with information about the
    vertex buffer
  // to simplify calculations later on

  // Each vertex has an X, Y, Z component
  buffer.itemSize = 3;

  // There are 3 unique vertices
  buffer.numItems = parseInt(vertices.length / buffer.itemSize);

  return buffer;
}

(function main() {
  // ...

  var triangleVertBuf = initTriangleBuffer(gl);
})();

在我们放置了一个着色器程序之后,这个程序将告诉显卡如何为我们绘制的点进行绘制,接下来我们需要一些点来绘制。因此,下一步创建了一个我们稍后将要绘制的点的缓冲区。如果您还记得第四章,使用 HTML5 捕捉蛇,在那里我们介绍了新的类型化数组,那么这对您来说将是熟悉的。WebGL 存储顶点数据的方式是使用这些类型化数组,但更具体地说,是 32 位浮点数组。

在这种情况下,我们只绘制一个三角形,计算和跟踪所有点是一个微不足道的任务。然而,3D 模型通常不是手工绘制的。在使用某种 3D 建模软件绘制复杂模型之后,我们将导出代表模型的几百到几千个单独顶点。在这种情况下,我们需要计算模型有多少个顶点,并且最好将这些数据存储在某个地方。由于 JavaScript 允许我们动态地向对象添加属性,我们利用这一点将这两个计算存储在缓冲对象本身上。

最后,让我们实际将我们的三角形绘制到屏幕上。当然,如果我们还没有写足够的样板代码,让我们谈谈 3D 编程的一个主要组成部分,并写一点额外的代码来允许我们最终渲染我们的模型。

不要深入讨论 3D 坐标空间和转换矩阵的话题,将 3D 形状渲染到 2D 屏幕(例如您的计算机显示器)的一个关键方面是,我们需要执行一些线性代数来将表示我们模型的点从 3D 空间转换为简单的 2D 空间(考虑 x 和 y 坐标)。这是通过创建一对矩阵结构并执行一些矩阵乘法来完成的。然后,我们只需要将我们 3D 模型中的每个点(在这个例子中是我们的三角形缓冲区)乘以一个称为MVP 矩阵的矩阵(这是由三个单独的矩阵组成的矩阵,即模型、视图和投影矩阵)。这个矩阵是通过乘以单独的矩阵构建的,每个矩阵代表从 3D 到 2D 的转换过程中的一步。

如果您以前上过任何线性代数课程,您会知道矩阵相乘并不像乘以两个数字那么简单。您还会注意到,在 JavaScript 中表示矩阵也不像定义一个整数类型的变量那么微不足道。为了简化和解决这个问题,我们可以使用 JavaScript 中提供的许多矩阵实用程序库之一。在这个例子中,我们将使用一个非常强大的名为GL-Matrix的库,这是由 Brandon Jones 和 Colin MacKenzie IV 创建的开源库。

<script src="img/glmatrix.js"></script>
…

function drawScene(gl, entityBuf, shader) {
  // Step 1: Create the Model, View and Projection matrices
  var mvMat = mat4.create();
  var pMat = mat4.create();

  // Step 2: Initialize matrices
  mat4.perspective(45, gl.viewportWidth / gl.viewportHeight, 0.1,
    100.0, pMat);
  mat4.identity(mvMat);
  mat4.translate(mvMat, [0.0, 0.5, -3.0]);

  // Step 3: Set up the rendering viewport
  gl.viewport(0, 0, gl.viewportWidth, gl.viewportHeight);
  gl.clear(gl.COLOR_BUFFER_BIT | gl.DEPTH_BUFFER_BIT);

  // Step 4: Send buffers to GPU
  gl.bindBuffer(gl.ARRAY_BUFFER, entityBuf);
  gl.vertexAttribPointer(shader.attribVertPos,
    entityBuf.itemSize, gl.FLOAT, false, 0, 0);
  gl.uniformMatrix4fv(shader.pMatrixUniform, false, pMat);
  gl.uniformMatrix4fv(shader.mvMatrixUniform, false, mvMat);

  // Step 5: Get this over with, and render the triangle already!
  gl.drawArrays(gl.TRIANGLES, 0, entityBuf.numItems);
}

(function main() {
  // ...

  // Clear the WebGL canvas context to some background color
  gl.clearColor(0.2, 0.8, 0.2, 1.0);
  gl.enable(gl.DEPTH_TEST);

  // WebGL: Please draw this triangle on the gl object,
    using this shader...
  drawScene(gl, triangleVertBuf, shader);
})();

关于前面的代码有几点值得注意。首先,您会注意到这是一个只绘制一次的单帧。如果我们决定在场景中进行动画(在真正的游戏中肯定会这样做),我们需要在请求动画帧循环中运行drawScene函数。这个循环将涉及到所有显示的步骤,包括生成我们的 MVP 矩阵的所有模型的矩阵数学。是的,这是要在更复杂的场景上多次每秒执行的大量计算。

其次,观察我们的模型视图投影矩阵的使用。我们首先将它们创建为 4x4 矩阵,然后实例化每一个。投影矩阵的作用就是这样——将 3D 点投影到 2D 空间(画布渲染上下文),根据需要拉伸点以保持画布指定的纵横比。在 WebGL 中,渲染上下文的坐标系在两个轴(垂直和水平轴)上从零到一。投影矩阵使得可能将点映射到超出该有限范围的点。

模型和视图矩阵使我们能够将点建模为相对于对象中心(其自己的坐标系)到世界坐标系的点。例如,假设我们正在建模一个机器人。假设机器人的头部位于点(0, 0, 0)的中心。从那个点开始,机器人的手臂可能分别位于相对于机器人头部的点(-5, 1, 0)和(5, 1, 0)。但是机器人在世界上的位置究竟在哪里?如果我们在这个场景中有另一个机器人,它们相对于彼此的位置是如何的?通过模型和视图矩阵,我们可以将它们都放在同一个全局坐标系上。在我们的例子中,我们将三角形移动到点(0, 0, -0.5, -3.0),这是一个接近世界坐标系原点的点。

最后,我们将我们的矩阵绑定到显卡上,在那里我们通过调用WebGLRenderingContext对象中定义的绘制函数来渲染我们的场景。如果您仔细观察drawScene函数的末尾,我们会向shader对象发送一些值。查看我们之前编写的两个着色器程序(使用 GLSL),我们指定了三个变量,这些变量作为程序的输入。细心的学生会问这些变量来自哪里(这些变量在顶点着色器中定义,命名为aVertPosuMVMatuPMat,这些是 GLSL 语言中定义的特殊数据类型)。它们来自我们的 JavaScript 代码,并通过调用gl.vertexAttribPointergl.uniformMatrix4fv将它们传递到 GPU 中的着色器程序。

大约 150 行代码后,我们有一个黄色三角形在绿色背景上渲染,如下面的截图所示。再次提醒您,WebGL 绝不是一个简单的编程接口,也不是用于可以使用更简单工具完成的简单绘图的首选工具,比如画布元素的 2DRenderingContext、SVG,甚至只是一个简单的图片编辑软件。

尽管 WebGL 需要大量样板代码来渲染一个非常简单的形状,如下面的截图所示,但渲染和动画复杂场景并不比这复杂多少。设置渲染上下文、创建着色器程序和加载缓冲区所需的基本步骤,在创建极其复杂的场景时也是一样的。

Hello, World!

总之,尽管 WebGL 对于刚接触 HTML5 甚至游戏开发的开发人员来说可能是一个难题,但其基本原理是相当简单的。对于那些希望加深对 WebGL(或 3D 图形编程)理解的人,建议您学习三维编程和线性代数的相关主题,以及 WebGL 独有的原则。作为奖励,可以继续熟悉 GLSL 着色语言,因为这是 WebGL 的核心所在。

Web 套接字

如果你曾经考虑过在 HTML5 中创建高性能的多人游戏,那么新的 Web 套接字 API 正是你一直在寻找的东西。如果你以前没有做过太多套接字编程,那么你一直缺少的就是这个:不是每次需要请求资源时都要建立与服务器的连接,而是套接字只需创建一次连接,然后客户端和服务器可以在同一连接上来回通信。换句话说,想象一下给某人打电话,说“你好”,然后在对方回答“你好”后挂断电话。然后,你再次给那个人打电话,等待他们接听电话,一旦你们都准备好了,你就问对方在电话那头怎么样。收到答案后,你再次挂断电话。这种情况持续了整个对话的时间,你每次只问一个问题(或者一次只做一个陈述),大部分时间都是你们两个在等待电话响起并连接电话。

现在,通过套接字编程,上述情景就像是打一个电话,然后在不挂断电话的情况下进行整个对话。你只有在对话最终结束,你和对方说再见,并同意挂断电话时才会挂断电话。在这种情况下,问题和答案之间几乎没有延迟,只有声音从一个电话传到另一个电话所涉及的固有延迟。

在 HTML5 中,套接字 API 分为两部分,即服务器部分和客户端部分。套接字的服务器端是我们在本书中不会过多讨论的,考虑到所涉及的性质。客户端接口是我们将大部分讨论的地方,尽管你会高兴地知道,Web 套接字和 Web 工作者的 JavaScript 接口几乎是相同的。

// Step 1: Open connection
var con = new WebSocket
  ("ws://localhost:8888/packt/sockets/multiplayer-game-server");

// Step 2: Register callbacks
con.addEventListener("open", doOnOpen);
con.addEventListener("error", doOnError);
con.addEventListener("message", doOnMessage);
con.addEventListener("close", doOnClose);

function doOnOpen(event) {
  var msg = document.createElement("p");
  msg.textContent = "Socket connected to " + event.srcElement.URL;
  document.body.appendChild(msg);
}

function doOnError(event) {
  var msg = document.createElement("p");
  msg.textContent = "Error: " + event;
  document.body.appendChild(msg);
}

function doOnMessage(event) {
  var response = JSON.parse(event.data);

  var msg = document.createElement("p");
  msg.textContent = "Message received: " + response.message;
  document.body.appendChild(msg);
}

function doOnClose(event) {
  var msg = document.createElement("p");
  msg.textContent = "Socket connection closed at " +
    event.timeStamp;
  document.body.appendChild(msg);
}

// Step 3: Send a message to the server
con.send("Hello!");

从前面的代码片段中可以看出,Web 套接字接口和 Web 工作者接口之间没有太多的区别。也许最显著的区别是我们可以通过哪个接口向服务器发送消息。Web 工作者使用postMessage函数,而 Web 套接字使用send函数。传统的事件处理函数与工作者的工作方式完全相同。套接字有四个与之关联的事件,分别是onOpenonCloseonErroronMessage。前两个事件onOpenonClose在服务器成功验证请求并升级与浏览器的连接时以及服务器以某种方式关闭与特定套接字的连接时被调用。onError事件在服务器应用程序发生错误时触发。最后,当服务器向客户端推送消息时,JavaScript 套接字的句柄通过onMessage回调函数被警告。传递给函数的事件对象与 Web 工作者onMessage事件对象类似,具有一个data属性,其中包含实际发送的数据,以及一个timestamp属性,指示消息发送的时间。

连接

了解 Web 应用程序如何通过 Web 套接字连接到后端服务器对于学习套接字 API 的工作原理至关重要。首先要记住的是,连接浏览器与服务器的协议与通常的 HTTP 连接不同。浏览器保持与服务器的连接方式是通过使用新的WebSocket协议,这是通过以下几个步骤完成的。WebSocket协议基于传统的 TCP,并使用 HTTP 来升级浏览器和后端服务器之间的连接,如下面的屏幕截图所示:

连接

当我们在 JavaScript 中创建WebSocket类的实例时,浏览器会尝试与服务器建立持久的套接字连接。首先发生的事情是浏览器向WebSocket构造函数中指定的 URI 发送 HTTP 请求。此请求包含一个升级标头,指定它希望将连接升级到使用WebSocket协议。然后服务器和浏览器执行典型的握手,对于本书的目的,不会详细解释。如果您有兴趣实现自己的后端服务器应用程序来处理这个低级握手,可以参考在线官方 Web 套接字文档。

简而言之,客户端将此 HTTP 请求发送到服务器,包括一个包含密钥的标头,这是一个简单的文本字符串。然后服务器对该字符串进行哈希和编码,并发送回一个 HTTP 响应,浏览器验证并接受协议升级是否一切正常。如果这个握手成功,浏览器将实例化WebSocket对象,然后我们可以使用它通过相同的连接与服务器通信。

服务器端代码

Web 套接字的典型用例是多人游戏,其中两个或更多玩家要么相互对战,要么共享同一游戏,但来自不同的地点。这样的游戏可以通过两个玩家从不同的计算机连接到服务器,然后服务器接收来自两个玩家的输入并发送计算出的输出来实现。然后,每个玩家的客户端应用程序只需渲染从服务器接收到的数据。例如,玩家 A 按下键盘上的一个键,使由玩家 A 控制的角色跳跃。这些数据被发送到服务器,服务器会跟踪角色的位置以及是否可以跳跃等。服务器根据从玩家 A 接收到的输入计算要执行的操作(在这个例子中,服务器确定玩家 A 的角色现在正在执行跳跃),然后将玩家 A 的角色的更新状态发送给玩家 A 和玩家 B。他们的应用程序然后只需渲染玩家 A 的角色在空中。当然,每个玩家的游戏本地实例也会根据本地玩家的操作渲染其计算出的状态,以提供即时反馈。但是,游戏的服务器端实例有能力使来自任一玩家的输入导致的任何游戏状态无效。这样,两个玩家都可以体验非常流畅、响应迅速的多人游戏体验,同时保持游戏的完整性。

现在,根据服务器端代码实现的具体语言,这可能是一个微不足道的任务,也可能是一个真正的噩梦。总的来说,这个服务器端代码需要跟踪连接到它的所有套接字。显然,应用程序的复杂性将与游戏的目标相关。然而,就 Web 套接字 API 而言,主要的重点是使用send接口函数将数据传递回客户端,并通过onMessage函数检查输入。

客户端代码

正如我们在前面的代码片段中看到的,使用 JavaScript 的WebSocket对象非常简单。但是需要记住的两件事是,对WebSocket.send的每次调用都是异步的,并且传递给WebSocket.send的任何数据必须是(或将被转换为)DOMString。这意味着如果我们向服务器发送对象、函数或其他任何内容,服务器将以 UTF-16 编码的字符串形式接收。如果我们向服务器发送 JSON 字符串,那么我们只需要解析数据并访问具体内容。但是,如果我们只是发送一个实际的对象,比如一个字面的 JSON 对象,服务器将收到以下代码片段中的内容:

// Client code
var con = new WebSocket
  ("ws://localhost:8888/packt/sockets/multiplayer-game-server");
// …

con.send({name: "Rodrigo"});

// Server code
String input = get_input_from_socket();
input.toString() == "[object Object]";

因此,通过 Web 套接字发送对象时,JavaScript 不会尝试对对象进行编码,而是简单地调用对象的toString函数,并将其输出发送到套接字。

视频

能够直接在浏览器内播放视频而无需担心插件是一种愉快的体验。不仅如此,由于视频元素实际上是 DOM 的一个本机部分,这意味着我们也可以像处理所有其他 DOM 元素一样处理它。换句话说,我们可以对视频元素应用 CSS 样式,浏览器会很乐意为我们解决问题。例如,假设我们想要创建视频在闪亮表面上播放的效果,其中视频在垂直方向反射,反射渐隐,融入背景,如下面的截图所示:

Video

由于浏览器负责渲染视频,以及对其管理的所有元素应用 CSS 样式和效果,我们不必担心渲染带有特殊效果的视频所涉及的逻辑。但是请记住,我们在视频上添加的 CSS 越多,浏览器就需要越多的工作来使视频看起来符合我们的要求,这可能会迅速影响性能。但是,如果我们在视频中添加的只是一些简单的细节,那么大多数现代 Web 浏览器都不会在渲染时出现问题。

<style>
video {
  -webkit-box-reflect: below 1px;
  -webkit-transition: all 1.5s;
}

video {
  -webkit-filter: contrast(250%);
}

div {
  position: relative;
}

div img {
  position: absolute;
  left: 0;
  top: 221px;
  width: 400px;
  height: 220px;
}
</style>

<div>
  <video controls width="400" height="220"
    poster="bunny-poster.png">
    <!-- Video courtesy of http://www.bigbuckbunny.org -->
    <source src="img/bunny.ogg" type="video/ogg" />
    <source src="img/bunny.mp4" type="video/mp4" />
    <source src="img/bunny.webm" type="video/webm" />
  </video>
  <img src="img/semi-transparent-mask.png" />
</div>

与新的 HTML5 音频元素类似,我们可以更多或更少地使用标签的两种方式。一种方法是简单地创建 HTML 节点,指定与audio标签相同的属性,指定一个或多个source节点,然后结束。或者,我们可以使用可用的 JavaScript API,并以编程方式操纵视频文件的播放。

// Step 1: Create the video object
var video = document.createElement("video");
video.width = 400;
video.height = 220;
video.controls = true;
video.poster = "bunny-poster.png";

// Step 2: Add one or more sources
var sources = [
  {src: "bunny.ogg", type: "video/ogg"},
  {src: "bunny.mp4", type: "video/mp4"},
  {src: "bunny.webm", type: "webm"}
];

for (var i in sources) {
  var source = document.createElement("source");
  source.src = sources[i].src;
  source.type = sources[i].type;

  video.appendChild(source);
}

// Step 3: Make video player visible
document.body.appendChild(video);

我们还可以忽略默认控件,并通过利用引用视频元素的 JavaScript 对象上可用的属性来自行管理播放、暂停、调整音量等操作。以下是我们可以在视频对象上调用的属性和函数列表。

属性

  • autoplay(布尔值)

  • currentTime(浮点数—以秒为单位)

  • paused(布尔值)

  • controls(布尔值)

  • muted(布尔值)

  • width(整数)

  • height(整数)

  • videoWidth(整数—只读)

  • videoHeight(整数—只读)

  • poster(字符串—图像 URI)

  • duration(整数—只读)

  • loop(布尔值)

  • currentSrc(字符串)

  • preload(布尔值)

  • seeking(布尔值)

  • playbackRange(整数)

  • ended(布尔值)

  • volume(整数—介于 0 和 100 之间,不包括 0 和 100)

事件

loadstart 用户代理开始查找媒体数据,作为资源选择算法的一部分。
progress 用户代理正在获取媒体数据。
suspend 用户代理有意不获取媒体数据。
abort 用户代理在完全下载之前停止获取媒体数据,但不是由于错误。
error 在获取媒体数据时发生错误。
emptied 其网络状态先前不处于NETWORK_EMPTY状态的媒体元素刚刚切换到该状态(要么是因为在加载过程中发生了致命错误,即将报告,要么是因为在资源选择算法已经运行时调用了load()方法)。
stalled 用户代理正在尝试获取媒体数据,但数据出乎意料地没有出现。
loadedmetadata 用户代理刚刚确定了媒体资源的持续时间和尺寸,文本轨道已准备就绪。
loadeddata 用户代理可以首次在当前播放位置渲染媒体数据。
canplay 用户代理可以恢复播放媒体数据,但估计如果现在开始播放,媒体资源无法以当前播放速率一直播放到结束,而无需停止进行进一步的内容缓冲。
canplaythrough 用户代理估计,如果现在开始播放,媒体资源可以以当前播放速率一直播放到结束,而无需停止进行进一步的缓冲。
playing 经过暂停或由于缺乏媒体数据而延迟后,播放已准备好开始。
waiting 播放已经停止,因为下一帧尚未准备好,但用户代理预计该帧将及时准备好。
seeking 寻找的 IDL 属性已更改为 true。
seeked 寻找的 IDL 属性已更改为 false。
ended 播放已停止,因为媒体资源的结束已经到达。
durationchange 持续时间属性刚刚被更新。
timeupdate 当前播放位置因正常播放或特别有趣的方式(例如不连续地)而发生了变化。
play 元素不再暂停。在play()方法返回后触发,或者autoplay属性导致播放开始时触发。
pause 元素已暂停。在pause()方法返回后触发。
ratechange 默认的Playback Rateplayback Rate属性刚刚被更新。
volumechange volume属性或muted属性已更改。在相关属性的 setter 返回后触发。

有关事件的更多信息,请访问 W3C 候选推荐媒体事件www.w3.org/TR/html5/embedded-content-0.html#mediaevents

你应该对新的 HTML5 视频元素感到兴奋的另一个原因是,视频的每一帧都可以直接渲染到画布 2D 渲染上下文中,就像单独的一帧是一个独立的图像一样。这样,我们就能够在浏览器上进行视频处理。不幸的是,我们无法导出由我们的 JavaScript 应用程序创建的视频的video.toDataURL等价物。

var ctx = null;
var ctxOff = null;

var poster = new Image();
poster.src = "bunny-poster.jpg";
poster.addEventListener("click", initVideo);
document.body.appendChild(poster);

// Step 1: When the video plays, call our custom drawing function
video.autoplay = false;
video.loop = false;

// Step 2: Add one or more sources
var sources = [
  {src: "bunny.ogg", type: "video/ogg"},
  {src: "bunny.mp4", type: "video/mp4"},
  {src: "bunny.webm", type: "webm"}
];

for (var i in sources) {
  var source = document.createElement("source");
  source.src = sources[i].src;
  source.type = sources[i].type;

  video.appendChild(source);
}

// Step 3: Initialize the video
function initVideo() {
  video.addEventListener("play", initCanvas);
  video.play();
}

// Step 4: Only initialize our canvases once
function initCanvas() {
  // Step 1: Initialize canvas, if needed
  if (ctx == null) {
    var canvas = document.createElement("canvas");
    var canvasOff = document.createElement("canvas");

    canvas.width = canvasOff.width = video.videoWidth;
    canvas.height = canvasOff.height = video.videoHeight;

    ctx = canvas.getContext("2d");
    ctxOff = canvasOff.getContext("2d");

    // Make the canvas - not video player – visible
    poster.parentNode.removeChild(poster);
    document.body.appendChild(canvas);
  }

  renderOnCanvas();
}

function renderOnCanvas() {
  // Draw frame to canvas if video is still playing
  if (!video.paused && !video.ended) {

    // Draw original frame to offscreen canvas
    ctxOff.drawImage(video, 0, 0, canvas.width, canvas.height);

    // Manipulate frames offscreen
    var frame = getVideoFrame();

    // Draw new frame to visible video player
    ctx.putImageData(frame, 0, 0);
    requestAnimationFrame(renderOnCanvas);
  }
}

function getVideoFrame() {
  var img = ctxOff.getImageData
    (0, 0, canvas.width, canvas.height);

  // Invert the color of every pixel in the canvas context
  for (var i = 0, len = img.data.length; i < len; i += 4) {
    img.data[i] = 255 - img.data[i];
    img.data[i + 1] = 255 - img.data[i + 1];
    img.data[i + 2] = 255 - img.data[i + 2];
  }

  return img;
}

这个想法是在屏幕外播放视频,这意味着实际的视频播放器从未附加到 DOM。视频仍在播放,但浏览器从不需要将每一帧闪电般地显示在屏幕上(它只在内存中播放)。当每一帧播放时,我们将该帧绘制到画布上下文中(就像我们对图像做的那样),从画布上下文中获取像素,操纵像素数据,然后最终将其重新绘制到画布上。

由于视频只不过是一个接着一个播放的帧序列,给人以动画的错觉,我们可以从 HTML5 视频中提取每一帧,并像处理其他图像一样使用它与画布 API。由于没有办法绘制到视频元素,我们只需将视频播放器中的每一帧绘制到一个普通的画布对象中,就能达到相同的效果——但是像素经过精心设计。以下截图展示了这种技术的结果:

Events

实现这一结果的一种方法是创建两个画布元素。如果我们只绘制到同一个画布上(绘制视频帧,然后处理该帧,然后绘制下一帧,依此类推),定制帧将只在屏幕上显示一小部分时间。只有在我们迅速绘制下一个传入帧之前才会可见。反过来,这个下一个帧只会在我们循环遍历该帧的像素数据并重新绘制该帧时才会可见。你明白了,结果会很混乱,一点也不是我们想要的。

因此,我们使用两个画布上下文。一个上下文负责仅显示我们正在处理的像素(也称为处理后的像素),另一个上下文对用户永远不可见,其目的是保存每一帧从视频中直接传来的像素。这样,我们每次迭代只在主画布上绘制一次,而在这个画布上显示的只有处理后的像素。原始像素(也称为内存中播放的原始视频的像素)将继续以尽可能快的速度流到离屏画布上下文。

地理位置

尽管 3D 图形很棒,基于套接字的多人游戏也很棒,但这两种技术都不一定是新的。另一方面,地理位置是一种较新的现象。有了它,我们能够使用 JavaScript 来确定用户的物理位置(地理位置)。拥有这样的工具使我们能够开发出令人惊叹的、高度创新的游戏概念。

现在,每当有一个新功能出现,承诺能够准确追踪用户的物理位置,大多数人(除了开发人员)都会对此感到至少有点害怕。毕竟,如果玩一个非常黑暗的生存恐怖游戏,知道其他玩家可以准确看到你的住址,那将是多么可怕。幸运的是,整个地理位置 API 都是基于用户选择的,这意味着用户会被提示应用程序尝试捕获用户的位置,只有当用户接受应用程序的请求时,浏览器才允许应用程序继续捕获用户的 GPS 位置。

如下截图所示,当尝试使用地理位置 API 时,浏览器会以某种方式向用户发出警报,并请求继续。如果用户决定不与应用程序共享他/她的位置,浏览器将不会与应用程序共享位置。

地理位置

尽管每个浏览器在请求步骤上的实现略有不同,特别是关于如何向用户图形化传达此通知和请求的方式,但应用程序无法强制或秘密收集此信息。

function getGeo(position) {
  var geo = document.createElement("ul");
  var lat = document.createElement("li");
  var lon = document.createElement("li");

  lat.textContent = "Latitude: " + position.coords.latitude;
  lon.textContent = "Longitude: " + position.coords.longitude;

  geo.appendChild(lat);
  geo.appendChild(lon);
  document.body.appendChild(geo);
}

function doOnPermissionDenied(message) {
  var p = document.createElement("p");

  p.textContent = "Permission Denied Error: " + message;
  document.body.appendChild(p);
}

function doOnPositionUnavailable(message) {
  var p = document.createElement("p");

  p.textContent = "Position Unavailable Error: " + message;
  document.body.appendChild(p);
}

function doOnTimeout(message) {
  var p = document.createElement("p");

  p.textContent = "Operation Timeout Error: " + message;
  document.body.appendChild(p);
}

function doNoGeo(positionError) {
  switch (positionError.code) {
    case positionError.PERMISSION_DENIED:
      doOnPermissionDenied(positionError.message);
      break;

    case positionError.POSITION_UNAVAILABLE:
      doOnPositionUnavailable(positionError.message);
      break;

    case positionError.TIMEOUT:
      doOnTimeout(positionError.message);
      break;
  }
}

// Ask the user if you may use Geolocation
navigator.geolocation.getCurrentPosition(getGeo, doNoGeo);

API 的第一部分涉及请求用户允许获取他/她的位置。这是通过在全局 navigator 对象的geolocation属性上调用getCurrentPosition函数来完成的。该函数接受两个参数,即一个回调函数,如果用户允许浏览器共享用户的位置,则调用该函数,以及一个回调函数,如果用户拒绝应用程序的请求,则调用该函数。

如果用户接受了应用程序的请求来共享地理位置,回调函数将被调用,并传入一个Geoposition对象。该对象有个我们可以使用的属性:

  • timestamp: 回调函数被调用时

  • coords: 一个Coordinates类的实例

  • accuracy: GPS 坐标的准确度(以米为单位)

  • altitude: 以米为单位

  • altitudeAccuracy: 海拔的准确度(以米为单位)

  • heading: 以顺时针方向的度数

  • latitude: 作为双精度

  • longitude: 作为双精度

  • speed: 以米/秒为单位

位置对象中只有三个属性是必须存在的。这些是纬度经度值,以及精度属性。如果使用的硬件支持,所有其他值都是可选的并且可用。还要记住,这个功能在移动设备上同样可用,因此用户的位置在应用程序使用过程中可能会有所变化。幸运的是,一旦用户同意与应用程序共享他或她的位置,任何后续调用获取当前位置的操作都将立即成功。当然,用户也可以从浏览器中清除对特定域的权限,因此任何后续获取位置的调用可能会失败(如果用户已经完全禁用了该功能),或者导致新的权限请求(如果用户只是清除了浏览器上的权限缓存)。

从下面的屏幕截图中可以看出,当页面使用地理位置时,谷歌浏览器在地址栏上显示不同的图标,以通知用户。通过点击这个特殊的图标,用户可以重置权限,或者在更长时间的基础上阻止或允许应用程序。

地理位置

一个谷歌地图示例

如今,地理位置最常见的用例可能涉及将位置呈现到地图上。幸运的是,谷歌提供了一个出色的免费 API,我们可以利用它来实现这一目的。通过这个地图服务,我们可以捕获用户的地理位置,然后在地图上渲染一个标记,就在用户所在的位置(或者在用户所在位置的精度距离内的某个地方)。虽然谷歌地图 API 相当强大,但我们只会简单介绍如何获取用户的位置,然后在地图上呈现该坐标点的一个相当琐碎的例子。

地图 API 的基本思想很简单:创建一个地图对象,将其呈现在某个 HTML 容器对象内,指定地图的中心位置(以便我们知道地图中用户立即可见的一般区域),并在地图上添加标记。标记对象至少需要两个属性,即对地图对象的引用和 GPS 坐标点。在我们的示例中,我们将把地图的中心放在用户的 GPS 坐标上,并在同一位置放置一个标记。

// Step 1: Request permission to get the user's location
function initGeo() {
  navigator.geolocation.getCurrentPosition(renderToMap, doNoGeo);
}

// Step 2: Render the user's location on a map
function renderToMap(position) {
  var container = document.createElement("div");
  container.id = "myContaier";
  container.style.width = window.innerWidth + "px";
  container.style.height = window.innerHeight + "px";

  document.body.appendChild(container);

  // Define some point based on a GPS coordinate
  var coords = new google.maps.LatLng(
    position.coords.latitude,
    position.coords.longitude);

  // Specify how we want the map to look
  var options = {
    zoom: 16,
    center: coords,
    mapTypeControl: false,
    mapTypeId: google.maps.MapTypeId.ROADMAP
  };

  // Create a map, and inject it into the DOM element referenced
  var map = new google.maps.Map(container, options);

  // Create a marker and associate it with our map
  var marker = new google.maps.Marker({
    position: coords,
    map: map,
    title: "Where's me?"
  });
}

虽然前面的例子可能不是你见过的最激动人心的软件,但它很好地说明了两个重要的观点。首先,地理位置 API 很强大,但也可能是所有其他 HTML5 API 中最容易使用的,因为它提供了所有功能和你需要知道的一切。其次,前面的片段展示了 Web 平台是多么开放,以及我们可以通过利用他人的工作来实现多少潜力。

运行前面的代码将导致一个非常漂亮的地图覆盖整个屏幕,地图的中心点是用户当前的位置,如下面的屏幕截图所示。请记住,谷歌地图只是许多免费 API 中的一个例子,我们可以与地理位置等强大的 HTML5 功能一起使用。

一个谷歌地图示例

即将推出的 CSS 功能

我最喜欢的关于开放网络的事情之一是它也是一个活跃的网络。随着新的想法的出现和新的需求的显现,新功能被引入到规范中只是时间问题。CSS 就是一个完美的例子,最近规范中添加了一些新功能。最重要的是,大多数浏览器供应商都非常积极地将这些新功能引入到他们的浏览器中。

在接下来的部分中,我们将介绍 CSS 的三个新功能,即 CSS 着色器、CSS 列和 CSS 区域和排除。为了让您了解这些功能的开发活跃程度,我们将讨论第一个功能CSS 着色器,它最近更名为 CSS 自定义滤镜。谈论一个快速发展的开发生命周期。

在最前沿编程

尽管本书中的大部分内容都是新的和最先进的,但到目前为止,讨论的大多数 HTML5 功能和 API 都是相当稳定的。我的意思是,几乎任何主要的浏览器都应该能够处理这些功能而不会出现任何问题。然而,以下 CSS 功能刚刚出炉。更具体地说,这三个功能仍在烘烤中,配方正在不断完善,直到达到更稳定的水平。

有了这个说法,这一部分可能需要您使用绝对最新的浏览器,使用最新的可能版本,甚至可能需要您深入您选择的浏览器的设置部分,以便设置任何高级标志,以便这些新的实验性功能能够工作。本章的所有代码示例都是为 Google Chrome Canary(夜间构建)编写和测试的。在我写这篇文章时,安装 Google Chrome Canary 后,必须手动启用以下标志:

  • 启用实验性 WebKit 功能

  • 启用CSS 着色器

您可能不需要启用WebGL标志,因为这个特定的标志已经默认启用了一段时间,但是如果该标志被禁用,您可以以相同的方式使其可用。要查看可以在 Google Chrome 上设置的所有可用标志,只需在浏览器的地址栏中输入以下命令(通常在那里输入网站的 URL):chrome://flags

在标志页中,您将看到一个标志列表,以及每个标志的描述。查找与实验性 WebKit 功能CSS 着色器相关的两个标志,并确保它们已启用。如下截图所示,要注意的是,粗心地设置和取消标志可能会影响 Google Chrome 的行为和性能。确保更改最少的标志,以避免使浏览器的工作不够理想,并确保跟踪您更改的任何标志,以便在发生任何不良情况时可以恢复更改。

在最前沿编程

关于使用这些绝对最新的实验性 API 进行开发的最后一点说明是,由于实验性 API 的性质,不同浏览器之间可能存在特定的语法和功能,以及显著的性能差异。由于并非所有浏览器同时开始采用新的 API,因此很大一部分用户无法查看您的最新和最棒的代码,直到 API 变得足够稳定——有时需要的时间比我们希望的长。

CSS 着色器

目前,这是 CSS 中添加的绝对最新功能。CSS 着色器背后的最初想法是允许设计师使用 GLSL 着色器来渲染任意 HTML 元素。现在,我们不仅可以指定元素的背景颜色、边框样式、框阴影等,还可以处理元素的每个像素是如何渲染的。

最近,这个功能已经合并到现有的 CSS 滤镜规范中,该规范规定了一些预先制作的滤镜,我们可以应用到一个元素上。例如,我们可以将模糊滤镜应用到图像元素上,让浏览器在从服务器传送到 Web 应用程序时动态处理图像。然而,我们现在不仅仅依赖于浏览器决定使用哪些滤镜,而是可以自己制作滤镜,并让 CSS 渲染引擎使用它们。因此,这个新的 CSS API 的当前名称(无论如何)是自定义 CSS 滤镜

使用 CSS 滤镜非常容易。毕竟,它们只是一个常规的 CSS 属性。截至目前,我们可以应用九种不同的滤镜,不仅适用于图像,还适用于任何可以接收 CSS 样式的东西。如果将滤镜添加到具有一个或多个子节点的元素中,正如 CSS 的性质一样,滤镜效果将传播到任何和所有子元素,除非其中一个或多个指定了自己的滤镜,或者故意指定不应该对其和其子元素应用任何滤镜。

CSS 滤镜的当前列表如下:

  • blur:应用高斯模糊

  • brightness:通过应用更多或更少的白色颜色来增加元素的亮度

  • contrast:调整元素的对比度

  • drop-shadow:对元素应用阴影效果

  • grayscale:将元素的颜色转换为灰度

  • hue-rotate:根据颜色圆对元素应用色相旋转

  • invert:反转元素的颜色

  • opacity:对元素应用透明度

  • saturate:增加元素的饱和度

  • sepia:将元素的颜色转换为棕褐色

请记住,尽管这些滤镜实际上只是 CSS 属性,但实际上它们是浏览器在 CSS 查询匹配的元素上执行的单独函数。因此,每个滤镜函数都需要一个或多个参数,在幕后,这些参数是传递给预定义的着色器程序的变量。

<style>
div {
  margin: 10px;
  padding: 0;
  border: 1px solid #ddd;
  background: #fafafa;
  width: 400px;

  transition: all 3.3s;
  filter: invert(1);
}

div:hover {
  -webkit-filter: invert(0) blur(3px) contrast(150%);
}

h2 {
  margin: 0;
  padding: 10px;
  font-size: 4.75em;
  color: #aaa;
  text-shadow: 0 -1px 0 #555, 0 1px 0 #fff;
}
</style>

<div>
  <h2>CSS Filters</h2>
  <img src="img/strawberry.jpg" width="400" height="350" />
</div>

在下面的屏幕截图中,左侧的图像是一个常规的 HTML 元素,带有一个标题和一个图像。在右侧,我们应用了一个 CSS 滤镜,反转了颜色。整个效果是用一行代码实现的。

CSS 着色器

请注意,我们可以通过简单地将其他滤镜列为 CSS 属性的值来将多个滤镜应用于同一元素。此外,请记住,即使只需一行代码就可以将这些令人兴奋的滤镜之一添加到我们的应用程序中,每个使用的滤镜都意味着浏览器需要在其已经在做的所有工作之上进行更多的工作。因此,我们使用这些滤镜越多,我们就可以预期性能相应地下降。

使用自定义滤镜

为了在渲染我们的应用程序时输入自己的过滤函数,我们需要创建执行我们想要的操作的着色器程序。值得庆幸的是,这些着色器程序是用我们在 WebGL 中使用的相同的着色语言编写的。如果你认为学习 JavaScript、CSS 和 HTML 已经是很多工作了,我很抱歉地说,但是请继续将 GLSL 添加到你必须掌握的语言列表中(或者找到已经掌握它的人),以充分利用 HTML5 革命。

要指定用于 CSS 滤镜的自定义着色器,我们只需将自定义函数作为 filter 属性的值调用,传入我们的顶点和片段着色器,然后是顶点着色器要使用的任何可能的变量。片段着色器使用的外部变量是从顶点着色器传入的,因此我们无法直接从 CSS 中传入任何内容。

div {
  margin: 10px;
  padding: 0;
  border: 1px solid #ddd;
  background: #fafafa;
  width: 400px;

  filter: custom(url(simple-vert-shader.glsl)
    mix(url(simple-frag-shader.glsl) normal source-atop,
    16 32,
    lightPosition 0.0 0.0 1.0;
}

前面的滤镜定义有三个部分。首先,我们调用custom表示我们将使用自己的着色器。我们传递给这个函数的第一个参数是顶点着色器。这个文件的扩展名并不重要,因为文件的内容将被编译并发送到 GPU。很多时候,你会看到其他开发人员为他们的着色器使用文件扩展名,比如.glsl.vs.fs(分别用于顶点着色器和片段着色器)。请注意,片段着色器通过mix()函数发送,而不是直接通过url()函数发送,这与顶点着色器的情况不同。最后,我们指定将构成元素内容网格的行数和列数。构成这个网格的顶点是浏览器自动创建的。最后,与我们自定义滤镜一起传递的最后一组参数是顶点着色器使用的 uniform 值(附带它们的名称)。

由于 GLSL 本身超出了本书的范围,我们将避免对这些自定义着色器进行彻底的示例。相反,我们将看一个象征性的例子,它将使用虚拟着色器。如果没有正确的背景知识和图形编程、着色器编程和其他 3D 图形主题的经验,解释自定义着色器程序将是相当具有挑战性的。

以下着色器程序从 CSS 中获取三个输入,即表示图像中每个像素应用的红色、绿色和蓝色的量的值,介绍 OpenGL 着色语言(GLSL)的快速简要入门课程,我只想说:uniform 就像是一个全局变量,我们可以传递给顶点着色器。顶点着色器每个顶点调用一次,并确定每个顶点的位置。为了将值发送到片段着色器,顶点着色器可以使用 varying 变量。如果我们在顶点着色器中定义了一个带有varying关键字的任何类型的变量,这意味着分配给它的任何值将可供片段着色器使用,前提是片段着色器还定义了相同名称和类型的 varying 变量。因此,如果我们希望从 CSS 直接将一个值传递到片段着色器,我们可以简单地将值发送到顶点着色器,然后使用varying将该值传递到片段着色器。片段着色器每个像素调用一次,并确定要应用于该像素的颜色。

// ----------------------------------------------------
// Vertex shader: simple-vert-shader.glsl
// ----------------------------------------------------
precision mediump float;

// Built-in attribute
attribute vec4 a_position;

// Built-in uniform
uniform mat4 u_projectionMatrix;

// Values sent in from CSS
uniform float red;
uniform float green;
uniform float blue;

// Send values to fragment shader
varying float v_r;
varying float v_g;
varying float v_b;

void main() {

  v_r = red;
  v_g = green;
  v_b = blue;

  // Set the position of each vertex
  gl_Position = u_projectionMatrix * a_position;
}

前面的顶点着色器所做的只有两件事:将我们的值从 CSS 传递到片段着色器,并设置内容网格上每个顶点的顶点位置。

// ----------------------------------------------------
// Vertex shader: simple-vert-shader.glsl
// ----------------------------------------------------
precision mediump float;

// Input from vertex shader
varying float v_r;
varying float v_g;
varying float v_b;

void main() {

  // Set the color of each fragment
  css_ColorMatrix = mat4(v_r, 0.0, 0.0, 0.0,
    0.0, v_g, 0.0, 0.0,
    0.0, 0.0, v_b, 0.0,
    0.0, 0.0, 0.0, 1.0);
}

有了这个着色器程序,我们只需要在 HTML 文件中调用它。我们需要注意的三个参数是红色、绿色和蓝色的 uniform 值。无论我们为这三个颜色通道发送什么值,它都会反映在我们应用这个滤镜的任何元素的渲染上。

<style>
div {
  margin: 10px;
  padding: 0;
  border: 1px solid #ddd;
  background: #fafafa;
  width: 400px;

  /**
   * We can leverage CSS transitions to make our simple
   * shaders seem even more impressive
   */
  transition: filter 1.0s;

  filter: custom(url(simple-vert-shader.glsl)
    mix(url(simple-frag-shader.glsl)
    normal source-atop),
    16 32,
    red 1.0, green 0.0, blue 0.0);
}

div:hover {
  filter: custom(url(simple-vert-shader.glsl)
    mix(url(simple-frag-shader.glsl)
    normal source-atop),
    16 32,
    red 1.0, green 1.0, blue 0.0);
}

h2 {
  margin: 0;
  padding: 10px;
  font-size: 4.75em;
  color: #aaa;
  text-shadow: 0 -1px 0 #555, 0 1px 0 #fff;
}
</style>

<div>
  <h2>CSS Filters</h2>
  <img src="img/strawberry.jpg" width="400" height="350" />
</div>

有了这个设置,我们的div元素将默认以一种特定的方式呈现。在这种情况下,我们只在 DOM 节点内的每个像素上打开红色通道。然而,当我们悬停在元素上时,我们应用相同的着色器,但颜色完全不同。这次我们让每个像素看起来更加黄色。借助 CSS 过渡,我们可以平滑地过渡这两种状态,产生一个简单而非常舒适的效果。当然,您对 GLSL 了解得越多,您就可以使这些自定义着色器变得更加花哨和强大。而且作为额外的奖励,我们不必担心在 WebGL 中使用着色器所涉及的所有设置工作。浏览器提供的默认抽象非常有用,使得自定义着色器非常可重用,因为使用我们的着色器的人只需要跟踪几个 CSS 属性。最重要的是,由于着色器程序在这个 CSS 级别上至少是纯文本文件,我们可以通过检查其源代码来了解其他人的着色器是如何工作的。通过使用我们的自定义着色器,我们可以轻松地控制哪些颜色通道在单个像素级别上打开或关闭,如下面的屏幕截图所示。这种像素级别的操作不仅限于图像,而是在我们将滤镜应用于的每个 DOM 元素的每个像素上执行。文字、图像、容器等。

使用自定义滤镜

然而,请注意,由于这项技术是最新的,几乎没有工具可以帮助我们开发、调试和维护 GLSL 着色器。您很快会注意到,当在您的着色器中发现错误时,您将只会看到一个未经过滤的 HTML 文档。例如,如果您的着色器程序无法编译,浏览器将不会告诉您发生了什么,或者在哪里,甚至可能为什么。因此,编写自定义 CSS 滤镜可能是目前网页开发中最具挑战性的方面,因为浏览器尚未在这个过程中提供很有用的帮助。

CSS 列

如果您至少使用互联网几周,或者至少看过几十个不同的网站,您肯定会注意到 HTML 文档的矩形特性。虽然可以使用 HTML、JavaScript 和 CSS 的组合来创建非常健壮的设计,但网页设计师已经等待了很长时间,以寻找一个简单的解决方案来创建多列设计。

通过新的 CSS 列功能,我们可以创建一个常规的文本块,然后告诉 CSS 引擎将该块显示为两列或更多列。其他所有事情都由浏览器非常高效地处理。例如,假设我们希望将一个文本块显示为四个等宽的列,每列之间间隔 20 像素。这可以通过两行直观的代码实现(可能需要供应商前缀,但在这个例子中被故意忽略)。

<style>
div {
  column-count: 4;
  column-gap: 20px;
</style>

<div>
  <p>Lorem ipsum dolor sit amet, consectetuer adipiscing elit, sed diam nonummy nibh euismod tincidunt ut laoreet dolore magna aliquam erat volutpat. Ut wisi enim ad minim veniam, quis nostrud exerci tation ullamcorper suscipit lobortis nisl ut aliquip ex ea commodo consequat. Duis autem vel eum iriure dolor in hendrerit in vulputate velit esse molestie consequat, vel illum dolore eu feugiat nulla facilisis at vero eros et accumsan et iusto odio dignissim qui blandit praesent luptatum zzril delenit augue duis dolore te feugait nulla facilisi.</p>

  <p>Nam liber tempor cum soluta nobis eleifend option congue nihil imperdiet doming id quod mazim placerat facer possim assum. Typi non habent claritatem insitam; est usus legentis in iis qui facit eorum claritatem. Investigationes demonstraverunt lectores legere me lius quod ii legunt saepius.</p>

  <p>Claritas est etiam processus dynamicus, qui sequitur mutationem consuetudium lectorum. Mirum est notare quam littera gothica, quam nunc putamus parum claram, anteposuerit litterarum formas humanitatis per seacula quarta decima et quinta decima. Eodem modo typi, qui nunc nobis videntur parum clari, fiant sollemnes in futurum.</p>
</div>

通过上述设置,浏览器知道我们希望将文本渲染成四列,每列之间间隔 20 像素。请注意,从来没有提到过每列的宽度。在这种情况下,浏览器计算出div容器内的可用空间,减去列间隙所需的总宽度(两列之间的空间,不包括列与容器之间的空间),然后将剩余宽度分成总列数。这样,当我们调整浏览器窗口大小时,列将自动调整大小,其他所有内容将保持其尺寸。

在我们指定列间距宽度之后,浏览器可以根据列的可用空间确定每一列的宽度(如果我们指定了固定数量的列),或者确定要显示的列数(如果我们为每一列指定了宽度),如下面的屏幕截图所示。通常情况下,指定列宽和列数是没有意义的。

CSS 列

或者,我们可以简单地告诉浏览器我们希望每列有多宽,以及两列之间有多少间隙。在这种情况下,浏览器会做相反的事情。它会计算剩余的可用空间来呈现列,然后在给定我们指定的宽度约束的情况下,尽可能多地呈现列。

<style>
div {
  column-width: 200px;
  column-gap: 20px;
</style>

列规则

与围绕在盒子周围的边框的概念类似,如 border: 1px solid #333,CSS 列带有规则的概念。简单地说,列规则是在两列之间垂直绘制的单个边框。规则可以像边框一样进行样式设置,并且在两列之间正确渲染,利用列间隙提供的空间。如果列规则的可用空间大于列间隙提供的空间,间隙将被正确渲染,规则将被忽略。

<style>
div {
  column-count: 3;
  column-gap: 20px;
  column-rule-width: 1px;
  column-rule-style: dashed;
  column-rule-color: rgb(255, 10, 10);
</style>

同样,类似于边框属性,我们可以指定与列规则相关的每个属性,或者按照与边框相同的顺序简写定义(宽度、样式和颜色)。边框样式的有效值包括以下内容:

  • none: 无边框

  • dotted: 边框是一系列点

  • dashed: 边框是一系列短线段

  • solid: 边框是单一线段

  • double: 边框是两条实线。两条线和它们之间的空间之和等于'border-width'的值

  • groove: 边框看起来像是雕刻在画布上

  • ridge: 与'groove'相反:边框看起来像是从画布中出来的

注意

有关表格边框样式的更多信息,您可以访问www.w3.org/TR/CSS2/tables.html#table-border-styles

列断

有时,我们可能希望对内容在哪里断开成新的列有一些控制。例如,如果我们有几个文本块,每个文本块前面都有某种标题。如果列的最后一行是一个孤立的标题,用来介绍下一节,那看起来可能不太好。列断属性给了我们这种能力,我们可以在元素之前或之后指定列断。

通过指定列应该在何处断开成下一列,我们可以更好地控制每列的呈现和填充,如下截图所示:

列断

在 CSS 中用于控制分页的相同属性也用于控制列的断开。我们可以使用三个属性来控制列断,即break-beforebreak-afterbreak-inside。前两个属性相当直观——我们可以使用 break before 或 after 来指示特定元素之前或之后的行为,例如总是断开列、永不断开,或者在应该正常插入的地方插入列断。另一方面,break inside 指定多行文本内部的行为,而不仅仅是在其开始或结束处。

<style>
div {
  -webkit-column-count: 3;
  -webkit-column-gap: 20px;
  -webkit-column-rule: 1px solid #fff;
  padding: 20px;
  margin: 10px;
  background: #eee;
}

div p {
  margin: 0 0 10px;
 -webkit-column-break-inside: auto;
}

div h2 {
  margin: 0 0 10px;
  color: #55c;
  text-shadow: 0 1px 0 #fff;
 -webkit-column-break-before: always;
}
</style>

<div>
  <h2>Lorem Ipsum</h2>
  <p>Lorem ipsum dolor sit amet, consectetuer adipiscing elit, sed diam nonummy nibh euismod tincidunt ut laoreet dolore magna aliquam erat volutpat. Ut wisi enim ad minim veniam, quis nostrud exerci tation ullamcorper suscipit lobortis nisl ut aliquip ex ea commodo consequat. Duis autem vel eum iriure dolor in hendrerit in vulputate velit esse molestie consequat, vel illum dolore eu feugiat nulla facilisis at vero eros et accumsan et iusto odio dignissim qui blandit praesent luptatum zzril delenit augue duis dolore te feugait nulla facilisi.</p>

  <h2>Nam Liber Tempor</h2>
  <p>Nam liber tempor cum soluta nobis eleifend option congue nihil imperdiet doming id quod mazim placerat facer possim assum. Typi non habent claritatem insitam; est usus legentis in iis qui facit eorum claritatem. Investigationes demonstraverunt lectores legere me lius quod ii legunt saepius.</p>

  <h2>Claritas est etiam</h2>
  <p>Claritas est etiam processus dynamicus, qui sequitur mutationem consuetudium lectorum. Mirum est notare quam littera gothica, quam nunc putamus parum claram, anteposuerit litterarum formas humanitatis per seacula quarta decima et quinta decima. Eodem modo typi, qui nunc nobis videntur parum clari, fiant sollemnes in futurum.</p>
</div>

请注意,列断属性应用于h2标签,现在它成为控制每列断的元素。当然,如果我们在这个文本块中指定的列数比标题标签多,那么段落标签中的文本显然会分成新的列。这种行为也可以被控制,尽管在这种情况下,我们只是将column-break-inside属性设置为auto,明确表示我们希望每个段落标签的内容如果需要的话可以溢出到其他列中。

CSS 区域和排除

CSS 的两个新的与文本相关的特性是区域和排除。区域的行为与列有些相似,因为我们指定了特定文本块的呈现和流动方式。区域和列之间的主要区别在于,列被限制为等宽的隐含矩形,而区域指定了一个单独的内容源,并定义了该内容的流动位置。例如,我们可以告诉 CSS 将来自给定源的文本呈现到三个独立的div元素中,以及一个任意的 SVG 多边形。这些元素中的每一个都不需要以任何特定的方式相关联 - 一个可以是绝对定位的,一个可以被转换,等等。然后文本将从一个元素流向下一个元素,按照每个元素在 HTML 文件中定义的顺序。另一方面,排除则完全相反。它不是定义文本流入的区域,而是描述文本应该绕过的区域或形状。

这两个分开但又密切相关的 API 的整个原因是推动我们可以将 Web 应用程序的视觉设计推向何方。直到现在,实现这种效果的唯一方法是通过外部软件,希望有一个非常特定的插件,允许在浏览器内执行这样的软件或技术。现在浏览器已经变得更加成熟,我们可以直接从样式表中实现这些类似杂志的效果。

区域

区域的工作方式与列有些相似,但基本上是不同的。总的来说,区域所做的就是指定一个内容源,然后将 CSS 表达式分配为该内容的目的地。内容从指定为源的元素移动,并流入所有分配为目的地的元素。如果一个或多个元素由于内容不足而没有接收到任何内容,这些元素将表现得就像一个普通的元素一样。除了将元素标识为目的地的 CSS 属性之外,该元素与任何其他常规 HTML 元素没有任何不同。

<style>
h2, p {
  margin: 0 0 10px;
}

#src {
  flow-into: mydiv;
}

.container {
  flow-from: mydiv;

  border: 1px solid #c00;
  padding: 0.5em;
  margin: 0.5em;
}

.col1, .col2, .col3 {
  float: left;
  width: 50%;
}

#one {
  height: 250px;
}

#two, #three {
  height: 111px;
}

.col3 {
  clear: both;
  width: 100%;
}
</style>

<div id="src">
  <h2>Lorem Ipsum</h2>
  <p>Lorem ipsum dolor sit amet, consectetuer adipiscing elit, sed diam nonummy nibh euismod tincidunt ut laoreet dolore magna aliquam erat volutpat. Ut wisi enim ad minim veniam, quis nostrud exerci tation ullamcorper suscipit lobortis nisl ut aliquip ex ea commodo consequat. Duis autem vel eum iriure dolor in hendrerit in vulputate velit esse molestie consequat, vel illum dolore eu feugiat nulla facilisis at vero eros et accumsan et iusto odio dignissim qui blandit praesent luptatum zzril delenit augue duis dolore te feugait nulla facilisi.</p>

  <h2>Nam Liber Tempor</h2>
  <p>Nam liber tempor cum soluta nobis eleifend option congue nihil imperdiet doming id quod mazim placerat facer possim assum. Typi non habent claritatem insitam; est usus legentis in iis qui facit eorum claritatem. Investigationes demonstraverunt lectores legere me lius quod ii legunt saepius.</p>

  <h2>Claritas est etiam</h2>
  <p>Claritas est etiam processus dynamicus, qui sequitur mutationem consuetudium lectorum. Mirum est notare quam littera gothica, quam nunc putamus parum claram, anteposuerit litterarum formas humanitatis per seacula quarta decima et quinta decima. Eodem modo typi, qui nunc nobis videntur parum clari, fiant sollemnes in futurum.</p>
</div>

<div class="col1">
  <div class="container" id="one"></div>
</div>
<div class="col2">
  <div class="container" id="two"></div>
  <div class="container" id="three"></div>
</div>
<div class="col3">
  <div class="container" id="four"></div>
</div>

在这里,我们将具有id属性为src的元素的内容分配为内容提供者,可以这么说。这是通过分配新的 CSS 属性flow-into来完成的,该属性分配了一个字符串,我们可以用它来标识这个特定的区域内容源。这意味着该元素的内容不会在 DOM 中呈现,而是会分布在所有具有flow-from CSS 属性的元素中,其值与具有flow-into属性的元素使用的关键字匹配。

#src {
  flow-into: description-text;
}

div.description {
  flow-from: description-text;
}

一旦定义了区域源,并创建了区域链,浏览器就会负责将内容分发到所有区域中。每个区域都可以有独特的样式,也可以是一个独特的元素。例如,可以定义一个区域源并创建两个目标。一个目标可以是标准的div元素,另一个可以是 SVG 形状。CSS 区域还可以与排除相结合,我们将在下一节讨论。

如下截图所示,四个元素被样式化并浮动,同时一个区域源负责填充这些区域。在区域调整大小的情况下,由于浏览器窗口本身被调整大小,用户代理会负责刷新内容,流入新调整大小的区域。

区域

排除

排除的工作方式与我们通常使文本围绕图像或任何其他内联元素流动的方式非常相似。主要区别在于,我们可以进一步指定一些 CSS 细节,告诉文本如何流动。

<style>
img {
  width: 300px;
  height: 60px;
  display: inline-block;
  float: left;
}
</style>

<div>
  <img src="img/lipsum-logo.png" />
  <h2>Lorem Ipsum</h2>
  <p>Lorem ipsum dolor sit amet, consectetuer adipiscing elit, sed diam nonummy nibh euismod tincidunt ut laoreet dolore magna aliquam erat volutpat. Ut wisi enim ad minim veniam, quis nostrud exerci tation ullamcorper suscipit lobortis nisl ut aliquip ex ea commodo consequat. Duis autem vel eum iriure dolor in hendrerit in vulputate velit esse molestie consequat, vel illum dolore eu feugiat nulla facilisis at vero eros et accumsan et iusto odio dignissim qui blandit praesent luptatum zzril delenit augue duis dolore te feugait nulla facilisi.</p>
</div>

这个琐碎的片段只是告诉div元素内的其余内容围绕图像的右侧流动。即使我们在那个图像的位置放置了一个 SVG 对象,而这个对象是一个指向右侧的三角形形状的多边形,文本也会围绕该对象进行换行,将其视为矩形。

然而,通过 CSS 排除的魔力,我们可以向图像标签或 SVG 对象添加属性,以改变其外部形状的解释方式。默认情况下,由于任何 HTML 元素都有 x 和 y 位置,以及widthheight属性,每个元素都被视为一个矩形。使用形状属性会改变这一点。

<style>
h2, p {
  margin: 0 0 10px;
}

svg {
  float: left;
  width: 300px;
  height: 400px;
 shape-outside: polygon(0 0, 100% 50%, 0 100%);
}

svg polygon {
  fill: #c33;
}
</style>

<div>
  <svg >
<polygon points="0, 0, 300, 200, 0, 400"></polygon></svg>

  <h2>Lorem Ipsum</h2>
  <p>Lorem ipsum dolor sit amet, consectetuer adipiscing elit, sed diam nonummy nibh euismod tincidunt ut laoreet dolore magna aliquam erat volutpat. Ut wisi enim ad minim veniam, quis nostrud exerci tation ullamcorper suscipit lobortis nisl ut aliquip ex ea commodo consequat. Duis autem vel eum iriure dolor in hendrerit in vulputate velit esse molestie consequat, vel illum dolore eu feugiat nulla facilisis at vero eros et accumsan et iusto odio dignissim qui blandit praesent luptatum zzril delenit augue duis dolore te feugait nulla facilisi.</p>
</div>

关于 CSS 排除的一个棘手之处是它只是定义了文本流动的形状或路径,而不一定是要呈现的形状或路径。换句话说,前面代码示例中突出显示的两行代码是完全独立的。这两个多边形定义之所以如此相似,只是为了视觉效果。如果我们在文本块中使用了图像、div或任何其他 HTML 元素,CSS 的shape-outside属性仍然会导致文本以相同的方式围绕该元素流动,无论该元素具有什么物理形状。仅仅添加 CSS 的shape属性到一个元素并不会改变它自己的视觉属性。

运行前面的代码示例会产生类似以下截图的输出。再次记住,文本遵循的路径与显示的元素形状之间的关系,即不允许文本进入的形状,纯粹是巧合和有意为之。如果我们不是一个 SVG 多边形,而是一个图像元素,文本仍然会遵循那个箭头形状,但是矩形图像会浮在遵循与图像边界相交路径的任何文本上方。严格来说,排除只涉及文本在给定文本块内的流动方式。文本沿着路径的任何东西是否被呈现,取决于设计师,这是排除之外的一个单独问题,如下图所示:

排除

如果最终目标只是简单地定义文本要遵循的路径,就像前面的例子一样,我们不需要使用 SVG 或任何特定的 HTML 元素。只要有一个元素存在,并为该元素分配基本的浮动属性,排除就足够工作了。记住,排除的唯一重要部分是形状属性。

<style>
.shape {
  display: inline-block;
  float: left;
  width: 300px;
  height: 400px;
  shape-outside: polygon(0 0, 100% 50%, 0 100%);
}
</style>

<div>
  <span class="shape"> </span>

  <h2>Lorem Ipsum</h2>
  <p>Lorem ipsum dolor sit amet, consectetuer adipiscing elit, sed diam nonummy nibh euismod tincidunt ut laoreet dolore magna aliquam erat volutpat. Ut wisi enim ad minim veniam, quis nostrud exerci tation ullamcorper suscipit lobortis nisl ut aliquip ex ea commodo consequat. Duis autem vel eum iriure dolor in hendrerit in vulputate velit esse molestie consequat, vel illum dolore eu feugiat nulla facilisis at vero eros et accumsan et iusto odio dignissim qui blandit praesent luptatum zzril delenit augue duis dolore te feugait nulla facilisi.</p>
</div>

或者,我们可以使用shape-outside的伴随属性,即shape-inside。直观地,这个属性定义了与其对应属性相反的作用。shape-outside属性告诉浏览器文本需要围绕(外部)的地方,而shape-inside属性告诉浏览器文本必须留在其中的区域。两个属性的所有属性值都是相同的。两个属性之间唯一的区别在于,在shape-outside中,文本被放置在占位元素的外部。而在shape-inside中,任何要在定义的形状内部引导的文本都被放置为形状元素的后代节点。

<style>
.shape {
  display: block;
  width: 300px;
  height: 400px;
  shape-inside: polygon(0 0, 100% 50%, 0 100%);
}
</style>

<div>
  <h2>Lorem Ipsum</h2>
  <span class="shape">
    <p>Lorem ipsum dolor sit amet, consectetuer adipiscing elit, sed diam nonummy nibh euismod tincidunt ut laoreet dolore magna aliquam erat volutpat. Ut wisi enim ad minim veniam, quis nostrud exerci tation ullamcorper suscipit lobortis nisl ut aliquip ex ea commodo consequat. Duis autem vel eum iriure dolor in hendrerit in vulputate velit esse molestie consequat, vel illum dolore eu feugiat nulla facilisis at vero eros et accumsan et iusto odio dignissim qui blandit praesent luptatum zzril delenit augue duis dolore te feugait nulla facilisi.</p>
  </span>
</div>

shape-outside相比,shape-inside属性将其自身的内容包含在内部,而shape-outside则只是一个其兄弟元素必须围绕的块,如下图所示:

排除

最后,为了预料到这两个属性可能引发的问题,是的,我们很可能结合定义shape-outside属性的排除和定义shape-inside属性的排除。请注意,shape-inside排除只是一个块级元素,就像任何其他元素一样。在没有任何 CSS 指令的 HTML 文件的源代码中,shape-inside排除将无法与普通文本块区分开。因此,我们很可能将shape-inside排除的元素用作shape-outside排除。同一个元素可以具有两个 CSS 属性,因为它们的效果是互斥的。元素内的任何文本将与shape-inside排除声明绑定,而元素周围的任何内容将与shape-outside属性的效果相关联。

<style>
h2, p {
  margin: 0 0 10px;
}

#wrap {
  width: 50%;
  height: 100%;
  float: left;

  shape-inside: polygon(0 0, 100% 50%, 0 100%);
  shape-outside: polygon(0 0, 100% 50%, 0 100%);
}
</style>

<div>
  <h2>Lorem Ipsum</h2>

  <div id="wrap">
    <p>Lorem ipsum dolor sit amet, consectetuer adipiscing elit, sed diam nonummy nibh euismod tincidunt ut laoreet dolore magna aliquam erat volutpat. Ut wisi enim ad minim veniam, quis nostrud exerci tation ullamcorper suscipit lobortis nisl ut aliquip ex ea commodo consequat. Duis autem vel eum iriure dolor in hendrerit in vulputate velit esse molestie consequat, vel illum dolore eu feugiat nulla facilisis at vero eros et accumsan et iusto odio dignissim qui blandit praesent luptatum zzril delenit augue duis dolore te feugait nulla facilisi.</p>
  </div>

  <h2>Nam Liber Tempor</h2>
  <p>Nam liber tempor cum soluta nobis eleifend option congue nihil imperdiet doming id quod mazim placerat facer possim assum. Typi non habent claritatem insitam; est usus legentis in iis qui facit eorum claritatem. Investigationes demonstraverunt lectores legere me lius quod ii legunt saepius.</p>

  <h2>Claritas est etiam</h2>
  <p>Claritas est etiam processus dynamicus, qui sequitur mutationem consuetudium lectorum. Mirum est notare quam littera gothica, quam nunc putamus parum claram, anteposuerit litterarum formas humanitatis per seacula quarta decima et quinta decima. Eodem modo typi, qui nunc nobis videntur parum clari, fiant sollemnes in futurum.</p>
</div>

定义形状

方便的是,形状属性的可能值与基本 SVG 形状相同。四种可用的形状是矩形、椭圆、圆和多边形。点值可以表示为长度值或百分比值。每种形状的语法非常一致,形式为<shape>([value]{?})。例如:

  • rectangle(x, y, width, height): 定义一个尖锐的矩形,形状的左上角位于点 x,y 处

  • rectangle(x, y, width, height, round-x, round-y): 定义一个矩形,并可以选择圆角

  • ellipse(x, y, radius-x, radius-y): 定义一个以点 x,y 为中心的椭圆

  • circle(x, y, radius): 定义一个给定半径的圆,以点 x,y 为中心

  • polygon(p1-x p1-y, p2-x p2-y, (…)): 给定三个或更多对 x,y 位置,定义一个多边形

总结

本章介绍了一些更复杂和尖端的 HTML5 API。主要亮点是新的 3D 渲染和图形编程 API—WebGL。我们还研究了 HTML5 的新视频播放能力,以及在浏览器上本地播放视频的每一帧的操作能力。最后,我们涉足了最新和最伟大的 CSS 改进和增加。这涉及到 CSS 着色器、列和区域以及排除等 API。

在下一章中,我们将通过深入研究使用 HTML5 进行移动网络开发来结束我们对 HTML5 游戏开发这个迷人世界的探索。我们将学习移动游戏开发与传统桌面应用程序开发的不同之处。我们还将学习两个 HTML5 API 来帮助我们。我们将构建一个完全适合移动设备的 2D 太空射击游戏来说明这些概念。

第七章:HTML5 和移动游戏开发

在本章中,我们将看一下不仅要为多个浏览器和平台开发,还要考虑多个设备的应用程序开发的非常重要的概念。过去,网页开发主要是关于支持多个浏览器。然后,它变成了利用最新技术并创建类似本机台式应用程序的应用程序。今天,我们不仅要记住那些允许我们使我们的应用程序尽可能多地面向人们的概念,还要记住标准台式电脑不是唯一访问我们的网络应用程序的设备。

在创建基于 HTML5 的游戏或应用程序时,我们必须能够设想我们的用户通过台式电脑、上网本、支持 HTML5 的视频游戏系统、智能手机、平板电脑、电视机,以及很快他们的智能手表、智能眼镜,可能还有智能烤面包机、鱼缸等等进行连接。不用说,除了它们的大小、形状和建议零售价格之外,这些设备之间有相当多的差异。

在我们开发本书中的最后一个游戏时,让我们来看看与 HTML5 移动游戏开发相关的一些最重要的概念。我们首先将看一下台式机和移动设备(特别是智能手机和平板电脑)之间的基本差异。本章讨论的两个与 HTML5 相关的 API 是媒体查询(它允许我们根据查看应用程序的设备的当前状态来调整 CSS 属性)和 JavaScript 触摸事件,以及它们对应的事件处理程序。最后,我们将结束本章,讨论如何开发和优化一款游戏,使其适用于台式浏览器和移动设备,只需一个代码库。

台式机与移动设备

乍一看,粗心的网页开发者可能会误以为,因为如今许多智能手机实际上比大多数五年级学生聪明得多,他们的花哨网页应用和游戏在移动设备上会运行得很好。不要被欺骗!虽然你的智能手机确实比你五年级时聪明(事实上,今天大多数不那么聪明的手机的计算能力比 NASA 在 1969 年用来将尼尔·阿姆斯特朗、迈克尔·柯林斯和爱德温·艾尔德林送上月球的硬件还要强大),但在许多情况下,它并不是大多数人上网和在线游戏的平均台式电脑的对手。

台式浏览器和移动浏览器之间存在一些重大差异。因此,我们需要考虑这些差异来开发我们的应用程序。毕竟,无论只有多少人仅仅使用移动设备或台式浏览器上网,我们没有理由将我们的覆盖范围限制在其中的任何一个群体。

移动友好开发的一个关键方面是,我们必须牢记的是,这并不总是适用于游戏,即一个人的移动策略通常在硬件限制和差异之外有固有的不同原因。例如,在移动设备上的使用情况与日记应用程序的台式机版本上的使用情况有很大不同。由于在小设备上打字更困难和慢,用户不太可能像在台式机上那样在手机应用程序上花几个小时输入日记条目,因为台式机上有全尺寸键盘可用。因此,整个移动体验必须考虑到不同的用户角色。

最后,由于移动设备的人机交互方式不同,应用程序的呈现必须考虑到这些特点。例如,在浏览网站时,大多数人会慷慨地在屏幕上移动鼠标指针,试图发现可以点击和可以交互的内容。每当悬停在一个动作元素上时,鼠标指针会发生变化(通常从箭头图标变为指向手指),提示用户可以通过点击该项来启动操作。然而,在移动设备上,这样的操作并不存在。因此,设计必须考虑到这一点,以免用户感到困惑、害怕,甚至犹豫使用您的游戏或应用程序。

本节的其余部分将介绍移动策略的一些主要实施考虑因素以及一些当前的最佳实践。这两个部分同样适用于移动游戏和移动应用程序(非游戏应用程序)。尽管不是很全面,但这些考虑因素和最佳实践清单应该足以让您朝着正确的方向思考,并指向一个成功的移动运营。

主要实施考虑因素

也许最明显的区别是移动设备与台式电脑的区别在于移动设备始终可以访问。一旦用户离开家,台式机可能要停放很多小时。另一方面,移动设备可以离用户的口袋很远。因此,用户随时可以拿出移动设备开始玩游戏或使用应用程序。

非常重要的是,始终可以访问的使用情况是,用户将在非常短的时间内玩游戏——在等待电梯时,在商店排队时,或者在第一次约会时试图消磨尴尬时刻。因此,您的游戏必须适应这些短暂、非常短暂的游戏时间,并且必须以一种方式来做到,玩家在每次 30 到 120 秒的游戏时间内仍然可以取得进展。

与移动设备的物理特性更相关的您的移动游戏的一些重要考虑因素包括其有限的屏幕尺寸,轻松改变屏幕方向的可能性,有限的计算能力(相对于台式电脑),有限的电池电量和浏览器差异(是的,在移动设备上这些问题还没有消失)。

屏幕尺寸和方向

台式电脑和移动设备之间最明显的区别是大小。尽管大多数现代设备可以模拟大屏幕分辨率,但物理屏幕尺寸仍然相当有限。而且,用户随时可以将设备旋转,期望应用程序能够做出响应。

在网页设计中,解决较小和不同屏幕尺寸问题的标准解决方案是一种称为响应式设计的技术。如今用于实现响应式设计的主要工具是新的 CSS 媒体查询 API(我们稍后在本章中讨论)。简而言之,这个 API 允许我们根据屏幕尺寸、比例和方向等因素加载不同的样式表或一组 CSS 规则。

例如,我们可以为屏幕宽度大于高度的情况定义一个 CSS 样式表(根据定义,这将使其成为横向视口),并设计第二个样式表,用于屏幕高度大于宽度的情况(使其成为纵向视图)。媒体查询将允许我们根据当前屏幕状态自动动态加载这两个样式表中的一个。也就是说,如果用户手动调整浏览器窗口大小,样式表将实时触发。

至于屏幕方向的变化,基于 DOM 的 Web 应用程序更容易,因为浏览器本身能够旋转一切,使其面向正确的方向。在基于画布的应用程序(无论是 2D 画布还是 WebGL)中,屏幕旋转也会旋转浏览器的坐标系统,但不会旋转画布的坐标系统。因此,如果你希望特定游戏在横向视图上水平播放,在纵向视图上垂直播放,你需要手动管理画布的坐标系统的旋转。

然而,最重要的是,当设计移动友好或游戏的移动版本时,屏幕尺寸将会显著较小。这意味着较少的元素可以塞进特定的视图中。需要呈现的信息较少,因为一次传达的信息较少。

解决这个问题的两种常见方法是开发一个应用程序,具有两个单独的呈现层(或两个单独的视图或模板),只为请求它的设备提供适当的视图(当模板访问应用程序时提供移动模板,当桌面浏览器请求应用程序时提供完整模板),以及开发一个单一应用程序,具有单一模板,但使该模板具有响应性,如前所述。较不受欢迎的选择是完全开发两个单独的应用程序,其中每个应用程序专注于特定的范例(移动和桌面)。

通常,移动模板是完整模板的缩小版本,其中并非一定显示所有元素,如下图所示:

屏幕尺寸和方向

计算能力

如前所述,今天的移动设备变得非常强大。更令人惊讶的是,它们的趋势是继续改进,将更多的计算能力塞进更小的物理空间。然而,即使是今天最好的智能手机也无法与平均性能的游戏 PC 相媲美。对于大多数 Web 应用程序来说,这种差异通常可以忽略,但对于更复杂的游戏来说绝对不能忽略。

移动设备在这方面与桌面计算机相比具有一个特定的劣势,即低端移动设备和普通移动设备之间的计算能力差距相当大,而低端桌面计算机和普通桌面计算机之间的差距则较小。因此,在考虑游戏用户的能力时,请记住大多数玩家将拥有非常有限的设备。

电池寿命

信不信由你,智能手机最常用的功能是,嗯,打电话和接电话。由于这样一个重要的功能是移动设备的主要用途,如果因为一个游戏耗尽了设备的电量而无法执行这样的任务,那将是相当悲剧的。因此,移动应用程序(包括游戏和移动 Web 应用程序)的一个非常重要的特征是它如何节约电力。

应用程序需要处理的数据越多,它所需的电力就越多。如果游戏大部分时间都在执行复杂的计算,它很可能会比人们希望的更快地耗尽设备的电量。你的移动游戏必须尽可能地节约电力,以便玩家能够尽可能地享受游戏,同时又能节省足够的电量,以便设备能够履行其最基本的任务——打电话。

浏览器差异

如果你认为一旦开始将网页开发工作重点放在移动设备上,所有浏览器兼容性问题都会消失,那就大错特错了。不仅各种移动浏览器之间存在差异(就像它们的桌面对应物上一样),而且并非每个 HTML5 API 和功能在给定桌面浏览器上可用时也在同一浏览器的移动版本上可用。

一些功能实际上在移动浏览器上是可用的,但性能仍然远远不够好。一个简单的例子是我们将在本章开发的游戏中看到的 CSS 动画。根据动画的创意程度,移动浏览器可能很难处理动画,而在桌面浏览器上,显示动画所需的计算和渲染能力是相当微不足道的。

总之,当定义移动应用程序的具体实现方式时,要记住一些 API 和功能必须被抛弃,否则应用程序的性能将无法接受。

最佳实践

现在,您已经准备好将刚讨论的理论付诸实践,让我们讨论一下如何做到这一点的一些最佳实践。虽然可以专门撰写一本完整的书来讨论这个主题,但以下选择涵盖了我认为对移动 Web 开发最重要的五个最佳实践。同样,这些概念也适用于通用的 Web 应用程序以及游戏。

优雅降级和渐进增强

直到几年前,关于向 Web 应用程序添加新的尖端功能的讨论都是围绕着优雅降级的话题展开的。最近,这种思想已经更多地转向了另一端,即建议构建多平台和多设备应用程序的方式是采用渐进增强。

在这种情况下,优雅降级是指首先构建完整的应用程序(最新和最先进的桌面浏览器),然后将其缩小,使其在性能较差的浏览器以及移动设备上运行。同样,渐进增强是指首先构建应用程序的移动版本,然后使其在桌面浏览器上运行。无论采取哪种方法,最终结果都是相同的——一个在两个平台上都能很好运行的应用程序。

虽然关于这两种方法都可以说很多,但在实践中,没有一种特别比另一种更好。唯一的区别就是你从哪里开始。希望应用程序经过良好规划,以便在开始构建之前已经设想了每个版本,这样无论从哪一端开始,应用程序都会达到同样的地方。然而,通常情况下,采取的理想方法很大程度上取决于所涉及的项目类型。

在大多数游戏的情况下,正如在本章中为之构建的 2D 太空射击游戏中所发生的那样,首先开发桌面版本,然后删除移动设备不支持或不适用的功能会更容易一些。

例如,我们游戏的主要渲染循环是基于新的requestAnimationFrame函数。现在,并非所有浏览器都暴露出这个函数,而其他浏览器则通过不同的接口暴露出来。优雅地降级这个功能意味着在可用的地方使用该函数,在不可用的地方使用备用方案。

window.requestAnimationFrame = (function() {

  // Check if the unprefixed version is present
  if (window.requestAnimationFrame) {
    return window.requestAnimationFrame;
  }

  // Check for WebKit based implementation
  if (window.webkitRequestAnimationFrame) {
    return window.webkitRequestAnimationFrame;
  }

  // Check for Mozilla based implementation
  if (window.mozRequestAnimationFrame) {
    return window.mozRequestAnimationFrame;
  }

  // Check for Microsoft based implementation
  if (window.msRequestAnimationFrame) {
    return window.msRequestAnimationFrame;
  }

  // Check for Opera based implementation
  if (window.oRequestAnimationFrame) {
    return window.oRequestAnimationFrame;
  }

  // If nothing else, simulate the functionality with
  // something similar - a custom timer
  return function(callback) {
    var fps = 1000 / 60;
    var timestamp = Date.now();

    setTimeout(function(){
      callback(timestamp);
    }, fps);
  };
})();

另一方面,渐进增强的方法将首先从最低公共分母开始,不向任何人承诺任何特殊的功能,但在客户端技术允许的情况下添加这些功能。

例如,假设我们想要广泛使用 CSS 动画。具体来说,我们希望使用一个非常大的图像作为背景,然后使用关键帧动画不断地改变其位置和大小。在移动设备上,这可能会消耗大量的处理能力,导致严重的性能问题。因此,在这种情况下,我们决定不使用这些动画。

在这种情况下,逐步增强应用程序意味着我们首先使用静态图像作为背景。默认情况下不加载定义动画并将其应用于应用程序的 CSS 文件。

// ---------------------------------
// Default Stylesheet: style.css
// ---------------------------------
.background-img {
  background: url("/img/bg.png");
}

// ---------------------------------
// HTML Template: template.html
// ---------------------------------
<div id="container" class="background-img"></div>

一旦最低功能得到满足,我们可以测试环境,以确定是否应该加载 CSS 文件,注入更强大的功能。

// ---------------------------------
// Enhanced Stylesheet: enhanced.css
// ---------------------------------

@-webkit-keyframes animagedBg {
  from {
    background-position: 0 0;
  }
  to {
    background-position: 1300% 600%;
  }
}

.anim-background {
  -webkit-animation: animagedBg;
  -webkit-animation-duration: 500s;
  -webkit-animation-timing-function: linear;
  -webkit-animation-iteration-count: infinite;
}

// ---------------------------------
// JavaScript Detection: main.js
// ---------------------------------

// Returns true if the browser is mobile
function isMobile(userAgent) {
  var mobileAgents = [
    "ANDROID",
    "BLACKBERRY",
    "IPHONE",
    "IPAD",
    "IPHONE",
    "OPERA MINI",
    "IEMOBILE"
  ];

  return mobileAgents.indexOf(userAgent.toUpperCase()) >= 0;
}

var mobile = isMobile(navigator.userAgent);

// If the browser is not mobile, add enhanced CSS functionality
if (!mobile) {
  var container = document.querySelector("#container");
  container.classList.add("anim-background");
}

现在,首先构建具有动画背景的游戏,然后在检测到特定设备时将其删除并不特别困难。采取这种方法也不一定会增加任何附加值。

总之,无论哪种方法更符合你特定的应用程序和设计目标,关键原则是要考虑用户体验。永远不要向你的游戏或应用程序的用户呈现无法使用的产品或功能。要么将功能降级为有用的东西,要么在确定用户的环境可以正确使用该功能时将其升级。

手指友好设计

另一个非常重要的设计考虑因素是各种元素的大小。尽管确保文本足够大很重要,但这相对容易做到。而且,文本大小相对容易动态调整,因此用户可以调整直到他们对应用程序中文本的确切大小感到舒适为止。然而,来自点按式世界的我们可能没有意识到不同的人有着截然不同的手指大小。对于手指较大的用户来说,没有什么比因为目标太小而错过点击目标更令人沮丧的了。几年前第一批触摸敏感的移动应用程序推出时,用户可能会考虑随身携带一个手指磨刀石,以便触及那些微小的触摸目标。

在下面的插图中,左侧的屏幕截图是用户无法触摸的项目的示例,以及太靠近在一起的项目。右侧的屏幕截图显示了更好的解决方案,使界面更容易操作,用户更难出错。

手指友好设计

如果你查看各种移动平台制造商发布的开发者指南,你会发现建议的小部件的最小像素尺寸以及两个或更多可触摸元素之间的最小距离。虽然没有任何特定小部件的完美尺寸,但我们必须始终以这个问题为考虑设计。

节省电池寿命

无论你的移动游戏有多么惊人,一旦用户意识到游戏对设备电池的需求过高,游戏就会立即受到指责。如果用户电量不足,但他们知道你的游戏对电量消耗非常友好,你的应用程序肯定会得到额外的喜爱。

现在,移动应用程序中能源效率的主要来源是过度、不必要的硬件使用。例如,如果一个应用程序每秒多次获取 GPS,它可能会很快耗尽电池。然而,在 HTML5 应用程序中,直接硬件访问并不那么容易获得。

在 Web 应用程序的情况下,节省能源的主要方法是尽可能多地进行缓存。这里缓存的主要目的是避免额外的网络活动。额外的处理不仅需要更多的能量,而且还会迫使用户花费通常有限的带宽。作为额外的奖励,缓存还会使你的应用程序表现得更快。

离线计划

今天有很多移动游戏玩家在他们的移动设备上有限的互联网访问。任何额外使用宝贵的数据计划可能会对用户造成昂贵的损失。因此,许多用户会主动禁用设备上的互联网访问。因此,你不应该假设你的游戏会持续访问互联网。

同样,这种用例的最佳解决方案是利用缓存。首先,通过减少服务器往返次数,即使每个批次更大,也会节省用户试图节省的昂贵带宽。其次,如果 HTTP 请求被保存到应用程序没有做任何有意义的事情(比如显示游戏相关消息或等待用户输入信息)的时刻,应用程序看起来会更快速和更具响应性。

提供桌面版本

有许多原因可能会导致用户想要查看移动应用的非移动版本。也许是因为缺少功能,也许是因为用户有一个足够好的移动设备可以很好地处理完整版本,或者可能是用户只是出于好奇想要从移动设备访问完整版本。无论原因是什么,添加一个指向应用程序完整版本的链接可能是你可以做的最简单的事情,那么为什么不为那些可能真正使用它的少数用户做呢!

理解媒体查询

媒体查询自 HTML4 和 CSS2 以来就存在。最初,CSS 媒体属性用于根据加载页面的媒体类型(如屏幕、电视、投影或手持设备)指定要加载的不同样式表。在 HTML5 中,媒体查询还允许检查文档查看用户代理的其他属性,如视口宽度、高度、分辨率等。

媒体查询由两部分组成,即媒体类型声明和一个或多个表达式,这些表达式评估为真或假。只要媒体查询表达式中的任何一个表达式评估为真,嵌套在媒体查询声明中的任何 CSS 规则都会被应用。或者,如果链接标签的媒体属性包含一个真值媒体查询表达式,那么被引用的样式表中的每个 CSS 规则都会应用到指定的媒体类型。

// ---------------------------------
// Media queries on the HTML file
// ---------------------------------
<link rel="stylesheet"
  media="screen and (min-device-width: 960px)"
  href="default-style.css" />

// ---------------------------------
// Media queries within a CSS file
// ---------------------------------
@media screen and (min-device-width: 960px) {
  html, body {
    margin: 0;
    padding: 0;
  }

  /* ... */
}

注意

根据规范,浏览器预期会继续评估媒体查询中的表达式,并在浏览器环境发生变化时更新 CSS 规则,但不是必须的。换句话说,如果页面中指定了两个媒体查询——一个用于宽度小于某个值的窗口,另一个用于宽度大于该值的窗口——如果用户手动调整浏览器宽度而不刷新页面,浏览器不需要加载相应的样式表。然而,由于这不是一个非常常见的用例,这对网页设计师来说应该不是太大的问题。此外,大多数现代浏览器实际上会实时重新评估媒体查询表达式。

可以从媒体查询中指定和定位的媒体类型有九种。或者,关键字all可以用来表示所有媒体类型。CSS 媒体类型中允许的媒体类型如下:

  • 盲文: 用于盲文触觉反馈设备

  • 手持设备: 用于手持设备

  • 打印: 用于打印机

  • projection: 用于投影仪

  • 屏幕: 用于计算机屏幕

  • tty: 用于使用固定间距字符网格的媒体

  • 电视: 用于电视

  • 浮雕: 用于分页盲文打印机

  • 语音: 用于语音合成器

可以用于连接两个或多个表达式的两个运算符是逻辑ANDOR运算符,分别由and关键字和逗号字符表示。此外,逻辑NOT运算符可以用于否定一个表达式。这个运算符由not关键字表示。

// Applies media queries to:
// viewport width between [200px, 450px] OR wider than orequals to 1200px
@media
  all and (min-width: 200px) and (max-width: 450px),
  (min-width: 1200px) {
  /* ... */
}

// Applies media queries to:
// non-printer viewport width between [200px, 450px]
// OR any media type wider than or equal to 1200px
@media
  not print and (min-width: 200px) and (max-width: 450px),
  all (min-width: 1200px) {
  /* ... */
}

媒体查询表达式中可以检查的 13 个值是宽度、高度、设备宽度、设备高度、方向、宽高比、设备宽高比、颜色、颜色索引、单色、分辨率、扫描和网格。只要表达式有意义,这些值的任意组合都可以在表达式中使用。这时一些基本的布尔逻辑以及一点常识就会派上用场。

最后,可以与每个表达式一起使用的单位与 CSS 单位相同。我们可以使用固定单位(如像素、厘米或英寸)或相对单位(如百分比或 em)。作为复习,以下列表描述了 CSS 中可能使用的单位,因此也适用于媒体查询表达式:

  • %: 百分比

  • in: 英寸

  • cm: 厘米

  • mm: 毫米

  • em: em(1 em = 当前字体大小的高度)

  • ex: ex(1 ex = 字体的高度)

  • pt: 点(1 点 = 1/72 英寸)

  • pc: 琴(1 琴 = 12 点)

  • px: CSS 像素

本节的其余部分将包含对媒体查询中使用的每个有效值的更详细解释,以及每个值的示例。

宽度

当针对连续媒体类型进行查询时,该值指的是设备的总视口(可见窗口区域)宽度,包括任何渲染的滚动条。当针对分页媒体类型进行查询时,总宽度是针对输出页面的宽度。

可选地,前缀minmax可以与width关键字一起使用,允许我们指定范围,而不是离散值。

@media all and (min-width: 250px) {
  body {
    background: red;
  }
}

@media all and (max-width: 249px) {
  body {
    background: blue;
  }
}

前面的片段指定了适用于所有媒体类型的两个媒体查询。当输出宽度小于 250(不包括 250)时,背景颜色设置为蓝色。否则,背景颜色变为红色。与大多数现代浏览器一样,我们可以手动调整浏览器窗口大小,新的 CSS 规则将自动应用。否则,属性将在浏览器首次渲染页面时进行测试和设置。

在下图中,左侧窗口的宽度不足以触发前面片段中的第一个媒体查询,导致第二个片段的评估结果为 true。通过简单地调整浏览器窗口大小(可以通过最大化浏览器或可能通过将移动设备转为横向模式来完成),第二个媒体查询将被作废,第一个将被启用。

![width](https://gitee.com/OpenDocCN/freelearn-html-css-zh/raw/master/docs/lrn-h5-crt-fun-gm/img/6029OT_08_03.jpg)

请注意,前面媒体查询评估中使用的单位是 CSS 像素。在为像素不太容易应用的媒体类型设置特殊规则时,我们可以使用其他单位,如in(英寸)或cm(厘米),如下例所示:

@media print and (min-width: 7.0in) {
  h1 {
    color: red;
  }
}

@media print and (max-width: 6.5in) {
  h1 {
    color: blue;
  }
}

前面代码片段的输出可以在以下截图中看到。请注意,问题中的最小宽度和最大宽度不一定是打印所在页面的宽度,而是纸张宽度减去打印机设置的任何边距所形成的盒子宽度。在这种情况下,宽度为 8.5 英寸的纸张,减去左右边距各一英寸,形成了 6.5 英寸的有效宽度。同一页面的横向版本宽度为 11 英寸,产生了 9 英寸的盒子宽度,足够宽以触发第一个媒体查询。

以下截图中的顶部打印预览表示以纵向模式打印的页面。也就是说,其宽度(在本例中)不超过 6.5 英寸。底部的预览宽度超过 7.0 英寸,导致启用了不同的媒体查询,从而改变了要打印页面的样式设置。

width

高度

width属性类似,height属性指的是连续媒体类型的视口高度,包括渲染的滚动条。对于分页媒体类型,这指的是输出媒体可用的有效页面框。不用说,高度属性的值不能是负单位。与前面描述的width属性一样,我们还可以在此属性上添加修饰符前缀minmax,以便指定值的范围,而不是单位完美的单个值。

@media all and (min-height: 500px) {
  article {
    width: 100%;
    float: none;
  }
}

@media all and (max-height: 499px) {
  article {
    width: 33%;
    float: left;
  }
}

设备宽度

width属性类似,设备宽度指的是整个物理窗口或页面,而不管当前浏览器窗口的宽度或分页媒体的可用输出宽度如何。

@media all and (min-device-width: 1601px) {
  h1 {
    color: red;
  }
}

@media all and (max-device-width: 1599px) {
  h1 {
    color: green;
  }
}

@media all and (device-width: 1600px) {
  h1 {
    color: blue;
  }
}

在前面的代码示例中,如果屏幕宽度(而不是浏览器宽度)恰好为 1600px,最后一个媒体查询将激活,而不考虑任何浏览器调整大小。对于页面也是一样——如果整个页面的宽度计算恰好为 1600px,相应的媒体查询将匹配。如果大于或小于该值,将使用其他两个媒体查询中的一个。同样,关键字minmax是我们可以与此属性结合使用的有效修饰符。

关于何时选择设备宽度或宽度以及反之的问题的答案很简单:每当您的应用程序设计需要时。在大多数情况下,最终结果是相同的。唯一的情况是当宽度比设备宽度更合适的时候,那就是当用户可能使用自定义大小的浏览器窗口(而不是最大化),并且设计意图重新流动并自动调整到浏览器的当前宽度时。另一方面,如果设计意味着在特定监视器宽度(或一系列宽度)上保持不变,而不考虑当前浏览器状态,则设备宽度可能是更优雅和高效的方式。

设备高度

最后,查询显示器矩形边的最后一个可能性,设备高度与设备宽度完全相同(除了测量的尺寸)。虽然到目前为止描述的其他视口查询也可以实现相同的结果,但在到目前为止描述的四个查询中,设备高度可能是理想的选择(与设备宽度一起)来识别移动设备的方向(纵向或横向)。

方向

由于媒体查询不允许比较两个属性(例如,如果宽度大于或等于高度),方向允许我们确定媒体类型旋转的方式。如果 CSS 媒体查询引擎中包含比较运算符,我们可以轻松确定页面是否处于横向模式。为此,我们只需确定宽度是否大于高度。如果两边长度相同(视口为正方形),规范确定媒体处于纵向模式。然而,由于媒体查询不直接支持这种方法,我们可以使用更直观的方向属性。

该属性的两个可能值是纵向横向。前缀minmax在此查询中不允许,因为将某物分类为至少横向或最多纵向是没有意义的。

@media all and (orientation: portrait) {
  body {
    backgroundcolor: red;
  }
}

@media all and (orientation: landscape) {
  body {
    backgroundcolor: green;
  }
}

@media all and
  not (orientation: portrait) and
  not (orientation: portrait) {
  body {
    backgroundcolor: blue;
  }
}

在前面的示例中,我们检查媒体是横向还是纵向。无论方向如何评估,媒体查询都会被激活。请注意,第三个查询试图基于错误的结论设置第三个方向。有人可能会想象,确定某物是横向还是纵向的方法是通过两者之间的比率来确定——如果宽度大于高度,则媒体处于横向模式,否则处于纵向模式。您可以想象有人可能会得出结论,如果两边(宽度和高度)相同,那么方向既不是横向也不是纵向。然而,重要的是要记住,正方形不是横向,而是纵向。关键是要记住,该属性只有两个可能的值,因为媒体一次只能处于两种可能的状态中的一种。

纵横比

纵横比属性允许我们检查媒体宽度相对于高度的比例(按照这个顺序)。该属性考虑了宽度高度媒体查询值之间的实际比率,这意味着视口宽度和高度的动态变化会直接影响该属性。minmax关键字可以用于评估此属性。

// Aspect ratio is exactly twice as high as wide
@media all and (aspect-ratio: 1/2) {
  h1 {
    color: blue;
    font-size: 1.0em;
  }
}

// Aspect ratio is at least three times as high as wide
@media all and (min-aspect-ratio: 1/3) {
  h1 {
    color: red;
    font-size: 0.5em;
  }
}

// Aspect ratio is no more than four times as wide as high
@media all and (max-aspect-ratio: 4/1) {
  h1 {
    color: green;
    font-size: 3.0em;
  }
}

// Aspect ratio is an exact square
@media all and (aspect-ratio: 1/1) {
  h1 {
    color: yellow;
    font-size: 2.0em;
  }
}

// Aspect ratio is no more than half as high as wide – ERROR!
@media all and (max-aspect-ratio: 1/0.5) {
  h1 {
    color: green;
    font-size: 3.0em;
  }
}

前面的代码片段演示了计算宽高比的各种方法。请记住,该属性的值必须始终读作一个分数,不涉及浮点数。简单地说,该值必须以整数形式表示,后跟斜杠,后跟第二个整数。第一个整数是宽度值,第二个是高度值。它们一起形成一个比例。

前面的示例中的第一个媒体查询测试的是每个宽度单位正好两个宽度单位的视口。换句话说,该表达式检查的是一个高度是宽度的两倍或宽度的一半的视口。相比之下,最后一个媒体查询试图以相反的方式生成相同的结果。那里的尝试是查询一个最多是宽度的两倍的媒体类型。这个表达式会引发一个静默表达式(该表达式将被忽略),因为格式不合适。与其检查 1/0.5,正确的方式是将其设为 2/1,使宽度长度是高度的两倍。

在指定媒体查询宽高比表达式的期望值时,左边的数字表示宽度相对于高度,右边的数字表示高度。两个数字必须是正整数,较大的值可以在任一侧。或者,两个值可以相同,这将测试一个正方形宽高比(1/1)。

宽高比

设备宽高比

检查设备宽高比的方式与宽高比相同,如前所述,唯一的区别在于widthheight的参考是基于设备宽度和设备高度的,如各自部分所述。

与设备宽度和设备高度一样,这是一种检查设备访问应用程序的底层指纹的好方法,这与测试媒体查询时浏览器窗口的当前状态无关。就响应用户操作而言,测试宽高比可能比设备宽高比更好,因为用户可能会独立于设备屏幕比例改变浏览器窗口的尺寸。然而,为了确定设备的真实宽高比,另一种选择是使用设备宽高比。

另外,请记住,在查询宽高比时很可能定义多余的媒体查询。在这种情况下,与 CSS 一样,最后匹配的表达式将覆盖先前重复的表达式和值。

// Aspect ratio evaluates to 1/1
@media all and (device-aspect-ratio: 1/1) {
  h1 {
    color: blue;
    font-size: 3.0em;
  }
}

// Aspect ratio evaluates to 1/1
@media all and (device-aspect-ratio: 1/1) {
  h1 {
    color: red;
    font-size: 3.0em;
  }
}

// Aspect ratio evaluates to 1/1
@media all and (device-aspect-ratio: 2/2) {
  h1 {
    color: green;
    font-size: 0.5em;
  }
}

// Aspect ratio evaluates to 1/1
@media all and (device-aspect-ratio: 10/10) {
  h1 {
    color: purple;
    font-size: 0.5em;
  }
}

// Aspect ratio evaluates to 1/1
@media all and (device-aspect-ratio: 2000/2000) {
  h1 {
    color: orange;
    font-size: 10.5em;
  }
}

// Aspect ratio evaluates to 1/1
@media all and (device-aspect-ratio: 17/17) {
  h1 {
    color: transparent;
    font-size: 0.0em;
    display: none;
  }
}

前面的代码示例显示了六个媒体查询表达式,所有这些表达式都评估为相同的宽高比。无论原始表达的比例是先前值的重复还是缩减为相同比例的不同值,最终结果都是相同的。当发现相等的比例并且没有其他表达式通过进一步限定整个表达式来打破平局时,那么重复表达式的最后一次出现就成为唯一激活的查询,用于不是先前表达式唯一的值。例如,如果两个或更多表达式评估为相同的宽高比,那么两个表达式共有的任何 CSS 属性都优先于查询的最后一次出现。每个表达式之间的唯一值被级联到最终评估中。

// Aspect ratio evaluates to 1/1
//  Properties set: color, font-size
//  Properties overridden: none
@media all and (device-aspect-ratio: 1/1) {
  h1 {
    color: blue;
    font-size: 1.5em;
  }
}

// Aspect ratio evaluates to 1/1
//  Properties set: color, border, padding
//  Properties overridden: color
@media all and (device-aspect-ratio: 1/1) {
  h1 {
    color: red;
    border: 1px solid green;
    padding: 20px;
  }
}

// Aspect ratio evaluates to 1/1 and anything landscape
//  Properties set: color
//  Properties overridden: color
@media all and (min-device-aspect-ratio: 1/1) {
  h1 {
    color: green;
  }
}

在前面的代码片段中,三个单独的媒体查询评估为相同的宽高比。最后一个查询还使用了min修饰符,这意味着它匹配任何不是 1/1 的宽高比(以及任何正好是 1/1 的宽高比),但设备宽度仍然大于高度(换句话说,任何宽高比为 1/1 和任何横向方向的媒体类型)。

在这种情况下,当媒体类型为landscape(记住正方形或 1/1 宽高比从不被认为是横向的)时,只有第三个查询匹配当前状态。因此,只有颜色属性被分配给该媒体查询中指定的h1标签。然而,当宽高比实际上是 1/1 时,那么所有三个媒体查询都会评估为 true,因此所有三个查询都会应用到它们指定的元素上。

第一个查询只将标签的颜色设置为绿色。第二个查询重置该标签颜色,并另外应用一些属性到该标签。最后,第三个查询再次重置标签颜色,但不影响任何其他属性。这些复合查询对于 1/1 宽高比的媒体类型的最终结果可以在以下代码片段中看到。

@media all and (device-aspect-ratio: 1/1) {
  h1 {
    color: green;
    border: 1px solid green;
    padding: 20;
  }
}

颜色

此媒体查询属性检查输出设备使用的每个颜色分量的位数。例如,如果输出设备使用 8 位颜色系统,其中使用 2 位表示红色、绿色、蓝色和 alpha 分量,则媒体查询表达式中的颜色属性为 2。minmax修饰符也可以用于测试这一点。

@media all and (color: 2) {
  h1 {
    color: green;
    border: 1px solid green;
    padding: 20;
  }
}

如果输出设备不是彩色设备,则颜色属性的值将为零。

@media all and (color: 0) {
  h1 {
    color: green;
    border: 1px solid green;
    padding: 20;
  }
}
// This query produces the exact same result as the previous one
@media all and (min-color: 1) {
  h1 {
    color: green;
    border: 1px solid green;
    padding: 20;
  }
}

在某些情况下,输出设备使用不同的颜色分量位数,颜色属性指的是每个分量的最小位数值。例如,如果输出设备使用 8 位颜色系统,并且红色分量使用 3 位,绿色分量使用 3 位,蓝色分量使用 2 位,那么用作媒体查询颜色属性的值将是 2。

颜色索引

color-index属性返回输出设备使用的颜色数。例如,具有 256 种颜色的设备将完全匹配以下媒体查询:

@media all and (color-index: 256) {
  h1 {
    color: green;
    border: 1px solid green;
    padding: 20;
  }
}

与颜色属性一样,颜色索引属性指定的值不能为负数。此外,如果输出设备不使用颜色查找表,则颜色索引的值为零。

单色

如果输出设备是单色的,此媒体查询属性指的是设备使用的每像素位数。这类似于颜色,但仅适用于单色设备,并且出于明显的原因,仅适用于单个像素,而不是最低的颜色分量。

@media all and (monochrome: 1) {
  h1 {
    color: black;
    border: 1px solid black;
    padding: 20;
  }
}

如果设备不是单色的,则此属性将匹配值为零。此外,我们可以使用minmax修饰符关键字来定位范围。或者,可以使用单个布尔表达式来确定设备是否为单色设备。

@media not all and (monochrome) {
  h1 {
    color: red;
    border: 1px solid purple;
    padding: 20;
  }
}

// This query produces the exact same result as the previous one
@media all and (color) {
  h1 {
    color: red;
    border: 1px solid purple;
    padding: 20;
  }
}

分辨率

与人们可能认为的相反,分辨率属性并不是查询屏幕分辨率,就像我们可以通过操作系统设置监视器的分辨率一样。相反,分辨率属性查询以 dpi(每英寸点数或每英寸像素)和 dpcm(每厘米点数或像素)表示的像素密度(或打印机的点密度)。

@media all and (resolution: 300dpi) {
  h1 {
    color: black;
    border: 1px solid black;
    padding: 20;
  }
}

minmax修饰符在此查询表达式中有效。如果输出设备不使用方形像素,则使用min-resolution查询来针对输出设备的最不密集的维度。当发出max-resolution查询时,将使用输出设备的最密集维度来评估表达式。

扫描

在电视上渲染时,扫描属性查询设备的扫描。可能的值只有progressiveinterlace。在电视扫描过程的上下文中使用minmax修饰符是没有意义的,因此会导致无效表达式。

@media all and (scan: interlace) {
  h1 {
    color: black;
    border: 1px solid black;
    padding: 20;
  }
}

@media all and (scan: progressive) {
  h1 {
    color: red;
    border: 1px solid red;
    padding: 20;
  }
}

网格

网格输出设备是指非基于位图的设备。查询grid属性在输出设备不是基于位图时返回 true。可以查询的唯一可能值是 1 和 0。minmax修饰符在此查询中无效。

基于网格的设备的示例包括使用字符网格的任何设备,例如那些旧计算器或甚至具有固定字体的旧型号手机。

// Evaluates to true on grid-based devices
@media all and (grid) {
  h1 {
    color: black;
    border: 1px solid black;
    padding: 20;
  }
}

// Evaluates to true on grid-based devices
@media all and (grid: 1) {
  h1 {
    color: black;
    border: 1px solid black;
    padding: 20;
  }
}

// Evaluates to true on bitmap-based devices
@media all and (grid: 0) {
  h1 {
    color: black;
    border: 1px solid black;
    padding: 20;
  }
}

// Evaluates to true on bitmap-based devices
@media not all and (grid) {
  h1 {
    color: black;
    border: 1px solid black;
    padding: 20;
  }
}

理解触摸事件

虽然与普通鼠标点击类似,触摸事件允许我们主要通过点和响应的方式与计算机进行交互。然而,触摸比点击更灵活,因此为全新类型的游戏打开了舞台。

从根本上说,触摸与点击不同之处在于在同一表面上可以同时进行多次触摸。此外,触摸通常与点击不同,因为它允许更大的目标区域以及不同的压力。我说通常是因为并非所有设备都能高精度地检测触摸区域(或根本没有精度)或触摸压力。同样,一些鼠标或其他等效输入设备实际上确实提供了压力灵敏度,尽管大多数浏览器都没有使用这样的功能,也不会通过点击事件对象公开这些数据。

注意

出于兼容性目的,大多数移动浏览器在 JavaScript 代码期望触摸时会响应触摸事件。换句话说,用户触摸屏幕可以触发点击处理程序。在这种情况下,常规点击事件对象被传递给注册的callback函数,而不是触摸事件对象。此外,拖动事件(dragMove事件)和触摸移动事件之间的体验可能会有所不同。最后,多个触摸可能会触发同时的点击事件监听器,也可能不会。

有三个与触摸相关的事件,即触摸开始、触摸移动和触摸结束。触摸开始和触摸结束分别可以与鼠标按下和鼠标松开事件相关联,而触摸移动事件类似于拖动移动事件。

touchstart

当触摸区域检测到新的触摸时,无论一个或多个触摸事件是否已经开始并且尚未结束,都会触发此事件。

document.body.addEventListener("touchstart", doOnTouchStart);

function doOnTouchStart(event) {
  event.preventDefault();

  // ...
}

传递给注册的callback函数的对象是TouchEvent类的一个实例,其中包含以下属性:

触摸

TouchList类的一个实例,看起来像一个普通数组,包含了所有已经在触摸设备上触摸并且尚未被移除的触摸的列表,即使其他活动的触摸已经在屏幕或输入设备上移动。该列表中的每个元素都是Touch类型的实例。

changedTouches

TouchList类的一个实例,包含了自上一个触摸事件以来引入的所有新触摸点的触摸对象列表。例如,如果已经检测到两个触摸对象(换句话说,两个手指已经按在触摸设备上),并且检测到第三个触摸,那么只有这第三个触摸存在于这个触摸列表中。同样,该触摸列表中包含的每个与触摸相关的元素都是Touch类型的。

targetTouches

TouchList类的一个实例,包含了代表已被给定 DOM 节点捕获的所有触摸点的触摸对象列表。例如,如果在整个屏幕上检测到了多个触摸,但是特定元素注册了触摸开始事件并捕获了此事件(无论是从捕获还是冒泡阶段),那么只有该节点捕获的触摸事件会出现在这个触摸列表中。同样,该触摸列表中包含的每个与触摸相关的元素都是Touch类型的。

touchend

类似于鼠标松开事件,当任何已注册的触摸事件离开输入触摸设备时,会触发touchend事件。

document.body.addEventListener("touchend", doOnTouchEnd);

function doOnTouchEnd(event) {
  event.preventDefault();

  // ...
}

就像touchstart事件一样,传递给注册的callback函数的对象是TouchEvent类的一个实例,其中包含相同的三个TouchList属性。touchestargetTouches属性的上下文与它们在touchstart中的版本完全相同。然而,在此事件中,changedTouches触摸列表的含义略有不同。

虽然touchend事件中的TouchList对象与touchstart中的完全相同,但这里包含的触摸对象列表代表已离开触摸输入设备的触摸。

touchmove

touchmove事件类似于drag事件,当至少一个注册的触摸对象改变位置而不触发touchend事件时触发。正如我们将很快看到的,每个触摸对象都是唯一跟踪的,因此可以确定是否有任何已注册的触摸对象移动,以及哪些实际上已经移动。

document.body.addEventListener("touchmove", doOnTouchMove);

function doOnTouchMove(event) {
  event.preventDefault();

  // ...
}

touchend事件一样,传递到注册的callback函数中的对象是TouchEvent类的实例,其中包含相同的三个TouchList属性。touchestargetTouches属性的上下文与它们在touchstart中的版本完全相同。touchmove事件中changedTouches列表中的触摸对象代表先前注册的触摸在输入设备上移动的情况。

关于touchmove事件的一个重要事项是它可以与drag事件相关联。如果注意到,默认情况下drag事件的行为是沿滚动方向滚动页面。在一些涉及用手指在屏幕上拖动的应用程序中,可能不希望出现这种行为。因此,调用event.preventDefault()方法,产生的效果是告诉浏览器不需要滚动。然而,如果意图是使用touchmove事件滚动屏幕,只要被触摸的元素支持这种行为,可以通过省略调用 prevent default 函数来实现这一点。

触摸对象

现在,您可能已经注意到,每个TouchList对象都包含一个非常特定的对象的实例,这是Touch类的一个实例。这很重要,因为输入设备需要跟踪单独的触摸。否则,changedTouches列表将不准确,从而限制我们可以通过 API 实现的功能。

每个触摸可以通过输入设备分配一个唯一的 ID 来唯一标识。这个 ID 对于相同的触摸对象保持不变,直到该对象被释放(换句话说,当该特定触摸离开输入设备时)。

让我们看看Touch类的所有其他属性,并了解其中包含的其他重要信息。

标识符

当前触摸TouchList中包含的特定触摸事件的唯一整数标识符。这个数字保持不变,直到触摸事件离开输入设备,这样我们可以跟踪每个触摸,即使在一个特定的触摸对象开始、移动和结束时,其他许多触摸对象也可以被单独找出并适当地保持。

请注意,有时此属性的值可能与TouchList对象中的触摸对象的数组索引值匹配。有时标识符属性甚至可能与输入设备检测到每次触摸的顺序匹配。作为一个细心的程序员,你绝不能假设这两个值总是相同的。

例如,假设设备第一次检测到触摸时具有标识符 ID 为零(由于这是** TouchList 中的第一个触摸,它显然将被索引为零)。现在检测到第二次触摸,使其成为 TouchList 数组中的第二个对象,这将使其索引键为一。假设这个触摸也收到标识符为一,以便所有三个值匹配(触摸顺序,数组顺序和标识符值)。现在,在移动这两个触摸设备后,假设释放第一个触摸对象并检测到新的触摸事件。现在 TouchList 中再次有两个触摸对象,但它们的值与前两个触摸元素完全不同。虽然第二次触摸事件仍具有相同的标识符(在这个例子中,标识符为一),但它现在(可能)是 TouchList **中的第一个元素。

尽管有时检测到触摸的顺序,触摸在** TouchList **数组中的位置和触摸的唯一标识号可能都匹配(假设输入设备甚至分配特定的标识符值),但您永远不应该使用这些假设来跟踪单个触摸。当跟踪多个触摸时,应始终通过其唯一标识符属性来跟踪触摸。如果只跟踪单个触摸,该触摸将始终是TouchList对象中的第一个元素。

identifier

总之,检测触摸并将其分配给TouchList对象的顺序是不可预测的,永远不应该假设。跟踪单个触摸对象的正确方法是通过分配给每个对象的标识符属性。一旦触摸事件被释放,其先前的标识符属性的值可以重新分配给后续的触摸,因此请务必记住这一点。

screenX

screenX坐标是指相对于系统显示器原点触摸的浏览器视口中的点。浏览器视口的原点在这个计算中根本没有考虑。点(0,0)是显示器的左上角,无论向右移动多少像素,该属性都将引用该点的位置。

screenY

screenY坐标是指从系统屏幕(显示器)向下的点,与浏览器相对位置无关。如果屏幕高度为 800 像素,浏览器设置为高度为 100 像素,位于屏幕顶部正下方 100 像素处,那么在触摸浏览器视口顶部和底部左上角之间的中点将导致触摸的screenY坐标为 150。

想想看,浏览器的视口高度为 100 像素,因此其中点恰好在其原点下方 50 像素。如果浏览器恰好在屏幕原点下方 100 像素,那么该中点就在屏幕垂直原点下方 150 像素。

screenXscreenY属性几乎看起来根本不考虑浏览器的坐标系统。因此,由于浏览器基于其屏幕的原点进行计算,因此screenXscreenY返回的点永远不会小于零,因为我们无法触摸屏幕范围之外的点,而屏幕仍然能够检测到该点。

clientX

screenX类似于clientX坐标,指的是触摸位置距离浏览器视口原点的偏移量,与页面内的任何滚动无关。换句话说,由于浏览器视口的原点是其左上角,距离该点右侧 100 像素的触摸对应于clientX值为 100。现在,如果用户将页面向右滚动了 500 像素,那么在浏览器左边框右侧 100 像素处的触摸仍将导致clientX值为 100,即使触摸发生在页面内的第 600 个点。

clientY

clientY坐标指的是距离浏览器视口原点向下的位置,与触摸发生在页面内的具体位置无关。如果页面向右和向下滚动了任意数量的像素,并且在浏览器视口的左上角右侧的第一个像素处检测到触摸,并且向下正好一个像素,那么clientY值将被计算为 1。

clientXclientY属性根本不考虑网页的坐标系。因此,由于这一点是相对于浏览器框架计算的,因此clientXclientY返回的点永远不会小于零,因为我们无法触摸到浏览器视口外的点,而浏览器仍然能够检测到该点。

pageX

最后,pageX表示的坐标指的是触摸被检测到的实际页面内的位置。换句话说,如果浏览器只有 500 像素宽,但应用程序有 3000 像素宽(这意味着我们可以将应用程序的内容向右滚动 2500 像素),那么在距离浏览器视口原点 2000 像素的地方检测到的触摸将导致pageX值为 2000。

在游戏世界中,pageX的更好名称可能是worldCoordinateX,因为触摸事件发生的世界位置会被考虑在内。当网页实际滚动时,这才有效,而不是当滚动的表示已经发生时。例如,假设我们将一个世界渲染到一个 2D 画布上,而世界实际上比画布元素的宽度和高度要大得多。如果我们以任意数量的像素滚动虚拟地图,但是画布元素本身实际上并没有移动,那么pageX值将与游戏地图的偏移量无关。

pageY

最后,pageY坐标指的是触摸被检测到的位置在浏览器视口原点下方的点,再加上任何滚动偏移量。与其他触摸点位置一样,pageXpageY属性不可能获得负值,因为我们无法触摸到尚未滚动到的页面上的点,尤其是页面原点后面的点,我们永远无法滚动到那里。

下图显示了屏幕、客户端和页面位置之间的差异。屏幕位置指的是屏幕内的位置(而不是浏览器窗口),原点是显示器的左上角。客户端位置类似于屏幕位置,但将原点放在浏览器视口的左上角。即使浏览器被调整大小并移动到屏幕的一半,浏览器视口右侧的第一个像素仍将是点(0, 0)。页面位置类似于客户端位置,但考虑了浏览器视口内的任何滚动。如果页面垂直滚动了 100 像素,水平没有滚动,那么浏览器视口左边距右侧的第一个像素将是(100, 1)。

pageY

radiusX

当输入设备检测到触摸时,输入设备会在触摸区域周围绘制一个椭圆。可以通过radiusXradiusY属性访问该椭圆的半径,暗示了触摸覆盖的面积。请记住,描述触摸区域的椭圆的准确性取决于所使用的设备,因此在这里可能会有很大的差异。

radiusY

为了获得输入设备检测到的触摸形成的椭圆在水平轴上的半径,我们可以使用radiusY属性。有了这些信息,我们可以为使用触摸作为输入的应用程序增加额外的深度。

作为示例应用程序,以下代码片段检测输入设备可以同时处理的触摸数量,跟踪每个触摸的半径,然后显示每个触摸的大致大小。

首先,我们需要设置文档视口与设备的宽度和高度相同,并设置初始缩放级别。我们还希望禁用捏合手势,因为在这个特定的示例应用程序中,我们希望该手势像其他触摸移动一样,没有任何特殊含义。

<meta name="viewport"
  content="width=device-width, initial-scale=1.0,
    user-scalable=no" />

meta 视口标签允许我们为视口定义特定的宽度和高度值,或者使用可选的 device-width 和 device-height 属性。如果只指定了宽度或高度值,则用户代理会推断另一个值。该标签还允许我们指定默认的缩放级别,以及通过手势或其他方式禁用缩放。

接下来,我们需要确保应用程序中的根 DOM 节点能够延伸到整个显示屏的宽度和高度,以便我们可以在其中捕获所有的触摸事件。

<style>
body, html {
  width: 200%;
  height: 100%;
  margin: 0;
  padding: 0;
  position: relative;
  top: 0;
  left: 0;
}

div {
  position: absolute;
  background: #c00;
  border-radius: 100px;
}
</style>

我们将body标签设置为与视口一样宽,并从中删除任何边距和填充,以便屏幕边缘附近的触摸不会被元素的事件处理所忽略。我们还将div元素样式设置为圆形,具有红色背景颜色,并且绝对定位,以便我们可以在检测到触摸的任何位置放置一个。我们可以使用画布元素而不是渲染多个div标签来表示每个触摸,但对于这个演示来说,这是一个微不足道的细节。

最后,我们来到应用程序的 JavaScript 逻辑。为了总结这个演示的结构,我们简单地使用一个全局数组来存储每个触摸。每当文档上检测到任何触摸事件时,我们都会清空跟踪每个触摸的全局数组,为每个活动触摸创建一个div元素,并将新节点推送到全局数组中。与此同时,我们使用请求动画帧来持续渲染全局触摸数组中包含的所有 DOM 节点。

// Global array that keeps track of all active touches.
// Each element of this array is a DOM element representingthe location
// and area of each touch.
var touches = new Array();

// Draw each DOM element in the touches array
function drawTouches() {
  for (var i = 0, len = touches.length; i < len; i++) {
    document.body.appendChild(touches[i]);
  }
}

// Deletes every DOM element drawn on screen
function clearMarks() {
  var marks = document.querySelectorAll("div");

  for (var i = 0, len = marks.length; i < len; i++) {
    document.body.removeChild(marks[i]);
  }
}

// Create a DOM element for each active touch detected by the
// input device. Each node is positioned where the touch was
// detected, and has a width and height close to what the device
// determined each touch was
function addTouch(event) {
  // Get a reference to the touches TouchList
  var _touches = event.touches;

  // Flush the current touches array
  touches = new Array();

  for (var i = 0, len = _touches.length; i < len; i++) {
    var width = _touches[i].webkitRadiusX * 20;
    var height = _touches[i].webkitRadiusY * 20;

    var touch = document.createElement("div");
    touch.style.width = width + "px";
    touch.style.height = height + "px";
    touch.style.left = (_touches[i].pageX - width / 2) + "px";
    touch.style.top = (_touches[i].pageY - height / 2) + "px";

    touches.push(touch);
  }
}

// Cancel the default behavior for a drag gesture,
// so that the application doesn't scroll.
document.body.addEventListener("touchmove", function(event) {
  event.preventDefault();
});

// Register our function for all the touch events we want to track.
document.body.addEventListener("touchstart", addTouch);
document.body.addEventListener("touchend", addTouch);
document.body.addEventListener("touchmove", addTouch);

// The render loop
(function render() {
  clearMarks();
  drawTouches();

  requestAnimationFrame(render);
})();

考虑到每个触摸的半径,多点触摸的示例如下所示。通过将封闭拳头的一侧触摸到移动设备上,我们可以看到手部触摸屏幕的每个部分都以其相对大小和接触面积被检测到。

radiusY

rotationAngle

根据触摸检测的方式,表示触摸的椭圆可能会旋转。与每个触摸对象相关联的rotationAngle属性是将椭圆顺时针旋转以最接近触摸的角度(以度为单位)。

force

一些触摸设备能够检测用户对输入表面施加的压力量。在这种情况下,力量属性表示该压力量,取值范围在 0.0 到 1.0 之间,其中 1.0 表示设备可以处理的最大压力量。当设备不支持力量灵敏度时,该属性将始终返回 1.0。

由于力量属性的值始终在零和一之间,我们可以方便地使用它来渲染具有不同透明度的元素(其中零表示完全透明,即不可见的元素,而一表示完全渲染的元素)。

var width = _touches[i].webkitRadiusX * 20;
var height = _touches[i].webkitRadiusY * 20;
var force = _touches[i].webkitForce;

var touch = document.createElement("div");
touch.style.width = width + "px";
touch.style.height = height + "px";
touch.style.left = (_touches[i].pageX - width / 2) + "px";
touch.style.top = (_touches[i].pageY - height / 2) + "px";
touch.style.opacity = force;

touches.push(touch);

目标

当检测到触摸事件时,通过target属性引用触摸最初被检测到的 DOM 元素。由于触摸对象在触摸结束之前被跟踪,所以target属性将在整个触摸生命周期内引用触摸最初开始的原始 DOM 元素。

游戏

正如我们在本章开头讨论的那样,在设计和构建适用于移动设备和桌面浏览器的游戏时,必须牢记几个考虑因素。在本书的最后一个游戏中,我们将应用这些原则和最佳实践,制作一个可以在移动设备和支持 HTML5 的浏览器上玩的游戏。

这个项目采取的方法是首先为桌面设计,然后稍后添加特定于移动设备的 API 和功能。这个决定的主要原因是因为在桌面浏览器上使用现有工具和常见做法进行测试和调试应用要容易得多,然后再添加必要的东西,使代码在移动设备上运行顺畅。

最终的游戏是一个传统的二维太空射击游戏,玩家控制一艘飞船在屏幕上移动,并始终向上射击。随机的敌人太空船从屏幕的各个方向出现,试图击中玩家的飞船,给玩家的飞船造成伤害,直到爆炸。

游戏

代码结构

考虑到这个游戏的复杂性,代码结构必须经过仔细考虑。为了简单起见,我们将采用基于组件的方法,这样添加功能就会更容易,特别是在动态添加输入处理机制时。由于游戏需要在移动设备和桌面上同样出色地运行(换句话说,游戏需要接受和处理鼠标和键盘输入以及触摸输入,取决于游戏所在的环境),能够在游戏中动态添加特定组件是一个非常重要的功能。

如果您对基于组件的游戏开发不太熟悉,不用太担心。基于组件的开发的一般思想是将每个功能模块从一个类中分离出来,使其成为自己的类。这样可以让我们创建代表各个功能模块的单独对象,比如移动、渲染等等。

这个游戏的最终项目结构如下,文件和目录列表显示了项目文件夹的根目录:

/css

这是存储单个样式表文件的地方。这个样式表定义了桌面和移动版本的所有样式,尽管两者之间几乎没有什么区别。向游戏版本添加 CSS 功能的方法是在 CSS 类中声明这些功能,然后在适当的时候将这些类分配给 DOM 元素。

我们想在这个样式表中首先声明的是视口,确保屏幕上的每个像素都是文档的一部分,这样我们就可以在文档的任何地方捕获输入事件。我们还希望保持文档不会以某种方式变得比视口更大,这样就不会为游戏引入滚动条,这在这种情况下是不希望的。

body, html {
  width: 100%;
  height: 100%;
  margin: 0;
  padding: 0;
  overflow: hidden;
}

body {
  background: url("../img/space-bg-01.jpg") repeat;
}

我们希望游戏具有两个功能,如果运行游戏的设备支持的话,那就是 DOM 元素的过渡效果以及背景图片的动画效果。为了只在适当的情况下添加这些功能(例如,将这些功能添加到某些移动设备可能会减慢游戏速度,因为移动浏览器需要进行大量处理才能产生 CSS 动画和过渡效果),我们创建 CSS 动画并将其添加到自定义类中。当我们确定设备可以处理动画时,我们只需将该类添加到文档中。

/**
 * Make the background image continually move up and to the left,
 * giving the illusion that the game world is scrolling at anangle.
 */
@-webkit-keyframes NebulaBg {
  from {
    background-position: 0 0;
  }
  to {
    background-position: 1300% 600%;
  }
}

/**
 * Add the animation to this class, and add a transition
 * to any box-shadow applied to whatever element this class isattached to.
 */
.animBody {
  -webkit-transition: box-shadow 8s;

  -webkit-animation: NebulaBg;
  -webkit-animation-duration: 500s;
  -webkit-animation-timing-function: linear;
  -webkit-animation-iteration-count: infinite;
}

最后,为了简化一些游戏用户界面,我们将一些游戏元素创建为普通的 DOM 元素,而不是直接在画布中渲染等效的元素。

我们构建为 DOM 元素的唯一游戏元素是玩家飞船的能量条,它表示飞船剩余的能量。这个能量条由一个包含在其中的div元素的容器元素组成。这个嵌套的div的宽度表示玩家剩余的能量,可以是 0-100%之间的值。

.energyBar {
  position: absolute;
  top: 2%;
  left: 4%;
  z-index: 99999;
  width: 92%;
  height: 25px;
  border: 1px solid #ff5;
  background: #c00;
  overflow: hidden;
}

.energyBar div {
  background: #ff5;
  height: 100%;
  width: 100%;
  -webkit-transition: width 0.2s;
}

/img

在这个文件夹中,我们存储了游戏中使用的所有图像资产。由于所有这些图像都是在画布内渲染的,我们完全可以将所有图像合并成一个单一的图像图集。这将是一个非常好的优化,特别是当游戏增长和图像资产的数量增长时。由于大多数浏览器限制了应用程序对同一服务器发出的并行 HTTP 请求的数量,我们只能同时获取有限数量的图像。这意味着如果有太多个别的图像文件从同一服务器获取,前 4-8 个请求会被处理(默认的并行连接数因浏览器而异,但通常大约为 6 个左右),而其余的则会等待在队列中。

因此,很容易看出,创建单个图像图集资产比下载多个单独的图像文件要好得多。即使图集的总图像文件大小大于所有其他图像的总大小,主要的收益在于传输延迟。即使游戏在某个时候以单独的图像资产翻倍,我们仍然只需要下载一个图像图集(或几个可以同时下载的单独图集)。

注意

由于并非每个人在为游戏创建出色的图形方面都非常有天赋,甚至更少的人有时间为游戏创建每个要使用的图像。许多游戏开发者发现从数字艺术家那里购买图形是值得的。

在这个游戏中,所有的图形都是从网站上下载的,艺术家在这里免费或者成本很低地分享他们的作品。这个美妙社区的网站地址是opengameart.org

/js

如前所述,这个游戏是基于组件的模型构建的。文件结构分为四个主要类别,即组件、实体、小部件和通用代码。这些代码的每个部分都意味着要相对通用和可重用。所有这些部分的粘合是在一个名为main.js的文件中完成的。

/components

组件目录是我们存储游戏中使用的所有组件的地方。在游戏开发的背景下,组件是一个非常具体的类,可能包含自己的数据并执行非常特定的功能。例如,当设计一个代表玩家的类时,我们可以将这个类的每个功能模块分解成许多单独的小类或组件,而不是让这个类处理玩家飞船的渲染、移动、执行碰撞检测等功能。

通常,游戏中的每个组件都实现一个共同的接口,这样我们就可以利用面向对象的技术。尽管在 JavaScript 中可以模拟经典继承和其他面向对象的技术,但我们只是为每个组件复制相同的基本接口,并在客户端代码中假设每个组件都遵循相同的接口。

// Namespace the component in order to keep the global namespaceclean
var Packt = Packt || {};
Packt.Components = Packt.Components || {};

Packt.Components.Component = function(entity) {
  var entity = entity;

  this.doSomething = function() {
};

在这个游戏中,每个组件都有两个共同点。它们都存在于Pack.Components对象中,模拟了基于包的结构,并且它们都持有对使用组件提供的服务的父实体的引用。

我们将创建的第一个组件将是“精灵”组件,它负责渲染实体。正如我们将在关于实体的讨论中看到的,实体只在游戏世界中跟踪自己的位置,并不知道自己的宽度和高度。因此,“精灵”组件还跟踪实体的物理大小以及代表实体的图像。

var Packt = Packt || {};
Packt.Components = Packt.Components || {};
Packt.Components.Sprite = function(pEntity, pImgSrc, pWidth, pHeight) {
  var entity = pEntity;
  var img = new Image();
  img.src = pImgSrc;

  var width = pWidth;
  var height = pHeight;
  var sWidth = pWidth;
  var sHeight = pHeight;
  var sX = 0;
  var sY = 0;
  var ctx = null;

  // Inject the canvas context where the rendering of the entity
  // managed by this component is done
  function setCtx(context) {
    ctx = context;
  }

  // Public access to the private function setCtx
  this.setCtx = setCtx;

  // If the image used to render the entity managed by thiscomponent
  // is part of an atlas, we can specify the specific region
  // within the atlas that we want rendered
  this.setSpriteCoords = function(x, y, width, height) {
    sX = x;
    sY = y;
    sWidth = width;
    sHeight = height;
  };

  // Render the entity
  this.update = function() {
    if (ctx && entity.isActive()) {
      var pos = entity.getPosition();
      ctx.drawImage(img, sX, sY, sWidth, sHeight, pos.x, pos.y,width, height);
    }
  };

  // Return both values at once, instead of using two getterfunctions
  this.getSize = function() {
    return {
      width: width,
      height: height
    };
  };
};

一旦渲染实体的功能就位,我们现在可以继续添加一个组件,允许玩家在屏幕上移动实体。现在,使用组件的整个目的是允许最大程度地重用代码。在我们的情况下,我们希望重用使玩家移动的组件,以便我们可以使用相同的功能使每艘敌舰在游戏世界中移动。

为了使实体移动,我们使用一个非常标准的Move组件,它根据实体的方向向量和实体在给定方向上移动的恒定速度来移动实体。Vec2数据类型是本章后面讨论的一个自定义通用类。基本上,这个类表示一个向量,它包含两个表示向量两个分量的变量,并在需要时定义一个非常方便的函数来对向量进行归一化。

var Packt = Packt || {};
Packt.Components = Packt.Components || {};

Packt.Components.Move = function(entity, speed) {
  var entity = entity;
  var speed = speed;
  var direction = new Packt.Vec2(0, 0);

  // Move the entity in the direction it is facing by a constantspeed
  this.update = function() {
    var pos = entity.getPosition();
    direction.normalize();

    var newPos = {
      x: pos.x + direction.get("x") * speed,
      y: pos.y + direction.get("y") * speed
    };

    entity.setPosition(newPos);
  };

  // Allow the input mechanism to tell the entity where to move
  this.setDirection = function(x, y) {
    direction.set(x, y);
  };
};

现在,玩家和敌人都可以使用相同的Move组件来移动它们的实体的方式略有不同。对于敌人,我们可以简单地创建一些原始的人工智能来定期设置敌人实体的方向,而Move组件负责根据需要更新实体的位置。

然而,为了使玩家的飞船移动,我们希望玩家本人告诉实体要去哪里。为了实现这一点,我们简单地创建一个输入组件来监听人类输入。然而,由于玩家可能会从可能支持鼠标事件或直接触摸事件的设备玩这个游戏,我们需要创建两个单独的组件来处理每种情况。

这些组件在每个方面都是相同的,唯一的区别是一个注册鼠标事件,另一个注册触摸事件。虽然这可以在单个组件内完成,并且有条件语句决定要监听哪些事件,但我们选择使用单独的组件,以使代码不与任何特定设备耦合。

var Packt = Packt || {};
Packt.Components = Packt.Components || {};

Packt.Components.TouchDrag = function(entity, canvas) {
  var entity = entity;
  var canvas = canvas;
  var isDown = false;
  var pos = entity.getPosition();

  canvas.getCanvas().addEventListener("touchstart", doOnTouchDown);
  canvas.getCanvas().addEventListener("touchend", doOnTouchUp);
  canvas.getCanvas().addEventListener("touchmove", doOnTouchMove);

  // Set a isDown flag on the entity, indicating that the playeris currently
  // touching the entity that is to be controlled
  function doOnTouchDown(event) {
    event.preventDefault();
    var phy = entity.getComponent("physics");
    var touch = event.changedTouches;

    if (phy) {
      isDown = phy.collide(touch.pageX, touch.pageY, 0, 0);
    }
  }

  // Whenever the player releases the touch on the screen,
  // we must unset the isDown flag
  function doOnTouchUp(event) {
    event.preventDefault();
    isDown = false;
  }

  // When the player drags his/her finger across the screen,
  // store the new touch position if and only if the player
  // is actually dragging the entity
  function doOnTouchMove(event) {
    event.preventDefault();
    var touch = event.changedTouches;

    if (isDown) {
      pos.x = touch.pageX;
      pos.y = touch.pageY;
    }
  }

  // Reposition the player's entity so that its center is placed
  // right below the player's finger
  this.centerEntity = function() {
    if (isDown) {
      var sprite = entity.getComponent("sprite");

      if (sprite) {
        var size = sprite.getSize();
        var x = pos.x - size.width / 2;
        var y = pos.y - size.height / 2;

        entity.setPosition({x: x, y: y});
      }
    }
  };

  this.getPosition = function() {
    return pos;
  };
};

接下来,让我们看看任何具有移动实体的游戏中非常关键的组件,即“物理”组件,其唯一责任是告诉两个实体是否发生碰撞。这是以一种非常简单和高效的方式完成的。为了使实体能够使用“物理”组件,它还必须具有“精灵”组件,因为“物理”组件需要知道每个实体的位置以及每个实体的高度和宽度。有了“精灵”组件,我们能够提取关于每个实体的这两个信息。

检查两个实体是否发生碰撞的方法非常简单。组件本身存储了对实体的引用,因此执行检查的函数需要知道我们要检查的实体的位置和大小。一旦我们有了两个实体的位置和尺寸,我们只需检查一个实体的右侧是否在另一个实体的左侧,一个实体的左侧是否在另一个实体的右侧,或者一个实体的底部是否在另一个实体的顶部,以及一个实体的顶部是否在另一个实体的底部之下。如果这些测试中有任何一个通过(换句话说,条件检查返回正值),那么我们知道没有发生碰撞,因为两个矩形不可能相交,但是关于它们的四个陈述中有任何一个是真的。同样,如果所有这些测试失败,我们知道实体相互交叉,并且发生了碰撞。

var Packt = Packt || {};
Packt.Components = Packt.Components || {};

Packt.Components.Physics = function(entity) {
  var entity = entity;

  // Check if these two rectangles are intersecting
  this.collide = function(x, y, w, h) {
    var sprite = entity.getComponent("sprite");
    if (sprite) {
      var pos = entity.getPosition();
      var size = sprite.getSize();

      if (pos.x > x + w) {
        return false;
      }

      if (pos.x + size.width < x) {
        return false;
      }

      if (pos.y > y + h) {
        return false;
      }

      if (pos.y + size.height < y) {
        return false;
      }

      return true;
    }

    return false;
  };

  // Return the entity's location and dimensions
  this.getBodyDef = function() {
    var pos = entity.getPosition();
    var sprite = entity.getComponent("sprite");
    var size = sprite.getSize() || {width: 0, height: 0};

    return {
      x: pos.x,
      y: pos.y,
      width: size.width,
      height: size.height
    };
  };
};

游戏中使用的最后两个组件非常简单,与其他组件相比,它们对这种特定类型的游戏稍微更加独特。这些组件是“力量”组件和“激光枪”组件,它们赋予实体向其他实体发射激光束的能力。

“力量”组件隔离了玩家自己的能量管理以及所有敌人飞船和所有激光的管理。该组件用于确定实体是否仍然活着,以及它在接触其他实体时能够造成多少伤害。如果一个实体不再活着(如果它的力量已经降到零以下),那么它将被从游戏中移除,就像激光每次与另一个实体碰撞时一样。

var Packt = Packt || {};
Packt.Components = Packt.Components || {};
Packt.Components.Strength = function(pEntity, pHP, pEnergy) {
  var entity = pEntity;
  var hp = pHP;
  var energy = pEnergy;

  // This is how much damage the entity causes to other entities
  // upon collision between the two
  this.getHP = function() {
    return hp;
  };

  // This represents how much energy the entity has left. When
  // the energy gets to or below zero, the entity dies
  this.getEnergy = function() {
    return energy;
  };

  // Update the entity's energy levels
  this.takeDamage = function(damage) {
    energy -= damage;
    return energy;
  };
};

“激光枪”组件稍微复杂,因为它包含一个它管理的实体集合。每次包含激光枪的实体发射激光束时,都会创建一个新实体来表示该激光束。这个实体与游戏中的所有其他实体类似,因为它还包含一个“精灵”组件来绘制自己,一个“移动”组件和一个“物理”组件。

每次激光枪更新自身时,它需要将所有激光向前移动,并在激光超出屏幕范围时从其控制中移除任何激光束。

var Packt = Packt || {};
Packt.Components = Packt.Components || {};

Packt.Components.LaserGun = function(entity, canvas, maxShots) {
  var entity = entity;
  var MAX_SHOTS = maxShots;
  var canvas = canvas;
  var shots = new Array();
  var shotsPerSec = 1000 / 15;
  var timeLastShot = 0;

  // Move all lasers forward, and remove any lasers outsidethe screen
  this.update = function() {
    for (var i = 0, len = shots.length; i < len; i++) {
      try {
        shots[i].update();
        var shotPos = shots[i].getPosition();

        if (shotPos.y < -100) {
          shots.splice(i, 1);
        }
      } catch (e) {}
    }
  };

  // Create a new laser entity, and assign all of the components
  // it will need in order to actually destroy other ships
  this.add = function(x, y) {
    var time = Date.now();

    // Don't add a new laser until at least some time has passed,
    // so that we don't fire too many lasers at once
    if (time - timeLastShot >= shotsPerSec) {

      // Restrict the amount of lasers that can be on the screenat once
      if (shots.length < MAX_SHOTS) {
        var shot = new Packt.Entity(Packt.ENTITY_TYPES.BULLET, x, y);
        var spriteComp = new Packt.Components.Sprite(
          shot, "./img/laser-blue.png", 8, 32);
        spriteComp.setCtx(canvas.getContext());
        var strengthComp = new Packt.Components.Strength(shot, 10, 0);
        var physComp = new Packt.Components.Physics(shot);
        var mockMove = new Packt.Components.Move(shot, 15);

        shot.addComponent("sprite", spriteComp);
        shot.addComponent("move", mockMove);
        shot.addComponent("physics", physComp);
        shot.addComponent("strength", strengthComp);

        shot.setOnUpdate(function() {
          mockMove.setDirection(0, -1);
          mockMove.update();
        });

        shots.push(shot);
      }

      timeLastShot = time;
    }
  };

  // Return a list of active shots
  this.getShots = function() {
    return shots;
  };
};

有了我们的主要组件,我们准备好看看游戏中的其他类了。不过,请记住,使用组件的整个目的是简化开发,并放松各个功能单元之间的耦合。因此,如果我们想要向游戏中添加更多组件,比如爆炸效果组件,我们只需要遵循相同的组件基本结构,就可以简单地将其插入到主游戏逻辑脚本中。

/entities

实体是游戏的主要构建块。它们是我们可以与之交互的任何东西的概括表示——玩家的飞船、敌人的飞船或激光束。有些人称它们的实体为对象、角色或者演员,但它们背后的理念是一样的。

在我们的游戏中,我们不扩展基本实体类,以区分飞船和激光。唯一区别它们的是它们使用的组件以及这些组件的使用方式。

我们游戏实体的结构是基本且简洁的。每个实体都跟踪其在游戏世界中的位置,一个指示其状态的标志(实体是否活动或死亡),一个组件列表和一个更新函数。此外,为了简化,每个实体声明一个“绘制”函数,将实际绘制委托给“精灵”组件,如果实体恰好有一个。我们还在每个实体内定义了一些通用函数,以便更轻松地添加、移除和使用组件。最后,每个实体都允许自定义更新函数,以便每个实例化的实体可以以不同的方式更新自己。

var Packt = Packt || {};
Packt.ENTITY_TYPES = {
  SHIP: 0,
  BULLET: 1
};

Packt.Entity = function(type, x, y) {
  var type = type;
  var pos = {
    x: x,
    y: y
  };

  var isActive = true;
  var components = new Object();

  // Make this function empty by default, and allow the user tooverride it
  var update = function(){};

  // Add a component to this entity if one by this name has notyet been added
  function addComponent(key, component) {
    if (!components[key]) {
      components[key] = component;
    }

    return component;
  }

  // Attempt to remove an entity by its name
  function removeComponent(key) {
    if (components[key]) {
      return delete components[key];
    }

    return false;
  }

  // Return a reference to a component
  function getComponent(key) {
    return components[key] || null;
  }

  // Draw this component
  function draw() {
    if (components.sprite) {
      components.sprite.update();
    }
  }

  // Expose these functions through a public interface
  this.addComponent = addComponent;
  this.removeComponent = removeComponent;
  this.getComponent = getComponent;
  this.getPosition = function() {
    return pos;
  };

  this.setPosition = function(newPos) {
    pos = newPos;
  };

  this.isActive = function() {
    return isActive;
  };

  this.setActive = function(active) {
    isActive = active;
  };

  this.draw = draw;
  this.update = update;
  this.update = function() {
    update();
  };
  // Save a reference to a new update callback function
  this.setOnUpdate = function(cb){
    update = cb;
  };
};

正如你所看到的,这个实体类实际上非常简单。它考虑了我们的游戏需要做什么,在游戏中使用了什么,并根据这些封装了最常见的功能。从这里,我们可以实例化一个实体并向其添加组件,使其成为一个非常独特的实体,基于它可能能够做和不能做的一切。

/widgets

这个游戏中唯一使用的小部件是“能量条”小部件。小部件的整个目的是简化不同用户界面元素的管理。每个小部件决定如何显示它们代表的元素的方式是它们自己的事情,任何使用它们的客户端代码只需要关心与小部件进行通信的接口。

EnergyBar小部件的作用是在页面顶部显示一个横条,表示玩家剩余的能量。每次玩家被敌舰击中时,其能量水平都会下降一定量。当能量计归零时,玩家死亡,游戏结束。

能量条的另一种渲染方式是通过画布 API,在游戏画布上直接渲染小部件。虽然这也是一个非常可接受的解决方案,也是一个非常常见的解决方案,但我决定只是使用一个普通的 DOM 元素。这样,样式可以更容易地通过 CSS 进行更改,而代码内部不需要进行任何更改。换句话说,当有人在实际代码上工作时,另一个人可以在小部件的样式上工作,他们需要访问的只是与之相关的样式表。

var Packt = Packt || {};
Packt.Widgets = Packt.Widgets || {};

Packt.Widgets.EnergyBar = function(cssClass) {
  var energy = 100;

  // Create the DOM element to represent this widget on screen
  var container = document.createElement("div");
  container.classList.add(cssClass);

  var bar = document.createElement("div");
  bar.style.width = energy + "%";
  container.appendChild(bar);

  // Return the DOM element so it can be appended to the document
  this.getElement = function() {
    return container;
  };

  // Increase the player's energy level and update the DOM element
  // that represents it on screen. To decrease the energy level, simply
  // pass a negative number to this function
  this.addEnergy = function(amount) {
    energy += amount;
    bar.style.width = energy + "%";
  };

  // Set the energy level directly, instead of just adding to
  // or removing from it
  this.setEnergy = function(amount) {
    energy = amount;
    bar.style.width = energy + "%";
  };
};

当实例化EnergyBar小部件时,它会创建一个表示小部件的 DOM 元素,添加任何与之相关的 CSS 类和 ID。成员属性 energy 表示实体的能量量,由小部件创建的 DOM 元素之一的宽度与其包含的能量百分比相匹配。在小部件的元素添加到文档后,我们可以通过其公共接口简单地与小部件类通信,文档上显示的 DOM 元素会相应地更新。

Canvas.js

除了EnergyBar小部件之外,游戏中渲染到屏幕上的其他所有内容都是通过画布使用 2D 渲染上下文渲染的。为了使代码更有条理并保持一致,我们在画布 API 上创建了一个非常简单的抽象。我们封装了画布元素、对其的 JavaScript 引用以及对渲染上下文的引用,而不是跟踪引用一些 DOM 元素的画布变量以及其相应的上下文引用,我们将所有这些都封装在一个对象中。

// Namespace the canvas abstraction
var Packt = Packt || {};

// Construct a canvas of an arbitrary size
Packt.Canvas = function(w, h) {
  var width = w;
  var height = h;
  var canvas = document.createElement("canvas");

  canvas.width = width;
  canvas.height = height;

  var ctx = canvas.getContext("2d");

  this.getCanvas = function() {
    return canvas;
  };

  this.getContext = function() {
    return ctx;
  };

  this.getWidth = function() {
    return width;
  };

  this.getHeight = function() {
    return height;
  };

  // Allow the client to clear the entire rendering buffer without
  // needing to know how things are done under the hood, andwithout
  // anyone needing to worry about the width and height of thecanvas
  this.clear = function() {
    ctx.clearRect(0, 0, width, height);
  };
};

我们还通过添加一些辅助函数(如getWidthgetHeightclear)隐藏了画布 API 的一些详细功能,以便代码中的其他区域可以通过这个简化的接口与画布交互。

另一个这样的抽象可以非常方便的原因是,如果我们决定使用两个或更多的画布,它将极大地简化事情。假设我们想要将一个小部件渲染到自己的画布中。如果没有这样的抽象,我们现在将在我们的代码中有四个单独的变量需要跟踪。

在使用 2D 画布进行 HTML5 游戏渲染时,一个常见的优化模式是将渲染分成层。例如,很少从一帧到另一帧改变的东西(比如一个级别的背景图形)可以比需要在每一帧渲染到不同位置的动态对象(玩家和试图杀死英雄的敌人)更少地重新渲染。我们可以将整个背景场景绘制到自己的画布上,并将其绝对定位在另一个只在其较小部分上绘制的画布后面,这样更容易在每一帧重新绘制。

由于背景层很少甚至根本不会改变,我们可以在其上渲染更复杂的图形,而不必担心经常重绘任何东西。虽然前景层通常需要在每一帧清除和重绘,但我们仍然可以保持良好的帧速率,因为我们通常只在前景画布的一小部分上进行渲染,这不需要像在每一帧重新绘制背景层那样多的处理。

Canvas.js

现在很容易看出,当使用更高级的渲染技术时,一个简单的画布抽象是多么有价值。在大多数情况下,即使我们只是在一个画布上渲染,能够封装与画布相关的所有松散变量通常会使事情更有效,特别是当您需要将画布和画布上下文传递给其他函数和类时。

EnemyManager.js

由于我们的游戏玩家在整个游戏过程中只控制一个实体,因此创建一个实体类的实例并让玩家控制该实体是微不足道的。挑战在于找到一种方法来创建敌人实体,移动它们,并在游戏进行时管理它们。为了解决这个问题,我们创建了一个EnemyManager类,其工作是在需要时创建敌人实体并管理它们的存在。

虽然这可能看起来是一个复杂的任务,但如果我们将任务分解成更小的部分,它就会变得更容易处理。EnemyManager类的职责包括创建一个新的敌人实体并将其添加到其存储的活动实体列表中,单独更新每个实体,并从其管理的实体列表中删除任何死亡实体。

// Namespace the enemy manager object
var Packt = Packt || {};

Packt.EnemyManager = function(canvas) {
  var entities = new Array();
  var canvas = canvas;
  var worldWidth = canvas.getWidth();
  var worldHeight = canvas.getHeight();

  // By returning the list of active enemies to the client code,
  // we can pass on the responsibility of rendering each entity,
  // as well as allow other components to interact with theentities
  this.getEntities = function() {
    return entities;
  };

  // Create a new entity at a certain screen location, along
  // with a list of components
  function addEnemies(x, y, components) {
    var entity = new Packt.Entity(Packt.ENTITY_TYPES.SHIP, x || 0,y || -100);
    for (var c in components) {
      entity.addComponent(c, components[c]);
    };

    var strengthComp = new Packt.Components.Strength(entity, 0.5, 25);
    var physComp = new Packt.Components.Physics(entity);
    var mockMove = new Packt.Components.Move(entity, (Math.random() * 5 >> 0) + 2);

    var enemySprite = "./img/enemy-red.png";

    // Randomly assign a different skin to the sprite component
    if (parseInt(Math.random() * 100) % 2 == 0) {
      enemySprite = "./img/spaceship.png";
    }

    var spriteComp = new Packt.Components.Sprite(entity, enemySprite, 64, 64);

    spriteComp.setCtx(canvas.getContext());
    spriteComp.setSpriteCoords(0, 0, 64, 64);
    entity.addComponent("sprite", spriteComp);
    entity.addComponent("move", mockMove);
    entity.addComponent("physics", physComp);
    entity.addComponent("strength", strengthComp);

    // Randomly assign a starting direction to each entity
    var randPathX = (Math.random() * 100 % 10) - 5;
    var randPathY = (Math.random() * 100 % 50) + 10;
    entity.setOnUpdate(function() {
      mockMove.setDirection(randPathX, 1);
      mockMove.update();
    });

    entities.push(entity);
  }

  this.add = addEnemies;

  // Remove dead entities from our management
  this.remove = function(entity) {
    for (var i = 0, len = entities.length; i < len; i++) {
      if (entities[i] === entity) {
        entities.splice(i, 1);
        return entity;
      }
    }

    return null;
  };

  // Update each entity's position, and remove dead entities
  this.update = function() {
    var enemiesDeleted = 0;
    for (var i = 0, len = entities.length; i < len; i++) {
      try {
        entities[i].update();

        var pos = entities[i].getPosition();

        if (pos.y > worldHeight + 100 || !entities[i].isActive())
        {
          entities.splice(i, 1);
          enemiesDeleted++;
        }

        if (pos.x < -100) {
          pos.x = worldWidth + 50;
          entities[i].setPosition(pos);
        } else if (pos.x > worldWidth + 100) {
          pos.x = -50;
          entities[i].setPosition(pos);
        }
      } catch (e) {}
    }

    if (enemiesDeleted > 0) {
      for (var i = 0; i < enemiesDeleted; i++) {
        var offset = (Math.random() * 100 >> 0) % (worldWidth / 75 >> 0);
        var x = 50 * offset + 25 + (25 * offset);
        var y = 0 - Math.random() * 100 - 100;
        addEnemies(x, y, {});
      }
    }
  };
};

由于我们正在使用基于组件的架构,这三个任务一点也不复杂。为了创建一个新实体,我们只需实例化该类并添加它需要的必要组件。为了增加游戏的变化,我们可以随机为每个创建的实体分配不同的sprite,并随机调整每个实体的属性,比如使其移动更快,造成更多伤害,看起来更大等等。

删除死亡实体更容易。我们只需要遍历活动实体列表,并在实体的活动标志未设置时将其从列表中删除。我们还可以做的一件事是删除任何漫游得太远的实体,这样我们就不需要管理玩家激光束不可能击中的实体。

最后,更新函数负责更新每个活动实体的位置(或者说,它告诉每个实体根据它们的方向更新自己的位置),通过移动每个实体向前模拟一些基本的人工智能,然后删除任何死亡实体。

GameLoop.js

游戏循环类负责每帧运行游戏逻辑。使用这样的类的主要附加值是,我们可以封装这些样板功能,并以最小的努力重复使用不同设置。

// Namespace the game loop class
var Packt = Packt || {};

Packt.GameLoop = function(fps) {
  var fps = fps;
  var frameDelay = 1000 / fps;
  var lastFrameTime = 0;
  var isRunning = true;

  // By default, the game tick is empty, indicating that we expect
    the client
  // to provide their own update function
  var update = function(){};

  // Once the game loop object is set to running, this functionwill be called
  // as close to the specified frame rate as it can, until theclient code
  // sets the object's running state to false
  function run(time) {
    if (isRunning) {
      var delta = time - lastFrameTime;

      if (delta >= frameDelay) {
        update();
        lastFrameTime = time;
      }

      requestAnimationFrame(run);
    }
  }

  // Allows client code to start/stop the game loop
  this.setRunning = function(running) {
    isRunning = running;
    return isRunning;
  };

  this.isRunning = function() {
    return isRunning;
  };

  this.run = run;

  // Allows client code to override default update function
  this.setOnUpdate = function(cb){
    update = cb;
  };
};

当我们创建这个类的实例时,我们告诉它我们希望游戏循环以每秒多少帧的速度运行,然后这个类会处理剩下的事情。设置好之后,类将以我们告诉它的任何频率调用自己的更新函数。作为额外的奖励,我们还可以指定我们自己的更新函数,在每次游戏循环滴答时执行。

PhysicsManager.js

EnemyManager类类似,PhysicsManager类负责隔离复杂功能,使客户端代码更清晰,并且功能可以在其他地方重复使用。由于这个类涉及的内容更多一些,我们不会在书中展示它的完整源代码。和其他章节一样,可以在 Packt 的网站上查看这本书的内容。

总之,PhysicsManager类接受所有敌人实体的引用(它可以从EnemyManager对象中获取),所有玩家的激光束以及玩家的实体。然后,在其更新方法中,它检查所有这些实体之间的碰撞。

Vec2.js

由于这款游戏的“物理”引擎大量使用向量结构,并且 JavaScript 不提供原生向量数据类型,因此我们决定创建自己的向量。这个简单的类表示一个具有两个分量的向量,并提供了一个规范化向量的函数。当我们想要以任何面向的方向移动实体时,这将特别有用。

main.js

最后,我们将所有内容整合到一个文件中,我们也可以称之为main.js。这个文件看起来非常像我去快餐店时的样子:拿一切,看看它们如何组合在一起。首先我们实例化一个画布对象,然后是玩家实体,一个EnemyManager对象,一个PhysicsManager对象,最后是一个游戏循环对象。在一切都连接好之后,我们启动游戏循环,游戏就开始了。

(function main(){
  var WIDTH = document.body.offsetWidth;
  var HEIGHT = document.body.offsetHeight;
  var MAX_ENEMIES = 100;

  // The main canvas where the game is rendered
  var canvas = new Packt.Canvas(WIDTH, HEIGHT);
  document.body.appendChild(canvas.getCanvas());

  // The energy widget
  var playerEnergy = new Packt.Widgets.EnergyBar("energyBar");
  document.body.appendChild(playerEnergy.getElement());

  // The player entity, along with its required components
  var player = new Packt.Entity(Packt.ENTITY_TYPES.SHIP,
    canvas.getWidth() / 2, canvas.getHeight() - 100);

  var playerLaserGunComp = new Packt.Components.LaserGun(player, canvas, 10);
  var playerStrengthComp = new Packt.Components.Strength(player, 0, 100);
  var playerMoveComp = new Packt.Components.Drag(player, canvas);
  var playerPhysComp = new Packt.Components.Physics(player);
  var playerSpriteComp = new Packt.Components.Sprite(player, "./img/fighter.png", 64, 64);
  playerSpriteComp.setCtx(canvas.getContext());
  playerSpriteComp.setSpriteCoords(64 * 3, 0, 64, 64);
  player.addComponent("sprite", playerSpriteComp);
  player.addComponent("drag", playerMoveComp);
  player.addComponent("physics", playerPhysComp);
  player.addComponent("strength", playerStrengthComp);
  player.addComponent("laserGun", playerLaserGunComp);

  // Override the player's update function
  player.setOnUpdate(function() {
    var drag = player.getComponent("drag");
    drag.centerEntity();

    var pos = player.getPosition();
    var laserGun = player.getComponent("laserGun");
    laserGun.add(pos.x + 28, pos.y);
    laserGun.update();
  });

  // The enemy manager
  var enMan = new Packt.EnemyManager(canvas);
  for (var i = 0; i < MAX_ENEMIES; i++) {
    var offset = i % (WIDTH / 75 >> 0);
    var x = 50 * offset + 25 + (25 * offset);
    var y = -50 * i + 25 + (-50 * i);
    enMan.add(x, y, {});
  }

  // The physics manager
  var phy = new Packt.PhysicsManager();
  phy.setPlayer(player);

  // The game loop, along with its overriden update function
  var gameLoop = new Packt.GameLoop(60);
  gameLoop.setOnUpdate(function() {
    // Check if game is over
    if (playerStrengthComp.getEnergy() < 0) {
      document.body.classList.add("zoomOut");

      var ctx = canvas.getContext();
      ctx.globalAlpha = 0.01;

      gameLoop.setRunning(false);
    }

    // Add everyone to the physics manager to check for collision
    var enemies = enMan.getEntities();
    for (var i = 0, len = enemies.length; i < len; i++) {
      phy.addEnemy(enemies[i]);
    }

    var playerLasers = playerLaserGunComp.getShots();
    for (var i = 0, len = playerLasers.length; i < len; i++) {
      phy.addPlayerShots(playerLasers[i]);
    }

    // Update positions
    enMan.update();
    player.update();

    // Check for collisions
    phy.checkCollisions();

    // Draw
    canvas.clear();
    for (var i = 0, len = enemies.length; i < len; i++) {
      enemies[i].draw();
    }

    for (var i = 0, len = playerLasers.length; i < len; i++) {
      playerLasers[i].draw();
    }

    player.draw();
    playerEnergy.setEnergy(playerStrengthComp.getEnergy());
  });

  // Get the game going
  gameLoop.run();
})();

自调用主函数的主要原因是将函数中包含的所有变量私有化,以防止用户从浏览器的 JavaScript 控制台操纵游戏。如果游戏变量都存储在全局范围内,任何有权访问的人都可以操纵游戏状态。此外,由于这个函数只是一个设置函数,这将是放置基于执行脚本的用户代理加载备用资源的任何条件逻辑的理想位置。

index.html

这款游戏的主机页面不可能再简洁了。我们在这个文件中所做的就是加载所有资源。由于不同的组件有时依赖于我们游戏中定义的其他组件或模块(并且由于 JavaScript 没有加载单个组件到脚本的机制),我们 JavaScript 资源的加载顺序非常重要。

<!doctype html>
<html>
  <head>
    <meta charset="utf-8" />
    <title>2D Space Shooter</title>
    <link rel="stylesheet" href="./css/style.css" />
  </head>

  <body class="animBody">
    <script src="img/Vec2.js"></script>
    <script src="img/Sprite.js"></script>
    <script src="img/Move.js"></script>
    <script src="img/Entity.js"></script>
    <script src="img/Canvas.js"></script>
    <script src="img/GameLoop.js"></script>
    <script src="img/TouchDrag.js"></script>
    <script src="img/Physics.js"></script>
    <script src="img/Strength.js"></script>
    <script src="img/LaserGun.js"></script>
    <script src="img/PhysicsManager.js"></script>
    <script src="img/EnemyManager.js"></script>
    <script src="img/EnergyBar.js"></script>
    <script src="img/main.js"></script>
  </body>
</html>

移动优化

在最后一部分,让我们看看游戏的一些方面,我们可以(也应该)特别针对移动设备进行优化。虽然下面讨论的一些优化也在桌面优化方面有重叠,但在移动 Web 开发中它们尤其有影响。

合并资源

虽然编写松散、模块化的代码是一个好习惯,但我们不能止步于此。在将应用程序部署到生产服务器之前,我们最好至少将所有这些文件合并成一个单一文件。最简单的方法就是简单地连接每个文件,并提供更大的文件而不是多个文件。

这种做法优于向客户端发送多个单独的文件,因为在对同一服务器的一定数量的并发连接之后,浏览器将排队后续连接,并且加载所有文件所需的总时间将增加。

此外,在所有资源合并成单一资源之后,我们还应该使用其中一种许多可用的工具,让我们压缩、缩小、混淆和丑化我们的代码。我们可以尽量减少代码的字节数,这对于移动玩家来说是一个巨大的胜利。其中一个特别强大的工具是由谷歌开发的流行的开源 Closure 编译器。在其众多功能中,Closure 编译器还提供了一个分析最终代码并删除任何不可达、死代码的功能。这样做将进一步减少应用程序代码的最终大小,特别适用于在有限网络连接上下载,比如今天大多数移动设备上的连接。

通过 ID 跟踪触摸

我们编写的组件处理触摸用户输入的方式假设在任何时候只会使用一个触摸。虽然这种假设在我们的游戏中大部分时间可能是正确的,但在其他游戏中可能并非如此。 TouchDrag组件总是在更改的触摸列表中查找第一个触摸对象上的触摸信息。唯一的问题是原始触摸对象可能并不总是其父数组中的第一个数组元素。

要改变这一点,我们只需要跟踪首次触摸屏幕的手指的触摸 ID,然后根据其识别值引用该触摸。

Packt.Components.TouchDrag = function(entity, canvas) {
  var touchId = 0;

  // When a successful touch is first captured, cache the touch'sidentification
  function doOnTouchDown(event) {
    event.preventDefault();
    var phy = entity.getComponent("physics");
    var touch = event.changedTouches;

    if (phy) {
      touchId = touch.identifier;
      isDown = phy.collide(touch[touchId].pageX, touch[touchId].pageY, 0, 0);
    }
  }

  // Clear the touch flag on the entity, as well as the touch id
  function doOnTouchUp(event) {
    event.preventDefault();
    isDown = false;
    touchId = 0;
  }

  // Always move the entity based on the cached touch id
  function doOnTouchMove(event) {
    event.preventDefault();
    var touch = event.changedTouches;

    if (isDown) {
      pos.x = touch[touchId].pageX;
      pos.y = touch[touchId].pageY;
    }
  }
};

通过跟踪原始触摸并仅对其做出响应,我们可以保证触摸输入的准确性,即使在屏幕上发起多次触摸。这也是跟踪单独触摸的正确方式,以实现基于触摸的手势或其他输入触发器的目的。

谨慎使用 CSS 动画

在移动浏览器中有时会出现一种奇怪的现象,当我们对一些较新的 CSS 属性过于慷慨时。例如,如果我们向一个元素添加一个盒子阴影,我们仍然可以获得相当强的性能。可选地,如果我们向另一个元素添加 CSS 过渡,性能仍然可以保持。然而,如果这两个属性都被同时分配,那么性能突然下降到几乎无法游玩的条件。

由于没有一个描述应该使用哪些属性,不应该使用哪些属性,以及应该避免哪些组合的公式,因此这里的建议是尽可能少地使用 CSS 属性,并慢慢添加它们。在我们的游戏中,桌面版本大量使用 CSS 动画来渲染背景,我们需要考虑这对移动设备可能产生的影响。在今天最流行的两个移动平台上尝试效果后,看到性能严重下降,我们得出结论,我们想要的特定动画,以及不断渲染的画布,对移动处理器来说太过于繁重。

确定特定的 CSS 动画在移动设备上是否过于苛刻的一种方法是使用诸如 Google 开发者工具之类的性能分析工具,并注意浏览器需要做的工作类型,以实现所需的动画。在这种情况下,例如在这个游戏中,一个背景细节的计算密集型生成与简单地玩游戏所需的计算发生冲突,我们可能会选择一个不那么苛刻的替代方案。在这个游戏中,我们不是将 CSS 动画加载到文档主体上,而是简单地显示一个静态背景图。

为每个游戏层使用单独的画布

正如前面简要讨论的那样,在 HTML5 渲染中一个强大的优化技术是使用多个画布。重点是尽可能少地渲染那些只需要偶尔渲染的东西。那些需要更频繁渲染的东西,我们在专用的画布上单独渲染,这样就不会使用 CPU(或 CPU)功率来渲染这些元素周围的细节。

例如,游戏的背景场景通常在几帧内保持不变。我们可以只在屏幕滚动或场景发生变化时再次渲染背景场景,而不是清除整个画布上的所有像素,然后再次绘制这些完全相同的像素。在此之前,这个画布就不需要被打扰。任何需要每秒渲染多次的可移动对象和实体都可以在第二个画布上渲染,带有透明背景,以便可以透过看到背景层。

在这个游戏中,我们完全可以将用作背景图形的图像渲染到一个专用的背景层上,然后以这种方式将背景动画提供给画布。然而,由于 HTML5 提供了一个产生相同效果的类似函数,我们选择了这个方法。

使用图像图集

图像图集背后的想法真的非常聪明。由于画布 API 指定了一个允许我们从源图像绘制到画布上的函数,指定了像素复制将发生的图像区域,我们可以简单地使用一个主图像,从中可以绘制出所有我们的图形资产。

我们可以将所有图像捆绑到单个图集文件中,然后从较大的拼贴画的某个部分绘制每个资产,而不是将多个松散图像从服务器发送到客户端。

以下是一个图像地图集,其中包含许多较小的图像,这些图像并排放置,允许我们从单个图像资产中检索每个单独的图像。这种技术的主要好处之一是,我们只需要向服务器发送一个 HTTP 请求,即可访问图集中使用的所有图像。

使用图像地图集

当然,使用这种技术的挑战在于我们需要一种方法来知道图集文件中每个特定图像的位置。虽然在这样一个小项目上手工操作可能不会显得太麻烦,但这个任务的复杂性会非常快地变得混乱。

存在许多开源工具可用于解决这个问题。这些工具将单独的松散图像文件捆绑在一起,生成可能从提供的图像列表中生成的最小图集,并生成一个 JSON 文件,我们可以使用它来将每个松散图像映射到它们在图集中的新表示。

总结

本章专门讨论了可用的新 HTML5 API 进行移动开发。我们谈到了在开放网络平台上针对移动设备的游戏开发者的巨大机会,以及与之相关的一些主要挑战。我们谈到了移动 Web 开发的一些最佳实践,包括优雅降级和渐进增强,为所有手指大小设计,尽可能节省电池寿命,规划离线游戏玩法,并提供应用程序的桌面版本。

本章介绍的最后两个 API 是 CSS 媒体查询和 JavaScript 触摸事件。媒体查询允许我们检查文档查看的用户代理的其他属性,例如视口宽度、高度、分辨率、方向等。根据执行我们的应用程序的用户代理上设置的属性,我们可以使用媒体查询来加载不同的 CSS 规则和文档,有效地在运行时修改文档样式。新的 JavaScript 触摸事件与鼠标事件不同,因为允许同时触摸多个触摸,以及压力检测、触摸大小和旋转角度。

现在您已经了解了 HTML5 的新功能,包括最新的 CSS 和 JavaScript API,下一步就是让您进行一些键盘操作,开始为有史以来最大最激动人心的计算平台——开放网络开发您自己的游戏。祝您游戏愉快!

posted @ 2024-05-24 11:15  绝不原创的飞龙  阅读(19)  评论(0编辑  收藏  举报