TIM-1EARCHIVE TERMINAL // 2026

LOG-003开发日志CLASS · PUBLIC

给《杀戮尖塔 2》做连胜簿,结果先得定义“今天”

从原生战绩字段到只读历史聚合,从右上角小窗到四种统计范围。记录 SpireStreak 0.1.0—0.2.0 的框架取舍、Codex 与 Opus 分工,以及几个必须进游戏才会暴露的坑。

DATE
2026.10.05
READ
9 MIN
LENGTH
3,372 字
CONTENTS · 目录(9)
  1. 先看看别人已经解决了什么
  2. 借旧工程的骨架,给新模组留自己的边界
  3. 游戏明明有连胜字段,为什么又改读历史?
  4. “今天”从中午十二点开始
  5. 不让卸载的角色变成幽灵
  6. Codex 管核心,Opus 做界面,中间放一份接口
  7. 编译器不负责告诉我,窗口掉到屏幕下面了
  8. 留下能复盘的证据,再发出去
  9. 归档卡片

做连胜簿时,我碰到了一道很短的选择题:

plain
单人胜 → 多人败 → 单人胜

当前连胜应该填 1,还是 2?

只统计单人,答案是 2;把多人也算进去,答案就是 1。界面上只差一个开关,背后的数字却需要重新计算。

这个项目最初只是想在《杀戮尖塔 2》的右上角放几个连胜数字。做到 0.2.0 时,它已经有了历史、今日、近一周、本次记录四种范围,可以同时展示,也能排成两栏。我把它叫作连胜簿 · Spire Streak,以“规范有勇气”的名字发布到了创意工坊。

连胜簿 0.2.0:四个统计范围以双栏显示

游戏内实际渲染,数字来自隔离测试的模拟战绩;左下角标有“演示数据”。

先看看别人已经解决了什么

开工前查了一轮工坊。连胜悬浮窗已经有人做过。

ZKQ 的 Win Streak Overlay在公开说明里列出了 A10 连胜、拖动缩放和角色筛选;waterkami 的战绩悬浮窗则明确从 RunHistory 汇总,并区分单人与团队统计。

这轮对照帮我收紧了需求:我要保留角色自己的头像,原版和模组角色都能逐一关闭;角色模组停用后,不能留下占位置的空行。多人是否计入要能切换,后来又加上了适合开播使用的时间范围。

这里参考的是公开功能说明,没有下载、移植这些模组的源码。说明里没写某项功能,也不能直接推断别人不支持。继续做独立模组,是为了把自己的这组需求落下来。

借旧工程的骨架,给新模组留自己的边界

本地已有一个“以撒尖塔”工程,可以复用初始化和构建方式:C# 编写逻辑,ModInitializer 作为入口,Harmony 接入游戏生命周期,PowerShell 调用 Roslyn 编译并引用本机游戏程序集。

新模组沿用了这套工具链,单独拥有自己的 ID、DLL、配置和安装目录。旧工程里改地图、房间和探索的玩法补丁没有带过来。隔离运行的测试方法也复用了已有经验,实际启动器来自此前的卡图项目。

设置设计还看过天体卡图项目:如何让开关回调更新偏好、如何单独保存配置。那个项目使用了 RitsuLib;连胜簿最终直接用游戏里的 Godot 控件实现设置面板。中文字体和角色头像也从游戏取,当前发行包无需额外框架或 PCK。

运行时代码最后分成四个文件,职责很具体:

文件负责什么
Mod.cs挂载 HUD,处理切档、历史保存通知、范围变化和后台读取
StreakData.cs读取历史摘要,过滤并计算各角色的连胜与胜率
UiContract.cs约定偏好、角色行和统计快照的数据形状
StreakHud.cs用 Godot 控件显示快照,处理用户输入

Harmony 的补丁也收得很窄:在 NGlobalUi.Initialize 之后挂上界面,在 SaveRunHistory 之后标记需要刷新。这里用的是原方法执行后接续处理的 Postfix。游戏继续负责保存战绩,模组读取保存后的结果。

