案例研究 - 将 Wordico 从 Flash 转换为 HTML5

Adam Mark
Adam Mark
Adrian Gould
Adrian Gould

简介

当我们将 Wordico 填字游戏从 Flash 转换为 HTML5 时,我们的第一个任务是忽略所有已掌握的关于在浏览器中打造丰富用户体验的知识。Flash 为应用程序开发的所有方面(从矢量绘制、多边形命中检测到 XML 解析)提供了一个单一、全面的 API,而 HTML5 则提供了一系列规范,支持不同的浏览器。我们还想知道 HTML(一种文档特定的语言)和一种 CSS(一种以框为中心的语言)是否适合构建游戏。游戏是否会像在 Flash 中一样在所有浏览器中显示,并且外观和行为都一样美观?对于 Wordico,答案是

Victor,你的媒介是什么?

我们仅使用矢量图形(线条、曲线、填充和渐变)开发了原始版本的 Wordico。其结果既紧凑,又具有无限扩缩能力:

Wordico 线框图
在 Flash 中,每个展示对象都是由矢量形状制成的。

我们还利用 Flash 时间轴创建了具有多个状态的对象。例如,我们为 Space 对象使用了九个已命名的关键帧:

Flash 中的三个字母空格。
Flash 中的三个字母空格。

不过,在 HTML5 中,我们使用的是位图精灵:

显示所有九个空格的 PNG 精灵图。
显示全部九个空格的 PNG 精灵图。

为了根据各个空格创建 15x15 游戏板,我们迭代了 225 个字符的字符串表示法,其中每个空格都由不同的字符表示(例如,“t”代表三字母,“T”代表三个单词)。这在 Flash 中非常简单;我们只需标记一些空格,并将其排列成网格即可:

var spaces:Array = new Array();

for (var i:int = 0; i < 225; i++) {
  var space:Space = new Space(i, layout.charAt(i));
  ...
  spaces.push(addChild(space));
}

LayoutUtil.grid(spaces, 15);

在 HTML5 中,则更为复杂。我们使用 <canvas> 元素(位图绘制表面)将游戏板一次绘制一个方形。第一步是加载图片精灵。加载完成后,我们会遍历布局表示法,每次迭代都绘制图像的不同部分:

var x = 0;  // x coordinate
var y = 0;  // y coordinate
var w = 35; // width and height of a space

for (var i = 0; i < 225; i++) {
  if (i && i % 15 == 0) {
    x = 0;
    y += w;
  }

  var imageX = "_dDFtTqQxm".indexOf(layout.charAt(i)) * 70;

  canvas.drawImage("spaces.png", imageX, 0, 70, 70, x, y, w, w);

  x += w;
}

以下是网络浏览器中的结果。请注意,画布本身就有一个 CSS 阴影:

在 HTML5 中,游戏板是单个画布元素。
在 HTML5 中,游戏板是单个画布元素。

转换 Tile 对象就是类似的步骤。在 Flash 中,我们使用了文本字段和矢量形状:

Flash 图块是文本字段和矢量形状的组合
Flash 功能块由文本字段和矢量形状组成。

在 HTML5 中,我们会在运行时在单个 <canvas> 元素上组合三个图片精灵:

HTML 功能块由三张图片组成。
HTML 功能块由三张图片组成。

现在,我们有 100 张画布(每个图块各一张)以及一张游戏板画布。以下是“H”功能块的标记:

<canvas width="35" height="35" class="tile tile-racked" title="H-2"/>

对应的 CSS 如下:

.tile {
  width: 35px;
  height: 35px;
  position: absolute;
  cursor: pointer;
  z-index: 1000;
}

.tile-drag {
  -moz-box-shadow: 1px 1px 7px rgba(0,0,0,0.8);
  -webkit-box-shadow: 1px 1px 7px rgba(0,0,0,0.8);
  -moz-transform: scale(1.10);
  -webkit-transform: scale(1.10);
  -webkit-box-reflect: 0px;
  opacity: 0.85;
}

.tile-locked {
  cursor: default;
}

.tile-racked {
  -webkit-box-reflect: below 0px -webkit-gradient(linear, 0% 0%, 0% 100%,  
    from(transparent), color-stop(0.70, transparent), to(white));
}

当图块被拖动(阴影、不透明度和缩放)以及图块放置在机架上(反射)时,我们会应用 CSS3 效果:

拖动的图块略大、略透明,并带有阴影。
拖动的图块略大、略透明,并带有阴影。

使用光栅图片有一些明显的优势。首先,结果精确到像素。其次,浏览器可以缓存这些图片。第三,稍微多做一些工作,我们就可以更换图像以创建新的图块设计,例如金属图块。这种设计工作可以在 Photoshop 中完成,不必在 Flash 中完成。

缺点呢?通过使用图片,我们放弃了对文本字段的程序化访问权限。在 Flash 中,更改颜色或其他类型的属性是一个简单的操作;在 HTML5 中,这些属性已并入图片本身。(我们尝试了 HTML 文本,但需要大量的额外标记和 CSS。我们还尝试了画布文本,但结果在不同浏览器之间并不一致。)

