Appearance
杀戮尖塔2 卡面批量导出 Mod CardExporter 开发记录
《杀戮尖塔2》(Slay the Spire 2)用 Godot + C#(.NET 9)开发,游戏本体没有提供导出高清卡面的功能。CardExporter 是一个只做一件事的 Mod:启动游戏后自动把全部卡组(含升级版)渲染成透明背景的 PNG,用于个人收藏和素材整理。
这个 Mod 本身逻辑简单,真正花时间的地方是搞清楚游戏卡牌节点 NCard 的渲染生命周期——按什么顺序调用它的方法,才能让离屏截图里出现完整的标题、费用和描述文字。调用顺序一旦出错,导出的就会是一张写着 "Broken Card" 的占位卡。
Mod 是怎么被加载的
STS2 的 Mod 系统很轻量:mods/<ModName>/ 下放一个清单 ModName.json 和一个 ModName.dll,游戏启动时按清单加载程序集,并调用被 [ModInitializer] 标记的静态方法。
json
{
"id": "CardExporter",
"name": "Card Exporter",
"author": "local",
"description": "Exports rendered card faces by reusing the game's NCard scene.",
"version": "1.0.0",
"has_pck": false,
"has_dll": true,
"dependencies": [],
"affects_gameplay": false,
"min_game_version": "0.0.0"
}入口本身不做任何导出逻辑,只负责“晚一点再开始”:
csharp
[ModInitializer(nameof(Initialize))]
public static class CardExporterMod
{
public static void Initialize()
{
SceneTreeTimer timer = tree.CreateTimer(1.0);
timer.Timeout += AddPendingRunner;
}
private static void AddPendingRunner()
{
CardExportRunner.RunFrom(tree);
}
}ModInitializer 触发的时机非常早,游戏的本地化系统 LocManager 和卡牌数据库 ModelDb 都还没初始化完。直接在这里跑导出逻辑几乎必然拿到空数据。用 SceneTreeTimer 延迟 1 秒是最简单的规避方式,真正的“数据是否就绪”判断还是交给 Runner 内部的轮询(见下文)。
工程文件表
| 文件 | 作用 |
|---|---|
| CardExporterMod.cs | Mod 入口,注册启动定时器 |
| CardExportRunner.cs | 核心导出逻辑:等待数据、遍历卡池、离屏渲染、落盘 |
| CardExporterConfig.cs | 配置解析与归一化(纯 C#,不依赖 Godot) |
| CardExportPath.cs | 文件名/路径的清理与拼装(纯 C#,不依赖 Godot) |
| CardExporterTrace.cs | 日志,同时写文件和游戏自身的日志系统 |
| CardExporterSelfTest.cs | 离线自测入口,不依赖游戏引擎即可运行 |
后两个“纯 C#”文件是刻意设计成这样的:CardExporterConfig 和 CardExportPath 不 using Godot,所以它们既能被编进 Mod 主 DLL,也能被一个独立的、不依赖游戏程序集的自测程序直接复用(见「离线自测」一节)。
NCard 渲染生命周期踩坑
这是整个 Mod 里唯一真正“踩坑”的部分。card.tscn 场景自带占位内容——标题 "Broken Card",费用 "0",描述 "If you can read this, there is a bug."——如果调用顺序不对,导出的图片就是这份占位符,而且不会有任何异常或报错,看起来一切正常。
关键在于 Reload() 和 UpdateVisuals() 的分工完全不同:
NCard.Model的 setter 会触发Reload(),但Reload()只处理贴图和边框(普通卡框 / 升级卡框 / 储君星形费用框);- 标题、费用数字、描述文本,全部由
UpdateVisuals(PileType, CardPreviewMode)负责填充。
所以正确顺序是:先实例化、入树等 _Ready() 跑完,再设置 Model,最后必须显式调用一次 UpdateVisuals(PileType.None, CardPreviewMode.Normal),缺这一步文字内容永远是占位符。这也是 README 里专门用一整段强调的细节——因为它不报错,光看程序有没有崩溃是发现不了的,只能靠肉眼看导出的图。
另外一个容易忽略的点:NCard.Create() 是游戏内部使用的工厂方法,背后走的是引擎自带的 NodePool。如果导出逻辑也调用它,就会和游戏正常运行时的节点池抢用同一批实例,造成渲染错乱。所以导出走的是 scene.Instantiate<NCard>() 直接实例化一个全新节点,用完 QueueFree() 主动释放,完全不进 NodePool。
导出主流程
CardExportRunner.RunOnce 是唯一的入口协程,做三件事:加载配置、等游戏数据就绪、遍历导出。
csharp
private static async Task RunOnce(SceneTree tree)
{
_config = CardExporterConfig.Load(configPath, gameDir);
if (!_config.Enabled || !_config.ExportOnStartup) return;
await WaitForGameData(tree);
if (LocManager.Instance != null &&
!string.Equals(LocManager.Instance.Language, _config.Language, StringComparison.OrdinalIgnoreCase))
{
LocManager.Instance.SetLanguage(_config.Language);
await WaitFrames(tree, 2);
}
Directory.CreateDirectory(_config.OutputDirectory);
int exported = await ExportAllCards(tree);
}WaitForGameData 是个简单的轮询:每帧检查一次 LocManager.Instance != null && ModelDb.AllCards.Any(),最多等 600 帧(约 10 秒),超时就抛异常。之所以要 try/catch 包一层,是因为 Mod 加载的最初几帧里 ModelDb 可能还没完成静态初始化,直接访问会抛异常,此时并不会得到一个空集合。
遍历逻辑按卡组名、再按卡牌 Id 排序,逐张导出普通版,如果卡牌可升级且配置里开着 include_upgraded,再用 CardCmd.Upgrade 生成一份升级态一并导出:
csharp
List<CardModel> cards = ModelDb.AllCards
.Where(card => CardExportPath.PoolAllowed(card.Pool.Title, _config.PoolFilter))
.OrderBy(card => card.Pool.Title)
.ThenBy(card => card.Id.Entry)
.ToList();
foreach (CardModel canonical in cards)
{
CardModel normal = canonical.ToMutable();
exported += await ExportVariant(tree, normal, upgraded: false);
if (_config.IncludeUpgraded && normal.IsUpgradable)
{
CardModel upgraded = canonical.ToMutable();
CardCmd.Upgrade(upgraded, CardPreviewStyle.None);
exported += await ExportVariant(tree, upgraded, upgraded: true);
}
}ToMutable() 是必须的一步:ModelDb.AllCards 里拿到的是只读的规范模型,直接改字段(比如升级)会影响全局数据库;ToMutable() 复制出一份独立实例再改,互不干扰。
留白与储君星形费用
单张卡牌的基准素材尺寸是 300×422,但费用图标和高光边框会画出这个范围之外——普通卡的能量图标向左上溢出约 16px,而储君(Regent)卡组用的是星形费用图标,比能量图标多向左偏移约 20px。截图区域如果不留白,图标会被生生切掉一块。
csharp
bool hasStarCost = card.CurrentStarCost >= 0 || card.HasStarCostX;
int padLeft = hasStarCost ? _config.PaddingLeftStarCost : _config.PaddingLeft;
Vector2I paddedSize = new(BaseCardSize.X + padLeft + padRight, BaseCardSize.Y + padTop + padBottom);Scale 配置项是整体缩放倍率:卡牌先摆在一个未缩放的“逻辑坐标系”里,最后靠一个 Control 容器节点的 Scale 属性把整体一次性放大,SubViewport 的尺寸同步乘上相同倍率。这样 padding 的计算只需要维护一份,每个缩放档位共用同一套坐标。
csharp
SubViewport viewport = new() { TransparentBg = true, Size = paddedSize * _config.Scale };
Control wrapper = new() { Size = paddedSize * _config.Scale, Scale = Vector2.One * _config.Scale };
cardNode.Position = new Vector2(padLeft + BaseCardSize.X / 2f, padTop + BaseCardSize.Y / 2f);最后一步是标准的 Godot 离屏截图三连:拿 SubViewport 的纹理、转成 Image、SavePng。
csharp
await WaitFrames(tree, 1);
cardNode.Model = card;
cardNode.UpdateVisuals(PileType.None, CardPreviewMode.Normal);
await WaitFrames(tree, _config.FramesToWait);
Image image = viewport.GetTexture().GetImage();
image.SavePng(outputPath);
viewport.QueueFree();配置系统
配置文件 config/card_exporter_settings.cfg 是 JSON 格式,解析靠的是一组针对单个字段的正则表达式,逐个匹配对应的键值对,跳过 System.Text.Json 那种整体解析方案:
csharp
private static bool GetBool(string json, string key, bool fallback)
{
Match match = Regex.Match(json, Quote(key) + @"\s*:\s*(true|false)", RegexOptions.IgnoreCase);
return match.Success ? bool.Parse(match.Groups[1].Value) : fallback;
}这么做有两个实际的好处:一是每个字段独立解析、独立有默认值,某个字段写错格式不会拖垮整份配置的加载;二是天然容忍 README 里那种带 // 行内注释的 JSONC 写法——正则只找它认识的键值对,其余字符(包括注释)直接忽略,不需要额外做“先剥注释再解析”这一步。
代价是牺牲了字段之间的强类型校验,所以所有数值字段都在 Normalize() 里统一夹一遍范围:
| 字段 | 默认值 | 取值范围 |
|---|---|---|
| scale | 2 | 1-4 |
| frames_to_wait | 3 | 1-30 |
| padding_top / bottom / left / right | 20 / 8 / 20 / 15 | 0-100 |
| padding_left_star_cost | 40 | 0-100 |
output_directory 的处理也在 Normalize() 里:如果配置写的是相对路径,就拼到游戏可执行文件所在目录下;如果是绝对路径就原样保留。这样默认配置只需要写 "exported_card_faces",不用关心游戏具体装在哪个盘。
离线自测
CardExportRunner 依赖 Godot、ModelDb、LocManager 这些只有游戏进程里才存在的类型,没法脱离游戏单独跑。但 CardExporterConfig 和 CardExportPath 是纯 C# 逻辑——路径清理、正则解析、数值 Clamp——完全可以脱离引擎测试。
于是项目拆成两个 csproj:主 Mod 编译成 CardExporter.dll,引用游戏自带的 GodotSharp.dll 和 sts2.dll;另一个 CardExporter.SelfTest.csproj 只编译 CardExporterSelfTest.cs + 两个纯逻辑文件,产出一个能直接双击运行的控制台程序:
bash
dotnet build CardExporter.SelfTest.csproj -c Release
dotnet CardExporter.SelfTest.dll自测覆盖的都是容易在重构时悄悄改坏的边界情况:空字符串会不会被清理成 _、非法文件名字符会不会被替换、scale 超出 1–4 会不会被夹住、相对路径和绝对路径的 output_directory 分别应该怎么解析、pool_filter 大小写是否敏感。不用启动游戏、不用等 Mod 加载,几秒钟就能跑完一整轮验证。
踩坑总结
| 问题 | 现象 | 解决方式 |
|---|---|---|
| 只设置 Model 不调 UpdateVisuals | 导出图片是 "Broken Card" 占位符,程序不报错 | 严格按生命周期顺序,Model 之后必须调 UpdateVisuals(PileType.None, CardPreviewMode.Normal) |
用 NCard.Create() 而非 Instantiate<NCard>() | 卡面渲染错乱,偶发和游戏当前界面互相影响 | 改用 scene.Instantiate<NCard>() 走独立节点,避开游戏自身的 NodePool |
| Mod 初始化时立刻访问 ModelDb | 抛异常或拿到空列表 | ModInitializer 里只挂一个延迟定时器,真正的数据就绪判断放进 WaitForGameData 轮询 |
| 储君卡星形费用图标被截断 | 图标左侧一小块被裁掉 | 按 CurrentStarCost >= 0 || HasStarCostX 单独判断,用更大的 padding_left_star_cost |
| output_directory 写相对路径 | 不同人电脑上落盘位置不一致,甚至写进游戏安装目录之外 | Normalize() 里统一拼到 OS.GetExecutablePath() 所在目录 |
| 配置文件允许写注释 | 严格 JSON 解析器直接报错 | 改用逐字段正则匹配,天然跳过不认识的内容 |
最终效果
导出的 PNG 是透明背景、带完整卡框和文字的成品图,升级版会自动带 _upgraded 后缀存成单独文件:


这张储君卡「黑洞」刚好能同时验证两件事:左上角的星形费用图标没有被裁边,说明储君卡的 padding 分支生效了;标题、费用、描述文字齐全,说明 UpdateVisuals 确实被正确调用了。
最终链路
CardExporter 逻辑简单,但每一步都踩在游戏引擎的隐藏契约上:
text
Mod 清单加载 -> ModInitializer 延迟启动
-> 轮询等待 LocManager / ModelDb 就绪
-> 遍历全部卡组,按需生成升级变体
-> Instantiate<NCard> 离屏渲染(严格遵循 Ready -> Model -> UpdateVisuals 顺序)
-> SubViewport 截图 -> SavePng
-> 按卡组/卡名落盘这类基于游戏内部节点复用的导出 Mod,工作量大头几乎都花在“搞清楚引擎不报错但结果不对”的隐藏顺序依赖上——Reload() 和 UpdateVisuals() 的分工、NCard.Create() 和 Instantiate() 的差异,都是只看 API 名字猜不出来的,得靠一张张导出图片反复核对才能确认。