泰拉瑞亞EasyBuildMod便捷建造模組開發詳細過程

2023-03-25 21:00:33

github地址:
https://github.com/lxmghct/Terraria-EasyBuildMod
如果覺得有幫助,記得在github上點個star哦~
創意工坊搜尋EasyBuildMod即可找到模組

目錄

1.簡介

EasyBuildMod是一個便捷建造模組,它包含了三個物品:物塊放置助手可以快速將物塊或牆壁放置在一個矩形區域、物塊摧毀助手可以快速摧毀矩形區域內的物塊或牆壁、物品拾取磁鐵則可用於快速拾取摧毀後掉落的物品。
製作這個模組主要是因為自己在遊戲中想挖空或建造地形進行建造戰鬥場地時,直接手動挖空或放置非常耗時,而自己嘗試過某些模組但都不太能滿足自己需要,比如Fargo的「城市剋星」,摧毀範圍大但是牆壁無法破壞,還有「更好的體驗」模組,放置和摧毀物塊的效率並不算太高。暫時也沒找到其他模組(如果有歡迎提出)。所以就自己動手寫了一個。

2.模組物品製作

本模組包含三個物品,物品拾取磁鐵、物塊放置助手和物塊摧毀助手。定義了一個全域性的EasyBuildModPlayer類繼承自ModPlayer,來控制使用某些物品或擁有某些效果時玩家的行為改變。

2.1物品拾取磁鐵

ItemGrabMagnet,該物品借鑑了懶人模組中的戰利品磁鐵和ItemMagnetPlus模組。希望達到的效果是使用後玩家擁有「物品拾取」的Buff,可以擴大拾取範圍,再次使用則可以關閉。
首先是ItemGrabMagnet的程式碼。這部分的程式碼比較簡單,就是在玩家使用物品時判斷是否已經擁有Buff,如果沒有則新增Buff,如果有則移除Buff。
有幾個細節的地方:由於想要的是使用磁鐵後buff時間無限長,在Buff中設定剩餘時間不可見,在給Buff時設定足夠長的時間即可。
還有一個要注意的地方就是CombatText.NewText會預設對所有玩家觸發,也就是玩家A在使用時,玩家B也能看到CombatText.NewText的訊息,所以CombatText.NewText的第一個引數不能用Main.LocalPlayer,否則其他人使用時也會顯示在自己這裡。

    public class ItemGrabBuff : ModBuff
    {
        public override void SetStaticDefaults()
        {
            Main.buffNoTimeDisplay[Type] = true;
            Main.debuff[Type] = false;
        }
        public override void Update(Player player, ref int buffIndex)
        {
            player.GetModPlayer<EasyBuildModPlayer>().ItemGrabBuff = true;
        }
    }
    public class ItemGrabMagnet : ModItem
    {
        internal static string GetText(string str, params object[] args)
        {
            return Language.GetTextValue($"Mods.EasyBuildMod.Content.Items.ItemGrabMagnet.{str}", args);
        }

        public override string Texture => "EasyBuildMod/Content/Items/ItemGrabMagnet";

        public bool IsMagnetOn;

        public override void SetStaticDefaults()
        {
            CreativeItemSacrificesCatalog.Instance.SacrificeCountNeededByItemId[Type] = 1;
        }

        public override void SetDefaults()
        {
            Item.width = 30;
            Item.height = 30;
            Item.maxStack = 1;
            Item.value = Item.sellPrice(gold: 1);
            Item.rare = ItemRarityID.Blue;
            Item.useAnimation = 15;
            Item.useTime = 20;
            Item.useStyle = ItemUseStyleID.HoldUp;
            Item.consumable = false;
            IsMagnetOn = false;
        }

        public override void AddRecipes()
        {
            // 20個鐵錠/鉛錠
            CreateRecipe()
                .AddRecipeGroup("IronBar", 20)
                .AddTile(TileID.Anvils)
                .Register();
        }

        public override bool? UseItem(Player player)
        {
            IsMagnetOn = !player.HasBuff(ModContent.BuffType<Buffs.ItemGrabBuff>());
            if (IsMagnetOn)
            {
                CombatText.NewText(player.Hitbox, Color.Green, GetText("OnTooltip"));
                player.AddBuff(ModContent.BuffType<Buffs.ItemGrabBuff>(), 2592000);
                SoundEngine.PlaySound(SoundID.MenuTick);
            }
            else
            {
                CombatText.NewText(player.Hitbox, Color.Red, GetText("OffTooltip"));
                player.ClearBuff(ModContent.BuffType<Buffs.ItemGrabBuff>());
                SoundEngine.PlaySound(SoundID.MenuClose);
            }
            return true;
        }

        public override void ModifyTooltips(List<TooltipLine> tooltips)
        {
            string color = IsMagnetOn ? "00FF00" : "FF0000";
            string tooltop = IsMagnetOn ? GetText("OnTooltip") : GetText("OffTooltip");
            var line = new TooltipLine(Mod, GetText("StatusName"), $"[c/{color}:{tooltop}]");
            tooltips.Add(line);
        }

    }

