开发文档 / SDK Guide
v1.0.2

SDK 开发文档

按模块说明接入:框架、管控、大空间、内容、网络、交互。 API 见 API 文档,变更见 更新说明。

1. 概述

UPM 安装:com.slhx.sdk(必装)、com.slhx.samples(推荐)。 对照示例 Packages/com.slhx.samples/Content/Entry.unity。

模块范围
框架入口初始化、Boot、设备 SN、地图路径
管控收发管控指令、内容状态上报(无特殊指令时内容一般不用处理)
大空间体验区加载/切换/预览;行进式须编辑器配置并提供 config.json
内容步骤、场景加载、进度;超时由内容自行处理
网络房间连接、事件、自定义消息、物体同步、成员/位姿、Avatar
交互XR 头手、手势

2. 环境要求

项要求
Unity2022.3.x LTS
设备PICO 设备
渲染URP
PICO SDK工程自行安装
OpenUPMLiteNetLib、YooAsset(版本以 package.json 为准)

3. 安装 SDK

  1. 工程使用 URP,并在 Graphics / Quality 中指定 URP Asset。
  2. 解压 SDK zip,将 com.slhx.sdk 与 com.slhx.samples 放入 Packages/。
  3. 配置 OpenUPM(LiteNetLib、YooAsset):
"scopedRegistries": [{
  "name": "package.openupm.com",
  "url": "https://package.openupm.com",
  "scopes": ["com.revenantx.litenetlib", "com.tuyoogame.yooasset"]
}]
  1. 导入 PICO Unity Integration SDK,XR Plug-in Management 启用 PICO。
  2. Package Manager 对 XR Interaction Toolkit、XR Hands 导入 Samples(Starter Assets、Hands Interaction Demo、XR Device Simulator、Gestures、HandVisualizer)。
  3. 执行 Tools → Slhx → 安装/修复 Layers 与 Tags。

约定 Layer 26–30:Overlay / Underlay / Boundary / OutBoundary / Avatar。

4. 快速接入

打开 Packages/com.slhx.samples/Content/Entry.unity。 平台节点可沿用已拖好的 Inspector 引用;内容推进脚本是 Samples 示例,按自己的内容改。

4.1 Entry 组件

节点名是示例命名,以组件类型为准。

模块场景节点(示例)组件作用
框架 FrameworkBootstrap FrameworkEntry Awake 初始化框架。nextScene 可空
BootSystem BootSystem 设备 SN、ContentMode、Editor 地图路径
管控 AndroidBridge AndroidBridge 接收管控指令
Network / ContentSync ContentStateSync 内容状态 / 进度上报
大空间 ContentExperienceHandler ExperienceAreaHandler 体验区模板、加载、切换、预览
同上 ExperienceAreaSafetyPresenter 出区相机 mask
ToBService DetectingSystem、IPDAutoCalibrator 出区检测、瞳距校准
内容 ContentSceneDirector ContentSceneDirectorSample、RoomContentSceneFlow、ContentProgressReporter 示例:切场景、切体验区、进度分段
网络 Network / RoomMgr RoomMgr + RoomNetClient 进房;引用 Member / Transform / Event / Object Handler
Network / AvatarMgr AvatarMgr 房间 Avatar
Network / UDPMgr 大厅在线 / 位姿上报 大厅设备列表
交互 XR Interaction Hands Setup XRI 官方 头手交互(须已 Import XR Samples)
InteractorMgr HandGesturesMgr 手势

各模块怎么用见后文对应章节。

5. 框架

FrameworkEntry 在入口 Awake 初始化。ServiceLocator 可取场景、定时器、事件、协程、资源、日志(见 API · 框架)。

BootSystem:

  • ContentMode:LargeSpace 实例化场地;Origin 不实例化
  • Test Device Sn:Editor 设备号
  • Editor Map Config Url:默认可落到 samples 的 Map/config.json
  • DeviceSn / Root:设备号与已解析的 boot 配置
if (!ServiceLocator.TryGet<ITimerService>(out var timer)) return;
var handle = timer.Wait(30f, () => { /* 超时 */ });

if (!ServiceLocator.TryGet<IEventBus>(out var bus)) return;
bus.Subscribe(100, _ => { });
bus.Publish(100);

6. 管控

Entry 已挂 ContentStateSync、CalibrationHandler。 内容不用自己接管控 JSON。 两种开播用 Inspector autoSync 区分;都不会自动切内容场景。

