Companion Dogs/模组制作
前往功能手册前往品种手册
加入 Discord
机器翻译,未经校对。本页由葡萄牙语机器翻译而成,没有经过人工校对,术语和数字都可能有误。如有疑问,请对照英文版或葡萄牙语原文。阅读英文版

Companion Dogs:模组制作手册

如何做出你自己的附加模组:一个新品种,或者一整个新物种,作为独立的创意工坊模组挂接到 Companion Dogs 上。

本手册写给做模组的人。如果你只是想玩,看另外两本手册: 功能讲狗能做什么, 品种讲品种。

它假设你能读 Lua,也已经有一个带骨骼和动画的动物模型。美术流程不在本手册的范围里。第 2 节列出模型文件必须满足的条件。

本手册点到名字的一切都是公开契约,就是给模组外部调用的。这里没有点到名字的东西属于内部实现,可能在任何版本里不加通知地改变。

1. 附加模组是什么

附加模组就是一个普通的创意工坊模组,它声明 require=CompanionDogs,并在加载时调用一个函数。它添加一个品种(共用同一副身体的话也可以是几个),除此之外什么都不加。

基础模组拥有:

  • 动画状态机、动画集和骨骼
  • 跟随、寻路、战斗、狩猎、放牧、哨兵、需求和日常照料
  • 全部界面:狗的窗口、环形菜单、右键菜单、狗舍、地图标记
  • 驯服、羁绊、繁殖、幼犬和混血
  • 沙盒选项,以及多人游戏里的同步

你的附加模组拥有:

  • 让你的动物区别于小卡犬的那些数值
  • 它的身体模型、贴图、肖像,以及 moodle 图标(如果有的话)
  • 它在世界上哪里能被找到
  • 它的名字和描述,在你随包附带的每一种语言里

两种表现相同的颜色,对基础模组来说仍然是两个品种(两个名字,狗舍里两条记录);你从一张共享的表里把它们写出来,就像 CD: Cats 用它的五种毛色那样。

2. 动手之前需要准备什么

你的动物需要自己的蒙皮 .glb,放在 media/models_X/Skinned/ 下,带完整的 21 个 Rac_* 动画片段。片段不会在模型文件之间继承:只有十五个片段的模型,第一次被要求播放另外六个中的某一个时就会僵住。模型还得控制在 60 根骨头以内,顶层节点为单位矩阵,否则引擎每帧抛一次异常,动物渲染成一团黑影。另有两个可选片段,Rac_WalkLimpFront 和 Rac_WalkLimpBack(瘸腿的行走循环);只有在品种定义里设了 limpAnim = true 时它们才有用。

如果你还没有模型,可以把 CD.applyDogModel 指向基础模组的某个身体,先把其余部分都写出来并测试。动物长得像小卡犬,但本手册里的每个系统都能跑。

你还需要一张身体贴图,和一张给狗舍窗口用的肖像。

写任何代码之前,先决定你的动物是一个品种还是一个物种。品种是数值不同的狗。物种是整块缺少某些职能的动物。这个选择会改变你要写哪些字段;第 6 节讲它。

3. 附加模组的文件

下面是 CD: Pug,已发布的附加模组里最小的一个,只留了重要的部分:

CompanionDogsPug/
  42/
    mod.info
    icon.png
    poster.png
    media/
      models_X/Skinned/Pug_Body.glb
      scripts/models_pug.txt
      textures/Body/Pug.png
      textures/CDPortrait_pug.png
      lua/shared/CompanionDogsPug_Breed.lua
      lua/shared/Definitions/animal/PugDefinitions.lua
      lua/shared/Definitions/animal/CompanionDogsPug_Parts.lua
      lua/client/CompanionDogsPug_Moodle.lua
      lua/shared/Translate/EN/IG_UI.json
      lua/shared/Translate/PTBR/IG_UI.json
  common/

还有 mod.info:

id=CompanionDogsPug
name=CD: Pug
description=Pugs for Companion Dogs. REQUIRES the Companion Dogs mod (0.6.8 or newer).
poster=poster.png
icon=icon.png
require=CompanionDogs
modversion=0.1.0
pzversion=42.19.0
versionMin=42.18.0

require=CompanionDogs 是要紧的那一行。它保证在每个阶段里,基础模组的全部 Lua 都比你的先跑:先 shared,再 client,再 server。没有它,你的文件可能先加载,每一个 CD. 调用都是对 nil 取索引。

versionMin 表达不了“需要 Companion Dogs 0.6.8”。那个字段只管游戏版本,而 mod.info 里没有写依赖版本的字段。对基础模组的下限由下一节的守卫强制,并在你的描述里告诉玩家。用文字写在那里,否则玩家能看到的唯一症状,就是动物永远不出现。

4. 版本守卫

把这段放在你附加模组每个 Lua 文件的开头,数字换成你需要的那个:

local CD = CompanionDogs
if not (CD and CD.registerBreed and (CD.API_VERSION or 0) >= 3) then return end

基础模组不在或者太旧时,文件在第二行就返回,附加模组什么都不做:没有品种,没有刷新,没有 moodle,日志里也没有一行。

守卫是按文件写的,每个文件守卫自己用到的东西。PugDefinitions.lua 需要模型辅助函数,于是它守卫这些:

if not (CompanionDogs and CompanionDogs.applyDogModel and CompanionDogs.DOG_SOUNDS) then return end

不要去创建那个全局变量。在附加模组里写 CompanionDogs = CompanionDogs or {},会把缺失的基础模组变成一张只搭了一半的表,后面每一道守卫都会在本不该通过的时候通过。

5. 注册品种

一次调用,写在一个 shared 文件里:

CD.registerBreed({
    key = "pug",
    engineBreed = "pug",
    typePrefix = "pug",
    nameKey = "IGUI_PD_Breed_pug",
    litter = { 1, 2 },
    xpMult = { scent = 1.5, combat = 1, obedience = 2.0, hunt = 1.2, herding = 1.0 },
    combatPower = 0.15,
    lethalityCurve = { min = 0.40, max = 1.3 },
    canKill = false,
    canKnockdown = false,
    combatStressMult = 0.9,
    panicThreshold = 0.70,
    bagMult = 0.6,
    sentinelMult = 1.35,
    barkNoiseMult = 1.5,
    alertModeLocked = true,
    loyaltyDecayMult = 0,
    geneRange = {
        strength       = { 0.00, 0.10 },
        aggressiveness = { 0.05, 0.20 },
        resistance     = { 0.00, 0.15 },
        stress         = { 0.55, 0.85 },
    },
    spawns = {
        { id = "pughouse", class = "house", suffix = "|pg", breed = "pug",
          chance = function() return CD.strayChancePerHouse() / CD.PUG_HOUSE_RARITY end },
    },
})

这次调用会插入品种,注册它的三个动物类型,重建繁殖顺序好让你的动物参与混血,并注册刷新步骤。

定义写坏了,调用会返回 nil 而不是抛异常,加载继续往下走。每一次拒绝都会在日志里打一行点名的记录。

必填字段

字段 是什么
key 你的品种标识符,在所有模组里唯一。发布之后不要改。
typePrefix 三个动物类型的前缀:<prefix>pup、<prefix>female、<prefix>male。它挑身体网格。
nameKey 显示名的翻译键。
xpMult 每项技能的学习速度:scent、combat、obedience、hunt、herding。1.0 是正常。
combatPower 它下嘴有多重。小卡犬是 0.20,战斗型品种在 1.0 以上。
lethalityCurve { min, max },伤害从战斗 0 级到战斗 10 级怎么涨。
geneRange 四个基因出生时的范围:strength、aggressiveness、resistance、stress。每个都是 0 到 1 之间的 { low, high }。

可选字段

字段 默认 作用
engineBreed 就是 key 交给引擎的品种名。它挑贴图。每个品种唯一。
descKey IGUI_PD_BreedDesc_<key> 描述的键。几个品种可以共用一个键。
litter 基础值 每胎的幼犬数 { min, max }。
canKill true false 表示它会把丧尸磨到虚弱,但永远不补上致命一击。
canKnockdown false true 让它能把丧尸撞倒。
combatStressMult 1 一场战斗要它付出多少压力。
panicThreshold 基础值 压力到这个水平它就不打了。panicImmune = true 表示它永远不停。
bagMult 1 驮袋容量的倍率。
puppySize 0.6 幼犬的绝对视觉缩放。小型品种必须设它,否则幼犬一生下来就是成年大小。
sentinelMult 1 乘在最终的哨兵半径上。
barkNoiseMult 1 缩放警报吠叫的半径和音量。沙盒里的噪音选项关掉时,无论这个值是多少,没有哪个品种会用吠叫引来丧尸。
loyaltyDecayMult 1 乘在每日忠诚衰减上。0 表示羁绊永不消退。
alertModeLocked false 动物一直处于全面警戒:主人在任何地方都不能切到静默或静音。
huntFetchLevel 6 从这个狩猎等级起,它会把猎物叼回给主人。
huntDeliverTimeoutMin 5 放弃之前它还会试着送多少游戏分钟。
distract 关 { <kind> = { chance = 0..1, ... } }。这个品种会自己去追东西,不需要模式,也不需要等级。
idleAnimMs 基础值 待机动画窗口的长度,单位毫秒。你的片段比狗的短时要设它,否则循环会重开,把动作从中间切断。
restAnim true false 让动物在停留和守卫时保持站着,而不是趴下。
restPoses 趴下 一份休息姿势列表,动物每次安顿下来都从里面抽一个,可选带进出过渡和待机变化。需要你自己的片段和动画节点。
limpAnim false true 让受伤的动物走路时一瘸一拐。只有你的模型带那两个可选片段 Rac_WalkLimpFront 和 Rac_WalkLimpBack(1.0 秒的行走循环,根运动和 Rac_Walk 相同)时才设它。没有这两个片段,动物会原地僵着走。
bandSkin 关 { base, front, back, cutFront, cutBack },五个身体贴图名。受伤的腿在伤口流血时换成带伤口的版本,包扎后换成缠绷带的版本。四个变体用 _dogrig/forge/_paw_band.py 生成。不设的话动物照样会受伤,只是看不到痕迹。
voices 狗的声音 { bark, growl, idle, wildbark, pet, whine, eat, drink }。
diet 狗的那几份列表 这只动物不能吃什么,对它来说什么算肉,以及它会自己从饲料槽里吃什么。
maleChance 0.5 该品种生为公的比例,0 到 1。
sterileMale false true 让该品种的公不参与繁殖。
species "dog" 繁殖按物种封闭。见第 6 节。
skills、canBreed、huntMaxPrey 全部开着 结构性的开关。见第 6 节。