然後是物品拾取範圍擴大的實現。新定義EasyBuildModGlobalItem繼承自GlobalItem,重寫其中的GrabRange方法。注意格子數與遊戲實際距離的換算是乘除16。

    public class EasyBuildModGlobalItem : GlobalItem
    {
        public override void GrabRange(Item item, Player player, ref int grabRange)
        {
            if (player.GetModPlayer<EasyBuildModPlayer>().ItemGrabBuff)
            {
                grabRange = ModContent.GetInstance<EasyBuildModConfig>().MagnetRange * 16;
            }
        }
    }

至此便基本實現了ItemGrabMagnet的功能。效果如下:

2.2物塊放置與摧毀助手基礎類別

ItemPlaceHelper與ItemDestroyHelper二者都有共同的特點,一個是可以通過右鍵調出選單進行選擇,一個是左鍵可以框選區域進行放置或破壞。自己最開始是先寫了ItemPlaceHelper,而後寫另一個的時候才意識到有大量重複的邏輯。為了避免過多重複程式碼,這裡就二者的共同特點提取出一個抽象基礎類別。
這裡分成了三部分,物品自身、選單和區域選擇。

2.2.1Item實現

主要就是定義了物品的基本行為,如右鍵調出選單,左鍵進行框選。調出選單在重寫CanUseItem中進行實現,選擇區域則是在UseItem,這樣處理更為方便。這種情況下,ItemPlaceHelperItemDestroyHelper只需重寫StartAction方法即可。
這部分核心程式碼如下:

    public abstract class AreaSelectItem : ModItem
    {
        // 選擇區域的起點和終點
        protected Point _beginPoint;
        protected Point _endPoint;

        // 是否開始選擇區域
        protected bool _startSelecting;

        // 選單UI的靜態範例
        protected MenuUI _menuUI;

        public override bool AltFunctionUse(Player player) => true;

        protected virtual bool useItemCondition(Player player) => true;

        public override bool CanUseItem(Player player)
        {
            UISystem.CurrentMenuUI = _menuUI;
            if (player.noBuilding)
            {
                return false;
            }
            if (player.altFunctionUse == 2)
            {
                if (_menuUI.Visible)
                {
                    SoundEngine.PlaySound(SoundID.MenuClose);
                    _menuUI.Close();
                }
                else
                {
                    SoundEngine.PlaySound(SoundID.MenuTick);
                    _menuUI.Open(this);
                }
                return false;
            }
            if (!useItemCondition(player))
            {
                return false;
            }
            if (!_startSelecting)
            {
                _beginPoint = Main.MouseWorld.ToTileCoordinates();
                _startSelecting = true;
            }
            return true;
        }
        
        public override bool? UseItem(Player player)
        {
            _endPoint = Main.MouseWorld.ToTileCoordinates();
            if (!Main.mouseLeft)
            {
                HandleMouseUp();
                return true;
            }
            if (Main.mouseRight && _startSelecting)
            {
                StopUse();
            }
            else
            {
                DrawingSystem.StartDraw(GetRectangle(_beginPoint, _endPoint));
            }
            return base.UseItem(player);
        }

        public virtual void StopUse()
        {
            DrawingSystem.StopDraw();
            _startSelecting = false;
        }

        public void HandleMouseUp()
        {
            if (_startSelecting)
            {
                StartAction(Main.LocalPlayer);
                SoundEngine.PlaySound(SoundID.Dig);
                StopUse();
            }
        }

        protected virtual void StartAction(Player player)
        {
        }
                
    }

