pi 编码代理 DOOM Overlay 扩展:用终端 Overlay 系统以 35 FPS 实时渲染游戏
本文基于 packages/coding-agent/examples/extensions/doom-overlay/README.md 及其配套源码,讲解 pi 项目中 DOOM Overlay 示例扩展的完整实现:如何在 pi 的交互式 TUI 上以覆盖层(Overlay)方式运行 DOOM 游戏、通过半块字符与 24 位真彩色实现终端帧缓冲渲染、把终端按键映射到 doomgeneric 的键值协议,以及 WASM 引擎的加载、WAD 资源自动下载与游戏状态恢复机制。读完后你将掌握 pi 扩展系统中 registerCommand + ui.custom(overlay) 的完整调用链,以及 overlay 布局参数(width/maxHeight/anchor)的解析原理。
这个示例想证明什么
DOOM Overlay 是 pi coding-agent 内置的官方示例扩展之一,定位非常明确:在 pi 中把 DOOM 作为覆盖层运行,证明 overlay 系统能够承受 35 FPS 的实时游戏渲染。它不是一个普通的对话框或面板组件,而是一个持续驱动游戏循环、每帧重绘整个画面区域的重量级组件,因此常被用作衡量 pi TUI 渲染管线(增量重绘、ANSI 转义处理、overlay 布局)承载能力的最极端示例。
示例目录结构(均位于 packages/coding-agent/examples/extensions/doom-overlay/):
| 文件 | 职责 |
|---|---|
index.ts |
扩展入口,注册 /doom-overlay 命令 |
doom-component.ts |
TUI 组件:35 FPS 游戏循环、半块字符渲染 |
doom-engine.ts |
WebAssembly 引擎封装(doomgeneric) |
doom-keys.ts |
终端按键 → DOOM 键码映射表 |
wad-finder.ts |
WAD 资源查找与自动下载 |
doom/doomgeneric_pi.c |
Emscripten 平台层 C 实现 |
快速上手
按照 README 的 Usage 章节,启动方式只有两步:
pi --extension ./examples/extensions/doom-overlay
然后在 pi 交互式界面中运行命令:
/doom-overlay
首次运行时会自动下载约 4MB 的 shareware WAD 文件。从源码看(wad-finder.ts),ensureWadFile() 的实际流程是:
- 依次检查候选路径:扩展目录内的捆绑 WAD(
doom/doom1.wad)、./doom1.wad、./DOOM1.WAD、~/doom1.wad、~/.doom/doom1.wad; - 全部未命中时,从 gamers.org 的公开镜像抓取
doom-1.8.wad.gz,用gunzipSync解压; - 校验解压结果前 4 字节必须是
IWAD魔数,防止把损坏/被劫持的文件当作游戏资源; - 校验通过后写入扩展目录的
doom1.wad并返回路径。
另外,命令支持可选参数:/doom-overlay <路径> 可以直接指定自定义 WAD 文件(见 index.ts 中 args?.trim() ? args.trim() : await ensureWadFile() 的分支),适合使用非 shareware 的 IWAD。
操作方式与按键映射
README 给出的操作表如下:
| 动作 | 按键 |
|---|---|
| 移动 | WASD 或方向键 |
| 奔跑 | Shift + WASD |
| 开火 | F 或 Ctrl |
| 使用/开门 | 空格 |
| 武器切换 | 1-7 |
| 地图 | Tab |
| 菜单 | Escape |
| 暂停/退出 | Q |
这张表背后对应 doom-keys.ts 中的两层实现:
第一层是 DOOM 键码常量表(DoomKeys,数值取自 doomgeneric 的 doomkeys.h):方向键为 0xac–0xaf,横向移动 KEY_STRAFE_L/R、KEY_USE、KEY_FIRE、KEY_PAUSE(0xff)等。终端输入经过 mapKeyToDoom(data) 函数翻译成这些键码数组,再推入 WASM 引擎。
第二层是按键映射规则,几个值得注意的细节:
- WASD 同时匹配原始字符和 Kitty 协议转义序列(
matchesKey(data, Key.shift("w"))等),即无论终端走经典转义还是现代 Kitty 键盘协议,Shift+WASD 奔跑都能正确产生「方向键 +KEY_RSHIFT」的组合; - 任何 Ctrl 组合键(Ctrl+C 除外,留给 pi 自身的中断语义)都映射为
KEY_FIRE,兼容老 DOOM 用 Ctrl 开火的习惯; - 数字键
0-9按 ASCII 码直接透传,用于武器切换;+/-映射为KEY_EQUALS/KEY_MINUS(DOOM 的+/-命令用于缩放画面); - 其余可打印字符一律小写透传,注释里说明这是为了支持 DOOM 秘籍(cheats)输入;
y/n单独处理,用于响应游戏内的确认提示。
退出路径在 doom-component.ts 的 handleInput 中:按 Q 时先向引擎推送一次「按下+释放」的 KEY_PAUSE(保证 DOOM 内部状态是暂停而非崩溃),再清理组件并回调 onExit() 关闭 overlay。
工作原理:WASM 引擎 + 半块字符渲染
doomgeneric 编译为 WebAssembly
README 的 "How It Works" 章节说明:DOOM 运行的是由 doomgeneric 编译出的 WebAssembly 构建。仓库中保留了该 WASM 平台的 C 侧实现 doom/doomgeneric_pi.c,可以清楚看到 pi 集成做了哪些取舍:
- 只实现帧缓冲与输入,不做声音(文件头注释明确 "Minimal implementation - no sound, just framebuffer and input");
DG_PushKeyEvent把 JS 推来的按键写入一个 256 长度的环形队列,游戏 tick 时通过DG_GetKey逐个取走——输入是事件驱动的、与渲染解耦的;DG_SleepMs是空操作,计时完全交给 JS 侧(即组件里的setInterval),这样游戏帧率由 TUI 事件循环掌控,而不是 WASM 内部阻塞;DG_GetFrameBuffer、DG_GetScreenWidth/Height等函数用EMSCRIPTEN_KEEPALIVE导出,供 JS 直接读取像素内存。
DoomEngine:加载 WASM 并注入 WAD
doom-engine.ts 封装了全部 WASM 互操作,关键步骤有四个:
-
定位与加载 WASM 模块:
init()期望在doom/build/doom.js找到 emcc 产出物(缺失时抛出Run ./doom/build.sh first的提示)。加载方式比较特殊——把doom.js源码读出来后用new Function("module", "exports", ..., doomJsCode)执行(见 init 方法),注释说明目的是「完全绕过 jiti」,因为 pi 扩展运行在 jiti 转译环境下,直接动态加载 Emscripten 生成代码不可靠。 -
向 Emscripten 虚拟文件系统注入 WAD:通过
preRun钩子调用FS_createPath("/", "doom", ...)与FS_createDataFile("/doom", "doom1.wad", wadArray, ...),把整个 WAD 文件塞进 WASM 沙箱的文件系统,DOOM 启动时就能像读取磁盘一样读它。 -
构造命令行参数:
initDoom()手动在 WASM 堆上_malloc出["doom", "-iwad", "/doom/doom1.wad"]的 argv 数组(逐字符setValue(ptr + i, charCode, "i8")写入并补\0终止符),然后调用_doomgeneric_Create(argc, argvPtr)完成游戏初始化。 -
帧数据换算:
getFrameRGBA()逐像素用getValue(frameBufferPtr + i*4, "i32")读出 DOOM 的 ARGB 32 位整数,再通过位移与掩码拆成 R/G/B 并固定 A=255,输出标准 RGBA 的Uint8Array供渲染层使用。
半块字符(▀)+ 24 位真彩色的帧渲染
这是整个示例最核心的渲染技巧,位于 doom-component.ts 的 renderHalfBlock:
- 终端里一个字符格的高度约是宽度的 2 倍,而字符
▀恰好能同时绘制上下两个半格像素——上半格的像素颜色用前景色(\x1b[38;2;r;g;bm)、下半格的像素颜色用背景色(\x1b[48;2;r;g;bm)表示,于是 640×400 的画面用targetRows行半块字符就能以 24 位真彩色完整呈现; - 采样采用最近邻:横向
srcX = floor(col * width/targetCols),纵向srcY1/srcY2分别对应同一字符格的上、下两个像素; - 每行末尾统一追加
\x1b[0m复位,防止样式泄漏到下一行。
画面比例与 README 描述一致但推导细节值得展开:DOOM 原生分辨率为 640×400(1.6:1),半块渲染让每个终端行代表 2 个像素,因此终端单元格下的有效宽高比是 640:200 = 3.2:1。render(width) 据此计算高度 height = max(10, floor(width / 3.2)),最低 10 行,再追加一行淡色的操作提示 footer(Q=Pause | WASD=Move | ...,超出宽度则截断)。
35 FPS 游戏循环在组件构造时启动:
this.interval = setInterval(() => {
try {
this.engine.tick(); // 推进一帧游戏逻辑
this.tui.requestRender(); // 请求 TUI 重绘
} catch {
// WASM 出错(例如在 DOOM 菜单中退出)- 视为退出
this.dispose();
this.onExit();
}
}, 1000 / 35);
每 1000/35 毫秒 tick 一次并请求渲染;dispose() 会清掉定时器。另外组件声明了 wantsKeyRelease = true,订阅按键释放事件——连续移动时「按下/松开」都推给引擎(pushKey(!released, key)),这是移动手感平滑的关键,普通 UI 组件通常只需要按下事件。
Overlay 布局参数
README 的 "How It Works" 提到 overlay 使用了百分比宽度、最大高度与居中对齐三项定位参数。需要注意:README 写的是 width: "90%" / maxHeight: "80%",而当前源码实际取值为 width: "75%"、maxHeight: "95%",并额外设置了 margin: { top: 1 },以 index.ts 第 57-65 行 为准:
await ctx.ui.custom(
(tui, _theme, _keybindings, done) => {
return new DoomOverlayComponent(tui, activeEngine!, () => done(undefined), isResume);
},
{
overlay: true,
overlayOptions: {
width: "75%",
maxHeight: "95%",
anchor: "center",
margin: { top: 1 },
},
},
);
这些参数对应 TUI 包中的 OverlayOptions 接口。从 packages/tui/src/tui.ts 的布局解析代码(resolveOverlayLayout)看:anchor 缺省即 "center",row/col 由 resolveAnchorRow/resolveAnchorCol 按锚点与可用区域计算,百分比尺寸相对终端尺寸换算,maxHeight 与 margin 共同约束最终渲染盒。DOOM 组件的 render(width) 拿到的是布局系统算好的实际列宽,再自行按 3.2:1 推高,因此 overlay 尺寸约束(上限)与组件自适配比例(下限)共同决定了最终画面大小。
扩展入口:命令注册、模式检查与状态恢复
index.ts 展示了 pi 扩展 API 的典型用法(扩展机制的完整说明见 extensions.md):
pi.registerCommand("doom-overlay", ...):注册 slash 命令,handler 签名为(args, ctx)。ctx.mode !== "tui"时直接ctx.ui.notify(..., "error")返回——DOOM 依赖键盘事件流,非交互模式下无法运行;ctx.ui.custom(componentFactory, { overlay: true, ... }):这是 pi 扩展向 TUI 注入自定义组件的入口,传入overlay: true时组件以覆盖层呈现而非替换主界面,工厂回调拿到的done(undefined)用于在组件退出时关闭 overlay;- 引擎实例持久化与恢复:
activeEngine/activeWadPath是模块级变量,跨命令调用存活。若上次退出时的 WAD 与本次一致,则复用已有引擎并传isResume=true,组件构造时会先推送一对KEY_PAUSE事件把 DOOM 从暂停中唤醒(doom-component.ts 构造器)。WASM 模块因此不必重新初始化,二次进入游戏是即时的;加载失败则清理两个模块级变量,回到干净状态。
小结
DOOM Overlay 示例把 pi 的扩展系统压到极限场景:一个 C 游戏核心经 Emscripten 编译成 WASM,输入走 256 容量环形队列事件解耦,计时交给 JS 侧 35 FPS 定时器,画面用半块字符 + 24 位真彩 ANSI 转义逐行重绘,布局交给 TUI 的 overlay 系统按百分比/锚点解析。它验证的不只是「能显示动画」,而是 overlay 组件契约(render/handleInput/dispose、requestRender 增量重绘、按键释放事件)在持续高负载下的可用性。想继续深入,可以从 doom-component.ts 的渲染循环入手,对照 packages/tui/src/tui.ts 的 showOverlay 与布局解析代码,再回看 extensions.md 中 ui.custom 的 API 约定。
atomcodeClaude Code 的开源替代方案。连接任意大模型,编辑代码,运行命令,自动验证 — 全自动执行。用 Rust 构建,极致性能。 | An open-source alternative to Claude Code. Connect any LLM, edit code, run commands, and verify changes — autonomously. Built in Rust for speed. Get StartedRust0624
Hy4-previewHy4 preview 是由腾讯混元团队研发的新一代混合专家(MoE)旗舰模型。模型总参数量 770B,每个 token 激活 49B,主干共包含78层,第一层采用标准 FFN,其余 77 层均为 MoE 结构,每层包含 256 个路由专家与 1 个共享专家,每个 token 激活 top-8 路由专家及共享专家。主干之外原生内置 1 层 MTP(总参数量 10B,激活 0.7B)以支持投机解码。Python00
GLM-5.3GLM-5.3 与 GLM-5.2 使用相同的基座模型——所有提升均来自后训练。与 GLM-5.2 相比,它在复杂编程和长程任务上的表现显著提升。Jinja00
GLM-5.3-FlashGLM-5.3-Flash (320B-A18B),是GLM-5系列的首个原生多模态模型。320B总参数,能力超过GLM-5.2Jinja00
Spark-X2.5-4BSpark-X2.5-4B 旨在让强大的 AI 更实用、更高效、更易获得。在广泛日常任务中表现强劲,涵盖对话、写作、翻译、推理、编码、工具调用以及智能体工作流,并在同等规模的开源模型中取得领先成绩。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00
Spark-X2.5-1.7BSpark-X2.5-1.7B 旨在让强大的 AI 更加实用、高效且易于获取。这些模型在广泛的日常任务中表现出色,涵盖对话、写作、翻译、推理、编程、工具调用和智能体工作流,并在同等规模的开源模型中取得领先结果。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00