实践证明,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,并添加一些额外的「秘密」等等。我尝试的第一件事是让 Flash 将游戏导出为可执行文件,但失败了,因为它的性能与 2005 年一样糟糕。我想制作一个以当代帧速率运行的东西。我想摆脱 Flash Player。其次,我花了太多时间摆弄 Adobe AIR(Flash 桌面 runtime)和 Starling(一个在 GPU 上绘制 Flash 场景的库)。最后我放弃了这个,部分原因是 AIR 有很多问题而且很糟糕,也是因为我不想在一切结束时得到一个奇怪的 Adobe 结果;我想拥有自己的东西,可以做我想做的事。例如,如果我想迁移到 Linux 怎么办?前进的道路是显然的:我必须制作自己的 Flash 播放器。以下是 Hapland 的运作方式。这里有一棵精灵树,在 Flash 中,动画精灵可以将代码附加到某些帧,当播放箭头到达那里时运行。Hapland 经常使用这一方式。游戏角色的行进路径都是很长的时间轴动画,角色经常有帧动作,比如门关了就打开,比如到了地雷区,如果还没爆炸就会触发。幸运的是,.fla 文件只是 XML。我只需要解析它,将相关数据导出为简单的自定义格式并编写一个播放器来读取它、绘制场景、处理输入并运行动画。Hapland 仍然是一个 Flash 项目,在 Flash 编辑器中编写和维护;只有 Flash Player 会被替换。Flash 确实支持光栅图,但实际上是为矢量图设计的。这就是 Flash 影片即使在拨号连接的情况下也能快速加载的原因。所有 Hapland 图形都是矢量图。而 GPU 不太喜欢绘制矢量图形,却喜欢大批量的纹理三角形。所以,我需要将这些矢量光栅化。我决定离线光栅化它们并将光栅文件打包到游戏中。在游戏运行时将它们光栅化并成为这个微小的可执行文件会很有趣,但我不想拥有那些额外的移动部件。我喜欢让尽可能多的代码在自己的开发机器上运行,这样我就可以随时关注到它。Flash 以 XML 格式存储矢量图。你可能会说, XML 不是图形数据的一种糟糕选择,但你毕竟不是 Macromedia 的产品经理。看看这个:尽管我无法访问 spec,但光栅化这并不是一个难题。自 PostScript 以来,矢量图形的贝兹曲线模型无处不在。所有这些 API 的工作方式都相同。经过反复试验,我编写了一个程序来解析这些形状定义,并使用 Mac 的 CoreGraphics 库将它们呈现为 PNG。CoreGraphics 是一个值得怀疑的选择。我选择它是因为我使用 Mac 工作,依赖性很强。但这确实成功了,所以我总是不得不在 Mac 上光栅化图形,即使是 Windows 版本也是如此。如果再一次做这件事,我可能会选择一个跨平台的库。渲染这些 PNG 后,导出器会将它们组装成地图集?并没有,它只是按高度对所有内容进行排序,然后像文档中的文本一样逐行排列。这远非最佳,但已经足够了。为简单起见,图集为 2048×2048 像素,这是 OpenGL 3.2 实现必须支持的最小纹理尺寸。光栅化形状非常慢,所以为了保持合理的构建时间,我需要跳过渲染没有改变的东西。Flash 使用的压缩 XML 格式确实有每个文件的最后修改字段,但 Flash 似乎没有正确使用它们,因此您不能依赖它们。相反,我只是对每个形状的 XML 进行哈希处理,并且只有在它发生变化时才进行重建。即使这样也失败了,因为 Flash 有时喜欢重新排列未更改的对象中的 XML 标记,但同样,这已经足够了。导出器将动画数据写入自定义二进制格式。它只是逐帧通过时间轴,并写出每一帧的所有更改。我在这里想到了写入汇编列表而不是直接写入二进制文件,我很喜欢这一点。没有 CPU 指令,只有数据,这让调试更容易,因为我可以查看汇编文件以查看生成的内容,而不是在十六进制编辑器中浏览字节。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
; 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
我本可以让导出器将字节写入一个文件,同时将单独的文本列表写入另一个文件,而不使用汇编程序,但我没有这样做,因为:导出器的其余部分大多不够有趣;它只是 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 的 Second Quest。
在 BGM 方面,我使用自己硬盘里的内容,并额外制作了一些音乐,为每款游戏制作了快速的环境配乐。有一次在日本度假时,我无缘无故地在山顶上进行了一次野外录音,能够将其用于某些事情真是太好了。我从互联网上找到了一位音乐家来做标题屏幕音乐,并自己录制了一些吉他和弦作为片尾字幕,它们淹没在效果中,所以你不能说我吉他学得不好。在工具上,我根据音乐使用 Logic 或 Live。我发现 Logic 更适合录音,而 Live 更适合声音设计。在 Steam 上,玩家总喜欢看成就,这一点不太好办,成就的设置取决于游戏设计师的思路,但其实也没什么大不了的。把成就系统上传到 Steam 是一件痛苦的事情,你不能只定义一个列表并将其提供给他们的命令行工具,而是必须费力地点击 Steam 合作伙伴网站缓慢、令人困惑的 PHP 框架,然后将它们一个一个添加进去。看起来,如果你是一家重要的大型游戏工作室,你就不必忍受这一点,他们为你提供了一个批量上传工具,但我显然不是其中之一。所以我查看了它构建的 HTTP 调用,保存了我的登陆 cookie,并编写了我自己的文件。几次修改之后,我选择了一组适度的成就:一个用于完成每个 Hapland 游戏,一个用于每个 Second Quest,还有两个是解锁更大的秘密。任何没人能发现的愚蠢、晦涩的秘密都没有成就,你一定要对发生的事情感到满意。虽然我主要是在自己的 Mac 上开发游戏,但在开发的过程中苹果发明了「Notarization」,如果你在新版本的 MacOS 上运行任何应用程序,它会向苹果发出网络请求,询问应用程序的开发者是否向苹果支付年费。如果开发者不支付年费,MacOS 会弹出一个对话框,强烈暗示该应用程序是病毒并拒绝启动。因此,Windows 将是该游戏的第一个,也许是唯一的发布平台。对于最终交付给最终用户的软件,我们通常希望将依赖性保持在最低限度,但使用一些高质量的软件也是必要的。除了 OpenGL 和标准操作系统之外,这是 Hapland Trilogy 可执行文件最终链接到的完整库列表:就是这样,如果你把技术做对了,人们在打游戏时根本不会注意到背后是什么,所以有时候这会让你想说:嘿,看看我做到了什么!https://foon.uk/how-flash-2022/https://news.ycombinator.com/item?id=34079543·················END·················