你加进定义里的数值和标志,用 CD.breedNumber(animal, field) 和 CD.breedFlag(animal, field) 读回来,不要用 CD.getBreedDef(animal).field。它们会应付字段缺失,基础模组变了也照样能用。

本能:distract

distract = {
    prey = { chance = 0.25 },
},

声明了这个字段的品种,会时不时注意到什么东西,丢下手上的事去追。停留时和跟随时都会这样。持续期间,命令会被拒绝,用的是服从判定本来就在用的 distracted 消息。只有“过来”这条命令能穿过去,而且它会取消这次分心。

每一种分心都接受同样的可选参数。你没写的部分用模组的默认值:

字段 作用
chance 0..1,只有这一种的触发器找到了东西才掷。想写“25%”而写成 25 会被拒绝,日志里有一行点名的记录
radius 触发器搜索的格数,前提是这一种确实要搜索
durationMin 这个窗口自己过期之前持续多少游戏分钟
cooldownMin 同一只动物再次分心之前要隔多少游戏分钟

prey 这一种

基础模组自带的唯一一种。动物会去追野生动物,classes 挑出哪些算数:

distract = {
    prey = { chance = 0.25, radius = 6, classes = { tiny = true, small = true } },
},

不管你声明什么,有两条限制照样生效:

  • 它绝不会越过你的 huntMaxPrey:上限是 small 的品种,就算声明了 large 也不会去追鹿。
  • 只有 tiny 会跳过等级门槛。刚驯服、狩猎 0 级的动物就已经能抓啮齿类了。声明了 small 之后,这个品种在任何等级都会去追兔子,但要杀死它,需要的狩猎等级和拉布拉多一样。

沙盒里的狩猎开关只影响击杀:动物照样追,但猎物会跑掉。

这样杀到的猎物走的是正常的回收流程,所以你品种的 huntFetchLevel 决定它会不会把尸体叼回给主人。设成 0 的话,动物从第一天起就送回来,猫给你叼来一只死老鼠就是这么回事。送回这件事比分心窗口活得久:猎物一死,动物就重新听命令,回家这一趟按它自己的 huntDeliverTimeoutMin 计时。

写你自己的一种

distract 是一个分发器。注册你自己的:

CD.registerDistraction("butterfly", {
    gate  = function(animal, cfg) return animal:isOutside() end,   -- optional, cheap pre-check
    find  = function(animal, cfg) return findButterflyNear(animal, cfg.radius) end,
    drive = function(animal, owner, d, cfg) return chaseIt(animal, d) end,
    stop  = function(animal, d) end,                               -- optional, cleanup
})

然后像其他种类一样在品种上声明它:distract = { butterfly = { chance = 0.10 } }。

gate 在模组的全局扫描预算之前跑。find 在预算之后跑,做开销大的那次搜索。drive 在还驱动着动物时返回 true,做完了返回 false,窗口随之关闭。你的处理函数跑在自己的 pcall 里。它抛异常的话,模组会在日志里写一行点名的记录,本次游戏里注销这一种,同伴的其余部分照常工作。

留给基础模组将来使用的名字:drink、eat、play。今天你可以用其中一个名字注册一种,但基础模组发布自己的那一种时会把名字收回去。

休息姿势

休息的动物会趴下。restPoses 把这唯一的姿势换成一份列表,动物每次安顿下来都从里面抽一个:

restPoses = {
    "cdRest",                                          -- the base lie-down
    { var = "cdSit", enter = "cdSitIn", exit = "cdSitOut",
      variation = { "cdSitGroom", "cdSitGroom2" } },
},

每一项要么是一个动画变量的名字,要么是一张表。var 是整个休息期间一直开着的布尔值。enter 和 exit 是一次性的脉冲,在动物安顿下来和起身时各播一次。variation 是一份一次性脉冲的列表,动物保持姿势期间时不时播一个,用的时钟和待机变化一样。只有 var 是必填的。

抽取是均匀的,每次进入休息都抽一次,所以两项就是各 50%,动物也可能连续两次抽到同一个。它不是轮流。

每个脉冲的时长由 idleAnimMs 对那个变量说了算,所以要传表的形式,给每个变量一个长度,包括你已经有的待机变量。填片段的真实长度。

idleAnimMs = { cdIdle2 = 3800, cdIdle3 = 3800, cdSitIn = 1375, cdSitOut = 1417,
               cdSitGroom = 19833, cdSitGroom2 = 25167 },

片段和节点是你的,不是基础模组的。每个变量都需要你附加模组里 media/AnimSets/raccoon/idle/ 下的一个节点文件,而它的 m_AnimName 指向的片段必须存在于你的模型里。节点指向模型没有的片段会静默失败,动物就只是站在基础模组的待机里。给姿势节点的 m_ConditionPriority 设成 10,和基础模组的一样,要在它上面播的东西给更大的数:变化给 11,两个过渡给 12。节点默认循环播放片段,除非你另外说明,所以每个只该播一次的节点,也就是两个过渡和每一个变化,都需要 <m_Looped>false</m_Looped>。没有它,片段一结束就重头开始,动物会明显地弹回去再做一遍,因为变量要到下一个服务器 tick 才会关掉。全部只放在 idle/ 里。放在 pathfind/ 里的姿势节点播的是没有根运动的片段,会把动物定在原地。

