机器之心报道
编辑:泽南、蛋酱
实践证明,Flash 实在太糟糕了,为了重制游戏甚至要重写一个 Flash 播放器。
两年多前,Adobe 发布了一则引人关注的公告 —— 将在 2020 年 12 月 31 日终止支持 Flash,宣告了一个时代的结束。
一晃两年过去了,Adobe 早已从官方网站中删除了 Flash Player 早期版本的所有存档,并阻止基于 Flash 的内容运行。微软也已经终止对 Adobe Flash Player 的支持,并禁止其在任何 Microsoft 浏览器上运行。Adobe Flash Player 组件于 2021 年 7 月通过 Windows 更新永久删除。
当 Flash 下架之后,在世界的某个角落,这位「老同志」却仍在发挥余热。
Hapland 是 2005 年推出的一款 Flash 解谜游戏,也是很多人的童年回忆。在游戏中,玩家需要通过争取这个世界中的人们的帮助,找到打开关卡的方法,同时不要让他们被怪物吃掉或被地雷炸死。
这款游戏的图形要在 Flash 中绘制,代码要在 Flash 中编写,所有动画都在 Flash 时间轴中完成。可以这么理解:这款游戏的「骨子里都带着 Flash」。
作为游戏开发行业的一员,Robin Allen 发现,人们似乎特别喜欢 Hapland 游戏,所以他想对这款基于 Flash 的游戏的 Steam 版本进行一些修复,包括绘制更好的图形、将帧速率提高到 60FPS,并添加一些额外的「秘密」等等。
这时候该怎么做?作者详细讲述了试验过程。
一些失败的经验
失败的尝试 1:
我尝试的第一件事是让 Flash 将游戏导出为可执行文件,但失败了,因为它的性能与 2005 年一样糟糕。我想制作一个以当代帧速率运行的东西。我想摆脱 Flash Player。
失败的尝试 2:
其次,我花了太多时间摆弄 Adobe AIR(Flash 桌面 runtime)和 Starling(一个在 GPU 上绘制 Flash 场景的库)。
最后我放弃了这个,部分原因是 AIR 有很多问题而且很糟糕,也是因为我不想在一切结束时得到一个奇怪的 Adobe 结果;我想拥有自己的东西,可以做我想做的事。例如,如果我想迁移到 Linux 怎么办?
前进的道路是显然的:我必须制作自己的 Flash 播放器。
计划
以下是 Hapland 的运作方式。这里有一棵精灵树,在 Flash 中,动画精灵可以将代码附加到某些帧,当播放箭头到达那里时运行。Hapland 经常使用这一方式。游戏角色的行进路径都是很长的时间轴动画,角色经常有帧动作,比如门关了就打开,比如到了地雷区,如果还没爆炸就会触发。
时间轴中的小 “a” 是帧动作。
幸运的是,.fla 文件只是 XML。我只需要解析它,将相关数据导出为简单的自定义格式并编写一个播放器来读取它、绘制场景、处理输入并运行动画。
Hapland 仍然是一个 Flash 项目,在 Flash 编辑器中编写和维护;只有 Flash Player 会被替换。
光栅化矢量
Flash 确实支持光栅图,但实际上是为矢量图设计的。这就是 Flash 影片即使在拨号连接的情况下也能快速加载的原因。
所有 Hapland 图形都是矢量图。而 GPU 不太喜欢绘制矢量图形,却喜欢大批量的纹理三角形。所以,我需要将这些矢量光栅化。
我决定离线光栅化它们并将光栅文件打包到游戏中。在游戏运行时将它们光栅化并成为这个微小的可执行文件会很有趣,但我不想拥有那些额外的移动部件。我喜欢让尽可能多的代码在自己的开发机器上运行,这样我就可以随时关注到它。
Flash 以 XML 格式存储矢量图。你可能会说, XML 不是图形数据的一种糟糕选择,但你毕竟不是 Macromedia 的产品经理。看看这个:
在 .fla 文件中看到的矢量数据。
我不是在抱怨,这让我的工作更轻松了。
尽管我无法访问 spec,但光栅化这并不是一个难题。自 PostScript 以来,矢量图形的贝兹曲线模型无处不在。所有这些 API 的工作方式都相同。经过反复试验,我编写了一个程序来解析这些形状定义,并使用 Mac 的 CoreGraphics 库将它们呈现为 PNG。
CoreGraphics 是一个值得怀疑的选择。我选择它是因为我使用 Mac 工作,依赖性很强。但这确实成功了,所以我总是不得不在 Mac 上光栅化图形,即使是 Windows 版本也是如此。如果再一次做这件事,我可能会选择一个跨平台的库。
渲染这些 PNG 后,导出器会将它们组装成地图集?并没有,它只是按高度对所有内容进行排序,然后像文档中的文本一样逐行排列。这远非最佳,但已经足够了。
为简单起见,图集为 2048×2048 像素,这是 OpenGL 3.2 实现必须支持的最小纹理尺寸。
来自 Hapland 3 的图例集。
光栅化形状非常慢,所以为了保持合理的构建时间,我需要跳过渲染没有改变的东西。Flash 使用的压缩 XML 格式确实有每个文件的最后修改字段,但 Flash 似乎没有正确使用它们,因此您不能依赖它们。
相反,我只是对每个形状的 XML 进行哈希处理,并且只有在它发生变化时才进行重建。即使这样也失败了,因为 Flash 有时喜欢重新排列未更改的对象中的 XML 标记,但同样,这已经足够了。
用汇编程序编写二进制文件
导出器将动画数据写入自定义二进制格式。它只是逐帧通过时间轴,并写出每一帧的所有更改。
我在这里想到了写入汇编列表而不是直接写入二进制文件,我很喜欢这一点。没有 CPU 指令,只有数据,这让调试更容易,因为我可以查看汇编文件以查看生成的内容,而不是在十六进制编辑器中浏览字节
输出.bin
13 92 49 EC : BD 31 E8 FF
09 DD BE DE : C9 5A 1D 36
3F C0 4E 31 : 52 FD 41 C6
8B 5D C0 20 : 19 1F 5F 1F
54 97 8C 27 : 34 1F 30 EA
A9 A9 E0 55 : 40 29 A3 19
89 BC 5F 24 : 3A 98 FD B9
DE 15 F2 D4 : 2A B7 41 2C
4E 9D 37 D9 : E2 13 4B 01
36 3F 40 08 : AC 3C FF 84
E9 AE C5 2C : 11 2F 69 CF
63 CE 85 D1 : A7 CB B1 1A
5F 5B 60 1A : 77 99 71 B0
60 6E C4 C7 : 73 1F EA 1F
31 0D 0C 39 : B0 86 70 42
输出.asm
; Left Side
timeline_132:; --- Left Side, Frame 0 ---
.frame_0:; --- Left Side, Frame 0, Layer 22 ---
db Quad
dd 0.152926, 0.162029, 0.184475, 1.000000 ; color
dd 799.599976, -20.950001dd 799.599976, 556.650024dd 46.000000, 556.650024dd 46.000000, -20.950001; --- Left Side, Frame 0, Layer 21 ---
; instance of shape [Left Side] [Wall Shadows] [Frame 0]
dd Shape
dw 1560
你更愿意调试哪个?
我本可以让导出器将字节写入一个文件,同时将单独的文本列表写入另一个文件,而不使用汇编程序,但我没有这样做,因为:
1) 汇编程序已经存在;
2) 我不是必须调试它们;
3) 它们支持标签。
导出器的其余部分大多不够有趣;它只是 walk the tree 并将变换矩阵、颜色效果等事物,然后继续游戏程序本身。我选择用 C++ 编写这个,因为我已经知道它,并且新事物让我害怕。
场景图
Hapland 非常适合场景图。这是 Flash 使用的模型,Hapland 就是围绕它设计的,因此尝试使用不同的模型是没有意义的。
我将场景存储在内存中,作为一棵节点树,每个节点都有一个变换,可以自行绘制并接受鼠标点击。每个具有自己行为的游戏对象都是其自己类的实例,派生自 Node.js。「面向对象」目前在游戏开发圈子里并不流行,但我使用的是 Flash,所以显然不关心这个问题。
Hapland 使用的 Flash 功能,如颜色变换和遮罩,都是存在的。不过我没有像 Flash 那样实现任意遮罩,只是实现了矩形剪辑并编辑了我所有的图形,所以所有的遮罩都是矩形。
框架脚本
几乎所有的 Hapland 逻辑都包含在附加到时间轴帧的 ActionScript 中。要如何导出所有这些东西?我可不想在我的游戏中包含 ActionScript 解释器。
一个简单的帧动作。
最后,我们使用了一些技巧,我的导出器从每一帧读取 ActionScript 并应用大量正则表达式以尝试将其转换为 C++。例如,crate.lid.play () 可能会变成 crate ()→lid ()→play ();。这两种语言在句法上非常相似,这对于许多更简单的框架动作来说效果很好,但它仍然留下了相当多的错误代码,除了手动重写所有剩余的框架动作之外别无他法。
对于 C++ 中的所有框架脚本,它们在构建时被提取并成为每个符号的 Node 子类上的方法。还会生成一个调度方法以在正确的时间调用,看起来像这样:
void tick() override {
switch (currentFrame) {
case 1: _frame_1(); break;
case 45: _frame_45(); break;
case 200: _frame_200(); break;
}
}
需要指出的最后一件事是脚本系统最终是某种静态类型的,这有点难受。游戏输出的最终游戏对象如下所示:
struct BigCrate: Node {
BigCrateLid *lid() { return (BigCrateLid *)getChild("lid"); }
BigCrateLabel *label() { return (BigCrateLabel *)getChild("label"); }
void swingOpen() { ... }
void snapShut() { ... }
void burnAway() { ... }
};
因此,即使一切仍然是大量的自动字符串名称查找,类型安全的单板会阻止你在错误的对象上调用错误的函数,从而使你免于在动态语言中遇到的那类烦人的 bug。
纵横比
HD 重置版游戏都会遇到画面拉伸的问题,最初的 Flash 游戏很多是页游,甚至没有全屏运行的能力,所以它们只是使用设计者喜欢的宽高比,大多是 3:2 左右。
如今最常见的纵横比似乎是 16:9,16:10 在笔记本电脑上也很流行。我希望游戏在其中任何一个方面看起来都不错,没有任何黑条或拉伸。要做到这一点的唯一方法是从原件上切掉一些部分,或者在上面添加一些部分。
所以,我为游戏画面画了两个矩形,一个比例为 16:9,另一个比例为 16:10。然后游戏根据屏幕的宽高比在它们之间进行插值,并使用插值矩形作为视图边界。只要所有重要的游戏元素都在这些矩形的交叉点内,并且它们的公共边界矩形不超出场景边缘,就可以很好地工作。
Hapland 2 的 16:10 和 16:9 框,与原来的 3:2 不同。
色彩空间的问题
经过一些测试后,我发现 Flash 在感知空间而不是线性空间中进行 alpha 混合和颜色变换。这在数学上是可疑的,但另一方面我们也该知道,很多绘图程序都是这样工作的,你希望你的消费级工具按照人们期望的方式工作,虽然这对于数学家来说是一种冒犯。但是从根本上来看,这是错误的!它会导致诸如抗锯齿之类的问题。
当你光栅化矢量图形并要求抗锯齿输出时,光栅器将输出 alpha 值,即所谓的「覆盖值」。这意味着如果给定像素被矢量形状半覆盖,则该像素将以 alpha = 0.5 输出。
但在 Flash 中,当某些东西的 alpha 为 0.5 时,这意味着它在感知上处于前景色和背景色之间的中间位置。
这完全不是一回事!
在不透明黑色像素之上绘制的半覆盖白色像素不应是感知的 50% 灰色。这不是光的工作原理,也不是矢量光栅化的工作原理。光栅器不能在不知道背景颜色的情况下说「这个像素应该在背景和前景颜色之间感知 xx%」。
在感知 (sRGB) 空间中完成的混合。顶部:黑色透明白色;中间:白底透明黑色;底部:灰色在线性(物理上准确)空间中完成的相同混合。请注意,50% 的覆盖率看起来与 50% 的灰色不同。
因此,我们的抗锯齿光栅化形状使用一种 alpha 定义,而我们的 Flash 导出的 alpha 透明度、渐变和颜色变换使用另一种定义。但是我们的渲染管道中只有一个 alpha 通道。那么渲染器应该如何解释 alpha 值呢?如果它将它们解释为感知混合因素,则半透明对象看起来是正确的,但一切的抗锯齿边缘看起来都是错误的。如果它将它们解释为覆盖率值,则反之亦然。有些东西总是看起来不对劲!
在此,我认为只有两个严谨的解决方案:1) 设定两个 alpha 通道,一个用于覆盖,一个用于感知混合;2) 在没有 AA 的情况下光栅化所有形状,将所有内容绘制到一个非常大的帧缓冲区,然后通过过滤将其缩小。
我必须承认,这些设想都没有获得实践。这些半透明的东西在 Flash 和游戏中看起来不对劲,我只是逐渐调整图形直到游戏看起来没问题。在 Flash 中的透明对象永远不会完全符合我设计他们的初衷,但它们并不多,这也不是什么大问题。
为了确保其他一切都正确,我制作了一个「颜色测试」图形,其中包含一堆不同强度的颜色、色调旋转效果 10 等等,让游戏显示它,并确保它在 Flash 中运行正确。
变成了比较颜色的问题。
帧率
原始的 Flash 游戏标称帧率是 24FPS,但实际上它们以 Flash Player 想要的任何帧速率运行。使用 Flash,你可能要求 24FPS 并得到 15FPS,或者要求 30FPS 突然得到 24FPS,这看起来一点也不严谨。
我想要把游戏重制成 60FPS,这意味着要在 Hapland 创作时期望以大约 24FPS 的速度播放这一事实动些手脚。Flash 的动画工具基于离散的帧,而不是连续的时间。
我首先让导出器将所有帧加倍,对于每个时间轴帧导出两个帧,这就直接地把 24FPS 提高到了 48FPS,但仍然不是 60,需要的动画仍然要快 25%。解决方法是老式的手工活:完整遍历游戏,然后手动将额外的帧添加到现在看起来太快的动画中。
至此,我们已对 Hapland 游戏进行了相当不错的 C++ 转换,肯定可以在现代计算机上运行至少再过一两年。但我就是无法摆脱应该尝试提供一些额外价值的感觉,所以加新活在所难免。除了重新绘制大量旧图形和动画外,我还进行了一些重大更改。
及时保存
我认为需要让 Hapland 3 不那么让人不知所措。这个游戏的关卡很长,有很多地方死掉了又得重新再来,也许这在 2006 年很有趣,但我们现在是成年人了,我们没有时间这样做。
保存状态是模拟器该有的功能,如果你按下「保存状态」,它会通过将控制台的内存转储到文件中来记录当前游戏的整个状态。然后,如果你搞砸了,按下「加载状态」,你就会回到要重试的地方附近。
在原始 Flash 游戏中实现保存状态是不可行的,因为 Flash 不让程序员访问其整个状态。但由于这次我使用的都是自己的代码,所以这是可能的。
我有一个叫做 Zone 的东西,它只是一个分配器,将其所有内存分配到一个固定大小的块中。所有场景节点都分配在当前区域内。为了实现保存和恢复,我只需要两个区域,活动区域和一个单独的「保存状态区域」。为了保存状态,我将活动区域 memcpy 到保存状态区域。要加载状态,我会以另一种方式返回 memcpy。
重复关卡
Hapland 的游戏时间并不是特别长,虽然一共有三个,但我们总是希望再想多给玩家几个小时的游戏时间。因此我决定给每个游戏一个「Second Quest」—— 原关卡修改版,布局和谜题略有不同。制作这样一个 Second Quest 比制作一个全新的游戏要省力,但仍能带来一些额外的价值。
创建 Second Quest 意味着我需要在大约 15 年来第一次重新开始 Flash 解谜游戏开发,老实说,这感觉不错。复古的 Flash 用户界面很棒,按钮都有边缘,图标是实物风格,空间也得到了充分的利用。
使用旧时代的 UI 让我感觉自己就像一位考古学家,正在发现某种被遗忘的罗马技术。失落的 UI 设计艺术,很整洁。
这是什么魔法?
尽管 Flash 的 bug 很多,速度也慢,还缺少一些极其基本的功能,但我基本上不讨厌使用它,当然使用现代应用程序是更顺手的。
为了防止第二个任务看起来与第一个任务太相似,它们需要有新的背景,整个场景也被水平翻转了。
Hapland 3。
Hapland 3 的 Second Quest。
音乐
在 BGM 方面,我使用自己硬盘里的内容,并额外制作了一些音乐,为每款游戏制作了快速的环境配乐。有一次在日本度假时,我无缘无故地在山顶上进行了一次野外录音,能够将其用于某些事情真是太好了。我从互联网上找到了一位音乐家来做标题屏幕音乐,并自己录制了一些吉他和弦作为片尾字幕,它们淹没在效果中,所以你不能说我吉他学得不好。
在工具上,我根据音乐使用 Logic 或 Live。我发现 Logic 更适合录音,而 Live 更适合声音设计。
成就系统
在 Steam 上,玩家总喜欢看成就,这一点不太好办,成就的设置取决于游戏设计师的思路,但其实也没什么大不了的。
把成就系统上传到 Steam 是一件痛苦的事情,你不能只定义