教程 4:人工编写界面(不推荐)
不借助 AI,从空目录手写一个网页 App,放进 Unreal 并接上通信;后半部分是各项功能的参考,每一节都可以单独查阅。不推荐用这种方式做界面:按教程 1交给 AI 要快得多。
本页目录
- 它是怎样工作的
- 准备
- 第 1 部分:建一个网页 App
- 目录
- .gitignore
- package.json
- tsconfig.json
- vite.config.ts
- index.html
- src/bridge/orion-webui.ts
- src/main.ts
- src/model
- src/App.vue
- 安装、检查、构建
- 在浏览器里看
- 第 2 部分:放进 Unreal
- App Definition 属性
- Orion WebUI 控件属性
- 显示、输入模式与焦点
- 在 UMG Designer 里预览
- dist 从哪里读
- 第 3 部分:网页与 Unreal 通信
- 接口一览
- 状态
- 操作
- Unreal 调用网页
- 内置事件
- 显示阶段
- 第 4 部分:页面写法规则
- 控件 Id
- data-orion-* 属性速查
- 第 5 部分:输入
- 第 6 部分:声音、文字、字体与图片
- 声音
- 文字与语言
- 字体
- 图片
- 实时画面:Native Surface
- 第 7 部分:WorldUI
- 第 8 部分:远程网站
- 第 9 部分:多页面与路由
- 第 10 部分:安全
- 第 11 部分:性能与配置
- 硬件加速的画面传输
- 第 12 部分:调试
- 第 13 部分:检查与交付
- 排查
让 AI 来写见教程 1,预览工具见教程 2,Unreal 侧的蓝图与 C++ 写法见教程 3。
它是怎样工作的
网页显示在一个 UMG 控件上,页面透明的地方透出游戏画面。页面文件来自 Content/,不需要 Web 服务器,也不需要联网。
Unreal 和网页之间只传 JSON 消息。状态属于 Unreal:Unreal 把状态发给网页显示,网页把玩家的操作提交回去,由 Unreal 决定接不接受。
- StateRevision
- 0
- Volume
- 80
- Quality
- high
- Culture
Settings
| 控件 | 用途 |
|---|---|
| Orion WebUI | 承载一个本地 App,带游戏通信接口。本教程的主角 |
| Orion Browser | 打开远程网站,没有通信接口 |
| Orion InstantScreen | 多个界面共用一个常驻浏览器,见教程 3 |
准备
- 完成安装并下载依赖:点击工具栏上的 WebUIStudio 按钮,在“OrionBrowser 依赖”窗口里点“下载”。插件不会自行下载。空白工程还要按安装页设置 CommonUI 视口。
- 安装 Node.js 22 或更新的 LTS 版本(自带 npm),构建网页时需要。
- 把插件放在工程的
Plugins/;放在别处时,要改写下面文件里指向插件的相对路径。OrionBrowser - 先运行一次随附示例
OrionBrowser/,确认环境正常。Showcase/ L_OrionBrowserOverview
第 1 部分:建一个网页 App
例子是一个设置面板:显示音量、画质和语言,可以调音量、选画质、还原默认、关闭。
目录
<工程>/ ├─ Plugins/OrionBrowser/ 插件,共享运行库在 Content/UI/WebUI/Shared └─ Content/UI/WebUI/SettingsPanel/ 你的 App,目录名就是 AppId ├─ .gitignore ├─ package.json ├─ tsconfig.json ├─ vite.config.ts ├─ index.html ├─ webui-preview.json 可选,WebUIStudio 的视图清单 ├─ src/ │ ├─ main.ts │ ├─ App.vue │ ├─ bridge/orion-webui.ts │ └─ model/ │ ├─ settings-panel-state.ts │ └─ settings-panel-preview.ts └─ dist/ 构建产物,运行和打包都读这里
三个名字必须完全一致:目录名、package.json 里的 orionWebUI.appId、App Definition 的 AppId。工程里的 App 从 Content/ 读取,打包也只收这里。
Content/UI/WebUI/SettingsPanel/package.json 的 orionWebUI.appId"orionWebUI": { "appId": "SettingsPanel", … }AppIdAppId = SettingsPanel三处逐字一致,页面才找得到:运行时从 Content/UI/WebUI/<AppId>/dist 读取,打包也只收这里。
.gitignore
在安装依赖之前创建。不要忽略 dist。
**/node_modules/
**/.npm/
**/.vite/
**/.cache/
*.tsbuildinfo
npm-debug.log*package.json
{
"private": true,
"name": "settings-panel",
"version": "1.0.0",
"type": "module",
"orionWebUI": { "appId": "SettingsPanel", "interfaceName": "settings-panel", "performanceProfile": "transactional-screen", "lifecyclePolicy": "transactional" },
"scripts": {
"dev": "vite --host 127.0.0.1",
"build": "vite build",
"test": "node ../../../../Plugins/OrionBrowser/Content/UI/WebUI/Shared/scripts/test-webui-app-contract.mjs",
"typecheck": "vue-tsc --noEmit"
},
"dependencies": {
"@vitejs/plugin-vue": "^5.2.0",
"typescript": "^5.8.0",
"vite": "^6.3.0",
"vue": "^3.5.0",
"vue-tsc": "^2.2.0"
}
}appId:大写开头的英文名,与目录名一致。interfaceName:小写加短横线,工程内唯一,决定构建产物的文件名。performanceProfile/lifecyclePolicy:菜单、弹窗等可交互页面用transactional-screen/transactional;一直显示、不接收输入的 HUD 用continuous-passive/continuous-passive。
tsconfig.json
{
"compilerOptions": {
"baseUrl": ".",
"paths": { "vue": ["./node_modules/vue"] },
"target": "ES2022",
"useDefineForClassFields": true,
"module": "ESNext",
"moduleResolution": "Bundler",
"strict": true,
"jsx": "preserve",
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"types": ["vite/client"]
},
"include": ["src/**/*.ts", "src/**/*.vue"]
}paths.vue 不能省,它让类型检查使用你 App 里安装的 vue。
vite.config.ts
import path from "node:path";
import { fileURLToPath } from "node:url";
import vue from "@vitejs/plugin-vue";
import { defineConfig } from "vite";
import { createOrionWebUIBuildOutput, createOrionWebUIVueOptions } from "../../../../Plugins/OrionBrowser/Content/UI/WebUI/Shared/vite-output-naming";
const rootDir = path.dirname(fileURLToPath(import.meta.url));
const sharedSourceDir = path.resolve(rootDir, "../../../../Plugins/OrionBrowser/Content/UI/WebUI/Shared/src");
const appId = "SettingsPanel";
export default defineConfig({
plugins: [
vue(createOrionWebUIVueOptions(rootDir)),
{
name: "orion-webui-manifest",
generateBundle() {
this.emitFile({
type: "asset",
fileName: "orion-webui.manifest.json",
source: JSON.stringify({ appId, entry: "index.html", apiVersion: "1.0.0", localOnly: true }, null, "\t") + "\n",
});
},
},
],
base: "./",
build: {
outDir: path.resolve(rootDir, "dist"),
emptyOutDir: true,
sourcemap: false,
...createOrionWebUIBuildOutput("settings-panel", { performanceProfile: "transactional-screen" }),
},
server: {
host: "127.0.0.1",
port: 5180,
strictPort: false,
fs: { allow: [rootDir, sharedSourceDir] },
},
});createOrionWebUIBuildOutput 的两个值要直接写成字符串,并与 package.json 一致。不要给文件名加 [hash]。每个 App 用不同的 server.port。
index.html
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Settings Panel</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.ts"></script>
</body>
</html>不要加 <meta name="color-scheme" content="dark">,否则页面底色不透明,游戏画面透不出来。
src/bridge/orion-webui.ts
指向插件的长路径集中在这个文件里,其余文件从这里导入。
export type { OrionWebUIApi } from "../../../../../../Plugins/OrionBrowser/Content/UI/WebUI/Shared/src/bridge/orion-webui";
export { installCommonActionRouter, resolveOrionWebUIForMount } from "../../../../../../Plugins/OrionBrowser/Content/UI/WebUI/Shared/src/bridge/orion-webui";
export { type OrionWebUILifecycleController, createOrionWebUILifecycle } from "../../../../../../Plugins/OrionBrowser/Content/UI/WebUI/Shared/src/runtime/orion-webui-lifecycle";
export { createOrionWebUIApp, ensureOrionWebUIFontStylesheet } from "../../../../../../Plugins/OrionBrowser/Content/UI/WebUI/Shared/src/instant-screen/orion-instant-screen-vue";src/main.ts
import App from "./App.vue";
import { createOrionWebUIApp, ensureOrionWebUIFontStylesheet, resolveOrionWebUIForMount } from "./bridge/orion-webui";
ensureOrionWebUIFontStylesheet("SettingsPanel");
async function mountSettingsPanel(): Promise<void> {
const api = await resolveOrionWebUIForMount();
createOrionWebUIApp(App, { api }).mount("#app");
}
void mountSettingsPanel().catch((error: unknown) => {
console.error("SettingsPanel failed to connect to the Orion WebUI host bridge.", error);
});resolveOrionWebUIForMount() 在 Unreal 里返回通信接口,在普通浏览器和 UMG Designer 里返回 undefined。用它判断有没有宿主,不要自己轮询 window.OrionWebUI。
src/model
// src/model/settings-panel-state.ts
export type Quality = "low" | "medium" | "high";
export interface SettingsPanelState {
stateRevision: number;
volume: number;
quality: Quality;
culture: string;
}// src/model/settings-panel-preview.ts
import type { SettingsPanelState } from "./settings-panel-state";
export function createSettingsPanelPreviewState(): SettingsPanelState {
return { stateRevision: 1, volume: 80, quality: "high", culture: "en" };
}预览数据单独放一个模块,没有宿主时才加载。导出名要同时含有 preview 和 state,WebUIStudio 才能找到它。
src/App.vue
<template>
<main ref="rootElement" class="settings">
<header class="settings__header">
<h1>{{ text.title }}</h1>
<button type="button" data-orion-control-id="settings-close" @click="close">{{ text.close }}</button>
</header>
<label class="settings__row" for="settings-volume">
<span>{{ text.volume }}</span>
<output>{{ volumeDraft }}</output>
</label>
<input
id="settings-volume"
v-model.number="volumeDraft"
type="range"
min="0"
max="100"
step="1"
data-orion-control-id="settings-volume"
@change="setVolume"
/>
<div class="settings__row" role="group" :aria-label="text.quality">
<span>{{ text.quality }}</span>
<button
v-for="option in qualityOptions"
:key="option"
type="button"
data-orion-control-id="settings-quality"
:aria-pressed="state.quality === option"
@click="setQuality(option)"
>{{ text[option] }}</button>
</div>
<footer class="settings__footer">
<button type="button" data-orion-control-id="settings-reset" :disabled="resetting" @click="resetDefaults">{{ text.reset }}</button>
<p role="status">{{ notice }}</p>
</footer>
</main>
</template>
<script setup lang="ts">
import { computed, nextTick, onBeforeUnmount, onMounted, reactive, ref } from "vue";
import { type OrionWebUIApi, type OrionWebUILifecycleController, createOrionWebUILifecycle, installCommonActionRouter } from "./bridge/orion-webui";
import type { Quality, SettingsPanelState } from "./model/settings-panel-state";
const dictionary = {
"en": { title: "Settings", volume: "Volume", quality: "Quality", low: "Low", medium: "Medium", high: "High", reset: "Restore defaults", close: "Close", restored: "Defaults restored." },
"zh-Hans": { title: "设置", volume: "音量", quality: "画质", low: "低", medium: "中", high: "高", reset: "还原默认", close: "关闭", restored: "已还原默认设置。" },
} as const;
const qualityOptions: readonly Quality[] = ["low", "medium", "high"];
const props = defineProps<{ api?: OrionWebUIApi }>();
const rootElement = ref<HTMLElement | null>(null);
const state = reactive<SettingsPanelState>({ stateRevision: 0, volume: 0, quality: "medium", culture: "en" });
const volumeDraft = ref(0);
const resetting = ref(false);
const notice = ref("");
const text = computed(() => dictionary[state.culture === "zh-Hans" ? "zh-Hans" : "en"]);
const releases: Array<() => void> = [];
let lifecycle: OrionWebUILifecycleController | null = null;
let disposed = false;
function applyState(next: SettingsPanelState): void {
if (next.stateRevision <= state.stateRevision) {
return;
}
Object.assign(state, next);
volumeDraft.value = state.volume;
void nextTick(() => props.api?.stateCommitted(state.stateRevision).catch(report));
}
function report(error: unknown): void {
notice.value = error instanceof Error ? error.message : String(error);
}
function setVolume(): void {
props.api?.emit("settingsPanel.setVolume", { volume: volumeDraft.value }).catch(report);
}
function setQuality(quality: Quality): void {
props.api?.emit("settingsPanel.setQuality", { quality }).catch(report);
}
function close(): void {
props.api?.emit("settingsPanel.close", {}).catch(report);
}
async function resetDefaults(): Promise<void> {
if (!props.api || resetting.value) {
return;
}
resetting.value = true;
try {
await props.api.call("settingsPanel.resetDefaults", {});
notice.value = text.value.restored;
} catch (error) {
report(error);
} finally {
resetting.value = false;
}
}
onMounted(async () => {
const root = rootElement.value;
if (!root) {
return;
}
const api = props.api;
if (!api) {
const preview = await import("./model/settings-panel-preview");
applyState(preview.createSettingsPanelPreviewState());
root.dataset.orionPresentation = "active";
return;
}
releases.push(api.on<SettingsPanelState>("ue:settingsPanel.state", applyState));
releases.push(installCommonActionRouter(api, root));
lifecycle = createOrionWebUILifecycle({ api, root });
const bootstrap = await lifecycle.initialize().catch((error: unknown) => {
if (error instanceof Error && error.name === "AbortError") {
return null;
}
throw error;
});
if (!bootstrap || disposed) {
return;
}
await api.emit("settingsPanel.ready", {}).catch(report);
});
onBeforeUnmount(() => {
disposed = true;
lifecycle?.dispose();
lifecycle = null;
for (const release of releases.splice(0)) {
release();
}
});
</script>
<style scoped>
:global(html), :global(body), :global(#app) {
width: 100%;
height: 100%;
margin: 0;
overflow: hidden;
background: transparent;
}
.settings {
position: absolute;
top: 50%;
left: 50%;
width: 520px;
padding: 28px;
transform: translate(-50%, -50%);
color: #f2f4f8;
background: rgba(12, 16, 24, 0.86);
border: 1px solid rgba(255, 255, 255, 0.16);
font-family: var(--orion-webui-body-font-family, var(--orion-webui-font-family, sans-serif));
opacity: 0;
pointer-events: none;
}
.settings[data-orion-presentation="entering"],
.settings[data-orion-presentation="active"] {
opacity: 1;
}
.settings[data-orion-presentation="active"] {
pointer-events: auto;
}
.settings__header,
.settings__row,
.settings__footer {
display: flex;
align-items: center;
gap: 12px;
margin-bottom: 16px;
}
.settings__header h1 {
flex: 1;
margin: 0;
font-size: 28px;
}
.settings__row span {
flex: 1;
}
input[type="range"] {
width: 100%;
margin-bottom: 20px;
}
button {
padding: 8px 16px;
color: inherit;
background: rgba(255, 255, 255, 0.08);
border: 1px solid rgba(255, 255, 255, 0.24);
font: inherit;
}
button:hover,
button:focus-visible,
button[aria-pressed="true"] {
background: rgba(255, 170, 60, 0.28);
border-color: #ffaa3c;
outline: none;
}
button:disabled {
opacity: 0.4;
}
</style>要点:
- 先监听,再就绪:先用
api.on注册状态监听,再创建生命周期并initialize(),最后发settingsPanel.ready。 - 状态整份覆盖:丢弃版本不更新的状态,界面更新后用
stateCommitted回执。滑块每次收到状态都重置为 Unreal 的值。 - 每个
emit和call都接住失败。 - 根元素的可见性由显示阶段控制:默认
opacity: 0,entering和active时可见,active才能点击。不要用display: none隐藏根元素。
安装、检查、构建
在 App 目录里运行:
npm.cmd install # 第一次:安装依赖并生成 package-lock.json。之后改用 npm.cmd ci
npm.cmd run typecheck # 类型检查
npm.cmd test # App 合同检查
npm.cmd run build # 生成 disttest 打印 Validated SettingsPanel WebUI app contract. 就是通过;build 生成 dist。构建不做类型检查,所以 typecheck 也要运行。package-lock.json 要提交进版本库。
在浏览器里看
npm.cmd run dev打开终端里显示的地址,页面显示预览数据。控制台里字体样式表加载失败的信息可以忽略。
第 2 部分:放进 Unreal
App Definition 属性
内容浏览器里右键,Miscellaneous → Data Asset,类选 OrionWebUIAppDefinition。最少只需要填 AppId;下面是常用的属性,其余保持默认。
| 属性 | 默认值 | 说明 |
|---|---|---|
AppId |
Sample |
与目录名一致 |
InitialRoute |
空 | 初始路由(#/<路由>) |
EntryHtml |
index.html |
dist 内的入口文件 |
bUseDevServerInEditor |
开 | Designer 里开发服务器连得上时加载它,否则加载 dist |
DevServerUrl |
http:// |
改成 vite.config.ts 里的端口 |
TextCatalog |
空 | 文字表,见文字与语言 |
FontManifest |
空 | 留空使用工程设置里的全局字体清单 |
AssetManifest / SoundManifest |
空 | 贴图与材质预览的清单 / 业务声音的清单 |
BridgeCallTimeoutSeconds |
15 | 请求的超时秒数 |
CursorPolicy |
GameControlled |
指针由游戏还是页面控制 |
AllowedFrameOrigins / AllowedImageOrigins / AllowedMediaOrigins |
空 | 允许嵌入的外部来源,见安全 |
bSupportsTransparency |
开 | 透明处透出游戏画面 |
Orion WebUI 控件属性
| 属性 | 默认值 | 说明 |
|---|---|---|
| App Definition | 空 | 要加载的 App |
InitialRouteOverride |
空 | 覆盖初始路由 |
bLoadOnConstruct |
开 | 构造时自动加载;关闭后用 Load App |
bAutoResolveUnhandledRequests |
开 | 没人应答的请求自动以 {} 成功,见教程 3 |
| Show Design Time Preview | 开 | Designer 里显示真实页面 |
| Design Time Control Id Preview Mode | Hovered Control | Designer 里显示鼠标所指控件的 Id;All Controls 全部显示,红框 ControlId: <missing> 表示缺 Id;Hidden 关闭 |
显示、输入模式与焦点
- 新建 Widget 蓝图,放入 Orion WebUI,命名为
WebUI,勾选 Is Variable,铺满父级,指定 App Definition。 - 在 Player Controller 里 Create Widget、Add to Viewport。
- Set Input Mode Game And UI:Player Controller 接
Self(空着时节点不起作用),In Widget to Focus 接WebUI;Set Show Mouse Cursor 设为true。 - 关闭时 Remove from Parent,恢复 Set Input Mode Game Only(同样要接 Player Controller),隐藏鼠标。
接收操作和发布状态的蓝图见教程 3。
在 UMG Designer 里预览
Orion WebUI 控件在 Designer 里直接显示真实页面。这里没有通信接口,蓝图不执行,页面要能显示自己的预览数据,否则是空白。
| 看到的文字 | 处理 |
|---|---|
OrionBrowser dependencies are missing. Click the WebUIStudio button on the toolbar to download them. |
依赖没有下载或版本不一致。点击工具栏上的 WebUIStudio 按钮下载 |
Orion WebUI AppDefinition is not set. |
指定 App Definition |
Orion WebUI dist entry is missing: <路径> |
没有构建,或 AppId 与目录名不一致 |
边改边看:在 App 目录运行 npm run dev,把 Dev Server Url 改成对应端口(例如 http://);服务器在 Designer 打开后才启动时,编译一次 Widget 蓝图。PIE 和成品游戏只加载 dist,PIE 之前要 npm run build。
dist 从哪里读
页面从 Content/ 读取。编辑器运行期间重新构建,Designer 和 PIE 里的页面会自动重载;成品游戏不会。
- 勾选了 Use Dev Server in Editor 并且本机开发服务器连得上:加载开发服务器,改源码立即可见
- 否则加载
dist - 没有通信接口,页面用自己的预览数据显示
- 永远加载
dist - 所以 PIE 之前要
npm run build dist/index.html变化并稳定后自动重载
- 永远加载
dist(打进包体的那一份) - 没有自动重载
- 网页文件变了要重新构建再打包
第 3 部分:网页与 Unreal 通信
接口一览
| 网页 | Unreal | 用途 |
|---|---|---|
api.emit(name, payload) |
On Web Event | 提交操作,不需要结果 |
api.call(name, payload) |
On Web Request | 需要结果。Resolve Json 时成功,Reject 时失败,错误码在 error.code |
api.on(name, handler) |
Post Event to Web、Post Latest Event to Web、Post Retained Latest Event to Web | 接收 Unreal 的事件,返回取消监听的函数 |
api.handle(name, handler) |
Call Web、On Web Call Completed | 应答 Unreal 发起的调用 |
api.stateCommitted(revision) |
不需要处理 | 回执这一版状态已经显示 |
api.routeChanged(route) |
On Route Changed | 页面内路由变化 |
api.playSound(id, options) |
Sound Manifest | 播放业务声音 |
api.playControlSound(id, "hover" | "click", contextId) |
控件音效策略 | 没有 DOM 事件的区域手动上报控件音效 |
api.bindNativeSurface(id, element) |
Set Native Surface Texture / Material | 把 Unreal 画面放进页面 |
状态
- Unreal 每次发布完整状态,带一个只增不减的
stateRevision。用 Post Retained Latest Event to Web 发布,晚注册的监听也能收到最近一条。 - 网页不保存游戏事实,进度、库存、设置不要写进
localStorage。操作提交后等下一份状态,不要先当作成功。
操作
- 事件名用“前缀加动作”,例如
settingsPanel.setVolume,前缀工程内唯一。在调用处直接写字符串,不要拼接。参数是普通对象。 call被拒绝时,error.code是 Unreal 给的错误码,超时是E_TIMEOUT。关闭控件的 Auto Resolve Unhandled Requests 后,没人应答的请求以E_UNHANDLED失败。
Unreal 调用网页
releases.push(api.handle("settingsPanel.measure", () => {
return { width: rootElement.value?.offsetWidth ?? 0 };
}));蓝图对 WebUI 调用 Call Web(Name 填 settingsPanel.measure),结果从 On Web Call Completed 回来。每个名字只能有一个处理函数,没有时返回 E_NOT_FOUND。只用它查询显示层面的信息。
内置事件
| 事件 | 内容 |
|---|---|
ue:bootstrap(通信建立后) |
appId、apiVersion、route、localization、assets、sounds、fonts |
ue:localeChanged |
新的文字表 { culture, texts } |
ue:inputModeChanged |
{ inputType, gamepadName } |
ue:inputPromptsChanged |
{ inputType, gamepadName, actions[] } |
ue:commonAction |
{ actionId, inputType, gamepadName } |
ue:backAction(返回键) |
{ inputType, gamepadName } |
后四个只发给 CommonUI 页面。
显示阶段
根元素的 data-orion-presentation 是当前显示阶段:preparing(还没显示)、entering、active、covered(被别的界面盖住)、suspended、closing、failed。active 时才能交互,帧循环和 WebGL 在这之后才启动;preparing 时不播放动画。被盖住或挂起时停止帧循环、定时器和 WebGL。蓝图不调用 Set Presentation Lifecycle State 时,页面就绪后自动进入 active。
自己的工作要跟着显示开始和停止时,给 createOrionWebUILifecycle 传回调:
lifecycle = createOrionWebUILifecycle({
api,
root,
onPresentationRequested: () => {
startLoops();
return nextTick();
},
onPresentationSuspended: () => {
stopLoops();
},
});这两个回调由 CommonUI 宿主(OrionWebUIActivatableWidget)在激活和停用时触发。普通控件上直接跟随阶段:CSS 用 [data-orion-presentation="active"],脚本用 MutationObserver 监听这个属性。
进场动画写在同时匹配 entering 和 active 的选择器上,只写在 entering 上会在阶段切换时被取消。
第 4 部分:页面写法规则
html、body、#app背景透明,overflow: hidden。- 不用
backdrop-filter、mix-blend-mode,不写prefers-reduced-motion,合同检查会拒绝。 - 每个
<img>写decoding="async",放在任何取值含>的属性之前。 - 脚本、样式、字体都来自 App 自己,不用内联脚本和远程脚本。
- 固定构图的界面按设计分辨率搭一个舞台,整体等比缩放。
- 不写
user-select: none,文字默认就不能选中;需要可选时用data-orion-user-select。 - 浏览器原生右键菜单始终被屏蔽,需要时监听
contextmenu自己实现。 - 本地视频读入内存后用 Blob 地址播放,否则不能拖动进度。格式用 WebM(VP9 与 Opus)等 Chromium 能解码的格式。
- three.js 等重模块用动态
import(),进场完成后再初始化。
控件 Id
每个可点击的元素都要有一个直接写出的 data-orion-control-id:
- 只用字母、数字、
.、_、-,以字母或数字开头,最长 128 个字符。 - 不用
:data-orion-control-id="表达式",不拼接变量。列表里同类条目共用一个 Id,条目编号放进操作参数。 - 禁用写成
:disabled="...",等待中用aria-busy="true"。只用样式类禁用的控件仍然会发声。
控件 Id 用于音效、Designer 检查和自动化,不随语言变化。
data-orion-* 属性速查
| 属性 | 取值 | 作用 |
|---|---|---|
data-orion-control-id |
稳定的字面量 | 控件身份 |
data-orion-action |
动作 Id | CommonUI 动作触发时,聚焦并点击这个元素 |
data-orion-sound-context |
上下文名 | 区域内的控件音效使用同名策略 |
data-orion-sound-policy |
none、manual、custom |
元素及其后代不自动发出悬停和点击声 |
data-orion-user-select |
text、all |
允许选中文字 |
data-orion-font-policy |
body、heading、none |
强制正文或标题字体;none 用页面自己的字体 |
data-orion-presentation |
由插件写入 | 当前显示阶段 |
第 5 部分:输入
- 鼠标、触控、键盘:和普通网页一样,键盘要等
WebUI获得焦点。使用标准的button、input、select、label。 - 输入法:按键处理中遇到
event.isComposing直接返回,组合输入中途不要移走焦点。 - 手柄:普通控件由蓝图处理手柄,再把命令作为事件发给页面(随附示例的
ue:station.input)。CommonUI 页面在 Input Manifest 里登记动作,触发时页面收到ue:commonAction;调用过installCommonActionRouter(api, root)后,带data-orion-action="<动作 Id>"的元素会被聚焦并点击。按键提示监听ue:inputModeChanged和ue:inputPromptsChanged。 - 拖拽、长按、旋转:用共享运行库的
createOrionPointerSession()(Shared/),不要自己在src/ input/ orion-pointer-session.ts window上挂监听。 - 鼠标指针:
CursorPolicy为GameControlled时由游戏决定,PageControlled时由页面 CSS 决定。 - 方向键移动焦点:自己实现,可参考随附示例的
Showcase/。src/ shell/ focus.ts
第 6 部分:声音、文字、字体与图片
声音
控件的悬停与点击声由 Unreal 自动播放,网页不写代码:带 data-orion-control-id 的元素,以及 button、a[href]、input、select、textarea、summary、[role="button"] 都会上报。策略配置见教程 3。
有业务含义的声音由网页请求:
await api.playSound("settings.saved", { volumeMultiplier: 0.8 });声音 Id 是 App Definition 的 Sound Manifest 里的 StableId。失败时的错误码:E_SOUND_DISABLED(App 关闭了网页声音)、E_INVALID_ARG、E_SOUND_UNAVAILABLE(清单里没有或已禁用)。
两条通道不要叠加:有自动点击声的按钮不要再 playSound;自己播声音的区域写 data-orion-sound-policy="manual"。不要在页面里播放音频文件来做界面音效。
data-orion-control-id="settings-reset"api.playSound("settings.saved")playSound 播一遍。文字与语言
- 简单做法:Unreal 在状态里发布语言(本教程的
culture),页面用自己的字典。 - Text Catalog:在 Unreal 里建
OrionWebUITextCatalog,把稳定的 Key 映射到 Unreal 文本,指定给 App Definition。页面这样用:
import { type OrionLocalizationTable, translate } from "../../../../../Plugins/OrionBrowser/Content/UI/WebUI/Shared/src/bridge/orion-webui";
const table = ref<OrionLocalizationTable>({ culture: "", texts: {} });
releases.push(api.on<OrionLocalizationTable>("ue:localeChanged", (next) => { table.value = next; }));
const bootstrap = await lifecycle.initialize();
table.value = bootstrap.localization;
// 模板里:{{ translate(table, "settingsPanel.title", "Settings") }}换语言时替换同一个元素里的文字,不要每种语言各放一个元素,也不要重新加载页面。
字体
- 字体随 App 打包:字体文件放进
src/assets,CSS 里用@font-face声明,在使用它的区域写data-orion-font-policy="none"。保留字体许可文件。 - 使用 Unreal 的字体资产:建
OrionWebUIFontManifest,在 Entries 里登记 Font Face 资产和 CSS 字体名,指定给 Project Settings → Game → Orion WebUI Font 的SystemFontManifest(所有 App 共用)或某个 App Definition 的FontManifest。main.ts里的ensureOrionWebUIFontStylesheet挂载字体样式表,CSS 用变量取字体:
font-family: var(--orion-webui-body-font-family, var(--orion-webui-font-family, sans-serif));标题用 --orion-webui-heading-font-family。
图片
| 图片来源 | 做法 |
|---|---|
| 界面自带的静态图 | 放进 src/assets,用相对路径或 import 引用,推荐 WebP |
| 固定的一批 Unreal 贴图或材质预览 | 在 OrionWebUIAssetManifest 里登记 StableId,指定给 App Definition;页面从 bootstrap.assets 里按 id 取 url。材质预览要在编辑器里用 OrionWebUIEditorLibrary 的 ExportWebUIAssets 导出并保存清单,每次网页构建后重新导出 |
| 运行时才知道的贴图(头像、生成的图标) | 蓝图 Request Texture Resource,就绪后把地址放进状态,见教程 3 |
| 持续变化的画面(场景捕获、视频、动态材质) | Native Surface,见下 |
不要把图片编码成 Base64 放进状态,也不要每帧用消息传图。
实时画面:Native Surface
页面里放一个占位元素,Unreal 把画面直接画在它的位置上:
<script setup lang="ts">
import OrionNativeSurface from "../../../../../Plugins/OrionBrowser/Content/UI/WebUI/Shared/src/components/OrionNativeSurface.vue";
</script>
<template>
<div class="capture-frame">
<OrionNativeSurface surface-id="station.capture" class="capture-anchor" />
</div>
</template>不用组件时:const release = api.bindNativeSurface("station.capture", element),卸载时调用 release()。
surface-id与蓝图 Set Native Surface Texture 的Surface Id逐字一致。- 画面跟随占位元素的位置和大小,支持平移、缩放、不透明度和祖先裁剪,不支持旋转、倾斜和圆角裁剪。
- 画面默认盖在网页上方,边框画在外层容器上。它不接收鼠标。
- 一个页面最多 512 个绑定;列表里的大量图标用运行时图片。
第 7 部分:WorldUI
跟随 Actor 的标签、血条和交互按钮画在同一个页面里。页面里挂一个覆盖层组件:
<script setup lang="ts">
import { ref } from "vue";
import OrionWorldOverlayDOM from "../../../../../Plugins/OrionBrowser/Content/UI/WebUI/Shared/src/components/OrionWorldOverlayDOM.vue";
const worldOverlay = ref<InstanceType<typeof OrionWorldOverlayDOM> | null>(null);
</script>
<template>
<OrionWorldOverlayDOM ref="worldOverlay" />
</template>Unreal 侧给 Actor 加 OrionWebUIWorldElementComponent、建 Definition、调用 Bind Overlay Widget,见教程 3。
默认显示:Definition 开启交互并列出动作时,显示 payload.label 和动作按钮(文字取 payload.actionLabel),点击触发组件的 On Interaction;否则显示血条,读取 payload.health、payload.maxHealth、payload.label、payload.color。要自己画,按 Definition 的 ElementType 注册渲染函数:
worldOverlay.value?.registerRenderer("StationCell", ({ container, element }) => {
container.textContent = String(element.payload.label ?? element.key);
});第 8 部分:远程网站
外部网站用 Orion Browser 控件,单独放在一个 Widget 里:在 Initial URL 填地址或调用 Load URL;用 On Load Started、On Load Completed、On Load Error 做加载中、完成和失败画面,失败时给一个 Reload 按钮;Go Back、Go Forward、Stop Load 做导航。它在 Designer 里只显示占位文字。
第 9 部分:多页面与路由
一个 App 可以有多个页面,用地址里的 #/<路由> 区分。InitialRoute 是默认路由;控件的 InitialRouteOverride 或 Set Initial Route Override 让同一个 App 在不同控件里打开不同页面。页面内切换路由后调用 api.routeChanged("<路由>"),蓝图收到 On Route Changed,Get Current Route 返回当前值。各路由的组件用动态 import() 加载。
第 10 部分:安全
- 页面只能在自己的 App 地址下导航,跳到别处会被拦截,On Web Error 报告
Blocked Orion WebUI top-level navigation: <地址>。 - 页面默认只能加载 App 自己的资源。要嵌入外部内容,在 App Definition 里填精确的来源(不写路径,不用通配符):
AllowedFrameOrigins给<iframe>,AllowedImageOrigins给图片,AllowedMediaOrigins给音视频。 - 网页发来的内容都当作不可信输入,在 Unreal 里检查类型、范围、对象是否仍然存在、玩家是否有权这样做;联网游戏还要通过你自己的服务器校验。
- 不要把密钥、令牌写进页面或状态。
第 11 部分:性能与配置
- 每帧都在变的数据不要每帧 Post Event to Web;持续变化的画面用 Native Surface。
- 游戏不限帧并占满 GPU 时,浏览器画面的更新会被挤掉。给游戏设帧率上限(Get Game User Settings → Set Frame Rate Limit → Apply Non Resolution Settings)比调低浏览器帧率更有效。
硬件加速的画面传输
打开后,界面的画面直接在显卡上交给 Unreal,不经过内存。全屏界面、高分辨率,或页面里有持续动画、WebGL、视频时,建议在工程 Config/ 里打开:
[OrionBrowser]
bUseAcceleratedPaint=True只在 Windows 的 D3D11 和 D3D12 下起作用;修改后重启编辑器或游戏。
第 12 部分:调试
| 手段 | 用法 |
|---|---|
| On Web Error | 接到 Print String。通信错误、被拦截的导航都从这里报告 |
| Output Log | 过滤 LogOrionWebUI、LogOrionBrowser |
| 网页开发者工具 | 启动参数 -cefdebug=9222,再用 Chrome 连接这个端口 |
页面的 console.log 不进 Output Log,用开发者工具或 WebUIStudio 的日志页看。
第 13 部分:检查与交付
每次修改后按顺序做:
npm.cmd run typecheck、npm.cmd test、npm.cmd run build。- 在 WebUIStudio 或浏览器里看每个视图。
- 在 UMG Designer 里确认页面能加载、控件 Id 齐全。
- 在 PIE 里实际操作:鼠标、键盘、手柄、输入法、两种语言、声音、反复打开关闭。
- 构建出最新的
dist,打包后在成品游戏里再走一遍。
排查
| 现象 | 检查 |
|---|---|
构建报 requires package.json orionWebUI appId, interfaceName, ... |
package.json 的四个字段与 vite.config.ts 里的两个值不一致 |
构建报 must bundle the shared presentation transaction ... |
页面没有使用 createOrionWebUILifecycle |
npm test 报缺少 decoding="async" |
给 <img> 补上;已经写了,就把它移到标签最前面 |
| 页面在 Unreal 里空白 | 三个名字是否一致;是否构建;看 On Web Error |
| Designer 里空白、PIE 里正常 | 页面缺少没有宿主时的预览分支 |
| 浏览器里正常、Unreal 里整屏不透明 | index.html 加了 color-scheme,或根元素有不透明背景 |
| 点击没有反应 | 根元素是否处于 active 阶段;事件名两边是否一致 |
| 状态不更新 | 版本号是否递增;监听是否在 ready 之前注册 |
| 改了开发服务器端口后 Designer 显示旧页面 | Dev Server Url 没有同步,或端口被别的 App 占用 |
| 改了源码,PIE 里没变化 | PIE 读的是 dist,重新 npm run build |
| 外部图片、视频、网页被拦截 | 在 App Definition 的三个 Allowed...Origins 里填精确来源 |