声音

没有 voices 时每个品种都用狗的声音,于是猫会吠。先注册你自己的声音:

CD.registerVoices({
    CDCatMeow = "bark", CDCatGrowl = "bark", CDCatHiss = "bark", CDCatPurr = "bark",
    CDCatPet = "fx", CDCatMeowAmbient = "ambient",
})

然后让品种指向它们:

voices = { bark = "CDCatMeow", growl = "CDCatGrowl", idle = "CDCatPurr",
           wildbark = "CDCatMeowAmbient", pet = "CDCatPet", whine = "CDCatHiss" },

没有 CD.registerVoices 时声音照样播放,但它们无视玩家的分类音量滑条和游戏的音效音量,日志里也没有任何东西指向这件事。voices 里漏掉的键会退回狗的声音。

whine 是动物受伤或生病时发出的声音。eat 和 drink 是吃喝的音效。狗的是循环音,动物吃完时由基础模组停止;如果你的也是循环音,把它们加进 CD.SOUND_LOOPED,听者走出范围时基础模组也会把它们停掉。比 API 11 更老的基础模组会无视这三个键,照放狗的声音。

第三个参数是每个声音的可听范围,单位是格。它应该和你自己声音脚本里写的 distanceMax 对上:

CD.registerVoices(map, nil, { CDCatMeow = 22, CDCatGrowl = 10 })

在服务器上,基础模组只把声音发给这个范围里的玩家。不写范围的话,声音会按分类拿到一个通用范围:只传十格的嘶声会发给三十格内的所有人。过了 distanceMax,FMOD 的反比衰减不再继续衰减,也不会归于无声,所以一个没配好的 .ogg 在任何距离上都会以 distanceMin / distanceMax 的音量继续响。出于同样的原因,音量大的声音要把 distanceMin 设小。

食性

没有 diet 时每个品种都像狗一样吃:同一批会毒到它的食物,同一批算作肉的食物,同一批它会从饲料槽里啃的食物。这个字段只为你的品种覆盖这三份列表。

diet = {
    bad          = { Fruits = true },              -- fruit now makes it sick
    badParts     = { onion = false },              -- onion no longer does
    protein      = { Cheese = true },              -- cheese now counts as meat
    proteinParts = { tuna = true },                -- and so does anything with "tuna" in its type
    trough       = { Vegetables = false },         -- it will not graze vegetables
    troughItems  = { ["Base.CatFoodOpen"] = true },
},

之所以有两种写法,是因为游戏自己的分类只能覆盖到这一步。Cheese 是真正的 FoodType,所以一个键就覆盖了游戏里、以及任何复用这个分类的食物模组里的每一种奶酪。金枪鱼不是:开了的罐头是 FoodType = Fish,和其他所有鱼混在一起,而没开的罐头根本不声明食物类型。badParts 和 proteinParts 就是你够到这类东西的办法。

每个字段都是一张表,写 <key> = true(加上)或者 <key> = false(去掉基础模组原有的)。既不是 true 也不是 false 的值会被丢掉,日志里有一行点名的记录。

字段 它的键是什么
bad 会让动物生病、扣它忠诚、并在喂食菜单里显示成红色条目的食物的 FoodType
badParts 物品类型里的一小段,小写,在类型名的任意位置匹配
protein 能还上动物那笔肉食欠账的 FoodType,也就是虚弱 moodle 盯着的那笔
proteinParts 物品类型里的一小段,匹配方式和 badParts 一样
nonProtein 你确定不是肉的 FoodType。它只是让基础模组不再为没见过的食物类型写那行日志
trough 动物会自己从饲料槽里吃的 FoodType 和 AnimalFeedType
troughItems 完整的物品类型,用于饲料槽里那些自己没有饲料类型的物品

replace = true 从一张空列表开始,而不是从狗的那份开始。你的动物和狗几乎没有共同点时才用它;猫通常写成几条增删更省事。

这三份列表跟着动物走,所以站在同一个饲料槽前的狗和猫,从里面吃到的东西不一样。模组自己的食盆是例外:它存的是食物点数,不是把它填满的那件物品,所以往里填东西对谁都按基础列表来。有毒的东西本来也放不进食盆。

出生时的性别

每只动物出现时都会抽一次公母,默认对半。maleChance 是生为公的比例,0 到 1,sterileMale 则把该品种的公排除在繁殖之外。

maleChance = 0.00033,
sterileMale = true,

这一对就是三花猫。橘色和黑色的花纹挂在 X 染色体上,所以三花几乎都是母的,偶尔出现的公是 XXY,大约三千只里一只,也生不出崽。sterileMale 品种的公永远不会被选为配偶,右键菜单里的繁殖选项也会消失。母的照常繁殖。

这种品种的公在游戏显示性别的地方都会带上“不育”:状态面板、右键菜单、流浪动物扫描和狗舍,玩家就知道它为什么不繁殖。

