维护提醒

BWIKI 全站将于 9 月 3 日(全天)进行维护,期间无法编辑任何页面或发布新的评论。

全站通知:

模组:制作指南/APIs/Content

来自星露谷物语维基
跳到导航 跳到搜索

模组:目录

SMAPI 的内容接口(content API)使您能够读取自定义素材,或读取/编辑/替换游戏素材。

介绍

何为素材?

素材是指提供给游戏的图像、地图或数据结构。游戏的默认素材存储在 Content 文件夹。模组可以有自定义素材。例如,阿比盖尔的所有肖像都存储在 Content\Portraits\Abigail.xnb 内的某个素材中。如果您解包了这个文件,就会找到一个图像文件:

Modding - creating an XNB mod - example portraits.png

参见编辑 XNB 文件以获取关于素材文件的更多信息。

何为素材名称?

素材名称用于定位一个素材。素材名称就是素材文件基于 Content 文件夹的相对路径,不包含 .xnb 扩展名。例如:

内容文件 素材名称
Content\Portraits\Abigail.xnb Portraits/Abigail
Content\Maps\Desert.ja-JA.xnb Maps/Desert.ja-JA

注意在 Content Patcher 内容包中,素材名称不包含语言代码。例如,上表第二行的 Content Patcher 素材名称为 Maps/Desert

内容接口能做什么?

SMAPI 会处理游戏加载的内容。这使得您能够:

  • 从模组文件夹中读取数据、图像或地图(支持格式:.json.png.tbin.tmx.xnb);
  • 从游戏的 Content 文件夹中读取素材;
  • 修改游戏素材(且保持素材文件不变);
  • 提供给游戏新素材。

此页面的余下部分将详细解释它们。

读取素材

读取模组素材

您可以通过指定自定义素材(相对于模组文件夹的)路径和类型来读取素材。例如:

// 读取图像文件
Texture2D texture = helper.ModContent.Load<Texture2D>("assets/texture.png");
// 另一种方法
IRawTextureData texture = helper.ModContent.Load<IRawTextureData>("assets/texture.png");

// 读取地图文件
Map map = helper.ModContent.Load<Map>("assets/map.tmx");

// 读取数据文件
IDictionary<string, string> data = helper.ModContent.Load<Dictionary<string, string>>("assets/data.json");

支持的文件类型为:

文件扩展名 游戏内的类型 说明
.xnb 任何 类似于游戏 Content 文件夹下的那种打包文件。不推荐使用 XNB 文件,因为难于编辑和维护。
.json 任何 数据文件,常用于存储 Dictionary<int, string>Dictionary<string, string> 数据。
.png Texture2D 图片文件。您可以使用它加载贴图集、地块集等。
.tbin or .tmx xTile.Map 地图文件。可用于创建或修改游戏内的地点。SMAPI 会自动在地图文件夹中寻找地图所需的地块集文件,若未找到,则尝试在 Content 文件夹中寻找相应的地块集。

一些实用的说明:

  • 惯例是将这些素材文件都放在 assets 子文件夹下,尽管这并非强制的。
  • 不要担心路径分隔符;SMAPI 会自动标准化分隔符。
  • 为了避免性能问题,不要在绘图指令中重复调用 content.Load<T> ,而应当依次加载完素材然后重复使用它。

获取实际的模组素材名称

当您从模组文件夹中加载了一个素材,SMAPI 就会使用一个唯一的素材名称来存储它。如果您需要将此素材名称传入游戏代码,您可以通过如下方法获取实际的素材名称:

tilesheet.ImageSource = helper.ModContent.GetActualAssetKey("assets/tilesheet.png");

读取内容素材

您也可以从游戏文件夹中读取素材:

Texture2D portraits = helper.GameContent.Load<Texture2D>("Portraits/Abigail");

这里传入的是素材名称而非文件名。

替换游戏素材

基础

您可以利用 AssetRequested 事件来完全替换掉一个素材(了解如何使用事件)。每次加载素材时(同一个素材可能被加载多次),该事件都会被调用,这使得您能够替换素材。如果有模组提供了被请求的素材,则原版文件不会被读取,也不会被更改。