进度 / 结束两种情况都由内容上报:

contentStateSync.SetContentProgressAndReport(42, "内容1");
contentStateSync.NotifyGameFinished();

6.1 自动开播

勾选 autoSync(默认)。校准完成后 SDK 自动:

  • StartSync():握手、上报、连房间
  • NotifyAgreementFinished():打开体验计时
  • 本地把 ContentStatus 置为 Running

这时的 Running 不是管控指令。内容不必等开播,校准完即可自己推进场景。体验时长从这时开始往管控报。

Offline / Editor 同样走这条;也可再调 NotifyExperienceStarted()。

6.2 管控开播

取消勾选 autoSync。校准和场地就绪后内容自己调 StartSync(),否则不会握手、不上报。

contentStateSync.StartSync();

StartSync() 之后就会握手、上报、连房间,还不是开播。 内容先停在自己的等待场景,等 ContentStatus == Running(管控下发 start)。

NotifyAgreementFinished():已 Running 且调过它,才开始计时。不调则时长为 0。

中途加入看 IsLateJoiner,不要再等开播。接入写法见 §8.1。

7. 大空间

行进式内容必须在编辑器中配置体验区,并提供配置好的 config.json。 未配置则无法正确加载场地与切区。

环境config.json 路径
设备(真机测试) /sdcard/Download/lycj/boot/config.json
Editor BootSystem 的 Editor Map Config Url;留空则用 samples 的 Map/config.json

设备内测试:将编辑器导出的 config.json 拷到上表设备路径后再运行内容。

7.1 体验区

组件在 Entry 的 ContentExperienceHandler 上。 切区参数是 Boot placeItems[].id(如 area_01),不是 Unity 场景名。 模板 areaId 须与 placeItems.id 一致。

Inspector:

  • Areas:每项 areaId + 体验区 Prefab
  • Base Object / Boundary Area:场地根与对齐边界
  • Content Root:可选,内容根随当前区对齐
h.LoadBoundaryPlace();
h.SwitchExperienceArea("area_01");
h.ShowExperienceAreaPreview("area_02");
h.ClearExperienceAreaPreview();
h.SwitchToNextExperienceArea();

string cur = h.ActiveAreaId;
h.OnAreaChanged += e => { };

7.2 边界系统

出区线、障碍提示、相机 mask 由 SDK 自动处理。Entry 已挂 DetectingSystem、ExperienceAreaSafetyPresenter,无特殊需求时内容一般不用自己开关表现。

检测靠 Tag 和 Marker,不要靠节点名字:

类型说明
Tag ContentArea + ExperienceAreaItem内容区碰撞体
ExperienceHexRoadMarkerHex 通道;在通道内不算出来区
ExperienceObstacleMarker障碍
ExperienceNearAreaLineMarker近区线
ExperienceOutAreaLineMarker出区线
ExperienceObstacleAreaMarker障碍区视觉
ExperienceBoundaryVisualMarker子视觉;Kind:Boundary / Arrow / GuideLine

内容要响应出区或障碍时,用 DetectingSystem;要自己改出区线 / 相机 mask 时,用当前区 ExperienceAreaItem 或 ExperienceAreaHandler。

DetectingSystem.Instance.EnableDetecting();
DetectingSystem.Instance.RegisterHeadDetecting(data => {
    bool outArea = data.isOutContentArea == 1; // 1 出区,0 在区内
    string areas = data.currentAreas;
});
bool inObstacle = DetectingSystem.Instance.IsInObstacle();

var h = GetComponent<ExperienceAreaHandler>();
var area = h.GetArea(h.ActiveAreaId);
area.SetOutAreaVisible(true);
h.SetCameraLayerMask(true);
h.LockCameraMask(true);

7.3 编辑器:体验区设置与预览

行进式内容用菜单 Tools → Slhx → 地图体验区配置工具 配置体验区,并产出 / 提供 config.json。

  • 导入服务端 placeOperation JSON
  • 在 Scene 中预览体验区线框与位姿
  • 导出配置好的 config.json,供 Boot 读取

导出后拷到设备 /sdcard/Download/lycj/boot/config.json 即可在头显内测场地与切区。

7.4 内容场景怎么配、位置如何对齐

