thumbnail
StardewValley下Mod制作思路

前言

在这里就不一一介绍SMAPI的安装使用以及简单的mod项目搭建了,现在直接开始针对于N网优秀的mod源码进行解析,会长期慢慢更新内容


一、全局事件

1.日志打印

在Mod入口类中 Entry 方法加入全局日志函数

public override void Entry(IModHelper helper)
{
    // 设置全局日志函数
    Utils.InitLog(this.Monitor);

    // 初始化I18n多语言文本
    I18n.Init(helper.Translation);
	
	...
}

方法中调用,打印信息至SMAPI控制台

string text = 'X';
// 打印内容
Utils.DebugLog($"Info {text}.", LogLevel.Info);

下面是日志工具类,一个打印内容的方法,一个初始化方法

internal class Utils
{
    /*********
    ** Properties
    *********/
    private static IMonitor MonitorRef;

    /*********
    ** Public methods
    *********/
    public static void InitLog(IMonitor monitor)
    {
        Utils.MonitorRef = monitor;
    }

    public static void DebugLog(string message, LogLevel level = LogLevel.Trace)
    {
#if WITH_LOGGING
        Debug.Assert(Utils.MonitorRef != null, "Monitor ref is not set.");
        Utils.MonitorRef.Log(message, level);
#else
        if (level > LogLevel.Debug)
        {
            Debug.Assert(MonitorRef != null, "Monitor ref is not set.");
            MonitorRef.Log(message, level);
        }
#endif
    }

    public static bool Ensure(bool condition, string message)
    {
#if DEBUG
        if (!condition)
        {
            DebugLog($"Failed Ensure: {message}");
        }
#endif
        return !!condition;
    }

}

2.I18n

多语言切换,下面是简单的默认 en英文 json数据与 zh简中 json数据

default.json

{
    "labels.single-price": "Single",
    "labels.stack-price": "Stack"
}

zh.json

{
    "labels.single-price": "单价",
    "labels.stack-price": "总计"
}

I18n工具类,可直接调用方法获取对应语言翻译后的文本内容

internal static class I18n
{
    /*********
    ** Fields
    *********/
    /// <summary>Mod翻译助手</summary>
    private static ITranslationHelper Translations;

    /*********
    ** Public methods
    *********/
    /// <summary>初始化</summary>
    /// <param name="translations">Mod翻译助手</param>
    public static void Init(ITranslationHelper translations)
    {
        I18n.Translations = translations;
    }

    /// <summary>获取单价对应翻译后的文本</summary>
    public static string Labels_SinglePrice()
    {
        return I18n.GetByKey("labels.single-price");
    }

    /// <summary>获取总价对应翻译后的文本</summary>
    public static string Labels_StackPrice()
    {
        return I18n.GetByKey("labels.stack-price");
    }

    /*********
    ** Private methods
    *********/
    /// <summary>通过KEY获取翻译后对应的文本</summary>
    /// <param name="key">JSON KEY</param>
    /// <param name="tokens">令牌,貌似没发现如何用</param>
    private static Translation GetByKey(string key, object tokens = null)
    {
    	// 保证在读取翻译文件前从mod中获取到设置的语言
        if (I18n.Translations == null)
            throw new InvalidOperationException($"You must call {nameof(I18n)}.{nameof(I18n.Init)} from the mod's entry method before reading translations.");
        return I18n.Translations.Get(key, tokens);
    }
}

二、绘制操作

下面举一些简单的实例,比如鼠标悬停物品上,新增一个文本提示框在鼠标左下方

1.物品单价提示

首先可设置强制出售物品种类数组,如果物品是不可出售的且种类不是强制可出售的那么就不会继续向下进行方法

{
    // 强制可销售物品种类数组
    "ForceSellable": [
        // 马龙公会:adventure guild
        -28,
        -98,
        -97,
        -96,

        // 克林特铁匠铺:blacksmith
        -12,
        -2,
        -15,

        // 玛尼动物店:Marnie's shop
        -18,
        -6,
        -5,
        -14,

        // 皮埃尔杂货店:pierre's shop
        -81,
        -75,
        -79,
        -80,
        -74,
        -17,
        -18,
        -6,
        -26,
        -5,
        -14,
        -19,
        -7,
        -25,

        // 罗宾木匠铺:Robin's shop
        -16,

        // 威利渔铺:Willy's shop
        -4,
        -23,
        -21,
        -22
    ]
}