下面列出了关于替换素材的两个重要概念:

加载优先级
逻辑上而言,每个素材只能有一个初始实例。如果多个模组希望加载相同的素材,则 SMAPI 会使用优先级最高的模组。可以在加载方法中设置优先级,例如 e.LoadFromModFile<Map>("assets/Farm.tmx", AssetLoadPriority.Medium) 中的 AssetLoadPriority.Medium。如果多个模组优先级相同,则 SMAPI 会使用最先注册此修改的模组。您可以使用预设优先级(例如 AssetLoadPriority.Medium),也可在预设优先级的基础上添加偏移(例如 AssetLoadPriority.Medium + 1,它的优先级高于 AssetLoadPriority.Medium 但低于 AssetLoadPriority.High)。
AssetLoadPriority.Exclusive 是一个特殊的优先级。它代表强制加载素材,无论其他模组是否也加载了此素材。不建议使用该优先级,因为会降低模组兼容性。如果多个模组都指定了 AssetLoadPriority.Exclusive 优先级,则 SMAPI 会记录一个错误,然后不会使用任何一个模组。
延迟加载
当您调用了 e.LoadFrome.LoadFromModFile 方法,素材并不会立刻被加载。只有当 SMAPI 需要加载相应的素材时,后者才会按您指定的方式被加载。SMAPI 会先调用监听此事件的所有模组,然后按它们指定的方式来检查素材存在性或提供相应素材(依赖于游戏调用的具体方法)。

替换图像素材

下面的例子演示了模组如何替换阿比盖尔的肖像:

internal sealed class ModEntry : Mod
{
    /// <inheritdoc/>
    public override void Entry(IModHelper helper)
    {
        helper.Events.Content.AssetRequested += this.OnAssetRequested;
    }

    /// <inheritdoc cref="IContentEvents.AssetRequested"/>
    /// <param name="sender">The event sender.</param>
    /// <param name="e">The event data.</param>
    private void OnAssetRequested(object sender, AssetRequestedEventArgs e)
    {
        if (e.Name.IsEquivalentTo("Portraits/Abigail"))
        {
            e.LoadFromModFile<Texture2D>("assets/abigail-portraits.png", AssetLoadPriority.Medium);
        }
    }
}

参见 e 参数的 IntelliSense ,以了解全部选项及其使用方法。

替换地图文件

您也可以使用 AssetRequested 事件来加载自行一地图。如果您将地图及其地块集置于同一目录下,则 SMAPI 会自动链接地图和地块集。如果地块集以季节名称加下划线开头,则游戏会自动使用相应的季节逻辑。

下面给出一个例子。假设您的模组有如下结构:

📁 ExampleMod/
    🗎 ExampleMapMod.dll
    🗎 manifest.json
    📁 assets/
        🗎 Farm.tmx
        🗎 fall_customTilesheet.png
        🗎 spring_customTilesheet.png
        🗎 summer_customTilesheet.png
        🗎 winter_customTilesheet.png

您可以通过如下方式加载此地图

internal sealed class ModEntry : Mod
{
    /// <inheritdoc/>
    public override void Entry(IModHelper helper)
    {
        helper.Events.Content.AssetRequested += this.OnAssetRequested;
    }

    /// <inheritdoc cref="IContentEvents.AssetRequested"/>
    /// <param name="sender">The event sender.</param>
    /// <param name="e">The event data.</param>
    private void OnAssetRequested(object sender, AssetRequestedEventArgs e)
    {
        if (e.Name.IsEquivalentTo("Maps/Farm"))
        {
            e.LoadFromModFile<Map>("assets/Farm.tmx", AssetLoadPriority.Medium);
        }
    }
}

大功告成!SMAPI 会检测到地图对 spring_customTilesheet.png 的引用,然后会找到此地块集,然后一并加载它。当游戏中的季节发生变化,SMAPI 也会自动地调整地块集,例如夏季会自动使用 summer_customTilesheet.png,以此类推。其他的地块集引用不会被处理(因为不存在相应的模组文件),而默认使用相应的游戏素材。