这个分工还有一个小好处:测试时可以直接给纯数据层喂模拟历史,不必为了验证一个统计公式先打一局尖塔。

游戏明明有连胜字段,为什么又改读历史?

最开始查到原生 CharacterStats.CurrentWinStreak 和 BestWinStreak,事情看起来已经完成了一半:取值,显示。

但在这次核对的游戏版本里,这两个聚合值已经混合了单人与多人。想让玩家随时切换“是否计入多人”,就得保留对局的先后顺序。文章开头那三局,靠减去一个多人胜场或者失败场次,算不出正确答案。

于是数据来源改成了当前档位的原生历史文件:读摘要,过滤标准局和多人归属,再按开局时间、角色分别计算。胜利延续连胜,失败或已经结算的放弃将其打断;每日挑战和自定义对局不混入。胜率的分母是计入范围的总场数,零场显示“—”。

最终实现直接枚举 *.run,用 JsonDocument 解析需要的字段。没有调用会修复或迁移历史的加载流程;无法读懂的记录跳过并提示统计可能不完整。自己的设置仍然会保存,“只读”针对的是原生战绩。

保存钩子也只发出“重新读取”的通知。它不会现场给胜场加一,因此重复打开面板、重新读取同一份历史,不会凭空多出一胜。

到 0.2.0,四个范围共用一次读取和排序,再分别聚合。UI 拿到的是完成计算的快照;旧的后台读取即使晚回来,也不能覆盖切档或修改范围后的新结果。

“今天”从中午十二点开始

加今日胜率时,我不想要一个一直滑动的“最近 24 小时”。人还在同一场直播里,昨天的一局却随着时间流逝滑出窗口,数字就会自己变掉。

最后给“统计日”设了一个明确的分界:默认本地时间 12:00,允许修改。

范围怎么算
历史当前档位全部可读取的历史
今日最近一次每日分界,到现在
近一周今日起点向前 6 个日历日,到现在,共 7 个统计日
本次记录最近一次点击记录按钮,到现在

例如 10 月 5 日上午 10 点打开面板,默认的“今日”从 10 月 4 日中午 12 点开始。跨过中午后再切到新的统计日。这个口径定下来,界面里的“今天”才有了可以解释的含义。

“开始记录”按钮更直接:保存一个时间锚点,把本次记录加入已选范围。下次开播再点一次,就换一个锚点。历史文件留在原处,连胜和胜率都从新的范围重新计算。

这里也有原生数据的限制:历史里没有可靠的结算时间,StartTime + RunTime 不能当作真实结束时刻。因此目前按已结束对局的开局时间归类。点击记录前已经开局的当前对局不会纳入本次记录,这一点写进了设置提示和发布说明。

范围多选、每日分界和重新开始记录的设置界面

设置页也来自实际游戏。每日分界可以修改,重新开始记录不会清除历史。

不让卸载的角色变成幽灵

统计历史可以记得一个角色,HUD 却不能据此认定它现在仍然存在。

角色名单取自运行时的 ModelDb.AllCharacters,筛出当前可玩的角色,再与用户的显示选择求交集。历史里留下一个旧 ID,不会自动生成一行。模组角色停用并重启游戏后退出面板;重新启用时,原来的显示偏好仍然保留。

BaseLib 的标准注册流程会扩展这份原生角色集合,所以连胜簿可以沿着同一入口识别这些角色,而不把 BaseLib 设为硬依赖。这里的兼容目标是原版和按标准流程注册的角色,不能据此承诺所有自定义角色框架都能工作。

头像则直接使用 CharacterModel.Icon 提供的完整控件。这样角色原有的图标和边框可以一起保留,也省去了根据角色名猜图片路径的工作。

Codex 管核心,Opus 做界面,中间放一份接口

这次我让 Codex 负责核心、集成和验证,把 UI 交给远端 Claude Code 的 Opus 5.5。调用链通过子代理和 SSH 衔接,先做一个很小的试跑,确认真实请求和返回,再进入正式界面开发。

