上一篇把 mod 的“最小可运行骨架”搭好之后,我收到最多的反馈就是:架子我会搭了,接下来呢?这篇文章就把“做一个新单位”当主线,从塞进游戏、让它能被 AI 正常使用,到额外挂一段 Lua 回血逻辑,完整走一遍。
我做文明 6 mod 已经有几年了,最大的体会是:不要一上来就憋一个“全新文明+领秀+特殊玩法”的大项目。第一次做内容是很容易在数据表关系里迷路的。做一个带明确机制的新单位反而是最好的练手项目,因为它同时涉及到数据库、文本、脚本三条线,这三条线都跑通了,后面再做文明、领袖、改良设施,基本都是同一套思路的延展。
这篇里的目标定义得很具体:单位叫“遗迹斥候”,定位介于侦察兵和弓手之间,三格移动力,两格远程攻击,成本中等偏下,同时自带一个特殊机制——每回合自动回复 10 点生命。这个“特殊机制”用 Lua 写,很小,但足够把文明 6 mod 里最难理解的那部分“脚本执行时机”讲明白。
1. 先搞懂文明6加载mod的整套逻辑
1.1 Modinfo 不只是入口文件,它决定加载顺序
新人在刚接触文明 6 mod 时,最喜欢做的事就是照着别人 mod 里的文件结构复制粘贴。复制多了你会发现,有些 mod 明明是同样改数据库,却在 .modinfo 里写了不一样的 Action,然后结果也一样。这就让人很困惑:到底哪个写法才是对的?
先把 .modinfo 理解成一个“打包清单”加“执行清单”。我习惯把 mod 文件按功能分目录:
text复制LegendScoutMod/
├── LegendScoutMod.modinfo
├── Data/
│ └── Gameplay.sql
├── Text/
│ └── LocalizedText.xml
└── Core/
└── LegendScoutScript.lua
对应的 .modinfo 长成下面这样:
xml复制<?xml version="1.0" encoding="utf-8"?>
<Mod id="LegendScoutMod_2025" version="1.0">
<Properties>
<Name>遗迹斥候单位包</Name>
<Teaser>一个自带回血机制的侦察单位</Teaser>
<Description>演示文明6 Mod 制作第二篇的完整数据与脚本流程</Description>
<Authors>YourName</Authors>
<AffectsSavedGames>0</AffectsSavedGames>
</Properties>
<Files>
<File>Data/Gameplay.sql</File>
<File>Text/LocalizedText.xml</File>
<File>Core/LegendScoutScript.lua</File>
</Files>
<Actions>
<UpdateDatabase>Data/Gameplay.sql</UpdateDatabase>
<UpdateText>Text/LocalizedText.xml</UpdateText>
<AddGameplayScripts>Core/LegendScoutScript.lua</AddGameplayScripts>
</Actions>
</Mod>
注意 id 一栏。文明 6 判断两个 mod 是不是同一个,主要看这个 id。新手最常踩的坑就是:别人 mod 改了 id,你在本地也建了一个同 id 的 mod,结果游戏里只加载其中一个,怎么删都删不干净。
我自己的规范是:id 里至少带一个前缀年份,例如
LegendScoutMod_2025,然后 version 用1.0.0这种三段式。这样不只是在创意工坊更新时方便,你自己本地排查“这个版本到底是不是我预期那个版本”也省事。
1.2 UpdateDatabase、UpdateText、AddGameplayScripts 该怎么选
.modinfo 的 <Actions> 是整个 mod 的发动机。文明 6 在加载 mod 时,不是“把所有文件读一遍”,而是按 Action 类型把文件丢给不同模块去处理。
常见的三类 Action 我在上面已经写出来了:
UpdateDatabase:处理 SQL 和 XML 格式的数据文件,最后都会进游戏数据库。单位、文明、科技、政策这些内容,都靠它塞进游戏。UpdateText:处理文本文件。虽然文本文件也可能长得很像 XML,但文明 6 专门把它拆出去,是为了照顾多语言。中文显示、英文显示,全靠这个 Action。AddGameplayScripts:加载 Lua 脚本。这个脚本只有进入“单局游戏逻辑”时才会运行。开局选完文明、进入地图加载阶段后,它才生效。
如果只是做一个“看起来能造的单位”,只用 UpdateDatabase 就够了。但如果要给单位做“特殊能力”这类行为逻辑,就必须加 AddGameplayScripts。
还有一种容易混淆的情况:有些 UI mod 需要在主菜单生效,它们用的是 AddFrontEndScripts,跟 AddGameplayScripts 不是一个环境。我见过不少新人把菜单脚本直接写成 AddGameplayScripts,结果游戏一启动就报错。这个坑最好从一开始就避掉——先想清楚你的 Lua 是要“在单局游戏里”运行,还是在“主界面”运行。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 从数据表开始,把单位真正“塞”进游戏
2.1 设计先行:同一个单位要写在多张表里
文明 6 的单位不是你在一张 Units 表里加一行就完事。一个单位通常要横跨好几张表:
Units:基础属性,比如移动力、战斗力、远程战斗力、造价。TypeTags:给单位打标签,很多系统逻辑靠标签来识别这个单位属于什么类型。UnitAiInfos:让 AI 知道该怎么用这个单位。
这个“散落在多张表”的机制,是很多新人看不懂别人 sql 的核心原因。你看到一个单位相关的 SQL 有三四十行,不要慌,大部分内容都是在往不同表里填同一个名字。
在设计阶段,我给“遗迹斥候”定的属性是:
- 移动力 3,视野 3。
- 近战战斗力 10,远程战斗力 25,射程 2。
- 成本 70。
- 解锁科技:射箭。
- 分类:侦察晋升线。
把“射程 2 的侦察单位”做出来很有意思,因为它比普通侦察兵更能站在高处白嫖敌人,但又没有弓手那样高的近战防御。放在真实对局里,是一个需要操作的单位。
2.2 SQL 文件怎么写:INSERT OR IGNORE 比 REPLACE 稳
下面是我实际用的 SQL,省去了很多系统内部字段,只保留核心项:
sql复制INSERT OR IGNORE INTO Units (
UnitType,
Name,
BaseMoves,
BaseSightRange,
Cost,
Combat,
RangedCombat,
Range,
Domain,
FormationClass,
PromotionClass,
AdvisorType,
PrereqTech
)
VALUES (
'UNIT_LEGEND_SCOUT',
'LOC_UNIT_LEGEND_SCOUT_NAME',
3,
3,
70,
10,
25,
2,
'DOMAIN_LAND',
'FORMATION_CLASS_LAND_COMBAT',
'PROMOTION_CLASS_RECON',
'ADVISOR_CONQUEST',
'TECH_ARCHERY'
);
INSERT OR IGNORE INTO TypeTags (Type, Tag)
VALUES
('UNIT_LEGEND_SCOUT', 'CLASS_RECON'),
('UNIT_LEGEND_SCOUT', 'CLASS_RANGED');
INSERT OR IGNORE INTO UnitAiInfos (UnitType, AiType)
VALUES
('UNIT_LEGEND_SCOUT', 'UNITAI_RANGED'),
('UNIT_LEGEND_SCOUT', 'UNITAI_EXPLORE'),
('UNIT_LEGEND_SCOUT', 'UNITAI_COMBAT');
关于是 INSERT OR REPLACE 还是 INSERT OR IGNORE,网上很多教程喜欢写 REPLACE。我的建议是:新增内容时尽量用 IGNORE,因为 REPLACE 本质上是“先删后插”。如果你在维护一个旧 mod,原表已经有过同 id 行,REPLACE 可能会把和这个 id 关联的其它记录也删掉。IGNORE 虽然会把重复 id 的内容跳过去,但至少不会破坏现有数据。
TypeTags 这里,我只打了两个标签:CLASS_RECON 和 CLASS_RANGED。这表示该单位能吃到侦察晋升,也能被系统识别成远程单位。很多新手会自己发明标签,比如写个 CLASS_MYSUPER_SOLDIER,结果进游戏后单位不享受任何加成,因为对应的晋升逻辑压根不认这个标签。
UnitAiInfos 是很容易被忽略的一步。不加这段,你自己的玩家单位照样能造能打,但 AI 根本不会造这个单位,就算造了也不知道该怎么用。做单人模组还好,想发给别人玩就会显得很“出戏”。
2.3 文本文件比数据库更容易出幺蛾子
单位数据写好后,必须写文本。如果你只写 Units 表,没写名字,游戏里单位名会直接显示成代码,比如 LOC_UNIT_LEGEND_SCOUT_NAME。
LocalizedText 文件按下面的结构写:
xml复制<?xml version="1.0" encoding="utf-8"?>
<LocalizedText>
<Row Language="zh_Hans_CN" Tag="LOC_UNIT_LEGEND_SCOUT_NAME" Text="遗迹斥候"/>
<Row Language="zh_Hans_CN" Tag="LOC_UNIT_LEGEND_SCOUT_DESCRIPTION" Text="拥有每回合回复10点生命能力的侦察单位。"/>
<Row Language="en_US" Tag="LOC_UNIT_LEGEND_SCOUT_NAME" Text="Legend Scout"/>
<Row Language="en_US" Tag="LOC_UNIT_LEGEND_SCOUT_DESCRIPTION" Text="A recon unit that heals 10 HP per turn."/>
</LocalizedText>
这里我踩过好几次坑,总结一下:
- 只写中文行不行?如果游戏是中文,行;但切到英文或繁体中文就又成了代码。想要模组更多人用,至少补一行
en_US。 zh_Hans_CN这个语言代码不能写错,写错就是显示 tag 不显示文字。- 文本文件保存时建议统一用 UTF-8 编码。Windows 记事本另存为时不要带 BOM,但 XML 文件带不带 BOM 很多老编辑器会有兼容差异。我现在的习惯是全部用 VS Code 保存为 UTF-8,能避免大部分乱码。
做完这一步,你在游戏里已经能造出这个单位了。但现在的“回血机制”还没实现,得靠 Lua 脚本。
3. Lua:给单位加上真正的行为逻辑
3.1 脚本写在哪,决定了它什么时候能跑
继续往下之前,必须先理解一个概念:文明 6 的 Lua 不是“从头到尾跑一遍就结束”的普通脚本,它是事件驱动型。你要做的是告诉游戏“当某个事件发生时,调用我注册的函数”。
这个单位自带的“每回合回 10 点血”,如果不用 Lua,用纯数据库也是能做的,但新手往往找不到入口。最直接的理解方式是:在文明 6 原版框架里,一个单位身上通常挂着“晋升”“能力”“建筑修改”等被动效果,这些效果大多通过 Modifier 系统实现。Modifier 系统很强大,但刚接触时理解成本高。想快速让一个自创单位“活”过来,Lua 是最直观的办法。
在 .modinfo 里我已经写了 AddGameplayScripts,所以这段脚本会在玩家开启一局游戏时被加载。注意,它不是在主菜单加载。所以如果主菜单没报错、一进游戏才报错,基本就是这类脚本的问题。
3.2 事件监听写法:从回合开始到单位回血
“每回合开始,让所有遗迹斥候回血”这个逻辑其实很简单:
lua复制local unitDef = GameInfo.Units["UNIT_LEGEND_SCOUT"]
if unitDef == nil then
print("LegendScout: unit not found, check SQL")
return
end
local UNIT_TYPE_INDEX = unitDef.Index
function LegendScout_OnPlayerTurnStarted(playerID)
local pPlayer = Players[playerID]
if pPlayer == nil then
return
end
local pUnits = pPlayer:GetUnits()
if pUnits == nil then
return
end
for _, pUnit in pUnits:Members() do
if pUnit ~= nil and pUnit:GetUnitType() == UNIT_TYPE_INDEX then
local currentDamage = pUnit:GetDamage()
if currentDamage ~= nil and currentDamage > 0 then
local newDamage = currentDamage - 10
if newDamage < 0 then
newDamage = 0
end
pUnit:SetDamage(newDamage)
end
end
end
end
GameEvents.PlayerTurnStarted.Add(LegendScout_OnPlayerTurnStarted)
print("LegendScout gameplay script loaded")
这段代码有几个关键点:
GameInfo.Units["UNIT_LEGEND_SCOUT"]返回的是一个查询结果。通过.Index拿到数字编号,后面比较单位类型时用数字比用字符串更快也更稳。Players[playerID]是文明 6 Lua API 里常见的玩家对象入口。多人游戏里每个玩家都会触发,所以我在开头加了pPlayer == nil的保护。SetDamage传的是“当前受伤值”。如果单位满血,当前受伤值是 0,所以我在前面加了currentDamage > 0的判断,避免无意义调用。
第一次跑这个脚本,建议留一句 print。文明 6 的 Lua 日志里能看到你要的东西有没有被正确加载。没留 print 的脚本如果没被加载,你只能对着一个毫无变化的游戏发呆,排查效率很低。
3.3 最容易翻车的地方:不是事件名,而是数据没加载
很多人第一次把 Lua 写好后,回血不生效,第一反应是 Lua 写错了。但根据我的经验,80% 的情况是前面的数据库文件根本没加载成功,导致 GameInfo.Units["UNIT_LEGEND_SCOUT"] 直接返回 nil,脚本在最开头的保护语句里就退出了。
所以排查顺序应该是:先看 SQL 是否真的入库,再看 Lua 是否有报错,最后才去怀疑事件监听写没写对。
还有一个隐蔽的坑:脚本文件里如果写死了单位的字符串 ID,但 SQL 里多打了一个空格或者用了全角字符,两边就永远对不上。比如 'UNIT_LEGEND_SCOUT ' 尾部多一个空格,肉眼很难发现,但 Lua 里查不到。遇到这种情况,最笨也最有效的方法是把单位 ID 复制粘贴到两边,不要手动敲。
4. Mod不生效时,按这套日志流程排查
4.1 先把日志开关打开
文明 6 的 mod 日志和错误日志,不是所有信息默认全量输出。Windows 下日志目录一般在:
text复制C:\Users\你的用户名\Documents\My Games\Sid Meier's Civilization VI\Logs\
但这个文件夹里的日志很多时候是不完整的。要拿到完整信息,需要去同目录下的 AppOptions.txt 里找日志相关开关。不同版本的配置文件里变量名不完全一样,我记得几个关键项是 EnableGameCoreLogging、EnableDatabaseLogging、EnableTuner,把它们改成 1,保存后重新启动游戏。
如果你找不到这些开关,还有一个更简单的办法:直接看默认生成的日志文件。文件名带日期的通常是最近一次运行的记录,比如:
Database.log:数据库导入报错。Lua.log:Lua 脚本报错和 print 输出。Gameplay.log:单局内的部分运行日志,偶尔排查事件触发会用到。
我强烈建议你每次改完 mod,跑一次加载,然后把这三个日志文件全部清空。这样再进游戏,报错只会出现在最新日志里,不会把历史错误混在一起。
4.2 典型报错长什么样
我把经常遇到的报错整理成了一个速查表:
| 现象 | 日志位置 | 常见原因 |
|---|---|---|
| 单位在游戏里根本不显示 | Database.log | SQL 文件没有被 Action 加载,或表名/字段名写错 |
| 单位显示,但名字是代码 | Lua.log 里无问题,游戏内显示 tag | 语言代码写错,或者 XML 文件没走 UpdateText 加载 |
| Lua 里能 print,但回血不触发 | Lua.log 无明显报错 | 事件注册不对,或者单位和脚本不在同一个执行环境 |
| 数据库报 no such table | Database.log | 表名拼写错误,或者你在不该用 SQL 的地方用了 SQL |
| 数据导入报 UNIQUE 冲突 | Database.log | 重复插入同一个 id,建议改用 INSERT OR IGNORE |
| AI 从不造这个单位 | 无报错 | 缺少 UnitAiInfos 数据 |
| 进游戏主界面一直转圈 | Lua.log 或 Database.log | 脚本在启动阶段卡死,通常是 UI 脚本被错加成了 Gameplay 脚本 |
这些错误里,新人最容易被“没有任何报错”搞懵。
比如 AI 不造新单位,游戏日志不会主动告诉你“这个单位缺少 AI 配置”。你必须自己反推:玩家能造,说明数据没问题;AI 不造