添加新素材

添加新素材和替换已有素材区别不大(参见前一节)。例如,下面的代码为一个自定义 NPC 添加了新的对话文件:

internal sealed class ModEntry : Mod
{
    /// <inheritdoc/>
    public override void Entry(IModHelper helper)
    {
        helper.Events.Content.AssetRequested += this.OnAssetRequested;
    }

    /// <inheritdoc cref="IContentEvents.AssetRequested"/>
    /// <param name="sender">The event sender.</param>
    /// <param name="e">The event data.</param>
    private void OnAssetRequested(object sender, AssetRequestedEventArgs e)
    {
        if (e.Name.IsEquivalentTo("Characters/Dialogue/John"))
        {
            e.LoadFrom(
                () => {
                    return new Dictionary<string, string>
                    {
                        ["Introduction"] = "Hi there! My name is Jonathan."
                    };
                },
                AssetLoadPriority.Medium
            );
        }
    }
}

编辑游戏素材

基础

在游戏素材被加载完成、但尚未提交给游戏之际,您可以编辑游戏素材。此时所做的编辑不会改变源文件。您可以利用 AssetRequested 事件 来完成编辑(了解如何使用事件)。每次加载素材时,该事件都会被调用(每个素材可能发生多次),因此您可以编辑素材。

下面列出了关于编辑素材的两个重要概念:

编辑顺序
当模组对同一个素材施加多个编辑时,这些编辑会按照其被注册顺序依次进行。当您调用 e.Edit 方法时,也可以酌情指定编辑优先级,以使您的编辑按指定顺序进行。您可以使用预设优先级(例如 AssetEditPriority.Default),也可以在此基础上添加任意偏移量(例如 AssetEditPriority.Default + 1,此优先级高于 AssetEditPriority.Default 但低于 AssetEditPriority.Late)。
延迟编辑
:: 当您调用了 e.Edit 方法,素材并不会立刻被编辑。只有当 SMAPI 需要使用相应的素材时,后者才会按您指定的方式被编辑。SMAPI 会先调用监听此事件的所有模组,然后按它们指定的方式来编辑素材。

数据编辑示例

下面的模组代码使所有物品的售价翻倍:

internal sealed class ModEntry : Mod
{
    /// <inheritdoc/>
    public override void Entry(IModHelper helper)
    {
        helper.Events.Content.AssetRequested += this.OnAssetRequested;
    }

    /// <inheritdoc cref="IContentEvents.AssetRequested"/>
    /// <param name="sender">The event sender.</param>
    /// <param name="e">The event data.</param>
    private void OnAssetRequested(object sender, AssetRequestedEventArgs e)
    {
        if (e.NameWithoutLocale.IsEquivalentTo("Data/Objects"))
        {
            e.Edit(asset =>
            {
                var data = asset.AsDictionary<string, ObjectData>().Data;

                foreach ((string itemID, ObjectData itemData) in data)
                {
                    itemData.Price *= 2;
                }
            });
        }
    }
}

Edit 方法的 IAssetData asset 参数提供了一些辅助方法,见下文。它们使编辑更方便。(参见 IntelliSense 以获得更多信息。)

编辑任意文件

您可以通过 Editasset 参数来直接使用下面列出的字段/方法,也可以通过后文提及的辅助方法来使用它们。

Data
指向被加载的素材数据的引用。
ReplaceWith
将整个素材替换为一个新素材。但大多数情况下您不应这样做;另请参阅 替换游戏素材,或使用下文提及的辅助方法。

编辑字典

字典是指一个键/值对数据结构,在 JSON 中表示如下:

{
   "key A": "value A",
   "key B": "value B",
   ...
}

您可以通过 asset.AsDictionary<TKey, string>() 获得一个字典辅助类,其中 TKey 为键类型(一般为 intstring)。

