简易星露谷模组二次开发之旅:捐赠追踪、颜色优化与动物状态警告
一、引言
作为《星露谷物语》的资深玩家,我一直很享受在鹈鹕镇打理农场、与村民建立友谊的悠闲时光。而在众多模组中,Lookup Anything 无疑是我最离不开的工具之一——按下 F1 就能查看任何东西的详细信息,简直像拥有了游戏内的百科全书。
但玩得越久,我越觉得有些信息如果能更直观地呈现就好了:这件文物我捐过博物馆了吗?今天抚摸过我的动物了吗?村民是否会喜欢这个礼物??这些问题的答案虽然都能查到,但总要花点时间去辨认。
于是,一个念头冒了出来:为什么不自己动手改一改呢?
作为一个完全没接触过星露谷模组开发的纯新小白。面对陌生的 C# 项目、复杂的 Harmony 补丁、还有各种编译错误,这一路走得并不轻松。从配置开发环境到理解项目结构,从反复编译失败到最终成功添加功能,每一步都伴随着困惑和坚持。
好在,最终我还是实现了三个想要的功能:
-
博物馆捐赠状态:一眼看出文物是否已捐;
-
动物健康警告:未喂食、心情差的动物会显示红色提示;
-
送礼颜色优化:村民的礼物喜好用不同颜色区分,最爱金色、其次绿色。
回头看,这段经历不仅让我得到了更顺手的工具,更让我深入了解了模组的工作原理,也体会到了开源社区的魅力。在这篇博客里,我想把从零开始的整个开发过程记录下来,包括遇到的问题、找到的解决方案,以及最终实现的代码。希望能给同样有兴趣的玩家一些启发和帮助。
如果你也想过“这个模组如果能加点功能就好了”,那就跟我一起开始这段二次开发之旅吧!^ - ^
二、在开始前你或许需要知道:
开发环境:
-
操作系统
Windows 10/11 -
核心开发工具:
Visual Studio Code
扩展:
C#
.NET SDK 6.0(编译 C# 模组的必备运行时)
- 游戏与模组基础
Stardew Valley 1.6.14(当前最新版)
SMAPI 4.5.1(星露谷物语模组加载器,用于运行和调试模组)
- 源代码与依赖
Lookup Anything 源代码https://github.com/Pathoschild/StardewMods
NuGet 包:
Pathoschild.Stardew.ModBuildConfig(自动配置游戏引用路径)
Pathoschild.Stardew.ModTranslationClassBuilder(用于多语言支持)
- 辅助工具
GitHub(代码托管平台,获取开源模组源码)
Nexus Mods(模组发布平台,用于参考和下载原版模组)
- 调试与测试
SMAPI 控制台(实时查看模组加载日志和错误信息)
游戏内测试(按 F1 触发查询,验证新增功能)
三、功能重构思路
在动手修改之前,我花了不少时间理解 Lookup Anything 的代码结构。这个模组的设计非常清晰:每个可查询的实体(物品、动物、村民等)都有一个对应的 Subject 类,负责收集并返回显示信息;而信息的渲染则由各种 Field 类完成。我的三个功能正好分别涉及这两种类型的修改。

四、功能实现
-
博物馆捐赠追踪:在物品查询中添加字段
-
作用:在查看物品信息时,直接显示该物品是否已捐献给博物馆。
-
定位:物品的显示信息在 ItemSubject.cs 的 GetData() 方法中生成。这个方法返回一组 ICustomField,每个字段就是查询窗口中的一行。
-
实现思路:首先判断物品是否可捐赠。游戏提供了 obj.needsToBeDonated() 方法,可以覆盖文物、矿物和宝石。然后从玩家的存档数据中读取捐赠状态:
文物存储在 Game1.player.archaeologyFound 字典中,键是物品 ID,值是一个数组,其中第一个元素为 1 表示已捐赠。
矿物和宝石存储在 Game1.player.mineralsFound 字典中,键是物品 ID,值通常为 1(但用 ContainsKey 判断更稳妥)。
将结果格式化为“✓ 已捐赠”或“✗ 未捐赠”,并用 yield return 添加到字段列表中。
if (this.Target is SObject museumObj)
{
bool isDonatable = museumObj.Type == "Artifact" || museumObj.Category == SObject.GemCategory || museumObj.Category == SObject.mineralsCategory;
if (isDonatable)
{
bool donated = false;
if (museumObj.Type == "Artifact")
donated = Game1.player.archaeologyFound.TryGetValue(museumObj.ItemId, out int[]? data) && data != null && data[0] == 1;
else // 矿物或宝石
donated = Game1.player.mineralsFound.ContainsKey(museumObj.ItemId);
string status = donated ? "✓ 已捐赠" : "✗ 未捐赠";
yield return new GenericField("博物馆捐赠", status);
}
}
我们可以看到在模组修改之前,矿石没有显示是否被捐赠这一栏:

而在修改之后则会多显示一栏:

这样的效果是:无论捐赠与否,文字都是默认的黑色,在密密麻麻的信息中不太醒目。为了让我们能一眼就能看出哪些物品已经捐过、哪些还需要捐,我决定给状态文本加上颜色——已捐赠用绿色,未捐赠用红色。
而做这部分的修改需要我们理解Lookup Anything的富文本支持。
查阅模组代码发现,GenericField 的构造函数其实有两个重载:一个接受 string,另一个接受 IFormattedText[]。后者可以让我们传递带格式的文本片段,包括颜色、加粗等。
IFormattedText 是一个接口,而项目中有一个现成的实现类 FormattedText,它的构造函数可以接受文本和颜色(Microsoft.Xna.Framework.Color)。
点击查看代码
public GenericField(string label, string? value, bool? hasValue = null)
{
this.Label = label;
this.Value = this.FormatValue(value);
this.HasValue = hasValue ?? this.Value?.Any() == true;
}
/// <summary>Construct an instance.</summary>
/// <param name="label">A short field label.</param>
/// <param name="value">The field value.</param>
/// <param name="hasValue">Whether the field should be displayed (or <c>null</c> to check the <paramref name="value"/>).</param>
public GenericField(string label, IFormattedText value, bool? hasValue = null)
: this(label, [value], hasValue) { }
/// <summary>Construct an instance.</summary>
/// <param name="label">A short field label.</param>
/// <param name="value">The field value.</param>
/// <param name="hasValue">Whether the field should be displayed (or <c>null</c> to check the <paramref name="value"/>).</param>
public GenericField(string label, IEnumerable<IFormattedText> value, bool? hasValue = null)
{
this.Label = label;
this.Value = value.ToArray();
this.HasValue = hasValue ?? this.Value?.Any() == true;
}
于是我将原来的代码进行了一点调整:
string text = donated ? "✓ 已捐赠" : "✗ 未捐赠";
Color color = donated ? Color.Green : Color.Red;
var formattedText = new FormattedText(text, color);
yield return new GenericField("博物馆捐赠", new IFormattedText[] { formattedText });
因为用到了 Microsoft.Xna.Framework.Color,需要在文件顶部添加:
using Microsoft.Xna.Framework;
好在 ItemSubject.cs 中已经引用了这个命名空间(因为其他地方也用到了颜色),所以不需要额外添加。
再次运行,我们可以看到:

捐赠过的文物可以显示绿色提示啦!(苯博主现在暂时没有持有但未捐赠的文物所以这里不予展示(。ì _ í。))
(下面是完整的修改后的源代码,可以直接复制粘贴)
点击查看代码
if (this.Target is SObject museumObj)
{
bool isDonatable = museumObj.Type == "Artifact" || museumObj.Category == SObject.GemCategory || museumObj.Category == SObject.mineralsCategory;
if (isDonatable)
{
bool donated = false;
if (museumObj.Type == "Artifact")
donated = Game1.player.archaeologyFound.TryGetValue(museumObj.ItemId, out int[]? data) && data != null && data[0] == 1;
else // 矿物或宝石
donated = Game1.player.mineralsFound.ContainsKey(museumObj.ItemId);
string text = donated ? "✓ 已捐赠" : "✗ 未捐赠";
Color color = donated ? Color.Green : Color.Red;
var formattedText = new FormattedText(text, color);
yield return new GenericField("博物馆捐赠", new IFormattedText[] { formattedText });
}
}
同理,有了第一个功能的运行经验,我们可以做其他小小的调整和改动:
-
动物健康警告:添加红色高亮提示
-
作用:查询动物时,如果它今天没被喂食、心情差或没被抚摸,显示红色警告。
-
定位:动物的 Subject 在 FarmAnimalSubject.cs 中,同样在 GetData() 方法里添加新字段。
-
实现思路:检查 animal.fullness.Value(饱食度,0-255,低于200视为未喂足)、animal.happiness.Value(心情,0-255,低于150视为不佳)和 animal.wasPet.Value(是否被抚摸)。
使用 List收集所有警告原因,避免依赖 LINQ。
如果列表非空,用 string.Join 合并为一句,然后用 FormattedText 包装成红色文本,最后作为 GenericField 输出。
var warnings = new List<string>();
if (animal.fullness.Value < 200)
warnings.Add("未喂食");
if (animal.happiness.Value < 150)
warnings.Add("心情差");
if (!animal.wasPet.Value)
warnings.Add("未抚摸");
if (warnings.Count > 0)
{
string warningText = string.Join(",", warnings);
var formattedWarning = new FormattedText(warningText, Color.Red);
yield return new GenericField("⚠ 健康警告", new IFormattedText[] { formattedWarning });
}
运行后效果如下:

-
村民送礼颜色优化:让礼物列表更直观
-
作用:在村民的送礼偏好列表中,不同喜好的物品用不同颜色显示(最爱金色、喜欢绿色、讨厌红色等)。
-
定位:村民的送礼列表由 CharacterGiftTastesField.cs 渲染。这个类继承自 GenericField,负责生成一行带有礼物名称的文本。
-
实现思路:首先在类中添加一个私有字段 private readonly GiftTaste ShowTaste;,用来保存当前字段代表的是哪种口味(Love/Like/Neutral/Dislike/Hate)。
在构造函数中,将传入的 showTaste 参数赋值给该字段。
添加一个颜色映射方法 GetColorForTaste,根据口味返回对应的 Color 值。
修改 GetText 方法:原来它根据物品是否在背包中分别赋予不同颜色,现在改为统一使用 baseColor(由口味决定),同时保留“未揭示”和“未拥有”的灰色汇总文本。
在CharacterGiftTastesField.cs 文件中,类的顶部添加:
private readonly GiftTaste ShowTaste;
然后在构造函数中赋值:
public CharacterGiftTastesField(string label, IDictionary<GiftTaste, GiftTasteModel[]> giftTastes, GiftTaste showTaste, bool showUnknown, bool highlightUnrevealed, bool onlyOwned, IDictionary<string, bool> ownedItemsCache)
: base(label)
{
this.ShowTaste = showTaste; // 新增的赋值
ItemRecord[] allItems = this.GetGiftTasteRecords(giftTastes, showTaste, ownedItemsCache);
this.TotalItems = allItems.Length;
this.Value = this.GetText(allItems, showUnknown, highlightUnrevealed, onlyOwned).ToArray();
this.HasValue = this.Value.Length > 0;
}
最后再修改GetText方法:
private IEnumerable<IFormattedText> GetText(ItemRecord[] items, bool showUnknown, bool highlightUnrevealed, bool onlyOwned)
{
if (!items.Any())
yield break;
int unrevealed = 0;
int unowned = 0;
Color baseColor = this.GetColorForTaste(this.ShowTaste);
for (int i = 0, last = items.Length - 1; i <= last; i++)
{
var entry = items[i];
if (!showUnknown && !entry.IsRevealed)
{
unrevealed++;
continue;
}
if (onlyOwned && !entry.IsOwned)
{
unowned++;
continue;
}
string text = i != last
? entry.Item.DisplayName + I18n.Generic_ListSeparator()
: entry.Item.DisplayName;
bool bold = highlightUnrevealed && !entry.IsRevealed;
yield return new FormattedText(text, baseColor, bold);
}
if (unrevealed > 0)
yield return new FormattedText(I18n.Npc_UndiscoveredGiftTaste(count: unrevealed), Color.Gray);
if (unowned > 0)
yield return new FormattedText(I18n.Npc_UnownedGiftTaste(count: unowned), Color.Gray);
}
效果如下:

我们可以点开显示更多:

可能遇到的问题:
最初修改后,发现讨厌和厌恶的列表根本不出现,后来意识到需要在配置文件中启用它们。打开 Mods/LookupAnything/config.json,将 ShowGiftTastes.Disliked 和 Hated 设为 true 即可。
颜色效果在游戏中非常明显,查询潘姆等村民时,一眼就能看出该送什么、不该送什么。


不过我没想到物品内容会有这么多......(−_−;)这部分的显示我计划再精简一下,今天就不多赘述了。
(以下是CharacterGiftTastesField.cs修改后的完整源代码)
点击查看代码
using System.Collections.Generic;
using System.Linq;
using Microsoft.Xna.Framework;
using Pathoschild.Stardew.LookupAnything.Framework.Constants;
using Pathoschild.Stardew.LookupAnything.Framework.Models;
using StardewValley;
namespace Pathoschild.Stardew.LookupAnything.Framework.Fields;
/// <summary>A metadata field which shows which items an NPC likes receiving.</summary>
internal class CharacterGiftTastesField : GenericField
{
private readonly GiftTaste ShowTaste;
/*********
** Accessors
*********/
/// <summary>The total number of items shown (including the sum of grouped entries like "11 unrevealed tastes").</summary>
public int TotalItems { get; }
/*********
** Public methods
*********/
/// <summary>Construct an instance.</summary>
/// <param name="label">A short field label.</param>
/// <param name="giftTastes">The items by how much this NPC likes receiving them.</param>
/// <param name="showTaste">The gift taste to show.</param>
/// <param name="showUnknown">Whether to show gift tastes the player hasn't discovered yet.</param>
/// <param name="highlightUnrevealed">Whether to highlight items which haven't been revealed in the NPC profile yet.</param>
/// <param name="onlyOwned">Whether to only show gift tastes for items which the player owns somewhere in the world.</param>
/// <param name="ownedItemsCache">A lookup cache for owned items, as created by <see cref="GetOwnedItemsCache"/>.</param>
public CharacterGiftTastesField(string label, IDictionary<GiftTaste, GiftTasteModel[]> giftTastes, GiftTaste showTaste, bool showUnknown, bool highlightUnrevealed, bool onlyOwned, IDictionary<string, bool> ownedItemsCache)
: base(label)
{
this.ShowTaste = showTaste; // 新增的赋值
ItemRecord[] allItems = this.GetGiftTasteRecords(giftTastes, showTaste, ownedItemsCache);
this.TotalItems = allItems.Length;
this.Value = this.GetText(allItems, showUnknown, highlightUnrevealed, onlyOwned).ToArray();
this.HasValue = this.Value.Length > 0;
}
/// <summary>Get a lookup cache for owned items indexed by <see cref="Item.QualifiedItemId"/>.</summary>
/// <param name="gameHelper">Provides utility methods for interacting with the game code.</param>
public static IDictionary<string, bool> GetOwnedItemsCache(GameHelper gameHelper)
{
return gameHelper
.GetAllOwnedItems()
.GroupBy(entry => entry.Item.QualifiedItemId)
.ToDictionary(group => group.Key, group => group.Any(p => p.IsInInventory));
}
/*********
** Private methods
*********/
/// <summary>Get the items that can be listed for the current gift taste, ignoring filter options.</summary>
/// <param name="giftTastes">The items by how much this NPC likes receiving them.</param>
/// <param name="showTaste">The gift taste to show.</param>
/// <param name="ownedItemsCache">A lookup cache for owned items, as created by <see cref="GetOwnedItemsCache"/>.</param>
private ItemRecord[] GetGiftTasteRecords(IDictionary<GiftTaste, GiftTasteModel[]> giftTastes, GiftTaste showTaste, IDictionary<string, bool> ownedItemsCache)
{
if (!giftTastes.TryGetValue(showTaste, out GiftTasteModel[]? entries))
return [];
// get data
return
(
from entry in entries
let item = entry.Item
let ownership = ownedItemsCache.TryGetValue(item.QualifiedItemId, out bool rawVal) ? rawVal : null as bool? // true = in inventory, false = owned elsewhere, null = none found
let isOwned = ownership is not null
let inInventory = ownership is true
orderby inInventory descending, isOwned descending, item.DisplayName
select new ItemRecord(item, inInventory, isOwned, entry.IsRevealed)
)
.ToArray();
}
/// <summary>Get the text to display.</summary>
/// <param name="items">The items that can be listed for the current gift taste, ignoring filter options.</param>
/// <param name="showUnknown">Whether to show gift tastes the player hasn't discovered yet.</param>
/// <param name="highlightUnrevealed">Whether to highlight items which haven't been revealed in the NPC profile yet.</param>
/// <param name="onlyOwned">Whether to only show gift tastes for items which the player owns somewhere in the world.</param>
private Color GetColorForTaste(GiftTaste taste)
{
return taste switch
{
GiftTaste.Love => Color.Gold,
GiftTaste.Like => Color.Green,
GiftTaste.Neutral => Color.Gray,
GiftTaste.Dislike => Color.Orange,
GiftTaste.Hate => Color.Red,
_ => Color.Black
};
}
private IEnumerable<IFormattedText> GetText(ItemRecord[] items, bool showUnknown, bool highlightUnrevealed, bool onlyOwned)
{
if (!items.Any())
yield break;
int unrevealed = 0;
int unowned = 0;
Color baseColor = this.GetColorForTaste(this.ShowTaste);
for (int i = 0, last = items.Length - 1; i <= last; i++)
{
var entry = items[i];
if (!showUnknown && !entry.IsRevealed)
{
unrevealed++;
continue;
}
if (onlyOwned && !entry.IsOwned)
{
unowned++;
continue;
}
string text = i != last
? entry.Item.DisplayName + I18n.Generic_ListSeparator()
: entry.Item.DisplayName;
bool bold = highlightUnrevealed && !entry.IsRevealed;
yield return new FormattedText(text, baseColor, bold);
}
if (unrevealed > 0)
yield return new FormattedText(I18n.Npc_UndiscoveredGiftTaste(count: unrevealed), Color.Gray);
if (unowned > 0)
yield return new FormattedText(I18n.Npc_UnownedGiftTaste(count: unowned), Color.Gray);
}
/// <summary>An item that can be shown in the list.</summary>
/// <param name="Item">The item instance.</param>
/// <param name="IsInventory">Whether this item is in the player's inventory.</param>
/// <param name="IsOwned">Whether the player owns at least one of this item somewhere in the world.</param>
/// <param name="IsRevealed">Whether the player has discovered this gift taste in-game.</param>
private record ItemRecord(Item Item, bool IsInventory, bool IsOwned, bool IsRevealed);
}
总结与收获
从最初只是想“给星露谷加点小功能”,到最终成功修改了 Lookup Anything 模组,这段二次开发之旅远比我想象的曲折,但也远比我想象的收获丰富。
开始我满怀信心地下载了源文件,却发现满屏的文件和代码我无从下手,学习了几个教程之后我试着上手建立自己的第一个框架,很高兴它运行成功了。接着我开始研究本次项目的整个框架和数据,说实话找寻数据的过程同样十分艰难,因为我并不是非常熟悉星露谷的数据存放。
我经历了反编译、配置环境、修复无数编译错误的“至暗时刻”,按下“dotnet build”的那一刻你永远不知道会等来几个报错还是“成功”。好在努力没有白费,最终让 Lookup Anything 成功运行,那一刻的成就感难以言喻。三个功能——博物馆捐赠追踪、动物健康警告、送礼颜色优化——逐个在游戏中呈现出来。虽然只是添加了几行信息,但每次在游戏中看到自己亲手加入的绿色“已捐赠”或红色警告,那种满足感是单纯玩游戏无法比拟的。
面对一个又一个编译错误,从最初的烦躁到后来冷静分析,我学会了如何阅读错误信息、如何搜索解决方案。而Lookup Anything 清晰的代码设计让我明白,一个好的项目结构能大大降低二次开发的门槛。每个实体对应一个 Subject,每种信息由 Field 渲染,这种设计值得学习。能站在作者 Pathoschild 的肩膀上,阅读他精心编写的代码,本身就是一种学习。感谢所有为社区贡献的开源作者!
最后
这次经历让我对模组开发有了更深的理解,也激发了我继续探索的热情。接下来,我打算尝试优化更多功能(最好把我很喜欢但作者不再更新的模组优化到适配当前游戏版本),甚至构思一个自己的独立模组。
感谢你读到这里,希望我的分享能给你带来一些启发。如果你也在进行类似的尝试,遇到了问题,或者只是想交流心得,欢迎留言讨论!(ˆ꜆ . ω . ). ω . ꜀ˆ)
浙公网安备 33010602011771号