1.在渲染菜单、渲染hud和游戏状态更新时触发3个对应的自定义事件 OnRenderedActiveMenuOnRenderedHudOnUpdateTicked

internal class DataModel
{
    /// <summary>存放商店可出售的物品种类的实体类</summary>
    public HashSet<int> ForceSellable { get; set; } = new HashSet<int>();
}
/// <summary>无法直接从游戏物品中获得的强制可出售的物品种类</summary>
private DataModel Data;

public override void Entry(IModHelper helper)
{
    ...

    // 加载强制可出售物品
    this.Data = helper.Data.ReadJsonFile<DataModel>("assets/data.json") ?? new DataModel();
    this.Data.ForceSellable ??= new HashSet<int>();

    // 事件触发
    helper.Events.Display.RenderedActiveMenu += this.OnRenderedActiveMenu;
    helper.Events.Display.RenderedHud += this.OnRenderedHud;
    helper.Events.GameLoop.UpdateTicked += this.OnUpdateTicked;
}

2.游戏状态更新时,为全局定义的工具栏和工具栏槽变量赋值

/// <summary>缓存的工具栏实例</summary>
private readonly PerScreen<Toolbar> Toolbar = new PerScreen<Toolbar>();

/// <summary>缓存的工具栏槽</summary>
private readonly PerScreen<IList<ClickableComponent>> ToolbarSlots = new PerScreen<IList<ClickableComponent>>();
        
/// <summary>游戏状态更新后触发 (≈60次/秒).</summary>
/// <param name="sender">当前事件对象</param>
/// <param name="e">事件参数</param>
private void OnUpdateTicked(object sender, UpdateTickedEventArgs e)
{
    // Utils.DebugLog("SellPrice UpdateTicked", LogLevel.Info);
    if (e.IsOneSecond)
    {
        if (Context.IsPlayerFree)
        {
            this.Toolbar.Value = Game1.onScreenMenus.OfType<Toolbar>().FirstOrDefault();
            this.ToolbarSlots.Value = this.Toolbar.Value != null
                ? this.Helper.Reflection.GetField<List<ClickableComponent>>(this.Toolbar.Value, "buttons").GetValue()
                : null;
        }
        else
        {
            this.Toolbar.Value = null;
            this.ToolbarSlots.Value = null;
        }
    }
}

3.渲染各个菜单时,从菜单中获取悬停位置物品的信息

/// <summary>当打开一个菜单时,在渲染屏幕前绘制时触发。确保 activeClickableMenu 不为空</summary>
/// <param name="sender">当前事件对象</param>
/// <param name="e">事件参数</param>
private void OnRenderedActiveMenu(object sender, RenderedActiveMenuEventArgs e)
{
    Utils.DebugLog("SellPrice RenderedActiveMenu", LogLevel.Info);
    Item item = this.GetItemFromMenu(Game1.activeClickableMenu);
    if (item == null)
        return;

    this.DrawPriceTooltip(Game1.spriteBatch, Game1.smallFont, item);
}

/// <summary>从菜单获取悬停物品的信息</summary>
/// <param name="menu">悬浮显示的菜单</param>
private Item GetItemFromMenu(IClickableMenu menu)
{
    // 游戏菜单获取
    if (menu is GameMenu gameMenu)
    {
        Utils.DebugLog("SellPrice GetItemFromMenu", LogLevel.Info);
        IClickableMenu page = this.Helper.Reflection.GetField<List<IClickableMenu>>(gameMenu, "pages").GetValue()[gameMenu.currentTab];
        if (page is InventoryPage)
            return this.Helper.Reflection.GetField<Item>(page, "hoveredItem").GetValue();
        else if (page is CraftingPage)
            return this.Helper.Reflection.GetField<Item>(page, "hoverItem").GetValue();
    }

    // 资源菜单获取
    else if (menu is MenuWithInventory inventoryMenu)
        return inventoryMenu.hoveredItem;

    return null;
}