Data
指向被加载的素材数据的引用。例如,下面演示了如何添加或替换上一例子的特定条目:
    /// <inheritdoc cref="IContentEvents.AssetRequested"/>
    /// <param name="sender">The event sender.</param>
    /// <param name="e">The event data.</param>
    private void OnAssetRequested(object sender, AssetRequestedEventArgs e)
    {
        if (e.NameWithoutLocale.IsEquivalentTo("Location/Of/The/Asset"))
        {
            e.Edit(asset =>
            {
                 var editor = asset.AsDictionary<string, string>();
                 editor.Data["Key C"] = "Value C";
            });
        }
    }

编辑图像

编辑图像时,您可以通过 asset.AsImage() 获取一个辅助类。

Data
指向被加载图像的引用。您可以通过此字段直接编辑每一个像素,尽管一般不需要这样做。
PatchImage
编辑或替换图像的一部分。这基本上是一个复制-粘贴操作,所以新图像会粘在原图像上。例如:
    /// <inheritdoc cref="IContentEvents.AssetRequested"/>
    /// <param name="sender">The event sender.</param>
    /// <param name="e">The event data.</param>
    private void OnAssetRequested(object sender, AssetRequestedEventArgs e)
    {
        if (e.NameWithoutLocale.IsEquivalentTo("Location/Of/The/Asset"))
        {
            e.Edit(asset =>
            {
                  var editor = asset.AsImage();
                  IRawTextureData sourceImage = this.Helper.ModContent.Load<IRawTextureData>("custom-texture.png");
                  editor.PatchImage(sourceImage, targetArea: new Rectangle(300, 100, 200, 200));
            });
        }
    }
可用的方法参数:
参数 用途
source 欲复制-粘贴到原图像上的新图像。可以为 Texture2DIRawTextureData
sourceArea (可选)欲复制的新图像的像素区域(若省略,则为整个图像)。尺寸必须与 targetArea 匹配。
targetArea (可选)原图像的像素区域(若忽略,则在左上角粘贴)。
patchMode (可选)如何粘贴图片。可用的取值为:
  • PatchMode.Replace(默认):直接擦除原图像的相应区域,并在该区域粘贴新图像。
  • PatchMode.Overlay:在原图像上层绘制新图像。因此,透过新图像的透明或半透明像素仍能看到原图像。
ExtendImage
如有需要,拉伸图像以匹配给定尺寸。注意拉伸图像的开销较高,这会创建一个新的贴图实例,且水平拉伸贴图集可能导致错误或漏洞。例如:
e.Edit(asset =>
{
   var editor = asset.AsImage();

   // make sure the image is at least 1000px high
   editor.ExtendImage(minWidth: editor.Data.Width, minHeight: 1000);
});
可用的方法参数:
参数 使用方法
minWidth 所需的最小宽度。如果图像宽度小于最小宽度,则会拉伸其右侧以达到最小宽度。
minHeight 所需的最小高度。如果图像高度小于最小高度,则会拉伸其底边以达到最小高度。

编辑地图

编辑地图文件时,可以用 asset.AsMap() 获取一个辅助类。

Data
指向被加载地图的引用。您可以通过此字段直接编辑地图或地块。
PatchMap
编辑或替换地图的一部分。这基本上是一个复制-粘贴操作,因此新地图会粘在原地图上。例如:
e.Edit(asset =>
{
   var editor = asset.AsMap();
   
   Map sourceMap = this.Helper.ModContent.Load<Map>("custom-map.tmx");
   editor.PatchMap(sourceMap, targetArea: new Rectangle(30, 10, 20, 20));
});
可用的方法参数:
参数 使用方法
source 欲复制-粘贴到原地图上的新地图。
sourceArea (可选)欲复制的地图的地块区域(若忽略,则为整张新地图)。尺寸必须与 targetArea 匹配。
targetArea (可选)原地图的地块区域(若忽略,则在左上角粘贴)。
patchMode (可选)如何将地块融合进原地图。默认为 ReplaceByLayer

下面给出一个例子。假设新地图大部分为空,且分为两层:Back(红色)和 Buildings(蓝色):
SMAPI content API - map patch mode - source.png

