泰拉瑞亚mod开发笔记 1

在开发mod之前,请务必保证你有以下辅助性资源,否则开发的过程将异常困难:

方法论

首先肯定是要按照官方文档过一遍,Wiki介绍了很多基本的修改方式,但是没有详尽列举所有可拓展部分。应该把Wiki教程作为方法论,它告诉哦我们应该如何去修改,大致在那个范围修改,但是具体执行修改还是需要去看源码,去看接口和Hook是如何定义的。

接着就是查看官方的ExampleMod,大部分能拓展的模块它都给出了一个实际的样例。具体的mod内容继承自ModXXX对象(ModItem、ModNPC等)开发的,ExampleMod则给出了一个继承的实例来补充了文档中没有提到的细节。

借助AI工具的辅助,我们可以比较轻松的找出原版代码实现某个机制的具体代码,这对于实现我们设计的机制而言非常有用。

讲完了方法论,我们来针对具体的模块来给出经验之谈。

本地化

这将是花费很多时间的板块,即使你的mod只支持中文,因为必须将文本和代码分离以解耦,否则既不便于多处调用同一段文本,也让代码变得很难看。

官方文档

**硬广:**如果想要有方便的图形化界面同步编辑多个语言文件,可以使用LocalizationEditor

对于本地化文件的结构划分,应该遵循一个原则:能复用的文本尽量复用,不要让相同的文本在多处重复出现,使用换元来解决。比如说下面这样一个简单的饰品:

A_Gear: {
    Tooltip:
        '''
        缓慢再生生命
        强化技能 ‘6767’
        [c/746f6f:啊吧啊吧]
        [c/746f6f:这是背景故事]
        '''
    DisplayName: 洁白的头饰
}

其中可以拆分出来的单元很多,比如说“缓慢再生生命”,“强化技能”,6767,以及[]包围的两行都可以也最好拆分成为独立键,如果“洁白的头饰”这一文本会在其他地方被引用到,那么也要将其设置为单独的Key,比如:

EffectRegen: 缓慢再生生命
SkillEnhance: 强化技能
A: {
    SkillName: 6767
    
    GearDesc:
        '''
        [c/746f6f:啊吧啊吧]
        [c/746f6f:这是背景故事]
        '''

	GearName: 洁白的头饰
}

# 假设你的mod名字叫做XMod
A_Gear: {
    Tooltip:
   
        '''
        {$Mods.XMod.EffectRegen} 
        {$Mods.XMod.SkillEnhance} {$Mods.XMod.A.SkillName}
        {$Mods.XMod.A.GearDesc}
        '''
    DisplayName: {$Mods.XMod.A.GearName}
}

如果我们发现,不同的Gear饰品之间的Tooltip格式都是一样的,区别就是A替换成为了B,C,D,那么更极端一点的做法就是:

EffectRegen: 缓慢再生生命
SkillEnhance: 强化技能
A: {
    SkillName: 6767
    
    GearEffect: "{$Mods.XMod.EffectRegen}"
    
    GearDesc:
        '''
        [c/746f6f:啊吧啊吧]
        [c/746f6f:这是背景故事]
        '''

	GearName: 洁白的头饰
}

# 假设你的mod名字叫做XMod
Tooltip:
	'''
	{0}{$Mods.XMod.SkillEnhance} {1}
	{2}
	'''

然后在代码里面格式化LocalizaedText,从而完全不需要每多出一个Item就手动拼接一次,最大程度减少重复。

货币系统

护卫奖章是原版的特殊货币,是独立于原版四币的货币系统,tmodloader自带提供了一个方便的实现CustomCurrencySingleCoin,用于支持单币种货币系统。但是显然,如果想要拓展到多币种系统,或者想要修改货币的显示方式,还是需要自己动手,这就需要继承CustomCurrencySystem。为了到达原版的效果,我们至少要做以下内容:

  • 货币不能作为可交易物品
  • 货币不能显示交易价格
  • 购买时扣钱应该尽量保持货币物体的槽位不变

第一点,通过查看源代码得知,在出售物品之前,ModPlayer.CanSellItem接口将检测这个物体是否能被出售,只需在这一层拦截,实现如下:

public override bool CanSellItem(NPC vendor, Item[] shopInventory, Item item)
{
    if (item.ModItem is Currency) return false;

    return base.CanSellItem(vendor, shopInventory, item);
}

第二点,需要修改货币Item对应的Tooltip显示,在显示之前ModItem.ModifyTooltips接口将给出自定义Tooltip的操作空间,这也是最好的拦截接口,实现如下:

public override void ModifyTooltips(List<TooltipLine> tooltips)
{
    var ptl = tooltips.FirstOrDefault(tl => tl.Name == "Price");
    if (ptl != null) ptl.Hide();
}

第三点,需要修改货币交易时的操作接口,CustomCurrencySystem.TryPurchase接口负责具体的货币交易,这里的实现比较复杂,但是逻辑很简单,就是先尽可能地从已有的而货币中扣款,不涉及到进位。如果这一轮之后还有余款,则破开一个比余款多的货币,然后执行找零。

BossBar生命条

关于BossBar有很多个概念,分别是GlobalBossBar、ModBossBar、ModBossBarStyle。

我们来逐个区分一下:

  • GlobalBossBar是对原版Boss生命条的机制修改,比如说,你可以通过检测是否为指定的Boss,从而为生命条施加特效。

  • ModBossBar则是让你自定义一个新的Boss生命条的机制,比如说你可以自定义Boss头像,自定义血量显示值,ModBossBar能做的事情和GlobalBossBar类似,只是ModBossBar可以通过NPC.BossBar = ModContent.GetInstance<MinionBossBossBar>();来覆盖自定义NPC,而原版NPC的BossBar属性无法修改。

  • ModBossBarStyle则给你完全重新绘制所有Boss生命条的机会,处于最高优先级。如果你创建了新的ModBossBarStyle并且应用了该风格,那么Boss生命条将完全由你接管,包含数值计算。有些Mod比如说灾厄就绘制了自己专属的ModBossBarStyle。

自定义字体

这里是最令人绝望和无奈的摸索环节,我基本上没有找到什么教程,AI回答的也是依托石。那么我给你最直接、最真相、最不绕弯、最硬核、最干脆、最只给结果、最只聊真相、最核心、最关键的答案——”TTF或者活字印刷“。

首先tmodloader里面使用的是Relogic.Graphics.DynamicSpriteFont,这意味着什么,MonoGame的xnb打包方式不会生效。唯一能找到的第三方转化器是DynamicFontGenerator,如果你手上有ttf字体,那么非常好,能够直接打包成xnb文件,从而加载。如果你的手上是一堆美术给出来的贴图的话,那么使用活字印刷是最简单的方式,反正DynamicSpriteFont也是活字印刷。在不考虑字体不支持的字符的情况下,活字印刷就只需要以下代码:

private Texture2D texture;
private readonly float scale;
private readonly int column;
private readonly int height;
private readonly int spacing;
private readonly List<int> startX;
private readonly List<int> width;

public void Draw(SpriteBatch sb, string text, Vector2 pos, Color col, float rot, Vector2 origin, float scale, SpriteEffects effects, float depth)
{
    if (string.IsNullOrEmpty(text)) return;

    var realScale = this.scale * scale;

    float totalH = height;
    float totalW = (text.Length - 1) * this.spacing;
    List<int> idx = new();

    for (int i = 0; i < text.Length; i++)
    {
        var delta = text[i] - '0';
        delta = Math.Clamp(delta, 0, 9);

        totalW += this.width[delta];
        idx.Add(delta);
    }

    Vector2 Origin = new Vector2(totalW * 0.5f, totalH * 0.5f);
    float unscaledCursorX = 0f;

    for (int i = 0; i < text.Length; i++)
    {
        int index = idx[i];

        int sx = this.startX[index];
        int sy = (index / this.column) * this.height;
        int width = this.width[index];

        var src = new Rectangle(sx, sy, width, this.height);
        sb.Draw(
            texture,
            position: pos,
            sourceRectangle: src,
            color: col,
            rotation: rot,
            origin: Origin - new Vector2(unscaledCursorX, 0f),
            scale: realScale,
            effects: effects,
            layerDepth: depth
        );
        unscaledCursorX += width + this.spacing;
    }
}