2.2.2選單實現

由於任意一個ItemPlaceHelper所調出的選單都相同,所以就沒必要給每一個ItemPlaceHelper配一個選單,所有ItemPlaceHelper共用一個選單即可。選單繼承自Terraria.UI.UIState,選單的正確顯示也花了不少時間,經過檢視原始碼以及參考了"更好的體驗"模組最後終於將UI顯示出來。
顯示選單的基本層次結構是ModSystem -> UserInterface -> UIState -> UIElement。具體就是UI中由若干像按鈕之類的UIElement構成,UI的顯示需要通過使用者介面去更新UI狀態,UIStateUserInterface的靜態變數都儲存在一個ModSystem中,讓其可以在模組載入時就被載入好,並通過重寫ModSystem的UpdateUIModifyInterfaceLayers去實現UI的更新與繪製。
UISystem的核心程式碼如下:

    public class UISystem : ModSystem
    {

        public static ItemPlaceHelperUI ItemPlaceHelperUI { get; set; }
        private static UserInterface _itemPlaceHelperInterface;

        public static ItemDestroyHelperUI ItemDestroyHelperUI { get; set; }
        private static UserInterface _itemDestroyHelperInterface;

        public override void Load()
        {
            ItemPlaceHelperUI = new ItemPlaceHelperUI();
            _itemPlaceHelperInterface = new UserInterface();
            _itemPlaceHelperInterface.SetState(ItemPlaceHelperUI);
            ItemDestroyHelperUI = new ItemDestroyHelperUI();
            _itemDestroyHelperInterface = new UserInterface();
            _itemDestroyHelperInterface.SetState(ItemDestroyHelperUI);
        }

        public override void Unload()
        {
            ItemPlaceHelperUI = null;
            _itemPlaceHelperInterface = null;
            ItemDestroyHelperUI = null;
            _itemDestroyHelperInterface = null;
        }

        public override void UpdateUI(GameTime gameTime)
        {
            if (ItemPlaceHelperUI.Visible)
            {
                _itemPlaceHelperInterface.Update(gameTime);
            }
            if (ItemDestroyHelperUI.Visible)
            {
                _itemDestroyHelperInterface.Update(gameTime);
            }
        }

        public override void ModifyInterfaceLayers(List<GameInterfaceLayer> layers)
        {
            int mouseTextIndex = layers.FindIndex(layer => layer.Name.Equals("Vanilla: Mouse Text")); // 表示在滑鼠文字之上
            if (mouseTextIndex != -1)
            {
                layers.Insert(mouseTextIndex, new LegacyGameInterfaceLayer(
                    "EasyBuildMod: MyMenuUI",
                    delegate
                    {
                        if (ItemPlaceHelperUI.Visible)
                        {
                            _itemPlaceHelperInterface.Draw(Main.spriteBatch, new GameTime());
                        }
                        if (ItemDestroyHelperUI.Visible)
                        {
                            _itemDestroyHelperInterface.Draw(Main.spriteBatch, new GameTime());
                        }
                        return true;
                    },
                    InterfaceScaleType.UI)
                );
            }
        }

    }

可以看到存在這樣的呼叫順序ModSystem.UpdateUI -> UseInterface.Update用於實時更新, ModSystem.ModifyInterfaceLayers -> UseInterface.Draw用於繪製。而這裡給UI新增了個變數Visible用於控制何時顯示。只有當Visible為true時上述兩個方法才對其進行更新。

接下來是UI,UI中的程式碼比較簡單,由於所有物品共用一個UI,所以這裡需要儲存調出UI的物品是哪一個(AreaSelectItem)。在其派生類中只需定義包含的元素以及相應的點選事件等即可。
這裡有一個需要注意的點就是UI的位置需要考慮使用者的UI縮放。

這部分核心程式碼如下:

    public abstract class MenuUI : UIState
    {
        internal AreaSelectItem AreaSelectItem;

        protected UIElement MainContainer;

        public bool Visible;

        public override void OnInitialize()
        {
            base.OnInitialize();
            Append(MainContainer = new ());
            MainContainer.Width.Set(200, 0);
            MainContainer.Height.Set(200, 0);
        }

        public virtual void Open(AreaSelectItem item)
        {
            this.AreaSelectItem = item;
            Visible = true;
            // 注意要除以UIScale,否則如果縮放比例不是100%就會錯位
            MainContainer.Left.Set(Main.mouseX / Main.UIScale - MainContainer.Width.Pixels / 2, 0);
            MainContainer.Top.Set(Main.mouseY / Main.UIScale - MainContainer.Height.Pixels / 2, 0);
        }

    }

