使用 P 语言编写 Crusader Kings 3 模组
目前所有的 LLM 模型在不经过微调的情况下处理使用 P 语言这类专用编程语言进行编程的任务效果较差,经常给出不存在的接口。因此,本文档收集一些上手 P 语言中遇到的问题。具有 C with class 编程基础在阅读此文档时将比较流畅。
预备知识¶
用于编写 CK3 模组的 P 语言文件必须以 UTF-8 with BOM 编码。
在启动器中的游戏设置中可以选择以调试模式启动游戏。
获取文档¶
- CK3 维基,其中包含一些有关 modding 的内容。这是目前在互联网上比较详细的文档。其中有关控制台的部分 也有一些帮助。
-
以调试模式启动游戏,在控制台输入
script_docs,将在Documents/Paradox Interactive/Crusader Kings III/logs中生成以下.log文件:文件名 描述 Effects.log所有 effect 接口的列表,包括它们的使用方式以及它们的潜在参数 Triggers.log所有 trigger 接口的列表。,包括它们支持的作用域和目标 Modifiers.log所有内置 modifier 的列表及其合法作用域 event_scopes.log所有作用域类型 event_targets.log所有的可能事件目标 -
在游戏安装目录的
game文件夹中的每一个.info文件。例如game/common/character_interactions/_character_interactions.info包含了所有角色互动的定义和简易文档。 - 在游戏安装目录中的
game文件夹下,可以找到游戏中几乎所有的功能的实现,从中可以寻找用例。可以事先使用git进行版本控制,当不慎修改游戏本体文件时,可以方便地进行回滚。一个方便的寻找用例的方法是利用本地化文件进行搜索。 - 如果想知道某个效果的全部可选参数(例如
add_special_building_slot),可以通过编写 python 脚本,使用正则表达式在game文件夹下找到被使用的参数(实际上就是在自己手动做一个简易的词法分析)
语法概览¶
P 语言脚本是由大量 key = { block } 嵌套起来的配置/脚本语言。
语句¶
P 语言最常见的结构是形如下面的例子的键值对与嵌套块:
在 P 语言里,= 运算符有多种含义,代表绑定一个名字到一个块或一个值。
具体来讲,根据其前一个 token 的不同,可以有赋值、调用、条件判断等多种含义。
这里所谓前一个 token 不同,其实指的是前一个 token 是作为 effect、trigger 或是别的什么其他的东西。
作用域¶
P 语言中的 scope 的概念类似于 OOP 中的对象。几乎所有的 effect/trigger 都是在某个作用域上调用的。
例如使得一个角色获得 500 金币:
用 C++ 的语法写出来就是调用函数 character.add_stress(20)。
例如将某个角色是否拥有 500 金币作为条件判断语句:
用 C++ 的语法写出来就是 if (character.gold >= 500) {...}。
用 scope:xxx 引用事先保存好的一个作用域。有时可以省略显式的作用域,此时默认是 this 即当前上下文。
作用域通过下面的方法生成:
save_as_scope = foo:把当前作用域保存为名为foo的作用域,可以在整个脚本后面引用scope:foo。save_as_temporary_scope = foo:只在当前代码块内有效的临时作用域。
P 语言还拥有一些保留作用域:
ROOT:当前脚本入口的根作用域;this:当前块的主作用域;prev:前一个作用域。
Effect¶
P 语言中的 effect 类似常规编程语言中无返回值的成员函数,也即过程调用。它们通常用来改变游戏状态、加减属性、触发事件等。
例如:
用 C++ 的语法写出来就是对 scope:character 这个角色类型的对象依次调用两个无返回值成员函数 add_prestige(100) 和 add_gold(100)。
Trigger¶
P 语言中的 trigger 类似于常规编程语言中的有返回值的成员函数。它们的返回值是布尔值,用于条件判断。拥有无参数和有参数两种不同的语法。
无参数的情况下的一个例子:
用 C++ 的语法写出来就是 character.is_female() == yes,整个表达式的值就是这个语句块的值,即角色是女性角色的真值。
有参数的情况下的一个例子:
用 C++ 的语法写出来就是调用 character.has_trait(beauty_good),然后将整个表达式的值设置为 yes/no,即角色拥有美貌特质组的特质的真值。通常放在条件语句,例如 limit = {...} 或者 is_valid = {...} 中使用,充当流程控制的真值。
trigger 也支持自定义以支持更复杂的逻辑。
变量¶
P 语言支持在作用域上绑定变量。变量跟随作用域而存在,可以理解为绑在作用域上的 key-value 数据结构。
变量通过下面的方法进行增删改查:
set_variable:创建/设置变量;remove_variable:删除变量;change_variable:对变量做加减乘除等修改;has_variable:判定是否存在某个变量;var:foo:在表达式中引用变量foo的值。
例如:
# event 1
scope:character = {
set_variable = {
name = foo
value = 100
}
}
# event 2
scope:character = {
if = {
limit = {
has_variable = foo
}
add_gold = var:foo
change_variable = {
name = foo
add = 10
}
}
}
这个例子分为两个代码片段。在第一个代码片段中,为角色 character 添加了变量 foo 并初始化为 100;在第二个代码片段中检定角色 character 是否拥有变量 foo,如果拥有则为其添加等额的金币,然后再将该变量增加 10。如果第一个代码片段只触发一次,第二个代码片段多次触发,这就等价于为某个角色添加递增数量的金币,且金币数量组成一个等差数列。
变量也有全局版本,只需要把 _variable 替换为 _global_variable。
流程控制¶
条件语句:
if = {
limit = {
# condition1
}
# condition1 == true, do something
}
else_if = {
limit = {
# condition2
}
# condition1 != true && condition2 == true, do something
}
else = {
# condition1 != true && condition2 != true, do somthing
}
就类似于 C 语言中的 if-else 语句块。limit = {...} 块中必须全部是 trigger 或由 trigger 组合而来的逻辑表达式。
P 语言支持逻辑连接:AND、OR、NOR 等,可以像搭组合逻辑电路一样把条件拼起来。
循环与遍历¶
P 语言提供一组以 every_、random_ 开头的内置 effect,用于在某个集合上遍历集合上的各个作用域。
例如通过 every_ 进行遍历:
其功能是为每个未成年的子女增加 100 金币,等价于 C++ 语句块:
for (auto &child : character.child_list) {
if (child.is_adult() == false) {
child.add_gold(100);
}
}
例如通过 random_ 进行遍历并随机选择其中一个执行效果:
其功能是随机选择一个满足条件拥有智慧特质组的特质的子女,给予其 1000 金币,等价于 C++ 语句块:
template <class Container, class Pred, class Action>
void random_modify_if(Container& c, Pred pred, Action action) {
using It = typename Container::iterator;
std::vector<It> candidates;
for (It it = c.begin(); it != c.end(); ++it) {
if (pred(*it)) candidates.push_back(it);
}
if (candidates.empty()) return;
std::random_device rd;
std::mt19937 gen(rd());
std::uniform_int_distribution<> dist(0, candidates.size() - 1);
action(*candidates[dist(gen)]);
}
并在其他函数中调用:
random_modify_if(
character.child_list,
[](auto &child) { return child.has_trait(intellect_good) },
[](auto &child) { child.add_gold(1000) }
);
(用 C++ 实现等价的功能还有点复杂...)
P 语言提供一组以 any_ 开头的内置 trigger,用于在某个集合上遍历集合上的各个作用域,并对这些作用域执行 trigger,返回所有 trigger 执行结果的或运算结果。也就是只要有一个作用域满足条件就返回 yes。
例如:
其功能是如果一个角色有成年的子女,为这个角色增加 1000 威望。等价的 C++:
bool flag = false;
for (auto &child : character.child_list) {
if (child.is_adult() == yes) {
flag = true;
}
}
if (flag == true) {
character.add_prestige(1000);
}
参数占位符¶
在脚本化的 effect/trigger/modifier 中,可以用 $arg$ 风格的占位符来实现形参,由调用方传入实际参数。
例如:
调用时:
本地化¶
在本地化文本中,使用 $key$ 可以引用其它已经存在的本地化键值。
在本地化文本中,使用 [] 调用一个数据类型以动态地输出特定的文本字符串。这些数据类型通过对作用域使用内置的 GetXxx 方法得到。此外,对于不同游戏内容,分别提供了不同的作用域可供本地化文件访问。例如,在互动中,使用 [recipient.GetShortUIName] 可以输出互动的目标的简短 UI 名称;使用 [GetTrait('beauty_good_3').GetName( GetNullCharacter )] 输出三级美貌特质的默认名称。
调试¶
以调试模式进入游戏,可以显示更多信息,例如在角色交互中,各个作用域指向的对象。
通过在代码中添加 error_log = {" ... "} 来进行插桩。
查看游戏的错误日志,可以在 Documents/Paradox Interactive/Crusader Kings III/logs/error.log 中找到。在错误日志中检索正在编写的脚本名。
在控制台中使用 reload [filename][target] 重新向内存中加载文件,输入 reload 后按下 Tab 键可以查看所有可重新加载的目标。但一般集中在 GUI 上。
用户界面¶
TODO