模糊逻辑

我们希望能够充分利用任意尺寸的浏览器窗口,避免滚动页面。这是 Flash 中相对简单的操作,因为整个游戏都是用矢量绘制的,可以在不降低保真度的情况下进行缩放。但在 HTML 中,它比较复杂。我们尝试使用 CSS 缩放,但最终得到画布的模糊:

CSS 缩放(左侧)与重新绘制(右侧)。
CSS 缩放(左侧)与重新绘制(右侧)。

我们的解决方案是,每当用户调整浏览器大小时,都要重新绘制游戏板、机架和图块:

window.onresize = function (evt) {
...
gameboard.setConstraints(boardWidth, boardWidth);

...
rack.setConstraints(rackWidth, rackHeight);

...
tileManager.resizeTiles(tileSize);
});

最终,我们可以为任何屏幕尺寸提供清晰的图片和令人愉悦的布局:

游戏板会填满垂直空间;其他页面元素在其周围流动。
游戏板会填满垂直空间;其他页面元素在其周围流动。

直奔主题

由于每个图块都已经准确定位,并且必须与游戏板和机架精确对齐,因此我们需要可靠的定位系统。我们使用 BoundsPoint 这两个函数来帮助管理元素在全局空间(HTML 网页)中的位置。Bounds 描述网页上的矩形区域,而 Point 描述相对于网页左上角 (0,0) 的 x,y 坐标,也称为“注册点”。

借助 Bounds,我们可以检测两个矩形元素的交集(例如当功能块穿过机架时),或者矩形区域(例如双字母空间)是否包含任意点(例如图块的中心点)。下面是 Bounds 的实现:

// bounds.js
function Bounds(element) {
var x = element.offsetLeft;
var y = element.offsetTop;
var w = element.offsetWidth;
var h = element.offsetHeight;

this.left = x;
this.right = x + w;
this.top = y;
this.bottom = y + h;
this.width = w;
this.height = h;
this.x = x;
this.y = y;
this.midx = x + (w / 2);
this.midy = y + (h / 2);
this.topleft = new Point(x, y);
this.topright = new Point(x + w, y);
this.bottomleft = new Point(x, y + h);
this.bottomright = new Point(x + w, y + h);
this.middle = new Point(x + (w / 2), y + (h / 2));
}

Bounds.prototype.contains = function (point) {
return point.x > this.left &amp;&amp;
point.x < this.right &amp;&amp;
point.y > this.top &amp;&amp;
point.y < this.bottom;
}

Bounds.prototype.intersects = function (bounds) {
return this.contains(bounds.topleft) ||
this.contains(bounds.topright) ||
this.contains(bounds.bottomleft) ||
this.contains(bounds.bottomright) ||
bounds.contains(this.topleft) ||
bounds.contains(this.topright) ||
bounds.contains(this.bottomleft) ||
bounds.contains(this.bottomright);
}

Bounds.prototype.toString = function () {
return [this.x, this.y, this.width, this.height].join(",");
}

我们使用 Point 来确定页面上任何元素或鼠标事件的绝对坐标(左上角)。Point 还包含用于计算距离和方向的方法,这些方法对于创建动画效果是必需的。Point 的实现代码如下:

// point.js

function Point(x, y) {
this.x = x;
this.y = y;
}

Point.prototype.distance = function (point) {
var a = point.x - this.x;
var b = point.y - this.y;

return Math.sqrt(Math.pow(a, 2) + Math.pow(b, 2));
}

Point.prototype.distanceX = function (point) {
return Math.abs(this.x - point.x);
}

Point.prototype.distanceY = function (point) {
return Math.abs(this.y - point.y);
}

Point.prototype.interpolate = function (point, pct) {
var x = this.x + ((point.x - this.x) * pct);
var y = this.y + ((point.y - this.y) * pct);

return new Point(x, y);
}

Point.prototype.offset = function (x, y) {
return new Point(this.x + x, this.y + y);
}

Point.prototype.vector = function (point) {
return new Point(point.x - this.x, point.y - this.y);
}

Point.prototype.toString = function () {
return this.x + "," + this.y;
}

// static
Point.fromElement = function (element) {
return new Point(element.offsetLeft, element.offsetTop);
}

// static
Point.fromEvent = function (evt) {
return new Point(evt.x || evt.clientX, evt.y || evt.clientY);
}

这些函数构成了拖放和动画功能的基础。例如,我们使用 Bounds.intersects() 来确定图块是否与游戏板上的空间重叠;我们使用 Point.vector() 来确定拖动的图块的方向;我们结合使用 Point.interpolate() 与计时器来创建补间动画效果,即缓和效果。

随遇而安

虽然固定尺寸的版式更容易在 Flash 中生成,但浮动版式通过 HTML 和 CSS 框模型生成起来要容易得多。请参考以下具有可变宽度和高度的网格视图:

此布局没有固定的尺寸:缩略图从左到右、从上到下。
此布局没有固定的尺寸:缩略图从左到右、从上到下。

您也可以参考聊天面板。Flash 版本需要多个事件处理脚本来响应鼠标操作、可滚动区域的蒙版、用于计算滚动位置的数学运算以及将大量其他代码结合在一起的大量其他代码。