切体验区时,SDK 会把内容根节点的位置、朝向(Y 轴)对齐到当前体验区。 因此内容场景必须有一个可被移动的根,且不要勾选 Static。

  • 根节点:内容场景里放一个根物体,把场景内容都挂在它下面。给根物体打 Tag ContentScene(安装 Layers/Tags 后可选到)。
  • 取消静态:根节点及其子物体 Inspector 上取消 Static。勾选 Static 后运行时无法跟着体验区移动,会出现内容还在世界原点、和场地对不齐。
  • 如何对齐:加载内容场景后找到 Tag 为 ContentScene 的根(没有 Tag 时用场景里非灯光的第一个根),调用 SetContentRoot,再 SwitchExperienceArea。根节点的 position 与体验区一致,eulerAngles.y 与体验区偏航一致。
// 加载内容场景后
handler.SetContentRoot(contentRootGo);   // Tag=ContentScene 的根
handler.SwitchExperienceArea("area_01"); // 根对齐到该体验区位姿

Entry 示例导演会在加载场景后自动按 Tag 找根并对齐。内容自己写流程时,加载完场景也要 SetContentRoot 再切区。

8. 内容

内容自己决定步骤、是否加载场景、以及何时切体验区。 场景配置与对齐见 §7.4。 超时由内容自行处理(等待、卡关、单步过久等)。

Samples 推荐对照:等待开播 → 步骤表 → 导演执行 → 可选房间推进 → 进度上报。正式玩法按自己的表改。

8.1 等待开播

对应 §6.2 管控开播:取消 autoSync,关掉导演 Auto Cycle。 校准和场地就绪后 StartSync():这时已经在握手和上报,还没开播。 先停在内容自己的等待场景。用 IsLateJoiner 区分下面两种。

stateSync.StartSync();
director.GoTo(ContentSceneSearcher.佩戴区);

NotifyAgreementFinished():已 Running 且调过它,才开始计时。不调则时长为 0。

8.1.1 正常进房

IsLateJoiner == false。等管控开播后再推进。

yield return new WaitUntil(() =>
    stateSync.ContentStatus == ContentStatus.Running);
stateSync.NotifyAgreementFinished();
director.GoTo(ContentSceneSearcher.内容1);

Offline / Editor 无管控开播:NotifyExperienceStarted() 本地进入 Running。

8.1.2 中途加入

IsLateJoiner == true:进房时房间已经 Running。不要再等开播。 IsLateJoiner 不带当前步骤;201 是当时全房确认才发,晚进不一定收得到。

需要和房间对齐时:进房后发自定义事件向房内要当前 Content 和时长(不要用 101 / 104 / 201 / 204)。 房内已有进度的端回一条;晚进的人按回复 GoTo 并对齐时长。

const ushort Req = 1101;
const ushort Ack = 1102;
var eb = RoomMgr.Instance.EventBroadcastHandler;

eb.RegisterJsonEvent<RoomSnap>(Ack, (senderId, snap) => {
    director.GoTo((ContentSceneSearcher)snap.step);
    // snap.time:房间已进行时长,内容自己对齐
});
eb.RegisterSimpleEvent(Req, senderId => {
    if (stateSync.IsLateJoiner) return;
    if (senderId == RoomNetClient.Instance.LocalPlayerId) return;
    eb.TriggerEventJson(Ack, new RoomSnap {
        step = (int)RoomContentSceneFlow.Instance.SceneStep,
        time = myElapsed
    });
});

if (stateSync.IsLateJoiner)
    eb.TriggerEventSimple(Req);

stateSync.NotifyAgreementFinished();

RoomSnap.step / time 由内容自己定义。自定义事件见 §9.3。

8.2 步骤表

示例枚举 ContentSceneSearcher:[Description] 是 Unity 场景名(空 = 本步不加载场景), [ExperienceAreaId] 是体验区 id(如 area_01),不要把场景名当 areaId。

string scene = ContentSceneStepMap.GetSceneName(ContentSceneSearcher.内容1); // Level_B
string area  = ContentSceneStepMap.GetAreaId(ContentSceneSearcher.内容1);    // area_02

8.3 导演

ContentSceneDirectorSample 本地执行:有场景则加载,再 SwitchExperienceArea; 无场景(如归还区)只切体验区、开透视。

director.GoTo(ContentSceneSearcher.内容1);
director.NextContent();
director.OnContentStepChanged += step => { };

8.4 房间推进

