Orion OrionBrowser使用手册
中文EN

教程 4:人工编写界面(不推荐)

不借助 AI,从空目录手写一个网页 App,放进 Unreal 并接上通信;后半部分是各项功能的参考,每一节都可以单独查阅。不推荐用这种方式做界面:按教程 1交给 AI 要快得多。

本页目录

让 AI 来写见教程 1,预览工具见教程 2,Unreal 侧的蓝图与 C++ 写法见教程 3。

它是怎样工作的

网页显示在一个 UMG 控件上,页面透明的地方透出游戏画面。页面文件来自 Content/UI/WebUI/<AppId>/dist,不需要 Web 服务器,也不需要联网。

Unreal 和网页之间只传 JSON 消息。状态属于 Unreal:Unreal 把状态发给网页显示,网页把玩家的操作提交回去,由 Unreal 决定接不接受。

Unreal蓝图持有状态,校验每个操作
StateRevision
0
Volume
80
Quality
high
Culture
网页显示状态,提交操作

Settings

Volume0
Quality

游戏画面(示意)
    图 1可以动手试:拖动滑块、切换画质或语言。网页只提交操作,Unreal 校验后把结果发回网页;勾上左侧的规则再试,被拒绝的数值会自动恢复。
    控件 用途
    Orion WebUI 承载一个本地 App,带游戏通信接口。本教程的主角
    Orion Browser 打开远程网站,没有通信接口
    Orion InstantScreen 多个界面共用一个常驻浏览器,见教程 3

    准备

    1. 完成安装并下载依赖:点击工具栏上的 WebUIStudio 按钮,在“OrionBrowser 依赖”窗口里点“下载”。插件不会自行下载。空白工程还要按安装页设置 CommonUI 视口。
    2. 安装 Node.js 22 或更新的 LTS 版本(自带 npm),构建网页时需要。
    3. 把插件放在工程的 Plugins/OrionBrowser;放在别处时,要改写下面文件里指向插件的相对路径。
    4. 先运行一次随附示例 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/UI/WebUI/<AppId>/dist 读取,打包也只收这里。

    目录名Content/UI/WebUI/SettingsPanel/
    package.json 的 orionWebUI.appId"orionWebUI": { "appId": "SettingsPanel", … }
    Unreal 里 App Definition 的 AppIdAppId = SettingsPanel

    三处逐字一致,页面才找得到:运行时从 Content/UI/WebUI/<AppId>/dist 读取,打包也只收这里。

    图 2同一个名字出现在三个地方。

    .gitignore

    在安装依赖之前创建。不要忽略 dist。

    .gitignore.gitignore
    **/node_modules/
    **/.npm/
    **/.vite/
    **/.cache/
    *.tsbuildinfo
    npm-debug.log*

    package.json

    package.jsonJSON
    {
    	"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

    tsconfig.jsonJSON
    {
    	"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

    vite.config.tsTypeScript
    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

    index.htmlHTML
    <!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

    指向插件的长路径集中在这个文件里,其余文件从这里导入。

    src/bridge/orion-webui.tsTypeScript
    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

    src/main.tsTypeScript
    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.tsTypeScript
    // 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.tsTypeScript
    // 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

    src/App.vueVue
    <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 目录里运行:

    PowerShell
    npm.cmd install          # 第一次:安装依赖并生成 package-lock.json。之后改用 npm.cmd ci
    npm.cmd run typecheck    # 类型检查
    npm.cmd test             # App 合同检查
    npm.cmd run build        # 生成 dist

    test 打印 Validated SettingsPanel WebUI app contract. 就是通过;build 生成 dist。构建不做类型检查,所以 typecheck 也要运行。package-lock.json 要提交进版本库。

    在浏览器里看

    PowerShell
    npm.cmd run dev

    打开终端里显示的地址,页面显示预览数据。控制台里字体样式表加载失败的信息可以忽略。

    图 3按本教程代码构建出的设置面板,在浏览器里的真实画面。这时没有 Unreal,页面走“没有宿主”的分支,显示预览数据(英文、音量 80、画质 High)。面板是半透明的;在游戏里,白色的位置是游戏画面。

    第 2 部分:放进 Unreal

    App Definition 属性

    内容浏览器里右键,Miscellaneous → Data Asset,类选 OrionWebUIAppDefinition。最少只需要填 AppId;下面是常用的属性,其余保持默认。

    属性 默认值 说明
    AppId Sample 与目录名一致
    InitialRoute 空 初始路由(#/<路由>)
    EntryHtml index.html dist 内的入口文件
    bUseDevServerInEditor 开 Designer 里开发服务器连得上时加载它,否则加载 dist
    DevServerUrl http://127.0.0.1:5173/ 改成 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 关闭

    显示、输入模式与焦点

    1. 新建 Widget 蓝图,放入 Orion WebUI,命名为 WebUI,勾选 Is Variable,铺满父级,指定 App Definition。
    2. 在 Player Controller 里 Create Widget、Add to Viewport。
    3. Set Input Mode Game And UI:Player Controller 接 Self(空着时节点不起作用),In Widget to Focus 接 WebUI;Set Show Mouse Cursor 设为 true。
    4. 关闭时 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://127.0.0.1:5180/);服务器在 Designer 打开后才启动时,编译一次 Widget 蓝图。PIE 和成品游戏只加载 dist,PIE 之前要 npm run build。

    dist 从哪里读

    页面从 Content/UI/WebUI/<AppId>/dist 读取。编辑器运行期间重新构建,Designer 和 PIE 里的页面会自动重载;成品游戏不会。

    UMG Designer
    • 勾选了 Use Dev Server in Editor 并且本机开发服务器连得上:加载开发服务器,改源码立即可见
    • 否则加载 dist
    • 没有通信接口,页面用自己的预览数据显示
    PIE
    • 永远加载 dist
    • 所以 PIE 之前要 npm run build
    • dist/index.html 变化并稳定后自动重载
    成品游戏
    • 永远加载 dist(打进包体的那一份)
    • 没有自动重载
    • 网页文件变了要重新构建再打包
    图 4三种环境各自加载哪些文件。开发服务器只在 Designer 里使用。

    第 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 调用网页

    TypeScript
    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 传回调:

    TypeScript
    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。

    有业务含义的声音由网页请求:

    TypeScript
    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"
    插件自动上报控件 Id
    控件音效策略默认声音,加按 Id 的覆盖
    Unreal 播放声音资产在蓝图里选
    业务声音:有含义的时刻,由网页请求
    页面调用api.playSound("settings.saved")
    声音 Id
    Sound ManifestApp Definition 上登记的 StableId
    Unreal 播放清单里没有就拒绝
    图 5两条声音通道互不叠加:已经有自动点击声的按钮,不要再用 playSound 播一遍。

    文字与语言

    • 简单做法:Unreal 在状态里发布语言(本教程的 culture),页面用自己的字典。
    • Text Catalog:在 Unreal 里建 OrionWebUITextCatalog,把稳定的 Key 映射到 Unreal 文本,指定给 App Definition。页面这样用:
    TypeScript
    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 用变量取字体:
    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 把画面直接画在它的位置上:

    Vue
    <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 个绑定;列表里的大量图标用运行时图片。
    屏幕上的 Orion WebUI 控件网页边框、标题、按钮由网页绘制Unreal 的画面Render Target 或材质占位元素<OrionNativeSurface surface-id="station.capture" />位置和大小跟随这个元素蓝图Set Native Surface TextureSurface Id =station.capture
    图 6Native Surface:Unreal 把画面直接画在占位元素的位置上,像素不经过网页。默认画在网页上方,不接收鼠标;圆角、遮罩和淡出放进 UI 材质里做。

    第 7 部分:WorldUI

    跟随 Actor 的标签、血条和交互按钮画在同一个页面里。页面里挂一个覆盖层组件:

    Vue
    <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。

    关卡里的 Actor能量单元OrionWebUIWorldElementComponentDefinition · Element Key · Payload Json终端OrionWebUIWorldElementComponentDefinition · Element Key · Payload Json每个本地玩家一个网页<OrionWorldOverlayDOM />能量单元 [收集]终端 [提交]标签跟着 Actor 在屏幕上移动位置、可见性、数据点击动作检查后触发 On Interaction
    图 7WorldUI:Actor 上只挂数据组件,所有世界标签画在同一个网页里。点击标签上的按钮,触发组件的 On Interaction。

    默认显示:Definition 开启交互并列出动作时,显示 payload.label 和动作按钮(文字取 payload.actionLabel),点击触发组件的 On Interaction;否则显示血条,读取 payload.health、payload.maxHealth、payload.label、payload.color。要自己画,按 Definition 的 ElementType 注册渲染函数:

    TypeScript
    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/DefaultEngine.ini 里打开:

    INI
    [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 部分:检查与交付

    每次修改后按顺序做:

    1. npm.cmd run typecheck、npm.cmd test、npm.cmd run build。
    2. 在 WebUIStudio 或浏览器里看每个视图。
    3. 在 UMG Designer 里确认页面能加载、控件 Id 齐全。
    4. 在 PIE 里实际操作:鼠标、键盘、手柄、输入法、两种语言、声音、反复打开关闭。
    5. 构建出最新的 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 里填精确来源

    输入两个以上字符开始搜索

    联系我们

    使用中遇到问题,或者有购买、合作方面的事,欢迎发邮件给我们。