两个字段都从品种定义里读,动物身上不写任何东西,老存档也不用迁移。同一次抽签还决定幼崽出生时的性别,所以在世界里是母的品种,在窝里也还是母的。

6. 整块关掉一个系统

五项技能就是五个系统:

技能 它是哪个系统
scent 哨兵:发现丧尸并警告你
combat 打架、守卫模式和自卫
obedience 技巧树
hunt 狩猎,以及它给主人的采集加成
herding 照看围栏里的牲畜

所以一个物种声明的是它没有什么:

skills = { combat = false, herding = false },
canBreed = false,
huntMaxPrey = "tiny",

不写,或者写 true,都表示动物有这项技能。

关掉一项技能,它也就不再攒经验,界面上的进度条和它的那些命令一并消失。

canBreed = false 单独放在表外面,因为繁殖既没有进度条也没有经验。这样的动物不会怀上,不会被选作配偶,和自己的同类也不繁殖。

物种

加进来的是另一种动物、而不是另一种狗的附加模组,要声明它:

CD.registerSpecies({ key = "cat",
                     nounKey  = "IGUI_PD_SpeciesNoun_cat",
                     youngKey = "IGUI_PD_Young_cat" })

CD.registerBreed({ ..., species = "cat" })

到目前为止发布的每个品种都没写这个字段,不写就是 "dog"。

繁殖按物种封闭:只有双方是同一个物种才会怀上,命令里、每小时的被动判定里和调试工具里都一样。界面也不再把你的动物叫狗:nounKey 是那个光秃秃的名词,用来填“这只%1已经属于另一位幸存者。”这类句子,youngKey 是你这个物种的幼崽在窗口、右键菜单和狗舍里带的标签,狗那里写的是幼犬。

这两个名词只管围着 %1 造的句子。其他每一句把狗字写死在里面的话(“流浪狗”、“你的狗饿了。”、“狗故意吠叫”),从 API 7 起都接受一个按物种区分的键:在你的翻译文件里带上 IGUI_PD_ScanStray_cat,屏幕上的动物是猫时,基础模组就显示它,而不是 IGUI_PD_ScanStray。你没带的键会退回狗的文本。接受这个后缀的键:ScanStray、ScanTitle、PauseGrowthTip、BadFood、 AlertFullDesc、AlertQuietDesc、AlertLockedTip、HuntModeDesc、每一个 Trick*Desc、 TrickDistractDone、TrickNeedsBag、TrickGotoPick、TrickSniffNoItem、TrickFetchNoItem、 AlertGroup、DogLost、每一个 Refused_*、KennelStatusNoData,以及照料类 moodle 的 Moodle_*_desc(Sick、Weak、Hunger、Thirst、Rested、Grief)。屏幕上没有动物的句子(名册标签页, “先和一只动物成为朋友吧。”)在基础模组里已经和物种无关了。猫在十四种语言里都带齐了它们;把它的 IG_UI.json 复制过来当模板。

动物的 group 只有调试刷新菜单和片段查看器会读,走的是 IGUI_Animal_Group_<group>。在行为辅助函数之后设 t.group = "cat" 并带上那个键,否则你的物种在那里顶着狗的名字。

声明了 species 却不注册它,并不致命:你的动物照样拒绝和别的物种繁殖,因为那道门槛比的是原始字符串。只有那两个名词会退回狗的,日志里会点名说明。

在低于 API 6 的基础模组上,这个字段被忽略,日志里也没有一行,你的动物就当狗一样繁殖。如果它绝不能和狗杂交,就守卫在 API 6 上,或者在基础模组更旧的时候保持 canBreed = false,猫就是这么做的。

huntMaxPrey 是在狩猎依然存在的前提下给猎物体型封顶;它不能代替 hunt = false。它接受 "tiny"(老鼠、耗子、松鼠)、"small"(兔子、浣熊)或者 "large"(鹿,默认值,不封顶)。超过上限的猎物,你的动物既不追也不指示。不在这个阶梯上的值会退回 "large",并在日志里打一行点名的记录。

被封掉的选项会从环形菜单、右键菜单和窗口里消失,服务器不给消息就拒绝它。这些字段一个都不加翻译键,所以把限制写进你的品种描述里。

7. 动物定义

引擎把网格绑在动物类型上,而不是品种上,所以你的品种需要三个自己的类型。最短的路是把某个现成附加模组的文件复制过来改名字。

三块东西,放在 Definitions/animal/ 下的一个 shared 文件里:

AnimalDefinitions.stages["pug"] = {}
AnimalDefinitions.stages["pug"].stages = {}
AnimalDefinitions.stages["pug"].stages["pugpup"] = {}
AnimalDefinitions.stages["pug"].stages["pugpup"].ageToGrow = 3 * 30
AnimalDefinitions.stages["pug"].stages["pugpup"].nextStage = "pugfemale"
AnimalDefinitions.stages["pug"].stages["pugpup"].nextStageMale = "pugmale"
AnimalDefinitions.breeds["pug"].breeds["pug"].texture = "Pug"
AnimalDefinitions.breeds["pug"].breeds["pug"].textureMale = "Pug"
AnimalDefinitions.breeds["pug"].breeds["pug"].rottenTexture = "Raccoon_Rotting"
AnimalDefinitions.breeds["pug"].breeds["pug"].invIconMale = "CDDogPaw_64"
AnimalDefinitions.breeds["pug"].breeds["pug"].invIconMaleDead = "CDDogPawDead_64"
local pugfemale = {}
CD.applyDogModel(pugfemale, "Pug_Body")
CD.applyDogBehaviour(pugfemale)
pugfemale.female = true
pugfemale.babyType = "pugpup"
pugfemale.minSize = 1.5
pugfemale.maxSize = 2.0
pugfemale.minWeight = 5
pugfemale.maxWeight = 8
pugfemale.wildFleeTimeUntilDeadTimer = CD.STRAY_NO_BLEEDOUT
pugfemale.breeds = AnimalDefinitions.breeds["pug"].breeds
pugfemale.stages = AnimalDefinitions.stages["pug"].stages
pugfemale.genes = AnimalDefinitions.genome["dog"].genes
AnimalDefinitions.animals["pugfemale"] = pugfemale

基础模组给你的辅助函数:

辅助函数 它替你填什么
CD.applyCompanionModel(t, "Body_Model") 身体模型、骨骼、屠宰后的贴图,以及动画集
CD.applyCompanionBehaviour(t) 一个同伴需要的每一个引擎行为标志
CD.applyCompanionAvatar(t) 动物窗口用的肖像相机
CD.COMPANION_SOUNDS 共享的脚步声和拟音表
CD.defineGenome("species") 新物种的基因组,带标准的基因列表(API 7)
CD.STRAY_NO_BLEEDOUT 让受惊的流浪动物不会流血流到死的那个值
CD.defineCompanionParts(typePrefix, engineBreed, meat) 三个阶段全部的屠宰产物。meat 可选:{ item, minNb, maxNb, pupMinNb, pupMaxNb },不写的话动物产出基础模组的狗肉。引擎会把每一块的数量和饱食度都乘上尸体的体型,所以体型数值大的物种(猫用 2.5 到 3.5 当视觉缩放)需要自己的物品,基础饱食度更小,数量更少

与物种无关的那批名字是 API 7 才有的。更老的那些,CD.applyDogModel、CD.applyDogBehaviour、 CD.applyDogAvatar、CD.DOG_SOUNDS、CD.defineDogParts 和 CD.DogMoodles,是同样的函数和表,只是顶着第一代的名字。想在低于 0.7.3 的基础模组上加载的附加模组,守卫写旧名字,新名字存在时就挑新的:local applyModel = CD.applyCompanionModel or CD.applyDogModel。

动画集必须保持 "raccoon"。分叉出去的动画集不会加载自己的状态机,动物就僵在原地。

基因组要复用。狗的品种指向基础模组的那份列表:AnimalDefinitions.genome["dog"].genes。新物种调用一次 CD.defineGenome("cat"),把它返回的表交给每个阶段。引擎只读每个阶段的 genes 列表,基因组的键只是个约定,所以你的物种能有自己的条目,而不必有自己的基因列表。

不要声明 mate。有它在,引擎会在任何动物区域里跑自己原生的交配,绕开模组的繁殖模块和它的沙盒选项,生出没有羁绊的幼崽。基础模组启动时会清掉这个字段,但别指望这一点。

屠宰产物是必须的。单独一个文件,三行:

if CompanionDogs and CompanionDogs.defineDogParts then
    CompanionDogs.defineDogParts("pug", "pug")
end

没有它们,屠宰你的动物会让原版代码崩掉,因为它按类型和品种去索引产物定义,索到的是 nil。

CD.applyDogAvatar 里的肖像缩放是按狗校准的,而头像的缩放是放大倍数:动物越小,数字越大。把缩放除以你的动物相对狗的体型比例,再把各个偏移乘上这个比例,否则你的动物会小小地缩在自己肖像的底部。

8. 模型脚本

media/scripts/ 下的一个文件告诉游戏你的模型存在:

module Base
{
    model Pug_Body
    {
        mesh = Skinned/Pug_Body,
        shader = animalEffect,
        static = false,
        animationsMesh = PugAnim,
        attachment head
        {
            offset = -0.0061 0.1671 0.0507,
            rotate = -179.0 -5.0 95.0,
            bone = Dummy01,
        }
    }

    animationsMesh PugAnim
    {
        meshFile = Skinned/Pug_Body,
        keepMeshAnimations = true,
    }
}

keepMeshAnimations = true 让你模型文件里的片段还能用。没有它,模型加载得起来,却永远不动。

head 挂点是帽子那类东西待的地方。想让驮袋合你的动物,就照同样的方式在脊椎骨上加 saddlebags_l、saddlebags_r 和 saddlebags_c。这几个数字靠在游戏里看着动物一点一点挪出来。从模型上量出来的值会把袋子放在网格的几何中心,那不是它在身体上看着对的位置。

9. 这只动物在哪里出现

流浪动物是按建筑、按区块抽取的,在那个区块第一次加载的时候。你把自己的抽取写成一串步骤,可以放在品种定义里叫 spawns,也可以用 CD.registerStraySpawns(list)。