Flash 中的聊天面板虽然设计相当复杂,
Flash 中的聊天面板相当复杂。

相比之下,HTML 版本只是具有固定高度且 overflow 属性设置为 hidden 的 <div>。滚动不会消耗任何东西。

CSS 盒子模型的实际运作
CSS 框模型的实际运作。

在这种情况下(普通的布局任务),HTML 和 CSS 会胜过 Flash。

现在你能听到我说话吗?

我们在使用 <audio> 标记时遇到了困难,因为它无法在某些浏览器中重复播放短促的音效。我们尝试了两种解决方法。首先,我们用冷空气来填充声音文件,使其更长一些。然后,我们尝试在多个声道间交替播放。这两种方法都无法完全有效或优雅。

最终,我们决定采用我们自己的 Flash 音频播放器,并使用 HTML5 音频作为后备。下面是 Flash 中的基本代码:

var sounds = new Array();

function playSound(path:String):void {
var sound:Sound = sounds[path];

if (sound == null) {
sound = new Sound();
sound.addEventListener(Event.COMPLETE, function (evt:Event) {
    sound.play();
});
sound.load(new URLRequest(path));
sounds[path] = sound;
}
else {
sound.play();
}
}

ExternalInterface.addCallback("playSound", playSound);

在 JavaScript 中,我们会尝试检测嵌入式 Flash 播放器。如果运行失败,我们将为每个声音文件创建一个 <audio> 节点:

function play(String soundId) {
var src = "/audio/" + soundId + ".mp3";

// Flash
try {
var swf = window["swfplayer"] || document["swfplayer"];
swf.playSound(src);
}
// or HTML5 audio
catch (e) {
var sound = document.getElementById(soundId);
if (sound == null || sound == undefined) {
    var sound = document.createElement("audio");
    sound.id = soundId;
    sound.src = src;
    document.body.appendChild(sound);
}
sound.play();
}
}

请注意,这仅适用于 MP3 文件,但我们不需要支持 OGG。我们希望在不久的将来,广告行业能够达成一致意见。

投票位置

在 HTML5 中,我们使用与 Flash 中相同的技术来刷新游戏状态:客户端每 10 秒就会向服务器请求一次更新。如果自上次轮询以来游戏状态发生了更改,客户端将接收并处理这些更改;否则,不会发生任何情况。这种传统的投票技术即使不是很优雅,也是可以接受的。然而,随着游戏日趋成熟,用户期望通过网络进行实时交互,我们希望改用长轮询WebSockets。特别是,WebSocket 提供了许多增强游戏玩法的机会。

太好了!

我们使用 Google Web Toolkit (GWT) 开发了前端界面和后端控制逻辑(身份验证、验证、持久性等)。JavaScript 本身是从 Java 源代码编译的。例如,Point 函数改编自 Point.java

package com.wordico.client.view.layout;

import com.google.gwt.dom.client.Element;
import com.google.gwt.dom.client.NativeEvent;
import com.google.gwt.event.dom.client.DomEvent;

public class Point {
public double x;
public double y;

public Point(double x, double y) {
this.x = x;
this.y = y;
}

public double distance(Point point) {
double a = point.x - this.x;
double b = point.y - this.y;

return Math.sqrt(Math.pow(a, 2) + Math.pow(b, 2));
}
...
}

某些界面类具有相应的模板文件,其中的页面元素“绑定”到类成员。例如,ChatPanel.ui.xml 对应于 ChatPanel.java

<!DOCTYPE ui:UiBinder SYSTEM "http://dl.google.com/gwt/DTD/xhtml.ent">

<ui:UiBinder
xmlns:ui="urn:ui:com.google.gwt.uibinder"
xmlns:g="urn:import:com.google.gwt.user.client.ui"
xmlns:w="urn:import:com.wordico.client.view.widget">

<g:HTMLPanel>
<div class="palette">
<g:ScrollPanel ui:field="messagesScroll">
    <g:FlowPanel ui:field="messagesFlow"></g:FlowPanel>
</g:ScrollPanel>
<g:TextBox ui:field="chatInput"></g:TextBox>
</div>
</g:HTMLPanel>

</ui:UiBinder>

完整的详细信息不在本文讨论范围之内,但我们建议您查看 GWT,了解您的下一个 HTML5 项目。

为什么使用 Java?首先,针对严格输入。虽然动态类型在 JavaScript 中很有用(例如,能够存储不同类型的值),但在复杂的大型项目中,却可能很麻烦。第二,关于功能重构。不妨想一想,您如何才能轻松更改数千行代码的 JavaScript 方法签名!但有了出色的 Java IDE,一切变得轻而易举。最后,出于测试目的。为 Java 类编写单元测试需要胜过“保存和刷新”这个久负盛名的技术。

摘要

除了音频问题之外,HTML5 也大大超出了我们的预期。Wordico 的外观不仅与 Flash 中一样出色,而且更加流畅和灵敏。如果没有 Canvas 和 CSS3,我们就无法做到这一点。我们的下一个挑战:让 Wordico 适合在移动设备上使用。