4.渲染HUD时,从工具栏中获取鼠标悬停位置物品的信息

/// <summary>在渲染到屏幕前绘制 HUD(工具栏、时钟、天气) 时触发</summary>
/// <param name="sender">当前事件对象</param>
/// <param name="e">事件参数</param>
private void OnRenderedHud(object sender, EventArgs e)
{
    Utils.DebugLog("SellPrice RenderedHud", LogLevel.Info);
    if (!Context.IsPlayerFree)
        return;

    Item item = this.GetItemFromToolbar();
    if (item == null)
        return;

    this.DrawPriceTooltip(Game1.spriteBatch, Game1.smallFont, item);
}

/// <summary>从工具栏获取悬停物品的信息</summary>
private Item GetItemFromToolbar()
{
    /*
        确保以下条件全都满足再继续获取物品
        1.角色处于闲置状态IsPlayerFree
        2.工具栏Value不为空
        3.工具栏中槽位不为空
        4.Hud处于显示状态
     */
    if (!Context.IsPlayerFree || this.Toolbar?.Value == null || this.ToolbarSlots == null || !Game1.displayHUD)
        return null;

    // 查找悬停位置
    int x = Game1.getMouseX();
    int y = Game1.getMouseY();
    ClickableComponent hoveredSlot = this.ToolbarSlots.Value.FirstOrDefault(slot => slot.containsPoint(x, y));
    if (hoveredSlot == null)
        return null;

    // 获取资源索引
    int index = this.ToolbarSlots.Value.IndexOf(hoveredSlot);
    if (index < 0 || index > Game1.player.Items.Count - 1)
        return null;
    Utils.DebugLog("SellPrice GetItemFromToolbar", LogLevel.Info);
    // 获取悬停物品
    return Game1.player.Items[index];
}

5.上面的方法中最后都会调用绘制单价提示框的方法 DrawPriceTooltip ,该方法是根据传来的物品参数来获取对应的价格,计算价格或总价,拼接文本,计算绘制长度,绘制提示框

/*********
** Fields
*********/
/// <summary>硬币图标矩形</summary>
private readonly Rectangle CoinSourceRect = new Rectangle(5, 69, 6, 6);

/// <summary>工具提示框矩形</summary>
private readonly Rectangle TooltipSourceRect = new Rectangle(0, 256, 60, 60);

/// <summary>边框像素大小</summary>
private const int TooltipBorderSize = 15;

/// <summary>提示框内边距</summary>
private const int Padding = 8;

/// <summary>光标对于工具栏的偏移量</summary>
private readonly Vector2 TooltipOffset = new Vector2(Game1.tileSize / 2);