當玩家手中物品切換時,也需要關閉UI,這裡需要在ModPlayer中重寫PostUpdate方法,當玩家手中物品不是AreaSelectItem時關閉UI。

    public override void PostUpdate()
    {
        if (UISystem.CurrentMenuUI is null || UISystem.CurrentMenuUI.AreaSelectItem is null)
        {
            return;
        }
        AreaSelectItem currentItem = UISystem.CurrentMenuUI.AreaSelectItem;
        Player player = Main.player[Main.myPlayer];
        Item item = player.inventory[player.selectedItem];
        if (item.type != currentItem.Type)
        {
            if (!Main.playerInventory)
            {
                UISystem.Hide();
            }
        }
        else
        {
            DrawingSystem.Init();
            if (!Main.mouseLeft)
            {
                UISystem.CurrentMenuUI.AreaSelectItem.
            }
        }
    }

2.2.3物塊選擇框實現

這裡也一樣需要通過在ModSystemModifyInterfaceLayers呼叫對應UserInterfaceDraw進行繪製。新定義一個DrawingSystem繼承自ModSystem,這部分程式碼就不多贅述了。
物品框選時希望顯示的有(1)在滑鼠末尾畫物塊預覽 (2)矩形區域預覽 (3) 矩形大小顯示,其中(2)和(3)只有在使用物品時才進行繪製。這部分核心程式碼如下:

    public class SelectedAreaDrawing
    {
        public bool IsDrawing;

        /// <summary>
        /// 繪製物塊預覽
        /// </summary>
        private void drawItemPreview()
        {
            Vector2 position = Main.MouseScreen + new Vector2(32, 32);
            Texture2D texture = TextureAssets.Item[itemId].Value;
            Main.spriteBatch.Draw(texture, position, null, Color.White, 0f, texture.Size() / 2, 1f, SpriteEffects.None, 0f);
        }

        /// <summary>
        /// 繪製矩形區域預覽
        /// </summary>
        private void drawRectanglePreview()
        {
            Vector2 leftTop = _rectangle.TopLeft() * 16 - Main.screenPosition;
            Vector2 size = _rectangle.Size() * 16;
            Color color = Color.White * 0.7f;
            _areaTexture.SetData(new Color[] { color });
            Main.spriteBatch.Draw(_areaTexture, leftTop, null, color, 0f, Vector2.Zero, size, SpriteEffects.None, 0f);
        }

        /// <summary>
        /// 標明矩形大小
        /// </summary>
        private void drawRectangleSize()
        {
            string sizeText = $"{_rectangle.Width} x {_rectangle.Height}";
            Vector2 size = FontAssets.MouseText.Value.MeasureString(sizeText);
            Vector2 position = Main.MouseScreen + new Vector2(16, -size.Y - 6);
            ChatManager.DrawColorCodedStringWithShadow(Main.spriteBatch, FontAssets.MouseText.Value, sizeText, position, Color.White, 0f, Vector2.Zero, Vector2.One);
        }

        public void Draw()
        {
            drawItemPreview();
            if (IsDrawing)
            {
                drawRectanglePreview();
                drawRectangleSize();
            }
        }
    }

2.3物塊放置助手

ItemPlaceHelper,想要實現的效果是右鍵開啟物品選擇選單,選擇物品後,左鍵可以選擇矩形區域放置。
選擇選單中只有一個用於放置選擇物塊的按鈕,通過獲取Main.mouseItem物品,根據其createTile和createWall判斷是否為物塊或牆壁,這部分程式碼如下:

    itemSelectButton.OnClick += (evt, element) =>
    {
        if (Main.mouseItem.type != 0)
        {
            // 如果物塊可以放置,則新增進來
            if ((Main.mouseItem.createTile != -1 && Main.tileSolid[Main.mouseItem.createTile]) || Main.mouseItem.createWall != -1)
            {
                AreaSelectItem.ContentItemType = Main.mouseItem.type;
                itemSelectButton.SetContent(TextureAssets.Item[Main.mouseItem.type]);
            }
        }
        else
        {
            itemSelectButton.SetContent(null);
            AreaSelectItem.ContentItemType = 0;
        }
    };

