BWIKI 全站将于 9 月 3 日(全天)进行维护,期间无法编辑任何页面或发布新的评论。
模组:制作指南/APIs/Content
← 模组:目录
SMAPI 的内容接口(content API)使您能够读取自定义素材,或读取/编辑/替换游戏素材。
介绍
何为素材?
素材是指提供给游戏的图像、地图或数据结构。游戏的默认素材存储在 Content 文件夹。模组可以有自定义素材。例如,阿比盖尔的所有肖像都存储在 Content\Portraits\Abigail.xnb 内的某个素材中。如果您解包了这个文件,就会找到一个图像文件:
参见编辑 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.LoadFrom 或 e.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 以获得更多信息。)
编辑任意文件
您可以通过 Edit 的 asset 参数来直接使用下面列出的字段/方法,也可以通过后文提及的辅助方法来使用它们。
- Data
- 指向被加载的素材数据的引用。
- ReplaceWith
- 将整个素材替换为一个新素材。但大多数情况下您不应这样做;另请参阅 替换游戏素材,或使用下文提及的辅助方法。
编辑字典
字典是指一个键/值对数据结构,在 JSON 中表示如下:
{
"key A": "value A",
"key B": "value B",
...
}
您可以通过 asset.AsDictionary<TKey, string>() 获得一个字典辅助类,其中 TKey 为键类型(一般为 int 或 string)。
- 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 欲复制-粘贴到原图像上的新图像。可以为 Texture2D 或 IRawTextureData。 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)); });
- 可用的方法参数:
- 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/Abigail 和 CHARACTERS\ABIGAIL 是同一个素材名称,但使用 C# 的 ==
运算符会返回 false。
您可以使用 SMAPI 的 IAssetName 类型来比较素材名称。例如,使用 assetName.IsEquivalentTo("Characters/Abigail")
比较上述两个名称会返回 true。有两种方式能获取 IAssetName 的值:
- 在类似于 AssetRequested 的内容事件中,使用 e.Name 或 e.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));
参见编辑游戏素材以了解可用的补丁辅助类。
让其他模组编辑您的内部素材
其他模组不能编辑您的内部模组文件(包括数据或贴图文件),但是它们 可以 编辑您通过内容管线提供的自定义素材。此技术由下面三步组成:
- 利用 AssetRequested 事件,基于内部文件定义一个自定义素材。
- 利用 AssetReady 事件 检测此素材是否被加载/修改。
- 当您需要它时,从内容管线加载它。
下面给出的模组代码加载了一个数据素材(一个字典,其值为数据模型):
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)
)。