自定义 UI
ONI 的 UI 可以做很多层:用户菜单按钮、右侧 SideScreen、世界浮动面板、完整弹窗。入门建议按这个顺序来,越往后越自由,也越容易写出维护成本很高的代码。
这一章重点讲 Mod 里最常用的两类:用户菜单按钮和 SideScreen。
用户菜单按钮
选中建筑后,右下角那排按钮可以通过 RefreshUserMenu 加:
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 骨架
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 后把面板挂进去:
[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 组件拼:
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, "当前模式:默认");
}文字:
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;
}按钮可以用 KButton 和 KImage,比裸 Button 更像游戏原生 UI。
刷新节奏
不要每帧重建 UI。可以每秒刷新文本,内容变化时才重建列表:
private float refreshTimer;
private void Update()
{
if (!gameObject.activeInHierarchy || targetBuilding == null)
{
return;
}
refreshTimer -= Time.deltaTime;
if (refreshTimer > 0f)
{
return;
}
refreshTimer = 1f;
Refresh();
}StorageNetwork 的端口过滤面板就是这种思路:算一个 signature,没变化就不重建行。
弹窗
复杂设置可以做独立弹窗。入口仍然可以是用户菜单按钮:
KIconButtonMenu.ButtonInfo button = new KIconButtonMenu.ButtonInfo(
"action_building_disabled",
"打开设置",
() => MySettingsDialog.Open(gameObject),
global::Action.NumActions,
null,
null,
null,
"打开这个建筑的详细设置",
true);弹窗要注意三件事:
- 关闭时销毁对象,不要留一堆隐藏面板。
- 输入框、滑条改值后要写回组件,并触发刷新。
- 不要抢游戏快捷键,尤其是暂停、复制、拆除这些键。
UI 文本
UI 文本也放进 STRINGS:
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 要比较签名,变化后再重建。