World Wide Maze 是一款游戏,您需要使用智能手机操控滚动的球,在由网站创建的 3D 迷宫中前进,尝试到达目标点。
该游戏大量使用了 HTML5 功能。例如,DeviceOrientation 事件会从智能手机检索倾斜度数据,然后通过 WebSocket 将数据发送到 PC,玩家可以在 PC 上通过 WebGL 和 Web Worker 构建的 3D 空间中找到自己的路线。
在本文中,我将详细介绍这些功能的使用方式、整个开发流程以及优化要点。
DeviceOrientation
DeviceOrientation 事件(示例)用于从智能手机检索倾斜度数据。将 addEventListener
与 DeviceOrientation
事件搭配使用时,系统会定期调用一个回调,并将 DeviceOrientationEvent
对象作为参数。间隔时间本身因所用设备而异。例如,在 iOS + Chrome 和 iOS + Safari 中,系统会大约每 1/20 秒调用一次回调,而在 Android 4 + Chrome 中,系统会大约每 1/10 秒调用一次回调。
window.addEventListener('deviceorientation', function (e) {
// do something here..
});
DeviceOrientationEvent
对象包含 X
、Y
和 Z
轴的倾斜度数据(以度 [而非弧度] 为单位)(详见 HTML5Rocks)。不过,返回值也会因所用设备和浏览器的组合而异。实际返回值的范围如下表所示:
顶部以蓝色突出显示的值是 W3C 规范中定义的值。以绿色突出显示的设备符合这些规范,而以红色突出显示的设备则不符合。令人惊讶的是,只有 Android-Firefox 组合返回的值符合规范。不过,在实现方面,更合理的做法是适应频繁出现的值。因此,World Wide Maze 会将 iOS 返回值用作标准值,并相应地针对 Android 设备进行调整。
if android and event.gamma > 180 then event.gamma -= 360
不过,Nexus 10 仍不受支持。虽然 Nexus 10 返回的值范围与其他 Android 设备相同,但存在一个 bug,会将 beta 和 gamma 值颠倒过来。我们会单独处理此问题。(也许它默认采用横向模式?)
这表明,即使涉及实体设备的 API 具有固定规范,也无法保证返回的值会与这些规范相符。因此,请务必在所有预期使用的设备上测试您的应用。这也意味着可能会输入意外值,因此需要创建权宜解决方法。在 World Wide Maze 教程中,系统会在第 1 步中提示首次玩家校准设备,但如果收到意外的倾斜值,则无法正确校准到零位置。因此,它具有内部时间限制,如果无法在该时间限制内校准,系统会提示玩家改用键盘控制。
WebSocket
在《世界迷宫》中,您的智能手机和 PC 通过 WebSocket 连接。更准确地说,它们是通过中继服务器连接的,即智能手机到服务器到 PC。这是因为 WebSocket 无法直接将浏览器连接到彼此。(使用 WebRTC 数据信道可实现点对点连接,无需使用中继服务器,但在实现时,此方法只能与 Chrome Canary 和 Firefox Nightly 搭配使用。)
我选择使用名为 Socket.IO (v0.9.11) 的库进行实现,该库包含在连接超时或断开连接时重新连接的功能。我将其与 NodeJS 搭配使用,因为在多次 WebSocket 实现测试中,这种 NodeJS + Socket.IO 组合表现出了最佳服务器端性能。
按数字配对
- 您的 PC 会连接到服务器。
- 服务器会为您的 PC 分配一个随机生成的数字,并记住该数字与 PC 的组合。
- 在移动设备上,指定一个号码并连接到服务器。
- 如果指定的号码与已连接的 PC 上的号码相同,则表示您的移动设备已与该 PC 配对。
- 如果没有指定的 PC,则会发生错误。
- 当移动设备收到数据时,系统会将其发送到与其配对的 PC,反之亦然。
您也可以改为通过移动设备进行初始连接。在这种情况下,只需将设备反转即可。
标签页同步
Chrome 专有的标签页同步功能可让配对过程变得更加简单。借助此功能,您可以轻松在移动设备上打开在 PC 上打开的网页,反之亦然。PC 会获取服务器发出的连接号,并使用 history.replaceState
将其附加到网页的网址。
history.replaceState(null, null, '/maze/' + connectionNumber)
如果标签页同步功能处于启用状态,网址会在几秒钟后同步,并且您可以在移动设备上打开同一网页。移动设备会检查打开的网页的网址,如果附加了号码,则会立即开始连接。这样,您就不必手动输入数字或使用相机扫描二维码。
延迟时间
由于中继服务器位于美国,因此从日本访问该服务器会导致智能手机的倾斜度数据到达 PC 之前出现大约 200 毫秒的延迟。与开发期间使用的本地环境相比,响应时间明显缓慢,但插入低通滤波(我使用了 EMA)后,响应时间得到了改善,不会造成干扰。(在实践中,为了呈现效果,还需要使用低通滤波器;倾斜传感器的返回值包含大量噪声,将这些值应用于屏幕会导致大量抖动。)这对跳跃没有用,跳跃明显很慢,但无法解决此问题。
由于我从一开始就预计会出现延迟问题,因此考虑在世界各地设置中继服务器,以便客户端能够连接到距离最近的可用服务器(从而最大限度地缩短延迟时间)。不过,我最终使用的是 Google Compute Engine (GCE),而该服务当时仅在美国提供,因此无法实现这一点。
Nagle 算法问题
Nagle 算法通常会集成到操作系统中,以便在 TCP 级别进行缓冲以实现高效通信,但我发现启用此算法后无法实时发送数据。(尤其是与 TCP 延迟确认结合使用时)。即使没有延迟的 ACK
,如果 ACK
因服务器位于海外等因素而出现一定程度的延迟,也会出现同样的问题。)
Chrome for Android 中的 WebSocket 不会出现 Nagle 延迟问题,因为它包含用于停用 Nagle 的 TCP_NODELAY
选项,但 Chrome for iOS 中使用的 WebKit WebSocket 会出现此问题,因为它未启用此选项。(使用相同 WebKit 的 Safari 也存在此问题。此问题已通过 Google 报告给 Apple,并且似乎已在 WebKit 的开发版本中得到解决。
出现此问题时,每 100 毫秒发送一次的倾斜度数据会合并为仅每 500 毫秒到达 PC 的数据块。游戏无法在这些情况下正常运行,因此它会让服务器端以短时间间隔(大约每 50 毫秒)发送数据,以避免出现这种延迟。我认为,以短时间间隔接收 ACK
会欺骗 Nagle 算法,使其认为可以发送数据。
上图显示了实际收到数据的时间间隔。它表示数据包之间的时间间隔;绿色表示输出间隔,红色表示输入间隔。最短为 54 毫秒,最长为 158 毫秒,中间值接近 100 毫秒。我使用的是一部 iPhone,中继服务器位于日本。输出和输入均约为 100 毫秒,且操作流畅。
相比之下,下图显示了使用美国服务器的结果。虽然绿色输出间隔保持在 100 毫秒的稳定值,但输入间隔在 0 毫秒(最低)和 500 毫秒(最高)之间波动,这表明 PC 以分块方式接收数据。
最后,此图表显示了通过让服务器发送占位符数据来避免延迟的结果。虽然其性能不如使用日本服务器时那么出色,但很明显,输入间隔时间保持在 100 毫秒左右,相对稳定。
错误?
尽管 Android 4 (ICS) 中的默认浏览器具有 WebSocket API,但无法连接,导致 Socket.IO 发生 connect_failed 事件。在内部,它会超时,并且服务器端也无法验证连接。(我尚未单独使用 WebSocket 进行测试,因此这可能是一个 Socket.IO 问题。)
扩缩中继服务器
由于中继服务器的角色并不复杂,因此只要确保同一台 PC 和移动设备始终连接到同一台服务器,扩容和增加服务器数量应该不会很难。
物理学
游戏中的球体运动(向下滚动、与地面碰撞、与墙壁碰撞、收集物品等)全部由 3D 物理模拟器完成。我使用了 Ammo.js(使用 Emscripten 将广泛使用的 Bullet 物理引擎移植到 JavaScript 中)以及 Physijs,将其用作“Web Worker”。
网络工作器
Web Worker 是一种用于在单独的线程中运行 JavaScript 的 API。作为 Web Worker 启动的 JavaScript 会作为与最初调用它的线程分离的线程运行,因此可以在保持网页响应能力的同时执行繁重任务。Physijs 高效使用 Web Worker 来帮助通常密集的 3D 物理引擎顺畅运行。World Wide Maze 以完全不同的帧速率处理物理引擎和 WebGL 图片渲染,因此即使低配置机器上的帧速率因 WebGL 渲染负载过重而下降,物理引擎本身也能大致保持 60 fps,不会妨碍游戏控制。
此图片显示了 Lenovo G570 上的最终帧速率。上方框显示的是 WebGL(图片渲染)的帧速率,下方框显示的是物理引擎的帧速率。GPU 是集成的 Intel HD Graphics 3000 芯片,因此图片渲染帧速率未达到预期的 60 fps。不过,由于物理引擎达到了预期的帧速率,因此游戏玩法与高规格机器上的性能相差无几。
由于包含活动 Web Worker 的线程没有控制台对象,因此必须通过 postMessage 将数据发送到主线程,才能生成调试日志。使用 console4Worker 可在 Worker 中创建等同于控制台对象的对象,从而显著简化调试过程。
在较新版本的 Chrome 中,您可以在启动 Web Worker 时设置断点,这对调试也很有用。您可以在开发者工具的“Workers”面板中找到该信息。
性能
多边形数较多的场景有时会超过 10 万个多边形,但即使完全以 Physijs.ConcaveMesh
(在 Bullet 中为 btBvhTriangleMeshShape
)生成这些多边形,性能也不会特别受影响。
最初,随着需要碰撞检测的对象数量的增加,帧速率会下降,但通过在 Physijs 中消除不必要的处理,性能得到了提升。此改进是对原始 Physijs 的分支进行的。
幽灵对象
在 Bullet 中,具有碰撞检测功能但不会因碰撞而产生影响,因此不会对其他对象产生影响的对象称为“幽灵对象”。虽然 Physijs 不正式支持幽灵对象,但您可以在生成 Physijs.Mesh
后通过调整标志在其中创建幽灵对象。World Wide Maze 使用幽灵对象来检测物品和目标点的碰撞。
hit = new Physijs.SphereMesh(geometry, material, 0)
hit._physijs.collision_flags = 1 | 4
scene.add(hit)
对于 collision_flags
,1 为 CF_STATIC_OBJECT
,4 为 CF_NO_CONTACT_RESPONSE
。如需了解详情,请尝试搜索 Bullet 论坛、Stack Overflow 或 Bullet 文档。由于 Physijs 是 Ammo.js 的封装容器,而 Ammo.js 与 Bullet 基本相同,因此在 Bullet 中可以执行的大多数操作在 Physijs 中也可以执行。
Firefox 18 问题
Firefox 从 17 版更新到 18 版后,Web Worker 交换数据的方式发生了变化,因此 Physijs 停止了工作。此问题已在 GitHub 上报告,并在几天后得到解决。虽然这种开源效率让我印象深刻,但这件事也提醒了我,World Wide Maze 是由多个不同的开源框架组成的。我撰写本文,希望提供一些反馈。
asm.js
虽然这与 World Wide Maze 没有直接关系,但 Ammo.js 已经支持 Mozilla 最近宣布的 asm.js(这并不奇怪,因为 asm.js 的创建目的主要是加快 Emscripten 生成的 JavaScript 的速度,而 Emscripten 的创建者也是 Ammo.js 的创建者)。如果 Chrome 也支持 asm.js,物理引擎的计算负载应该会大幅减少。使用 Firefox Nightly 进行测试时,速度明显更快。或许最好使用 C/C++ 编写需要更快速度的部分,然后使用 Emscripten 将其移植到 JavaScript?
WebGL
对于 WebGL 实现,我使用了开发最活跃的库 three.js (r53)。虽然修订版 57 在开发后期就已发布,但 API 已发生重大变化,因此我坚持使用原始修订版进行发布。
光晕效果
添加到球核心和项上的光晕效果是使用所谓的“Kawase 方法 MGF”的简单版本实现的。不过,虽然 Kawase 方法会使所有明亮区域都呈现出绽放效果,但 World Wide Maze 会为需要发光的区域创建单独的渲染目标。这是因为必须使用网站屏幕截图作为舞台纹理,而如果网站的背景是白色,那么仅提取所有亮色区域就会导致整个网站发光。我还考虑过以 HDR 格式处理所有内容,但这次决定不这样做,因为实现起来会非常复杂。
左上角显示了第一遍渲染,其中光晕区域是单独渲染的,然后应用了模糊处理。右下角显示了第二次传递,其中图像大小缩减了 50%,然后应用了模糊处理。右上角显示了第三次传递,其中图片再次缩小了 50%,然后进行了模糊处理。然后,将这三张图片叠加在一起,得到左下角显示的最终合成图片。对于模糊处理,我使用了 three.js 中包含的 VerticalBlurShader
和 HorizontalBlurShader
,因此仍有进一步优化的空间。
反光球
球上的反射基于 three.js 中的示例。所有方向均从球的位置渲染,并用作环境贴图。每次球移动时,环境贴图都需要更新,但由于以 60 fps 的速率进行更新会占用大量资源,因此它们改为每三帧更新一次。结果不如更新每帧那么流畅,但除非有人指出,否则差异几乎无法察觉。
着色器、着色器、着色器…
WebGL 需要着色器(顶点着色器、Fragment 着色器)才能进行所有渲染。虽然 three.js 中包含的着色器已经支持各种各样的效果,但如果要实现更精细的着色和优化,则无法避免编写自己的着色器。由于 World Wide Maze 会使用其物理引擎占用 CPU,因此我尝试使用着色语言 (GLSL) 尽可能编写更多代码,以便利用 GPU,即使通过 JavaScript 进行 CPU 处理会更容易也是如此。海洋波浪效果自然需要着色器,目标点上的烟花和球出现时使用的网格效果也是如此。
以上是球体出现时所使用的网格效果的测试结果。左侧是游戏中使用的模型,由 320 个多边形组成。中间的地图使用了大约 5,000 个多边形,右边的地图使用了大约 300,000 个多边形。即使有这么多的多边形,使用着色器进行处理也能保持 30 fps 的稳定帧速率。
散布在整个舞台上的小物品都集成到一个网格中,各个物品的移动依赖于着色器移动每个多边形顶点。这是来自一项测试,旨在了解在存在大量对象的情况下性能是否会受到影响。此处布置了大约 5,000 个对象,由大约 20,000 个多边形组成。性能丝毫没有受到影响。
poly2tri
阶段是根据从服务器收到的轮廓信息形成的,然后由 JavaScript 多边形化。三角测量是该流程的关键部分,但 three.js 实现得不好,通常会失败。因此,我决定自行集成一个名为 poly2tri 的其他三角剖分库。事实证明,three.js 显然在过去尝试过相同的事情,因此我只需注释掉其中的一部分代码,就能让它正常运行。因此,错误数量显著减少,可玩关卡也增加了许多。偶尔会出现错误,而且 poly2tri 出于某种原因会通过发出提醒来处理错误,因此我将其修改为改为抛出异常。
上图展示了如何对蓝色轮廓进行三角剖分并生成红色多边形。
各向异性过滤
由于标准等轴 MIP 映射会缩小水平和垂直轴上的图像,因此从斜角查看多边形会使《世界迷宫》关卡远端的纹理看起来像水平拉长的低分辨率纹理。此维基百科页面右上角的图片就是一个很好的例子。在实践中,需要更多的水平分辨率,WebGL (OpenGL) 使用一种称为各向异性滤波的方法来解决此问题。在 three.js 中,为 THREE.Texture.anisotropy
设置大于 1 的值可启用各向异性滤波。不过,此功能是一项扩展功能,并非所有 GPU 都支持。
优化
正如这篇 WebGL 最佳实践文章中所提到的,提高 WebGL (OpenGL) 性能的最关键方法是尽可能减少绘制调用次数。在 World Wide Maze 的最初开发阶段,游戏中的所有岛屿、桥梁和护栏都是单独的对象。这有时会导致超过 2,000 次绘制调用,使复杂的阶段难以处理。不过,当我将同一类型的对象全部打包到一个网格中后,绘制调用次数降到了 50 次左右,性能显著提升。
我使用了 Chrome Tracing 功能进行了进一步优化。Chrome 开发者工具中包含的性能分析器在一定程度上可以确定方法的总处理时间,但跟踪功能可以精确地告诉您每个部分的用时,精确到 1/1000 秒。如需详细了解如何使用轨迹,请参阅这篇文章。
以上是为球的反射创建环境贴图的轨迹结果。将 console.time
和 console.timeEnd
插入 Three.js 中似乎相关的位置,我们会得到如下图所示的图表。时间从左向右流动,每个图层都类似于调用堆栈。将 console.time 嵌套在 console.time
中可进行进一步的测量。顶部的图表是优化前,底部的图表是优化后。如顶部图表所示,在预优化期间,系统针对 0-5 的每个渲染调用了 updateMatrix
(尽管该字词被截断了)。不过,我对其进行了修改,使其只调用一次,因为只有在对象更改位置或方向时才需要执行此过程。
跟踪进程本身会占用资源,因此过度插入 console.time
可能会导致与实际性能出现明显偏差,从而难以确定需要优化的方面。
效果调整器
由于互联网的特性,游戏可能会在规格差异很大的系统上运行。2 月初发布的Find Your Way to Oz 使用名为 IFLAutomaticPerformanceAdjust
的类根据帧速率波动来缩减特效,有助于确保流畅的播放。World Wide Maze 基于相同的 IFLAutomaticPerformanceAdjust
类构建,并按以下顺序缩减效果,以尽可能流畅地进行游戏:
- 如果帧速率低于 45 fps,环境贴图将停止更新。
- 如果仍低于 40 fps,则渲染分辨率会降低到 70%(Surface 比率的 50%)。
- 如果帧速率仍低于 40 fps,系统会停用 FXAA(抗锯齿)。
- 如果帧速率仍低于 30 fps,系统会消除光晕效果。
内存泄漏
使用 three.js 巧妙地移除对象有点麻烦。但如果不加以处理,显然会导致内存泄漏,因此我设计了以下方法。@renderer
引用 THREE.WebGLRenderer
。(最新版的 three.js 使用了略有不同的取消分配方法,因此此方法可能无法直接用于它。)
destructObjects: (object) =>
switch true
when object instanceof THREE.Object3D
@destructObjects(child) for child in object.children
object.parent?.remove(object)
object.deallocate()
object.geometry?.deallocate()
@renderer.deallocateObject(object)
object.destruct?(this)
when object instanceof THREE.Material
object.deallocate()
@renderer.deallocateMaterial(object)
when object instanceof THREE.Texture
object.deallocate()
@renderer.deallocateTexture(object)
when object instanceof THREE.EffectComposer
@destructObjects(object.copyPass.material)
object.passes.forEach (pass) =>
@destructObjects(pass.material) if pass.material
@renderer.deallocateRenderTarget(pass.renderTarget) if pass.renderTarget
@renderer.deallocateRenderTarget(pass.renderTarget1) if pass.renderTarget1
@renderer.deallocateRenderTarget(pass.renderTarget2) if pass.renderTarget2
HTML
我个人认为,WebGL 应用的最大优势在于能够使用 HTML 设计页面布局。在 Flash 或 openFrameworks (OpenGL) 中构建 2D 界面(例如得分或文本显示)很麻烦。Flash 至少有 IDE,但如果您不熟悉 openFrameworks,则会很难上手(使用 Cocos2D 等工具可能会更容易)。另一方面,HTML 允许使用 CSS 精确控制所有前端设计方面,就像构建网站时一样。虽然无法实现像粒子凝聚成徽标这样的复杂效果,但 CSS 转换功能可以实现一些 3D 效果。World Wide Maze 的“GOAL”和“TIME IS UP”文字效果是使用 CSS 转场中的缩放功能(通过 Transit 实现)添加的动画效果。(显然,背景渐变使用的是 WebGL。)
游戏中的每个页面(标题、结果、排名等)都有自己的 HTML 文件,这些页面作为模板加载后,系统会在适当的时间使用适当的值调用 $(document.body).append()
。一个问题是,无法在附加之前设置鼠标和键盘事件,因此在附加之前尝试 el.click (e) -> console.log(e)
不起作用。
国际化 (i18n)
使用 HTML 还便于制作英语版本。我选择使用 i18next(一种 Web i18n 库)来满足国际化需求,并且能够直接使用该库,无需进行任何修改。
游戏内文本的编辑和翻译是在 Google 文档电子表格中完成的。由于 i18next 需要 JSON 文件,因此我将电子表格导出为 TSV,然后使用自定义转换器进行转换。我在发布前进行了大量更新,因此自动执行从 Google 文档电子表格导出数据的流程会更轻松。
由于这些网页是使用 HTML 构建的,因此 Chrome 的自动翻译功能也能正常运行。不过,它有时会检测出错误的语言,而误认为是完全不同的语言(例如,越南语),因此此功能目前处于停用状态。(您可以使用元标记停用此功能。)
RequireJS
我选择了 RequireJS 作为 JavaScript 模块系统。游戏的 10,000 行源代码被划分为大约 60 个类(即 coffee 文件),并编译为各个 js 文件。RequireJS 会根据依赖项以适当的顺序加载这些单独的文件。
define ->
class Hoge
hogeMethod: ->
您可以按如下方式使用上面定义的类 (hoge.coffee):
define ['hoge'], (Hoge) ->
class Moge
constructor: ->
@hoge = new Hoge()
@hoge.hogeMethod()
为了正常运行,必须先加载 hoge.js,然后再加载 moge.js。由于“hoge”被指定为“define”函数的第一个参数,因此系统始终会先加载 hoge.js(在 hoge.js 加载完毕后调用回调)。此机制称为 AMD,只要第三方库支持 AMD,就可以用于相同类型的回调。即使不支持(例如 three.js),只要提前指定依赖项,也能实现类似的效果。
这与导入 AS3 类似,因此应该不会让您感到奇怪。如果您最终有更多依赖项文件,此方法或许可行。
r.js
RequireJS 包含一个名为 r.js 的优化器。这会将主 js 与所有依赖的 js 文件捆绑到一个文件中,然后使用 UglifyJS(或 Closure Compiler)对其进行缩减。这样可以减少浏览器需要加载的文件数量和数据总量。World Wide Maze 的 JavaScript 文件总大小约为 2 MB,通过 r.js 优化可缩减至约 1 MB。如果游戏可以使用 gzip 分发,则该大小将进一步缩减到 250 KB。(GAE 存在一个问题,不允许传输 1 MB 或更大的 gzip 文件,因此游戏目前以未压缩的 1 MB 纯文本形式分发。)
Stage Builder
阶段数据的生成方式如下,完全在美国的 GCE 服务器上执行:
- 系统会通过 WebSocket 发送要转换为阶段的网站的网址。
- PhantomJS 会截取屏幕截图,并检索 div 和 img 标记位置,然后以 JSON 格式输出。
- 根据第 2 步中的屏幕截图和 HTML 元素的位置数据,自定义 C++ (OpenCV、Boost) 程序会删除不必要的区域、生成岛屿、将岛屿连接起来、计算护栏和项位置、设置目标点等。结果以 JSON 格式输出并返回给浏览器。
PhantomJS
PhantomJS 是一款无需屏幕的浏览器。它无需打开窗口即可加载网页,因此可用于自动化测试或在服务器端截取屏幕截图。其浏览器引擎是 WebKit,与 Chrome 和 Safari 使用的引擎相同,因此其布局和 JavaScript 执行结果与标准浏览器大致相同。
在 PhantomJS 中,您可以使用 JavaScript 或 CoffeeScript 编写要执行的流程。截取屏幕截图非常简单,如此示例所示。我使用的是 Linux 服务器 (CentOS),因此需要安装字体才能显示日语 (M+ FONTS)。即便如此,字体渲染方式也与 Windows 或 Mac OS 不同,因此同一字体在其他机器上看起来可能会有所不同(不过差异很小)。
检索 img 和 div 标记位置的处理方式与标准网页基本相同。您也可以毫无问题地使用 jQuery。
stage_builder
我最初考虑使用更基于 DOM 的方法来生成阶段(类似于 Firefox 3D 检查器),并尝试在 PhantomJS 中执行 DOM 分析之类的操作。不过,最终我还是选择了图片处理方法。为此,我编写了一个名为“stage_builder”的 C++ 程序,该程序使用 OpenCV 和 Boost。它会执行以下操作:
- 加载屏幕截图和 JSON 文件。
- 将图片和文字转换为“岛屿”。
- 创建桥梁来连接岛屿。
- 消除不必要的桥梁以创建迷宫。
- 放置大型物品。
- 放置小物品。
- 放置护栏。
- 以 JSON 格式输出定位数据。
下面详细介绍了每个步骤。
加载屏幕截图和 JSON 文件
常规的 cv::imread
用于加载屏幕截图。我测试了几个 JSON 文件库,但 picojson 似乎最易于使用。
将图片和文字转换为“岛屿”
上图是 aid-dcc.com 的“新闻”版块的屏幕截图(点击可查看实际大小)。图片和文本元素必须转换为岛状元素。为了隔离这些部分,我们应删除白色背景颜色,也就是说,删除屏幕截图中最常见的颜色。完成后,界面将如下所示:
白色部分是可能的岛屿。
文本过细且过于锐利,因此我们将使用 cv::dilate
、cv::GaussianBlur
和 cv::threshold
将其加粗。图像内容也缺失,因此我们将根据 PhantomJS 输出的 img 标记数据,将这些区域填充为白色。生成的图片如下所示:
文本现在形成了合适的块,每张图片都是一个合适的岛屿。
创建桥梁来连接岛屿
岛屿建成后,便会通过桥梁相连。每个岛屿都会在左、右、上、下查找相邻的岛屿,然后将桥梁连接到最近岛屿的最近点,结果如下所示:
移除不必要的桥梁以创建迷宫
如果保留所有桥梁,关卡会太容易导航,因此必须移除一些桥梁才能形成迷宫。系统会选择一个岛屿(例如左上角的岛屿)作为起点,并删除连接到该岛屿的所有桥梁(除了一个随机选择的桥梁)。然后,对通过剩余桥梁连接的下一个岛屿执行相同的操作。当路径到达死胡同或返回到之前访问过的岛屿时,它会回溯到可进入新岛屿的点。以这种方式处理完所有岛屿后,迷宫就完成了。
放置大型物品
系统会根据每个岛屿的尺寸,在岛屿上放置一个或多个大型项,选择的位置是离岛屿边缘最远的位置。虽然不太清晰,但这些点在下方以红色显示:
从所有这些可能的点中,将左上角的点设置为起点(红色圆圈),将右下角的点设置为终点(绿色圆圈),并从其余点中选择最多 6 个点作为大件商品放置点(紫色圆圈)。
放置小物品
适当数量的小项沿线条放置,与岛屿边缘保持设定的距离。上图(非来自 aid-dcc.com)显示了灰色的预测放置线,这些线条经过偏移,并以固定间隔放置在岛屿边缘。红点表示小物品的放置位置。由于此图片来自开发中期版本,因此项以直线排列,但最终版本会将项以更不规则的方式散布在灰色线条的两侧。
放置护栏
护栏通常沿岛屿的外边界放置,但必须在桥梁处截断,以便通行。Boost Geometry 库非常适合此用途,可简化几何计算,例如确定岛屿边界数据与桥梁两侧线条的交点。
勾勒出岛屿的绿色线条是护栏。此图片中可能很难看清,但桥梁所在的位置没有绿色线条。这是用于调试的最终图片,其中包含需要输出到 JSON 的所有对象。浅蓝色圆点是小项,灰色圆点是建议的重启点。当球掉入大海时,游戏会从最近的重启点恢复。重启点的排列方式与小物品大同小异,它们会以固定的间隔距离从岛屿边缘延伸。
以 JSON 格式输出定位数据
我还使用了 picojson 进行输出。它会将数据写入标准输出,然后由调用方 (Node.js) 接收。
在 Mac 上创建要在 Linux 中运行的 C++ 程序
该游戏是在 Mac 上开发并在 Linux 中部署的,但由于这两种操作系统都支持 OpenCV 和 Boost,因此在建立编译环境后,开发本身并不困难。我使用 Xcode 中的命令行工具在 Mac 上调试了 build,然后使用 automake/autoconf 创建了配置文件,以便在 Linux 中编译 build。然后,我只需在 Linux 中使用“configure && make”即可创建可执行文件。由于编译器版本差异,我遇到了一些特定于 Linux 的 bug,但能够使用 gdb 相对轻松地加以解决。
总结
您可以使用 Flash 或 Unity 制作这样的游戏,这样做有诸多优势。不过,此版本无需插件,而且 HTML5 + CSS3 的布局功能非常强大。拥有适合每项任务的工具非常重要。我个人对这款完全使用 HTML5 开发的游戏的效果感到惊讶。虽然它在许多方面仍有欠缺,但我期待看到它未来的发展。