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说一声。