交给 UI 的材料是一份共享接口、模拟数据和界面要求。它能读到角色行、统计快照,也能调用保存偏好、创建原生头像、开始记录这几个回调。读取历史、识别本人的多人记录、设置时间锚点,都留在核心里。

0.2.0 的远端任务就在现有 StreakHud.cs 上增量修改:加期间区块、胜率、单/双栏、时间输入和记录按钮。返回后使用真实游戏 API 编译,再进入游戏验证。原始输出和调用记录保留下来,便于查清哪一版代码实际交过来、又在哪一步出了问题。Claude Code 的程序化运行与结构化输出提供了这条链路需要的入口。

这份接口让分工很省心。UI 无须再解释“近一周”,核心也不用决定金色边框画多粗;两边在快照和回调处对接。

编译器不负责告诉我,窗口掉到屏幕下面了

最有记忆点的一次错误,来自一个看起来很可信的名字:TopBar。

为了避让游戏顶栏,早期实现读了这个节点的高度。进游戏才发现,TopBar 的根节点铺满了整个视口;真正可见的顶栏,只是里面的 BgImage 子节点。拿整屏高度当避让距离,HUD 自然被推到了屏幕下面。编译器对此毫无意见。

修正后,位置计算改用可见子节点的边界,测试也从检查“节点是否可见”,加到了检查四个角是否真的落在视口内。

右键也踩过类似的坑。按帧轮询鼠标状态时,一次很短的按下和释放可能落在两次采样之间。后来改用 Godot 原生 GuiInput:小面板接收输入,全屏根节点保持鼠标忽略,再专门验证同一帧按下和释放的情况。相关行为可以对照 Godot 的 Control 输入说明。

到了 0.2.0,还有一次失败发生在测试自身。首轮实机已经通过 102 项检查,列数菜单也正常弹出,探针却没能用方向键选中菜单项。最后把测试改成向弹窗发送实际鼠标输入,核对选项和关闭状态,重跑通过;生产 UI 没有因此改动。方向键为什么没有按预期选中,不能靠这一结果倒推出一个未经确认的引擎结论。

这几次经历让我给“可用”补了几个条件:数字正确,用户看得见,输入能到达,设置也真的保存了。

留下能复盘的证据,再发出去

0.1.0 通过了 75 项纯数据断言和 60 项原生游戏检查。0.2.0 扩展到 169 项数据断言、125 项原生检查,其中包括:

  • 同一角色在不同范围显示不同胜率,零场保持“—”。
  • 实际点击范围开关、列数菜单和记录按钮,再读回保存的设置。
  • 键入 25:61 不污染原设置,合法的 06:30 可以保存。
  • 四范围单栏在 150% 缩放下仍在屏幕内,超出的列表可以滚动。
  • 读取、修改显示选项、切换档位后,测试历史文件的哈希不变。

这些是隔离目录里的模拟历史和实际游戏 UI 检查,不能换算成 125 场真实对局。此次没有连接真实多人房间,也没有逐一运行第三方角色包;原生日志仍有渲染和资源回收告警。数字能说明覆盖了哪些场景,不能替代范围说明。

发布时则多核对了一步:发行 DLL 是否就是刚才跑过测试的那一份。最终包只包含 DLL、manifest、README 和预览图,测试探针不随包分发。0.2.0 沿用原工坊条目和主图,保留原三张展示图,再追加本文里的两张实机演示图。

归档卡片

项目记录
名称连胜簿 · Spire Streak
作者规范有勇气
本文归档版本0.2.0,2026-10-05
验证基线游戏 v0.111.0 / 41cef1ea
运行实现C#、Harmony、游戏原生 Godot 控件与资源
开发工具PowerShell + Roslyn 构建;Python 辅助隔离启动测试
AI 分工Codex 核心与集成,远端 Claude Code Opus 5.5 增量设计 UI
发布入口Steam 创意工坊

如果再做一个类似的统计小工具,我会先写三件事:哪些记录算进去,哪个事件触发重算,屏幕上的数字怎样对应回原始记录。等这三件事讲清楚,右上角那个小窗口就有了可以维护的依据。