模组:常用方法
← 目录
页面仍需完善
该页面不完善且缺少信息。您可以通过扩充内容来帮助我们。 |
此页面展示了制作SMAPI模组时常用功能的构建方法。在阅读时,请结合参考模组制作入门和游戏基本架构。
基础技巧
追踪一个值的变化
在写模组时,你可能常常需要了解一个值的变化(什么时候变,变化前后的值分别是多少,等等)。如果该值没有包括在SMAPI内置的事件 (event)中,那么你可以为该值创建一个私有变量,然后在SMAPI的update tick事件中刷新此变量,以达到追踪值变化的目的。
物品 (Items)
物品 代表那些能够放在背包里的东西,比如说工具、农作物等等。
创建一个物品 (Object) 的实例
Object中所有的构造函数:
public Object(Vector2 tileLocation, int parentSheetIndex, int initialStack);
public Object(Vector2 tileLocation, int parentSheetIndex, bool isRecipe = false);
public Object(int parentSheetIndex, int initialStack, bool isRecipe = false, int price = -1, int quality = 0);
public Object(Vector2 tileLocation, int parentSheetIndex, string Givenname, bool canBeSetDown, bool canBeGrabbed, bool isHoedirt, bool isSpawnedObject);
参数parentSheetIndex表示该物品的ID(储存在 ObjectInformation.xnb 文件中)。
在地上生成物品
public virtual bool dropObject(Object obj, Vector2 dropLocation, xTile.Dimensions.Rectangle viewport, bool initialPlacement, Farmer who = null);
// 调用:
Game1.getLocationFromName("Farm").dropObject(new StardewValley.Object(itemId, 1, false, -1, 0), new Vector2(x, y) * 64f, Game1.viewport, true, (Farmer)null);
添加物品到背包 (Inventory)
// You can add items found in ObjectInformation using:
Game1.player.addItemByMenuIfNecessary((Item)new StardewValley.Object(int parentSheetIndex, int initialStack, [bool isRecipe = false], [int price = -1], [int quality = 0]));
例2:
// Add a weapon directly into player's inventory
const int WEAP_ID = 19; // Shadow Dagger -- see Data/weapons
Item weapon = new MeleeWeapon(WEAP_ID); // MeleeWeapon is a class in StardewValley.Tools
Game1.player.addItemByMenuIfNecessary(weapon);
// Note: This code WORKS.
从背包移除物品
取决于你背包的具体情况。很少有情况需要你亲自来调用,因为相关的方法在Farmer类中已经有了。
在大多数情况下,仅需调用 .removeItemFromInventory(Item) 方法。
地点 (Locations)
见 游戏基本架构#地点。
获取所有地点
Game1.locations
属性中虽然储存着主要的地点,但是不包括建筑的室内(constructed building interiors)。以下这个方法提供了主玩家的所有地点。
/// <summary>Get all game locations.</summary>
public static IEnumerable<GameLocation> GetLocations()
{
return Game1.locations
.Concat(
from location in Game1.locations.OfType<BuildableGameLocation>()
from building in location.buildings
where building.indoors.Value != null
select building.indoors.Value
);
}
遍历:
foreach (GameLocation location in this.GetLocations())
{
// ...
}
注意:在联机模式中,客机是拿不到上述所有地点的。要解决这一问题,见获取有效的地点。
编辑地图
见模组:地图数据。
玩家 (Player)
自定义贴图 (Custom Sprite)
位置 (Position)
角色(Character) 的位置(Position) 表示他在当前地点(Location) 的坐标。
相对于地图 (Map)
每个地点(location) 都有一个对应的xTile地图(map)。如果以像素(pixel) 为单位,地图左上角坐标代表(0, 0),坐下角则代表(location.Map.DisplayWidth, location.Map.DisplayHeight)。 角色在当前地点的位置有两种表达方式:
- 以像素(pixel) 为单位的绝对(absoulte) 坐标:
Position.X
与Position.Y
。 - 以图块(tile) 为单位的图块(tile) 坐标:
getTileX()
与getTileY()
。
常量Game1.tileSize
规定,游戏内每个图块(tile) 大小为64x64像素。于是有以下单位换算:
// 绝对坐标 → 图块坐标
Math.Floor(Game1.player.Position.X / Game1.tileSize)
Math.Floor(Game1.player.Position.Y / Game1.tileSize)
// 图块坐标 → 绝对坐标
Game1.player.getTileX() * Game1.tileSize
Game1.player.getTileY() * Game1.tileSize
// 地图大小(以图块为单位)
Math.Floor(Game1.player.currentLocation.Map.DisplayWidth / Game1.tileSize)
Math.Floor(Game1.player.currentLocation.Map.DisplayHeight / Game1.tileSize)
相对于视野 (Viewport)
视野、视口、视窗(Viewport) 代表在当前屏幕上的区域。若以像素计算,其宽高应该与游戏的屏幕分辨率相等,分别为Game1.viewport.Width
和Game1.viewport.Height
。
玩家相对于视野的位置(像素)可表示为:
Game1.player.Position.X - Game1.viewport.X
Game1.player.Position.Y - Game1.viewport.Y
NPC
自定义NPC
想要自定义NPC,你得修改或添加以下文件:
格式: 具体操作:(往)目标文件夹或文件名
- 添加新文件:Characters\Dialogue\<文件名>
- 添加新文件:Characters\schedules\<文件名>
- 添加新文件:Portraits\<文件名>
- 添加新文件:Characters\<文件名>
- 在已有文件中添加新的条目:Data\EngagementDialogue(可结婚NPC)
- 在已有文件中添加新的条目:Data\NPCDispositions
- 在已有文件中添加新的条目:Data\NPCGiftTastes
- 在已有文件中添加新的条目:Characters\Dialogue\rainy
- 在已有文件中添加新的条目:Data\animationDescriptions(在行程(schedule) 中增加自定义的动画)
以上所有的操作都可以通过IAssetLoaders
/IAssetEditors
或者Content Patcher
做到。
最后,你需要调用NPC
的构造器来创建实例。
public NPC(AnimatedSprite sprite, Vector2 position, int facingDir, string name, LocalizedContentManager content = null);
public NPC(AnimatedSprite sprite, Vector2 position, string defaultMap, int facingDir, string name, Dictionary<int, int[]> schedule, Texture2D portrait, bool eventActor);
public NPC(AnimatedSprite sprite, Vector2 position, string defaultMap, int facingDirection, string name, bool datable, Dictionary<int, int[]> schedule, Texture2D portrait);
// 调用:
Game1.getLocationFromName("Town").addCharacter(npc);
若不调用NPC
的构造器来创建实例,而是将Data\NPCDispositions的字段"SpawnIfMissing"设置为true,则在打开未拥有该Npc的存档时,会在字段"Home"的位置生成该NPC,且该NPC在当天的行程信息失效。在睡觉保存后行程信息正常。
推断原因为行程信息读取在生成缺失的Npc之后导致。
用户界面 (UI)
用户界面(User-interface、UI) 是指一系列有关界面元素(如按钮、列表框、下拉框等等)以及它们组合起来呈现的画面(如菜单、HUD等等)。
//TODO:欢迎补充UI方面的内容。
HUD消息
HUD消息是指你屏幕左下角时常弹出来的消息框。以下是它的构造函数(不包括一部分无关的):
public HUDMessage(string message);
public HUDMessage(string message, int whatType);
public HUDMessage(string type, int number, bool add, Color color, Item messageSubject = null);
public HUDMessage(string message, string leaveMeNull)
public HUDMessage(string message, Color color, float timeLeft, bool fadeIn)
可选的样式(type):
- 成就
HUDMessage.achievement_type
- 新任务
HUDMessage.newQuest_type
- 错误
HUDMessage.error_type
- 体力值
HUDMessage.stamina_type
- 生命值
HUDMessage.health_type
颜色(color):
第1、2个构造器并没有给出表示颜色的参数,此时颜色默认为Color.OrangeRed
。
若使用第4个构造器,颜色则与游戏内文字颜色一样。
特别:
- public HUDMessage(string type, int number, bool add, Color color, Item messageSubject = null); 支持消息内容扩展。常用于金钱相关。
- public HUDMessage(string message, string leaveMeNull); 左侧没有图标框。
- public HUDMessage(string message, Color color, float timeLeft, bool fadeIn); 文字渐入效果。
Game1.addHUDMessage(new HUDMessage("MESSAGE", 3));
示例2: 弹出一个纯文字的消息框。
Game1.addHUDMessage(new HUDMessage("MESSAGE", "")); // second parameter is the 'leaveMeNull' parameter
菜单指绘制于最顶层的UI,能够接受用户输入。比方说,当你按下ESC或B键时呈现的GameMenu
就是一个菜单。
菜单的值储存在Game1.activeClickableMenu
,当该字段不为null时,其值便能呈现出一个菜单了。
每个菜单不尽相同,请阅读代码来了解其运作方式。你可能经常需要了解GameMenu
的当前栏目 (tab),这是一个示例:
if (Game1.activeClickableMenu is GameMenu menu)
{
// 获取栏目页
IList<IClickableMenu> pages = this.Helper.Reflection.GetField<List<IClickableMenu>>(menu, "pages").GetValue();
// 方法1:比较栏目的ID
if (menu.currentTab == GameMenu.mapTab)
{
...
}
// 方法2:比较菜单类型
switch (pages[menu.currentTab])
{
case MapPage mapPage:
...
break;
}
}
如果你想要自定义菜单,请继承自IClickableMenu,将对象分配给Game1.activeClickableMenu。 一个菜单基本上重写了一些方法,如draw、receiveLeftClick 等等。 draw方法绘制屏幕上的元素;receiveLeftClick 方法处理左键点击事件。 你通常可以使用一些游戏封装好的类作为菜单元素,如ClickableTextureButton。
这里提供了一个简单的例子,这是Birthday Mod的菜单。
对话框 (DialogueBox)
对话框有许多变种,比如有种对话框能够选择想要的对话内容。
如果想换行,请输入"^"。
下面是一种不带选项的对话框示例:
using StardewValley.Menus; // 引用DialogueBox类的命名空间
string message = "This looks like a typewriter ... ^But it's not ...^It's a computer.^";
Game1.activeClickableMenu = new DialogueBox(message);
邮件 (Mail)
如果您不熟悉 SMAPI 或一般对 Stardew Valley 进行改装,向玩家的邮箱发送一封简单的信件是开始学习之旅的好地方。 您将接触到一些简单易懂的代码和概念,并以有形的游戏内信件的形式获得一些即时的满足,您可以在实际操作中看到这些信件。 如果本节中的示例不足,有很多人可以在 Discord 频道上为您提供帮助。
概述
在您实际向播放器发送任何您自己的自定义邮件之前,您必须决定您的信件将如何撰写。 我的意思是,您的信件是静态的 - 总是相同的文本 - 还是动态的 - 文本会根据可变信息而变化? 显然,静态信件更容易实现,所以如果你刚刚开始,现在就走这条路。 然而,静态和动态方法都在下面解释。
要发送静态或动态邮件,您首先必须让 Stardew Valley 知道您的内容,也称为Aeest。 对于邮件,您必须将添加内容注入邮件数据。 您可以通过 IAssetEditor 界面完成此操作。 您可以从 ModEntry 类实现 IAssetEditor,或者创建一个单独的类来实现 IAssetEditor 以将新邮件内容注入“Data\Mail.xnb”。 为了清楚起见、易于重用和封装,下面引用的示例使用后一种方法:
注入静态内容 (Inject static content)
大多数情况下,一个静态的、预定义的信件就足够了,无论您是否包含附件(即对象、金钱等)。 “静态”只是意味着您不需要在发送信件之前输入文本后更改文本。 “静态”信件在游戏中始终可用(除非您将其从模组中移除或模组被玩家移除),这意味着如果玩家退出且您的信件仍在邮箱中,则该信件仍然可用稍后回来玩。这可能是“动态”字母的问题,如该部分更详细的解释,因此请尽可能使用“静态”内容。
您可以使用“@”轻轻地引用播放器的名称,但其他可能在对话文本中起作用的替换代码,如 %pet 或 %farm,目前在静态邮件内容中不起作用。但是,您可以使用一些在字母中显示图标的特殊字符,例如“=”,将显示一个紫色的星星,“<”,将显示一个粉红色的心形,“$”,将显示替换为金币,“>”将显示向右箭头,“`”将显示向上箭头,“+”将显示骑着滑板的头(也许?)。可能还有其他尚未记录的特殊情况。
下面的示例将 4 个字母添加到邮件数据集合中。请注意,下面的代码不会向玩家发送任何信件,而只是将它们提供给 Stardew Valley 游戏,以便它们可以发送。
using StardewModdingAPI;
namespace MyMod
{
public class MyModMail : IAssetEditor
{
public MyModMail()
{
}
public bool CanEdit<T>(IAssetInfo asset)
{
return asset.AssetNameEquals("Data\\mail");
}
public void Edit<T>(IAssetData asset)
{
var data = asset.AsDictionary<string, string>().Data;
// "MyModMail1" is referred to as the mail Id. It is how you will uniquely identify and reference your mail.
// The @ will be replaced with the player's name. Other items do not seem to work (''i.e.,'' %pet or %farm)
// %item object 388 50 %% - this adds 50 pieces of wood when added to the end of a letter.
// %item money 250 601 %% - this sends a random amount of gold from 250 to 601 inclusive.
// %item cookingRecipe %% - this is for recipes (did not try myself) Not sure how it know which recipe.
data["MyModMail1"] = "Hello @... ^A single carat is a new line ^^Two carats will double space.";
data["MyModMail2"] = "This is how you send an existing item via email! %item object 388 50 %%";
data["MyModMail3"] = "Coin $ Star = Heart < Dude + Right Arrow > Up Arrow `";
data["MyWizardMail"] = "Include Wizard in the mail Id to use the special background on a letter";
}
}
}
客户端调用静态内容 (Send a letter with static content)
例如,要在您自己的项目中使用此类,从而使静态邮件数据可用,请挂入 OnGameLaunch 事件。
/// <summary>
/// Fires after game is launched, right before first update tick. Happens once per game session (unrelated to loading saves).
/// All mods are loaded and initialized at this point, so this is a good time to set up mod integrations.
/// </summary>
private void OnGameLaunched(object sender, GameLaunchedEventArgs e)
{
Helper.Content.AssetEditors.Add(new MyModMail());
}
Now that you have your letter loaded, it's time to send it to the player. There are a couple different methods available to accomplish this as well, depending on your need. Two examples are shown below. The distinction between the two methods will be explained below:
Game1.player.mailbox.Add("MyModMail1");
Game1.addMailForTomorrow("MyModMail2");
第一种方法(Game1.player.mailbox.Add)将信件直接添加到当天的邮箱中。例如,这可以在您的“DayStaring”事件代码中完成。即使在保存后,直接添加到邮箱的邮件也不会被“记住”为已发送。根据您的需要,这在某些情况下很有用。
第二种方法(Game1.addMailForTomorrow),顾名思义,会在第二天将信件添加到玩家的邮箱中。此方法会记住发送的邮件 (Id),从而可以不一遍又一遍地发送相同的信件。这可以根据您的需要在“DayStaring”、“DayEnding”或其他事件中处理。
您可以将信件直接放入邮箱,并使用 mailRecieved 集合记住它。如果您希望在使用直接添加到邮箱方法时记住它,您可以简单地手动添加您的 mailId。
如果您希望 Stardew Valley 忘记已经发送了特定的信件,您可以将其从 mailReceived 集合中删除。如果您需要批量删除邮件,您也可以使用 foreach 遍历集合。
这就是发送一封简单的信件的全部内容。 附上物品和通过信件寄钱很简单,但发送食谱更复杂,以后需要一些额外的解释。
注入动态内容 (Inject dynamic content)
如果您想发送一封包含需要根据情况更改的数据的信件,例如今天吃的紫色蘑菇的数量,那么您必须在每次计划发送时创建该信件内容,特别是如果您想要一个 最新值。 这就是我所说的“动态”字母。
考虑以下源代码,它基本上是上述静态邮件类的增强版本,也将支持“动态”内容。 您当然可以始终使用此代码的增强版本,除非需要,否则不要使用动态内容。 代码被分开是为了说明差异。
using StardewModdingAPI;
using System.Collections.Generic;
namespace MyMail
{
public class MailData : IAssetEditor
{
// This collection holds any letters loaded after the initial load or last cache refresh
private Dictionary<string, string> dynamicMail = new Dictionary<string, string>();
public MailData()
{
}
public bool CanEdit<T>(IAssetInfo asset)
{
return asset.AssetNameEquals("Data\\mail");
}
public void Edit<T>(IAssetData asset)
{
var data = asset.AsDictionary<string, string>().Data;
// This is just an example
data["StaticMail"] = "If there were any letters with static content they could be placed here.";
// Inject any mail that was added after the initial load.
foreach (var item in dynamicMail)
{
data.Add(item);
}
dynamicMail.Clear(); // For the usage of this MOD the letters are cleared
}
/// <summary>
/// Add a new mail asset into the collection so it can be injected by the next cache refresh. The letter will
/// not be available to send until the cache is invalidated in the code.
/// </summary>
/// <param name="mailId">The mail key</param>
/// <param name="mailText">The mail text</param>
public void Add(string mailId, string mailText)
{
if (!string.IsNullOrEmpty(mailId))
{
if (dynamicMail.ContainsKey(mailId))
dynamicMail[mailId] = mailText;
else
dynamicMail.Add(mailId, mailText);
}
}
}
}
您会注意到用于静态邮件和动态邮件的代码实际上几乎没有区别。支持动态邮件的类有一个私有字典集合,用于保存等待注入的任何邮件内容。本来可以公开以允许将邮件直接添加到集合中,但这不是一个好的做法。相反,提供了一个公共 Add 方法,以便可以将邮件发送到集合。此代码适用于特定的 MOD,而不是健壮的框架,因此不会过分关注错误处理。您可以根据自己的需要进行改进。
请注意 Edit 方法中的附加代码,其中 dynamicMail 集合中的任何邮件都被注入到 Stardew Valley 的内容中。第一次加载MOD时(在这种情况下),dynamicMail集合中将没有邮件。如果在原始加载后添加邮件,则必须通过使缓存无效来重新加载内容。更多细节请参考Cache invalidation。
客户端调用动态内容 (Send a letter with dynamic content)
例如,要在您自己的项目中使用此类,从而使动态邮件可用,请挂入 OnGameLaunch 事件。
// Make this available to other methods in the class to access
private MailData mailData = new MailData();
/// <summary>
/// Fires after game is launched, right before first update tick. Happens once per game session (unrelated to loading saves).
/// All mods are loaded and initialized at this point, so this is a good time to set up mod integrations.
/// </summary>
private void OnGameLaunched(object sender, GameLaunchedEventArgs e)
{
Helper.Content.AssetEditors.Add(mailData);
}
您可以连接到其他事件,例如“开始日期”或“结束日期”以生成要发送的信件。 考虑这个简单的例子,它仅用于说明目的。
private void OnDayStarting(object sender, DayStartedEventArgs e)
{
string mailMessage = $"@, you have gathered {Game1.stats.rabbitWoolProduced} units of rabbit wool!";
mailData.Add("MyModMailWool", mailMessage); // Add this new letter to the mail collection (for next refresh).
Game1.mailbox.Add("MyModMailWool"); // Add to mailbox and we don't need to track it
modHelper.Content.InvalidateCache("Data\\mail"); // (modHelper was assigned in ModEntry for use throughout the class)
}
此示例格式化一封信,显示最新的兔毛计数,使其可用于邮件收集,将该信放入邮箱,然后使缓存无效,以便在缓存刷新期间注入此新信.在这种情况下,不需要记住 mailId,因为每次需要发送信件时都会重新创建该信件,在本例中是每天。同样,此代码仅用于说明该概念。
在以这种简单的方式注入邮件时,需要了解一个重要的警告。可用的各种邮件框架处理这个问题,本节将被扩展以解释如何解决这个问题,但在此处进行介绍是为了确保您完全了解 MOD 如何与 Stardew Valley 和 SMAPI 一起工作。
如果添加动态信件并在Day Ending 的内容中注入它,显然您必须添加明天显示的邮件。这意味着游戏将通过对邮箱中待处理的动态信件(在本例中为“MyMailModWool”)的引用来保存。如果玩家此时退出游戏并稍后返回继续玩,则该动态字母不可用,从而导致“幻影字母”。邮箱将显示一封可用的信件,但单击时不会显示任何内容。这可以通过多种方式处理,包括保存自定义字母并在玩家继续时加载它们,但同样,此示例代码尚未涵盖这一点。这就是该示例使用 On Day Start 并立即使信件可用的原因。
其他 (Other)
简单动画(animation) 的添加
location.temporarySprites.Add(new TemporaryAnimatedSprite(...));
详见TemporaryAnimatedSprite类。
播放一段声音
location.playSound("SOUND"); // "SOUND"为声音的名字
详见声音名字一览。
开源 (Open Source)
详见模组大全中源代码(source)一栏。