其中itemSelectButton是自己定義的一個圓形按鈕。

該物品的關鍵在於放置物品,即重寫基礎類別AreaSelectItemStartAction方法。

    protected override void StartAction(Player player)
    {
        var rect = GetRectangle(_beginPoint, _endPoint);
        int consumeCount = 0;
        int total = GetItemCountOfInventory(player.inventory, ContentItemType);
        Item item = new Item();
        item.SetDefaults(ContentItemType);
        bool isWall = item.createWall > 0;
        bool hasHammer = getMaxHammerPower(player) > 0;
        // 從下到上,從左到右
        // 這種順序可以保證某些具有自由落體性質的方塊(如沙塊)能夠被正確的放置
        // 不過也會導致替換方塊時, 像沙塊這樣的方塊無法被從下往上替換
        for (int y = rect.Y + rect.Height - 1; y >= rect.Y; y--)
        {
            for (int x = rect.X; x < rect.X + rect.Width; x++)
            {
                if (consumeCount >= total)
                {
                    break;
                }
                Tile tile = Main.tile[x, y];
                if (isWall)
                {
                    if (tile.WallType > 0)
                    {
                        if (!player.TileReplacementEnabled)
                        {
                            continue;
                        }
                        if (hasHammer)
                        {
                            WorldGen.KillWall(x, y, false);
                            WorldGen.PlaceWall(x, y, (ushort)item.createWall, true);
                            consumeCount++;
                        }
                    }
                    else
                    {
                        WorldGen.PlaceWall(x, y, (ushort)item.createWall, true);
                        consumeCount++;
                    }
                }
                else
                {
                    if (tile.HasTile)
                    {
                        if (!player.TileReplacementEnabled || !player.HasEnoughPickPowerToHurtTile(x, y))
                        {
                            continue;
                        }
                        // 判斷是否是同一種方塊,是則跳過
                        WorldGen.KillTile_GetItemDrops(x, y, tile, out int tileType, out _, out _, out _);
                        if (tileType == item.type)
                        {
                            continue;
                        }
                        if (WorldGen.ReplaceTile(x, y, (ushort)item.createTile, item.placeStyle))
                        {
                            consumeCount++;
                        }
                    }
                    else
                    {
                        if (WorldGen.PlaceTile(x, y, (ushort)item.createTile, true, true, player.whoAmI, item.placeStyle))
                        {
                            consumeCount++;
                        }
                    }
                }
            }
        }
        if (consumeCount > 0)
        {
            for (int i = 0; i < player.inventory.Length; i++)
            {
                if (player.inventory[i].type == ContentItemType)
                {
                    if (player.inventory[i].stack > consumeCount)
                    {
                        player.inventory[i].stack -= consumeCount;
                        break;
                    }
                    else
                    {
                        consumeCount -= player.inventory[i].stack;
                        player.inventory[i].SetDefaults();
                    }
                }
            }
        }
    }

以上有幾個需要注意的問題,

  1. 如果使用player.PickTile進行破壞物塊,則第三個引數鎬力值需要儘可能的填大一些,如果鎬力剛好大於物塊所需的最大鎬力,則完全有可能不能直接摧毀物塊而僅僅是對其造成一定程度損壞
  2. 替換物塊可以用WorldGen.ReplaceTile但替換牆壁則沒找到對應的ReplaceWall,需要先使用KillWall再進行放置。
  3. 獲取位於x,y處的物塊或牆壁的型別的問題放在了文章末尾 點這裡跳轉

2.4物塊摧毀助手

ItemDestroyHelper,設計思路與ItemPlaceHelper幾乎完全相同,只是將StartAction中的放置改為了摧毀,實際上摧毀的邏輯在替換物塊時已經實現,這裡就不多贅述。

3.多人模式下的相關修改

這部分主要集中在物塊牆壁放置和摧毀時與遊戲內其他玩家的同步問題上,於是我就在摧毀和放置的末尾加上:

    if (Main.netMode == NetmodeID.MultiplayerClient)
    {
        NetMessage.SendData(MessageID.TileSquare, Main.myPlayer, -1, null, rect.X, rect.Y, rect.Width, rect.Height);
    }

