跳转至

使用 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 语言最常见的结构是形如下面的例子的键值对与嵌套块:

some_key = {
    another_key = value
}

在 P 语言里,= 运算符有多种含义,代表绑定一个名字到一个块或一个值。 具体来讲,根据其前一个 token 的不同,可以有赋值、调用、条件判断等多种含义。

这里所谓前一个 token 不同,其实指的是前一个 token 是作为 effect、trigger 或是别的什么其他的东西。

作用域

P 语言中的 scope 的概念类似于 OOP 中的对象。几乎所有的 effect/trigger 都是在某个作用域上调用的。

例如使得一个角色获得 500 金币:

scope:character = {
    add_gold = 500
}

用 C++ 的语法写出来就是调用函数 character.add_stress(20)

例如将某个角色是否拥有 500 金币作为条件判断语句:

if = {
    limit = { scope:character.gold >= 500 }
    # do something...
}

用 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 类似常规编程语言中无返回值的成员函数,也即过程调用。它们通常用来改变游戏状态、加减属性、触发事件等。

例如:

scope:character = {
    add_prestige = 100
    add_gold = 100
}

用 C++ 的语法写出来就是对 scope:character 这个角色类型的对象依次调用两个无返回值成员函数 add_prestige(100)add_gold(100)

Trigger

P 语言中的 trigger 类似于常规编程语言中的有返回值的成员函数。它们的返回值是布尔值,用于条件判断。拥有无参数和有参数两种不同的语法。

无参数的情况下的一个例子:

scope:character = {
    is_female = yes
}

用 C++ 的语法写出来就是 character.is_female() == yes,整个表达式的值就是这个语句块的值,即角色是女性角色的真值。

有参数的情况下的一个例子:

scope:character = {
    has_trait = beauty_good
}

用 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 语言支持逻辑连接:ANDORNOR 等,可以像搭组合逻辑电路一样把条件拼起来。

循环与遍历

P 语言提供一组以 every_random_ 开头的内置 effect,用于在某个集合上遍历集合上的各个作用域。

例如通过 every_ 进行遍历:

scope:character = {
    every_child = {
        limit = { is_adult = no }
        add_gold = 100
    }
}

其功能是为每个未成年的子女增加 100 金币,等价于 C++ 语句块:

for (auto &child : character.child_list) {
    if (child.is_adult() == false) {
        child.add_gold(100);
    }
}

例如通过 random_ 进行遍历并随机选择其中一个执行效果:

scope:character = {
    random_child = {
        limit = { has_trait = intellect_good }
        add_gold = 1000
    }
}

其功能是随机选择一个满足条件拥有智慧特质组的特质的子女,给予其 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

例如:

scope:character = {
    if = {
        limit = {
            any_child = {
                is_adult = yes
            }
        }
        add_prestige = 1000
    }
}

其功能是如果一个角色有成年的子女,为这个角色增加 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$ 风格的占位符来实现形参,由调用方传入实际参数。

例如:

my_scripted_effect = {
    add_prestige = $VALUE$
}

调用时:

ROOT = {
    my_scripted_effect = { VALUE = 100 }
}

本地化

在本地化文本中,使用 $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