下面的图片显示了不同的 patchMode 对地图融合的影响(黑色区域是因为没有地块覆盖而裸露的地图底色)

ExtendMap
如有需要,拉伸地图以匹配给定尺寸。注意拉伸地图的开销较高,且会改变地图本身(in-place)。例如:
e.Edit(asset =>
{
   var editor = asset.AsMap();

   // make sure the map is at least 256 tiles high
   editor.ExtendMap(minHeight: 256);
});
可用的方法参数:
参数 使用方法
minWidth 所需的最小宽度,单位为地块。如果地图宽度小于最小宽度,则会拉伸其右侧以达到最小宽度。
minHeight 所需的最小高度,单位为地块。如果地图高度小于最小高度,则会拉伸其右侧以达到最小高度。

高级

使用IRawTextureData

加载图像数据时,创建 Texture2D 实例或调用其 GetData/SetData 方法会造成很大开销,且涉及调用 GPU。您可以使用 SMAPI 的 IRawTextureData 来加载图片以避免上述问题,它会直接返回数据而不调用 GPU。然后,您可以将得到的 IRawTextureData 对象传入其他 SMAPI 接口,例如 PatchImage

下面的例子演示了模组如何将一张图片覆盖在阿比盖尔的肖像上,而避免创建 Texture2D 实例:

public class ModEntry : Mod
{
    public override void Entry(IModHelper helper)
    {
        helper.Events.Content.AssetRequested += this.OnAssetRequested;
    }

    private void OnAssetRequested(object sender, AssetRequestedEventArgs e)
    {
        if (e.NameWithoutLocale.IsEquivalentTo("Portraits/Abigail"))
        {
            e.Edit(asset =>
            {
                var editor = asset.AsImage();
   
                IRawTextureData overlay = this.Helper.ModContent.Load<IRawTextureData>("assets/overlay.png");
                editor.PatchImage(overlay);
            });
        }
    }
}

在将 IRawTextureData 数据传给其他方法之前,您也可以直接编辑它。例如,下面的代码将贴图转为灰阶:

IRawTextureData image = this.Helper.ModContent.Load<IRawTextureData>("assets/image.png");

int pixelCount = image.Width * image.Height;
for (int i = 0; i < pixelCount; i++)
{
    Color color = image.Data[i];
    if (color.A == 0)
        continue; // ignore transparent color

    int grayscale = (int)((color.R * 0.3) + (color.G * 0.59) + (color.B * 0.11)); // https://stackoverflow.com/a/596282/262123
    image.Data[i] = new Color(grayscale, grayscale, grayscale, color.A);
}

()

(说明:尽管 SMAPI 对 IRawTextureData 的实现短小精悍,但模组对 IRawTextureData 却不必如此。)

比较素材名称

您不能使用通常的字符串比较方法来比较素材名称。例如,Characters/AbigailCHARACTERS\ABIGAIL 是同一个素材名称,但使用 C# 的 == 运算符会返回 false。

您可以使用 SMAPI 的 IAssetName 类型来比较素材名称。例如,使用 assetName.IsEquivalentTo("Characters/Abigail") 比较上述两个名称会返回 true。有两种方式能获取 IAssetName 的值:

  • 在类似于 AssetRequested 的内容事件中,使用 e.Namee.NameWithoutLocale 属性。
  • 您可以将自定义素材名称解析为 IAssetName
    IAssetName assetName = this.Helper.GameContent.ParseAssetName("CHARACTERS/Abigail");
    if (assetName.StartsWith("Characters/")) { ... }
    if (assetName.IsEquivalentTo("Characters/Abigail")) { ... }
    

如果您的确需要手动比较两个字符串,您应当使用 PathUtilities 以正规化素材名称,并按大小写不敏感的方式进行比较。例如:

string assetName = "Characters/Dialogue/Abigail";
string dialoguePrefix = PathUtilities.NormalizeAssetName("Characters/Dialogue/");
bool isDialogue = assetName.StartsWith(dialoguePrefix, StringComparison.OrdinalIgnoreCase);

