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

Adam Mark
Adam Mark
Adrian Gould
Adrian Gould

简介

在将Wordico 填字游戏从 Flash 转换为 HTML5 时,我们首先要做的是忘记自己在浏览器中打造丰富用户体验的所有知识。虽然 Flash 提供了一个全面的 API,可用于应用开发的各个方面(从矢量绘制到多边形碰撞检测到 XML 解析),但 HTML5 提供了一系列规范,浏览器对这些规范的支持各不相同。我们还想知道,HTML(一种专门用于文档的语言)和 CSS(一种以盒子为中心的语言)是否适合构建游戏。游戏在各种浏览器中是否能像在 Flash 中一样统一显示,并且外观和行为是否一样出色?对于 Wordico,答案是

Victor,您的向量是什么?

我们在开发 Wordico 的原始版本时,仅使用了矢量图形:线条、曲线、填充和渐变。最终的结果既非常紧凑,又可无限扩展:

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

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

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

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

显示所有 9 个聊天室的 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 中,棋盘是一个单独的画布元素。

转换功能块对象的过程类似。在 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>

本文无法详细介绍,但我们建议您在下一个 HTML5 项目中使用 GWT。

为什么使用 Java?首先,对于严格类型检查。虽然动态类型在 JavaScript 中很有用(例如,数组能够存储不同类型的值),但在大型复杂项目中,它可能会让人头疼。第二,重构功能。想想如何更改数千行代码中的 JavaScript 方法签名 - 这并不容易!不过,借助优秀的 Java IDE,这一切都很简单。最后,出于测试目的。为 Java 类编写单元测试比使用久经考验的“保存并刷新”方法更有效。

摘要

除了音频问题之外,HTML5 的效果大大超出了我们的预期。Wordico 不仅在外观上与 Flash 版一样出色,在流畅度和响应速度上也丝毫不逊色。如果没有 Canvas 和 CSS3,我们就无法实现这一点。我们的下一项挑战:将 Wordico 改造成适用于移动设备的应用。