AI无法重现《Thrust》游戏(但能帮你理解它)

Hacker News Top 工具

摘要

一位开发者让Claude重现1986年经典游戏《Thrust》,但发现AI的尝试效果不佳;不过,使用Claude分析原始6502汇编源代码,对于理解该游戏的物理机制和设计很有价值。

暂无内容
查看原文
查看缓存全文

缓存时间: 2026/07/11 19:25

# AI 无法重现《Thrust》(但它能帮你理解它)| James Randall 来源:https://www.jamesdrandall.com/posts/thrust_ai_powered_software_archaeology/ 我让 Claude 在浏览器中为我重现经典的 1986 年游戏《Thrust》。它生成了一堆垃圾,但随后事情彻底失控了。 《Thrust》是我在 BBC Micro 上最喜欢的游戏之一——由 Jeremy C. Smith 编写并于 1986 年发布,这是一款看似简单实则深奥的游戏,拥有令人惊叹的物理效果和玩法。你驾驶一艘飞船穿越洞穴,收集燃料,躲避炮塔火力,并在对抗重力和惯性的同时回收一个吊舱以获取奖励分数。Jeremy 后来与 Peter Irvin 共同创作了更令人印象深刻的《Exile》,之后不幸于 1992 年在一场事故中去世。他写《Thrust》时大概只有 16 到 18 岁。你可以在线玩原版游戏 (http://bbcmicro.co.uk/game.php?id=432)。 我桌上有一台 BBC Master,我仍然会偶尔在上面运行《Thrust》以及一些其他经典游戏。这是一款我不断回归的游戏,与《Elite》、《Exile》和《Holed Out》并列。我现在已经以不同的方式重现了其中三款……第四款看起来越来越不可避免了。 ## 从垃圾开始 不管怎样。我想我一直在想《Thrust》,因为最近某天早上,我相当随意地让 Claude Code 在浏览器中为我创建它。我想我刚刚读到了 OpenAI 和 Anthropic 最新的能力宣言,于是我整理了一份相当全面的技术规格,让它访问原始的反汇编源代码、截图,然后说:“去给我重现《Thrust》。” 它生成的东西,用“垃圾”来形容都算客气了,它只是非常 vaguely 像《Thrust》——它大致有扫描线之类的东西——但实在是太糟糕了。它甚至没有让重力正常工作,飞船下落的方式不对,控制感觉很奇怪,而且就是……惨不忍睹。从某种程度上说,它居然创建了一个勉强能用、勉强像《Thrust》的东西,这确实令人惊叹,但它根本不能玩,也远不及原版游戏的优雅和美丽。 这就是像《Thrust》这样的游戏的关键所在。你可以很快地拼凑出一个表面相似的东西——只需以设备帧率运行,使用标准的增量时间物理,绘制一些洞穴。但它的感觉会和《Thrust》完全不同。魔力在于特定的时序、飞船的重量、以及动量累积的方式。特别是如果你玩过原版,那么这些细节就是一切,而一个依据文本描述(事实证明,甚至包括原始源代码)工作的 AI,是无法捕捉到它们的。 ## 考古 但这激起了我的好奇心。原版到底*是*如何工作的?我发现开发者们让这些东西在 8 位机上运行的技巧非常迷人,这变成了一次考古行动。我很快找到了 Kieran Connell 提供的这份精彩的带注释的反汇编 (https://github.com/kieranhj/thrust-disassembly) 原始源代码,并开始将其输入到 Claude 中并提出问题。 事情从这里开始变得有趣起来。不是因为 AI 写了代码——代码本身并不复杂,这是一个运行在 32K RAM 中的 1986 年游戏——而是因为 Claude 被证明是一个用于审问 6502 汇编的非凡工具。我可以输入一段反汇编的源代码,然后问“关卡数据是如何工作的?”或者“这里的物理模型在做什么?”,然后得到关于原始代码正在做什么的详细、准确的解释。 公平地说,我是在一些带注释的反汇编源代码的基础上工作的,但即使如此,它也能够从注释和汇编中提取信息,并提出关于游戏如何工作的详细描述。我的感觉是,如果没有注释帮助它将注意力集中到正确的区域,它会变得不那么有用——但即便如此,它还是让这项工作变得简单和愉快得多。是的,我似乎很可能会从代码中剥离注释,然后看看 Claude 的表现如何。 在这样做的过程中,我意识到我可以将这些答案作为重现原版游戏的基础,并开始让 Claude 为各个子系统创建规范。我生成的大部分规范可以在源代码的 specs 文件夹 (https://github.com/JamesRandall/ts-thrust/tree/main/specs) 中找到。我可能会在某个时候将它们正式整理成文,但目前它们很好地揭示了原版中的细微差别——其中涉及的内容相当多,比我意识到的要多。例如,我从未注意到如果你击中发电机,炮塔会停止射击一段时间,而且它们的射击角度存在细微差别,只有当你阅读实际代码时才会变得明显。 ## 物理 物理部分是最值得深入研究的领域之一。《Thrust》使用 Q7.8 定点算术——这是在没有浮点硬件的 8 位机器上常用的一种技术。旋转系统使用 32 个步骤,并带有力分量的查找表。 但真正棘手的部分是时序。我的第一次实现使用了反汇编中的正确常量——相同的重力,相同的推力值,相同的阻力——但飞船太快、太灵活了。它没有正确的重量。常量与原版相同,所以这一定是一个时序问题。 确实如此。原版并非以 BBC Micro 的 50 Hz VSync 速率运行其物理。tick 循环每帧至少等待 3 厘秒,有效速率约为 33.33 Hz。但还不止于此:在每个 tick 内,物理更新被限制在每 16 个 tick 窗口中只有 6 个活动槽位。物理步骤的核心如下所示: ``` /** 每 16 个 tick 窗口中的 6 个活动物理槽位 */ private static readonly ACTIVE_SLOTS = new Set([0, 3, 5, 8, 11, 13]); private tickStep(input: ThrustInput): void { const slot = this.tickCounter & 0x0F; this.tickCounter = (this.tickCounter + 1) & 0xFF; // 旋转:每 4 个 tick 中有 3 个,仅整数步进 if ((slot & 0x03) !== 0 && input.rotate !== 0) { s.angle = ((s.angle + input.rotate) + 32) % 32; } const isActiveSlot = ThrustPhysics.ACTIVE_SLOTS.has(slot); // 力的计算——仅活动槽位(每 16 个 tick 中的 6 个) if (isActiveSlot) { s.forceY += this.gravity; if (input.thrust) { s.forceY += ANGLE_Y[angleIdx] / (1 << this.massShift); s.forceX += ANGLE_X[angleIdx] / (1 << this.massShift); } // 线性阻力 s.forceX *= 1 - 1/64; // X: *= 63/64 s.forceY *= 1 - 1/256; // Y: *= 255/256 } // 位置积分——每个 tick s.x += s.forceX; s.y += s.forceY; } ``` 这种门控使得有效的力/阻力速率约为 12.5 Hz——重力和推力仅在那 6 个特定的 tick 上应用,而不是每一帧。即使是旋转也有自己的门控:它跳过每第四个 tick。这些不是随意选择的数字;它们正是 6502 源代码中的精确模式,并且它们定义了游戏的感觉——如果你随意改动它们,一切很快就会感觉不对劲。非对称阻力也很有趣——X 轴(63/64)比 Y 轴(255/256)强得多,这就是为什么水平移动感觉比垂直移动更“粘”。 一旦我正确地调整了这些时序——匹配原版精确的更新节奏,而不仅仅是它的常数——感觉就完美了。你可以在 BBC 模拟器和我的版本之间切换,控制感觉是一样的。 这才真正引起了我的兴趣。使用普通的物理创建一个类似《Thrust》的游戏是微不足道的——特别是使用编码 AI 时。但重现*《Thrust》*——包含其所有特定的感觉和重量——需要精确理解原版是如何工作的,一直到游戏循环内单个物理更新的时序。 ## 声音 另一个需要真正专注的领域是声音。当我制作 TypeScript 版本的《Elite》(https://www.jamesdrandall.com/projects/webglite/) 时,我直接从模拟器中采样了声音。这样做效果不错,因为《Elite》的声音相当离散——短促、尖锐的效果。尽管光束和军用激光效果有些偏差。但在《Thrust》中,引擎是一种连续的低鸣声,会响应按键,而爆炸则有特定的包络。我本可以对它们进行采样,但感觉就是……不对。 所以我决定采用不同的方法,转而重现 SN76489 声音芯片(我在做这个时了解到,这颗芯片与世嘉 Master System 中的芯片相同)以及与之交互的 BBC MOS 接口。MOS——BBC 的机器操作系统——提供了游戏与声音芯片通话的接口,使用了 OSWORD 调用和内存映射 I/O。 在这方面,Toby Nelson 整理的带反汇编的 MOS 代码 (https://tobylobster.github.io/mos/),以及 BBC 高级用户指南和 SN76489 芯片规格,都提供了巨大的帮助。我能够将所有这些东西输入给 Claude,为声音系统生成一份全面的规范,并据此构建一个在 AudioWorklet 中运行的模拟声音系统。完整的 BBC MOS 包络处理器(OSWORD 7/8)在音频线程上驱动芯片模拟器。 结果呢?声音完全一致。而通过其他任何方式都很难做到这一点——它对时序的要求非常高,非常依赖于精确的包络形状和芯片行为,以至于你确实需要模拟实际硬件。 声音系统本身在设计上非常优雅。在最顶层,播放一个声音意味着发送与原版游戏相同的 OSWORD 7 参数——通道、振幅、音高、持续时间: ``` const sounds = { own_gun: { channel: 0x0012, amplitude: 1, pitch: 0x50, duration: 2 }, explosion_1: { channel: 0x0011, amplitude: 2, pitch: 0x96, duration: 100 }, explosion_2: { channel: 0x0010, amplitude: 3, pitch: 0x07, duration: 100 }, hostile_gun: { channel: 0x0013, amplitude: 4, pitch: 0x1e, duration: 20 }, engine: { channel: 0x0010, amplitude: -10, pitch: 0x05, duration: 3 }, }; ``` 这些参数被送入一个 MOS 包络处理器,该处理器驱动 SN76489 芯片模拟器,两者都在音频线程的 AudioWorklet 中运行。芯片模拟器在硬件级别生成采样——带有 10 位周期计数器的音调通道,一个带有 15 位线性反馈移位寄存器的噪声通道,以及一个每步 -2dB 的音量表,与原始硅片匹配: ``` generate(out: Float32Array, offset: number, length: number, sampleRate: number): void { const step = 250000.0 / sampleRate; // 每个音频采样的芯片时钟数 for (let i = 0; i < length; i++) { let sample = 0; // 音调通道 0–2 for (let ch = 0; ch < 3; ch++) { this.counter[ch] -= step; if (this.counter[ch] <= 0) { const p = this.period[ch] || 1024; this.counter[ch] += p; this.polarity[ch] = -this.polarity[ch]; } sample += this.polarity[ch] * this.vol[ch]; } // 噪声通道 — 15 位 LFSR // ... sample += ((this.lfsr & 1) ? 1 : -1) * this.vol[3]; out[offset + i] = sample; } } ``` 这是一层层的模拟。MOS 在音频线程上以 100 Hz 的频率 tick,以与真实 BBC 硬件相同的速率处理包络和更新芯片寄存器,最终产生的声音是真实的。 ## 图形和关卡 字体、图形和关卡数据我可以相对容易地从反汇编的源代码中提取出来。你可以在源代码中找到一些工具 (https://github.com/JamesRandall/ts-thrust/tree/main/tools) 来做这件事——一个解码关卡地形数据,另一个通过模拟 BBC 的帧缓冲区并将输出转换为 PNG 来提取飞船旋转精灵。 我最初尝试为飞船使用矢量方法——在每个旋转角度以数学方式绘制它——但在 BBC 分辨率下看起来粗糙,因为事实证明原版为 32 个旋转角度中的每一个都有硬编码的精灵,由 Jeremy Smith 手工优化,使它们在每个位置看起来都正确。一旦我提取并使用了那些实际精灵,它看起来就正确了。 地形渲染使用原版的扫描线奇偶多边形填充——每隔一行绘制——这赋予了它标志性的 BBC Micro 外观。内部分辨率为 320×256,与原版匹配。我的版本有一个稍大的视口——原版周围有一个边框,可能是为了在 BBC 上保持足够的滚动速度——我可能会在某个时候为此添加一个切换开关。 ## 演示模式 我最后添加的东西之一是演示模式——游戏在标题屏幕上自行播放的吸引序列。深入研究原版如何实现这一点是令人满意的:它只是按时间间隔向游戏循环注入按键。没有单独的演示渲染器——普通的游戏引擎使用来自一对并行表的虚假输入运行: ``` // 每个片段要按住的按键 const DEMO_KEYPRESS_BIT_MASK_TABLE = [ 0x00, // 0: 自由落体 INPUT_ROTATE_RIGHT, // 1: 右转 0x00, // 2: 无操作 INPUT_FIRE, // 3: 向炮塔开火 INPUT_ROTATE_LEFT, // 4: 左转 INPUT_SHIELD_TRACTOR, // 5: 护盾/牵引光束 INPUT_THRUST | INPUT_SHIELD, // 6: 推进 + 护盾 // ... 共 18 个条目 ]; // 每个条目要按住的时间(以游戏 tick 为单位) const demoKeypressTimerTable = [ 0x18, // 24 ticks 自由落体 0x0F, // 15 ticks 右转 0x05, // 5 ticks 无操作 0x05, // 5 ticks 开火 0x08, // 8 ticks 左转 0x14, // 20 ticks 护盾/牵引光束 0x17, // 23 ticks 推进 + 护盾 // ... ]; ``` 其中三个定时器槽位在每次演示运行开始时被随机化——例如 `(rnd & 0x03) + 0x08` 产生 8–11 ticks 的范围,以及最后长螺旋和短螺旋之间的 75/25 分配。优雅且简单:它创造了足够的变化,使得重复的吸引序列不会感觉机械。 让它工作起来需要理解另一层时序,并且暴露了玩家起始位置的一个小误差——正好足够大,使得演示序列错过了一个它本应击中的炮塔。这导致我发现我略微算错了生成点,进而发现在每个关卡有多个生成点,这是我在最初的关卡解码中忽略的东西。 这个演示模式能够如此工作,证明了物理重现是精确的——如果时序不对,定时按键会导致不同的行为,且子弹不太可能击中炮塔,玩家也不太可能带着吊舱逃脱。 ## 传送动画 有一个时刻让我对编码助手印象深刻:传送动画——当你跳入一个关卡时的效果——基于我从反汇编中提取的规范,与原版不匹配。我没有在代码上花费更多时间,而是录制了原版游戏的视频,捕捉了动画帧,将它们粘贴到 Claude Code 中,然后说“重现这个。”它在大约五分钟内几乎完美地完成了。不是困难的代码——我本可以在半小时内完成——但这是一个很好的例子,说明 AI 如何在无趣的事情上节省时间,以便你可以专注于有趣的事情。 ## CRT 效果 为了完善效果,我添加了一些 CRT 后处理效果:扫描线、绿色荧光粉、琥珀色荧光粉、黑白电视和 VCR 外观。这些是作为 WGSL 的 WebGPU 计算着色器实现的,当 WebGPU 不可用时优雅地降级。没什么特别困难的——我大部分是从我的《Elite》转换中移植过来的,并添加了黑白滤镜。 我并没有太多时间在彩色电视上玩 BBC。当我第一次得到一台 BBC 时,我在一个 12 英寸的小黑白电视上玩,后来是一个小的绿色屏幕显示器。这些滤镜只是为了好玩,但它们确实给人一种真实的感觉。 ## 这到底是为了什么 这个过程中有趣的事情不是写代码。代码本身很直接——

相似文章

AI让克隆游戏变得前所未有地简单

Reddit r/artificial

文章讨论了生成式AI如何使克隆游戏原型变得更容易,正如Freya Holmér独特的俄罗斯方块变种迅速被他人利用vibe-coding复制时所展现的那样,这引发了关于原创性和技能价值贬低的伦理担忧。

Claude 游玩 World of ClaudeCraft

Reddit r/artificial

来自 Anthropic 的 AI 模型 Claude 展示了其与名为 World of ClaudeCraft 的游戏世界进行交互的能力,展现了 AI 在游戏方面的能力。

Can AI Make a Game Engine? (Yes)

Reddit r/singularity

作者尝试让 Claude(Fable 5.1)与 GPT(GPT6 Astra)各自独立编写一个生产级 C++ 3D 游戏引擎,先界定任务范围并编写需求文档交由模型自主发挥。文章探讨了 AI 能否端到端完成游戏引擎开发,以及在何种程度上 AI 可以取代经验丰富的工程团队。

与AI共事:一个具体例子

Hacker News Top

作者分享了一个具体例子,使用Claude AI调试hyperscript中的解析回归问题,展示了AI辅助开发的优势与不足,并提醒不要过度依赖。