简易星露谷模组二次开发之旅:捐赠追踪、颜色优化与动物状态警告

一、引言
作为《星露谷物语》的资深玩家,我一直很享受在鹈鹕镇打理农场、与村民建立友谊的悠闲时光。而在众多模组中,Lookup Anything 无疑是我最离不开的工具之一——按下 F1 就能查看任何东西的详细信息,简直像拥有了游戏内的百科全书。

但玩得越久,我越觉得有些信息如果能更直观地呈现就好了:这件文物我捐过博物馆了吗?今天抚摸过我的动物了吗?村民是否会喜欢这个礼物??这些问题的答案虽然都能查到,但总要花点时间去辨认。

于是,一个念头冒了出来:为什么不自己动手改一改呢?

作为一个完全没接触过星露谷模组开发的纯新小白。面对陌生的 C# 项目、复杂的 Harmony 补丁、还有各种编译错误,这一路走得并不轻松。从配置开发环境到理解项目结构,从反复编译失败到最终成功添加功能,每一步都伴随着困惑和坚持。

好在,最终我还是实现了三个想要的功能:

  • 博物馆捐赠状态:一眼看出文物是否已捐;

  • 动物健康警告:未喂食、心情差的动物会显示红色提示;

  • 送礼颜色优化:村民的礼物喜好用不同颜色区分,最爱金色、其次绿色。

回头看,这段经历不仅让我得到了更顺手的工具,更让我深入了解了模组的工作原理,也体会到了开源社区的魅力。在这篇博客里,我想把从零开始的整个开发过程记录下来,包括遇到的问题、找到的解决方案,以及最终实现的代码。希望能给同样有兴趣的玩家一些启发和帮助。

如果你也想过“这个模组如果能加点功能就好了”,那就跟我一起开始这段二次开发之旅吧!^ - ^

二、在开始前你或许需要知道:

开发环境:

  1. 操作系统
    Windows 10/11

  2. 核心开发工具:

Visual Studio Code

扩展:

C#

.NET SDK 6.0(编译 C# 模组的必备运行时)

  1. 游戏与模组基础

Stardew Valley 1.6.14(当前最新版)

SMAPI 4.5.1(星露谷物语模组加载器,用于运行和调试模组)

  1. 源代码与依赖

Lookup Anything 源代码https://github.com/Pathoschild/StardewMods

NuGet 包:

Pathoschild.Stardew.ModBuildConfig(自动配置游戏引用路径)

Pathoschild.Stardew.ModTranslationClassBuilder(用于多语言支持)

  1. 辅助工具

GitHub(代码托管平台,获取开源模组源码)

Nexus Mods(模组发布平台,用于参考和下载原版模组)

  1. 调试与测试

SMAPI 控制台(实时查看模组加载日志和错误信息)

游戏内测试(按 F1 触发查询,验证新增功能)

  1. 更多教程与帮助
    【星露谷模组开发教程#1 配置开发环境】 https://www.bilibili.com/video/BV1hr421K79d/?share_source=copy_web&vd_source=df43935c5ba91d8069b7138b97fac4a1

三、功能重构思路
在动手修改之前,我花了不少时间理解 Lookup Anything 的代码结构。这个模组的设计非常清晰:每个可查询的实体(物品、动物、村民等)都有一个对应的 Subject 类,负责收集并返回显示信息;而信息的渲染则由各种 Field 类完成。我的三个功能正好分别涉及这两种类型的修改。

image

四、功能实现

  • 博物馆捐赠追踪:在物品查询中添加字段

  • 作用:在查看物品信息时,直接显示该物品是否已捐献给博物馆。

  • 定位:物品的显示信息在 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);
            }
        }

我们可以看到在模组修改之前,矿石没有显示是否被捐赠这一栏:

屏幕截图 2026-03-10 001638

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

屏幕截图 2026-03-10 002401

这样的效果是:无论捐赠与否,文字都是默认的黑色,在密密麻麻的信息中不太醒目。为了让我们能一眼就能看出哪些物品已经捐过、哪些还需要捐,我决定给状态文本加上颜色——已捐赠用绿色,未捐赠用红色。

而做这部分的修改需要我们理解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 中已经引用了这个命名空间(因为其他地方也用到了颜色),所以不需要额外添加。

再次运行,我们可以看到:

屏幕截图 2026-03-10 003815
捐赠过的文物可以显示绿色提示啦!(苯博主现在暂时没有持有但未捐赠的文物所以这里不予展示(。ì _ í。))
(下面是完整的修改后的源代码,可以直接复制粘贴)

点击查看代码
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 });
       }

运行后效果如下:

屏幕截图 2026-03-10 011016

  • 村民送礼颜色优化:让礼物列表更直观

  • 作用:在村民的送礼偏好列表中,不同喜好的物品用不同颜色显示(最爱金色、喜欢绿色、讨厌红色等)。

  • 定位:村民的送礼列表由 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);
}

效果如下:

屏幕截图 2026-03-10 012403

我们可以点开显示更多:

屏幕截图 2026-03-10 013851

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

屏幕截图 2026-03-10 111814

屏幕截图 2026-03-10 111857

不过我没想到物品内容会有这么多......(−_−;)这部分的显示我计划再精简一下,今天就不多赘述了。

(以下是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 的肩膀上,阅读他精心编写的代码,本身就是一种学习。感谢所有为社区贡献的开源作者!

最后

这次经历让我对模组开发有了更深的理解,也激发了我继续探索的热情。接下来,我打算尝试优化更多功能(最好把我很喜欢但作者不再更新的模组优化到适配当前游戏版本),甚至构思一个自己的独立模组。

感谢你读到这里,希望我的分享能给你带来一些启发。如果你也在进行类似的尝试,遇到了问题,或者只是想交流心得,欢迎留言讨论!(ˆ꜆ . ω . ). ω . ꜀ˆ)

posted @ 2026-03-10 14:56  Hzkam  阅读(168)  评论(0)    收藏  举报