RoomContentSceneFlow 只负责请求与确认步骤 / 体验区,不加载场景。 导演订阅确认事件后再 GoTo。事件协议见 §9.3。

flow.OnSceneStepChanged += step => director.GoTo(step);
flow.ClientRequestNextContent(ContentSceneSearcher.内容1);
flow.ClientRequestNextExperienceArea("area_02");

8.5 进度

ContentProgressReporter 按段权重自动归一成 0~100,每升 1% 上报;到 100% 发结束。 归还区默认不计入。Segments 权重按自己内容改。

reporter.OnContentStepChanged(ContentSceneSearcher.内容1);
int pct = reporter.GlobalProgressPct;

9. 网络

房间连接、事件、自定义消息、物体同步、成员/位姿、媒体同步、Avatar 都在本模块。 Entry 中 RoomMgr 与 RoomNetClient 同物体,Handler 引用已拖好。

9.1 连接

RoomNetClient.Instance.Configure(host, port);
RoomNetClient.Instance.Connect();
// ConnectStatus == RoomNetStatus.Connected 后再发业务
RoomNetClient.Instance.Disconnect();
API说明
Configure(ip, port, roomKey?)写入地址,不自动连接
Connect() / Disconnect()连接 / 断开
ConnectStatus / IsConnected状态
LocalPlayerId进房后由服务分配;未就绪为 -1

全房同步状态用事件广播;自定义字节流用频道;道具位姿用 NetworkedObject。

9.2 编辑器多人调试

ParrelSync 开两个 Editor,连接本机 房间服务(端口 11001)。

RoomNetClient.Instance.Configure("192.168.x.x", 11001);
RoomNetClient.Instance.Connect();
  1. 安装并启动房间服务。
  2. 主工程 Configure 后 Play。
  3. Clone 同样 Configure,并把 BootSystem 的 Test Device Sn 改成与主工程不同后再 Play。
  4. 两端 Connected,成员为两个不同 DeviceSn。

每次测试前建议重启房间服务。

9.3 事件广播

通过 RoomMgr.EventBroadcastHandler,进房后使用。全房用同一个 eventId(ushort)。

自定义事件

自己选一个 eventId(不要用下面的 101 / 104 / 201 / 204)。 各端先 Register,再 Trigger。 服务器只转发,收到即发给全房,不做人数或 payload 校验。

var eb = RoomMgr.Instance.EventBroadcastHandler;

// 各端都要注册(含发送端,若本端也要执行)
eb.RegisterEvent(1001, (senderId, payload) => {
    // senderId:触发方玩家 Id;payload 可为 null
});
eb.RegisterStringEvent(1002, (senderId, text) => { });
eb.RegisterJsonEvent<MyDto>(1003, (senderId, dto) => { });
eb.RegisterSimpleEvent(1004, senderId => { });

// 已进房后发送
eb.TriggerEvent(1001, myBytes);
eb.TriggerEventString(1002, "hello");
eb.TriggerEventJson(1003, new MyDto { step = 2 });
eb.TriggerEventSimple(1004);

eb.UnregisterEvent(1001, myListener);

回调里的 senderId 是触发方的房间玩家 Id,由服务器在转发时带上,内容不用自己传。 本端 Id:RoomNetClient.Instance.LocalPlayerId(进房后由服务器分配,未就绪为 -1)。

eb.RegisterEvent(1001, (senderId, payload) => {
    int self = RoomNetClient.Instance.LocalPlayerId;
    if (senderId == self) {
        // 自己发的(本端若也 Register 了同样会收到)
    } else {
        // 其他玩家发的
    }
});
  • 适合离散玩法事件(过关、开关、同步阶段),不适合每帧高频。
  • payload 编解码全房必须一致(字符串 / JSON / 字节)。
  • 未连接时 Trigger 会丢弃。未 Register 的端收不到。

约定推进协议(101 / 104)

这两条由服务器做校验:客户端上报 101 / 104; 服务器收到同等人数、相同 payload 后,才向全房回 201 / 204。 内容端监听 201 / 204 再执行切步骤、切体验区。

事件Id说明
CArrivedNextContent101客户端请求切内容步骤
SConfirmNextContent201人齐且 payload 一致后,服务器确认全房执行
CArrivedNextExperienceArea104客户端请求切体验区
SConfirmNextExperienceArea204人齐且 payload 一致后,服务器确认全房切区