/// <summary>绘制商品单价与总价提示框</summary>
/// <param name="spriteBatch">绘图刷</param>
/// <param name="font">绘制文本的字体</param>
/// <param name="item">要显示信息的物品</param>
private void DrawPriceTooltip(SpriteBatch spriteBatch, SpriteFont font, Item item)
{
    int stack = item.Stack;
    bool showStack = stack > 1;
    int? price = this.GetSellPrice(item);
    if (price == null)
        return;

    // 获取全局设置的边框、内边距、硬币尺寸、行高
    const int borderSize = LaSellPriceEntry.TooltipBorderSize;
    const int padding = LaSellPriceEntry.Padding;
    int coinSize = this.CoinSourceRect.Width * Game1.pixelZoom;
    int lineHeight = (int)font.MeasureString("X").Y;
    Vector2 offsetFromCursor = this.TooltipOffset;

    // 文本拼接
    string unitLabel = I18n.Labels_SinglePrice() + ":";
    string unitPrice = price.ToString();
    string stackLabel = I18n.Labels_StackPrice() + ":";
    string stackPrice = (price * stack).ToString();

    // 计算单价尺寸,总价尺寸,文本尺寸
    Vector2 unitPriceSize = font.MeasureString(unitPrice);
    Vector2 stackPriceSize = font.MeasureString(stackPrice);
    Vector2 labelSize = font.MeasureString(unitLabel);
    // 有总价的话,取最长的
    if (showStack)
        labelSize = new Vector2(Math.Max(labelSize.X, font.MeasureString(stackLabel).X), labelSize.Y * 2);
    // 计算提示框内容尺寸以及最外层尺寸
    Vector2 innerSize = new Vector2(labelSize.X + padding + Math.Max(unitPriceSize.X, showStack ? stackPriceSize.X : 0) + padding + coinSize, labelSize.Y);
    Vector2 outerSize = innerSize + new Vector2((borderSize + padding) * 2);

    // 根据鼠标计算位置
    //float x = Game1.getMouseX() - offsetFromCursor.X - outerSize.X;
    float x = Game1.getMouseX() - outerSize.X;
    float y = Game1.getMouseY() + offsetFromCursor.Y + borderSize;

    // 调整位置以适应屏幕
    Rectangle area = new Rectangle((int)x, (int)y, (int)outerSize.X, (int)outerSize.Y);
    if (area.Right > Game1.uiViewport.Width)
        x = Game1.uiViewport.Width - area.Width;
    if (area.Bottom > Game1.uiViewport.Height)
        y = Game1.uiViewport.Height - area.Height;

    // 绘制提示框
    IClickableMenu.drawTextureBox(spriteBatch, Game1.menuTexture, this.TooltipSourceRect, (int)x, (int)y, (int)outerSize.X, (int)outerSize.Y, Color.White);

    // 绘制硬币与文本,如果showStack库存大于1则绘制总价行硬币与文本
    spriteBatch.Draw(Game1.debrisSpriteSheet, new Vector2(x + outerSize.X - borderSize - padding - coinSize, y + borderSize + padding), this.CoinSourceRect, Color.White, 0.0f, Vector2.Zero, Game1.pixelZoom, SpriteEffects.None, 1f);
    if (showStack)
        spriteBatch.Draw(Game1.debrisSpriteSheet, new Vector2(x + outerSize.X - borderSize - padding - coinSize, y + borderSize + padding + lineHeight), this.CoinSourceRect, Color.White, 0.0f, Vector2.Zero, Game1.pixelZoom, SpriteEffects.None, 1f);

    Utility.drawTextWithShadow(spriteBatch, unitLabel, font, new Vector2(x + borderSize + padding, y + borderSize + padding), Game1.textColor);
    Utility.drawTextWithShadow(spriteBatch, unitPrice, font, new Vector2(x + outerSize.X - borderSize - padding - coinSize - padding - unitPriceSize.X, y + borderSize + padding), Game1.textColor);
    if (showStack)
    {
        Utility.drawTextWithShadow(spriteBatch, stackLabel, font, new Vector2(x + borderSize + padding, y + borderSize + padding + lineHeight), Game1.textColor);
        Utility.drawTextWithShadow(spriteBatch, stackPrice, font, new Vector2(x + outerSize.X - borderSize - padding - coinSize - padding - stackPriceSize.X, y + borderSize + padding + lineHeight), Game1.textColor);
    }
}

/// <summary>从物品信息中获取售价</summary>
/// <param name="item">物品</param>
/// <returns>返回售价, 或者不能售出返回 <c>null</c> </returns>
private int? GetSellPrice(Item item)
{
    // 跳过不可出售物品
    if (!this.CanBeSold(item))
        return null;

    // 使用sv中Utility公用类方法获取出售价格
    // return ((i is Object) ? (i as Object).sellToStorePrice(-1L) : (i.salePrice() / 2)) * ((!countStack) ? 1 : i.Stack);
    int price = Utility.getSellToStorePriceOfItem(item, countStack: false);
    return price >= 0 ? price : null as int?;
}

/// <summary>判断是否可出售</summary>
/// <param name="item">物品</param>
private bool CanBeSold(Item item)
{
    // 物品类型是否正确并且可被出售 or 是否包含在强制出售数组中(根据物品分类判断)
    return
        (item is SObject obj && obj.canBeShipped())
        || this.Data.ForceSellable.Contains(item.Category);
}

总结

C#yyds啊

上一篇
下一篇