首页
/ pi 编码代理 DOOM Overlay 扩展:用终端 Overlay 系统以 35 FPS 实时渲染游戏

pi 编码代理 DOOM Overlay 扩展:用终端 Overlay 系统以 35 FPS 实时渲染游戏

2026-09-06 21:29:05作者:宣聪麟

本文基于 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() 的实际流程是:

  1. 依次检查候选路径:扩展目录内的捆绑 WAD(doom/doom1.wad)、./doom1.wad./DOOM1.WAD~/doom1.wad~/.doom/doom1.wad
  2. 全部未命中时,从 gamers.org 的公开镜像抓取 doom-1.8.wad.gz,用 gunzipSync 解压;
  3. 校验解压结果前 4 字节必须是 IWAD 魔数,防止把损坏/被劫持的文件当作游戏资源;
  4. 校验通过后写入扩展目录的 doom1.wad 并返回路径。

另外,命令支持可选参数:/doom-overlay <路径> 可以直接指定自定义 WAD 文件(见 index.tsargs?.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):方向键为 0xac0xaf,横向移动 KEY_STRAFE_L/RKEY_USEKEY_FIREKEY_PAUSE0xff)等。终端输入经过 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.tshandleInput 中:按 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_GetFrameBufferDG_GetScreenWidth/Height 等函数用 EMSCRIPTEN_KEEPALIVE 导出,供 JS 直接读取像素内存。

DoomEngine:加载 WASM 并注入 WAD

doom-engine.ts 封装了全部 WASM 互操作,关键步骤有四个:

  1. 定位与加载 WASM 模块init() 期望在 doom/build/doom.js 找到 emcc 产出物(缺失时抛出 Run ./doom/build.sh first 的提示)。加载方式比较特殊——把 doom.js 源码读出来后用 new Function("module", "exports", ..., doomJsCode) 执行(见 init 方法),注释说明目的是「完全绕过 jiti」,因为 pi 扩展运行在 jiti 转译环境下,直接动态加载 Emscripten 生成代码不可靠。

  2. 向 Emscripten 虚拟文件系统注入 WAD:通过 preRun 钩子调用 FS_createPath("/", "doom", ...)FS_createDataFile("/doom", "doom1.wad", wadArray, ...),把整个 WAD 文件塞进 WASM 沙箱的文件系统,DOOM 启动时就能像读取磁盘一样读它。

  3. 构造命令行参数initDoom() 手动在 WASM 堆上 _malloc["doom", "-iwad", "/doom/doom1.wad"] 的 argv 数组(逐字符 setValue(ptr + i, charCode, "i8") 写入并补 \0 终止符),然后调用 _doomgeneric_Create(argc, argvPtr) 完成游戏初始化。

  4. 帧数据换算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:1render(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/colresolveAnchorRow/resolveAnchorCol 按锚点与可用区域计算,百分比尺寸相对终端尺寸换算,maxHeightmargin 共同约束最终渲染盒。DOOM 组件的 render(width) 拿到的是布局系统算好的实际列宽,再自行按 3.2:1 推高,因此 overlay 尺寸约束(上限)与组件自适配比例(下限)共同决定了最终画面大小。

扩展入口:命令注册、模式检查与状态恢复

index.ts 展示了 pi 扩展 API 的典型用法(扩展机制的完整说明见 extensions.md):

  1. pi.registerCommand("doom-overlay", ...):注册 slash 命令,handler 签名为 (args, ctx)ctx.mode !== "tui" 时直接 ctx.ui.notify(..., "error") 返回——DOOM 依赖键盘事件流,非交互模式下无法运行;
  2. ctx.ui.custom(componentFactory, { overlay: true, ... }):这是 pi 扩展向 TUI 注入自定义组件的入口,传入 overlay: true 时组件以覆盖层呈现而非替换主界面,工厂回调拿到的 done(undefined) 用于在组件退出时关闭 overlay;
  3. 引擎实例持久化与恢复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/disposerequestRender 增量重绘、按键释放事件)在持续高负载下的可用性。想继续深入,可以从 doom-component.ts 的渲染循环入手,对照 packages/tui/src/tui.tsshowOverlay 与布局解析代码,再回看 extensions.mdui.custom 的 API 约定。

登录后查看全文
热门项目推荐
相关项目推荐