9.4 自定义消息

customMessageHandler.RegisterChannel(10, (senderId, data) => { });
customMessageHandler.SendReliable(10, bytes);
customMessageHandler.SendUnreliable(10, bytes);

未注册的频道不会处理。同一 channelId 重复注册会覆盖。

9.5 网络物体

挂 NetworkedObject。场景物体 id ≥ 1000(编辑器自动分配);运行时 Spawn ≥ 1_000_000。

仅 Owner 同步 Transform。Owner = -1 时本端移动不会同步。

netObj.RequestOwnership(force: false);
if (netObj.IsOwner()) netObj.SyncNow();
RoomMgr.Instance.RegisterPrefab("MyProp", prefab);
uint id = RoomMgr.Instance.SpawnObject("MyProp", pos, rot, scale, "{}");
RoomMgr.Instance.ObjectSyncHandler.OnObjectSpawned += (oid, go) => { };

9.6 成员与位姿

MemberHandler:成员列表(PlayerId、DeviceSn),并为其他成员创建房间 Avatar。 TransformHandler:头手位姿。 双开时两端 DeviceSn 必须不同。

RoomMgr.Instance.MemberHandler.RegisterHandler(list => {
    foreach (var m in list) { /* m.PlayerId, m.DeviceSn */ }
});
RoomMgr.Instance.TransformHandler.RegisterHandler(msg => { });

9.7 内容播放同步

视频 / 音频播放状态对齐。实现 IContentController 后:

cs.RegisterContent("intro_video", myVideo);
cs.Play("intro_video", seekTime: 0f);
cs.Pause("intro_video");
cs.Stop("intro_video");
cs.Seek("intro_video", 12.5f);

9.8 Avatar

默认 Catalog:Resources/AvatarTemplate/DefaultAvatarCatalog。

AvatarMgr.Instance.SetCatalog(myCatalog);
var av = AvatarMgr.Instance.GetAvatarRoom(playerId);
av.SetNickName("P1");
av.Calibrate();

默认只同步头手。全身 IK 实现 IAvatarIkDriver 赋给 AvatarMono 的 vrIk。Samples AvatarVRIK 需自行准备 FinalIK。

大厅在线:LobbyDeviceOnlineMonitor.OnOnlineChanged。

10. 交互

头手与手势在 Entry 的 XR Interaction Hands Setup、InteractorMgr(组件 HandGesturesMgr)上。须已 Import XR Samples。

抓取、点击、手势由内容在自己的物体上接 XRI / HandGestureHold;平台只提供场景里的交互原点与手势管理。

HandGesturesMgr.Instance.RegisterLeftHandTrackingEvent(
    () => { /* acquired */ },
    () => { /* lost */ });

hold.Register();
hold.gesturePerformed.AddListener(() => { });

11. 配置路径

行进式场地配置说明见 §7。路径速查:

# 设备(真机测试必须放到此路径)
/sdcard/Download/lycj/boot/config.json

# Editor
# BootSystem.editorMapConfigUrl,留空则 samples:
Packages/com.slhx.samples/Map/config.json

12. 常见问题

等开播但一校准就进游戏?

勾选 autoSync 会在校准后本地进入 Running。等管控开播须取消 autoSync,关导演 Auto Cycle。见 §8.1。

事件或自定义消息无反应?

是否已 Connected;是否注册了同一 eventId / channelId;房间服务是否在运行。

物体不同步?

Owner 是否为本端(-1 需先申请所有权)。两端 objectId 须相同且已进房。

体验区 area not found?

是否已 LoadBoundaryPlace;placeItems.id 与模板 areaId 是否一致。设备上确认 /sdcard/Download/lycj/boot/config.json 已放好。

设备上场地不对 / 切不了区?

行进式必须用编辑器配体验区并提供 config.json,拷到设备上述路径。内容场景根节点 Tag ContentScene,并取消 Static。

Editor 双开成员 / Avatar 异常?

Clone 的 BootSystem.testDeviceSn 必须与主工程不同。

场景粉红或 Missing Script?

确认已指定 URP Asset,并已 Import XR Samples。

找不到 LiteNetLib / YooAsset?

检查 scopedRegistries;版本与 SDK package.json 对齐。

Entry 里物体怎么对应?

按 §4.1 六模块对照。内容推进脚本是 Samples 示例。