在你的模组中使用 DearOreUI
效果展示


在这个教程中,你会逐步创建一个由oreui驱动的日历模组
- 仓库:magicobs0z/dearoreui-ExampleMod
- 课程源码:
src/mod/examples/ - 默认课程:
07,完整日历 - 课程切换:
bin/my-mod/config.json
这个仓库有一个很实用的特点:01 到 07 不是七个互不相干的 Demo,而是一条逐步增加能力的路线。先连接运行时,再注册组件、注入页面脚本、接收 C++ 事件、订阅帧服务,最后组合成一个可以新增和删除事件的日历。
先看清楚:DearOreUI 负责什么
你的客户端 Mod 负责业务和注册,DearOreUI 负责把 UI 挂到 OreUI 页面里。
你的 Mod
├─ 获取 IDearOreUIApi*
├─ registerMod()
├─ registerComponent() / registerHostMethod()
├─ publishEvent()
└─ subscribePage() / subscribeFrame()
│
▼
DearOreUI 运行时
├─ 监听页面生命周期
├─ 将组件和 DOM 注入页面
├─ 把事件送到页面脚本
└─ 在页面销毁时通知 Mod示例模组并不链接一个 DearOreUI import library。MyMod.cpp 通过 Windows 模块导出查找 DearOreUI.dll 中的 DearOreUI_QueryApi,拿到 IDearOreUIApi*。这样做是这个示例仓库的实际集成方式,不要把它改写成文档里不存在的 runtime::IRuntime::api()。
前置条件
示例仓库当前的构建目标是 Windows x64 客户端:
| 项目 | 示例仓库中的实际配置 |
|---|---|
| 加载器 | LeviLamina 客户端 |
| 语言 | C++20 |
| 构建 | xmake |
| DearOreUI 头文件 | xmake.lua 的 dearoreui_include,默认指向 ../DearOreUI/src |
| DearOreUI 运行时 | 游戏启动时必须已经加载 DearOreUI.dll |
| UI 页面 | OreUI 页面,例如世界列表;不是 JsonUI 页面 |
| 默认示例 | 07 |
xmake.lua 当前使用:
add_requires("levilamina 26.10.*", {
configs = { target_type = get_config("target_type") }
})
option("dearoreui_include")
set_default("../DearOreUI/src")
option_end()如果你的 DearOreUI 头文件不在默认相对路径,可以这样配置:
xmake f --dearoreui_include=F:/path/to/DearOreUI/src
xmake build -y
tooth.json负责这个示例模组的 LeviLamina 打包配置。DearOreUI 是运行时前置条件,示例代码通过 DLL bridge 查找它;不要只安装示例模组而漏掉 DearOreUI 本体。
运行示例模组
克隆仓库后,先构建:
git clone https://github.com/magicobs0z/dearoreui-ExampleMod.git
cd dearoreui-ExampleMod
xmake build -y构建产物位于 bin/my-mod/。部署时复制整个目录,而不是只复制 DLL,因为示例还读取:
bin/my-mod/config.json
bin/my-mod/manifest.json
bin/my-mod/my-mod.dll默认配置:
{
"example": "07"
}课程切换:
{ "example": "01" }
{ "example": "02" }
{ "example": "03" }
{ "example": "04" }
{ "example": "05" }
{ "example": "06" }
{ "example": "07" }Mod 启动时,ExampleFactory 读取 config.json,只创建一个课程实例。空值、未知值和 07 都回退到完整日历。06 不在默认路径上,必须显式选择。
课程路线:从连接到完整日历
| 课程 | 文件 | 学什么 |
|---|---|---|
| 01 | ex01_hello_connection.h | 获取 API、注册 Mod、读取运行时信息 |
| 02 | ex02_static_component.h | 注册静态 Panel/Card/Text 组件 |
| 03 | ex03_page_script.h | C++ 注入页面脚本,绘制月历骨架 |
| 04 | ex04_events.h | C++ 发布 calendar.today,JS 接收事件 |
| 05 | ex05_frame_data.h | 使用帧服务推送时钟,处理跨日 |
| 06 | ex06_host_method.h | 注册一个 JS→C++ Host method,演示单 dispatch |
| 07 | ex07_calendar.cpp | 组合月历、事件、输入、删除和时钟 |
建议按顺序看。直接跳到 07 也可以,但你会错过这个仓库为什么没有把所有东西都塞进一个组件树的原因。
第一步:注册 Mod 身份
所有后续注册都要有一个已注册的 ModId。01 课程的核心代码很短:
dearoreui::api::ModManifest manifest;
manifest.id = dearoreui::api::ModId{"example.hello"};
manifest.modNamespace = "example.hello";
manifest.displayName = "Hello Connection Example";
manifest.modVersion = dearoreui::api::Version{1, 0, 0};
manifest.permissions = {};
auto result = mApi.registerMod(manifest);
if (result.isErr()) {
logger.error("registerMod failed: {}", result.error().message);
return false;
}ExampleBase 保存了两个对象:
dearoreui::api::IDearOreUIApi& mApi;
MyMod& mMod;每个课程自己拥有一个命名空间,例如:
mModId("example.calendar")课程结束时注销同一个 ModId:
static_cast<void>(mApi.unregisterMod(mModId));不要在一个课程里偷偷复用另一个课程的注册句柄。示例工厂一次只启用一个课程,这样切换配置不会留下上一课的 UI 和订阅。
第二步:声明 UI Manifest
07 日历使用的是 Overlay:
dearoreui::api::UiManifest uiManifest;
uiManifest.modNamespace = mModId.value();
uiManifest.id = "calendar";
uiManifest.kind = dearoreui::api::UiKind::Overlay;
uiManifest.pageScopes = {dearoreui::api::PageScope::Any};
uiManifest.anchor = dearoreui::api::UiAnchor::TopRight;
uiManifest.pointerEvents = true;
uiManifest.containerId = dearoreui::api::makeUiContainerId(
uiManifest.modNamespace,
uiManifest.kind,
uiManifest.id
);
uiManifest.fingerprint = "calendar.v1";这里有一个容易误会的地方:anchor = TopRight 是 DearOreUI 的挂载锚点,不等于你的页面内容必须永远挤在右上角。07 在挂载后把自己的根节点设为固定视口层,再在视口内部计算内容区域。
root.style.cssText =
'position:fixed;left:0;top:0;width:100vw;height:100vh;...';如果你的 UI 只是一个小提示框,使用更小的普通布局即可。只有页面型 UI 才需要接管整个 viewport。
第三步:注册组件或页面内容
DearOreUI 的组件注册入口是:
auto result = mApi.registerComponent(
mModId,
uiManifest,
componentSpec
);ComponentSpec 支持:
kind:组件类型label:主文本variant:按钮变体等style:组件风格children:嵌套组件树body:原始DomNode内容
最小的组件示例:
dearoreui::api::ComponentSpec page;
page.kind = dearoreui::api::ComponentKind::Section;
dearoreui::api::ComponentSpec title;
title.kind = dearoreui::api::ComponentKind::Text;
title.label = "CALENDAR";
title.variant = "heading";
page.children.push_back(std::move(title));
dearoreui::api::ComponentSpec button;
button.kind = dearoreui::api::ComponentKind::Button;
button.label = "OK";
page.children.push_back(std::move(button));上面的类型名在真实代码中都位于 dearoreui::api 命名空间。示例仓库的 ex02_static_component.h 使用的是 Panel、Card、Text 等组件,并且静态 label 使用 ASCII。这不是偶然的格式偏好:本次真机验证发现,主题字体对静态 CJK label 的支持并不稳定。
组件树什么时候适合用
静态结构适合用组件:
Panel / Card / Text / Divider / Button它们可以直接获得 DearOreUI 的原版纹理和主题样式。
什么时候应该用原始 DOM
07 先尝试过用 Grid、Stack 和 42 个动态 Button 组件构建整个月历。真机结果是日期按钮变成横向大条,组件状态样式还会反复覆盖脚本设置。最后的可用版本保留 Section 作为 DearOreUI 页面壳,动态月历、事件列表、输入框和时钟使用显式绝对定位 DOM。
这是示例仓库当前的事实,不是 API 理想图:
DearOreUI Section
└─ body: cal-root + script
├─ 标题栏
├─ 7 列 × 6 行日期
├─ 事件列表
├─ 输入框与添加操作
└─ 时钟工程上,能稳定使用组件的部分就使用组件;组件布局会破坏页面时,先保证页面能完整显示,再使用 DomNode 和页面脚本补上动态区域。
第四步:正确注入页面脚本
示例在 ComponentSpec.body 的末尾追加脚本节点:
std::vector<dearoreui::api::DomNode> body;
body.push_back(dearoreui::api::DomNode{
.tag = "div",
.attrs = {{"id", "cal-root"}},
.style = "position:fixed;left:0;top:0;width:100vw;height:100vh;",
.text = "",
});
body.push_back(dearoreui::api::DomNode{
.tag = "script",
.text = kPageScript,
});
page.body = std::move(body);脚本必须在页面节点挂载后运行。示例的 boot() 会轮询 cal-root 和 window.oreui:
var tries = 0;
function boot() {
tries++;
var root = document.getElementById('cal-root');
if (!root || !window.oreui) {
if (tries < 60) setTimeout(boot, 50);
return;
}
build(root);
render();
}
boot();在这个客户端上,不要把页面脚本交给不确定的 eval()、普通绑定通道或另一个未经验证的执行路径。示例使用的是 C++ 注册组件时追加的脚本节点,并在脚本内部等待挂载完成。
第五步:C++ 推送数据到 JS
04、05、07 都使用 C++ 到 JS 的事件发布。07 在页面 Ready 后推送种子事件:
dearoreui::api::EventPublishOptions seed;
seed.owner = mModId;
seed.context = view.id;
seed.name = "calendar.events";
seed.payload = seedEventsJson();
auto result = mApi.publishEvent(seed);
if (result.isErr()) {
logger.error("publish events failed: {}", result.error().message);
}JS 订阅同一个事件名:
window.oreui.event.on('calendar.events', function (payload) {
if (payload && payload.events) {
state.events = payload.events;
}
render();
});事件 payload 要在 C++ 和 JS 两端保持同一个契约。07 的种子数据形状是:
{
"events": {
"2026-08-22": ["今日计划", "示例事件"],
"2026-08-23": ["午休 12:00", "提交周报"],
"2026-08-24": ["周末活动"]
}
}示例没有把每次本地新增都发回 C++。本地事件保存在当前页面脚本的内存里,页面销毁后消失。需要持久化时,再设计 Host method 或资源/配置存储,不要默认把 JS 内存当成存档。
第六步:页面生命周期
07 订阅两个页面事件:
auto ready = mApi.subscribePage(
dearoreui::api::PageSubscriptionOptions{
mModId,
{dearoreui::api::PageScope::Any}
},
dearoreui::api::PageEvent::Ready,
[this](dearoreui::api::PageContextView const& view) {
onPageReady(view);
}
);Ready 回调里保存 view.id,用它作为事件发布上下文。Destroyed 回调里清除上下文并停止帧订阅:
void CalendarExample::onPageDestroyed(
dearoreui::api::PageContextView const& /*view*/
) {
mContextId.reset();
if (mFrameSub.has_value()) {
static_cast<void>(mApi.unsubscribeFrame(*mFrameSub));
mFrameSub.reset();
}
mLastClockSecond = -1;
}shutdown() 再按相反方向注销页面订阅、Host method、UI 和 Mod。这个顺序很重要:页面监听和帧回调不能在 UI 或 Mod 已经注销后继续运行。
第七步:使用帧服务推送时钟
05 和 07 没有用 JS setInterval 作为游戏数据源,而是订阅 DearOreUI 的 frame service。07 每 30 帧检查一次,并按秒去重:
auto handle = mApi.subscribeFrame(
dearoreui::api::FrameSubscriptionOptions{mModId},
[this]() { onFrame(); }
);onFrame() 生成 calendar.clock payload:
{
"y": 2026,
"m": 8,
"d": 22,
"h": 20,
"mi": 19,
"s": 3
}JS 只更新时钟;跨日时更新今天标记。用户手动选择了其他日期时,时钟不能偷偷把详情页切走,这是示例最后修复的状态同步问题。
Host method:先把权限和通道讲清楚
06 是明确关闭的实验课程。它注册 calendar.init,服务端实现 IHostMethod:
class CalendarInitMethod final : public dearoreui::api::IHostMethod {
public:
std::string name() const override { return "calendar.init"; }
dearoreui::api::Permission requiredPermission() const override {
return dearoreui::api::Permission::HostReadOnly;
}
dearoreui::api::Result<std::string> execute(
dearoreui::api::ContextId /*contextId*/,
std::string_view /*args*/
) override {
return dearoreui::api::Result<std::string>::success(
mOwner.handleInit()
);
}
};本次项目验证出的限制:一个页面的 dispatch 通道只有一次可靠使用额度。绑定通道在这个客户端出现过 0x40080201 / ViewDispatchAlreadyUsed 相关问题,所以 07 默认只走 C++→JS 事件,不调用 Host method。需要研究 JS→C++ 时,显式选择 06。
不要为了“看起来双向完整”在 07 里偷偷加第二次 dispatch。先把权限、页面范围和错误处理做好。
字体、颜色与布局:示例中真正验证过的结论
中文字体
07 使用这组字体栈:
font-family: 'Microsoft YaHei', 'SimHei', 'Noto Sans SC',
'Noto Sans', 'Segoe UI', sans-serif;Windows 客户端上中文已经可以正常显示,但这是字体栈匹配结果,不等于所有机器都一定使用同一个字体。若要做可移植字体资源,应另外验证 ResourceKind::Font、资源 URI 和页面字体加载链路;示例仓库没有假装这条链路已经完成。
颜色
07 最终只使用四组颜色:
白色 #ffffff
淡灰色 #d0d7de
深灰色 #161b22 / #21262d / #30363d
绿色 #3fb950蓝色、红色和渐变在最终版本中已经移除。删除符号使用淡灰,今天、选中状态和事件标记使用绿色。
布局
CSS Grid 和 Flex 在本次目标页面上下文中表现不稳定,最早的日历版本出现过日期散落和按钮横向拉伸。最终日历使用显式坐标:
var mx = Math.floor(viewportWidth * 0.10);
var my = Math.floor(viewportHeight * 0.10);
var contentWidth = viewportWidth - mx * 2;
var contentHeight = viewportHeight - my * 2;
var cellWidth = contentWidth / 7;
var cellHeight = gridHeight / 6;这样做看起来不如 Grid 简洁,但在这个运行环境里更容易控制。页面教程应该记录真机结果,而不是把理想中的 CSS 能力写成已验证事实。
验证和排查
先看日志
示例会输出类似:
Connected to DearOreUI, protocol 1
[example.calendar] full calendar registered
Example '07-calendar' is active.01 会额外打印:
[example.hello] connected: protocol=v1 ready=true minecraft=... oreui=... coherent=...再看配置
如果始终看到 07,检查实际加载目录中的 config.json,不要只修改源码目录里的同名文件。MyMod.cpp 读取的是:
getSelf().getModDir() / "config.json"常见现象
| 现象 | 先检查什么 |
|---|---|
| 没有连接日志 | DearOreUI.dll 是否已经加载,bridge 导出是否存在 |
| 注册失败 | ModId、命名空间、页面范围和权限是否正确 |
| 页面完全不显示 | 当前页面是否是 OreUI 页面,boot() 是否找到根节点 |
| 日期变成横向大条 | 是否又把动态日期塞回 Grid/Stack,或父节点样式覆盖了绝对坐标 |
| 中文变成方块 | 页面字体栈是否命中,组件静态 label 是否直接使用了中文 |
| 点击日期无反应 | 是否使用真实 DOM addEventListener 委托,是否把点击落点冒泡到日期节点 |
| 时间改变选中日期 | 不要让 calendar.clock 无条件覆盖 state.selected |
| 页面退出后仍有回调 | Destroyed 和 shutdown() 是否取消了帧/页面订阅 |
真机检查清单
进入一个已知的 OreUI 页面后检查:
- 标题、日期、事件文本是否居中;
- 页面四边是否保留约 10% 空间;
- 日期是否始终是 7 列 × 6 行;
- 当前日期和手动选择日期是否没有互相覆盖;
- 添加事件后刷新当前日期是否出现事件标记;
- 删除事件后列表和标记是否一起消失;
- 页面切换或退出时日志是否没有继续刷回调。
资源、脚本和 CSS:不要把未验证接口当成现成答案
DearOreUI 的 API 确实提供资源、脚本和样式注册接口:
mApi.registerResource(owner, resourceManifest, payload);
mApi.registerScript(owner, scriptManifest, source);
mApi.registerStyleSheet(owner, styleManifest, source);具体字段以当前 DearOreUI/src/api/manifest/ 头文件为准。字体资源尤其需要在目标客户端验证字体二进制、资源 URI 和 @font-face 是否完整打通。示例日历因此使用系统字体栈,而不是在仓库里声称已经注册了一套中文字体。
同样,示例中的动态脚本使用 DomNode 注入,而不是文档里凭空捏造一个 registerPageScript() API。先复用已经在仓库和客户端走通的路径。
生命周期注销模板
示例课程的 shutdown() 大致遵循这个顺序:
unsubscribeFrame();
unsubscribePage(Destroyed);
unsubscribePage(Ready);
unregisterHostMethod();
unregisterUi();
unregisterMod();每一步都用保存的 RegistrationHandle,并在注销后 reset 对应的 optional。unregisterMod() 不能代替你在页面仍活跃时停止回调;显式取消订阅仍然要做。
继续阅读
如果你要从自己的 Mod 开始,建议先复制示例的 01 课程,确认 bridge 和 registerMod() 正常,再一步步加 UI。不要直接从 07 的完整脚本开始抄;07 里有不少代码是为了适应这个特定客户端的页面布局限制。