這樣一次性同步範圍內的所有方塊。但這時也遇到了個問題,雖然方塊同步了,但卻沒有掉落物。摧毀物塊和牆壁呼叫的是WorldGen.KillTileWorldGen.KillWall,仔細檢視原始碼才發現當遊戲處於多人模式時,這兩個方法禁用了物塊的正常掉落。

這時我注意到另一個函數player.PickTile(x, y, 10000)在進行破壞物塊時可以在多人模式下掉落,於是我進入Player的PickTile方法中檢視了一下,如下圖所示:

仿照這個方法,我重新寫了一下破壞物塊和牆壁的程式碼,注意牆壁和物塊的SendData第五個引數不同:

    public static class WallUtils
    {
        public static void KillWall(int x, int y, bool fail = false)
        {
            WorldGen.KillWall(x, y, fail);
            if (!fail && Main.netMode == NetmodeID.MultiplayerClient)
            {
                // Wall對應的SendData第5個引數值為2
                NetMessage.SendData(MessageID.TileManipulation, -1, -1, null, 2, (float)x, (float)y, 0f, 0, 0, 0);
            }
        }

    }

    public static class TileUtils
    {
        public static void KillTile(int x, int y, bool fail = false, bool effectOnly = false, bool noItem = false)
        {
            WorldGen.KillTile(x, y, fail, effectOnly, noItem);
            if (!fail && Main.netMode == NetmodeID.MultiplayerClient)
            {
                // Tile對應的SendData第5個引數值為0
                NetMessage.SendData(MessageID.TileManipulation, -1, -1, null, 0, (float)x, (float)y, 0f, 0, 0, 0);
            }
        }

    }

4.開發過程中的其他問題

1.使用語言檔案時遇到的問題

將模組中需要用到的文字資訊的不同語言版本放在XXX.hjson如en-US.hjson中,再通過Language.GetTextValue("Mods.XXX.XXX")來獲取對應語言的文字。但在我使用時遇到一個小問題,在設定物品的DisplayName和Tooltip的名稱時,以下程式碼並不能正常獲取到對應語言的文字資訊:

    DisplayName.SetDefault(Language.GetTextValue("Mods.XXX.XXX"));
    Tooltip.SetDefault(Language.GetTextValue("Mods.XXX.XXX"));

在遊戲執行時有時能正常顯示,有時則直接顯示了「Mods.XXXXXX」這個字串。推測是語言檔案與物品靜態資訊載入順序關。後面參考了官方的ExampleMod,解決方法是在語言檔案hjson中直接寫明物品或Buff的DisplayName和Tooltip,會自動讀取到遊戲中。

    Mods.XXXMod.ItemName: {
        物品類名: 物品名稱
    }
    Mods.XXXMod.ItemTooltip: {
        物品類名: 物品Tooltip
    }
    Mods.XXXMod.BuffName: {
        Buff類名: Buff名稱
    }
    Mods.XXXMod.BuffDescription: {
        Buff類名: Buff描述
    }

2.獲取物塊或牆壁對應的型別

判斷位於x,y處的物塊對應的item的型別,也就是itemId。這個方法我找了很久,最後也是成功從掉落物塊的相關原始碼中找到了直接想要的內容:Terraria.WorldGen.KillTile_DropItems是用於破壞物塊後進行掉落用的,不過它是私有方法,而它呼叫的KillTile_GetItemDrops剛好是public方法,所以在判斷物塊時就使用了這種方法。但是奇怪的是KillWall_GetItemDrops卻是私有方法,所以這裡直接將原始碼中的程式碼複製過來,如下圖所示:

由於是反編譯得到的原始碼,所以有大量的switch-caseif分支。唯一要注意的是圖中*tileCache.wallTileinternal屬性:

圖中可以看到實際上wall對應的實際就是公有屬性WallType,故將原始碼中的*tileCache.wall全改為tileCache.WallType即可。

3.按鈕的點選問題

當滑鼠上已經有物塊時,點選按鈕如果滑鼠上的物塊可以被正確放在按鈕所在的原位置,則系統會優先將物塊放置再觸發按鈕的點選事件。這個由於不是非常影響體驗,將滑鼠離玩家遠一點即可,暫時沒去研究解決方法。