作废缓存

您可以通过作废缓存中的素材来重新加载素材。下次游戏请求此素材时,该素材就会被重新加载(这时模组就又可以拦截和修改此请求了),且大多情况下 SMAPI 会自动更新对此素材的引用。例如,作废缓存可用于改变 NPC 所穿的衣服(通过作废缓存中的贴图或肖像)。

请注意,有时本地化素材也会被缓存,如果您(在非英语环境下)仅作废了默认素材而没有作废本地化素材,则可能不能达到预期。

重新加载素材的开销相当大,因此请审慎地使用此接口以避免影响游戏性能。您显然不应该在每次游戏刷新时都重新加载素材。

通常,您需要指定欲作废的素材名称:

helper.GameContent.InvalidateCache("Data/ObjectInformation");

您也可以作废满足特定表达式的所有素材:

helper.GameContent.InvalidateCache(asset => asset.DataType == typeof(Texture2D) && asset.Name.IsEquivalentTo("Data/ObjectInformation"));

自定义素材的补丁辅助类

“补丁辅助类”提供了编辑给定素材的工具方法(例如,融合地图或缩放图像)。

对于任何数据,您都可以获取补丁辅助类。例如,下面的例子加载了两个地图文件并融合了它们:

Map farm = this.Helper.ModContent.Load<Map>("assets/farm.tmx");
Map islands = this.Helper.ModContent.Load<Map>("assets/islands.tmx");

this.Helper.ModContent
   .GetPatchHelper(farm)
   .AsMap()
   .PatchMap(source: islands, targetArea: new Rectangle(0, 26, 56, 49));

参见编辑游戏素材以了解可用的补丁辅助类。

让其他模组编辑您的内部素材

其他模组不能编辑您的内部模组文件(包括数据或贴图文件),但是它们 可以 编辑您通过内容管线提供的自定义素材。此技术由下面三步组成:

  1. 利用 AssetRequested 事件,基于内部文件定义一个自定义素材。
  2. 利用 AssetReady 事件 检测此素材是否被加载/修改。
  3. 当您需要它时,从内容管线加载它。

下面给出的模组代码加载了一个数据素材(一个字典,其值为数据模型):

public class ModEntry : Mod
{
    /// <summary>The loaded data.</summary>
    private Dictionary<string, ExampleModel> Data;

    /// <inheritdoc/>
    public override void Entry(IModHelper helper)
    {
        helper.Events.Content.AssetRequested += this.OnAssetRequested;
        helper.Events.Content.AssetReady += this.OnAssetReady;
        helper.Events.GameLoop.GameLaunched += this.OnGameLaunched;
    }

    private void OnAssetRequested(object sender, AssetRequestedEventArgs e)
    {
        //
        // 1. define the custom asset based on the internal file
        //
        if (e.Name.IsEquivalentTo("Mods/Your.ModId/Data"))
        {
            e.LoadFromModFile<Dictionary<string, ExampleModel>>("assets/default-data.json", AssetLoadPriority.Medium);
        }
    }

    private void OnAssetReady(object sender, AssetReadyEventArgs e)
    {
        //
        // 2. update the data when it's reloaded
        //
        if (e.Name.IsEquivalentTo("Mods/Your.ModId/Data"))
        {
            this.Data = Game1.content.Load<Dictionary<string, ExampleModel>>("Mods/Your.ModId/Data");
        }
    }

    private void OnGameLaunched(object sender, GameLaunchedEventArgs e)
    {
        //
        // 3. load the data
        //    (This doesn't need to be in OnGameLaunched, you can load it later depending on your mod logic.)
        //
        this.Data = Game1.content.Load<Dictionary<string, ExampleModel>>("Mods/Your.ModId/Data");
    }
}

这适用于任何素材类型(例如地图或贴图),甚至没有对应的内部文件也可以完成上述步骤(例如,通过 e.LoadFrom(() => new Dictionary<string, ExampleModel>(), AssetLoadPriority.Medium))。