Skip to content

自定义 UI

ONI 的 UI 可以做很多层:用户菜单按钮、右侧 SideScreen、世界浮动面板、完整弹窗。入门建议按这个顺序来,越往后越自由,也越容易写出维护成本很高的代码。

这一章重点讲 Mod 里最常用的两类:用户菜单按钮和 SideScreen。

用户菜单按钮

选中建筑后,右下角那排按钮可以通过 RefreshUserMenu 加:

csharp
private static readonly EventSystem.IntraObjectHandler<MyBuilding>
    OnRefreshUserMenuDelegate =
        new EventSystem.IntraObjectHandler<MyBuilding>(
            (component, data) => component.OnRefreshUserMenu(data));

protected override void OnSpawn()
{
    base.OnSpawn();
    Subscribe((int)GameHashes.RefreshUserMenu, OnRefreshUserMenuDelegate);
}

private void OnRefreshUserMenu(object data)
{
    KIconButtonMenu.ButtonInfo button = new KIconButtonMenu.ButtonInfo(
        "action_empty_contents",
        "清空内容",
        () => storage.DropAll(false, false, default(Vector3), true, null),
        global::Action.NumActions,
        null,
        null,
        null,
        "移除储存中的所有物品",
        true);

    Game.Instance.userMenu.AddButton(gameObject, button, 1f);
}

这种适合一次性命令,比如清空、切换模式、重置、打开自定义窗口。

SideScreen 适合什么

SideScreen 是选中对象后右侧详情面板里的扩展区域。适合:

  • 阈值设置。
  • 模式选择。
  • 输出过滤。
  • 列表选择。
  • 简单状态展示。

如果只是一个按钮,不要上 SideScreen。按钮能解决就用按钮。

SideScreen 骨架

csharp
using UnityEngine;

namespace MyMod.UI
{
    public class MyBuildingSideScreen : SideScreenContent
    {
        private MyBuilding targetBuilding;

        public MyBuildingSideScreen()
        {
            titleKey = string.Empty;
        }

        public override string GetTitle()
        {
            return "建筑设置";
        }

        protected override void OnSpawn()
        {
            base.OnSpawn();
            BuildContent();
        }

        public override bool IsValidForTarget(GameObject target)
        {
            return target != null && target.GetComponent<MyBuilding>() != null;
        }

        public override void SetTarget(GameObject target)
        {
            base.SetTarget(target);
            targetBuilding = target.GetComponent<MyBuilding>();
            Refresh();
        }

        public override void ClearTarget()
        {
            targetBuilding = null;
            base.ClearTarget();
        }
    }
}

IsValidForTarget() 决定面板什么时候出现。条件一定要写得窄一点,只对自己的组件返回 true。

注册 SideScreen

常见做法是在 DetailsScreen.OnPrefabInit 后把面板挂进去:

csharp
[HarmonyPatch(typeof(DetailsScreen), "OnPrefabInit")]
public static class DetailsScreen_OnPrefabInit_Patch
{
    public static void Postfix(DetailsScreen __instance)
    {
        GameObject body = Traverse.Create(__instance)
            .Field<GameObject>("sideScreen2ContentBody")
            .Value;

        GameObject panel = new GameObject("MyBuildingSideScreen");
        panel.transform.SetParent(
            body != null ? body.transform : __instance.transform,
            false);
        panel.AddComponent<MyBuildingSideScreen>();
    }
}

当前源码里侧栏容器字段名是 sideScreen2ContentBody,它是私有序列化字段,示例用 Harmony 的 Traverse 读取。不同版本 UI 层级可能有差异,面板不显示时先用 Unity Explorer、dnSpy 或日志确认当前版本的字段名和容器对象。

构建内容

可以直接用 Unity UI 组件拼:

csharp
private TextMeshProUGUI statusText;

private void BuildContent()
{
    Transform parent = ContentContainer != null
        ? ContentContainer.transform
        : transform;

    GameObject root = new GameObject("Content");
    root.transform.SetParent(parent, false);
    root.AddComponent<RectTransform>();

    VerticalLayoutGroup layout = root.AddComponent<VerticalLayoutGroup>();
    layout.spacing = 6f;
    layout.childControlWidth = true;
    layout.childControlHeight = true;

    statusText = CreateText(root.transform, "当前模式:默认");
}

文字:

csharp
private static TextMeshProUGUI CreateText(Transform parent, string value)
{
    GameObject go = new GameObject("Text");
    go.transform.SetParent(parent, false);
    TextMeshProUGUI text = go.AddComponent<TextMeshProUGUI>();
    text.text = value;
    text.fontSize = 12f;
    text.alignment = TextAlignmentOptions.MidlineLeft;
    text.raycastTarget = false;
    return text;
}

按钮可以用 KButtonKImage,比裸 Button 更像游戏原生 UI。

刷新节奏

不要每帧重建 UI。可以每秒刷新文本,内容变化时才重建列表:

csharp
private float refreshTimer;

private void Update()
{
    if (!gameObject.activeInHierarchy || targetBuilding == null)
    {
        return;
    }

    refreshTimer -= Time.deltaTime;
    if (refreshTimer > 0f)
    {
        return;
    }

    refreshTimer = 1f;
    Refresh();
}

StorageNetwork 的端口过滤面板就是这种思路:算一个 signature,没变化就不重建行。

弹窗

复杂设置可以做独立弹窗。入口仍然可以是用户菜单按钮:

csharp
KIconButtonMenu.ButtonInfo button = new KIconButtonMenu.ButtonInfo(
    "action_building_disabled",
    "打开设置",
    () => MySettingsDialog.Open(gameObject),
    global::Action.NumActions,
    null,
    null,
    null,
    "打开这个建筑的详细设置",
    true);

弹窗要注意三件事:

  • 关闭时销毁对象,不要留一堆隐藏面板。
  • 输入框、滑条改值后要写回组件,并触发刷新。
  • 不要抢游戏快捷键,尤其是暂停、复制、拆除这些键。

UI 文本

UI 文本也放进 STRINGS

csharp
public static class UI
{
    public static class MYBUILDING
    {
        public static LocString TITLE = "建筑设置";
        public static LocString MODE_AUTO = "自动";
        public static LocString MODE_MANUAL = "手动";
    }
}

UI 是玩家最常看到的部分,文案别写成变量名。Auto Mode 不如“自动模式”,Threshold High 不如“高阈值”。

常见坑

不要在 SetTarget() 里重复创建 UI。SetTarget() 会经常调用,内容创建放 OnSpawn(),数据刷新放 Refresh()

不要把目标组件缓存后就不再检查。对象切换、拆除、保存读取后,目标可能已经失效。

不要让 SideScreen 对所有对象都 valid。右侧面板会变乱,也可能拖慢选择操作。

不要频繁 Destroy/Create 大量控件。列表类 UI 要比较签名,变化后再重建。