spawns = {
    { id = "pughouse", class = "house", suffix = "|pg", breed = "pug", indoor = 85,
      chance = function() return CD.strayChancePerHouse() / CD.PUG_HOUSE_RARITY end },
    { id = "pugpetvet", class = "petvet", suffix = "|pv", breed = "pug",
      chance = function() return CD.PUG_PETVET_CHANCE * CD.dogSpawnMultiplier() end },
},
键 是什么
id 这一步的名字,给你自己的日志用
class 在哪一类建筑上抽
chance 一个返回百分比的函数,所以它能在抽取的那一刻读到沙盒倍率。
suffix 持久存储的键。在所有模组之间唯一,而且永远不改。
breed 要生成的品种键
gate 可选函数,返回真假,在抽取之前检查。哈士奇用的是 CD.isWinter。
indoor 生在建筑内部而不是街上的百分比几率。默认 35。

把你的几率乘上 CD.dogSpawnMultiplier(),或者把 CD.strayChancePerHouse() 除以你自己的稀有度。两种做法都能让玩家的沙盒设置继续有效。

indoor 只是个偏好:那个区块里内部没有空格子的建筑会退回到外面的格子。

建筑类别

基础版注册了四个:

类别 匹配什么
house 不是商店的住宅建筑
police 警察局之类
petvet 宠物店和兽医诊所
farm 农场建筑,而且允许出现在城镇之外

如果没有一个合适,就注册你自己的:

CD.registerBuildingClass("mineshaft", function(def) return def:isX() end,
                         { skipUrbanGate = true })

匹配函数收到建筑定义,并且运行在一次保护调用里,所以它里面出错不会中断区块加载。 skipUrbanGate 让这个类别可以在非城区的区块里抽取,农场或者林中小屋需要这一点。不要设 exclusive。基础类别彼此互斥,附加模组的类别是叠在它们之上匹配的。

已经被占用的后缀

后缀是“这栋建筑已经抽过了”这条记录的键的一部分。两个模组用同一个后缀会互相破坏对方的抽取结果。

每个后缀都以一条竖线开头。已经在用的有:

  • 基础版:空后缀,还有 g、h、hv、bc、gh、hh、bh
  • 罗威纳犬:rw、r
  • 杜宾犬:db、dm、dh
  • 拉布拉多:lb、lk
  • 巴哥犬:pg、pv
  • 马林诺斯犬:ml、mm、mh
  • 猫:以 ct、cv 或 cs 开头的所有后缀(每种毛色一套)

10. 品种 moodle

moodle 是一个客户端文件。你往 CD.DogMoodles 里追加一张表,剩下的绘制、跟踪和清理都由基础版负责:

CD.DogMoodles[#CD.DogMoodles + 1] = {
    id = "shadow",
    breed = "pug",
    nameKey = "IGUI_PD_Moodle_Shadow",
    descKey = "IGUI_PD_Moodle_Shadow_desc",
    icon = "CD_Moodle_Shadow",
    fg = "CD_MoodShadowFG",
    tintR = 0.92, tintG = 0.52, tintB = 0.68,
    condition = function(player, dog)
        if not dog or dog:isDead() then return 0 end
        if CD.getBreed(dog) ~= "pug" then return 0 end
        if CD.isDisloyal(dog) then return 0 end
        return 1
    end,
    apply = function(player, dog, elapsedMin)
        CD.relieveMood(player, CD.moodleReliefPerMin() * elapsedMin)
    end,
}

condition 返回等级,0 表示“不显示”。apply 收到距离上次调用过去的游戏分钟数,所以要把你的效果乘上 elapsedMin。

图标要提供六种尺寸,从 32 到 128 像素。界面按玩家的缩放挑尺寸,缺一个尺寸就会显示成一个空方块。图标出现在模组自己那一条里,不和原版 moodle 混在一起。

11. 钩子

两个函数列表,基础版在服务器上调用它们。从一个 shared 文件往里追加。每个处理函数都跑在自己的保护调用里,所以一个附加模组出错不会中断基础版。

CD.onHuntDelivered 从 API 2 开始有,在动物把猎物放到主人脚边之后立刻被调用:

CD.onHuntDelivered[#CD.onHuntDelivered + 1] = function(animal, owner)
    -- server side, so this is the right place to write mod data
end

CD.onUpkeepStress 从 API 3 开始有,每个照料周期都会被调用,在基础版加完未满足需求带来的压力之后、写入之前。返回一个数字,它会被加进总数:

CD.onUpkeepStress[#CD.onUpkeepStress + 1] = function(animal, needsStress)
    return CD.pugHeatRatio(animal) * CD.PUG_HEAT_STRESS_MAX
end

照料周期每一轮都会重算并覆盖这个值,所以从外面写进去的东西下一个 tick 就没了。钩子适合用在来自环境的条件上,比如炎热、寒冷或者下雨。

在多人模式里,客户端永远不写 mod data。客户端这边的一次写入会在下一次同步时覆盖服务器的副本,而症状要过几分钟才在一个不相干的地方冒出来。请在服务器文件里做这件事,或者用这些钩子。

SERVER TUNING面板里的数字都是普通的 CD.* 常量,附加模组可以在加载时从 shared 或服务器文件里给其中一个赋值。从 API 9 起,面板把服务器加载完成那一刻生效的值当作那个旋钮的默认值:它一直保持到管理员手动改写,重置按钮回到它,面板也把它显示为默认值。在更旧的基础模组上,面板每次启动都会把出厂值写回去。要在加载时赋值,不要在之后的事件里赋,否则下一次改动任何旋钮都会把出厂值放回来。

12. 翻译键

每个语言文件夹放一个 IG_UI.json,位置在 media/lua/shared/Translate/<LANG>/。基础版带了十四种:EN、PTBR、CH、CN、DE、ES、FR、IT、JP、KO、RU、TH、UA、VI。

一个品种需要的键:

键 显示在哪里
IGUI_PD_Breed_<key> 品种名,模组界面里到处都用
IGUI_PD_BreedDesc_<key> 动物卡片上的描述
IGUI_Breed_<engineBreed> 游戏自己那些界面里的品种名
IGUI_AnimalType_<type> 你那三个类型各一个
你的物种键 你传给 CD.registerSpecies 的 nounKey 和 youngKey
你的 moodle 键 名字和描述
{
    "IGUI_Breed_pug": "Pug",
    "IGUI_AnimalType_pugmale": "Pug (male)",
    "IGUI_AnimalType_pugfemale": "Pug (female)",
    "IGUI_AnimalType_pugpup": "Pug (puppy)",
    "IGUI_PD_Breed_pug": "Pug",
    "IGUI_PD_BreedDesc_pug": "The house dog: the first companion whose worth is not fighting."
}

翻译字符串里的裸百分号会让显示它的那个界面崩溃,因为格式化器把它当成占位符读。带字节顺序标记的语言文件无法编译,而且报错指的是另一个文件,不是坏掉的那个。

13. 存档记住的东西

你的三个标识符会被写进存档文件,并且永远被读回来:

  • key,存在你每一只动物身上的品种标识
  • typePrefix,它组成引擎保存的那些动物类型
  • suffix,“已经抽过”这份存储的键,每个生成步骤一个

这三个在发布之后都不能改名。改 key 会让那个品种所有活着的动物变成孤儿。改 suffix 会让所有现存存档里的每一栋建筑重新抽一次,玩了几个月的世界会塞满你的动物。

新后缀同时也是新品种进入已经在跑的世界的办法。CD: Cats 给它四种新毛色配了新后缀,它们于是开始在现有存档里出现,而原来那只黑猫保留旧后缀,没有再抽一次。

从还有该品种活体动物的存档里移除附加模组,会把它们弄丢。引擎会丢掉一个它不再认识类型的动物。基础版不会崩溃,羁绊和犬舍都会干净地降级。请在你的 Workshop 页面上说明这一点。

如果你的附加模组缺席,基础版对任何被问到的东西都退回默认品种,所以没装你的模组打开的存档仍然可读。

14. 发布之前

  • 守卫在每一个 Lua 文件里,而且写的是你实际用到的那个 API。
  • 基础版的版本下限用文字写在你的描述里。versionMin 说不了这件事。
  • 你在第一次加载时读了日志。否则 CD.registerBreed 会默默拒绝,而每一次拒绝都会打印一行带名字的记录。
  • CD.defineDogParts 调用了。屠宰一只你的动物,看看它不会崩。
  • 你的 key、typePrefix 和生成后缀都是唯一的,而且你不会需要改它们。
  • 小型品种设置了 puppySize,而且你亲眼见过一只幼犬来证明这一点。
  • 如果是别的物种,CD.registerVoices 调用了而且 voices 填了,里面也有 whine,不然它会像狗一样又吠又呜咽。
  • moodle 图标六种尺寸都在。
  • 你提供的每一个语言文件夹都有每一个键。缺一个键,屏幕上就会显示原始键名。
  • 你在多人模式里测过,不只是单人。位置、归属和 mod data 在那边的表现都不一样。
  • 你的 Workshop 页面警告了不要从还有活体动物的存档里移除附加模组。

15. 常见错误

症状 是怎么回事
"我的动物从来不出现。" 几乎总是守卫:附加模组需要一个已安装基础模组没有的 API,于是它提前返回,什么都没注册。
"它出现了,但是僵住不动。" 动画集不是 "raccoon",或者模型缺了动画片段。
"它渲染成一团黑,日志刷个不停。" 模型的骨骼超过 60 根。
"宰它的时候崩溃。" 没有调用 CD.defineDogParts。
"幼崽出生就是成年体型。" 小体型品种没有设置 puppySize。这个值是绝对值,而且每一轮扫描都会重新套用,所以该类型的尺寸区间盖不住它。
"我的两个品种老是互相变来变去。" 它们共用了一个 engineBreed。前缀选的是网格,同一家族可以共用。而 engineBreed 选的是贴图,用来区分这个家族里的各个品种,所以每个品种都必须唯一。
"声音放得出来,却不理会音量滑块。" 那个声音没有调用 CD.registerVoices。
"单人能用,上服务器就出毛病。" 有东西在客户端写 ModData。把它挪到服务端。
"我明明关掉了战斗,它还是和僵尸打起来。" 它运行所在的基础模组比 API 5 更旧。那个基础模组不认识这个字段,会直接忽略。守卫应当在这个基础模组上拦住加载,而不是缺着字段照样加载。

这份手册里有哪里写错了,或者少了什么?到 Discord说一声。